<!-- GENERATED by scripts/build-llms.mjs from llms/forms.md — do not edit this file. -->

# `lr-swatch-picker`

- **Import** `import '@aceshooting/lyra-ui/components/lr-swatch-picker.js';` (stable tag alias; registers the tag)
- **Class** `LyraSwatchPicker`, also available unregistered from `@aceshooting/lyra-ui/components/forms/swatch-picker/swatch-picker.class.js`
- **Family** `components/forms/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 4 parts, 9 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-swatch-picker`

A single-select picker over a small, fixed set of color swatches with the WAI-ARIA APG
`radiogroup` contract built in: `role="radiogroup"`/`role="radio"`, roving tabindex, automatic
activation (click or arrow-key move both select immediately, like a native radio group), cyclic
Arrow/Home/End navigation. First-party invention (no Web Awesome equivalent). Distinct from
`lr-color-picker`, which is a freeform picker over the whole colour space — this picks exactly one
of N designer-chosen named colors, the shape apps otherwise hand-roll as a row of round
accent-color buttons. Its `items` are the _only_ choices; a `lr-color-picker`'s `swatches` are a
shortcut list alongside a grid, a hue ramp and a text field that can still express any colour.
Arrow/Home/End navigation starts from the swatch that actually received the keyboard event, even
when a controlled `value` write changed the selected or remembered roving item first.

Own `disabled` changes take effect immediately for host `click()` and `focus()`, including calls in
the same task as the property assignment. Blocked activation leaves value and `lr-change` untouched;
blocked focus preserves outside focus and emits no native focus inside the picker.

**Properties:**

- `items: readonly SwatchPickerItem[] = []` (attribute: false) — `SwatchPickerItem { readonly value:
string; readonly color: string; readonly label: string; readonly icon?: unknown; readonly
gemstone?: GemstoneKey }`; a valid CSS `color` is used as the
  swatch fill, while invalid values, declaration-breaking input, and `url()` are ignored (and are
  never interpolated into a gemstone SVG). `label` is each swatch's accessible name and `title`.
  `icon` is an optional custom shape rendered _instead of_ the plain filled circle. Its rendered
  subtree stays visible but is inert and hidden from assistive technology, so the swatch button
  remains the sole action. `gemstone` selects the canonical faceted glyph when
  `mode="gemstone"`. An explicit `icon` wins over `gemstone`. Assignments are bounded and copied
  into a frozen owned snapshot; mutate a new array/item and reassign it to update the palette.
- `value: string | null = null` — the currently selected option's `value` (controlled); `null`
  leaves nothing selected while keeping the first swatch tabbable.
- `size: '2xs' | 'xs' | 's' | 'm' | 'l' | 'xl' = 'm'` (reflected — scales the swatch hit-area and
  fill diameter proportionally, hit-area floored at 24px; not pixel-matched to `lr-input`'s
  row-height scale)
- `mode: 'swatch' | 'gemstone' = 'swatch'` (reflected) — `swatch` preserves the plain-circle
  default. `gemstone` renders the shared glyph for options carrying a `gemstone` key and enables
  the selected glow/shine defaults. That automatic glyph's checked-state halo/shine is the exact
  `gemstoneSelectedGlyphStyles` stylesheet the theme module also exports standalone (see
  `theme/gemstones.js` below), so a glyph rendered elsewhere on the page — e.g. a header trigger
  showing the current selection — can match this picker exactly by consuming the same export. An
  explicit `icon` on a `gemstone`-mode item is a consumer shape, not that shared glyph, and keeps
  this picker's own generic selected-icon glow instead (see `--lr-swatch-picker-gemstone-*` below).
- `accessibleLabel: string = ''` (attribute `aria-label`) — accessible name copied to the internal
  `role="radiogroup"`; attribute presence wins, including an explicitly empty name.
- The 9.x compatibility aliases were removed in 10.0.0: `options` is `items`, the exported
  `SwatchOption` type is `SwatchPickerItem`, and the former invisible `label` IDL is
  `accessibleLabel`/`aria-label`. The canonical sibling radiogroup vocabulary is now the only
  spelling.
- `disabled: boolean = false` (reflected) — locks the whole picker. Every swatch renders as a real
  `disabled` `<button>`, so it leaves the tab sequence and cannot be activated; arrow/Home/End
  navigation and host `click()` become no-ops; and the swatches dim to `--lr-opacity-disabled` with
  a `not-allowed` cursor and no hover lift. This is the picker's own attribute only: the control is
  deliberately **not** form-associated (it submits nothing and carries no `name`, validity or reset
  semantics), so an ancestor `<fieldset disabled>` does not cascade into it — disable the picker
  itself alongside the fieldset when a form needs both.

