@dorsk/tsumikit 0.53.0 → 0.55.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
@@ -222,6 +222,23 @@ renders, and the trigger belongs to `Popover`, so `.toolbar .my-trigger` compile
222
222
  to `.toolbar.svelte-x .my-trigger.svelte-x` and matches nothing. Use `style` and
223
223
  the properties above instead.
224
224
 
225
+ ### Anchoring to a component
226
+
227
+ Components that render a single addressable element forward rest props onto it,
228
+ so `data-journey`, `data-testid` and analytics attributes land where you expect.
229
+ `Popover` puts them on its **trigger** (so `Menu`, `ThemePicker` and
230
+ `FontScalePicker` forward through to it), `Tabs` on its root — with a per-tab
231
+ `attrs` bag on each `TabItem` — `FilterInput` on its root, and `FilterSearchBar`
232
+ forwards onto that. `Menu` items take an `attrs` bag. Components that forward
233
+ into another component take `AnchorAttributes` (`data-*` only), which cannot
234
+ collide with the child's own props; the rest take full `HTMLAttributes`. The
235
+ component's own `role`, `aria-*`, `class` and keyboard wiring are applied after
236
+ the spread and always win, so an anchor can never break behaviour; `class` in a
237
+ rest/`attrs` bag is therefore dropped — use `class` / `triggerClass`.
238
+
239
+ Never target the private `data-tsu="…"` markers: they are internal, unguarded by
240
+ any test, and change without a major.
241
+
225
242
  ## Components
226
243
 
227
244
  **Atoms:** Text, Heading, Button, Input (`icon` inset leading glyph,
@@ -241,7 +258,8 @@ always/hover/none, `align`), Icon (open registry — pass a `children` snippet f
241
258
  any custom SVG).
242
259
 
243
260
  **Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
244
- Popover, Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet),
261
+ Popover, Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet,
262
+ and an `attrs` object for `data-*`/test ids on the row),
245
263
  Tabs, RadioGroup (`variant="rows"`: bordered rows, per-option `note`/`description`,
246
264
  `action(option)` trailing control that never toggles, `below(option)` inline panel),
247
265
  Tooltip, Accordion, CopyButton, FileButton,
@@ -523,6 +541,14 @@ triggers, and `SegmentedControl`. Each renders an exact
523
541
  original ghost icon-button default. Use `control` on `Button` or `Popover` when
524
542
  the roomier shared `--control-height` composer contract is required.
525
543
 
544
+ `Toolbar` implements the keyboard contract its `role="toolbar"` announces: the
545
+ bar is a **single tab stop**, and `←`/`→` (plus `Home`/`End`) move between the
546
+ controls that are actually visible — collapsed `data-overflow` children drop out
547
+ of the ring and the `…` trigger is its last stop. Controls that own the arrow
548
+ keys themselves (text inputs, `select`, sliders) keep them. Pass
549
+ `roving={false}` to opt out; that drops the role along with the promise, leaving
550
+ every child an ordinary tab stop.
551
+
526
552
  Button and Popover share the same semantic tones. For a confirmed positive
527
553
  action, `tone="success"` gives neutral controls a success tint; combine it with
528
554
  `variant="primary"` for a filled success action without consumer CSS.
@@ -610,7 +636,8 @@ label, copy, line numbers, wrap, scroll) and takes code three ways: plain
610
636
  `disabled` and native events pass through.
611
637
  - Visible `:focus-visible` rings; ARIA patterns implemented for switch, menu
612
638
  (`role=menu` + roving focus), tabs (`tablist` + arrow keys), radiogroup,
613
- dialog; polite live region for toasts; `.sr-only`.
639
+ toolbar (`role=toolbar` + roving tabindex), dialog; polite live region for
640
+ toasts; `.sr-only`.
614
641
  - Mobile-first: one `min-width: 640px` breakpoint, bottom-sheet→centered-modal,
615
642
  safe-area insets, 16px-min inputs (no iOS zoom).
616
643
  - A verified color-blind-safe theme (Okabe-Ito); meaning never relies on hue
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `data-*` attributes for addressing a component later — tour anchors, test ids.
3
+ *
4
+ * Components rendering their own element take a full `HTMLAttributes` rest
5
+ * instead; this narrower bag is for those that forward into another component,
6
+ * where a full attribute type collides with the child's narrower props.
7
+ */
8
+ export type AnchorAttributes = {
9
+ [key: `data-${string}`]: string | number | boolean | null | undefined;
10
+ };
package/dist/anchor.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -3,10 +3,22 @@
3
3
  // every child marked `data-overflow` is hidden and a `…` button opens a Menu
