@dorsk/tsumikit 0.22.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 +44 -3
- package/dist/components/atoms/Button.svelte +121 -12
- package/dist/components/atoms/Button.svelte.d.ts +4 -0
- package/dist/components/layouts/Cluster.svelte +33 -0
- package/dist/components/layouts/Cluster.svelte.d.ts +1 -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/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/variables.css +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,7 +80,15 @@ 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
94
|
sidebar on desktop, overlay drawer on mobile, optionally resizable;
|
|
@@ -90,8 +98,10 @@ the content column only, `stickySidebar` pins it to the viewport, and
|
|
|
90
98
|
children are `min-width: 0` so a wide title/actions row can't widen the grid on
|
|
91
99
|
mobile), NavItem
|
|
92
100
|
(collapses to an icon rail when the sidebar is narrow), Container, Stack
|
|
93
|
-
(vertical), Cluster (wrapping row
|
|
94
|
-
|
|
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).
|
|
95
105
|
|
|
96
106
|
### Container
|
|
97
107
|
|
|
@@ -199,6 +209,37 @@ Button and Popover share the same semantic tones. For a confirmed positive
|
|
|
199
209
|
action, `tone="success"` gives neutral controls a success tint; combine it with
|
|
200
210
|
`variant="primary"` for a filled success action without consumer CSS.
|
|
201
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
|
+
|
|
202
243
|
## Built on the platform
|
|
203
244
|
|
|
204
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;
|
|
@@ -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>
|
|
@@ -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>
|
|
@@ -4,7 +4,11 @@ type $$ComponentProps = {
|
|
|
4
4
|
label?: string;
|
|
5
5
|
copiedLabel?: string;
|
|
6
6
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
7
|
+
/** Defaults to true, or false when `box` makes it icon-only. */
|
|
7
8
|
showLabel?: boolean;
|
|
9
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`): a square icon-only copy control. */
|
|
10
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
11
|
+
hitArea?: 'auto' | 'compact';
|
|
8
12
|
resetMs?: number;
|
|
9
13
|
class?: string;
|
|
10
14
|
};
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
disabled = false,
|
|
17
17
|
variant = 'default',
|
|
18
18
|
size = 'md',
|
|
19
|
+
box,
|
|
20
|
+
hitArea = 'auto',
|
|
19
21
|
class: klass = ''
|
|
20
22
|
}: {
|
|
21
23
|
onfiles: (files: File[]) => void;
|
|
@@ -36,9 +38,15 @@
|
|
|
36
38
|
/** Match the Button atom's sizes so a FileButton lines up with buttons in
|
|
37
39
|
* the same row. */
|
|
38
40
|
size?: 'sm' | 'md' | 'lg';
|
|
41
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
|
|
42
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
43
|
+
/** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
44
|
+
hitArea?: 'auto' | 'compact';
|
|
39
45
|
class?: string;
|
|
40
46
|
} = $props();
|
|
41
47
|
|
|
48
|
+
const onlyIcon = $derived(iconOnly || box !== undefined);
|
|
49
|
+
|
|
42
50
|
function onchange(e: Event) {
|
|
43
51
|
const input = e.currentTarget as HTMLInputElement;
|
|
44
52
|
const files = Array.from(input.files ?? []);
|
|
@@ -54,9 +62,12 @@
|
|
|
54
62
|
class:ghost={variant === 'ghost'}
|
|
55
63
|
class:sm={size === 'sm'}
|
|
56
64
|
class:lg={size === 'lg'}
|
|
57
|
-
class:icon-only={
|
|
65
|
+
class:icon-only={onlyIcon}
|
|
66
|
+
class:box={box !== undefined}
|
|
67
|
+
class:hit-compact={hitArea === 'compact'}
|
|
68
|
+
style:--file-box={box ? `var(--box-${box})` : undefined}
|
|
58
69
|
class:disabled
|
|
59
|
-
aria-label={
|
|
70
|
+
aria-label={onlyIcon ? label : undefined}
|
|
60
71
|
>
|
|
61
72
|
<input
|
|
62
73
|
class="sr-only"
|
|
@@ -71,7 +82,7 @@
|
|
|
71
82
|
{:else if emoji}
|
|
72
83
|
<span class="emoji" aria-hidden="true">{emoji}</span>
|
|
73
84
|
{/if}
|
|
74
|
-
<span class:sr-only={
|
|
85
|
+
<span class:sr-only={onlyIcon}>{label}</span>
|
|
75
86
|
</label>
|
|
76
87
|
|
|
77
88
|
<style>
|
|
@@ -120,6 +131,24 @@
|
|
|
120
131
|
padding: var(--sp-2);
|
|
121
132
|
aspect-ratio: 1;
|
|
122
133
|
}
|
|
134
|
+
.file-btn.box {
|
|
135
|
+
width: var(--file-box);
|
|
136
|
+
min-width: var(--file-box);
|
|
137
|
+
height: var(--file-box);
|
|
138
|
+
min-height: var(--file-box);
|
|
139
|
+
padding: 0;
|
|
140
|
+
flex: none;
|
|
141
|
+
}
|
|
142
|
+
@media (pointer: coarse) {
|
|
143
|
+
.file-btn.icon-only:not(.hit-compact) {
|
|
144
|
+
position: relative;
|
|
145
|
+
}
|
|
146
|
+
.file-btn.icon-only:not(.hit-compact)::after {
|
|
147
|
+
content: '';
|
|
148
|
+
position: absolute;
|
|
149
|
+
inset: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
123
152
|
/* Emoji glyph is bumped above the text size — at 1em a paperclip is hard to
|
|
124
153
|
read, so render it a touch larger for legibility. */
|
|
125
154
|
.emoji {
|
|
@@ -18,6 +18,10 @@ type $$ComponentProps = {
|
|
|
18
18
|
/** Match the Button atom's sizes so a FileButton lines up with buttons in
|
|
19
19
|
* the same row. */
|
|
20
20
|
size?: 'sm' | 'md' | 'lg';
|
|
21
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
|
|
22
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
23
|
+
/** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
24
|
+
hitArea?: 'auto' | 'compact';
|
|
21
25
|
class?: string;
|
|
22
26
|
};
|
|
23
27
|
declare const FileButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
@@ -18,9 +18,18 @@
|
|
|
18
18
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
19
19
|
// Semantic state tint (forwarded to Button). Pairs well with `chip`.
|
|
20
20
|
tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
|
|
21
|
-
//
|
|
21
|
+
// Outlined `--box-lg` square icon-chip (header/toolbar actions).
|
|
22
22
|
chip?: boolean;
|
|
23
|
+
// Shared square box scale (`--box-xs/sm/md/lg`); default `md` is the classic
|
|
24
|
+
// 2.25rem box. Forwarded to Button.
|
|
25
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
26
|
+
// SVG glyph px. Emoji text glyphs render at ×1.35 of it.
|
|
23
27
|
size?: number;
|
|
28
|
+
// Exact glyph size for SVG *and* text glyphs (px number or any CSS length),
|
|
29
|
+
// no emoji multiplier. Wins over `size`.
|
|
30
|
+
glyphSize?: number | string;
|
|
31
|
+
// Opt out of the 44px coarse-pointer hit slab in dense rows.
|
|
32
|
+
hitArea?: 'auto' | 'compact';
|
|
24
33
|
// Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
|
|
25
34
|
// no square box; just a muted glyph that brightens on hover. Pair with
|
|
26
35
|
// `hoverDanger` to tint it red on hover (delete affordances).
|
|
@@ -46,7 +55,10 @@
|
|
|
46
55
|
variant = 'ghost',
|
|
47
56
|
tone = 'none',
|
|
48
57
|
chip = false,
|
|
58
|
+
box,
|
|
49
59
|
size = 18,
|
|
60
|
+
glyphSize,
|
|
61
|
+
hitArea = 'auto',
|
|
50
62
|
inline = false,
|
|
51
63
|
hoverDanger = false,
|
|
52
64
|
pressed,
|
|
@@ -55,6 +67,11 @@
|
|
|
55
67
|
class: klass = '',
|
|
56
68
|
...rest
|
|
57
69
|
}: IconButtonProps = $props();
|
|
70
|
+
|
|
71
|
+
const glyphCss = $derived(
|
|
72
|
+
glyphSize === undefined ? undefined : typeof glyphSize === 'number' ? `${glyphSize}px` : glyphSize
|
|
73
|
+
);
|
|
74
|
+
const emojiCss = $derived(glyphCss ?? `${size * 1.35}px`);
|
|
58
75
|
</script>
|
|
59
76
|
|
|
60
77
|
<!-- Composition: the icon-only button is a Button (canonical control styling)
|
|
@@ -65,6 +82,8 @@
|
|
|
65
82
|
{variant}
|
|
66
83
|
{tone}
|
|
67
84
|
{chip}
|
|
85
|
+
{box}
|
|
86
|
+
{hitArea}
|
|
68
87
|
{disabled}
|
|
69
88
|
{title}
|
|
70
89
|
{onclick}
|
|
@@ -76,19 +95,28 @@
|
|
|
76
95
|
aria-label={label}
|
|
77
96
|
>
|
|
78
97
|
{#if children}
|
|
79
|
-
|
|
98
|
+
{#if glyphCss}
|
|
99
|
+
<span class="glyph" style="font-size: {glyphCss}"><Icon>{@render children()}</Icon></span>
|
|
100
|
+
{:else}
|
|
101
|
+
<Icon {size}>{@render children()}</Icon>
|
|
102
|
+
{/if}
|
|
80
103
|
{:else if emoji}
|
|
81
|
-
<span class="emoji" style="font-size: {
|
|
104
|
+
<span class="emoji" style="font-size: {emojiCss}" aria-hidden="true">{emoji}</span>
|
|
82
105
|
{:else if icon}
|
|
83
|
-
|
|
106
|
+
{#if glyphCss}
|
|
107
|
+
<span class="glyph" style="font-size: {glyphCss}"><Icon name={icon} /></span>
|
|
108
|
+
{:else}
|
|
109
|
+
<Icon name={icon} {size} />
|
|
110
|
+
{/if}
|
|
84
111
|
{/if}
|
|
85
112
|
</Button>
|
|
86
113
|
|
|
87
114
|
<style>
|
|
88
115
|
/* Off-registry glyph (emoji) rendered as text rather than an SVG. Sized off the
|
|
89
116
|
`size` prop (×1.35, since an emoji reads small next to an SVG glyph of the
|
|
90
|
-
same px)
|
|
91
|
-
.emoji
|
|
117
|
+
same px) unless `glyphSize` is exact; centered so it shares the tap target. */
|
|
118
|
+
.emoji,
|
|
119
|
+
.glyph {
|
|
92
120
|
display: inline-flex;
|
|
93
121
|
align-items: center;
|
|
94
122
|
justify-content: center;
|
|
@@ -14,7 +14,10 @@ type IconButtonProps = HTMLButtonAttributes & {
|
|
|
14
14
|
variant?: 'default' | 'primary' | 'ghost' | 'danger';
|
|
15
15
|
tone?: 'none' | 'accent' | 'info' | 'warn' | 'danger';
|
|
16
16
|
chip?: boolean;
|
|
17
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
17
18
|
size?: number;
|
|
19
|
+
glyphSize?: number | string;
|
|
20
|
+
hitArea?: 'auto' | 'compact';
|
|
18
21
|
inline?: boolean;
|
|
19
22
|
hoverDanger?: boolean;
|
|
20
23
|
pressed?: boolean;
|
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
variant,
|
|
28
28
|
tone = 'none',
|
|
29
29
|
size,
|
|
30
|
+
box,
|
|
30
31
|
control = false,
|
|
31
32
|
block = false,
|
|
33
|
+
hitArea = 'auto',
|
|
32
34
|
disabled = false,
|
|
33
35
|
onopen,
|
|
34
36
|
onclose
|
|
@@ -52,9 +54,14 @@
|
|
|
52
54
|
variant?: TriggerVariant;
|
|
53
55
|
tone?: TriggerTone;
|
|
54
56
|
size?: TriggerSize;
|
|
57
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
|
|
58
|
+
* the default trigger is the `md` 2.25rem box. */
|
|
59
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
55
60
|
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
56
61
|
control?: boolean;
|
|
57
62
|
block?: boolean;
|
|
63
|
+
/** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
64
|
+
hitArea?: 'auto' | 'compact';
|
|
58
65
|
disabled?: boolean;
|
|
59
66
|
onopen?: () => void;
|
|
60
67
|
onclose?: () => void;
|
|
@@ -109,6 +116,9 @@
|
|
|
109
116
|
class:trigger-lg={size === 'lg'}
|
|
110
117
|
class:trigger-control={control}
|
|
111
118
|
class:trigger-block={block}
|
|
119
|
+
class:trigger-box={box !== undefined}
|
|
120
|
+
class:hit-compact={hitArea === 'compact'}
|
|
121
|
+
style:--pop-box={box ? `var(--box-${box})` : undefined}
|
|
112
122
|
class:trigger-tone-accent={tone === 'accent'}
|
|
113
123
|
class:trigger-tone-success={tone === 'success'}
|
|
114
124
|
class:trigger-tone-info={tone === 'info'}
|
|
@@ -143,8 +153,8 @@
|
|
|
143
153
|
display: inline-flex;
|
|
144
154
|
align-items: center;
|
|
145
155
|
justify-content: center;
|
|
146
|
-
min-height:
|
|
147
|
-
min-width:
|
|
156
|
+
min-height: var(--box-md);
|
|
157
|
+
min-width: var(--box-md);
|
|
148
158
|
padding: var(--sp-2);
|
|
149
159
|
border: 1px solid transparent;
|
|
150
160
|
border-radius: var(--r-md);
|
|
@@ -221,6 +231,26 @@
|
|
|
221
231
|
.pop-trigger.trigger-block {
|
|
222
232
|
width: 100%;
|
|
223
233
|
}
|
|
234
|
+
.pop-trigger.trigger-box {
|
|
235
|
+
width: var(--pop-box);
|
|
236
|
+
min-width: var(--pop-box);
|
|
237
|
+
height: var(--pop-box);
|
|
238
|
+
min-height: var(--pop-box);
|
|
239
|
+
padding: 0;
|
|
240
|
+
flex: none;
|
|
241
|
+
}
|
|
242
|
+
/* Coarse pointers: icon-only triggers extend their hit area to --touch-target
|
|
243
|
+
via an invisible slab; layout does not move. */
|
|
244
|
+
@media (pointer: coarse) {
|
|
245
|
+
.pop-trigger:not(.canonical, .bare, .hit-compact) {
|
|
246
|
+
position: relative;
|
|
247
|
+
}
|
|
248
|
+
.pop-trigger:not(.canonical, .bare, .hit-compact)::after {
|
|
249
|
+
content: '';
|
|
250
|
+
position: absolute;
|
|
251
|
+
inset: min(0px, calc((100% - var(--touch-target)) / 2));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
224
254
|
.pop-trigger.trigger-tone-accent {
|
|
225
255
|
--pop-trigger-tone: var(--accent);
|
|
226
256
|
}
|
|
@@ -23,9 +23,14 @@ type $$ComponentProps = {
|
|
|
23
23
|
variant?: TriggerVariant;
|
|
24
24
|
tone?: TriggerTone;
|
|
25
25
|
size?: TriggerSize;
|
|
26
|
+
/** Shared square box scale (`--box-xs/sm/md/lg`) for an icon-only trigger;
|
|
27
|
+
* the default trigger is the `md` 2.25rem box. */
|
|
28
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
26
29
|
/** Use the shared `--control-height` toolbar/composer contract. */
|
|
27
30
|
control?: boolean;
|
|
28
31
|
block?: boolean;
|
|
32
|
+
/** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
|
|
33
|
+
hitArea?: 'auto' | 'compact';
|
|
29
34
|
disabled?: boolean;
|
|
30
35
|
onopen?: () => void;
|
|
31
36
|
onclose?: () => void;
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
options,
|
|
18
18
|
groups,
|
|
19
19
|
onchange,
|
|
20
|
+
box,
|
|
21
|
+
hitArea = 'auto',
|
|
20
22
|
class: klass = '',
|
|
21
23
|
...rest
|
|
22
24
|
}: {
|
|
@@ -31,6 +33,9 @@
|
|
|
31
33
|
// the theme picker to split light/dark; falls back to `options` otherwise.
|
|
32
34
|
groups?: { label: string; options: Option[] }[];
|
|
33
35
|
onchange: (value: string) => void;
|
|
36
|
+
// Shared square box scale (`--box-xs/sm/md/lg`); default is the 2.25rem icon box.
|
|
37
|
+
box?: 'xs' | 'sm' | 'md' | 'lg';
|
|
38
|
+
hitArea?: 'auto' | 'compact';
|
|
34
39
|
class?: string;
|
|
35
40
|
[key: string]: unknown;
|
|
36
41
|
} = $props();
|
|
@@ -39,7 +44,7 @@
|
|
|
39
44
|
<!-- The wrapper (owned here, so styled scoped) is the positioning context that
|
|
40
45
|
clips the transparent overlaid <select>; the icon Button shows through. -->
|
|
41
46
|
<span class="select-button {klass}" data-tsu="SelectButton" {...rest}>
|
|
42
|
-
<Button variant="ghost" icon {title} aria-label={label}>
|
|
47
|
+
<Button variant="ghost" icon {box} {hitArea} {title} aria-label={label}>
|
|
43
48
|
<span aria-hidden="true">{glyph}</span>
|
|
44
49
|
<Select
|
|
45
50
|
variant="ghost"
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
<script lang="ts" module>
|
|
2
|
+
export type RowTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
3
|
+
|
|
2
4
|
export interface Column<T> {
|
|
3
5
|
/** Key into the row, or an arbitrary id when paired with a cell snippet. */
|
|
4
6
|
key: string;
|
|
@@ -10,6 +12,12 @@
|
|
|
10
12
|
/** Header becomes a sort toggle. Sorts by the displayed value unless an
|
|
11
13
|
* `onsort` handler is supplied (then the caller controls ordering). */
|
|
12
14
|
sortable?: boolean;
|
|
15
|
+
/** One-line cells clipped with an ellipsis (pair with `layout="fixed"`). */
|
|
16
|
+
truncate?: boolean;
|
|
17
|
+
nowrap?: boolean;
|
|
18
|
+
/** Drop the column when the table's own box is narrower than the breakpoint
|
|
19
|
+
* (sm 30rem, md 48rem, lg 64rem). */
|
|
20
|
+
hideBelow?: 'sm' | 'md' | 'lg';
|
|
13
21
|
}
|
|
14
22
|
</script>
|
|
15
23
|
|
|
@@ -30,7 +38,19 @@
|
|
|
30
38
|
onsort,
|
|
31
39
|
cellSnippets = {},
|
|
32
40
|
empty = 'No data.',
|
|
33
|
-
stickyHeader = false
|
|
41
|
+
stickyHeader = false,
|
|
42
|
+
stickyOffset,
|
|
43
|
+
layout = 'auto',
|
|
44
|
+
hideHeader = false,
|
|
45
|
+
size = 'md',
|
|
46
|
+
rowTone,
|
|
47
|
+
rowClass,
|
|
48
|
+
rowActions,
|
|
49
|
+
rowActionsLabel = 'Actions',
|
|
50
|
+
loading = false,
|
|
51
|
+
loadingLabel = 'Loading…',
|
|
52
|
+
onloadmore,
|
|
53
|
+
loadMoreLabel = 'Load more'
|
|
34
54
|
}: {
|
|
35
55
|
columns: Column<T>[];
|
|
36
56
|
rows: T[];
|
|
@@ -41,13 +61,34 @@
|
|
|
41
61
|
* table only reflects the indicator and emits; it does not reorder rows. */
|
|
42
62
|
onsort?: (key: string, dir: 'asc' | 'desc') => void;
|
|
43
63
|
cellSnippets?: Record<string, Snippet<[T]>>;
|
|
44
|
-
empty?: string;
|
|
64
|
+
empty?: string | Snippet;
|
|
45
65
|
stickyHeader?: boolean;
|
|
66
|
+
/** Distance from the scroll container's top for the sticky header, e.g.
|
|
67
|
+
* `var(--header-h)` when the page header overlaps. Defaults to 0. */
|
|
68
|
+
stickyOffset?: string;
|
|
69
|
+
/** `fixed` makes declared column widths authoritative (needed for truncate). */
|
|
70
|
+
layout?: 'auto' | 'fixed';
|
|
71
|
+
/** Visually hides the header; it stays in the DOM for assistive tech. */
|
|
72
|
+
hideHeader?: boolean;
|
|
73
|
+
size?: 'sm' | 'md';
|
|
74
|
+
/** Per-row semantic tone, rendered as a left accent bar + `data-tone`. */
|
|
75
|
+
rowTone?: (row: T) => RowTone | undefined;
|
|
76
|
+
rowClass?: (row: T) => string | undefined;
|
|
77
|
+
/** Trailing actions cell revealed on hover/focus (always visible on touch). */
|
|
78
|
+
rowActions?: Snippet<[T]>;
|
|
79
|
+
rowActionsLabel?: string;
|
|
80
|
+
loading?: boolean;
|
|
81
|
+
loadingLabel?: string;
|
|
82
|
+
/** Renders a footer "load more" button that calls this. */
|
|
83
|
+
onloadmore?: () => void;
|
|
84
|
+
loadMoreLabel?: string;
|
|
46
85
|
} = $props();
|
|
47
86
|
|
|
48
87
|
let sortKey = $state<string | null>(null);
|
|
49
88
|
let sortDir = $state<'asc' | 'desc'>('asc');
|
|
50
89
|
|
|
90
|
+
const colCount = $derived(columns.length + (rowActions ? 1 : 0));
|
|
91
|
+
|
|
51
92
|
function display(col: Column<T>, row: T): unknown {
|
|
52
93
|
if (col.get) return col.get(row);
|
|
53
94
|
return (row as Record<string, unknown>)[col.key];
|
|
@@ -80,13 +121,21 @@
|
|
|
80
121
|
});
|
|
81
122
|
</script>
|
|
82
123
|
|
|
83
|
-
<div class="dt-scroll" data-tsu="DataTable">
|
|
84
|
-
<table
|
|
85
|
-
|
|
124
|
+
<div class="dt-scroll" data-tsu="DataTable" data-size={size} aria-busy={loading || undefined}>
|
|
125
|
+
<table
|
|
126
|
+
class="dt"
|
|
127
|
+
class:sticky={stickyHeader}
|
|
128
|
+
class:fixed={layout === 'fixed'}
|
|
129
|
+
class:head-hidden={hideHeader}
|
|
130
|
+
class:sm={size === 'sm'}
|
|
131
|
+
style:--dt-sticky-offset={stickyOffset}
|
|
132
|
+
>
|
|
133
|
+
<thead data-part="head">
|
|
86
134
|
<tr>
|
|
87
135
|
{#each columns as col (col.key)}
|
|
88
136
|
<th
|
|
89
137
|
scope="col"
|
|
138
|
+
data-hide-below={col.hideBelow}
|
|
90
139
|
style:width={col.width}
|
|
91
140
|
style:text-align={col.align ?? 'left'}
|
|
92
141
|
aria-sort={col.sortable
|
|
@@ -109,17 +158,31 @@
|
|
|
109
158
|
{/if}
|
|
110
159
|
</th>
|
|
111
160
|
{/each}
|
|
161
|
+
{#if rowActions}
|
|
162
|
+
<th scope="col" class="dt-actions-head"><span class="sr-only">{rowActionsLabel}</span></th>
|
|
163
|
+
{/if}
|
|
112
164
|
</tr>
|
|
113
165
|
</thead>
|
|
114
166
|
<tbody>
|
|
115
|
-
{#if sortedRows.length === 0}
|
|
116
|
-
<tr>
|
|
117
|
-
<td class="dt-empty" colspan={
|
|
167
|
+
{#if sortedRows.length === 0 && !loading}
|
|
168
|
+
<tr data-part="empty">
|
|
169
|
+
<td class="dt-empty" colspan={colCount}>
|
|
170
|
+
{#if typeof empty === 'string'}
|
|
171
|
+
{empty}
|
|
172
|
+
{:else}
|
|
173
|
+
{@render empty()}
|
|
174
|
+
{/if}
|
|
175
|
+
</td>
|
|
118
176
|
</tr>
|
|
119
177
|
{:else}
|
|
120
178
|
{#each sortedRows as row (rowKey(row))}
|
|
179
|
+
{@const tone = rowTone?.(row)}
|
|
121
180
|
<tr
|
|
181
|
+
data-part="row"
|
|
182
|
+
data-tone={tone}
|
|
183
|
+
class={rowClass?.(row)}
|
|
122
184
|
class:clickable={!!onrowclick}
|
|
185
|
+
class:toned={!!tone && tone !== 'neutral'}
|
|
123
186
|
tabindex={onrowclick ? 0 : undefined}
|
|
124
187
|
role={onrowclick ? 'button' : undefined}
|
|
125
188
|
onclick={onrowclick ? () => onrowclick(row) : undefined}
|
|
@@ -133,7 +196,13 @@
|
|
|
133
196
|
: undefined}
|
|
134
197
|
>
|
|
135
198
|
{#each columns as col (col.key)}
|
|
136
|
-
<td
|
|
199
|
+
<td
|
|
200
|
+
data-part="cell"
|
|
201
|
+
data-hide-below={col.hideBelow}
|
|
202
|
+
class:truncate={col.truncate}
|
|
203
|
+
class:nowrap={col.nowrap}
|
|
204
|
+
style:text-align={col.align ?? 'left'}
|
|
205
|
+
>
|
|
137
206
|
{#if cellSnippets[col.key]}
|
|
138
207
|
{@render cellSnippets[col.key](row)}
|
|
139
208
|
{:else}
|
|
@@ -141,10 +210,29 @@
|
|
|
141
210
|
{/if}
|
|
142
211
|
</td>
|
|
143
212
|
{/each}
|
|
213
|
+
{#if rowActions}
|
|
214
|
+
<td data-part="actions" class="dt-actions">
|
|
215
|
+
{@render rowActions(row)}
|
|
216
|
+
</td>
|
|
217
|
+
{/if}
|
|
144
218
|
</tr>
|
|
145
219
|
{/each}
|
|
146
220
|
{/if}
|
|
221
|
+
{#if loading}
|
|
222
|
+
<tr data-part="loading">
|
|
223
|
+
<td class="dt-empty" colspan={colCount}>{loadingLabel}</td>
|
|
224
|
+
</tr>
|
|
225
|
+
{/if}
|
|
147
226
|
</tbody>
|
|
227
|
+
{#if onloadmore && !loading}
|
|
228
|
+
<tfoot>
|
|
229
|
+
<tr>
|
|
230
|
+
<td class="dt-more" colspan={colCount}>
|
|
231
|
+
<button type="button" class="dt-more-btn" onclick={onloadmore}>{loadMoreLabel}</button>
|
|
232
|
+
</td>
|
|
233
|
+
</tr>
|
|
234
|
+
</tfoot>
|
|
235
|
+
{/if}
|
|
148
236
|
</table>
|
|
149
237
|
</div>
|
|
150
238
|
|
|
@@ -155,6 +243,7 @@
|
|
|
155
243
|
-webkit-overflow-scrolling: touch;
|
|
156
244
|
border: 1px solid var(--border);
|
|
157
245
|
border-radius: var(--r-lg);
|
|
246
|
+
container-type: inline-size;
|
|
158
247
|
}
|
|
159
248
|
.dt {
|
|
160
249
|
width: 100%;
|
|
@@ -162,11 +251,21 @@
|
|
|
162
251
|
font-size: var(--fs-sm);
|
|
163
252
|
background: var(--surface);
|
|
164
253
|
}
|
|
254
|
+
.dt.fixed {
|
|
255
|
+
table-layout: fixed;
|
|
256
|
+
}
|
|
257
|
+
.dt.sm {
|
|
258
|
+
font-size: var(--fs-xs);
|
|
259
|
+
}
|
|
165
260
|
th,
|
|
166
261
|
td {
|
|
167
262
|
padding: var(--sp-2) var(--sp-3);
|
|
168
263
|
border-bottom: 1px solid var(--border);
|
|
169
264
|
}
|
|
265
|
+
.dt.sm th,
|
|
266
|
+
.dt.sm td {
|
|
267
|
+
padding: var(--sp-1) var(--sp-2);
|
|
268
|
+
}
|
|
170
269
|
th {
|
|
171
270
|
font-size: var(--fs-xs);
|
|
172
271
|
font-weight: var(--fw-semibold);
|
|
@@ -178,9 +277,23 @@
|
|
|
178
277
|
}
|
|
179
278
|
.dt.sticky th {
|
|
180
279
|
position: sticky;
|
|
181
|
-
top: 0;
|
|
280
|
+
top: var(--dt-sticky-offset, 0);
|
|
182
281
|
z-index: 1;
|
|
183
282
|
}
|
|
283
|
+
/* Header stays in flow (so widths still apply) but paints nothing. */
|
|
284
|
+
.dt.head-hidden th {
|
|
285
|
+
height: 0;
|
|
286
|
+
padding: 0;
|
|
287
|
+
border: 0;
|
|
288
|
+
line-height: 0;
|
|
289
|
+
overflow: hidden;
|
|
290
|
+
clip-path: inset(50%);
|
|
291
|
+
}
|
|
292
|
+
.dt.head-hidden th > * {
|
|
293
|
+
display: block;
|
|
294
|
+
height: 0;
|
|
295
|
+
overflow: hidden;
|
|
296
|
+
}
|
|
184
297
|
/* Sort toggle: a bare button that inherits the th's type styling. */
|
|
185
298
|
.dt-sort {
|
|
186
299
|
display: inline-flex;
|
|
@@ -218,9 +331,96 @@
|
|
|
218
331
|
background: var(--bg-elevated-2);
|
|
219
332
|
outline: none;
|
|
220
333
|
}
|
|
334
|
+
td.truncate {
|
|
335
|
+
white-space: nowrap;
|
|
336
|
+
overflow: hidden;
|
|
337
|
+
text-overflow: ellipsis;
|
|
338
|
+
max-width: 0;
|
|
339
|
+
}
|
|
340
|
+
td.nowrap {
|
|
341
|
+
white-space: nowrap;
|
|
342
|
+
}
|
|
343
|
+
tr[data-tone='ok'] {
|
|
344
|
+
--dt-tone: var(--ok);
|
|
345
|
+
}
|
|
346
|
+
tr[data-tone='warn'] {
|
|
347
|
+
--dt-tone: var(--warn);
|
|
348
|
+
}
|
|
349
|
+
tr[data-tone='danger'] {
|
|
350
|
+
--dt-tone: var(--danger);
|
|
351
|
+
}
|
|
352
|
+
tr[data-tone='info'] {
|
|
353
|
+
--dt-tone: var(--info);
|
|
354
|
+
}
|
|
355
|
+
tr.toned td:first-child {
|
|
356
|
+
box-shadow: inset 3px 0 0 var(--dt-tone);
|
|
357
|
+
}
|
|
358
|
+
.dt-actions-head,
|
|
359
|
+
.dt-actions {
|
|
360
|
+
width: 1%;
|
|
361
|
+
white-space: nowrap;
|
|
362
|
+
text-align: right;
|
|
363
|
+
}
|
|
364
|
+
.dt-actions {
|
|
365
|
+
opacity: 0;
|
|
366
|
+
transition: opacity 0.12s var(--ease);
|
|
367
|
+
}
|
|
368
|
+
tr:hover .dt-actions,
|
|
369
|
+
tr:focus-within .dt-actions {
|
|
370
|
+
opacity: 1;
|
|
371
|
+
}
|
|
372
|
+
@media (pointer: coarse) {
|
|
373
|
+
.dt-actions {
|
|
374
|
+
opacity: 1;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
@container (max-width: 30rem) {
|
|
378
|
+
[data-hide-below='sm'] {
|
|
379
|
+
display: none;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
@container (max-width: 48rem) {
|
|
383
|
+
[data-hide-below='md'] {
|
|
384
|
+
display: none;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
@container (max-width: 64rem) {
|
|
388
|
+
[data-hide-below='lg'] {
|
|
389
|
+
display: none;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
221
392
|
.dt-empty {
|
|
222
393
|
text-align: center;
|
|
223
394
|
color: var(--text-faint);
|
|
224
395
|
padding: var(--sp-8);
|
|
225
396
|
}
|
|
397
|
+
.dt-more {
|
|
398
|
+
text-align: center;
|
|
399
|
+
padding: var(--sp-2);
|
|
400
|
+
border-top: 1px solid var(--border);
|
|
401
|
+
}
|
|
402
|
+
.dt-more-btn {
|
|
403
|
+
border: 0;
|
|
404
|
+
background: none;
|
|
405
|
+
padding: var(--sp-1) var(--sp-3);
|
|
406
|
+
font: inherit;
|
|
407
|
+
color: var(--accent);
|
|
408
|
+
cursor: pointer;
|
|
409
|
+
border-radius: var(--r-md, 6px);
|
|
410
|
+
}
|
|
411
|
+
.dt-more-btn:hover,
|
|
412
|
+
.dt-more-btn:focus-visible {
|
|
413
|
+
background: var(--bg-elevated-2);
|
|
414
|
+
}
|
|
415
|
+
.sr-only {
|
|
416
|
+
position: absolute;
|
|
417
|
+
width: 1px;
|
|
418
|
+
height: 1px;
|
|
419
|
+
padding: 0;
|
|
420
|
+
margin: -1px;
|
|
421
|
+
overflow: hidden;
|
|
422
|
+
clip: rect(0, 0, 0, 0);
|
|
423
|
+
white-space: nowrap;
|
|
424
|
+
border: 0;
|
|
425
|
+
}
|
|
226
426
|
</style>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type RowTone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
|
|
1
2
|
export interface Column<T> {
|
|
2
3
|
/** Key into the row, or an arbitrary id when paired with a cell snippet. */
|
|
3
4
|
key: string;
|
|
@@ -9,6 +10,12 @@ export interface Column<T> {
|
|
|
9
10
|
/** Header becomes a sort toggle. Sorts by the displayed value unless an
|
|
10
11
|
* `onsort` handler is supplied (then the caller controls ordering). */
|
|
11
12
|
sortable?: boolean;
|
|
13
|
+
/** One-line cells clipped with an ellipsis (pair with `layout="fixed"`). */
|
|
14
|
+
truncate?: boolean;
|
|
15
|
+
nowrap?: boolean;
|
|
16
|
+
/** Drop the column when the table's own box is narrower than the breakpoint
|
|
17
|
+
* (sm 30rem, md 48rem, lg 64rem). */
|
|
18
|
+
hideBelow?: 'sm' | 'md' | 'lg';
|
|
12
19
|
}
|
|
13
20
|
import type { Snippet } from 'svelte';
|
|
14
21
|
declare function $$render<T>(): {
|
|
@@ -22,8 +29,27 @@ declare function $$render<T>(): {
|
|
|
22
29
|
* table only reflects the indicator and emits; it does not reorder rows. */
|
|
23
30
|
onsort?: (key: string, dir: "asc" | "desc") => void;
|
|
24
31
|
cellSnippets?: Record<string, Snippet<[T]>>;
|
|
25
|
-
empty?: string;
|
|
32
|
+
empty?: string | Snippet;
|
|
26
33
|
stickyHeader?: boolean;
|
|
34
|
+
/** Distance from the scroll container's top for the sticky header, e.g.
|
|
35
|
+
* `var(--header-h)` when the page header overlaps. Defaults to 0. */
|
|
36
|
+
stickyOffset?: string;
|
|
37
|
+
/** `fixed` makes declared column widths authoritative (needed for truncate). */
|
|
38
|
+
layout?: "auto" | "fixed";
|
|
39
|
+
/** Visually hides the header; it stays in the DOM for assistive tech. */
|
|
40
|
+
hideHeader?: boolean;
|
|
41
|
+
size?: "sm" | "md";
|
|
42
|
+
/** Per-row semantic tone, rendered as a left accent bar + `data-tone`. */
|
|
43
|
+
rowTone?: (row: T) => RowTone | undefined;
|
|
44
|
+
rowClass?: (row: T) => string | undefined;
|
|
45
|
+
/** Trailing actions cell revealed on hover/focus (always visible on touch). */
|
|
46
|
+
rowActions?: Snippet<[T]>;
|
|
47
|
+
rowActionsLabel?: string;
|
|
48
|
+
loading?: boolean;
|
|
49
|
+
loadingLabel?: string;
|
|
50
|
+
/** Renders a footer "load more" button that calls this. */
|
|
51
|
+
onloadmore?: () => void;
|
|
52
|
+
loadMoreLabel?: string;
|
|
27
53
|
};
|
|
28
54
|
exports: {};
|
|
29
55
|
bindings: "";
|
package/dist/index.d.ts
CHANGED
|
@@ -53,7 +53,7 @@ export { default as Toaster } from './components/molecules/Toaster.svelte';
|
|
|
53
53
|
export { default as Toggle } from './components/molecules/Toggle.svelte';
|
|
54
54
|
export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
55
55
|
export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
56
|
-
export { type Column, default as DataTable } from './components/organisms/DataTable.svelte';
|
|
56
|
+
export { type Column, default as DataTable, type RowTone, } from './components/organisms/DataTable.svelte';
|
|
57
57
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
58
58
|
export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, suggest, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
|
|
59
59
|
export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
|
package/dist/index.js
CHANGED
|
@@ -64,7 +64,7 @@ export { default as Toggle } from './components/molecules/Toggle.svelte';
|
|
|
64
64
|
export { default as Tooltip } from './components/molecules/Tooltip.svelte';
|
|
65
65
|
export { default as Truncate } from './components/molecules/Truncate.svelte';
|
|
66
66
|
// ---- organisms ----
|
|
67
|
-
export { default as DataTable } from './components/organisms/DataTable.svelte';
|
|
67
|
+
export { default as DataTable, } from './components/organisms/DataTable.svelte';
|
|
68
68
|
export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
|
|
69
69
|
// ---- query core (headless: schema / parser / AST / suggest / compilers) ----
|
|
70
70
|
export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, walk, } from './query';
|
|
@@ -172,6 +172,17 @@
|
|
|
172
172
|
--control-height-default: 2.5rem;
|
|
173
173
|
--control-height-large: 3rem;
|
|
174
174
|
--control-height: 2.75rem;
|
|
175
|
+
/* Square box scale for icon-only controls (IconButton, Button `box`/`square`,
|
|
176
|
+
SelectButton, Popover trigger, CopyButton, FileButton). md is the classic
|
|
177
|
+
2.25rem icon button; sm/lg reuse the control heights so a boxed control
|
|
178
|
+
lines up with text controls in the same row. */
|
|
179
|
+
--box-xs: 1.5rem;
|
|
180
|
+
--box-sm: var(--control-height-compact);
|
|
181
|
+
--box-md: 2.25rem;
|
|
182
|
+
--box-lg: var(--control-height-default);
|
|
183
|
+
/* Minimum hit area on coarse pointers (WCAG 2.5.8); px so it never shrinks
|
|
184
|
+
under a small root font-size. */
|
|
185
|
+
--touch-target: 44px;
|
|
175
186
|
|
|
176
187
|
/* ---- Layout ---- */
|
|
177
188
|
/* Fixed (px, not rem): the header is chrome that must NOT scale with the
|
package/package.json
CHANGED