**Events:** `lr-change` (`detail: { value }`) — fired only when the selected value actually
changes via click or keyboard (re-selecting the current swatch is a no-op).
`lr-activate` (`detail: { value }`) — fired on **every** activation of a swatch (a click, or an
Arrow/Home/End key that lands on one), whether or not the selection actually moved. Bubbling,
composed, not cancelable — it reports that the user picked a swatch and gates nothing. `value` is
the activated swatch's own value. Use it for the repeat pick `lr-change` deliberately stays silent
for, which is otherwise unobservable: the swatches live in this shadow root, so a retargeted `click`
names no swatch, and keyboard activation produces no click at all — Home on an already-first
selection, End on an already-last one, or an arrow key in a one-item row activates a swatch and
fires nothing else. When an activation _does_ move the selection, `lr-change` is emitted first and
`lr-activate` second, so either listener reads the settled `value`.

**Slots:** none.

**CSS parts:** `base` (the `role="radiogroup"` root), `swatch` (a single `role="radio"` color
swatch's interactive hit target, sized via `--lr-swatch-picker-hit-size` — its private default
follows `size` and is floored at 24px; the selected one is
`[part='swatch'][aria-checked='true']`), `swatch-fill` (the filled circle inside it, sized via
`--lr-swatch-picker-fill-size` — defaults to `--lr-size-1-5rem`, with a private default that also
follows `size` —
rendered when the option has no `icon`), `swatch-icon` (the option's `icon` shape, rendered in its
place when it has one, with its inherited `font-size` set to the same fill-size token so a `1em`
glyph fills the wrapper; the wrapper is inert and aria-hidden across the flattened subtree).
Exactly one of `swatch-fill`/`swatch-icon` is mounted per swatch, so the two never coexist.

**Themeable custom properties:** `--lr-swatch-picker-selected-color` (ring color around the
selected swatch, defaults to `--lr-color-brand`, themeable independently of the focus ring),
`--lr-swatch-picker-selected-blur` (default `0` — a crisp ring; set a real length such as `0.4rem`
for a soft glow. It is the blur radius of `swatch-fill`'s `box-shadow` ring, and of the equivalent
`drop-shadow()` used for a `swatch-icon` rendering a consumer-supplied `icon`, since `box-shadow`
can't follow a slotted icon's real shape), `--lr-swatch-picker-shine-duration` (default `0s`, a
no-op; set a duration such as `1.6s` for a looping brighten-and-settle pulse on the selected
swatch. It drives a separate `filter: brightness()` keyframe rather than `box-shadow`, so it
composes with the blur token and works identically for a fill and a consumer-supplied icon;
disabled outright under `prefers-reduced-motion: reduce`, which also drops the hover/selection
scale transition), `--lr-swatch-picker-hit-size` (hit-area
size; its private default follows `size`), `--lr-swatch-picker-fill-size` (visible fill/icon
diameter; its private default follows `size`; set this hook on an ancestor/direct host to override
every tier, or `--lr-theme-swatch-picker-fill-size` on an ancestor for a shared default),
`--lr-swatch-picker-gemstone-selected-blur` (default `--lr-size-0-5rem` in gemstone mode, aliased
onto the shared `--lr-gemstone-selected-blur` so the two cannot drift apart — applies to a plain
color-fill swatch and to a consumer-supplied `icon` override in gemstone mode, NOT to the
automatic gemstone glyph itself), `--lr-swatch-picker-gemstone-shine-duration` (default `1.8s` in
gemstone mode, same aliasing and scope as `--lr-swatch-picker-gemstone-selected-blur`);
plus shared tokens — `--lr-color-border`/`-brand`, `--lr-space-xs`,
`--lr-border-width-thin`/`-thick`, `--lr-radius`, `--lr-transition-fast`, `--lr-focus-ring-*`,
and the per-tier `--lr-size-*` tokens. The automatic gemstone glyph's own checked-state halo/shine
is themed independently, through `--lr-gemstone-selected-color`/`-blur`/`-shine-duration` — see
`theme/gemstones.js`'s `gemstoneSelectedGlyphStyles` below, the exact stylesheet this picker
includes in its own `static styles` for that glyph.

**Optional peer deps:** none.

