@ultimat3/ui 3.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.
package/CLAUDE.md
CHANGED
|
@@ -31,9 +31,11 @@ Tier 5. Imports `@ultimat3/core`, `schema`, `i18n`, `money`, `time`. Never `http
|
|
|
31
31
|
- Formatting logic lives in a pure `*-view.ts` next to the component (`money-view.ts`, `date-time-view.ts`) so it is testable with no renderer. Every other renderer-free core follows the same rule under its own name (`sort-state.ts`, `image-source.ts`) — the `.tsx` holds markup, never a rule.
|
|
32
32
|
- **The rule being pure is not enough — the WIRING has to be tested too.** `createRovingTabindex` was correct and `Menu` handed it `[role="menuitem"]`, so a disabled item made every item after it unreachable and every assertion in the package still passed. `src/jsx-probe.ts` reads the props an element actually carries (a `tabindex`, an `aria-live`, an `onKeyDown`, a `ref`) and `src/fake-dom.ts` gives it a DOM where a disabled control REFUSES focus, exactly as the real one does. Both are test-only and neither is in `index.ts`. `components/interaction.test.ts` is where a keyboard or form-participation claim gets proven; asserting the pure helper alone is how these shipped.
|
|
33
33
|
- **A roving group excludes disabled items from both answers** — the set arrows walk and the one item holding the tab stop (`src/roving.ts`). `focus()` on a disabled control is a no-op, so a disabled item left in the list pins the reducer on its index forever. And a control that answers arrows itself (`handlesOwnArrowKeys`) keeps them: a `Toolbar` exists to hold a search field.
|
|
34
|
+
- **A single-winner state attribute is decided by POSITION, never by a missing prop.** `Breadcrumb` gives `aria-current="page"` to the last item and to nothing else; an href-less ancestor renders as plain text with no `aria-current` at all. Reading "no href" as "is the current page" put two of them in one `<nav>`. `Tabs.tsx` is the same rule for `tabindex`, and `Breadcrumb.test.ts` proves it through `jsx-probe` rather than through the pure helper.
|
|
34
35
|
- **Live semantics belong to the container that outlives the message.** `ToastRegion`'s `<ol>` carries `aria-live`; a `Toast` is a plain `<li>`. A region created with its content already inside it is not announced, and a `role="status"` on the `<li>` also strips its `listitem` semantics.
|
|
35
36
|
- **`aria-checked` never mirrors a native `checked`.** ARIA outranks host state in the accessibility tree, and on the no-JS path this package supports there is nothing to rewrite the attribute after the user ticks the box. `Checkbox` writes only `'mixed'` (an IDL property with no attribute form, so ARIA is the only server-side lever); `Switch` writes none at all, over a `biome-ignore` that says why.
|
|
36
37
|
- **Generated source is a CODE sink.** `build-icons.ts` writes modules every app EXECUTES at import, from data fetched over the network, so an attribute value goes through `JSON.stringify` and never `'${value}'` — and `SAFE_ATTR_VALUE` refuses anything that is not glyph geometry one layer earlier. `iconElements` guards tags and attribute NAMES; it has never guarded a value. Malformed upstream data is `X_UI_INVALID_VALUE`; only the generator's real environment faults (no network, no biome binary) are `X_UI_RUNTIME_MISSING`.
|
|
38
|
+
- **The icon NAME is the third sink, and it has no escape.** The upstream map key becomes a filesystem path under `GLYPHS_DIR`, an exported identifier and a `//` banner, and `buildIcons` clears `GLYPHS_DIR` before it writes — so `../../index` was a delete of the glyph tree followed by an overwrite of a hand-written module, and a key carrying `;` produced a module that typechecked and ran. `SAFE_ICON_NAME` (`/^[a-z0-9]+(-[a-z0-9]+)*$/`) is checked in `parseIconNodes` BEFORE `out.set`, the same allowlist-over-a-sink shape as `SAFE_ATTR_VALUE` one layer down; all 1767 committed names pass it and `build-icons.test.ts` asserts that.
|
|
37
39
|
|
|
38
40
|
## Files
|
|
39
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "SolidJS design system: semantic design tokens, dark/RTL-ready SCSS modules, a11y primitives",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"icons": "bun run src/icons/build-icons.ts"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@ultimat3/core": "
|
|
43
|
-
"@ultimat3/i18n": "
|
|
44
|
-
"@ultimat3/money": "
|
|
45
|
-
"@ultimat3/time": "
|
|
42
|
+
"@ultimat3/core": "4.0.0",
|
|
43
|
+
"@ultimat3/i18n": "4.0.0",
|
|
44
|
+
"@ultimat3/money": "4.0.0",
|
|
45
|
+
"@ultimat3/time": "4.0.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"solid-js": "^1.9.0"
|
|
@@ -43,3 +43,9 @@
|
|
|
43
43
|
color: t.role('fg-strong');
|
|
44
44
|
font-weight: t.weight(medium);
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
// An ancestor with no href: reads as trail, not as a control, and carries no
|
|
48
|
+
// `aria-current` — only the last item is the current page.
|
|
49
|
+
.text {
|
|
50
|
+
color: t.role('fg-muted');
|
|
51
|
+
}
|
|
@@ -32,10 +32,15 @@ export function Breadcrumb(props: BreadcrumbProps): JSX.Element {
|
|
|
32
32
|
<ol class={styles['list']}>
|
|
33
33
|
{props.items.map((item, index) => (
|
|
34
34
|
<li class={styles['item']}>
|
|
35
|
-
{
|
|
35
|
+
{/* POSITION decides `aria-current`, never the presence of an href: an ancestor with no
|
|
36
|
+
link is still an ancestor, and a second `aria-current="page"` in one nav announces
|
|
37
|
+
the current page on a node that is not it. A trail carries exactly one. */}
|
|
38
|
+
{index === props.items.length - 1 ? (
|
|
36
39
|
<span class={styles['current']} aria-current="page">
|
|
37
40
|
{item.label}
|
|
38
41
|
</span>
|
|
42
|
+
) : item.href === undefined ? (
|
|
43
|
+
<span class={styles['text']}>{item.label}</span>
|
|
39
44
|
) : (
|
|
40
45
|
<a class={styles['link']} href={linkTarget(item.href).href}>
|
|
41
46
|
{item.label}
|
|
@@ -63,10 +63,17 @@ export function ErrorState(props: ErrorStateProps): JSX.Element {
|
|
|
63
63
|
return (
|
|
64
64
|
<div class={cx(styles['error'], props.class)} role="alert">
|
|
65
65
|
<p class={styles['head']}>
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
{/* The heading is the ONE string here the design system owns, so it is translated;
|
|
67
|
+
`parts()` carries the error's own three, which are English by construction — a code's
|
|
68
|
+
registry title, its cause and its fix are the same text the terminal prints. */}
|
|
69
|
+
<span class={styles['title']}>{ui.t(UI_KEYS.error)}</span>
|
|
68
70
|
</p>
|
|
69
71
|
<dl class={styles['detail']}>
|
|
72
|
+
{/* A bare code with no label reads as noise to anyone who is not the author of the throw. */}
|
|
73
|
+
<dt>{ui.t(UI_KEYS.errorCode)}</dt>
|
|
74
|
+
<dd>
|
|
75
|
+
<code class={styles['code']}>{parts().code}</code> {parts().title}
|
|
76
|
+
</dd>
|
|
70
77
|
<dt>{ui.t(UI_KEYS.errorCause)}</dt>
|
|
71
78
|
<dd>{parts().cause}</dd>
|
|
72
79
|
<dt>{ui.t(UI_KEYS.errorFix)}</dt>
|
package/src/icons/build-icons.ts
CHANGED
|
@@ -37,11 +37,28 @@ export function parseIconNodes(text: string): ReadonlyMap<string, IconGlyph> {
|
|
|
37
37
|
}
|
|
38
38
|
const out = new Map<string, IconGlyph>();
|
|
39
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
|
+
}
|
|
40
49
|
out.set(name, toGlyph(name, value));
|
|
41
50
|
}
|
|
42
51
|
return out;
|
|
43
52
|
}
|
|
44
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
|
+
|
|
45
62
|
/**
|
|
46
63
|
* Glyph geometry, as characters: digits, the path-command letters, `currentColor`/`none`, and the
|
|
47
64
|
* separators between them. Deliberately not "anything that is not a quote" — an allowlist over a
|