@lilydesignsystem/svelte-headless 0.1.0 → 0.2.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/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/)
|
|
4
4
|
and the package follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## 0.2.0 — 2026-09-21
|
|
7
|
+
|
|
8
|
+
**`Listbox` and `IconButton` extended, additively, to support the
|
|
9
|
+
`*-helpers` catalog's icon-button-triggered pickers depending on them
|
|
10
|
+
instead of hand-rolling equivalent markup/keyboard logic.** Both
|
|
11
|
+
components had zero consumers elsewhere in this 491-component catalog
|
|
12
|
+
(confirmed by search before changing either), so these are pure
|
|
13
|
+
additions with no risk to an existing consumer — full suite (4917
|
|
14
|
+
tests) still green.
|
|
15
|
+
|
|
16
|
+
- `IconButton` gains `baseClass` (default `"icon-button"`, unchanged)
|
|
17
|
+
— a consumer whose own contract requires an exact class (no extra
|
|
18
|
+
`icon-button` token) sets `baseClass` instead of layering `class` on
|
|
19
|
+
top of it — and a bindable `ref` exposing the rendered `<button>` so
|
|
20
|
+
a consumer can call `.focus()` on it.
|
|
21
|
+
- `Listbox` gains `baseClass` (default `"listbox"`, unchanged, same
|
|
22
|
+
reasoning as `IconButton`'s) and `as` (default `"div"`, unchanged —
|
|
23
|
+
a consumer needing e.g. a `<ul>` root sets `as="ul"`), plus an opt-in
|
|
24
|
+
`navigation="active-descendant"` mode (default remains
|
|
25
|
+
`"roving-focus"`, byte-for-byte unchanged behaviour): the root holds
|
|
26
|
+
real focus and tracks a virtual cursor (`aria-activedescendant`,
|
|
27
|
+
bindable `activeIndex`) rather than moving DOM focus between
|
|
28
|
+
options, plus `clamp` (vs. wrap), `typeahead`, `pageSize` paging,
|
|
29
|
+
and `onActivate`/`onEscape`/`onTabOut` callbacks — the full
|
|
30
|
+
WAI-ARIA APG listbox keyboard contract the picker helpers already
|
|
31
|
+
implement by hand. A bindable `ref` prop exposes the rendered root
|
|
32
|
+
so a consumer can call `.focus()` on it.
|
|
33
|
+
|
|
6
34
|
## 0.1.0 — 2026-09-16
|
|
7
35
|
|
|
8
36
|
**Package renamed: `lily-design-system-svelte-headless` → `@lilydesignsystem/svelte-headless`.** npm scoped packages
|
|
@@ -8,13 +8,21 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Props:
|
|
10
10
|
// class — string, optional. CSS class appended to the base class.
|
|
11
|
+
// baseClass — string, default "icon-button". The base class token itself
|
|
12
|
+
// (not appended — replaces "icon-button" outright). A consumer that owns
|
|
13
|
+
// its own exact class-hook contract (e.g. a picker component whose spec
|
|
14
|
+
// requires `class="{helper}-button"` with no extra "icon-button" token)
|
|
15
|
+
// sets this instead of layering on top of the default.
|
|
11
16
|
// label — string, REQUIRED. aria-label for screen readers.
|
|
12
17
|
// type — "button" | "submit" | "reset", default "button".
|
|
13
18
|
// disabled — boolean, default false.
|
|
14
19
|
// pressed — boolean | undefined, default undefined. Toggle button state.
|
|
15
20
|
// onclick — (event: MouseEvent) => void, optional. Click handler.
|
|
21
|
+
// ref — HTMLButtonElement | undefined, bindable. The rendered button, for
|
|
22
|
+
// a consumer that needs to call .focus() on it.
|
|
16
23
|
// children — Snippet, required. The icon content (svg, glyph, emoji).
|
|
17
|
-
// ...restProps — additional HTML attributes spread onto the <button
|
|
24
|
+
// ...restProps — additional HTML attributes spread onto the <button>
|
|
25
|
+
// (e.g. aria-haspopup, aria-expanded, aria-controls, onkeydown).
|
|
18
26
|
//
|
|
19
27
|
// Syntax:
|
|
20
28
|
// <IconButton label="Close" onclick={handleClose}>×</IconButton>
|
|
@@ -45,14 +53,18 @@
|
|
|
45
53
|
|
|
46
54
|
let {
|
|
47
55
|
class: className = "",
|
|
56
|
+
baseClass = "icon-button",
|
|
48
57
|
label,
|
|
49
58
|
type = "button",
|
|
50
59
|
disabled = false,
|
|
51
60
|
pressed = undefined,
|
|
52
61
|
onclick = undefined,
|
|
62
|
+
ref = $bindable<HTMLButtonElement | undefined>(undefined),
|
|
53
63
|
children,
|
|
54
64
|
...restProps
|
|
55
65
|
}: {
|
|
66
|
+
/** Base class token, replacing "icon-button" outright (not appended). */
|
|
67
|
+
baseClass?: string;
|
|
56
68
|
/** Accessible label (REQUIRED for icon-only buttons) */
|
|
57
69
|
label: string;
|
|
58
70
|
/** HTML button type */
|
|
@@ -63,6 +75,8 @@
|
|
|
63
75
|
pressed?: boolean;
|
|
64
76
|
/** Click handler */
|
|
65
77
|
onclick?: (event: MouseEvent) => void;
|
|
78
|
+
/** The rendered button element. Bindable. */
|
|
79
|
+
ref?: HTMLButtonElement;
|
|
66
80
|
/** Icon content */
|
|
67
81
|
children: Snippet;
|
|
68
82
|
[key: string]: unknown;
|
|
@@ -71,12 +85,13 @@
|
|
|
71
85
|
|
|
72
86
|
<!-- IconButton.svelte -->
|
|
73
87
|
<button
|
|
74
|
-
class={
|
|
88
|
+
class={`${baseClass} ${className}`}
|
|
75
89
|
{type}
|
|
76
90
|
{disabled}
|
|
77
91
|
aria-label={label}
|
|
78
92
|
aria-pressed={pressed}
|
|
79
93
|
{onclick}
|
|
94
|
+
bind:this={ref}
|
|
80
95
|
{...restProps}
|
|
81
96
|
>
|
|
82
97
|
{@render children?.()}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Snippet } from "svelte";
|
|
2
2
|
type $$ComponentProps = {
|
|
3
|
+
/** Base class token, replacing "icon-button" outright (not appended). */
|
|
4
|
+
baseClass?: string;
|
|
3
5
|
/** Accessible label (REQUIRED for icon-only buttons) */
|
|
4
6
|
label: string;
|
|
5
7
|
/** HTML button type */
|
|
@@ -10,10 +12,12 @@ type $$ComponentProps = {
|
|
|
10
12
|
pressed?: boolean;
|
|
11
13
|
/** Click handler */
|
|
12
14
|
onclick?: (event: MouseEvent) => void;
|
|
15
|
+
/** The rendered button element. Bindable. */
|
|
16
|
+
ref?: HTMLButtonElement;
|
|
13
17
|
/** Icon content */
|
|
14
18
|
children: Snippet;
|
|
15
19
|
[key: string]: unknown;
|
|
16
20
|
};
|
|
17
|
-
declare const IconButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
21
|
+
declare const IconButton: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
18
22
|
type IconButton = ReturnType<typeof IconButton>;
|
|
19
23
|
export default IconButton;
|
|
@@ -2,15 +2,48 @@
|
|
|
2
2
|
// Listbox component
|
|
3
3
|
//
|
|
4
4
|
// A headless listbox that presents a list of selectable options using the ARIA
|
|
5
|
-
// listbox role with full keyboard navigation.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// listbox role with full keyboard navigation. Two navigation models, chosen via
|
|
6
|
+
// `navigation`:
|
|
7
|
+
//
|
|
8
|
+
// "roving-focus" (default, unchanged since this component's introduction) —
|
|
9
|
+
// real DOM focus moves between child `[role="option"]` elements; arrows wrap
|
|
10
|
+
// at the ends; no typeahead, paging, or activation callback.
|
|
11
|
+
//
|
|
12
|
+
// "active-descendant" (added for consumers needing the full WAI-ARIA APG
|
|
13
|
+
// listbox keyboard contract, e.g. an icon-button-triggered picker) — the
|
|
14
|
+
// listbox root itself holds real focus and tracks a virtual cursor via
|
|
15
|
+
// `aria-activedescendant`, mirroring `activeIndex` (bindable) so the
|
|
16
|
+
// consumer's own each-block can render `data-active`/`aria-selected` on the
|
|
17
|
+
// option at that index. Adds `clamp` (vs. wrap), typeahead, PageUp/PageDown
|
|
18
|
+
// paging, and `onActivate`/`onEscape`/`onTabOut` callbacks so the consumer
|
|
19
|
+
// decides what "select" / "cancel" / "leave" actually do.
|
|
20
|
+
//
|
|
21
|
+
// Used when users need to select one or more items from a visible list, such
|
|
22
|
+
// as settings panels, filter selections, or multi-select interfaces.
|
|
9
23
|
//
|
|
10
24
|
// Props:
|
|
11
25
|
// className — string, optional. CSS class name.
|
|
12
26
|
// label — string, required. Accessible name applied via aria-label.
|
|
13
27
|
// children — Snippet, required. Option elements (should have role="option" and tabindex="-1").
|
|
28
|
+
// navigation — "roving-focus" | "active-descendant", default "roving-focus".
|
|
29
|
+
// ref — HTMLElement | undefined, bindable. The rendered root, for a consumer
|
|
30
|
+
// that needs to call .focus() on it (active-descendant mode only).
|
|
31
|
+
// activeIndex — number, bindable, default -1. The virtual cursor position
|
|
32
|
+
// (active-descendant mode only); -1 means no option is active.
|
|
33
|
+
// clamp — boolean, default false. Arrow keys clamp at the ends instead of
|
|
34
|
+
// wrapping (active-descendant mode only; roving-focus always wraps).
|
|
35
|
+
// typeahead — boolean, default false. Printable characters move the cursor
|
|
36
|
+
// to the next option whose text starts with the typed buffer, cycling on a
|
|
37
|
+
// repeated character (active-descendant mode only).
|
|
38
|
+
// pageSize — number, default 10. PageUp/PageDown move the cursor by this
|
|
39
|
+
// many options, clamped (active-descendant mode only).
|
|
40
|
+
// onActivate — (index: number) => void, optional. Enter/Space on the active
|
|
41
|
+
// option (active-descendant mode only).
|
|
42
|
+
// onEscape — () => void, optional. Escape pressed (active-descendant mode only).
|
|
43
|
+
// onTabOut — (event: KeyboardEvent) => void, optional. Tab pressed, called
|
|
44
|
+
// BEFORE the browser processes the key (not prevented) so the consumer can
|
|
45
|
+
// move focus first — e.g. to the trigger button — before Tab's default
|
|
46
|
+
// action computes the next stop from wherever focus ends up (active-descendant mode only).
|
|
14
47
|
// ...restProps — additional HTML attributes spread onto the <div>.
|
|
15
48
|
//
|
|
16
49
|
// Syntax:
|
|
@@ -19,30 +52,40 @@
|
|
|
19
52
|
// </Listbox>
|
|
20
53
|
//
|
|
21
54
|
// Examples:
|
|
22
|
-
// <!-- Basic listbox with static options -->
|
|
55
|
+
// <!-- Basic listbox with static options (roving-focus, unchanged default) -->
|
|
23
56
|
// <Listbox label="Fruits">
|
|
24
57
|
// <div role="option" tabindex="-1">Apple</div>
|
|
25
58
|
// <div role="option" tabindex="-1">Banana</div>
|
|
26
59
|
// </Listbox>
|
|
27
60
|
//
|
|
28
|
-
// <!--
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
61
|
+
// <!-- active-descendant mode: consumer owns rendering each option's
|
|
62
|
+
// id/aria-selected/data-active from the bound activeIndex -->
|
|
63
|
+
// <Listbox
|
|
64
|
+
// label="Fruits"
|
|
65
|
+
// navigation="active-descendant"
|
|
66
|
+
// clamp
|
|
67
|
+
// bind:activeIndex
|
|
68
|
+
// onActivate={(i) => choose(fruits[i])}
|
|
69
|
+
// >
|
|
70
|
+
// {#each fruits as fruit, i}
|
|
71
|
+
// <div role="option" id={`fruit-${i}`} aria-selected={i === activeIndex}
|
|
72
|
+
// data-active={i === activeIndex ? "" : undefined}>{fruit}</div>
|
|
33
73
|
// {/each}
|
|
34
74
|
// </Listbox>
|
|
35
75
|
//
|
|
36
76
|
// Keyboard:
|
|
37
|
-
// - ArrowDown
|
|
38
|
-
// - ArrowUp
|
|
39
|
-
//
|
|
40
|
-
//
|
|
77
|
+
// roving-focus: ArrowDown/ArrowUp move focus and wrap; Home/End jump.
|
|
78
|
+
// active-descendant: ArrowDown/ArrowUp move the cursor (wrap unless `clamp`);
|
|
79
|
+
// Home/End jump; PageUp/PageDown move by `pageSize` (clamped); typeahead
|
|
80
|
+
// when `typeahead` is set; Enter/Space calls `onActivate`; Escape calls
|
|
81
|
+
// `onEscape`; Tab calls `onTabOut` without being prevented.
|
|
41
82
|
//
|
|
42
83
|
// Accessibility:
|
|
43
84
|
// - role="listbox" identifies the container as a listbox widget
|
|
44
85
|
// - aria-label provides an accessible name describing the listbox purpose
|
|
45
|
-
// -
|
|
86
|
+
// - roving-focus: child elements should use role="option" and optionally aria-selected
|
|
87
|
+
// - active-descendant: the root carries aria-activedescendant and tabindex="-1";
|
|
88
|
+
// the consumer's option elements carry the matching id
|
|
46
89
|
//
|
|
47
90
|
// Internationalization:
|
|
48
91
|
// - The label prop accepts any translated string
|
|
@@ -51,8 +94,12 @@
|
|
|
51
94
|
//
|
|
52
95
|
// Claude rules:
|
|
53
96
|
// - Headless: no CSS, no styles — consumer provides all styling
|
|
54
|
-
// -
|
|
55
|
-
//
|
|
97
|
+
// - roving-focus (default): arrow keys wrap around at boundaries, consumer
|
|
98
|
+
// handles selection state externally — unchanged from this component's
|
|
99
|
+
// original behaviour, so existing consumers of the default mode see no
|
|
100
|
+
// difference.
|
|
101
|
+
// - active-descendant: opt-in via `navigation="active-descendant"`; every
|
|
102
|
+
// new prop is inert unless that mode is selected.
|
|
56
103
|
//
|
|
57
104
|
// References:
|
|
58
105
|
// - WAI-ARIA Listbox Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
|
|
@@ -61,61 +108,193 @@
|
|
|
61
108
|
|
|
62
109
|
let {
|
|
63
110
|
class: className = "",
|
|
111
|
+
baseClass = "listbox",
|
|
112
|
+
as = "div",
|
|
64
113
|
label,
|
|
65
114
|
children,
|
|
115
|
+
navigation = "roving-focus",
|
|
116
|
+
ref = $bindable<HTMLElement | undefined>(undefined),
|
|
117
|
+
activeIndex = $bindable(-1),
|
|
118
|
+
clamp = false,
|
|
119
|
+
typeahead = false,
|
|
120
|
+
pageSize = 10,
|
|
121
|
+
onActivate,
|
|
122
|
+
onEscape,
|
|
123
|
+
onTabOut,
|
|
66
124
|
...restProps
|
|
67
125
|
}: {
|
|
126
|
+
/** Base class token, replacing "listbox" outright (not appended). */
|
|
127
|
+
baseClass?: string;
|
|
128
|
+
/** Root element tag. Default "div" (unchanged). A consumer whose spec
|
|
129
|
+
* requires e.g. a `<ul>` root sets `as="ul"`. */
|
|
130
|
+
as?: string;
|
|
68
131
|
/** Accessible label. */
|
|
69
132
|
label: string;
|
|
70
133
|
/** Option elements. */
|
|
71
134
|
children: Snippet;
|
|
135
|
+
/** Navigation/focus model. Default "roving-focus" (unchanged legacy behaviour). */
|
|
136
|
+
navigation?: "roving-focus" | "active-descendant";
|
|
137
|
+
/** The rendered root element. Bindable. */
|
|
138
|
+
ref?: HTMLElement;
|
|
139
|
+
/** Virtual cursor position (active-descendant mode). Bindable, -1 = none. */
|
|
140
|
+
activeIndex?: number;
|
|
141
|
+
/** Arrow keys clamp instead of wrap (active-descendant mode). */
|
|
142
|
+
clamp?: boolean;
|
|
143
|
+
/** Printable-character typeahead (active-descendant mode). */
|
|
144
|
+
typeahead?: boolean;
|
|
145
|
+
/** PageUp/PageDown step size (active-descendant mode). */
|
|
146
|
+
pageSize?: number;
|
|
147
|
+
/** Enter/Space on the active option (active-descendant mode). */
|
|
148
|
+
onActivate?: (index: number) => void;
|
|
149
|
+
/** Escape pressed (active-descendant mode). */
|
|
150
|
+
onEscape?: () => void;
|
|
151
|
+
/** Tab pressed, called before the key is processed (active-descendant mode). */
|
|
152
|
+
onTabOut?: (event: KeyboardEvent) => void;
|
|
72
153
|
[key: string]: unknown;
|
|
73
154
|
} = $props();
|
|
74
155
|
|
|
75
|
-
|
|
156
|
+
function options(): HTMLElement[] {
|
|
157
|
+
return ref ? Array.from(ref.querySelectorAll<HTMLElement>("[role='option']")) : [];
|
|
158
|
+
}
|
|
76
159
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
160
|
+
// ---------------------------------------------------------------
|
|
161
|
+
// roving-focus (default, unchanged): real DOM focus per option, wraps.
|
|
162
|
+
// ---------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function onRovingFocusKeydown(event: KeyboardEvent) {
|
|
165
|
+
const opts = options();
|
|
82
166
|
const current = document.activeElement as HTMLElement;
|
|
83
|
-
const index =
|
|
167
|
+
const index = opts.indexOf(current);
|
|
84
168
|
switch (event.key) {
|
|
85
169
|
case "ArrowDown": {
|
|
86
170
|
event.preventDefault();
|
|
87
|
-
const next = index <
|
|
88
|
-
|
|
171
|
+
const next = index < opts.length - 1 ? index + 1 : 0;
|
|
172
|
+
opts[next]?.focus();
|
|
89
173
|
break;
|
|
90
174
|
}
|
|
91
175
|
case "ArrowUp": {
|
|
92
176
|
event.preventDefault();
|
|
93
|
-
const prev = index > 0 ? index - 1 :
|
|
94
|
-
|
|
177
|
+
const prev = index > 0 ? index - 1 : opts.length - 1;
|
|
178
|
+
opts[prev]?.focus();
|
|
95
179
|
break;
|
|
96
180
|
}
|
|
97
181
|
case "Home": {
|
|
98
182
|
event.preventDefault();
|
|
99
|
-
|
|
183
|
+
opts[0]?.focus();
|
|
100
184
|
break;
|
|
101
185
|
}
|
|
102
186
|
case "End": {
|
|
103
187
|
event.preventDefault();
|
|
104
|
-
|
|
188
|
+
opts[opts.length - 1]?.focus();
|
|
105
189
|
break;
|
|
106
190
|
}
|
|
107
191
|
}
|
|
108
192
|
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------
|
|
195
|
+
// active-descendant (opt-in): virtual cursor, clamp/wrap, typeahead, paging.
|
|
196
|
+
// ---------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
let typeaheadBuffer = "";
|
|
199
|
+
let typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
|
|
200
|
+
|
|
201
|
+
function moveActive(delta: number) {
|
|
202
|
+
const count = options().length;
|
|
203
|
+
if (count === 0) return;
|
|
204
|
+
const next = activeIndex + delta;
|
|
205
|
+
activeIndex = clamp ? Math.min(Math.max(next, 0), count - 1) : ((next % count) + count) % count;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function runTypeahead(char: string) {
|
|
209
|
+
const opts = options();
|
|
210
|
+
if (opts.length === 0) return;
|
|
211
|
+
const lower = char.toLowerCase();
|
|
212
|
+
const sameCharRun = typeaheadBuffer === "" || [...typeaheadBuffer].every((c) => c === lower);
|
|
213
|
+
typeaheadBuffer += lower;
|
|
214
|
+
clearTimeout(typeaheadTimer);
|
|
215
|
+
typeaheadTimer = setTimeout(() => (typeaheadBuffer = ""), 500);
|
|
216
|
+
const query = sameCharRun ? lower : typeaheadBuffer;
|
|
217
|
+
const anchor = activeIndex < 0 ? 0 : activeIndex;
|
|
218
|
+
const start = sameCharRun ? anchor + 1 : anchor;
|
|
219
|
+
for (let n = 0; n < opts.length; n++) {
|
|
220
|
+
const i = (start + n) % opts.length;
|
|
221
|
+
if ((opts[i].textContent ?? "").trim().toLowerCase().startsWith(query)) {
|
|
222
|
+
activeIndex = i;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function onActiveDescendantKeydown(event: KeyboardEvent) {
|
|
229
|
+
switch (event.key) {
|
|
230
|
+
case "ArrowDown":
|
|
231
|
+
event.preventDefault();
|
|
232
|
+
moveActive(1);
|
|
233
|
+
break;
|
|
234
|
+
case "ArrowUp":
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
moveActive(-1);
|
|
237
|
+
break;
|
|
238
|
+
case "Home":
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
activeIndex = options().length ? 0 : -1;
|
|
241
|
+
break;
|
|
242
|
+
case "End":
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
activeIndex = options().length - 1;
|
|
245
|
+
break;
|
|
246
|
+
case "PageDown":
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
moveActive(pageSize);
|
|
249
|
+
break;
|
|
250
|
+
case "PageUp":
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
moveActive(-pageSize);
|
|
253
|
+
break;
|
|
254
|
+
case "Enter":
|
|
255
|
+
case " ":
|
|
256
|
+
event.preventDefault();
|
|
257
|
+
if (activeIndex >= 0) onActivate?.(activeIndex);
|
|
258
|
+
break;
|
|
259
|
+
case "Escape":
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
onEscape?.();
|
|
262
|
+
break;
|
|
263
|
+
case "Tab":
|
|
264
|
+
// Not prevented: the consumer's onTabOut (e.g. moving focus to a
|
|
265
|
+
// trigger button) runs first, so the browser's default Tab
|
|
266
|
+
// proceeds from wherever focus ends up, not from this element.
|
|
267
|
+
onTabOut?.(event);
|
|
268
|
+
break;
|
|
269
|
+
default:
|
|
270
|
+
if (typeahead && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
271
|
+
runTypeahead(event.key);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function onkeydown(event: KeyboardEvent) {
|
|
277
|
+
if (navigation === "active-descendant") onActiveDescendantKeydown(event);
|
|
278
|
+
else onRovingFocusKeydown(event);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const activeId = $derived.by(() => {
|
|
282
|
+
if (navigation !== "active-descendant" || activeIndex < 0) return undefined;
|
|
283
|
+
return options()[activeIndex]?.id || undefined;
|
|
284
|
+
});
|
|
109
285
|
</script>
|
|
110
286
|
|
|
111
287
|
<!-- Listbox.svelte -->
|
|
112
|
-
<
|
|
113
|
-
|
|
288
|
+
<svelte:element
|
|
289
|
+
this={as}
|
|
290
|
+
class={`${baseClass} ${className}`}
|
|
114
291
|
role="listbox"
|
|
115
292
|
aria-label={label}
|
|
116
|
-
|
|
293
|
+
tabindex={navigation === "active-descendant" ? -1 : undefined}
|
|
294
|
+
aria-activedescendant={activeId}
|
|
295
|
+
bind:this={ref}
|
|
117
296
|
{onkeydown}
|
|
118
297
|
{...restProps}
|
|
119
298
|
>
|
|
120
299
|
{@render children?.()}
|
|
121
|
-
</
|
|
300
|
+
</svelte:element>
|
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import type { Snippet } from "svelte";
|
|
2
2
|
type $$ComponentProps = {
|
|
3
|
+
/** Base class token, replacing "listbox" outright (not appended). */
|
|
4
|
+
baseClass?: string;
|
|
5
|
+
/** Root element tag. Default "div" (unchanged). A consumer whose spec
|
|
6
|
+
* requires e.g. a `<ul>` root sets `as="ul"`. */
|
|
7
|
+
as?: string;
|
|
3
8
|
/** Accessible label. */
|
|
4
9
|
label: string;
|
|
5
10
|
/** Option elements. */
|
|
6
11
|
children: Snippet;
|
|
12
|
+
/** Navigation/focus model. Default "roving-focus" (unchanged legacy behaviour). */
|
|
13
|
+
navigation?: "roving-focus" | "active-descendant";
|
|
14
|
+
/** The rendered root element. Bindable. */
|
|
15
|
+
ref?: HTMLElement;
|
|
16
|
+
/** Virtual cursor position (active-descendant mode). Bindable, -1 = none. */
|
|
17
|
+
activeIndex?: number;
|
|
18
|
+
/** Arrow keys clamp instead of wrap (active-descendant mode). */
|
|
19
|
+
clamp?: boolean;
|
|
20
|
+
/** Printable-character typeahead (active-descendant mode). */
|
|
21
|
+
typeahead?: boolean;
|
|
22
|
+
/** PageUp/PageDown step size (active-descendant mode). */
|
|
23
|
+
pageSize?: number;
|
|
24
|
+
/** Enter/Space on the active option (active-descendant mode). */
|
|
25
|
+
onActivate?: (index: number) => void;
|
|
26
|
+
/** Escape pressed (active-descendant mode). */
|
|
27
|
+
onEscape?: () => void;
|
|
28
|
+
/** Tab pressed, called before the key is processed (active-descendant mode). */
|
|
29
|
+
onTabOut?: (event: KeyboardEvent) => void;
|
|
7
30
|
[key: string]: unknown;
|
|
8
31
|
};
|
|
9
|
-
declare const Listbox: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
32
|
+
declare const Listbox: import("svelte").Component<$$ComponentProps, {}, "ref" | "activeIndex">;
|
|
10
33
|
type Listbox = ReturnType<typeof Listbox>;
|
|
11
34
|
export default Listbox;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lilydesignsystem/svelte-headless",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": "=26"
|
|
6
6
|
},
|
|
@@ -38,10 +38,11 @@
|
|
|
38
38
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
39
39
|
"@testing-library/svelte": "^5.4.2",
|
|
40
40
|
"@testing-library/user-event": "^14.6.7",
|
|
41
|
-
"@vitest/ui": "^
|
|
41
|
+
"@vitest/ui": "^5.0.0",
|
|
42
42
|
"jsdom": "^30.0.1",
|
|
43
43
|
"svelte": "^5.57.0",
|
|
44
|
-
"
|
|
44
|
+
"vite": "^8.0.0",
|
|
45
|
+
"vitest": "^5.0.0"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
48
|
"aria-query": "^5.3.2"
|