@lilydesignsystem/web-components-theme-picker 0.1.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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `<lily-theme-picker>` — Lily Design System HTML helper.
3
+ *
4
+ * See `./spec/index.md` for the canonical contract. This file implements
5
+ * the custom-element class but does NOT register it. The `index.ts`
6
+ * barrel registers it on import.
7
+ *
8
+ * The control is an icon button that opens a dropdown listbox
9
+ * (WAI-ARIA APG listbox pattern). It is not a native `<select>`.
10
+ */
11
+ /**
12
+ * Default button icon: a bundled SVG (contrast/half-circle), not a
13
+ * Unicode character. Reversed 2026-09-16 from the font-dependent-glyph
14
+ * convention (was U+25D1 CIRCLE WITH RIGHT HALF BLACK, exported as
15
+ * `CIRCLE_WITH_RIGHT_HALF_BLACK` — removed, not renamed, since there is
16
+ * no longer a single swappable character value). A bundled outline SVG
17
+ * renders identically across every font stack and platform. `viewBox="0
18
+ * 0 16 16"`, stroke-based (`stroke-width="1.6"`, round caps/joins) to
19
+ * match the other four picker icons as one visual family. Override via
20
+ * `renderButtonContent()`, same as before.
21
+ */
22
+ /** Change-event detail dispatched on every applied theme. */
23
+ type ThemePickerChangeDetail = {
24
+ theme: string;
25
+ };
26
+ /** Mirrors the observed attributes / properties for typing convenience. */
27
+ type ThemePickerProps = {
28
+ label: string;
29
+ themesUrl: string;
30
+ themes: string[];
31
+ value?: string;
32
+ defaultValue?: string;
33
+ storageKey?: string;
34
+ detectFromSystem?: boolean;
35
+ name?: string;
36
+ extension?: string;
37
+ themeLabels?: Record<string, string>;
38
+ target?: HTMLElement | null;
39
+ class?: string;
40
+ };
41
+ /**
42
+ * Resolve a theme slug to its display label: each hyphen-separated
43
+ * word title-cased, so a slug like
44
+ * "united-kingdom-national-health-service-england-for-patients"
45
+ * renders as "United Kingdom National Health Service England For
46
+ * Patients" rather than a half-capitalised hyphenated string.
47
+ *
48
+ * Mirrors `localeName` in locale-picker. The element's `labelFor`
49
+ * delegates here after consulting `theme-labels`, so there is exactly
50
+ * one implementation of the title-casing rule.
51
+ */
52
+ declare function themeName(theme: string): string;
53
+ /**
54
+ * Resolve the OS colour-scheme preference to a supported theme slug.
55
+ * Mirrors `matchNavigatorLanguage` in locale-picker.
56
+ *
57
+ * Returns `""` when the preferred scheme is not present in `themes`,
58
+ * or when `matchMedia` is unavailable — the SSR case, and also jsdom,
59
+ * which does not implement `matchMedia` either. The guard is required,
60
+ * not optional.
61
+ */
62
+ declare function matchSystemTheme(themes: readonly string[]): string;
63
+ /** Normalise the themes directory URL to end with exactly one `/`. */
64
+ declare function normalizeThemesUrl(themesUrl: string): string;
65
+ /** Construct the href for a given theme slug. */
66
+ declare function themeHref(themesUrl: string, slug: string, extension: string): string;
67
+ /** Stable per-instance id prefix; SSR-safe (no Math.random / Date.now). */
68
+ declare function nextThemePickerId(): string;
69
+ /** Custom-element class implementing `<lily-theme-picker>`. */
70
+ declare class ThemePicker extends HTMLElement {
71
+ #private;
72
+ static get observedAttributes(): string[];
73
+ get label(): string;
74
+ set label(v: string);
75
+ get themesUrl(): string;
76
+ set themesUrl(v: string);
77
+ get themes(): string[];
78
+ set themes(v: string[]);
79
+ get value(): string;
80
+ set value(v: string);
81
+ get defaultValue(): string;
82
+ set defaultValue(v: string);
83
+ get storageKey(): string;
84
+ set storageKey(v: string);
85
+ /**
86
+ * Resolve `prefers-color-scheme` to a supported theme on first
87
+ * visit. Mirrors `detectFromNavigator` in locale-picker, including
88
+ * the boolean-attribute convention: absent → false, present →
89
+ * true, present and equal to "false" → false.
90
+ */
91
+ get detectFromSystem(): boolean;
92
+ set detectFromSystem(v: boolean);
93
+ get name(): string;
94
+ set name(v: string);
95
+ get extension(): string;
96
+ set extension(v: string);
97
+ get themeLabels(): Record<string, string>;
98
+ set themeLabels(v: Record<string, string>);
99
+ get target(): HTMLElement | null;
100
+ set target(v: HTMLElement | null);
101
+ /** Is the listbox open? Read-only; use `openList()` / `closeList()`. */
102
+ get open(): boolean;
103
+ /** id of the rendered `<ul role="listbox">`. */
104
+ get listId(): string;
105
+ /** id of the rendered option at `index`. */
106
+ optionId(index: number): string;
107
+ /**
108
+ * Build the content of the button. The default is a bundled SVG icon
109
+ * (contrast/half-circle) wrapped in `aria-hidden="true"` so the
110
+ * accessible name comes from the button's `aria-label` alone.
111
+ *
112
+ * This is the HTML-helper equivalent of the Svelte/React/Vue
113
+ * `children` snippet: it replaces the icon inside the button, and
114
+ * has `this.value`, `this.open`, and `this.labelFor(...)` available.
115
+ * Subclasses may override it. Whatever it returns is placed inside
116
+ * the button; the button's own aria wiring is not the subclass's to
117
+ * change. See `docs/custom-rendering.md`.
118
+ */
119
+ renderButtonContent(): Node;
120
+ /** Resolve a slug to its display label. Public for subclasses. */
121
+ labelFor(theme: string): string;
122
+ connectedCallback(): void;
123
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
124
+ disconnectedCallback(): void;
125
+ /** Open the listbox. `startIndex` overrides the active option. */
126
+ openList(startIndex?: number): void;
127
+ /** Close the listbox. Returns focus to the button unless `refocus` is false. */
128
+ closeList(refocus?: boolean): void;
129
+ }
130
+
131
+ export { ThemePicker, type ThemePickerChangeDetail, type ThemePickerProps, matchSystemTheme, nextThemePickerId, normalizeThemesUrl, themeHref, themeName };
package/dist/index.js ADDED
@@ -0,0 +1,567 @@
1
+ // lily-design-system-web-components-theme-picker/theme-picker.ts
2
+ var SVG_NS = "http://www.w3.org/2000/svg";
3
+ function themeName(theme) {
4
+ return theme.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
5
+ }
6
+ function matchSystemTheme(themes) {
7
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
8
+ return "";
9
+ }
10
+ const wanted = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
11
+ return themes.includes(wanted) ? wanted : "";
12
+ }
13
+ function normalizeThemesUrl(themesUrl) {
14
+ return themesUrl.endsWith("/") ? themesUrl : themesUrl + "/";
15
+ }
16
+ function themeHref(themesUrl, slug, extension) {
17
+ return normalizeThemesUrl(themesUrl) + slug + extension;
18
+ }
19
+ var uid = 0;
20
+ function nextThemePickerId() {
21
+ uid += 1;
22
+ return `theme-picker-${uid}`;
23
+ }
24
+ var ThemePicker = class extends HTMLElement {
25
+ static get observedAttributes() {
26
+ return [
27
+ "label",
28
+ "themes-url",
29
+ "themes",
30
+ "value",
31
+ "default-value",
32
+ "storage-key",
33
+ "detect-from-system",
34
+ "name",
35
+ "extension",
36
+ "theme-labels",
37
+ "class"
38
+ ];
39
+ }
40
+ // Backing storage for properties.
41
+ #themes = [];
42
+ #themeLabels = {};
43
+ #target = null;
44
+ #initialised = false;
45
+ // Rendered-DOM references. Null until #render() has run.
46
+ #rootEl = null;
47
+ #inputEl = null;
48
+ #buttonEl = null;
49
+ #listEl = null;
50
+ #optionEls = [];
51
+ // Listbox state.
52
+ #open = false;
53
+ #activeIndex = -1;
54
+ // Stable ids for the button/listbox aria wiring.
55
+ #baseId = nextThemePickerId();
56
+ // Typeahead buffer: APG listbox behaviour. Reset after a pause.
57
+ #typeahead = "";
58
+ #typeaheadTimer;
59
+ #onDocumentClick = (event) => {
60
+ if (!this.#open) return;
61
+ if (!event.composedPath().includes(this)) this.closeList(false);
62
+ };
63
+ // ---- Property accessors ----
64
+ get label() {
65
+ return this.getAttribute("label") ?? "";
66
+ }
67
+ set label(v) {
68
+ this.setAttribute("label", v);
69
+ }
70
+ get themesUrl() {
71
+ return this.getAttribute("themes-url") ?? "";
72
+ }
73
+ set themesUrl(v) {
74
+ this.setAttribute("themes-url", v);
75
+ }
76
+ get themes() {
77
+ return [...this.#themes];
78
+ }
79
+ set themes(v) {
80
+ this.#themes = Array.isArray(v) ? v.slice() : [];
81
+ const csv = this.#themes.join(",");
82
+ if (this.getAttribute("themes") !== csv) {
83
+ this.setAttribute("themes", csv);
84
+ return;
85
+ }
86
+ this.#render();
87
+ }
88
+ get value() {
89
+ return this.getAttribute("value") ?? "";
90
+ }
91
+ set value(v) {
92
+ if (v) this.setAttribute("value", v);
93
+ else this.removeAttribute("value");
94
+ }
95
+ get defaultValue() {
96
+ return this.getAttribute("default-value") ?? "";
97
+ }
98
+ set defaultValue(v) {
99
+ if (v) this.setAttribute("default-value", v);
100
+ else this.removeAttribute("default-value");
101
+ }
102
+ get storageKey() {
103
+ return this.getAttribute("storage-key") ?? "";
104
+ }
105
+ set storageKey(v) {
106
+ if (v) this.setAttribute("storage-key", v);
107
+ else this.removeAttribute("storage-key");
108
+ }
109
+ /**
110
+ * Resolve `prefers-color-scheme` to a supported theme on first
111
+ * visit. Mirrors `detectFromNavigator` in locale-picker, including
112
+ * the boolean-attribute convention: absent → false, present →
113
+ * true, present and equal to "false" → false.
114
+ */
115
+ get detectFromSystem() {
116
+ const v = this.getAttribute("detect-from-system");
117
+ return v !== null && v !== "false";
118
+ }
119
+ set detectFromSystem(v) {
120
+ if (v) this.setAttribute("detect-from-system", "");
121
+ else this.removeAttribute("detect-from-system");
122
+ }
123
+ get name() {
124
+ return this.getAttribute("name") ?? "theme";
125
+ }
126
+ set name(v) {
127
+ if (v) this.setAttribute("name", v);
128
+ else this.removeAttribute("name");
129
+ }
130
+ get extension() {
131
+ return this.getAttribute("extension") ?? ".css";
132
+ }
133
+ set extension(v) {
134
+ if (v) this.setAttribute("extension", v);
135
+ else this.removeAttribute("extension");
136
+ }
137
+ get themeLabels() {
138
+ return { ...this.#themeLabels };
139
+ }
140
+ set themeLabels(v) {
141
+ this.#themeLabels = v && typeof v === "object" ? { ...v } : {};
142
+ const json = JSON.stringify(this.#themeLabels);
143
+ if (this.getAttribute("theme-labels") !== json) {
144
+ this.setAttribute("theme-labels", json);
145
+ return;
146
+ }
147
+ this.#render();
148
+ }
149
+ get target() {
150
+ return this.#target;
151
+ }
152
+ set target(v) {
153
+ this.#target = v ?? null;
154
+ }
155
+ /** Is the listbox open? Read-only; use `openList()` / `closeList()`. */
156
+ get open() {
157
+ return this.#open;
158
+ }
159
+ /** id of the rendered `<ul role="listbox">`. */
160
+ get listId() {
161
+ return `${this.#baseId}-list`;
162
+ }
163
+ /** id of the rendered option at `index`. */
164
+ optionId(index) {
165
+ return `${this.#baseId}-option-${index}`;
166
+ }
167
+ // ---- Public, overridable rendering hook ----
168
+ /**
169
+ * Build the content of the button. The default is a bundled SVG icon
170
+ * (contrast/half-circle) wrapped in `aria-hidden="true"` so the
171
+ * accessible name comes from the button's `aria-label` alone.
172
+ *
173
+ * This is the HTML-helper equivalent of the Svelte/React/Vue
174
+ * `children` snippet: it replaces the icon inside the button, and
175
+ * has `this.value`, `this.open`, and `this.labelFor(...)` available.
176
+ * Subclasses may override it. Whatever it returns is placed inside
177
+ * the button; the button's own aria wiring is not the subclass's to
178
+ * change. See `docs/custom-rendering.md`.
179
+ */
180
+ renderButtonContent() {
181
+ const svg = document.createElementNS(SVG_NS, "svg");
182
+ svg.setAttribute("class", "theme-picker-icon");
183
+ svg.setAttribute("viewBox", "0 0 16 16");
184
+ svg.setAttribute("width", "1.05rem");
185
+ svg.setAttribute("height", "1.05rem");
186
+ svg.setAttribute("aria-hidden", "true");
187
+ svg.setAttribute("fill", "none");
188
+ svg.setAttribute("stroke", "currentColor");
189
+ svg.setAttribute("stroke-width", "1.6");
190
+ svg.setAttribute("stroke-linecap", "round");
191
+ svg.setAttribute("stroke-linejoin", "round");
192
+ const circle = document.createElementNS(SVG_NS, "circle");
193
+ circle.setAttribute("cx", "8");
194
+ circle.setAttribute("cy", "8");
195
+ circle.setAttribute("r", "6");
196
+ svg.appendChild(circle);
197
+ const path = document.createElementNS(SVG_NS, "path");
198
+ path.setAttribute("d", "M8 2a6 6 0 0 1 0 12z");
199
+ path.setAttribute("fill", "currentColor");
200
+ path.setAttribute("stroke", "none");
201
+ svg.appendChild(path);
202
+ return svg;
203
+ }
204
+ /** Resolve a slug to its display label. Public for subclasses. */
205
+ labelFor(theme) {
206
+ if (theme in this.#themeLabels) return this.#themeLabels[theme];
207
+ return themeName(theme);
208
+ }
209
+ // ---- Lifecycle ----
210
+ connectedCallback() {
211
+ const themesAttr = this.getAttribute("themes");
212
+ if (themesAttr !== null && this.#themes.length === 0) {
213
+ this.#themes = parseCsv(themesAttr);
214
+ }
215
+ const labelsAttr = this.getAttribute("theme-labels");
216
+ if (labelsAttr !== null && Object.keys(this.#themeLabels).length === 0) {
217
+ this.#themeLabels = parseJsonObject(labelsAttr);
218
+ }
219
+ if (!this.#initialised) {
220
+ this.#initialised = true;
221
+ this.#resolveInitialValue();
222
+ }
223
+ this.#render();
224
+ document.addEventListener("click", this.#onDocumentClick);
225
+ if (this.value) this.#applyTheme(this.value);
226
+ }
227
+ attributeChangedCallback(name, _old, value) {
228
+ switch (name) {
229
+ case "themes":
230
+ this.#themes = value === null ? [] : parseCsv(value);
231
+ this.#render();
232
+ break;
233
+ case "theme-labels":
234
+ this.#themeLabels = value === null ? {} : parseJsonObject(value);
235
+ this.#render();
236
+ break;
237
+ case "value":
238
+ this.#syncState();
239
+ if (this.isConnected && value) this.#applyTheme(value);
240
+ break;
241
+ case "label":
242
+ case "name":
243
+ case "class":
244
+ this.#render();
245
+ break;
246
+ // themes-url / default-value / storage-key / extension don't
247
+ // need a re-render; they affect the next apply.
248
+ default:
249
+ break;
250
+ }
251
+ }
252
+ disconnectedCallback() {
253
+ document.removeEventListener("click", this.#onDocumentClick);
254
+ clearTimeout(this.#typeaheadTimer);
255
+ this.#appliedValue = "";
256
+ const sameName = document.querySelectorAll(
257
+ `theme-picker[name="${this.name}"]`
258
+ );
259
+ if (sameName.length === 0) {
260
+ const link = document.head.querySelector(
261
+ `link[data-lily-theme-picker="${this.name}"]`
262
+ );
263
+ link?.remove();
264
+ }
265
+ }
266
+ // ---- Behaviour ----
267
+ #resolveInitialValue() {
268
+ let initial = this.value;
269
+ if (!initial && this.storageKey) {
270
+ try {
271
+ initial = localStorage.getItem(this.storageKey) ?? "";
272
+ } catch {
273
+ }
274
+ }
275
+ if (!initial && this.detectFromSystem) {
276
+ initial = matchSystemTheme(this.#themes);
277
+ }
278
+ if (!initial) {
279
+ initial = this.defaultValue || (this.#themes.includes("light") ? "light" : this.#themes[0]) || "";
280
+ }
281
+ if (initial && initial !== this.value) {
282
+ this.setAttribute("value", initial);
283
+ }
284
+ }
285
+ #getManagedLink() {
286
+ const selector = `link[data-lily-theme-picker="${this.name}"]`;
287
+ let link = document.head.querySelector(selector);
288
+ if (!link) {
289
+ link = document.createElement("link");
290
+ link.rel = "stylesheet";
291
+ link.setAttribute("data-lily-theme-picker", this.name);
292
+ document.head.appendChild(link);
293
+ }
294
+ return link;
295
+ }
296
+ // The theme the DOM currently carries. Applying is idempotent: a
297
+ // theme already applied is a no-op. `attributeChangedCallback` fires
298
+ // on every `setAttribute("value", …)`, unchanged value included, so
299
+ // without this a consumer whose `themechange` listener mirrors the value
300
+ // back onto the element re-enters apply forever.
301
+ #appliedValue = "";
302
+ #applyTheme(slug) {
303
+ if (typeof document === "undefined" || !slug) return;
304
+ if (slug === this.#appliedValue) return;
305
+ this.#appliedValue = slug;
306
+ this.#getManagedLink().href = themeHref(
307
+ this.themesUrl,
308
+ slug,
309
+ this.extension
310
+ );
311
+ (this.#target ?? document.documentElement).setAttribute("data-theme", slug);
312
+ if (this.storageKey) {
313
+ try {
314
+ localStorage.setItem(this.storageKey, slug);
315
+ } catch {
316
+ }
317
+ }
318
+ this.dispatchEvent(
319
+ new CustomEvent("themechange", {
320
+ detail: { theme: slug },
321
+ bubbles: true,
322
+ composed: true
323
+ })
324
+ );
325
+ }
326
+ // ---- Open / close ----
327
+ /** Open the listbox. `startIndex` overrides the active option. */
328
+ openList(startIndex) {
329
+ const selected = this.#themes.indexOf(this.value);
330
+ this.#activeIndex = this.#themes.length === 0 ? -1 : startIndex ?? (selected >= 0 ? selected : 0);
331
+ this.#open = true;
332
+ this.#syncState();
333
+ this.#listEl?.focus({ preventScroll: true });
334
+ this.#scrollActiveIntoView();
335
+ }
336
+ /** Close the listbox. Returns focus to the button unless `refocus` is false. */
337
+ closeList(refocus = true) {
338
+ if (!this.#open) return;
339
+ this.#open = false;
340
+ this.#activeIndex = -1;
341
+ this.#syncState();
342
+ if (refocus) this.#buttonEl?.focus({ preventScroll: true });
343
+ }
344
+ #choose(index) {
345
+ const slug = this.#themes[index];
346
+ if (slug) this.value = slug;
347
+ this.closeList();
348
+ }
349
+ #scrollActiveIntoView() {
350
+ if (this.#activeIndex < 0) return;
351
+ this.#optionEls[this.#activeIndex]?.scrollIntoView?.({ block: "nearest" });
352
+ }
353
+ #moveActive(delta) {
354
+ if (this.#themes.length === 0) return;
355
+ this.#activeIndex = Math.min(
356
+ Math.max(this.#activeIndex + delta, 0),
357
+ this.#themes.length - 1
358
+ );
359
+ this.#syncState();
360
+ this.#scrollActiveIntoView();
361
+ }
362
+ #setActive(index) {
363
+ this.#activeIndex = index;
364
+ this.#syncState();
365
+ this.#scrollActiveIntoView();
366
+ }
367
+ #runTypeahead(char) {
368
+ const lower = char.toLowerCase();
369
+ const sameCharRun = this.#typeahead === "" || [...this.#typeahead].every((c) => c === lower);
370
+ this.#typeahead += lower;
371
+ clearTimeout(this.#typeaheadTimer);
372
+ this.#typeaheadTimer = setTimeout(() => {
373
+ this.#typeahead = "";
374
+ }, 500);
375
+ const query = sameCharRun ? lower : this.#typeahead;
376
+ const anchor = this.#activeIndex < 0 ? 0 : this.#activeIndex;
377
+ const start = sameCharRun ? anchor + 1 : anchor;
378
+ for (let n = 0; n < this.#themes.length; n++) {
379
+ const i = (start + n) % this.#themes.length;
380
+ if (this.labelFor(this.#themes[i]).toLowerCase().startsWith(query)) {
381
+ this.#setActive(i);
382
+ return;
383
+ }
384
+ }
385
+ }
386
+ #onButtonKeydown = (event) => {
387
+ switch (event.key) {
388
+ case "ArrowDown":
389
+ case "Enter":
390
+ case " ":
391
+ event.preventDefault();
392
+ this.openList();
393
+ break;
394
+ case "ArrowUp":
395
+ event.preventDefault();
396
+ this.openList(this.#themes.length - 1);
397
+ break;
398
+ }
399
+ };
400
+ #onListKeydown = (event) => {
401
+ switch (event.key) {
402
+ case "ArrowDown":
403
+ event.preventDefault();
404
+ this.#moveActive(1);
405
+ break;
406
+ case "ArrowUp":
407
+ event.preventDefault();
408
+ this.#moveActive(-1);
409
+ break;
410
+ case "Home":
411
+ event.preventDefault();
412
+ this.#setActive(0);
413
+ break;
414
+ case "End":
415
+ event.preventDefault();
416
+ this.#setActive(this.#themes.length - 1);
417
+ break;
418
+ case "Enter":
419
+ case " ":
420
+ event.preventDefault();
421
+ if (this.#activeIndex >= 0) this.#choose(this.#activeIndex);
422
+ break;
423
+ case "Escape":
424
+ event.preventDefault();
425
+ this.closeList();
426
+ break;
427
+ case "PageUp":
428
+ event.preventDefault();
429
+ this.#moveActive(-10);
430
+ break;
431
+ case "PageDown":
432
+ event.preventDefault();
433
+ this.#moveActive(10);
434
+ break;
435
+ case "Tab":
436
+ this.#buttonEl?.focus?.({ preventScroll: true });
437
+ this.closeList(false);
438
+ break;
439
+ default:
440
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
441
+ this.#runTypeahead(event.key);
442
+ }
443
+ }
444
+ };
445
+ #onRootFocusOut = (event) => {
446
+ const next = event.relatedTarget;
447
+ if (next && this.#rootEl?.contains(next)) return;
448
+ queueMicrotask(() => {
449
+ const active = document.activeElement;
450
+ if (active && this.#rootEl?.contains(active)) return;
451
+ this.closeList(false);
452
+ });
453
+ };
454
+ // ---- Rendering ----
455
+ /**
456
+ * Update every state-carrying attribute without rebuilding the DOM:
457
+ * `aria-expanded`, `hidden`, `aria-activedescendant`, per-option
458
+ * `aria-selected` / `data-active`, and the hidden input's value.
459
+ */
460
+ #syncState() {
461
+ if (!this.#rootEl) return;
462
+ const value = this.value;
463
+ if (this.#inputEl) this.#inputEl.value = value;
464
+ if (this.#buttonEl) {
465
+ this.#buttonEl.setAttribute("aria-expanded", String(this.#open));
466
+ this.#buttonEl.replaceChildren(this.renderButtonContent());
467
+ }
468
+ if (this.#listEl) {
469
+ if (this.#open) this.#listEl.removeAttribute("hidden");
470
+ else this.#listEl.setAttribute("hidden", "");
471
+ if (this.#open && this.#activeIndex >= 0) {
472
+ this.#listEl.setAttribute(
473
+ "aria-activedescendant",
474
+ this.optionId(this.#activeIndex)
475
+ );
476
+ } else {
477
+ this.#listEl.removeAttribute("aria-activedescendant");
478
+ }
479
+ }
480
+ this.#optionEls.forEach((option, i) => {
481
+ option.setAttribute("aria-selected", String(this.#themes[i] === value));
482
+ if (i === this.#activeIndex) option.setAttribute("data-active", "");
483
+ else option.removeAttribute("data-active");
484
+ });
485
+ }
486
+ #render() {
487
+ if (!this.isConnected) return;
488
+ this.#open = false;
489
+ this.#activeIndex = -1;
490
+ const extraClass = this.getAttribute("class") ?? "";
491
+ const root = document.createElement("div");
492
+ root.className = `theme-picker ${extraClass}`.trim();
493
+ root.addEventListener("focusout", this.#onRootFocusOut);
494
+ const input = document.createElement("input");
495
+ input.type = "hidden";
496
+ input.name = this.name;
497
+ input.value = this.value;
498
+ root.appendChild(input);
499
+ const button = document.createElement("button");
500
+ button.type = "button";
501
+ button.className = "theme-picker-button";
502
+ button.setAttribute("aria-label", this.label);
503
+ button.setAttribute("aria-haspopup", "listbox");
504
+ button.setAttribute("aria-expanded", "false");
505
+ button.setAttribute("aria-controls", this.listId);
506
+ button.appendChild(this.renderButtonContent());
507
+ button.addEventListener("click", () => {
508
+ if (this.#open) this.closeList();
509
+ else this.openList();
510
+ });
511
+ button.addEventListener("keydown", this.#onButtonKeydown);
512
+ root.appendChild(button);
513
+ const list = document.createElement("ul");
514
+ list.className = "theme-picker-list";
515
+ list.id = this.listId;
516
+ list.setAttribute("role", "listbox");
517
+ list.setAttribute("aria-label", this.label);
518
+ list.setAttribute("tabindex", "-1");
519
+ list.setAttribute("hidden", "");
520
+ list.addEventListener("keydown", this.#onListKeydown);
521
+ const optionEls = [];
522
+ this.#themes.forEach((theme, i) => {
523
+ const option = document.createElement("li");
524
+ option.className = "theme-picker-option";
525
+ option.id = this.optionId(i);
526
+ option.setAttribute("role", "option");
527
+ option.setAttribute("aria-selected", String(theme === this.value));
528
+ option.textContent = this.labelFor(theme);
529
+ option.addEventListener("click", () => this.#choose(i));
530
+ list.appendChild(option);
531
+ optionEls.push(option);
532
+ });
533
+ root.appendChild(list);
534
+ this.#rootEl = root;
535
+ this.#inputEl = input;
536
+ this.#buttonEl = button;
537
+ this.#listEl = list;
538
+ this.#optionEls = optionEls;
539
+ this.replaceChildren(root);
540
+ }
541
+ };
542
+ function parseCsv(s) {
543
+ return s.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
544
+ }
545
+ function parseJsonObject(s) {
546
+ try {
547
+ const v = JSON.parse(s);
548
+ if (v && typeof v === "object" && !Array.isArray(v)) {
549
+ return v;
550
+ }
551
+ } catch {
552
+ }
553
+ return {};
554
+ }
555
+
556
+ // lily-design-system-web-components-theme-picker/index.ts
557
+ if (typeof customElements !== "undefined" && !customElements.get("lily-theme-picker")) {
558
+ customElements.define("lily-theme-picker", ThemePicker);
559
+ }
560
+ export {
561
+ ThemePicker,
562
+ matchSystemTheme,
563
+ nextThemePickerId,
564
+ normalizeThemesUrl,
565
+ themeHref,
566
+ themeName
567
+ };
package/index.md ADDED
@@ -0,0 +1,654 @@
1
+ # `<lily-theme-picker>` (HTML helper)
2
+
3
+ A reusable, headless vanilla HTML/JS theme picker that **loads themes
4
+ dynamically at runtime** from a developer-specified directory,
5
+ packaged as a **web component (custom element)**.
6
+
7
+ The control is an **icon button that opens a dropdown listbox**
8
+ (WAI-ARIA APG listbox pattern) — not a native `<select>`.
9
+
10
+ The single source of truth is [spec/index.md](./spec/index.md). This file is the
11
+ comprehensive user guide. For topic deep-dives see
12
+ [docs/](./docs/) and for working code see [examples/](./examples/).
13
+
14
+ ## Table of contents
15
+
16
+ - [Why this exists](#why-this-exists)
17
+ - [Install](#install)
18
+ - [Quick start](#quick-start)
19
+ - [Rendered markup](#rendered-markup)
20
+ - [Styling is required](#styling-is-required)
21
+ - [How it works](#how-it-works)
22
+ - [Keyboard](#keyboard)
23
+ - [Default theme](#default-theme)
24
+ - [Attributes](#attributes)
25
+ - [JS properties](#js-properties)
26
+ - [Methods](#methods)
27
+ - [Events](#events)
28
+ - [Custom button rendering](#custom-button-rendering)
29
+ - [Persistence](#persistence)
30
+ - [Accessibility](#accessibility)
31
+ - [SSR and static-site generation](#ssr-and-static-site-generation)
32
+ - [Preloading for zero-flicker switching](#preloading-for-zero-flicker-switching)
33
+ - [Multiple selects in one page](#multiple-selects-in-one-page)
34
+ - [Recipes](#recipes)
35
+ - [Troubleshooting](#troubleshooting)
36
+ - [Testing](#testing)
37
+
38
+ ## Why this exists
39
+
40
+ Most theme pickers couple selection, persistence, and styling into one
41
+ opinionated widget. This one splits the contract cleanly:
42
+
43
+ - **Authors** drop theme CSS files (e.g. `light.css`, `dark.css`) into
44
+ a directory served by the app.
45
+ - **This element** owns selection, dynamic loading, persistence, and
46
+ accessibility.
47
+ - **Consumers** own every visual decision via the `theme-picker`
48
+ class hooks — including the dropdown's positioning.
49
+
50
+ The result is a small reusable widget that works in any HTML host
51
+ (static HTML, Eleventy, Astro, Hugo, plain Vite, any framework that
52
+ emits HTML) and against any theme catalog — Lily™'s 45 reference
53
+ themes (DaisyUI-inspired, NHS-aligned, and public-sector / vendor
54
+ reference themes), or your own bespoke set.
55
+
56
+ The element is a direct port of the Svelte canonical
57
+ `@lilydesignsystem/svelte-theme-picker`. APIs and behaviour match;
58
+ only the framework idioms differ. The change-notification path uses
59
+ a bubbling `CustomEvent` instead of Svelte's prop callback.
60
+
61
+ ## Install
62
+
63
+ The directory is published as a folder-style import. Consumers either
64
+ copy it into their project or wire it as a workspace dependency. The
65
+ only runtime dependency is the browser DOM.
66
+
67
+ ```ts
68
+ // One side-effect import registers <lily-theme-picker> globally:
69
+ import "./lily-design-system-web-components-theme-picker";
70
+
71
+ // Or grab the class + helpers + types:
72
+ import {
73
+ ThemePicker,
74
+ normalizeThemesUrl,
75
+ themeHref,
76
+ nextThemePickerId,
77
+ type ThemePickerProps,
78
+ type ThemePickerChangeDetail,
79
+ } from "./lily-design-system-web-components-theme-picker";
80
+ ```
81
+
82
+ The barrel guards registration with
83
+ `customElements.get("lily-theme-picker")` so re-imports and SSR contexts
84
+ don't throw.
85
+
86
+ ## Quick start
87
+
88
+ 1. Drop theme CSS files into a directory served by your app, e.g.
89
+ `public/assets/themes/light.css`,
90
+ `public/assets/themes/dark.css`. Each theme scopes its tokens to
91
+ `:root[data-theme="<slug>"]` (the convention every Lily theme
92
+ uses).
93
+ 2. Place the custom element in your markup. On `connectedCallback`
94
+ it renders an icon button plus a hidden dropdown listbox holding
95
+ one option per theme.
96
+ 3. **Add the positioning CSS.** The package ships no CSS, so the
97
+ dropdown renders in flow until you position it. This is not
98
+ optional — see [Styling is required](#styling-is-required).
99
+
100
+ ```html
101
+ <script type="module" src="/dist/theme-picker.js"></script>
102
+
103
+ <style>
104
+ /* The minimum: make the dropdown float over the page. */
105
+ .theme-picker {
106
+ position: relative;
107
+ }
108
+ .theme-picker-list {
109
+ position: absolute;
110
+ inset-block-start: 100%;
111
+ inset-inline-start: 0;
112
+ z-index: 10;
113
+ }
114
+ .theme-picker-option[data-active] {
115
+ background: #eee;
116
+ }
117
+ .theme-picker-option[aria-selected="true"] {
118
+ font-weight: 600;
119
+ }
120
+ </style>
121
+
122
+ <lily-theme-picker
123
+ label="Theme"
124
+ themes-url="/assets/themes/"
125
+ themes="light,dark,abyss"
126
+ storage-key="lily-theme"
127
+ ></lily-theme-picker>
128
+
129
+ <p class="theme-picker-status" aria-live="polite">Active theme: Light</p>
130
+
131
+ <script type="module">
132
+ await customElements.whenDefined("theme-picker");
133
+
134
+ const select = document.querySelector("theme-picker");
135
+ const status = document.querySelector(".theme-picker-status");
136
+
137
+ select.addEventListener("themechange", (e) => {
138
+ status.textContent = `Active theme: ${select.labelFor(e.detail.theme)}`;
139
+ });
140
+ </script>
141
+ ```
142
+
143
+ **The status line is part of the pattern, not an optional extra.**
144
+ The closed control is an icon button — it shows an icon and nothing
145
+ else — so this line is the only place the current selection is
146
+ displayed and announced. `aria-live="polite"` speaks on each change
147
+ and stays silent on first paint, which is why the initial text is
148
+ authored in the markup rather than written by JS on startup. Making
149
+ it visible (rather than `sr-only`) serves sighted and
150
+ cognitive-accessibility users too. `labelFor()` is a public method
151
+ on the element, so the line picks up `theme-labels` overrides and
152
+ translations for free. See
153
+ [`docs/accessibility.md`](./docs/accessibility.md) for the full
154
+ rationale and the visually-hidden variant.
155
+
156
+ When the user selects `dark`, the element:
157
+
158
+ - swaps a managed `<link rel="stylesheet">` in `<head>` to
159
+ `/assets/themes/dark.css`,
160
+ - sets `data-theme="dark"` on `<html>`,
161
+ - writes `"dark"` to `localStorage["lily-theme"]`,
162
+ - dispatches `new CustomEvent("themechange", { detail: { theme: "dark" }, bubbles: true, composed: true })`.
163
+
164
+ ## Rendered markup
165
+
166
+ The element renders this into its light DOM:
167
+
168
+ ```html
169
+ <lily-theme-picker
170
+ label="Theme"
171
+ themes-url="/assets/themes/"
172
+ themes="light,dark,abyss"
173
+ >
174
+ <div class="theme-picker">
175
+ <input type="hidden" name="theme" value="light" />
176
+ <button
177
+ type="button"
178
+ class="theme-picker-button"
179
+ aria-label="Theme"
180
+ aria-haspopup="listbox"
181
+ aria-expanded="false"
182
+ aria-controls="theme-picker-1-list"
183
+ >
184
+ <svg class="theme-picker-icon" viewBox="0 0 16 16" width="1.05rem" height="1.05rem" aria-hidden="true">…</svg>
185
+ </button>
186
+ <ul
187
+ class="theme-picker-list"
188
+ id="theme-picker-1-list"
189
+ role="listbox"
190
+ aria-label="Theme"
191
+ tabindex="-1"
192
+ hidden
193
+ >
194
+ <li
195
+ class="theme-picker-option"
196
+ id="theme-picker-1-option-0"
197
+ role="option"
198
+ aria-selected="true"
199
+ data-active
200
+ >
201
+ Light
202
+ </li>
203
+ <li
204
+ class="theme-picker-option"
205
+ id="theme-picker-1-option-1"
206
+ role="option"
207
+ aria-selected="false"
208
+ >
209
+ Dark
210
+ </li>
211
+ <li
212
+ class="theme-picker-option"
213
+ id="theme-picker-1-option-2"
214
+ role="option"
215
+ aria-selected="false"
216
+ >
217
+ Abyss
218
+ </li>
219
+ </ul>
220
+ </div>
221
+ </lily-theme-picker>
222
+ ```
223
+
224
+ Points worth knowing:
225
+
226
+ - The **icon** is a bundled SVG (half-filled circle), not a Unicode
227
+ character — reversed 2026-09-16. It is `aria-hidden="true"`, so the
228
+ accessible name comes from the button's `aria-label` alone.
229
+ - The **hidden `<input>`** preserves form participation and carries
230
+ `name` — a listbox is not a form control.
231
+ - **`aria-activedescendant`** appears on the `<ul>` only while open,
232
+ pointing at the active option's id.
233
+ - **`data-active` is not `aria-selected`.** `data-active` marks the
234
+ keyboard-highlighted option (where `Enter` would land);
235
+ `aria-selected` marks the chosen one. They often differ while the
236
+ list is open, and consumer CSS should style them differently.
237
+ - **ids** come from a module-level counter, so they are unique per
238
+ instance, stable across runs, and SSR-safe.
239
+
240
+ Read the selection from `el.value` or the `themechange` detail. The
241
+ active theme is not visible anywhere while the list is closed, which
242
+ is why the [Quick start](#quick-start) pairs the element with a
243
+ status region by default; see [Accessibility](#accessibility).
244
+
245
+ ## Styling is required
246
+
247
+ The package ships **no CSS at all**, and that includes positioning.
248
+ The element renders the dropdown as an ordinary `<ul>` and toggles
249
+ its `hidden` attribute; it sets no `position`, no `z-index`, and no
250
+ offsets.
251
+
252
+ So out of the box the list appears _below the button in normal
253
+ flow_, pushing subsequent content down when it opens. That is
254
+ expected, not a bug. The minimum fix:
255
+
256
+ ```css
257
+ .theme-picker {
258
+ position: relative;
259
+ }
260
+
261
+ .theme-picker-list {
262
+ position: absolute;
263
+ inset-block-start: 100%;
264
+ inset-inline-start: 0;
265
+ z-index: 10;
266
+ }
267
+ ```
268
+
269
+ One trap to know about: if you set any `display` value on
270
+ `.theme-picker-list`, it overrides the user-agent
271
+ `[hidden] { display: none }` rule and the closed list stays visible.
272
+ Re-assert `.theme-picker-list[hidden] { display: none }` after your
273
+ rule, or scope yours with `:not([hidden])`.
274
+
275
+ Full hook list, state selectors, and a complete worked example:
276
+ [`docs/styling.md`](./docs/styling.md).
277
+
278
+ ## How it works
279
+
280
+ On every theme change the select performs four steps, in order:
281
+
282
+ 1. **Locate or create** a managed
283
+ `<link rel="stylesheet" data-lily-theme-picker="{name}">` in
284
+ `document.head`.
285
+ 2. **Swap the href** to `${themesUrl}${slug}${extension}` so the new
286
+ theme's CSS is fetched and applied. The previous theme's CSS is
287
+ unloaded when the href changes.
288
+ 3. **Set `data-theme="{slug}"`** on the resolved target element
289
+ (defaults to `document.documentElement`). Theme CSS files match
290
+ this attribute via their `:root[data-theme="…"]` selector.
291
+ 4. **Persist + notify**: if `storage-key` is set, write to
292
+ `localStorage` (silently swallowing private-mode errors); then
293
+ dispatch `themechange` with the slug in `event.detail.theme`.
294
+
295
+ All four steps are SSR-safe — the element only mutates the DOM inside
296
+ `connectedCallback` and `attributeChangedCallback`, which never run
297
+ in Node.
298
+
299
+ ## Keyboard
300
+
301
+ The control implements the WAI-ARIA APG listbox pattern in
302
+ JavaScript; none of this comes from the platform.
303
+
304
+ On the button:
305
+
306
+ | Key | Action |
307
+ | ----------------- | --------------------------------------------------------------- |
308
+ | `ArrowDown` | Open the list with the selected option active (else the first). |
309
+ | `Enter` / `Space` | Same as `ArrowDown`. |
310
+ | `ArrowUp` | Open the list with the **last** option active. |
311
+
312
+ Opening moves focus to the `<ul>`. On the list:
313
+
314
+ | Key | Action |
315
+ | ----------------------- | ------------------------------------------------------------- |
316
+ | `ArrowDown` / `ArrowUp` | Move the active option; clamps at both ends (no wrapping). |
317
+ | `Home` / `End` | Jump to the first / last option. |
318
+ | `Enter` / `Space` | Select the active option, apply, close, refocus the button. |
319
+ | `Escape` | Close and refocus the button without changing the theme. |
320
+ | `PageUp` / `PageDown` | Move the active option by ten; clamps at both ends. |
321
+ | `Tab` | Move focus to the button, then close — so the default Tab proceeds from the picker's position. |
322
+ | printable character | Typeahead over the option labels; buffer resets after 500 ms. A repeated character cycles through its matches; differing characters refine from the active option. |
323
+
324
+ Focus sits on the `<ul>` while open, never on an `<li>` — the
325
+ highlighted option is conveyed by `aria-activedescendant`. That is
326
+ why consumer CSS must style `.theme-picker-option[data-active]`
327
+ rather than `:focus`.
328
+
329
+ Clicking outside the control, or moving focus out of it, closes the
330
+ list.
331
+
332
+ ## Default theme
333
+
334
+ The default theme is `"light"` whenever `"light"` appears in your
335
+ `themes` list. The full resolution order on first
336
+ `connectedCallback` is:
337
+
338
+ 1. `value` attribute (if non-empty)
339
+ 2. `localStorage[storage-key]` (if `storage-key` is set and readable)
340
+ 3. `matchSystemTheme(themes)` (if `detect-from-system` is set) —
341
+ `prefers-color-scheme` mapped to the `"dark"` / `"light"` slug, or
342
+ `""` when that slug is absent or `matchMedia` is unavailable
343
+ 4. `default-value` attribute
344
+ 5. `"light"` (if present in `themes`)
345
+ 6. `themes[0]`
346
+ 7. `""` — nothing is applied; the select waits for user interaction
347
+
348
+ The control never displays the word `"default"`. Option labels
349
+ default to the title-cased slug, per hyphen-separated word
350
+ (`"light"` → `"Light"`, `"high-contrast"` → `"High Contrast"`);
351
+ override with `theme-labels` (JSON-encoded object).
352
+
353
+ ## Attributes
354
+
355
+ The complete table is in [spec/index.md §4.1](./spec/index.md#41-observed-attributes).
356
+ Highlights:
357
+
358
+ | Attribute | Type | Required | Notes |
359
+ | -------------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------------------ |
360
+ | `label` | string | yes | `aria-label` on the button and the listbox. The control is icon-only, so this is the entire accessible name. |
361
+ | `themes-url` | string | yes | Trailing `/` is auto-added. |
362
+ | `themes` | string (CSV) | yes | Available slugs, e.g. `"light,dark"`. |
363
+ | `value` | string | no | Currently selected slug. |
364
+ | `default-value` | string | no | Initial when nothing else applies. |
365
+ | `storage-key` | string | no | `localStorage` persistence. |
366
+ | `detect-from-system` | boolean attr | no | Resolve `prefers-color-scheme` on first visit. Mirrors locale-picker's `detect-from-navigator`. |
367
+ | `name` | string | no | Hidden `<input>` `name` and managed-`<link>` discriminator; defaults to `"theme"`. |
368
+ | `extension` | string | no | Defaults to `".css"`. |
369
+ | `theme-labels` | string (JSON) | no | `{ "light": "Bright" }` overrides. |
370
+ | `class` | string | no | Extra class on the rendered root `<div>`. |
371
+
372
+ There is no `placeholder` attribute; it was removed along with the
373
+ native `<select>`.
374
+
375
+ See [docs/attributes-reference.md](./docs/attributes-reference.md) for
376
+ a field-by-field reference.
377
+
378
+ ## JS properties
379
+
380
+ Every observed attribute mirrors a JS property of the same name (in
381
+ camelCase):
382
+
383
+ | Property | Type | Notes |
384
+ | --------------------- | ------------------------ | --------------------------------------------------- |
385
+ | `el.label` | `string` | round-trips with `label` attribute |
386
+ | `el.themesUrl` | `string` | round-trips with `themes-url` |
387
+ | `el.themes` | `string[]` | CSV-encoded in the `themes` attribute |
388
+ | `el.value` | `string` | round-trips with `value` |
389
+ | `el.defaultValue` | `string` | round-trips with `default-value` |
390
+ | `el.storageKey` | `string` | round-trips with `storage-key` |
391
+ | `el.detectFromSystem` | `boolean` | round-trips with `detect-from-system` (presence) |
392
+ | `el.name` | `string` | round-trips with `name` |
393
+ | `el.extension` | `string` | round-trips with `extension` |
394
+ | `el.themeLabels` | `Record<string, string>` | JSON-encoded in `theme-labels` |
395
+ | `el.target` | `HTMLElement \| null` | no attribute form (HTMLElement is not serialisable) |
396
+
397
+ Array / object properties accept the native form:
398
+
399
+ ```ts
400
+ const select = document.querySelector("theme-picker") as ThemePicker;
401
+ select.themes = ["light", "dark", "abyss"];
402
+ select.themeLabels = { light: "Bright", dark: "Midnight" };
403
+ ```
404
+
405
+ ## Methods
406
+
407
+ Beyond the attribute mirrors, the element exposes the listbox state
408
+ and a small imperative API:
409
+
410
+ | Member | Type | Notes |
411
+ | -------------------------- | --------- | ----------------------------------------------------------------------------------- |
412
+ | `el.open` | `boolean` | Read-only. Whether the listbox is open. |
413
+ | `el.listId` | `string` | Read-only. id of the rendered `<ul role="listbox">`. |
414
+ | `el.optionId(index)` | `string` | id of the rendered option at `index`. |
415
+ | `el.openList(startIndex?)` | `void` | Open the list; `startIndex` overrides the active option. Moves focus to the `<ul>`. |
416
+ | `el.closeList(refocus?)` | `void` | Close the list. Returns focus to the button unless `refocus` is `false`. |
417
+ | `el.labelFor(slug)` | `string` | Display label for a slug — applies `theme-labels`, else title-cases. |
418
+ | `el.renderButtonContent()` | `Node` | Overridable hook building the button's content. |
419
+
420
+ ```ts
421
+ const select = document.querySelector<ThemePicker>("theme-picker")!;
422
+
423
+ select.openList(); // open on the selected option
424
+ select.closeList(false); // close without moving focus
425
+ select.labelFor("light"); // → "Light"
426
+ ```
427
+
428
+ ## Events
429
+
430
+ | Event | Detail | Bubbles | Composed | When |
431
+ | ------------- | ------------------- | ------- | -------- | ------------------------------------- |
432
+ | `themechange` | `{ theme: string }` | yes | yes | After the select applies a new theme. |
433
+
434
+ ```ts
435
+ const select = document.querySelector("theme-picker")!;
436
+ select.addEventListener("themechange", (e) => {
437
+ const { theme } = (e as CustomEvent<{ theme: string }>).detail;
438
+ console.log("theme is now", theme);
439
+ });
440
+ ```
441
+
442
+ Because the event bubbles, event delegation works too:
443
+
444
+ ```ts
445
+ document.body.addEventListener("themechange", handleThemeChange);
446
+ ```
447
+
448
+ ## Custom button rendering
449
+
450
+ The Web Components helpers don't expose Vue scoped slots or Svelte snippets —
451
+ `<slot>` is Shadow DOM only, and these helpers commit to light DOM.
452
+ The equivalent of the other frameworks' `children` is an overridable
453
+ method, **`renderButtonContent()`**. Whatever `Node` it returns
454
+ replaces the default icon inside the button:
455
+
456
+ ```ts
457
+ import { ThemePicker } from "./lily-design-system-web-components-theme-picker";
458
+
459
+ class MyThemePicker extends ThemePicker {
460
+ renderButtonContent(): Node {
461
+ const span = document.createElement("span");
462
+ span.textContent = this.labelFor(this.value); // ChildArgs.value + labelFor
463
+ span.dataset.open = String(this.open); // ChildArgs.open
464
+ return span;
465
+ }
466
+ }
467
+
468
+ customElements.define("my-theme-picker", MyThemePicker);
469
+ ```
470
+
471
+ `this.value`, `this.open`, and `this.labelFor(...)` stand in for the
472
+ `ChildArgs` the other frameworks pass. The base class still builds
473
+ the button and the listbox, so all the aria wiring and the whole
474
+ keyboard contract keep working — **this is the recommended
475
+ customisation path**, and the only one that cannot break
476
+ accessibility.
477
+
478
+ A subclass that needs a fundamentally different structure has to
479
+ post-process after `super.connectedCallback()` (`#render()` is a
480
+ private field and cannot be overridden), and in doing so takes over
481
+ the entire accessibility contract.
482
+
483
+ Working example: [`examples/09-custom-rendering.html`](./examples/09-custom-rendering.html).
484
+ Topic guide, both tiers, and the invariants:
485
+ [`docs/custom-rendering.md`](./docs/custom-rendering.md).
486
+
487
+ ## Persistence
488
+
489
+ Pass a `storage-key` to persist the active slug to `localStorage`.
490
+ On a fresh mount the select reads back the stored slug as part of
491
+ the initial-value resolution (§ Default theme).
492
+
493
+ Errors writing to or reading from `localStorage` (private mode,
494
+ quota, disabled storage) are silently swallowed — the select
495
+ continues to work in-memory.
496
+
497
+ If you need cookie-based persistence (so SSR can read the theme
498
+ before first paint), see [`docs/ssr.md`](./docs/ssr.md) and the
499
+ [`examples/eleventy-cookie/`](./examples/eleventy-cookie/) recipe.
500
+
501
+ ## Accessibility
502
+
503
+ - The control implements the WAI-ARIA APG listbox pattern: a
504
+ `<button aria-haspopup="listbox">` paired with a
505
+ `<ul role="listbox">`, with `aria-label={label}` on both.
506
+ - The full keyboard contract is implemented in JS — see
507
+ [Keyboard](#keyboard).
508
+ - The active state is exposed via `aria-selected`, `data-theme` on
509
+ the root, the host's `value`, and the hidden input. No colour-only
510
+ meaning is required.
511
+ - WCAG 2.2 AAA is the target; visible focus styling is the
512
+ consumer's CSS responsibility — style
513
+ `.theme-picker-list:focus-visible` too, since focus moves to the
514
+ `<ul>` while the list is open.
515
+
516
+ **Two tradeoffs to know about**, both documented in full in
517
+ [`docs/accessibility.md`](./docs/accessibility.md):
518
+
519
+ 1. **Icon-only control.** `aria-label` is the entire accessible
520
+ name. A vague `label` makes the control unusable to
521
+ screen-reader users, and the absence of a visible label means the
522
+ control fails WCAG 2.5.3 Label in Name unless you add one.
523
+ 2. **A custom listbox is weaker than a native `<select>`.** The old
524
+ native control got combobox semantics, platform keyboard
525
+ behaviour, mobile OS pickers, and typeahead for free, all
526
+ battle-tested in every AT. An APG listbox with
527
+ `aria-activedescendant` has more variable support across screen
528
+ readers and mobile browsers, and no native mobile picker.
529
+
530
+ (The old platform-dependent-glyph-rendering tradeoff no longer
531
+ applies: the default icon is a bundled SVG, not a Unicode character
532
+ — reversed 2026-09-16. Override `renderButtonContent()` with your own
533
+ SVG when a different appearance is needed.)
534
+
535
+ Because the closed button shows only an icon, the active theme is
536
+ not visible or announced anywhere unless you surface it. The status
537
+ region shown in [Quick start](#quick-start) is the **default
538
+ pattern** — ship it unless you have a specific reason not to.
539
+
540
+ Topic guide: [`docs/accessibility.md`](./docs/accessibility.md).
541
+
542
+ ## SSR and static-site generation
543
+
544
+ The element compiles cleanly under static-site generators (Eleventy,
545
+ Astro, Hugo, Jekyll). On the server no lifecycle hook runs and no
546
+ DOM is touched; the SSG emits the literal `<lily-theme-picker>` tag, and
547
+ the browser upgrades it after the JS loads.
548
+
549
+ For zero-flicker static rendering, resolve the theme at build time
550
+ (or via cookie for dynamic SSR) and pre-render two things:
551
+
552
+ - `<html data-theme="…">` in the document shell
553
+ - a matching `<link rel="stylesheet">` for the chosen theme
554
+
555
+ Then pass the resolved value as the `value` attribute on the host so
556
+ the select doesn't re-resolve from storage and clobber the inlined
557
+ attribute.
558
+
559
+ See [`docs/ssr.md`](./docs/ssr.md) and
560
+ [`examples/eleventy-cookie/`](./examples/eleventy-cookie/).
561
+
562
+ ## Preloading for zero-flicker switching
563
+
564
+ By default the select swaps one `<link>` href, so the active theme
565
+ is fetched on demand. To switch instantly between themes, preload
566
+ them all yourself:
567
+
568
+ ```html
569
+ <link rel="stylesheet" href="/assets/themes/light.css" />
570
+ <link rel="stylesheet" href="/assets/themes/dark.css" />
571
+ <link rel="stylesheet" href="/assets/themes/abyss.css" />
572
+ ```
573
+
574
+ The select still mutates `data-theme`, and since every theme's CSS
575
+ is scoped to `:root[data-theme="…"]`, the active rules switch
576
+ instantly with the attribute change — no network round-trip.
577
+
578
+ Topic guide: [`docs/preloading.md`](./docs/preloading.md). Working
579
+ example: [`examples/05-preloaded.html`](./examples/05-preloaded.html).
580
+
581
+ ## Multiple selects in one page
582
+
583
+ Pass a distinct `name` attribute to each control. The `name` is used
584
+ as both the hidden `<input>`'s `name` and the discriminator on the
585
+ managed `<link>` element (`data-lily-theme-picker="{name}"`). Option
586
+ and list ids are unique per instance automatically.
587
+
588
+ Example: [`examples/03-multiple-selects.html`](./examples/03-multiple-selects.html).
589
+
590
+ ## Recipes
591
+
592
+ Quick cookbook in [`docs/recipes.md`](./docs/recipes.md):
593
+
594
+ - Following the OS colour scheme via `prefers-color-scheme`.
595
+ - Reading a theme cookie in Eleventy before render.
596
+ - Migrating from a `localStorage`-only select to a cookie-backed one.
597
+ - Positioning the dropdown, and putting the theme name on the button.
598
+ - Opening / closing the list from your own code.
599
+ - Loading themes from a CDN.
600
+
601
+ ## Troubleshooting
602
+
603
+ See [`docs/troubleshooting.md`](./docs/troubleshooting.md). Common
604
+ pitfalls:
605
+
606
+ - **The dropdown pushes the page down.** The package ships no CSS.
607
+ Give the root `position: relative` and the list
608
+ `position: absolute`.
609
+ - **The dropdown never hides.** A `display` rule on
610
+ `.theme-picker-list` beats the UA's `[hidden] { display: none }`.
611
+ Re-assert it, or scope your rule with `:not([hidden])`.
612
+ - **CSS does not switch.** Check that each theme file scopes its
613
+ rules to `:root[data-theme="<slug>"]` (not `:root` alone).
614
+ - **404 on theme href.** Check the file is served from `themes-url`
615
+ and uses the configured `extension` (defaults to `.css`).
616
+ - **Flash of default theme.** Pass a build-time-resolved `value`
617
+ attribute and inline `<html data-theme>` in the SSG output.
618
+ - **Theme does not persist.** Confirm `storage-key` is set and that
619
+ `localStorage` is available (not blocked by private mode).
620
+
621
+ ## Testing
622
+
623
+ ```sh
624
+ pnpm test
625
+ ```
626
+
627
+ Runs the vitest + jsdom suite that exercises every numbered
628
+ acceptance criterion in
629
+ [spec/index.md §7](./spec/index.md#7-testing-acceptance-criteria).
630
+
631
+ ## Files in this directory
632
+
633
+ | File | Purpose |
634
+ | ----------------------- | ------------------------------------------------ |
635
+ | `spec/index.md` | Single source of truth — API, behaviour, tests. |
636
+ | `AGENTS.md` | Fast-index pointer; loads the AGENTS bundle. |
637
+ | `AGENTS/` | Topic-by-topic agent files. |
638
+ | `CLAUDE.md` | `@AGENTS.md`. |
639
+ | `theme-picker.ts` | The custom-element class. |
640
+ | `theme-picker.test.ts` | vitest suite covering every spec §7 item. |
641
+ | `index.ts` | Barrel + side-effectful `customElements.define`. |
642
+ | `index.md` | This file. |
643
+ | `docs/` | Deep-dive topic guides. |
644
+ | `examples/` | Runnable `.html` files. |
645
+ | `CHANGELOG.md` | Version history. |
646
+
647
+ ## License
648
+
649
+ MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact
650
+ joel@joelparkerhenderson.com for other terms.
651
+
652
+ ---
653
+
654
+ Lily™ and Lily Design System™ are trademarks.
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@lilydesignsystem/web-components-theme-picker",
3
+ "version": "0.1.0",
4
+ "engines": {
5
+ "node": "=26"
6
+ },
7
+ "description": "Lily Design System - HTML custom element theme picker",
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "index.md",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "prepublishOnly": "cd .. && npm run build"
25
+ },
26
+ "keywords": [
27
+ "lily",
28
+ "design",
29
+ "system",
30
+ "html",
31
+ "theme",
32
+ "picker"
33
+ ],
34
+ "author": "Joel Parker Henderson <joel@joelparkerhenderson.com>",
35
+ "license": "MIT OR Apache-2.0 OR GPL-2.0-only OR GPL-3.0-only OR BSD-3-Clause",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/LilyDesignSystem/lily-design-system-web-components-helpers.git",
39
+ "directory": "lily-design-system-web-components-theme-picker"
40
+ },
41
+ "homepage": "https://lilydesignsystem.com/",
42
+ "bugs": {
43
+ "url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
44
+ }
45
+ }