@ultimat3/ui 2.0.0 → 4.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.
@@ -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
- // whenever a scrollable element has no focusable children.
26
- <section class={cx(styles['scroller'], props.class)} tabindex="0" aria-label={props.caption}>
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'],
@@ -4,6 +4,7 @@
4
4
  import type { JSX } from 'solid-js';
5
5
  import { ariaBool, createRovingTabindex, useId } from '../a11y';
6
6
  import { cx } from '../cx';
7
+ import { TAB_SELECTOR, tabStopIndex } from '../roving';
7
8
  import { useUi } from '../theme/context';
8
9
  import styles from './Tabs.module.scss';
9
10
 
@@ -31,11 +32,20 @@ export function Tabs(props: TabsProps): JSX.Element {
31
32
  const base = useId('tabs');
32
33
  let list: HTMLDivElement | undefined;
33
34
 
35
+ // A disabled tab is excluded from BOTH answers — the list arrows walk and the one tab that
36
+ // carries the tab stop — because `focus()` on a disabled button silently does nothing, so a
37
+ // disabled tab left in the list pins the reducer on its index and hides every tab after it.
34
38
  const onKeyDown = createRovingTabindex(
35
- () =>
36
- list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>('[role="tab"]')),
39
+ () => (list === undefined ? [] : Array.from(list.querySelectorAll<HTMLElement>(TAB_SELECTOR))),
37
40
  { orientation: props.orientation ?? 'horizontal', dir: ui.dir },
38
41
  );
42
+ // The selected tab holds the tab stop; a selected tab that is disabled, or a `value` matching no
43
+ // tab at all, falls back to the first enabled one rather than leaving the tablist unreachable.
44
+ const tabStop = (): number =>
45
+ tabStopIndex(
46
+ props.items,
47
+ props.items.findIndex((item) => item.id === props.value),
48
+ );
39
49
 
