@ultimat3/ui 19.4.0 → 20.0.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/CATALOG.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  Every component and every token, projected from source. Import all of it from `@ultimat3/ui`.
6
6
 
7
- 52 components: `Accordion` · `Alert` · `AppShell` · `Avatar` · `Badge` · `Breadcrumb` · `Button` · `Card` · `Checkbox` · `Combobox` · `Container` · `DataTable` · `DateTime` · `Dialog` · `Divider` · `Drawer` · `Dropzone` · `EmptyState` · `ErrorState` · `Field` · `FileInput` · `Form` · `Grid` · `Icon` · `IconButton` · `Image` · `InfiniteScroll` · `Input` · `Link` · `LocaleSwitcher` · `Menu` · `Money` · `PageHeader` · `Pagination` · `Popover` · `Radio` · `RelativeTime` · `Section` · `Select` · `Skeleton` · `Spinner` · `Stack` · `Switch` · `Table` · `Tabs` · `Text` · `Textarea` · `ThemeToggle` · `ToastRegion` · `Toast` · `Toolbar` · `Tooltip`
7
+ 54 components: `Accordion` · `Alert` · `AppShell` · `AsyncRegion` · `Avatar` · `Badge` · `Breadcrumb` · `Button` · `Card` · `Checkbox` · `Combobox` · `Container` · `DataTable` · `DateTime` · `Dialog` · `Divider` · `Drawer` · `Dropzone` · `EmptyState` · `ErrorState` · `Field` · `FileInput` · `Form` · `Grid` · `Icon` · `IconButton` · `Image` · `InfiniteScroll` · `Input` · `Link` · `LocaleSwitcher` · `Menu` · `Money` · `PageHeader` · `Pagination` · `Popover` · `Radio` · `RelativeTime` · `Section` · `Select` · `Skeleton` · `Spinner` · `Stack` · `Switch` · `Table` · `Tabs` · `Text` · `Textarea` · `ThemeToggle` · `ToastRegion` · `Toast` · `Toaster` · `Toolbar` · `Tooltip`
8
8
 
9
9
  ## Vocabulary
10
10
 
@@ -61,6 +61,20 @@ The page frame every app screen sits in: skip link, banner, navigation, main, co
61
61
  | `stickyHeader` | `boolean` | — | Keeps the header pinned while the main region scrolls. |
62
62
  | `class` | `string` | — | |
63
63
 
64
+ ### AsyncRegion
65
+
66
+ The one way to render a region whose content has to arrive. Four branches, decided by `asyncBranch` and never by the caller: a skeleton while it loads, the error report when it fails, the empty state when a completed result held nothing, and the content. `empty` and `ready` are REQUIRED props, so a region that forgot what "nothing here" looks like is a type error rather than a review comment. `pending` and `failed` are not props at all — the placeholder is derived from `reserve` so it cannot mismatch the loaded box, and the failure renders through `ErrorState`, which is the one thing allowed to phrase an error.
67
+
68
+ | Prop | Type | Required | Notes |
69
+ |---|---|---|---|
70
+ | `state` | `AsyncState<T>` | yes | A live-query accessor, a resource, or a plain signal — narrowed to one of four shapes. |
71
+ | `reserve` | `ReserveBox` | yes | The box the placeholder holds, and the box the loaded content lands in. Same value, once. |
72
+ | `ready` | `(data: T) => JSX.Element` | yes | The content. Called only with data a completed result actually carried. |
73
+ | `empty` | `() => JSX.Element` | yes | Required with no default: "nothing here yet" is a screen, not an oversight, and `EmptyState` is one line of it — `empty={() => <EmptyState title={t('posts.none')} />}`. |
74
+ | `isEmpty` | `((data: T) => boolean)` | — | Emptiness for a shape that is not a list. Defaults to `isEmptyData`. |
75
+ | `onRetry` | `(() => void)` | — | Passed straight to `ErrorState` on the failed branch. |
76
+ | `class` | `string` | — | |
77
+
64
78
  ### Avatar
65
79
 
66
80
  Identity chip. An avatar image always carries intrinsic dimensions and an empty alt (the name is rendered as text or the accessible label), so it can never shift layout or duplicate the name to a screen reader.
@@ -99,7 +113,7 @@ Ancestor trail. The last item is the current page: rendered as text, never a lin
99
113
 
100
114
  ### Button
101
115
 
102
- The one button. Variants and tones are token-driven, so dark mode and RTL need no extra rules; `loading` keeps the label mounted to avoid a layout jump.
116
+ The one button. Variants and tones are token-driven, so dark mode and RTL need no extra rules; `loading` keeps the label mounted to avoid a layout jump. `loading` does NOT set the native `disabled` attribute, and that WILL read as a mistake — it set one until 2026-09. Four things go wrong when a control disables itself mid-flow: the browser moves focus off it to `<body>`, so a keyboard user's next Tab restarts at the top of the document; a disabled control is exempt from WCAG's contrast minimum, so the state the user most needs to read is the one allowed to be unreadable; it explains nothing, because `disabled` has no announced reason; and it does not actually prevent the double submit, which is a race on the server. `aria-disabled` says unavailable and keeps the control focusable, the click is refused here, and the form refuses it again — `Form busy` — because the button is not the only way in.
103
117
 
104
118
  | Prop | Type | Required | Notes |
105
119
  |---|---|---|---|
@@ -191,7 +205,7 @@ Centred measure with a gutter. `margin-inline: auto` and `min()` mean one declar
191
205
 
