@dorsk/tsumikit 0.22.0 → 0.24.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.
Files changed (36) hide show
  1. package/README.md +151 -8
  2. package/dist/components/atoms/Button.svelte +121 -12
  3. package/dist/components/atoms/Button.svelte.d.ts +4 -0
  4. package/dist/components/atoms/Scrim.svelte +81 -0
  5. package/dist/components/atoms/Scrim.svelte.d.ts +14 -0
  6. package/dist/components/layouts/Cluster.svelte +33 -0
  7. package/dist/components/layouts/Cluster.svelte.d.ts +1 -0
  8. package/dist/components/layouts/ResizablePanel.svelte +238 -120
  9. package/dist/components/layouts/ResizablePanel.svelte.d.ts +24 -8
  10. package/dist/components/layouts/resizable-panel-frame.d.ts +70 -0
  11. package/dist/components/layouts/resizable-panel-frame.js +161 -0
  12. package/dist/components/molecules/CopyButton.svelte +11 -2
  13. package/dist/components/molecules/CopyButton.svelte.d.ts +4 -0
  14. package/dist/components/molecules/FileButton.svelte +32 -3
  15. package/dist/components/molecules/FileButton.svelte.d.ts +4 -0
  16. package/dist/components/molecules/IconButton.svelte +34 -6
  17. package/dist/components/molecules/IconButton.svelte.d.ts +3 -0
  18. package/dist/components/molecules/Popover.svelte +32 -2
  19. package/dist/components/molecules/Popover.svelte.d.ts +5 -0
  20. package/dist/components/molecules/SelectButton.svelte +6 -1
  21. package/dist/components/molecules/SelectButton.svelte.d.ts +2 -0
  22. package/dist/components/molecules/ThemePicker.svelte +11 -13
  23. package/dist/components/organisms/DataTable.svelte +210 -10
  24. package/dist/components/organisms/DataTable.svelte.d.ts +27 -1
  25. package/dist/index.d.ts +3 -1
  26. package/dist/index.js +3 -1
  27. package/dist/stores/theme.svelte.d.ts +21 -5
  28. package/dist/stores/theme.svelte.js +48 -22
  29. package/dist/styles/app.css +7 -297
  30. package/dist/styles/reset.css +97 -0
  31. package/dist/styles/syntax.css +94 -0
  32. package/dist/styles/themes.css +729 -0
  33. package/dist/styles/tokens.css +207 -0
  34. package/dist/styles/utilities.css +111 -0
  35. package/dist/styles/variables.css +5 -915
  36. package/package.json +7 -2
package/README.md CHANGED
@@ -57,15 +57,91 @@ import { Button, Field, Input, Modal, ThemePicker } from '@dorsk/tsumikit';
57
57
 
58
58
  ## Theming
59
59
 
60
- - 17 themes ship (dark, light, sepia, **colorblind** — Okabe-Ito —, plus mocha,
61
- dracula, nord, tokyonight, gruvbox, solarized, rosepine, onedark, everforest,
62
- monokai, amoled, highcontrast).
63
- - A new theme = one entry in `THEMES` (`stores/theme.svelte.ts`) + one
64
- `[data-theme="id"]` block in `variables.css`. Nothing else changes.
60
+ - 23 themes ship (light, highcontrast, gruvboxlight, solarizedlight,
61
+ everforestlight, rosepinedawn, latte, nordlight, tokyoday, kanagawalotus,
62
+ sepia, dark, **colorblind** — Okabe-Ito —, mocha, dracula, nord, tokyonight,
63
+ gruvbox, solarized, rosepine, onedark, everforest, monokai, amoled).
65
64
  - `<ThemePicker />` and `<FontScalePicker />` wire the stores to the UI. Theme
66
65
  is persisted to `localStorage` and applied with no flash (head snippet in
67
66
  `app.html`) and updates the mobile `<meta name="theme-color">`.
68
67
 
