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">×</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();
}
}