@dorsk/tsumikit 0.69.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`,
@@ -348,9 +350,13 @@ hooks on head/row/cell; `responsive="stack"` turns rows into cards below
348
350
  `stackBelow` (48rem, measured on the table's own box) using `Column.role`
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
- and details share lines, `--dt-meta-font` restyles meta values, the `<table>`
353
+ and details share lines, `--dt-meta-font` restyles meta values (e.g. `var(--font-sans)`; a
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>`
352
357
  stays a table for assistive tech), FilterSearchBar /
353
- FilterInput (`size="sm"` compact bar on `--control-height-compact`,
358
+ FilterInput (`size="sm"` compact bar on `--control-height-compact`, text
359
+ floored at 16px under `pointer: coarse` so iOS does not zoom on focus,
354
360
  `shape="pill"`, `surface`, `hotkey="/"` focuses the input from anywhere
355
361
  outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`;
356
362
  `key="cwd"` puts FilterInput in single-key mode — `value` is the bare value
@@ -363,7 +369,11 @@ sidebar on desktop, overlay drawer on mobile, optionally resizable;
363
369
  the content column only, `stickySidebar` pins it to the viewport, and
364
370
  `sidebarPadding="none" | "sm" | "md"` sets the aside gutter — the header and its
365
371
  children are `min-width: 0` so a wide title/actions row can't widen the grid on
366
- mobile), NavBar (horizontal route bar — `<nav>` + `<a aria-current="page">`,
372
+ mobile; the mobile drawer's state is `bind:open` with `onOpenChange(open)`, and
373
+ `closeOnNavigate` (default `true`) closes it when a plain same-origin link inside
374
+ it is clicked — a router can also close it, e.g. SvelteKit
375
+ `afterNavigate(() => (navOpen = false))` with `<AppShell bind:open={navOpen}>`
376
+ to cover `goto` and back/forward), NavBar (horizontal route bar — `<nav>` + `<a aria-current="page">`,
367
377
  icon/emoji + label + count badge; `placement="bar"` pins it to the bottom edge
368
378
  over `--z-nav` with `--safe-bottom` padding, `orientation` stacks or inlines the
369
379
  glyph, `maxWidth` centres the row; items without `href` are buttons that set a
@@ -966,6 +976,15 @@ from the hidden text unless `aria-label` is set:
966
976
  <Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
967
977
  ```
968
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
+
969
988
  `--control-height-*` and `--box-*` are **required tokens**: a consumer that
970
989
  forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
971
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;
@@ -7,10 +7,11 @@
7
7
  // nav content collapse to icons.
8
8
  // • mobile: the sidebar becomes an off-canvas drawer that slides in over a
9
9
  // scrim; AppShell renders the hamburger toggle for you (shown only on
10
- // mobile), locks body scroll while open, closes on Escape / scrim click,
11
- // and marks the rest of the page `inert` so focus stays in the drawer.
10
+ // mobile), locks body scroll while open, closes on Escape / scrim click /
11
+ // a same-origin link click inside it, and marks the rest of the page
12
+ // `inert` so focus stays in the drawer. `bind:open` to drive it yourself.
12
13
  // Switching to desktop auto-closes the drawer. Safe-area insets are honored.
13
- import type { Snippet } from 'svelte';
14
+ import { untrack, type Snippet } from 'svelte';
14
15
  import { browser } from '../../env';
15
16
  import IconButton from '../molecules/IconButton.svelte';
16
17
 
@@ -28,6 +29,9 @@
28
29
  layout = 'header-top',
29
30
  stickySidebar = false,
30
31
  sidebarPadding = 'md',
32
+ open = $bindable(false),
33
+ onOpenChange,
34
+ closeOnNavigate = true,
31
35
  class: klass = '',
32
36
  style: styleProp = '',
33
37
  }: {
@@ -54,12 +58,42 @@
54
58
  stickySidebar?: boolean;
55
59
  /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
56
60
  sidebarPadding?: 'none' | 'sm' | 'md';
61
+ /** Mobile drawer state; reset to `false` when the viewport reaches 48rem. */
62
+ open?: boolean;
63
+ /** Fires when AppShell itself opens or closes the drawer. */
64
+ onOpenChange?: (open: boolean) => void;
65
+ /** Close the mobile drawer when a same-origin link inside it is clicked
66
+ * (plain left click, no modifier keys, no `target` / `download`). */
67
+ closeOnNavigate?: boolean;
57
68
  class?: string;
58
69
  style?: string;
59
70
  } = $props();
60
71
 
61
- let open = $state(false);
62
72
  let isMobile = $state(false);
73
+ let aside = $state<HTMLElement>();
74
+
75
+ function setOpen(next: boolean) {
76
+ if (open === next) return;
77
+ open = next;
78
+ onOpenChange?.(next);
79
+ }
80
+
81
+ function onSidebarClick(e: MouseEvent) {
82
+ if (!closeOnNavigate || !open) return;
83
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
84
+ const link = (e.target as Element | null)?.closest?.('a[href]');
85
+ if (!link) return;
86
+ const target = link.getAttribute('target');
87
+ if ((target && target !== '_self') || link.hasAttribute('download')) return;
88
+ let url: URL;
89
+ try {
90
+ url = new URL(link.getAttribute('href') ?? '', document.baseURI);
91
+ } catch {
92
+ return;
93
+ }
94
+ if (url.origin !== location.origin) return;
95
+ setOpen(false);
96
+ }
63
97
 
64
98
  // --- resizable sidebar (desktop) ---
65
99
  function loadW(): number | null {
@@ -108,13 +142,20 @@
108
142
  const mq = window.matchMedia('(max-width: 47.999rem)');
109
143
  const sync = () => {
110
144
  isMobile = mq.matches;
111
- if (!isMobile) open = false; // desktop: drawer concept doesn't apply
145
+ if (!isMobile) setOpen(false);
112
146
  };
113
- sync();
147
+ untrack(sync);
114
148
  mq.addEventListener('change', sync);
115
149
  return () => mq.removeEventListener('change', sync);
116
150
  });
