feat(ui): Toaster with pure store (TDD) + FormGrid; provider mounted at root

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 0b46c03530
commit 03402ba0e1

@ -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(<App />)
createRoot(document.getElementById('root')!).render(<ToastProvider><App /></ToastProvider>)

@ -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 <div className="wf-formgrid">{props.children}</div>
}
export function FormField(props: { label: string; wide?: boolean; children: ReactNode }) {
return (
<div className={props.wide === true ? 'wide' : undefined}>
<Field label={props.label}>{props.children}</Field>
</div>
)
}

@ -9,3 +9,5 @@ export * from './tabs'
export * from './record'
export * from './palette-match'
export * from './palette'
export * from './toast'
export * from './formgrid'

@ -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) }
}

@ -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<ToastApi | null>(null)
/** Mount once at app root; renders the bottom-right stack. */
export function ToastProvider(props: { children: ReactNode }) {
const [state, setState] = useState<ToastState>(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<ToastApi>(() => ({
ok: (msg) => push('ok', msg),
err: (msg) => push('err', msg),
}), [push])
return (
<Ctx.Provider value={api}>
{props.children}
<div className="wf-toasts" aria-live="polite">
{state.toasts.map((t) => (
<div key={t.id} className={`wf-toast ${t.tone}`}>
<span className="msg">{t.message}</span>
<button type="button" aria-label="Dismiss" onClick={() => setState((s) => dismissToast(s, t.id))}></button>
</div>
))}
</div>
</Ctx.Provider>
)
}
export function useToast(): ToastApi {
const api = useContext(Ctx)
if (api === null) throw new Error('useToast requires <ToastProvider> at the app root')
return api
}

@ -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; } }

@ -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)
})
})
Loading…
Cancel
Save