@structuralists/scaffolding 0.6.0 → 0.6.1

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,38 @@
1
+ name: CI
2
+
3
+ # The same four checks the release job runs post-merge (publish.yml), but on
4
+ # every PR — so merging green is enforced, and the release job's checks are a
5
+ # formality rather than the first place a break surfaces.
6
+ on:
7
+ pull_request:
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ checks:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - uses: oven-sh/setup-bun@v2
20
+ with:
21
+ bun-version: latest
22
+
23
+ - run: bun install --frozen-lockfile
24
+
25
+ - name: Cache Playwright browsers
26
+ uses: actions/cache@v4
27
+ with:
28
+ path: ~/.cache/ms-playwright
29
+ key: playwright-${{ runner.os }}-${{ hashFiles('bun.lock') }}
30
+ restore-keys: playwright-${{ runner.os }}-
31
+
32
+ - name: Install Playwright chromium
33
+ run: npx playwright install chromium --with-deps
34
+
35
+ - run: bun run typecheck
36
+ - run: bun run lint
37
+ - run: bun run test
38
+ - run: bun run test:storybook
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -1,5 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { expect, userEvent, within } from 'storybook/test';
3
4
  import { useFormState } from './useFormState';
4
5
  import { allOf, matches, minLength, notEmpty } from '../validators/validators';
5
6
  import { Field } from '../../components/Forms/Field';
@@ -153,6 +154,43 @@ export const SignupForm: Story = {
153
154
  <SignupDemo />
154
155
  </div>
155
156
  ),
157
+ // Walks the headline flow: errors gated on submit, allOf first-error
158
+ // progression, live clearing, and the narrowed payload reaching onSubmit.
159
+ play: async ({ canvasElement }) => {
160
+ const canvas = within(canvasElement);
161
+ const body = within(canvasElement.ownerDocument.body);
162
+
163
+ // Errors are gated on submitAttempted — nothing shown initially.
164
+ await expect(
165
+ canvas.queryByText("'email' cannot be empty"),
166
+ ).not.toBeInTheDocument();
167
+
168
+ // A failing submit surfaces every constrained field's error.
169
+ await userEvent.click(canvas.getByRole('button', { name: 'Sign up' }));
170
+ await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
171
+ await expect(
172
+ canvas.getByText("'displayName' cannot be empty"),
173
+ ).toBeInTheDocument();
174
+ await expect(canvas.getByText("'role' cannot be empty")).toBeInTheDocument();
175
+
176
+ // allOf progression: notEmpty now passes, matches takes over.
177
+ await userEvent.type(canvas.getByLabelText(/^Email/), 'not-an-email');
178
+ await expect(
179
+ canvas.getByText("'email' must be a valid email"),
180
+ ).toBeInTheDocument();
181
+
182
+ // Fix every field; errors clear live.
183
+ await userEvent.clear(canvas.getByLabelText(/^Email/));
184
+ await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
185
+ await userEvent.type(canvas.getByLabelText(/^Display name/), 'Will');
186
+ await userEvent.click(canvas.getByRole('button', { name: 'Role' }));
187
+ // The option list is portaled — query the document, not the canvas.
188
+ await userEvent.click(await body.findByRole('option', { name: 'Engineer' }));
189
+
190
+ // Valid submit delivers the narrowed payload to onSubmit.
191
+ await userEvent.click(canvas.getByRole('button', { name: 'Sign up' }));
192
+ await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
193
+ },
156
194
  };
157
195
 
158
196
  const LiveValidityDemo = () => {
@@ -193,6 +231,19 @@ export const LiveValidity: Story = {
193
231
  <LiveValidityDemo />
194
232
  </div>
195
233
  ),
234
+ play: async ({ canvasElement }) => {
235
+ const canvas = within(canvasElement);
236
+
237
+ // Errors here are live — no submit gating.
238
+ await expect(canvas.getByText('false')).toBeInTheDocument();
239
+ await userEvent.type(canvas.getByLabelText(/^Nickname/), 'ab');
240
+ await expect(
241
+ canvas.getByText("'nickname' must be at least 3 characters"),
242
+ ).toBeInTheDocument();
243
+
244
+ await userEvent.type(canvas.getByLabelText(/^Nickname/), 'c');
245
+ await expect(canvas.getByText('true')).toBeInTheDocument();
246
+ },
196
247
  };
197
248
 
198
249
  type DebuggerDemoValues = {
@@ -300,4 +351,40 @@ export const WithDebugger: Story = {
300
351
  <DebuggerDemo />
301
352
  </div>
302
353
  ),
354
+ // The Debugger portals to <body>, so its trigger/window are queried on the
355
+ // document, not the canvas. Exercises open → live update → close, which
356
+ // also pins the stable-identity guarantee: a remounting Debugger would
357
+ // lose its open state on the first keystroke.
358
+ play: async ({ canvasElement }) => {
359
+ const canvas = within(canvasElement);
360
+ const body = within(canvasElement.ownerDocument.body);
361
+
362
+ // Closed: trigger only, no window content.
363
+ await expect(body.queryByText('isValid')).not.toBeInTheDocument();
364
+
365
+ // Open: live state, including errors the form itself isn't showing yet
366
+ // (its display is submit-gated; the debugger sees the raw truth).
367
+ await userEvent.click(body.getByRole('button', { name: 'signup form' }));
368
+ await expect(body.getByText('isValid')).toBeInTheDocument();
369
+ await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
370
+ await expect(
371
+ body.getByText("'nickname' cannot be empty"),
372
+ ).toBeInTheDocument();
373
+
374
+ // Live update while open: value appears, its error entry drops out.
375
+ await userEvent.type(canvas.getByLabelText(/^Nickname/), 'will');
376
+ await expect(body.getByText('will')).toBeInTheDocument();
377
+ await expect(
378
+ body.queryByText("'nickname' cannot be empty"),
379
+ ).not.toBeInTheDocument();
380
+
381
+ // List values render in the window (index-keyed).
382
+ await userEvent.type(canvas.getByLabelText(/^Tags/), 'typescript');
383
+ await userEvent.click(canvas.getByRole('button', { name: 'Add' }));
384
+ await expect(body.getByText('typescript')).toBeInTheDocument();
385
+
386
+ // Close: window unmounts, trigger stays.
387
+ await userEvent.click(body.getByRole('button', { name: 'signup form' }));
388
+ await expect(body.queryByText('isValid')).not.toBeInTheDocument();
389
+ },
303
390
  };