@lilydesignsystem/svelte-headless 0.4.0 → 0.5.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,21 @@
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.5.0 — 2026-10-14 (scrollspy, link counts)
7
+
8
+ - `ContentsNav` gains scrollspy (`spy`): the link of the section in view gets `aria-current` and `data-active`. Props `spy`, `offset`, `root`, `current`, bindable `active`, `onActiveChange`.
9
+ - `ContentsLink` gains `count` and `countLabel`: a visible, localised count inside the link, with `data-count` (and `data-complete` at 0). The Svelte `ContentsLink` is now a real `<a>` (it was a placeholder `<div>`).
10
+
11
+ ## Unreleased — 2026-10-11 (labels and contracts)
12
+
13
+ `PinInputDiv` takes `digitLabel` and no longer names its boxes in English; `FiveStarRatingPicker` takes `starLabel`; `FiveFaceRatingPicker` / `FiveFaceRatingView` default their labels to the numbers. `Tag` is not a live region unless `live` is set. The form-reset fixes listed above now also reach `components/`, which the build publishes. See the repository CHANGELOG (2026-10-11).
14
+
15
+ ## Unreleased — 2026-10-11
16
+
17
+ Fixed: `FiveStarRatingPicker`, `FiveFaceRatingPicker`, `NetPromoterScorePicker` and `PinInputDiv` restore their initial value when their `<form>` is reset (one tick after `reset`, unless it is
18
+ cancelled); before, a reset changed the inputs without telling the component, so its value disagreed with what the
19
+ form showed or submitted. `PinInputDiv` also now shows its initial `value` instead of overwriting it with an empty string on mount.
20
+
6
21
  ## 0.4.0 — 2026-10-06
7
22
 
8
23
  **16 new components (catalog 555 → 571); no breaking changes.**
@@ -1,18 +1,52 @@
1
1
  <script lang="ts">
2
+ // ContentsLink component
3
+ //
4
+ // One table of contents link: an <a href> to a page section, with an optional count (for example
5
+ // of tasks to do in that section).
6
+ //
7
+ // Props:
8
+ // className — string, optional. CSS class name.
9
+ // href — string, default "#". The link target.
10
+ // label — string, optional. aria-label override. It replaces the link's whole accessible name, so
11
+ // if you also show a count, include the count in it.
12
+ // count — number, optional. Sets data-count on the link (and data-complete when 0), as CSS hooks.
13
+ // countLabel — string, optional. Visible, localised count text (e.g. "3 to do"), rendered inside
14
+ // the link after the text so the link's accessible name includes it. No default, no English.
15
+ // children — Snippet, required. The link text.
16
+ // ...restProps — additional HTML attributes spread onto the <a>.
17
+ //
18
+ // Accessibility:
19
+ // - The count is text inside the link, never colour alone (WCAG 1.4.1).
20
+
2
21
  import type { Snippet } from "svelte";
22
+
3
23
  let {
4
24
  class: className = "",
5
25
  label = undefined,
26
+ href = "#",
27
+ count = undefined,
28
+ countLabel = undefined,
6
29
  children,
7
30
  ...restProps
8
31
  }: {
9
32
  label?: string;
33
+ href?: string;
34
+ count?: number;
35
+ countLabel?: string;
10
36
  children: Snippet;
11
37
  [key: string]: unknown;
12
38
  } = $props();
13
39
  </script>
14
40
 
15
41
  <!-- ContentsLink.svelte -->
16
- <div class={`contents-link ${className}`} aria-label={label} {...restProps}>
42
+ <a
43
+ class={`contents-link ${className}`}
44
+ {href}
45
+ aria-label={label}
46
+ data-count={count}
47
+ data-complete={count === 0 ? "" : undefined}
48
+ {...restProps}
49
+ >
17
50
  {@render children?.()}
