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

# `lr-time-range`

- **Import** `import '@aceshooting/lyra-ui/components/lr-time-range.js';` (stable tag alias; registers the tag)
- **Class** `LyraTimeRange`, also available unregistered from `@aceshooting/lyra-ui/components/forms/time-range/time-range.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** 7 parts, 19 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-time-range`

A two-handle brush/scrubber over a numeric domain (no date logic — callers map their own time axis
onto `[min, max]`). Form-associated (`static formAssociated = true`, via `ElementInternals`): an
ancestor `<fieldset disabled>` disables both handles and every preset button through an internal
`effectiveDisabled` getter, the same way it would a native `<input>`, without ever mutating the
consumer-facing `disabled` property/attribute itself.
The `base` part is an accessible `role="group"`: a non-empty host `aria-label` names the two-handle
aggregate. A native external `<label for>` remains available through `labels`, but it does not cross
into the shadow-root group. `startLabel` and `endLabel` continue to name the individual sliders.

Only the primary mouse button starts seeking or dragging. Right and middle presses leave the range
and input/change events untouched. Touch and pen gestures keep their existing behavior, including
concurrent pointer support; pointer cancellation and capture cleanup retain their existing
semantics.

**Properties:**

- `min: number = 0`
- `max: number = 100`
- `start: number = 0`
- `end: number = 100`
- `step: number = 1`
- `size: '2xs' | 'xs' | 's' | 'm' | 'l' | 'xl' | 'small' | 'medium' | 'large' = 'm'` (reflected) — visual size; proportionally
  scales the handle, track, and preset buttons via a single `--lr-time-range-size-scale` multiplier
  (not pixel-matched to `lr-input`'s row-height scale — this component's own dimensions aren't on
  that ladder); `small`/`medium`/`large` are exact aliases for `s`/`m`/`l`, and the drag hit-area
  never shrinks below 24px (WCAG 2.5.8)
- `disabled: boolean = false` (reflected)
- `startLabel?: string` (attribute `start-label`) — caller-owned `aria-label` override for the start
  handle; absence resolves localized `rangeStart` (`"Range start"` in English), while every
  supplied string — including `"Range start"` and `""` — remains literal
- `endLabel?: string` (attribute `end-label`) — equivalent override for the end handle; absence
  resolves localized `rangeEnd` (`"Range end"` in English)
- `valueFormatter?: TimeRangeValueFormatter` (attribute: false) — maps each finite, clamped
  `aria-valuenow` to optional human-readable `aria-valuetext`; called as
  `(value, handle: TimeRangeHandle)`, where `TimeRangeHandle = 'start' | 'end'`. The formatter may
  return `string | null | undefined`; a nullish result omits `aria-valuetext` for that handle.
  Leaving the property unset preserves the numeric-only contract
- `presets: readonly TimeRangePreset[] = []` (attribute: false) — readonly `TimeRangePreset {
label: string; start: number; end: number; id?: string }`; a bounded frozen snapshot of optional
  discrete presets (e.g. "Last 7 days") rendered as a
  `[part="presets"]` button row above the track — purely additive, the continuous brush is
  unaffected and both interaction modes coexist; picking one sets both handles and emits the same
  native/prefixed input and change sequences a committed drag or keyboard step would. Preset
  endpoints are clamped and ordered once, and that same normalized pair drives both application
  and `aria-pressed`/`data-active` projection. The optional `id` is a caller-owned
  correlation key copied into the snapshot verbatim and never read by the control itself; an
  untagged preset is unaffected
- `appliedPreset: TimeRangePreset | undefined` (read-only, attribute: false) — the frozen
  `presets` snapshot whose button produced the current range. Preset application updates this
  identity before its synchronous event sequence, so it can be read inside `input`/`change` or
  `lr-input`/`lr-change` handlers. It remains `undefined` before a preset is selected and is
  cleared by a real manual handle move, a controlled endpoint change away from that preset, a
  preset-collection replacement, or a form reset. Numeric equality never infers identity; no-op
  endpoint writes and reassigning the same preset snapshot preserve it
- `customError: string | null` (attribute `custom-error`, reflected) — consumer validation message

**Events:** a native-style composed `input` (no detail) then `lr-input` (`detail: { start, end }`),
both fired continuously while dragging or on each arrow/Home/End/PageUp/PageDown key press; and a
native-style composed `change` (no detail) then `lr-change` (`detail: { start, end }`), both fired
on pointer release, keyboard keyup, handle blur while a changed keyboard gesture is still pending,
or when a preset button is clicked. A blur commit retires the gesture before the later physical
keyup, so it cannot emit a duplicate change. The focused handle's native `focus` and `blur` are
re-dispatched from the host as bubbling, composed events. A failed native validity check emits one bubbling/composed,
cancelable `lr-invalid` alias; cancelling it cancels the native `invalid` event and suppresses the
browser's default validation UI.