68
+ ### Stylesheets
69
+
70
+ `@dorsk/tsumikit/styles/app.css` is a shell over five files you can import
71
+ individually, in this order:
72
+
73
+ ```css
74
+ @import '@dorsk/tsumikit/styles/tokens.css';
75
+ @import '@dorsk/tsumikit/styles/themes.css'; /* optional */
76
+ @import '@dorsk/tsumikit/styles/reset.css';
77
+ @import '@dorsk/tsumikit/styles/utilities.css';
78
+ @import '@dorsk/tsumikit/styles/syntax.css';
79
+ @import './brand.css';
80
+ ```
81
+
82
+ | export | contents |
83
+ | --- | --- |
84
+ | `styles/tokens.css` | `:root` only — every token the kit reads (`--c-*` palette, `--bg`/`--text`/`--accent` aliases, type scale, spacing, radii, shadows, fonts, `--control-height`, …). Theme-less. |
85
+ | `styles/themes.css` | the built-in `[data-theme="id"]` blocks, one per `THEMES` entry. |
86
+ | `styles/reset.css` | reset + element defaults. |
87
+ | `styles/utilities.css` | `.container`, `.stack`, `.row`, `.sr-only`, icon sizing, … |
88
+ | `styles/syntax.css` | highlight.js / Prism class → `--syn-*` mapping. |
89
+
90
+ `styles/variables.css` = `tokens.css` + `themes.css` (kept for compatibility).
91
+ Nothing is wrapped in `@layer`: kit rules are unlayered so component-scoped
92
+ styles cascade exactly as before, and a consumer stylesheet imported **after**
93
+ the kit wins on source order at equal specificity. Do not put brand overrides
94
+ inside a `@layer` of your own — layered rules lose to the kit's unlayered ones.
95
+
96
+ ### Your own theme, without vendoring
97
+
98
+ ```ts
99
+ // +layout.ts (or any module that runs before first render)
100
+ import { theme } from '@dorsk/tsumikit';
101
+ theme.register({ id: 'kusaritoi', label: 'Kusaritoi', icon: '鎖', themeColor: '#2a2a2a', mode: 'dark' });
102
+ theme.setDefault('kusaritoi');
103
+ ```
104
+
105
+ ```css
106
+ /* brand.css — imported after the kit stylesheet(s) */
107
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap');
108
+ :root { --font-mono: 'JetBrains Mono', ui-monospace, monospace; }
109
+
110
+ [data-theme='kusaritoi'] {
111
+ color-scheme: dark;
112
+ --c-bg: #2a2a2a; --c-bg-elev: #323232; --c-bg-elev-2: #3a3a3a;
113
+ --c-surface: #323232; --c-border: #484848; --c-border-strong: #585858;
114
+ --c-text: #e8e8e8; --c-text-muted: #b0b0b0; --c-text-faint: #808080;
115
+ --c-accent: #5ac8c8; --c-accent-ink: #0d1f1f; --c-accent-dim: #3a8a8a;
116
+ --c-blue: #5a9fd4; --c-amber: #d9a543; --c-red: #e06060;
117
+ --c-green: #6abf69; --c-violet: #b48ef0; --c-gold: #d9a543;
118
+ --c-teal: #5ac8c8;
119
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
120
+ --shadow-md: 0 6px 20px rgba(0, 0, 0, 0.5);
121
+ --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.6);
122
+ --mach-bg-sl: 45% 24%; --mach-fg-sl: 70% 82%; --mach-border-sl: 45% 40%;
123
+ }
124
+ ```
125
+
126
+ Contract for a `[data-theme='x']` block: all nineteen `--c-*` palette tokens,
127
+ `--shadow-sm|md|lg`, `--mach-bg-sl|fg-sl|border-sl`, and `color-scheme: light`
128
+ for a light theme (`:root` defaults to dark, so dark themes may omit it).
129
+ `--md-code-bg` is optional (derives from `--c-blue`). Everything else (`--bg`,
130
+ `--text`, `--syn-*`, …) is an alias in `tokens.css` and follows automatically.
131
+
132
+ - `theme.register(def | def[])` appends to `theme.all` (built-ins first); an
133
+ entry with a built-in `id` replaces that built-in. `icon` and `themeColor` are
134
+ optional. `ThemePicker` reads `theme.all`, so registered themes appear at once.
135
+ - `theme.setDefault(id)` is the theme used when `localStorage` holds nothing
136
+ valid — set the same id as `data-theme` in your `app.html` so the no-flash
137
+ snippet and the store agree. A persisted id that only becomes valid after
138
+ `register()` is picked up as soon as it is registered.
139
+ - Only the palette or only the fonts? Import `tokens.css` + your `brand.css`
140
+ and skip `themes.css`; or import the full `app.css` and override `:root`
141
+ tokens after it. Never copy the kit files.
142
+ - Adding a built-in theme to the kit = one entry in `THEMES`
143
+ (`stores/theme.svelte.ts`) + one block in `styles/themes.css`.
144
+
69
145
  ## Components