```html
<lr-swatch-picker aria-label="Accent color"></lr-swatch-picker>
<script type="module">
  const picker = document.querySelector("lr-swatch-picker");
  picker.items = [
    { value: "blue", color: "#0969da", label: "Blue" },
    { value: "green", color: "#1a7f37", label: "Green" },
    { value: "purple", color: "#8250df", label: "Purple" },
  ];
  picker.value = "green";
  picker.addEventListener("lr-change", (e) => console.log(e.detail.value));
</script>
```

For the shared gemstone accent mode, import the Lit-free palette data entry. The glyph renderer
remains available separately from `theme/gemstones.js` for Lit templates; palette-only consumers
do not need to load Lit. The consumer still owns localized labels, display order, and the initial
value:

```ts
import "@aceshooting/lyra-ui/components/forms/swatch-picker/swatch-picker.js";
import { GEMSTONES } from "@aceshooting/lyra-ui/theme/gemstones-data.js";

const order = ["emerald", "ruby", "sapphire", "hematite"] as const;
picker.mode = "gemstone";
picker.items = order.map((key) => ({
  value: key,
  color: GEMSTONES[key].fill,
  label: translateGemstone(key),
  gemstone: key,
}));
picker.value = "ruby";
```

To match this picker's checked-glyph halo/shine on a `gemstoneGlyph()` rendered anywhere else
(e.g. a header trigger button showing the current selection before it opens the picker in a
popover), import both Lit-based exports and reuse them directly instead of re-authoring the
treatment: `gemstoneGlyph()` for the markup and `gemstoneSelectedGlyphStyles` — a `CSSResult` — in
the consuming component's own `static styles`, then toggle the `data-lr-gemstone-selected` boolean
attribute on the element wrapping the rendered glyph to switch the halo/shine on:

```ts
import { gemstoneGlyph, gemstoneSelectedGlyphStyles } from "@aceshooting/lyra-ui/theme/gemstones.js";

class AccentTrigger extends LitElement {
  static styles = [gemstoneSelectedGlyphStyles];
  render() {
    return html`<button ?data-lr-gemstone-selected=${this.hasSelection}>
      ${gemstoneGlyph()}
    </button>`;
  }
}
```

**Known gotchas:**

- arrow-key navigation cycles (past the last swatch wraps to the first, and vice versa) rather than
  clamping, and self-selects on move — arrow-navigating to a swatch immediately updates `value` and
  fires `lr-change`, there's no separate commit step.
- live `items` changes preserve a focused swatch by item-object identity across reorders.
  Removing the focused option moves focus and the roving tab stop to the nearest surviving swatch
  (the next item at that position, or the previous item when the final option was removed) without
  changing the controlled `value` or emitting `lr-change`.
- under RTL (nearest `dir="rtl"` ancestor) `ArrowLeft`/`ArrowRight` swap which direction they move.
- each swatch's fill comes from its option's `color`, applied through a per-swatch custom property
  set inline on `[part='swatch']` and read by `[part='swatch-fill']`, so a consumer's
  `::part(swatch-fill)` `background-color` rule can still override it.
- style the selected state through `--lr-swatch-picker-selected-color`/`-selected-blur`/
  `-shine-duration`, not through `::part(swatch)[aria-checked='true']` from outside: the CSS Shadow
  Parts spec only allows a fixed set of pseudo-classes after `::part()`, not arbitrary attribute
  selectors, so that combinator can silently fail to match depending on the engine.
- the semantic `radiogroup` lives inside shadow DOM. Set `accessibleLabel` or a host `aria-label`;
  the component deliberately forwards the resulting name to that internal role.
- the automatic gemstone glyph's checked-state halo/shine is themed through
  `--lr-gemstone-selected-color`/`-blur`/`-shine-duration`, not through
  `--lr-swatch-picker-selected-*`/`-gemstone-*` — those style a plain color-fill swatch or a
  consumer-supplied `icon` override instead, even while `mode="gemstone"`.

**Additional API surface:**

- `--lr-swatch-picker-gap` — Gap between swatches. Default: `var(--lr-space-xs)`.
- `--lr-swatch-picker-wrap` — `flex-wrap` on the swatch row. Default: `wrap`, reproducing the
  previously hardcoded wrapping row exactly. Set `nowrap` to confine every swatch to a single
  row — the case this exists for is a picker inside a fixed-width popover, where a second row
  changes the panel's height and moves the popover under its trigger. A custom property rather
  than an attribute, so it inherits through wrappers like the gap and hit-size hooks beside it;
  tuning the gap and hit size small enough to avoid wrapping is a guess that breaks at the next
  swatch added.

---
