@ultimat3/ui 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CATALOG.md +8 -7
- package/CLAUDE.md +9 -0
- package/README.md +22 -2
- package/package.json +5 -5
- package/src/a11y.ts +40 -9
- package/src/components/Checkbox.tsx +7 -2
- package/src/components/Dialog.tsx +4 -3
- package/src/components/Dropzone.tsx +10 -2
- package/src/components/Field.tsx +1 -7
- package/src/components/Form.tsx +22 -3
- package/src/components/Menu.tsx +13 -3
- package/src/components/Pagination.tsx +8 -1
- package/src/components/Popover.tsx +11 -1
- package/src/components/Select.tsx +7 -4
- package/src/components/Switch.tsx +11 -4
- package/src/components/Table.tsx +5 -3
- package/src/components/Tabs.tsx +14 -4
- package/src/components/Toast.tsx +22 -10
- package/src/components/Toolbar.tsx +6 -2
- package/src/components/file-input-view.ts +25 -0
- package/src/errors.ts +15 -0
- package/src/fake-dom.ts +194 -0
- package/src/icons/build-icons.ts +38 -9
- package/src/index.ts +10 -0
- package/src/jsx-probe.ts +125 -0
- package/src/roving.ts +65 -0
- package/src/tokens/reset.scss +7 -0
package/src/components/Tabs.tsx
CHANGED
|
@@ -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={
|
|
76
|
+
tabindex={index === tabStop() ? 0 : -1}
|
|
67
77
|
disabled={item.disabled === true}
|
|
68
78
|
onClick={() => props.onChange(item.id)}
|
|
69
79
|
>
|
package/src/components/Toast.tsx
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
// Transient notification. ToastRegion is the single live region for the app;
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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']}
|
|
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
|
|
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
|
+
}
|
package/src/fake-dom.ts
ADDED
|
@@ -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
|
+
}
|
package/src/icons/build-icons.ts
CHANGED
|
@@ -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,9 +30,9 @@ 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
|
|
33
|
-
|
|
34
|
-
|
|
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>();
|
|
@@ -41,6 +42,13 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
|
41
42
|
return out;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Glyph geometry, as characters: digits, the path-command letters, `currentColor`/`none`, and the
|
|
47
|
+
* separators between them. Deliberately not "anything that is not a quote" — an allowlist over a
|
|
48
|
+
* value bound for a code sink is the only shape that stays correct when the sink changes.
|
|
49
|
+
*/
|
|
50
|
+
export const SAFE_ATTR_VALUE = /^[\w\s.,+-]*$/;
|
|
51
|
+
|
|
44
52
|
function toGlyph(name: string, value: unknown): IconGlyph {
|
|
45
53
|
const glyph: (readonly [string, Readonly<Record<string, string>>])[] = [];
|
|
46
54
|
for (const node of Array.isArray(value) ? (value as unknown[]) : []) {
|
|
@@ -48,7 +56,20 @@ function toGlyph(name: string, value: unknown): IconGlyph {
|
|
|
48
56
|
const attrs: Record<string, string> = {};
|
|
49
57
|
for (const [key, item] of Object.entries((node[1] ?? {}) as Record<string, unknown>)) {
|
|
50
58
|
// Upstream keys an element for its diff tooling; it is not an SVG attribute.
|
|
51
|
-
if (key
|
|
59
|
+
if (key === 'key') continue;
|
|
60
|
+
const text = String(item);
|
|
61
|
+
// Defence in depth behind `moduleSource`'s escaping: geometry is digits, path commands and
|
|
62
|
+
// separators, so anything else in a value that is about to be written into a TYPESCRIPT
|
|
63
|
+
// module is refused here rather than escaped and shipped. Measured against the whole
|
|
64
|
+
// committed set — all 1767 glyphs pass (`build-icons.test.ts` asserts it), so this rejects
|
|
65
|
+
// no legitimate Lucide artwork.
|
|
66
|
+
if (!SAFE_ATTR_VALUE.test(text)) {
|
|
67
|
+
throw invalidIconDataError(
|
|
68
|
+
`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`,
|
|
69
|
+
'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
attrs[key] = text;
|
|
52
73
|
}
|
|
53
74
|
glyph.push([node[0], attrs]);
|
|
54
75
|
}
|
|
@@ -56,17 +77,25 @@ function toGlyph(name: string, value: unknown): IconGlyph {
|
|
|
56
77
|
// would throw in a page is a build failure here instead.
|
|
57
78
|
iconElements(glyph);
|
|
58
79
|
if (glyph.length === 0) {
|
|
59
|
-
throw
|
|
60
|
-
`
|
|
61
|
-
|
|
80
|
+
throw invalidIconDataError(
|
|
81
|
+
`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`,
|
|
82
|
+
'bun run --filter @ultimat3/ui icons # after raising LUCIDE_VERSION in packages/ui/src/icons/build-icons.ts',
|
|
62
83
|
);
|
|
63
84
|
}
|
|
64
85
|
return glyph;
|
|
65
86
|
}
|
|
66
87
|
|
|
88
|
+
/**
|
|
89
|
+
* `JSON.stringify`, never `'${value}'`. The value is network-fetched data on its way into a
|
|
90
|
+
* TypeScript module that every app importing that icon will EXECUTE at import — a code sink, not
|
|
91
|
+
* an attribute sink. A raw quote in the value ends the string literal, and `');` after it starts
|
|
92
|
+
* a statement. `JSON.stringify` escapes quotes, backslashes and control characters, and Biome
|
|
93
|
+
* rewrites the quote style afterwards (`format()` below), so the committed output is unchanged.
|
|
94
|
+
* The key needs no escaping: `iconElements` has already refused every name off the allowlist.
|
|
95
|
+
*/
|
|
67
96
|
const attrsSource = (attrs: Readonly<Record<string, string>>): string =>
|
|
68
97
|
Object.entries(attrs)
|
|
69
|
-
.map(([key, value]) => `${/^[a-z]+$/.test(key) ? key : `'${key}'`}:
|
|
98
|
+
.map(([key, value]) => `${/^[a-z]+$/.test(key) ? key : `'${key}'`}: ${JSON.stringify(value)}`)
|
|
70
99
|
.join(', ');
|
|
71
100
|
|
|
72
101
|
/** 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';
|
package/src/jsx-probe.ts
ADDED
|
@@ -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
|
+
}
|