40
50
  return (
41
51
  <div
@@ -55,7 +65,7 @@ export function Tabs(props: TabsProps): JSX.Element {
55
65
  aria-orientation={props.orientation ?? 'horizontal'}
56
66
  onKeyDown={onKeyDown}
57
67
  >
58
- {props.items.map((item) => (
68
+ {props.items.map((item, index) => (
59
69
  <button
60
70
  type="button"
61
71
  role="tab"
@@ -63,7 +73,7 @@ export function Tabs(props: TabsProps): JSX.Element {
63
73
  class={styles['tab']}
64
74
  aria-selected={ariaBool(props.value === item.id)}
65
75
  aria-controls={`${base}-panel-${item.id}`}
66
- tabindex={props.value === item.id ? 0 : -1}
76
+ tabindex={index === tabStop() ? 0 : -1}
67
77
  disabled={item.disabled === true}
68
78
  onClick={() => props.onChange(item.id)}
69
79
  >
@@ -1,8 +1,11 @@
1
- // Transient notification. ToastRegion is the single live region for the app;
2
- // individual Toasts are its children, so announcements are not duplicated and
3
- // the region exists before the first message (screen readers require that).
1
+ // Transient notification. ToastRegion is the single live region for the app; individual Toasts
2
+ // are its children, so announcements are not duplicated and the region exists before the first
3
+ // message. That ordering is the whole point: a live region created with its content already in it
4
+ // is not announced by most screen readers, which is why `aria-live` sits on the persistent <ol>
5
+ // here and NOT on the <li> each Toast renders.
4
6
 
5
7
  import type { JSX } from 'solid-js';
8
+ import type { Politeness } from '../a11y';
6
9
  import { cx } from '../cx';
7
10
  import { UI_KEYS } from '../i18n-keys';
8
11
  import { useUi } from '../theme/context';
@@ -14,11 +17,20 @@ export interface ToastRegionProps {
14
17
  children: JSX.Element;
15
18
  /** Already-translated landmark name, e.g. "Notifications". */
16
19
  label: string;
20
+ /**
21
+ * How the region announces. `polite` waits for a pause and is right for everything an app
22
+ * routinely confirms; `assertive` interrupts whatever the user is being read, so it belongs only
23
+ * to a region that carries errors alone. One region, one politeness — mixing tones inside one
24
+ * list cannot work, because the live semantics belong to the list, not to the message.
25
+ */
26
+ politeness?: Politeness | undefined;
17
27
  placement?: 'block-end-inline-end' | 'block-start-inline-end' | 'block-end-center' | undefined;
18
28
  class?: string | undefined;
19
29
  }
20
30
 
21
31
  export function ToastRegion(props: ToastRegionProps): JSX.Element {
32
+ // `aria-atomic="false"` on the list: only the toast that was just added is read, never the whole
33
+ // list again on every arrival.
22
34
  return (
23
35
  <section
24
36
  class={cx(
@@ -28,7 +40,9 @@ export function ToastRegion(props: ToastRegionProps): JSX.Element {
28
40
  )}
29
41
  aria-label={props.label}
30
42
  >
31
- <ol class={styles['list']}>{props.children}</ol>
43
+ <ol class={styles['list']} aria-live={props.politeness ?? 'polite'} aria-atomic="false">
44
+ {props.children}
45
+ </ol>
32
46
  </section>
33
47
  );
34
48
  }
@@ -47,14 +61,12 @@ export interface ToastProps {
47
61
  export function Toast(props: ToastProps): JSX.Element {
48
62
  const ui = useUi();
49
63
  const tone = (): Tone => props.tone ?? 'neutral';
50
- const assertive = (): boolean => tone() === 'danger';
51
64
 
65
+ // No `role` and no `aria-live`: the enclosing ToastRegion owns both. `role="status"` here would
66
+ // also strip the element's `listitem` semantics, so the <ol> around it would announce a list of
67
+ // nothing. A danger toast belongs in a ToastRegion declared `politeness="assertive"`.
52
68
  return (
53
- <li
54
- class={cx(styles['toast'], styles[`tone-${tone()}`], props.class)}
55
- role={assertive() ? 'alert' : 'status'}
56
- aria-live={assertive() ? 'assertive' : 'polite'}
57
- >
69
+ <li class={cx(styles['toast'], styles[`tone-${tone()}`], props.class)}>
58
70
  <div class={styles['content']}>
59
71
  {props.title === undefined ? null : <p class={styles['title']}>{props.title}</p>}
60
72
  <div class={styles['body']}>{props.children}</div>
@@ -1,6 +1,8 @@
1
1
  // The control strip above a table or a list: filters and search at the inline start, actions at
2
2
  // the inline end. `role="toolbar"` with the same roving-tabindex helper Tabs uses, so arrow keys
3
- // move between controls and the strip costs one Tab stop instead of a dozen.
3
+ // move between the strip's buttons. It is NOT one tab stop: the strip holds arbitrary children it
4
+ // cannot reach into to set an initial `tabindex`, and a search field at the inline start keeps its
5
+ // own arrow keys, so making the strip a single stop would strand every control past it.
4
6
 
5
7
  import type { JSX } from 'solid-js';
6
8
  import { createRovingTabindex, focusableWithin } from '../a11y';
@@ -24,7 +26,9 @@ export function Toolbar(props: ToolbarProps): JSX.Element {
24
26
  const ui = useUi();
25
27
  let strip: HTMLDivElement | undefined;
26
28
 
27
- // Arrows follow the writing direction; `focusableWithin` skips anything hidden or disabled.
29
+ // Arrows follow the writing direction; `focusableWithin` skips anything hidden or disabled, and
30
+ // `createRovingTabindex` declines outright while a control that answers arrows itself — the
31
+ // search Input this strip exists to hold — has focus.
28
32
  const onKeyDown = createRovingTabindex(
29
33
  () => (strip === undefined ? [] : focusableWithin(strip)),
30
34
  { orientation: 'horizontal', dir: ui.dir, loop: false },
@@ -100,3 +100,28 @@ export function formatBytes(bytes: number, locale: string): string {
100
100
  maximumFractionDigits: index === 0 ? 0 : 1,
101
101
  }).format(value);
102
102
  }
103
+
104
+ /** The one field of the `<input type="file">` a drop has to write. Structural, so a test needs no DOM. */
105
+ export interface FileTarget {
106
+ files: FileList | null;
107
+ }
108
+
109
+ /**
110
+ * Hand a drop's files to the real `<input>`, so a dropped file behaves exactly like a picked one.
111
+ *
112
+ * Without this a `<Dropzone name="avatar" required>` inside a `<form>` shows the accepted file and
113
+ * then refuses to submit: `onSelect` fired, but `input.files` is still empty, so `required` blocks
114
+ * on "Please select a file" and a submit that got past it would post no file at all. Native form
115
+ * participation is what the `name`/`required` props promise. `files` is assignable from a
116
+ * `DataTransfer`'s own `FileList` in every current engine — that is the supported way to do this.
117
+ */
118
+ export function adoptDroppedFiles(
119
+ input: FileTarget | undefined,
120
+ dropped: FileList | null | undefined,
121
+ ): void {
122
+ // An empty drop must not CLEAR a previous pick: the browser does not, and neither does this.
123
+ if (input === undefined || dropped === null || dropped === undefined || dropped.length === 0) {
124
+ return;
125
+ }
126
+ input.files = dropped;
127
+ }
package/src/errors.ts CHANGED
@@ -129,3 +129,18 @@ export function invalidValueError(kind: string, value: unknown, expected: string
129
129
  fix: `pass ${expected} — parse or validate the value in the loader, not in the component`,
130
130
  });
131
131
  }
132
+
133
+ /**
134
+ * The icon generator was handed upstream data it cannot turn into a glyph module. No new code:
135
+ * malformed data is exactly what X_UI_INVALID_VALUE already names, and a code is stable forever
136
+ * once shipped. It is NOT X_UI_RUNTIME_MISSING, which says a host capability is absent and sends
137
+ * an operator to audit their environment for a fault that is in the file they just downloaded.
138
+ * The generator's environment faults — no network, no biome binary — keep that code.
139
+ */
140
+ export function invalidIconDataError(found: string, fix: string): UiError {
141
+ return new UiError({
142
+ code: UI_ERROR_CODES.invalidValue,
143
+ cause: `lucide icon data ${found}`,
144
+ fix,
145
+ });
146
+ }
@@ -0,0 +1,194 @@
1
+ // TEST-ONLY. A DOM small enough to read and real enough to run this package's keyboard code
2
+ // against: focus that a disabled control silently refuses, `document.activeElement`, `contains`,
3
+ // and `querySelectorAll` for the selector grammar these helpers actually pass. Not exported from
4
+ // `index.ts` — the alternative is a DOM dependency, and the bugs it catches are the ones that only
5
+ // exist because nothing ever called `createRovingTabindex` with elements attached.
6
+
7
+ /** The subset of a selector these helpers use: comma groups of tag + `[attr]` + `:not(...)`. */
8
+ const PART = /\[[^\]]*\]|:not\([^)]*\)/g;
9
+
10
+ function attrMatches(element: FakeElement, test: string): boolean {
11
+ const inner = test.slice(1, -1);
12
+ const eq = inner.indexOf('=');
13
+ if (eq === -1) return element.getAttribute(inner) !== null;
14
+ const name = inner.slice(0, eq);
15
+ const value = inner.slice(eq + 1).replace(/^["']|["']$/g, '');
16
+ return element.getAttribute(name) === value;
17
+ }
18
+
19
+ function compoundMatches(element: FakeElement, compound: string): boolean {
20
+ const tag = compound.split(/[[:]/, 1)[0] ?? '';
21
+ if (tag !== '' && tag !== element.tagName.toLowerCase()) return false;
22
+ for (const part of compound.slice(tag.length).match(PART) ?? []) {
23
+ const ok = part.startsWith(':not(')
24
+ ? !attrMatches(element, part.slice(5, -1))
25
+ : attrMatches(element, part);
26
+ if (!ok) return false;
27
+ }
28
+ return true;
29
+ }
30
+
31
+ /** Listener book shared by the element and the document: registration only, and NO bubbling — a
32
+ * listener on a node never sees an event dispatched somewhere else, which is the whole point of
33
+ * the question `createFocusTrap` gets wrong. */
34
+ class Listeners {
35
+ readonly listeners = new Map<string, Set<(event: unknown) => void>>();
36
+
37
+ addEventListener(type: string, handler: (event: unknown) => void): void {
38
+ const set = this.listeners.get(type) ?? new Set();
39
+ set.add(handler);
40
+ this.listeners.set(type, set);
41
+ }
42
+
43
+ removeEventListener(type: string, handler: (event: unknown) => void): void {
44
+ this.listeners.get(type)?.delete(handler);
45
+ }
46
+
47
+ dispatch(type: string, event: unknown): void {
48
+ for (const handler of [...(this.listeners.get(type) ?? [])]) handler(event);
49
+ }
50
+ }
51
+
52
+ const NATIVELY_FOCUSABLE = new Set(['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY']);
53
+ const LINK_TAGS = new Set(['A', 'AREA']);
54
+
55
+ /** An element that behaves like the real thing in the two ways this package's bugs depend on. */
56
+ export class FakeElement extends Listeners {
57
+ readonly tagName: string;
58
+ readonly children: FakeElement[] = [];
59
+ parent: FakeElement | null = null;
60
+ readonly attrs: Record<string, string>;
61
+ /** Test assertion surface: how many times focus actually landed here. */
62
+ focusCount = 0;
63
+ owner: FakeDocument | null = null;
64
+
65
+ constructor(tagName: string, attrs: Record<string, string> = {}) {
66
+ super();
67
+ this.tagName = tagName.toUpperCase();
68
+ this.attrs = { ...attrs };
69
+ }
70
+
71
+ get tabIndex(): number {
72
+ return Number.parseInt(this.attrs['tabindex'] ?? '-1', 10);
73
+ }
74
+
75
+ set tabIndex(value: number) {
76
+ this.attrs['tabindex'] = String(value);
77
+ }
78
+
79
+ getAttribute(name: string): string | null {
80
+ return this.attrs[name] ?? null;
81
+ }
82
+
83
+ append(...children: readonly FakeElement[]): FakeElement {
84
+ for (const child of children) {
85
+ child.parent = this;
86
+ this.children.push(child);
87
+ }
88
+ return this;
89
+ }
90
+
91
+ /** The bug's mechanism, faithfully: focusing a disabled control does NOTHING and reports nothing.
92
+ * A tag that is not natively focusable and carries no `tabindex` refuses in exactly the same
93
+ * silence — `<div>.focus()` is the no-op behind the focus trap's empty-panel fallback. */
94
+ focus(): void {
95
+ if (this.getAttribute('disabled') !== null) return;
96
+ // `tabindex="-1"` IS focusable programmatically; it is only out of the TAB order.
97
+ const declared = this.getAttribute('tabindex') !== null;
98
+ if (!declared && !this.nativelyFocusable()) return;
99
+ this.focusCount += 1;
100
+ const doc = this.document();
101
+ if (doc !== null) doc.activeElement = this;
102
+ }
103
+
104
+ contains(node: unknown): boolean {
105
+ for (let at = node as FakeElement | null; at !== null; at = at.parent) {
106
+ if (at === this) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ descendants(): FakeElement[] {
112
+ return this.children.flatMap((child) => [child, ...child.descendants()]);
113
+ }
114
+
115
+ querySelectorAll(selector: string): FakeElement[] {
116
+ const groups = selector.split(',').map((one) => one.trim());
117
+ return this.descendants().filter((el) => groups.some((one) => compoundMatches(el, one)));
118
+ }
119
+
120
+ // `focusableWithin` filters on visibility; nothing in a fake tree is laid out, so everything is
121
+ // visible — a hidden-element rule would be this harness inventing a fact, not testing one.
122
+ readonly offsetParent: unknown = null;
123
+
124
+ getClientRects(): readonly unknown[] {
125
+ return [{}];
126
+ }
127
+
128
+ /** Focusable with no `tabindex` at all. `A`/`AREA` only with an `href` — the same condition
129
+ * `FOCUSABLE_SELECTOR` spells `a[href]`. */
130
+ private nativelyFocusable(): boolean {
131
+ if (NATIVELY_FOCUSABLE.has(this.tagName)) return true;
132
+ return LINK_TAGS.has(this.tagName) && this.getAttribute('href') !== null;
133
+ }
134
+
135
+ private document(): FakeDocument | null {
136
+ for (let at: FakeElement | null = this; at !== null; at = at.parent) {
137
+ if (at.owner !== null) return at.owner;
138
+ }
139
+ return null;
140
+ }
141
+ }
142
+
143
+ export interface FakeKeyboardEvent {
144
+ readonly key: string;
145
+ readonly shiftKey: boolean;
146
+ defaultPrevented: boolean;
147
+ preventDefault(): void;
148
+ }
149
+
150
+ export function keydown(key: string, shiftKey = false): FakeKeyboardEvent {
151
+ return {
152
+ key,
153
+ shiftKey,
154
+ defaultPrevented: false,
155
+ preventDefault(): void {
156
+ this.defaultPrevented = true;
157
+ },
158
+ };
159
+ }
160
+
161
+ export class FakeDocument extends Listeners {
162
+ activeElement: FakeElement | null = null;
163
+ }
164
+
165
+ export interface InstalledDom {
166
+ readonly document: FakeDocument;
167
+ readonly restore: () => void;
168
+ }
169
+
170
+ /**
171
+ * Publish `document` and `HTMLElement` globally for the duration of one test. Both are needed:
172
+ * `createRovingTabindex` reads `document.activeElement` and narrows it with `instanceof
173
+ * HTMLElement`, so a fake that is not the global class is silently treated as "focus is nowhere".
174
+ * ALWAYS restored — `solid()` decides a render is a server render by `document` being absent, and
175
+ * a leaked one turns every later component test in the process into `X_UI_RUNTIME_MISSING`.
176
+ */
177
+ export function installFakeDom(root: FakeElement): InstalledDom {
178
+ const document = new FakeDocument();
179
+ root.owner = document;
180
+ const hadDocument = 'document' in globalThis;
181
+ const hadElement = 'HTMLElement' in globalThis;
182
+ const previousDocument: unknown = Reflect.get(globalThis, 'document');
183
+ const previousElement: unknown = Reflect.get(globalThis, 'HTMLElement');
184
+ Object.assign(globalThis, { document, HTMLElement: FakeElement });
185
+ return {
186
+ document,
187
+ restore(): void {
188
+ if (hadDocument) Object.assign(globalThis, { document: previousDocument });
189
+ else Reflect.deleteProperty(globalThis, 'document');
190
+ if (hadElement) Object.assign(globalThis, { HTMLElement: previousElement });
191
+ else Reflect.deleteProperty(globalThis, 'HTMLElement');
192
+ },
193
+ };
194
+ }
@@ -3,9 +3,10 @@
3
3
  // icons, so an upstream fix is a version bump plus a re-run and never a hand edit.
4
4
 
5
5
  import { rm } from 'node:fs/promises';
6
+ import { renderCauseValue } from '@ultimat3/core';
6
7
  import type { IconGlyph } from '../components/icon-glyph';
7
8
  import { iconElements } from '../components/icon-glyph';
8
- import { runtimeMissingError } from '../errors';
9
+ import { invalidIconDataError, runtimeMissingError } from '../errors';
9
10
 
10
11
  /** Pinned: a floating version would silently redraw icons under an app that never asked. */
11
12
  export const LUCIDE_VERSION = '1.31.0';
@@ -29,18 +30,42 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
29
30
  const parsed: unknown = JSON.parse(text);
30
31
  // `typeof [] === 'object'`, and an array of icons is a different upstream format, not this one.
31
32
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
32
- throw runtimeMissingError(
33
- 'lucide icon-nodes.json as an object',
34
- `check ${LUCIDE_ICON_NODES_URL} is reachable, then re-run: bun run --filter @ultimat3/ui icons`,
33
+ throw invalidIconDataError(
34
+ `parsed as ${renderCauseValue(parsed)}, not the object of name to nodes that icon-nodes.json publishes; ${LUCIDE_ICON_NODES_URL} served something else`,
35
+ 'bun run --filter @ultimat3/ui icons',
35
36
  );
36
37
  }
37
38
  const out = new Map<string, IconGlyph>();
38
39
  for (const [name, value] of Object.entries(parsed as Record<string, unknown>)) {
40
+ // Checked BEFORE the map holds it: the key reaches three sinks downstream — a filesystem path,
41
+ // a TypeScript identifier and a `//` banner — and `buildIcons` clears GLYPHS_DIR before it
42
+ // writes, so `../../index` is a delete plus an overwrite of a hand-written module.
43
+ if (!SAFE_ICON_NAME.test(name)) {
44
+ throw invalidIconDataError(
45
+ `carries ${renderCauseValue(name)} as an icon name, which is not the kebab-case shape every Lucide icon uses; the name becomes a file path and an exported identifier, so it cannot be escaped. lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
46
+ 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
47
+ );
48
+ }
39
49
  out.set(name, toGlyph(name, value));
40
50
  }
41
51
  return out;
42
52
  }
43
53
 
54
+ /**
55
+ * An icon name, as characters: lowercase kebab-case, no leading, trailing or doubled hyphen. The
56
+ * same allowlist-over-a-sink shape as `SAFE_ATTR_VALUE`, one layer up — the name is interpolated
57
+ * into a PATH and an IDENTIFIER, neither of which has an escape, so refusing is the only move.
58
+ * Measured against the whole committed set: all 1767 names pass (`build-icons.test.ts` asserts it).
59
+ */
60
+ export const SAFE_ICON_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
61
+
62
+ /**
63
+ * Glyph geometry, as characters: digits, the path-command letters, `currentColor`/`none`, and the
64
+ * separators between them. Deliberately not "anything that is not a quote" — an allowlist over a
65
+ * value bound for a code sink is the only shape that stays correct when the sink changes.
66
+ */
67
+ export const SAFE_ATTR_VALUE = /^[\w\s.,+-]*$/;
68
+
44
69
  function toGlyph(name: string, value: unknown): IconGlyph {
45
70
  const glyph: (readonly [string, Readonly<Record<string, string>>])[] = [];
46
71
  for (const node of Array.isArray(value) ? (value as unknown[]) : []) {
@@ -48,7 +73,20 @@ function toGlyph(name: string, value: unknown): IconGlyph {
48
73
  const attrs: Record<string, string> = {};
49
74
  for (const [key, item] of Object.entries((node[1] ?? {}) as Record<string, unknown>)) {
50
75
  // Upstream keys an element for its diff tooling; it is not an SVG attribute.
51
- if (key !== 'key') attrs[key] = String(item);
76
+ if (key === 'key') continue;
77
+ const text = String(item);
78
+ // Defence in depth behind `moduleSource`'s escaping: geometry is digits, path commands and
79
+ // separators, so anything else in a value that is about to be written into a TYPESCRIPT
80
+ // module is refused here rather than escaped and shipped. Measured against the whole
81
+ // committed set — all 1767 glyphs pass (`build-icons.test.ts` asserts it), so this rejects
82
+ // no legitimate Lucide artwork.
83
+ if (!SAFE_ATTR_VALUE.test(text)) {
84
+ throw invalidIconDataError(
85
+ `for the icon "${name}" carries ${renderCauseValue(text)} as "${key}", which is not glyph geometry; lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
86
+ 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
87
+ );
88
+ }
89
+ attrs[key] = text;
52
90
  }
53
91
  glyph.push([node[0], attrs]);
54
92
  }
@@ -56,17 +94,25 @@ function toGlyph(name: string, value: unknown): IconGlyph {
56
94
  // would throw in a page is a build failure here instead.
57
95
  iconElements(glyph);
58
96
  if (glyph.length === 0) {
59
- throw runtimeMissingError(
60
- `renderable node data for the lucide icon "${name}"`,
61
- `bump LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts, then: bun run --filter @ultimat3/ui icons`,
97
+ throw invalidIconDataError(
98
+ `for the icon "${name}" carries no renderable node data; lucide-static@${LUCIDE_VERSION} is the pin that published it, so a re-run against the same pin repeats this`,
99
+ 'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
62
100
  );
63
101
  }
64
102
  return glyph;
65
103
  }
66
104
 
105
+ /**
106
+ * `JSON.stringify`, never `'${value}'`. The value is network-fetched data on its way into a
107
+ * TypeScript module that every app importing that icon will EXECUTE at import — a code sink, not
108
+ * an attribute sink. A raw quote in the value ends the string literal, and `');` after it starts
109
+ * a statement. `JSON.stringify` escapes quotes, backslashes and control characters, and Biome
110
+ * rewrites the quote style afterwards (`format()` below), so the committed output is unchanged.
111
+ * The key needs no escaping: `iconElements` has already refused every name off the allowlist.
112
+ */
67
113
  const attrsSource = (attrs: Readonly<Record<string, string>>): string =>
68
114
  Object.entries(attrs)
69
- .map(([key, value]) => `${/^[a-z]+$/.test(key) ? key : `'${key}'`}: '${value}'`)
115
+ .map(([key, value]) => `${/^[a-z]+$/.test(key) ? key : `'${key}'`}: ${JSON.stringify(value)}`)
70
116
  .join(', ');
71
117
 
72
118
  /** One module's full text. Biome reformats it afterwards, so the layout here is only a seed. */
package/src/index.ts CHANGED
@@ -83,9 +83,11 @@ export type {
83
83
  FileRejectionReason,
84
84
  FileSelection,
85
85
  FileSelectionLimits,
86
+ FileTarget,
86
87
  } from './components/file-input-view';
87
88
  export {
88
89
  acceptMatches,
90
+ adoptDroppedFiles,
89
91
  formatBytes,
90
92
  progressPercent,
91
93
  selectFiles,
@@ -178,6 +180,7 @@ export type { UiErrorCode } from './errors';
178
180
  export {
179
181
  invalidBrandTokenError,
180
182
  invalidGlyphError,
183
+ invalidIconDataError,
181
184
  invalidThemeError,
182
185
  invalidValueError,
183
186
  providerNeedsRuntimeError,
@@ -188,6 +191,13 @@ export {
188
191
  } from './errors';
189
192
  export type { UiKey } from './i18n-keys';
190
193
  export { UI_KEYS } from './i18n-keys';
194
+ export type { ArrowKeyElement, RovingItem } from './roving';
195
+ export {
196
+ handlesOwnArrowKeys,
197
+ MENU_ITEM_SELECTOR,
198
+ TAB_SELECTOR,
199
+ tabStopIndex,
200
+ } from './roving';
191
201
  export type { Brand, BrandInput, FontSlot } from './theme/brand';
192
202
  export { brandStyleCspSource, brandStyleTag, defineTheme, FONT_SLOTS } from './theme/brand';
193
203
  export type { Direction, UiContextValue } from './theme/context';
@@ -0,0 +1,125 @@
1
+ // TEST-ONLY. Calls a component and walks what it returned, so a test can assert on the props an
2
+ // element actually carries — the `tabindex`, the `aria-live`, the `onKeyDown`, the `ref` — rather
3
+ // than on a string it was serialised into. Complements `theme/inert-render.test.ts`, which asks
4
+ // the other question (does the markup come out at all).
5
+ //
6
+ // Which JSX factory a `.tsx` in this package compiled to is NOT ours to choose: `@ultimat3/render`
7
+ // installs a process-global `Bun.plugin` `onLoad` for `/\.tsx$/` at import, and `bun test` is one
8
+ // process. Both factories build the same shape — a `type`, a `props`, children under
9
+ // `props.children` — so this walker recognises both and the run's file order stops deciding.
10
+
11
+ const RENDER_NODE: symbol = Symbol.for('ultimate.render.jsx');
12
+
13
+ export interface ProbeNode {
14
+ readonly type: string | ((props: Record<string, unknown>) => unknown);
15
+ readonly props: Record<string, unknown>;
16
+ }
17
+
18
+ export function isProbeNode(value: unknown): value is ProbeNode {
19
+ if (typeof value !== 'object' || value === null) return false;
20
+ return 'inert' in value || RENDER_NODE in value;
21
+ }
22
+
23
+ function h(
24
+ type: ProbeNode['type'],
25
+ props: Record<string, unknown> | null,
26
+ ...children: readonly unknown[]
27
+ ): ProbeNode {
28
+ const base = { ...(props ?? {}) };
29
+ if (children.length > 0) base['children'] = children.length === 1 ? children[0] : children;
30
+ return { inert: true, type, props: base } as unknown as ProbeNode;
31
+ }
32
+
33
+ /**
34
+ * How many probes are live, and what `globalThis.React` was before the first one. A harness that
35
+ * DELETED the property on the way out destroyed a binding it did not create, and a nested or
36
+ * repeated probe tore the factory out from under the suite still using it — the counter is what
37
+ * makes the last unprobe the only one that restores.
38
+ */
39
+ let depth = 0;
40
+ let saved: PropertyDescriptor | undefined;
41
+
42
+ /** Install the classic-factory global these `.tsx` files fall back to. Paired with `unprobe()`. */
43
+ export function probe(): void {
44
+ if (depth === 0) {
45
+ // The descriptor, not the value: the property may be a getter or non-writable, and `assign`
46
+ // onto either throws or silently loses. `defineProperty` installs over both.
47
+ saved = Object.getOwnPropertyDescriptor(globalThis, 'React');
48
+ Object.defineProperty(globalThis, 'React', {
49
+ value: { createElement: h },
50
+ configurable: true,
51
+ writable: true,
52
+ enumerable: true,
53
+ });
54
+ }
55
+ depth += 1;
56
+ }
57
+
58
+ /** Undo the matching `probe()`. Unbalanced calls are inert: nothing this did not install is torn
59
+ * down, because the binding at depth 0 belongs to somebody else. */
60
+ export function unprobe(): void {
61
+ if (depth === 0) return;
62
+ depth -= 1;
63
+ if (depth > 0) return;
64
+ if (saved === undefined) Reflect.deleteProperty(globalThis, 'React');
65
+ else Object.defineProperty(globalThis, 'React', saved);
66
+ saved = undefined;
67
+ }
68
+
69
+ /**
70
+ * Every host node in the tree, depth first, with nested components CALLED. Thunks are called too:
71
+ * a component that reads a prop inside one renders nothing until something asks.
72
+ */
73
+ export function nodesOf(value: unknown): ProbeNode[] {
74
+ if (value === null || value === undefined || typeof value === 'boolean') return [];
75
+ if (typeof value === 'string' || typeof value === 'number') return [];
76
+ if (Array.isArray(value)) return value.flatMap(nodesOf);
77
+ if (isProbeNode(value)) {
78
+ if (typeof value.type === 'function') return nodesOf(value.type(value.props));
79
+ return [value, ...nodesOf(value.props['children'])];
80
+ }
81
+ if (typeof value === 'function') return nodesOf((value as () => unknown)());
82
+ return [];
83
+ }
84
+
85
+ /** Render a component to its host nodes. `props` is the component's own, untyped on purpose. */
86
+ export function renderNodes(component: unknown, props: Record<string, unknown> = {}): ProbeNode[] {
87
+ return nodesOf((component as (p: Record<string, unknown>) => unknown)(props));
88
+ }
89
+
90
+ const attr = (node: ProbeNode, name: string): unknown => node.props[name];
91
+
92
+ /** Nodes whose attribute equals `value`; `undefined` matches "carries the attribute at all". */
93
+ export function withAttr(nodes: readonly ProbeNode[], name: string, value?: unknown): ProbeNode[] {
94
+ return nodes.filter((node) =>
95
+ value === undefined ? attr(node, name) !== undefined : attr(node, name) === value,
96
+ );
97
+ }
98
+
99
+ export function byTag(nodes: readonly ProbeNode[], tag: string): ProbeNode[] {
100
+ return nodes.filter((node) => node.type === tag);
101
+ }
102
+
103
+ /** The one node a test means, or a throw naming what it looked for — never a silent `undefined`. */
104
+ export function one(nodes: readonly ProbeNode[], what: string): ProbeNode {
105
+ const node = nodes[0];
106
+ if (node === undefined || nodes.length !== 1) {
107
+ throw new Error(`expected exactly one ${what}, found ${nodes.length}`);
108
+ }
109
+ return node;
110
+ }
111
+
112
+ /** Call an element's `ref` prop with the element a test built for it. */
113
+ export function attachRef(node: ProbeNode, element: unknown): void {
114
+ const ref = node.props['ref'];
115
+ if (typeof ref !== 'function') throw new Error(`node <${String(node.type)}> carries no ref`);
116
+ (ref as (el: unknown) => void)(element);
117
+ }
118
+
119
+ /** Call an element's event handler prop, e.g. `fire(menu, 'onKeyDown', keydown('ArrowDown'))`. */
120
+ export function fire(node: ProbeNode, handler: string, event: unknown): void {
121
+ const fn = node.props[handler];
122
+ if (typeof fn !== 'function')
123
+ throw new Error(`node <${String(node.type)}> carries no ${handler}`);
124
+ (fn as (e: unknown) => void)(event);
125
+ }