70
146
 
71
147
  **Atoms:** Text, Heading, Button, Input, Textarea, Select, Switch, Checkbox,
@@ -80,7 +156,15 @@ any custom SVG).
80
156
  Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
81
157
  Dropzone, CodeBlock, Callout, EmptyState, Toaster, ThemePicker, FontScalePicker.
82
158
 
83
- **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets).
159
+ **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
160
+ `layout="fixed"` makes column widths authoritative, `Column.truncate` /
161
+ `nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
162
+ `hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
163
+ paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
164
+ for a hover/focus-revealed trailing cell (always visible on touch),
165
+ `stickyOffset` for the sticky header's `top`, `size="sm"`, `loading`,
166
+ `onloadmore` footer button, `empty` as string or snippet; `data-part`
167
+ hooks on head/row/cell).
84
168
 
85
169
  **Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
86
170
  sidebar on desktop, overlay drawer on mobile, optionally resizable;
@@ -90,8 +174,10 @@ the content column only, `stickySidebar` pins it to the viewport, and
90
174
  children are `min-width: 0` so a wide title/actions row can't widen the grid on
91
175
  mobile), NavItem
92
176
  (collapses to an icon rail when the sidebar is narrow), Container, Stack
93
- (vertical), Cluster (wrapping row), AutoGrid (intrinsically responsive columns
94
- no media/container query needed).
177
+ (vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
178
+ query container and stacks children full-width below 18/30/40/48rem — phone
179
+ action rows without a viewport query), AutoGrid (intrinsically responsive
180
+ columns — no media/container query needed).
95
181
 
96
182
  ### Container
97
183
 
@@ -103,6 +189,32 @@ edge-to-edge sections — inside AppShell's main column use `size="none"` instea
103
189
  Children can bleed to the column edge with
104
190
  `margin-inline: calc(-1 * var(--container-gutter))`.
105
191
 
192
+ ### ResizablePanel
193
+
194
+ Inline by default: `panel` shares the row with `children` on its `side`, drag the
195
+ edge or use the arrow keys/Home/End on the separator, collapse it with the edge
196
+ chevron; `width`/`minWidth`/`maxWidth` (px numbers or CSS lengths such as
197
+ `'12rem'`) and `widthKey` persist the width. `mode="overlay"` turns it into a
198
+ fixed non-modal drawer (`role="dialog"`) on the viewport edge: `bind:open`,
199
+ `onclose` fires on Escape, scrim click or the edge control, `scrim={false}` drops
200
+ the dim backdrop, `fullWidthBelow="960px"` makes the drawer span the viewport (no
201
+ handle, no scrim) on small screens, and `clampToViewport` (default true) keeps a
202
+ stored width inside a shrunken window. `children` is optional in overlay mode.
203
+
204
+ ```svelte
205
+ <ResizablePanel mode="overlay" side="right" label="Conversation" bind:open
206
+ width={720} minWidth={360} maxWidth="90vw" widthKey="conv-w" fullWidthBelow="960px">
207
+ {#snippet panel()}…{/snippet}
208
+ </ResizablePanel>
209
+ ```
210
+
211
+ `Scrim` (`onclose`, `hideBelow`, `z`, `label`) is the drawer's backdrop atom,
212
+ exported for custom overlays: click or Escape (document-level) calls `onclose`.
213
+ `resizeHandle` is the shared drag action behind every grip: `use:resizeHandle={{
214
+ side, min, max, step, onwidth, oncommit, onreset, onactive, measure }}` gives
215
+ any element pointer-capture + rAF-coalesced dragging, arrow/Home/End keys and
216
+ double-click reset; the width defaults to the element's parent box.
217
+
106
218
  ### Stacked distribution + legend
107
219
 
108
220
  `SegmentedProgress mode="stacked"` turns the bar into one shared track whose slice
@@ -199,6 +311,37 @@ Button and Popover share the same semantic tones. For a confirmed positive
199
311
  action, `tone="success"` gives neutral controls a success tint; combine it with
200
312
  `variant="primary"` for a filled success action without consumer CSS.
201
313
 