192
206
  ### DataTable
193
207
 
194
- Data-driven table: sortable headers, cursor pagination, and the four states a real list always has (loading, error, empty, data). The error state renders an UltimateError with the same code/cause/fix strings the terminal prints.
208
+ Data-driven table: sortable headers, cursor pagination, and the four states a real list always has (loading, error, empty, data). The error state renders an UltimateError with the same code/cause/fix strings the terminal prints. The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a table and a card list cannot disagree about what "loading with stale rows" looks like. Only the PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
195
209
 
196
210
  | Prop | Type | Required | Notes |
197
211
  |---|---|---|---|
@@ -355,7 +369,7 @@ A file picker that keeps the platform control and dresses it. The native button
355
369
 
356
370
  ### Form
357
371
 
358
- Form shell. Owns the one thing every form needs and always forgets: a top-of-form error summary that is announced (the Alert inside it is a live region) and that TAKES focus when an error arrives — the focus move is what makes the summary reachable at all, since its id is internal.
372
+ Form shell. Owns the one thing every form needs and always forgets: a top-of-form error summary that is announced (the Alert inside it is a live region) and that TAKES focus when an error arrives — the focus move is what makes the summary reachable at all, since its id is internal. Focus goes to the first INVALID CONTROL when there is one, and to the summary only when there is not. GOV.UK's tested pattern is a summary whose entries LINK to their fields; that shape is not available here, because `Field` mints its control ids internally (`Field.tsx`) and inverting that ownership is the drift `Field` exists to prevent — a summary cannot write an `href` to an id it cannot see. Focusing the control directly reaches the same place in one step. The summary still announces, and is still where a form-level rejection (a policy refusal, an unmatched issue) puts the reader, because that one names no control to send them to.
359
373
 
360
374
  | Prop | Type | Required | Notes |
361
375
  |---|---|---|---|
@@ -363,6 +377,8 @@ Form shell. Owns the one thing every form needs and always forgets: a top-of-for
363
377
  | `error` | `string` | — | Already-translated summary shown above the fields when submit fails. |
364
378
  | `errorTitle` | `string` | — | Already-translated heading for the error summary region. |
365
379
  | `actions` | `JSX.Element` | — | |
380
+ | `invalidField` | `string` | — | The `name` of the control a failed submit should send the reader to — `form.firstInvalidField()`. Focused in preference to the summary: the summary describes the problem, the control is where it is fixed, and leaving the user on the summary strands them one Tab away from nothing. |
381
+ | `busy` | `boolean` | — | A submit is in flight — `form.pending()`. Suppresses the submit outright, so a double submit is refused HERE and not only on whatever control happened to be clicked: Enter in a text field submits a form with no button involved at all. |
366
382
  | `gap` | `SpaceStep` | — | |
367
383
  | `method` | `'get' \| 'post'` | — | |
368
384
  | `action` | `string` | — | |
@@ -777,7 +793,9 @@ Transient notification. ToastRegion is the single live region for the app; indiv
777
793
  | `children` | `JSX.Element` | yes | |
778
794
  | `label` | `string` | yes | Already-translated landmark name, e.g. "Notifications". |
779
795
  | `politeness` | `Politeness` | — | How the region announces. `polite` waits for a pause and is right for everything an app routinely confirms; `assertive` interrupts whatever the user is being read, so it belongs only to a region that carries errors alone. One region, one politeness — mixing tones inside one list cannot work, because the live semantics belong to the list, not to the message. |
780
- | `placement` | `'block-end-inline-end' \| 'block-start-inline-end' \| 'block-end-center'` | — | |
796
+ | `placement` | `ToastPlacement` | — | |
797
+ | `onHold` | `((reason: ToastHold) => void)` | — | Stop the dwell while the reader is engaged with the stack, and start it again when they leave. WCAG 2.2 2.2.1 wants a timing the user can extend, and a message that expires under the pointer reaching for its undo is the failure that rule is about. Two reasons, reported separately: a pointer leaving a toast a keyboard user is still inside must not restart the countdown, which one boolean cannot express. |
798
+ | `onRelease` | `((reason: ToastHold) => void)` | — | |
781
799
  | `class` | `string` | — | |
782
800
 
783
801
  ### Toast
@@ -794,6 +812,19 @@ Transient notification. ToastRegion is the single live region for the app; indiv
794
812
  | `dismissLabel` | `string` | — | |
795
813
  | `class` | `string` | — | |
796
814
 
815
+ ### Toaster
816
+
817
+ The one way to render a toast queue: the store's VISIBLE slice, inside the region that owns the live semantics. The half that was missing — `Toast` and `ToastRegion` shipped with no store, so every app wrote its own queue, its own dwell and its own pause rules. Toasts over the cap are not drawn and not counted on screen: they are still queued, and their dwell has not started. A "+3 more" badge would be a message about messages, in a corner the reader is already being asked to look away to.
818
+
819
+ | Prop | Type | Required | Notes |
820
+ |---|---|---|---|
821
+ | `store` | `ToastStore` | yes | The queue. One per app — `createToastStore()` in the island that mounts this. |
822
+ | `label` | `string` | yes | Already-translated landmark name, e.g. "Notifications". |
823
+ | `politeness` | `Politeness` | — | |
824
+ | `placement` | `ToastPlacement` | — | |
825
+ | `dismissLabel` | `string` | — | Already-translated; falls back to the `ui.dismiss` catalog key. |
826
+ | `class` | `string` | — | |
827
+
797
828
  ### Toolbar
