@dorsk/tsumikit 0.53.0 → 0.54.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
@@ -523,6 +523,14 @@ triggers, and `SegmentedControl`. Each renders an exact
523
523
  original ghost icon-button default. Use `control` on `Button` or `Popover` when
524
524
  the roomier shared `--control-height` composer contract is required.
525
525
 
526
+ `Toolbar` implements the keyboard contract its `role="toolbar"` announces: the
527
+ bar is a **single tab stop**, and `←`/`→` (plus `Home`/`End`) move between the
528
+ controls that are actually visible — collapsed `data-overflow` children drop out
529
+ of the ring and the `…` trigger is its last stop. Controls that own the arrow
530
+ keys themselves (text inputs, `select`, sliders) keep them. Pass
531
+ `roving={false}` to opt out; that drops the role along with the promise, leaving
532
+ every child an ordinary tab stop.
533
+
526
534
  Button and Popover share the same semantic tones. For a confirmed positive
527
535
  action, `tone="success"` gives neutral controls a success tint; combine it with
528
536
  `variant="primary"` for a filled success action without consumer CSS.
@@ -610,7 +618,8 @@ label, copy, line numbers, wrap, scroll) and takes code three ways: plain
610
618
  `disabled` and native events pass through.
611
619
  - Visible `:focus-visible` rings; ARIA patterns implemented for switch, menu
612
620
  (`role=menu` + roving focus), tabs (`tablist` + arrow keys), radiogroup,
613
- dialog; polite live region for toasts; `.sr-only`.
621
+ toolbar (`role=toolbar` + roving tabindex), dialog; polite live region for
622
+ toasts; `.sr-only`.
614
623
  - Mobile-first: one `min-width: 640px` breakpoint, bottom-sheet→centered-modal,
615
624
  safe-area insets, 16px-min inputs (no iOS zoom).
616
625
  - A verified color-blind-safe theme (Okabe-Ito); meaning never relies on hue
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.53.0",
3
+ "version": "0.54.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",