4
4
  // with `items` (or the `overflow` snippet in a Popover) that stands in for
5
5
  // them. `sticky` pins the bar under `stickyOffset`.
6
+ //
7
+ // `role="toolbar"` is a promise of keyboard behaviour, so the bar keeps it:
8
+ // roving tabindex makes it one tab stop and ←/→/Home/End move between the
9
+ // controls actually visible, the `…` trigger included. Children are arbitrary
10
+ // snippet content, so the ring is read from the DOM rather than a list prop.
6
11
  import type { Snippet } from 'svelte';
7
12
  import IconButton from '../molecules/IconButton.svelte';
8
13
  import Menu, { type MenuItem } from '../molecules/Menu.svelte';
9
14
  import Popover from '../molecules/Popover.svelte';
15
+ import {
16
+ consumesArrowKeys,
17
+ isToolbarNavKey,
18
+ nextToolbarStop,
19
+ TOOLBAR_STOP_ATTR,
20
+ toolbarStops
21
+ } from './toolbar-roving.js';
10
22
 
11
23
  let {
12
24
  children,
@@ -18,6 +30,7 @@
18
30
  density = 'default',
19
31
  overflowLabel = 'More',
20
32
  label,
33
+ roving = true,
21
34
  class: klass = '',
22
35
  style: styleProp = ''
23
36
  }: {
@@ -34,6 +47,10 @@
34
47
  overflowLabel?: string;
35
48
  /** Accessible name of the bar (`role="toolbar"`). */
36
49
  label?: string;
50
+ /** Roving tabindex: the bar is one tab stop and ←/→ (plus Home/End) move
51
+ * between its controls. `false` drops `role="toolbar"` along with the
52
+ * keyboard contract it promises, leaving children as ordinary tab stops. */
53
+ roving?: boolean;
37
54
  class?: string;
38
55
  style?: string;
39
56
  } = $props();
@@ -64,13 +81,77 @@
64
81
  });
65
82
 
66
83
  const hasOverflow = $derived(!!items?.length || !!overflow);
84
+
85
+ let activeStop: HTMLElement | null = null;
86
+
87
+ // Collapsed `data-overflow` children are `display: none`, so they have no box —
88
+ // that, not a class check, is what takes them out of the ring.
89
+ const stops = () => toolbarStops(el, (node) => node.getClientRects().length === 0);
90
+
91
+ function rove(list: HTMLElement[], index: number) {
92
+ activeStop = list[index] ?? null;
93
+ list.forEach((node, i) => {
94
+ node.setAttribute(TOOLBAR_STOP_ATTR, '');
95
+ node.tabIndex = i === index ? 0 : -1;
96
+ });
97
+ }
98
+
99
+ function sync() {
100
+ const list = stops();
101
+ if (!list.length) return;
102
+ const current = activeStop ? list.indexOf(activeStop) : -1;
103
+ rove(list, current < 0 ? 0 : current);
104
+ }
105
+
106
+ $effect(() => {
107
+ if (!roving || !el) return;
108
+ const node = el;
109
+ sync();
110
+ // Only attributes the caller owns are observed: writing tabindex and
111
+ // TOOLBAR_STOP_ATTR back must not re-enter this.
112
+ const mo = new MutationObserver(sync);
113
+ mo.observe(node, {
114
+ childList: true,
115
+ subtree: true,
116
+ attributes: true,
117
+ attributeFilter: ['disabled', 'hidden', 'aria-hidden', 'class', 'style']
118
+ });
119
+ return () => mo.disconnect();
120
+ });
121
+
122
+ function onKeydown(event: KeyboardEvent) {
123
+ if (!roving || event.defaultPrevented) return;
124
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
125
+ if (!isToolbarNavKey(event.key)) return;
126
+ const target = event.target as HTMLElement | null;
127
+ if (consumesArrowKeys(target)) return;
128
+ const list = stops();
129
+ const next = nextToolbarStop(
130
+ list.length,
131
+ list.findIndex((node) => node.contains(target)),
132
+ event.key
133
+ );
134
+ if (next === undefined) return;
135
+ event.preventDefault();
136
+ rove(list, next);
137
+ list[next].focus();
138
+ }
139
+
140
+ function onFocusin(event: FocusEvent) {
141
+ if (!roving) return;
142
+ const list = stops();
143
+ const index = list.findIndex((node) => node.contains(event.target as Node));
144
+ if (index >= 0 && list[index] !== activeStop) rove(list, index);
145
+ }
67
146
  </script>