798
829
 
799
830
  The control strip above a table or a list: filters and search at the inline start, actions at the inline end. `role="toolbar"` with the same roving-tabindex helper Tabs uses, so arrow keys move between the strip's buttons. It is NOT one tab stop: the strip holds arbitrary children it cannot reach into to set an initial `tabindex`, and a search field at the inline start keeps its own arrow keys, so making the strip a single stop would strand every control past it.
package/CLAUDE.md CHANGED
@@ -40,6 +40,51 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
40
40
  - **The rule being pure is not enough — the WIRING has to be tested too.** `createRovingTabindex` was correct and `Menu` handed it `[role="menuitem"]`, so a disabled item made every item after it unreachable and every assertion in the package still passed. `src/jsx-probe.ts` reads the props an element actually carries (a `tabindex`, an `aria-live`, an `onKeyDown`, a `ref`) and `src/fake-dom.ts` gives it a DOM where a disabled control REFUSES focus, exactly as the real one does. Both are test-only and neither is in `index.ts`. `jsx-probe`'s `probe`/`unprobe` are the one exception, reachable at the subpath `@ultimat3/ui/jsx-probe` and still absent from the barrel: `globalThis.React` is ONE property, so its install/restore counter has to be ONE counter — `@ultimat3/admin` (tier 5) kept a second pair, and interleaved installs restored the two harnesses in the wrong order, leaving the global holding a harness the run had already torn down. `components/interaction.test.ts` is where a keyboard or form-participation claim gets proven; asserting the pure helper alone is how these shipped.
41
41
  - **A roving group excludes disabled items from both answers** — the set arrows walk and the one item holding the tab stop (`src/roving.ts`). `focus()` on a disabled control is a no-op, so a disabled item left in the list pins the reducer on its index forever. And a control that answers arrows itself (`handlesOwnArrowKeys`) keeps them: a `Toolbar` exists to hold a search field.
42
42
  - **A single-winner state attribute is decided by POSITION, never by a missing prop.** `Breadcrumb` gives `aria-current="page"` to the last item and to nothing else; an href-less ancestor renders as plain text with no `aria-current` at all. Reading "no href" as "is the current page" put two of them in one `<nav>`. `Tabs.tsx` is the same rule for `tabindex`, and `Breadcrumb.test.ts` proves it through `jsx-probe` rather than through the pure helper.