18
- </div>
51
+ {#if countLabel}<span class="contents-link-count">{countLabel}</span>{/if}
52
+ </a>
@@ -1,6 +1,9 @@
1
1
  import type { Snippet } from "svelte";
2
2
  type $$ComponentProps = {
3
3
  label?: string;
4
+ href?: string;
5
+ count?: number;
6
+ countLabel?: string;
4
7
  children: Snippet;
5
8
  [key: string]: unknown;
6
9
  };
@@ -10,8 +10,21 @@
10
10
  // className — string, optional. CSS class name.
11
11
  // label — string, required. Accessible name for the navigation landmark (e.g., "Contents").
12
12
  // children — Snippet, required. Typically a ContentsList component.
13
+ // spy — boolean, default false. Scrollspy: mark the link of the section currently in view.
14
+ // offset — number, default 0. Scrollspy activation line, in px below the top of `root`
15
+ // (set it to a fixed header's height, matching the targets' scroll-margin-top).
16
+ // root — HTMLElement | null, default null (the viewport). A scrolling element other than the viewport.
17
+ // current — string, default "true". The aria-current token set on the active link.
18
+ // active — string, bindable. The id of the section currently in view (spy only).
19
+ // onActiveChange — (id: string) => void, optional. Called when the active section changes.
13
20
  // ...restProps — additional HTML attributes spread onto the <nav>.
14
21
  //
22
+ // Scrollspy (spy): the links are the consumer's own `<a href="#id">` elements inside the nav. The
23
+ // active section is the last one whose top has passed the activation line; at the bottom of the
24
+ // scroll range the last section is active; before the first section, the first. The active link gets
25
+ // aria-current={current} and data-active; its ancestor <li>s get data-descendant-active. A click makes
26
+ // its link active at once and holds it until the scroll settles. Lily never animates or moves focus.
27
+ //
15
28
  // Syntax:
16
29
  // <ContentsNav label="Contents">
17
30
  // <ContentsList>
@@ -53,12 +66,191 @@
53
66
  class: className = "",
54
67
  label,
55
68
  children,
69
+ spy = false,
70
+ offset = 0,
71
+ root = null,
72
+ current = "true",
73
+ active = $bindable(""),
74
+ onActiveChange,
56
75
  ...restProps
57
- }: { label: string; children: Snippet; [key: string]: unknown } = $props();
76
+ }: {
77
+ label: string;
78
+ children: Snippet;
79
+ spy?: boolean;
80
+ offset?: number;
81
+ root?: HTMLElement | null;
82
+ current?: string;
83
+ active?: string;
84
+ onActiveChange?: (id: string) => void;
85
+ [key: string]: unknown;
86
+ } = $props();
87
+
88
+ let navEl: HTMLElement | undefined = $state();
89
+
90
+ $effect(() => {
91
+ const nav = navEl;
92
+ if (!spy || !nav || typeof window === "undefined") return;
93
+ const spyOffset = offset;
94
+ const spyRoot = root;
95
+ const token = current;
96
+ let locked = false;
97
+ let lockTimer: ReturnType<typeof setTimeout> | undefined;
98
+ let frame = 0;
99
+ let last = "";
100
+ // Until the reader first scrolls, the URL hash decides (links may render after mount).
101
+ let hashPending = false;
102
+
103
+ const scroller: Window | HTMLElement = spyRoot ?? window;
104
+
105
+ function mark(link: HTMLAnchorElement | undefined): void {
106
+ for (const a of contentsLinks(nav!)) {
107
+ if (a === link) {
108
+ a.setAttribute("aria-current", token);
109
+ a.setAttribute("data-active", "");
110
+ } else {
111
+ a.removeAttribute("aria-current");
112
+ a.removeAttribute("data-active");
113
+ }
114
+ }
115
+ for (const li of nav!.querySelectorAll("li")) {
116
+ if (link && li.contains(link) && li.querySelector(":scope > a") !== link) {
117
+ li.setAttribute("data-descendant-active", "");
118
+ } else {
119
+ li.removeAttribute("data-descendant-active");
120
+ }
121
+ }
122
+ const id = link ? contentsLinkId(link) : "";
123
+ if (id !== last) {
124
+ last = id;
125
+ active = id;
126
+ if (id) onActiveChange?.(id);
127
+ }
128
+ }
129
+
130
+ function update(): void {
131
+ if (locked) return;
132
+ if (hashPending) {
133
+ const id = decodeURIComponent(window.location.hash.slice(1));
134
+ const hashed = id ? contentsLinks(nav!).find((a) => contentsLinkId(a) === id) : undefined;
135
+ if (hashed) {
136
+ mark(hashed);
137
+ return;
138
+ }
139
+ }
140
+ const links = contentsLinks(nav!);
141
+ const pairs = links
142
+ .map((a) => ({ a, el: contentsTarget(a, nav!.ownerDocument) }))
143
+ .filter((p): p is { a: HTMLAnchorElement; el: HTMLElement } => p.el !== null);
144
+ if (pairs.length === 0) {
145
+ mark(undefined);
146
+ return;
147
+ }
148
+ const base = spyRoot ? spyRoot.getBoundingClientRect().top : 0;
149
+ const tops = pairs.map((p) => p.el.getBoundingClientRect().top - base);
150
+ const atBottom = spyRoot
151
+ ? spyRoot.scrollTop + spyRoot.clientHeight >= spyRoot.scrollHeight - 1
152
+ : window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 1;
153
+ mark(pairs[activeSectionIndex(tops, spyOffset, atBottom)].a);
154
+ }
155
+
156
+ function schedule(): void {
157
+ if (frame) return;
158
+ frame = requestAnimationFrame(() => {
159
+ frame = 0;
160
+ update();
161
+ });
162
+ }
163
+
164
+ function onScroll(): void {
165
+ hashPending = false;
166
+ schedule();
167
+ }
168
+
169
+ function release(): void {
170
+ locked = false;
171
+ if (lockTimer) clearTimeout(lockTimer);
172
+ update();
173
+ }
174
+
175
+ function onClick(event: Event): void {
176
+ const link = (event.target as Element | null)?.closest?.("a");
177
+ if (!link || !nav!.contains(link) || !contentsTarget(link as HTMLAnchorElement, nav!.ownerDocument)) return;
178
+ // The link is current at once; observer-style updates wait until the scroll settles.
179
+ mark(link as HTMLAnchorElement);
180
+ locked = true;
181
+ if (lockTimer) clearTimeout(lockTimer);
182
+ lockTimer = setTimeout(release, 1000);
183
+ }
184
+
185
+ function onHashChange(): void {
186
+ const id = decodeURIComponent(window.location.hash.slice(1));
187
+ const link = contentsLinks(nav!).find((a) => contentsLinkId(a) === id);
188
+ if (link) mark(link);
189
+ }
190
+
191
+ scroller.addEventListener("scroll", onScroll, { passive: true });
192
+ scroller.addEventListener("scrollend", release);
193
+ window.addEventListener("resize", schedule);
194
+ window.addEventListener("hashchange", onHashChange);
195
+ nav.addEventListener("click", onClick);
196
+ // Links rendered or re-pointed after mount (a list built from data) are picked up too.
197
+ const mutations = typeof MutationObserver === "undefined" ? null : new MutationObserver(schedule);
198
+ mutations?.observe(nav, { childList: true, subtree: true, attributes: true, attributeFilter: ["href"] });
199
+
200
+ // The URL hash wins on load; otherwise the position decides.
201
+ const initial = decodeURIComponent(window.location.hash.slice(1));
202
+ hashPending = initial !== "";
203
+ const hashLink = initial ? contentsLinks(nav).find((a) => contentsLinkId(a) === initial) : undefined;
204
+ if (hashLink) mark(hashLink);
205
+ else update();
206
+
207
+ return () => {
208
+ scroller.removeEventListener("scroll", onScroll);
209
+ scroller.removeEventListener("scrollend", release);
210
+ window.removeEventListener("resize", schedule);
211
+ window.removeEventListener("hashchange", onHashChange);
212
+ nav.removeEventListener("click", onClick);
213
+ mutations?.disconnect();
214
+ if (frame) cancelAnimationFrame(frame);
215
+ if (lockTimer) clearTimeout(lockTimer);
216
+ };
217
+ });
218
+ </script>
219
+
220
+ <script lang="ts" module>
221
+ /**
222
+ * Which section is current: the last whose top has passed the activation line
223
+ * (`tops` are each section's top relative to the scroller; `offset` is the line).
224
+ * At the bottom of the scroll range the last section wins (a short last section
225
+ * never reaches the line); before the first section, the first.
226
+ */
227
+ export function activeSectionIndex(tops: number[], offset: number, atBottom: boolean): number {
228
+ if (tops.length === 0) return -1;
229
+ if (atBottom) return tops.length - 1;
230
+ let index = 0;
231
+ for (let i = 0; i < tops.length; i++) {
232
+ if (tops[i] <= offset + 1) index = i;
233
+ }
234
+ return index;
235
+ }
236
+
237
+ /** The in-page `<a href="#id">` links inside `nav`. */
238
+ export function contentsLinks(nav: Element): HTMLAnchorElement[] {
239
+ return Array.from(nav.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')).filter((a) => a.getAttribute("href")!.length > 1);
240
+ }
241
+
242
+ export function contentsLinkId(link: Element): string {
243
+ return decodeURIComponent((link.getAttribute("href") ?? "").slice(1));
244
+ }
245
+
246
+ export function contentsTarget(link: Element, doc: Document): HTMLElement | null {
247
+ return doc.getElementById(contentsLinkId(link));
248
+ }
58
249
  </script>
59
250
 
60
251
  <!-- ContentsNav.svelte -->
61
252
  <nav
253
+ bind:this={navEl}
62
254
  class={`contents-nav ${className}`}
63
255
  aria-label={label}
64
256
  {...restProps}
@@ -1,9 +1,26 @@
1
+ /**
2
+ * Which section is current: the last whose top has passed the activation line
3
+ * (`tops` are each section's top relative to the scroller; `offset` is the line).
4
+ * At the bottom of the scroll range the last section wins (a short last section
5
+ * never reaches the line); before the first section, the first.
6
+ */
7
+ export declare function activeSectionIndex(tops: number[], offset: number, atBottom: boolean): number;
8
+ /** The in-page `<a href="#id">` links inside `nav`. */
9
+ export declare function contentsLinks(nav: Element): HTMLAnchorElement[];
10
+ export declare function contentsLinkId(link: Element): string;
11
+ export declare function contentsTarget(link: Element, doc: Document): HTMLElement | null;
1
12
  import type { Snippet } from "svelte";
2
13
  type $$ComponentProps = {
3
14
  label: string;
4
15
  children: Snippet;
16
+ spy?: boolean;
17
+ offset?: number;
18
+ root?: HTMLElement | null;
19
+ current?: string;
20
+ active?: string;
21
+ onActiveChange?: (id: string) => void;
5
22
  [key: string]: unknown;
6
23
  };
7
- declare const ContentsNav: import("svelte").Component<$$ComponentProps, {}, "">;
24
+ declare const ContentsNav: import("svelte").Component<$$ComponentProps, {}, "active">;
8
25
  type ContentsNav = ReturnType<typeof ContentsNav>;
9
26
  export default ContentsNav;
@@ -2,13 +2,12 @@
2
2
  // DataTableTD component
3
3
  //
4
4
  // A single data cell within a DataTableRow. Renders as a <td> with
5
- // role="gridcell". Supports an active state for indicating the currently
6
- // focused or selected cell, communicated via aria-selected for screen
7
- // readers. Uses a roving tabindex pattern.
5
+ // role="gridcell". Supports an active state marking the grid's single roving tab
6
+ // stop (tabindex="0"); it does not set aria-selected.
8
7
  //
9
8
  // Props:
10
9
  // className — string, optional. CSS class name.
11
- // active — boolean, default false. Whether this cell is active/selected.
10
+ // active — boolean, default false. Whether this cell is the roving tab stop.
12
11
  // children — Snippet, required. Cell content.
13
12
  // ...restProps — additional HTML attributes spread onto the <td>.
14
13
  //
@@ -21,7 +20,7 @@
21
20
  //
22
21
  // Accessibility:
23
22
  // - role="gridcell" identifies the cell as part of an interactive grid
24
- // - aria-selected set to true when active; omitted otherwise
23
+ // - `active` is the roving tab stop only: it never sets aria-selected, because focus is not selection (a grid with real selection passes aria-selected itself)
25
24
  // - Roving tabindex: tabindex="0" when active, "-1" otherwise
26
25
  //
27
26
  // Claude rules:
@@ -39,7 +38,7 @@
39
38
  children,
40
39
  ...restProps
41
40
  }: {
42
- /** Whether this cell is active/selected. */
41
+ /** Whether this cell is the roving tab stop (tabindex="0"). Never sets aria-selected. */
43
42
  active?: boolean;
44
43
  /** Cell content. */
45
44
  children: Snippet;
@@ -51,7 +50,6 @@
51
50
  <td
52
51
  class={`data-table-td ${className}`}
53
52
  role="gridcell"
54
- aria-selected={active || undefined}
55
53
  tabindex={active ? 0 : -1}
56
54
  {...restProps}
57
55
  >
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from "svelte";
2
2
  type $$ComponentProps = {
3
- /** Whether this cell is active/selected. */
3
+ /** Whether this cell is the roving tab stop (tabindex="0"). Never sets aria-selected. */
4
4
  active?: boolean;
5
5
  /** Cell content. */
6
6
  children: Snippet;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from "svelte";
2
3
  // FiveFaceRatingPicker component
3
4
  //
4
5
  // A five-face rating input that lets users select a satisfaction level from 1 to 5
@@ -11,7 +12,7 @@
11
12
  // label — string, required. Accessible name for the rating group via aria-label.
12
13
  // value — number, default 0. Current rating (1-5, 0 = none); bindable via bind:value.
13
14
  // name — string, default "face-rating". Radio group name for form submission.
14
- // labels — string[], default ["Very bad", "Bad", "Okay", "Good", "Very good"]. Accessible labels for each face.
15
+ // labels — string[], default ["1", "2", "3", "4", "5"] (no English default; pass words, e.g. ["Very bad", …, "Very good"]). Accessible labels for each face.
15
16
  // disabled — boolean, default false. Whether the entire group is disabled.
16
17
  // ...restProps — additional HTML attributes spread onto the fieldset.
17
18
  //
@@ -53,7 +54,8 @@
53
54
  label,
54
55
  value = $bindable(0),
55
56
  name = "face-rating",
56
- labels = ["Very bad", "Bad", "Okay", "Good", "Very good"],
57
+ // The numbers, not English words: the words are the consumer's to supply.
58
+ labels = ["1", "2", "3", "4", "5"],
57
59
  disabled = false,
58
60
  ...restProps
59
61
  }: {
@@ -71,10 +73,38 @@
71
73
  } = $props();
72
74
 
73
75
  const faces = [1, 2, 3, 4, 5];
76
+
77
+ // Form reset: the radios use a one-way `checked`, which a reset clears
78
+ // without telling this component, so `value` would keep a rating the form no
79
+ // longer submits. Restore the initial value instead — one tick later, after
80
+ // the browser has reset the DOM, and not when the reset was cancelled.
81
+ let root: HTMLFieldSetElement | undefined = $state();
82
+ const initialValue = untrack(() => value);
83
+ $effect(() => {
84
+ const form = root?.form ?? root?.closest("form");
85
+ if (!form) return;
86
+ let timer: ReturnType<typeof setTimeout> | undefined;
87
+ const onReset = (event: Event) => {
88
+ clearTimeout(timer);
89
+ timer = setTimeout(() => {
90
+ if (event.defaultPrevented || !root) return;
91
+ value = initialValue;
92
+ for (const input of root.querySelectorAll<HTMLInputElement>('input[type="radio"]')) {
93
+ input.checked = input.value === String(initialValue);
94
+ }
95
+ }, 0);
96
+ };
97
+ form.addEventListener("reset", onReset);
98
+ return () => {
99
+ clearTimeout(timer);
100
+ form.removeEventListener("reset", onReset);
101
+ };
102
+ });
74
103
  </script>
75
104
 
76
105
  <!-- FiveFaceRatingPicker.svelte -->
77
106
  <fieldset
107
+ bind:this={root}
78
108
  class={`five-face-rating-picker ${className}`}
79
109
  role="radiogroup"
80
110
  aria-label={label}
@@ -10,7 +10,7 @@
10
10
  // className — string, optional. CSS class name.
11
11
  // value — number, required. Rating value (1-5) to display.
12
12
  // label — string, required. Accessible description via aria-label.
13
- // labels — string[], default ["Very bad", "Bad", "Okay", "Good", "Very good"]. Text labels for each face.
13
+ // labels — string[], default ["1", "2", "3", "4", "5"] (no English default; pass words, e.g. ["Very bad", …, "Very good"]). Text labels for each face.
14
14
  // ...restProps — additional HTML attributes spread onto the span.
15
15
  //
16
16
  // Syntax:
@@ -46,7 +46,8 @@
46
46
  class: className = "",
47
47
  value,
48
48
  label,
49
- labels = ["Very bad", "Bad", "Okay", "Good", "Very good"],
49
+ // The numbers, not English words: the words are the consumer's to supply.
50
+ labels = ["1", "2", "3", "4", "5"],
50
51
  ...restProps
51
52
  }: {
52
53
  /** Rating value (1-5). */
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from "svelte";
2
3
  // FiveStarRatingPicker component
3
4
  //
4
5
  // A five-star rating input that lets users select a rating from 1 to 5 stars using
@@ -9,6 +10,7 @@
9
10
  // Props:
10
11
  // className — string, optional. CSS class name.
11
12
  // label — string, required. Accessible name for the rating group via aria-label.
13
+ // starLabel — (star: number) => string, optional. Each option's text; defaults to the number (no English).
12
14
  // value — number, default 0. Current rating (1-5, 0 = none); bindable via bind:value.
13
15
  // name — string, default "rating". Radio group name for form submission.
14
16
  // disabled — boolean, default false. Whether the entire group is disabled.
@@ -52,6 +54,7 @@
52
54
  label,
53
55
  value = $bindable(0),
54
56
  name = "rating",
57
+ starLabel,
55
58
  disabled = false,
56
59
  ...restProps
57
60
  }: {
@@ -61,16 +64,46 @@
61
64
  value?: number;
62
65
  /** Radio group name for form submission. */
63
66
  name?: string;
67
+ /** Each option's text, e.g. (n) => `${n} stars`. Defaults to the number alone: no English default. */
68
+ starLabel?: (star: number) => string;
64
69
  /** Whether the entire group is disabled. */
65
70
  disabled?: boolean;
66
71
  [key: string]: unknown;
67
72
  } = $props();
