@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 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 flips sides automatically under `dir="rtl"`.
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
- - **No client runtime exists yet.** A new interactive component must 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.
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` → `OS preference`. `setTheme()` persists,
500
- `clearTheme()` forgets and follows the OS again, and the OS listener only applies
501
- while nothing is stored. `themeInlineScriptTag()` goes in `<head>` and applies the
502
- result before first paint, so there is no flash and screenshots are deterministic:
503
-
504
- ```
505
- Content-Security-Policy: script-src 'self' 'sha256-…' # themeInlineScriptCspSource()
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": "23.0.0",
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": "23.0.0",
53
- "@ultimat3/i18n": "23.0.0",
54
- "@ultimat3/money": "23.0.0",
55
- "@ultimat3/time": "23.0.0"
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
- * Unique id for label/description wiring, from a PROCESS-wide counter. Prefixed so a mismatch is
26
- * obvious in the DOM rather than silent.
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
- * Correct for the only render path that exists: a server render walks a tree once, so every id in
29
- * one document is distinct, which is all `for`/`aria-describedby` need. It is NOT stable across two
30
- * renders of the same tree — a second process, or a client re-render, starts its own count — so it
31
- * cannot survive hydration. That is latent rather than broken: this package has no client runtime
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-inline-start: 50%;
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
- // Transform-only translate keeps the 50% inline offset from fighting `margin-inline`.
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: translateX(-50%) translateY(0) scale(1);
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
- return (
220
- <div class={cx(styles['wrap'], props.class)}>
221
- <Table
222
- caption={props.caption}
223
- stickyHeader={props.stickyHeader !== false}
224
- density={props.density ?? 'comfortable'}
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
- <thead>
227
- <tr>
228
- {props.columns.map((column) => (
229
- <th
230
- scope="col"
231
- style={column.width === undefined ? undefined : { 'inline-size': column.width }}
232
- aria-sort={ariaSortFor(props.sort, column.key)}
233
- class={column.numeric === true ? styles['numeric'] : undefined}
234
- >
235
- {sortControl(column)}
236
- </th>
237
- ))}
238
- </tr>
239
- </thead>
240
- <tbody
241
- class={decided.kind === 'ready' && decided.busy ? styles['stale'] : undefined}
242
- aria-busy={ariaBool(isBusyBranch(decided))}
243
- >
244
- {body()}
245
- </tbody>
246
- </Table>
247
- {props.nextCursor === undefined && props.prevCursor === undefined ? null : pager()}
248
- </div>
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
- @include t.disabled;
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 { adoptDroppedFiles, progressPercent, selectFiles } from './file-input-view';
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
- const offer = (files: FileList | null | undefined): void => {
53
- props.onSelect(selectFiles([...(files ?? [])], props));
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
- // The input first, then the callback: a drop that only reaches `onSelect` leaves the real
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
- @include t.disabled;
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
- props.onSelect?.(selectFiles([...(event.currentTarget.files ?? [])], props));
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 : (
@@ -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
- .align-start {
37
- inset-inline-start: 0;
38
- }
39
-
40
- .align-end {
41
- inset-inline-end: 0;
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-start: auto;
56
- inset-inline-end: auto;
57
- translate: none;
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 loop and the panel
3
- // flips sides automatically under `dir="rtl"`.
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
- /** The anchor. Wire its onClick to `onOpenChange` in the caller. */
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({ id: triggerId, 'aria-expanded': props.open, 'aria-controls': panelId })}
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
- * The leading newline is the serializer's, not the value's: the HTML parser strips exactly one
32
- * newline after `<textarea>`, so emitting it unconditionally is what makes a value that itself
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
- inset-inline-start: 50%;
13
- translate: -50% t.space(1);
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: -50% 0;
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
- const width = (BAR_CHART.width - BAR_CHART.gap * (count - 1)) / count;
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 + BAR_CHART.gap),
63
+ x: index * (width + gap),
60
64
  y: BAR_CHART.top + BAR_CHART.plot - height,
61
65
  width,
62
66
  height,