43
+ - **One async region, four branches, and `empty` is unreachable while `pending`.** `asyncBranch`
44
+ (`src/components/async-branch.ts`) is the ONLY place the `(pending, failed, empty, data)` decision
45
+ is made — `AsyncRegion` renders it and `DataTable` calls it, so a table and a card list cannot
46
+ disagree about what "loading with stale rows" looks like. The property is structural: an
47
+ `AsyncState` in `pending` carries no data, so nothing can be found empty in it, and "No results"
48
+ for one frame before the first page arrives is unconstructible rather than discouraged.
49
+ `<AsyncRegion>`'s `empty` and `ready` are REQUIRED props, so forgetting the empty state is a type
50
+ error. `refreshing` CARRIES the previous data — a refetch dims what is on screen (`aria-busy`) and
51
+ never tears it down, which is what makes a search box feel fast; the empty branch is reachable
52
+ only from a completed result that returned zero. `reserve` feeds the placeholder AND the
53
+ `min-block-size` of every branch, so the skeleton and the loaded content cannot be written into
54
+ different boxes.
55
+ - **A toast dwell stops for THREE independent reasons, and one of them is `document.hidden`.**
56
+ Hover and focus-within are the two everybody implements; a backgrounded tab spends the whole dwell
57
+ and the corner is empty when the user comes back. `ToastHold` is a set, never a boolean: a pointer
58
+ leaving a toast a keyboard user is still inside must not restart the countdown, which one flag
59
+ cannot express. Duration is a TOKEN (`TOAST_DWELL_MS`), never a per-call number of milliseconds.
60
+ The queue's rules are pure (`src/toast/toast-state.ts`) and the clock is injected (`ToastEnv`), so
61
+ a server render gets `INERT_TOAST_ENV` — nothing scheduled — and `ToastRegion` still emits its
62
+ empty live region, which is what the document has to carry BEFORE the first message.
63
+ - **`announce()` writes into a region `AppShell` already rendered.** A live region created and
64
+ filled in the same frame is not announced by most screen readers, so `announce()`'s own
65
+ create-if-absent branch could only ever be right from its second call. `liveRegionAttrs` is the
66
+ one derivation both halves read. It is for a state change with no surface of its own ("12
67
+ results", "sorted by name"); a notification is a `Toast`, and two announcement paths for one
68
+ message is how a screen reader reads it twice.
69
+ - **`loading` never sets the native `disabled` attribute** (`Button`), and that WILL read as a
70
+ mistake. A control that disables itself mid-flow drops focus to `<body>`, is exempt from the
71
+ contrast minimum, announces no reason, and still does not prevent the double submit — that race is
72
+ on the server. `aria-disabled` + a refused click keeps it focusable and readable; `Button.module.scss`
73
+ restores full opacity under `[aria-busy='true']` because `t.disabled` dims to 0.55. `<Form busy>`
74
+ refuses the submit again, because Enter in a text field touches no button at all.
75
+ - **A failed submit focuses the first invalid CONTROL, and the summary only when there is none.**
76
+ GOV.UK's summary-with-links shape is not available here: `Field` mints its control ids internally,
77
+ and inverting that ownership is the drift `Field` exists to prevent. `firstInvalidField` answers in
78
+ DECLARATION order, never issue order — a server may report the last field first. `fieldSelector`
79
+ is the allowlist between a caller's string and a selector; a name the grammar refuses reaches no
80
+ `querySelector`.
81
+ - **`defineTheme()` refuses a palette that fails WCAG 2.2 AA** (`X_UI_CONTRAST_INSUFFICIENT`),
82
+ measured against `CONTRAST_PAIRS` — the same table `contrast.test.ts` holds the shipped palette to,
83
+ in SOURCE rather than in a test, so an app cannot ship a brand the design system would have failed
84
+ its own suite over. Only pairings the brand can have CHANGED are measured, on the resolved palette
85
+ (shipped channels + overrides): a new `accent` against the shipped `accent-fg` is the commonest way
86
+ a brand goes unreadable. AA, never APCA — APCA is not a standard, and AA is the operative legal
87
+ benchmark.
43
88
  - **Live semantics belong to the container that outlives the message.** `ToastRegion`'s `<ol>` carries `aria-live`; a `Toast` is a plain `<li>`. A region created with its content already inside it is not announced, and a `role="status"` on the `<li>` also strips its `listitem` semantics.
44
89
  - **`aria-checked` never mirrors a native `checked`.** ARIA outranks host state in the accessibility tree, and on the no-JS path this package supports there is nothing to rewrite the attribute after the user ticks the box. `Checkbox` writes only `'mixed'` (an IDL property with no attribute form, so ARIA is the only server-side lever); `Switch` writes none at all, over a `biome-ignore` that says why.
45
90
  - **A form binds to an action; it never decides for one.** `useForm` (`src/form/`) is a binding over an existing `action`, not a ninth primitive and not a component — `submit` is REQUIRED and is the only producer of a `succeeded` state, and the client-side parse's **value** is discarded so that only its issues are read. A binding that submitted the locally-parsed value would let a browser choose what the server was asked to store; `FormSchema` has no output type for that reason.
@@ -68,6 +113,12 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
68
113
  | `src/tokens/contrast.ts` | WCAG ratios over the channel tokens; `contrast.test.ts` gates AA in both themes |
69
114
  | `src/catalog/` | parses `components/*.tsx` into `CATALOG.md`; `bun run catalog` writes it, `catalog.test.ts` fails on drift |
70
115
  | `src/roving.ts` | the pure rules of a keyboard group: navigable set, tab stop, who keeps their own arrows |
116
+ | `src/components/async-branch.ts` | the one `(pending, failed, empty, ready)` decision, plus the `reserve` box both the placeholder and the loaded content land in |
117
+ | `src/toast/toast-state.ts` | the toast queue's pure rules: dedupe, the visible cap, and what a tick may spend |
118
+ | `src/toast/toast-store.ts` | the same queue with a clock and the three pause holds; `ToastEnv` is the injected host |
119
+ | `src/toast/use-toasts.ts` | the Solid shell: the store's queue in a signal, subscribed from an effect so a server render never does |
120
+ | `src/form/form-touch.ts` | touched and dirty — progress through a form, never a second copy of its values |
121
+ | `src/tokens/contrast-pairs.ts` | every pairing a component renders, with its AA floor; read by `defineTheme()` and by `contrast.test.ts` |
71
122
  | `src/form/field-path.ts` | the one path grammar, both directions — pure, and deliberately free of `../errors` so mapping an issue costs no chunk the error registry |
72
123
  | `src/form/form-issue.ts` | the two readers of an issue: a local parse result, and whatever the server rejected with |
73
124
  | `src/form/form-state.ts` | where an issue LANDS — the declared field, or the form |
package/README.md CHANGED
@@ -51,6 +51,88 @@ roleContrast('dark', 'accent', 'bg') >= AA_TEXT; // true
51
51
  contrastRatio('31 110 178', '253 246 240'); // 4.99 — check a brand before shipping it
52
52
  ```
53
53
 
54
+ `As of 2026-09` **a brand override is measured too, and a failing one is refused.**
55
+ `defineTheme()` resolves your channels over the shipped palette and checks every pairing in
56
+ `CONTRAST_PAIRS` — the same table the framework's own palette is held to — throwing
57
+ `X_UI_CONTRAST_INSUFFICIENT` with the measured ratio, the required one and the role to move.
58
+ Only pairings your brand can have *changed* are measured: a new `accent` against the shipped
59
+ white `accent-fg` is the commonest way a palette goes unreadable, and it is half a pair.
60
+ WCAG 2.2 AA, never APCA — APCA is not a standard, and AA is the operative legal benchmark.
61
+
62
+ ```ts
63
+ import { defineTheme } from '@ultimat3/ui';
64
+
65
+ defineTheme({ colors: { light: { accent: '235 235 235' } } });
66
+ // X_UI_CONTRAST_INSUFFICIENT: defineTheme() light palette renders the label on a primary Button:
67
+ // "accent-fg" on "accent" measures 1.09:1, and WCAG 2.2 AA requires 4.5:1
68
+ ```
69
+
70
+ ## One async region
71
+
72
+ Four states, one decision. `asyncBranch` is the only place `(pending, failed, empty, ready)` is
73
+ decided — `<AsyncRegion>` renders it, `<DataTable>` calls it, and an app's own list can call it too.
74
+
75
+ ```tsx
76
+ import { AsyncRegion, asyncStateOf, EmptyState } from '@ultimat3/ui';
77
+ import type { JSX } from 'solid-js';
78
+
79
+ declare const posts: { loading: boolean; error: unknown; latest: readonly string[] | undefined };
80
+ declare const t: (key: string) => string;
81
+ declare const PostList: (props: { rows: readonly string[] }) => JSX.Element;
82
+ declare const refetch: () => void;
83
+
84
+ const region = (
85
+ <AsyncRegion
86
+ state={asyncStateOf({ loading: posts.loading, error: posts.error, data: posts.latest })}
87
+ reserve={{ lines: 5, height: '3rem' }}
88
+ empty={() => <EmptyState title={t('posts.none')} />}
89
+ ready={(rows) => <PostList rows={rows} />}
90
+ onRetry={() => refetch()}
91
+ />
92
+ );
93
+ ```
94
+
95
+ `empty` and `ready` are **required**, so forgetting the empty state is a type error rather than a
96
+ review comment. Three properties are structural, not documented:
97
+
98
+ - **`empty` is unreachable while pending.** A `pending` state carries no data, so nothing can be
99
+ found empty in it — "No results" before the first page arrives is unconstructible.
100
+ - **A refetch keeps the previous data.** `refreshing` carries it: the current page stays rendered
101
+ and dimmed under `aria-busy`, and the empty branch is reachable only from a *completed* result
102
+ that returned zero. `<DataTable loading>` with rows behaves the same way.
103
+ - **The placeholder shares the loaded box.** `reserve` feeds the `<Skeleton>` *and* the
104
+ `min-block-size` of every branch, so a skeleton that resizes on load — a slower layout shift —
105
+ cannot be written.
106
+
107
+ ## Toasts
108
+
109
+ `Toast` and `ToastRegion` render; `createToastStore()` is the queue behind them, and `<Toaster>` is
110
+ the one way to draw it.
111
+
112
+ ```tsx
113
+ import { createToastStore, Toaster } from '@ultimat3/ui';
114
+
115
+ declare const t: (key: string) => string;
116
+ declare const restore: () => void;
117
+
118
+ const toasts = createToastStore();
119
+ toasts.show({ message: t('post.saved'), tone: 'success' });
120
+ toasts.show({ message: t('post.deleted'), action: { label: t('undo'), onAction: restore } });
121
+
122
+ const region = <Toaster store={toasts} label={t('notifications')} />;
123
+ ```
124
+
125
+ Auto-dismiss on a **token** (`short` 4s, `long` 8s, `sticky` never), a three-deep visible cap with
126
+ the rest queued — a queued toast has not started its dwell, because nobody has read it — identical
127
+ messages deduped rather than stacked, and the countdown held for three independent reasons: pointer
128
+ over the stack, focus inside it, **and `document.hidden`**. The last is the one everybody forgets
129
+ and the only one that loses the message outright: a backgrounded tab spends the whole dwell and the
130
+ corner is empty when the user comes back.
131
+
132
+ A toast never takes focus, never carries the only copy of anything, and carries at most one
133
+ undo-shaped action. The region renders server-side and empty, which is what makes it announce at
134
+ all — a live region created with its content already inside it is not read.
135
+
54
136
  ## Page layout
55
137
 
56
138
  Four composites cover the frame of an app screen. Below them are `Container`,
@@ -432,6 +514,25 @@ expected number`) — `packages/action/src/errors.ts` puts nothing in `meta`, an
432
514
  field only when the head is a declared path, so a message holding `'; '` or `': '` degrades to a
