@tristan2828/ui-foundation 4.41.0 → 4.43.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/README.md CHANGED
@@ -22,7 +22,7 @@ than installing this by hand:
22
22
  | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
23
23
  | `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
24
24
  | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
25
- | `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `defineDataEnvironmentBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady`, `expectDataViewsLoaded` |
25
+ | `@tristan2828/ui-foundation/testing` | Playwright: `test` and `expect` (a page whose `goto()`/`reload()` wait for the app's boot; `withBootWait` for a page of your own), `defineA11ySuite`, `defineMockModeBannerSuite`, `defineDataEnvironmentBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady`, `expectDataViewsLoaded` |
26
26
  | `@tristan2828/ui-foundation/eslint` | the lint rules, as a flat config: `export default [...uiFoundation()]` |
27
27
  | `@tristan2828/ui-foundation/styles.css` | tokens and base styles, after `@import "tailwindcss";` |
28
28
  | `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth/*`, `/environment` (optional) and the error bodies |
@@ -4,7 +4,7 @@
4
4
  ## Index
5
5
 
6
6
  - [The reference files](#the-reference-files) · lines 37–59
7
- - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–702
7
+ - [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–706
8
8
  - Step 1: Add `<Entity>` to `openapi.yaml` · lines 99–106
9
9
  - Step 2: `npm run gen:api` · lines 107–108
10
10
  - Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 109–120
@@ -12,13 +12,13 @@
12
12
  - Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 131–135
13
13
  - Step 6: Copy the reference files, one for one, not just the two screens · lines 136–598
14
14
  - Step 7: Register routes · lines 599–609
15
- - Step 8: Add Playwright specs for all three screens · lines 610–684
16
- - Step 9: Register the nav entry and route names · lines 685–699
17
- - Step 10: `npm run verify`. · lines 700–702
18
- - [What not to copy](#what-not-to-copy) · lines 704–730
19
- - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 732–749
20
- - [Deleting](#deleting) · lines 751–780
21
- - [Fields only a script writes](#fields-only-a-script-writes) · lines 782–803
15
+ - Step 8: Add Playwright specs for all three screens · lines 610–688
16
+ - Step 9: Register the nav entry and route names · lines 689–703
17
+ - Step 10: `npm run verify`. · lines 704–706
18
+ - [What not to copy](#what-not-to-copy) · lines 708–734
19
+ - [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 736–753
20
+ - [Deleting](#deleting) · lines 755–784
21
+ - [Fields only a script writes](#fields-only-a-script-writes) · lines 786–807
22
22
 
23
23
  <!-- /index -->
24
24
 
@@ -612,7 +612,11 @@ is a filled-in example while the Widgets demo is still here).
612
612
  MSW overrides — `forceMswOverride`, `waitForMswReady` and the
613
613
  `window.__msw` handle from `@tristan2828/ui-foundation/testing`, and
614
614
  `e2e/widgets-table.spec.ts`/`e2e/widget-form.spec.ts`/
615
- `e2e/widget-view.spec.ts` for the pattern. The view's states are
615
+ `e2e/widget-view.spec.ts` for the pattern. Import `test` and
616
+ `expect` from there too, not from `@playwright/test`: its page's
617
+ `goto()` and `reload()` return once the app has rendered (after MSW's
618
+ start), and load once more if the server refused one of its files,
619
+ so a spec's first expect never meets an empty page. The view's states are
616
620
  loading, not found (a 404: "Not found", the way back, no retry), error
617
621
  (a failed load, then Try again recovering) and success (every section,
618
622
  each value as its cell shows it), plus the sparse record showing its
@@ -3,21 +3,23 @@
3
3
  <!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
4
4
  ## Index
5
5
 
6
- - [Semantic tones](#semantic-tones) · lines 30–58
7
- - [The three badge styles](#the-three-badge-styles) · lines 60–77
8
- - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 79–108
9
- - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 110–123
10
- - [Logos and brand marks](#logos-and-brand-marks) · lines 125–169
11
- - [Categorical colour](#categorical-colour) · lines 171–241
12
- - [Typography](#typography) · lines 243–325
13
- - [Written text (notes)](#written-text-notes) · lines 274–325
14
- - [Density](#density) · lines 327–384
15
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 386–464
16
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 466–524
17
- - [Notices](#notices) · lines 526–554
18
- - [A value over time](#a-value-over-time) · lines 556–589
19
- - [A list as a board](#a-list-as-a-board) · lines 591–642
20
- - [A list as cards](#a-list-as-cards) · lines 644–690
6
+ - [Semantic tones](#semantic-tones) · lines 32–60
7
+ - [The three badge styles](#the-three-badge-styles) · lines 62–79
8
+ - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 81–110
9
+ - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 112–125
10
+ - [Logos and brand marks](#logos-and-brand-marks) · lines 127–171
11
+ - [Categorical colour](#categorical-colour) · lines 173–243
12
+ - [Typography](#typography) · lines 245–327
13
+ - [Written text (notes)](#written-text-notes) · lines 276–327
14
+ - [Density](#density) · lines 329–386
15
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 388–466
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 468–526
17
+ - [Notices](#notices) · lines 528–556
18
+ - [A value over time](#a-value-over-time) · lines 558–591
19
+ - [A list as a board](#a-list-as-a-board) · lines 593–644
20
+ - [A list as cards](#a-list-as-cards) · lines 646–692
21
+ - [Picking at random](#picking-at-random) · lines 694–725
22
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 727–777
21
23
 
22
24
  <!-- /index -->
23
25
 
@@ -688,3 +690,88 @@ const { layout, setLayout } = useTableLayout({
688
690
  dash. Never an action only one layout has.
689
691
  - `totals` and `highlightRow` are the table's alone. Sorting is the
690
692
  toolbar's in a card layout (there is no header to click).
693
+
694
+ ## Picking at random
695
+
696
+ `SpinWheel` (4.42) picks one of a few candidates by chance, for "what do
697
+ I do next?" when every one would do: a wheel of slices, a pointer at the
698
+ top and a Spin button.
699
+
700
+ ```tsx
701
+ const [picked, setPicked] = useState<SpinWheelItem | null>(null)
702
+
703
+ <SpinWheel
704
+ aria-label="Ready tasks"
705
+ items={tasks.map((task) => ({ id: task.id, label: task.title, weight: task.priority, emphasis: task.flagged }))}
706
+ result={picked?.id ?? null}
707
+ onResult={setPicked}
708
+ emptyMessage="Nothing ready to pick from"
709
+ />
710
+ ```
711
+
712
+ - **The candidates and their chances are the app's.** `weight` (> 0,
713
+ default 1) is both a slice's size and its chance, so a bigger slice
714
+ is visibly likelier. Keep it to what fits: a dozen or so; past that,
715
+ slices thin out and lose their labels (the full label is still each
716
+ slice's tooltip).
717
+ - **The result is the app's too:** `onResult` when the wheel stops, and
718
+ `result` is the slice drawn picked at rest (a solid fill). Show what
719
+ was picked next to the wheel, with what to do about it (a link to the
720
+ record, Start); the wheel only points.
721
+ - `emphasis` tints a slice (a flagged record). It doesn't change the
722
+ chance; give it a weight for that.
723
+ - It spins a few turns with an ease-out and says "Picked <label>" to a
724
+ screen reader; with reduced motion it doesn't turn, the result is at
725
+ once. Square, the width of its container up to 28rem.
726
+
727
+ ## Filling in what's missing
728
+
729
+ `FillInQueue` (4.42) walks a list of records that are each missing a
730
+ value, one record at a time: a size for every project without one, a
731
+ category for each unsorted purchase. It is a screen of its own for
732
+ clearing a backlog of blanks, linked from the list ("12 without a
733
+ size"); the list stays where a record is edited one field at a time.
734
+
735
+ ```tsx
736
+ <FillInQueue
737
+ items={missing}
738
+ getKey={(project) => project.id}
739
+ progress={{ done: total - missing.length, total }}
740
+ canSave={(project) => drafts[project.id]?.size != null}
741
+ onSave={(project) => saveSize.mutateAsync({ id: project.id, size: drafts[project.id].size })}
742
+ renderItem={(project) => (
743
+ <>
744
+ <RecordHeading project={project} />
745
+ <FillInChoice
746
+ label="Size"
747
+ choices={SIZES}
748
+ value={drafts[project.id]?.size}
749
+ onChange={(size) => setDraft(project.id, { size })}
750
+ suggestion={project.guess ? { value: project.guess, note: 'from similar projects' } : undefined}
751
+ shortcut
752
+ />
753
+ </>
754
+ )}
755
+ loading={isLoading}
756
+ error={error}
757
+ onRetry={refetch}
758
+ empty="Every project has a size"
759
+ />
760
+ ```
761
+
762
+ - **The app owns the list, the draft and the save.** `items` is the
763
+ query for records still missing the value, so a saved one leaves it
764
+ and the next takes its place; one still missing something after a
765
+ save is moved past. `progress` is the app's count. The draft is kept
766
+ per record, so Skip and back finds it as it was.
767
+ - **A record is its heading, the few values to decide by, then its
768
+ fields:** a `FillInChoice` (a row of toggle buttons, one picked,
769
+ clicked again to clear) for a short choice, any input for the rest.
770
+ A guess is a `suggestion`: drawn dashed in the info tone and said
771
+ under the row, never picked for the person.
772
+ - **Quick from the keyboard:** Enter is Save and next (not in a
773
+ textarea), and the one row marked `shortcut` takes the number keys.
774
+ After a move the focus is on the new record.
775
+ - A refused save keeps the record and says why under the buttons, not
776
+ in a toast. It owns its states: skeletons, `ErrorState`, and the
777
+ empty queue, which is the good news ("Every project has a size").
@@ -0,0 +1,13 @@
1
+ export type FillInKey = string | number;
2
+ /**
3
+ * The index to show: the held key's, while it's in the list; else the
4
+ * place it was at (`lastIndex`), kept inside the list, which now holds the
5
+ * record after it. -1 for an empty list.
6
+ */
7
+ export declare function resolveFillInIndex(keys: readonly FillInKey[], key: FillInKey | null, lastIndex: number): number;
8
+ /**
9
+ * The key after `key`, wrapping to the first; `key` itself when it is the
10
+ * only one. When `key` isn't in the list, the one now at `lastIndex`.
11
+ * Null for an empty list.
12
+ */
13
+ export declare function nextFillInKey(keys: readonly FillInKey[], key: FillInKey | null, lastIndex: number): FillInKey | null;
@@ -0,0 +1,33 @@
1
+ // Which record FillInQueue shows, as its `items` change under it. The
2
+ // record is held by its key, not its place: saving one usually takes it out
3
+ // of `items` (it isn't missing anything now), and the list may arrive after
4
+ // the save resolves or before. Holding the key of the record to show next
5
+ // makes both orders land on the same one.
6
+ /**
7
+ * The index to show: the held key's, while it's in the list; else the
8
+ * place it was at (`lastIndex`), kept inside the list, which now holds the
9
+ * record after it. -1 for an empty list.
10
+ */
11
+ export function resolveFillInIndex(keys, key, lastIndex) {
12
+ if (keys.length === 0)
13
+ return -1;
14
+ if (key !== null) {
15
+ const found = keys.indexOf(key);
16
+ if (found !== -1)
17
+ return found;
18
+ }
19
+ return Math.min(Math.max(lastIndex, 0), keys.length - 1);
20
+ }
21
+ /**
22
+ * The key after `key`, wrapping to the first; `key` itself when it is the
23
+ * only one. When `key` isn't in the list, the one now at `lastIndex`.
24
+ * Null for an empty list.
25
+ */
26
+ export function nextFillInKey(keys, key, lastIndex) {
27
+ if (keys.length === 0)
28
+ return null;
29
+ const at = key === null ? -1 : keys.indexOf(key);
30
+ if (at === -1)
31
+ return keys[resolveFillInIndex(keys, null, lastIndex)];
32
+ return keys[(at + 1) % keys.length];
33
+ }
@@ -0,0 +1,62 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { AppError } from '../../api/contracts.js';
3
+ export interface FillInQueueProgress {
4
+ /** Records filled so far. */
5
+ done: number;
6
+ /** Records in all, filled or not. */
7
+ total: number;
8
+ /** The word after the count: "12 of 160 <label>". Default "filled". */
9
+ label?: string;
10
+ }
11
+ export interface FillInQueueProps<T> {
12
+ /** The records still missing something, in order. */
13
+ items: readonly T[];
14
+ getKey: (item: T) => string | number;
15
+ /** One record: its heading and values, then its fields. */
16
+ renderItem: (item: T) => ReactNode;
17
+ /** Saves the app's draft for the record; the queue moves on when it resolves. A rejection keeps the record and shows its message. */
18
+ onSave: (item: T) => Promise<void> | void;
19
+ /** False disables Save and next (nothing picked yet); a function is asked about the record on screen. Default true. */
20
+ canSave?: boolean | ((item: T) => boolean);
21
+ /** Told when Skip moves past a record without saving. */
22
+ onSkip?: (item: T) => void;
23
+ /** A bar and "12 of 160 filled" at the top. The app counts. */
24
+ progress?: FillInQueueProgress;
25
+ /** Default "Save and next". */
26
+ saveLabel?: string;
27
+ /** Default "Skip". */
28
+ skipLabel?: string;
29
+ loading?: boolean;
30
+ /** An AppError (ErrorState, with `onRetry`), or the app's own message. */
31
+ error?: AppError | ReactNode;
32
+ onRetry?: () => void;
33
+ /** The empty queue's title. Default "Nothing left to fill in". */
34
+ empty?: ReactNode;
35
+ /** Shows the record at this index; the queue still moves on its own after a save or a skip and reports it. */
36
+ index?: number;
37
+ /** Told whenever another record is on screen, or the one on screen moves to another index. */
38
+ onIndexChange?: (index: number, item: T) => void;
39
+ className?: string;
40
+ }
41
+ export declare function FillInQueue<T>({ items, getKey, renderItem, onSave, canSave, onSkip, progress, saveLabel, skipLabel, loading, error, onRetry, empty, index: indexProp, onIndexChange, className, }: FillInQueueProps<T>): import("react").JSX.Element;
42
+ export interface FillInChoiceProps<V extends string | number> {
43
+ /** Names the row of choices. */
44
+ label: string;
45
+ choices: readonly {
46
+ value: V;
47
+ label: ReactNode;
48
+ }[];
49
+ value: V | null | undefined;
50
+ /** The choice picked, or null when the picked one is clicked again. */
51
+ onChange: (value: V | null) => void;
52
+ /** A guess drawn dashed in the info tone, with "Suggested: <label> (<note>)" under the row. */
53
+ suggestion?: {
54
+ value: V;
55
+ note?: ReactNode;
56
+ };
57
+ /** The number keys pick in this row while the focus is in the queue (not in a text field). One row per queue. */
58
+ shortcut?: boolean;
59
+ className?: string;
60
+ }
61
+ /** One field of a FillInQueue record as a row of toggle buttons: one picked, or none. */
62
+ export declare function FillInChoice<V extends string | number>({ label, choices, value, onChange, suggestion, shortcut, className, }: FillInChoiceProps<V>): import("react").JSX.Element;
@@ -0,0 +1,229 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // Fill in what a list of records is missing, one record at a time: a size
3
+ // for every project without one, a category for each unsorted purchase.
4
+ // A screen for clearing a backlog of blanks quickly, beside the list
5
+ // (where the same fields stay editable one by one), never instead of it.
6
+ //
7
+ // - Records: `items` are the ones still missing something, in order, and
8
+ // `renderItem` draws one: its heading and a few values to decide by,
9
+ // then its fields (FillInChoice, or any input). The draft is the app's:
10
+ // it keeps what's picked for each record, and `canSave` says whether
11
+ // there's enough to save.
12
+ // - Moving: "Save and next" calls `onSave(item)` (the app saves its draft)
13
+ // and, when it resolves, shows the next record. A saved record usually
14
+ // leaves `items`, so the one after it takes its place; if it stays (still
15
+ // missing something), the queue moves past it. Skip moves on without
16
+ // saving, wrapping from the last record to the first. After either, the
17
+ // focus is on the new record, for a screen reader and the keys below.
18
+ // - Keys, while the focus is inside the queue: Enter (not in a textarea,
19
+ // not on a button) is Save and next; with one FillInChoice marked
20
+ // `shortcut`, the number keys pick its choices (not in a text field).
21
+ // - Saving: the button shows it's pending; a refusal keeps the record and
22
+ // says why under the buttons, not in a toast.
23
+ // - Progress: the app counts (`progress`): a bar and "12 of 160 filled".
24
+ // - States: `loading` (skeletons), `error` (ErrorState, with `onRetry`),
25
+ // and an empty queue (`empty`, default "Nothing left to fill in").
26
+ //
27
+ // The record is a centred column, 40rem at most; the buttons sit under it,
28
+ // Skip at the start and Save and next at the end.
29
+ import { createContext, isValidElement, useCallback, useContext, useEffect, useId, useRef, useState, } from 'react';
30
+ import { cn } from 'cn';
31
+ import { ErrorState } from './error-state.js';
32
+ import { nextFillInKey, resolveFillInIndex } from './fill-in-queue-index.js';
33
+ import { Button } from '../ui/button.js';
34
+ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
35
+ import { Skeleton } from '../ui/skeleton.js';
36
+ import { Spinner } from '../ui/spinner.js';
37
+ import { Toggle } from '../ui/toggle.js';
38
+ const ShortcutContext = createContext(null);
39
+ const isAppError = (value) => typeof value === 'object' &&
40
+ value !== null &&
41
+ !isValidElement(value) &&
42
+ typeof value.kind === 'string' &&
43
+ typeof value.message === 'string';
44
+ const messageOf = (reason) => {
45
+ if (typeof reason === 'string' && reason !== '')
46
+ return reason;
47
+ if (typeof reason === 'object' && reason !== null && typeof reason.message === 'string') {
48
+ const message = reason.message;
49
+ if (message !== '')
50
+ return message;
51
+ }
52
+ return "Couldn't save this record.";
53
+ };
54
+ const TEXT_INPUT_TYPES = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password', 'number', 'date', 'time', 'datetime-local', 'month', 'week']);
55
+ // Where a key types into a field, so it's the field's, not the queue's.
56
+ function isTextField(element) {
57
+ if (element.isContentEditable)
58
+ return true;
59
+ if (element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)
60
+ return true;
61
+ if (element instanceof HTMLInputElement)
62
+ return TEXT_INPUT_TYPES.has(element.type);
63
+ return element.getAttribute('role') === 'combobox' || element.getAttribute('role') === 'textbox';
64
+ }
65
+ // Where Enter already does something of its own.
66
+ function ownsEnter(element) {
67
+ if (element.isContentEditable || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement)
68
+ return true;
69
+ return (element.closest('button, a[href], summary, [role=button], [role=link], [role=combobox], [role=listbox], [role=option], [role=menu], [role=menuitem], [role=menuitemradio], [role=menuitemcheckbox], [role=switch], [role=checkbox], [role=radio], [role=tab]') !== null);
70
+ }
71
+ export function FillInQueue({ items, getKey, renderItem, onSave, canSave = true, onSkip, progress, saveLabel = 'Save and next', skipLabel = 'Skip', loading = false, error, onRetry, empty = 'Nothing left to fill in', index: indexProp, onIndexChange, className, }) {
72
+ const keys = items.map(getKey);
73
+ const [held, setHeld] = useState(() => ({
74
+ key: indexProp !== undefined ? (keys[indexProp] ?? null) : null,
75
+ index: indexProp ?? 0,
76
+ }));
77
+ const index = resolveFillInIndex(keys, held.key, held.index);
78
+ const current = index === -1 ? undefined : items[index];
79
+ const currentKey = index === -1 ? null : keys[index];
80
+ const savable = current !== undefined && (typeof canSave === 'function' ? canSave(current) : canSave);
81
+ const [saving, setSaving] = useState(false);
82
+ const [saveError, setSaveError] = useState(null);
83
+ const [moves, setMoves] = useState(0);
84
+ const rootRef = useRef(null);
85
+ const recordRef = useRef(null);
86
+ const latest = useRef({ keys, index, items });
87
+ useEffect(() => {
88
+ latest.current = { keys, index, items };
89
+ });
90
+ // Re-pin the hold to the record on screen once the one held has left
91
+ // (adjusting state while rendering, so nothing draws the old one).
92
+ if (index !== -1 && (held.key !== currentKey || held.index !== index))
93
+ setHeld({ key: currentKey, index });
94
+ // The app moving the queue to an index.
95
+ const appliedIndex = useRef(indexProp);
96
+ useEffect(() => {
97
+ if (indexProp === undefined || indexProp === appliedIndex.current)
98
+ return;
99
+ appliedIndex.current = indexProp;
100
+ const { keys: now } = latest.current;
101
+ if (indexProp >= 0 && indexProp < now.length)
102
+ setHeld({ key: now[indexProp], index: indexProp });
103
+ }, [indexProp]);
104
+ const reported = useRef({ index, key: currentKey });
105
+ const onIndexChangeRef = useRef(onIndexChange);
106
+ useEffect(() => {
107
+ onIndexChangeRef.current = onIndexChange;
108
+ });
109
+ useEffect(() => {
110
+ if (index === reported.current.index && currentKey === reported.current.key)
111
+ return;
112
+ reported.current = { index, key: currentKey };
113
+ appliedIndex.current = index;
114
+ const item = latest.current.items[index];
115
+ if (index !== -1 && item !== undefined)
116
+ onIndexChangeRef.current?.(index, item);
117
+ }, [index, currentKey]);
118
+ // After a move, the focus goes to the record now on screen (to the queue
119
+ // when none is left).
120
+ const focusPending = useRef(false);
121
+ useEffect(() => {
122
+ if (!focusPending.current)
123
+ return;
124
+ focusPending.current = false;
125
+ (recordRef.current ?? rootRef.current)?.focus();
126
+ }, [moves]);
127
+ const moveOn = useCallback((from) => {
128
+ const { keys: now, index: at } = latest.current;
129
+ const next = nextFillInKey(now, from, at);
130
+ setHeld({ key: next, index: next === null ? at : now.indexOf(next) });
131
+ setSaveError(null);
132
+ focusPending.current = true;
133
+ setMoves((count) => count + 1);
134
+ }, []);
135
+ const save = async () => {
136
+ if (saving || !savable || current === undefined || currentKey === null)
137
+ return;
138
+ setSaving(true);
139
+ setSaveError(null);
140
+ try {
141
+ await onSave(current);
142
+ }
143
+ catch (reason) {
144
+ setSaving(false);
145
+ setSaveError(messageOf(reason));
146
+ return;
147
+ }
148
+ setSaving(false);
149
+ moveOn(currentKey);
150
+ };
151
+ const skip = () => {
152
+ if (saving || current === undefined || currentKey === null)
153
+ return;
154
+ onSkip?.(current);
155
+ moveOn(currentKey);
156
+ };
157
+ const shortcuts = useRef(new Set());
158
+ const register = useCallback((handler) => {
159
+ shortcuts.current.add(handler);
160
+ return () => {
161
+ shortcuts.current.delete(handler);
162
+ };
163
+ }, []);
164
+ const onKeyDown = (event) => {
165
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing)
166
+ return;
167
+ const target = event.target;
168
+ if (event.key === 'Enter' && !event.shiftKey) {
169
+ if (ownsEnter(target))
170
+ return;
171
+ event.preventDefault();
172
+ void save();
173
+ return;
174
+ }
175
+ if (/^[1-9]$/.test(event.key) && !event.shiftKey && !isTextField(target)) {
176
+ for (const handler of shortcuts.current) {
177
+ if (handler(Number(event.key))) {
178
+ event.preventDefault();
179
+ return;
180
+ }
181
+ }
182
+ }
183
+ };
184
+ const progressBar = progress && _jsx(ProgressLine, { progress: progress });
185
+ if (error !== undefined && error !== null && error !== false) {
186
+ return (_jsx("div", { "data-slot": "fill-in-queue", "data-state": "error", className: className, children: isAppError(error) ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : (_jsxs(Empty, { "data-state": "error", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: "Something went wrong" }), _jsx(EmptyDescription, { children: error })] }), onRetry && (_jsx(Button, { size: "sm", variant: "outline", onClick: onRetry, children: "Try again" }))] })) }));
187
+ }
188
+ if (loading) {
189
+ return (_jsxs("div", { "data-slot": "fill-in-queue", "data-state": "loading", "aria-busy": "true", className: cn('flex flex-col gap-6', className), children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-2 w-full" }), _jsx(Skeleton, { className: "h-4 w-40" })] }), _jsxs("div", { className: "mx-auto flex w-full max-w-[40rem] flex-col gap-4", children: [_jsx(Skeleton, { className: "h-6 w-2/3" }), _jsx(Skeleton, { className: "h-4 w-1/2" }), _jsx(Skeleton, { className: "h-9 w-full" }), _jsx(Skeleton, { className: "h-9 w-full" })] })] }));
190
+ }
191
+ if (current === undefined) {
192
+ return (_jsxs("div", { ref: rootRef, tabIndex: -1, "data-slot": "fill-in-queue", "data-state": "empty", className: cn('flex flex-col gap-6 outline-none', className), children: [progressBar, _jsx(Empty, { children: _jsx(EmptyHeader, { children: _jsx(EmptyTitle, { children: empty }) }) })] }));
193
+ }
194
+ const position = `Record ${index + 1} of ${items.length}`;
195
+ return (_jsx(ShortcutContext.Provider, { value: register, children: _jsxs("div", { ref: rootRef, tabIndex: -1, "data-slot": "fill-in-queue", "data-state": "success", onKeyDown: onKeyDown, className: cn('flex flex-col gap-6 outline-none', className), children: [_jsxs("div", { className: "flex flex-col gap-2", children: [progressBar, _jsx("p", { className: "type-caption text-muted-foreground tabular-nums", "data-slot": "fill-in-queue-position", children: position })] }), _jsxs("div", { className: "mx-auto flex w-full max-w-[40rem] flex-col gap-6", children: [_jsx("div", { ref: recordRef, role: "group", "aria-label": position, tabIndex: -1, "data-slot": "fill-in-queue-record", className: "flex flex-col gap-6 rounded-lg outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", children: renderItem(current) }, currentKey ?? undefined), _jsxs("div", { className: "flex flex-col gap-2 border-t border-border pt-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(Button, { variant: "ghost", onClick: skip, disabled: saving, children: skipLabel }), _jsxs(Button, { onClick: () => void save(), disabled: !savable || saving, "aria-busy": saving || undefined, children: [saving && _jsx(Spinner, { "aria-hidden": "true", role: "presentation", "aria-label": undefined, "data-icon": "inline-start" }), saveLabel] })] }), saveError !== null && (_jsx("p", { role: "alert", className: "text-end type-caption text-destructive-text", children: saveError }))] })] })] }) }));
196
+ }
197
+ function ProgressLine({ progress }) {
198
+ const { done, total, label = 'filled' } = progress;
199
+ const share = total > 0 ? Math.min(Math.max(done / total, 0), 1) : 0;
200
+ const text = `${done} of ${total} ${label}`;
201
+ return (_jsxs("div", { className: "flex flex-col gap-1.5", "data-slot": "fill-in-queue-progress", children: [_jsx("div", { role: "progressbar", "aria-label": "Progress", "aria-valuemin": 0, "aria-valuemax": total, "aria-valuenow": done, "aria-valuetext": text, className: "h-2 w-full overflow-hidden rounded-full bg-muted", children: _jsx("div", { className: "h-full rounded-full bg-primary transition-[width]", style: { width: `${share * 100}%` } }) }), _jsx("p", { className: "type-caption text-muted-foreground tabular-nums", children: text })] }));
202
+ }
203
+ /** One field of a FillInQueue record as a row of toggle buttons: one picked, or none. */
204
+ export function FillInChoice({ label, choices, value, onChange, suggestion, shortcut = false, className, }) {
205
+ const labelId = useId();
206
+ const register = useContext(ShortcutContext);
207
+ const latest = useRef({ choices, onChange });
208
+ useEffect(() => {
209
+ latest.current = { choices, onChange };
210
+ });
211
+ useEffect(() => {
212
+ if (!shortcut || register === null)
213
+ return;
214
+ return register((digit) => {
215
+ const choice = latest.current.choices[digit - 1];
216
+ if (choice === undefined)
217
+ return false;
218
+ latest.current.onChange(choice.value);
219
+ return true;
220
+ });
221
+ }, [shortcut, register]);
222
+ const suggested = suggestion ? choices.find((choice) => choice.value === suggestion.value) : undefined;
223
+ const keys = shortcut && register !== null;
224
+ return (_jsxs("div", { "data-slot": "fill-in-choice", className: cn('flex flex-col gap-2', className), children: [_jsx("div", { id: labelId, className: "type-label", children: label }), _jsx("div", { role: "group", "aria-labelledby": labelId, className: "flex flex-wrap gap-2", children: choices.map((choice, index) => {
225
+ const pressed = value !== null && value !== undefined && choice.value === value;
226
+ const isSuggested = suggested !== undefined && choice.value === suggested.value;
227
+ return (_jsxs(Toggle, { variant: "outline", pressed: pressed, onPressedChange: () => onChange(pressed ? null : choice.value), "aria-keyshortcuts": keys && index < 9 ? String(index + 1) : undefined, "data-suggested": isSuggested || undefined, className: cn('h-auto min-h-9 px-3 py-1.5', isSuggested && 'border-dashed border-info-text', 'aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90 aria-pressed:hover:text-primary-foreground'), children: [keys && index < 9 && (_jsx("span", { "aria-hidden": "true", className: "type-caption tabular-nums text-muted-foreground group-aria-pressed/toggle:text-primary-foreground", children: index + 1 })), choice.label] }, String(choice.value)));
228
+ }) }), suggested !== undefined && (_jsxs("p", { className: "type-caption text-muted-foreground", children: ["Suggested: ", suggested.label, suggestion?.note !== undefined && suggestion.note !== null && _jsxs(_Fragment, { children: [" (", suggestion.note, ")"] })] }))] }));
229
+ }
@@ -0,0 +1,15 @@
1
+ export interface SpinWheelItem {
2
+ id: string | number;
3
+ label: string;
4
+ /** > 0, default 1: the slice's size and its chance (weight over the sum). */
5
+ weight?: number;
6
+ /** A tinted slice: a flagged record, a favourite. */
7
+ emphasis?: boolean;
8
+ }
9
+ /** An item's weight: its own when > 0 and finite, else 1. */
10
+ export declare const spinWheelWeight: (item: SpinWheelItem) => number;
11
+ /**
12
+ * The index of the item a value in [0, 1) lands on: each item's share of
13
+ * [0, 1) is its weight over the sum, in order. -1 for no items.
14
+ */
15
+ export declare function pickSpinWheelIndex(items: readonly SpinWheelItem[], value: number): number;
@@ -0,0 +1,21 @@
1
+ // SpinWheel's chance: which item a value in [0, 1) lands on, each item's
2
+ // share being its weight over the sum of weights.
3
+ /** An item's weight: its own when > 0 and finite, else 1. */
4
+ export const spinWheelWeight = (item) => item.weight !== undefined && Number.isFinite(item.weight) && item.weight > 0 ? item.weight : 1;
5
+ /**
6
+ * The index of the item a value in [0, 1) lands on: each item's share of
7
+ * [0, 1) is its weight over the sum, in order. -1 for no items.
8
+ */
9
+ export function pickSpinWheelIndex(items, value) {
10
+ if (items.length === 0)
11
+ return -1;
12
+ const total = items.reduce((sum, item) => sum + spinWheelWeight(item), 0);
13
+ const target = Math.min(Math.max(value, 0), 1) * total;
14
+ let reached = 0;
15
+ for (let index = 0; index < items.length; index++) {
16
+ reached += spinWheelWeight(items[index]);
17
+ if (target < reached)
18
+ return index;
19
+ }
20
+ return items.length - 1;
21
+ }
@@ -0,0 +1,22 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type SpinWheelItem } from './spin-wheel-pick.js';
3
+ export type { SpinWheelItem };
4
+ export interface SpinWheelProps {
5
+ items: readonly SpinWheelItem[];
6
+ /** Called when the wheel stops, with the item under the pointer. */
7
+ onResult: (item: SpinWheelItem) => void;
8
+ /** The slice drawn as picked while the wheel is at rest. The app owns it. */
9
+ result?: SpinWheelItem['id'] | null;
10
+ /** The button's label. Default "Spin". */
11
+ spinLabel?: string;
12
+ /** How long a spin turns. Default 4000. */
13
+ durationMs?: number;
14
+ /** Shown in the wheel's place, with the button disabled, when `items` is empty. */
15
+ emptyMessage?: ReactNode;
16
+ /** The source of chance, [0, 1). Default Math.random; a test passes a fixed one. */
17
+ random?: () => number;
18
+ /** Names the wheel. Default "Wheel". */
19
+ 'aria-label'?: string;
20
+ className?: string;
21
+ }
22
+ export declare function SpinWheel({ items, onResult, result, spinLabel, durationMs, emptyMessage, random, 'aria-label': ariaLabel, className, }: SpinWheelProps): import("react").JSX.Element;
@@ -0,0 +1,128 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A wheel that picks one item at random, weighted: "what do I do next?"
3
+ // answered by chance among the app's own candidates.
4
+ //
5
+ // - Items: `items`, each a slice in order clockwise from the pointer. Its
6
+ // `weight` (> 0, default 1; anything else counts as 1) is both the
7
+ // slice's size and its chance: weight over the sum of weights. An
8
+ // `emphasis` item is a tinted slice (a flagged record, a favourite).
9
+ // - Spinning: the Spin button turns the wheel several full turns with an
10
+ // ease-out and stops with the pointer inside the picked slice, a little
11
+ // off its centre. Spinning again starts from where it stopped. While it
12
+ // turns the button is disabled; when it stops, `onResult(item)` and a
13
+ // polite live region says "Picked <label>". With reduced motion asked
14
+ // for, it doesn't turn: the result is at once.
15
+ // - The result at rest is the app's: `result` is the id of the slice drawn
16
+ // picked (a solid fill), so the app can keep it, clear it, or show one
17
+ // it chose itself.
18
+ // - Empty: `emptyMessage` in the wheel's place, the button disabled.
19
+ //
20
+ // Drawn in SVG so every fill is a theme token. Labels read from the hub
21
+ // out, cut to fit their slice, and left off a slice too thin to read; the
22
+ // full label is the slice's <title>. Square, the container's width up to
23
+ // 28rem.
24
+ import { useEffect, useId, useRef, useState } from 'react';
25
+ import { cn } from 'cn';
26
+ import { Button } from '../ui/button.js';
27
+ import { usePrefersReducedMotion } from '../../hooks/use-prefers-reduced-motion.js';
28
+ import { pickSpinWheelIndex, spinWheelWeight as weightOf } from './spin-wheel-pick.js';
29
+ // Degrees clockwise from the top, where the pointer is.
30
+ function slicesOf(items) {
31
+ const total = items.reduce((sum, item) => sum + weightOf(item), 0);
32
+ let at = 0;
33
+ return items.map((item) => {
34
+ const start = at;
35
+ at += (weightOf(item) / total) * 360;
36
+ return { item, start, end: at };
37
+ });
38
+ }
39
+ // The wheel in viewBox units: centred on 0, 208 across so the pointer fits.
40
+ const RADIUS = 96;
41
+ const HUB = 18;
42
+ const FONT = 7.5;
43
+ // Roughly a character's width at FONT, for cutting a label to its slice.
44
+ const CHAR = FONT * 0.52;
45
+ const RIM_GAP = 7;
46
+ const point = (degrees, radius) => {
47
+ const radians = (degrees * Math.PI) / 180;
48
+ return `${(radius * Math.sin(radians)).toFixed(3)} ${(-radius * Math.cos(radians)).toFixed(3)}`;
49
+ };
50
+ const slicePath = ({ start, end }) => `M 0 0 L ${point(start, RADIUS)} A ${RADIUS} ${RADIUS} 0 ${end - start > 180 ? 1 : 0} 1 ${point(end, RADIUS)} Z`;
51
+ // The label cut to the room its slice has: from where the slice is wide
52
+ // enough for a line of text out to the rim. Null when that's under three
53
+ // characters.
54
+ function fitLabel(label, degrees) {
55
+ const radians = (Math.min(degrees, 180) * Math.PI) / 180;
56
+ // The radius at which the slice is a line of text and a little wide.
57
+ const wideEnough = degrees >= 180 ? 0 : (FONT * 1.3) / radians;
58
+ const from = Math.max(HUB + 5, wideEnough);
59
+ const room = RADIUS - RIM_GAP - from;
60
+ const chars = Math.floor(room / CHAR);
61
+ if (chars < 3)
62
+ return null;
63
+ return label.length <= chars ? label : `${label.slice(0, chars - 1).trimEnd()}…`;
64
+ }
65
+ // How far to turn so the pointer lands at `angle` on the wheel, from
66
+ // `rotation`, clockwise: never backwards.
67
+ const turnTo = (rotation, angle) => (((-angle - rotation) % 360) + 360) % 360;
68
+ export function SpinWheel({ items, onResult, result = null, spinLabel = 'Spin', durationMs = 4000, emptyMessage = 'Nothing to pick from', random = Math.random, 'aria-label': ariaLabel = 'Wheel', className, }) {
69
+ const reducedMotion = usePrefersReducedMotion();
70
+ const [rotation, setRotation] = useState(0);
71
+ const [spinning, setSpinning] = useState(false);
72
+ const [announcement, setAnnouncement] = useState('');
73
+ const timer = useRef(null);
74
+ const onResultRef = useRef(onResult);
75
+ useEffect(() => {
76
+ onResultRef.current = onResult;
77
+ });
78
+ useEffect(() => () => {
79
+ if (timer.current !== null)
80
+ clearTimeout(timer.current);
81
+ }, []);
82
+ const titleId = useId();
83
+ const slices = slicesOf(items);
84
+ const empty = items.length === 0;
85
+ const spin = () => {
86
+ if (spinning || empty)
87
+ return;
88
+ const index = pickSpinWheelIndex(items, random());
89
+ const slice = slices[index];
90
+ // Somewhere inside the slice, not on its edge.
91
+ const angle = slice.start + (slice.end - slice.start) * (0.15 + 0.7 * random());
92
+ const turns = reducedMotion ? 0 : Math.max(3, Math.round(durationMs / 800));
93
+ const next = rotation + turns * 360 + turnTo(rotation, angle);
94
+ const finish = () => {
95
+ timer.current = null;
96
+ setSpinning(false);
97
+ setAnnouncement(`Picked ${slice.item.label}`);
98
+ onResultRef.current(slice.item);
99
+ };
100
+ setRotation(next);
101
+ if (reducedMotion) {
102
+ finish();
103
+ return;
104
+ }
105
+ setAnnouncement('');
106
+ setSpinning(true);
107
+ timer.current = setTimeout(finish, durationMs);
108
+ };
109
+ return (_jsxs("div", { "data-slot": "spin-wheel", className: cn('flex w-full max-w-md flex-col items-center gap-4', className), children: [_jsx("div", { className: "relative aspect-square w-full", children: empty ? (_jsx("div", { "data-state": "empty", className: "flex size-full items-center justify-center rounded-full border-2 border-dashed border-border p-8 text-center type-body text-muted-foreground", children: emptyMessage })) : (_jsxs("svg", { viewBox: "-104 -104 208 208", role: "img", "aria-labelledby": titleId, className: "size-full select-none", "data-state": spinning ? 'spinning' : 'idle', children: [_jsx("title", { id: titleId, children: `${ariaLabel}, ${items.length} ${items.length === 1 ? 'item' : 'items'}` }), _jsxs("g", { "data-slot": "spin-wheel-rotor", style: {
110
+ transform: `rotate(${rotation}deg)`,
111
+ transformBox: 'fill-box',
112
+ transformOrigin: 'center',
113
+ transition: spinning && !reducedMotion ? `transform ${durationMs}ms cubic-bezier(0.15, 0.75, 0.2, 1)` : 'none',
114
+ }, children: [slices.map((slice, index) => {
115
+ const picked = !spinning && result !== null && slice.item.id === result;
116
+ const degrees = slice.end - slice.start;
117
+ const text = fitLabel(slice.item.label, degrees);
118
+ const mid = (slice.start + slice.end) / 2;
119
+ return (_jsxs("g", { "data-slot": "spin-wheel-slice", "data-picked": picked || undefined, "data-emphasis": slice.item.emphasis || undefined, children: [_jsx("title", { children: slice.item.label }), degrees >= 359.999 ? (_jsx("circle", { r: RADIUS, className: sliceFill(picked, slice.item.emphasis, index) })) : (_jsx("path", { d: slicePath(slice), className: cn(sliceFill(picked, slice.item.emphasis, index), 'stroke-border'), strokeWidth: 0.75 })), text !== null && (_jsx("text", { transform: `rotate(${mid - 90})`, x: RADIUS - RIM_GAP, y: 0, textAnchor: "end", dominantBaseline: "central", fontSize: FONT, className: cn(picked ? 'fill-primary-foreground font-semibold' : 'fill-foreground'), children: text }))] }, slice.item.id));
120
+ }), _jsx("circle", { r: RADIUS, className: "fill-none stroke-border", strokeWidth: 1.5 })] }), _jsx("circle", { r: HUB, className: "fill-background stroke-border", strokeWidth: 1.5 }), _jsx("text", { textAnchor: "middle", dominantBaseline: "central", fontSize: 11, className: "fill-foreground font-semibold tabular-nums", "aria-hidden": "true", children: items.length }), _jsx("path", { d: "M -7 -104 L 7 -104 L 0 -88 Z", className: "fill-foreground stroke-background", strokeWidth: 1.5, strokeLinejoin: "round" })] })) }), _jsx(Button, { onClick: spin, disabled: spinning || empty, size: "lg", className: "min-w-32", children: spinLabel }), _jsx("p", { role: "status", "aria-live": "polite", className: "sr-only", children: announcement })] }));
121
+ }
122
+ function sliceFill(picked, emphasis, index) {
123
+ if (picked)
124
+ return 'fill-primary';
125
+ if (emphasis)
126
+ return 'fill-info/20';
127
+ return index % 2 === 0 ? 'fill-muted' : 'fill-card';
128
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  export { AppShell, type AppShellProps, type AppShellVersion, type BottomBarItem, type NavEntry, type NavItem, type NavSection, } from './components/app/app-shell.js';
2
2
  export type { IconComponent } from './components/app/icon-component.js';
3
3
  export { Board, type BoardColumn, type BoardMoveTarget, type BoardProps } from './components/app/board.js';
4
+ export { SpinWheel, type SpinWheelProps } from './components/app/spin-wheel.js';
5
+ export { pickSpinWheelIndex, type SpinWheelItem } from './components/app/spin-wheel-pick.js';
6
+ export { FillInChoice, FillInQueue, type FillInChoiceProps, type FillInQueueProgress, type FillInQueueProps, } from './components/app/fill-in-queue.js';
4
7
  export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
5
8
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
6
9
  export { DataTable, type DataTableCardLayout, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
package/dist/index.js CHANGED
@@ -5,6 +5,9 @@
5
5
  // Composites
6
6
  export { AppShell, } from './components/app/app-shell.js';
7
7
  export { Board } from './components/app/board.js';
8
+ export { SpinWheel } from './components/app/spin-wheel.js';
9
+ export { pickSpinWheelIndex } from './components/app/spin-wheel-pick.js';
10
+ export { FillInChoice, FillInQueue, } from './components/app/fill-in-queue.js';
8
11
  export { Artwork, ArtworkTag, ArtworkToggle, } from './components/app/artwork.js';
9
12
  export { CountLink } from './components/app/count-link.js';
10
13
  export { DataTable, } from './components/app/data-table.js';
@@ -1,7 +1,24 @@
1
- import { type Page } from '@playwright/test';
1
+ import { expect, type Page } from '@playwright/test';
2
2
  import { type NavEntry } from '../components/app/app-shell-nav.js';
3
3
  import type { MswOverride } from '../mocks/override.js';
4
4
  export type { MswHandle, MswOverride } from '../mocks/override.js';
5
+ export { expect };
6
+ /**
7
+ * Makes the page's `goto()` and `reload()` return once the app has
8
+ * booted (its first render into `#root`), not at the load event, and load
9
+ * the page once more when one of the app's own files is refused before
10
+ * then; a second refusal fails naming the file. The wait is bounded by
11
+ * the test's timeout like any navigation, and the page's own expects keep
12
+ * their time from there. The `test` exported here applies it to every
13
+ * test's `page`; this is for a page an app's own fixture opens.
14
+ */
15
+ export declare function withBootWait(page: Page): Page;
16
+ /**
17
+ * Playwright's `test`, with a `page` whose `goto()` and `reload()` wait
18
+ * for the app's boot (`withBootWait`). Import `test` and `expect` from
19
+ * here instead of '@playwright/test'; the suites below use it too.
20
+ */
21
+ export declare const test: import("playwright/test").TestType<import("playwright/test").PlaywrightTestArgs & import("playwright/test").PlaywrightTestOptions, import("playwright/test").PlaywrightWorkerArgs & import("playwright/test").PlaywrightWorkerOptions>;
5
22
  /**
6
23
  * Forces a response for the page's *first* load: set before navigation, so
7
24
  * it wins the race against the app's own first fetch. For states forced
@@ -12,8 +12,75 @@
12
12
  // Runs in Node, inside Playwright: @playwright/test and @axe-core/playwright
13
13
  // are optional peer dependencies, needed only by apps that import this.
14
14
  import AxeBuilder from '@axe-core/playwright';
15
- import { expect, test } from '@playwright/test';
15
+ import { expect, test as base } from '@playwright/test';
16
16
  import { navPathname, navTargets } from '../components/app/app-shell-nav.js';
17
+ export { expect };
18
+ // A page load returns at the load event, but the app isn't up yet: React
19
+ // renders only after MSW's worker.start(), which took up to 2.4 s past
20
+ // load in a busy run, and a test's first 5 s expect then failed on an
21
+ // empty page. Under the same load `vite preview` sometimes refuses a
22
+ // connection (about 1 page in 1500): one of the entry's chunks fails, the
23
+ // browser never retries a module script, and the page stays empty until
24
+ // the test's timeout. Both were seen as "never rendered" in every suite.
25
+ const REFUSED = /ERR_CONNECTION_(REFUSED|RESET)/;
26
+ // The app's first render into the template's `<div id="root">`, which is
27
+ // the boot in a mock build (after MSW's start) and a real one alike. A
28
+ // page without one (another site, about:blank) has nothing to wait for.
29
+ const rendered = (page) => page.waitForFunction(() => {
30
+ const root = document.getElementById('root');
31
+ return !root || root.hasChildNodes();
32
+ });
33
+ async function boot(page, load, again) {
34
+ for (let attempt = 1;; attempt++) {
35
+ let onFailed = () => { };
36
+ const refused = new Promise((resolve) => {
37
+ onFailed = (request) => {
38
+ const url = new URL(request.url());
39
+ const own = url.origin === new URL(page.url()).origin;
40
+ if (own && REFUSED.test(request.failure()?.errorText ?? ''))
41
+ resolve(url.pathname);
42
+ };
43
+ page.on('requestfailed', onFailed);
44
+ });
45
+ try {
46
+ const response = await (attempt === 1 ? load() : again());
47
+ const file = await Promise.race([rendered(page).then(() => null), refused]);
48
+ if (file === null)
49
+ return response;
50
+ if (attempt === 2)
51
+ throw new Error(`The server refused ${file} twice: the app never booted`);
52
+ }
53
+ finally {
54
+ page.off('requestfailed', onFailed);
55
+ }
56
+ }
57
+ }
58
+ /**
59
+ * Makes the page's `goto()` and `reload()` return once the app has
60
+ * booted (its first render into `#root`), not at the load event, and load
61
+ * the page once more when one of the app's own files is refused before
62
+ * then; a second refusal fails naming the file. The wait is bounded by
63
+ * the test's timeout like any navigation, and the page's own expects keep
64
+ * their time from there. The `test` exported here applies it to every
65
+ * test's `page`; this is for a page an app's own fixture opens.
66
+ */
67
+ export function withBootWait(page) {
68
+ const goto = page.goto.bind(page);
69
+ const reload = page.reload.bind(page);
70
+ page.goto = (url, options) => boot(page, () => goto(url, options), () => reload(options));
71
+ page.reload = (options) => boot(page, () => reload(options), () => reload(options));
72
+ return page;
73
+ }
74
+ /**
75
+ * Playwright's `test`, with a `page` whose `goto()` and `reload()` wait
76
+ * for the app's boot (`withBootWait`). Import `test` and `expect` from
77
+ * here instead of '@playwright/test'; the suites below use it too.
78
+ */
79
+ export const test = base.extend({
80
+ page: async ({ page }, provide) => {
81
+ await provide(withBootWait(page));
82
+ },
83
+ });
17
84
  /**
18
85
  * Forces a response for the page's *first* load: set before navigation, so
19
86
  * it wins the race against the app's own first fetch. For states forced
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.41.0",
3
+ "version": "4.43.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {