@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.
- package/dist/index.d.ts +131 -0
- package/dist/index.js +567 -0
- package/index.md +654 -0
- package/package.json +45 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|