**Methods:** `focus(options?)` and `click()` forward to `[part="handle-start"]`. `blur()` releases
whichever handle actually owns focus, falling back to the start handle when neither does. Without
these overrides the host's own `focus()`/`blur()`/`click()` are no-ops, because the real controls
live in the shadow root. `getForm()` returns the browser-resolved owning form.

`setCustomValidity(message)` is this control's **only** validation channel: every reachable range is
intrinsically legal, so there is no constraint for it to compute. A non-empty message raises
`customError`, becomes `validationMessage`, and blocks submission of the form it sits in; `''`
clears it. The error survives handle moves, preset picks and a form reset, exactly like a native
control — so a consumer re-validating a range on every `lr-input` calls this with the new message
(or `''`) each time rather than expecting the movement itself to clear it. The message is
caller-supplied and is used verbatim, never localized.

Programmatic writes to `start` and `end` are event-silent. When a controlled caller writes both in
one update, the pair is clamped and ordered atomically (for example `90/10` becomes `10/90` without
losing either endpoint). A one-sided write retains moved-handle semantics: a start above the current
end is pulled back to that end, while an end below the current start is pulled up to that start.

**`form.reset()` — `formResetCallback()`.** The control has no submitted value, but it does take
part in its owning form's reset, and a reset undoes everything the _user_ did to it:

- **The range** goes back to the declared `start`/`end` **content attributes** — the markup default,
  the way a native `<input>` resets to its `value` attribute rather than to its current IDL value. A
  handle with no attribute falls back to the domain bound it started at. The restored pair is
  normalized the same way a preset pick is (clamped into `[min, max]`, then ordered so
  `start <= end`), so an inverted or out-of-range declared range still restores to a legal one.
- **The interaction flag** is cleared, which makes the control pristine again: `:state(user-valid)`
  and `:state(user-invalid)` stop matching until the user touches it again. Without this, a range a
  consumer had rejected kept rendering as the user's mistake on a form they had just reset.
- **An in-flight keyboard gesture** is dropped, so the next key-up cannot commit an `lr-change` for
  a step the reset already discarded. Every in-flight pointer drag is also retired synchronously,
  including its window listeners, so a later pointer release cannot commit the restored range as a
  stale user change. Direct and fieldset disablement use the same gesture invalidation path.

Two things deliberately **survive** the reset, matching native semantics:

- **A `setCustomValidity()` message**, and with it `customError`, `validationMessage`,
  `:state(invalid)` and blocked submission. Only another `setCustomValidity('')` clears it. The
  reset stops it looking like the user's error; it does not decide the consumer's constraint is
  satisfied. If a reset should also clear your rejection, call `setCustomValidity('')` from your own
  `reset` listener.
- **`disabled`, `min`/`max`, `step`, `presets`, and every other author-set property** — a reset
  restores the user's edits, not the component's configuration.

The reset **emits nothing**: like a native control, it is the form's edit rather than the user's, so
no `input`/`change`/`lr-input`/`lr-change` fires. Read `start`/`end` in a `reset` listener on the
form if you need to react. There is deliberately no `formStateRestoreCallback()` beside it — this
control never calls `setFormValue()`, so the browser has no serialized state to hand back for
autofill or back/forward restore.

**Slots:** none.

**CSS parts:** `base` (the aggregate `role="group"`), `track`, `range`, `handle-start`, `handle-end`,
`presets`, `preset-button`

