@ultimat3/ui 23.0.0 → 25.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.
Files changed (100) hide show
  1. package/CATALOG.md +119 -15
  2. package/CLAUDE.md +17 -8
  3. package/README.md +119 -33
  4. package/package.json +6 -6
  5. package/src/a11y.ts +27 -13
  6. package/src/catalog/build-catalog.ts +2 -2
  7. package/src/components/Accordion.tsx +8 -2
  8. package/src/components/AppShell.module.scss +105 -4
  9. package/src/components/AppShell.tsx +48 -10
  10. package/src/components/BarChart.module.scss +47 -9
  11. package/src/components/BarChart.tsx +152 -72
  12. package/src/components/Card.tsx +52 -16
  13. package/src/components/ChartFrame.module.scss +188 -0
  14. package/src/components/ChartFrame.tsx +155 -0
  15. package/src/components/CommandPalette.module.scss +8 -5
  16. package/src/components/Container.tsx +46 -9
  17. package/src/components/DataTable.module.scss +190 -0
  18. package/src/components/DataTable.tsx +161 -51
  19. package/src/components/Divider.tsx +0 -2
  20. package/src/components/DonutChart.module.scss +73 -0
  21. package/src/components/DonutChart.tsx +154 -0
  22. package/src/components/Dropzone.module.scss +3 -1
  23. package/src/components/Dropzone.tsx +17 -8
  24. package/src/components/ErrorState.tsx +39 -12
  25. package/src/components/FileInput.module.scss +3 -1
  26. package/src/components/FileInput.tsx +6 -2
  27. package/src/components/Form.tsx +17 -4
  28. package/src/components/Gauge.module.scss +48 -0
  29. package/src/components/Gauge.tsx +67 -0
  30. package/src/components/Grid.tsx +34 -13
  31. package/src/components/Image.tsx +2 -2
  32. package/src/components/InlineBar.module.scss +23 -0
  33. package/src/components/InlineBar.tsx +31 -0
  34. package/src/components/LineChart.module.scss +147 -0
  35. package/src/components/LineChart.tsx +169 -0
  36. package/src/components/Menu.tsx +9 -5
  37. package/src/components/Meter.tsx +3 -3
  38. package/src/components/PageHeader.tsx +5 -2
  39. package/src/components/Popover.module.scss +17 -20
  40. package/src/components/Popover.tsx +14 -7
  41. package/src/components/Section.tsx +45 -26
  42. package/src/components/Stack.tsx +59 -18
  43. package/src/components/Table.module.scss +30 -0
  44. package/src/components/Table.tsx +14 -3
  45. package/src/components/Tabs.module.scss +6 -0
  46. package/src/components/Tabs.tsx +2 -2
  47. package/src/components/Text.tsx +41 -15
  48. package/src/components/Textarea.tsx +7 -4
  49. package/src/components/Toaster.tsx +1 -1
  50. package/src/components/Toolbar.tsx +7 -6
  51. package/src/components/Tooltip.module.scss +8 -3
  52. package/src/components/app-shell-view.ts +6 -4
  53. package/src/components/bar-chart-view.ts +11 -6
  54. package/src/components/chart-frame-view.ts +219 -0
  55. package/src/components/chart-ticks-view.ts +173 -0
  56. package/src/components/data-table-view.ts +68 -0
  57. package/src/components/donut-chart-view.ts +120 -0
  58. package/src/components/file-input-view.ts +32 -13
  59. package/src/components/gauge-view.ts +43 -0
  60. package/src/components/heading-node.tsx +62 -0
  61. package/src/components/image-source.ts +3 -3
  62. package/src/components/line-chart-view.ts +153 -0
  63. package/src/components/link-target.ts +21 -1
  64. package/src/components/meter-view.ts +13 -0
  65. package/src/components/money-view.ts +2 -7
  66. package/src/components/qr-matrix.ts +16 -23
  67. package/src/components/relative-time-view.ts +11 -4
  68. package/src/form/form-binding.ts +32 -14
  69. package/src/form/use-form.ts +3 -3
  70. package/src/global.scss +9 -0
  71. package/src/icons/build-icons.ts +109 -24
  72. package/src/index.ts +47 -15
  73. package/src/jsx-probe.ts +5 -4
  74. package/src/theme/brand-declarations.ts +113 -0
  75. package/src/theme/brand.ts +104 -96
  76. package/src/theme/context.ts +1 -1
  77. package/src/theme/preset-scifi.ts +99 -0
  78. package/src/theme/preset-shape.ts +18 -0
  79. package/src/theme/presets.ts +15 -0
  80. package/src/theme/shadow-value.ts +39 -0
  81. package/src/theme/theme.ts +36 -11
  82. package/src/toast/toast-state.ts +0 -0
  83. package/src/toast/toast-store.ts +1 -1
  84. package/src/toast/use-toasts.ts +1 -1
  85. package/src/tokens/_colors.scss +20 -0
  86. package/src/tokens/_effects.scss +59 -0
  87. package/src/tokens/_index.scss +2 -0
  88. package/src/tokens/_mixins.scss +6 -4
  89. package/src/tokens/_shadow.scss +16 -0
  90. package/src/tokens/_touch.scss +39 -0
  91. package/src/tokens/_typography.scss +14 -0
  92. package/src/tokens/color-tokens.ts +133 -0
  93. package/src/tokens/colour-vision.ts +106 -0
  94. package/src/tokens/contrast-pairs.ts +6 -1
  95. package/src/tokens/contrast.ts +1 -1
  96. package/src/tokens/theme.scss +2 -0
  97. package/src/tokens/tokens.ts +41 -87
  98. package/src/fake-dom.ts +0 -206
  99. package/src/sass-probe.ts +0 -55
  100. package/src/theme/inline-script.ts +0 -49
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
- }
@@ -1,49 +0,0 @@
1
- // The blocking <head> snippet that kills the flash of wrong theme. Built from the
2
- // same constants as theme.ts so the two can never disagree. Server/build-only:
3
- // the hash is computed with Bun's hasher for the CSP header.
4
- //
5
- // DEPRECATED `As of 20.2.0`: the framework's boot inlines this script itself, from
6
- // `@ultimat3/render`'s `themeScript({ fallback })` with `theme.defaultMode` in `app.config.ts` as
7
- // the fallback, and admits it to the CSP — an app no longer writes either half. This module stays
8
- // exported for one major so an app that inlined it by hand keeps building; it is removed in 21.
9
-
10
- import { runtimeMissingError } from '../errors';
11
- import { THEME_ATTRIBUTE, THEME_MEDIA_QUERY, THEME_STORAGE_KEY } from './theme';
12
-
13
- /**
14
- * Minified on purpose — this runs before first paint and is inlined into every
15
- * document. Reads the explicit choice, falls back to the OS, and always stamps
16
- * `data-theme` so screenshots and Playwright runs are deterministic.
17
- */
18
- export const THEME_INLINE_SCRIPT =
19
- `try{var d=document.documentElement,` +
20
- `s=localStorage.getItem('${THEME_STORAGE_KEY}');` +
21
- `d.setAttribute('${THEME_ATTRIBUTE}',` +
22
- `s==='light'||s==='dark'?s:` +
23
- `matchMedia('${THEME_MEDIA_QUERY}').matches?'dark':'light')}catch(e){}`;
24
-
25
- /** The exact tag to inline. No attributes, so the CSP hash stays valid. */
26
- export function themeInlineScriptTag(): string {
27
- return `<script>${THEME_INLINE_SCRIPT}</script>`;
28
- }
29
-
30
- let cachedHash: string | null = null;
31
-
32
- /** `sha256-<base64>` for `script-src`. Stable for a given script text. */
33
- export function themeInlineScriptHash(): string {
34
- if (cachedHash !== null) return cachedHash;
35
- if (typeof Bun === 'undefined') {
36
- throw runtimeMissingError(
37
- 'Bun.CryptoHasher to hash the theme inline script',
38
- 'call themeInlineScriptHash() during build or SSR, never in browser code',
39
- );
40
- }
41
- const digest = new Bun.CryptoHasher('sha256').update(THEME_INLINE_SCRIPT).digest('base64');
42
- cachedHash = `sha256-${digest}`;
43
- return cachedHash;
44
- }
45
-
46
- /** Quoted CSP source expression, ready to concatenate into a `script-src`. */
47
- export function themeInlineScriptCspSource(): string {
48
- return `'${themeInlineScriptHash()}'`;
49
- }