Changeset 0.37.2 (#214)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 23:52:55 +00:00
committed by xcaliber
parent 8c53f61b71
commit 4f1abc6321
24 changed files with 1775 additions and 25 deletions

View File

@@ -0,0 +1,35 @@
/**
* Avatar primitive — circle avatar with letter fallback
* Sizes: sm (24px), md (32px), lg (48px)
*/
const { html } = window;
const { useState } = hooks;
function initials(name) {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
return parts.length > 1
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: name.slice(0, 2).toUpperCase();
}
export function Avatar({ src, name = '', size = 'md' }) {
const [failed, setFailed] = useState(false);
if (src && !failed) {
return html`
<img
class="sw-avatar sw-avatar--${size}"
src=${src}
alt=${name}
onError=${() => setFailed(true)}
/>
`;
}
return html`
<span class="sw-avatar sw-avatar--${size} sw-avatar--fallback"
aria-label=${name || 'Avatar'}>
${initials(name)}
</span>
`;
}

View File

@@ -0,0 +1,26 @@
/**
* Banner primitive — persistent top/bottom bar
* Variants: info, warn, error, success
*/
const { html } = window;
const { useState } = hooks;
export function Banner({ text, variant = 'info', dismissible = false, position = 'top', onDismiss }) {
const [dismissed, setDismissed] = useState(false);
if (dismissed || !text) return null;
const handleDismiss = () => {
setDismissed(true);
if (onDismiss) onDismiss();
};
return html`
<div class="sw-banner sw-banner--${variant} sw-banner--${position}" role="status">
<span class="sw-banner__text">${text}</span>
${dismissible && html`
<button class="sw-banner__close" onClick=${handleDismiss} aria-label="Dismiss">\u00d7</button>
`}
</div>
`;
}

View File

@@ -0,0 +1,22 @@
/**
* Button primitive — variants: primary, secondary, danger, ghost
* Sizes: sm, md (default), lg
*/
const { html } = window;
const { useState } = hooks;
export function Button({ variant = 'primary', size = 'md', disabled, loading, onClick, children, type = 'button', className = '' }) {
const cls = `sw-btn sw-btn--${variant} sw-btn--${size} ${loading ? 'sw-btn--loading' : ''} ${className}`.trim();
return html`
<button
type=${type}
class=${cls}
disabled=${disabled || loading}
onClick=${onClick}
>
${loading && html`<span class="sw-btn__spinner" />`}
<span class=${loading ? 'sw-btn__content--hidden' : ''}>${children}</span>
</button>
`;
}

View File

@@ -0,0 +1,44 @@
/**
* Confirm primitive — specialized Dialog that resolves a Promise<boolean>
* Usage: const ok = await confirm('Delete this?', { destructive: true })
*/
const { html } = window;
const { useState } = hooks;
const { h, render } = preact;
import { Dialog } from './dialog.js';
let _setConfirm = null;
let _resolve = null;
export function confirm(message, opts = {}) {
return new Promise((resolve) => {
_resolve = resolve;
if (_setConfirm) _setConfirm({ message, ...opts });
});
}
function handleClose(result) {
if (_resolve) _resolve(result);
_resolve = null;
if (_setConfirm) _setConfirm(null);
}
export function ConfirmHost() {
const [state, setState] = useState(null);
_setConfirm = setState;
if (!state) return null;
const { message, destructive, confirmLabel = 'Confirm', cancelLabel = 'Cancel' } = state;
const actions = [
{ label: cancelLabel, variant: 'secondary', onClick: () => handleClose(false) },
{ label: confirmLabel, variant: destructive ? 'danger' : 'primary', onClick: () => handleClose(true) },
];
return html`
<${Dialog} open title="Confirm" onClose=${() => handleClose(false)} actions=${actions}>
<p>${message}</p>
<//>
`;
}

View File

@@ -0,0 +1,93 @@
/**
* Dialog primitive — modal with focus trapping, Escape close, backdrop click
* Actions: [{label, onClick, variant}]
*/
const { html } = window;
const { useEffect, useRef, useCallback } = hooks;
export function Dialog({ open, title, children, onClose, actions = [], persistent = false, className = '' }) {
const dialogRef = useRef(null);
const prevFocus = useRef(null);
// Focus trap + restore
useEffect(() => {
if (!open) return;
prevFocus.current = document.activeElement;
const el = dialogRef.current;
if (!el) return;
// Focus first focusable element
requestAnimationFrame(() => {
const focusable = el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusable.length) focusable[0].focus();
});
// Lock body scroll
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
if (prevFocus.current) prevFocus.current.focus();
};
}, [open]);
// Escape key
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (e.key === 'Escape' && onClose) onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
// Focus trap: Tab cycles within dialog
const onKeyDown = useCallback((e) => {
if (e.key !== 'Tab') return;
const el = dialogRef.current;
if (!el) return;
const focusable = el.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}, []);
if (!open) return null;
const backdropClick = persistent ? undefined : onClose;
return html`
<div class="sw-dialog-overlay" onClick=${backdropClick}>
<div class="sw-dialog ${className}" ref=${dialogRef}
role="dialog" aria-modal="true" aria-label=${title || 'Dialog'}
onClick=${(e) => e.stopPropagation()}
onKeyDown=${onKeyDown}>
${title && html`
<div class="sw-dialog__header">
<h2 class="sw-dialog__title">${title}</h2>
${onClose && html`
<button class="sw-dialog__close" onClick=${onClose} aria-label="Close">\u00d7</button>
`}
</div>
`}
<div class="sw-dialog__body">${children}</div>
${actions.length > 0 && html`
<div class="sw-dialog__actions">
${actions.map(a => html`
<button class="sw-btn sw-btn--${a.variant || 'secondary'} sw-btn--sm"
onClick=${a.onClick}>
${a.label}
</button>
`)}
</div>
`}
</div>
</div>
`;
}