**Themeable custom properties:** mostly shared tokens — `--lr-color-border`, `--lr-color-brand`,
`--lr-color-surface`, `--lr-shadow-s` (handles), `--lr-opacity-disabled` (`:host(:disabled)`
dimming, including ancestor-fieldset disablement), plus (for `presets`) `--lr-color-text`,
`--lr-color-on-brand` (the active preset
button's text), `--lr-radius`, `--lr-space-xs/-s`, `--lr-transition-fast`,
`--lr-focus-ring-*`.

Three component-local properties recolor the **active** preset button independently of the shared
palette: `--lr-time-range-preset-active-bg` (falls back to `--lr-color-brand`),
`--lr-time-range-preset-active-border-color` (falls back to `--lr-color-brand`), and
`--lr-time-range-preset-active-color` (falls back to `--lr-color-on-brand`). Unset, each resolves
to exactly the token the rule used before they existed, so the default rendering is unchanged.

Pointer states and handle chrome are independently themeable too:

- `--lr-time-range-preset-hover-border-color`,
  `--lr-time-range-preset-pressed-border-color`, and `--lr-time-range-preset-pressed-bg` control
  preset hover/press paint.
- `--lr-time-range-handle-bg`, `--lr-time-range-handle-border-color`,
  `--lr-time-range-handle-hover-bg`, and `--lr-time-range-handle-pressed-bg` control the handle's
  resting, hovered, and pressed paint.

Every hook falls through to the prior shared brand/surface token or color-mix expression, so old
themes retain their rendering and can opt into only the state they need.

One additional component-local property, `--lr-time-range-size-scale` (unitless multiplier,
default 1, automatically set based on the `size` property), scales the handle, track, and preset
button dimensions proportionally — unset, it defaults to 1 (size='m', no scaling).

They exist because the active preset is marked with an attribute on a part, and
`::part(preset-button)[data-active]` is **invalid CSS** — an attribute selector cannot follow
`::part()`. Without these, recoloring just the active preset meant hijacking the shared
`--lr-color-brand`/`--lr-color-on-brand` tokens and repainting everything else that reads them.

They are written as **inline `var()` fallbacks at the point of use, never declared on `:host`** —
deliberately, because a `:host` declaration would shadow any value an ancestor set. Setting one on
any ancestor of the `<lr-time-range>` therefore reaches it. (The same technique is used for
`lr-emoji-picker`'s `--lr-emoji-picker-active-bg`.)

**Optional peer deps:** none.

```html
<lr-time-range id="months" min="0" max="2" start="0" end="2"></lr-time-range>
<script>
  const months = ["April 2023", "May 2023", "June 2023"];
  const range = document.getElementById("months");
  range.valueFormatter = (value, handle) =>
    `${handle === "start" ? "From" : "Through"} ${months[value]}`;
  range.addEventListener("lr-change", (e) =>
    console.log(e.detail.start, e.detail.end)
  );
</script>
```

**Known gotchas:**

- Keyboard support now matches the full WAI-ARIA APG slider pattern: ArrowUp/Right and ArrowDown/Left
  move by `step` (RTL-aware — under `direction: rtl` the forward/backward keys swap so they still
  track the visually-adjacent direction), PageUp/PageDown move by `step * 10`, and Home/End jump to
  that handle's actual _reachable_ bound — clamped by the sibling handle's current value, not the
  component's full `[min, max]` domain, so Home/End on the `end` handle can't jump past `start` (and
  vice versa). Pointer-drag is RTL-aware the same way (mirrors the drag ratio under `direction:
rtl`).
- **Click-to-seek on the track.** A pointerdown anywhere on `[part="base"]` other than a handle
  itself jumps whichever handle is nearer the clicked position to that point and continues as the
  same drag gesture, then focuses that handle so arrow keys carry on from there. It emits `lr-input`
  on the jump and a single `lr-change` on release, mirrors the ratio under RTL exactly as dragging
  does, breaks a tie toward the handle that can actually travel toward the click, and does nothing
  while the control is disabled. This matches `lr-slider[range]`'s identical behavior; a pointerdown
  that starts on a handle is still a plain handle drag.
- A disabled handle now gets `aria-disabled="true"` in addition to losing `tabindex` — a
  screen-reader user exploring by virtual cursor no longer hears it announced as a live, adjustable
  slider.
- `aria-valuemin`/`aria-valuemax` on each handle report that handle's reachable sub-range (bounded by
  its sibling), not the full domain — matching what Home/End actually jump to.
- `valueFormatter` is presentation-only: `aria-valuenow`, geometry, emitted values, and preset
  matching stay numeric. Non-finite handles omit both `aria-valuenow` and `aria-valuetext` and are
  never passed to the formatter.
- Handles a `min > max` domain, a non-positive/non-finite `step`, and disabled-mid-drag/
  disconnect-mid-drag correctly (tested) — safe to rely on those edge cases. Concurrent drags are
  tracked per `pointerId` (not a single scalar), so a two-finger touch — one finger per handle —
  moves both independently instead of the second pointer hijacking which handle the first pointer's
  moves apply to; `pointercancel`/`lostpointercapture` (not just `pointerup`) both end a drag, same
  fix as `lr-multi-split`.
- Non-finite domain/handle values use finite fallback geometry, and non-finite or negative steps are
  treated as unstepped; invalid values never become `NaN`/`Infinity` CSS or ARIA strings.
- `startLabel`/`endLabel` only override each handle's `aria-label`; they don't affect
  `aria-valuenow`/`aria-valuemin`/`aria-valuemax`, `valueFormatter`, or any visible text.
- An ancestor `<fieldset disabled>` toggling is reflected via `formDisabledCallback` into
  `effectiveDisabled` (tracked separately from the consumer's own `disabled`), so re-enabling the
  fieldset correctly restores a handle that had `disabled` set explicitly by the consumer, and vice
  versa — mirrors `lr-combobox`'s identical pattern.

**Additional API surface:**

- `--lr-time-range-handle-size` — Visible handle diameter. Default: `14px*scale`.
- `--lr-time-range-hit-size` — Actual drag hit-area diameter; endpoint handles are inset by half this distance so the hit geometry stays inside the host. Default: `max(24px,28px*scale)`.
- `--lr-time-range-track-size` — Track and selected-range thickness. Default: `4px*scale`.
- `--lr-time-range-base-size` — Brush baseline block size. Default: `1.5rem*scale`.
- `--lr-time-range-preset-gap` — Gap between preset buttons. Default: `var(--lr-space-xs)`.
- `--lr-time-range-preset-radius` — Preset button corner radius. Default: `var(--lr-radius)`.
- `--lr-time-range-preset-padding` — Preset button padding, scaled by `size`.
- `--lr-time-range-preset-font-size` — Preset button font size, scaled by `size`.

---
