@dorsk/tsumikit 0.70.0 → 0.72.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.
Files changed (56) hide show
  1. package/README.md +49 -8
  2. package/dist/autoresize.js +13 -0
  3. package/dist/clipboard.js +12 -0
  4. package/dist/components/atoms/Artwork.svelte +10 -3
  5. package/dist/components/atoms/Artwork.svelte.d.ts +2 -0
  6. package/dist/components/atoms/Avatar.svelte +4 -3
  7. package/dist/components/atoms/Badge.svelte +23 -8
  8. package/dist/components/atoms/Badge.svelte.d.ts +2 -1
  9. package/dist/components/atoms/Button.svelte +39 -11
  10. package/dist/components/atoms/Card.svelte +2 -0
  11. package/dist/components/atoms/Dot.svelte +1 -1
  12. package/dist/components/atoms/Icon.svelte +2 -0
  13. package/dist/components/atoms/Icon.svelte.d.ts +2 -0
  14. package/dist/components/atoms/Input.svelte +3 -0
  15. package/dist/components/atoms/Kbd.svelte +28 -16
  16. package/dist/components/atoms/Link.svelte +18 -4
  17. package/dist/components/atoms/Scrim.svelte +18 -1
  18. package/dist/components/atoms/Select.svelte +1 -0
  19. package/dist/components/atoms/Slider.svelte +11 -1
  20. package/dist/components/atoms/Swatch.svelte +12 -0
  21. package/dist/components/atoms/Switch.svelte +12 -0
  22. package/dist/components/layouts/AppShell.svelte +70 -39
  23. package/dist/components/layouts/Container.svelte +25 -3
  24. package/dist/components/layouts/MasterDetail.svelte +59 -33
  25. package/dist/components/layouts/MasterDetail.svelte.d.ts +1 -1
  26. package/dist/components/layouts/NavItem.svelte +8 -4
  27. package/dist/components/layouts/NavSection.svelte +8 -2
  28. package/dist/components/layouts/ResizablePanel.svelte +14 -0
  29. package/dist/components/layouts/TabBar.svelte +217 -0
  30. package/dist/components/layouts/TabBar.svelte.d.ts +27 -0
  31. package/dist/components/layouts/Toolbar.svelte +5 -0
  32. package/dist/components/layouts/toolbar-roving.d.ts +16 -0
  33. package/dist/components/layouts/toolbar-roving.js +47 -1
  34. package/dist/components/molecules/Breadcrumb.svelte +44 -2
  35. package/dist/components/molecules/Breadcrumb.svelte.d.ts +6 -0
  36. package/dist/components/molecules/FilterInput.svelte +7 -0
  37. package/dist/components/molecules/FilterInput.svelte.d.ts +2 -0
  38. package/dist/components/molecules/PullToRefresh.svelte +205 -0
  39. package/dist/components/molecules/PullToRefresh.svelte.d.ts +18 -0
  40. package/dist/components/molecules/SegmentedControl.svelte +44 -1
  41. package/dist/components/molecules/SwipeRow.svelte +349 -0
  42. package/dist/components/molecules/SwipeRow.svelte.d.ts +37 -0
  43. package/dist/components/molecules/Toggle.svelte +12 -0
  44. package/dist/components/organisms/ConversationFeed.svelte +22 -8
  45. package/dist/components/organisms/ConversationFeed.svelte.d.ts +2 -0
  46. package/dist/components/organisms/DataTable.svelte +72 -12
  47. package/dist/components/organisms/DataTable.svelte.d.ts +7 -2
  48. package/dist/components/organisms/FilterSearchBar.svelte +4 -0
  49. package/dist/components/organisms/FilterSearchBar.svelte.d.ts +2 -0
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.js +3 -0
  52. package/dist/markdown.js +15 -7
  53. package/dist/styles/reset.css +30 -0
  54. package/dist/styles/syntax.css +0 -29
  55. package/dist/styles/tokens.css +6 -0
  56. package/package.json +1 -1
package/README.md CHANGED
@@ -102,7 +102,7 @@ individually, in this order:
102
102
  | --- | --- |
103
103
  | `styles/tokens.css` | `:root` only — every token the kit reads (`--c-*` palette, `--bg`/`--text`/`--accent` aliases, type scale, spacing, radii, shadows, fonts, `--control-height`, …). Theme-less. |