314
+ ### Square boxes & touch targets
315
+
316
+ Icon-ish controls share one square scale, `box="xs" | "sm" | "md" | "lg"` =
317
+ `--box-xs/sm/md/lg` (1.5rem / `--control-height-compact` / 2.25rem /
318
+ `--control-height-default`): `IconButton`, `Button`, `SelectButton`, `Popover`
319
+ (trigger), `CopyButton` (hides its label) and `FileButton` (implies `iconOnly`).
320
+ A boxed control is always square, padding-less and `flex: none`, so a header
321
+ row of IconButton + SelectButton + Popover lines up with no per-app overrides.
322
+ `md` is the classic 2.25rem icon button and the default; `chip` is an outlined
323
+ `lg`. `Button square` instead takes its side from the height contract in force
324
+ (`size` tier, or `--control-height` with `control`), for a square text glyph in
325
+ a toolbar. `IconButton glyphSize` sets the exact glyph size for SVG *and* text
326
+ glyphs (`size` stays the SVG px, emoji ×1.35).
327
+
328
+ On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
329
+ trigger) carries an invisible `::after` slab that extends its hit area to
330
+ `--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
331
+ untouched. `hitArea="compact"` opts a dense-table row out. `Button
332
+ collapseLabel="mobile" | "container"` hides `<span data-label>` children below
333
+ 40rem viewport / 30rem container, drops to icon padding, and names the button
334
+ from the hidden text unless `aria-label` is set:
335
+
336
+ ```svelte
337
+ <Button collapseLabel="container"><Icon name="search" /><span data-label>Search</span></Button>
338
+ <Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
339
+ ```
340
+
341
+ `--control-height-*` and `--box-*` are **required tokens**: a consumer that
342
+ forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
343
+ or those controls render with unresolved sizes.
344
+
202
345
  ## Built on the platform
203
346
 
204
347
  Interactive components lean on modern web features rather than reimplementing
@@ -21,15 +21,29 @@
21
21
  href?: string;
22
22
  target?: string;
23
23
  rel?: string;
24
- // Square 2.25rem icon-only tap target (IconButton). `iconInline` is the
25
- // borderless, compact variant (chip-remove ✕, inline edit ✎); pair with
26
- // `hoverDanger` to tint it red on hover (delete affordances).
24
+ // Square icon-only tap target (IconButton), `--box-md` (2.25rem) unless
25
+ // `box` says otherwise. `iconInline` is the borderless, compact variant
26
+ // (chip-remove ✕, inline edit ✎); pair with `hoverDanger` to tint it red on
27
+ // hover (delete affordances).
27
28
  icon?: boolean;
28
- // Larger 2.5rem outlined square icon-chip (header/toolbar action). Pairs
29
- // with `tone` for tinted severity chips (back/archive/interrupt/more).
29
+ // Shared square box scale (`--box-xs/sm/md/lg`): always square, no padding,
30
+ // never flexes. Implies an icon-only control.
31
+ box?: 'xs' | 'sm' | 'md' | 'lg';
32
+ // Square text/icon control whose side equals the current height contract:
33
+ // `size` tier, or `--control-height` with `control`.
34
+ square?: boolean;
35
+ // Outlined `--box-lg` icon-chip (header/toolbar action). Pairs with `tone`
36
+ // for tinted severity chips (back/archive/interrupt/more).
30
37
  chip?: boolean;
31
38
  iconInline?: boolean;
32
39
  hoverDanger?: boolean;
40
+ // Hide `[data-label]` children below 40rem viewport (`mobile`) or 30rem
41
+ // container (`container`) so the button becomes icon-only; the hidden text
42
+ // becomes the aria-label unless one is set.
43
+ collapseLabel?: 'never' | 'mobile' | 'container';
44
+ // Icon-only/square buttons grow a 44px hit slab on coarse pointers without
45
+ // moving layout; `compact` opts out in dense tables.
46
+ hitArea?: 'auto' | 'compact';
33
47
  // Async/busy state: shows a spinner, blocks clicks, sets aria-busy. Stays
34
48
  // disabled-equivalent while true (so a double-submit can't fire).
35
49
  loading?: boolean;
@@ -47,9 +61,13 @@
47
61
  as = 'button',
48
62
  href,
49
63
  icon = false,
64
+ box,
65
+ square = false,
50
66
  chip = false,
51
67
  iconInline = false,
52
68
  hoverDanger = false,
69
+ collapseLabel = 'never',
70
+ hitArea = 'auto',
53
71
  loading = false,
54
72
  type = 'button',
55
73
  disabled = false,
@@ -69,10 +87,22 @@
69
87
  ? { href, 'aria-disabled': inactive || undefined }
70
88
  : { type, disabled: inactive }
71
89
  );
90
+
91
+ let el = $state<HTMLElement | null>(null);
92
+ const explicitName = $derived(rest['aria-label'] ?? rest['aria-labelledby']);
93
+ $effect(() => {
94
+ if (!el || collapseLabel === 'never' || explicitName) return;
95
+ const text = Array.from(el.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
96
+ .filter(Boolean)
97
+ .join(' ');
98
+ if (text) el.setAttribute('aria-label', text);
99
+ else el.removeAttribute('aria-label');
100
+ });
72
101
  </script>
73
102
 
74
103
  <svelte:element
75
104
  this={tag}
105
+ bind:this={el}
76
106
  data-tsu="Button"
77
107
  {...rest}
78
108
  {...elementAttrs}
@@ -88,7 +118,13 @@
88
118
  class:btn-control={control}
89
119
  class:btn-block={block}
90
120
  class:btn-icon={icon}
121
+ class:btn-box={box !== undefined}
122
+ class:btn-square={square}
91
123
  class:btn-chip={chip}
124
+ class:btn-collapse-mobile={collapseLabel === 'mobile'}
125
+ class:btn-collapse-container={collapseLabel === 'container'}
126
+ class:hit-compact={hitArea === 'compact'}
127
+ style:--btn-box={box ? `var(--box-${box})` : undefined}
92
128
  class:btn-tone-accent={tone === 'accent'}
93
129
  class:btn-tone-success={tone === 'success'}
94
130
  class:btn-tone-info={tone === 'info'}
@@ -226,11 +262,12 @@
226
262
  /* Icon-chip: larger square outlined tap target for header/toolbar actions.
227
263
  Combines with a tone for tinted severity chips. */
228
264
  .btn-chip {
229
- min-height: 2.5rem;
230
- min-width: 2.5rem;
231
- height: 2.5rem;
232
- width: 2.5rem;
265
+ min-height: var(--box-lg);
266
+ min-width: var(--box-lg);
267
+ height: var(--box-lg);
268
+ width: var(--box-lg);
233
269
  padding: 0;
270
+ flex: none;
234
271
  border-radius: var(--r-md);
235
272
  }
236
273
 
@@ -317,12 +354,41 @@
317
354
  filter: brightness(1.08);
318
355
  }
319
356
 
320
- /* Icon-only buttons (IconButton). Square box = consistent 2.25rem tap target. */
357
+ /* Icon-only buttons (IconButton). Square box = consistent --box-md tap target. */
321
358
  .btn-icon {
322
- min-height: 2.25rem;
323
- min-width: 2.25rem;
359
+ min-height: var(--box-md);
360
+ min-width: var(--box-md);
324
361
  padding: var(--sp-2);
325
362
  }
363
+ /* `square`: side = the height contract in force (size tier or control). */
364
+ .btn-square {
365
+ width: var(--control-height-default);
366
+ min-width: var(--control-height-default);
367
+ padding: 0;
368
+ flex: none;
369
+ }
370
+ .btn-square.btn-sm {
371
+ width: var(--control-height-compact);
372
+ min-width: var(--control-height-compact);
373
+ }
374
+ .btn-square.btn-lg {
375
+ width: var(--control-height-large);
376
+ min-width: var(--control-height-large);
377
+ height: var(--control-height-large);
378
+ }
379
+ .btn-square.btn-control {
380
+ width: var(--control-height);
381
+ min-width: var(--control-height);
382
+ }
383
+ /* `box`: explicit square scale, wins over icon/chip/square/size sizing. */
384
+ .btn-box {
385
+ width: var(--btn-box);
386
+ min-width: var(--btn-box);
387
+ height: var(--btn-box);
388
+ min-height: var(--btn-box);
389
+ padding: 0;
390
+ flex: none;
391
+ }
326
392
  .btn-icon-inline {
327
393
  min-height: 0;
328
394
  min-width: 0;
@@ -340,6 +406,49 @@
340
406
  color: var(--danger);
341
407
  }
342
408
 
409
+ /* Label collapse: `[data-label]` children disappear below the threshold and
410
+ the button falls back to icon-only padding. */
411
+ @media (max-width: 40rem) {
412
+ .btn-collapse-mobile :global([data-label]) {
413
+ display: none;
414
+ }
415
+ .btn-collapse-mobile {
416
+ padding: var(--sp-2);
417
+ gap: 0;
418
+ }
419
+ }
420
+ @container (max-width: 30rem) {
421
+ .btn-collapse-container :global([data-label]) {
422
+ display: none;
423
+ }
424
+ .btn-collapse-container {
425
+ padding: var(--sp-2);
426
+ gap: 0;
427
+ }
428
+ }
429
+
430
+ /* Coarse pointers: icon-only and square buttons carry an invisible slab that
431
+ extends the hit area to --touch-target without changing layout. Buttons
432
+ already at or above the target keep their own box (inset never positive). */
433
+ @media (pointer: coarse) {
434
+ .btn-icon:not(.hit-compact),
435
+ .btn-icon-inline:not(.hit-compact),
436
+ .btn-box:not(.hit-compact),
437
+ .btn-square:not(.hit-compact),
438
+ .btn-chip:not(.hit-compact) {
439
+ position: relative;
440
+ }
441
+ .btn-icon:not(.hit-compact)::after,
442
+ .btn-icon-inline:not(.hit-compact)::after,
443
+ .btn-box:not(.hit-compact)::after,
444
+ .btn-square:not(.hit-compact)::after,
445
+ .btn-chip:not(.hit-compact)::after {
446
+ content: '';
447
+ position: absolute;
448
+ inset: min(0px, calc((100% - var(--touch-target)) / 2));
449
+ }
450
+ }
451
+
343
452
  /* Two-state (toggle) buttons — e.g. an IconButton with `pressed`. Reacts to
344
453
  the native aria-pressed that flows through, so no extra class. Tint defaults
345
454
  to the accent; override per-instance with `style="--btn-on: var(--warn)"`. */
@@ -12,9 +12,13 @@ type ButtonProps = HTMLButtonAttributes & {
12
12
  target?: string;
13
13
  rel?: string;
14
14
  icon?: boolean;
15
+ box?: 'xs' | 'sm' | 'md' | 'lg';
16
+ square?: boolean;
15
17
  chip?: boolean;
16
18
  iconInline?: boolean;
17
19
  hoverDanger?: boolean;
20
+ collapseLabel?: 'never' | 'mobile' | 'container';
21
+ hitArea?: 'auto' | 'compact';
18
22
  loading?: boolean;
19
23
  class?: string;
20
24
  children?: Snippet;
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ import { browser } from '../../env';
3
+
4
+ let {
5
+ onclose,
6
+ hideBelow,
7
+ z = 'var(--z-drawer)',
8
+ label = 'Close'
9
+ }: {
10
+ /** Called on click anywhere on the scrim and on Escape (document-level). */
11
+ onclose?: () => void;
12
+ /** Viewport width (CSS length) under which the scrim is not rendered,
13
+ * for overlays that go full-bleed on small screens. */
14
+ hideBelow?: string;
15
+ /** z-index; defaults to the drawer layer. */
16
+ z?: string | number;
17
+ /** Accessible name of the click target. */
18
+ label?: string;
19
+ } = $props();
20
+
21
+ let hidden = $state(false);
22
+
23
+ $effect(() => {
24
+ if (!browser || !hideBelow) {
25
+ hidden = false;
26
+ return;
27
+ }
28
+ const query = matchMedia(`(max-width: ${hideBelow})`);
29
+ const sync = () => {
30
+ hidden = query.matches;
31
+ };
32
+ sync();
33
+ query.addEventListener('change', sync);
34
+ return () => query.removeEventListener('change', sync);
35
+ });
36
+
37
+ $effect(() => {
38
+ if (!browser) return;
39
+ const onKeydown = (event: KeyboardEvent) => {
40
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
41
+ event.preventDefault();
42
+ onclose?.();
43
+ };
44
+ document.addEventListener('keydown', onKeydown);
45
+ return () => document.removeEventListener('keydown', onKeydown);
46
+ });
47
+ </script>
48
+
49
+ {#if !hidden}
50
+ <button
51
+ type="button"
52
+ class="scrim"
53
+ style:z-index={z}
54
+ aria-label={label}
55
+ tabindex="-1"
56
+ onclick={() => onclose?.()}
57
+ data-tsu="Scrim"
58
+ ></button>
59
+ {/if}
60
+
61
+ <style>
62
+ .scrim {
63
+ position: fixed;
64
+ inset: 0;
65
+ padding: 0;
66
+ border: 0;
67
+ background: color-mix(in srgb, var(--bg) 45%, transparent);
68
+ cursor: default;
69
+ animation: scrim-fade 0.18s var(--ease);
70
+ }
71
+ @keyframes scrim-fade {
72
+ from {
73
+ opacity: 0;
74
+ }
75
+ }
76
+ @media (prefers-reduced-motion: reduce) {
77
+ .scrim {
78
+ animation: none;
79
+ }
80
+ }
81
+ </style>
@@ -0,0 +1,14 @@
1
+ type $$ComponentProps = {
2
+ /** Called on click anywhere on the scrim and on Escape (document-level). */
3
+ onclose?: () => void;
4
+ /** Viewport width (CSS length) under which the scrim is not rendered,
5
+ * for overlays that go full-bleed on small screens. */
6
+ hideBelow?: string;
7
+ /** z-index; defaults to the drawer layer. */
8
+ z?: string | number;
9
+ /** Accessible name of the click target. */
10
+ label?: string;
11
+ };
12
+ declare const Scrim: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type Scrim = ReturnType<typeof Scrim>;
14
+ export default Scrim;
@@ -10,6 +10,9 @@
10
10
  // SegmentedControl/Select `control`), so a whole toolbar lines up from one prop
11
11
  // instead of per-child sizing. `grow` makes direct children share the row width
12
12
  // equally (flex: 1) — the toolbar equivalent of `style="flex:1"` on each child.
13
+ // `stackAt` turns the cluster into its own inline-size query container and
14
+ // stacks children full-width once it is narrower than the tier (18/30/40/48rem),
15
+ // for phone action rows; the cluster then no longer shrink-wraps its content.
13
16
  const CONTROL_TIER = {
14
17
  sm: 'var(--control-height-compact)',
15
18
  md: 'var(--control-height-default)',
@@ -24,6 +27,7 @@
24
27
  wrap = true,
25
28
  size,
26
29
  grow = false,
30
+ stackAt,
27
31
  class: klass = '',
28
32
  children,
29
33
  ...rest
@@ -35,6 +39,7 @@
35
39
  wrap?: boolean;
36
40
  size?: 'sm' | 'md' | 'lg';
37
41
  grow?: boolean;
42
+ stackAt?: 'xs' | 'sm' | 'md' | 'lg';
38
43
  class?: string;
39
44
  children?: Snippet;
40
45
  [key: string]: unknown;
@@ -46,6 +51,11 @@
46
51
  data-tsu="Cluster"
47
52
  class="cluster-c {klass}"
48
53
  class:cluster-grow={grow}
54
+ class:cluster-stack={stackAt !== undefined}
55
+ class:stack-xs={stackAt === 'xs'}
56
+ class:stack-sm={stackAt === 'sm'}
57
+ class:stack-md={stackAt === 'md'}
58
+ class:stack-lg={stackAt === 'lg'}
49
59
  style:gap
50
60
  style:align-items={align}
51
61
  style:justify-content={justify}
@@ -64,4 +74,27 @@
64
74
  flex: 1 1 0;
65
75
  min-width: 0;
66
76
  }
77
+ .cluster-stack {
78
+ container-type: inline-size;
79
+ }
80
+ @container (max-width: 18rem) {
81
+ .stack-xs > :global(*) {
82
+ flex: 1 1 100%;
83
+ }
84
+ }
85
+ @container (max-width: 30rem) {
86
+ .stack-sm > :global(*) {
87
+ flex: 1 1 100%;
88
+ }
89
+ }
90
+ @container (max-width: 40rem) {
91
+ .stack-md > :global(*) {
92
+ flex: 1 1 100%;
93
+ }
94
+ }
95
+ @container (max-width: 48rem) {
96
+ .stack-lg > :global(*) {
97
+ flex: 1 1 100%;
98
+ }
99
+ }
67
100
  </style>
@@ -7,6 +7,7 @@ type $$ComponentProps = {
7
7
  wrap?: boolean;
8
8
  size?: 'sm' | 'md' | 'lg';
9
9
  grow?: boolean;
10
+ stackAt?: 'xs' | 'sm' | 'md' | 'lg';
10
11
  class?: string;
11
12
  children?: Snippet;
12
13
  [key: string]: unknown;