From 03402ba0e1e82c5231f24fc2e348b5f6d93ecba1 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 12:06:34 +0530 Subject: [PATCH] feat(ui): Toaster with pure store (TDD) + FormGrid; provider mounted at root Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/hq-web/src/main.tsx | 4 +-- packages/ui/src/formgrid.tsx | 15 ++++++++++ packages/ui/src/index.ts | 2 ++ packages/ui/src/toast-store.ts | 17 +++++++++++ packages/ui/src/toast.tsx | 45 ++++++++++++++++++++++++++++ packages/ui/src/tokens.css | 23 ++++++++++++++ packages/ui/test/toast-store.test.ts | 24 +++++++++++++++ 7 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/formgrid.tsx create mode 100644 packages/ui/src/toast-store.ts create mode 100644 packages/ui/src/toast.tsx create mode 100644 packages/ui/test/toast-store.test.ts diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index db64a8a..4f51de0 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -4,7 +4,7 @@ import '@fontsource-variable/inter' import '@fontsource-variable/jetbrains-mono' import '../../../packages/ui/src/tokens.css' import './app.css' -import { initTheme } from '@sims/ui' +import { initTheme, ToastProvider } from '@sims/ui' import { Layout } from './Layout' import { Login } from './Login' import { Dashboard } from './pages/Dashboard' @@ -42,4 +42,4 @@ function App() { } initTheme() -createRoot(document.getElementById('root')!).render() +createRoot(document.getElementById('root')!).render() diff --git a/packages/ui/src/formgrid.tsx b/packages/ui/src/formgrid.tsx new file mode 100644 index 0000000..b91712f --- /dev/null +++ b/packages/ui/src/formgrid.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { Field } from './components' + +/** Two-column field grid for dialog bodies; single column under 560px. */ +export function FormGrid(props: { children: ReactNode }) { + return
{props.children}
+} + +export function FormField(props: { label: string; wide?: boolean; children: ReactNode }) { + return ( +
+ {props.children} +
+ ) +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2c1e39c..333679a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -9,3 +9,5 @@ export * from './tabs' export * from './record' export * from './palette-match' export * from './palette' +export * from './toast' +export * from './formgrid' diff --git a/packages/ui/src/toast-store.ts b/packages/ui/src/toast-store.ts new file mode 100644 index 0000000..93289c4 --- /dev/null +++ b/packages/ui/src/toast-store.ts @@ -0,0 +1,17 @@ +/** Pure toast state — testable without a DOM. */ + +export interface Toast { id: number; tone: 'ok' | 'err'; message: string } +export interface ToastState { nextId: number; toasts: Toast[] } + +export const TOAST_LIMIT = 3 +export const initialToasts: ToastState = { nextId: 1, toasts: [] } + +export function pushToast(s: ToastState, tone: Toast['tone'], message: string): ToastState { + const toast: Toast = { id: s.nextId, tone, message } + const toasts = [...s.toasts, toast].slice(-TOAST_LIMIT) + return { nextId: s.nextId + 1, toasts } +} + +export function dismissToast(s: ToastState, id: number): ToastState { + return { ...s, toasts: s.toasts.filter((t) => t.id !== id) } +} diff --git a/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx new file mode 100644 index 0000000..98d0c5c --- /dev/null +++ b/packages/ui/src/toast.tsx @@ -0,0 +1,45 @@ +import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react' +import { dismissToast, initialToasts, pushToast, type Toast, type ToastState } from './toast-store' + +const AUTO_DISMISS_MS = 4000 + +interface ToastApi { ok: (msg: string) => void; err: (msg: string) => void } +const Ctx = createContext(null) + +/** Mount once at app root; renders the bottom-right stack. */ +export function ToastProvider(props: { children: ReactNode }) { + const [state, setState] = useState(initialToasts) + const stateRef = useRef(state) + stateRef.current = state + + const push = useCallback((tone: Toast['tone'], message: string) => { + const id = stateRef.current.nextId + setState((s) => pushToast(s, tone, message)) + window.setTimeout(() => setState((s) => dismissToast(s, id)), AUTO_DISMISS_MS) + }, []) + + const api = useMemo(() => ({ + ok: (msg) => push('ok', msg), + err: (msg) => push('err', msg), + }), [push]) + + return ( + + {props.children} +
+ {state.toasts.map((t) => ( +
+ {t.message} + +
+ ))} +
+
+ ) +} + +export function useToast(): ToastApi { + const api = useContext(Ctx) + if (api === null) throw new Error('useToast requires at the app root') + return api +} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 1597060..c6965df 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -376,3 +376,26 @@ table.wf tbody tr.tone-ok:hover td { filter: brightness(0.97); } display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 18px; } button.wf.pill { border-radius: 999px; padding: 8px 18px; } + +/* ================= toasts ================= */ +.wf-toasts { + position: fixed; right: 16px; bottom: 16px; z-index: 70; + display: flex; flex-direction: column; gap: 8px; max-width: 360px; +} +.wf-toast { + display: flex; align-items: center; gap: 10px; + background: var(--bg-raised); border: 1px solid var(--border); + border-radius: var(--radius); box-shadow: var(--shadow-lg); + padding: 10px 14px; font-size: 13px; +} +.wf-toast .msg { flex: 1; } +.wf-toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); } +.wf-toast.err { border-color: color-mix(in srgb, var(--err) 45%, transparent); background: var(--err-soft); } +.wf-toast button { + border: none; background: none; color: var(--text-dim); cursor: pointer; font-size: 11px; padding: 2px; +} + +/* ================= form grid (dialog bodies) ================= */ +.wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; } +.wf-formgrid .wide { grid-column: 1 / -1; } +@media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } } diff --git a/packages/ui/test/toast-store.test.ts b/packages/ui/test/toast-store.test.ts new file mode 100644 index 0000000..fc2584b --- /dev/null +++ b/packages/ui/test/toast-store.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { dismissToast, initialToasts, pushToast, TOAST_LIMIT } from '../src/toast-store' + +describe('toast store', () => { + it('pushes with increasing ids', () => { + let s = pushToast(initialToasts, 'ok', 'saved') + s = pushToast(s, 'err', 'failed') + expect(s.toasts.map((t) => t.id)).toEqual([1, 2]) + expect(s.toasts[1]).toMatchObject({ tone: 'err', message: 'failed' }) + }) + it('keeps only the newest TOAST_LIMIT toasts', () => { + let s = initialToasts + for (let i = 0; i < TOAST_LIMIT + 2; i++) s = pushToast(s, 'ok', `m${i}`) + expect(s.toasts).toHaveLength(TOAST_LIMIT) + expect(s.toasts[0]!.message).toBe('m2') + }) + it('dismisses by id and ignores unknown ids', () => { + let s = pushToast(initialToasts, 'ok', 'a') + s = pushToast(s, 'ok', 'b') + s = dismissToast(s, 1) + expect(s.toasts.map((t) => t.message)).toEqual(['b']) + expect(dismissToast(s, 99).toasts).toHaveLength(1) + }) +})