View File

@@ -0,0 +1,47 @@
/**
* Drawer primitive — slide-in panel (left/right)
*/
const { html } = window;
const { useEffect, useRef } = hooks;
export function Drawer({ open, side = 'right', width = '360px', title, children, onClose }) {
const drawerRef = useRef(null);
// Escape to close
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (e.key === 'Escape' && onClose) onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
// Lock body scroll
useEffect(() => {
if (!open) return;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [open]);
return html`
<div class="sw-drawer-overlay ${open ? 'sw-drawer-overlay--open' : ''}"
onClick=${onClose}>
<div class="sw-drawer sw-drawer--${side} ${open ? 'sw-drawer--open' : ''}"
ref=${drawerRef}
style="width:${width}"
onClick=${(e) => e.stopPropagation()}
role="dialog" aria-modal="true" aria-label=${title || 'Panel'}>
${title && html`
<div class="sw-drawer__header">
<h2 class="sw-drawer__title">${title}</h2>
${onClose && html`
<button class="sw-drawer__close" onClick=${onClose} aria-label="Close">\u00d7</button>
`}
</div>
`}
<div class="sw-drawer__body">${children}</div>
</div>
</div>
`;
}

View File

@@ -0,0 +1,106 @@
/**
* Dropdown primitive — select replacement with search/filter
* options: [{ label, value, disabled? }]
*/
const { html } = window;
const { useState, useRef, useEffect, useCallback, useMemo } = hooks;
export function Dropdown({ options = [], value, onChange, placeholder = 'Select...', searchable = false }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [focusIdx, setFocusIdx] = useState(-1);
const wrapRef = useRef(null);
const inputRef = useRef(null);
const filtered = useMemo(() => {
if (!search) return options;
const q = search.toLowerCase();
return options.filter(o => o.label.toLowerCase().includes(q));
}, [options, search]);
const selected = options.find(o => o.value === value);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// Focus search on open
useEffect(() => {
if (open && searchable && inputRef.current) inputRef.current.focus();
if (open) setFocusIdx(-1);
if (!open) setSearch('');
}, [open, searchable]);
const select = useCallback((opt) => {
if (opt.disabled) return;
if (onChange) onChange(opt.value);
setOpen(false);
}, [onChange]);
const onKeyDown = useCallback((e) => {
if (!open) {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
}
return;
}
const count = filtered.length;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusIdx(i => Math.min(i + 1, count - 1));
break;
case 'ArrowUp':
e.preventDefault();
setFocusIdx(i => Math.max(i - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (focusIdx >= 0 && focusIdx < count) select(filtered[focusIdx]);
break;
case 'Escape':
setOpen(false);
break;
}
}, [open, filtered, focusIdx, select]);
return html`
<div class="sw-dropdown ${open ? 'sw-dropdown--open' : ''}" ref=${wrapRef} onKeyDown=${onKeyDown}>
<button class="sw-dropdown__trigger" type="button"
onClick=${() => setOpen(!open)}
aria-haspopup="listbox" aria-expanded=${open}>
<span class="sw-dropdown__value">${selected ? selected.label : placeholder}</span>
<span class="sw-dropdown__chevron">\u25be</span>
</button>
${open && html`
<div class="sw-dropdown__list" role="listbox">
${searchable && html`
<input class="sw-dropdown__search sw-input" ref=${inputRef}
placeholder="Search..."
value=${search}
onInput=${(e) => { setSearch(e.target.value); setFocusIdx(0); }} />
`}
${filtered.map((opt, i) => html`
<div class="sw-dropdown__option ${opt.value === value ? 'sw-dropdown__option--selected' : ''} ${opt.disabled ? 'sw-dropdown__option--disabled' : ''} ${i === focusIdx ? 'sw-dropdown__option--focused' : ''}"
role="option"
aria-selected=${opt.value === value}
onMouseEnter=${() => setFocusIdx(i)}
onClick=${() => select(opt)}>
${opt.label}
</div>
`)}
${filtered.length === 0 && html`
<div class="sw-dropdown__empty">No results</div>
`}
</div>
`}
</div>
`;
}

View File

@@ -0,0 +1,19 @@
/**
* FormField primitive — wrapper for form inputs with label + validation
*/
const { html } = window;
export function FormField({ label, error, hint, required, children }) {
return html`
<div class="sw-field ${error ? 'sw-field--error' : ''}">
${label && html`
<label class="sw-field__label">
${label}${required && html`<span class="sw-field__required">*</span>`}
</label>
`}
<div class="sw-field__control">${children}</div>
${error && html`<p class="sw-field__error">${error}</p>`}
${hint && !error && html`<p class="sw-field__hint">${hint}</p>`}
</div>
`;
}

View File

@@ -0,0 +1,18 @@
/**
* Layer 0 — Primitives re-export
* All UI primitives available via: import { Button, Dialog, ... } from './primitives/index.js'
*/
export { Button } from './button.js';
export { Spinner } from './spinner.js';
export { Avatar } from './avatar.js';
export { FormField } from './form-field.js';
export { Tooltip } from './tooltip.js';
export { Banner } from './banner.js';
export { Dialog } from './dialog.js';
export { confirm, ConfirmHost } from './confirm.js';
export { prompt, PromptHost } from './prompt.js';
export { toast, ToastContainer } from './toast.js';
export { Menu } from './menu.js';
export { Drawer } from './drawer.js';
export { Tabs } from './tabs.js';
export { Dropdown } from './dropdown.js';

View File

@@ -0,0 +1,159 @@
/**
* Menu primitive — flyout menu with viewport clamping, keyboard nav, submenus
*
* Items: [{ label, action, icon?, disabled?, divider?, children? }]
* Directions: down-right (default), down-left, up-right, up-left
*/
const { html } = window;
const { useState, useEffect, useRef, useCallback } = hooks;
export function Menu({ items = [], anchor, open, direction = 'down-right', onSelect, onClose }) {
const menuRef = useRef(null);
const [pos, setPos] = useState(null);
const [focusIdx, setFocusIdx] = useState(-1);
const [submenu, setSubmenu] = useState(null);
const selectableItems = items.filter(i => !i.divider && !i.disabled);
// Position relative to anchor
useEffect(() => {
if (!open || !anchor) return;
const rect = typeof anchor.getBoundingClientRect === 'function'
? anchor.getBoundingClientRect()
: anchor;
const vw = window.innerWidth;
const vh = window.innerHeight;
let top, left;
const [vDir, hDir] = direction.split('-');
if (vDir === 'down') top = rect.bottom + 4;
else top = rect.top - 4;
if (hDir === 'right') left = rect.left;
else left = rect.right;
// Defer measurement to get menu size
requestAnimationFrame(() => {
const el = menuRef.current;
if (!el) return;
const mw = el.offsetWidth;
const mh = el.offsetHeight;
// Viewport clamp
if (vDir === 'down' && top + mh > vh) top = Math.max(4, rect.top - mh - 4);
if (vDir === 'up') { top = top - mh; if (top < 4) top = rect.bottom + 4; }
if (hDir === 'right' && left + mw > vw) left = vw - mw - 4;
if (hDir === 'left') { left = left - mw; if (left < 4) left = 4; }
setPos({ top, left });
});
setPos({ top: -9999, left: -9999 }); // initial offscreen for measurement
setFocusIdx(-1);
}, [open, anchor, direction]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (menuRef.current && !menuRef.current.contains(e.target)) {
if (onClose) onClose();
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open, onClose]);
// Keyboard navigation
const onKeyDown = useCallback((e) => {
const count = selectableItems.length;
if (!count) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusIdx(i => (i + 1) % count);
break;
case 'ArrowUp':
e.preventDefault();
setFocusIdx(i => (i - 1 + count) % count);
break;
case 'Home':
e.preventDefault();
setFocusIdx(0);
break;
case 'End':
e.preventDefault();
setFocusIdx(count - 1);
break;
case 'Enter': {
e.preventDefault();
if (focusIdx >= 0 && focusIdx < count) {
const item = selectableItems[focusIdx];
if (item.children) setSubmenu(item);
else if (onSelect) { onSelect(item.action || item.label); if (onClose) onClose(); }
}
break;
}
case 'ArrowRight': {
if (focusIdx >= 0 && selectableItems[focusIdx]?.children) {
setSubmenu(selectableItems[focusIdx]);
}
break;
}
case 'ArrowLeft':
if (submenu) setSubmenu(null);
break;
case 'Escape':
if (submenu) setSubmenu(null);
else if (onClose) onClose();
break;
}
}, [selectableItems, focusIdx, submenu, onSelect, onClose]);
// Focus menu on open
useEffect(() => {
if (open && menuRef.current) menuRef.current.focus();
}, [open, pos]);
if (!open) return null;
let selIdx = 0;
return html`
<div class="sw-menu" ref=${menuRef} role="menu" tabindex="-1"
style=${pos ? `position:fixed;top:${pos.top}px;left:${pos.left}px` : 'position:fixed;top:-9999px;left:-9999px'}
onKeyDown=${onKeyDown}>
${items.map((item) => {
if (item.divider) return html`<div class="sw-menu__divider" role="separator" />`;
const mySelIdx = selIdx++;
const focused = mySelIdx === focusIdx;
return html`
<div class="sw-menu__item ${item.disabled ? 'sw-menu__item--disabled' : ''} ${focused ? 'sw-menu__item--focused' : ''}"
role="menuitem"
aria-disabled=${item.disabled || false}
onMouseEnter=${() => { setFocusIdx(mySelIdx); if (item.children) setSubmenu(item); }}
onClick=${() => {
if (item.disabled) return;
if (item.children) { setSubmenu(item); return; }
if (onSelect) onSelect(item.action || item.label);
if (onClose) onClose();
}}>
${item.icon && html`<span class="sw-menu__icon">${item.icon}</span>`}
<span class="sw-menu__label">${item.label}</span>
${item.children && html`<span class="sw-menu__arrow">\u25b6</span>`}
</div>
`;
})}
${submenu && html`
<${Menu}
items=${submenu.children}
open
anchor=${menuRef.current}
direction="down-right"
onSelect=${(action) => { if (onSelect) onSelect(action); if (onClose) onClose(); }}
onClose=${() => setSubmenu(null)}
/>
`}
</div>
`;
}

