SPB Git

Repository Public

ui.tsx 18.7 KB 251 lines tsx

1// Small accessible building blocks shared by every screen. No external UI library.2import { type ButtonHTMLAttributes, createContext, type InputHTMLAttributes, type ReactNode, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';3import { createPortal } from 'react-dom';4import { Art } from './art.tsx';5import { Glyph } from './glyphs.tsx';6import { errorText, useT } from './i18n.ts';78export const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');910// --- buttons ---11type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';12const VARIANT: Record<Variant, string> = {13  primary: 'btn-grad font-semibold',14  secondary: 'bg-surface text-ink ring-1 ring-inset ring-line-strong hover:bg-hover',15  ghost: 'text-ink-2 hover:bg-hover hover:text-ink',16  danger: 'bg-danger text-white dark:text-[#1a0508] font-semibold hover:brightness-110',17};18export function Button({ variant = 'secondary', size = 'md', busy, icon, className, children, ...p }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: Variant; size?: 'sm' | 'md' | 'lg'; busy?: boolean; icon?: string }) {19  return (20    <button type="button" {...p} disabled={p.disabled || busy} aria-busy={busy || undefined}21      className={cx('press inline-flex items-center justify-center gap-2 font-medium select-none disabled:opacity-50 disabled:pointer-events-none whitespace-nowrap',22        size === 'sm' ? 'h-9 rounded-xl px-3.5 text-sm' : size === 'lg' ? 'h-14 rounded-2xl px-5 text-base sm:text-[17px]' : 'h-11 rounded-[14px] px-4 text-[15px] sm:text-sm', VARIANT[variant], className)}>23      {busy ? <Spinner size={16} /> : icon ? <Glyph name={icon} size={size === 'sm' ? 16 : size === 'lg' ? 20 : 18} className="shrink-0" /> : null}24      {children}25    </button>26  );27}28export function IconButton({ icon, label, className, size = 20, active, ...p }: ButtonHTMLAttributes<HTMLButtonElement> & { icon: string; label: string; size?: number; active?: boolean }) {29  return (30    <button type="button" {...p} aria-label={label} title={label} aria-pressed={active}31      className={cx('press inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-ink-2 hover:bg-hover hover:text-ink active:bg-hover disabled:opacity-40 disabled:pointer-events-none', active && 'bg-accent-soft text-accent', className)}>32      <Glyph name={icon} size={size} />33    </button>34  );35}36export function Spinner({ size = 20 }: { size?: number }) {37  return <svg width={size} height={size} viewBox="0 0 24 24" className="animate-spin" role="status" aria-label="…"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" /><path d="M21 12a9 9 0 0 0-9-9" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" /></svg>;38}3940// --- form fields ---41// 16 px on phones: iOS zooms the page when a focused field is smaller42const inputCls = 'h-12 sm:h-11 w-full rounded-[14px] border border-line-strong bg-surface px-3.5 text-base sm:text-sm text-ink placeholder:text-ink-3 transition-[border-color,box-shadow] focus:border-accent focus:outline-none focus:ring-4 focus:ring-accent/15 disabled:opacity-60';43export function Field({ label, hint, error, children }: { label: string; hint?: string; error?: string | null; children: (id: string, describedBy: string | undefined) => ReactNode }) {44  const id = useId();45  const desc = hint || error ? `${id}-d` : undefined;46  return (47    <div className="flex flex-col gap-1.5">48      <label htmlFor={id} className="text-sm font-medium text-ink">{label}</label>49      {children(id, desc)}50      {error ? <p id={desc} role="alert" className="text-xs text-danger">{error}</p> : hint ? <p id={desc} className="text-xs text-ink-3">{hint}</p> : null}51    </div>52  );53}54export function TextField({ label, hint, error, className, ...p }: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: string; error?: string | null }) {55  return <Field label={label} hint={hint} error={error}>{(id, d) => <input id={id} aria-describedby={d} aria-invalid={!!error || undefined} {...p} className={cx(inputCls, className)} />}</Field>;56}57export function TextArea({ label, hint, rows = 3, ...p }: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string; hint?: string }) {58  return <Field label={label} hint={hint}>{(id, d) => <textarea id={id} aria-describedby={d} rows={rows} {...p} className={cx(inputCls, 'h-auto! py-2.5 leading-relaxed')} />}</Field>;59}60export function Select({ label, hint, children, ...p }: React.SelectHTMLAttributes<HTMLSelectElement> & { label: string; hint?: string }) {61  return <Field label={label} hint={hint}>{(id, d) => <select id={id} aria-describedby={d} {...p} className={inputCls}>{children}</select>}</Field>;62}63export function Switch({ checked, onChange, label, hint, disabled }: { checked: boolean; onChange: (v: boolean) => void; label: string; hint?: string; disabled?: boolean }) {64  const id = useId();65  return (66    <div className="flex items-start justify-between gap-4">67      <label htmlFor={id} className="min-w-0 cursor-pointer"><span className="block text-sm font-medium text-ink">{label}</span>{hint ? <span className="block text-xs text-ink-3">{hint}</span> : null}</label>68      <button id={id} type="button" role="switch" aria-checked={checked} disabled={disabled} onClick={() => onChange(!checked)}69        className={cx('relative mt-0.5 h-[31px] w-[51px] shrink-0 rounded-full transition-colors duration-300 disabled:opacity-50', checked ? 'grad' : 'bg-line-strong')}>70        <span className={cx('absolute top-0.5 left-0.5 h-[27px] w-[27px] rounded-full bg-white shadow-md transition-transform duration-300 ease-(--ease-spring)', checked && 'translate-x-5')} />71      </button>72    </div>73  );74}75export function Badge({ tone = 'neutral', children }: { tone?: 'neutral' | 'ok' | 'warn' | 'danger' | 'accent'; children: ReactNode }) {76  const c = { neutral: 'bg-hover text-ink-2', ok: 'bg-ok-soft text-ok', warn: 'bg-warn-soft text-warn', danger: 'bg-danger-soft text-danger', accent: 'bg-accent-soft text-accent' }[tone];77  return <span className={cx('inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold', c)}>{children}</span>;78}79export function ProgressBar({ value, label }: { value: number; label?: string }) {80  const v = Math.max(0, Math.min(1, value));81  return <div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(v * 100)} className="h-2 w-full overflow-hidden rounded-full bg-hover"><div className={cx('h-full rounded-full transition-[width] duration-300', v > 0 && v < 1 ? 'progress-live' : 'grad')} style={{ width: `${v * 100}%` }} /></div>;82}83export function EmptyState({ icon, title, text, action }: { icon: string; title: string; text?: string; action?: ReactNode }) {84  return (85    <div className="anim-rise flex flex-col items-center justify-center gap-3 px-6 py-12 text-center sm:py-16">86      <Art name={icon} />87      <h2 className="text-xl font-bold tracking-tight text-ink">{title}</h2>88      {text ? <p className="max-w-xs text-[15px] leading-relaxed text-ink-2">{text}</p> : null}89      {action ? <div className="mt-2">{action}</div> : null}90    </div>91  );92}93export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {94  const t = useT();95  return <EmptyState icon="warning" title={errorText(error)} action={onRetry ? <Button onClick={onRetry} icon="refresh">{t('common.retry')}</Button> : undefined} />;96}97export function Loading({ label }: { label?: string }) {98  const t = useT();99  return <div className="flex items-center justify-center gap-3 py-16 text-ink-3"><span className="text-accent"><Spinner /></span><span className="text-sm">{label ?? t('common.loading')}</span></div>;100}101102// --- dialog: modal on desktop, bottom sheet on phones; focus is trapped and restored, Escape closes ---103const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';104export function Dialog({ title, onClose, children, footer, wide, dismissable = true }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode; wide?: boolean; dismissable?: boolean }) {105  const ref = useRef<HTMLDivElement>(null);106  const titleId = useId();107  const t = useT();108  useLayoutEffect(() => {109    const prev = document.activeElement as HTMLElement | null;110    const el = ref.current!;111    (el.querySelector<HTMLElement>('[data-autofocus]') ?? el.querySelector<HTMLElement>(FOCUSABLE) ?? el).focus();112    const overflow = document.body.style.overflow; document.body.style.overflow = 'hidden';113    return () => { document.body.style.overflow = overflow; prev?.focus?.(); };114  }, []);115  const onKey = (e: React.KeyboardEvent) => {116    if (e.key === 'Escape' && dismissable) { e.stopPropagation(); onClose(); }117    if (e.key !== 'Tab') return;118    const f = [...ref.current!.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((x) => x.offsetParent !== null);119    if (!f.length) return;120    const first = f[0]!, last = f[f.length - 1]!;121    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }122  };123  return createPortal(124    <div className="anim-fade fixed inset-0 z-50 flex items-end justify-center bg-(--scrim) backdrop-blur-[3px] sm:items-center sm:p-4" onMouseDown={(e) => { if (e.target === e.currentTarget && dismissable) onClose(); }}>125      <div ref={ref} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1} onKeyDown={onKey}126        className={cx('anim-sheet safe-b relative flex max-h-[92dvh] w-full flex-col rounded-t-[28px] bg-raised shadow-pop outline-none sm:rounded-[26px]', wide ? 'sm:max-w-2xl' : 'sm:max-w-md')}>127        <span aria-hidden="true" className="absolute top-2 left-1/2 h-[5px] w-10 -translate-x-1/2 rounded-full bg-line-strong sm:hidden" />128        <header className="flex items-center justify-between gap-3 px-5 pt-6 pb-2 sm:pt-5">129          <h2 id={titleId} className="min-w-0 truncate text-xl font-bold tracking-tight">{title}</h2>130          {dismissable ? <IconButton icon="x" label={t('common.close')} onClick={onClose} className="-mr-2 bg-hover h-9! w-9!" size={18} /> : null}131        </header>132        <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-2 pb-5">{children}</div>133        {footer ? <footer className="flex flex-wrap items-center justify-end gap-2 px-5 pt-1 pb-4 max-sm:flex-col-reverse max-sm:items-stretch">{footer}</footer> : null}134      </div>135    </div>, document.body);136}137138// --- menu (dropdown or context menu) with arrow-key navigation ---139export interface MenuItem { label: string; icon?: string; onSelect: () => void; danger?: boolean; disabled?: boolean; separatorBefore?: boolean; shortcut?: string }140export function Menu({ items, anchor, onClose, label }: { items: MenuItem[]; anchor: { x: number; y: number } | DOMRect; onClose: () => void; label: string }) {141  const ref = useRef<HTMLDivElement>(null);142  const [pos, setPos] = useState<{ left: number; top: number } | null>(null);143  useLayoutEffect(() => {144    const el = ref.current!;145    const r = el.getBoundingClientRect();146    const x = 'width' in anchor ? anchor.right - r.width : anchor.x, y = 'width' in anchor ? anchor.bottom + 4 : anchor.y;147    setPos({ left: Math.max(8, Math.min(x, window.innerWidth - r.width - 8)), top: Math.max(8, Math.min(y, window.innerHeight - r.height - 8)) });148    el.querySelector<HTMLElement>('[role=menuitem]:not([disabled])')?.focus({ preventScroll: true });149  }, [anchor]);150  useEffect(() => {151    const close = (e: Event) => { if (!ref.current?.contains(e.target as Node)) onClose(); };152    const scroll = (e: Event) => { if (!ref.current?.hasAttribute('data-sheet')) close(e); }; // a sheet covers the page: nothing under it can move away153    window.addEventListener('mousedown', close); window.addEventListener('scroll', scroll, true); window.addEventListener('resize', onClose);154    return () => { window.removeEventListener('mousedown', close); window.removeEventListener('scroll', scroll, true); window.removeEventListener('resize', onClose); };155  }, [onClose]);156  const onKey = (e: React.KeyboardEvent) => {157    const els = [...ref.current!.querySelectorAll<HTMLElement>('[role=menuitem]:not([disabled])')];158    const i = els.indexOf(document.activeElement as HTMLElement);159    if (e.key === 'ArrowDown') { e.preventDefault(); els[(i + 1) % els.length]?.focus(); }160    else if (e.key === 'ArrowUp') { e.preventDefault(); els[(i - 1 + els.length) % els.length]?.focus(); }161    else if (e.key === 'Home') { e.preventDefault(); els[0]?.focus(); } else if (e.key === 'End') { e.preventDefault(); els[els.length - 1]?.focus(); }162    else if (e.key === 'Escape' || e.key === 'Tab') { e.preventDefault(); onClose(); }163  };164  // on a phone the menu is an action sheet at the bottom of the screen, within reach of the thumb165  const sheet = isMobile();166  const list = items.map((it, i) => (167    <div key={i}>168      {it.separatorBefore ? <div className={cx('h-px bg-line', sheet ? 'mx-4 my-1.5' : 'mx-2 my-1')} role="separator" /> : null}169      <button type="button" role="menuitem" disabled={it.disabled} onClick={() => { onClose(); it.onSelect(); }}170        className={cx('flex w-full items-center text-left outline-none transition focus:bg-hover hover:bg-hover active:bg-hover disabled:opacity-40', sheet ? 'min-h-[52px] gap-4 rounded-2xl px-4 text-[17px]' : 'gap-3 rounded-[10px] px-3 py-2 text-sm', it.danger ? 'text-danger' : 'text-ink')}>171        {it.icon ? <Glyph name={it.icon} size={sheet ? 22 : 18} className={cx('shrink-0', it.danger ? '' : 'text-ink-2')} /> : null}172        <span className="min-w-0 flex-1 truncate">{it.label}</span>173        {it.shortcut ? <kbd className="hidden font-sans text-xs text-ink-3 sm:block">{it.shortcut}</kbd> : null}174      </button>175    </div>176  ));177  if (sheet) return createPortal(178    <div className="anim-fade fixed inset-0 z-[60] flex items-end bg-(--scrim) backdrop-blur-[3px]" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>179      <div ref={ref} role="menu" aria-label={label} onKeyDown={onKey} data-sheet180        className="anim-sheet safe-b relative max-h-[82dvh] w-full overflow-y-auto overscroll-contain rounded-t-[28px] bg-raised px-2 pt-7 shadow-pop">181        <span aria-hidden="true" className="absolute top-2 left-1/2 h-[5px] w-10 -translate-x-1/2 rounded-full bg-line-strong" />182        <p className="truncate px-4 pb-2 text-[13px] font-medium text-ink-3" aria-hidden="true">{label}</p>183        {list}184        <div className="h-2" />185      </div>186    </div>, document.body);187  return createPortal(188    <div ref={ref} role="menu" aria-label={label} onKeyDown={onKey} style={{ left: pos?.left ?? -9999, top: pos?.top ?? -9999 }}189      className="glass anim-pop fixed z-[60] min-w-56 max-w-[calc(100vw-16px)] rounded-2xl p-1.5 shadow-pop">190      {list}191    </div>, document.body);192}193194// --- toasts ---195interface Toast { id: number; text: string; tone: 'info' | 'ok' | 'error'; action?: { label: string; run: () => void } }196const ToastCtx = createContext<(text: string, o?: { tone?: Toast['tone']; action?: Toast['action']; ms?: number }) => void>(() => {});197export const useToast = () => useContext(ToastCtx);198export function ToastProvider({ children }: { children: ReactNode }) {199  const [toasts, setToasts] = useState<Toast[]>([]);200  const push = useCallback((text: string, o: { tone?: Toast['tone']; action?: Toast['action']; ms?: number } = {}) => {201    const id = Date.now() + Math.random();202    setToasts((ts) => [...ts.slice(-3), { id, text, tone: o.tone ?? 'info', action: o.action }]);203    setTimeout(() => setToasts((ts) => ts.filter((x) => x.id !== id)), o.ms ?? (o.tone === 'error' ? 7000 : 4000));204  }, []);205  return (206    <ToastCtx.Provider value={push}>207      {children}208      <div className="pointer-events-none fixed inset-x-0 z-[70] flex flex-col items-center gap-2 px-4" style={{ bottom: 'calc(var(--dock) + 14px)' }} aria-live="polite" aria-atomic="false">209        {toasts.map((x) => (210          <div key={x.id} role={x.tone === 'error' ? 'alert' : 'status'} className={cx('anim-pop pointer-events-auto flex max-w-md items-center gap-3 rounded-2xl py-3 pr-4 pl-3 text-sm font-medium shadow-pop', x.tone === 'error' ? 'bg-danger text-white dark:text-[#1a0508]' : 'bg-ink text-bg')}>211            <span className={cx('flex h-7 w-7 shrink-0 items-center justify-center rounded-full', x.tone === 'ok' ? 'grad' : 'bg-white/15 dark:bg-black/10')}><Glyph name={x.tone === 'error' ? 'warning' : x.tone === 'ok' ? 'check' : 'info'} size={16} strokeWidth={2.2} /></span>212            <span className="min-w-0">{x.text}</span>213            {x.action ? <button type="button" className="shrink-0 font-semibold underline underline-offset-2" onClick={() => { x.action!.run(); setToasts((ts) => ts.filter((y) => y.id !== x.id)); }}>{x.action.label}</button> : null}214          </div>215        ))}216      </div>217    </ToastCtx.Provider>218  );219}220221export function ConfirmDialog({ title, text, confirmLabel, danger, onConfirm, onClose }: { title: string; text: string; confirmLabel: string; danger?: boolean; onConfirm: () => Promise<void> | void; onClose: () => void }) {222  const t = useT();223  const toast = useToast();224  const [busy, setBusy] = useState(false);225  const run = async () => { setBusy(true); try { await onConfirm(); onClose(); } catch (e) { toast(errorText(e), { tone: 'error' }); setBusy(false); } };226  return (227    <Dialog title={title} onClose={onClose} footer={<><Button onClick={onClose}>{t('common.cancel')}</Button><Button variant={danger ? 'danger' : 'primary'} busy={busy} onClick={run} data-autofocus>{confirmLabel}</Button></>}>228      <p className="text-[15px] leading-relaxed text-ink-2">{text}</p>229    </Dialog>230  );231}232/** Copy text; falls back to a selection-based copy where the async clipboard is unavailable. */233export async function copyText(text: string): Promise<boolean> {234  try { await navigator.clipboard.writeText(text); return true; } catch {235    const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select();236    const ok = document.execCommand('copy'); ta.remove(); return ok;237  }238}239/** A read-only value with a copy button (links shown once, recovery codes…). */240export function CopyField({ value, label }: { value: string; label: string }) {241  const t = useT();242  const [done, setDone] = useState(false);243  return (244    <div className="flex items-center gap-2">245      <input readOnly aria-label={label} value={value} onFocus={(e) => e.currentTarget.select()} className={cx(inputCls, 'font-mono text-xs')} />246      <Button variant="primary" icon={done ? 'check' : 'copy'} onClick={async () => { if (await copyText(value)) { setDone(true); setTimeout(() => setDone(false), 1800); } }}>{done ? t('common.copied') : t('common.copy')}</Button>247    </div>248  );249}250export const isMobile = () => window.matchMedia('(max-width: 639px)').matches;251
Files main
Menu

Theme