117
151
 
152
+ $effect(() => {
153
+ const el = aside;
154
+ if (!el) return;
155
+ el.addEventListener('click', onSidebarClick);
156
+ return () => el.removeEventListener('click', onSidebarClick);
157
+ });
158
+
118
159
  // Lock body scroll while the mobile drawer is open.
119
160
  $effect(() => {
120
161
  if (!browser) return;
@@ -128,7 +169,7 @@
128
169
  const bgInert = $derived(open && isMobile);
129
170
  </script>
130
171
 
131
- <svelte:window onkeydown={(e) => e.key === 'Escape' && (open = false)} />
172
+ <svelte:window onkeydown={(e) => e.key === 'Escape' && setOpen(false)} />
132
173
 
133
174
  <div
134
175
  class="shell {klass}"
@@ -147,7 +188,7 @@
147
188
  icon="menu"
148
189
  label="Toggle navigation"
149
190
  aria-expanded={open}
150
- onclick={() => (open = !open)}
191
+ onclick={() => setOpen(!open)}
151
192
  />
152
193
  </div>
153
194
  {/if}
@@ -160,7 +201,7 @@
160
201
  class:show={open && isMobile}
161
202
  tabindex="-1"
162
203
  aria-hidden="true"
163
- onclick={() => (open = false)}
204
+ onclick={() => setOpen(false)}
164
205
  ></button>
165
206
  <aside
166
207
  class="shell-sidebar"
@@ -168,6 +209,7 @@
168
209
  data-padding={sidebarPadding}
169
210
  aria-label={navLabel}
170
211
  inert={isMobile && !open ? true : undefined}
212
+ bind:this={aside}
171
213
  >
172
214
  {@render sidebar()}
173
215
  {#if resizableSidebar}
@@ -1,4 +1,4 @@
1
- import type { Snippet } from 'svelte';
1
+ import { type Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  header?: Snippet;
4
4
  sidebar?: Snippet;
@@ -23,9 +23,16 @@ type $$ComponentProps = {
23
23
  stickySidebar?: boolean;
24
24
  /** Inner padding of the sidebar; `'none'` when the nav owns its gutters. */
25
25
  sidebarPadding?: 'none' | 'sm' | 'md';
26
+ /** Mobile drawer state; reset to `false` when the viewport reaches 48rem. */
27
+ open?: boolean;
28
+ /** Fires when AppShell itself opens or closes the drawer. */
29
+ onOpenChange?: (open: boolean) => void;
30
+ /** Close the mobile drawer when a same-origin link inside it is clicked
31
+ * (plain left click, no modifier keys, no `target` / `download`). */
32
+ closeOnNavigate?: boolean;
26
33
  class?: string;
27
34
  style?: string;
28
35
  };
29
- declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "">;
36
+ declare const AppShell: import("svelte").Component<$$ComponentProps, {}, "open">;
30
37
  type AppShell = ReturnType<typeof AppShell>;
31
38
  export default AppShell;
@@ -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
  };
@@ -524,6 +524,14 @@
524
524
  outline: none;
525
525
  min-width: 0;
526
526
  }
527
+ @media (pointer: coarse) {
528
+ .fi__input {
529
+ font-size: max(16px, var(--fs-md));
530
+ }
531
+ .fi--sm .fi__input {
532
+ font-size: max(16px, var(--fs-sm));
533
+ }
534
+ }
527
535
  .fi__input::placeholder {
528
536
  color: var(--text-faint);
529
537
  }
@@ -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.69.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",