Changeset 0.37.2 (#214)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
35
src/js/sw/primitives/avatar.js
Normal file
35
src/js/sw/primitives/avatar.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
26
src/js/sw/primitives/banner.js
Normal file
26
src/js/sw/primitives/banner.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
22
src/js/sw/primitives/button.js
Normal file
22
src/js/sw/primitives/button.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
44
src/js/sw/primitives/confirm.js
Normal file
44
src/js/sw/primitives/confirm.js
Normal 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>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
93
src/js/sw/primitives/dialog.js
Normal file
93
src/js/sw/primitives/dialog.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
47
src/js/sw/primitives/drawer.js
Normal file
47
src/js/sw/primitives/drawer.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
106
src/js/sw/primitives/dropdown.js
Normal file
106
src/js/sw/primitives/dropdown.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
19
src/js/sw/primitives/form-field.js
Normal file
19
src/js/sw/primitives/form-field.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
18
src/js/sw/primitives/index.js
Normal file
18
src/js/sw/primitives/index.js
Normal 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';
|
||||
159
src/js/sw/primitives/menu.js
Normal file
159
src/js/sw/primitives/menu.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
60
src/js/sw/primitives/prompt.js
Normal file
60
src/js/sw/primitives/prompt.js
Normal 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); }} />
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
9
src/js/sw/primitives/spinner.js
Normal file
9
src/js/sw/primitives/spinner.js
Normal 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" />`;
|
||||
}
|
||||
60
src/js/sw/primitives/tabs.js
Normal file
60
src/js/sw/primitives/tabs.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
94
src/js/sw/primitives/toast.js
Normal file
94
src/js/sw/primitives/toast.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
36
src/js/sw/primitives/tooltip.js
Normal file
36
src/js/sw/primitives/tooltip.js
Normal 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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user