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

# `lr-rating`

- **Import** `import '@aceshooting/lyra-ui/components/lr-rating.js';` (stable tag alias; registers the tag)
- **Class** `LyraRating`, also available unregistered from `@aceshooting/lyra-ui/components/overlays/rating/rating.class.js`
- **Family** `components/overlays/` — 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-rating`

A keyboard-accessible star rating control with slider semantics. It is a **form-associated control**
that lives in this family rather than in `components/forms/` — if you came looking for it among the
form controls, this is its section. Everything the "Form association" section says about `name`,
submission, validity and the `user-*` custom states applies to it.

Readonly transitions synchronize validity and `aria-invalid` in the same completed update. Form
reset restores the independent `default-value` rather than the live `value` attribute.

It is form-associated through `ElementInternals` directly rather than through the shared
`FormAssociated` mixin, because its `value` is a number and the mixin's contract assumes a plain
string — routing through it would force every consumer into string round-tripping for what is
natively a numeric score. The submitted entry is the clamped value stringified (`"0"` while
unrated), and `required` reports `valueMissing` until a rating above zero is set. The `value`
content attribute and IDL property both control the live score. `defaultValue` and its
`default-value` attribute independently own the reset target that `form.reset()` restores.

**Properties:** live `value: number = 0` (attribute `value`);
`defaultValue: number = 0` (attribute `default-value`, the current reset target); `customError: string |
null` (attribute `custom-error`); `max: number = 5`; `precision: number = 1`;
`readonly: boolean = false` (reflected), `disabled`, `required`, `name`,
`size: '2xs'|'xs'|'s'|'m'|'l'|'xl'|'small'|'medium'|'large' = 'm'` (reflected — changes the private
fallback behind `--lr-rating-size` from a type ramp rather than the shared control ladder, since a
rating has no control frame to size; an inherited or direct public size wins; the `m` default
reproduces the treatment this component had before `size` existed; valid long-form spellings
round-trip unchanged), plus two separate naming knobs:
`accessibleLabel: string = ''` (property) and `label: string = ''` (attribute
`label`). An authored host `aria-label` wins by attribute presence, including an explicitly empty
value. Without one, a non-empty external `<label for>` names the host; `accessibleLabel`, `label`,
then the localized name are the fallback order when no external label supplies text. Clicking an
associated external label focuses the host-owned slider without changing its value. Neither
property is visible label text, since a rating is a bare row of symbols with no field frame of its
own; wrap the element in your own layout for a labelled field, exactly as `<lr-slider>` does.

For the managed slider name only, an empty or whitespace-only `label` counts as absent and falls
through to localized `rating`; the raw `label` property still reads exactly as assigned. An
associated external label and an authored `aria-label`, including `aria-label=""`, keep their
existing precedence and are never replaced by that fallback.

**Static constructor API:** `LyraRating.validators` is the mirrored callable validator catalog.
Each access returns a fresh `LyraFormValidator<LyraRating>[]`; its entry observes
`required`/`disabled`/`readonly`/`value`/`max`, and `checkValidity(element)` projects the element's
current `ValidityState` into `{ isValid, message, invalidKeys }` without changing it.

```ts
import { LyraRating } from "@aceshooting/lyra-ui/components/overlays/rating/rating.js";

