@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.
@@ -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
- const body = (): JSX.Element => {
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
- if (props.error !== undefined && props.error !== null) {
93
- return <ErrorState error={props.error} onRetry={props.onRetry} class={props.class} />;
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
- if (props.loading !== true && props.rows.length === 0) {
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 aria-busy={ariaBool(props.loading === true)}>{body()}</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;
@@ -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
- onSubmit={props.onSubmit}
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');
@@ -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 {
@@ -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?: 'block-end-inline-end' | 'block-start-inline-end' | 'block-end-center' | undefined;
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
- <ol class={styles['list']} aria-live={props.politeness ?? 'polite'} aria-atomic="false">
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)));
@@ -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
+ }