433
515
  form-level error rather than to a wrong control.
434
516
 
517
+ ### The submit state, and why `disabled` is refused
518
+
519
+ `form.pending()` is the one value that reaches both halves: `<Button loading>` and `<Form busy>`.
520
+ Neither sets the native `disabled` attribute, and that is deliberate — a control that disables
521
+ itself mid-flow drops focus to `<body>`, is exempt from the contrast minimum, announces no reason,
522
+ and does not actually prevent the double write, which is a race on the server. `aria-disabled` says
523
+ unavailable and keeps the control focusable; the click is refused in the component, and `<Form busy>`
524
+ refuses the submit again because Enter in a text field touches no button at all.
525
+
526
+ A failed submit focuses the **first invalid control** (`form.firstInvalidField()`, in declaration
527
+ order — a server may report the last field first), and the announced summary keeps the focus only
528
+ when the rejection names no control at all. `form.touch(path)` and `form.edit(path, value)` record
529
+ touched and dirty against the binding's `initial`; an empty control and an absent baseline are the
530
+ same thing, so deleting what you just typed leaves the form clean.
531
+
532
+ Blur-time validation is deliberately **absent**: this binding is server-authoritative, the local
533
+ parse's value is already discarded, and a client-side "is this field valid" would be a second source
534
+ of truth for the one thing the server decides.
535
+
435
536
  ## Errors
436
537
 
437
538
  | Code | When |
@@ -440,6 +541,7 @@ form-level error rather than to a wrong control.
440
541
  | `X_THEME_INVALID` | a theme other than `light` / `dark` |
441
542
  | `X_UI_RUNTIME_MISSING` | a DOM render with no registered Solid runtime, `<UiProvider>` on the server, or `browserThemeEnv()` off-DOM. A server render with no runtime is **not** one of them — it gets `INERT_SOLID_RUNTIME` |
442
543
  | `X_UI_FORM_PATH_INVALID` | a form field or control name the path grammar cannot read (`items.0.price`, `items[]`, `__proto__`), or two control names describing different shapes for one path (`user` beside `user.name`) |
