@dorsk/tsumikit 0.21.0 → 0.23.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 +60 -4
- package/dist/components/atoms/Button.svelte +121 -12
- package/dist/components/atoms/Button.svelte.d.ts +4 -0
- package/dist/components/layouts/AppShell.svelte +47 -5
- package/dist/components/layouts/AppShell.svelte.d.ts +8 -0
- package/dist/components/layouts/Cluster.svelte +33 -0
- package/dist/components/layouts/Cluster.svelte.d.ts +1 -0
- package/dist/components/layouts/Container.svelte +54 -13
- package/dist/components/layouts/Container.svelte.d.ts +11 -3
- 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/organisms/DataTable.svelte +210 -10
- package/dist/components/organisms/DataTable.svelte.d.ts +27 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/styles/app.css +1 -3
- package/dist/styles/variables.css +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,13 +80,38 @@ any custom SVG).
|
|
|
80
80
|
Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
|
|
81
81
|
Dropzone, CodeBlock, Callout, EmptyState, Toaster, ThemePicker, FontScalePicker.
|
|
82
82
|
|
|
83
|
-
**Organisms:** DataTable (generic `<T>`, typed columns + cell snippets
|
|
83
|
+
**Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
|
|
84
|
+
`layout="fixed"` makes column widths authoritative, `Column.truncate` /
|
|
85
|
+
`nowrap` / `hideBelow="sm|md|lg"` (container-query on the table's own box),
|
|
86
|
+
`hideHeader` clips the header but keeps it for assistive tech, `rowTone(row)`
|
|
87
|
+
paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
|
|
88
|
+
for a hover/focus-revealed trailing cell (always visible on touch),
|
|
89
|
+
`stickyOffset` for the sticky header's `top`, `size="sm"`, `loading`,
|
|
90
|
+
`onloadmore` footer button, `empty` as string or snippet; `data-part`
|
|
91
|
+
hooks on head/row/cell).
|
|
84
92
|
|
|
85
93
|
**Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
|
|
86
|
-
sidebar on desktop, overlay drawer on mobile, optionally resizable
|
|
94
|
+
sidebar on desktop, overlay drawer on mobile, optionally resizable;
|
|
95
|
+
`layout="sidebar-full"` runs the sidebar the full height with the header over
|
|
96
|
+
the content column only, `stickySidebar` pins it to the viewport, and
|
|
97
|
+
`sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
|
|
98
|
+
children are `min-width: 0` so a wide title/actions row can't widen the grid on
|
|
99
|
+
mobile), NavItem
|
|
87
100
|
(collapses to an icon rail when the sidebar is narrow), Container, Stack
|
|
88
|
-
(vertical), Cluster (wrapping row
|
|
89
|
-
|
|
101
|
+
(vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
|
|
102
|
+
query container and stacks children full-width below 18/30/40/48rem — phone
|
|
103
|
+
action rows without a viewport query), AutoGrid (intrinsically responsive
|
|
104
|
+
columns — no media/container query needed).
|
|
105
|
+
|
|
106
|
+
### Container
|
|
107
|
+
|
|
108
|
+
`size` caps the column (`--content-max` by default; `size="none"` fills the parent),
|
|
109
|
+
`gutter` overrides the `--sp-4` inline padding (safe-area insets still win),
|
|
110
|
+
`align="start"` drops the centering. `fullWidth` is a *viewport* breakout for
|
|
111
|
+
edge-to-edge sections — inside AppShell's main column use `size="none"` instead;
|
|
112
|
+
`inset="<left> <right>"` keeps a `fullWidth` container clear of docked panels.
|
|
113
|
+
Children can bleed to the column edge with
|
|
114
|
+
`margin-inline: calc(-1 * var(--container-gutter))`.
|
|
90
115
|
|
|
91
116
|
### Stacked distribution + legend
|
|
92
117
|
|
|
@@ -184,6 +209,37 @@ Button and Popover share the same semantic tones. For a confirmed positive
|
|
|
184
209
|
action, `tone="success"` gives neutral controls a success tint; combine it with
|
|
185
210
|
`variant="primary"` for a filled success action without consumer CSS.
|
|
186
211
|
|
|
212
|
+
### Square boxes & touch targets
|
|
213
|
+
|
|
214
|
+
Icon-ish controls share one square scale, `box="xs" | "sm" | "md" | "lg"` =
|
|
215
|
+
`--box-xs/sm/md/lg` (1.5rem / `--control-height-compact` / 2.25rem /
|
|
216
|
+
`--control-height-default`): `IconButton`, `Button`, `SelectButton`, `Popover`
|
|
217
|
+
(trigger), `CopyButton` (hides its label) and `FileButton` (implies `iconOnly`).
|
|
218
|
+
A boxed control is always square, padding-less and `flex: none`, so a header
|
|
219
|
+
row of IconButton + SelectButton + Popover lines up with no per-app overrides.
|
|
220
|
+
`md` is the classic 2.25rem icon button and the default; `chip` is an outlined
|
|
221
|
+
`lg`. `Button square` instead takes its side from the height contract in force
|
|
222
|
+
(`size` tier, or `--control-height` with `control`), for a square text glyph in
|
|
223
|
+
a toolbar. `IconButton glyphSize` sets the exact glyph size for SVG *and* text
|
|
224
|
+
glyphs (`size` stays the SVG px, emoji ×1.35).
|
|
225
|
+
|
|
226
|
+
On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
|
|
227
|
+
trigger) carries an invisible `::after` slab that extends its hit area to
|
|
228
|
+
`--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
|
|
229
|
+
untouched. `hitArea="compact"` opts a dense-table row out. `Button
|
|
230
|
+
collapseLabel="mobile" | "container"` hides `<span data-label>` children below
|
|
231
|
+
40rem viewport / 30rem container, drops to icon padding, and names the button
|
|
232
|
+
from the hidden text unless `aria-label` is set:
|
|
233
|
+
|
|
234
|
+
```svelte
|
|
235
|
+
<Button collapseLabel="container"><Icon name="search" /><span data-label>Search</span></Button>
|
|
236
|
+
<Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`--control-height-*` and `--box-*` are **required tokens**: a consumer that
|
|
240
|
+
forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
|
|
241
|
+
or those controls render with unresolved sizes.
|
|
242
|
+
|
|
187
243
|
## Built on the platform
|
|
188
244
|
|
|
189
245
|
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;
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
resizableSidebar = false,
|
|
25
25
|
minSidebar = 64,
|
|
26
26
|
maxSidebar = 360,
|
|
27
|
-
sidebarWidthKey
|
|
27
|
+
sidebarWidthKey,
|
|
28
|
+
layout = 'header-top',
|
|
29
|
+
stickySidebar = false,
|
|
30
|
+
sidebarPadding = 'md'
|
|
28
31
|
}: {
|
|
29
32
|
header?: Snippet;
|
|
30
33
|
sidebar?: Snippet;
|
|
@@ -41,6 +44,14 @@
|
|
|
41
44
|
maxSidebar?: number;
|
|
42
45
|
/** localStorage key to persist the resized width. */
|
|
43
46
|
sidebarWidthKey?: string;
|
|
47
|
+
/** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
|
|
48
|
+
* and the header only covers the content column (brand sits top-left). */
|
|
49
|
+
layout?: 'header-top' | 'sidebar-full';
|
|
50
|
+
/** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
|
|
51
|
+
* footer stays visible on long pages. */
|
|
52
|
+
stickySidebar?: boolean;
|
|
53
|
+
/** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
|
|
54
|
+
sidebarPadding?: 'none' | 'sm' | 'md';
|
|
44
55
|
} = $props();
|
|
45
56
|
|
|
46
57
|
let open = $state(false);
|
|
@@ -115,7 +126,14 @@
|
|
|
115
126
|
|
|
116
127
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && (open = false)} />
|
|
117
128
|
|
|
118
|
-
<div
|
|
129
|
+
<div
|
|
130
|
+
class="shell"
|
|
131
|
+
class:dragging
|
|
132
|
+
class:sidebar-full={layout === 'sidebar-full'}
|
|
133
|
+
class:sticky-sidebar={stickySidebar}
|
|
134
|
+
style="--shell-sidebar-w: {widthCss}"
|
|
135
|
+
data-tsu="AppShell"
|
|
136
|
+
>
|
|
119
137
|
<header class="shell-header">
|
|
120
138
|
{#if sidebar}
|
|
121
139
|
<!-- Wrapper owned here so the responsive hide is a scoped rule on our own
|
|
@@ -143,6 +161,7 @@
|
|
|
143
161
|
<aside
|
|
144
162
|
class="shell-sidebar"
|
|
145
163
|
class:open
|
|
164
|
+
data-padding={sidebarPadding}
|
|
146
165
|
aria-label={navLabel}
|
|
147
166
|
inert={isMobile && !open ? true : undefined}
|
|
148
167
|
>
|
|
@@ -190,6 +209,7 @@
|
|
|
190
209
|
display: flex;
|
|
191
210
|
align-items: center;
|
|
192
211
|
gap: var(--sp-3);
|
|
212
|
+
min-width: 0;
|
|
193
213
|
height: var(--header-h);
|
|
194
214
|
padding-inline: max(var(--sp-4), var(--safe-left)) max(var(--sp-4), var(--safe-right));
|
|
195
215
|
padding-top: var(--safe-top);
|
|
@@ -197,6 +217,9 @@
|
|
|
197
217
|
backdrop-filter: blur(8px);
|
|
198
218
|
border-bottom: 1px solid var(--border);
|
|
199
219
|
}
|
|
220
|
+
.shell-header > :global(*) {
|
|
221
|
+
min-width: 0;
|
|
222
|
+
}
|
|
200
223
|
.shell-menu-btn {
|
|
201
224
|
display: inline-flex;
|
|
202
225
|
align-items: center;
|
|
@@ -229,15 +252,22 @@
|
|
|
229
252
|
-webkit-overflow-scrolling: touch;
|
|
230
253
|
background: var(--bg-elevated);
|
|
231
254
|
border-right: 1px solid var(--border);
|
|
232
|
-
|
|
233
|
-
padding
|
|
234
|
-
padding-
|
|
255
|
+
--shell-sidebar-pad: var(--sp-3);
|
|
256
|
+
padding: var(--shell-sidebar-pad);
|
|
257
|
+
padding-top: max(var(--shell-sidebar-pad), var(--safe-top));
|
|
258
|
+
padding-bottom: max(var(--shell-sidebar-pad), var(--safe-bottom));
|
|
235
259
|
/* A query container named `sidebar` so nav items collapse to an icon rail
|
|
236
260
|
based on the sidebar's own width (see NavItem). */
|
|
237
261
|
container: sidebar / inline-size;
|
|
238
262
|
transform: translateX(-100%);
|
|
239
263
|
transition: transform 0.2s var(--ease);
|
|
240
264
|
}
|
|
265
|
+
.shell-sidebar[data-padding='sm'] {
|
|
266
|
+
--shell-sidebar-pad: var(--sp-2);
|
|
267
|
+
}
|
|
268
|
+
.shell-sidebar[data-padding='none'] {
|
|
269
|
+
--shell-sidebar-pad: 0px;
|
|
270
|
+
}
|
|
241
271
|
.shell-sidebar.open {
|
|
242
272
|
transform: translateX(0);
|
|
243
273
|
box-shadow: var(--shadow-lg);
|
|
@@ -273,6 +303,12 @@
|
|
|
273
303
|
'sidebar main'
|
|
274
304
|
'footer footer';
|
|
275
305
|
}
|
|
306
|
+
.shell.sidebar-full {
|
|
307
|
+
grid-template-areas:
|
|
308
|
+
'sidebar header'
|
|
309
|
+
'sidebar main'
|
|
310
|
+
'sidebar footer';
|
|
311
|
+
}
|
|
276
312
|
.shell-sidebar {
|
|
277
313
|
position: relative; /* anchor the absolute resize handle */
|
|
278
314
|
grid-area: sidebar;
|
|
@@ -281,6 +317,12 @@
|
|
|
281
317
|
box-shadow: none;
|
|
282
318
|
border-right: 1px solid var(--border);
|
|
283
319
|
}
|
|
320
|
+
.shell.sticky-sidebar .shell-sidebar {
|
|
321
|
+
position: sticky;
|
|
322
|
+
top: 0;
|
|
323
|
+
height: 100dvh;
|
|
324
|
+
align-self: start;
|
|
325
|
+
}
|
|
284
326
|
.shell-scrim,
|
|
285
327
|
.shell-menu-btn {
|
|
286
328
|
display: none !important;
|
|
@@ -15,6 +15,14 @@ type $$ComponentProps = {
|
|
|
15
15
|
maxSidebar?: number;
|
|
16
16
|
/** localStorage key to persist the resized width. */
|
|
17
17
|
sidebarWidthKey?: string;
|
|
18
|
+
/** `'sidebar-full'`: the sidebar spans the whole height on desktop/tablet
|
|
19
|
+
* and the header only covers the content column (brand sits top-left). */
|
|
20
|
+
layout?: 'header-top' | 'sidebar-full';
|
|
21
|
+
/** Pin the desktop sidebar to the viewport (100dvh, own scroll) so its
|
|
22
|
+
* footer stays visible on long pages. */
|
|
23
|
+
stickySidebar?: boolean;
|
|
24
|
+
/** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
|
|
25
|
+
sidebarPadding?: 'none' | 'sm' | 'md';
|
|
18
26
|
};
|
|
19
27
|
declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
20
28
|
type AppShell = ReturnType<typeof AppShell>;
|
|
@@ -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>
|
|
@@ -1,31 +1,51 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// (the `margin-inline: calc(50% - 50vw)` trick), for edge-to-edge sections.
|
|
7
|
-
// Polymorphic via `as` so it can be a <main>, <section>, etc.
|
|
2
|
+
// Max-width content column with token gutters that respect safe-area insets.
|
|
3
|
+
// Self-contained: does not depend on the global `.container` utility. Exposes
|
|
4
|
+
// `--container-gutter` so children can bleed to the column edge with
|
|
5
|
+
// `margin-inline: calc(-1 * var(--container-gutter))`.
|
|
8
6
|
import type { Snippet } from 'svelte';
|
|
9
7
|
|
|
10
8
|
let {
|
|
11
9
|
as = 'div',
|
|
12
10
|
size,
|
|
11
|
+
gutter,
|
|
12
|
+
align = 'center',
|
|
13
13
|
pad = false,
|
|
14
14
|
fullWidth = false,
|
|
15
|
+
inset,
|
|
15
16
|
class: klass = '',
|
|
16
17
|
children,
|
|
17
18
|
...rest
|
|
18
19
|
}: {
|
|
19
20
|
as?: 'div' | 'main' | 'section' | 'article';
|
|
20
|
-
/** Max width (any CSS length). Defaults to --content-max. Ignored when `fullWidth`. */
|
|
21
|
-
size?: string;
|
|
21
|
+
/** Max width (any CSS length), or `'none'` to fill the parent. Defaults to --content-max. Ignored when `fullWidth`. */
|
|
22
|
+
size?: string | 'none';
|
|
23
|
+
/** Inline gutter (any CSS length). Defaults to --sp-4; safe-area insets still win when larger. */
|
|
24
|
+
gutter?: string;
|
|
25
|
+
/** `'center'` (margin-inline auto) or `'start'` (flush with the parent's start edge). */
|
|
26
|
+
align?: 'center' | 'start';
|
|
22
27
|
pad?: boolean;
|
|
23
|
-
/** Break out to the full viewport width, ignoring `size`/--content-max
|
|
28
|
+
/** Break out to the full *viewport* width, ignoring `size`/--content-max and any
|
|
29
|
+
* centered ancestor. Inside a sidebar layout prefer `size="none"` (fills the column). */
|
|
24
30
|
fullWidth?: boolean;
|
|
31
|
+
/** Space reserved at the viewport edges when `fullWidth` (`'left right'` or one value
|
|
32
|
+
* for both), e.g. `"var(--dock-left-w) var(--dock-right-w)"` for docked panels. */
|
|
33
|
+
inset?: string;
|
|
25
34
|
class?: string;
|
|
26
35
|
children?: Snippet;
|
|
27
36
|
[key: string]: unknown;
|
|
28
37
|
} = $props();
|
|
38
|
+
|
|
39
|
+
const style = $derived.by(() => {
|
|
40
|
+
const vars: string[] = [];
|
|
41
|
+
if (!fullWidth && size && size !== 'none') vars.push(`--ct-max: ${size}`);
|
|
42
|
+
if (gutter) vars.push(`--container-gutter: ${gutter}`);
|
|
43
|
+
if (fullWidth && inset) {
|
|
44
|
+
const [left, right = left] = inset.trim().split(/\s+/);
|
|
45
|
+
vars.push(`--ct-inset-l: ${left}`, `--ct-inset-r: ${right}`);
|
|
46
|
+
}
|
|
47
|
+
return vars.length ? vars.join('; ') : undefined;
|
|
48
|
+
});
|
|
29
49
|
</script>
|
|
30
50
|
|
|
31
51
|
<svelte:element
|
|
@@ -33,25 +53,46 @@
|
|
|
33
53
|
data-tsu="Container"
|
|
34
54
|
class="container ct {klass}"
|
|
35
55
|
class:pad
|
|
56
|
+
class:none={size === 'none'}
|
|
57
|
+
class:start={align === 'start'}
|
|
36
58
|
class:full={fullWidth}
|
|
37
|
-
style
|
|
59
|
+
{style}
|
|
38
60
|
{...rest}
|
|
39
61
|
>
|
|
40
62
|
{@render children?.()}
|
|
41
63
|
</svelte:element>
|
|
42
64
|
|
|
43
65
|
<style>
|
|
66
|
+
.ct {
|
|
67
|
+
--container-gutter: var(--sp-4);
|
|
68
|
+
width: 100%;
|
|
69
|
+
max-width: var(--ct-max, var(--content-max));
|
|
70
|
+
margin-inline: auto;
|
|
71
|
+
padding-inline: max(var(--container-gutter), var(--safe-left))
|
|
72
|
+
max(var(--container-gutter), var(--safe-right));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.ct.none {
|
|
76
|
+
max-width: none;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.ct.start {
|
|
80
|
+
margin-inline: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
44
83
|
.ct.pad {
|
|
45
84
|
padding-top: var(--sp-6);
|
|
46
85
|
padding-bottom: var(--sp-12);
|
|
47
86
|
}
|
|
48
87
|
|
|
49
|
-
/* Break out of any centered ancestor to span the
|
|
88
|
+
/* Break out of any centered ancestor to span the viewport (minus `inset`).
|
|
50
89
|
`margin-inline: calc(50% - 50vw)` pulls each edge out to the viewport,
|
|
51
90
|
keeping the element in normal flow (no transform/overflow side-effects). */
|
|
52
91
|
.ct.full {
|
|
92
|
+
--ct-inset-l: 0px;
|
|
93
|
+
--ct-inset-r: 0px;
|
|
53
94
|
max-width: none;
|
|
54
|
-
width: 100vw;
|
|
55
|
-
margin-inline: calc(50% - 50vw);
|
|
95
|
+
width: calc(100vw - var(--ct-inset-l) - var(--ct-inset-r));
|
|
96
|
+
margin-inline: calc(50% - 50vw + var(--ct-inset-l)) calc(50% - 50vw + var(--ct-inset-r));
|
|
56
97
|
}
|
|
57
98
|
</style>
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
as?: 'div' | 'main' | 'section' | 'article';
|
|
4
|
-
/** Max width (any CSS length). Defaults to --content-max. Ignored when `fullWidth`. */
|
|
5
|
-
size?: string;
|
|
4
|
+
/** Max width (any CSS length), or `'none'` to fill the parent. Defaults to --content-max. Ignored when `fullWidth`. */
|
|
5
|
+
size?: string | 'none';
|
|
6
|
+
/** Inline gutter (any CSS length). Defaults to --sp-4; safe-area insets still win when larger. */
|
|
7
|
+
gutter?: string;
|
|
8
|
+
/** `'center'` (margin-inline auto) or `'start'` (flush with the parent's start edge). */
|
|
9
|
+
align?: 'center' | 'start';
|
|
6
10
|
pad?: boolean;
|
|
7
|
-
/** Break out to the full viewport width, ignoring `size`/--content-max
|
|
11
|
+
/** Break out to the full *viewport* width, ignoring `size`/--content-max and any
|
|
12
|
+
* centered ancestor. Inside a sidebar layout prefer `size="none"` (fills the column). */
|
|
8
13
|
fullWidth?: boolean;
|
|
14
|
+
/** Space reserved at the viewport edges when `fullWidth` (`'left right'` or one value
|
|
15
|
+
* for both), e.g. `"var(--dock-left-w) var(--dock-right-w)"` for docked panels. */
|
|
16
|
+
inset?: string;
|
|
9
17
|
class?: string;
|
|
10
18
|
children?: Snippet;
|
|
11
19
|
[key: string]: unknown;
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
label = 'Copy',
|
|
14
14
|
copiedLabel = 'Copied',
|
|
15
15
|
variant = 'ghost',
|
|
16
|
-
showLabel
|
|
16
|
+
showLabel,
|
|
17
|
+
box,
|
|
18
|
+
hitArea = 'auto',
|
|
17
19
|
resetMs = 1500,
|
|
18
20
|
class: klass = ''
|
|
19
21
|
}: {
|
|
@@ -22,11 +24,16 @@
|
|
|
22
24
|
label?: string;
|
|
23
25
|
copiedLabel?: string;
|
|
24
26
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
27
|
+
/** Defaults to true, or false when `box` makes it icon-only. */
|
|
25
28
|
showLabel?: boolean;
|
|
29
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
|
|
30
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
31
|
+
hitArea?: 'auto' | 'compact';
|
|
26
32
|
resetMs?: number;
|
|
27
33
|
class?: string;
|
|
28
34
|
} = $props();
|
|
29
35
|
|
|
36
|
+
const labelShown = $derived(showLabel ?? box === undefined);
|
|
30
37
|
let copied = $state(false);
|
|
31
38
|
let status = $state('');
|
|
32
39
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -46,12 +53,14 @@
|
|
|
46
53
|
<Button
|
|
47
54
|
data-tsu="CopyButton"
|
|
48
55
|
{variant}
|
|
56
|
+
{box}
|
|
57
|
+
{hitArea}
|
|
49
58
|
class={klass}
|
|
50
59
|
onclick={copy}
|
|
51
60
|
aria-label={copied ? copiedLabel : label}
|
|
52
61
|
title={label}
|
|
53
62
|
>
|
|
54
63
|
<Icon name={copied ? 'check' : 'copy'} />
|
|
55
|
-
{#if
|
|
64
|
+
{#if labelShown}<span>{copied ? copiedLabel : label}</span>{/if}
|
|
56
65
|
</Button>
|
|
57
66
|
<span class="sr-only" role="status" aria-live="polite">{status}</span>
|