@ultimat3/ui 2.0.0 → 3.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 +8 -7
- package/CLAUDE.md +9 -0
- package/README.md +22 -2
- package/package.json +5 -5
- package/src/a11y.ts +40 -9
- package/src/components/Checkbox.tsx +7 -2
- package/src/components/Dialog.tsx +4 -3
- package/src/components/Dropzone.tsx +10 -2
- package/src/components/Field.tsx +1 -7
- package/src/components/Form.tsx +22 -3
- package/src/components/Menu.tsx +13 -3
- package/src/components/Pagination.tsx +8 -1
- package/src/components/Popover.tsx +11 -1
- package/src/components/Select.tsx +7 -4
- package/src/components/Switch.tsx +11 -4
- package/src/components/Table.tsx +5 -3
- package/src/components/Tabs.tsx +14 -4
- package/src/components/Toast.tsx +22 -10
- package/src/components/Toolbar.tsx +6 -2
- package/src/components/file-input-view.ts +25 -0
- package/src/errors.ts +15 -0
- package/src/fake-dom.ts +194 -0
- package/src/icons/build-icons.ts +38 -9
- package/src/index.ts +10 -0
- package/src/jsx-probe.ts +125 -0
- package/src/roving.ts +65 -0
- package/src/tokens/reset.scss +7 -0
package/CATALOG.md
CHANGED
|
@@ -146,7 +146,7 @@ Native checkbox with a token-drawn indicator. The label element wraps the input,
|
|
|
146
146
|
| `name` | `string` | — | |
|
|
147
147
|
| `value` | `string` | — | |
|
|
148
148
|
| `checked` | `boolean` | — | |
|
|
149
|
-
| `indeterminate` | `boolean` | — | Tri-state for "some children selected".
|
|
149
|
+
| `indeterminate` | `boolean` | — | Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. |
|
|
150
150
|
| `disabled` | `boolean` | — | |
|
|
151
151
|
| `required` | `boolean` | — | |
|
|
152
152
|
| `description` | `string` | — | |
|
|
@@ -230,7 +230,7 @@ Renders <time datetime="<ISO instant>"> with text formatted in the context time
|
|
|
230
230
|
|
|
231
231
|
### Dialog
|
|
232
232
|
|
|
233
|
-
Modal built on native <dialog>: the platform gives us the top layer, the backdrop, inert background content, and Escape-to-close for free. We add the labelled heading
|
|
233
|
+
Modal built on native <dialog>: the platform gives us the top layer, the backdrop, inert background content, and Escape-to-close for free. We add the labelled heading and the close affordance. Body scroll locking is one CSS rule in `tokens/reset.scss` (`html:has(dialog:modal)`) rather than anything here, so it covers Drawer too and cannot leak when a close path throws.
|
|
234
234
|
|
|
235
235
|
| Prop | Type | Required | Notes |
|
|
236
236
|
|---|---|---|---|
|
|
@@ -355,7 +355,7 @@ A file picker that keeps the platform control and dresses it. The native button
|
|
|
355
355
|
|
|
356
356
|
### Form
|
|
357
357
|
|
|
358
|
-
Form shell. Owns the one thing every form needs and always forgets: a top-of-form error summary that is
|
|
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.
|
|
359
359
|
|
|
360
360
|
| Prop | Type | Required | Notes |
|
|
361
361
|
|---|---|---|---|
|
|
@@ -680,7 +680,7 @@ Flex layout primitive. Gap comes from the space scale as a custom property, so t
|
|
|
680
680
|
|
|
681
681
|
### Switch
|
|
682
682
|
|
|
683
|
-
Boolean toggle with immediate effect (as opposed to Checkbox, which is part of a form submit). `role="switch"` on a native checkbox keeps keyboard behaviour.
|
|
683
|
+
Boolean toggle with immediate effect (as opposed to Checkbox, which is part of a form submit). `role="switch"` on a native checkbox keeps keyboard behaviour — and its checked state, which is why nothing here writes `aria-checked`: `role="switch"` maps the host `checked` state on its own, and an attribute mirroring it never updates on the no-JS path, where ARIA would then outrank the truth and announce the switch stuck in whichever position it was rendered in.
|
|
684
684
|
|
|
685
685
|
| Prop | Type | Required | Notes |
|
|
686
686
|
|---|---|---|---|
|
|
@@ -770,18 +770,19 @@ Theme control. `toggle` flips light/dark; `select` also offers "system", which c
|
|
|
770
770
|
|
|
771
771
|
### ToastRegion
|
|
772
772
|
|
|
773
|
-
Transient notification. ToastRegion is the single live region for the app; individual Toasts are its children, so announcements are not duplicated and the region exists before the first message
|
|
773
|
+
Transient notification. ToastRegion is the single live region for the app; individual Toasts are its children, so announcements are not duplicated and the region exists before the first message. That ordering is the whole point: a live region created with its content already in it is not announced by most screen readers, which is why `aria-live` sits on the persistent <ol> here and NOT on the <li> each Toast renders.
|
|
774
774
|
|
|
775
775
|
| Prop | Type | Required | Notes |
|
|
776
776
|
|---|---|---|---|
|
|
777
777
|
| `children` | `JSX.Element` | yes | |
|
|
778
778
|
| `label` | `string` | yes | Already-translated landmark name, e.g. "Notifications". |
|
|
779
|
+
| `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. |
|
|
779
780
|
| `placement` | `'block-end-inline-end' \| 'block-start-inline-end' \| 'block-end-center'` | — | |
|
|
780
781
|
| `class` | `string` | — | |
|
|
781
782
|
|
|
782
783
|
### Toast
|
|
783
784
|
|
|
784
|
-
Transient notification. ToastRegion is the single live region for the app; individual Toasts are its children, so announcements are not duplicated and the region exists before the first message
|
|
785
|
+
Transient notification. ToastRegion is the single live region for the app; individual Toasts are its children, so announcements are not duplicated and the region exists before the first message. That ordering is the whole point: a live region created with its content already in it is not announced by most screen readers, which is why `aria-live` sits on the persistent <ol> here and NOT on the <li> each Toast renders.
|
|
785
786
|
|
|
786
787
|
| Prop | Type | Required | Notes |
|
|
787
788
|
|---|---|---|---|
|
|
@@ -795,7 +796,7 @@ Transient notification. ToastRegion is the single live region for the app; indiv
|
|
|
795
796
|
|
|
796
797
|
### Toolbar
|
|
797
798
|
|
|
798
|
-
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
|
|
799
|
+
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.
|
|
799
800
|
|
|
800
801
|
| Prop | Type | Required | Notes |
|
|
801
802
|
|---|---|---|---|
|
package/CLAUDE.md
CHANGED
|
@@ -29,6 +29,11 @@ Tier 5. Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http
|
|
|
29
29
|
- **`inert-render.test.ts` must not assume which factory its `.tsx` compiled to.** `@ultimat3/render`'s `index.ts` installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import, and `bun test` is one process — so any file in the run that imports render first makes every ui component after it compile to render's `h` instead of the file's own inert copy. The walker recognises both (`Symbol.for('ultimate.render.jsx')`, off the global registry, never an import), and the first test in the describe asserts a component returned a node it recognises. Without both halves the file silently rendered `"[object Object]"` and 26 assertions were decided by shard packing.
|
|
30
30
|
- **Components are not unit-tested through a renderer.** `.tsx` compiles to `@ultimat3/render`'s `h`, which this package may not import, so every rule lives in a pure module beside the component (`icon-glyph.ts`, `accordion-view.ts`, `combobox-filter.ts`, `infinite-scroll-view.ts`) and *that* is what the tests assert.
|
|
31
31
|
- Formatting logic lives in a pure `*-view.ts` next to the component (`money-view.ts`, `date-time-view.ts`) so it is testable with no renderer. Every other renderer-free core follows the same rule under its own name (`sort-state.ts`, `image-source.ts`) — the `.tsx` holds markup, never a rule.
|
|
32
|
+
- **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`. `components/interaction.test.ts` is where a keyboard or form-participation claim gets proven; asserting the pure helper alone is how these shipped.
|
|
33
|
+
- **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.
|
|
34
|
+
- **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.
|
|
35
|
+
- **`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.
|
|
36
|
+
- **Generated source is a CODE sink.** `build-icons.ts` writes modules every app EXECUTES at import, from data fetched over the network, so an attribute value goes through `JSON.stringify` and never `'${value}'` — and `SAFE_ATTR_VALUE` refuses anything that is not glyph geometry one layer earlier. `iconElements` guards tags and attribute NAMES; it has never guarded a value. Malformed upstream data is `X_UI_INVALID_VALUE`; only the generator's real environment faults (no network, no biome binary) are `X_UI_RUNTIME_MISSING`.
|
|
32
37
|
|
|
33
38
|
## Files
|
|
34
39
|
|
|
@@ -46,6 +51,10 @@ Tier 5. Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http
|
|
|
46
51
|
| `src/theme/brand.ts` | `defineTheme()` — the ONE brand-override seam; there is no SCSS `@use ... with ()` path |
|
|
47
52
|
| `src/tokens/contrast.ts` | WCAG ratios over the channel tokens; `contrast.test.ts` gates AA in both themes |
|
|
48
53
|
| `src/catalog/` | parses `components/*.tsx` into `CATALOG.md`; `bun run catalog` writes it, `catalog.test.ts` fails on drift |
|
|
54
|
+
| `src/roving.ts` | the pure rules of a keyboard group: navigable set, tab stop, who keeps their own arrows |
|
|
55
|
+
| `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
|
|
56
|
+
| `src/jsx-probe.ts` | TEST-ONLY: a component's node tree, so a test can assert the props and call the handlers an element carries |
|
|
57
|
+
| `src/components/style-classes.test.ts` | the build error behind "every `styles['x']` a component names is declared in its own `.module.scss`" — under `bun test` a `.module.scss` import resolves to the file PATH, so no render can catch a dead class |
|
|
49
58
|
|
|
50
59
|
## Assumed peer contracts
|
|
51
60
|
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Four composites cover the frame of an app screen. Below them are `Container`,
|
|
|
61
61
|
| `AppShell` | skip link + `header` / `nav` / `main` / `footer` landmarks on a CSS grid | the frame every screen sits in — one per document |
|
|
62
62
|
| `PageHeader` | breadcrumbs, the page's one `h1`, description, actions | the top of a screen |
|
|
63
63
|
| `Section` | a labelled `section` with a real heading and `aria-labelledby` | second-level structure inside a page |
|
|
64
|
-
| `Toolbar` | `role="toolbar"` strip, start + end slots, arrow-key roving | filters and actions above a table or list |
|
|
64
|
+
| `Toolbar` | `role="toolbar"` strip, start + end slots, arrow-key roving between its buttons (`As of 2026-08`) | filters and actions above a table or list |
|
|
65
65
|
|
|
66
66
|
`AppShell` holds no state: below `md` the sidebar becomes a band above the content,
|
|
67
67
|
and an off-canvas menu is `Drawer` — the one component that already does that.
|
|
@@ -262,6 +262,26 @@ renditions and the data-URI blur placeholder are build-pipeline steps
|
|
|
262
262
|
of this package. The component emits what it is handed and fabricates nothing —
|
|
263
263
|
no variants it was not given, no dimensions it did not measure.
|
|
264
264
|
|
|
265
|
+
## Keyboard groups
|
|
266
|
+
|
|
267
|
+
`As of 2026-08`: a roving group — `Menu`, `Tabs`, `Toolbar` — is one Tab stop into a set of
|
|
268
|
+
controls, and arrows move within it. Three rules, all of them in `src/roving.ts` and all of them
|
|
269
|
+
enforced by tests:
|
|
270
|
+
|
|
271
|
+
| Rule | Why |
|
|
272
|
+
|---|---|
|
|
273
|
+
| a disabled item is in neither the navigable set nor the tab stop (`MENU_ITEM_SELECTOR`, `TAB_SELECTOR`, `tabStopIndex`) | `focus()` on a disabled control is a **no-op**, so a disabled item left in the list pins the reducer on its index and hides everything after it |
|
|
274
|
+
| the tab stop is the selection, or the first **enabled** item (`tabStopIndex`) | a group whose only tab stop is disabled cannot be entered at all |
|
|
275
|
+
| a control that answers arrows itself keeps them (`handlesOwnArrowKeys`) | `Toolbar` exists to hold a search field, and stealing ArrowRight from it eats the keystroke moving the caret |
|
|
276
|
+
|
|
277
|
+
`Toolbar` is deliberately **not** a single Tab stop: it holds arbitrary children it cannot reach
|
|
278
|
+
into to set an initial `tabindex`, and a search field at its inline start keeps its own arrows —
|
|
279
|
+
one stop there would strand every control past it.
|
|
280
|
+
|
|
281
|
+
`As of 2026-08`, `ToastRegion` owns the live region, not `Toast`: the `<ol>` carries `aria-live`
|
|
282
|
+
and outlives every message, because a region created with its content already inside it is not announced. One region,
|
|
283
|
+
one politeness — `politeness="assertive"` for a region that carries errors alone.
|
|
284
|
+
|
|
265
285
|
## Theme resolution
|
|
266
286
|
|
|
267
287
|
`explicit choice in localStorage` → `OS preference`. `setTheme()` persists,
|
|
@@ -280,7 +300,7 @@ Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCsp
|
|
|
280
300
|
| `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define — including a `defineTheme()` override of a role, radius or font slot that is not in the scale |
|
|
281
301
|
| `X_THEME_INVALID` | a theme other than `light` / `dark` |
|
|
282
302
|
| `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` |
|
|
283
|
-
| `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`,
|
|
303
|
+
| `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) |
|
|
284
304
|
|
|
285
305
|
## Commands
|
|
286
306
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.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",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"icons": "bun run src/icons/build-icons.ts"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@ultimat3/core": "
|
|
43
|
-
"@ultimat3/i18n": "
|
|
44
|
-
"@ultimat3/money": "
|
|
45
|
-
"@ultimat3/time": "
|
|
42
|
+
"@ultimat3/core": "3.0.0",
|
|
43
|
+
"@ultimat3/i18n": "3.0.0",
|
|
44
|
+
"@ultimat3/money": "3.0.0",
|
|
45
|
+
"@ultimat3/time": "3.0.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"solid-js": "^1.9.0"
|
package/src/a11y.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// The decision logic is pure (and tested); only the thin shells touch the DOM.
|
|
3
3
|
|
|
4
4
|
import type { Direction } from '@ultimat3/i18n';
|
|
5
|
+
import { handlesOwnArrowKeys } from './roving';
|
|
5
6
|
|
|
6
7
|
export const FOCUSABLE_SELECTOR = [
|
|
7
8
|
'a[href]',
|
|
@@ -66,26 +67,46 @@ export interface FocusTrap {
|
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
|
-
* Cycles Tab within `root` and restores focus to the
|
|
70
|
-
* on release —
|
|
70
|
+
* Cycles Tab within `root`, moves focus into it on `activate`, and restores focus to the element
|
|
71
|
+
* that had it on `release` — what `Menu` and `Popover` need, because a panel unmounted with focus
|
|
72
|
+
* inside it resets focus to `<body>` and the next Tab restarts at the top of the document.
|
|
73
|
+
*
|
|
74
|
+
* The listener sits on `document`, not on `root`: a keydown while focus is OUTSIDE never reaches
|
|
75
|
+
* `root`, so a trap listening on its own root can never recapture focus that already left — the
|
|
76
|
+
* branch that pulls it back was unreachable by construction. Every branch still tests
|
|
77
|
+
* `root.contains`, so the trap only acts while it is the active one.
|
|
71
78
|
*/
|
|
72
79
|
export function createFocusTrap(root: HTMLElement): FocusTrap {
|
|
73
80
|
let previous: HTMLElement | null = null;
|
|
74
81
|
|
|
82
|
+
/**
|
|
83
|
+
* The empty-panel fallback, and the reason it needs a line of its own: a plain `<div>` is not
|
|
84
|
+
* focusable, so `focus()` on it is a no-op that reports nothing — and `Menu` and `Popover` both
|
|
85
|
+
* hand this trap exactly that. `tabindex="-1"` is the one value that makes an element reachable
|
|
86
|
+
* programmatically without putting it in the Tab order, so the root never becomes a stop of its
|
|
87
|
+
* own (`FOCUSABLE_SELECTOR` excludes `-1`). A root that already declares one keeps it.
|
|
88
|
+
*/
|
|
89
|
+
function focusRoot(): void {
|
|
90
|
+
if (root.getAttribute('tabindex') === null) root.tabIndex = -1;
|
|
91
|
+
root.focus();
|
|
92
|
+
}
|
|
93
|
+
|
|
75
94
|
function onKeyDown(event: KeyboardEvent): void {
|
|
76
95
|
if (event.key !== 'Tab') return;
|
|
77
96
|
const items = focusableWithin(root);
|
|
78
97
|
if (items.length === 0) {
|
|
79
98
|
event.preventDefault();
|
|
99
|
+
focusRoot();
|
|
80
100
|
return;
|
|
81
101
|
}
|
|
82
102
|
const first = items[0] as HTMLElement;
|
|
83
103
|
const last = items[items.length - 1] as HTMLElement;
|
|
84
104
|
const active = document.activeElement;
|
|
85
|
-
|
|
105
|
+
const outside = !root.contains(active);
|
|
106
|
+
if (event.shiftKey && (outside || active === first)) {
|
|
86
107
|
event.preventDefault();
|
|
87
108
|
last.focus();
|
|
88
|
-
} else if (!event.shiftKey && active === last) {
|
|
109
|
+
} else if (!event.shiftKey && (outside || active === last)) {
|
|
89
110
|
event.preventDefault();
|
|
90
111
|
first.focus();
|
|
91
112
|
}
|
|
@@ -94,11 +115,13 @@ export function createFocusTrap(root: HTMLElement): FocusTrap {
|
|
|
94
115
|
return {
|
|
95
116
|
activate() {
|
|
96
117
|
previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
document.addEventListener('keydown', onKeyDown);
|
|
119
|
+
const first = focusableWithin(root)[0];
|
|
120
|
+
if (first === undefined) focusRoot();
|
|
121
|
+
else first.focus();
|
|
99
122
|
},
|
|
100
123
|
release() {
|
|
101
|
-
|
|
124
|
+
document.removeEventListener('keydown', onKeyDown);
|
|
102
125
|
previous?.focus();
|
|
103
126
|
},
|
|
104
127
|
};
|
|
@@ -151,11 +174,19 @@ export function createRovingTabindex(
|
|
|
151
174
|
return (event) => {
|
|
152
175
|
const items = getItems();
|
|
153
176
|
const active = document.activeElement;
|
|
177
|
+
// A control with its own arrow-key behaviour keeps it. A group cannot know whether the caret
|
|
178
|
+
// in the Input it contains was about to move, so it never guesses — it declines.
|
|
179
|
+
if (handlesOwnArrowKeys(active)) return;
|
|
154
180
|
// Focus may sit on nothing, on `<body>`, or on a non-HTML element (an SVG child): none of
|
|
155
181
|
// those are in `items`, and all of them mean "start from the first item".
|
|
156
182
|
const current = active instanceof HTMLElement ? items.indexOf(active) : -1;
|
|
157
|
-
const
|
|
158
|
-
|
|
183
|
+
const start = Math.max(current, 0);
|
|
184
|
+
const next = nextRovingIndex(start, event.key, items.length, options);
|
|
185
|
+
// Compared against `start`, the value the reducer was actually given — not the unclamped
|
|
186
|
+
// `current`. A key the reducer does not navigate answers its own input, so comparing against
|
|
187
|
+
// `-1` made every such key (Tab included) look like a move and steal focus out of whatever
|
|
188
|
+
// held it whenever the active element was not one of the group's items.
|
|
189
|
+
if (next === start || next < 0) return;
|
|
159
190
|
event.preventDefault();
|
|
160
191
|
for (const [index, item] of items.entries()) {
|
|
161
192
|
item.tabIndex = index === next ? 0 : -1;
|
|
@@ -13,7 +13,7 @@ export interface CheckboxProps {
|
|
|
13
13
|
name?: string | undefined;
|
|
14
14
|
value?: string | undefined;
|
|
15
15
|
checked?: boolean | undefined;
|
|
16
|
-
/** Tri-state for "some children selected".
|
|
16
|
+
/** Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. */
|
|
17
17
|
indeterminate?: boolean | undefined;
|
|
18
18
|
disabled?: boolean | undefined;
|
|
19
19
|
required?: boolean | undefined;
|
|
@@ -36,7 +36,12 @@ export function Checkbox(props: CheckboxProps): JSX.Element {
|
|
|
36
36
|
checked={props.checked === true}
|
|
37
37
|
disabled={props.disabled === true}
|
|
38
38
|
required={props.required === true}
|
|
39
|
-
|
|
39
|
+
// Only "mixed" is set here. `indeterminate` is an IDL property with no attribute form, so
|
|
40
|
+
// ARIA is the sole server-side lever for it — whereas a plain checked/unchecked box already
|
|
41
|
+
// maps from the native `checked` state, and an `aria-checked` mirroring it would FREEZE the
|
|
42
|
+
// announced state: on the no-JS path a user ticks the box, the native state changes, the
|
|
43
|
+
// attribute does not, and ARIA wins in the accessibility tree.
|
|
44
|
+
aria-checked={props.indeterminate === true ? 'mixed' : undefined}
|
|
40
45
|
aria-describedby={props['aria-describedby']}
|
|
41
46
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
42
47
|
onChange={props.onChange}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// Modal built on native <dialog>: the platform gives us the top layer, the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Modal built on native <dialog>: the platform gives us the top layer, the backdrop, inert
|
|
2
|
+
// background content, and Escape-to-close for free. We add the labelled heading and the close
|
|
3
|
+
// affordance. Body scroll locking is one CSS rule in `tokens/reset.scss` (`html:has(dialog:modal)`)
|
|
4
|
+
// rather than anything here, so it covers Drawer too and cannot leak when a close path throws.
|
|
4
5
|
|
|
5
6
|
import type { JSX } from 'solid-js';
|
|
6
7
|
import { useId } from '../a11y';
|
|
@@ -12,7 +12,7 @@ import { useUi } from '../theme/context';
|
|
|
12
12
|
import { solid } from '../theme/solid-adapter';
|
|
13
13
|
import styles from './Dropzone.module.scss';
|
|
14
14
|
import type { FileSelection } from './file-input-view';
|
|
15
|
-
import { progressPercent, selectFiles } from './file-input-view';
|
|
15
|
+
import { adoptDroppedFiles, progressPercent, selectFiles } from './file-input-view';
|
|
16
16
|
|
|
17
17
|
export interface DropzoneProps {
|
|
18
18
|
/** Already-translated instruction. Required — it is the control's accessible name. */
|
|
@@ -45,6 +45,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
45
45
|
const rt = solid();
|
|
46
46
|
const [over, setOver] = rt.createSignal(false);
|
|
47
47
|
const fallbackId = useId('dropzone');
|
|
48
|
+
let input: HTMLInputElement | undefined;
|
|
48
49
|
const inputId = (): string => props.id ?? fallbackId;
|
|
49
50
|
const percent = (): number => progressPercent(props.progress ?? 0);
|
|
50
51
|
|
|
@@ -72,12 +73,19 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
72
73
|
onDrop={(event: DragEvent) => {
|
|
73
74
|
event.preventDefault();
|
|
74
75
|
setOver(false);
|
|
75
|
-
if (props.disabled
|
|
76
|
+
if (props.disabled === true) return;
|
|
77
|
+
// The input first, then the callback: a drop that only reaches `onSelect` leaves the real
|
|
78
|
+
// control empty, and the form the label sits in submits nothing.
|
|
79
|
+
adoptDroppedFiles(input, event.dataTransfer?.files);
|
|
80
|
+
offer(event.dataTransfer?.files);
|
|
76
81
|
}}
|
|
77
82
|
>
|
|
78
83
|
<span class={styles['label']}>{props.label}</span>
|
|
79
84
|
{props.hint === undefined ? null : <span class={styles['hint']}>{props.hint}</span>}
|
|
80
85
|
<input
|
|
86
|
+
ref={(el: HTMLInputElement) => {
|
|
87
|
+
input = el;
|
|
88
|
+
}}
|
|
81
89
|
class={styles['input']}
|
|
82
90
|
type="file"
|
|
83
91
|
id={inputId()}
|
package/src/components/Field.tsx
CHANGED
|
@@ -52,13 +52,7 @@ export function Field(props: FieldProps): JSX.Element {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
return (
|
|
55
|
-
<div
|
|
56
|
-
class={cx(
|
|
57
|
-
styles['field'],
|
|
58
|
-
props.error === undefined ? undefined : styles['invalid'],
|
|
59
|
-
props.class,
|
|
60
|
-
)}
|
|
61
|
-
>
|
|
55
|
+
<div class={cx(styles['field'], props.class)}>
|
|
62
56
|
<label class={styles['label']} for={id}>
|
|
63
57
|
{props.label}
|
|
64
58
|
{props.required === true ? (
|
package/src/components/Form.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// Form shell. Owns the one thing every form needs and always forgets: a
|
|
2
|
-
//
|
|
1
|
+
// Form shell. Owns the one thing every form needs and always forgets: a top-of-form error summary
|
|
2
|
+
// that is announced (the Alert inside it is a live region) and that TAKES focus when an error
|
|
3
|
+
// arrives — the focus move is what makes the summary reachable at all, since its id is internal.
|
|
3
4
|
|
|
4
5
|
import type { JSX } from 'solid-js';
|
|
5
6
|
import { useId } from '../a11y';
|
|
6
7
|
import { cx } from '../cx';
|
|
8
|
+
import { solid } from '../theme/solid-adapter';
|
|
7
9
|
import { Alert } from './Alert';
|
|
8
10
|
import styles from './Form.module.scss';
|
|
9
11
|
import type { SpaceStep } from './variants';
|
|
@@ -25,7 +27,17 @@ export interface FormProps {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
export function Form(props: FormProps): JSX.Element {
|
|
30
|
+
const rt = solid();
|
|
28
31
|
const summaryId = useId('form-error');
|
|
32
|
+
let summary: HTMLDivElement | undefined;
|
|
33
|
+
|
|
34
|
+
// `tabindex="-1"` alone was a focus target nothing ever aimed at: `summaryId` is internal, so no
|
|
35
|
+
// caller could move focus here, and the component never did either. A failed submit that leaves
|
|
36
|
+
// focus on the button leaves a keyboard user to hunt for what went wrong.
|
|
37
|
+
rt.createEffect(() => {
|
|
38
|
+
if (props.error !== undefined) summary?.focus();
|
|
39
|
+
});
|
|
40
|
+
|
|
29
41
|
return (
|
|
30
42
|
<form
|
|
31
43
|
class={cx(styles['form'], props.class)}
|
|
@@ -38,7 +50,14 @@ export function Form(props: FormProps): JSX.Element {
|
|
|
38
50
|
onSubmit={props.onSubmit}
|
|
39
51
|
>
|
|
40
52
|
{props.error === undefined ? null : (
|
|
41
|
-
<div
|
|
53
|
+
<div
|
|
54
|
+
ref={(el: HTMLDivElement) => {
|
|
55
|
+
summary = el;
|
|
56
|
+
}}
|
|
57
|
+
id={summaryId}
|
|
58
|
+
tabindex="-1"
|
|
59
|
+
class={styles['summary']}
|
|
60
|
+
>
|
|
42
61
|
<Alert tone="danger" title={props.errorTitle}>
|
|
43
62
|
{props.error}
|
|
44
63
|
</Alert>
|
package/src/components/Menu.tsx
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// Distinct from Select — a menu runs commands, it does not hold a form value.
|
|
3
3
|
|
|
4
4
|
import type { JSX } from 'solid-js';
|
|
5
|
-
import { createRovingTabindex, useId } from '../a11y';
|
|
5
|
+
import { createFocusTrap, createRovingTabindex, useId } from '../a11y';
|
|
6
6
|
import { cx } from '../cx';
|
|
7
|
+
import { MENU_ITEM_SELECTOR, tabStopIndex } from '../roving';
|
|
7
8
|
import { useUi } from '../theme/context';
|
|
8
9
|
import { solid } from '../theme/solid-adapter';
|
|
9
10
|
import styles from './Menu.module.scss';
|
|
@@ -42,11 +43,14 @@ export function Menu(props: MenuProps): JSX.Element {
|
|
|
42
43
|
let root: HTMLDivElement | undefined;
|
|
43
44
|
let list: HTMLDivElement | undefined;
|
|
44
45
|
|
|
46
|
+
// Disabled items are excluded from BOTH answers — the list arrows walk and the one item that
|
|
47
|
+
// carries the tab stop — because `focus()` on a disabled button silently does nothing.
|
|
45
48
|
const onKeyDown = createRovingTabindex(
|
|
46
49
|
() =>
|
|
47
|
-
list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(
|
|
50
|
+
list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR)),
|
|
48
51
|
{ orientation: 'vertical', dir: ui.dir },
|
|
49
52
|
);
|
|
53
|
+
const tabStop = (): number => tabStopIndex(props.items);
|
|
50
54
|
|
|
51
55
|
rt.createEffect(() => {
|
|
52
56
|
if (!props.open || typeof document === 'undefined') return;
|
|
@@ -58,9 +62,15 @@ export function Menu(props: MenuProps): JSX.Element {
|
|
|
58
62
|
};
|
|
59
63
|
document.addEventListener('pointerdown', onPointerDown, true);
|
|
60
64
|
document.addEventListener('keydown', onEscape);
|
|
65
|
+
// Closing unmounts the panel with focus inside it, which resets focus to <body> and makes the
|
|
66
|
+
// next Tab restart at the top of the document. The trap moves focus in on open and hands it
|
|
67
|
+
// back to the trigger on close, so Escape returns the user where they were.
|
|
68
|
+
const trap = list === undefined ? undefined : createFocusTrap(list);
|
|
69
|
+
trap?.activate();
|
|
61
70
|
rt.onCleanup(() => {
|
|
62
71
|
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
63
72
|
document.removeEventListener('keydown', onEscape);
|
|
73
|
+
trap?.release();
|
|
64
74
|
});
|
|
65
75
|
});
|
|
66
76
|
|
|
@@ -93,7 +103,7 @@ export function Menu(props: MenuProps): JSX.Element {
|
|
|
93
103
|
type="button"
|
|
94
104
|
role="menuitem"
|
|
95
105
|
class={cx(styles['item'], item.destructive === true && styles['destructive'])}
|
|
96
|
-
tabindex={index ===
|
|
106
|
+
tabindex={index === tabStop() ? 0 : -1}
|
|
97
107
|
disabled={item.disabled === true}
|
|
98
108
|
onClick={() => {
|
|
99
109
|
item.onSelect();
|
|
@@ -27,7 +27,14 @@ export function Pagination(props: PaginationProps): JSX.Element {
|
|
|
27
27
|
const ui = useUi();
|
|
28
28
|
const previous = (): string => props.labelPrevious ?? ui.t(UI_KEYS.previous);
|
|
29
29
|
const next = (): string => props.labelNext ?? ui.t(UI_KEYS.next);
|
|
30
|
-
|
|
30
|
+
// A cursor present means cursor mode, exactly as `page`'s own doc says. The inverted form —
|
|
31
|
+
// "cursor mode when a number is MISSING" — silently dropped the cursor and paged by number the
|
|
32
|
+
// moment a caller passed both, which is the shape a list that knows its total naturally has.
|
|
33
|
+
const cursorMode = (): boolean =>
|
|
34
|
+
props.nextCursor !== undefined ||
|
|
35
|
+
props.prevCursor !== undefined ||
|
|
36
|
+
props.page === undefined ||
|
|
37
|
+
props.totalPages === undefined;
|
|
31
38
|
|
|
32
39
|
return (
|
|
33
40
|
<nav class={cx(styles['pagination'], props.class)} aria-label={ui.t(UI_KEYS.page)}>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// flips sides automatically under `dir="rtl"`.
|
|
4
4
|
|
|
5
5
|
import type { JSX } from 'solid-js';
|
|
6
|
-
import { useId } from '../a11y';
|
|
6
|
+
import { createFocusTrap, useId } from '../a11y';
|
|
7
7
|
import { cx } from '../cx';
|
|
8
8
|
import { solid } from '../theme/solid-adapter';
|
|
9
9
|
import styles from './Popover.module.scss';
|
|
@@ -32,6 +32,7 @@ export function Popover(props: PopoverProps): JSX.Element {
|
|
|
32
32
|
const panelId = useId('popover');
|
|
33
33
|
const triggerId = `${panelId}-trigger`;
|
|
34
34
|
let root: HTMLDivElement | undefined;
|
|
35
|
+
let panel: HTMLDivElement | undefined;
|
|
35
36
|
|
|
36
37
|
rt.createEffect(() => {
|
|
37
38
|
if (!props.open || typeof document === 'undefined') return;
|
|
@@ -43,9 +44,15 @@ export function Popover(props: PopoverProps): JSX.Element {
|
|
|
43
44
|
};
|
|
44
45
|
document.addEventListener('pointerdown', onPointerDown, true);
|
|
45
46
|
document.addEventListener('keydown', onKeyDown);
|
|
47
|
+
// Closing unmounts the panel with focus inside it, which drops focus to <body> and makes the
|
|
48
|
+
// next Tab restart at the top of the document. The trap moves focus into the panel on open and
|
|
49
|
+
// hands it back to the trigger on close.
|
|
50
|
+
const trap = panel === undefined ? undefined : createFocusTrap(panel);
|
|
51
|
+
trap?.activate();
|
|
46
52
|
rt.onCleanup(() => {
|
|
47
53
|
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
48
54
|
document.removeEventListener('keydown', onKeyDown);
|
|
55
|
+
trap?.release();
|
|
49
56
|
});
|
|
50
57
|
});
|
|
51
58
|
|
|
@@ -59,6 +66,9 @@ export function Popover(props: PopoverProps): JSX.Element {
|
|
|
59
66
|
{props.trigger({ id: triggerId, 'aria-expanded': props.open, 'aria-controls': panelId })}
|
|
60
67
|
{props.open ? (
|
|
61
68
|
<div
|
|
69
|
+
ref={(el: HTMLDivElement) => {
|
|
70
|
+
panel = el;
|
|
71
|
+
}}
|
|
62
72
|
id={panelId}
|
|
63
73
|
role="dialog"
|
|
64
74
|
aria-label={props.label}
|
|
@@ -38,8 +38,11 @@ export interface SelectProps {
|
|
|
38
38
|
* is also what keeps an unset field unsubmittable: that option is `disabled`.
|
|
39
39
|
*/
|
|
40
40
|
export function Select(props: SelectProps): JSX.Element {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
// Thunks, not setup-time reads: a prop read once at setup never tracks, so under a client Solid
|
|
42
|
+
// runtime the selection would freeze at whatever the first render saw (ThemeToggle already feeds
|
|
43
|
+
// this a signal). Every other value-formatting component in the package wraps its read the same way.
|
|
44
|
+
const current = (): string => props.value ?? '';
|
|
45
|
+
const matched = (): boolean => props.options.some((option) => option.value === current());
|
|
43
46
|
return (
|
|
44
47
|
<span class={cx(styles['wrap'], styles[`size-${props.size ?? 'md'}`], props.class)}>
|
|
45
48
|
<select
|
|
@@ -54,7 +57,7 @@ export function Select(props: SelectProps): JSX.Element {
|
|
|
54
57
|
onChange={props.onChange}
|
|
55
58
|
>
|
|
56
59
|
{props.placeholder === undefined ? null : (
|
|
57
|
-
<option value="" disabled selected={!matched}>
|
|
60
|
+
<option value="" disabled selected={!matched()}>
|
|
58
61
|
{props.placeholder}
|
|
59
62
|
</option>
|
|
60
63
|
)}
|
|
@@ -62,7 +65,7 @@ export function Select(props: SelectProps): JSX.Element {
|
|
|
62
65
|
<option
|
|
63
66
|
value={option.value}
|
|
64
67
|
disabled={option.disabled === true}
|
|
65
|
-
selected={option.value === current}
|
|
68
|
+
selected={option.value === current()}
|
|
66
69
|
>
|
|
67
70
|
{option.label}
|
|
68
71
|
</option>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
// Boolean toggle with immediate effect (as opposed to Checkbox, which is part of
|
|
2
|
-
//
|
|
1
|
+
// Boolean toggle with immediate effect (as opposed to Checkbox, which is part of a form submit).
|
|
2
|
+
// `role="switch"` on a native checkbox keeps keyboard behaviour — and its checked state, which is
|
|
3
|
+
// why nothing here writes `aria-checked`: `role="switch"` maps the host `checked` state on its own,
|
|
4
|
+
// and an attribute mirroring it never updates on the no-JS path, where ARIA would then outrank the
|
|
5
|
+
// truth and announce the switch stuck in whichever position it was rendered in.
|
|
3
6
|
|
|
4
7
|
import type { JSX } from 'solid-js';
|
|
5
|
-
import { ariaBool } from '../a11y';
|
|
6
8
|
import { cx } from '../cx';
|
|
7
9
|
import styles from './Switch.module.scss';
|
|
8
10
|
|
|
@@ -26,12 +28,17 @@ export function Switch(props: SwitchProps): JSX.Element {
|
|
|
26
28
|
<input
|
|
27
29
|
class={styles['input']}
|
|
28
30
|
type="checkbox"
|
|
31
|
+
// The rule reads ARIA alone; this element's state comes from the HOST language. Applying
|
|
32
|
+
// `role="switch"` to a native checkbox is the recommended technique precisely because the
|
|
33
|
+
// browser maps the element's own checkedness onto it — and an `aria-checked` mirroring
|
|
34
|
+
// `props.checked` is WORSE than absent: on the no-JS path nothing rewrites it after the
|
|
35
|
+
// user toggles the box, and ARIA outranks host state in the accessibility tree.
|
|
36
|
+
// biome-ignore lint/a11y/useAriaPropsForRole: native checkedness supplies the state
|
|
29
37
|
role="switch"
|
|
30
38
|
id={props.id}
|
|
31
39
|
name={props.name}
|
|
32
40
|
checked={props.checked === true}
|
|
33
41
|
disabled={props.disabled === true}
|
|
34
|
-
aria-checked={ariaBool(props.checked === true)}
|
|
35
42
|
aria-describedby={props['aria-describedby']}
|
|
36
43
|
onChange={props.onChange}
|
|
37
44
|
/>
|
package/src/components/Table.tsx
CHANGED
|
@@ -21,9 +21,11 @@ export interface TableProps {
|
|
|
21
21
|
|
|
22
22
|
export function Table(props: TableProps): JSX.Element {
|
|
23
23
|
return (
|
|
24
|
-
// tabindex makes the scroll region keyboard-reachable, which is required
|
|
25
|
-
//
|
|
26
|
-
|
|
24
|
+
// tabindex makes the scroll region keyboard-reachable, which is required whenever a scrollable
|
|
25
|
+
// element has no focusable children. No `aria-label`: it would OVERRIDE the <caption> as the
|
|
26
|
+
// accessible name rather than add to it, naming the scroll box the same thing as the table
|
|
27
|
+
// inside it — and a <section> with no name is generic, so the table keeps its own caption.
|
|
28
|
+
<section class={cx(styles['scroller'], props.class)} tabindex="0">
|
|
27
29
|
<table
|
|
28
30
|
class={cx(
|
|
29
31
|
styles['table'],
|