@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.
- package/README.md +151 -8
- package/dist/components/atoms/Button.svelte +121 -12
- package/dist/components/atoms/Button.svelte.d.ts +4 -0
- package/dist/components/atoms/Scrim.svelte +81 -0
- package/dist/components/atoms/Scrim.svelte.d.ts +14 -0
- package/dist/components/layouts/Cluster.svelte +33 -0
- package/dist/components/layouts/Cluster.svelte.d.ts +1 -0
- package/dist/components/layouts/ResizablePanel.svelte +238 -120
- package/dist/components/layouts/ResizablePanel.svelte.d.ts +24 -8
- package/dist/components/layouts/resizable-panel-frame.d.ts +70 -0
- package/dist/components/layouts/resizable-panel-frame.js +161 -0
- package/dist/components/molecules/CopyButton.svelte +11 -2
- package/dist/components/molecules/CopyButton.svelte.d.ts +4 -0
- package/dist/components/molecules/FileButton.svelte +32 -3
- package/dist/components/molecules/FileButton.svelte.d.ts +4 -0
- package/dist/components/molecules/IconButton.svelte +34 -6
- package/dist/components/molecules/IconButton.svelte.d.ts +3 -0
- package/dist/components/molecules/Popover.svelte +32 -2
- package/dist/components/molecules/Popover.svelte.d.ts +5 -0
- package/dist/components/molecules/SelectButton.svelte +6 -1
- package/dist/components/molecules/SelectButton.svelte.d.ts +2 -0
- package/dist/components/molecules/ThemePicker.svelte +11 -13
- package/dist/components/organisms/DataTable.svelte +210 -10
- package/dist/components/organisms/DataTable.svelte.d.ts +27 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/stores/theme.svelte.d.ts +21 -5
- package/dist/stores/theme.svelte.js +48 -22
- package/dist/styles/app.css +7 -297
- package/dist/styles/reset.css +97 -0
- package/dist/styles/syntax.css +94 -0
- package/dist/styles/themes.css +729 -0
- package/dist/styles/tokens.css +207 -0
- package/dist/styles/utilities.css +111 -0
- package/dist/styles/variables.css +5 -915
- 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
|
-
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
25
|
-
//
|
|
26
|
-
// `hoverDanger` to tint it red on
|
|
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
|
-
//
|
|
29
|
-
//
|
|
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:
|
|
230
|
-
min-width:
|
|
231
|
-
height:
|
|
232
|
-
width:
|
|
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
|
|
357
|
+
/* Icon-only buttons (IconButton). Square box = consistent --box-md tap target. */
|
|
321
358
|
.btn-icon {
|
|
322
|
-
min-height:
|
|
323
|
-
min-width:
|
|
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>
|