104
104
  | `styles/themes.css` | the built-in `[data-theme="id"]` blocks, one per `THEMES` entry. |
105
- | `styles/reset.css` | reset + element defaults. |
105
+ | `styles/reset.css` | reset + element defaults, global reduced-motion / forced-colors / prefers-contrast rules. |
106
106
  | `styles/utilities.css` | `.container`, `.stack`, `.row`, `.sr-only`, icon sizing, … |
107
107
  | `styles/syntax.css` | highlight.js / Prism class → `--syn-*` mapping. |
108
108
 
@@ -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
@@ -273,7 +273,7 @@ else aria-hidden, `interactive` renders a button with `onclick` + `aria-pressed`
273
273
  from `selected`), Divider (standalone
274
274
  horizontal/vertical hairline, `tone`, `spacing`, optional centred label,
275
275
  `decorative` to drop the `separator` role), Link (`tone`, `underline`
276
- always/hover/none, `align`), Icon (open registry — pass a `children` snippet for
276
+ always/hover/none, default hover for navigation and titles; pass `always` inside prose, `align`), Icon (open registry — pass a `children` snippet for
277
277
  any custom SVG).
278
278
 
279
279
  **Molecules:** Field (`grow`), IconButton (`filled` forwards to `Icon`, for a
@@ -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`,
@@ -342,16 +344,19 @@ replaces a header cell (a select-all checkbox);
342
344
  paints a left accent bar + `data-tone`, `rowClass(row)`, `rowActions` snippet
343
345
  for a hover/focus-revealed trailing cell (always visible on touch; under
344
346
  `layout="fixed"` its column is `--dt-actions-w`, default 3.5rem),
345
- `stickyOffset` for the sticky header's `top`, `size="sm"`, `loading`,
347
+ `stickyOffset` for the sticky header's `top` (it sticks within the table's own
348
+ scroller, so give the table a bounded height), `size="sm"`, `loading`,
346
349
  `onloadmore` footer button, `empty` as string or snippet; `data-part`
347
350
  hooks on head/row/cell; `responsive="stack"` turns rows into cards below
348
351
  `stackBelow` (48rem, measured on the table's own box) using `Column.role`
349
352
  `title | detail | meta | actions | hidden` — detail cells get a `data-label`
350
353
  prefix, `Column.stackLabel: false` drops it so metas join on one `·` line
351
354
  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>`
355
+ literal `inherit` falls back to mono), stacked tables use `table-layout: fixed`,
356
+ `stackActions="end"` closes every card with its actions, cells that render
357
+ nothing get `data-empty` and are hidden and skipped by the `·` separators, the `<table>`
353
358
  stays a table for assistive tech), FilterSearchBar /
