@ultimat3/ui 23.0.0 → 24.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 +2 -2
- package/CLAUDE.md +3 -3
- package/README.md +10 -9
- package/package.json +5 -5
- package/src/a11y.ts +24 -10
- package/src/components/CommandPalette.module.scss +8 -5
- package/src/components/DataTable.tsx +50 -47
- package/src/components/Dropzone.module.scss +3 -1
- package/src/components/Dropzone.tsx +9 -8
- package/src/components/FileInput.module.scss +3 -1
- package/src/components/FileInput.tsx +6 -2
- package/src/components/Meter.tsx +3 -3
- package/src/components/Popover.module.scss +17 -20
- package/src/components/Popover.tsx +12 -5
- package/src/components/Tabs.module.scss +6 -0
- package/src/components/Textarea.tsx +7 -4
- package/src/components/Tooltip.module.scss +8 -3
- package/src/components/bar-chart-view.ts +6 -2
- package/src/components/file-input-view.ts +31 -12
- package/src/components/link-target.ts +21 -1
- package/src/components/meter-view.ts +13 -0
- package/src/components/qr-matrix.ts +16 -23
- package/src/components/qr-reader.ts +187 -0
- package/src/components/relative-time-view.ts +11 -4
- package/src/form/form-binding.ts +9 -8
- package/src/icons/build-icons.ts +96 -20
- package/src/index.ts +2 -6
- package/src/theme/brand.ts +22 -3
- package/src/theme/theme.ts +35 -10
- package/src/toast/toast-state.ts +0 -0
- package/src/theme/inline-script.ts +0 -49
package/CATALOG.md
CHANGED
|
@@ -659,13 +659,13 @@ Cursor-first pagination — the only shape a Postgres-backed list should use. Nu
|
|
|
659
659
|
|
|
660
660
|
### Popover
|
|
661
661
|
|
|
662
|
-
Non-modal anchored panel. Positioning is CSS-only (a relatively positioned anchor plus logical insets), so there is no measure/reflow loop and the panel
|
|
662
|
+
Non-modal anchored panel. Positioning is CSS-only (a relatively positioned anchor plus logical insets and auto-margin centring), so there is no measure/reflow loop and the panel mirrors under `dir="rtl"` — `rtl-sheets.test.ts` holds that.
|
|
663
663
|
|
|
664
664
|
| Prop | Type | Required | Notes |
|
|
665
665
|
|---|---|---|---|
|
|
666
666
|
| `open` | `boolean` | yes | |
|
|
667
667
|
| `onOpenChange` | `(open: boolean) => void` | yes | |
|
|
668
|
-
| `trigger` | `(control: { id: string; 'aria-expanded': boolean; 'aria-controls': string; }) => JSX.Element` | yes | The anchor. Wire its onClick to `onOpenChange` in the caller. |
|
|
668
|
+
| `trigger` | `(control: { id: string; 'aria-expanded': boolean; 'aria-controls': string \| undefined; }) => JSX.Element` | yes | The anchor. Wire its onClick to `onOpenChange` in the caller. `aria-controls` is present only while the panel is mounted: an IDREF to an element not in the document names nothing. |
|
|
669
669
|
| `children` | `JSX.Element` | yes | |
|
|
670
670
|
| `label` | `string` | yes | Already-translated accessible name for the panel. |
|
|
671
671
|
| `placement` | `Placement` | — | |
|
package/CLAUDE.md
CHANGED
|
@@ -31,7 +31,7 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
|
|
|
31
31
|
- **`@include t.tone-classes`** emits the `.tone-*` custom-property blocks. Never hand-write a seventh copy; `$tones` in `_colors.scss` mirrors `TONES` and `variants.test.ts` gates it.
|
|
32
32
|
- **Icons are generated, never authored.** `src/icons/glyphs/*.ts` is one module per Lucide icon, written by `bun run icons` from `lucide-static@LUCIDE_VERSION`. Never hand-edit a glyph, never add a hand-drawn one, never introduce a second icon source. An upstream fix is a version bump plus a re-run. `lucide` itself is NOT a dependency — the data is committed, so the package still installs, typechecks and renders offline with zero runtime deps.
|
|
33
33
|
- **One icon, one module.** The `Icon` component takes a `glyph`, not a name: a `name → glyph` map would be one module holding 1767 icons, and no bundler can split that. Per-icon imports are the whole point (1 icon = 104 B minified, 50 = 8.9 kB).
|
|
34
|
-
- **
|
|
34
|
+
- **Server-rendered first, an island second.** Components run in islands (`examples/dummy`'s settings and feed islands render them), but every one must still be correct server-rendered and usable with scripting off — `<details>` for disclosure, `<input list>`+`<datalist>` for suggestions, a real `rel="next"` link for paging — with listeners and observers as additive extras. A component that renders nothing until JS runs does not ship. In an island the body runs ONCE: a branch decided there (an early `return <ErrorState>`) is decided for the island's life, so branches live inside the returned JSX (`AsyncRegion`, `DataTable`). A server-only spelling is gated on `typeof document` (`Textarea`'s parser newline). Ids minted in an island carry a per-copy scope (`a11y.ts`), because each island bundles its own counter.
|
|
35
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.
|
|
36
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.
|
|
37
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.
|
|
@@ -64,11 +64,10 @@ Tier 4. Imports `@ultimat3/core`, `i18n`, `money`, `time` — **not `schema`**,
|
|
|
64
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 |
|
|
65
65
|
| `src/theme/solid-adapter.ts` | the runtime's SHAPE, and the one rule that decides which runtime a render gets |
|
|
66
66
|
| `src/theme/inert-runtime.ts` | `INERT_SOLID_RUNTIME` — what a server render IS, not a stub of what it lacks |
|
|
67
|
-
| `src/theme/theme.ts` | resolution: stored choice → OS; all side effects via injected `ThemeEnv` |
|
|
67
|
+
| `src/theme/theme.ts` | resolution: stored choice → the theme the boot stamped on `<html>` (`ThemeEnv.current()`) → the app default (`data-theme-default`, `ThemeEnv.appDefault()`) → OS; clearing and the OS listener answer the app default too; all side effects via injected `ThemeEnv` |
|
|
68
68
|
| `src/theme/ambient-slot.ts` | the module-scope slot holding the reader `useUi()` falls back to on the server — imports nothing, so a browser chunk pays nothing for it |
|
|
69
69
|
| `src/theme/ambient.ts` | the server's reader (`ambientUiContext`: request locale, zone, direction, translator), registered at import; `index.ts` imports it bare, and the `browser` field maps it to `ambient.browser.ts`, which answers the defaults and registers nothing |
|
|
70
70
|
| `src/error-registry.ts` | the `registerErrorCodes()` call, alone, so `errors.ts` is pure and the barrel's re-export of it costs a chunk nothing |
|
|
71
|
-
| `src/theme/inline-script.ts` | anti-flash `<head>` snippet + its CSP sha256 |
|
|
72
71
|
| `src/components/` | 62 components, `PascalCase.tsx` (component convention overrides the repo's kebab-case) |
|
|
73
72
|
| `src/icons/glyphs/` | GENERATED: 1767 per-icon modules, `@ultimat3/ui/icons/<name>` → `icon<Name>` |
|
|
74
73
|
| `src/icons/build-icons.ts` | the generator + the pinned `LUCIDE_VERSION`; `LICENSE.lucide` is upstream's ISC text |
|
|
@@ -107,6 +106,7 @@ bun test # from the repo root
|
|
|
107
106
|
bun run --filter @ultimat3/ui typecheck
|
|
108
107
|
bun run --filter @ultimat3/ui catalog # after any prop change — CATALOG.md is gated
|
|
109
108
|
bun run --filter @ultimat3/ui icons # regenerate the glyph set (network; dev-only)
|
|
109
|
+
bun run --filter @ultimat3/ui icons --bump # move LUCIDE_VERSION to the latest lucide-static, then regenerate
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
## Deep import
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ A rung is `sm md lg xl 2xl`, quoted or bare. Prefer a container query (`t.contai
|
|
|
44
44
|
| 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
45
|
| No Tailwind | not a dependency, not a config, not an escape hatch |
|
|
46
46
|
| 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 |
|
|
47
|
+
| 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
48
|
| No hardcoded strings | labels are props, or `t()` through `UI_KEYS` |
|
|
49
49
|
| One token source | `src/tokens/*.scss` is canonical; `tokens.ts` mirrors it and `x verify` fails on drift |
|
|
50
50
|
| AA contrast, both themes | `contrast.test.ts` measures every pairing a component renders — text, status fills, soft tints, focus rings, borders |
|
|
@@ -496,14 +496,15 @@ one politeness — `politeness="assertive"` for a region that carries errors alo
|
|
|
496
496
|
|
|
497
497
|
## Theme resolution
|
|
498
498
|
|
|
499
|
-
`explicit choice in localStorage` → `
|
|
500
|
-
`
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
499
|
+
`explicit choice in localStorage` → `the theme already on <html>` → `defaultTheme(env)`: the
|
|
500
|
+
app's `theme.defaultMode` (`data-theme-default`), and the OS only when that is `'system'` or
|
|
501
|
+
absent. The framework's boot inlines `themeScript({ fallback })` from
|
|
502
|
+
`@ultimat3/render` and stamps `data-theme` before first paint, and `resolveTheme()` reads it back
|
|
503
|
+
through `ThemeEnv.current()` — so with `defaultMode: 'dark'` on a light-OS machine the first toggle
|
|
504
|
+
flips to light rather than writing the dark already on screen. `setTheme()` persists,
|
|
505
|
+
`clearTheme()` forgets and returns to the app default (`data-theme-default`, which the boot stamps
|
|
506
|
+
from `theme.defaultMode`; the OS for `'system'`), and the OS listener applies only while nothing is
|
|
507
|
+
stored and that default is `'system'`.
|
|
507
508
|
|
|
508
509
|
## Forms bound to an action's input schema
|
|
509
510
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.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",
|
|
@@ -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": "24.0.0",
|
|
53
|
+
"@ultimat3/i18n": "24.0.0",
|
|
54
|
+
"@ultimat3/money": "24.0.0",
|
|
55
|
+
"@ultimat3/time": "24.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
55
|
export function resetIdCounter(): void {
|
|
43
56
|
idCounter = 0;
|
|
57
|
+
islandScope = undefined;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
/**
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
.palette {
|
|
4
4
|
position: fixed;
|
|
5
5
|
inset-block-start: 12vh;
|
|
6
|
-
inset-
|
|
6
|
+
// Centred by auto margins between two zero insets, never by a 50% inset and a -50% translate:
|
|
7
|
+
// the inset is logical and the translate physical, so under `dir="rtl"` that pair puts the
|
|
8
|
+
// palette a whole width off-centre (`rtl-sheets.test.ts`).
|
|
9
|
+
inset-inline: 0;
|
|
7
10
|
inline-size: min(32rem, calc(100vw - #{t.space(8)}));
|
|
8
11
|
max-block-size: 60vh;
|
|
9
|
-
margin: 0;
|
|
12
|
+
margin-block: 0;
|
|
13
|
+
margin-inline: auto;
|
|
10
14
|
padding: 0;
|
|
11
15
|
border: 1px solid t.role('line');
|
|
12
16
|
border-radius: t.radius(lg);
|
|
@@ -20,8 +24,7 @@
|
|
|
20
24
|
overflow: hidden;
|
|
21
25
|
z-index: t.z(dialog);
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
transform: translateX(-50%) translateY(calc(-1 * #{t.space(1)})) scale(0.98);
|
|
27
|
+
transform: translateY(calc(-1 * #{t.space(1)})) scale(0.98);
|
|
25
28
|
opacity: 0;
|
|
26
29
|
visibility: hidden;
|
|
27
30
|
transition:
|
|
@@ -30,7 +33,7 @@
|
|
|
30
33
|
visibility t.duration(fast) t.easing(out);
|
|
31
34
|
|
|
32
35
|
&[open] {
|
|
33
|
-
transform:
|
|
36
|
+
transform: translateY(0) scale(1);
|
|
34
37
|
opacity: 1;
|
|
35
38
|
visibility: visible;
|
|
36
39
|
transition-duration: t.duration(base);
|
|
@@ -137,23 +137,6 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
137
137
|
));
|
|
138
138
|
};
|
|
139
139
|
|
|
140
|
-
const decided = branch();
|
|
141
|
-
if (decided.kind === 'failed') {
|
|
142
|
-
return <ErrorState error={decided.error} onRetry={props.onRetry} class={props.class} />;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two ifs:
|
|
146
|
-
// "No results" under a first page in flight is the bug that ordering used to be all that stopped.
|
|
147
|
-
if (decided.kind === 'empty') {
|
|
148
|
-
return (
|
|
149
|
-
<EmptyState
|
|
150
|
-
title={props.emptyTitle}
|
|
151
|
-
description={props.emptyDescription}
|
|
152
|
-
class={props.class}
|
|
153
|
-
/>
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
140
|
/** One mode per use, exactly as `Pagination` declares it — the table only hands it through. */
|
|
158
141
|
const pager = (): JSX.Element =>
|
|
159
142
|
props.hrefFor === undefined ? (
|
|
@@ -216,35 +199,55 @@ export function DataTable<Row>(props: DataTableProps<Row>): JSX.Element {
|
|
|
216
199
|
);
|
|
217
200
|
};
|
|
218
201
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
202
|
+
// An array, not a fragment: this package's `.tsx` compiles to two factories (Solid's and render's
|
|
203
|
+
// `h`), and only an array means the same sibling list to both.
|
|
204
|
+
const table = (decided: AsyncBranch<readonly Row[]>): JSX.Element => [
|
|
205
|
+
<Table
|
|
206
|
+
caption={props.caption}
|
|
207
|
+
stickyHeader={props.stickyHeader !== false}
|
|
208
|
+
density={props.density ?? 'comfortable'}
|
|
209
|
+
>
|
|
210
|
+
<thead>
|
|
211
|
+
<tr>
|
|
212
|
+
{props.columns.map((column) => (
|
|
213
|
+
<th
|
|
214
|
+
scope="col"
|
|
215
|
+
style={column.width === undefined ? undefined : { 'inline-size': column.width }}
|
|
216
|
+
aria-sort={ariaSortFor(props.sort, column.key)}
|
|
217
|
+
class={column.numeric === true ? styles['numeric'] : undefined}
|
|
218
|
+
>
|
|
219
|
+
{sortControl(column)}
|
|
220
|
+
</th>
|
|
221
|
+
))}
|
|
222
|
+
</tr>
|
|
223
|
+
</thead>
|
|
224
|
+
<tbody
|
|
225
|
+
class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
|
|
226
|
+
aria-busy={ariaBool(isBusyBranch(decided))}
|
|
225
227
|
>
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
228
|
+
{body()}
|
|
229
|
+
</tbody>
|
|
230
|
+
</Table>,
|
|
231
|
+
props.nextCursor === undefined && props.prevCursor === undefined ? null : pager(),
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The branch is decided INSIDE the returned JSX, as `AsyncRegion` does: a component body runs
|
|
236
|
+
* once, so a decision taken there is the decision for the island's whole life — a table that
|
|
237
|
+
* first rendered its error state kept it after the query recovered.
|
|
238
|
+
*/
|
|
239
|
+
const content = (): JSX.Element => {
|
|
240
|
+
const decided = branch();
|
|
241
|
+
if (decided.kind === 'failed') {
|
|
242
|
+
return <ErrorState error={decided.error} onRetry={props.onRetry} />;
|
|
243
|
+
}
|
|
244
|
+
// Unreachable while pending, by the shape of `AsyncBranch` rather than by the order of two
|
|
245
|
+
// ifs: "No results" under a first page in flight is the bug that ordering used to stop.
|
|
246
|
+
if (decided.kind === 'empty') {
|
|
247
|
+
return <EmptyState title={props.emptyTitle} description={props.emptyDescription} />;
|
|
248
|
+
}
|
|
249
|
+
return table(decided);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return <div class={cx(styles['wrap'], props.class)}>{content()}</div>;
|
|
250
253
|
}
|
|
@@ -25,8 +25,10 @@
|
|
|
25
25
|
background: t.role('bg-soft');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// Dimmed directly: `@include t.disabled` appends `:disabled`, which a `<label>` carrying a data
|
|
29
|
+
// flag can never be, so the rule it compiled to matched nothing (`disabled-dim.test.ts`).
|
|
28
30
|
&[data-disabled='true'] {
|
|
29
|
-
|
|
31
|
+
opacity: 0.55;
|
|
30
32
|
background: t.role('bg-soft');
|
|
31
33
|
cursor: not-allowed;
|
|
32
34
|
}
|
|
@@ -12,7 +12,7 @@ import { useUi } from '../theme/context';
|
|
|
12
12
|
import { solid } from '../theme/solid-adapter';
|
|
13
13
|
import styles from './Dropzone.module.scss';
|
|
14
14
|
import type { FileSelection } from './file-input-view';
|
|
15
|
-
import {
|
|
15
|
+
import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
|
|
16
16
|
|
|
17
17
|
export interface DropzoneProps {
|
|
18
18
|
/** Already-translated instruction. Required — it is the control's accessible name. */
|
|
@@ -49,8 +49,12 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
49
49
|
const inputId = (): string => props.id ?? fallbackId;
|
|
50
50
|
const percent = (): number => progressPercent(props.progress ?? 0);
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// The input first, then the callback: a drop that only reaches `onSelect` leaves the real
|
|
53
|
+
// control empty, and a refused file left in it is posted by the form anyway.
|
|
54
|
+
const offer = (files: FileList | null | undefined, origin: 'drop' | 'pick'): void => {
|
|
55
|
+
const selection = selectFiles([...(files ?? [])], props);
|
|
56
|
+
adoptAcceptedFiles(input, files, selection, origin);
|
|
57
|
+
props.onSelect(selection);
|
|
54
58
|
};
|
|
55
59
|
|
|
56
60
|
// `dragover` must be cancelled on every tick or the browser refuses the drop and navigates to
|
|
@@ -74,10 +78,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
74
78
|
event.preventDefault();
|
|
75
79
|
setOver(false);
|
|
76
80
|
if (props.disabled === true) return;
|
|
77
|
-
|
|
78
|
-
// control empty, and the form the label sits in submits nothing.
|
|
79
|
-
adoptDroppedFiles(input, event.dataTransfer?.files);
|
|
80
|
-
offer(event.dataTransfer?.files);
|
|
81
|
+
offer(event.dataTransfer?.files, 'drop');
|
|
81
82
|
}}
|
|
82
83
|
>
|
|
83
84
|
<span class={styles['label']}>{props.label}</span>
|
|
@@ -96,7 +97,7 @@ export function Dropzone(props: DropzoneProps): JSX.Element {
|
|
|
96
97
|
disabled={props.disabled === true}
|
|
97
98
|
aria-describedby={props['aria-describedby']}
|
|
98
99
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
99
|
-
onChange={(event) => offer(event.currentTarget.files)}
|
|
100
|
+
onChange={(event) => offer(event.currentTarget.files, 'pick')}
|
|
100
101
|
/>
|
|
101
102
|
{props.progress === undefined ? null : (
|
|
102
103
|
<span
|
|
@@ -51,8 +51,10 @@
|
|
|
51
51
|
border-color: t.role('accent');
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// Dimmed directly: a pseudo-element is never itself `:disabled`, so `@include t.disabled` here
|
|
55
|
+
// compiled to a selector nothing matches (`disabled-dim.test.ts`).
|
|
54
56
|
&:disabled::file-selector-button {
|
|
55
|
-
|
|
57
|
+
opacity: 0.55;
|
|
56
58
|
cursor: not-allowed;
|
|
57
59
|
}
|
|
58
60
|
}
|
|
@@ -10,7 +10,7 @@ import { UI_KEYS } from '../i18n-keys';
|
|
|
10
10
|
import { useUi } from '../theme/context';
|
|
11
11
|
import styles from './FileInput.module.scss';
|
|
12
12
|
import type { FileSelection } from './file-input-view';
|
|
13
|
-
import { progressPercent, selectFiles } from './file-input-view';
|
|
13
|
+
import { adoptAcceptedFiles, progressPercent, selectFiles } from './file-input-view';
|
|
14
14
|
import type { Size } from './variants';
|
|
15
15
|
|
|
16
16
|
export interface FileInputProps {
|
|
@@ -61,7 +61,11 @@ export function FileInput(props: FileInputProps): JSX.Element {
|
|
|
61
61
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
62
62
|
onBlur={props.onBlur}
|
|
63
63
|
onChange={(event) => {
|
|
64
|
-
|
|
64
|
+
const selection = selectFiles([...(event.currentTarget.files ?? [])], props);
|
|
65
|
+
// `accept` only steers the picker; "All files" gets past it, and the form would post
|
|
66
|
+
// what this control just refused.
|
|
67
|
+
adoptAcceptedFiles(event.currentTarget, event.currentTarget.files, selection, 'pick');
|
|
68
|
+
props.onSelect?.(selection);
|
|
65
69
|
}}
|
|
66
70
|
/>
|
|
67
71
|
{props.progress === undefined ? null : (
|
package/src/components/Meter.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { JSX } from 'solid-js';
|
|
10
10
|
import { cx } from '../cx';
|
|
11
11
|
import styles from './Meter.module.scss';
|
|
12
|
-
import { meterShare, meterWidth } from './meter-view';
|
|
12
|
+
import { meterAria, meterShare, meterWidth } from './meter-view';
|
|
13
13
|
import type { Tone } from './variants';
|
|
14
14
|
|
|
15
15
|
export interface MeterProps {
|
|
@@ -49,8 +49,8 @@ export function Meter(props: MeterProps): JSX.Element {
|
|
|
49
49
|
role="meter"
|
|
50
50
|
aria-label={props.label}
|
|
51
51
|
aria-valuemin={0}
|
|
52
|
-
aria-valuemax={props.max}
|
|
53
|
-
aria-valuenow={props.value}
|
|
52
|
+
aria-valuemax={meterAria(props.value, props.max).max}
|
|
53
|
+
aria-valuenow={meterAria(props.value, props.max).now}
|
|
54
54
|
>
|
|
55
55
|
{bars()}
|
|
56
56
|
</svg>
|
|
@@ -33,27 +33,24 @@
|
|
|
33
33
|
inset-block-start: 0;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.align-center {
|
|
45
|
-
inset-inline-start: 50%;
|
|
46
|
-
translate: -50% 0;
|
|
47
|
-
}
|
|
36
|
+
// Alignment is along the anchor's inline edge, so it exists only for the block placements. An
|
|
37
|
+
// inline placement owns both inline insets itself; an align rule that reached it (and the reset
|
|
38
|
+
// that used to undo one) wiped the placement's own inset and dropped the panel over the anchor.
|
|
39
|
+
.placement-block-end,
|
|
40
|
+
.placement-block-start {
|
|
41
|
+
&.align-start {
|
|
42
|
+
inset-inline-start: 0;
|
|
43
|
+
}
|
|
48
44
|
|
|
49
|
-
// Inline placements own their own inline axis; alignment must not fight them.
|
|
50
|
-
.placement-inline-end,
|
|
51
|
-
.placement-inline-start {
|
|
52
|
-
&.align-start,
|
|
53
|
-
&.align-center,
|
|
54
45
|
&.align-end {
|
|
55
|
-
inset-inline-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
inset-inline-end: 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Auto margins inside an inset band far wider than any panel: centred in either direction. A 50%
|
|
50
|
+
// logical inset with a -50% physical translate is a whole width off under `dir="rtl"`.
|
|
51
|
+
&.align-center {
|
|
52
|
+
inset-inline: -100vw;
|
|
53
|
+
margin-inline: auto;
|
|
54
|
+
inline-size: fit-content;
|
|
58
55
|
}
|
|
59
56
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Non-modal anchored panel. Positioning is CSS-only (a relatively positioned
|
|
2
|
-
// anchor plus logical insets), so there is no measure/reflow
|
|
3
|
-
//
|
|
2
|
+
// anchor plus logical insets and auto-margin centring), so there is no measure/reflow
|
|
3
|
+
// loop and the panel mirrors under `dir="rtl"` — `rtl-sheets.test.ts` holds that.
|
|
4
4
|
|
|
5
5
|
import type { JSX } from 'solid-js';
|
|
6
6
|
import { createFocusTrap, useId } from '../a11y';
|
|
@@ -13,11 +13,14 @@ export type Placement = 'block-end' | 'block-start' | 'inline-end' | 'inline-sta
|
|
|
13
13
|
export interface PopoverProps {
|
|
14
14
|
open: boolean;
|
|
15
15
|
onOpenChange: (open: boolean) => void;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The anchor. Wire its onClick to `onOpenChange` in the caller. `aria-controls` is present only
|
|
18
|
+
* while the panel is mounted: an IDREF to an element not in the document names nothing.
|
|
19
|
+
*/
|
|
17
20
|
trigger: (control: {
|
|
18
21
|
id: string;
|
|
19
22
|
'aria-expanded': boolean;
|
|
20
|
-
'aria-controls': string;
|
|
23
|
+
'aria-controls': string | undefined;
|
|
21
24
|
}) => JSX.Element;
|
|
22
25
|
children: JSX.Element;
|
|
23
26
|
/** Already-translated accessible name for the panel. */
|
|
@@ -63,7 +66,11 @@ export function Popover(props: PopoverProps): JSX.Element {
|
|
|
63
66
|
root = el;
|
|
64
67
|
}}
|
|
65
68
|
>
|
|
66
|
-
{props.trigger({
|
|
69
|
+
{props.trigger({
|
|
70
|
+
id: triggerId,
|
|
71
|
+
'aria-expanded': props.open,
|
|
72
|
+
'aria-controls': props.open ? panelId : undefined,
|
|
73
|
+
})}
|
|
67
74
|
{props.open ? (
|
|
68
75
|
<div
|
|
69
76
|
ref={(el: HTMLDivElement) => {
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
border-inline-end: 1px solid t.role('line');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// On the list's inline-END edge, beside its border. A shadow's x offset is physical, so RTL —
|
|
22
|
+
// where that edge is the left one — gets the mirrored sign, as `Switch.module.scss` does.
|
|
21
23
|
.tab[aria-selected='true'] {
|
|
22
24
|
box-shadow: inset calc(-1 * #{t.stroke(thick)}) 0 0 0 t.role('accent');
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
[dir='rtl'] .orientation-vertical .tab[aria-selected='true'] {
|
|
29
|
+
box-shadow: inset t.stroke(thick) 0 0 0 t.role('accent');
|
|
30
|
+
}
|
|
31
|
+
|
|
26
32
|
.list {
|
|
27
33
|
@include t.custom-scrollbar;
|
|
28
34
|
display: flex;
|
|
@@ -28,11 +28,13 @@ export interface TextareaProps {
|
|
|
28
28
|
/**
|
|
29
29
|
* `<textarea>` has NO `value` attribute — its value is its text content, and the parser drops the
|
|
30
30
|
* attribute silently, so `value={…}` rendered every admin edit box empty whatever the row held.
|
|
31
|
-
*
|
|
32
|
-
* newline after `<textarea>`, so emitting it unconditionally is what makes a value that
|
|
33
|
-
* starts with a newline survive the round trip.
|
|
31
|
+
* On the SERVER the leading newline is the serializer's, not the value's: the HTML parser strips
|
|
32
|
+
* exactly one newline after `<textarea>`, so emitting it unconditionally is what makes a value that
|
|
33
|
+
* itself starts with a newline survive the round trip. In a BROWSER no parser runs — an island
|
|
34
|
+
* builds the element — so the value is the `.value` property, and the newline would be data.
|
|
34
35
|
*/
|
|
35
36
|
export function Textarea(props: TextareaProps): JSX.Element {
|
|
37
|
+
const onServer = typeof document === 'undefined';
|
|
36
38
|
return (
|
|
37
39
|
<textarea
|
|
38
40
|
class={cx(styles['textarea'], props.autoGrow === true && styles['autoGrow'], props.class)}
|
|
@@ -49,8 +51,9 @@ export function Textarea(props: TextareaProps): JSX.Element {
|
|
|
49
51
|
aria-invalid={ariaBool(props['aria-invalid'])}
|
|
50
52
|
onInput={props.onInput}
|
|
51
53
|
onBlur={props.onBlur}
|
|
54
|
+
value={onServer ? undefined : (props.value ?? '')}
|
|
52
55
|
>
|
|
53
|
-
{`\n${props.value ?? ''}`}
|
|
56
|
+
{onServer ? `\n${props.value ?? ''}` : undefined}
|
|
54
57
|
</textarea>
|
|
55
58
|
);
|
|
56
59
|
}
|
|
@@ -9,8 +9,13 @@
|
|
|
9
9
|
@include t.transition(opacity, translate);
|
|
10
10
|
position: absolute;
|
|
11
11
|
z-index: t.z(tooltip);
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Centred over the trigger in either direction: auto margins inside an inset band far wider than
|
|
13
|
+
// any bubble. A 50% logical inset with a -50% physical translate lands a whole width off-centre
|
|
14
|
+
// under `dir="rtl"` (`rtl-sheets.test.ts`).
|
|
15
|
+
inset-inline: -100vw;
|
|
16
|
+
margin-inline: auto;
|
|
17
|
+
inline-size: max-content;
|
|
18
|
+
translate: 0 t.space(1);
|
|
14
19
|
max-inline-size: 18rem;
|
|
15
20
|
padding-block: t.space(1);
|
|
16
21
|
padding-inline: t.space(2);
|
|
@@ -36,6 +41,6 @@
|
|
|
36
41
|
// Hover and keyboard focus both reveal it — a hover-only tooltip is inaccessible.
|
|
37
42
|
.wrap:hover .bubble,
|
|
38
43
|
.wrap:focus-within .bubble {
|
|
39
|
-
translate:
|
|
44
|
+
translate: 0 0;
|
|
40
45
|
opacity: 1;
|
|
41
46
|
}
|
|
@@ -50,13 +50,17 @@ export function gridY(step: number): number {
|
|
|
50
50
|
/** One rect per point, oldest first. At least 2px tall so a zero still draws a visible bar. */
|
|
51
51
|
export function barRects(points: readonly ChartPoint[]): readonly BarRect[] {
|
|
52
52
|
const count = Math.max(points.length, 1);
|
|
53
|
-
|
|
53
|
+
// The gap gives way before the bars do: at most half of each bar's slot, so past the 201 points
|
|
54
|
+
// where 3px gaps alone fill the width, every bar is still a positive rect and the row still ends
|
|
55
|
+
// at the right edge.
|
|
56
|
+
const gap = Math.min(BAR_CHART.gap, BAR_CHART.width / count / 2);
|
|
57
|
+
const width = (BAR_CHART.width - gap * (count - 1)) / count;
|
|
54
58
|
const max = maxOf(points);
|
|
55
59
|
return points.map((point, index) => {
|
|
56
60
|
const value = Number.isFinite(point.value) ? Math.max(0, point.value) : 0;
|
|
57
61
|
const height = Math.max(2, (value / max) * BAR_CHART.plot);
|
|
58
62
|
return {
|
|
59
|
-
x: index * (width +
|
|
63
|
+
x: index * (width + gap),
|
|
60
64
|
y: BAR_CHART.top + BAR_CHART.plot - height,
|
|
61
65
|
width,
|
|
62
66
|
height,
|