68
147
 
69
148
  <div
70
149
  bind:this={el}
71
150
  data-tsu="Toolbar"
72
- role="toolbar"
73
- aria-label={label}
151
+ role={roving ? 'toolbar' : undefined}
152
+ aria-label={roving ? label : undefined}
153
+ onkeydown={onKeydown}
154
+ onfocusin={onFocusin}
74
155
  class="toolbar density-{density} {klass}"
75
156
  class:sticky
76
157
  class:collapsed
@@ -14,6 +14,10 @@ type $$ComponentProps = {
14
14
  overflowLabel?: string;
15
15
  /** Accessible name of the bar (`role="toolbar"`). */
16
16
  label?: string;
17
+ /** Roving tabindex: the bar is one tab stop and ←/→ (plus Home/End) move
18
+ * between its controls. `false` drops `role="toolbar"` along with the
19
+ * keyboard contract it promises, leaving children as ordinary tab stops. */
20
+ roving?: boolean;
17
21
  class?: string;
18
22
  style?: string;
19
23
  };
@@ -0,0 +1,53 @@
1
+ /** @param {string} key */
2
+ export function isToolbarNavKey(key: string): boolean;
3
+ /**
4
+ * Controls that own the arrow keys themselves (caret, spinner, native listbox):
5
+ * the toolbar must not steal them.
6
+ *
7
+ * @param {{ tagName?: string; type?: string; isContentEditable?: boolean } | null | undefined} el
8
+ */
9
+ export function consumesArrowKeys(el: {
10
+ tagName?: string;
11
+ type?: string;
12
+ isContentEditable?: boolean;
13
+ } | null | undefined): boolean;
14
+ /**
15
+ * @param {{
16
+ * disabled?: boolean;
17
+ * hidden?: boolean;
18
+ * ariaHidden?: boolean;
19
+ * inPopover?: boolean;
20
+ * tabindex?: string | null;
21
+ * claimed?: boolean;
22
+ * }} el
23
+ */
24
+ export function acceptsRovingStop(el: {
25
+ disabled?: boolean;
26
+ hidden?: boolean;
27
+ ariaHidden?: boolean;
28
+ inPopover?: boolean;
29
+ tabindex?: string | null;
30
+ claimed?: boolean;
31
+ }): boolean;
32
+ /**
33
+ * The ring, in DOM order. `isHidden` is injected because "has no box" is the only
34
+ * honest test for a collapsed child and it cannot be computed without layout.
35
+ *
36
+ * @param {Element | null} container
37
+ * @param {(node: HTMLElement) => boolean} isHidden
38
+ * @returns {HTMLElement[]}
39
+ */
40
+ export function toolbarStops(container: Element | null, isHidden: (node: HTMLElement) => boolean): HTMLElement[];
41
+ /**
42
+ * Arrows wrap; Home/End jump to the ends. `undefined` means "not ours" — the
43
+ * caller must leave the event alone.
44
+ *
45
+ * @param {number} count
46
+ * @param {number} current
47
+ * @param {string} key
48
+ * @returns {number | undefined}
49
+ */
50
+ export function nextToolbarStop(count: number, current: number, key: string): number | undefined;
51
+ export const TOOLBAR_STOP_SELECTOR: "button, a[href], input, select, textarea, summary, [tabindex]";
52
+ /** Marks a control the toolbar has taken over, so its `-1` reads as ours, not the author's. */
53
+ export const TOOLBAR_STOP_ATTR: "data-toolbar-stop";
@@ -0,0 +1,104 @@
1
+ export const TOOLBAR_STOP_SELECTOR =
2
+ 'button, a[href], input, select, textarea, summary, [tabindex]';
3
+
4
+ /** Marks a control the toolbar has taken over, so its `-1` reads as ours, not the author's. */
5
+ export const TOOLBAR_STOP_ATTR = 'data-toolbar-stop';
6
+
7
+ const NAV_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']);
8
+
9
+ const ARROW_CONSUMING_INPUTS = new Set([
10
+ 'text',
11
+ 'search',
12
+ 'email',
13
+ 'url',
14
+ 'tel',
15
+ 'password',
16
+ 'number',
17
+ 'date',
18
+ 'datetime-local',
19
+ 'month',
20
+ 'week',
21
+ 'time',
22
+ 'range',
23
+ ]);
24
+
25
+ /** @param {string} key */
26
+ export function isToolbarNavKey(key) {
27
+ return NAV_KEYS.has(key);
28
+ }
29
+
30
+ /**
31
+ * Controls that own the arrow keys themselves (caret, spinner, native listbox):
32
+ * the toolbar must not steal them.
33
+ *
34
+ * @param {{ tagName?: string; type?: string; isContentEditable?: boolean } | null | undefined} el
35
+ */
36
+ export function consumesArrowKeys(el) {
37
+ if (!el) return false;
38
+ if (el.isContentEditable) return true;
39
+ const tag = (el.tagName ?? '').toLowerCase();
40
+ if (tag === 'textarea' || tag === 'select') return true;
41
+ if (tag !== 'input') return false;
42
+ return ARROW_CONSUMING_INPUTS.has((el.type ?? 'text').toLowerCase());
43
+ }
44
+
45
+ /**
46
+ * @param {{
47
+ * disabled?: boolean;
48
+ * hidden?: boolean;
49
+ * ariaHidden?: boolean;
50
+ * inPopover?: boolean;
51
+ * tabindex?: string | null;
52
+ * claimed?: boolean;
53
+ * }} el
54
+ */
55
+ export function acceptsRovingStop(el) {
56
+ if (el.disabled || el.hidden || el.ariaHidden || el.inPopover) return false;
57
+ return el.claimed === true || el.tabindex !== '-1';
58
+ }
59
+
60
+ /**
61
+ * The ring, in DOM order. `isHidden` is injected because "has no box" is the only
62
+ * honest test for a collapsed child and it cannot be computed without layout.
63
+ *
64
+ * @param {Element | null} container
65
+ * @param {(node: HTMLElement) => boolean} isHidden
66
+ * @returns {HTMLElement[]}
67
+ */
68
+ export function toolbarStops(container, isHidden) {
69
+ if (!container) return [];
70
+ return [
71
+ .../** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll(TOOLBAR_STOP_SELECTOR)),
72
+ ].filter((node) =>
73
+ acceptsRovingStop({
74
+ disabled: /** @type {HTMLButtonElement} */ (node).disabled,
75
+ hidden: node.hidden || isHidden(node),
76
+ ariaHidden: node.getAttribute('aria-hidden') === 'true',
77
+ inPopover: node.closest('[popover]') !== null,
78
+ tabindex: node.getAttribute('tabindex'),
79
+ claimed: node.hasAttribute(TOOLBAR_STOP_ATTR),
80
+ }),
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Arrows wrap; Home/End jump to the ends. `undefined` means "not ours" — the
86
+ * caller must leave the event alone.
87
+ *
88
+ * @param {number} count
89
+ * @param {number} current
90
+ * @param {string} key
91
+ * @returns {number | undefined}
92
+ */
93
+ export function nextToolbarStop(count, current, key) {
94
+ if (count <= 0 || current < 0 || current >= count) return undefined;
95
+ if (key === 'Home') return 0;
96
+ if (key === 'End') return count - 1;
97
+
98
+ let direction;
99
+ if (key === 'ArrowRight' || key === 'ArrowDown') direction = 1;
100
+ else if (key === 'ArrowLeft' || key === 'ArrowUp') direction = -1;
101
+ else return undefined;
102
+
103
+ return (((current + direction) % count) + count) % count;
104
+ }
@@ -38,6 +38,7 @@
38
38
  // `ValueProvider`s). No `fetch` lives in here; the bar owns the QUERY string.
