Imported from LilyDesignSystem/lily-design-system (
lily-design-system-web-components-helpers/lily-design-system-web-components-theme-picker/AGENTS.md). Install upstream withnpx skills add LilyDesignSystem/lily-design-system --skill lily-design-system-web-components-theme-picker. Copyright stays with the author.
AGENTS — <lily-theme-picker> (HTML helper)
Single source of truth: spec/index.md. Read it first; everything below is a fast index.
What this package is
A reusable vanilla HTML/JS headless theme picker, packaged as the
<lily-theme-picker> custom element. Loads theme CSS files dynamically
at runtime from a developer-supplied directory URL. The control is
an icon button that opens a dropdown listbox (WAI-ARIA APG
listbox pattern) — not a native <select>. Ships no CSS; the
consumer styles the theme-picker class hooks on the rendered
children, and must supply the list's positioning.
Files
| File | Purpose |
|---|---|
spec/index.md |
Specification-driven contract (canonical). |
theme-picker.ts |
Implementation (TypeScript class). |
theme-picker.test.ts |
Vitest + jsdom spec, one assertion per §7 item. |
index.ts |
Barrel re-export + side-effectful registration. |
index.md |
Human-readable guide. |
Public surface
- Class
ThemePicker extends HTMLElement(registered as<lily-theme-picker>on import ofindex.ts). - Named exports:
ThemePicker,themeName,matchSystemTheme,normalizeThemesUrl,themeHref,nextThemePickerId.themeNameandmatchSystemThemeare the mirrors of locale-picker'slocaleNameandmatchNavigatorLanguage. - No glyph constant — the default icon is a bundled SVG, not a Unicode character (reversed 2026-09-16).
- Type exports:
ThemePickerProps,ThemePickerChangeDetail. - Instance members beyond the attribute mirrors:
open(getter),listId(getter),optionId(index),openList(startIndex?),closeList(refocus = true),labelFor(slug), andrenderButtonContent()— the overridable rendering hook.
Required attributes: label, themes-url, themes. Full table in
spec/index.md §4.1.
There is no placeholder attribute; it was removed with the native
<select>.
Behaviour contract (one paragraph)
On every theme change the element (1) sets the href of one managed
<link rel="stylesheet" data-lily-theme-picker="{name}"> in
document.head to ${themesUrl}${slug}${extension}, (2) sets
data-theme="{slug}" on target (defaults to
document.documentElement), (3) optionally writes the slug to
localStorage[storageKey], and (4) dispatches a themechange
CustomEvent. Initial value resolves from value > storage >
system detection (if detect-from-system is set) > default-value >
"light" (if present) > themes[0] — the same shape locale-picker
uses, with detect-from-navigator in the detection slot.
The real selection lives on this.value (attribute + property);
consumers read it from there or from the themechange detail. A
value change syncs state attributes in place rather than
rebuilding the DOM, because a rebuild while the listbox is open
would destroy focus and the active descendant.
HTML
<lily-theme-picker> contains one rendered
<div class="theme-picker {class}"> holding, in order: a hidden
<input name="{name}"> for form participation; a
<button type="button" class="theme-picker-button" aria-label="{label}" aria-haspopup="listbox" aria-expanded aria-controls="{listId}">
whose content defaults to a bundled
<svg class="theme-picker-icon" aria-hidden="true"> (half-filled
circle, not a Unicode character — reversed 2026-09-16); and a
<ul class="theme-picker-list" id="{listId}" role="listbox" aria-label="{label}" tabindex="-1" hidden> with one
<li class="theme-picker-option" role="option" aria-selected> per
slug. aria-activedescendant sits on the <ul> only while open;
data-active marks the keyboard-highlighted option, which is a
different thing from aria-selected. Full markup:
spec/index.md §4.5.
Accessibility
- WCAG 2.2 AAA target; WAI-ARIA APG listbox pattern.
- The keyboard contract is implemented in JS, not inherited from
the platform. Button:
ArrowDown/Enter/Spaceopen,ArrowUpopens on the last option. List: arrows move and clamp,Home/Endjump,PageUp/PageDownmove by ten (clamped),Enter/Spaceselect and refocus the button,Escapecloses without changing the value,Tabputs focus on the button first and then closes — without cancelling the key, so the default Tab proceeds from the picker's position — and printable characters run a 500 ms typeahead where a repeated character cycles through its matches and differing characters refine from the active option. Table: spec/index.md §6.2. - Focus sits on the
<ul>while open, never on an<li>; the highlighted option is conveyed byaria-activedescendant. aria-labelcarries the consumer-supplied accessible name on both the button and the list. The icon isaria-hidden="true".- Option labels default to title-cased slugs; the word "default" is never emitted.
- Two known tradeoffs — icon-only naming (and WCAG 2.5.3), and a
custom listbox being weaker than a native
<select>in AT — are recorded in spec/index.md §6.5 anddocs/accessibility.md. The closed button shows only an icon, so consumers should surface the active theme in visible text or a polite live region. (The old platform-dependent-glyph tradeoff no longer applies: the icon is a bundled SVG, not a Unicode character — reversed 2026-09-16.)
Conventions this package follows
- Vanilla web component (custom element extending
HTMLElement). - Light DOM only (no Shadow DOM).
- Strict TypeScript on the public surface.
- No runtime dependencies.
- No bundled CSS, fonts, or images. The one deliberate exception is the default button icon: a bundled SVG (reversed 2026-09-16 from a Unicode glyph).
- All user-facing strings come from attributes / properties.
- Mirrors the Svelte sibling's §7 acceptance criteria.