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
parent
0b46c03530
commit
03402ba0e1
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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…
Reference in New Issue