@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.
- package/CATALOG.md +119 -15
- package/CLAUDE.md +17 -8
- package/README.md +119 -33
- package/package.json +6 -6
- package/src/a11y.ts +27 -13
- 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/CommandPalette.module.scss +8 -5
- package/src/components/Container.tsx +46 -9
- package/src/components/DataTable.module.scss +190 -0
- package/src/components/DataTable.tsx +161 -51
- 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.module.scss +3 -1
- package/src/components/Dropzone.tsx +17 -8
- package/src/components/ErrorState.tsx +39 -12
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- 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/Meter.tsx +3 -3
- package/src/components/PageHeader.tsx +5 -2
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +14 -7
- 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.module.scss +6 -0
- package/src/components/Tabs.tsx +2 -2
- package/src/components/Text.tsx +41 -15
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Toaster.tsx +1 -1
- package/src/components/Toolbar.tsx +7 -6
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/app-shell-view.ts +6 -4
- package/src/components/bar-chart-view.ts +11 -6
- 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 +32 -13
- 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/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/money-view.ts +2 -7
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +32 -14
- package/src/form/use-form.ts +3 -3
- package/src/global.scss +9 -0
- package/src/icons/build-icons.ts +109 -24
- package/src/index.ts +47 -15
- package/src/jsx-probe.ts +5 -4
- package/src/theme/brand-declarations.ts +113 -0
- package/src/theme/brand.ts +104 -96
- 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 +36 -11
- package/src/toast/toast-state.ts +0 -0
- 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/fake-dom.ts +0 -206
- package/src/sass-probe.ts +0 -55
- package/src/theme/inline-script.ts +0 -49
package/README.md
CHANGED
|
@@ -20,10 +20,32 @@ channels** so `rgb(var(--color-accent) / 0.12)` gives a tint with no extra token
|
|
|
20
20
|
| `scrim` | modal backdrops |
|
|
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
|
+
| `chart-1` … `chart-8` | categorical chart series, in assignment order (`CHART_ROLES`) |
|
|
24
|
+
|
|
25
|
+
The chart series are Okabe–Ito-derived and **measured, not picked** (`As of 2026-10`): each clears
|
|
26
|
+
3:1 against all four surfaces in both themes (WCAG 1.4.11, in `CONTRAST_PAIRS`), and every pair
|
|
27
|
+
stays at least `CHART_DISTINCT_MIN` (ΔE 10) apart under protanopia, deuteranopia and tritanopia
|
|
28
|
+
simulation (`colour-vision.test.ts`). A ninth series reuses `chart-1` with a second encoding — a
|
|
29
|
+
dash, a pattern, a label — never a ninth hue.
|
|
23
30
|
|
|
24
31
|
Scales: `--space-*` (4px base), `--stroke-*` (line weights: `hairline thick heavy`), `--text-*` (fluid `clamp()`), `--radius-*`,
|
|
25
32
|
`--shadow-*` (themed — dark gets deeper, higher-alpha shadows), `--duration-*`,
|
|
26
|
-
`--easing-*`, `--z-*` (named ladder, no magic numbers).
|
|
33
|
+
`--easing-*`, `--z-*` (named ladder, no magic numbers), `--touch-target` (44px, as `2.75rem`).
|
|
34
|
+
|
|
35
|
+
| Shadow rung (`t.shadow('…')`) | What it is |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `xs` `sm` `md` `lg` `xl` | elevation: a drop shadow, deeper and darker in dark |
|
|
38
|
+
| `glow-sm` `glow-md` `glow-lg` | a halo drawn from the **accent** role, no offset — a live tile, a focused panel, a selected series |
|
|
39
|
+
| `tinted-sm` `tinted-md` `tinted-lg` | coloured elevation: a drop shadow in the accent hue |
|
|
40
|
+
|
|
41
|
+
Glow and tinted rungs read `--color-accent`, so a brand's accent recolours them with no second
|
|
42
|
+
override. `SHADOW_NAMES` / `ShadowName` is the list.
|
|
43
|
+
|
|
44
|
+
| Font slot | Default stack | Read by |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--font-sans` | `system-ui` … | body copy |
|
|
47
|
+
| `--font-mono` | `ui-monospace` … | `code` and `pre`, error frames |
|
|
48
|
+
| `--font-data` | `ui-monospace`, `'Cascadia Mono'`, `'JetBrains Mono'` … | `@include t.data-text` — figures in tables, axes and stat tiles, ids, `Kbd` |
|
|
27
49
|
|
|
28
50
|
Authoring helpers, from the same `@use '@ultimat3/ui/tokens' as t` — an app writes no mixin of its own for any of them:
|
|
29
51
|
|
|
@@ -34,6 +56,11 @@ Authoring helpers, from the same `@use '@ultimat3/ui/tokens' as t` — an app wr
|
|
|
34
56
|
| `@include t.respond-between(md, lg)` | `@media (min-width: 768px) and (max-width: 1023.98px)` |
|
|
35
57
|
| `t.rem(24px)` | `1.5rem` — px, rem or a unitless px count in; `t.rem(20px, 10px)` states another root |
|
|
36
58
|
| `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 |
|
|
59
|
+
| `@include t.data-text` | `font-family: var(--font-data); font-variant-numeric: tabular-nums` — columns of figures that do not wobble |
|
|
60
|
+
| `@include t.touch-target` | `min-block-size` + `min-inline-size` of `var(--touch-target)` — the box itself is hittable |
|
|
61
|
+
| `@include t.touch-target($extend: true)` | the box keeps its size; a centred `::after` grows the HIT AREA to the target — a chart point, a dense icon row |
|
|
62
|
+
| `@include t.glow-edge` | `box-shadow: inset 0 0 0 var(--stroke-hairline) <accent / 0.55>, var(--shadow-glow-sm)` — `t.glow-edge('glow-lg', 0.8)` picks rung and ring alpha |
|
|
63
|
+
| `@include t.grid-texture` | a faint accent grid on a `t.space(6)` pitch with scanlines over it, as `background-image` only; `t.grid-texture(t.space(4), 0.1, false)` drops the scanlines. Withdrawn under `prefers-contrast: more` and `forced-colors: active`; animates nothing |
|
|
37
64
|
|
|
38
65
|
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
66
|
|
|
@@ -44,10 +71,10 @@ A rung is `sm md lg xl 2xl`, quoted or bare. Prefer a container query (`t.contai
|
|
|
44
71
|
| No raw colours | a hex or `rgb()` literal in a component stylesheet fails review; `tokens.test.ts` asserts the shared SCSS is hex-free |
|
|
45
72
|
| No Tailwind | not a dependency, not a config, not an escape hatch |
|
|
46
73
|
| No CSS-in-JS | styles are `Foo.module.scss` next to `Foo.tsx`, compiled at build |
|
|
47
|
-
| No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet |
|
|
74
|
+
| No physical directions | `margin-inline`, `inset-inline-start`, `text-align: start` — RTL needs no second stylesheet. Centring is `inset-inline` + `margin-inline: auto`, never a 50% logical inset beside a physical −50% translate, and an edge shadow's physical x offset gets a `[dir='rtl']` mirror (`components/rtl-sheets.test.ts`) |
|
|
48
75
|
| No hardcoded strings | labels are props, or `t()` through `UI_KEYS` |
|
|
49
76
|
| One token source | `src/tokens/*.scss` is canonical; `tokens.ts` mirrors it and `x verify` fails on drift |
|
|
50
|
-
| AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders |
|
|
77
|
+
| AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders, chart series (3:1 on every surface) |
|
|
51
78
|
|
|
52
79
|
## Contrast
|
|
53
80
|
|
|
@@ -118,16 +145,16 @@ review comment. Three properties are structural, not documented:
|
|
|
118
145
|
|
|
119
146
|
## Toasts
|
|
120
147
|
|
|
121
|
-
`Toast` and `ToastRegion` render; `
|
|
148
|
+
`Toast` and `ToastRegion` render; `toastStore()` is the queue behind them, and `<Toaster>` is
|
|
122
149
|
the one way to draw it.
|
|
123
150
|
|
|
124
151
|
```tsx
|
|
125
|
-
import {
|
|
152
|
+
import { toastStore, Toaster } from '@ultimat3/ui';
|
|
126
153
|
|
|
127
154
|
declare const t: (key: string) => string;
|
|
128
155
|
declare const restore: () => void;
|
|
129
156
|
|
|
130
|
-
const toasts =
|
|
157
|
+
const toasts = toastStore();
|
|
131
158
|
toasts.show({ message: t('post.saved'), tone: 'success' });
|
|
132
159
|
toasts.show({ message: t('post.deleted'), action: { label: t('undo'), onAction: restore } });
|
|
133
160
|
|
|
@@ -157,11 +184,32 @@ Four composites cover the frame of an app screen. Below them are `Container`,
|
|
|
157
184
|
| `Section` | a labelled `section` with a real heading and `aria-labelledby` | second-level structure inside a page |
|
|
158
185
|
| `Toolbar` | `role="toolbar"` strip, start + end slots, arrow-key roving between its buttons (`As of 2026-08`) | filters and actions above a table or list |
|
|
159
186
|
|
|
160
|
-
`AppShell` holds no state: below `md` the sidebar
|
|
161
|
-
|
|
187
|
+
`AppShell` holds no state: below `md` the sidebar is a native `popover` panel behind a menu
|
|
188
|
+
button (`menuLabel`), opened, closed and dismissed by the browser with no script; where `popover`
|
|
189
|
+
is unsupported it stays a band above the content. A modal off-canvas panel is still `Drawer`.
|
|
162
190
|
Heading levels are props (`headingTag`, `nextHeadingLevel`), so a nested `Section`
|
|
163
191
|
never skips a level.
|
|
164
192
|
|
|
193
|
+
### Data display
|
|
194
|
+
|
|
195
|
+
Server-rendered, no island and no JS: a chart costs a route nothing until it asks to be focusable.
|
|
196
|
+
Every chart stands in a `ChartFrame` — a `figure`, its caption, a legend and a visually-hidden data
|
|
197
|
+
table — and every series has a dash and a marker as well as a colour (`chart-1` … `chart-8`).
|
|
198
|
+
|
|
199
|
+
| Component | Renders | Use for |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| `LineChart` / `AreaChart` | multi-series line on a 1-2-5 axis (filled for `AreaChart`); `null` is a gap | a trend over keys |
|
|
202
|
+
| `BarChart` | bars, an optional stacked second series with a legend | a count per key |
|
|
203
|
+
| `DonutChart` | ring segments, a centre figure, a legend with value and share | parts of a whole |
|
|
204
|
+
| `Gauge` | a 270° `meter` dial with a tone and a visible label | one value against its max |
|
|
205
|
+
| `ChartFrame` | the figure, caption, legend and data table every chart stands in | a custom chart |
|
|
206
|
+
| `niceTicks` · `chartTable` · `seriesStyle` | axis ticks, the fallback table, a series' dash and marker | building a custom chart |
|
|
207
|
+
| `DataTable` | sort, cursor paging, states; `narrow: 'cards'` (default) collapses rows to labelled cards below `sm`; `Column.priority` hides a column behind a per-row disclosure, never losing its value; `stickyFirstColumn` | any list of records |
|
|
208
|
+
| `Table` | a captioned table with a sticky header; `stickyFirstColumn` | static tabular data |
|
|
209
|
+
| `InlineBar` | a caller-formatted figure with a decorative proportional bar | a number in a table cell |
|
|
210
|
+
| `StatTile` / `Meter` / `Sparkline` | a KPI with its delta; a bar against a max; an inline mini line | the KPI row |
|
|
211
|
+
|
|
212
|
+
|
|
165
213
|
```tsx
|
|
166
214
|
<AppShell header={<Toolbar label={t('nav.main')}>{nav}</Toolbar>} sidebar={<SideNav />}>
|
|
167
215
|
<PageHeader
|
|
@@ -281,8 +329,13 @@ Components cancel theirs on cleanup, so a filter never fires into a tree that is
|
|
|
281
329
|
override, no forked package, no second entry point — one call, validated, rendered
|
|
282
330
|
as the custom properties that beat `theme.scss` at every specificity level it emits.
|
|
283
331
|
|
|
332
|
+
An app declares it once, as `export const brand` in `apps/web/shared/theme.ts`. `x dev`, the
|
|
333
|
+
container, `x build`'s static export and `/admin` inline it after the surface stylesheet, with its
|
|
334
|
+
`sha256` in `style-src`; with no such module nothing is emitted. There is nothing to paste.
|
|
335
|
+
|
|
284
336
|
```ts
|
|
285
|
-
|
|
337
|
+
// apps/web/shared/theme.ts
|
|
338
|
+
import { defineTheme } from '@ultimat3/ui';
|
|
286
339
|
|
|
287
340
|
export const brand = defineTheme({
|
|
288
341
|
colors: {
|
|
@@ -292,27 +345,56 @@ export const brand = defineTheme({
|
|
|
292
345
|
radius: { md: '0.125rem', lg: '0.25rem' },
|
|
293
346
|
font: { sans: "Inter, system-ui, sans-serif" },
|
|
294
347
|
});
|
|
295
|
-
|
|
296
|
-
// in <head>, AFTER global.scss
|
|
297
|
-
`${brandStyleTag(brand)}`;
|
|
298
|
-
|
|
299
|
-
// …and the one source that admits it under the framework's locked CSP. The baseline is
|
|
300
|
-
// `style-src 'self' 'sha256-…'` with no 'unsafe-inline', so a tag whose hash the header does
|
|
301
|
-
// not carry is a stylesheet the browser parses zero rules out of.
|
|
302
|
-
`Content-Security-Policy: style-src 'self' ${brandStyleCspSource(brand)}`;
|
|
303
348
|
```
|
|
304
349
|
|
|
350
|
+
`brandStyleTag(brand)` and `brandStyleCspSource(brand)` are what the framework emits — exported
|
|
351
|
+
for a host that renders documents itself.
|
|
352
|
+
|
|
305
353
|
| Slot | Accepts | Refused with |
|
|
306
354
|
|---|---|---|
|
|
307
355
|
| `colors.light` / `colors.dark` | any `ColorRole`, as `R G B` channels | `X_TOKEN_UNKNOWN` for the role, `X_UI_INVALID_VALUE` for the value |
|
|
308
356
|
| `radius` | any `RadiusName`, as a bare CSS length | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
|
|
309
|
-
| `font` | `sans`, `mono`, as a `font-family` list | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
|
|
357
|
+
| `font` | `sans`, `mono`, `data`, as a `font-family` list | `X_TOKEN_UNKNOWN` / `X_UI_INVALID_VALUE` |
|
|
358
|
+
| `shadows.light` / `shadows.dark` | any `ShadowName`, as a `box-shadow`: comma-separated layers of lengths plus `rgb(R G B / a)` or `rgb(var(--color-<role>) / a)`, or `none` | `X_TOKEN_UNKNOWN` for the rung, `X_UI_INVALID_VALUE` for the value |
|
|
359
|
+
| `preset` | a `THEME_PRESETS` name — `'scifi'` | `X_UI_INVALID_VALUE` |
|
|
310
360
|
|
|
311
361
|
Values are validated, never escaped: the output goes into a `<style>` element, so
|
|
312
362
|
anything carrying `;`, `}` or `</style>` is a refusal at the app's entry point rather
|
|
313
363
|
than a CSS injection. Every component in the system follows the override — they only
|
|
314
364
|
ever read the roles, never a colour.
|
|
315
365
|
|
|
366
|
+
### The sci-fi preset
|
|
367
|
+
|
|
368
|
+
`defineTheme({ preset: 'scifi' })` swaps the whole palette for a dashboard look: near-black
|
|
369
|
+
panels in dark (`bg` `5 7 13`), cool paper in light, a cyan `accent` whose `accent-strong` (hover)
|
|
370
|
+
is magenta, its own eight chart series, sharper radii, and — dark only — every elevation rung
|
|
371
|
+
redrawn as an accent hairline plus a halo, so each card and popover has a glowing edge with no
|
|
372
|
+
stylesheet of yours. It is a **base**: every other slot layers onto it role by role, and the
|
|
373
|
+
merged palette is what `CONTRAST_PAIRS` measures.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import { defineTheme } from '@ultimat3/ui';
|
|
377
|
+
|
|
378
|
+
export const brand = defineTheme({
|
|
379
|
+
preset: 'scifi',
|
|
380
|
+
colors: { dark: { 'chart-1': '120 220 255' } }, // scifi, with your own first series
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
The preset ships tokens only (axiom 8). The texture and the edge are yours to place:
|
|
385
|
+
|
|
386
|
+
```scss
|
|
387
|
+
@use '@ultimat3/ui/tokens' as t;
|
|
388
|
+
|
|
389
|
+
.main { background-color: t.role('bg'); @include t.grid-texture; }
|
|
390
|
+
.tile { @include t.surface; @include t.glow-edge; }
|
|
391
|
+
.tile[aria-current='true'] { @include t.glow-edge('glow-lg', 0.8); }
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
A dashboard that should open dark whatever the OS says sets the app default theme
|
|
395
|
+
(`data-theme-default`, [Theme resolution](#theme-resolution)) — the preset never forces a theme.
|
|
396
|
+
Like every brand, export it from `apps/web/shared/theme.ts` and the framework ships it with its CSP hash.
|
|
397
|
+
|
|
316
398
|
## The two render paths
|
|
317
399
|
|
|
318
400
|
Every component renders on the server. Half of them read the ambient presentation context —
|
|
@@ -358,7 +440,7 @@ import '../../shared/global'; // `shared/global.scss` is the app's one `@use '@u
|
|
|
358
440
|
|
|
359
441
|
```tsx
|
|
360
442
|
// A client entry — an island's `mount()`, or a hydrated app shell. In this order, once.
|
|
361
|
-
import {
|
|
443
|
+
import { catalogTranslator } from '@ultimat3/i18n';
|
|
362
444
|
import { setSolidRuntime, UiProvider } from '@ultimat3/ui';
|
|
363
445
|
import type { JSX } from 'solid-js';
|
|
364
446
|
import {
|
|
@@ -395,7 +477,7 @@ export function mount(el: HTMLElement, props: Props): void {
|
|
|
395
477
|
locale={props.locale}
|
|
396
478
|
timeZone={props.timeZone}
|
|
397
479
|
currency={props.currency}
|
|
398
|
-
t={
|
|
480
|
+
t={catalogTranslator(props.strings, props.locale)}
|
|
399
481
|
>
|
|
400
482
|
{props.tree}
|
|
401
483
|
</UiProvider>
|
|
@@ -406,7 +488,7 @@ export function mount(el: HTMLElement, props: Props): void {
|
|
|
406
488
|
```
|
|
407
489
|
|
|
408
490
|
**The prop is `t`, it takes a `Translator`, and omitting it is not neutral.** `<UiProvider>` with
|
|
409
|
-
no `t` falls back to `fallbackTranslator(locale)` — `
|
|
491
|
+
no `t` falls back to `fallbackTranslator(locale)` — `catalogTranslator({}, locale)`, an **empty**
|
|
410
492
|
catalog — so every built-in string in the tree renders its key: `<Dialog>`'s close button reads
|
|
411
493
|
`⟦ui.close⟧`, `<Field>`'s marker `⟦ui.required⟧`. The keys are `UI_KEYS`, and they live in the
|
|
412
494
|
framework catalog the SERVER has registered; a browser chunk has none, which is why
|
|
@@ -462,7 +544,7 @@ is a type error rather than a review comment.
|
|
|
462
544
|
|
|
463
545
|
| Prop | Emitted |
|
|
464
546
|
|---|---|
|
|
465
|
-
| `variants` | `srcset`, descriptors derived and ordered ascending (`
|
|
547
|
+
| `variants` | `srcset`, descriptors derived and ordered ascending (`variantSrcset`) |
|
|
466
548
|
| `sources` | `{ avif?, webp? }` width lists → `<source type="image/avif">`, then `image/webp`, before the `<img>` (`sourceSetsFor`) |
|
|
467
549
|
| `sizes` | `sizes`, verbatim, on the `<img>` and every `<source>` |
|
|
468
550
|
| `priority` | `loading="eager"` + `fetchpriority="high"`; otherwise `lazy` + `auto`, always `decoding="async"` |
|
|
@@ -496,14 +578,15 @@ one politeness — `politeness="assertive"` for a region that carries errors alo
|
|
|
496
578
|
|
|
497
579
|
## Theme resolution
|
|
498
580
|
|
|
499
|
-
`explicit choice in localStorage` → `
|
|
500
|
-
`
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
581
|
+
`explicit choice in localStorage` → `the theme already on <html>` → `defaultTheme(env)`: the
|
|
582
|
+
app's `theme.defaultMode` (`data-theme-default`), and the OS only when that is `'system'` or
|
|
583
|
+
absent. The framework's boot inlines `themeScript({ fallback })` from
|
|
584
|
+
`@ultimat3/render` and stamps `data-theme` before first paint, and `resolveTheme()` reads it back
|
|
585
|
+
through `ThemeEnv.current()` — so with `defaultMode: 'dark'` on a light-OS machine the first toggle
|
|
586
|
+
flips to light rather than writing the dark already on screen. `setTheme()` persists,
|
|
587
|
+
`clearTheme()` forgets and returns to the app default (`data-theme-default`, which the boot stamps
|
|
588
|
+
from `theme.defaultMode`; the OS for `'system'`), and the OS listener applies only while nothing is
|
|
589
|
+
stored and that default is `'system'`.
|
|
507
590
|
|
|
508
591
|
## Forms bound to an action's input schema
|
|
509
592
|
|
|
@@ -582,9 +665,12 @@ refuses the submit again because Enter in a text field touches no button at all.
|
|
|
582
665
|
|
|
583
666
|
A failed submit focuses the **first invalid control** (`form.firstInvalidField()`, in declaration
|
|
584
667
|
order — a server may report the last field first), and the announced summary keeps the focus only
|
|
585
|
-
when the rejection names no control at all. `
|
|
586
|
-
|
|
587
|
-
|
|
668
|
+
when the rejection names no control at all. Focus moves once per failure: a `touch()` or `edit()`
|
|
669
|
+
afterwards re-renders the form but never pulls focus back, so the user can work down the list.
|
|
670
|
+
`form.touch(path)` and `form.edit(path, value)` record touched and dirty against the binding's
|
|
671
|
+
`initial`; an empty control and an absent baseline are the same thing, so deleting what you just
|
|
672
|
+
typed leaves the form clean. A successful submit clears dirty except for the fields edited while it
|
|
673
|
+
was in flight — the server never saw those, so a navigation guard still has something to protect.
|
|
588
674
|
|
|
589
675
|
Blur-time validation is deliberately **absent**: this binding is server-authoritative, the local
|
|
590
676
|
parse's value is already discarded, and a client-side "is this field valid" would be a second source
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
42
|
"engines": {
|
|
43
|
-
"bun": ">=1.4.
|
|
43
|
+
"bun": ">=1.4.2"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
@@ -49,10 +49,10 @@
|
|
|
49
49
|
"icons": "bun run src/icons/build-icons.ts"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@ultimat3/core": "
|
|
53
|
-
"@ultimat3/i18n": "
|
|
54
|
-
"@ultimat3/money": "
|
|
55
|
-
"@ultimat3/time": "
|
|
52
|
+
"@ultimat3/core": "25.0.0",
|
|
53
|
+
"@ultimat3/i18n": "25.0.0",
|
|
54
|
+
"@ultimat3/money": "25.0.0",
|
|
55
|
+
"@ultimat3/time": "25.0.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"solid-js": "^1.9.0"
|
package/src/a11y.ts
CHANGED
|
@@ -20,27 +20,41 @@ export const FOCUSABLE_SELECTOR = [
|
|
|
20
20
|
].join(',');
|
|
21
21
|
|
|
22
22
|
let idCounter = 0;
|
|
23
|
+
let islandScope: string | undefined;
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
26
|
+
* The part of an id that tells two copies of this module apart. Each island is its own bundle by
|
|
27
|
+
* default (`islands.sharedChunks` off — measured: every island chunk inlines this counter; with it
|
|
28
|
+
* on, one shared chunk holds it), so each island counts from zero and two of them both minted
|
|
29
|
+
* `field-1`. In a browser every copy draws its own scope once; a server render has one copy and no
|
|
30
|
+
* scope, so a document's ids carry no random part.
|
|
31
|
+
*/
|
|
32
|
+
function scope(): string {
|
|
33
|
+
if (typeof document === 'undefined') return '';
|
|
34
|
+
if (islandScope === undefined) {
|
|
35
|
+
const [draw = 0] = crypto.getRandomValues(new Uint32Array(1));
|
|
36
|
+
islandScope = draw.toString(36);
|
|
37
|
+
}
|
|
38
|
+
return `${islandScope}-`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Unique id for label/description wiring. Prefixed so a mismatch is obvious in the DOM.
|
|
27
43
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* (see CLAUDE.md), so nothing re-renders a server tree yet. Making it survive needs a
|
|
33
|
-
* RENDER-SCOPED counter, which means a seam in `@ultimat3/render` (or a `SolidRuntime` member);
|
|
34
|
-
* resetting this one per request would only move the collision. Do not paper over it here.
|
|
44
|
+
* On the server it comes from a PROCESS-wide counter: every id in one document is distinct, which
|
|
45
|
+
* is all `for`/`aria-describedby` need, but two renders of one page mint different numbers — a
|
|
46
|
+
* render-scoped counter needs a seam in `@ultimat3/render`. An island never reuses a server id: it
|
|
47
|
+
* replaces its shell rather than hydrating it, and its own ids carry its scope (above).
|
|
35
48
|
*/
|
|
36
49
|
export function useId(prefix = 'u'): string {
|
|
37
50
|
idCounter += 1;
|
|
38
|
-
return `${prefix}-${idCounter.toString(36)}`;
|
|
51
|
+
return `${prefix}-${scope()}${idCounter.toString(36)}`;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
/** Test-only: make id assertions deterministic. */
|
|
42
|
-
export function
|
|
55
|
+
export function resetUseIdCounter(): void {
|
|
43
56
|
idCounter = 0;
|
|
57
|
+
islandScope = undefined;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
/**
|
|
@@ -76,7 +90,7 @@ export interface FocusTrap {
|
|
|
76
90
|
* branch that pulls it back was unreachable by construction. Every branch still tests
|
|
77
91
|
* `root.contains`, so the trap only acts while it is the active one.
|
|
78
92
|
*/
|
|
79
|
-
export function
|
|
93
|
+
export function focusTrap(root: HTMLElement): FocusTrap {
|
|
80
94
|
let previous: HTMLElement | null = null;
|
|
81
95
|
|
|
82
96
|
/**
|
|
@@ -167,7 +181,7 @@ export function nextRovingIndex(
|
|
|
167
181
|
}
|
|
168
182
|
|
|
169
183
|
/** Wire a roving group: one tabbable item, arrows move focus and selection. */
|
|
170
|
-
export function
|
|
184
|
+
export function rovingTabindex(
|
|
171
185
|
getItems: () => readonly HTMLElement[],
|
|
172
186
|
options: RovingOptions = {},
|
|
173
187
|
): (event: KeyboardEvent) => void {
|
|
@@ -5,8 +5,8 @@ import type { ComponentDoc } from './parse-component';
|
|
|
5
5
|
import { parseComponents } from './parse-component';
|
|
6
6
|
import { renderCatalog } from './render-catalog';
|
|
7
7
|
|
|
8
|
-
const COMPONENTS_DIR = new URL('../components/', import.meta.url)
|
|
9
|
-
export const CATALOG_PATH = new URL('../../CATALOG.md', import.meta.url)
|
|
8
|
+
const COMPONENTS_DIR = Bun.fileURLToPath(new URL('../components/', import.meta.url));
|
|
9
|
+
export const CATALOG_PATH = Bun.fileURLToPath(new URL('../../CATALOG.md', import.meta.url));
|
|
10
10
|
|
|
11
11
|
/** Alphabetical by file, so the page's order is the order an agent scans for a name. */
|
|
12
12
|
export async function collectComponents(): Promise<ComponentDoc[]> {
|
|
@@ -11,6 +11,7 @@ import type { AccordionSection } from './accordion-view';
|
|
|
11
11
|
import { accordionOpenIds } from './accordion-view';
|
|
12
12
|
import type { HeadingLevel } from './heading-level';
|
|
13
13
|
import { headingTag } from './heading-level';
|
|
14
|
+
import { headingNode } from './heading-node';
|
|
14
15
|
import { Icon } from './Icon';
|
|
15
16
|
|
|
16
17
|
export interface AccordionItem extends AccordionSection {
|
|
@@ -39,7 +40,8 @@ export interface AccordionProps {
|
|
|
39
40
|
export function Accordion(props: AccordionProps): JSX.Element {
|
|
40
41
|
const base = useId('accordion');
|
|
41
42
|
const open = accordionOpenIds(props.items, props.exclusive === true);
|
|
42
|
-
|
|
43
|
+
// Resolved once, so an off-scale level throws `X_UI_INVALID_VALUE` here as it always did.
|
|
44
|
+
const heading = props.level === undefined ? undefined : headingTag(props.level);
|
|
43
45
|
|
|
44
46
|
return (
|
|
45
47
|
<div class={cx(styles['accordion'], props.class)}>
|
|
@@ -53,7 +55,11 @@ export function Accordion(props: AccordionProps): JSX.Element {
|
|
|
53
55
|
>
|
|
54
56
|
<summary class={styles['summary']}>
|
|
55
57
|
<Icon glyph={iconChevronDown} class={styles['marker']} size="sm" />
|
|
56
|
-
|
|
58
|
+
{heading === undefined ? (
|
|
59
|
+
<span class={styles['title']}>{item.title}</span>
|
|
60
|
+
) : (
|
|
61
|
+
headingNode(heading, { class: styles['title'] }, () => item.title)
|
|
62
|
+
)}
|
|
57
63
|
</summary>
|
|
58
64
|
<div class={styles['panel']}>{item.panel}</div>
|
|
59
65
|
</details>
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// writing mode is handled by the grid itself, so RTL needs no second rule.
|
|
5
5
|
.shell {
|
|
6
6
|
display: grid;
|
|
7
|
+
// `minmax(0, …)`, never the implicit `auto`: an auto track is at least its content's min-content
|
|
8
|
+
// width, so a header that did not shrink laid a 390px phone out 429px wide.
|
|
9
|
+
grid-template-columns: minmax(0, 1fr);
|
|
7
10
|
grid-template-areas: 'header' 'sidebar' 'main' 'footer';
|
|
8
11
|
grid-template-rows: auto auto 1fr auto;
|
|
9
12
|
min-block-size: 100dvh;
|
|
@@ -51,13 +54,66 @@
|
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
// The bar the banner sits in: the menu button (below `md`) and the app's header, side by side.
|
|
58
|
+
.top {
|
|
59
|
+
display: flex;
|
|
60
|
+
min-inline-size: 0;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: t.space(2);
|
|
63
|
+
grid-area: header;
|
|
64
|
+
padding-inline-start: t.space(2);
|
|
65
|
+
border-block-end: 1px solid t.role('line');
|
|
66
|
+
background: t.role('surface');
|
|
67
|
+
|
|
68
|
+
@include t.respond-to(md) {
|
|
69
|
+
padding-inline-start: 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.menuOnly {
|
|
74
|
+
display: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
.header {
|
|
55
78
|
@include t.row(t.space(4), center, space-between);
|
|
56
|
-
|
|
79
|
+
flex: 1;
|
|
80
|
+
min-inline-size: 0;
|
|
57
81
|
padding-block: t.space(3);
|
|
58
82
|
padding-inline: t.space(4);
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Hidden unless the engine can open the panel it names: a button that does nothing is worse than
|
|
86
|
+
// the band an engine without popovers still shows. A touch target: this bar exists for a phone.
|
|
87
|
+
.menuButton,
|
|
88
|
+
.close {
|
|
89
|
+
@include t.focus-ring;
|
|
90
|
+
@include t.touch-target;
|
|
91
|
+
display: none;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
gap: t.space(2);
|
|
95
|
+
padding-inline: t.space(3);
|
|
96
|
+
border: 0;
|
|
97
|
+
border-radius: t.radius(md);
|
|
98
|
+
background: none;
|
|
99
|
+
color: t.role('fg-strong');
|
|
100
|
+
font: inherit;
|
|
101
|
+
cursor: pointer;
|
|
102
|
+
|
|
103
|
+
&:hover {
|
|
104
|
+
background: t.role('bg-soft');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.close {
|
|
109
|
+
margin-inline-start: auto;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// On a phone the menu button is its icon; the label stays in the accessibility tree as its name.
|
|
113
|
+
.menuLabel {
|
|
114
|
+
@include t.respond-down(sm) {
|
|
115
|
+
@include t.visually-hidden;
|
|
116
|
+
}
|
|
61
117
|
}
|
|
62
118
|
|
|
63
119
|
.sticky {
|
|
@@ -66,13 +122,23 @@
|
|
|
66
122
|
inset-block-start: 0;
|
|
67
123
|
}
|
|
68
124
|
|
|
69
|
-
//
|
|
125
|
+
// From `md` the sidebar is the inline-start column; below it, the popover panel further down.
|
|
126
|
+
// Every `[popover]` gets a UA style — fixed, centred, bordered, hidden while closed — so the
|
|
127
|
+
// in-flow box undoes each, and is what an engine without popovers shows at every width.
|
|
70
128
|
.sidebar {
|
|
71
129
|
@include t.custom-scrollbar;
|
|
130
|
+
display: block;
|
|
131
|
+
position: static;
|
|
132
|
+
inset: auto;
|
|
133
|
+
inline-size: auto;
|
|
134
|
+
block-size: auto;
|
|
135
|
+
margin: 0;
|
|
72
136
|
grid-area: sidebar;
|
|
73
137
|
padding: t.space(4);
|
|
138
|
+
border: 0;
|
|
74
139
|
border-block-end: 1px solid t.role('line');
|
|
75
140
|
background: t.role('bg-soft');
|
|
141
|
+
color: t.role('fg');
|
|
76
142
|
overflow: auto;
|
|
77
143
|
|
|
78
144
|
// Deliberately not sticky: a sticky sidebar and a sticky header both anchored at 0 overlap,
|
|
@@ -108,3 +174,38 @@
|
|
|
108
174
|
color: t.role('fg-muted');
|
|
109
175
|
font-size: t.text(sm);
|
|
110
176
|
}
|
|
177
|
+
|
|
178
|
+
// --- Below `md`, where the engine has popovers: the sidebar is an edge panel ----------------------
|
|
179
|
+
// The same look as `Drawer`'s inline-start side — raised surface, line edge, scrim — so the two
|
|
180
|
+
// off-canvas surfaces in an app read as one.
|
|
181
|
+
@include t.respond-down(md) {
|
|
182
|
+
@supports selector(:popover-open) {
|
|
183
|
+
.sidebar:not(:popover-open) {
|
|
184
|
+
display: none;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.sidebar:popover-open {
|
|
188
|
+
@include t.scrim-backdrop;
|
|
189
|
+
position: fixed;
|
|
190
|
+
inset-block: 0;
|
|
191
|
+
inset-inline-start: 0;
|
|
192
|
+
inset-inline-end: auto;
|
|
193
|
+
inline-size: min(var(--shell-sidebar, 16rem), calc(100vw - #{t.space(12)}));
|
|
194
|
+
block-size: 100dvh;
|
|
195
|
+
max-block-size: 100dvh;
|
|
196
|
+
border-block-end: 0;
|
|
197
|
+
border-inline-end: 1px solid t.role('line');
|
|
198
|
+
background: t.role('surface-raised');
|
|
199
|
+
box-shadow: t.shadow(xl);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.menuButton,
|
|
203
|
+
.close {
|
|
204
|
+
display: inline-flex;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.menuOnly {
|
|
208
|
+
display: flex;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -1,28 +1,35 @@
|
|
|
1
1
|
// The page frame every app screen sits in: skip link, banner, navigation, main, contentinfo.
|
|
2
|
-
// Stateless
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Stateless, and scriptless: below `md` the sidebar `<nav>` is a native `popover` panel at the
|
|
3
|
+
// inline-start edge, opened by a `popovertarget` menu button — the browser holds the open state,
|
|
4
|
+
// Esc and light dismiss, so the frame on every page costs no island. Not `Drawer`: that is a modal
|
|
5
|
+
// `<dialog>` opened from an effect, so it needs a hydrated island, and a `<dialog>` cannot also be
|
|
6
|
+
// the in-flow `navigation` landmark at `md` and up. An engine with no popovers keeps the old band.
|
|
5
7
|
|
|
6
8
|
import type { JSX } from 'solid-js';
|
|
7
9
|
import { LIVE_REGION_LEVELS, liveRegionAttrs, useId } from '../a11y';
|
|
8
10
|
import { cx } from '../cx';
|
|
9
11
|
import { UI_KEYS } from '../i18n-keys';
|
|
12
|
+
import { iconMenu } from '../icons/glyphs/menu';
|
|
13
|
+
import { iconX } from '../icons/glyphs/x';
|
|
10
14
|
import { useUi } from '../theme/context';
|
|
11
15
|
import styles from './AppShell.module.scss';
|
|
12
16
|
import { shellIds } from './app-shell-view';
|
|
17
|
+
import { Icon } from './Icon';
|
|
13
18
|
|
|
14
19
|
export interface AppShellProps {
|
|
15
20
|
/** The page. Rendered inside the one `<main>`, which is the skip link's target. */
|
|
16
21
|
children: JSX.Element;
|
|
17
22
|
header?: JSX.Element | undefined;
|
|
18
|
-
/** Rendered inside a `<nav>` landmark
|
|
23
|
+
/** Rendered inside a `<nav>` landmark: the inline-start column at `md` and up, a panel below. */
|
|
19
24
|
sidebar?: JSX.Element | undefined;
|
|
20
25
|
footer?: JSX.Element | undefined;
|
|
21
26
|
/** Accessible name for the sidebar landmark. Defaults to the translated `ui.navigation`. */
|
|
22
27
|
sidebarLabel?: string | undefined;
|
|
23
28
|
/** Skip-link text. Defaults to the translated `ui.skip`. */
|
|
24
29
|
skipLabel?: string | undefined;
|
|
25
|
-
/**
|
|
30
|
+
/** The menu button's text below `md`. Defaults to the translated `ui.menu`. */
|
|
31
|
+
menuLabel?: string | undefined;
|
|
32
|
+
/** Sidebar track width at `md` and up, and the panel's width below. Any CSS length. */
|
|
26
33
|
sidebarWidth?: string | undefined;
|
|
27
34
|
/** Keeps the header pinned while the main region scrolls. */
|
|
28
35
|
stickyHeader?: boolean | undefined;
|
|
@@ -41,13 +48,44 @@ export function AppShell(props: AppShellProps): JSX.Element {
|
|
|
41
48
|
<a class={styles['skip']} href={ids.skipHref}>
|
|
42
49
|
{props.skipLabel ?? ui.t(UI_KEYS.skip)}
|
|
43
50
|
</a>
|
|
44
|
-
{props.header === undefined ? null : (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
{props.header === undefined && props.sidebar === undefined ? null : (
|
|
52
|
+
// The menu button sits beside the banner, not inside it: the header holds exactly what
|
|
53
|
+
// the app handed it, and the bar is what sticks.
|
|
54
|
+
<div
|
|
55
|
+
class={cx(
|
|
56
|
+
styles['top'],
|
|
57
|
+
// A bar holding only the menu button is shown only where that button is.
|
|
58
|
+
props.header === undefined && styles['menuOnly'],
|
|
59
|
+
props.stickyHeader !== false && styles['sticky'],
|
|
60
|
+
)}
|
|
61
|
+
>
|
|
62
|
+
{props.sidebar === undefined ? null : (
|
|
63
|
+
<button type="button" class={styles['menuButton']} popovertarget={ids.navId}>
|
|
64
|
+
<Icon glyph={iconMenu} size="md" />
|
|
65
|
+
<span class={styles['menuLabel']}>{props.menuLabel ?? ui.t(UI_KEYS.menu)}</span>
|
|
66
|
+
</button>
|
|
67
|
+
)}
|
|
68
|
+
{props.header === undefined ? null : (
|
|
69
|
+
<header class={styles['header']}>{props.header}</header>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
48
72
|
)}
|
|
49
73
|
{props.sidebar === undefined ? null : (
|
|
50
|
-
<nav
|
|
74
|
+
<nav
|
|
75
|
+
id={ids.navId}
|
|
76
|
+
class={styles['sidebar']}
|
|
77
|
+
popover="auto"
|
|
78
|
+
aria-label={props.sidebarLabel ?? ui.t(UI_KEYS.navigation)}
|
|
79
|
+
>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class={styles['close']}
|
|
83
|
+
popovertarget={ids.navId}
|
|
84
|
+
popovertargetaction="hide"
|
|
85
|
+
aria-label={ui.t(UI_KEYS.close)}
|
|
86
|
+
>
|
|
87
|
+
<Icon glyph={iconX} size="md" />
|
|
88
|
+
</button>
|
|
51
89
|
{props.sidebar}
|
|
52
90
|
</nav>
|
|
53
91
|
)}
|