68
73
 
69
74
  const stars = [1, 2, 3, 4, 5];
75
+
76
+ // Form reset: the radios use a one-way `checked`, which a reset clears
77
+ // without telling this component, so `value` would keep a rating the form no
78
+ // longer submits. Restore the initial value instead — one tick later, after
79
+ // the browser has reset the DOM, and not when the reset was cancelled.
80
+ let root: HTMLFieldSetElement | undefined = $state();
81
+ const initialValue = untrack(() => value);
82
+ $effect(() => {
83
+ const form = root?.form ?? root?.closest("form");
84
+ if (!form) return;
85
+ let timer: ReturnType<typeof setTimeout> | undefined;
86
+ const onReset = (event: Event) => {
87
+ clearTimeout(timer);
88
+ timer = setTimeout(() => {
89
+ if (event.defaultPrevented || !root) return;
90
+ value = initialValue;
91
+ for (const input of root.querySelectorAll<HTMLInputElement>('input[type="radio"]')) {
92
+ input.checked = input.value === String(initialValue);
93
+ }
94
+ }, 0);
95
+ };
96
+ form.addEventListener("reset", onReset);
97
+ return () => {
98
+ clearTimeout(timer);
99
+ form.removeEventListener("reset", onReset);
100
+ };
101
+ });
70
102
  </script>