39
39
  // ───────────────────────────────────────────────────────────────────────
40
40
  import type { Snippet } from 'svelte';
41
+ import type { HTMLAttributes } from 'svelte/elements';
41
42
  import Icon, { type IconName } from '../atoms/Icon.svelte';
42
43
  import { filters, freeText } from '../../query/ast';
43
44
  import { autoQuoteEdit, backspaceEmptyQuotes, closingQuoteExit } from '../../query/edit';
@@ -70,7 +71,10 @@
70
71
  below,
71
72
  class: klass = '',
72
73
  style: styleProp = '',
73
- }: {
74
+ ...rest
75
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
76
+
77
+ type Own = {
74
78
  schema: Schema;
75
79
  /**
76
80
  * Single-key mode: the name (or alias) of the ONE schema field being
@@ -127,7 +131,7 @@
127
131
  below?: Snippet<[FilterInputContext]>;
128
132
  class?: string;
129
133
  style?: string;
130
- } = $props();
134
+ };
131
135
 
132
136
  const field = getFieldContext();
133
137
  let el = $state<HTMLInputElement | null>(null);
@@ -335,6 +339,7 @@
335
339
  </script>
336
340
 
337
341
  <div
342
+ {...rest}
338
343
  class="fi {klass}"
339
344
  style={styleProp}
340
345
  class:fi--sm={size === 'sm'}
@@ -20,9 +20,10 @@ export interface FilterInputContext {
20
20
  }
21
21
  import type { ControlSize } from '../../size';
22
22
  import type { Snippet } from 'svelte';
23
+ import type { HTMLAttributes } from 'svelte/elements';
23
24
  import { type IconName } from '../atoms/Icon.svelte';
24
25
  import { type Schema } from '../../query/schema';
25
- type $$ComponentProps = {
26
+ type Own = {
26
27
  schema: Schema;
27
28
  /**
28
29
  * Single-key mode: the name (or alias) of the ONE schema field being
@@ -80,6 +81,7 @@ type $$ComponentProps = {
80
81
  class?: string;
81
82
  style?: string;
82
83
  };
84
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
83
85
  declare const FilterInput: import("svelte").Component<$$ComponentProps, {}, "value">;
84
86
  type FilterInput = ReturnType<typeof FilterInput>;
85
87
  export default FilterInput;
@@ -12,6 +12,12 @@
12
12
  pressed?: boolean;
13
13
  /** Custom row content (a rename Input, a slider…) replacing icon/label/tag. */
14
14
  content?: import('svelte').Snippet<[MenuItem]>;
15
+ /**
16
+ * Extra attributes for the rendered row — `data-*`, `title`, `aria-describedby`…
17
+ * The row's own semantics (`role`, `aria-checked`, `disabled`, `class`, `onclick`)
18
+ * are applied after and win.
19
+ */
20
+ attrs?: import('svelte/elements').HTMLButtonAttributes;
15
21
  }
16
22
  </script>
17
23
 
@@ -165,6 +171,7 @@
165
171
  {#each items as item (item.label)}
166
172
  <button
167
173
  type="button"
174
+ {...item.attrs}
168
175
  role={item.pressed === undefined ? 'menuitem' : 'menuitemcheckbox'}
169
176
  aria-checked={item.pressed === undefined ? undefined : item.pressed}
170
177
  class="menu-item"
@@ -11,6 +11,12 @@ export interface MenuItem {
11
11
  pressed?: boolean;
12
12
  /** Custom row content (a rename Input, a slider…) replacing icon/label/tag. */
13
13
  content?: import('svelte').Snippet<[MenuItem]>;
14
+ /**
15
+ * Extra attributes for the rendered row — `data-*`, `title`, `aria-describedby`…
16
+ * The row's own semantics (`role`, `aria-checked`, `disabled`, `class`, `onclick`)
17
+ * are applied after and win.
18
+ */
19
+ attrs?: import('svelte/elements').HTMLButtonAttributes;
14
20
  }
15
21
  import type { ComponentProps, Snippet } from 'svelte';
16
22
  import Popover from './Popover.svelte';
@@ -10,6 +10,7 @@
10
10
  // ships beyond Chromium; the popover semantics above are the hard part and
11
11
  // are broadly supported today.)
12
12
  import { tick, type Snippet } from 'svelte';
13
+ import type { HTMLAttributes } from 'svelte/elements';
13
14
  import { place } from '../../floating';
14
15
  import { HOVER_CLOSE_GRACE, HOVER_OPEN_DELAY, createHoverIntent, opensOnHover } from './popover-hover.js';
15
16
 
@@ -20,36 +21,7 @@
20
21
  type PanelRole = 'dialog' | 'menu' | 'listbox' | 'group';
21
22
  type HasPopup = 'menu' | 'dialog' | 'listbox' | true;
22
23
 
23
- let {
24
- placement = 'bottom-start',
25
- gap = 6,
26
- label,
27
- trigger,
28
- children,
29
- triggerClass = '',
30
- bare = false,
31
- variant,
32
- tone = 'none',
33
- size,
34
- box,
35
- pill = false,
36
- control = false,
37
- block = false,
38
- hitArea = 'auto',
39
- disabled = false,
40
- openOn = 'click',
41
- hoverDelay = HOVER_OPEN_DELAY,
42
- as = 'button',
43
- href,
44
- role = 'dialog',
45
- haspopup = 'dialog',
46
- onopen,
47
- onclose,
48
- class: klass = '',
49
- style: styleProp = '',
50
- panelClass = '',
51
- panelStyle = '',
52
- }: {
24
+ type Own = {
53
25
  placement?: Placement;
54
26
  gap?: number;
55
27
  /** Accessible name for the trigger. */
@@ -106,7 +78,39 @@
106
78
  /** Class / inline style on the floating panel. */
107
79
  panelClass?: string;
108
80
  panelStyle?: string;
109
- } = $props();
81
+ };
82
+
83
+ let {
84
+ placement = 'bottom-start',
85
+ gap = 6,
86
+ label,
87
+ trigger,
88
+ children,
89
+ triggerClass = '',
90
+ bare = false,
91
+ variant,
92
+ tone = 'none',
93
+ size,
94
+ box,
95
+ pill = false,
96
+ control = false,
97
+ block = false,
98
+ hitArea = 'auto',
99
+ disabled = false,
100
+ openOn = 'click',
101
+ hoverDelay = HOVER_OPEN_DELAY,
102
+ as = 'button',
103
+ href,
104
+ role = 'dialog',
105
+ haspopup = 'dialog',
106
+ onopen,
107
+ onclose,
108
+ class: klass = '',
109
+ style: styleProp = '',
110
+ panelClass = '',
111
+ panelStyle = '',
112
+ ...rest
113
+ }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
110
114
 
111
115
  const canonicalChrome = $derived(
112
116
  variant !== undefined || tone !== 'none' || size !== undefined || control || block
@@ -219,6 +223,7 @@
219
223
  <svelte:element
220
224
  this={as}
221
225
  bind:this={triggerEl}
226
+ {...rest}
222
227
  data-tsu="Popover"
223
228
  class="pop-trigger {triggerClass} {klass}"
224
229
  style={styleProp}
@@ -1,11 +1,12 @@
1
1
  import { type Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
2
3
  type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
3
4
  type TriggerVariant = 'default' | 'primary' | 'ghost' | 'danger';
4
5
  type TriggerTone = 'none' | 'accent' | 'success' | 'info' | 'warn' | 'danger';
5
6
  type TriggerSize = 'sm' | 'md' | 'lg';
6
7
  type PanelRole = 'dialog' | 'menu' | 'listbox' | 'group';
7
8
  type HasPopup = 'menu' | 'dialog' | 'listbox' | true;
8
- type $$ComponentProps = {
9
+ type Own = {
9
10
  placement?: Placement;
10
11
  gap?: number;
11
12
  /** Accessible name for the trigger. */
@@ -65,6 +66,7 @@ type $$ComponentProps = {
65
66
  panelClass?: string;
66
67
  panelStyle?: string;
67
68
  };
69
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
68
70
  declare const Popover: import("svelte").Component<$$ComponentProps, {}, "">;
69
71
  type Popover = ReturnType<typeof Popover>;
70
72
  export default Popover;
@@ -7,6 +7,12 @@
7
7
  disabled?: boolean;
8
8
  /** Trailing count badge. */
9
9
  count?: number | string;
10
+ /**
11
+ * Extra attributes for this tab's button — `data-*`, `title`…
12
+ * The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
13
+ * `tabindex`, `onclick`) are applied after and win.
14
+ */
15
+ attrs?: import('svelte/elements').HTMLButtonAttributes;
10
16
  }
11
17
  </script>
12
18
 
@@ -18,6 +24,7 @@
18
24
  // the Tab order. `value` is bindable; `panel` is a snippet that receives the
19
25
  // active id so the caller renders the matching content.
20
26
  import type { Snippet } from 'svelte';
27
+ import type { HTMLAttributes } from 'svelte/elements';
21
28
  import Icon from '../atoms/Icon.svelte';
22
29
 
23
30
  let {
@@ -29,7 +36,10 @@
29
36
  style: styleProp = '',
30
37
  panelClass = '',
31
38
  panelPadding = 'md',
32
- }: {
39
+ ...rest
40
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
41
+
42
+ type Own = {
33
43
  tabs: TabItem[];
34
44
  value?: string;
35
45
  label?: string;
@@ -38,7 +48,7 @@
38
48
  style?: string;
39
49
  panelClass?: string;
40
50
  panelPadding?: 'none' | 'sm' | 'md';
41
- } = $props();
51
+ };
42
52
 
43
53
  // Default to the first selectable tab when no value is supplied.
44
54
  $effect(() => {
@@ -84,11 +94,12 @@
84
94
  }
85
95
  </script>
86
96
 
87
- <div class="tabs {klass}" style={styleProp} data-tsu="Tabs">
97
+ <div {...rest} class="tabs {klass}" style={styleProp} data-tsu="Tabs">
88
98
  <div bind:this={listEl} role="tablist" aria-label={label} tabindex="-1" class="tablist" {onkeydown}>
89
99
  {#each tabs as t (t.id)}
90
100
  <button
91
101
  type="button"
102
+ {...t.attrs}
92
103
  role="tab"
93
104
  id="{baseId}-tab-{t.id}"
94
105
  aria-selected={value === t.id}
@@ -6,9 +6,16 @@ export interface TabItem {
6
6
  disabled?: boolean;
7
7
  /** Trailing count badge. */
8
8
  count?: number | string;
9
+ /**
10
+ * Extra attributes for this tab's button — `data-*`, `title`…
11
+ * The tab's own semantics (`role`, `aria-*`, `id`, `class`, `disabled`,
12
+ * `tabindex`, `onclick`) are applied after and win.
13
+ */
14
+ attrs?: import('svelte/elements').HTMLButtonAttributes;
9
15
  }
10
16
  import type { Snippet } from 'svelte';
11
- type $$ComponentProps = {
17
+ import type { HTMLAttributes } from 'svelte/elements';
18
+ type Own = {
12
19
  tabs: TabItem[];
13
20
  value?: string;
14
21
  label?: string;
@@ -18,6 +25,7 @@ type $$ComponentProps = {
18
25
  panelClass?: string;
19
26
  panelPadding?: 'none' | 'sm' | 'md';
20
27
  };
28
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
21
29
  declare const Tabs: import("svelte").Component<$$ComponentProps, {}, "value">;
22
30
  type Tabs = ReturnType<typeof Tabs>;
23
31
  export default Tabs;
@@ -6,6 +6,7 @@
6
6
  // resolved once at :root and would not re-scope). The root default theme
7
7
  // has no [data-theme] block, so its swatch carries the :root values.
8
8
  import type { ComponentProps } from 'svelte';
9
+ import type { AnchorAttributes } from '../../anchor';
9
10
  import Popover from './Popover.svelte';
10
11
  import { type ThemeDef, theme } from '../../stores/theme.svelte';
11
12
  import { AUTO_THEME } from '../../theme-mode';
@@ -34,7 +35,10 @@
34
35
  darkLabel = 'Dark',
35
36
  class: klass = '',
36
37
  style: styleProp = '',
37
- }: TriggerChrome & {
38
+ ...rest
39
+ }: AnchorAttributes & TriggerChrome & Own = $props();
40
+
41
+ type Own = {
38
42
  /** Offer an "auto" row that follows `prefers-color-scheme`, remembering
39
43
  * one light and one dark theme. */
40
44
  auto?: boolean;
@@ -44,7 +48,7 @@
44
48
  darkLabel?: string;
45
49
  class?: string;
46
50
  style?: string;
47
- } = $props();
51
+ };
48
52
 
49
53
  let hovered = $state<ThemeDef | null>(null);
50
54
  let hoveredAuto = $state(false);
@@ -74,6 +78,7 @@
74
78
  {/snippet}
75
79
 
76
80
  <Popover
81
+ {...rest}
77
82
  label={title}
78
83
  {placement}
79
84
  {box}
@@ -1,7 +1,8 @@
1
1
  import type { ComponentProps } from 'svelte';
2
+ import type { AnchorAttributes } from '../../anchor';
2
3
  import Popover from './Popover.svelte';
3
4
  type TriggerChrome = Pick<ComponentProps<typeof Popover>, 'variant' | 'tone' | 'size' | 'box' | 'pill' | 'control' | 'block' | 'bare' | 'hitArea' | 'placement' | 'disabled'>;
4
- type $$ComponentProps = TriggerChrome & {
5
+ type Own = {
5
6
  /** Offer an "auto" row that follows `prefers-color-scheme`, remembering
6
7
  * one light and one dark theme. */
7
8
  auto?: boolean;
@@ -12,6 +13,7 @@ type $$ComponentProps = TriggerChrome & {
12
13
  class?: string;
13
14
  style?: string;
14
15
  };
16
+ type $$ComponentProps = AnchorAttributes & TriggerChrome & Own;
15
17
  declare const ThemePicker: import("svelte").Component<$$ComponentProps, {}, "">;
16
18
  type ThemePicker = ReturnType<typeof ThemePicker>;
17
19
  export default ThemePicker;
@@ -14,6 +14,7 @@
14
14
  // and forwards onchange/onsubmit. Everything project-specific is INJECTED via
15
15
  // `schema` (with per-field async `ValueProvider`s).
16
16
  // ───────────────────────────────────────────────────────────────────────
17
+ import type { AnchorAttributes } from '../../anchor';
17
18
  import Badge from '../atoms/Badge.svelte';
18
19
  import FilterInput from '../molecules/FilterInput.svelte';
19
20
  import type { Query } from '../../query/ast';
@@ -35,7 +36,10 @@
35
36
  onsubmit,
36
37
  class: klass = '',
37
38
  style: styleProp = '',
38
- }: {
39
+ ...rest
40
+ }: AnchorAttributes & Own = $props();
41
+
42
+ type Own = {
39
43
  schema: Schema;
40
44
  /** The raw textual query (two-way bindable). */
41
45
  value?: string;
@@ -61,7 +65,7 @@
61
65
  onsubmit?: (value: string) => void;
62
66
  class?: string;
63
67
  style?: string;
64
- } = $props();
68
+ };
65
69
 
66
70
  function labelFor(fieldName: string): string {
67
71
  return findField(schema, fieldName)?.label ?? fieldName;
@@ -69,6 +73,7 @@
69
73
  </script>
70
74
 
71
75
  <FilterInput
76
+ {...rest}
72
77
  {schema}
73
78
  bind:value
74
79
  {placeholder}
@@ -1,7 +1,8 @@
1
1
  import type { ControlSize } from '../../size';
2
+ import type { AnchorAttributes } from '../../anchor';
2
3
  import type { Query } from '../../query/ast';
3
4
  import { type Schema } from '../../query/schema';
4
- type $$ComponentProps = {
5
+ type Own = {
5
6
  schema: Schema;
6
7
  /** The raw textual query (two-way bindable). */
7
8
  value?: string;
@@ -28,6 +29,7 @@ type $$ComponentProps = {
28
29
  class?: string;
29
30
  style?: string;
30
31
  };
32
+ type $$ComponentProps = AnchorAttributes & Own;
31
33
  declare const FilterSearchBar: import("svelte").Component<$$ComponentProps, {}, "value">;
32
34
  type FilterSearchBar = ReturnType<typeof FilterSearchBar>;
33
35
  export default FilterSearchBar;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export type { AnchorAttributes } from './anchor';
1
2
  export { artworkGradient, artworkHue, initials } from './artwork';
2
3
  export { autoresize } from './autoresize';
3
4
  export { copyToClipboard } from './clipboard';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.53.0",
3
+ "version": "0.55.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",
@@ -73,6 +73,7 @@
73
73
  "@sveltejs/kit": "^2.22.0",
74
74
  "@sveltejs/package": "^2.3.0",
75
75
  "@sveltejs/vite-plugin-svelte": "^6.0.0",
76
+ "@types/jsdom": "^30.0.0",
76
77
  "@types/node": "^25.9.3",
77
78
  "esbuild": "^0.28.2",
78
79
  "esbuild-svelte": "^0.9.5",