@structuralists/scaffolding 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/forms/plan.md CHANGED
@@ -1,8 +1,31 @@
1
1
  # Plan: composable, recursive `Validations`
2
2
 
3
- Status: **proposed** — nothing below is built yet. The baseline (flat
4
- `Validations<T>`, one validator per key, shallow `Refine`) is on
5
- `forms/useformstate-baseline`.
3
+ Status: **in progress** — order of operations below. The baseline (flat
4
+ `Validations<T>`, one validator per key, shallow `Refine`) is **merged to main**
5
+ (PR #12, squashed; released as 0.5.0).
6
+
7
+ ## Order of operations
8
+
9
+ The items were written unordered. The working order is **low-risk /
10
+ low-coupling first**: each early item is independently mergeable and valuable
11
+ even if the risky type work later stalls, and the risk zone comes last so a
12
+ TS wall can't strand finished work behind it.
13
+
14
+ 1. **Item 7 — Debugger.** Zero type risk, valuable for developing everything
15
+ after it (watching nested state and structured errors live). ← *current*
16
+ 2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
17
+ proven inside `allOf`.
18
+ 3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
19
+ are single-key paths). Doesn't depend on nested constraints; hard
20
+ prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
21
+ 4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
22
+ item 6 so the wrappers land in their final home once.
23
+ 5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
24
+ error model and the split; `Path`/`ValueAt` already validated.
25
+ 6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
26
+ `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
27
+ any runtime work; three outcomes (works / slow / intractable) each with a
28
+ known response. Worst case, everything above still shipped.
6
29
 
7
30
  ## Goal
8
31
 
@@ -15,13 +38,19 @@ Evolve `Validations<T>` so a constraints object can:
15
38
  (`Refinement` markers → `Refine<T, V>` → narrowed `onSubmit`) keeps
16
39
  working at every level.
17
40
 
18
- Plus two items that ride on top, independent of the type work:
41
+ Plus four items that ride on top, independent of the type work:
19
42
 
20
43
  5. split the forms area into **'form elements'** and **'form state'**
21
44
  (see the section at the end),
22
45
  6. **field binding**: a `getFormFieldPropsAt(path)` helper plus
23
46
  form-element shorthands (`TextInputForForm` style) so wiring a field is
24
- one expression (see the section at the end).
47
+ one expression (see the section at the end),
48
+ 7. **form `Debugger`**: a dev-time overlay component, returned from the
49
+ hook, that shows the form's live internal state (see the section at the
50
+ end),
51
+ 8. **per-field meta state**: visited/touched tracking and stable React key
52
+ generation, plus the open research question of keeping that meta state
53
+ in sync through array manipulation (see the section at the end).
25
54
 
26
55
  ## Target grammar
27
56
 
@@ -255,6 +284,104 @@ Placement note: these wrappers are the bridge between 'form elements' and
255
284
  'form state', so where they live should fall out of the split in item 5 —
256
285
  they are the one layer allowed to know about both sides.
257
286
 
287
+ ## 7. Form `Debugger` — dev-time state introspection
288
+
289
+ A component for watching a form's internal state live while interacting with
290
+ the form proper, in stories and during development. Two elements:
291
+
292
+ - **Trigger** — small, `position: fixed`, floats above all content
293
+ (z-index above everything the library renders).
294
+ - **Window** — opened by the trigger; shows a JSON-ish representation of the
295
+ form's internal state: `values`, `errors`, `isValid`, `submitAttempted`,
296
+ and whatever internal state lands later (touched map, etc.).
297
+ `JsonTable` is the obvious in-house renderer for this.
298
+
299
+ ### Hook plumbing
300
+
301
+ Each `useFormState` instance binds its internal state to its own debugger
302
+ and returns the component on the hook result:
303
+
304
+ ```tsx
305
+ const { values, submit, Debugger, ... } = useFormState({ ... });
306
+
307
+ return (
308
+ <>
309
+ <form>...</form>
310
+ <Debugger /> {/* dev/story usage; omit it and nothing renders */}
311
+ </>
312
+ );
313
+ ```
314
+
315
+ Mechanism sketch — a tiny event-emitter/subscription channel, so the
316
+ form's own render tree never pays for the debugger:
317
+
318
+ - The hook keeps a small store (current snapshot + subscriber set) in a ref
319
+ and publishes into it each render — publishing is a no-op when nobody
320
+ subscribes.
321
+ - `Debugger` is created once per hook instance (stable identity across
322
+ renders — memoized, not redefined inline, or it remounts every keystroke)
323
+ and closes over that store. When mounted and open, it subscribes —
324
+ `useSyncExternalStore` is the natural fit — so state flows hook → store →
325
+ debugger without threading props or context.
326
+ - Rendering the window is where the debugger pays its cost; a closed
327
+ trigger is ~free.
328
+
329
+ ### Open questions (for when this is picked up)
330
+
331
+ - **Snapshot transport**: display-side needs stable snapshots (rendering
332
+ live mutable refs tears); likely publish a frozen snapshot object per
333
+ render.
334
+ - **Multiple forms on one screen**: each hook returns its own `Debugger`,
335
+ so triggers could stack/overlap. May want an instance label prop and/or
336
+ offset management; punt until it hurts.
337
+ - **Production leakage**: it's dev tooling, but it ships in the library.
338
+ Decide whether it's tree-shaken naturally (only renders if placed in
339
+ JSX — probably sufficient) or needs an env gate.
340
+ - **Placement re: item 5 split**: the plumbing is 'form state'; the
341
+ trigger/window UI is presentational. Like the item-6 wrappers, it's a
342
+ bridge layer — settle its home when the split lands.
343
+
344
+ ## 8. Per-field meta state: visited tracking + stable React keys
345
+
346
+ Alongside the *value* tree, the hook needs a parallel *meta* tree — state
347
+ about fields that isn't field data:
348
+
349
+ - **visited/touched** — has the user interacted with this field yet? Feeds
350
+ the error-display policy (item 6's `errorMessage` gates on it via
351
+ `onBlur`).
352
+ - **stable React keys** — list elements need identity that survives
353
+ reorders/inserts/removals. Index keys break exactly when lists get
354
+ interesting; the meta layer is the natural place to mint and hold a
355
+ stable id per element.
356
+
357
+ ### The hard part: array manipulation keeping meta in sync (open research)
358
+
359
+ When a list is reordered, inserted into, or spliced, per-element meta
360
+ (visited flags, keys) must follow its element — not its index. Two candidate
361
+ approaches, deliberately unresolved until we get there:
362
+
363
+ - **Owned array operations.** Past projects needed a dedicated set of list
364
+ manipulation ops (`insertAt`, `removeAt`, `move`, …) that update values
365
+ and meta in the same motion. Known to work; the cost is that consumers
366
+ must use *our* ops — raw `onValueChanges` with a hand-spliced array
367
+ silently desyncs meta.
368
+ - **Identity tracking via Map/WeakMap.** Keep meta keyed by element object
369
+ identity (`WeakMap<element, meta>`) rather than by path/index, and detect
370
+ reorders by re-matching identities after each change. Frees consumers to
371
+ manipulate arrays however they like; open questions are elements that get
372
+ *replaced* rather than moved (immutable-update styles create new objects
373
+ on every edit — identity breaks precisely when fields are edited), and
374
+ primitive-element lists (`string[]`) which have no stable identity at all.
375
+
376
+ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
377
+ the guaranteed path. **Research topic for when we get here** — record
378
+ findings in the learning map before committing to either.
379
+
380
+ Sequencing: visited tracking lands with item 6 (its `onBlur` is the write
381
+ path). Keys + array-sync matter from phase 3 (list `each` specs) onward —
382
+ error paths and per-element meta both need element identity once lists are
383
+ editable.
384
+
258
385
  ## Open decisions
259
386
 
260
387
  - **Whole-value + structural constraints on the same key** (e.g. validate
@@ -0,0 +1,41 @@
1
+ .root {
2
+ position: fixed;
3
+ right: var(--ui-space-4);
4
+ bottom: var(--ui-space-4);
5
+ /* Same z-index tier as every other portal layer (Modal, Popover, Tooltip);
6
+ * stacking between layers comes from DOM-append order, not z-index. */
7
+ z-index: 100;
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: flex-end;
11
+ gap: var(--ui-space-2);
12
+ font-family: var(--ui-font);
13
+ }
14
+
15
+ .trigger {
16
+ padding: var(--ui-space-1) var(--ui-space-3);
17
+ font-family: var(--ui-font-mono);
18
+ font-size: var(--ui-text-xsmall);
19
+ color: var(--ui-foreground);
20
+ background-color: var(--ui-background-1);
21
+ border: 1px solid var(--ui-border);
22
+ border-radius: var(--ui-radius-large);
23
+ box-shadow: 0 2px 8px var(--ui-shadow-soft);
24
+ cursor: pointer;
25
+ user-select: none;
26
+ }
27
+
28
+ .trigger:hover {
29
+ background-color: var(--ui-background-2);
30
+ }
31
+
32
+ .window {
33
+ width: 360px;
34
+ max-height: min(480px, 60vh);
35
+ overflow: auto;
36
+ padding: var(--ui-space-2);
37
+ background-color: var(--ui-background-0);
38
+ border: 1px solid var(--ui-border);
39
+ border-radius: var(--ui-radius-large);
40
+ box-shadow: 0 4px 16px var(--ui-shadow-soft);
41
+ }
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, test, expect } from 'bun:test';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+
4
+ // The Debugger portals into document.body, so leftovers from a previous
5
+ // test are visible to `screen` queries — clean up explicitly (bun:test has
6
+ // no global afterEach for RTL's auto-cleanup to hook).
7
+ afterEach(cleanup);
8
+ import { useFormState } from './useFormState';
9
+ import { notEmpty } from '../validators/validators';
10
+
11
+ type DebugForm = { nickname: string | undefined };
12
+
13
+ const DebugHost = () => {
14
+ const { values, onValueChanges, Debugger } = useFormState({
15
+ initialValues: { nickname: undefined } as DebugForm,
16
+ constraints: { nickname: notEmpty('nickname') },
17
+ });
18
+
19
+ return (
20
+ <div>
21
+ <input
22
+ aria-label="nickname"
23
+ value={values.nickname ?? ''}
24
+ onChange={(e) => {
25
+ const nickname = e.target.value;
26
+ onValueChanges((prev) => ({ ...prev, nickname }));
27
+ }}
28
+ />
29
+ <Debugger label="debug me" />
30
+ </div>
31
+ );
32
+ };
33
+
34
+ describe('FormDebugger', () => {
35
+ test('renders the trigger with the window closed', () => {
36
+ render(<DebugHost />);
37
+ expect(screen.getByRole('button', { name: 'debug me' })).toBeTruthy();
38
+ expect(screen.queryByText('isValid')).toBeNull();
39
+ });
40
+
41
+ test('clicking the trigger opens the window with the current state', () => {
42
+ render(<DebugHost />);
43
+ fireEvent.click(screen.getByRole('button', { name: 'debug me' }));
44
+
45
+ expect(screen.getByText('isValid')).toBeTruthy();
46
+ expect(screen.getByText('submitAttempted')).toBeTruthy();
47
+ expect(screen.getByText("'nickname' cannot be empty")).toBeTruthy();
48
+ });
49
+
50
+ test('the open window live-updates as the form changes', () => {
51
+ render(<DebugHost />);
52
+ fireEvent.click(screen.getByRole('button', { name: 'debug me' }));
53
+
54
+ fireEvent.change(screen.getByLabelText('nickname'), {
55
+ target: { value: 'will' },
56
+ });
57
+
58
+ // The window survived the form re-render (stable component identity —
59
+ // a remount would have reset it to closed) and shows the new state.
60
+ expect(screen.getByText('will')).toBeTruthy();
61
+ expect(screen.queryByText("'nickname' cannot be empty")).toBeNull();
62
+ });
63
+
64
+ test('clicking the trigger again closes the window', () => {
65
+ render(<DebugHost />);
66
+ const trigger = screen.getByRole('button', { name: 'debug me' });
67
+
68
+ fireEvent.click(trigger);
69
+ expect(screen.getByText('isValid')).toBeTruthy();
70
+
71
+ fireEvent.click(trigger);
72
+ expect(screen.queryByText('isValid')).toBeNull();
73
+ });
74
+ });
@@ -0,0 +1,72 @@
1
+ import { useState, useSyncExternalStore } from 'react';
2
+ import type { ReactElement } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { JsonTable } from '../../components/Json/JsonTable';
5
+ import { toInspectable } from './inspectable';
6
+ import type { SnapshotStore } from './snapshotStore';
7
+ import type { FormDebugSnapshot, FormValuesObject } from './types';
8
+ import styles from './FormDebugger.module.css';
9
+
10
+ export type FormDebuggerProps = {
11
+ /** Trigger text. Give each form a distinct label when several debuggers
12
+ * are mounted at once — triggers stack in the same corner. */
13
+ label?: string;
14
+ };
15
+
16
+ export type FormDebuggerComponent = (props: FormDebuggerProps) => ReactElement;
17
+
18
+ // Builds the per-hook-instance Debugger component. Called once per
19
+ // `useFormState` instance (from a lazy ref) so the returned component's
20
+ // identity is stable across renders — a component recreated each render
21
+ // would remount, and lose its open/closed state, on every keystroke.
22
+ export const createFormDebugger = <T extends FormValuesObject>(
23
+ store: SnapshotStore<FormDebugSnapshot<T>>,
24
+ ): FormDebuggerComponent => {
25
+ // The subscription lives in the window, not the trigger: a closed
26
+ // debugger never re-renders with the form.
27
+ const DebuggerWindow = (props: { label: string }) => {
28
+ const { label } = props;
29
+
30
+ const snapshot = useSyncExternalStore(
31
+ store.subscribe,
32
+ store.getSnapshot,
33
+ store.getSnapshot,
34
+ );
35
+
36
+ return (
37
+ <div className={styles.window}>
38
+ <JsonTable
39
+ title={label}
40
+ value={toInspectable({
41
+ isValid: snapshot.isValid,
42
+ submitAttempted: snapshot.submitAttempted,
43
+ values: snapshot.values,
44
+ errors: snapshot.errors,
45
+ })}
46
+ />
47
+ </div>
48
+ );
49
+ };
50
+
51
+ const FormDebugger = (props: FormDebuggerProps) => {
52
+ const { label = 'form state' } = props;
53
+
54
+ const [isOpen, setIsOpen] = useState(false);
55
+
56
+ return createPortal(
57
+ <div className={styles.root}>
58
+ {isOpen && <DebuggerWindow label={label} />}
59
+ <button
60
+ type="button"
61
+ className={styles.trigger}
62
+ onClick={() => setIsOpen((open) => !open)}
63
+ >
64
+ {label}
65
+ </button>
66
+ </div>,
67
+ document.body,
68
+ );
69
+ };
70
+
71
+ return FormDebugger;
72
+ };
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { toInspectable } from './inspectable';
3
+
4
+ describe('toInspectable', () => {
5
+ test('passes leaves through untouched', () => {
6
+ expect(toInspectable('hi')).toBe('hi');
7
+ expect(toInspectable(42)).toBe(42);
8
+ expect(toInspectable(10n)).toBe(10n);
9
+ expect(toInspectable(null)).toBeNull();
10
+ expect(toInspectable(undefined)).toBeUndefined();
11
+ expect(toInspectable(true)).toBe(true);
12
+ });
13
+
14
+ test('converts arrays to index-keyed objects', () => {
15
+ expect(toInspectable(['a', 'b'])).toEqual({ 0: 'a', 1: 'b' });
16
+ });
17
+
18
+ test('converts arrays of objects recursively', () => {
19
+ expect(toInspectable([{ name: 'ada' }, { name: 'bo' }])).toEqual({
20
+ 0: { name: 'ada' },
21
+ 1: { name: 'bo' },
22
+ });
23
+ });
24
+
25
+ test('renders Sets as a descriptive string leaf', () => {
26
+ expect(toInspectable(new Set(['a', 'b']))).toBe('Set(2) { "a", "b" }');
27
+ expect(toInspectable(new Set())).toBe('Set(0) { }');
28
+ });
29
+
30
+ test('walks nested objects', () => {
31
+ const form = {
32
+ email: 'a@b.co',
33
+ address: { city: undefined, tags: ['home'] },
34
+ drivers: [{ name: 'ada', incidents: [] }],
35
+ };
36
+ expect(toInspectable(form)).toEqual({
37
+ email: 'a@b.co',
38
+ address: { city: undefined, tags: { 0: 'home' } },
39
+ drivers: { 0: { name: 'ada', incidents: {} } },
40
+ });
41
+ });
42
+ });
@@ -0,0 +1,35 @@
1
+ // Converts a form-state snapshot into a shape JsonTable renders without
2
+ // throwing. JsonTable dispatches on "plain object vs leaf": arrays and Sets
3
+ // fall to the leaf renderer, and an array of objects (FormValueList) would
4
+ // throw as a React child. A debugger must render *any* legal form state, so:
5
+ //
6
+ // - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
7
+ // - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
8
+ // - objects → walked recursively
9
+ // - leaves → passed through untouched
10
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
11
+ typeof value === 'object' &&
12
+ value !== null &&
13
+ (Object.getPrototypeOf(value) === Object.prototype ||
14
+ Object.getPrototypeOf(value) === null);
15
+
16
+ export const toInspectable = (value: unknown): unknown => {
17
+ if (value instanceof Set) {
18
+ const members = [...value].map((member) => JSON.stringify(member));
19
+ return `Set(${value.size}) { ${members.join(', ')} }`;
20
+ }
21
+
22
+ if (Array.isArray(value)) {
23
+ return Object.fromEntries(
24
+ value.map((element, index) => [index, toInspectable(element)]),
25
+ );
26
+ }
27
+
28
+ if (isPlainObject(value)) {
29
+ return Object.fromEntries(
30
+ Object.entries(value).map(([key, child]) => [key, toInspectable(child)]),
31
+ );
32
+ }
33
+
34
+ return value;
35
+ };
@@ -0,0 +1,56 @@
1
+ import { describe, test, expect, mock } from 'bun:test';
2
+ import { createSnapshotStore } from './snapshotStore';
3
+
4
+ describe('createSnapshotStore', () => {
5
+ test('getSnapshot returns the initial snapshot', () => {
6
+ const store = createSnapshotStore({ n: 1 });
7
+ expect(store.getSnapshot()).toEqual({ n: 1 });
8
+ });
9
+
10
+ test('publish replaces the snapshot and notifies subscribers', () => {
11
+ const store = createSnapshotStore({ n: 1 });
12
+ const onChange = mock(() => {});
13
+ store.subscribe(onChange);
14
+
15
+ store.publish({ n: 2 });
16
+
17
+ expect(store.getSnapshot()).toEqual({ n: 2 });
18
+ expect(onChange).toHaveBeenCalledTimes(1);
19
+ });
20
+
21
+ test('publish keeps the snapshot reference stable between publishes', () => {
22
+ const store = createSnapshotStore({ n: 1 });
23
+ store.publish({ n: 2 });
24
+ expect(store.getSnapshot()).toBe(store.getSnapshot());
25
+ });
26
+
27
+ test('unsubscribe stops notifications', () => {
28
+ const store = createSnapshotStore({ n: 1 });
29
+ const onChange = mock(() => {});
30
+ const unsubscribe = store.subscribe(onChange);
31
+
32
+ unsubscribe();
33
+ store.publish({ n: 2 });
34
+
35
+ expect(onChange).not.toHaveBeenCalled();
36
+ });
37
+
38
+ test('publish with no subscribers is a no-op notify', () => {
39
+ const store = createSnapshotStore({ n: 1 });
40
+ expect(() => store.publish({ n: 2 })).not.toThrow();
41
+ expect(store.getSnapshot()).toEqual({ n: 2 });
42
+ });
43
+
44
+ test('notifies every subscriber', () => {
45
+ const store = createSnapshotStore({ n: 1 });
46
+ const first = mock(() => {});
47
+ const second = mock(() => {});
48
+ store.subscribe(first);
49
+ store.subscribe(second);
50
+
51
+ store.publish({ n: 2 });
52
+
53
+ expect(first).toHaveBeenCalledTimes(1);
54
+ expect(second).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
@@ -0,0 +1,31 @@
1
+ // Minimal external store connecting a hook's per-render state to observers
2
+ // outside its render tree (the form Debugger). Shaped for
3
+ // `useSyncExternalStore`: `subscribe` returns an unsubscribe function and
4
+ // `getSnapshot` must keep returning the same reference until the next
5
+ // `publish` — snapshots are replaced whole, never mutated.
6
+ export type SnapshotStore<Snapshot> = {
7
+ publish: (next: Snapshot) => void;
8
+ subscribe: (onChange: () => void) => () => void;
9
+ getSnapshot: () => Snapshot;
10
+ };
11
+
12
+ export const createSnapshotStore = <Snapshot,>(
13
+ initial: Snapshot,
14
+ ): SnapshotStore<Snapshot> => {
15
+ let current = initial;
16
+ const listeners = new Set<() => void>();
17
+
18
+ return {
19
+ publish: (next) => {
20
+ current = next;
21
+ for (const listener of listeners) listener();
22
+ },
23
+ subscribe: (onChange) => {
24
+ listeners.add(onChange);
25
+ return () => {
26
+ listeners.delete(onChange);
27
+ };
28
+ },
29
+ getSnapshot: () => current,
30
+ };
31
+ };
@@ -1,3 +1,7 @@
1
+ // Type-only import; FormDebugger.tsx imports value types from this file in
2
+ // turn, but the cycle never exists at runtime.
3
+ import type { FormDebuggerComponent } from './FormDebugger';
4
+
1
5
  export type FormValueSimple = string | number | bigint | string[] | Set<string> | undefined | null;
2
6
 
3
7
  export type FormValuesObject = { [k in string]: FormValue };
@@ -10,6 +14,16 @@ export type FormErrors<T extends FormValuesObject> = Partial<
10
14
  Record<keyof T, string>
11
15
  >;
12
16
 
17
+ // What the hook publishes to its Debugger after every commit. Snapshots are
18
+ // replaced whole (never mutated) so `useSyncExternalStore` consumers can
19
+ // rely on reference equality.
20
+ export type FormDebugSnapshot<T extends FormValuesObject> = {
21
+ values: T;
22
+ errors: FormErrors<T>;
23
+ isValid: boolean;
24
+ submitAttempted: boolean;
25
+ };
26
+
13
27
  export type FormHelpers<T extends FormValuesObject> = {
14
28
  values: T;
15
29
  // todo: likely remove once other setter are available
@@ -20,4 +34,8 @@ export type FormHelpers<T extends FormValuesObject> = {
20
34
  isValid: boolean;
21
35
  submitAttempted: boolean;
22
36
  submit: () => void;
37
+ // Dev-time introspection overlay bound to this form instance: a fixed
38
+ // trigger that opens a window showing the form's live internal state.
39
+ // Render it anywhere (it portals to <body>); omit it and nothing mounts.
40
+ Debugger: FormDebuggerComponent;
23
41
  };
@@ -194,3 +194,110 @@ export const LiveValidity: Story = {
194
194
  </div>
195
195
  ),
196
196
  };
197
+
198
+ type DebuggerDemoValues = {
199
+ email: string | undefined;
200
+ nickname: string | undefined;
201
+ tags: string[];
202
+ };
203
+
204
+ // The Debugger comes off the hook itself — drop it anywhere in the tree and
205
+ // a fixed trigger appears bottom-right; open it and watch values/errors/
206
+ // isValid/submitAttempted update live as you interact with the form.
207
+ const DebuggerDemo = () => {
208
+ const [tagDraft, setTagDraft] = useState('');
209
+
210
+ const { values, onValueChanges, errors, submitAttempted, submit, Debugger } =
211
+ useFormState({
212
+ initialValues: {
213
+ email: undefined,
214
+ nickname: undefined,
215
+ tags: [],
216
+ } as DebuggerDemoValues,
217
+ constraints: {
218
+ email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
219
+ nickname: notEmpty('nickname'),
220
+ },
221
+ });
222
+
223
+ const shownErrors = submitAttempted ? errors : {};
224
+
225
+ return (
226
+ <form
227
+ style={{ maxWidth: 420, display: 'grid', gap: 16 }}
228
+ onSubmit={(e) => {
229
+ e.preventDefault();
230
+ submit();
231
+ }}
232
+ >
233
+ <Field label="Email" error={shownErrors.email} htmlFor="debug-email">
234
+ <Input
235
+ id="debug-email"
236
+ type="email"
237
+ value={values.email ?? ''}
238
+ onChange={(e) => {
239
+ const email = e.target.value;
240
+ onValueChanges((prev) => ({ ...prev, email }));
241
+ }}
242
+ placeholder="you@example.com"
243
+ />
244
+ </Field>
245
+
246
+ <Field label="Nickname" error={shownErrors.nickname} htmlFor="debug-nickname">
247
+ <Input
248
+ id="debug-nickname"
249
+ value={values.nickname ?? ''}
250
+ onChange={(e) => {
251
+ const nickname = e.target.value;
252
+ onValueChanges((prev) => ({ ...prev, nickname }));
253
+ }}
254
+ />
255
+ </Field>
256
+
257
+ <Field
258
+ label="Tags"
259
+ hint="Unconstrained string[] — shows how the Debugger renders list values"
260
+ htmlFor="debug-tag-draft"
261
+ >
262
+ <div style={{ display: 'flex', gap: 8 }}>
263
+ <Input
264
+ id="debug-tag-draft"
265
+ value={tagDraft}
266
+ onChange={(e) => setTagDraft(e.target.value)}
267
+ placeholder="add a tag"
268
+ />
269
+ <Button
270
+ type="button"
271
+ onClick={() => {
272
+ if (!tagDraft) return;
273
+ onValueChanges((prev) => ({ ...prev, tags: [...prev.tags, tagDraft] }));
274
+ setTagDraft('');
275
+ }}
276
+ >
277
+ Add
278
+ </Button>
279
+ </div>
280
+ </Field>
281
+
282
+ {values.tags.length > 0 && (
283
+ <div style={{ fontSize: 13 }}>tags: {values.tags.join(', ')}</div>
284
+ )}
285
+
286
+ <div>
287
+ <Button type="submit" variant="primary">
288
+ Submit
289
+ </Button>
290
+ </div>
291
+
292
+ <Debugger label="signup form" />
293
+ </form>
294
+ );
295
+ };
296
+
297
+ export const WithDebugger: Story = {
298
+ render: () => (
299
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
300
+ <DebuggerDemo />
301
+ </div>
302
+ ),
303
+ };