View File

@@ -0,0 +1,60 @@
/**
* Prompt primitive — specialized Dialog that resolves Promise<string|null>
* Usage: const val = await prompt('Enter name:', { defaultValue: 'foo' })
*/
const { html } = window;
const { useState, useRef, useEffect } = hooks;
import { Dialog } from './dialog.js';
let _setPrompt = null;
let _resolve = null;
export function prompt(message, opts = {}) {
return new Promise((resolve) => {
_resolve = resolve;
if (_setPrompt) _setPrompt({ message, ...opts });
});
}
function handleClose(value) {
if (_resolve) _resolve(value);
_resolve = null;
if (_setPrompt) _setPrompt(null);
}
export function PromptHost() {
const [state, setState] = useState(null);
const [value, setValue] = useState('');
const inputRef = useRef(null);
_setPrompt = (s) => {
setState(s);
if (s) setValue(s.defaultValue || '');
};
useEffect(() => {
if (state && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [state]);
if (!state) return null;
const { message, placeholder = '', submitLabel = 'OK', cancelLabel = 'Cancel' } = state;
const actions = [
{ label: cancelLabel, variant: 'secondary', onClick: () => handleClose(null) },
{ label: submitLabel, variant: 'primary', onClick: () => handleClose(value) },
];
return html`
<${Dialog} open title="Prompt" onClose=${() => handleClose(null)} actions=${actions}>
<p style="margin-bottom: 0.75rem">${message}</p>
<input class="sw-input" ref=${inputRef}
value=${value}
placeholder=${placeholder}
onInput=${(e) => setValue(e.target.value)}
onKeyDown=${(e) => { if (e.key === 'Enter') handleClose(value); }} />
<//>
`;
}

View File

@@ -0,0 +1,9 @@
/**
* Spinner primitive — loading indicator
* Sizes: sm (16px), md (24px), lg (40px)
*/
const { html } = window;
export function Spinner({ size = 'md' }) {
return html`<span class="sw-spinner sw-spinner--${size}" role="status" aria-label="Loading" />`;
}

View File

@@ -0,0 +1,60 @@
/**
* Tabs primitive — horizontal tab bar with overflow scroll arrows
* tabs: [{ label, value, icon?, disabled? }]
*/
const { html } = window;
const { useRef, useEffect, useState, useCallback } = hooks;
export function Tabs({ tabs = [], active, onChange }) {
const scrollRef = useRef(null);
const [overflow, setOverflow] = useState({ left: false, right: false });
const checkOverflow = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setOverflow({
left: el.scrollLeft > 0,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
}, []);
useEffect(() => {
checkOverflow();
const el = scrollRef.current;
if (el) el.addEventListener('scroll', checkOverflow, { passive: true });
window.addEventListener('resize', checkOverflow);
return () => {
if (el) el.removeEventListener('scroll', checkOverflow);
window.removeEventListener('resize', checkOverflow);
};
}, [checkOverflow, tabs]);
const scroll = (dir) => {
const el = scrollRef.current;
if (el) el.scrollBy({ left: dir * 150, behavior: 'smooth' });
};
return html`
<div class="sw-tabs">
${overflow.left && html`
<button class="sw-tabs__arrow sw-tabs__arrow--left" onClick=${() => scroll(-1)} aria-label="Scroll left">\u2039</button>
`}
<div class="sw-tabs__scroll" ref=${scrollRef}>
${tabs.map(tab => html`
<button
class="sw-tabs__tab ${tab.value === active ? 'sw-tabs__tab--active' : ''} ${tab.disabled ? 'sw-tabs__tab--disabled' : ''}"
disabled=${tab.disabled}
onClick=${() => !tab.disabled && onChange && onChange(tab.value)}
role="tab"
aria-selected=${tab.value === active}>
${tab.icon && html`<span class="sw-tabs__icon">${tab.icon}</span>`}
${tab.label}
</button>
`)}
</div>
${overflow.right && html`
<button class="sw-tabs__arrow sw-tabs__arrow--right" onClick=${() => scroll(1)} aria-label="Scroll right">\u203a</button>
`}
</div>
`;
}

View File

@@ -0,0 +1,94 @@
/**
* Toast primitive + ToastContainer
* Variants: success, error, info, warn
* Auto-dismiss, hover-pause, max 5 visible, stacks bottom-right
*/
const { html } = window;
const { useState, useEffect, useRef, useCallback } = hooks;
const { h, render } = preact;
// ── Singleton state ──────────────────────────
let _toasts = [];
let _id = 0;
let _setToasts = null;
export function toast(message, variant = 'info', duration) {
if (!duration) duration = variant === 'error' ? 5000 : 3000;
const id = ++_id;
const t = { id, message, variant, duration };
_toasts = [..._toasts, t];
if (_toasts.length > 5) _toasts = _toasts.slice(-5);
if (_setToasts) _setToasts([..._toasts]);
return id;
}
toast.success = (msg, dur) => toast(msg, 'success', dur);
toast.error = (msg, dur) => toast(msg, 'error', dur);
toast.info = (msg, dur) => toast(msg, 'info', dur);
toast.warn = (msg, dur) => toast(msg, 'warn', dur);
function dismiss(id) {
_toasts = _toasts.filter(t => t.id !== id);
if (_setToasts) _setToasts([..._toasts]);
}
// ── Single Toast ─────────────────────────────
function ToastItem({ id, message, variant, duration }) {
const [exiting, setExiting] = useState(false);
const timer = useRef(null);
const hovered = useRef(false);
const startTimer = useCallback(() => {
clearTimeout(timer.current);
timer.current = setTimeout(() => {
setExiting(true);
setTimeout(() => dismiss(id), 200);
}, duration);
}, [id, duration]);
useEffect(() => {
startTimer();
return () => clearTimeout(timer.current);
}, [startTimer]);
const onEnter = useCallback(() => {
hovered.current = true;
clearTimeout(timer.current);
}, []);
const onLeave = useCallback(() => {
hovered.current = false;
startTimer();
}, [startTimer]);
const cls = `sw-toast sw-toast--${variant} ${exiting ? 'sw-toast--exit' : 'sw-toast--enter'}`;
return html`
<div class=${cls} role="alert"
onMouseEnter=${onEnter}
onMouseLeave=${onLeave}>
<span class="sw-toast__msg">${message}</span>
<button class="sw-toast__close" onClick=${() => {
setExiting(true);
setTimeout(() => dismiss(id), 200);
}} aria-label="Dismiss">\u00d7</button>
</div>
`;
}
// ── Container ────────────────────────────────
export function ToastContainer() {
const [toasts, setToasts] = useState(_toasts);
_setToasts = setToasts;
useEffect(() => () => { _setToasts = null; }, []);
if (!toasts.length) return null;
return html`
<div class="sw-toast-container" aria-live="polite">
${toasts.map(t => html`<${ToastItem} key=${t.id} ...${t} />`)}
</div>
`;
}

View File

@@ -0,0 +1,36 @@
/**
* Tooltip primitive — hover tooltip
* Positions: top (default), bottom, left, right
*/
const { html } = window;
const { useState, useRef, useCallback } = hooks;
export function Tooltip({ content, position = 'top', children }) {
const [visible, setVisible] = useState(false);
const timeout = useRef(null);
const show = useCallback(() => {
clearTimeout(timeout.current);
timeout.current = setTimeout(() => setVisible(true), 200);
}, []);
const hide = useCallback(() => {
clearTimeout(timeout.current);
setVisible(false);
}, []);
return html`
<span class="sw-tooltip-wrap"
onMouseEnter=${show}
onMouseLeave=${hide}
onFocus=${show}
onBlur=${hide}>
${children}
${visible && html`
<span class="sw-tooltip sw-tooltip--${position}" role="tooltip">
${content}
</span>
`}
</span>
`;
}

View File

@@ -0,0 +1,81 @@
/**
* AppShell — Layer 1 layout scaffold
*
* ┌─────────────────────────────────┐ ← fixed banner (top, optional)
* ├─────────────────────────────────┤
* │ message (optional, dismissible)│
* ├─────────────────────────────────┤
* │ │
* │ surface (fills remaining) │
* │ │
* ├─────────────────────────────────┤
* │ footer (optional) │
* ├─────────────────────────────────┤
* └─────────────────────────────────┘ ← fixed banner (bottom, same as top)
*
* Props:
* banner — { text, variant } or null (one config → top AND bottom)
* message — { text, variant } or null (dismissible)
* footer — VNode children or null
* children — Surface content
*/
const { html } = window;
const { useState, useEffect, useRef } = hooks;
function ShellBanner({ position, text, variant = 'info' }) {
const ref = useRef(null);
const prop = position === 'top' ? '--banner-top-height' : '--banner-bottom-height';
useEffect(() => {
if (ref.current) {
const h = ref.current.offsetHeight + 'px';
document.documentElement.style.setProperty(prop, h);
}
return () => document.documentElement.style.setProperty(prop, '0px');
}, [text, prop]);
return html`
<div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}" ref=${ref}>
<span class="sw-shell__banner-text">${text}</span>
</div>
`;
}
export function AppShell({ banner, message, footer, children }) {
const [msgDismissed, setMsgDismissed] = useState(false);
const showMsg = message && !msgDismissed;
return html`
<div class="sw-shell">
${banner && html`
<${ShellBanner} position="top" text=${banner.text} variant=${banner.variant} />
`}
<div class="sw-shell__body">
${showMsg && html`
<div class="sw-shell__announcement">
<div class="sw-shell__announcement-inner sw-shell__announcement--${message.variant || 'info'}">
<span class="sw-shell__announcement-text">${message.text}</span>
<button class="sw-shell__banner-close" onClick=${() => setMsgDismissed(true)} aria-label="Dismiss">×</button>
</div>
</div>
`}
<main class="sw-shell__surface">
${children}
</main>
${footer && html`
<footer class="sw-shell__footer">
${footer}
</footer>
`}
</div>
${banner && html`
<${ShellBanner} position="bottom" text=${banner.text} variant=${banner.variant} />
`}
</div>
`;
}

2
src/js/sw/vendor/hooks.module.js vendored Normal file
View File

@@ -0,0 +1,2 @@
import{options as n}from"./preact.module.js";var t,r,u,i,o=0,f=[],c=n,e=c.__b,a=c.__r,v=c.diffed,l=c.__c,m=c.unmount,s=c.__;function p(n,t){c.__h&&c.__h(r,n,o||t),o=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=u.__.length&&u.__.push({}),u.__[n]}function d(n){return o=1,h(D,n)}function h(n,u,i){var o=p(t++,2);if(o.t=n,!o.__c&&(o.__=[i?i(u):D(void 0,u),function(n){var t=o.__N?o.__N[0]:o.__[0],r=o.t(t,n);t!==r&&(o.__N=[r,o.__[1]],o.__c.setState({}))}],o.__c=r,!r.__f)){var f=function(n,t,r){if(!o.__c.__H)return!0;var u=o.__c.__H.__.filter(function(n){return n.__c});if(u.every(function(n){return!n.__N}))return!c||c.call(this,n,t,r);var i=o.__c.props!==n;return u.some(function(n){if(n.__N){var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=!0)}}),c&&c.call(this,n,t,r)||i};r.__f=!0;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,r){if(this.__e){var u=c;c=void 0,f(n,t,r),c=u}e&&e.call(this,n,t,r)},r.shouldComponentUpdate=f}return o.__N||o.__}function y(n,u){var i=p(t++,3);!c.__s&&C(i.__H,u)&&(i.__=n,i.u=u,r.__H.__h.push(i))}function _(n,u){var i=p(t++,4);!c.__s&&C(i.__H,u)&&(i.__=n,i.u=u,r.__h.push(i))}function A(n){return o=5,T(function(){return{current:n}},[])}function F(n,t,r){o=6,_(function(){if("function"==typeof n){var r=n(t());return function(){n(null),r&&"function"==typeof r&&r()}}if(n)return n.current=t(),function(){return n.current=null}},null==r?r:r.concat(n))}function T(n,r){var u=p(t++,7);return C(u.__H,r)&&(u.__=n(),u.__H=r,u.__h=n),u.__}function q(n,t){return o=8,T(function(){return n},t)}function x(n){var u=r.context[n.__c],i=p(t++,9);return i.c=n,u?(null==i.__&&(i.__=!0,u.sub(r)),u.props.value):n.__}function P(n,t){c.useDebugValue&&c.useDebugValue(t?t(n):n)}function b(n){var u=p(t++,10),i=d();return u.__=n,r.componentDidCatch||(r.componentDidCatch=function(n,t){u.__&&u.__(n,t),i[1](n)}),[i[0],function(){i[1](void 0)}]}function g(){var n=p(t++,11);if(!n.__){for(var u=r.__v;null!==u&&!u.__m&&null!==u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++}return n.__}function j(){for(var n;n=f.shift();){var t=n.__H;if(n.__P&&t)try{t.__h.some(z),t.__h.some(B),t.__h=[]}catch(r){t.__h=[],c.__e(r,n.__v)}}}c.__b=function(n){r=null,e&&e(n)},c.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),s&&s(n,t)},c.__r=function(n){a&&a(n),t=0;var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0})):(i.__h.some(z),i.__h.some(B),i.__h=[],t=0)),u=r},c.diffed=function(n){v&&v(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==f.push(t)&&i===c.requestAnimationFrame||((i=c.requestAnimationFrame)||w)(j)),t.__H.__.some(function(n){n.u&&(n.__H=n.u),n.u=void 0})),u=r=null},c.__c=function(n,t){t.some(function(n){try{n.__h.some(z),n.__h=n.__h.filter(function(n){return!n.__||B(n)})}catch(r){t.some(function(n){n.__h&&(n.__h=[])}),t=[],c.__e(r,n.__v)}}),l&&l(n,t)},c.unmount=function(n){m&&m(n);var t,r=n.__c;r&&r.__H&&(r.__H.__.some(function(n){try{z(n)}catch(n){t=n}}),r.__H=void 0,t&&c.__e(t,r.__v))};var k="function"==typeof requestAnimationFrame;function w(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n)},u=setTimeout(r,35);k&&(t=requestAnimationFrame(r))}function z(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t}function B(n){var t=r;n.__c=n.__(),r=t}function C(n,t){return!n||n.length!==t.length||t.some(function(t,r){return t!==n[r]})}function D(n,t){return"function"==typeof t?t(n):t}export{q as useCallback,x as useContext,P as useDebugValue,y as useEffect,b as useErrorBoundary,g as useId,F as useImperativeHandle,_ as useLayoutEffect,T as useMemo,h as useReducer,A as useRef,d as useState};
//# sourceMappingURL=hooks.module.js.map

