@ultimat3/ui 19.4.0 → 20.1.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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Data-driven table: sortable headers, cursor pagination, and the four states a
|
|
2
2
|
// real list always has (loading, error, empty, data). The error state renders an
|
|
3
3
|
// UltimateError with the same code/cause/fix strings the terminal prints.
|
|
4
|
+
//
|
|
5
|
+
// The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a
|
|
6
|
+
// table and a card list cannot disagree about what "loading with stale rows" looks like. Only the
|
|
7
|
+
// PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
|
|
4
8
|
|
|
5
9
|
import { finiteCount } from '@ultimat3/core';
|
|
6
10
|
import type { JSX } from 'solid-js';
|
|
@@ -8,6 +12,7 @@ import { ariaBool } from '../a11y';
|
|
|
8
12
|
import { cx } from '../cx';
|
|
9
13
|
import { UI_KEYS } from '../i18n-keys';
|
|
10
14
|
import { useUi } from '../theme/context';
|
|
15
|
+
import { type AsyncBranch, type AsyncState, asyncBranch, isBusyBranch } from './async-branch';
|
|
11
16
|
import styles from './DataTable.module.scss';
|
|
12
17
|
import { EmptyState } from './EmptyState';
|
|
13
18
|
import { ErrorState } from './ErrorState';
|
|
@@ -60,8 +65,28 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
60
65
|
? ui.t(UI_KEYS.sortDescending)
|
|
61
66
|
: ui.t(UI_KEYS.sortAscending);
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
/**
|
|
69
|
+
* `rows` is one prop carrying two meanings, and this is where they are separated. An EMPTY array
|
|
70
|
+
* under `loading` is a first page in flight — there is nothing to keep, so the placeholder shows.
|
|
71
|
+
* A NON-EMPTY one is the previous page, which stays on screen dimmed rather than collapsing to a
|
|
72
|
+
* skeleton: re-sorting a table used to blank every row it was about to render again.
|
|
73
|
+
*/
|
|
74
|
+
const state = (): AsyncState<readonly Row[]> => {
|
|
75
|
+
if (props.error !== undefined && props.error !== null) {
|
|
76
|
+
return { status: 'failed', error: props.error };
|
|
77
|
+
}
|
|
64
78
|
if (props.loading === true) {
|
|
79
|
+
return props.rows.length === 0
|
|
80
|
+
? { status: 'pending' }
|
|
81
|
+
: { status: 'refreshing', data: props.rows };
|
|
82
|
+
}
|
|
83
|
+
return { status: 'ready', data: props.rows };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const branch = (): AsyncBranch<readonly Row[]> => asyncBranch(state());
|
|
87
|
+
|
|
88
|
+
const body = (): JSX.Element => {
|
|
89
|
+
if (branch().kind === 'pending') {
|
|
65
90
|
// `Array.from({ length: NaN })` is `[]`, so a busy tbody with no placeholders in it is the
|
|
66
91
|
// collapsed layout this state exists to prevent, reported as healthy; `Infinity` asks for
|
|
67
92
|
// 2^53 - 1 elements and dies with a bare, uncoded `RangeError` out of a render. `??` reaches
|
|
@@ -89,11 +114,14 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
89
114
|
));
|
|
90
115
|
};
|
|
91
116
|
|
|
92
|
-
|
|
93
|
-
|
|
117
|
+
const decided = branch();
|
|
118
|
+
if (decided.kind === 'failed') {
|
|
119
|
+
return <ErrorState error={decided.error} onRetry={props.onRetry} class={props.class} />;
|
|
94
120
|
}
|
|
95
121
|
|
|
96
|
-
|
|
122
|
+
// Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two ifs:
|
|
123
|
+
// "No results" under a first page in flight is the bug that ordering used to be all that stopped.
|
|
124
|
+
if (decided.kind === 'empty') {
|
|
97
125
|
return (
|
|
98
126
|
<EmptyState
|
|
99
127
|
title={props.emptyTitle}
|
|
@@ -142,7 +170,12 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
142
170
|
))}
|
|
143
171
|
</tr>
|
|
144
172
|
</thead>
|
|
145
|
-
<tbody
|
|
173
|
+
<tbody
|
|
174
|
+
class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
|
|
175
|
+
aria-busy={ariaBool(isBusyBranch(decided))}
|
|
176
|
+
>
|
|
177
|
+
{body()}
|
|
178
|
+
</tbody>
|
|
146
179
|
</Table>
|
|
147
180
|
{props.nextCursor === undefined && props.prevCursor === undefined ? null : (
|
|
148
181
|
<Pagination
|
|
@@ -59,8 +59,19 @@
|
|
|
59
59
|
border-radius: t.radius(pill);
|
|
60
60
|
background: t.role('bg-soft');
|
|
61
61
|
overflow: hidden;
|
|
62
|
+
// Bounds the layout pass the `.bar` transition below costs to this subtree. See the note there.
|
|
63
|
+
contain: layout;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
// The upload bar. `inline-size` is a LAYOUT-triggering property and a transition on one is not
|
|
67
|
+
// compositor-only, which is a real cost and a deliberate trade — the compositor-safe form is
|
|
68
|
+
// `transform: scaleX()`, and `transform-origin` has no logical keyword, so the bar would grow from
|
|
69
|
+
// the physical left edge and fill away from the reading edge under `dir="rtl"`. A progress bar that
|
|
70
|
+
// runs backwards in Arabic is a worse defect than a layout pass. `contain: layout` on the track is
|
|
71
|
+
// what bounds the cost instead: the invalidation stops at the track, so nothing outside it is
|
|
72
|
+
// re-laid-out per frame. `layout` alone and NOT `layout size` — `size` makes a box ignore its
|
|
73
|
+
// contents in both axes, and FileInput's track takes its inline size from its containing block.
|
|
74
|
+
//
|
|
64
75
|
// Inline-start, not `left`: the bar has to fill from the reading edge under `dir="rtl"` too.
|
|
65
76
|
.bar {
|
|
66
77
|
display: block;
|
|
@@ -63,8 +63,19 @@
|
|
|
63
63
|
border-radius: t.radius(pill);
|
|
64
64
|
background: t.role('bg-soft');
|
|
65
65
|
overflow: hidden;
|
|
66
|
+
// Bounds the layout pass the `.bar` transition below costs to this subtree. See the note there.
|
|
67
|
+
contain: layout;
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// The upload bar. `inline-size` is a LAYOUT-triggering property and a transition on one is not
|
|
71
|
+
// compositor-only, which is a real cost and a deliberate trade — the compositor-safe form is
|
|
72
|
+
// `transform: scaleX()`, and `transform-origin` has no logical keyword, so the bar would grow from
|
|
73
|
+
// the physical left edge and fill away from the reading edge under `dir="rtl"`. A progress bar that
|
|
74
|
+
// runs backwards in Arabic is a worse defect than a layout pass. `contain: layout` on the track is
|
|
75
|
+
// what bounds the cost instead: the invalidation stops at the track, so nothing outside it is
|
|
76
|
+
// re-laid-out per frame. `layout` alone and NOT `layout size` — `size` makes a box ignore its
|
|
77
|
+
// contents in both axes, and FileInput's track takes its inline size from its containing block.
|
|
78
|
+
//
|
|
68
79
|
// Inline-start, not `left`: the bar has to fill from the reading edge under `dir="rtl"` too.
|
|
69
80
|
.bar {
|
|
70
81
|
display: block;
|
package/src/components/Form.tsx
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
// Form shell. Owns the one thing every form needs and always forgets: a top-of-form error summary
|
|
2
2
|
// that is announced (the Alert inside it is a live region) and that TAKES focus when an error
|
|
3
3
|
// arrives — the focus move is what makes the summary reachable at all, since its id is internal.
|
|
4
|
+
//
|
|
5
|
+
// Focus goes to the first INVALID CONTROL when there is one, and to the summary only when there is
|
|
6
|
+
// not. GOV.UK's tested pattern is a summary whose entries LINK to their fields; that shape is not
|
|
7
|
+
// available here, because `Field` mints its control ids internally (`Field.tsx`) and inverting that
|
|
8
|
+
// ownership is the drift `Field` exists to prevent — a summary cannot write an `href` to an id it
|
|
9
|
+
// cannot see. Focusing the control directly reaches the same place in one step. The summary still
|
|
10
|
+
// announces, and is still where a form-level rejection (a policy refusal, an unmatched issue) puts
|
|
11
|
+
// the reader, because that one names no control to send them to.
|
|
4
12
|
|
|
5
13
|
import type { JSX } from 'solid-js';
|
|
6
|
-
import { useId } from '../a11y';
|
|
14
|
+
import { ariaBool, useId } from '../a11y';
|
|
7
15
|
import { cx } from '../cx';
|
|
16
|
+
import { fieldSelector } from '../form/field-path';
|
|
8
17
|
import { solid } from '../theme/solid-adapter';
|
|
9
18
|
import { Alert } from './Alert';
|
|
10
19
|
import styles from './Form.module.scss';
|
|
@@ -17,6 +26,18 @@ export interface FormProps {
|
|
|
17
26
|
/** Already-translated heading for the error summary region. */
|
|
18
27
|
errorTitle?: string | undefined;
|
|
19
28
|
actions?: JSX.Element | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The `name` of the control a failed submit should send the reader to — `form.firstInvalidField()`.
|
|
31
|
+
* Focused in preference to the summary: the summary describes the problem, the control is where
|
|
32
|
+
* it is fixed, and leaving the user on the summary strands them one Tab away from nothing.
|
|
33
|
+
*/
|
|
34
|
+
invalidField?: string | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* A submit is in flight — `form.pending()`. Suppresses the submit outright, so a double submit is
|
|
37
|
+
* refused HERE and not only on whatever control happened to be clicked: Enter in a text field
|
|
38
|
+
* submits a form with no button involved at all.
|
|
39
|
+
*/
|
|
40
|
+
busy?: boolean | undefined;
|
|
20
41
|
gap?: SpaceStep | undefined;
|
|
21
42
|
method?: 'get' | 'post' | undefined;
|
|
22
43
|
action?: string | undefined;
|
|
@@ -30,16 +51,54 @@ export function Form(props: FormProps): JSX.Element {
|
|
|
30
51
|
const rt = solid();
|
|
31
52
|
const summaryId = useId('form-error');
|
|
32
53
|
let summary: HTMLDivElement | undefined;
|
|
54
|
+
let element: HTMLFormElement | undefined;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The control the rejection named, inside THIS form. `fieldSelector` answers `null` for a name
|
|
58
|
+
* the path grammar does not accept, which is the allowlist that keeps a caller's string out of a
|
|
59
|
+
* selector — no accepted name can carry a quote or close the attribute.
|
|
60
|
+
*/
|
|
61
|
+
const invalidControl = (): HTMLElement | null => {
|
|
62
|
+
const name = props.invalidField;
|
|
63
|
+
if (name === undefined || element === undefined) return null;
|
|
64
|
+
const selector = fieldSelector(name);
|
|
65
|
+
return selector === null ? null : element.querySelector<HTMLElement>(selector);
|
|
66
|
+
};
|
|
33
67
|
|
|
34
68
|
// `tabindex="-1"` alone was a focus target nothing ever aimed at: `summaryId` is internal, so no
|
|
35
69
|
// caller could move focus here, and the component never did either. A failed submit that leaves
|
|
36
70
|
// focus on the button leaves a keyboard user to hunt for what went wrong.
|
|
37
71
|
rt.createEffect(() => {
|
|
72
|
+
const control = invalidControl();
|
|
73
|
+
if (control !== null) {
|
|
74
|
+
control.focus();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
38
77
|
if (props.error !== undefined) summary?.focus();
|
|
39
78
|
});
|
|
40
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A form already submitting cannot submit again. `aria-disabled` on the button is advisory and
|
|
82
|
+
* Enter in a text field never touches the button at all, so this is the refusal that holds — and
|
|
83
|
+
* it is a guard, not the guarantee: the binding joins an in-flight submit, and the server is the
|
|
84
|
+
* only place a duplicate write is finally refused.
|
|
85
|
+
*/
|
|
86
|
+
const onSubmit = (event: SubmitEvent): void => {
|
|
87
|
+
if (props.busy === true) {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const handler = props.onSubmit;
|
|
92
|
+
if (handler === undefined) return;
|
|
93
|
+
if (typeof handler === 'function') handler(event as Parameters<typeof handler>[0]);
|
|
94
|
+
else handler[0](handler[1], event as Parameters<(typeof handler)[0]>[1]);
|
|
95
|
+
};
|
|
96
|
+
|
|
41
97
|
return (
|
|
42
98
|
<form
|
|
99
|
+
ref={(el: HTMLFormElement) => {
|
|
100
|
+
element = el;
|
|
101
|
+
}}
|
|
43
102
|
class={cx(styles['form'], props.class)}
|
|
44
103
|
style={{ '--form-gap': `var(--space-${props.gap ?? 5})` }}
|
|
45
104
|
method={props.method ?? 'post'}
|
|
@@ -47,7 +106,8 @@ export function Form(props: FormProps): JSX.Element {
|
|
|
47
106
|
novalidate={props.novalidate === true}
|
|
48
107
|
aria-label={props['aria-label']}
|
|
49
108
|
aria-describedby={props.error === undefined ? undefined : summaryId}
|
|
50
|
-
|
|
109
|
+
aria-busy={ariaBool(props.busy)}
|
|
110
|
+
onSubmit={onSubmit}
|
|
51
111
|
>
|
|
52
112
|
{props.error === undefined ? null : (
|
|
53
113
|
<div
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
max-inline-size: 100%;
|
|
8
8
|
block-size: auto;
|
|
9
9
|
|
|
10
|
+
// The second half of the reservation, and the half that survives styling: the width/height
|
|
11
|
+
// attributes reserve the box only until a stylesheet sets a size of its own. `auto` when the
|
|
12
|
+
// dimensions are unknown, which is what a replaced element does without this rule at all.
|
|
13
|
+
aspect-ratio: var(--image-ratio, auto);
|
|
14
|
+
|
|
10
15
|
// Painted only when the image fails to load: the browser renders the alt text
|
|
11
16
|
// inside this box, and it inherits the img's own colour and font-size.
|
|
12
17
|
color: t.role('fg-muted');
|
package/src/components/Image.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type ImageLoadingHints,
|
|
16
16
|
type ImageVariant,
|
|
17
17
|
loadingHints,
|
|
18
|
+
ratioFor,
|
|
18
19
|
srcsetFor,
|
|
19
20
|
} from './image-source';
|
|
20
21
|
|
|
@@ -45,6 +46,9 @@ export function Image(props: ImageProps): JSX.Element {
|
|
|
45
46
|
return (
|
|
46
47
|
<img
|
|
47
48
|
class={cx(styles['image'], props.class)}
|
|
49
|
+
// A custom property, because that is the one thing an inline `style` may carry here. Absent
|
|
50
|
+
// when the dimensions are unknown, and the stylesheet's fallback is then `auto`.
|
|
51
|
+
style={box() === undefined ? undefined : { '--image-ratio': ratioFor(box()) }}
|
|
48
52
|
src={src()}
|
|
49
53
|
alt={props.alt}
|
|
50
54
|
srcset={srcsetFor(props.variants)}
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
@use '../tokens' as t;
|
|
2
2
|
|
|
3
|
-
@keyframes ultimate-toast-in {
|
|
4
|
-
from {
|
|
5
|
-
opacity: 0;
|
|
6
|
-
translate: 0 12px;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
|
|
10
3
|
.region {
|
|
11
4
|
position: fixed;
|
|
12
5
|
z-index: t.z(toast);
|
|
@@ -47,8 +40,24 @@
|
|
|
47
40
|
padding: t.space(4);
|
|
48
41
|
border-inline-start: 3px solid var(--toast-accent);
|
|
49
42
|
color: t.role('fg');
|
|
50
|
-
animation: ultimate-toast-in t.duration(base) t.easing(out);
|
|
51
43
|
pointer-events: auto;
|
|
44
|
+
|
|
45
|
+
// A TRANSITION, not a keyframe animation, and `@starting-style` is what makes an entry
|
|
46
|
+
// transition possible with no JS at all. The difference is interruption: a transition retargets
|
|
47
|
+
// from wherever the property currently is when the state changes mid-flight, and a keyframe
|
|
48
|
+
// animation restarts from its own `from`. A toast stack is the surface where that shows — a
|
|
49
|
+
// second message arriving 200ms into the first one's entry restarted the whole stack's motion.
|
|
50
|
+
// `opacity` and `translate` are the two the compositor can run without laying the page out again.
|
|
51
|
+
opacity: 1;
|
|
52
|
+
translate: 0 0;
|
|
53
|
+
transition:
|
|
54
|
+
opacity t.duration(base) t.easing(out),
|
|
55
|
+
translate t.duration(base) t.easing(out);
|
|
56
|
+
|
|
57
|
+
@starting-style {
|
|
58
|
+
opacity: 0;
|
|
59
|
+
translate: 0 12px;
|
|
60
|
+
}
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
.content {
|
package/src/components/Toast.tsx
CHANGED
|
@@ -9,10 +9,14 @@ import type { Politeness } from '../a11y';
|
|
|
9
9
|
import { cx } from '../cx';
|
|
10
10
|
import { UI_KEYS } from '../i18n-keys';
|
|
11
11
|
import { useUi } from '../theme/context';
|
|
12
|
+
import type { ToastHold } from '../toast/toast-state';
|
|
12
13
|
import { IconButton } from './IconButton';
|
|
13
14
|
import styles from './Toast.module.scss';
|
|
14
15
|
import type { Tone } from './variants';
|
|
15
16
|
|
|
17
|
+
/** Where the stack sits. Logical corners, so it follows the writing direction. */
|
|
18
|
+
export type ToastPlacement = 'block-end-inline-end' | 'block-start-inline-end' | 'block-end-center';
|
|
19
|
+
|
|
16
20
|
export interface ToastRegionProps {
|
|
17
21
|
children: JSX.Element;
|
|
18
22
|
/** Already-translated landmark name, e.g. "Notifications". */
|
|
@@ -24,13 +28,28 @@ export interface ToastRegionProps {
|
|
|
24
28
|
* list cannot work, because the live semantics belong to the list, not to the message.
|
|
25
29
|
*/
|
|
26
30
|
politeness?: Politeness | undefined;
|
|
27
|
-
placement?:
|
|
31
|
+
placement?: ToastPlacement | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Stop the dwell while the reader is engaged with the stack, and start it again when they leave.
|
|
34
|
+
* WCAG 2.2 2.2.1 wants a timing the user can extend, and a message that expires under the
|
|
35
|
+
* pointer reaching for its undo is the failure that rule is about.
|
|
36
|
+
*
|
|
37
|
+
* Two reasons, reported separately: a pointer leaving a toast a keyboard user is still inside
|
|
38
|
+
* must not restart the countdown, which one boolean cannot express.
|
|
39
|
+
*/
|
|
40
|
+
onHold?: ((reason: ToastHold) => void) | undefined;
|
|
41
|
+
onRelease?: ((reason: ToastHold) => void) | undefined;
|
|
28
42
|
class?: string | undefined;
|
|
29
43
|
}
|
|
30
44
|
|
|
31
45
|
export function ToastRegion(props: ToastRegionProps): JSX.Element {
|
|
32
46
|
// `aria-atomic="false"` on the list: only the toast that was just added is read, never the whole
|
|
33
47
|
// list again on every arrival.
|
|
48
|
+
//
|
|
49
|
+
// The four handlers sit on the <ol> and all four BUBBLE. `mouseenter`/`mouseleave` do not, and
|
|
50
|
+
// this list is `pointer-events: none` so it is not a hit target of its own — only the toasts
|
|
51
|
+
// inside it are. `mouseover`/`mouseout` reach here from them; `mouseenter` would fire on a box
|
|
52
|
+
// the pointer can never be over.
|
|
34
53
|
return (
|
|
35
54
|
<section
|
|
36
55
|
class={cx(
|
|
@@ -40,7 +59,20 @@ export function ToastRegion(props: ToastRegionProps): JSX.Element {
|
|
|
40
59
|
)}
|
|
41
60
|
aria-label={props.label}
|
|
42
61
|
>
|
|
43
|
-
|
|
62
|
+
{/* biome-ignore lint/a11y/useKeyWithMouseEvents: the rule wants `onFocus` beside
|
|
63
|
+
`onMouseOver`, and `onFocus` is the WRONG half of the pair here — `focus` does not
|
|
64
|
+
bubble, so a handler on this list would never hear a toast's dismiss button being
|
|
65
|
+
reached, which is the exact case the pause exists for. `onFocusIn`/`onFocusOut` are the
|
|
66
|
+
bubbling forms and are both present, so the keyboard path this rule protects is covered. */}
|
|
67
|
+
<ol
|
|
68
|
+
class={styles['list']}
|
|
69
|
+
aria-live={props.politeness ?? 'polite'}
|
|
70
|
+
aria-atomic="false"
|
|
71
|
+
onMouseOver={() => props.onHold?.('pointer')}
|
|
72
|
+
onMouseOut={() => props.onRelease?.('pointer')}
|
|
73
|
+
onFocusIn={() => props.onHold?.('focus')}
|
|
74
|
+
onFocusOut={() => props.onRelease?.('focus')}
|
|
75
|
+
>
|
|
44
76
|
{props.children}
|
|
45
77
|
</ol>
|
|
46
78
|
</section>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The one way to render a toast queue: the store's VISIBLE slice, inside the region that owns the
|
|
2
|
+
// live semantics. The half that was missing — `Toast` and `ToastRegion` shipped with no store, so
|
|
3
|
+
// every app wrote its own queue, its own dwell and its own pause rules.
|
|
4
|
+
//
|
|
5
|
+
// Toasts over the cap are not drawn and not counted on screen: they are still queued, and their
|
|
6
|
+
// dwell has not started. A "+3 more" badge would be a message about messages, in a corner the
|
|
7
|
+
// reader is already being asked to look away to.
|
|
8
|
+
|
|
9
|
+
import type { JSX } from 'solid-js';
|
|
10
|
+
import type { Politeness } from '../a11y';
|
|
11
|
+
import { type ToastItem, visibleToasts } from '../toast/toast-state';
|
|
12
|
+
import type { ToastStore } from '../toast/toast-store';
|
|
13
|
+
import { useToasts } from '../toast/use-toasts';
|
|
14
|
+
import { Button } from './Button';
|
|
15
|
+
import { Toast, type ToastPlacement, ToastRegion } from './Toast';
|
|
16
|
+
|
|
17
|
+
export interface ToasterProps {
|
|
18
|
+
/** The queue. One per app — `createToastStore()` in the island that mounts this. */
|
|
19
|
+
store: ToastStore;
|
|
20
|
+
/** Already-translated landmark name, e.g. "Notifications". */
|
|
21
|
+
label: string;
|
|
22
|
+
politeness?: Politeness | undefined;
|
|
23
|
+
placement?: ToastPlacement | undefined;
|
|
24
|
+
/** Already-translated; falls back to the `ui.dismiss` catalog key. */
|
|
25
|
+
dismissLabel?: string | undefined;
|
|
26
|
+
class?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Toaster(props: ToasterProps): JSX.Element {
|
|
30
|
+
const queue = useToasts(props.store);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One undo-shaped control, or nothing. A toast never carries the only affordance for an action:
|
|
34
|
+
* it disappears on a timer, and a keyboard user who was typing when it arrived never reached it.
|
|
35
|
+
* Taking the undo also dismisses — the offer has been answered.
|
|
36
|
+
*/
|
|
37
|
+
const actionSlot = (item: ToastItem): JSX.Element => {
|
|
38
|
+
const action = item.action;
|
|
39
|
+
if (action === undefined) return null;
|
|
40
|
+
return (
|
|
41
|
+
<Button
|
|
42
|
+
size="sm"
|
|
43
|
+
variant="ghost"
|
|
44
|
+
tone={item.tone}
|
|
45
|
+
onClick={() => {
|
|
46
|
+
action.onAction();
|
|
47
|
+
props.store.dismiss(item.id);
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{action.label}
|
|
51
|
+
</Button>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<ToastRegion
|
|
57
|
+
label={props.label}
|
|
58
|
+
politeness={props.politeness}
|
|
59
|
+
placement={props.placement}
|
|
60
|
+
class={props.class}
|
|
61
|
+
onHold={(reason) => props.store.hold(reason)}
|
|
62
|
+
onRelease={(reason) => props.store.release(reason)}
|
|
63
|
+
>
|
|
64
|
+
{visibleToasts(queue()).map((item) => (
|
|
65
|
+
<Toast
|
|
66
|
+
title={item.title}
|
|
67
|
+
tone={item.tone}
|
|
68
|
+
dismissLabel={props.dismissLabel}
|
|
69
|
+
action={actionSlot(item)}
|
|
70
|
+
onDismiss={() => props.store.dismiss(item.id)}
|
|
71
|
+
>
|
|
72
|
+
{item.message}
|
|
73
|
+
</Toast>
|
|
74
|
+
))}
|
|
75
|
+
</ToastRegion>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The ONE four-way decision every async region makes — pending, failed, empty, ready — written as
|
|
2
|
+
// a pure rule so "what does this look like while it loads" stops being a judgement an agent makes
|
|
3
|
+
// once per screen. Two properties are structural here rather than documented: `empty` is
|
|
4
|
+
// unreachable until a result has arrived, and a refetch keeps the previous data on screen.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a caller hands an async region. A STATE, never a query: `@ultimat3/ui` is tier 4 and may
|
|
8
|
+
* not import `query`, `action` or `realtime`, so a live-query accessor, a `createResource` and a
|
|
9
|
+
* plain signal all arrive here as the same four shapes.
|
|
10
|
+
*
|
|
11
|
+
* `refreshing` is the one that makes search feel fast — it CARRIES the previous data, so a refetch
|
|
12
|
+
* re-renders what is already on screen instead of tearing it down to a skeleton.
|
|
13
|
+
*/
|
|
14
|
+
export type AsyncState<T> =
|
|
15
|
+
| { readonly status: 'pending' }
|
|
16
|
+
| { readonly status: 'refreshing'; readonly data: T }
|
|
17
|
+
| { readonly status: 'ready'; readonly data: T }
|
|
18
|
+
| { readonly status: 'failed'; readonly error: unknown };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The branch a region renders. `empty` and `ready` carry `busy`; `pending` and `failed` do not,
|
|
22
|
+
* because `pending` IS busy and a failure is not in flight.
|
|
23
|
+
*
|
|
24
|
+
* There is no `{ kind: 'empty' }` reachable from `{ status: 'pending' }` — that is the whole
|
|
25
|
+
* point of this module. "No results" rendered for one frame before the first page arrives is the
|
|
26
|
+
* most common agent-authored UX bug in a list screen, and the union above makes it unconstructible
|
|
27
|
+
* rather than merely discouraged: `pending` holds no data, so nothing can be found empty in it.
|
|
28
|
+
*/
|
|
29
|
+
export type AsyncBranch<T> =
|
|
30
|
+
| { readonly kind: 'pending' }
|
|
31
|
+
| { readonly kind: 'failed'; readonly error: unknown }
|
|
32
|
+
| { readonly kind: 'empty'; readonly busy: boolean }
|
|
33
|
+
| { readonly kind: 'ready'; readonly data: T; readonly busy: boolean };
|
|
34
|
+
|
|
35
|
+
const PENDING: AsyncBranch<never> = Object.freeze({ kind: 'pending' });
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The default emptiness test: a list with no items, a map or set with no entries, or nothing at
|
|
39
|
+
* all. Anything else is data — a `{ total: 0, items: [] }` envelope is a shape only its author
|
|
40
|
+
* knows, so it passes its own `isEmpty` rather than being guessed at here.
|
|
41
|
+
*/
|
|
42
|
+
export function isEmptyData(data: unknown): boolean {
|
|
43
|
+
if (data === null || data === undefined) return true;
|
|
44
|
+
if (Array.isArray(data)) return data.length === 0;
|
|
45
|
+
if (data instanceof Map || data instanceof Set) return data.size === 0;
|
|
46
|
+
if (typeof data === 'string') return data === '';
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* State → branch. The only place the four-way decision is made, so `DataTable`, `AsyncRegion` and
|
|
52
|
+
* an app's own region cannot disagree about what "loading with stale rows" looks like.
|
|
53
|
+
*/
|
|
54
|
+
export function asyncBranch<T>(
|
|
55
|
+
state: AsyncState<T>,
|
|
56
|
+
isEmpty: (data: T) => boolean = isEmptyData,
|
|
57
|
+
): AsyncBranch<T> {
|
|
58
|
+
// Failure first: a query that failed while holding stale data must report the failure, never
|
|
59
|
+
// render the stale rows as if they were the answer to the request that just errored.
|
|
60
|
+
if (state.status === 'failed') return { kind: 'failed', error: state.error };
|
|
61
|
+
if (state.status === 'pending') return PENDING;
|
|
62
|
+
// `ready | refreshing`, and this annotation is the build error: a fifth status added to
|
|
63
|
+
// `AsyncState` fails to assign here instead of falling through to a region that renders nothing.
|
|
64
|
+
const settled: { readonly status: 'ready' | 'refreshing'; readonly data: T } = state;
|
|
65
|
+
const busy = settled.status === 'refreshing';
|
|
66
|
+
return isEmpty(settled.data)
|
|
67
|
+
? { kind: 'empty', busy }
|
|
68
|
+
: { kind: 'ready', data: settled.data, busy };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Whether the region is waiting on the network — `aria-busy`, in one derivation. */
|
|
72
|
+
export function isBusyBranch<T>(branch: AsyncBranch<T>): boolean {
|
|
73
|
+
return branch.kind === 'pending' || (branch.kind !== 'failed' && branch.busy);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The flag-shaped source a `createResource` (and most hand-rolled fetches) already are.
|
|
78
|
+
* `data: undefined` means NOTHING HAS ARRIVED; an empty array is data, and it is what makes the
|
|
79
|
+
* empty branch reachable. Solid's `latest` is exactly this: it holds the previous value across a
|
|
80
|
+
* refetch, which is why `loading` beside a defined `data` is `refreshing` and not `pending`.
|
|
81
|
+
*/
|
|
82
|
+
export interface AsyncFlags<T> {
|
|
83
|
+
readonly loading?: boolean | undefined;
|
|
84
|
+
readonly error?: unknown;
|
|
85
|
+
readonly data?: T | undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function asyncStateOf<T>(flags: AsyncFlags<T>): AsyncState<T> {
|
|
89
|
+
if (flags.error !== undefined && flags.error !== null)
|
|
90
|
+
return { status: 'failed', error: flags.error };
|
|
91
|
+
if (flags.data === undefined) return { status: 'pending' };
|
|
92
|
+
return flags.loading === true
|
|
93
|
+
? { status: 'refreshing', data: flags.data }
|
|
94
|
+
: { status: 'ready', data: flags.data };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How much box the pending branch holds, so the skeleton and the loaded content share one size. */
|
|
98
|
+
export interface ReserveBox {
|
|
99
|
+
/** Placeholder lines. Match what the loaded content renders, or the load is a layout shift. */
|
|
100
|
+
readonly lines: number;
|
|
101
|
+
/** CSS length of ONE line. Defaults to `1em`, the same default `Skeleton` uses. */
|
|
102
|
+
readonly height?: string | undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The reserved block size, as one `calc()`. Emitted as a custom property on the region in EVERY
|
|
107
|
+
* branch — that is the mechanism behind `Skeleton`'s own rule (a placeholder that changes size on
|
|
108
|
+
* load is just a slower layout shift): one value feeds the placeholder and the box the real
|
|
109
|
+
* content lands in, so the two cannot be written to disagree.
|
|
110
|
+
*/
|
|
111
|
+
export function reserveBlockSize(reserve: ReserveBox): string {
|
|
112
|
+
return `calc(${String(reserve.lines)} * ${reserve.height ?? '1em'})`;
|
|
113
|
+
}
|
|
@@ -103,6 +103,21 @@ export function boxFor(
|
|
|
103
103
|
return { width, height };
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* The `aspect-ratio` for a known box, as CSS writes it. `undefined` when the dimensions are
|
|
108
|
+
* unknown — there is no ratio to state, and `aspect-ratio: auto` is what a replaced element does
|
|
109
|
+
* anyway.
|
|
110
|
+
*
|
|
111
|
+
* Not a duplicate of the `width`/`height` attributes, which is what it looks like: those reserve
|
|
112
|
+
* the box only while the element's own layout is UNSTYLED, and a stylesheet that sets
|
|
113
|
+
* `inline-size: 100%` (this one sets `max-inline-size`, and an app's grid routinely sets the rest)
|
|
114
|
+
* drops the reserved height on the floor. `aspect-ratio` survives that, which is the class of
|
|
115
|
+
* layout shift left over after the attributes have done their part.
|
|
116
|
+
*/
|
|
117
|
+
export function ratioFor(box: ImageBox | undefined): string | undefined {
|
|
118
|
+
return box === undefined ? undefined : `${String(box.width)} / ${String(box.height)}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
106
121
|
/**
|
|
107
122
|
* A `src` with real content once whitespace is trimmed. Shared by the primary `src` prop and
|
|
108
123
|
* every variant `src`: an empty or blank one emits a broken `<img>` or a srcset entry the
|
package/src/errors.ts
CHANGED
|
@@ -9,6 +9,7 @@ export const UI_ERROR_CODES = {
|
|
|
9
9
|
runtimeMissing: 'X_UI_RUNTIME_MISSING',
|
|
10
10
|
invalidValue: 'X_UI_INVALID_VALUE',
|
|
11
11
|
formPathInvalid: 'X_UI_FORM_PATH_INVALID',
|
|
12
|
+
contrastInsufficient: 'X_UI_CONTRAST_INSUFFICIENT',
|
|
12
13
|
} as const;
|
|
13
14
|
|
|
14
15
|
export type UiErrorCode = (typeof UI_ERROR_CODES)[keyof typeof UI_ERROR_CODES];
|
|
@@ -22,6 +23,7 @@ registerErrorCodes({
|
|
|
22
23
|
X_UI_RUNTIME_MISSING: { title: 'a host capability @ultimat3/ui needs is absent' },
|
|
23
24
|
X_UI_INVALID_VALUE: { title: 'a formatting component received an unrenderable value' },
|
|
24
25
|
X_UI_FORM_PATH_INVALID: { title: 'a form control name is not a usable field path' },
|
|
26
|
+
X_UI_CONTRAST_INSUFFICIENT: { title: 'a brand palette pairing does not meet WCAG 2.2 AA' },
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
export class UiError extends UltimateError {
|
|
@@ -167,6 +169,30 @@ export function invalidFieldPathError(subject: string, name: string): UiError {
|
|
|
167
169
|
});
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/**
|
|
173
|
+
* A `defineTheme()` palette that renders text nobody can read. Refused rather than warned about:
|
|
174
|
+
* a warning in a build log is the "enforced, not documented" failure this repo names as axiom 3,
|
|
175
|
+
* and an inaccessible theme is a legal exposure (EN 301 549, ADA Title II, the EAA) that the app
|
|
176
|
+
* author will not discover until an audit. WCAG 2.2 AA, never APCA — APCA is not a standard.
|
|
177
|
+
*
|
|
178
|
+
* The cause names the measured ratio and the required one, so the fix is arithmetic rather than
|
|
179
|
+
* guesswork; the fix names the role to move, because a pairing is repaired from one side.
|
|
180
|
+
*/
|
|
181
|
+
export function insufficientContrastError(
|
|
182
|
+
theme: string,
|
|
183
|
+
what: string,
|
|
184
|
+
fg: string,
|
|
185
|
+
bg: string,
|
|
186
|
+
ratio: number,
|
|
187
|
+
minimum: number,
|
|
188
|
+
): UiError {
|
|
189
|
+
return new UiError({
|
|
190
|
+
code: UI_ERROR_CODES.contrastInsufficient,
|
|
191
|
+
cause: `defineTheme() ${theme} palette renders ${what}: "${fg}" on "${bg}" measures ${ratio.toFixed(2)}:1, and WCAG 2.2 AA requires ${String(minimum)}:1`,
|
|
192
|
+
fix: `darken or lighten the "${fg}" channels in defineTheme({ colors: { ${theme}: { '${fg}': '<R G B>' } } }) until contrastRatio() answers ${String(minimum)} or more against "${bg}" — @ultimat3/ui exports contrastRatio and roleContrast to measure a candidate before you ship it`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
170
196
|
/**
|
|
171
197
|
* Two controls whose names describe different shapes for one path (`user` beside `user.name`).
|
|
172
198
|
* Refused rather than resolved: either answer silently drops one control's value, and the value
|
package/src/fake-dom.ts
CHANGED
|
@@ -119,6 +119,11 @@ export class FakeElement extends Listeners {
|
|
|
119
119
|
return this.children.flatMap((child) => [child, ...child.descendants()]);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** First match or `null`, exactly as the real one answers — `[0]` on an empty list is not it. */
|
|
123
|
+
querySelector(selector: string): FakeElement | null {
|
|
124
|
+
return this.querySelectorAll(selector)[0] ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
122
127
|
querySelectorAll(selector: string): FakeElement[] {
|
|
123
128
|
const groups = selector.split(',').map((one) => one.trim());
|
|
124
129
|
return this.descendants().filter((el) => groups.some((one) => compoundMatches(el, one)));
|
package/src/form/field-path.ts
CHANGED
|
@@ -89,3 +89,15 @@ export function parseFieldPath(name: string): readonly FieldPathSegment[] | null
|
|
|
89
89
|
// `expectKey` still set means the name ended on a `.` or was empty — both are half a path.
|
|
90
90
|
return expectKey ? null : segments;
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The attribute selector that finds a control by its field path, or `null` for a name that is not
|
|
95
|
+
* one. The grammar above is the ALLOWLIST and the only thing between a caller's string and a
|
|
96
|
+
* selector: `parseFieldPath` accepts `[A-Za-z_$][A-Za-z0-9_$]*`, `.` and `[0]` and nothing else, so
|
|
97
|
+
* no name it returns a path for can carry a quote, a bracket it did not open, or a second selector.
|
|
98
|
+
* Refused rather than escaped, for the same reason the parser is total — a name no issue can name
|
|
99
|
+
* is a field whose errors land nowhere, and building a selector for it would hide that.
|
|
100
|
+
*/
|
|
101
|
+
export function fieldSelector(name: string): string | null {
|
|
102
|
+
return parseFieldPath(name) === null ? null : `[name="${name}"]`;
|
|
103
|
+
}
|