354
- FilterInput (`size="sm"` compact bar on `--control-height-compact`, text
359
+ FilterInput (`font="sans"` for a plain search box, `size="sm"` compact bar on `--control-height-compact`, text
355
360
  floored at 16px under `pointer: coarse` so iOS does not zoom on focus,
356
361
  `shape="pill"`, `surface`, `hotkey="/"` focuses the input from anywhere
357
362
  outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`;
@@ -379,7 +384,8 @@ insets it as an elevated rounded card), NavItem
379
384
  (collapses to an icon rail when the sidebar is narrow; `icon` from the
380
385
  registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
381
386
  for the inset-bar active look), MasterDetail (list + detail columns that
382
- become two pages — one pane at a time with a sticky 44px back header — below
387
+ become two pages — one pane at a time with a sticky 44px back header, keeping the
388
+ list's scroll and focus — below
383
389
  their own `breakpoint`, so `selected` can be driven from the URL; `gap` spaces
384
390
  the columns and `divider="none"` drops the seam, both also settable as the
385
391
  `--md-gap` / `--md-divider` custom properties), Container, Stack
@@ -861,6 +867,32 @@ proportionally with no code. That's the recommended path for magnification.
861
867
  text tokens only) for reading-dense apps that want larger body text while
862
868
  keeping chrome compact. It isn't wired into AppShell or any default.
863
869
 
870
+ ### Mobile: TabBar, SwipeRow, PullToRefresh
871
+
872
+ `TabBar` is a phone bottom tab bar: `items: { id, label, icon?, href?, badge? }[]`,
873
+ `bind:value` (the selected `id`), `onselect(id, item)`. Items without `href` are a
874
+ `tablist` with roving focus (←/→/Home/End); items with `href` are links with
875
+ `aria-current="page"`. It is fixed to the bottom with `env(safe-area-inset-bottom)`
876
+ (`placement="inline"` flows with the page) and, while mounted as a bar, publishes its
877
+ height as `--tabbar-h` on `<html>`, so content clears it with
878
+ `padding-bottom: var(--tabbar-h)`. Targets are at least 48px; numeric badges cap at `99+`.
879
+
880
+ `SwipeRow` wraps a list row with one action per side: `leadingAction` on a rightward
881
+ swipe, `trailingAction` on a leftward one, each `{ label, icon?, tone?, onaction }`
882
+ (or `leading` / `trailing` snippets receiving the action and an `armed` flag).
883
+ Releasing past `threshold` (0.35 of the width) fires `onaction`. Drags that are more
884
+ vertical than horizontal are left to the scroller (`touch-action: pan-y`). The same
885
+ actions are visually hidden buttons for keyboard and screen-reader users.
886
+
887
+ `PullToRefresh` is a scroll container with pull-down-to-refresh for touch and pen.
888
+ `onrefresh` returns a promise; the Spinner holds and the container is `aria-busy`
889
+ with `label` ("Refreshing…") announced politely until it settles. It starts only at
890
+ `scrollTop === 0`, fires once past `threshold` (72px), and contains overscroll so the
891
+ browser's own pull does not fight it. Keep a visible refresh control for mouse users.
892
+
893
+ AppShell exposes `--shell-header-h` (the header plus the top safe-area inset) for
894
+ sticky offsets under `viewport-fit=cover`.
895
+
864
896
  ### Toolbar control heights
865
897
 
866
898
  `size="sm"` is the shared compact-toolbar contract for `Button`, `Popover`
@@ -972,6 +1004,15 @@ from the hidden text unless `aria-label` is set:
972
1004
  <Cluster stackAt="md"><Button grow>Cancel</Button><Button grow variant="primary">Save</Button></Cluster>
973
1005
  ```
974
1006
 
1007
+ Compact text controls (`Button size="sm"`, `Toggle`, `Switch`, `Link`) take
1008
+ the same shape, sized by `--touch-slab`: at least 8px past the block edges and
1009
+ the inline start, and at least `--touch-target-compact` (40px) tall, with no
1010
+ inline-end overflow. `SegmentedControl` items grow on the block axis and by
1011
+ half the gap sideways, which stays inside the group's padding, so neighbours
1012
+ never overlap. `Input size="sm"` cannot host a pseudo-element and grows to 40px
1013
+ instead, as should any control that clips its own overflow:
1014
+ `min-height: var(--touch-target-compact)`.
1015
+
975
1016
  `--control-height-*` and `--box-*` are **required tokens**: a consumer that
976
1017
  forks `variables.css` must keep them (Button `control`, Select `sm`, every `box`)
977
1018
  or those controls render with unresolved sizes.
@@ -32,6 +32,18 @@ export function autoresize(node, _value) {
32
32
  };
33
33
  resize();
34
34
  node.addEventListener('input', resize);
35
+ // Re-wrap on width changes, and measure once a hidden textarea is revealed.
36
+ let width = node.clientWidth;
37
+ const ro = typeof ResizeObserver === 'undefined'
38
+ ? undefined
39
+ : new ResizeObserver(([entry]) => {
40
+ const w = entry.contentRect.width;
41
+ if (w === width)
42
+ return;
43
+ width = w;
44
+ resize();
45
+ });
46
+ ro?.observe(node);
35
47
  return {
36
48
  update() {
37
49
  // re-measure when the bound value changes from outside (e.g. cleared)
@@ -39,6 +51,7 @@ export function autoresize(node, _value) {
39
51
  },
40
52
  destroy() {
41
53
  node.removeEventListener('input', resize);
54
+ ro?.disconnect();
42
55
  },
43
56
  };
44
57
  }
package/dist/clipboard.js CHANGED
@@ -13,15 +13,27 @@ export async function copyToClipboard(value) {
13
13
  /* fall through to legacy path */
14
14
  }
15
15
  // Fallback for insecure contexts / older browsers.
16
+ const prev = document.activeElement;
17
+ const sel = document.getSelection();
18
+ const ranges = sel
19
+ ? Array.from({ length: sel.rangeCount }, (_, i) => sel.getRangeAt(i).cloneRange())
20
+ : [];
16
21
  try {
17
22
  const ta = document.createElement('textarea');
18
23
  ta.value = value;
24
+ ta.setAttribute('readonly', '');
19
25
  ta.style.position = 'fixed';
20
26
  ta.style.opacity = '0';
21
27
  document.body.appendChild(ta);
22
28
  ta.select();
23
29
  const ok = document.execCommand('copy');
24
30
  document.body.removeChild(ta);
31
+ prev?.focus?.({ preventScroll: true });
32
+ if (sel && ranges.length) {
33
+ sel.removeAllRanges();
34
+ for (const r of ranges)
35
+ sel.addRange(r);
36
+ }
25
37
  return ok;
26
38
  }
27
39
  catch {
@@ -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,11 +54,13 @@
50
54
  let failed = $state(false);
51
55
  $effect(() => {
52
56
  src;
57
+ srcset;
53
58
  failed = false;
54
59
  });
55
60
 
56
61
  const key = $derived(seed ?? alt);
57
62
  const showImage = $derived(Boolean(src) && !failed);
63
+ const tile = $derived(showImage ? undefined : alt.trim() ? 'named' : 'decorative');
58
64
  const gradient = $derived(showImage ? undefined : artworkGradient(key));
59
65
  const text = $derived(fallback === 'initials' ? initialsOf(key) : '');
60
66
 
@@ -71,12 +77,13 @@
71
77
  style:aspect-ratio={aspect}
72
78
  style:width={size}
73
79
  style:background={gradient}
74
- role={showImage ? undefined : 'img'}
75
- aria-label={showImage ? undefined : alt}
80
+ role={tile === 'named' ? 'img' : undefined}
81
+ aria-label={tile === 'named' ? alt : undefined}
82
+ aria-hidden={tile === 'decorative' ? 'true' : undefined}
76
83
  {...rest}
77
84
  >
78
85
  {#if showImage}
79
- <img {src} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
86
+ <img {src} {srcset} {sizes} {alt} class="img fit-{fit}" loading="lazy" decoding="async" onerror={fail} />
80
87
  {:else if fallback === 'initials' && text}
81
88
  <span class="initials" aria-hidden="true">{text}</span>
82
89
  {: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;
@@ -38,6 +38,7 @@
38
38
 
39
39
  const text = $derived(glyph?.trim() || avatarInitial(name) || '?');
40
40
  const hashed = $derived(tone === undefined);
41
+ const hidden = $derived(decorative || !name.trim());
41
42
  const resolvedHue = $derived(hashed ? (hue ?? avatarHue(seed ?? name)) : undefined);
42
43
  </script>
43
44
 
@@ -55,9 +56,9 @@
55
56
  class:size-lg={size === 'lg'}
56
57
  style:--avatar-size={typeof size === 'number' ? `${size}px` : undefined}
57
58
  style:--avatar-hue={resolvedHue}
58
- role={decorative ? undefined : 'img'}
59
- aria-label={decorative ? undefined : name}
60
- aria-hidden={decorative ? 'true' : undefined}
59
+ role={hidden ? undefined : 'img'}
60
+ aria-label={hidden ? undefined : name}
61
+ aria-hidden={hidden ? 'true' : undefined}
61
62
  {...rest}
62
63
  >
63
64
  <span class="text" aria-hidden="true">{text}</span>
@@ -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'}
@@ -138,6 +140,7 @@
138
140
  class:interactive={as === 'button' || !!href}
139
141
  style:--badge-tone={toneColor}
140
142
  style:--badge-max-width={maxWidth}
143
+ type={!href && as === 'button' ? 'button' : undefined}
141
144
  {...rest}
142
145
  >
143
146
  {#if dot}
@@ -193,7 +196,7 @@
193
196
  }
194
197
  .badge-sm,
195
198
  .badge-xs {
196
- font-size: var(--badge-fs, calc(var(--fs-xs) * 0.92));
199
+ font-size: var(--badge-fs, var(--fs-xs));
197
200
  gap: 0.15rem;
198
201
  }
199
202
  .badge-sm {
@@ -293,4 +296,16 @@
293
296
  .action:hover {
294
297
  opacity: 1;
295
298
  }
299
+ @media (pointer: coarse) {
300
+ .action {
301
+ position: relative;
302
+ }
303
+ .action::before {
304
+ content: '';
305
+ position: absolute;
306
+ inset-block: var(--touch-slab);
307
+ inset-inline-start: var(--touch-slab);
308
+ inset-inline-end: 0;
309
+ }
310
+ }
296
311
  </style>
@@ -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`). */
@@ -114,14 +114,23 @@
114
114
  const t = $derived(canonicalTone(tone));
115
115
 
116
116
  // A link-flavoured Button is a real <a>; native `disabled`/`type` don't apply,
117
- // so a disabled link is expressed via aria-disabled + inert pointer handling.
117
+ // so an inactive link drops `href` (no focus, no keyboard activation).
118
118
  const tag = $derived(href !== undefined || as === 'a' ? 'a' : 'button');
119
119
  const inactive = $derived(disabled || loading);
120
120
  const elementAttrs = $derived(
121
121
  tag === 'a'
122
- ? { href, 'aria-disabled': inactive || undefined }
122
+ ? inactive
123
+ ? { href: undefined, role: 'link', 'aria-disabled': true }
124
+ : { href }
123
125
  : { type, disabled: inactive }
124
126
  );
127
+ const handleClick = (e: Parameters<NonNullable<typeof onclick>>[0]) => {
128
+ if (inactive) {
129
+ e.preventDefault();
130
+ return;
131
+ }
132
+ onclick?.(e);
133
+ };
125
134
 
126
135
  const counted = $derived(hasCount(count));
127
136
  const countText = $derived(hasCount(count) ? formatCount(count, countMax) : '');
@@ -137,12 +146,19 @@
137
146
  const ariaLabel = $derived(rest['aria-label'] ? withCount(rest['aria-label']) : undefined);
138
147
  $effect(() => {
139
148
  if (!el || collapseLabel === 'never' || explicitName) return;
140
- const labels = Array.from(el.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
141
- .filter(Boolean)
142
- .join(' ');
143
- const text = labels && withCount(labels);
144
- if (text) el.setAttribute('aria-label', text);
145
- else el.removeAttribute('aria-label');
149
+ const node = el;
150
+ const sync = () => {
151
+ const labels = Array.from(node.querySelectorAll('[data-label]'), (n) => n.textContent?.trim())
152
+ .filter(Boolean)
153
+ .join(' ');
154
+ const text = labels && withCount(labels);
155
+ if (text) node.setAttribute('aria-label', text);
156
+ else node.removeAttribute('aria-label');
157
+ };
158
+ sync();
159
+ const observer = new MutationObserver(sync);
160
+ observer.observe(node, { childList: true, characterData: true, subtree: true });
161
+ return () => observer.disconnect();
146
162
  });
147
163
  </script>
148
164
 
@@ -188,7 +204,7 @@
188
204
  class:btn-counted={counted}
189
205
  class:btn-stacked={stacked}
190
206
  class:btn-hinted={keyHint !== undefined}
191
- onclick={onclick}
207
+ onclick={handleClick}
192
208
  >
193
209
  {#if loading}<span class="btn-spinner" aria-hidden="true"></span>{/if}
194
210
  {#if glyph !== undefined}
@@ -544,7 +560,8 @@
544
560
  The slab is the centred target with its inline-end half cut off: inline-end
545
561
  overflow counts towards the scrollable overflow of every scroll ancestor, and
546
562
  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. */
563
+ the start would let each slab cover the previous sibling's centre. Text
564
+ buttons at size sm take the same shape sized by --touch-slab. */
548
565
  @media (pointer: coarse) {
549
566
  .btn-icon:not(.hit-compact),
550
567
  .btn-icon-inline:not(.hit-compact),
@@ -566,6 +583,16 @@
566
583
  inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
567
584
  inset-inline-end: 0;
568
585
  }
586
+ .btn-sm:not(.btn-icon, .btn-icon-inline, .btn-box, .btn-square, .btn-chip, .btn-stacked, .hit-compact) {
587
+ position: relative;
588
+ }
589
+ .btn-sm:not(.btn-icon, .btn-icon-inline, .btn-box, .btn-square, .btn-chip, .btn-stacked, .hit-compact)::before {
590
+ content: '';
591
+ position: absolute;
592
+ inset-block: var(--touch-slab);
593
+ inset-inline-start: var(--touch-slab);
594
+ inset-inline-end: 0;
595
+ }
569
596
  }
570
597
 
571
598
  /* Two-state (toggle) buttons — e.g. an IconButton with `pressed`. Reacts to
@@ -703,7 +730,8 @@
703
730
  .btn-link .btn-count,
704
731
  .btn-icon-inline .btn-count {
705
732
  --btn-count-size: 1rem;
706
- font-size: calc(var(--fs-xs) * 0.85);
733
+ padding: 0 0.2rem;
734
+ font-size: var(--fs-xs);
707
735
  }
708
736
  /* The accent fill would swallow an accent pill: invert on primary. */
709
737
  .btn-primary .btn-count {
@@ -65,6 +65,7 @@
65
65
  tap = false,
66
66
  interactive = false,
67
67
  as = 'div',
68
+ type = 'button',
68
69
  padding = 'md',
69
70
  surface = 'base',
70
71
  border = 'solid',
@@ -147,6 +148,7 @@
147
148
  style={`${stackStyle}${style}`}
148
149
  role={interactive && !native ? 'button' : undefined}
149
150
  tabindex={interactive && !native ? 0 : undefined}
151
+ type={as === 'button' ? type : undefined}
150
152
  onclick={handleClick}
151
153
  onkeydown={handleKeydown}
152
154
  {...rest}
@@ -53,7 +53,7 @@
53
53
  </span>
54
54
  {:else}
55
55
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
56
- <span class="dot {klass}" class:glow class:ring style="--dot-color:{resolved}" data-tsu="Dot" role={interactive ? 'img' : undefined} tabindex={interactive ? 0 : undefined} {...rest}></span>
56
+ <span class="dot {klass}" class:glow class:ring style="--dot-color:{resolved}" data-tsu="Dot" role={interactive ? 'img' : undefined} tabindex={interactive ? 0 : undefined} aria-label={interactive && !color ? status : undefined} {...rest}></span>
57
57
  {/if}
58
58
 
59
59
  <style>
@@ -50,6 +50,7 @@
50
50
  music: '<path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" />',
51
51
  tv: '<path d="m17 2-5 5-5-5" /><rect width="20" height="15" x="2" y="7" rx="2" />',
52
52
  film: '<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M7 3v18" /><path d="M3 7.5h4" /><path d="M3 12h18" /><path d="M3 16.5h4" /><path d="M17 3v18" /><path d="M17 7.5h4" /><path d="M17 16.5h4" />',
53
+ video: '<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" /><rect x="2" y="6" width="14" height="12" rx="2" />',
53
54
  disc: '<circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="2" />',
54
55
 
55
56
  // — files / content —
@@ -68,6 +69,7 @@
68
69
  unlink: '<path d="m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71" /><path d="m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71" /><line x1="8" x2="8" y1="2" y2="5" /><line x1="2" x2="5" y1="8" y2="8" /><line x1="16" x2="16" y1="19" y2="22" /><line x1="19" x2="22" y1="16" y2="16" />',
69
70
  'text-cursor': '<path d="M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1" /><path d="M7 22h1a4 4 0 0 0 4-4" /><path d="M7 2h1a4 4 0 0 1 4 4" />',
70
71
  tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" /><circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />',
72
+ map: '<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" /><path d="M15 5.764v15" /><path d="M9 3.236v15" />',
71
73
  pin: '<path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z" />',
72
74
  'pin-off': '<path d="M12 17v5" /><path d="M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89" /><path d="m2 2 20 20" /><path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11" />',
73
75
  'life-buoy': '<circle cx="12" cy="12" r="10" /><path d="m4.93 4.93 4.24 4.24" /><path d="m14.83 9.17 4.24-4.24" /><path d="m14.83 14.83 4.24 4.24" /><path d="m9.17 14.83-4.24 4.24" /><circle cx="12" cy="12" r="4" />',
@@ -37,6 +37,7 @@ declare const ICONS: {
37
37
  readonly music: "<path d=\"M9 18V5l12-2v13\" /><circle cx=\"6\" cy=\"18\" r=\"3\" /><circle cx=\"18\" cy=\"16\" r=\"3\" />";
38
38
  readonly tv: "<path d=\"m17 2-5 5-5-5\" /><rect width=\"20\" height=\"15\" x=\"2\" y=\"7\" rx=\"2\" />";
39
39
  readonly film: "<rect width=\"18\" height=\"18\" x=\"3\" y=\"3\" rx=\"2\" /><path d=\"M7 3v18\" /><path d=\"M3 7.5h4\" /><path d=\"M3 12h18\" /><path d=\"M3 16.5h4\" /><path d=\"M17 3v18\" /><path d=\"M17 7.5h4\" /><path d=\"M17 16.5h4\" />";
40
+ readonly video: "<path d=\"m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5\" /><rect x=\"2\" y=\"6\" width=\"14\" height=\"12\" rx=\"2\" />";
40
41
  readonly disc: "<circle cx=\"12\" cy=\"12\" r=\"10\" /><circle cx=\"12\" cy=\"12\" r=\"2\" />";
41
42
  readonly file: "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /><path d=\"M14 2v5a1 1 0 0 0 1 1h5\" />";
42
43
  readonly 'file-text': "<path d=\"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z\" /><path d=\"M14 2v5a1 1 0 0 0 1 1h5\" /><path d=\"M10 9H8\" /><path d=\"M16 13H8\" /><path d=\"M16 17H8\" />";
@@ -51,6 +52,7 @@ declare const ICONS: {
51
52
  readonly unlink: "<path d=\"m18.84 12.25 1.72-1.71h-.02a5.004 5.004 0 0 0-.12-7.07 5.006 5.006 0 0 0-6.95 0l-1.72 1.71\" /><path d=\"m5.17 11.75-1.71 1.71a5.004 5.004 0 0 0 .12 7.07 5.006 5.006 0 0 0 6.95 0l1.71-1.71\" /><line x1=\"8\" x2=\"8\" y1=\"2\" y2=\"5\" /><line x1=\"2\" x2=\"5\" y1=\"8\" y2=\"8\" /><line x1=\"16\" x2=\"16\" y1=\"19\" y2=\"22\" /><line x1=\"19\" x2=\"22\" y1=\"16\" y2=\"16\" />";
52
53
  readonly 'text-cursor': "<path d=\"M17 22h-1a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h1\" /><path d=\"M7 22h1a4 4 0 0 0 4-4\" /><path d=\"M7 2h1a4 4 0 0 1 4 4\" />";
53
54
  readonly tag: "<path d=\"M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z\" /><circle cx=\"7.5\" cy=\"7.5\" r=\".5\" fill=\"currentColor\" />";
55
+ readonly map: "<path d=\"M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z\" /><path d=\"M15 5.764v15\" /><path d=\"M9 3.236v15\" />";
54
56
  readonly pin: "<path d=\"M12 17v5\" /><path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\" />";
55
57
  readonly 'pin-off': "<path d=\"M12 17v5\" /><path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\" /><path d=\"m2 2 20 20\" /><path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\" />";
56
58
  readonly 'life-buoy': "<circle cx=\"12\" cy=\"12\" r=\"10\" /><path d=\"m4.93 4.93 4.24 4.24\" /><path d=\"m14.83 9.17 4.24-4.24\" /><path d=\"m14.83 14.83 4.24 4.24\" /><path d=\"m9.17 14.83-4.24 4.24\" /><circle cx=\"12\" cy=\"12\" r=\"4\" />";
@@ -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;
@@ -18,43 +18,46 @@
18
18
  typeof navigator !== 'undefined' &&
19
19
  /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '');
20
20
 
21
- function label(token: string): string {
21
+ type Key = { glyph: string; name: string };
22
+ const key = (glyph: string, name = glyph): Key => ({ glyph, name });
23
+
24
+ function label(token: string): Key {
22
25
  const t = token.trim().toLowerCase();
23
26
  switch (t) {
24
27
  case 'mod':
25
- return isMac ? '⌘' : 'Ctrl';
28
+ return isMac ? key('⌘', 'Command') : key('Ctrl', 'Control');
26
29
  case 'ctrl':
27
30
  case 'control':
28
- return 'Ctrl';
31
+ return key('Ctrl', 'Control');
29
32
  case 'meta':
30
33
  case 'cmd':
31
34
  case 'command':
32
- return '⌘';
35
+ return key('⌘', 'Command');
33
36
  case 'alt':
34
37
  case 'option':
35
- return isMac ? '⌥' : 'Alt';
38
+ return isMac ? key('⌥', 'Option') : key('Alt');
36
39
  case 'shift':
37
- return '⇧';
40
+ return key('⇧', 'Shift');
38
41
  case 'enter':
39
42
  case 'return':
40
- return '↵';
43
+ return key('↵', 'Enter');
41
44
  case 'esc':
42
45
  case 'escape':
43
- return 'Esc';
46
+ return key('Esc', 'Escape');
44
47
  case 'tab':
45
- return 'Tab';
48
+ return key('Tab');
46
49
  case 'space':
47
- return 'Space';
50
+ return key('Space');
48
51
  case 'up':
49
- return '↑';
52
+ return key('↑', 'Up arrow');
50
53
  case 'down':
51
- return '↓';
54
+ return key('↓', 'Down arrow');
52
55
  case 'left':
53
- return '←';
56
+ return key('←', 'Left arrow');
54
57
  case 'right':
55
- return '→';
58
+ return key('→', 'Right arrow');
56
59
  default:
57
- return t.charAt(0).toUpperCase() + t.slice(1);
60
+ return key(t.charAt(0).toUpperCase() + t.slice(1));
58
61
  }
59
62
  }
60
63
 
@@ -73,7 +76,7 @@
73
76
  >
74
77
  {#each tokens as token, i (i)}
75
78
  {#if i > 0}<span class="kbd-sep" aria-hidden="true">+</span>{/if}
76
- <kbd class="kbd">{token}</kbd>
79
+ <kbd class="kbd">{#if token.name === token.glyph}{token.glyph}{:else}<span aria-hidden="true">{token.glyph}</span><span class="kbd-name">{token.name}</span>{/if}</kbd>
77
80
  {/each}
78
81
  </span>
79
82
 
@@ -111,6 +114,15 @@
111
114
  background: var(--bg-elevated-2);
112
115
  color: inherit;
113
116
  }
117
+ .kbd-name {
118
+ position: absolute;
119
+ width: 1px;
120
+ height: 1px;
121
+ margin: -1px;
122
+ overflow: hidden;
123
+ clip: rect(0, 0, 0, 0);
124
+ white-space: nowrap;
125
+ }
114
126
  .kbd-sep {
115
127
  font-size: 0.85em;
116
128
  color: var(--text-faint);
@@ -1,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- // Inline text link — underlined accent text with no button chrome. Renders an
4
- // <a> when `href` is given, otherwise a <button> (for in-page actions that
5
- // read as links, e.g. "Open ChatGPT again").
3
+ // Inline text link — accent text with no button chrome. Renders an <a> when
4
+ // `href` is given, otherwise a <button> (for in-page actions that read as
5
+ // links, e.g. "Open ChatGPT again").
6
+ // Underline: the default `hover` is for navigation, lists and entity titles;
7
+ // use `always` for links inside running prose.
6
8
  import type { Snippet } from 'svelte';
7
9
 
8
10
  type Own = {
@@ -20,7 +22,7 @@
20
22
  let {
21
23
  href,
22
24
  tone = 'accent',
23
- underline = 'always',
25
+ underline = 'hover',
24
26
  align = 'start',
25
27
  class: klass = '',
26
28
  children,
@@ -74,4 +76,16 @@
74
76
  .align-center {
75
77
  text-align: center;
76
78
  }
79
+ @media (pointer: coarse) {
80
+ .link {
81
+ position: relative;
82
+ }
83
+ .link::before {
84
+ content: '';
85
+ position: absolute;
86
+ inset-block: var(--touch-slab);
87
+ inset-inline-start: var(--touch-slab);
88
+ inset-inline-end: 0;
89
+ }
90
+ }
77
91
  </style>