@ultimat3/ui 20.1.6 → 20.2.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 +100 -1
- package/CLAUDE.md +1 -1
- package/README.md +1 -0
- package/package.json +5 -5
- package/src/components/BarChart.module.scss +45 -0
- package/src/components/BarChart.tsx +78 -0
- package/src/components/CommandPalette.module.scss +110 -0
- package/src/components/CommandPalette.tsx +127 -0
- package/src/components/CopyButton.module.scss +40 -0
- package/src/components/CopyButton.tsx +62 -0
- package/src/components/Kbd.module.scss +15 -0
- package/src/components/Kbd.tsx +17 -0
- package/src/components/Meter.module.scss +18 -0
- package/src/components/Meter.tsx +58 -0
- package/src/components/QrCode.module.scss +41 -0
- package/src/components/QrCode.tsx +49 -0
- package/src/components/Sparkline.module.scss +20 -0
- package/src/components/Sparkline.tsx +40 -0
- package/src/components/StatTile.module.scss +86 -0
- package/src/components/StatTile.tsx +49 -0
- package/src/components/bar-chart-view.ts +55 -0
- package/src/components/command-palette-view.ts +61 -0
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-encode.ts +187 -0
- package/src/components/qr-matrix.ts +329 -0
- package/src/components/sparkline-view.ts +36 -0
- package/src/components/stat-delta.ts +30 -0
- package/src/errors.ts +16 -0
- package/src/i18n-keys.ts +4 -0
- package/src/index.ts +32 -0
- package/src/theme/inline-script.ts +5 -0
- package/src/tokens/_mixins.scss +30 -0
- package/src/tokens/reset.scss +7 -0
package/CATALOG.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Every component and every token, projected from source. Import all of it from `@ultimat3/ui`.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
62 components: `Accordion` · `Alert` · `AppShell` · `AsyncRegion` · `Avatar` · `Badge` · `BarChart` · `Breadcrumb` · `Button` · `Card` · `Checkbox` · `Combobox` · `CommandPalette` · `Container` · `CopyButton` · `DataTable` · `DateTime` · `Dialog` · `Divider` · `Drawer` · `Dropzone` · `EmptyState` · `ErrorState` · `Field` · `FileInput` · `Form` · `Grid` · `Icon` · `IconButton` · `Image` · `InfiniteScroll` · `Input` · `Kbd` · `Link` · `LocaleSwitcher` · `Menu` · `Meter` · `Money` · `PageHeader` · `Pagination` · `Popover` · `QrCode` · `Radio` · `RelativeTime` · `Section` · `Select` · `Skeleton` · `Sparkline` · `Spinner` · `Stack` · `StatTile` · `Switch` · `Table` · `Tabs` · `Text` · `Textarea` · `ThemeToggle` · `ToastRegion` · `Toast` · `Toaster` · `Toolbar` · `Tooltip`
|
|
8
8
|
|
|
9
9
|
## Vocabulary
|
|
10
10
|
|
|
@@ -101,6 +101,17 @@ Small status label. Tone maps straight onto the status colour roles so the same
|
|
|
101
101
|
| `dot` | `boolean` | — | Renders a leading dot; pair with a tone that carries the meaning. |
|
|
102
102
|
| `class` | `string` | — | |
|
|
103
103
|
|
|
104
|
+
### BarChart
|
|
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`.
|
|
107
|
+
|
|
108
|
+
| Prop | Type | Required | Notes |
|
|
109
|
+
|---|---|---|---|
|
|
110
|
+
| `label` | `string` | yes | The accessible name for the whole chart, already translated. |
|
|
111
|
+
| `points` | `readonly ChartPoint[]` | yes | Oldest first. Every bar comes from exactly this array — the caller zero-fills gaps. |
|
|
112
|
+
| `highlightLast` | `boolean` | — | Draw the last bar at full strength — the eye lands where the live number is. Default on. |
|
|
113
|
+
| `class` | `string` | — | |
|
|
114
|
+
|
|
104
115
|
### Breadcrumb
|
|
105
116
|
|
|
106
117
|
Ancestor trail. The last item is the current page: rendered as text, never a link, and marked `aria-current="page"`. Separators are decorative CSS.
|
|
@@ -191,6 +202,27 @@ A searchable text field with suggestions: one `<input list>` plus a `<datalist>`
|
|
|
191
202
|
| `aria-describedby` | `string` | — | |
|
|
192
203
|
| `aria-invalid` | `boolean` | — | |
|
|
193
204
|
|
|
205
|
+
### CommandPalette
|
|
206
|
+
|
|
207
|
+
The ⌘K palette: a filter field over a list of commands and destinations, in a NON-modal `<dialog>` driven by its `open` attribute — never `showModal()`, so a test harness with no dialog methods and a server with no DOM both render it the same way. Controlled and presentational: the open flag, the query and the active item all live in the caller, and the rules (`filterItems`, `stepActive`, `keyAction`) live in `command-palette-view.ts` so the caller does not rewrite them. Renders closed on the server unless told otherwise; with scripting off it is inert markup.
|
|
208
|
+
|
|
209
|
+
| Prop | Type | Required | Notes |
|
|
210
|
+
|---|---|---|---|
|
|
211
|
+
| `open` | `boolean` | yes | |
|
|
212
|
+
| `labels` | `CommandPaletteLabels` | yes | |
|
|
213
|
+
| `query` | `string` | yes | |
|
|
214
|
+
| `items` | `readonly CommandPaletteItem[]` | yes | Already filtered — `filterItems(all, query)` is the caller's one line. |
|
|
215
|
+
| `activeId` | `string` | — | The roving selection; `settleActive`/`stepActive` keep it inside `items`. |
|
|
216
|
+
| `onQueryInput` | `(value: string) => void` | yes | |
|
|
217
|
+
| `onFilterKeyDown` | `(event: KeyboardEvent) => void` | yes | The raw key event; `keyAction(event.key)` says what it means. |
|
|
218
|
+
| `onHoverItem` | `(id: string) => void` | yes | |
|
|
219
|
+
| `onRunItem` | `(id: string) => void` | yes | |
|
|
220
|
+
| `onClose` | `() => void` | yes | |
|
|
221
|
+
| `id` | `string` | — | The element's id, so a trigger can name it with `aria-controls`. |
|
|
222
|
+
| `filterRef` | `((el: HTMLInputElement) => void)` | — | Refs out, so a caller can read focus or the attribute behind its own capability guard. |
|
|
223
|
+
| `dialogRef` | `((el: HTMLDialogElement) => void)` | — | |
|
|
224
|
+
| `class` | `string` | — | |
|
|
225
|
+
|
|
194
226
|
### Container
|
|
195
227
|
|
|
196
228
|
Centred measure with a gutter. `margin-inline: auto` and `min()` mean one declaration covers every viewport and both writing directions.
|
|
@@ -203,6 +235,18 @@ Centred measure with a gutter. `margin-inline: auto` and `min()` mean one declar
|
|
|
203
235
|
| `as` | `'div' \| 'main' \| 'section' \| 'article' \| 'header' \| 'footer'` | — | |
|
|
204
236
|
| `class` | `string` | — | |
|
|
205
237
|
|
|
238
|
+
### CopyButton
|
|
239
|
+
|
|
240
|
+
Copies one string to the clipboard and says so — the control beside a short URL, an id, a command. Server-rendered as a real button; the clipboard write and the 1.6 s "copied" state are additive client behaviour, so with scripting off the button is there and does nothing, which is what a clipboard needs a script for anyway.
|
|
241
|
+
|
|
242
|
+
| Prop | Type | Required | Notes |
|
|
243
|
+
|---|---|---|---|
|
|
244
|
+
| `value` | `string` | yes | What lands on the clipboard. |
|
|
245
|
+
| `label` | `string` | — | The accessible name; defaults to the catalog's `ui.copy`. |
|
|
246
|
+
| `copiedLabel` | `string` | — | Announced to assistive tech once the write succeeds; defaults to `ui.copied`. |
|
|
247
|
+
| `size` | `Size` | — | |
|
|
248
|
+
| `class` | `string` | — | |
|
|
249
|
+
|
|
206
250
|
### DataTable
|
|
207
251
|
|
|
208
252
|
Data-driven table: sortable headers, cursor pagination, and the four states a real list always has (loading, error, empty, data). The error state renders an UltimateError with the same code/cause/fix strings the terminal prints. The four-way decision itself is NOT here — it is `asyncBranch`, shared with `AsyncRegion`, so a table and a card list cannot disagree about what "loading with stale rows" looks like. Only the PLACEHOLDER is local, because a table's is table-shaped: rows of cells, not lines of text.
|
|
@@ -489,6 +533,15 @@ Single-line text control. No `type="number"` convenience wrapper: numeric input
|
|
|
489
533
|
| `onChange` | `JSX.EventHandlerUnion<HTMLInputElement, Event>` | — | |
|
|
490
534
|
| `onBlur` | `JSX.EventHandlerUnion<HTMLInputElement, FocusEvent>` | — | |
|
|
491
535
|
|
|
536
|
+
### Kbd
|
|
537
|
+
|
|
538
|
+
A keyboard key or chord as the user reads it — "⌘K", "Esc", "/". A native `<kbd>`, because a keyboard hint is inline code, not a badge: the element carries the meaning, so it works with no script and reads as a key to a screen reader.
|
|
539
|
+
|
|
540
|
+
| Prop | Type | Required | Notes |
|
|
541
|
+
|---|---|---|---|
|
|
542
|
+
| `children` | `JSX.Element` | yes | The key or chord, already translated where a key name is a word. |
|
|
543
|
+
| `class` | `string` | — | |
|
|
544
|
+
|
|
492
545
|
### Link
|
|
493
546
|
|
|
494
547
|
Anchor primitive. External links get `rel` hardening and a translated "opens in a new tab" hint automatically — never a bare `target="_blank"`.
|
|
@@ -532,6 +585,18 @@ Action menu: a trigger plus a `role="menu"` list with roving tabindex. Distinct
|
|
|
532
585
|
| `align` | `'start' \| 'end'` | — | |
|
|
533
586
|
| `class` | `string` | — | |
|
|
534
587
|
|
|
588
|
+
### Meter
|
|
589
|
+
|
|
590
|
+
A thin horizontal bar showing one value against a maximum — a table cell's "how busy is this row" beside the number, or a quota. Decorative by default: with no `label` it is hidden from the accessibility tree, because the number beside it is the fact. With one it is a `meter` role with the value spoken. An SVG with a `width` ATTRIBUTE rather than a styled box: it paints identically on the server copy and the hydrated one and asks nothing of the CSP's `style-src-attr`.
|
|
591
|
+
|
|
592
|
+
| Prop | Type | Required | Notes |
|
|
593
|
+
|---|---|---|---|
|
|
594
|
+
| `value` | `number` | yes | |
|
|
595
|
+
| `max` | `number` | yes | |
|
|
596
|
+
| `label` | `string` | — | The accessible name. Omit for a purely decorative bar beside a visible number. |
|
|
597
|
+
| `tone` | `Tone` | — | |
|
|
598
|
+
| `class` | `string` | — | |
|
|
599
|
+
|
|
535
600
|
### Money
|
|
536
601
|
|
|
537
602
|
Formats through @ultimat3/money using the locale and currency from context — never a process-wide default, never a float, never a hardcoded symbol.
|
|
@@ -591,6 +656,17 @@ Non-modal anchored panel. Positioning is CSS-only (a relatively positioned ancho
|
|
|
591
656
|
| `align` | `'start' \| 'center' \| 'end'` | — | |
|
|
592
657
|
| `class` | `string` | — | |
|
|
593
658
|
|
|
659
|
+
### QrCode
|
|
660
|
+
|
|
661
|
+
A QR code of one short value — a link's short URL, a join code — as static SVG from the pure-TypeScript encoder in `qr-encode.ts` / `qr-matrix.ts`. No QR library: a route's JS budget counts raw minified bytes, and one `<rect>` per dark module costs nothing to hydrate, so the server-rendered shell IS the code. Versions 1-3 only (42 bytes); longer is `X_UI_QR_CAPACITY`.
|
|
662
|
+
|
|
663
|
+
| Prop | Type | Required | Notes |
|
|
664
|
+
|---|---|---|---|
|
|
665
|
+
| `value` | `string` | yes | What the code encodes — a short URL, at most 42 UTF-8 bytes. Longer throws. |
|
|
666
|
+
| `label` | `string` | yes | The accessible name, already translated — say what scanning it does, not "QR code". |
|
|
667
|
+
| `quietZone` | `number` | — | Light modules on every side. Default 4, the minimum a scanner needs to find the finders. |
|
|
668
|
+
| `class` | `string` | — | |
|
|
669
|
+
|
|
594
670
|
### Radio
|
|
595
671
|
|
|
596
672
|
Radio group. Rendered as a <fieldset>/<legend> so the group has a name, and as one native radio per option so arrow-key navigation is the platform's job.
|
|
@@ -668,6 +744,16 @@ Loading placeholder. Sized by the caller so the real content lands in the same b
|
|
|
668
744
|
| `lines` | `number` | — | Repeat as stacked lines, e.g. a paragraph placeholder. |
|
|
669
745
|
| `class` | `string` | — | |
|
|
670
746
|
|
|
747
|
+
### Sparkline
|
|
748
|
+
|
|
749
|
+
One series as a single line with no axes — the trend beside a figure, in a table cell, on a detail page. Static SVG for `BarChart`'s reason: the framework's own docs use a sparkline pulling in a chart library as the cautionary example, and this file IS that sparkline written the way the docs say to. The path comes from `sparkline-view.ts`.
|
|
750
|
+
|
|
751
|
+
| Prop | Type | Required | Notes |
|
|
752
|
+
|---|---|---|---|
|
|
753
|
+
| `label` | `string` | yes | The accessible name for the whole chart, already translated. |
|
|
754
|
+
| `points` | `readonly ChartPoint[]` | yes | Oldest first. Every point comes from exactly this array — the caller zero-fills gaps. |
|
|
755
|
+
| `class` | `string` | — | |
|
|
756
|
+
|
|
671
757
|
### Spinner
|
|
672
758
|
|
|
673
759
|
Indeterminate progress. `role="status"` plus a translated name, because a spinner with no accessible name is silence to a screen reader.
|
|
@@ -694,6 +780,19 @@ Flex layout primitive. Gap comes from the space scale as a custom property, so t
|
|
|
694
780
|
| `as` | `'div' \| 'ul' \| 'ol' \| 'nav' \| 'section' \| 'header' \| 'footer'` | — | Renders a semantic element instead of a div. |
|
|
695
781
|
| `class` | `string` | — | |
|
|
696
782
|
|
|
783
|
+
### StatTile
|
|
784
|
+
|
|
785
|
+
One labelled figure with an optional trend chip and a line of context — the tile a dashboard's top row is made of. The figure arrives formatted: number formatting is the caller's (`Intl.NumberFormat` against the page locale), so the tile never guesses a locale. Intrinsic tags only: a runtime-chosen root (`const Tag = props.as ?? 'div'`) is called as a component by the island build, so a tile built on `Card` hydrated differently from how it was served. A `<div>` renders identically on both sides.
|
|
786
|
+
|
|
787
|
+
| Prop | Type | Required | Notes |
|
|
788
|
+
|---|---|---|---|
|
|
789
|
+
| `label` | `string` | yes | The tile's caption, already translated — "Total links", "Clicks today". |
|
|
790
|
+
| `value` | `string` | yes | The figure, already formatted as the string this tile shows. |
|
|
791
|
+
| `stat` | `string` | yes | The stable hook a test or a live check reads the figure through: `data-stat`. |
|
|
792
|
+
| `delta` | `StatDelta` | — | The trend chip; `deltaOf()` builds one from a current and a baseline. |
|
|
793
|
+
| `hint` | `string` | — | One short line of context under the figure: "vs yesterday", "849 clicks". |
|
|
794
|
+
| `class` | `string` | — | |
|
|
795
|
+
|
|
697
796
|
### Switch
|
|
698
797
|
|
|
699
798
|
Boolean toggle with immediate effect (as opposed to Checkbox, which is part of a form submit). `role="switch"` on a native checkbox keeps keyboard behaviour — and its checked state, which is why nothing here writes `aria-checked`: `role="switch"` maps the host `checked` state on its own, and an attribute mirroring it never updates on the no-JS path, where ARIA would then outrank the truth and announce the switch stuck in whichever position it was rendered in.
|
package/CLAUDE.md
CHANGED
|
@@ -106,7 +106,7 @@ Tier 4 (moved 5 → 4 on 2026-08-19, when the `admin → ui` exception was delet
|
|
|
106
106
|
| `src/theme/inert-runtime.ts` | `INERT_SOLID_RUNTIME` — what a server render IS, not a stub of what it lacks |
|
|
107
107
|
| `src/theme/theme.ts` | resolution: stored choice → OS; all side effects via injected `ThemeEnv` |
|
|
108
108
|
| `src/theme/inline-script.ts` | anti-flash `<head>` snippet + its CSP sha256 |
|
|
109
|
-
| `src/components/` |
|
|
109
|
+
| `src/components/` | 62 components, `PascalCase.tsx` (component convention overrides the repo's kebab-case) |
|
|
110
110
|
| `src/icons/glyphs/` | GENERATED: 1767 per-icon modules, `@ultimat3/ui/icons/<name>` → `icon<Name>` |
|
|
111
111
|
| `src/icons/build-icons.ts` | the generator + the pinned `LUCIDE_VERSION`; `LICENSE.lucide` is upstream's ISC text |
|
|
112
112
|
| `src/theme/brand.ts` | `defineTheme()` — the ONE brand-override seam; there is no SCSS `@use ... with ()` path |
|
package/README.md
CHANGED
|
@@ -542,6 +542,7 @@ of truth for the one thing the server decides.
|
|
|
542
542
|
| `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` |
|
|
543
543
|
| `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`) |
|
|
544
544
|
| `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 |
|
|
545
|
+
| `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 |
|
|
545
546
|
| `X_UI_INVALID_VALUE` | `<Money>` given a float, `<DateTime>` given an unparseable instant, `<Image>` given mixed `w`/`x` descriptors or one dimension without the other, 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) |
|
|
546
547
|
|
|
547
548
|
## Commands
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "20.
|
|
3
|
+
"version": "20.2.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",
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
"icons": "bun run src/icons/build-icons.ts"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@ultimat3/core": "20.
|
|
48
|
-
"@ultimat3/i18n": "20.
|
|
49
|
-
"@ultimat3/money": "20.
|
|
50
|
-
"@ultimat3/time": "20.
|
|
47
|
+
"@ultimat3/core": "20.2.0",
|
|
48
|
+
"@ultimat3/i18n": "20.2.0",
|
|
49
|
+
"@ultimat3/money": "20.2.0",
|
|
50
|
+
"@ultimat3/time": "20.2.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"solid-js": "^1.9.0"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.chart {
|
|
4
|
+
display: block;
|
|
5
|
+
inline-size: 100%;
|
|
6
|
+
block-size: auto;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.grid {
|
|
10
|
+
stroke: t.role('line', 0.45);
|
|
11
|
+
stroke-width: 1;
|
|
12
|
+
// Hairlines stay hairlines however wide the chart is stretched.
|
|
13
|
+
vector-effect: non-scaling-stroke;
|
|
14
|
+
stroke-dasharray: 2 4;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.bar {
|
|
18
|
+
fill: t.role('accent');
|
|
19
|
+
opacity: 0.5;
|
|
20
|
+
// Only opacity, never a layout property: a hover that resized the bar would reflow every bar
|
|
21
|
+
// beside it.
|
|
22
|
+
transition: opacity t.duration(fast) t.easing(out);
|
|
23
|
+
|
|
24
|
+
&:hover {
|
|
25
|
+
opacity: 1;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.last {
|
|
30
|
+
opacity: 1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.axis {
|
|
34
|
+
@include t.data-text;
|
|
35
|
+
|
|
36
|
+
// SVG text scales with the chart: below `sm` these shrink to unreadable specks, and an
|
|
37
|
+
// unreadable label is noise. Every bar still carries its `<title>`.
|
|
38
|
+
display: none;
|
|
39
|
+
font-size: 8px;
|
|
40
|
+
fill: t.role('fg-muted');
|
|
41
|
+
|
|
42
|
+
@include t.respond-to(sm) {
|
|
43
|
+
display: block;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// A bar chart of one series — clicks per day, signups per week — as static SVG. No charting
|
|
2
|
+
// library: the framework's own docs use a sparkline pulling one in as the cautionary example, and
|
|
3
|
+
// a route's JS budget counts raw minified bytes. `<rect>` bars cost nothing to hydrate, so the
|
|
4
|
+
// server-rendered shell IS the chart. Geometry lives in `bar-chart-view.ts`.
|
|
5
|
+
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { cx } from '../cx';
|
|
8
|
+
import styles from './BarChart.module.scss';
|
|
9
|
+
import type { ChartPoint } from './bar-chart-view';
|
|
10
|
+
import { BAR_CHART, barRects, GRID_STEPS, gridY, maxOf } from './bar-chart-view';
|
|
11
|
+
|
|
12
|
+
export interface BarChartProps {
|
|
13
|
+
/** The accessible name for the whole chart, already translated. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Oldest first. Every bar comes from exactly this array — the caller zero-fills gaps. */
|
|
16
|
+
points: readonly ChartPoint[];
|
|
17
|
+
/** Draw the last bar at full strength — the eye lands where the live number is. Default on. */
|
|
18
|
+
highlightLast?: boolean | undefined;
|
|
19
|
+
class?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function BarChart(props: BarChartProps): JSX.Element {
|
|
23
|
+
const rects = (): readonly ReturnType<typeof barRects>[number][] => barRects(props.points);
|
|
24
|
+
const lastIndex = (): number => props.points.length - 1;
|
|
25
|
+
const first = (): ChartPoint | undefined => props.points[0];
|
|
26
|
+
const last = (): ChartPoint | undefined => props.points.at(-1);
|
|
27
|
+
const highlight = (): boolean => props.highlightLast !== false;
|
|
28
|
+
return (
|
|
29
|
+
<svg
|
|
30
|
+
role="img"
|
|
31
|
+
aria-label={props.label}
|
|
32
|
+
viewBox={`0 0 ${BAR_CHART.width} ${BAR_CHART.height}`}
|
|
33
|
+
class={cx(styles['chart'], props.class)}
|
|
34
|
+
>
|
|
35
|
+
{GRID_STEPS.map((step) => (
|
|
36
|
+
<line
|
|
37
|
+
class={styles['grid']}
|
|
38
|
+
x1={0}
|
|
39
|
+
x2={BAR_CHART.width}
|
|
40
|
+
y1={gridY(step)}
|
|
41
|
+
y2={gridY(step)}
|
|
42
|
+
/>
|
|
43
|
+
))}
|
|
44
|
+
{props.points.map((point, index) => {
|
|
45
|
+
const rect = rects()[index];
|
|
46
|
+
if (rect === undefined) return null;
|
|
47
|
+
return (
|
|
48
|
+
<rect
|
|
49
|
+
class={cx(styles['bar'], highlight() && index === lastIndex() && styles['last'])}
|
|
50
|
+
x={rect.x}
|
|
51
|
+
y={rect.y}
|
|
52
|
+
width={rect.width}
|
|
53
|
+
height={rect.height}
|
|
54
|
+
rx={2}
|
|
55
|
+
data-bar="true"
|
|
56
|
+
>
|
|
57
|
+
<title>
|
|
58
|
+
{point.key}: {point.value}
|
|
59
|
+
</title>
|
|
60
|
+
</rect>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
<text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.top - 3} text-anchor="end">
|
|
64
|
+
{maxOf(props.points)}
|
|
65
|
+
</text>
|
|
66
|
+
{first() === undefined ? null : (
|
|
67
|
+
<text class={styles['axis']} x={0} y={BAR_CHART.height - 3} text-anchor="start">
|
|
68
|
+
{first()?.key}
|
|
69
|
+
</text>
|
|
70
|
+
)}
|
|
71
|
+
{last() === undefined ? null : (
|
|
72
|
+
<text class={styles['axis']} x={BAR_CHART.width} y={BAR_CHART.height - 3} text-anchor="end">
|
|
73
|
+
{last()?.key}
|
|
74
|
+
</text>
|
|
75
|
+
)}
|
|
76
|
+
</svg>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.palette {
|
|
4
|
+
position: fixed;
|
|
5
|
+
inset-block-start: 12vh;
|
|
6
|
+
inset-inline-start: 50%;
|
|
7
|
+
inline-size: min(32rem, calc(100vw - #{t.space(8)}));
|
|
8
|
+
max-block-size: 60vh;
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
border: 1px solid t.role('line');
|
|
12
|
+
border-radius: t.radius(lg);
|
|
13
|
+
background: t.role('surface-raised');
|
|
14
|
+
color: t.role('fg');
|
|
15
|
+
// Two shadows: the lift, and a viewport-sized spread that dims the page behind. A NON-modal
|
|
16
|
+
// `<dialog open>` has no `::backdrop` to style — this is the scrim.
|
|
17
|
+
box-shadow:
|
|
18
|
+
t.shadow(xl),
|
|
19
|
+
0 0 0 100vmax t.role('scrim', 0.55);
|
|
20
|
+
overflow: hidden;
|
|
21
|
+
z-index: t.z(dialog);
|
|
22
|
+
|
|
23
|
+
// Transform-only translate keeps the 50% inline offset from fighting `margin-inline`.
|
|
24
|
+
transform: translateX(-50%) translateY(-4px) scale(0.98);
|
|
25
|
+
opacity: 0;
|
|
26
|
+
visibility: hidden;
|
|
27
|
+
transition:
|
|
28
|
+
transform t.duration(fast) t.easing(out),
|
|
29
|
+
opacity t.duration(fast) t.easing(out),
|
|
30
|
+
visibility t.duration(fast) t.easing(out);
|
|
31
|
+
|
|
32
|
+
&[open] {
|
|
33
|
+
transform: translateX(-50%) translateY(0) scale(1);
|
|
34
|
+
opacity: 1;
|
|
35
|
+
visibility: visible;
|
|
36
|
+
transition-duration: t.duration(base);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.panel {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-direction: column;
|
|
43
|
+
max-block-size: 60vh;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.filterRow {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: t.space(2);
|
|
50
|
+
padding: t.space(3) t.space(4);
|
|
51
|
+
border-block-end: 1px solid t.role('line');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.filter {
|
|
55
|
+
@include t.focus-ring;
|
|
56
|
+
|
|
57
|
+
flex: 1;
|
|
58
|
+
border: 0;
|
|
59
|
+
background: transparent;
|
|
60
|
+
color: t.role('fg-strong');
|
|
61
|
+
font-size: t.text(md);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.empty {
|
|
65
|
+
padding: t.space(6) t.space(4);
|
|
66
|
+
color: t.role('fg-muted');
|
|
67
|
+
text-align: center;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.list {
|
|
71
|
+
@include t.custom-scrollbar;
|
|
72
|
+
|
|
73
|
+
overflow-y: auto;
|
|
74
|
+
padding: t.space(2);
|
|
75
|
+
margin: 0;
|
|
76
|
+
list-style: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.item {
|
|
80
|
+
@include t.focus-ring;
|
|
81
|
+
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
justify-content: space-between;
|
|
85
|
+
gap: t.space(3);
|
|
86
|
+
inline-size: 100%;
|
|
87
|
+
padding: t.space(2) t.space(3);
|
|
88
|
+
border: 0;
|
|
89
|
+
border-radius: t.radius(sm);
|
|
90
|
+
background: transparent;
|
|
91
|
+
color: t.role('fg');
|
|
92
|
+
text-align: start;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
|
|
95
|
+
&[data-active='true'] {
|
|
96
|
+
background: t.role('bg-soft');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.label {
|
|
101
|
+
color: t.role('fg-strong');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.hint {
|
|
105
|
+
@include t.data-text;
|
|
106
|
+
@include t.truncate;
|
|
107
|
+
|
|
108
|
+
font-size: t.text(xs);
|
|
109
|
+
color: t.role('fg-muted');
|
|
110
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The ⌘K palette: a filter field over a list of commands and destinations, in a NON-modal
|
|
2
|
+
// `<dialog>` driven by its `open` attribute — never `showModal()`, so a test harness with no
|
|
3
|
+
// dialog methods and a server with no DOM both render it the same way. Controlled and
|
|
4
|
+
// presentational: the open flag, the query and the active item all live in the caller, and the
|
|
5
|
+
// rules (`filterItems`, `stepActive`, `keyAction`) live in `command-palette-view.ts` so the caller
|
|
6
|
+
// does not rewrite them. Renders closed on the server unless told otherwise; with scripting off
|
|
7
|
+
// it is inert markup.
|
|
8
|
+
|
|
9
|
+
import type { JSX } from 'solid-js';
|
|
10
|
+
import { cx } from '../cx';
|
|
11
|
+
import { solid } from '../theme/solid-adapter';
|
|
12
|
+
import styles from './CommandPalette.module.scss';
|
|
13
|
+
import type { CommandPaletteItem } from './command-palette-view';
|
|
14
|
+
import { Kbd } from './Kbd';
|
|
15
|
+
|
|
16
|
+
/** Every string the palette renders, already translated. */
|
|
17
|
+
export interface CommandPaletteLabels {
|
|
18
|
+
/** The dialog's accessible name. */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
readonly filterLabel: string;
|
|
21
|
+
readonly filterPlaceholder: string;
|
|
22
|
+
/** Shown in place of the list when no item matches. */
|
|
23
|
+
readonly emptyText: string;
|
|
24
|
+
/** The `<Kbd>` beside the filter — "Esc" in the page's language. */
|
|
25
|
+
readonly escHint: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CommandPaletteProps {
|
|
29
|
+
open: boolean;
|
|
30
|
+
labels: CommandPaletteLabels;
|
|
31
|
+
query: string;
|
|
32
|
+
/** Already filtered — `filterItems(all, query)` is the caller's one line. */
|
|
33
|
+
items: readonly CommandPaletteItem[];
|
|
34
|
+
/** The roving selection; `settleActive`/`stepActive` keep it inside `items`. */
|
|
35
|
+
activeId?: string | undefined;
|
|
36
|
+
onQueryInput: (value: string) => void;
|
|
37
|
+
/** The raw key event; `keyAction(event.key)` says what it means. */
|
|
38
|
+
onFilterKeyDown: (event: KeyboardEvent) => void;
|
|
39
|
+
onHoverItem: (id: string) => void;
|
|
40
|
+
onRunItem: (id: string) => void;
|
|
41
|
+
onClose: () => void;
|
|
42
|
+
/** The element's id, so a trigger can name it with `aria-controls`. */
|
|
43
|
+
id?: string | undefined;
|
|
44
|
+
/** Refs out, so a caller can read focus or the attribute behind its own capability guard. */
|
|
45
|
+
filterRef?: ((el: HTMLInputElement) => void) | undefined;
|
|
46
|
+
dialogRef?: ((el: HTMLDialogElement) => void) | undefined;
|
|
47
|
+
class?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CommandPalette(props: CommandPaletteProps): JSX.Element {
|
|
51
|
+
const rt = solid();
|
|
52
|
+
let dialogEl: HTMLDialogElement | undefined;
|
|
53
|
+
let filterEl: HTMLInputElement | undefined;
|
|
54
|
+
|
|
55
|
+
// The attribute is ALSO written imperatively: a JSX-bound `open` compiles to a property
|
|
56
|
+
// assignment on the client, which a fake DOM never reflects; `setAttribute` is what a browser
|
|
57
|
+
// and a harness answer alike. Focus follows the write — an element under `display: none`
|
|
58
|
+
// ignores `.focus()`, so it cannot be called from the caller's own open handler.
|
|
59
|
+
rt.createEffect(() => {
|
|
60
|
+
if (dialogEl === undefined) return;
|
|
61
|
+
if (props.open) {
|
|
62
|
+
dialogEl.setAttribute('open', '');
|
|
63
|
+
if (filterEl !== undefined && typeof filterEl.focus === 'function') filterEl.focus();
|
|
64
|
+
} else {
|
|
65
|
+
dialogEl.removeAttribute('open');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<dialog
|
|
71
|
+
ref={(el: HTMLDialogElement) => {
|
|
72
|
+
dialogEl = el;
|
|
73
|
+
props.dialogRef?.(el);
|
|
74
|
+
}}
|
|
75
|
+
id={props.id}
|
|
76
|
+
open={props.open ? true : undefined}
|
|
77
|
+
class={cx(styles['palette'], props.class)}
|
|
78
|
+
aria-label={props.labels.title}
|
|
79
|
+
onCancel={(event: Event) => {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
props.onClose();
|
|
82
|
+
}}
|
|
83
|
+
>
|
|
84
|
+
<div class={styles['panel']}>
|
|
85
|
+
<div class={styles['filterRow']}>
|
|
86
|
+
<input
|
|
87
|
+
ref={(el: HTMLInputElement) => {
|
|
88
|
+
filterEl = el;
|
|
89
|
+
props.filterRef?.(el);
|
|
90
|
+
}}
|
|
91
|
+
type="text"
|
|
92
|
+
class={styles['filter']}
|
|
93
|
+
aria-label={props.labels.filterLabel}
|
|
94
|
+
placeholder={props.labels.filterPlaceholder}
|
|
95
|
+
value={props.query}
|
|
96
|
+
onInput={(event) => props.onQueryInput(event.currentTarget.value)}
|
|
97
|
+
onKeyDown={props.onFilterKeyDown}
|
|
98
|
+
/>
|
|
99
|
+
<Kbd>{props.labels.escHint}</Kbd>
|
|
100
|
+
</div>
|
|
101
|
+
{props.items.length === 0 ? (
|
|
102
|
+
<p class={styles['empty']}>{props.labels.emptyText}</p>
|
|
103
|
+
) : (
|
|
104
|
+
<ul class={styles['list']}>
|
|
105
|
+
{props.items.map((item) => (
|
|
106
|
+
<li>
|
|
107
|
+
{/* A native button, never a row with a handler; roving TABINDEX, not roving focus. */}
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
class={styles['item']}
|
|
111
|
+
data-active={item.id === props.activeId ? 'true' : undefined}
|
|
112
|
+
tabIndex={item.id === props.activeId ? 0 : -1}
|
|
113
|
+
onClick={() => props.onRunItem(item.id)}
|
|
114
|
+
onMouseEnter={() => props.onHoverItem(item.id)}
|
|
115
|
+
onKeyDown={props.onFilterKeyDown}
|
|
116
|
+
>
|
|
117
|
+
<span class={styles['label']}>{item.label}</span>
|
|
118
|
+
{item.hint === '' ? null : <span class={styles['hint']}>{item.hint}</span>}
|
|
119
|
+
</button>
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
</dialog>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
@use '../tokens' as t;
|
|
2
|
+
|
|
3
|
+
.copy {
|
|
4
|
+
@include t.focus-ring;
|
|
5
|
+
@include t.transition(color, background-color, border-color);
|
|
6
|
+
|
|
7
|
+
display: inline-grid;
|
|
8
|
+
place-items: center;
|
|
9
|
+
padding: 0;
|
|
10
|
+
border: 1px solid transparent;
|
|
11
|
+
border-radius: t.radius(md);
|
|
12
|
+
background: transparent;
|
|
13
|
+
color: t.role('fg-muted');
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
|
|
16
|
+
&:hover {
|
|
17
|
+
border-color: t.role('line');
|
|
18
|
+
background: t.role('surface-raised');
|
|
19
|
+
color: t.role('fg-strong');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
&[data-copied='true'] {
|
|
23
|
+
color: t.role('success');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.size-sm {
|
|
28
|
+
inline-size: 1.75rem;
|
|
29
|
+
block-size: 1.75rem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.size-md {
|
|
33
|
+
inline-size: 2rem;
|
|
34
|
+
block-size: 2rem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.size-lg {
|
|
38
|
+
inline-size: 2.5rem;
|
|
39
|
+
block-size: 2.5rem;
|
|
40
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Copies one string to the clipboard and says so — the control beside a short URL, an id, a
|
|
2
|
+
// command. Server-rendered as a real button; the clipboard write and the 1.6 s "copied" state are
|
|
3
|
+
// additive client behaviour, so with scripting off the button is there and does nothing, which
|
|
4
|
+
// is what a clipboard needs a script for anyway.
|
|
5
|
+
|
|
6
|
+
import type { JSX } from 'solid-js';
|
|
7
|
+
import { announce } from '../a11y';
|
|
8
|
+
import { cx } from '../cx';
|
|
9
|
+
import { UI_KEYS } from '../i18n-keys';
|
|
10
|
+
import { iconCheck } from '../icons/glyphs/check';
|
|
11
|
+
import { iconCopy } from '../icons/glyphs/copy';
|
|
12
|
+
import { useUi } from '../theme/context';
|
|
13
|
+
import { solid } from '../theme/solid-adapter';
|
|
14
|
+
import styles from './CopyButton.module.scss';
|
|
15
|
+
import { Icon } from './Icon';
|
|
16
|
+
import type { Size } from './variants';
|
|
17
|
+
|
|
18
|
+
/** How long the check mark stays before the copy icon returns. */
|
|
19
|
+
export const COPIED_MS = 1600;
|
|
20
|
+
|
|
21
|
+
export interface CopyButtonProps {
|
|
22
|
+
/** What lands on the clipboard. */
|
|
23
|
+
value: string;
|
|
24
|
+
/** The accessible name; defaults to the catalog's `ui.copy`. */
|
|
25
|
+
label?: string | undefined;
|
|
26
|
+
/** Announced to assistive tech once the write succeeds; defaults to `ui.copied`. */
|
|
27
|
+
copiedLabel?: string | undefined;
|
|
28
|
+
size?: Size | undefined;
|
|
29
|
+
class?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CopyButton(props: CopyButtonProps): JSX.Element {
|
|
33
|
+
const ui = useUi();
|
|
34
|
+
const rt = solid();
|
|
35
|
+
const [copied, setCopied] = rt.createSignal(false);
|
|
36
|
+
const label = (): string => props.label ?? ui.t(UI_KEYS.copy);
|
|
37
|
+
const copiedLabel = (): string => props.copiedLabel ?? ui.t(UI_KEYS.copied);
|
|
38
|
+
|
|
39
|
+
const onClick = async (): Promise<void> => {
|
|
40
|
+
// Optional chaining on both: a harness has a `navigator` with no `clipboard`, and `announce`
|
|
41
|
+
// needs a document that can find its live region.
|
|
42
|
+
await navigator.clipboard?.writeText(props.value);
|
|
43
|
+
setCopied(true);
|
|
44
|
+
setTimeout(() => setCopied(false), COPIED_MS);
|
|
45
|
+
if (typeof document !== 'undefined' && typeof document.getElementById === 'function') {
|
|
46
|
+
announce(copiedLabel());
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
class={cx(styles['copy'], styles[`size-${props.size ?? 'md'}`], props.class)}
|
|
54
|
+
title={label()}
|
|
55
|
+
aria-label={copied() ? copiedLabel() : label()}
|
|
56
|
+
data-copied={copied() ? 'true' : undefined}
|
|
57
|
+
onClick={() => void onClick()}
|
|
58
|
+
>
|
|
59
|
+
<Icon glyph={copied() ? iconCheck : iconCopy} size="sm" />
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
}
|