@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
|
|
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=
|
|
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.
|
|
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",
|