@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 +36 -5
- package/CLAUDE.md +51 -0
- package/README.md +102 -0
- package/package.json +5 -5
- package/src/a11y.ts +49 -10
- package/src/components/AppShell.tsx +18 -1
- package/src/components/AsyncRegion.module.scss +15 -0
- package/src/components/AsyncRegion.tsx +78 -0
- package/src/components/Button.module.scss +10 -1
- package/src/components/Button.tsx +37 -5
- package/src/components/DataTable.module.scss +7 -0
- package/src/components/DataTable.tsx +38 -5
- package/src/components/Dropzone.module.scss +11 -0
- package/src/components/FileInput.module.scss +11 -0
- package/src/components/Form.tsx +62 -2
- package/src/components/Image.module.scss +5 -0
- package/src/components/Image.tsx +4 -0
- package/src/components/Toast.module.scss +17 -8
- package/src/components/Toast.tsx +34 -2
- package/src/components/Toaster.tsx +77 -0
- package/src/components/async-branch.ts +113 -0
- package/src/components/image-source.ts +15 -0
- package/src/errors.ts +26 -0
- package/src/fake-dom.ts +5 -0
- package/src/form/field-path.ts +12 -0
- package/src/form/form-binding.ts +69 -4
- package/src/form/form-state.ts +19 -1
- package/src/form/form-touch.ts +51 -0
- package/src/form/use-form.ts +11 -1
- package/src/index.ts +61 -4
- package/src/theme/brand.ts +39 -2
- package/src/toast/toast-state.ts +0 -0
- package/src/toast/toast-store.ts +179 -0
- package/src/toast/use-toasts.ts +26 -0
- package/src/tokens/contrast-pairs.ts +71 -0
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
|
-
|
|
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` | `
|
|
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": "
|
|
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": "
|
|
48
|
-
"@ultimat3/i18n": "
|
|
49
|
-
"@ultimat3/money": "
|
|
50
|
-
"@ultimat3/time": "
|
|
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
|
-
*
|
|
223
|
-
*
|
|
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
|
|
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 =
|
|
234
|
-
region.setAttribute('role',
|
|
235
|
-
region.setAttribute('aria-live',
|
|
236
|
-
region.setAttribute('aria-atomic', '
|
|
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
|
|
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
|
|
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(
|
|
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={
|
|
85
|
+
onClick={onClick}
|
|
54
86
|
>
|
|
55
|
-
{
|
|
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
|
+
}
|