alert — Inline alert + toast notification s 4 variantami (info, success, warning, error). Auto-dismiss volitelně.

app/components/Alert/alert.latte
{*
	Alert — inline notifikace.

	Parametry:
	- $variant (string): info|success|warning|error (default info)
	- $title (string|null): volitelný titulek
	- $message (string): obsah
	- $dismissible (bool): default true
*}
{var $variant = $variant ?? 'info'}
{var $dismissible = $dismissible ?? true}

<div class="alert alert--{$variant}" role="alert" data-alert>
	<div class="alert__body">
		{if isset($title) && $title !== null}
			<strong class="alert__title">{$title}</strong>
		{/if}
		<div class="alert__message">{$message|noescape}</div>
	</div>

	{if $dismissible}
		<button type="button" class="alert__close" data-alert-close aria-label="Zavřít">&times;</button>
	{/if}
</div>
resources/sass/components/_alert.scss
.alert {
	display: flex;
	align-items: flex-start;
	gap: 12px;
	padding: 12px 16px;
	border-radius: 6px;
	border: 1px solid;
	font-size: 0.95em;
	animation: czg-fade-in 0.2s ease-out;

	&__body { flex: 1; }

	&__title {
		display: block;
		margin-bottom: 2px;
	}

	&__message {
		color: inherit;
	}

	&__close {
		flex-shrink: 0;
		background: transparent;
		border: none;
		font-size: 1.4em;
		line-height: 1;
		padding: 0 4px;
		cursor: pointer;
		color: inherit;
		opacity: 0.6;

		@include cgui-hover() {
			opacity: 1;
		}
	}

	&--info {
		background: #eff6ff;
		border-color: #bfdbfe;
		color: #1e3a8a;
	}

	&--success {
		background: #f0fdf4;
		border-color: #bbf7d0;
		color: #14532d;
	}

	&--warning {
		background: #fffbeb;
		border-color: #fde68a;
		color: #78350f;
	}

	&--error {
		background: #fef2f2;
		border-color: #fecaca;
		color: #7f1d1d;
	}
}

// Toast container — fixed pozice, stack notifikací
.toast-container {
	position: fixed;
	top: 16px;
	right: 16px;
	z-index: $cgui-z-toast;
	display: flex;
	flex-direction: column;
	gap: 8px;
	max-width: 400px;
	pointer-events: none;
}

.toast-container .alert {
	pointer-events: auto;
	box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
	animation: czg-slide-in-right 0.25s ease-out;

	&.is-leaving {
		animation: czg-fade-out 0.2s ease-in forwards;
	}
}
resources/js/components/alert.js
// Alert — inline dismiss + Toast API.
//
// Inline alert: auto-bind data-alert-close.
// Toast: import { toast } from './alert.js'; toast({ variant: 'success', message: 'OK!' });

import { on } from './base/utils.js';

// ─── Inline alert auto-close ─────────────────────────────────────────────
function bindAutoClose(root = document) {
	on(root, 'click', (e) => {
		const btn = e.target.closest('[data-alert-close]');
		if (!btn) return;
		const alert = btn.closest('[data-alert]');
		if (alert) dismiss(alert);
	});
}

function dismiss(alert) {
	alert.classList.add('is-leaving');
	const cleanup = () => alert.remove();
	alert.addEventListener('animationend', cleanup, { once: true });
	// Fallback pokud animace nezabere
	setTimeout(() => {
		if (alert.isConnected) alert.remove();
	}, 500);
}

// ─── Toast API ───────────────────────────────────────────────────────────
let _container = null;

function getContainer() {
	if (_container && _container.isConnected) return _container;
	_container = document.querySelector('.toast-container') || (() => {
		const c = document.createElement('div');
		c.className = 'toast-container';
		document.body.appendChild(c);
		return c;
	})();
	return _container;
}

/**
 * Zobrazí toast notifikaci. Title i message jsou ošetřené přes textContent — žádný XSS.
 *
 * @param {Object} opts
 * @param {string} opts.variant   info|success|warning|error (default info)
 * @param {string} opts.message   plaintext zprávy
 * @param {string} [opts.title]   volitelný plaintext titulek
 * @param {number} [opts.duration] ms, default 4000; 0 = nezavírat automaticky
 * @returns {HTMLElement} alert element
 */
export function toast({ variant = 'info', message, title = null, duration = 4000 }) {
	const el = document.createElement('div');
	el.className = `alert alert--${variant}`;
	el.setAttribute('role', 'alert');
	el.setAttribute('data-alert', '');

	const body = document.createElement('div');
	body.className = 'alert__body';

	if (title !== null) {
		const t = document.createElement('strong');
		t.className = 'alert__title';
		t.textContent = title;
		body.appendChild(t);
	}

	const msg = document.createElement('div');
	msg.className = 'alert__message';
	msg.textContent = message;
	body.appendChild(msg);

	const close = document.createElement('button');
	close.type = 'button';
	close.className = 'alert__close';
	close.setAttribute('data-alert-close', '');
	close.setAttribute('aria-label', 'Zavřít');
	close.textContent = '×'; // ×

	el.appendChild(body);
	el.appendChild(close);
	getContainer().appendChild(el);

	if (duration > 0) {
		setTimeout(() => {
			if (el.isConnected) dismiss(el);
		}, duration);
	}

	return el;
}

// Init
if (typeof document !== 'undefined') {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', () => bindAutoClose());
	} else {
		bindAutoClose();
	}
}