const rating = document.querySelector("lr-rating")!;
const result = LyraRating.validators[0].checkValidity(rating);
```

The host is the one focusable `role="slider"` owner and carries `tabindex`, its accessible name,
`aria-valuemin`/`aria-valuemax`/`aria-valuenow`/`aria-valuetext`, and explicit true/false disabled,
readonly and required states. The shadow star row is `aria-hidden` presentation only, so custom
host ARIA never creates a competing second slider.

Assigning `null` to `name` is accepted for mapped source compatibility; it removes the attribute and
clears to the canonical `''` read value rather than creating a nullable state.

`getSymbol?: (value: number, selected: boolean) => unknown` (property only, no attribute) — **new in
8.0.0.** Renders a consumer-supplied symbol per position instead of the built-in star. It is called
_twice per position_: once for the empty backdrop (`selected` false) and once for the overlay
clipped to that position's filled fraction (`selected` true), which is what keeps a fractional
`precision` rendering a partial fill. Return any Lit-renderable value; a plain string renders as
text, never as markup. Renderer output is decorative, inert, and pointer-transparent, so it cannot
become a second focus or action target; pointer and keyboard selection stay on the rating control.
Left unset, the built-in star outline/solid pair is unchanged.

**Events:**

- `change` — a native `Event` (bubbling, composed, non-cancelable, and carrying no `detail`) emitted
  when a user commits a genuinely new value. It fires immediately before `lr-change`; read the
  numeric score from `event.target.value`. Programmatic `value`/`defaultValue` writes, reset/state
  restore, and gestures that clamp to the current value are silent.
- `lr-change` — `detail: { value }`. The rating was committed to a new value. Not emitted when the
  clamped value is unchanged, nor on a programmatic `value` write. It fires immediately after the
  native `change` event for the same user commit.
- `lr-activate` — `detail: { value }`, the committed rating. Fired on **every** interactive commit
  (a click on a symbol, or an Arrow/Home/End key), whether or not the value actually moved.
  Bubbling, composed, not cancelable — it reports that the user committed a rating and gates
  nothing. Use it for the re-commit of the current rating that `lr-change` deliberately stays silent
  for; from the keyboard that case is otherwise unobservable, because End on an already-maximum
  rating, Home on an already-zero one, or an arrow key at either bound commits a rating and produces
  no click at all. When the commit _does_ move the value, `change` and `lr-change` are emitted
  first, so a listener reading `value` from any of the three sees the settled rating. A
  non-interactive (`readonly`/`disabled`) rating fires none of them.
- `lr-hover` — **new in 8.0.0.** `detail: { phase: 'start' | 'move' | 'end', value }`, where `value`
  is the rating that committing the current pointer position _would_ produce — enough to render a
  live description of what is being hovered without waiting for a click. Fires only while the rating
  is settable (neither `disabled`, fieldset-disabled, nor `readonly`). `start` also covers a pointer
  that reaches the symbols without a `pointerenter` the component saw; `end` fires on
  `pointerleave` **and** on `pointercancel` (a touch drag taken over by scrolling, palm rejection),
  so an interrupted gesture never leaves the preview frozen. A disconnect or a disablement drops the
  preview silently, with no `end` phase — that teardown wasn't user-driven.
- `focus` / `blur` — the host-owned slider's ordinary native focus transitions. Like native focus
  events, they do not bubble; listen on the rating itself (or use capture on an ancestor).
- `lr-invalid` — no detail; fired when a validity check finds the rating invalid.

**Methods:** `focus()`, `blur()` and `click()` operate on the host-owned slider and are gated while
disabled. `rating: HTMLElement | null` is the live presentational symbol row (the element carrying
the `rating`/`base` parts), or `null` before rendering; its identity remains stable across ordinary
updates and reconnection.
`getForm()` returns the browser-resolved owning form. `checkValidity()` and `reportValidity()`
behave as on a native form control — `reportValidity()`
additionally shows the browser's validation UI, and counts as interaction, so a failed submit is
what starts `user-invalid` matching. `setCustomValidity(message: string)` sets a consumer-supplied
rejection no client-side constraint can express ("you have already rated this item"): a non-empty
message raises `customError` and becomes `validationMessage`, so the control fails
`checkValidity()`, blocks submission and matches `:state(invalid)`. It is caller-supplied content,
so it is used verbatim and never localized. `setCustomValidity('')` clears it and restores the
control's _computed_ validity rather than forcing it valid — a `required` control that is still
unrated stays `valueMissing`. Like a native control, the custom error survives every intrinsic
recomputation in between (each `value`/`max`/`required` change re-runs validation) and a
`form.reset()`; `setCustomValidity('')` or `resetValidity()` clears it.

**Reset and state restore.** A live `value` or `value`-attribute write updates the current score;
later `defaultValue`/`default-value` mutations update the reset target without overwriting that
live score. `form.reset()` restores the current default, drops any
in-flight hover preview, and returns the control to pristine, so the `user-valid`/`user-invalid`
states stop matching even though a required-and-unrated control is still `invalid`. Browser session
restore (`formStateRestoreCallback`) reinstates the previously submitted numeric value; a
non-string restored state falls back to `0` rather than producing NaN geometry.

**Custom states:** `required`, `optional`, `valid`, `invalid`, `user-valid`, `user-invalid` —
`lr-rating:state(user-invalid)` is the one to paint red. Plain `invalid` matches a pristine
`required` rating that has never been set.

**CSS parts:** `base` (compatibility name for the presentational symbol row; use `rating`),
`rating` (the presentational symbol row; it is the same node as `base`), `star` (each rendered symbol), `star-fill` (the
filled overlay inside each symbol, clipped to that symbol's filled fraction — 0%, a partial
percentage under a fractional `precision`, or 100%).

**Themeable custom properties:** `--lr-rating-fill` (default `--lr-color-warning` — filled-symbol
color), `--lr-rating-empty-color` (default `--lr-color-border` — unfilled-symbol color, also
retained during hover preview), `--lr-rating-active-color` (default: the existing active mix of the
empty-symbol color — pressed-symbol color only), `--lr-rating-size` (default `--lr-font-size-xl` —
symbol size; its private default follows each `size` step while a public value wins), and
`--lr-rating-gap` (default
`--symbol-spacing`, then `--lr-space-xs` — gap between symbols). The mapped compatibility hooks
are `--symbol-color` (inactive symbols), `--symbol-color-active` (filled symbols), `--symbol-size`
(symbol size), and `--symbol-spacing` (the gap around symbols). The Lyra-prefixed color, size, and
gap names win if both a Lyra property and its compatibility alias are set. `--symbol-size` otherwise
feeds the active `size` step, while `--symbol-spacing` remains the fallback for
`--lr-rating-gap` before the shared `--lr-space-xs` default.

Pointer selection resolves the position within the clicked star and snaps upward to `precision`
(with the physical fraction mirrored under RTL), so half/quarter-star precision applies to pointer
input as well as keyboard/value updates. The host-owned slider's presentational symbol row keeps a
40×40px minimum activation area even for the degenerate `max=0`/`max=1` cases; larger ratings
naturally grow wider, while symbols may shrink within a narrow allocation instead of forcing the
host beyond its container.

```html
<lr-rating
  name="score"
  label="Overall rating"
  default-value="2"
  max="5"
  precision="0.5"
  size="l"
  style="--lr-rating-active-color: var(--lr-color-success); --lr-rating-gap: var(--lr-space-s); --symbol-color-active: var(--lr-color-success); --symbol-size: var(--lr-font-size-2xl)"
></lr-rating>
<p id="preview"></p>
<script type="module">
  import "@aceshooting/lyra-ui/components/overlays/rating/rating.js";

  const rating = document.querySelector("lr-rating");
  const preview = document.getElementById("preview");
  rating.getSymbol = (value, selected) => (selected ? "♥" : "♡");
  rating.addEventListener("lr-hover", (event) => {
    const { phase, value } = event.detail;
    preview.textContent = phase === "end" ? "" : `Rate ${value}`;
  });
  rating.addEventListener("change", (event) =>
    console.log("native commit", event.target.value)
  );
  rating.addEventListener("lr-change", (event) =>
    console.log("committed", event.detail.value)
  );
</script>
```
