@ultimat3/ui 24.0.0 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CATALOG.md +117 -13
- package/CLAUDE.md +14 -5
- package/README.md +109 -24
- package/package.json +6 -6
- package/src/a11y.ts +3 -3
- package/src/catalog/build-catalog.ts +2 -2
- package/src/components/Accordion.tsx +8 -2
- package/src/components/AppShell.module.scss +105 -4
- package/src/components/AppShell.tsx +48 -10
- package/src/components/BarChart.module.scss +47 -9
- package/src/components/BarChart.tsx +152 -72
- package/src/components/Card.tsx +52 -16
- package/src/components/ChartFrame.module.scss +188 -0
- package/src/components/ChartFrame.tsx +155 -0
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +124 -17
- package/src/components/Divider.tsx +0 -2
- package/src/components/DonutChart.module.scss +73 -0
- package/src/components/DonutChart.tsx +154 -0
- package/src/components/Dropzone.tsx +9 -1
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/Form.tsx +17 -4
- package/src/components/Gauge.module.scss +48 -0
- package/src/components/Gauge.tsx +67 -0
- package/src/components/Grid.tsx +34 -13
- package/src/components/Image.tsx +2 -2
- package/src/components/InlineBar.module.scss +23 -0
- package/src/components/InlineBar.tsx +31 -0
- package/src/components/LineChart.module.scss +147 -0
- package/src/components/LineChart.tsx +169 -0
- package/src/components/Menu.tsx +9 -5
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.tsx +2 -2
- package/src/components/Section.tsx +45 -26
- package/src/components/Stack.tsx +59 -18
- package/src/components/Table.module.scss +30 -0
- package/src/components/Table.tsx +14 -3
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +5 -4
- package/src/components/chart-frame-view.ts +219 -0
- package/src/components/chart-ticks-view.ts +173 -0
- package/src/components/data-table-view.ts +68 -0
- package/src/components/donut-chart-view.ts +120 -0
- package/src/components/file-input-view.ts +1 -1
- package/src/components/gauge-view.ts +43 -0
- package/src/components/heading-node.tsx +62 -0
- package/src/components/image-source.ts +3 -3
- package/src/components/line-chart-view.ts +153 -0
- package/src/components/money-view.ts +2 -7
- package/src/form/form-binding.ts +23 -6
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +15 -6
- package/src/index.ts +45 -9
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +98 -109
- package/src/theme/context.ts +1 -1
- package/src/theme/preset-scifi.ts +99 -0
- package/src/theme/preset-shape.ts +18 -0
- package/src/theme/presets.ts +15 -0
- package/src/theme/shadow-value.ts +39 -0
- package/src/theme/theme.ts +1 -1
- package/src/toast/toast-store.ts +1 -1
- package/src/toast/use-toasts.ts +1 -1
- package/src/tokens/_colors.scss +20 -0
- package/src/tokens/_effects.scss +59 -0
- package/src/tokens/_index.scss +2 -0
- package/src/tokens/_mixins.scss +6 -4
- package/src/tokens/_shadow.scss +16 -0
- package/src/tokens/_touch.scss +39 -0
- package/src/tokens/_typography.scss +14 -0
- package/src/tokens/color-tokens.ts +133 -0
- package/src/tokens/colour-vision.ts +106 -0
- package/src/tokens/contrast-pairs.ts +6 -1
- package/src/tokens/contrast.ts +1 -1
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +41 -87
- package/src/components/qr-reader.ts +0 -187
- package/src/fake-dom.ts +0 -206
- package/src/sass-probe.ts +0 -55
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
// TEST-ONLY: an independent QR reader for versions 1-3, byte mode, level M — written from ISO
|
|
2
|
-
// 18004's layout, sharing no code with `qr-encode.ts`/`qr-matrix.ts`, so a placement defect in
|
|
3
|
-
// the encoder cannot be mirrored here. Never exported from `index.ts`.
|
|
4
|
-
|
|
5
|
-
/** The eight published level-M format strings (ISO 18004 Table C.1), indexed by mask pattern —
|
|
6
|
-
* a table, not a BCH computation, so the reader does not share the encoder's arithmetic. */
|
|
7
|
-
const LEVEL_M_FORMAT: readonly number[] = [
|
|
8
|
-
0b101010000010010, 0b101000100100101, 0b101111001111100, 0b101101101001011, 0b100010111111001,
|
|
9
|
-
0b100000011001110, 0b100111110010111, 0b100101010100000,
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
/** Total and data codewords per version, level M (ISO 18004 Table 9). */
|
|
13
|
-
const CODEWORDS: ReadonlyMap<number, { readonly total: number; readonly data: number }> = new Map([
|
|
14
|
-
[1, { total: 26, data: 16 }],
|
|
15
|
-
[2, { total: 44, data: 28 }],
|
|
16
|
-
[3, { total: 70, data: 44 }],
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
|
-
export interface QrReading {
|
|
20
|
-
readonly version: number;
|
|
21
|
-
readonly mask: number;
|
|
22
|
-
readonly codewords: readonly number[];
|
|
23
|
-
readonly text: string;
|
|
24
|
-
/** Every place the symbol departs from the standard's fixed layout, as `what@row,col`. */
|
|
25
|
-
readonly layoutFaults: readonly string[];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
type Grid = readonly (readonly boolean[])[];
|
|
29
|
-
|
|
30
|
-
const at = (grid: Grid, row: number, col: number): boolean => grid[row]?.[col] === true;
|
|
31
|
-
|
|
32
|
-
export function isFunctionModule(size: number, row: number, col: number): boolean {
|
|
33
|
-
const inCorner = (r0: number, c0: number): boolean =>
|
|
34
|
-
row >= r0 && row < r0 + 8 && col >= c0 && col < c0 + 8;
|
|
35
|
-
if (inCorner(0, 0) || inCorner(0, size - 8) || inCorner(size - 8, 0)) return true;
|
|
36
|
-
if (row === 6 || col === 6) return true;
|
|
37
|
-
if (row === 8 && (col <= 8 || col >= size - 8)) return true;
|
|
38
|
-
if (col === 8 && (row <= 8 || row >= size - 8)) return true;
|
|
39
|
-
// Versions 2-3 have exactly one alignment pattern, centred 7 modules in from the far corner.
|
|
40
|
-
if (size > 21 && Math.abs(row - (size - 7)) <= 2 && Math.abs(col - (size - 7)) <= 2) return true;
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function finderFaults(grid: Grid, size: number): string[] {
|
|
45
|
-
const faults: string[] = [];
|
|
46
|
-
for (const [r0, c0] of [
|
|
47
|
-
[0, 0],
|
|
48
|
-
[0, size - 7],
|
|
49
|
-
[size - 7, 0],
|
|
50
|
-
] as const) {
|
|
51
|
-
for (let dr = -1; dr <= 7; dr++) {
|
|
52
|
-
for (let dc = -1; dc <= 7; dc++) {
|
|
53
|
-
const row = r0 + dr;
|
|
54
|
-
const col = c0 + dc;
|
|
55
|
-
if (row < 0 || col < 0 || row >= size || col >= size) continue;
|
|
56
|
-
const ring = Math.max(Math.abs(dr - 3), Math.abs(dc - 3));
|
|
57
|
-
const dark = ring <= 1 || ring === 3;
|
|
58
|
-
if (at(grid, row, col) !== dark) faults.push(`finder@${row},${col}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return faults;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function fixedFaults(grid: Grid, size: number): string[] {
|
|
66
|
-
const faults = finderFaults(grid, size);
|
|
67
|
-
for (let i = 8; i < size - 8; i++) {
|
|
68
|
-
if (at(grid, 6, i) !== (i % 2 === 0)) faults.push(`timing@6,${i}`);
|
|
69
|
-
if (at(grid, i, 6) !== (i % 2 === 0)) faults.push(`timing@${i},6`);
|
|
70
|
-
}
|
|
71
|
-
if (size > 21) {
|
|
72
|
-
const centre = size - 7;
|
|
73
|
-
for (let dr = -2; dr <= 2; dr++) {
|
|
74
|
-
for (let dc = -2; dc <= 2; dc++) {
|
|
75
|
-
const dark = Math.max(Math.abs(dr), Math.abs(dc)) !== 1;
|
|
76
|
-
if (at(grid, centre + dr, centre + dc) !== dark) {
|
|
77
|
-
faults.push(`alignment@${centre + dr},${centre + dc}`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (!at(grid, size - 8, 8)) faults.push(`dark-module@${size - 8},8`);
|
|
83
|
-
return faults;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
type Cell = readonly [number, number];
|
|
87
|
-
|
|
88
|
-
/** The cells of both format copies, bit 14 first; positions straight from ISO 18004 Figure 25. */
|
|
89
|
-
export function formatCells(size: number): readonly [readonly Cell[], readonly Cell[]] {
|
|
90
|
-
const first: Cell[] = [];
|
|
91
|
-
for (let col = 0; col <= 8; col++) if (col !== 6) first.push([8, col]);
|
|
92
|
-
for (let row = 7; row >= 0; row--) if (row !== 6) first.push([row, 8]);
|
|
93
|
-
const second: Cell[] = [];
|
|
94
|
-
for (let row = size - 1; row >= size - 7; row--) second.push([row, 8]);
|
|
95
|
-
for (let col = size - 8; col < size; col++) second.push([8, col]);
|
|
96
|
-
return [first, second];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function formatCopies(grid: Grid, size: number): readonly [number, number] {
|
|
100
|
-
const read = (cells: readonly Cell[]): number =>
|
|
101
|
-
cells.reduce((acc, [row, col]) => (acc << 1) | (at(grid, row, col) ? 1 : 0), 0);
|
|
102
|
-
const [first, second] = formatCells(size);
|
|
103
|
-
return [read(first), read(second)];
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function maskFlips(mask: number, row: number, col: number): boolean {
|
|
107
|
-
const formulas: readonly ((i: number, j: number) => boolean)[] = [
|
|
108
|
-
(i, j) => (i + j) % 2 === 0,
|
|
109
|
-
(i) => i % 2 === 0,
|
|
110
|
-
(_i, j) => j % 3 === 0,
|
|
111
|
-
(i, j) => (i + j) % 3 === 0,
|
|
112
|
-
(i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0,
|
|
113
|
-
(i, j) => ((i * j) % 2) + ((i * j) % 3) === 0,
|
|
114
|
-
(i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0,
|
|
115
|
-
(i, j) => (((i * j) % 3) + ((i + j) % 2)) % 2 === 0,
|
|
116
|
-
];
|
|
117
|
-
return formulas[mask]?.(row, col) === true;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function readCodewords(grid: Grid, size: number, mask: number, count: number): number[] {
|
|
121
|
-
const bits: number[] = [];
|
|
122
|
-
// Two-column strips from the right edge; the strip that would straddle the vertical timing
|
|
123
|
-
// column moves one left, so column 6 is never read and column 0 always is.
|
|
124
|
-
const strips: number[] = [];
|
|
125
|
-
for (let right = size - 1; right >= 1; right -= 2) strips.push(right > 6 ? right : right - 1);
|
|
126
|
-
strips.forEach((right, index) => {
|
|
127
|
-
const upward = index % 2 === 0;
|
|
128
|
-
for (let step = 0; step < size; step++) {
|
|
129
|
-
const row = upward ? size - 1 - step : step;
|
|
130
|
-
for (const col of [right, right - 1]) {
|
|
131
|
-
if (isFunctionModule(size, row, col)) continue;
|
|
132
|
-
bits.push(at(grid, row, col) !== maskFlips(mask, row, col) ? 1 : 0);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
const words: number[] = [];
|
|
137
|
-
for (let i = 0; i < count; i++) {
|
|
138
|
-
words.push(bits.slice(i * 8, i * 8 + 8).reduce((acc, bit) => (acc << 1) | bit, 0));
|
|
139
|
-
}
|
|
140
|
-
return words;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Reed-Solomon syndromes over GF(256)/0x11d: all zero exactly when no codeword is corrupt. */
|
|
144
|
-
export function syndromesZero(codewords: readonly number[], eccCount: number): boolean {
|
|
145
|
-
const exp: number[] = [];
|
|
146
|
-
let x = 1;
|
|
147
|
-
for (let i = 0; i < 255; i++) {
|
|
148
|
-
exp.push(x);
|
|
149
|
-
x = x & 0x80 ? ((x << 1) ^ 0x11d) & 0xff : x << 1;
|
|
150
|
-
}
|
|
151
|
-
const log = new Map<number, number>(exp.map((v, i) => [v, i]));
|
|
152
|
-
const mul = (a: number, b: number): number =>
|
|
153
|
-
a === 0 || b === 0 ? 0 : (exp[((log.get(a) ?? 0) + (log.get(b) ?? 0)) % 255] ?? 0);
|
|
154
|
-
for (let k = 0; k < eccCount; k++) {
|
|
155
|
-
let s = 0;
|
|
156
|
-
for (const word of codewords) s = mul(s, exp[k] ?? 1) ^ word;
|
|
157
|
-
if (s !== 0) return false;
|
|
158
|
-
}
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Reads `grid` as a QR symbol. Never throws: a symbol that does not read is `text: ''` plus
|
|
163
|
-
* whatever faults explain why. */
|
|
164
|
-
export function readQr(grid: Grid): QrReading {
|
|
165
|
-
const size = grid.length;
|
|
166
|
-
const version = (size - 17) / 4;
|
|
167
|
-
const faults = fixedFaults(grid, size);
|
|
168
|
-
const [first, second] = formatCopies(grid, size);
|
|
169
|
-
if (first !== second) faults.push('format-copies-differ');
|
|
170
|
-
const mask = LEVEL_M_FORMAT.indexOf(first);
|
|
171
|
-
if (mask < 0) faults.push('format-not-level-m');
|
|
172
|
-
const total = CODEWORDS.get(version)?.total ?? 0;
|
|
173
|
-
const codewords = mask < 0 ? [] : readCodewords(grid, size, mask, total);
|
|
174
|
-
const dataCount = CODEWORDS.get(version)?.data ?? 0;
|
|
175
|
-
if (codewords.length === 0 || !syndromesZero(codewords, total - dataCount)) {
|
|
176
|
-
return { version, mask, codewords, text: '', layoutFaults: [...faults, 'reed-solomon'] };
|
|
177
|
-
}
|
|
178
|
-
const bits = codewords
|
|
179
|
-
.slice(0, dataCount)
|
|
180
|
-
.flatMap((word) => Array.from({ length: 8 }, (_, i) => (word >>> (7 - i)) & 1));
|
|
181
|
-
const field = (start: number, length: number): number =>
|
|
182
|
-
bits.slice(start, start + length).reduce((acc, bit) => (acc << 1) | bit, 0);
|
|
183
|
-
if (field(0, 4) !== 0b0100) return { version, mask, codewords, text: '', layoutFaults: faults };
|
|
184
|
-
const length = field(4, 8);
|
|
185
|
-
const bytes = Uint8Array.from({ length }, (_, i) => field(12 + i * 8, 8));
|
|
186
|
-
return { version, mask, codewords, text: new TextDecoder().decode(bytes), layoutFaults: faults };
|
|
187
|
-
}
|
package/src/fake-dom.ts
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
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
|
-
/**
|
|
80
|
-
* `Object.hasOwn`, never the read alone: `attrs` is a `{...spread}`, so
|
|
81
|
-
* `attrs['constructor']` answered with the `Object` FUNCTION where a real
|
|
82
|
-
* `Element.getAttribute` answers `null` — and `attrMatches` decides presence with
|
|
83
|
-
* `getAttribute(inner) !== null`, so `querySelectorAll('[constructor]')` matched every
|
|
84
|
-
* element in the tree. A fake that disagrees with the DOM makes every suite built on it lie.
|
|
85
|
-
*/
|
|
86
|
-
getAttribute(name: string): string | null {
|
|
87
|
-
return Object.hasOwn(this.attrs, name) ? (this.attrs[name] ?? null) : null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
append(...children: readonly FakeElement[]): FakeElement {
|
|
91
|
-
for (const child of children) {
|
|
92
|
-
child.parent = this;
|
|
93
|
-
this.children.push(child);
|
|
94
|
-
}
|
|
95
|
-
return this;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The bug's mechanism, faithfully: focusing a disabled control does NOTHING and reports nothing.
|
|
99
|
-
* A tag that is not natively focusable and carries no `tabindex` refuses in exactly the same
|
|
100
|
-
* silence — `<div>.focus()` is the no-op behind the focus trap's empty-panel fallback. */
|
|
101
|
-
focus(): void {
|
|
102
|
-
if (this.getAttribute('disabled') !== null) return;
|
|
103
|
-
// `tabindex="-1"` IS focusable programmatically; it is only out of the TAB order.
|
|
104
|
-
const declared = this.getAttribute('tabindex') !== null;
|
|
105
|
-
if (!declared && !this.nativelyFocusable()) return;
|
|
106
|
-
this.focusCount += 1;
|
|
107
|
-
const doc = this.document();
|
|
108
|
-
if (doc !== null) doc.activeElement = this;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
contains(node: unknown): boolean {
|
|
112
|
-
for (let at = node as FakeElement | null; at !== null; at = at.parent) {
|
|
113
|
-
if (at === this) return true;
|
|
114
|
-
}
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
descendants(): FakeElement[] {
|
|
119
|
-
return this.children.flatMap((child) => [child, ...child.descendants()]);
|
|
120
|
-
}
|
|
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
|
-
|
|
127
|
-
querySelectorAll(selector: string): FakeElement[] {
|
|
128
|
-
const groups = selector.split(',').map((one) => one.trim());
|
|
129
|
-
return this.descendants().filter((el) => groups.some((one) => compoundMatches(el, one)));
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// `focusableWithin` filters on visibility; nothing in a fake tree is laid out, so everything is
|
|
133
|
-
// visible — a hidden-element rule would be this harness inventing a fact, not testing one.
|
|
134
|
-
readonly offsetParent: unknown = null;
|
|
135
|
-
|
|
136
|
-
getClientRects(): readonly unknown[] {
|
|
137
|
-
return [{}];
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Focusable with no `tabindex` at all. `A`/`AREA` only with an `href` — the same condition
|
|
141
|
-
* `FOCUSABLE_SELECTOR` spells `a[href]`. */
|
|
142
|
-
private nativelyFocusable(): boolean {
|
|
143
|
-
if (NATIVELY_FOCUSABLE.has(this.tagName)) return true;
|
|
144
|
-
return LINK_TAGS.has(this.tagName) && this.getAttribute('href') !== null;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private document(): FakeDocument | null {
|
|
148
|
-
for (let at: FakeElement | null = this; at !== null; at = at.parent) {
|
|
149
|
-
if (at.owner !== null) return at.owner;
|
|
150
|
-
}
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export interface FakeKeyboardEvent {
|
|
156
|
-
readonly key: string;
|
|
157
|
-
readonly shiftKey: boolean;
|
|
158
|
-
defaultPrevented: boolean;
|
|
159
|
-
preventDefault(): void;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
export function keydown(key: string, shiftKey = false): FakeKeyboardEvent {
|
|
163
|
-
return {
|
|
164
|
-
key,
|
|
165
|
-
shiftKey,
|
|
166
|
-
defaultPrevented: false,
|
|
167
|
-
preventDefault(): void {
|
|
168
|
-
this.defaultPrevented = true;
|
|
169
|
-
},
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export class FakeDocument extends Listeners {
|
|
174
|
-
activeElement: FakeElement | null = null;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export interface InstalledDom {
|
|
178
|
-
readonly document: FakeDocument;
|
|
179
|
-
readonly restore: () => void;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Publish `document` and `HTMLElement` globally for the duration of one test. Both are needed:
|
|
184
|
-
* `createRovingTabindex` reads `document.activeElement` and narrows it with `instanceof
|
|
185
|
-
* HTMLElement`, so a fake that is not the global class is silently treated as "focus is nowhere".
|
|
186
|
-
* ALWAYS restored — `solid()` decides a render is a server render by `document` being absent, and
|
|
187
|
-
* a leaked one turns every later component test in the process into `X_UI_RUNTIME_MISSING`.
|
|
188
|
-
*/
|
|
189
|
-
export function installFakeDom(root: FakeElement): InstalledDom {
|
|
190
|
-
const document = new FakeDocument();
|
|
191
|
-
root.owner = document;
|
|
192
|
-
const hadDocument = 'document' in globalThis;
|
|
193
|
-
const hadElement = 'HTMLElement' in globalThis;
|
|
194
|
-
const previousDocument: unknown = Reflect.get(globalThis, 'document');
|
|
195
|
-
const previousElement: unknown = Reflect.get(globalThis, 'HTMLElement');
|
|
196
|
-
Object.assign(globalThis, { document, HTMLElement: FakeElement });
|
|
197
|
-
return {
|
|
198
|
-
document,
|
|
199
|
-
restore(): void {
|
|
200
|
-
if (hadDocument) Object.assign(globalThis, { document: previousDocument });
|
|
201
|
-
else Reflect.deleteProperty(globalThis, 'document');
|
|
202
|
-
if (hadElement) Object.assign(globalThis, { HTMLElement: previousElement });
|
|
203
|
-
else Reflect.deleteProperty(globalThis, 'HTMLElement');
|
|
204
|
-
},
|
|
205
|
-
};
|
|
206
|
-
}
|
package/src/sass-probe.ts
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
// TEST-ONLY. Compiles a stylesheet with the compiler an app's build uses, so a test can read what
|
|
2
|
-
// Sass EMITS — a media query, a `clamp()`, the class list a mixin generates, an `@error` — instead
|
|
3
|
-
// of the source it was written as. Never exported from `index.ts`, like `jsx-probe.ts`.
|
|
4
|
-
|
|
5
|
-
/** The two members of Sass's API this file calls; the package itself is not a dependency of `ui`. */
|
|
6
|
-
interface SassApi {
|
|
7
|
-
compileString(source: string, options: { readonly url: URL }): { readonly css: string };
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
let loading: Promise<SassApi> | undefined;
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* `sass` is `@ultimat3/render`'s dependency — the package that compiles an app's sheets — and `ui`
|
|
14
|
-
* may not import `render` (tier 4, both). Resolved FROM render's directory, so a test compiles with
|
|
15
|
-
* the one compiler at the one version the lockfile pins and `ui` declares no second copy. Only a
|
|
16
|
-
* specifier is read from render; no module of it loads. Lazy, so importing this file has no effect.
|
|
17
|
-
*/
|
|
18
|
-
function sassCompiler(): Promise<SassApi> {
|
|
19
|
-
loading ??= import(
|
|
20
|
-
Bun.resolveSync('sass', new URL('../../render', import.meta.url).pathname)
|
|
21
|
-
) as Promise<SassApi>;
|
|
22
|
-
return loading;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Compile `source` as though it were the file at `path`, so its relative `@use`s resolve. */
|
|
26
|
-
export async function compileScss(source: string, path: string): Promise<string> {
|
|
27
|
-
const sass = await sassCompiler();
|
|
28
|
-
return sass.compileString(source, { url: new URL(`file://${path}`) }).css;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Compile a stylesheet that exists on disk. */
|
|
32
|
-
export async function compileScssFile(path: string): Promise<string> {
|
|
33
|
-
return compileScss(await Bun.file(path).text(), path);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The `@error` text Sass refused `source` with, or `undefined` when it compiled. A refusal that is
|
|
38
|
-
* not Sass's own — the compiler failing to load — is rethrown: it is not an answer about `source`.
|
|
39
|
-
*/
|
|
40
|
-
export async function scssRefusal(source: string, path: string): Promise<string | undefined> {
|
|
41
|
-
try {
|
|
42
|
-
await compileScss(source, path);
|
|
43
|
-
} catch (error) {
|
|
44
|
-
if (typeof error === 'object' && error !== null && 'sassMessage' in error) {
|
|
45
|
-
return String(error.sassMessage);
|
|
46
|
-
}
|
|
47
|
-
throw error;
|
|
48
|
-
}
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Every class a compiled stylesheet declares. */
|
|
53
|
-
export function declaredClasses(css: string): ReadonlySet<string> {
|
|
54
|
-
return new Set([...css.matchAll(/\.([A-Za-z][\w-]*)/g)].map((match) => match[1] as string));
|
|
55
|
-
}
|