71
103
 
72
104
  <!-- FiveStarRatingPicker.svelte -->
73
105
  <fieldset
106
+ bind:this={root}
74
107
  class={`five-star-rating-picker ${className}`}
75
108
  role="radiogroup"
76
109
  aria-label={label}
@@ -86,8 +119,7 @@
86
119
  checked={value === star}
87
120
  onchange={() => (value = star)}
88
121
  />
89
- {star}
90
- {star === 1 ? "star" : "stars"}
122
+ {starLabel ? starLabel(star) : star}
91
123
  </label>
92
124
  {/each}
93
125
  </fieldset>
@@ -5,6 +5,8 @@ type $$ComponentProps = {
5
5
  value?: number;
6
6
  /** Radio group name for form submission. */
7
7
  name?: string;
8
+ /** Each option's text, e.g. (n) => `${n} stars`. Defaults to the number alone: no English default. */
9
+ starLabel?: (star: number) => string;
8
10
  /** Whether the entire group is disabled. */
9
11
  disabled?: boolean;
10
12
  [key: string]: unknown;
@@ -3,8 +3,8 @@
3
3
  //
4
4
  // A single cell within a GanttTableTr representing a time period in the
5
5
  // Gantt table grid. Renders as a <td> with role="gridcell". Supports an
6
- // active state to indicate the task spans this time period, communicated
7
- // via aria-selected for screen readers.
6
+ // active state that makes the cell a tab stop (tabindex="0"); it does
7
+ // not set aria-selected, which would announce focus as selection.
8
8
  //
9
9
  // Props:
10
10
  // className — string, optional. CSS class name.
@@ -31,7 +31,7 @@
31
31
  //
32
32
  // Accessibility:
33
33
  // - role="gridcell" identifies the cell as part of a grid
34
- // - aria-selected set to true when active; omitted otherwise
34
+ // - `active` is the roving tab stop only: it never sets aria-selected, because focus is not selection (a grid with real selection passes aria-selected itself)
35
35
  // - Roving tabindex: tabindex="0" when active, "-1" otherwise
36
36
  //
37
37
  // Internationalization:
@@ -65,7 +65,6 @@
65
65
  <td
66
66
  class={`gantt-table-td ${className}`}
67
67
  role="gridcell"
68
- aria-selected={active || undefined}
69
68
  tabindex={active ? 0 : -1}
70
69
  {...restProps}
71
70
  >
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // Props:
9
9
  // className — string, optional. CSS class name.
10
- // active — boolean, optional. Marks the cell as currently active/selected.
10
+ // active — boolean, optional. Marks the cell as the roving tab stop.
11
11
  // label — string, optional. Accessible name for the cell via aria-label.
12
12
  // children — Snippet, optional. Cell content such as task cards or text.
13
13
  // ...restProps — additional HTML attributes spread onto the <td>.
@@ -28,7 +28,7 @@
28
28
  //
29
29
  // Accessibility:
30
30
  // - role="gridcell" identifies the cell as part of a grid
31
- // - aria-selected indicates the active/selected state when active is true
31
+ // - `active` is the roving tab stop only: it never sets aria-selected, because focus is not selection (a grid with real selection passes aria-selected itself)
32
32
  // - tabindex enables roving tabindex (0 when active, -1 otherwise)
33
33
  // - aria-label optionally provides an accessible name for the cell
34
34
  //
@@ -52,7 +52,7 @@
52
52
  children,
53
53
  ...restProps
54
54
  }: {
55
- /** Marks the cell as currently active/selected. */
55
+ /** Marks the cell as the roving tab stop (tabindex="0"). Never sets aria-selected. */
56
56
  active?: boolean;
57
57
  /** Accessible name for the cell. */
58
58
  label?: string;
@@ -66,7 +66,6 @@
66
66
  <td
67
67
  class={`kanban-table-td ${className}`}
68
68
  role="gridcell"
69
- aria-selected={active || undefined}
70
69
  tabindex={active ? 0 : -1}
71
70
  aria-label={label}
72
71
  {...restProps}
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from "svelte";
2
2
  type $$ComponentProps = {
3
- /** Marks the cell as currently active/selected. */
3
+ /** Marks the cell as the roving tab stop (tabindex="0"). Never sets aria-selected. */
4
4
  active?: boolean;
5
5
  /** Accessible name for the cell. */
6
6
  label?: string;
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from "svelte";
2
3
  // NetPromoterScorePicker component
3
4
  //
4
5
  // A headless Net Promoter Score (NPS) rating input that renders a fieldset
@@ -69,10 +70,38 @@
69
70
  name?: string;
70
71
  [key: string]: unknown;
71
72
  } = $props();
73
+
74
+ // Form reset: the radios use a one-way `checked`, which a reset clears
75
+ // without telling this component, so `value` would keep a rating the form no
76
+ // longer submits. Restore the initial value instead — one tick later, after
77
+ // the browser has reset the DOM, and not when the reset was cancelled.
78
+ let root: HTMLFieldSetElement | undefined = $state();
79
+ const initialValue = untrack(() => value);
80
+ $effect(() => {
81
+ const form = root?.form ?? root?.closest("form");
82
+ if (!form) return;
83
+ let timer: ReturnType<typeof setTimeout> | undefined;
84
+ const onReset = (event: Event) => {
85
+ clearTimeout(timer);
86
+ timer = setTimeout(() => {
87
+ if (event.defaultPrevented || !root) return;
88
+ value = initialValue;
89
+ for (const input of root.querySelectorAll<HTMLInputElement>('input[type="radio"]')) {
90
+ input.checked = input.value === String(initialValue);
91
+ }
92
+ }, 0);
93
+ };
94
+ form.addEventListener("reset", onReset);
95
+ return () => {
96
+ clearTimeout(timer);
97
+ form.removeEventListener("reset", onReset);
98
+ };
99
+ });
72
100
  </script>
73
101
 
74
102
  <!-- NetPromoterScorePicker: a fieldset radiogroup with 11 radio buttons (0-10) -->
75
103
  <fieldset
104
+ bind:this={root}
76
105
  class={`net-promoter-score-picker ${className}`}
77
106
  role="radiogroup"
78
107
  aria-label={label}
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import { untrack } from "svelte";
2
3
  // PinInputDiv component
3
4
  //
4
5
  // A headless PIN input that renders a group of single-character numeric inputs
@@ -12,6 +13,7 @@
12
13
  // className — string, optional. CSS class name.
13
14
  // label — string, required. Accessible label for the group of inputs via aria-label.
14
15
  // length — number, default 4. Number of digit inputs to render.
16
+ // digitLabel — (position, length) => string, optional. Each input's accessible name; defaults to `label`.
15
17
  // value — string, default "". Bindable combined string of all entered digits.
16
18
  // disabled — boolean, default false. Whether all inputs are disabled.
17
19
  // ...restProps — additional HTML attributes spread onto the group <div>.
@@ -60,6 +62,7 @@
60
62
  class: className = "",
61
63
  label,
62
64
  length = 4,
65
+ digitLabel,
63
66
  value = $bindable(""),
64
67
  disabled = false,
65
68
  ...restProps
@@ -68,6 +71,11 @@
68
71
  label: string;
69
72
  /** Number of digit inputs to render */
70
73
  length?: number;
74
+ /**
75
+ * Each digit input's accessible name, e.g. (n, of) => `Digit ${n} of ${of}`.
76
+ * Without it each input is named by `label`: no English default.
77
+ */
78
+ digitLabel?: (position: number, length: number) => string;
71
79
  /** Combined string of all entered digits, bindable */
72
80
  value?: string;
73
81
  /** Whether all inputs are disabled */
@@ -79,7 +87,12 @@
79
87
  // Internal state: array of individual digit characters.
80
88
  // Note: the length prop is captured at initialization time for the initial
81
89
  // array size. This is intentional -- the pin length is expected to be static.
82
- let digits: string[] = $state(Array.from({ length }, () => ""));
90
+ // Seeded from the initial `value`: starting empty meant the effect below
91
+ // overwrote a bound starting value with "" on mount.
92
+ const initialDigits = untrack(() =>
93
+ Array.from({ length }, (_, i) => (/^[0-9]$/.test(value[i] ?? "") ? value[i] : "")),
94
+ );
95
+ let digits: string[] = $state([...initialDigits]);
83
96
 
84
97
  // References to each input element for focus management
85
98
  let inputRefs: HTMLInputElement[] = $state([]);
@@ -93,6 +106,32 @@
93
106
  });
