@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={`icon-button ${className}`}
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. It renders a <div> with
6
- // role="listbox" and manages focus movement between child option elements via
7
- // arrow keys. Used when users need to select one or more items from a visible
8
- // list, such as settings panels, filter selections, or multi-select interfaces.
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
- // <!-- Listbox with dynamic selection -->
29
- // <Listbox label="Select a color">
30
- // {#each colors as color}
31
- // <div role="option" tabindex="-1" aria-selected={selected === color}
32
- // onclick={() => selected = color}>{color}</div>
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: move focus to next option, wrapping from last to first
38
- // - ArrowUp: move focus to previous option, wrapping from first to last
39
- // - Home: move focus to the first option
40
- // - End: move focus to the last option
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
- // - Child elements should use role="option" and optionally aria-selected
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
- // - Arrow keys wrap around at boundaries
55
- // - Consumer handles selection state externally
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
- let listRef: HTMLElement | undefined = $state(undefined);
156
+ function options(): HTMLElement[] {
157
+ return ref ? Array.from(ref.querySelectorAll<HTMLElement>("[role='option']")) : [];
158
+ }
76
159
 
77
- function onkeydown(event: KeyboardEvent) {
78
- if (!listRef) return;
79
- const options = Array.from(
80
- listRef.querySelectorAll<HTMLElement>("[role='option']"),
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 = options.indexOf(current);
167
+ const index = opts.indexOf(current);
84
168
  switch (event.key) {
85
169
  case "ArrowDown": {
86
170
  event.preventDefault();
87
- const next = index < options.length - 1 ? index + 1 : 0;
88
- options[next]?.focus();
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 : options.length - 1;
94
- options[prev]?.focus();
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
- options[0]?.focus();
183
+ opts[0]?.focus();
100
184
  break;
101
185
  }
102
186
  case "End": {
103
187
  event.preventDefault();
104
- options[options.length - 1]?.focus();
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
- <div
113
- class={`listbox ${className}`}
288
+ <svelte:element
289
+ this={as}
290
+ class={`${baseClass} ${className}`}
114
291
  role="listbox"
115
292
  aria-label={label}
116
- bind:this={listRef}
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
- </div>
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.1.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": "^4.1.11",
41
+ "@vitest/ui": "^5.0.0",
42
42
  "jsdom": "^30.0.1",
43
43
  "svelte": "^5.57.0",
44
- "vitest": "^4.1.11"
44
+ "vite": "^8.0.0",
45
+ "vitest": "^5.0.0"
45
46
  },
46
47
  "dependencies": {
47
48
  "aria-query": "^5.3.2"