@dorsk/tsumikit 0.70.0 → 0.71.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 CHANGED
@@ -261,9 +261,9 @@ the trigger overlays the selected option's glyph + muted hint on the native cont
261
261
  sm/md/lg as on `Switch`, or `--cb-box` for the box alone),
262
262
  Slider (`ticks` draws a dot per step for a segmented slider), Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
263
263
  threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
264
- fallback, `aspect`, `status` overlay), Avatar (grapheme-safe initial or `glyph`, hue hashed from
264
+ fallback, `aspect`, `status` overlay, `srcset`/`sizes` passed to the `<img>`), Avatar (grapheme-safe initial or `glyph`, hue hashed from
265
265
  `seed`/`name` or a fixed `tone`, `size` xs–lg or px, `shape` circle/square, `decorative`, corner `status`), Card (`tone` tints the surface for inline banners), Badge
266
- (`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
266
+ (`tone` semantic palette (the shared `Tone`, `success` = `ok`, plus `muted`/`violet`) or `color` for any CSS colour, `size` xs/sm/md (sm/xs on `--fs-xs`), `dot`,
267
267
  `icon`, `numeric`, `truncate`, `variant="text"`, `removable`/`onremove` or any
268
268
  trailing action via `actionIcon`/`actionLabel`/`onaction`, `removed` mutes and
269
269
  strikes the pill for a restorable entry; all tints derive from
@@ -290,6 +290,8 @@ sizes the panel; an item with a `control` snippet renders as a non-button row ho
290
290
  Tabs (omit `panel` for a strip-only tablist of document tabs: `closable` + `onclose(id)`
291
291
  close through the ✕, Delete and middle-click, per-tab `leading` snippet, `title`,
292
292
  trailing `actions` snippet; `--tab-max-width` truncates long labels),
293
+ Breadcrumb (`maxItems` collapses the middle, `size` sm/md, `wrap={false}` keeps one
294
+ line with the current crumb ellipsised, `collapseCurrent` keeps only the ancestors),
293
295
  RadioGroup (per-option `description` in every variant, announced via
294
296
  `aria-describedby`; `onfocuschange(value)` fires once per newly focused/hovered option;
295
297
  `variant="rows"`: bordered rows, per-option `note`,
@@ -349,7 +351,9 @@ hooks on head/row/cell; `responsive="stack"` turns rows into cards below
349
351
  `title | detail | meta | actions | hidden` — detail cells get a `data-label`
350
352
  prefix, `Column.stackLabel: false` drops it so metas join on one `·` line
351
353
  and details share lines, `--dt-meta-font` restyles meta values (e.g. `var(--font-sans)`; a
352
- literal `inherit` falls back to mono), the `<table>`
354
+ literal `inherit` falls back to mono), stacked tables use `table-layout: fixed`,
355
+ `stackActions="end"` closes every card with its actions, cells that render
356
+ nothing get `data-empty` and are hidden and skipped by the `·` separators, the `<table>`
353
357
  stays a table for assistive tech), FilterSearchBar /
354
358
  FilterInput (`size="sm"` compact bar on `--control-height-compact`, text
355
359
  floored at 16px under `pointer: coarse` so iOS does not zoom on focus,
@@ -972,6 +976,15 @@ from the hidden text unless `aria-label` is set:
972
976
  <Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
973
977
  ```
974
978
 
979
+ Compact text controls (`Button size="sm"`, `Toggle`, `Switch`, `Link`) take
980
+ the same shape, sized by `--touch-slab`: at least 8px past the block edges and
981
+ the inline start, and at least `--touch-target-compact` (40px) tall, with no
982
+ inline-end overflow. `SegmentedControl` items grow on the block axis and by
983
+ half the gap sideways, which stays inside the group's padding, so neighbours
984
+ never overlap. `Input size="sm"` cannot host a pseudo-element and grows to 40px
985
+ instead, as should any control that clips its own overflow:
986
+ `min-height: var(--touch-target-compact)`.
987
+
975
988
  `--control-height-*` and `--box-*` are **required tokens**: a consumer that
976
989
  forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
977
990
  or those controls render with unresolved sizes.
@@ -11,6 +11,8 @@
11
11
 
12
12
  type Own = {
13
13
  src?: string | null;
14
+ srcset?: string;
15
+ sizes?: string;
14
16
  alt: string;
15
17
  /** Drives the gradient hue and the initials. Defaults to `alt`. */
16
18
  seed?: string;
@@ -32,6 +34,8 @@
32
34
  };
33
35
  let {
34
36
  src,
37
+ srcset,
38
+ sizes,
35
39
  alt,
36
40
  seed,
37
41
  aspect = '1/1',
@@ -50,6 +54,7 @@
50
54
  let failed = $state(false);
51
55
  $effect(() => {
52
56
  src;
57
+ srcset;
53
58
  failed = false;
54
59
  });
55
60
 
@@ -76,7 +81,7 @@
76
81
  {...rest}
77
82
  >
78
83
  {#if showImage}
79
- <img {src} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
84
+ <img {src} {srcset} {sizes} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
80
85
  {:else if fallback === 'initials' && text}
81
86
  <span class="initials" aria-hidden="true">{text}</span>
82
87
  {:else if fallback === 'icon'}
@@ -3,6 +3,8 @@ import type { Snippet } from 'svelte';
3
3
  import type { IconName } from './Icon.svelte';
4
4
  type Own = {
5
5
  src?: string | null;
6
+ srcset?: string;
7
+ sizes?: string;
6
8
  alt: string;
7
9
  /** Drives the gradient hue and the initials. Defaults to `alt`. */
8
10
  seed?: string;
@@ -13,10 +13,11 @@
13
13
  import type { Snippet } from 'svelte';
14
14
  import Dot from './Dot.svelte';
15
15
  import Icon, { type IconName } from './Icon.svelte';
16
+ import { canonicalTone, type Tone as SharedTone } from '../../tone';
16
17
 
17
- type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'accent' | 'muted' | 'violet';
18
+ type Tone = SharedTone | 'muted' | 'violet';
18
19
 
19
- const TONE_COLOR: Record<Exclude<Tone, 'neutral'>, string> = {
20
+ const TONE_COLOR: Record<Exclude<Tone, 'neutral' | 'success'>, string> = {
20
21
  ok: 'var(--ok)',
21
22
  warn: 'var(--warn)',
22
23
  danger: 'var(--danger)',
@@ -106,7 +107,8 @@
106
107
  ...rest
107
108
  }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
108
109
 
109
- const toneColor = $derived(color ?? (tone === 'neutral' ? undefined : TONE_COLOR[tone]));
110
+ const t = $derived(canonicalTone(tone));
111
+ const toneColor = $derived(color ?? (t === 'neutral' ? undefined : TONE_COLOR[t]));
110
112
  const hasAction = $derived(removable || actionIcon !== undefined);
111
113
  </script>
112
114
 
@@ -120,10 +122,10 @@
120
122
  class:grow={grow}
121
123
  class:no-shrink={!shrink}
122
124
  class:block={block}
123
- class:badge-ok={tone === 'ok'}
124
- class:badge-warn={tone === 'warn'}
125
- class:badge-danger={tone === 'danger'}
126
- class:badge-info={tone === 'info'}
125
+ class:badge-ok={t === 'ok'}
126
+ class:badge-warn={t === 'warn'}
127
+ class:badge-danger={t === 'danger'}
128
+ class:badge-info={t === 'info'}
127
129
  class:toned={toneColor !== undefined}
128
130
  class:badge-sm={size === 'sm'}
129
131
  class:badge-xs={size === 'xs'}
@@ -193,7 +195,7 @@
193
195
  }
194
196
  .badge-sm,
195
197
  .badge-xs {
196
- font-size: var(--badge-fs, calc(var(--fs-xs) * 0.92));
198
+ font-size: var(--badge-fs, var(--fs-xs));
197
199
  gap: 0.15rem;
198
200
  }
199
201
  .badge-sm {
@@ -1,7 +1,8 @@
1
1
  import type { HTMLAttributes } from 'svelte/elements';
2
2
  import type { Snippet } from 'svelte';
3
3
  import { type IconName } from './Icon.svelte';
4
- type Tone = 'neutral' | 'ok' | 'warn' | 'danger' | 'info' | 'accent' | 'muted' | 'violet';
4
+ import { type Tone as SharedTone } from '../../tone';
5
+ type Tone = SharedTone | 'muted' | 'violet';
5
6
  type Own = {
6
7
  tone?: Tone;
7
8
  /** Fill the free space of a flex row (`flex: 1 1 0`). */
@@ -544,7 +544,8 @@
544
544
  The slab is the centred target with its inline-end half cut off: inline-end
545
545
  overflow counts towards the scrollable overflow of every scroll ancestor, and
546
546
  in a row that half lies under the next sibling anyway. Growing further towards
547
- the start would let each slab cover the previous sibling's centre. */
547
+ the start would let each slab cover the previous sibling's centre. Text
548
+ buttons at size sm take the same shape sized by --touch-slab. */
548
549
  @media (pointer: coarse) {
549
550
  .btn-icon:not(.hit-compact),
550
551
  .btn-icon-inline:not(.hit-compact),
@@ -566,6 +567,16 @@
566
567
  inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
567
568
  inset-inline-end: 0;
568
569
  }
570
+ .btn-sm:not(.btn-icon, .btn-icon-inline, .btn-box, .btn-square, .btn-chip, .btn-stacked, .hit-compact) {
571
+ position: relative;
572
+ }
573
+ .btn-sm:not(.btn-icon, .btn-icon-inline, .btn-box, .btn-square, .btn-chip, .btn-stacked, .hit-compact)::before {
574
+ content: '';
575
+ position: absolute;
576
+ inset-block: var(--touch-slab);
577
+ inset-inline-start: var(--touch-slab);
578
+ inset-inline-end: 0;
579
+ }
569
580
  }
570
581
 
571
582
  /* Two-state (toggle) buttons — e.g. an IconButton with `pressed`. Reacts to
@@ -703,7 +714,8 @@
703
714
  .btn-link .btn-count,
704
715
  .btn-icon-inline .btn-count {
705
716
  --btn-count-size: 1rem;
706
- font-size: calc(var(--fs-xs) * 0.85);
717
+ padding: 0 0.2rem;
718
+ font-size: var(--fs-xs);
707
719
  }
708
720
  /* The accent fill would swallow an accent pill: invert on primary. */
709
721
  .btn-primary .btn-count {
@@ -170,6 +170,9 @@
170
170
  .input-sm {
171
171
  font-size: max(16px, var(--fs-sm));
172
172
  }
173
+ .input-sm:not(.grouped) {
174
+ min-height: max(var(--touch-target-compact), var(--input-size, var(--control-height-compact)));
175
+ }
173
176
  }
174
177
  .input-grow {
175
178
  flex: 1 1 0;
@@ -74,4 +74,16 @@
74
74
  .align-center {
75
75
  text-align: center;
76
76
  }
77
+ @media (pointer: coarse) {
78
+ .link {
79
+ position: relative;
80
+ }
81
+ .link::before {
82
+ content: '';
83
+ position: absolute;
84
+ inset-block: var(--touch-slab);
85
+ inset-inline-start: var(--touch-slab);
86
+ inset-inline-end: 0;
87
+ }
88
+ }
77
89
  </style>
@@ -241,6 +241,7 @@
241
241
  with Button size="sm", Popover size="sm" and SegmentedControl size="sm". */
242
242
  .select.select-sm {
243
243
  height: var(--select-size, var(--control-height-compact));
244
+ min-height: var(--select-size, var(--control-height-compact));
244
245
  }
245
246
  .select.w-auto {
246
247
  width: auto;
@@ -124,6 +124,18 @@
124
124
  .switch-sm.on .knob {
125
125
  transform: translateX(0.8rem);
126
126
  }
127
+ @media (pointer: coarse) {
128
+ .switch {
129
+ position: relative;
130
+ }
131
+ .switch::before {
132
+ content: '';
133
+ position: absolute;
134
+ inset-block: var(--touch-slab);
135
+ inset-inline-start: var(--touch-slab);
136
+ inset-inline-end: 0;
137
+ }
138
+ }
127
139
  .switch-row {
128
140
  display: inline-flex;
129
141
  align-items: center;
@@ -19,6 +19,9 @@
19
19
  char,
20
20
  separator,
21
21
  maxItems = 0,
22
+ size = 'sm',
23
+ wrap = true,
24
+ collapseCurrent = false,
22
25
  class: klass = '',
23
26
  style: styleProp = '',
24
27
  }: {
@@ -29,6 +32,12 @@
29
32
  /** Collapse the middle to an ellipsis when the trail is longer than this
30
33
  * (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
31
34
  maxItems?: number;
35
+ size?: 'sm' | 'md';
36
+ /** `false` keeps the trail on one line: ancestors keep their width and the
37
+ * current crumb shrinks with an ellipsis. */
38
+ wrap?: boolean;
39
+ /** Hide the current crumb and its separator, keeping only the ancestors. */
40
+ collapseCurrent?: boolean;
32
41
  class?: string;
33
42
  style?: string;
34
43
  } = $props();
@@ -43,7 +52,14 @@
43
52
  </script>
44
53
 
45
54
  <nav aria-label="breadcrumb" data-tsu="Breadcrumb" class={klass} style={styleProp}>
46
- <ol class="crumbs">
55
+ <ol
56
+ class={[
57
+ 'crumbs',
58
+ size === 'md' && 'crumbs--md',
59
+ !wrap && 'crumbs--nowrap',
60
+ collapseCurrent && 'crumbs--collapse',
61
+ ]}
62
+ >
47
63
  {#each shown as item, i (i)}
48
64
  <li class="crumb">
49
65
  {#if 'ellipsis' in item}
@@ -57,7 +73,7 @@
57
73
  <span class="sep" aria-hidden="true">
58
74
  {#if separator}{@render separator()}{:else if char}{char}{:else}<Icon
59
75
  name="chevron-right"
60
- size={14}
76
+ size={size === 'md' ? 16 : 14}
61
77
  />{/if}
62
78
  </span>
63
79
  {/if}
@@ -77,6 +93,26 @@
77
93
  list-style: none;
78
94
  font-size: var(--fs-sm);
79
95
  }
96
+ .crumbs--md {
97
+ font-size: var(--fs-md);
98
+ }
99
+ .crumbs--nowrap {
100
+ flex-wrap: nowrap;
101
+ min-width: 0;
102
+ }
103
+ .crumbs--nowrap .crumb {
104
+ flex-shrink: 0;
105
+ }
106
+ .crumbs--nowrap .crumb:last-child {
107
+ flex-shrink: 1;
108
+ }
109
+ .crumbs--nowrap .current {
110
+ max-width: none;
111
+ }
112
+ .crumbs--collapse .crumb:last-child:not(:first-child),
113
+ .crumbs--collapse .crumb:nth-last-child(2) .sep {
114
+ display: none;
115
+ }
80
116
  .crumb {
81
117
  display: inline-flex;
82
118
  align-items: center;
@@ -104,6 +140,12 @@
104
140
  text-overflow: ellipsis;
105
141
  max-width: 24ch;
106
142
  }
143
+ @media (hover: none) and (pointer: coarse) {
144
+ .link {
145
+ min-height: 40px;
146
+ align-content: center;
147
+ }
148
+ }
107
149
  .ellipsis {
108
150
  color: var(--text-muted);
109
151
  }
@@ -11,6 +11,12 @@ type $$ComponentProps = {
11
11
  /** Collapse the middle to an ellipsis when the trail is longer than this
12
12
  * (0 = never collapse). Keeps the first item and the last `maxItems - 1`. */
13
13
  maxItems?: number;
14
+ size?: 'sm' | 'md';
15
+ /** `false` keeps the trail on one line: ancestors keep their width and the
16
+ * current crumb shrinks with an ellipsis. */
17
+ wrap?: boolean;
18
+ /** Hide the current crumb and its separator, keeping only the ancestors. */
19
+ collapseCurrent?: boolean;
14
20
  class?: string;
15
21
  style?: string;
16
22
  };
@@ -179,6 +179,7 @@
179
179
  flex-wrap: nowrap;
180
180
  max-width: 100%;
181
181
  overflow-x: auto;
182
+ overflow-y: hidden;
182
183
  scrollbar-width: none;
183
184
  -webkit-overflow-scrolling: touch;
184
185
  }
@@ -244,6 +245,37 @@
244
245
  .seg-box .seg-item {
245
246
  height: 100%;
246
247
  }
248
+ /* Wrapped rows size to their content; each item keeps the one-row height. */
249
+ .seg-wrap.seg-sm,
250
+ .seg-wrap.seg-control,
251
+ .seg-wrap.seg-box {
252
+ height: auto;
253
+ }
254
+ .seg-wrap.seg-sm .seg-item {
255
+ height: auto;
256
+ min-height: calc(var(--control-height-compact) - 8px);
257
+ }
258
+ .seg-wrap.seg-control .seg-item {
259
+ height: auto;
260
+ min-height: calc(var(--control-height) - 8px);
261
+ }
262
+ .seg-wrap.seg-box .seg-item {
263
+ height: auto;
264
+ min-height: calc(var(--control-height-default) - 8px);
265
+ }
266
+ /* Capped at half a row: one row stays a pill, extra rows square the corners. */
267
+ .seg-wrap:not(.seg-icon) {
268
+ border-radius: calc((0.6rem + 1.3 * var(--fs-sm)) / 2 + 4px);
269
+ }
270
+ .seg-wrap.seg-sm:not(.seg-icon) {
271
+ border-radius: calc(var(--control-height-compact) / 2);
272
+ }
273
+ .seg-wrap.seg-control:not(.seg-icon) {
274
+ border-radius: calc(var(--control-height) / 2);
275
+ }
276
+ .seg-wrap.seg-box:not(.seg-icon) {
277
+ border-radius: calc(var(--control-height-default) / 2);
278
+ }
247
279
  .seg-icon .seg-item {
248
280
  gap: 0;
249
281
  padding: 0.35rem;
@@ -320,13 +352,24 @@
320
352
  border-radius: var(--r-pill);
321
353
  background: var(--bg-elevated-2);
322
354
  color: var(--text-faint);
323
- font-size: 0.6875rem;
355
+ font-size: var(--fs-xs);
324
356
  font-weight: var(--fw-semibold);
325
357
  font-variant-numeric: tabular-nums;
326
358
  transition:
327
359
  background 0.12s var(--ease),
328
360
  color 0.12s var(--ease);
329
361
  }
362
+ @media (pointer: coarse) {
363
+ .seg-item {
364
+ position: relative;
365
+ }
366
+ .seg-item::before {
367
+ content: '';
368
+ position: absolute;
369
+ inset-block: var(--touch-slab);
370
+ inset-inline: calc(var(--sp-1) / -2);
371
+ }
372
+ }
330
373
  .seg-item.selected .seg-count {
331
374
  background: color-mix(in srgb, var(--accent) 18%, transparent);
332
375
  color: var(--accent);
@@ -108,4 +108,16 @@
108
108
  .toggle.struck {
109
109
  text-decoration: line-through;
110
110
  }
111
+ @media (pointer: coarse) {
112
+ .toggle {
113
+ position: relative;
114
+ }
115
+ .toggle::before {
116
+ content: '';
117
+ position: absolute;
118
+ inset-block: var(--touch-slab);
119
+ inset-inline-start: var(--touch-slab);
120
+ inset-inline-end: 0;
121
+ }
122
+ }
111
123
  </style>
@@ -25,6 +25,16 @@
25
25
  * detail cells share lines (`responsive="stack"` only, default true). */
26
26
  stackLabel?: boolean;
27
27
  }
28
+
29
+ let rootFontPx: number | undefined;
30
+
31
+ function rootFontSize(): number {
32
+ if (rootFontPx === undefined) {
33
+ rootFontPx = Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
34
+ window.addEventListener('resize', () => (rootFontPx = undefined), { once: true });
35
+ }
36
+ return rootFontPx;
37
+ }
28
38
  </script>
29
39
 
30
40
  <script lang="ts" generics="T">
@@ -60,6 +70,7 @@
60
70
  loadMoreLabel = 'Load more',
61
71
  responsive = 'scroll',
62
72
  stackBelow = '48rem',
73
+ stackActions = 'start',
63
74
  class: klass = '',
64
75
  style: styleProp = '',
65
76
  tableClass = '',
@@ -101,6 +112,9 @@
101
112
  responsive?: 'scroll' | 'stack';
102
113
  /** Width (px/em/rem) under which `stack` mode kicks in. */
103
114
  stackBelow?: string;
115
+ /** Where actions sit in a stacked card: `start` beside the title (wraps
116
+ * under a long one), `end` always closing the card. */
117
+ stackActions?: 'start' | 'end';
104
118
  class?: string;
105
119
  style?: string;
106
120
  /** Class on the inner <table>. */
@@ -121,22 +135,31 @@
121
135
  return { destroy: () => observer.disconnect() };
122
136
  }
123
137
 
138
+ function markEmpty(node: HTMLElement) {
139
+ const update = () => node.toggleAttribute('data-empty', !node.firstElementChild && !node.textContent?.trim());
140
+ update();
141
+ const observer = new MutationObserver(update);
142
+ observer.observe(node, { childList: true, subtree: true, characterData: true });
143
+ return () => observer.disconnect();
144
+ }
145
+
124
146
  function toPx(length: string, el: HTMLElement): number {
125
147
  const n = Number.parseFloat(length);
126
- const fontSize = (node: Element) => Number.parseFloat(getComputedStyle(node).fontSize);
127
- if (length.endsWith('rem')) return n * fontSize(document.documentElement);
128
- if (length.endsWith('em')) return n * fontSize(el);
148
+ if (length.endsWith('rem')) return n * rootFontSize();
149
+ if (length.endsWith('em')) return n * Number.parseFloat(getComputedStyle(el).fontSize);
129
150
  return n;
130
151
  }
131
152
 
153
+ // Font sizes are read in the observer callback, after layout, so mounting never forces a style recalc.
132
154
  $effect(() => {
133
155
  if (responsive !== 'stack' || !wrapEl) {
134
156
  stacked = false;
135
157
  return;
136
158
  }
137
159
  const el = wrapEl;
138
- const limit = toPx(stackBelow, el);
160
+ const below = stackBelow;
139
161
  const observer = new ResizeObserver(([entry]) => {
162
+ const limit = toPx(below, el);
140
163
  stacked = entry.contentRect.width < limit;
141
164
  });
142
165
  observer.observe(el);
@@ -188,6 +211,7 @@
188
211
  data-tsu="DataTable"
189
212
  data-size={size}
190
213
  data-stacked={stacked || undefined}
214
+ data-stack-actions={responsive === 'stack' && stackActions === 'end' ? 'end' : undefined}
191
215
  aria-busy={loading || undefined}
192
216
  >
193
217
  <table
@@ -267,20 +291,25 @@
267
291
  : undefined}
268
292
  >
269
293
  {#each columns as col (col.key)}
294
+ {@const value = cellSnippets[col.key] ? undefined : display(col, row)}
270
295
  <td
271
296
  data-part="cell"
272
297
  data-hide-below={col.hideBelow}
273
298
  data-role={responsive === 'stack' ? (col.role ?? 'detail') : undefined}
274
299
  data-label={responsive === 'stack' ? col.label : undefined}
275
300
  data-stack-label={responsive === 'stack' && col.stackLabel === false ? 'false' : undefined}
301
+ data-empty={responsive === 'stack' && !cellSnippets[col.key] && (value == null || value === '')
302
+ ? ''
303
+ : undefined}
276
304
  class:truncate={col.truncate}
277
305
  class:nowrap={col.nowrap}
278
306
  style:text-align={stacked ? undefined : (col.align ?? 'left')}
307
+ {@attach responsive === 'stack' && cellSnippets[col.key] ? markEmpty : undefined}
279
308
  >
280
309
  {#if cellSnippets[col.key]}
281
310
  {@render cellSnippets[col.key](row)}
282
311
  {:else}
283
- {display(col, row)}
312
+ {value}
284
313
  {/if}
285
314
  </td>
286
315
  {/each}
@@ -479,6 +508,9 @@
479
508
  [data-stacked] {
480
509
  overflow-x: visible;
481
510
  }
511
+ [data-stacked] .dt {
512
+ table-layout: fixed;
513
+ }
482
514
  [data-stacked] thead {
483
515
  position: absolute;
484
516
  width: 1px;
@@ -552,10 +584,17 @@
552
584
  [data-stacked] td[data-stack-label='false']::before {
553
585
  content: none;
554
586
  }
555
- [data-stacked] td[data-role='meta'] ~ td[data-role='meta'][data-stack-label='false'] {
587
+ [data-stacked] td[data-part='cell'][data-empty] {
588
+ display: none;
589
+ }
590
+ [data-stacked]
591
+ td[data-role='meta']:not([data-empty])
592
+ ~ td[data-role='meta'][data-stack-label='false']:not([data-empty]) {
556
593
  margin-inline-start: calc(var(--sp-1) - var(--sp-3));
557
594
  }
558
- [data-stacked] td[data-role='meta'] ~ td[data-role='meta'][data-stack-label='false']::before {
595
+ [data-stacked]
596
+ td[data-role='meta']:not([data-empty])
597
+ ~ td[data-role='meta'][data-stack-label='false']:not([data-empty])::before {
559
598
  content: '·';
560
599
  margin-inline-end: var(--sp-1);
561
600
  color: var(--text-faint);
@@ -568,6 +607,10 @@
568
607
  width: auto;
569
608
  opacity: 1;
570
609
  }
610
+ [data-stacked][data-stack-actions='end'] td[data-role='actions'],
611
+ [data-stacked][data-stack-actions='end'] td.dt-actions {
612
+ order: 2;
613
+ }
571
614
  [data-stacked] td[data-role='hidden'] {
572
615
  display: none;
573
616
  }
@@ -63,6 +63,9 @@ declare function $$render<T>(): {
63
63
  responsive?: "scroll" | "stack";
64
64
  /** Width (px/em/rem) under which `stack` mode kicks in. */
65
65
  stackBelow?: string;
66
+ /** Where actions sit in a stacked card: `start` beside the title (wraps
67
+ * under a long one), `end` always closing the card. */
68
+ stackActions?: "start" | "end";
66
69
  class?: string;
67
70
  style?: string;
68
71
  /** Class on the inner <table>. */
@@ -184,6 +184,12 @@
184
184
  /* Minimum hit area on coarse pointers (WCAG 2.5.8); px so it never shrinks
185
185
  under a small root font-size. */
186
186
  --touch-target: 44px;
187
+ /* Compact controls on coarse pointers: an absolute ::before with inset-block
188
+ and inset-inline-start at var(--touch-slab) and inset-inline-end: 0 (no
189
+ scrollable overflow sideways). Controls that clip their overflow grow
190
+ min-height to --touch-target-compact instead. */
191
+ --touch-target-compact: 40px;
192
+ --touch-slab: min(-8px, calc((100% - var(--touch-target-compact)) / 2));
187
193
 
188
194
  /* ---- Layout ---- */
189
195
  /* Fixed (px, not rem): the header is chrome that must NOT scale with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",