94
107
 
95
108
  // Handle input events on individual digit fields
109
+ // Form reset: the digit inputs use a one-way `value`, which a reset clears
110
+ // without telling this component, so `value` would keep digits the fields no
111
+ // longer show. Restore the initial digits instead — one tick later, after the
112
+ // browser has reset the DOM, and not when the reset was cancelled.
113
+ let root: HTMLDivElement | undefined = $state();
114
+ $effect(() => {
115
+ const form = root?.closest("form");
116
+ if (!form) return;
117
+ let timer: ReturnType<typeof setTimeout> | undefined;
118
+ const onReset = (event: Event) => {
119
+ clearTimeout(timer);
120
+ timer = setTimeout(() => {
121
+ if (event.defaultPrevented) return;
122
+ digits = [...initialDigits];
123
+ inputRefs.forEach((input, i) => {
124
+ if (input) input.value = initialDigits[i];
125
+ });
126
+ }, 0);
127
+ };
128
+ form.addEventListener("reset", onReset);
129
+ return () => {
130
+ clearTimeout(timer);
131
+ form.removeEventListener("reset", onReset);
132
+ };
133
+ });
134
+
96
135
  function handleInput(index: number, event: Event): void {
97
136
  const target = event.target as HTMLInputElement;
98
137
  const inputValue = target.value;
@@ -137,6 +176,7 @@
137
176
 
138
177
  <!-- PinInputDiv.svelte -->
139
178
  <div
179
+ bind:this={root}
140
180
  class={`pin-input-div ${className}`}
141
181
  role="group"
142
182
  aria-label={label}
@@ -148,7 +188,7 @@
148
188
  type="text"
149
189
  inputmode="numeric"
150
190
  maxlength="1"
151
- aria-label={`Digit ${index + 1} of ${length}`}
191
+ aria-label={digitLabel ? digitLabel(index + 1, length) : label}
152
192
  value={digits[index]}
153
193
  {disabled}
154
194
  oninput={(e) => handleInput(index, e)}
@@ -3,6 +3,11 @@ type $$ComponentProps = {
3
3
  label: string;
4
4
  /** Number of digit inputs to render */
5
5
  length?: number;
6
+ /**
7
+ * Each digit input's accessible name, e.g. (n, of) => `Digit ${n} of ${of}`.
8
+ * Without it each input is named by `label`: no English default.
9
+ */
10
+ digitLabel?: (position: number, length: number) => string;
6
11
  /** Combined string of all entered digits, bindable */
7
12
  value?: string;
8
13
  /** Whether all inputs are disabled */
@@ -47,11 +47,18 @@
47
47
  let {
48
48
  class: className = "",
49
49
  label,
50
+ live = false,
50
51
  children,
51
52
  ...restProps
52
53
  }: {
53
54
  /** Accessible label for screen readers */
54
55
  label: string;
56
+ /**
57
+ * Announce changes: a `role="status"` live region, for a tag whose text
58
+ * changes in place (a job's status). Off by default — a page of static
59
+ * tags should not be a page of live regions.
60
+ */
61
+ live?: boolean;
55
62
  /** The tag content (text, icon, etc.) */
56
63
  children: Snippet;
57
64
  /** Additional HTML attributes */
@@ -60,11 +67,16 @@
60
67
  </script>
61
68
 
62
69
  <!-- Tag.svelte -->
63
- <span
64
- class={`tag ${className}`}
65
- role="status"
66
- aria-label={label}
67
- {...restProps}
68
- >
69
- {@render children?.()}
70
- </span>
70
+ {#if live}
71
+ <span class={`tag ${className}`} role="status" aria-label={label} {...restProps}>
72
+ {@render children?.()}
73
+ </span>
74
+ {:else}
75
+ <!-- Not a live region: aria-label is not allowed on a plain <span>, so the
76
+ label is screen-reader text appended after the content (the themes hide
77
+ .screen-reader-span). The content stays exposed: it may hold a focusable
78
+ control, such as a remove button. -->
79
+ <span class={`tag ${className}`} {...restProps}>
80
+ {@render children?.()}{#if label}<span class="screen-reader-span">{label}</span>{/if}
81
+ </span>
82
+ {/if}
@@ -2,6 +2,12 @@ import type { Snippet } from "svelte";
2
2
  type $$ComponentProps = {
3
3
  /** Accessible label for screen readers */
4
4
  label: string;
5
+ /**
6
+ * Announce changes: a `role="status"` live region, for a tag whose text
7
+ * changes in place (a job's status). Off by default — a page of static
8
+ * tags should not be a page of live regions.
9
+ */
10
+ live?: boolean;
5
11
  /** The tag content (text, icon, etc.) */
6
12
  children: Snippet;
7
13
  /** Additional HTML attributes */
package/index.md CHANGED
@@ -85,7 +85,7 @@ A headless Svelte 5 component library with 490 components. All components are he
85
85
  - **data-table-td**: a data table interactive grid data cell for displaying and sorting tabular data
86
86
  - **date-field**: a structured field for entering date components
87
87
  - **date-input**: an input for entering a date value
88
- - **date-range**: a display of a start and end date range
88
+ - **date-range**: paired start and end date inputs
89
89
  - **date-time-now-input**: an input for entering a date and time and "now" button
90
90
  - **date-time-local-input**: an input for entering a date and time without time zone
91
91
  - **details**: a disclosure widget that shows and hides content
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lilydesignsystem/svelte-headless",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "engines": {
5
5
  "node": "=26"
6
6
  },
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "git+https://github.com/LilyDesignSystem/lily-design-system-svelte-headless.git"
17
+ "url": "git+https://github.com/LilyDesignSystem/lily-design-system.git",
18
+ "directory": "lily-design-system-svelte-headless"
18
19
  },
19
20
  "keywords": [
20
21
  "lily",