544
+ | `X_UI_CONTRAST_INSUFFICIENT` | a `defineTheme()` palette whose resolved channels put a pairing in `CONTRAST_PAIRS` below WCAG 2.2 AA — 4.5:1 for text, 3:1 for the focus ring. Only pairings the brand changed are measured; the cause names the measured ratio and the required one |
443
545
  | `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry) |
444
546
 
445
547
  ## Commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/ui",
3
- "version": "19.4.0",
3
+ "version": "20.0.0",
4
4
  "description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,10 +44,10 @@
44
44
  "icons": "bun run src/icons/build-icons.ts"
45
45
  },
46
46
  "dependencies": {
47
- "@ultimat3/core": "19.4.0",
48
- "@ultimat3/i18n": "19.4.0",
49
- "@ultimat3/money": "19.4.0",
50
- "@ultimat3/time": "19.4.0"
47
+ "@ultimat3/core": "20.0.0",
48
+ "@ultimat3/i18n": "20.0.0",
49
+ "@ultimat3/money": "20.0.0",
50
+ "@ultimat3/time": "20.0.0"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "solid-js": "^1.9.0"
package/src/a11y.ts CHANGED
@@ -218,22 +218,61 @@ export type Politeness = 'polite' | 'assertive';
218
218
  */
219
219
  const LIVE_REGION_ID = 'ultimate-live-region';
220
220
 
221
+ /** Both levels, in the order `AppShell` emits them. One region per politeness, never per message. */
222
+ export const LIVE_REGION_LEVELS: readonly Politeness[] = ['polite', 'assertive'];
223
+
224
+ /** The attributes ONE live region carries. */
225
+ export interface LiveRegionAttrs {
226
+ readonly id: string;
227
+ readonly class: string;
228
+ readonly role: 'status' | 'alert';
229
+ readonly 'aria-live': Politeness;
230
+ readonly 'aria-atomic': 'true';
231
+ }
232
+
221
233
  /**
222
- * Announce a message to assistive tech. Uses one persistent region per
223
- * politeness level, because creating a region and writing to it in the same
224
- * frame is not announced by most screen readers.
234
+ * One derivation, read by both halves — `AppShell`, which renders these regions into the SERVER
235
+ * response, and `announce()`, which writes into them. That ordering is the reason this exists at
236
+ * all: a live region created and filled in the same frame is not announced by most screen readers,
237
+ * so the region that carries the first message has to predate the message. `announce()`'s own
238
+ * fallback (below) can only ever be right from its second call onwards; a shell that already
239
+ * emitted the region makes the first one right too.
240
+ *
241
+ * `aria-atomic="true"` is correct HERE and wrong on `ToastRegion`'s `<ol>`: this element holds one
242
+ * message at a time and is re-read whole, where the toast list holds several and must announce
243
+ * only the arrival.
244
+ */
245
+ export function liveRegionAttrs(politeness: Politeness): LiveRegionAttrs {
246
+ return {
247
+ id: `${LIVE_REGION_ID}-${politeness}`,
248
+ class: LIVE_REGION_ID,
249
+ role: politeness === 'assertive' ? 'alert' : 'status',
250
+ 'aria-live': politeness,
251
+ 'aria-atomic': 'true',
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Announce a message to assistive tech — for a state change with no surface of its own: "12
257
+ * results", "sorted by name, descending", "page 3 of 9". NOT for a notification, which is a
258
+ * `Toast` in the `ToastRegion` that owns its own live semantics; two announcement paths for one
259
+ * message is how a screen reader ends up reading it twice.
260
+ *
261
+ * Writes into the region `AppShell` already rendered. The create-if-absent branch is the fallback
262
+ * for a tree with no shell, and it carries this module's known limit: the region it builds is
263
+ * appended and written in the same frame, so the FIRST message through it may be silent.
225
264
  */
226
265
  export function announce(message: string, politeness: Politeness = 'polite'): void {
227
266
  if (typeof document === 'undefined') return;
228
- const id = `${LIVE_REGION_ID}-${politeness}`;
229
- let region = document.getElementById(id);
267
+ const attrs = liveRegionAttrs(politeness);
268
+ let region = document.getElementById(attrs.id);
230
269
  if (region === null) {
231
270
  region = document.createElement('div');
232
- region.id = id;
233
- region.className = LIVE_REGION_ID;
234
- region.setAttribute('role', politeness === 'assertive' ? 'alert' : 'status');
235
- region.setAttribute('aria-live', politeness);
236
- region.setAttribute('aria-atomic', 'true');
271
+ region.id = attrs.id;
272
+ region.className = attrs.class;
273
+ region.setAttribute('role', attrs.role);
274
+ region.setAttribute('aria-live', attrs['aria-live']);
275
+ region.setAttribute('aria-atomic', attrs['aria-atomic']);
237
276
  document.body.appendChild(region);
238
277
  }
239
278
  region.textContent = '';
@@ -4,7 +4,7 @@
4
4
  // exactly one of those.
5
5
 
6
6
  import type { JSX } from 'solid-js';
7
- import { useId } from '../a11y';
7
+ import { LIVE_REGION_LEVELS, liveRegionAttrs, useId } from '../a11y';
8
8
  import { cx } from '../cx';
9
9
  import { UI_KEYS } from '../i18n-keys';
10
10
  import { useUi } from '../theme/context';
@@ -56,6 +56,23 @@ export function AppShell(props: AppShellProps): JSX.Element {
56
56
  {props.children}
57
57
  </main>
58
58
  {props.footer === undefined ? null : <footer class={styles['footer']}>{props.footer}</footer>}
59
+ {/* The two live regions, in the SERVER response and empty. `announce()` had none until
60
+ 2026-09 and built its own on first call — a region appended and written in the same frame,
61
+ which most screen readers do not announce, so the first message of a session was silent.
62
+ They are last in DOM order because they are never visible and never focusable: the class
63
+ is `visually-hidden`, not `display: none`, which would stop them being read at all. */}
64
+ {LIVE_REGION_LEVELS.map((politeness) => {
65
+ const attrs = liveRegionAttrs(politeness);
66
+ return (
67
+ <div
68
+ id={attrs.id}
69
+ class={attrs.class}
70
+ role={attrs.role}
71
+ aria-live={attrs['aria-live']}
72
+ aria-atomic={attrs['aria-atomic']}
73
+ />
74
+ );
75
+ })}
59
76
  </div>
60
77
  );
61
78
  }
@@ -0,0 +1,15 @@
1
+ @use '../tokens' as t;
2
+
3
+ // The reserved box, held in every branch: the placeholder, the error, the empty state and the
4
+ // content are all at least this tall, so the answer arriving moves nothing below the region.
5
+ .region {
6
+ min-block-size: var(--async-reserve);
7
+ }
8
+
9
+ // A refetch over content already on screen. Dimmed rather than replaced — the previous page stays
10
+ // readable, which is what makes a search-as-you-type list feel fast instead of flickering.
11
+ // Behind the global `prefers-reduced-motion` guard in `reset.scss` like every other animated rule.
12
+ .stale {
13
+ opacity: 0.6;
14
+ transition: opacity t.duration(fast) t.easing(out);
15
+ }
@@ -0,0 +1,78 @@
1
+ // The one way to render a region whose content has to arrive. Four branches, decided by
2
+ // `asyncBranch` and never by the caller: a skeleton while it loads, the error report when it
3
+ // fails, the empty state when a completed result held nothing, and the content.
4
+ //
5
+ // `empty` and `ready` are REQUIRED props, so a region that forgot what "nothing here" looks like
6
+ // is a type error rather than a review comment. `pending` and `failed` are not props at all — the
7
+ // placeholder is derived from `reserve` so it cannot mismatch the loaded box, and the failure
8
+ // renders through `ErrorState`, which is the one thing allowed to phrase an error.
9
+
10
+ import { finiteCount } from '@ultimat3/core';
11
+ import type { JSX } from 'solid-js';
12
+ import { ariaBool } from '../a11y';
13
+ import { cx } from '../cx';
14
+ import styles from './AsyncRegion.module.scss';
15
+ import {
16
+ type AsyncBranch,
17
+ type AsyncState,
18
+ asyncBranch,
19
+ isBusyBranch,
20
+ type ReserveBox,
21
+ reserveBlockSize,
22
+ } from './async-branch';
23
+ import { ErrorState } from './ErrorState';
24
+ import { Skeleton } from './Skeleton';
25
+
26
+ export interface AsyncRegionProps<T> {
27
+ /** A live-query accessor, a resource, or a plain signal — narrowed to one of four shapes. */
28
+ state: AsyncState<T>;
29
+ /** The box the placeholder holds, and the box the loaded content lands in. Same value, once. */
30
+ reserve: ReserveBox;
31
+ /** The content. Called only with data a completed result actually carried. */
32
+ ready: (data: T) => JSX.Element;
33
+ /**
34
+ * Required with no default: "nothing here yet" is a screen, not an oversight, and `EmptyState`
35
+ * is one line of it — `empty={() => <EmptyState title={t('posts.none')} />}`.
36
+ */
37
+ empty: () => JSX.Element;
38
+ /** Emptiness for a shape that is not a list. Defaults to `isEmptyData`. */
39
+ isEmpty?: ((data: T) => boolean) | undefined;
40
+ /** Passed straight to `ErrorState` on the failed branch. */
41
+ onRetry?: (() => void) | undefined;
42
+ class?: string | undefined;
43
+ }
44
+
45
+ export function AsyncRegion<T>(props: AsyncRegionProps<T>): JSX.Element {
46
+ // `Array.from({ length: NaN })` is `[]` and `Infinity` asks for 2^53 - 1 elements, so an
47
+ // unscreened line count is either a collapsed box reported as a healthy loading state or a bare
48
+ // `RangeError` out of a render — the same screen `Skeleton` and `DataTable` already apply.
49
+ const lines = (): number => finiteCount('AsyncRegion', 'reserve.lines', props.reserve.lines, 0);
50
+
51
+ const branch = (): AsyncBranch<T> => asyncBranch(props.state, props.isEmpty);
52
+
53
+ const content = (): JSX.Element => {
54
+ const at = branch();
55
+ if (at.kind === 'failed') return <ErrorState error={at.error} onRetry={props.onRetry} />;
56
+ if (at.kind === 'pending') {
57
+ return <Skeleton lines={lines()} height={props.reserve.height} />;
58
+ }
59
+ return at.kind === 'empty' ? props.empty() : props.ready(at.data);
60
+ };
61
+
62
+ // Dimmed only where there is something to dim: a refetch over content the reader is looking at.
63
+ // A pending region is already a placeholder, and fading a placeholder says nothing.
64
+ const stale = (): boolean => branch().kind !== 'pending' && isBusyBranch(branch());
65
+
66
+ return (
67
+ <div
68
+ class={cx(styles['region'], stale() && styles['stale'], props.class)}
69
+ // The reserved box is emitted in EVERY branch, which is what makes the four the same size:
70
+ // a skeleton, an error, an empty state and the content all land in one box, so nothing under
71
+ // the region moves when the answer arrives.
72
+ style={{ '--async-reserve': reserveBlockSize({ ...props.reserve, lines: lines() }) }}
73
+ aria-busy={ariaBool(isBusyBranch(branch()))}
74
+ >
75
+ {content()}
76
+ </div>
77
+ );
78
+ }
@@ -15,11 +15,20 @@
15
15
  white-space: nowrap;
16
16
  cursor: pointer;
17
17
 
18
- &:active:not(:disabled) {
18
+ &:active:not(:disabled, [aria-disabled='true']) {
19
19
  transform: translateY(1px);
20
20
  }
21
21
 
22
22
  @include t.disabled;
23
+
24
+ // A BUSY control is one the reader is waiting on, so it has to stay readable: `t.disabled` dims
25
+ // to 0.55, and WCAG exempts a disabled control from the contrast minimum precisely because
26
+ // nobody is meant to read it. `loading` now carries `aria-disabled` too — this is what keeps the
27
+ // two states from sharing one appearance. Written after the mixin: same specificity, later wins.
28
+ &[aria-busy='true'] {
29
+ opacity: 1;
30
+ cursor: progress;
31
+ }
23
32
  }
24
33
 
25
34
  .full {
@@ -1,5 +1,14 @@
1
1
  // The one button. Variants and tones are token-driven, so dark mode and RTL
2
2
  // need no extra rules; `loading` keeps the label mounted to avoid a layout jump.
3
+ //
4
+ // `loading` does NOT set the native `disabled` attribute, and that WILL read as a mistake — it set
5
+ // one until 2026-09. Four things go wrong when a control disables itself mid-flow: the browser
6
+ // moves focus off it to `<body>`, so a keyboard user's next Tab restarts at the top of the
7
+ // document; a disabled control is exempt from WCAG's contrast minimum, so the state the user most
8
+ // needs to read is the one allowed to be unreadable; it explains nothing, because `disabled` has
9
+ // no announced reason; and it does not actually prevent the double submit, which is a race on the
10
+ // server. `aria-disabled` says unavailable and keeps the control focusable, the click is refused
11
+ // here, and the form refuses it again — `Form busy` — because the button is not the only way in.
3
12
 
4
13
  import type { JSX } from 'solid-js';
5
14
  import { ariaBool } from '../a11y';
@@ -29,8 +38,29 @@ export interface ButtonProps {
29
38
  onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> | undefined;
30
39
  }
31
40
 
41
+ type ButtonClick = Parameters<JSX.EventHandler<HTMLButtonElement, MouseEvent>>[0];
42
+
32
43
  export function Button(props: ButtonProps): JSX.Element {
33
- const inert = (): boolean => props.disabled === true || props.loading === true;
44
+ const busy = (): boolean => props.loading === true;
45
+ const inert = (): boolean => props.disabled === true || busy();
46
+
47
+ /**
48
+ * `aria-disabled` is advisory — it changes what is announced and nothing else — so the refusal
49
+ * has to happen here. `preventDefault` is what stops a `type="submit"` reaching its form; not
50
+ * calling the caller's handler is what stops everything else.
51
+ */
52
+ const onClick = (event: ButtonClick): void => {
53
+ if (inert()) {
54
+ event.preventDefault();
55
+ return;
56
+ }
57
+ const handler = props.onClick;
58
+ if (handler === undefined) return;
59
+ // Solid's bound form is `[handler, data]`, and a component that only called the function form
60
+ // would silently drop every `onClick={[save, id]}` in the app.
61
+ if (typeof handler === 'function') handler(event);
62
+ else handler[0](handler[1], event);
63
+ };
34
64
 
35
65
  return (
36
66
  <button
@@ -44,15 +74,17 @@ export function Button(props: ButtonProps): JSX.Element {
44
74
  props.fullWidth === true && styles['full'],
45
75
  props.class,
46
76
  )}
47
- disabled={inert()}
77
+ // Only the caller's explicit `disabled` reaches the attribute. `loading` is a state the user
78
+ // is meant to read and wait out, not a control taken away from under them.
79
+ disabled={props.disabled === true ? true : undefined}
48
80
  aria-disabled={ariaBool(inert())}
49
- aria-busy={ariaBool(props.loading === true)}
81
+ aria-busy={ariaBool(busy())}
50
82
  aria-label={props['aria-label']}
51
83
  aria-controls={props['aria-controls']}
52
84
  aria-expanded={ariaBool(props['aria-expanded'])}
53
- onClick={props.onClick}
85
+ onClick={onClick}
54
86
  >
55
- {props.loading === true ? (
87
+ {busy() ? (
56
88
  <span class={styles['spinner']}>
57
89
  <Spinner size="sm" />
58
90
  </span>
@@ -31,3 +31,10 @@
31
31
  color: t.role('fg-muted');
32
32
  font-size: 0.75em;
33
33
  }
34
+
35
+ // A refetch over rows already on screen: dimmed, never torn down. Same rule and same tokens as
36
+ // `AsyncRegion`'s `.stale`, because it is the same state — the shared `asyncBranch` decides both.
37
+ .stale {
38
+ opacity: 0.6;
39
+ transition: opacity t.duration(fast) t.easing(out);
40
+ }