@structuralists/scaffolding 0.13.0 → 0.15.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.
@@ -0,0 +1,467 @@
1
+ import { useRef, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
4
+ import {
5
+ BigTable,
6
+ badgeColumn,
7
+ booleanColumn,
8
+ currencyColumn,
9
+ dateColumn,
10
+ textColumn,
11
+ } from '../components/Tables/BigTable';
12
+ import type { ColumnDef } from '../components/Tables/BigTable';
13
+ import { Bar } from '../components/Layout/Bar';
14
+ import { Panels } from '../components/Layout/Panels';
15
+ import { Stack } from '../components/Layout/Stack';
16
+ import { Text } from '../components/Content/Text';
17
+ import { MediumModal } from '../components/Modals/MediumModal';
18
+ import { ConfirmModal } from '../components/Modals/ConfirmModal';
19
+ import { Button } from '../forms/elements/Button';
20
+ import { IconButton } from '../forms/elements/IconButton';
21
+ import type { SelectOption } from '../forms/elements/Select';
22
+ import { useFormState } from '../forms/state/useFormState/useFormState';
23
+ import { matches, notEmpty } from '../forms/state/validators/validators';
24
+ import { TextInputForForm } from '../forms/state/bindings/TextInputForForm';
25
+ import { SingleSelectForForm } from '../forms/state/bindings/SingleSelectForForm';
26
+
27
+ // The Demo section is a showcase: polished compositions of the library's
28
+ // public pieces, not component API docs. This one is the full CRUD loop —
29
+ // BigTable + MediumModal + useFormState field bindings + ConfirmModal —
30
+ // wired together the way a consuming app would.
31
+ const meta: Meta = {
32
+ title: 'Demo/Team Directory',
33
+ parameters: { layout: 'fullscreen' },
34
+ };
35
+
36
+ export default meta;
37
+ type Story = StoryObj;
38
+
39
+ const ROLES = ['engineer', 'designer', 'pm', 'sales'] as const;
40
+ type Role = (typeof ROLES)[number];
41
+
42
+ const TEAMS = ['Platform', 'Growth', 'Infra', 'Brand', 'Revenue', 'Search'] as const;
43
+ type Team = (typeof TEAMS)[number];
44
+
45
+ const ROLE_OPTIONS: SelectOption<Role>[] = [
46
+ { value: 'engineer', label: 'Engineer' },
47
+ { value: 'designer', label: 'Designer' },
48
+ { value: 'pm', label: 'PM' },
49
+ { value: 'sales', label: 'Sales' },
50
+ ];
51
+
52
+ const TEAM_OPTIONS: SelectOption<Team>[] = TEAMS.map((team) => ({
53
+ value: team,
54
+ label: team,
55
+ }));
56
+
57
+ type Member = {
58
+ id: string;
59
+ name: string;
60
+ email: string;
61
+ role: Role;
62
+ team: Team;
63
+ salary: number;
64
+ startDate: string; // ISO
65
+ isActive: boolean;
66
+ };
67
+
68
+ const FIRST_NAMES = [
69
+ 'Ada', 'Alan', 'Edsger', 'Barbara', 'Donald', 'Margaret',
70
+ 'John', 'Katherine', 'Dennis', 'Radia', 'Ken', 'Frances',
71
+ ];
72
+ const LAST_NAMES = [
73
+ 'Lovelace', 'Turing', 'Dijkstra', 'Liskov', 'Knuth',
74
+ 'Hamilton', 'Backus', 'Johnson', 'Ritchie',
75
+ ];
76
+
77
+ // Deterministic: every value is index arithmetic over literal arrays — the
78
+ // same 34 rows on every render, so the story (and its play test) never
79
+ // flickers. The (i % 12, 5i % 9) name pairing has period 36, so all 34
80
+ // name/email combos are distinct.
81
+ const SEED_MEMBERS: Member[] = Array.from({ length: 34 }, (_, i) => {
82
+ const first = FIRST_NAMES[i % FIRST_NAMES.length];
83
+ const last = LAST_NAMES[(i * 5) % LAST_NAMES.length];
84
+ return {
85
+ id: `emp_${100 + i}`,
86
+ name: `${first} ${last}`,
87
+ email: `${first.toLowerCase()}.${last.toLowerCase()}@acme.dev`,
88
+ role: ROLES[i % ROLES.length],
89
+ team: TEAMS[(i * 7) % TEAMS.length],
90
+ salary: 95_000 + (i % 7) * 8_000 + (i % 3) * 2_500,
91
+ startDate: new Date(
92
+ Date.UTC(2019 + (i % 6), (i * 5) % 12, ((i * 7) % 27) + 1),
93
+ ).toISOString(),
94
+ isActive: i % 6 !== 0,
95
+ };
96
+ });
97
+
98
+ // What the modal form holds while editing: loose everywhere a field starts
99
+ // blank. Salary is a string because it's typed into a text input; the
100
+ // digits-only constraint plus Number() at the boundary turn it back into
101
+ // a number.
102
+ type MemberFormValues = {
103
+ name: string | undefined;
104
+ email: string | undefined;
105
+ role: Role | null;
106
+ team: Team | null;
107
+ salary: string | undefined;
108
+ };
109
+
110
+ // What onSubmit receives: the constraints below strip undefined/null from
111
+ // every field. This type annotating the parent's handler compiles only
112
+ // because useFormState actually delivers the narrowed type.
113
+ type MemberFormSubmit = {
114
+ name: string;
115
+ email: string;
116
+ role: Role;
117
+ team: Team;
118
+ salary: string;
119
+ };
120
+
121
+ const EMPTY_MEMBER_FORM: MemberFormValues = {
122
+ name: undefined,
123
+ email: undefined,
124
+ role: null,
125
+ team: null,
126
+ salary: undefined,
127
+ };
128
+
129
+ type MemberFormModalProps = {
130
+ isOpen: boolean;
131
+ title: string;
132
+ submitLabel: string;
133
+ initialValues: MemberFormValues;
134
+ onCancel: () => void;
135
+ onSubmit: (values: MemberFormSubmit) => void;
136
+ };
137
+
138
+ // One modal serves both add and edit — the parent remounts it fresh per
139
+ // opening (via key) with the right initialValues, so the hook state never
140
+ // leaks between sessions. Every field is wired with one getFormFieldPropsAt
141
+ // expression; error display (touched OR submit-attempted) comes with it.
142
+ const MemberFormModal = (props: MemberFormModalProps) => {
143
+ const { isOpen, title, submitLabel, initialValues, onCancel, onSubmit } =
144
+ props;
145
+
146
+ const { getFormFieldPropsAt, submit } = useFormState({
147
+ initialValues,
148
+ constraints: {
149
+ name: notEmpty('name'),
150
+ email: [
151
+ notEmpty('email'),
152
+ matches('email', /^[^@\s]+@[^@\s]+\.[^@\s]+$/, 'a valid email'),
153
+ ],
154
+ role: notEmpty('role'),
155
+ team: notEmpty('team'),
156
+ salary: [
157
+ notEmpty('salary'),
158
+ matches('salary', /^\d+$/, 'a whole dollar amount (digits only)'),
159
+ ],
160
+ },
161
+ onSubmit,
162
+ });
163
+
164
+ return (
165
+ <MediumModal
166
+ isOpen={isOpen}
167
+ onClose={onCancel}
168
+ title={title}
169
+ footer={
170
+ <>
171
+ <Button onClick={onCancel}>Cancel</Button>
172
+ <Button variant="primary" onClick={() => submit()}>
173
+ {submitLabel}
174
+ </Button>
175
+ </>
176
+ }
177
+ >
178
+ <Stack gap={3}>
179
+ <TextInputForForm
180
+ label="Full name"
181
+ placeholder="Ada Lovelace"
182
+ formFieldProps={getFormFieldPropsAt(['name'])}
183
+ />
184
+ <TextInputForForm
185
+ label="Email"
186
+ type="email"
187
+ placeholder="ada.lovelace@acme.dev"
188
+ formFieldProps={getFormFieldPropsAt(['email'])}
189
+ />
190
+ <SingleSelectForForm
191
+ label="Role"
192
+ options={ROLE_OPTIONS}
193
+ placeholder="Pick a role"
194
+ formFieldProps={getFormFieldPropsAt(['role'])}
195
+ />
196
+ <SingleSelectForForm
197
+ label="Team"
198
+ options={TEAM_OPTIONS}
199
+ placeholder="Pick a team"
200
+ formFieldProps={getFormFieldPropsAt(['team'])}
201
+ />
202
+ <TextInputForForm
203
+ label="Salary (USD)"
204
+ hint="Whole dollars, digits only"
205
+ placeholder="120000"
206
+ formFieldProps={getFormFieldPropsAt(['salary'])}
207
+ />
208
+ </Stack>
209
+ </MediumModal>
210
+ );
211
+ };
212
+
213
+ const PencilIcon = () => (
214
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
215
+ <path
216
+ d="M11.5 2.5 L13.5 4.5 L5.5 12.5 L2.8 13.2 L3.5 10.5 Z"
217
+ stroke="currentColor"
218
+ strokeWidth="1.5"
219
+ strokeLinejoin="round"
220
+ />
221
+ </svg>
222
+ );
223
+
224
+ const TrashIcon = () => (
225
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
226
+ <path
227
+ d="M3 4.5 H13 M6.5 4.5 V3 H9.5 V4.5 M4.5 4.5 L5.2 13 H10.8 L11.5 4.5 M6.8 7 V10.7 M9.2 7 V10.7"
228
+ stroke="currentColor"
229
+ strokeWidth="1.5"
230
+ strokeLinecap="round"
231
+ strokeLinejoin="round"
232
+ />
233
+ </svg>
234
+ );
235
+
236
+ type EditorSession = { mode: 'add' } | { mode: 'edit'; member: Member };
237
+
238
+ // The session outlives the open flag so the form keeps rendering its
239
+ // contents while ModalShell plays the 200ms exit animation; sessionId keys
240
+ // a fresh remount per opening so form state never leaks between sessions.
241
+ type EditorState = {
242
+ session: EditorSession;
243
+ sessionId: number;
244
+ isOpen: boolean;
245
+ };
246
+
247
+ const TeamDirectoryDemo = () => {
248
+ const [rows, setRows] = useState<Member[]>(SEED_MEMBERS);
249
+ const [editor, setEditor] = useState<EditorState | null>(null);
250
+ const [pendingDelete, setPendingDelete] = useState<Member | null>(null);
251
+ const nextIdRef = useRef(100 + SEED_MEMBERS.length);
252
+
253
+ const openEditor = (session: EditorSession) =>
254
+ setEditor((prev) => ({
255
+ session,
256
+ sessionId: (prev?.sessionId ?? 0) + 1,
257
+ isOpen: true,
258
+ }));
259
+
260
+ const closeEditor = () =>
261
+ setEditor((prev) => (prev ? { ...prev, isOpen: false } : prev));
262
+
263
+ const handleEditorSubmit = (vals: MemberFormSubmit) => {
264
+ if (editor?.session.mode === 'edit') {
265
+ const { id } = editor.session.member;
266
+ setRows((prev) =>
267
+ prev.map((row) =>
268
+ row.id === id
269
+ ? { ...row, ...vals, salary: Number(vals.salary) }
270
+ : row,
271
+ ),
272
+ );
273
+ } else {
274
+ const id = `emp_${nextIdRef.current++}`;
275
+ setRows((prev) => [
276
+ ...prev,
277
+ {
278
+ ...vals,
279
+ salary: Number(vals.salary),
280
+ id,
281
+ startDate: '2026-07-01T00:00:00.000Z',
282
+ isActive: true,
283
+ },
284
+ ]);
285
+ }
286
+ closeEditor();
287
+ };
288
+
289
+ const columnDefs: ColumnDef<Member>[] = [
290
+ textColumn({ id: 'name', header: 'Name', value: (m) => m.name, minWidth: 160 }),
291
+ textColumn({ id: 'email', header: 'Email', value: (m) => m.email, minWidth: 220 }),
292
+ badgeColumn({ id: 'role', header: 'Role', value: (m) => m.role }),
293
+ textColumn({ id: 'team', header: 'Team', value: (m) => m.team }),
294
+ currencyColumn({ id: 'salary', header: 'Salary', value: (m) => m.salary }),
295
+ booleanColumn({ id: 'active', header: 'Active', value: (m) => m.isActive }),
296
+ dateColumn({ id: 'start', header: 'Started', value: (m) => m.startDate }),
297
+ {
298
+ id: 'actions',
299
+ header: '',
300
+ align: 'right',
301
+ cell: (m) => (
302
+ <Stack direction="row" gap={1} justify="end">
303
+ <IconButton
304
+ ariaLabel={`Edit ${m.name}`}
305
+ tooltip="Edit"
306
+ size="small"
307
+ onClick={() => openEditor({ mode: 'edit', member: m })}
308
+ >
309
+ <PencilIcon />
310
+ </IconButton>
311
+ <IconButton
312
+ ariaLabel={`Delete ${m.name}`}
313
+ tooltip="Delete"
314
+ size="small"
315
+ variant="danger"
316
+ onClick={() => setPendingDelete(m)}
317
+ >
318
+ <TrashIcon />
319
+ </IconButton>
320
+ </Stack>
321
+ ),
322
+ },
323
+ ];
324
+
325
+ return (
326
+ <div style={{ height: '100vh' }}>
327
+ <Panels
328
+ header={
329
+ <Bar
330
+ title="Team Directory"
331
+ right={
332
+ <Stack direction="row" gap={2} align="center">
333
+ <Text size="small" isMuted>
334
+ {rows.length} members
335
+ </Text>
336
+ <Button
337
+ size="small"
338
+ variant="primary"
339
+ onClick={() => openEditor({ mode: 'add' })}
340
+ >
341
+ Add member
342
+ </Button>
343
+ </Stack>
344
+ }
345
+ />
346
+ }
347
+ >
348
+ <BigTable data={rows} columnDefs={columnDefs} getRowKey={(m) => m.id} />
349
+ </Panels>
350
+
351
+ {editor && (
352
+ <MemberFormModal
353
+ key={editor.sessionId}
354
+ isOpen={editor.isOpen}
355
+ title={editor.session.mode === 'edit' ? 'Edit member' : 'Add member'}
356
+ submitLabel="Save"
357
+ initialValues={
358
+ editor.session.mode === 'edit'
359
+ ? {
360
+ name: editor.session.member.name,
361
+ email: editor.session.member.email,
362
+ role: editor.session.member.role,
363
+ team: editor.session.member.team,
364
+ salary: String(editor.session.member.salary),
365
+ }
366
+ : EMPTY_MEMBER_FORM
367
+ }
368
+ onCancel={closeEditor}
369
+ onSubmit={handleEditorSubmit}
370
+ />
371
+ )}
372
+
373
+ <ConfirmModal
374
+ isOpen={pendingDelete !== null}
375
+ title="Delete member"
376
+ description={
377
+ pendingDelete
378
+ ? `Remove ${pendingDelete.name} from the directory? This cannot be undone.`
379
+ : undefined
380
+ }
381
+ confirmLabel="Delete member"
382
+ isDanger
383
+ onConfirm={() => {
384
+ if (pendingDelete) {
385
+ const { id } = pendingDelete;
386
+ setRows((prev) => prev.filter((row) => row.id !== id));
387
+ }
388
+ setPendingDelete(null);
389
+ }}
390
+ onCancel={() => setPendingDelete(null)}
391
+ />
392
+ </div>
393
+ );
394
+ };
395
+
396
+ export const FullCrud: Story = {
397
+ name: 'Full CRUD',
398
+ render: () => <TeamDirectoryDemo />,
399
+ // Walks the whole loop: add (validation errors → fix → row appends),
400
+ // edit (same modal pre-filled → row updates), delete (confirm → row
401
+ // removed). Modals and select option lists are portaled to <body>, so
402
+ // everything inside them is queried on the document body.
403
+ play: async ({ canvasElement }) => {
404
+ const canvas = within(canvasElement);
405
+ const body = within(canvasElement.ownerDocument.body);
406
+
407
+ await expect(canvas.getByText('34 members')).toBeInTheDocument();
408
+
409
+ // — Add: a failing submit surfaces every constrained field's error.
410
+ await userEvent.click(canvas.getByRole('button', { name: 'Add member' }));
411
+ await userEvent.click(await body.findByRole('button', { name: 'Save' }));
412
+ await expect(body.getByText("'name' cannot be empty")).toBeInTheDocument();
413
+ await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
414
+ await expect(body.getByText("'role' cannot be empty")).toBeInTheDocument();
415
+ await expect(body.getByText("'team' cannot be empty")).toBeInTheDocument();
416
+ await expect(body.getByText("'salary' cannot be empty")).toBeInTheDocument();
417
+
418
+ // Validator-array progression: notEmpty passes, matches takes over.
419
+ await userEvent.type(body.getByLabelText(/^Email/), 'not-an-email');
420
+ await expect(
421
+ body.getByText("'email' must be a valid email"),
422
+ ).toBeInTheDocument();
423
+
424
+ // Fill everything in; select option lists are portaled too.
425
+ await userEvent.type(body.getByLabelText(/^Full name/), 'Grace Hopper');
426
+ await userEvent.clear(body.getByLabelText(/^Email/));
427
+ await userEvent.type(body.getByLabelText(/^Email/), 'grace.hopper@acme.dev');
428
+ await userEvent.click(body.getByRole('button', { name: 'Role' }));
429
+ await userEvent.click(await body.findByRole('option', { name: 'Engineer' }));
430
+ await userEvent.click(body.getByRole('button', { name: 'Team' }));
431
+ await userEvent.click(await body.findByRole('option', { name: 'Platform' }));
432
+ await userEvent.type(body.getByLabelText(/^Salary/), '180000');
433
+
434
+ // A valid submit closes the modal and appends the row.
435
+ await userEvent.click(body.getByRole('button', { name: 'Save' }));
436
+ await expect(await canvas.findByText('Grace Hopper')).toBeInTheDocument();
437
+ await expect(canvas.getByText('grace.hopper@acme.dev')).toBeInTheDocument();
438
+ await expect(canvas.getByText('35 members')).toBeInTheDocument();
439
+
440
+ // — Edit: the same modal opens pre-filled; saving updates in place.
441
+ await userEvent.click(
442
+ canvas.getByRole('button', { name: 'Edit Grace Hopper' }),
443
+ );
444
+ const nameInput = await body.findByLabelText(/^Full name/);
445
+ await expect(nameInput).toHaveValue('Grace Hopper');
446
+ await expect(body.getByLabelText(/^Salary/)).toHaveValue('180000');
447
+ await userEvent.clear(nameInput);
448
+ await userEvent.type(nameInput, 'Grace Murray Hopper');
449
+ await userEvent.click(body.getByRole('button', { name: 'Save' }));
450
+ await expect(
451
+ await canvas.findByText('Grace Murray Hopper'),
452
+ ).toBeInTheDocument();
453
+ await expect(canvas.getByText('35 members')).toBeInTheDocument();
454
+
455
+ // — Delete: per-row action asks for confirmation before removing.
456
+ await userEvent.click(
457
+ canvas.getByRole('button', { name: 'Delete Grace Murray Hopper' }),
458
+ );
459
+ await userEvent.click(
460
+ await body.findByRole('button', { name: 'Delete member' }),
461
+ );
462
+ await waitFor(() =>
463
+ expect(canvas.queryByText('Grace Murray Hopper')).not.toBeInTheDocument(),
464
+ );
465
+ await expect(canvas.getByText('34 members')).toBeInTheDocument();
466
+ },
467
+ };