1
src/js/sw/vendor/htm.module.js vendored Normal file
View File

@@ -0,0 +1 @@
var n=function(t,s,r,e){var u;s[0]=0;for(var h=1;h<s.length;h++){var p=s[h++],a=s[h]?(s[0]|=p?1:2,r[s[h++]]):s[++h];3===p?e[0]=a:4===p?e[1]=Object.assign(e[1]||{},a):5===p?(e[1]=e[1]||{})[s[++h]]=a:6===p?e[1][s[++h]]+=a+"":p?(u=t.apply(a,n(t,a,r,["",null])),e.push(u),a[0]?s[0]|=2:(s[h-2]=0,s[h]=u)):e.push(a)}return e},t=new Map;export default function(s){var r=t.get(this);return r||(r=new Map,t.set(this,r)),(r=n(this,r.get(s)||(r.set(s,r=function(n){for(var t,s,r=1,e="",u="",h=[0],p=function(n){1===r&&(n||(e=e.replace(/^\s*\n\s*|\s*\n\s*$/g,"")))?h.push(0,n,e):3===r&&(n||e)?(h.push(3,n,e),r=2):2===r&&"..."===e&&n?h.push(4,n,0):2===r&&e&&!n?h.push(5,0,!0,e):r>=5&&((e||!n&&5===r)&&(h.push(r,0,e,s),r=6),n&&(h.push(r,n,0,s),r=6)),e=""},a=0;a<n.length;a++){a&&(1===r&&p(),p(a));for(var l=0;l<n[a].length;l++)t=n[a][l],1===r?"<"===t?(p(),h=[h],r=3):e+=t:4===r?"--"===e&&">"===t?(r=1,e=""):e=t+e[0]:u?t===u?u="":e+=t:'"'===t||"'"===t?u=t:">"===t?(p(),r=1):r&&("="===t?(r=5,s=e,e=""):"/"===t&&(r<5||">"===n[a][l+1])?(p(),3===r&&(h=h[0]),r=h,(h=h[0]).push(2,0,r),r=0):" "===t||"\t"===t||"\n"===t||"\r"===t?(p(),r=2):e+=t),3===r&&"!--"===e&&(r=4,h=h[0])}return p(),h}(s)),r),arguments,[])).length>1?r:r[0]}

2
src/js/sw/vendor/preact.module.js vendored Normal file

File diff suppressed because one or more lines are too long