@ultimat3/ui 22.15.0 → 23.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 +43 -12
- package/CLAUDE.md +13 -49
- package/README.md +59 -4
- package/package.json +6 -5
- package/src/catalog/parse-component.ts +85 -12
- package/src/catalog/render-catalog.ts +21 -1
- package/src/components/Avatar.module.scss +1 -1
- package/src/components/BarChart.module.scss +12 -1
- package/src/components/BarChart.tsx +41 -15
- package/src/components/Button.tsx +3 -8
- package/src/components/Card.module.scss +1 -1
- package/src/components/Checkbox.module.scss +4 -4
- package/src/components/Checkbox.tsx +6 -0
- package/src/components/CommandPalette.module.scss +1 -1
- package/src/components/DataTable.module.scss +2 -0
- package/src/components/DataTable.tsx +90 -29
- package/src/components/Dialog.module.scss +2 -2
- package/src/components/Dropzone.module.scss +3 -3
- package/src/components/FileInput.module.scss +1 -1
- package/src/components/Form.module.scss +2 -2
- package/src/components/Input.module.scss +1 -1
- package/src/components/Link.tsx +64 -12
- package/src/components/Pagination.tsx +89 -37
- package/src/components/Radio.module.scss +2 -2
- package/src/components/Select.module.scss +3 -3
- package/src/components/Skeleton.module.scss +1 -1
- package/src/components/Spinner.module.scss +3 -3
- package/src/components/Switch.module.scss +2 -2
- package/src/components/Table.module.scss +2 -2
- package/src/components/Tabs.module.scss +5 -5
- package/src/components/ThemeToggle.module.scss +1 -1
- package/src/components/Toast.module.scss +2 -2
- package/src/components/Tooltip.module.scss +1 -1
- package/src/components/bar-chart-view.ts +29 -3
- package/src/components/button-classes.ts +25 -0
- package/src/components/link-classes.ts +38 -0
- package/src/components/link-target.ts +13 -0
- package/src/index.ts +13 -3
- package/src/sass-probe.ts +55 -0
- package/src/tokens/_index.scss +3 -1
- package/src/tokens/_mixins.scss +56 -8
- package/src/tokens/_motion.scss +4 -0
- package/src/tokens/_stroke.scss +20 -0
- package/src/tokens/_units.scss +47 -0
- package/src/tokens/reset.scss +4 -4
- package/src/tokens/theme.scss +2 -0
- package/src/tokens/tokens.ts +6 -0
package/CATALOG.md
CHANGED
|
@@ -103,13 +103,14 @@ Small status label. Tone maps straight onto the status colour roles so the same
|
|
|
103
103
|
|
|
104
104
|
### BarChart
|
|
105
105
|
|
|
106
|
-
A bar chart of one series — clicks per day, signups per week — as static SVG. No charting library: the framework's own docs use a sparkline pulling one in as the cautionary example, and a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the server-rendered shell IS the chart. Geometry lives in `bar-chart-view.ts`.
|
|
106
|
+
A bar chart of one series — clicks per day, signups per week — or two stacked (done, and failed on top), as static SVG. No charting library: the framework's own docs use a sparkline pulling one in as the cautionary example, and a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the server-rendered shell IS the chart. Geometry lives in `bar-chart-view.ts`.
|
|
107
107
|
|
|
108
108
|
| Prop | Type | Required | Notes |
|
|
109
109
|
|---|---|---|---|
|
|
110
110
|
| `label` | `string` | yes | The accessible name for the whole chart, already translated. |
|
|
111
111
|
| `points` | `readonly ChartPoint[]` | yes | Oldest first. Every bar comes from exactly this array — the caller zero-fills gaps. |
|
|
112
112
|
| `highlightLast` | `boolean` | — | Draw the last bar at full strength — the eye lands where the live number is. Default on. |
|
|
113
|
+
| `seriesLabels` | `{ readonly primary: string; readonly secondary: string }` | — | Names of the two series, already translated, read by each rect's `<title>`. Give them when a point carries `secondary`: a stacked bar with no name for its top is two numbers and no key. |
|
|
113
114
|
| `class` | `string` | — | |
|
|
114
115
|
|
|
115
116
|
### Breadcrumb
|
|
@@ -170,6 +171,7 @@ Native checkbox with a token-drawn indicator. The label element wraps the input,
|
|
|
170
171
|
| `id` | `string` | — | |
|
|
171
172
|
| `name` | `string` | — | |
|
|
172
173
|
| `value` | `string` | — | |
|
|
174
|
+
| `form` | `string` | — | The id of the `<form>` this box submits with, when it cannot sit inside it — a row checkbox in a table whose page already holds another form. The platform's own association, no script. |
|
|
173
175
|
| `checked` | `boolean` | — | |
|
|
174
176
|
| `indeterminate` | `boolean` | — | Tri-state for "some children selected". The ONLY thing mirrored to `aria-checked`. |
|
|
175
177
|
| `disabled` | `boolean` | — | |
|
|
@@ -258,7 +260,6 @@ Data-driven table: sortable headers, cursor pagination, and the four states a re
|
|
|
258
260
|
| `rows` | `readonly Row[]` | yes | |
|
|
259
261
|
| `rowKey` | `(row: Row) => string` | yes | |
|
|
260
262
|
| `sort` | `SortState` | — | |
|
|
261
|
-
| `onSortChange` | `(sort: SortState \| undefined) => void` | — | |
|
|
262
263
|
| `loading` | `boolean` | — | |
|
|
263
264
|
| `error` | `unknown` | — | An UltimateError (or anything shaped like one) from the query. |
|
|
264
265
|
| `onRetry` | `(() => void)` | — | |
|
|
@@ -266,11 +267,14 @@ Data-driven table: sortable headers, cursor pagination, and the four states a re
|
|
|
266
267
|
| `emptyDescription` | `string` | — | |
|
|
267
268
|
| `nextCursor` | `string` | — | Opaque cursors from the query result; absent means no further page. |
|
|
268
269
|
| `prevCursor` | `string` | — | |
|
|
269
|
-
| `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | |
|
|
270
270
|
| `stickyHeader` | `boolean` | — | |
|
|
271
271
|
| `density` | `'comfortable' \| 'compact'` | — | |
|
|
272
272
|
| `skeletonRows` | `number` | — | Placeholder row count while loading. Match the usual page size. |
|
|
273
273
|
| `class` | `string` | — | |
|
|
274
|
+
| `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback paging. Never beside `hrefFor` — the type refuses the pair. |
|
|
275
|
+
| `onSortChange` | `((sort: SortState \| undefined) => void)` | — | Callback sorting. Never beside `sortHrefFor` — the type refuses the pair. |
|
|
276
|
+
| `hrefFor` | `(cursor: string, direction: 'next' \| 'prev') => string` | — | Link paging: the URL of the page a cursor names, handed to `<Pagination hrefFor>`. |
|
|
277
|
+
| `sortHrefFor` | `((sort: SortState \| undefined) => string)` | — | Link sorting: the URL of the list under the NEXT sort state of a header's cycle (`undefined` is "unsorted"). Absent, a sortable header is plain text — never a button with no handler. |
|
|
274
278
|
|
|
275
279
|
### DateTime
|
|
276
280
|
|
|
@@ -546,7 +550,7 @@ A keyboard key or chord as the user reads it — "⌘K", "Esc", "/". A native `<
|
|
|
546
550
|
|
|
547
551
|
### Link
|
|
548
552
|
|
|
549
|
-
Anchor primitive. External links get `rel` hardening and a translated "opens in a new tab" hint automatically — never a bare `target="_blank"`.
|
|
553
|
+
Anchor primitive. External links get `rel` hardening and a translated "opens in a new tab" hint automatically — never a bare `target="_blank"`. `appearance="button"` is Button's look on the same anchor: a control that navigates is a link.
|
|
550
554
|
|
|
551
555
|
| Prop | Type | Required | Notes |
|
|
552
556
|
|---|---|---|---|
|
|
@@ -554,12 +558,19 @@ Anchor primitive. External links get `rel` hardening and a translated "opens in
|
|
|
554
558
|
| `children` | `JSX.Element` | yes | |
|
|
555
559
|
| `external` | `boolean` | — | |
|
|
556
560
|
| `externalHint` | `string` | — | Announced suffix for external links; supply via `t()` at the call site. |
|
|
557
|
-
| `
|
|
558
|
-
| `tone` | `'accent' \| 'inherit'` | — | |
|
|
561
|
+
| `rel` | `'next' \| 'prev'` | — | A pager's direction. Joins the hardening an external link always carries, never replaces it. |
|
|
559
562
|
| `id` | `string` | — | |
|
|
560
563
|
| `class` | `string` | — | |
|
|
561
564
|
| `aria-current` | `'page' \| 'step' \| 'true' \| false` | — | |
|
|
562
565
|
| `onClick` | `JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>` | — | |
|
|
566
|
+
| `appearance` | `'link' \| 'button'` | — | `button` wears Button's classes on the same `<a>`; the two looks take different props. |
|
|
567
|
+
| `underline` | `'always' \| 'hover' \| 'none'` | — | |
|
|
568
|
+
| `tone` | `'accent' \| 'inherit' \| Tone` | — | A text link: `accent` or `inherit`. A button-link: any `Tone`. |
|
|
569
|
+
| `variant` | `ButtonVariant` | — | `appearance="button"` only. |
|
|
570
|
+
| `size` | `Size` | — | `appearance="button"` only. |
|
|
571
|
+
| `fullWidth` | `boolean` | — | `appearance="button"` only. |
|
|
572
|
+
| `iconStart` | `JSX.Element` | — | `appearance="button"` only. |
|
|
573
|
+
| `iconEnd` | `JSX.Element` | — | `appearance="button"` only. |
|
|
563
574
|
|
|
564
575
|
### LocaleSwitcher
|
|
565
576
|
|
|
@@ -631,19 +642,20 @@ The top of a screen: breadcrumbs, the page's one heading, a description, and the
|
|
|
631
642
|
|
|
632
643
|
### Pagination
|
|
633
644
|
|
|
634
|
-
Cursor-first pagination — the only shape a Postgres-backed list should use. Numbered mode exists for prerendered archives where the total is known.
|
|
645
|
+
Cursor-first pagination — the only shape a Postgres-backed list should use. Numbered mode exists for prerendered archives where the total is known. One mode per use: callbacks (`onCursor` / `onPage`) for an island, or `hrefFor` for a server-rendered list — anchors the browser follows, with no script on the page.
|
|
635
646
|
|
|
636
647
|
| Prop | Type | Required | Notes |
|
|
637
648
|
|---|---|---|---|
|
|
638
649
|
| `nextCursor` | `string` | — | Opaque cursor for the following page; absent disables "next". |
|
|
639
650
|
| `prevCursor` | `string` | — | |
|
|
640
|
-
| `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | |
|
|
641
|
-
| `page` | `number` | — | Numbered mode: 1-based page and total. Ignored when cursors are present. |
|
|
642
|
-
| `totalPages` | `number` | — | |
|
|
643
|
-
| `onPage` | `((page: number) => void)` | — | |
|
|
644
651
|
| `labelPrevious` | `string` | — | Already-translated; falls back to the ui.* catalog keys. |
|
|
645
652
|
| `labelNext` | `string` | — | |
|
|
646
653
|
| `class` | `string` | — | |
|
|
654
|
+
| `onCursor` | `((cursor: string, direction: 'next' \| 'prev') => void)` | — | Callback mode. Never beside `hrefFor` — the type refuses the pair. |
|
|
655
|
+
| `page` | `number` | — | Numbered mode: 1-based page and total. Ignored when cursors are present. |
|
|
656
|
+
| `totalPages` | `number` | — | |
|
|
657
|
+
| `onPage` | `((page: number) => void)` | — | |
|
|
658
|
+
| `hrefFor` | `(cursor: string, direction: 'next' \| 'prev') => string` | — | Link mode: the URL of the page a cursor names. Renders `<a rel="next\|prev">` and attaches no handler, so it needs no island. Cursor paging only — never beside `onCursor`, `onPage`, `page` or `totalPages`. |
|
|
647
659
|
|
|
648
660
|
### Popover
|
|
649
661
|
|
|
@@ -1014,6 +1026,14 @@ charts and email.
|
|
|
1014
1026
|
| `pill` | `999px` |
|
|
1015
1027
|
| `full` | `50%` |
|
|
1016
1028
|
|
|
1029
|
+
### Stroke — `--stroke-*`
|
|
1030
|
+
|
|
1031
|
+
| Token | Value |
|
|
1032
|
+
|---|---|
|
|
1033
|
+
| `hairline` | `1px` |
|
|
1034
|
+
| `thick` | `2px` |
|
|
1035
|
+
| `heavy` | `3px` |
|
|
1036
|
+
|
|
1017
1037
|
### Font size — `--text-*`
|
|
1018
1038
|
|
|
1019
1039
|
| Token | Value |
|
|
@@ -1078,7 +1098,7 @@ charts and email.
|
|
|
1078
1098
|
| `toast` | `700` |
|
|
1079
1099
|
| `skip-nav` | `800` |
|
|
1080
1100
|
|
|
1081
|
-
### Breakpoints —
|
|
1101
|
+
### Breakpoints — `respond-to` · `respond-down` · `respond-between`
|
|
1082
1102
|
|
|
1083
1103
|
| Token | Value |
|
|
1084
1104
|
|---|---|
|
|
@@ -1087,3 +1107,14 @@ charts and email.
|
|
|
1087
1107
|
| `lg` | `1024px` |
|
|
1088
1108
|
| `xl` | `1280px` |
|
|
1089
1109
|
| `2xl` | `1536px` |
|
|
1110
|
+
|
|
1111
|
+
`@include t.respond-to(md)` is `md` and wider, `t.respond-down(md)` is narrower than `md`, and
|
|
1112
|
+
`t.respond-between(md, lg)` is `md` up to `lg`. A max-width arm stops 0.02px under its rung, so
|
|
1113
|
+
no width matches two of them.
|
|
1114
|
+
|
|
1115
|
+
### Computed lengths
|
|
1116
|
+
|
|
1117
|
+
| Function | Answers |
|
|
1118
|
+
|---|---|
|
|
1119
|
+
| `t.rem(24px)` | `1.5rem` — px, rem or a unitless px count in, rem out |
|
|
1120
|
+
| `t.fluid(1rem, 2rem, 20rem, 80rem)` | one `clamp()`: the first size at a 20rem viewport, the second at 80rem, linear between |
|
package/CLAUDE.md
CHANGED
|
@@ -12,17 +12,19 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
|
|
|
12
12
|
|
|
13
13
|
- **SCSS modules only.** `Foo.tsx` + `Foo.module.scss`, always paired. No Tailwind, no CSS-in-JS, no inline `style` except CSS custom properties.
|
|
14
14
|
- **No raw colours.** Only `t.role('<role>')` / `var(--color-*)`. Canonical roles live in `src/tokens/_colors.scss`; `tokens.ts` mirrors them and `tokens.test.ts` fails on drift.
|
|
15
|
+
- **No raw length, breakpoint, z-index, shadow or duration** in any sheet here: `t.space()` / `t.stroke()` / `t.rem()`, `respond-*`, `t.z()`, `t.shadow()`, `t.duration()`. Held by `packages/cli/src/templates/scaffold-guards-style.test.ts`, which runs the guards `x new` ships over this package.
|
|
15
16
|
- **Logical properties only.** `margin-inline`, `inset-inline-start`, `text-align: start`. A `left`/`right` in a stylesheet is a bug.
|
|
16
17
|
- **solid-js is a type-only import.** All reactive access goes through `src/theme/solid-adapter.ts`. Never `import { createSignal } from 'solid-js'`.
|
|
17
18
|
- **The runtime SLOT is `src/theme/runtime-slot.ts`; the RULE is `solid-adapter.ts`, and the split is a byte measurement.** `solid-adapter.ts` reaches `../errors` and so core's error registry; the slot imports nothing, so an island that only calls `setSolidRuntime` pays 72 B, not 5,719. `barrel-bytes.test.ts` holds the ceiling at 1 kB.
|
|
18
19
|
- **The barrel is the ONE import path; component subpath exports are refused on measurement** (issue #275). A barrel import and a deep path emit the same modules and bytes, so `@ultimat3/ui/button` would be a second idiom for zero bytes. The two subpaths that exist are not exceptions: `./icons/*` is 1,767 data modules no bundler can split, and `./jsx-probe` is test-only. `barrel-bytes.test.ts` compares the two paths and names `schema-error-codes.ts` (Bun 1.4.0's load-correlated shake, issues #273 #276) rather than allowing bytes for it.
|
|
19
|
-
- **`useUi()`'s server branch is a SLOT, and the module that fills it is browser-mapped** (issue #490). `context.ts` imports no value from `@ultimat3/i18n` or `@ultimat3/time
|
|
20
|
+
- **`useUi()`'s server branch is a SLOT, and the module that fills it is browser-mapped** (issue #490). `context.ts` imports no value from `@ultimat3/i18n` or `@ultimat3/time`. `src/theme/ambient.ts` registers into `ambient-slot.ts` at import; `index.ts` imports it BARE above every re-export; `package.json`'s `browser` field maps it to `ambient.browser.ts`. A test reaching components by module path must `import './ambient'` itself. `errors.ts` is pure: `registerErrorCodes()` is `src/error-registry.ts`, imported bare.
|
|
20
21
|
- **`solid()` always answers off-DOM.** No registered runtime and no DOM is a *server render*, and it gets `INERT_SOLID_RUNTIME` (`src/theme/inert-runtime.ts`) — signals hold, memos recompute on read, effects never run, `useContext` returns the default. No DOM means no reactivity to lose; a **DOM** with no runtime is still `X_UI_RUNTIME_MISSING`, because that one is the theme toggle that does nothing. Never widen this to "no runtime, never throw": that is the silent degradation the split exists to prevent.
|
|
21
22
|
- **`useUi()` reads the request on the server**, via `ambientUiContext()` — `currentLocale()`, `currentTimeZone()`, `useI18n()`, which read **core's own `Ctx.locale` / `Ctx.tz`** (written once per request by `@ultimat3/http`'s `locale` stage). Never add a second ambient store here or in `i18n`/`time`. `theme`/`currency` have no ambient source at all.
|
|
22
23
|
- **Every locale-sensitive call takes the locale as an ARGUMENT, `toLocaleUpperCase` included.** `initialsOf(name, locale)` reads `useUi().locale`; a bare `toLocaleUpperCase()` reads the runtime's default (Turkish dotted `i` → `İ`).
|
|
23
24
|
- **`UiProvider` is client-only and throws on the server** (`providerNeedsRuntimeError`, the same `X_UI_RUNTIME_MISSING`). A Provider in an inert tree reaches no descendant — the tree is built before the renderer walks it, so consumers are walked outside every owner and read the context default even with a real Solid runtime registered. Rendering the children anyway would drop its locale, zone, currency and translator silently. Making it work needs the *renderer* to scope a context around the walk; until then it refuses.
|
|
24
25
|
- **A component may call `solid()` freely.** Its effects must stay DOM-only work — they simply never run on the server.
|
|
25
26
|
- **No `{...rest}` prop spreading.** Splitting props reactively needs solid's `splitProps` (a value import), so components declare explicit props and read `props.x` inside JSX.
|
|
27
|
+
- **One mode per use is a UNION of interfaces** (`LinkProps`, `PaginationProps`, `DataTableProps` — where the mode covers the pager AND the sort headers: `hrefFor` + `sortHrefFor`, or `onCursor` + `onSortChange`; a linked table never renders a button with no handler): a mode forbids the other's props with `x?: undefined`, and `catalog/parse-component.ts` merges the modes.
|
|
26
28
|
- **No hardcoded strings.** Label props, or `useUi().t(UI_KEYS.x)`. New built-in strings go in `src/i18n-keys.ts`.
|
|
27
29
|
- **Page layout is four composites**: `AppShell` (frame + skip link + landmarks), `PageHeader`, `Section`, `Toolbar`. A screen that hand-rolls a header grid is the bug they exist to prevent. `AppShell` holds no state — off-canvas is `Drawer`.
|
|
28
30
|
- **Restyling goes through `defineTheme()`**, never a forked stylesheet and never an SCSS `with ()` override. Its values are validated, not escaped: the output lands in a `<style>` element.
|
|
@@ -33,55 +35,15 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
|
|
|
33
35
|
- **`inert-render.test.ts` must not assume which factory its `.tsx` compiled to.** `@ultimat3/render` installs a process-global `.tsx` `onLoad` plugin at import, so in one `bun test` process a component may compile to render's `h`. The walker recognises both (`Symbol.for('ultimate.render.jsx')`), and the first test asserts a recognised node — otherwise the file renders `"[object Object]"` and passes by shard packing.
|
|
34
36
|
- **Components are not unit-tested through a renderer.** `.tsx` compiles to `@ultimat3/render`'s `h`, which this package may not import, so every rule lives in a pure module beside the component (`icon-glyph.ts`, `accordion-view.ts`, `combobox-filter.ts`, `infinite-scroll-view.ts`) and *that* is what the tests assert.
|
|
35
37
|
- 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.
|
|
36
|
-
- **The rule being pure is not enough — the WIRING
|
|
38
|
+
- **The rule being pure is not enough — the WIRING is tested too.** `src/jsx-probe.ts` reads the props an element carries and `src/fake-dom.ts` is a DOM where a disabled control refuses focus; both TEST-ONLY, neither in `index.ts`. `probe`/`unprobe` are the framework's ONE owner of `globalThis.React`, reachable at `@ultimat3/ui/jsx-probe` (admin imports them). A keyboard or form-participation claim is proven in `components/interaction.test.ts`.
|
|
37
39
|
- **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.
|
|
38
40
|
- **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.
|
|
39
|
-
- **One async region, four branches, and `empty` is unreachable while `pending`.** `asyncBranch`
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
for one frame before the first page arrives is unconstructible rather than discouraged.
|
|
46
|
-
`<AsyncRegion>`'s `empty` and `ready` are REQUIRED props, so forgetting the empty state is a type
|
|
47
|
-
error. `refreshing` CARRIES the previous data — a refetch dims what is on screen (`aria-busy`) and
|
|
48
|
-
never tears it down, which is what makes a search box feel fast; the empty branch is reachable
|
|
49
|
-
only from a completed result that returned zero. `reserve` feeds the placeholder AND the
|
|
50
|
-
`min-block-size` of every branch, so the skeleton and the loaded content cannot be written into
|
|
51
|
-
different boxes.
|
|
52
|
-
- **A toast dwell stops for THREE independent reasons, and one of them is `document.hidden`.**
|
|
53
|
-
Hover and focus-within are the two everybody implements; a backgrounded tab spends the whole dwell
|
|
54
|
-
and the corner is empty when the user comes back. `ToastHold` is a set, never a boolean: a pointer
|
|
55
|
-
leaving a toast a keyboard user is still inside must not restart the countdown, which one flag
|
|
56
|
-
cannot express. Duration is a TOKEN (`TOAST_DWELL_MS`), never a per-call number of milliseconds.
|
|
57
|
-
The queue's rules are pure (`src/toast/toast-state.ts`) and the clock is injected (`ToastEnv`), so
|
|
58
|
-
a server render gets `INERT_TOAST_ENV` — nothing scheduled — and `ToastRegion` still emits its
|
|
59
|
-
empty live region, which is what the document has to carry BEFORE the first message.
|
|
60
|
-
- **`announce()` writes into a region `AppShell` already rendered.** A live region created and
|
|
61
|
-
filled in the same frame is not announced by most screen readers, so `announce()`'s own
|
|
62
|
-
create-if-absent branch could only ever be right from its second call. `liveRegionAttrs` is the
|
|
63
|
-
one derivation both halves read. It is for a state change with no surface of its own ("12
|
|
64
|
-
results", "sorted by name"); a notification is a `Toast`, and two announcement paths for one
|
|
65
|
-
message is how a screen reader reads it twice.
|
|
66
|
-
- **`loading` never sets the native `disabled` attribute** (`Button`), and that WILL read as a
|
|
67
|
-
mistake. A control that disables itself mid-flow drops focus to `<body>`, is exempt from the
|
|
68
|
-
contrast minimum, announces no reason, and still does not prevent the double submit — that race is
|
|
69
|
-
on the server. `aria-disabled` + a refused click keeps it focusable and readable; `Button.module.scss`
|
|
70
|
-
restores full opacity under `[aria-busy='true']` because `t.disabled` dims to 0.55. `<Form busy>`
|
|
71
|
-
refuses the submit again, because Enter in a text field touches no button at all.
|
|
72
|
-
- **A failed submit focuses the first invalid CONTROL, and the summary only when there is none.**
|
|
73
|
-
GOV.UK's summary-with-links shape is not available here: `Field` mints its control ids internally,
|
|
74
|
-
and inverting that ownership is the drift `Field` exists to prevent. `firstInvalidField` answers in
|
|
75
|
-
DECLARATION order, never issue order — a server may report the last field first. `fieldSelector`
|
|
76
|
-
is the allowlist between a caller's string and a selector; a name the grammar refuses reaches no
|
|
77
|
-
`querySelector`.
|
|
78
|
-
- **`defineTheme()` refuses a palette that fails WCAG 2.2 AA** (`X_UI_CONTRAST_INSUFFICIENT`),
|
|
79
|
-
measured against `CONTRAST_PAIRS` — the same table `contrast.test.ts` holds the shipped palette to,
|
|
80
|
-
in SOURCE rather than in a test, so an app cannot ship a brand the design system would have failed
|
|
81
|
-
its own suite over. Only pairings the brand can have CHANGED are measured, on the resolved palette
|
|
82
|
-
(shipped channels + overrides): a new `accent` against the shipped `accent-fg` is the commonest way
|
|
83
|
-
a brand goes unreadable. AA, never APCA — APCA is not a standard, and AA is the operative legal
|
|
84
|
-
benchmark.
|
|
41
|
+
- **One async region, four branches, and `empty` is unreachable while `pending`.** `asyncBranch` (`src/components/async-branch.ts`) is the ONLY place the decision is made — `AsyncRegion` renders it, `DataTable` calls it. `AsyncState` is core's (imported, never re-exported). `empty` and `ready` are REQUIRED props; `refreshing` carries the previous data and dims it (`aria-busy`), never tears it down; `reserve` feeds the placeholder AND every branch's `min-block-size`.
|
|
42
|
+
- **A toast dwell stops for THREE independent reasons** — hover, focus-within, `document.hidden` — so `ToastHold` is a set, never a boolean. Duration is a TOKEN (`TOAST_DWELL_MS`). Rules are pure (`src/toast/toast-state.ts`), the clock injected (`ToastEnv`); a server render gets `INERT_TOAST_ENV` and `ToastRegion` still emits its empty live region.
|
|
43
|
+
- **`announce()` writes into a region `AppShell` already rendered** (`liveRegionAttrs` is the one derivation). For a state change with no surface of its own; a notification is a `Toast` — never both for one message.
|
|
44
|
+
- **`loading` never sets the native `disabled` attribute** (`Button`): `aria-disabled` + a refused click keeps it focusable and readable; `Button.module.scss` restores full opacity under `[aria-busy='true']`. `<Form busy>` refuses the submit again.
|
|
45
|
+
- **A failed submit focuses the first invalid CONTROL, and the summary only when there is none.** `firstInvalidField` answers in DECLARATION order; `fieldSelector` is the allowlist between a caller's string and a selector.
|
|
46
|
+
- **`defineTheme()` refuses a palette that fails WCAG 2.2 AA** (`X_UI_CONTRAST_INSUFFICIENT`), measured against `CONTRAST_PAIRS` on the RESOLVED palette, only for pairings the brand changed. AA, never APCA.
|
|
85
47
|
- **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.
|
|
86
48
|
- **`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.
|
|
87
49
|
- **A form binds to an action; it never decides for one.** `useForm` (`src/form/`) is a binding over an existing `action`, not a ninth primitive and not a component — `submit` is REQUIRED and is the only producer of a `succeeded` state, and the client-side parse's **value** is discarded so that only its issues are read. A binding that submitted the locally-parsed value would let a browser choose what the server was asked to store; `FormSchema` has no output type for that reason.
|
|
@@ -96,7 +58,7 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
|
|
|
96
58
|
|
|
97
59
|
| Path | Responsibility |
|
|
98
60
|
|---|---|
|
|
99
|
-
| `src/tokens/*.scss` | canonical token maps + `_mixins.scss` authoring helpers |
|
|
61
|
+
| `src/tokens/*.scss` | canonical token maps + `_mixins.scss` authoring helpers (`respond-to`/`-down`/`-between`) + `_units.scss` (`rem()`, `fluid()`). Every `@error` is `string.unquote('X_CODE: … fix: …')` with a REGISTERED code — `mixins.test.ts` |
|
|
100
62
|
| `src/tokens/theme.scss` | the only stylesheet that emits global custom properties |
|
|
101
63
|
| `src/tokens/reset.scss` | the reset, ZERO specificity: every rule a component may restyle sits in `:where(…)` whole, pseudo-class inside (`:where(a:hover)`, never `:where(a):hover`). `a:hover` (0,1,1) beat `.primary` and hid a button link's text until 22.3.2. `reset.test.ts` computes it |
|
|
102
64
|
| `src/theme/runtime-slot.ts` | the module-scope slot holding the app's Solid runtime — and nothing else, so registering one costs an island 72 B |
|
|
@@ -127,6 +89,8 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
|
|
|
127
89
|
| `src/form/use-form.ts` | the Solid shell: the same binding with its state in a signal |
|
|
128
90
|
| `src/form/form-values.ts` | `FormData` → the nested object the action's input schema declares |
|
|
129
91
|
| `src/form/field-binding.test.ts` | the WIRING, end to end: a rejection reaching `aria-invalid` and `aria-describedby` on the control it named |
|
|
92
|
+
| `src/sass-probe.ts` | TEST-ONLY: what Sass EMITS. `sass` is resolved from `packages/render`'s directory — never an import of render, never a second copy |
|
|
93
|
+
| `src/components/button-classes.ts` | the keys of a button's look, asked by `Button` AND `<Link appearance="button">` (`link-classes.ts`). `Button` gets no `href` |
|
|
130
94
|
| `src/fake-dom.ts` | TEST-ONLY: a DOM where a disabled control refuses focus. Never exported from `index.ts` |
|
|
131
95
|
| `src/jsx-probe.ts` | TEST-ONLY: a component's node tree, so a test can assert the props and call the handlers an element carries. `probe`/`unprobe` are the framework's ONE owner of `globalThis.React`, reached from `@ultimat3/admin` at `@ultimat3/ui/jsx-probe`; the walkers stay internal |
|
|
132
96
|
| `src/barrel-bytes.test.ts` | the build error behind the three claims above: the setter's byte ceiling, barrel-vs-deep-path parity, and the theme-toggle island's retained-module list — the last two read off the `minify: false` banners, never a byte allowance |
|
package/README.md
CHANGED
|
@@ -21,10 +21,22 @@ channels** so `rgb(var(--color-accent) / 0.12)` gives a tint with no extra token
|
|
|
21
21
|
| `accent` / `accent-strong` / `accent-fg` | primary action, its hover, text on it |
|
|
22
22
|
| `success` / `warning` / `danger` / `info` | status solid, each with a `-soft` tint and a `-fg` text-on-solid |
|
|
23
23
|
|
|
24
|
-
Scales: `--space-*` (4px base), `--text-*` (fluid `clamp()`), `--radius-*`,
|
|
24
|
+
Scales: `--space-*` (4px base), `--stroke-*` (line weights: `hairline thick heavy`), `--text-*` (fluid `clamp()`), `--radius-*`,
|
|
25
25
|
`--shadow-*` (themed — dark gets deeper, higher-alpha shadows), `--duration-*`,
|
|
26
26
|
`--easing-*`, `--z-*` (named ladder, no magic numbers).
|
|
27
27
|
|
|
28
|
+
Authoring helpers, from the same `@use '@ultimat3/ui/tokens' as t` — an app writes no mixin of its own for any of them:
|
|
29
|
+
|
|
30
|
+
| Helper | Emits |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `@include t.respond-to(md)` | `@media (min-width: 768px)` — the rung and wider |
|
|
33
|
+
| `@include t.respond-down(md)` | `@media (max-width: 767.98px)` — narrower than the rung; 0.02px under it, so no width matches both arms |
|
|
34
|
+
| `@include t.respond-between(md, lg)` | `@media (min-width: 768px) and (max-width: 1023.98px)` |
|
|
35
|
+
| `t.rem(24px)` | `1.5rem` — px, rem or a unitless px count in; `t.rem(20px, 10px)` states another root |
|
|
36
|
+
| `t.fluid(1rem, 2rem, 20rem, 80rem)` | `clamp(1rem, 0.6666666667rem + 1.6666666667vw, 2rem)` — the first size at a 20rem viewport, the second at 80rem (the default range), linear between. Takes px too; a size that shrinks is legal |
|
|
37
|
+
|
|
38
|
+
A rung is `sm md lg xl 2xl`, quoted or bare. Prefer a container query (`t.container` + `t.container-query`) to all three breakpoint mixins.
|
|
39
|
+
|
|
28
40
|
## The law
|
|
29
41
|
|
|
30
42
|
| Rule | Enforcement |
|
|
@@ -193,7 +205,7 @@ commit the diff. There is no hand-edited icon in the package.
|
|
|
193
205
|
|
|
194
206
|
## Works without JavaScript
|
|
195
207
|
|
|
196
|
-
|
|
208
|
+
These components are interactive without a client runtime, because the platform already
|
|
197
209
|
has the behaviour. Each is correct server-rendered, and the script layer only adds.
|
|
198
210
|
|
|
199
211
|
| Component | Platform base | The enhancement |
|
|
@@ -201,6 +213,8 @@ has the behaviour. Each is correct server-rendered, and the script layer only ad
|
|
|
201
213
|
| `Accordion` | `<details>` / `<summary>`; `exclusive` is the native `name` group | `onToggle` notification, after the browser has applied it |
|
|
202
214
|
| `Combobox` | `<input list>` + `<datalist>` — typing, filtering, keyboard, mobile | `onFilter`, debounced, for a live/server-side query |
|
|
203
215
|
| `InfiniteScroll` | a real `rel="next"` link to the next page | an `IntersectionObserver` sentinel that calls `onLoadMore` and intercepts the click |
|
|
216
|
+
| `Pagination` with `hrefFor` | `<a rel="next">` / `<a rel="prev">`, one per cursor | none — link mode attaches no handler |
|
|
217
|
+
| `Link appearance="button"` | an `<a href>` wearing Button's classes | none |
|
|
204
218
|
|
|
205
219
|
```tsx
|
|
206
220
|
<Accordion level={3} exclusive items={[{ id: 'ship', title: t('faq.ship'), panel: <p>…</p> }]} />
|
|
@@ -217,6 +231,47 @@ scripting off the control is a link, and a link needs somewhere to go. `Combobox
|
|
|
217
231
|
what it is given by `value` (`filterOptions` — case- and accent-insensitive, prefix first),
|
|
218
232
|
so the list is right on the first paint and after a form round-trip, not only once JS runs.
|
|
219
233
|
|
|
234
|
+
### A pager and a button that navigate
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
import { Link, Pagination } from '@ultimat3/ui';
|
|
238
|
+
import type { JSX } from 'solid-js';
|
|
239
|
+
|
|
240
|
+
interface PostsFooterProps {
|
|
241
|
+
prevCursor?: string | undefined;
|
|
242
|
+
nextCursor?: string | undefined;
|
|
243
|
+
/** Already translated: `t('posts.new')` at the call site. */
|
|
244
|
+
newLabel: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function PostsFooter(props: PostsFooterProps): JSX.Element {
|
|
248
|
+
return (
|
|
249
|
+
<footer>
|
|
250
|
+
<Link appearance="button" variant="secondary" size="sm" href="/posts/new">
|
|
251
|
+
{props.newLabel}
|
|
252
|
+
</Link>
|
|
253
|
+
<Pagination
|
|
254
|
+
prevCursor={props.prevCursor}
|
|
255
|
+
nextCursor={props.nextCursor}
|
|
256
|
+
hrefFor={(cursor, direction) =>
|
|
257
|
+
`/posts?${direction === 'next' ? 'after' : 'before'}=${cursor}`
|
|
258
|
+
}
|
|
259
|
+
/>
|
|
260
|
+
</footer>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
| Rule | Held by |
|
|
266
|
+
|---|---|
|
|
267
|
+
| `Pagination` is one mode per use: `hrefFor`, or `onCursor` / `onPage` / `page` / `totalPages` — never both | the type: `PaginationProps` is `PaginationLinkProps \| PaginationCallbackProps`, and each forbids the other's props |
|
|
268
|
+
| `DataTable` takes the same pair — `hrefFor` or `onCursor` — and hands it to its pager | the type: `DataTableProps<Row>` is `DataTableLinkProps<Row> \| DataTableCallbackProps<Row>` |
|
|
269
|
+
| a `DataTable` sort header follows the table's mode: an anchor at `sortHrefFor(next)` in link mode, a button calling `onSortChange` in callback mode; a linked table with no `sortHrefFor` renders the header as text | the same union — `sortHrefFor` lives on `DataTableLinkProps`, `onSortChange` on `DataTableCallbackProps` — and `DataTable.test.ts` |
|
|
270
|
+
| link mode pages by cursor only; a side with no cursor is a disabled button, never an anchor with nowhere to go | `Pagination.test.ts` |
|
|
271
|
+
| a control that navigates is a `Link`; `Button` has no `href` | `ButtonProps` |
|
|
272
|
+
| a button-link takes `variant` / `tone` / `size` / `fullWidth` / `iconStart` / `iconEnd`; a text link takes `underline` and `tone: 'accent' \| 'inherit'` | the type: `LinkProps` is `TextLinkProps \| ButtonLinkProps` |
|
|
273
|
+
| both elements wear ONE set of classes | `buttonClassKeys` (`button-classes.ts`), asked by `Button` and `Link` |
|
|
274
|
+
|
|
220
275
|
`debounce(fn, ms)` is exported on its own: trailing edge, `cancel()`, `flush()`, `pending()`.
|
|
221
276
|
Components cancel theirs on cleanup, so a filter never fires into a tree that is gone.
|
|
222
277
|
|
|
@@ -539,13 +594,13 @@ of truth for the one thing the server decides.
|
|
|
539
594
|
|
|
540
595
|
| Code | When |
|
|
541
596
|
|---|---|
|
|
542
|
-
| `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define — including a `defineTheme()` override of a role, radius or font slot that is not in the scale |
|
|
597
|
+
| `X_TOKEN_UNKNOWN` | a token role the SCSS source does not define — including a `defineTheme()` override of a role, radius or font slot that is not in the scale, and a breakpoint rung `respond-to` / `respond-down` / `respond-between` was handed that is not in `$breakpoints` (a Sass `@error`: the stylesheet does not compile) |
|
|
543
598
|
| `X_THEME_INVALID` | a theme other than `light` / `dark` |
|
|
544
599
|
| `X_UI_RUNTIME_MISSING` | a DOM render with no registered Solid runtime, `<UiProvider>` on the server, or `browserThemeEnv()` off-DOM. A server render with no runtime is **not** one of them — it gets `INERT_SOLID_RUNTIME` |
|
|
545
600
|
| `X_UI_FORM_PATH_INVALID` | a form field or control name the path grammar cannot read (`items.0.price`, `items[]`, `__proto__`), or two control names describing different shapes for one path (`user` beside `user.name`) |
|
|
546
601
|
| `X_UI_CONTRAST_INSUFFICIENT` | a `defineTheme()` palette whose resolved channels put a pairing in `CONTRAST_PAIRS` below WCAG 2.2 AA — 4.5:1 for text, 3:1 for the focus ring. Only pairings the brand changed are measured; the cause names the measured ratio and the required one |
|
|
547
602
|
| `X_UI_QR_CAPACITY` | a `<QrCode>` value over version 3's 42-byte ceiling (byte mode, error-correction level M). The encoder draws versions 1–3 only; the cause names the byte count and the ceiling |
|
|
548
|
-
| `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors, one dimension without the other, or no reserved box at all, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry) |
|
|
603
|
+
| `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors, one dimension without the other, or no reserved box at all, a heading level off 1–6, a `defineTheme()` value that is not a token value, an `<Icon>` glyph with a tag/attribute/colour outside `ICON_TAGS`, two `Accordion` items sharing an id, `InfiniteScroll` with `hasMore` and no `nextHref`, a negative `debounce` window, or (`As of 2026-08`) upstream icon data `bun run icons` refuses (not an object, no renderable nodes, an attribute value that is not glyph geometry), or — as a Sass `@error` — `t.rem()` / `t.fluid()` given a unit other than px or rem, `t.fluid()` given a viewport range that is empty or reversed, `respond-between` given `$from` at or above `$to` |
|
|
549
604
|
|
|
550
605
|
### Error classes
|
|
551
606
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.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",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"files": [
|
|
34
34
|
"src",
|
|
35
35
|
"!src/**/*.test.ts",
|
|
36
|
+
"!src/**/*-fixture.ts",
|
|
36
37
|
"CATALOG.md",
|
|
37
38
|
"CLAUDE.md",
|
|
38
39
|
"README.md",
|
|
@@ -48,10 +49,10 @@
|
|
|
48
49
|
"icons": "bun run src/icons/build-icons.ts"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@ultimat3/core": "
|
|
52
|
-
"@ultimat3/i18n": "
|
|
53
|
-
"@ultimat3/money": "
|
|
54
|
-
"@ultimat3/time": "
|
|
52
|
+
"@ultimat3/core": "23.0.0",
|
|
53
|
+
"@ultimat3/i18n": "23.0.0",
|
|
54
|
+
"@ultimat3/money": "23.0.0",
|
|
55
|
+
"@ultimat3/time": "23.0.0"
|
|
55
56
|
},
|
|
56
57
|
"peerDependencies": {
|
|
57
58
|
"solid-js": "^1.9.0"
|
|
@@ -46,12 +46,64 @@ export function headerComment(source: string): string {
|
|
|
46
46
|
return lines.join(' ').trim();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Every prop a caller may write for `<name>`: the members of `interface <name>`, its bases' first,
|
|
51
|
+
* or — where `<name>` is `type <name> = A | B`, one interface per mode — the modes merged.
|
|
52
|
+
*/
|
|
53
|
+
export function parseProps(source: string, typeName: string): PropDoc[] {
|
|
54
|
+
const modes = unionMembers(source, typeName);
|
|
55
|
+
if (modes === undefined) return interfaceProps(source, typeName);
|
|
56
|
+
return mergeModes(modes.map((mode) => interfaceProps(source, mode)));
|
|
57
|
+
}
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
/** `GridLinkProps<Row>` names the interface `GridLinkProps`. */
|
|
60
|
+
const withoutTypeArguments = (name: string): string => name.replace(/<[^>]*>/, '').trim();
|
|
61
|
+
|
|
62
|
+
/** `A`, `B` out of `export type <name> = A | B;`. `undefined` when `<name>` is not such an alias. */
|
|
63
|
+
function unionMembers(source: string, typeName: string): string[] | undefined {
|
|
64
|
+
const alias = new RegExp(`export type ${typeName}(?:<[^>]*>)?\\s*=\\s*([\\w\\s|<>]+);`).exec(
|
|
65
|
+
source,
|
|
66
|
+
);
|
|
67
|
+
return alias?.[1]
|
|
68
|
+
?.split('|')
|
|
69
|
+
.map((member) => withoutTypeArguments(member))
|
|
70
|
+
.filter((member) => member !== '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One row per prop across modes. Required only when EVERY mode requires it — a prop one mode
|
|
75
|
+
* forbids is optional to a reader choosing between them — and typed as each mode types it.
|
|
76
|
+
*/
|
|
77
|
+
function mergeModes(modes: readonly (readonly PropDoc[])[]): PropDoc[] {
|
|
78
|
+
const merged = new Map<string, PropDoc>();
|
|
79
|
+
for (const props of modes) {
|
|
80
|
+
for (const prop of props) {
|
|
81
|
+
const seen = merged.get(prop.name);
|
|
82
|
+
if (seen === undefined) {
|
|
83
|
+
merged.set(prop.name, prop);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
merged.set(prop.name, {
|
|
87
|
+
name: prop.name,
|
|
88
|
+
type: seen.type === prop.type ? seen.type : `${seen.type} | ${prop.type}`,
|
|
89
|
+
required: seen.required && prop.required,
|
|
90
|
+
doc: seen.doc === '' ? prop.doc : seen.doc,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...merged.values()].map((prop) => ({
|
|
95
|
+
...prop,
|
|
96
|
+
required: prop.required && modes.every((props) => props.some((p) => p.name === prop.name)),
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The members of `interface <name> { … }`, generics and all, after those of every base. */
|
|
101
|
+
function interfaceProps(source: string, interfaceName: string): PropDoc[] {
|
|
102
|
+
const declared = interfaceDeclaration(source, interfaceName);
|
|
103
|
+
if (declared === undefined) return [];
|
|
104
|
+
const { body, bases } = declared;
|
|
105
|
+
|
|
106
|
+
const own: PropDoc[] = [];
|
|
55
107
|
let doc: string[] = [];
|
|
56
108
|
let buffer = '';
|
|
57
109
|
let depth = 0;
|
|
@@ -68,11 +120,16 @@ export function parseProps(source: string, interfaceName: string): PropDoc[] {
|
|
|
68
120
|
// A member ends at a `;` that is not inside a function type or an inline object type.
|
|
69
121
|
if (depth > 0 || !buffer.endsWith(';')) continue;
|
|
70
122
|
const prop = toProp(buffer, doc.join(' ').trim());
|
|
71
|
-
if (prop !== undefined)
|
|
123
|
+
if (prop !== undefined) own.push(prop);
|
|
72
124
|
buffer = '';
|
|
73
125
|
doc = [];
|
|
74
126
|
}
|
|
75
|
-
|
|
127
|
+
|
|
128
|
+
const names = new Set(own.map((prop) => prop.name));
|
|
129
|
+
const inherited = bases
|
|
130
|
+
.flatMap((base) => interfaceProps(source, base))
|
|
131
|
+
.filter((prop) => !names.has(prop.name));
|
|
132
|
+
return [...inherited, ...own];
|
|
76
133
|
}
|
|
77
134
|
|
|
78
135
|
function isComment(line: string): boolean {
|
|
@@ -105,9 +162,13 @@ function toProp(declaration: string, doc: string): PropDoc | undefined {
|
|
|
105
162
|
const match = /^(?:readonly\s+)?('[^']+'|"[^"]+"|\w+)(\?)?:\s*([\s\S]+);$/.exec(declaration);
|
|
106
163
|
if (match === null) return undefined;
|
|
107
164
|
const [, rawName = '', optional, rawType = ''] = match;
|
|
165
|
+
const type = normaliseType(rawType);
|
|
166
|
+
// `hrefFor?: undefined` is how one mode of a union forbids another mode's prop: a member that
|
|
167
|
+
// exists so the prop CANNOT be written is not a prop.
|
|
168
|
+
if (type === '' || type === 'never') return undefined;
|
|
108
169
|
return {
|
|
109
170
|
name: rawName.replace(/^['"]|['"]$/g, ''),
|
|
110
|
-
type
|
|
171
|
+
type,
|
|
111
172
|
required: optional === undefined,
|
|
112
173
|
doc,
|
|
113
174
|
};
|
|
@@ -126,17 +187,29 @@ function normaliseType(type: string): string {
|
|
|
126
187
|
);
|
|
127
188
|
}
|
|
128
189
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Brace-matched so a nested object type does not end the interface early. `export` is optional:
|
|
192
|
+
* a base shared by two modes is a detail of the file, and its members are still the component's.
|
|
193
|
+
*/
|
|
194
|
+
function interfaceDeclaration(
|
|
195
|
+
source: string,
|
|
196
|
+
interfaceName: string,
|
|
197
|
+
): { readonly body: string; readonly bases: readonly string[] } | undefined {
|
|
198
|
+
const header = new RegExp(
|
|
199
|
+
`(?:export )?interface ${interfaceName}(?:<[^>]*>)?(?:\\s+extends\\s+([\\w\\s,<>]+?))?\\s*\\{`,
|
|
200
|
+
).exec(source);
|
|
132
201
|
if (header === null) return undefined;
|
|
202
|
+
const bases = (header[1] ?? '')
|
|
203
|
+
.split(',')
|
|
204
|
+
.map((base) => withoutTypeArguments(base))
|
|
205
|
+
.filter((base) => base !== '');
|
|
133
206
|
const start = header.index + header[0].length;
|
|
134
207
|
let depth = 1;
|
|
135
208
|
for (let i = start; i < source.length; i += 1) {
|
|
136
209
|
const char = source[i];
|
|
137
210
|
if (char === '{') depth += 1;
|
|
138
211
|
if (char === '}') depth -= 1;
|
|
139
|
-
if (depth === 0) return source.slice(start, i);
|
|
212
|
+
if (depth === 0) return { body: source.slice(start, i), bases };
|
|
140
213
|
}
|
|
141
214
|
return undefined;
|
|
142
215
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
lineHeightTokens,
|
|
14
14
|
radiusTokens,
|
|
15
15
|
spaceTokens,
|
|
16
|
+
strokeTokens,
|
|
16
17
|
zTokens,
|
|
17
18
|
} from '../tokens/tokens';
|
|
18
19
|
import type { ComponentDoc } from './parse-component';
|
|
@@ -99,6 +100,8 @@ ${scaleTable('Space — `--space-*`', spaceTokens)}
|
|
|
99
100
|
|
|
100
101
|
${scaleTable('Radius — `--radius-*`', radiusTokens)}
|
|
101
102
|
|
|
103
|
+
${scaleTable('Stroke — `--stroke-*`', strokeTokens)}
|
|
104
|
+
|
|
102
105
|
${scaleTable('Font size — `--text-*`', fontSizeTokens)}
|
|
103
106
|
|
|
104
107
|
${scaleTable('Font weight — `--weight-*`', fontWeightTokens)}
|
|
@@ -111,6 +114,23 @@ ${scaleTable('Easing — `--easing-*`', easingTokens)}
|
|
|
111
114
|
|
|
112
115
|
${scaleTable('Z-index — `--z-*`', zTokens)}
|
|
113
116
|
|
|
114
|
-
${scaleTable('Breakpoints —
|
|
117
|
+
${scaleTable('Breakpoints — `respond-to` · `respond-down` · `respond-between`', breakpointTokens)}
|
|
118
|
+
|
|
119
|
+
\`@include t.respond-to(md)\` is \`md\` and wider, \`t.respond-down(md)\` is narrower than \`md\`, and
|
|
120
|
+
\`t.respond-between(md, lg)\` is \`md\` up to \`lg\`. A max-width arm stops 0.02px under its rung, so
|
|
121
|
+
no width matches two of them.
|
|
122
|
+
|
|
123
|
+
### Computed lengths
|
|
124
|
+
|
|
125
|
+
${table(
|
|
126
|
+
['Function', 'Answers'],
|
|
127
|
+
[
|
|
128
|
+
['`t.rem(24px)`', '`1.5rem` — px, rem or a unitless px count in, rem out'],
|
|
129
|
+
[
|
|
130
|
+
'`t.fluid(1rem, 2rem, 20rem, 80rem)`',
|
|
131
|
+
'one `clamp()`: the first size at a 20rem viewport, the second at 80rem, linear between',
|
|
132
|
+
],
|
|
133
|
+
],
|
|
134
|
+
)}
|
|
115
135
|
`;
|
|
116
136
|
}
|
|
@@ -26,6 +26,17 @@
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// The second series — what went wrong, stacked on what went right.
|
|
30
|
+
.secondary {
|
|
31
|
+
fill: t.role('danger');
|
|
32
|
+
opacity: 0.6;
|
|
33
|
+
transition: opacity t.duration(fast) t.easing(out);
|
|
34
|
+
|
|
35
|
+
&:hover {
|
|
36
|
+
opacity: 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
.last {
|
|
30
41
|
opacity: 1;
|
|
31
42
|
}
|
|
@@ -36,7 +47,7 @@
|
|
|
36
47
|
// SVG text scales with the chart: below `sm` these shrink to unreadable specks, and an
|
|
37
48
|
// unreadable label is noise. Every bar still carries its `<title>`.
|
|
38
49
|
display: none;
|
|
39
|
-
font-size: 8px;
|
|
50
|
+
font-size: t.rem(8px);
|
|
40
51
|
fill: t.role('fg-muted');
|
|
41
52
|
|
|
42
53
|
@include t.respond-to(sm) {
|