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

# `lr-tab-group`

- **Import** `import '@aceshooting/lyra-ui/components/lr-tab-group.js';` (stable tag alias; registers the tag)
- **Compound usage registrations** `import '@aceshooting/lyra-ui/components/lr-tab.js';`, `import '@aceshooting/lyra-ui/components/lr-tab-panel.js';` — required by the consumer-supplied child tags in this reference; usage-only, not registration dependencies of `lr-tab-group`
- **Class** `LyraTabGroup`, also available unregistered from `@aceshooting/lyra-ui/components/layout/tab-group/tab-group.class.js`
- **Family** `components/layout/` — see `llms/index.md` for its siblings
- **Status** `stable` since `8.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** 16 parts, 17 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-tab-group`

A tab strip. Mirrors `wa-tab-group` / `sl-tab-group`.

With manual activation, removing the focused unselected tab rehomes actual focus to a valid survivor
while retaining a valid selected tab and panel. This repair emits no show/hide events and preserves
focus already held by an outside control.

**Renamed in 8.0.0.** This element used to be `<lr-tabs>`. The tag is now `<lr-tab-group>`, its
single `lr-tabs-change` event is now the `lr-tab-hide` → `lr-tab-show` pair below, and every
`--lr-tabs-*` custom property is now spelled `--lr-tab-group-*` (`--lr-tabs-selected-color` →
`--lr-tab-group-selected-color`, and so on). Neither old spelling survives as an alias, and all
three fail silently: `<lr-tabs>` is an unknown element that renders its children unstyled,
`lr-tabs-change` never fires, and a `--lr-tabs-*` declaration is inert. Rename all three in the same
change. The rename is what lets `<lr-tab>` and `<lr-tab-panel>` (below) exist as a family, which is
what makes migrating from either upstream a pure tag rename.

**Canonical child model:** direct `<lr-tab panel="x">` + `<lr-tab-panel name="x">` pairs. This is
the single shape shared with both upstreams, so markup renames mechanically. The pre-9.0
`<div slot="x" label="…">` data/attribute model is removed; migrate each former child into one
descriptor and one matching panel. An unpaired panel never creates a tab.

The group assigns private projection `slot` values itself; consumers do not need to write them.
Those writes are temporary ownership, not destructive normalization: when a descriptor or panel is
removed, moved to another group, disconnected/reconnected, or adopted into another document, the
group restores that element's latest author-owned `slot` value. An author write made while the group
owns the projection is remembered and then reprojected until release.

Each `<lr-tab>`'s content is projected into the real `role="tab"` button, so a tab can carry an icon
or badge while the button's accessible name stays exactly its accessibility-exposed flattened text.
Direct default-slot element roots in that visual label become inert while projected and regain
their latest author-owned inert state when released; use text/glyph markup, not an independent
action. Author `aria-hidden`, hidden, inert, and CSS-hidden branches are excluded from the name, and
direct-label text or visibility changes refresh it. `active` on a paired or labeled unpaneled tab is
an SSR hint: the group reads an initially active tab and then keeps the source tab and any matching
panel attributes synchronized with its own selection after hydration.

Implements the WAI-ARIA APG tabs pattern. With the default `activation="auto"`, Left/Right (swapped
under RTL, or Up/Down when `placement` is `start`/`end`) move focus _and_ selection together; with
`activation="manual"` they move focus only and Enter/Space commits. Home/End jump to the first/last
enabled tab, and a roving `tabindex` follows the focused tab.
Keyboard handling starts from the real event-target tab (then actual shadow focus), so a controlled
`active` write cannot make Arrow/Delete/Enter operate on a different remembered tab.
An enabled `closable` `<lr-tab>` also puts `aria-keyshortcuts="Delete"` on its real tab button.
Delete emits that descriptor's `lr-close` request without creating a second tab stop or changing
selection.

**Properties:**

- `active: string = ''` (reflected) — the active tab's panel name; falls back to the first enabled
  tab whenever the current value doesn't resolve to one (including on every children/attribute
  change, tracked via a `MutationObserver`)
- `accessibleLabel: string | null = null` (attribute `aria-label`) — accessible name applied to the
  `role="tablist"` strip; attribute-reflects from a host-level `aria-label`. `null` omits the
  attribute; an explicitly empty value is preserved (there is no localized default name).
- `placement: 'top' | 'bottom' | 'start' | 'end' = 'top'` (attribute `placement`, reflected) — which
  edge the strip sits on. `start`/`end` are _logical_, so they mirror under RTL with no `:dir()`
  rule of your own; either turns the tablist vertical, which sets `aria-orientation="vertical"` and
  switches the navigation keys to Up/Down per the APG.
- `activation: 'auto' | 'manual' = 'auto'` (reflected) — `auto` moves selection with focus; `manual`
  moves focus only and waits for Enter or Space. Use `manual` whenever revealing a panel is
  expensive: automatic activation would reveal every panel the user arrows past. Under `manual` the
  roving `tabindex="0"` sits on the _focused_ tab, which may differ from the selected one.
- `withoutScrollControls: boolean = false` (reflected, attribute `without-scroll-controls`) and
  `noScrollControls: boolean = false` (reflected, attribute `no-scroll-controls`) — the same opt-out
  under Web Awesome's spelling and Shoelace's. Both are read, either one suppresses the overflow
  scroll controls described below, and neither is deprecated: a consumer arriving from either
  upstream finds their own attribute working. Left unset, an overflowing horizontal strip gets the
  controls.
- `fixedScrollControls: boolean = false` (reflected, attribute `fixed-scroll-controls`) —
  keeps both controls laid out across an overflowing range. Without it, the start control is hidden
  at the logical start and the end control is hidden at the logical end; an exhausted control is a
  no-op in either mode. The flag never makes controls appear on a row that fits, which remains gated
  on real overflow.
- `defaultSlot: HTMLSlotElement` (property only) — the real unnamed shadow slot expected by mapped
  integrations. Lyra exposes it for slot observation but keeps it hidden because every accepted
  tab and panel is projected through a deterministic named slot.

**Methods:** `show(name: string): void` activates the matching enabled tab through the same
`lr-tab-hide` then `lr-tab-show` sequence as pointer/keyboard selection. Unknown, disabled, and
already-active names are no-ops.

**Overflow and scrolling.** The tablist is a native scroll container (`overflow-x: auto`). A
horizontal row that does not fit additionally gets two pointer scroll controls flanking it inside
`[part="nav"]`. Logical edge state drives both controls and the mask: at the initial edge only the
inline-end fade/control appears, in the middle both appear, and at the final edge only inline-start
appears. Native scroll plus a `ResizeObserver` on the strip and its rendered tabs refresh that state,
so intrinsic label/font geometry changes cannot leave stale controls. The same contract works under
RTL, and forced-colors mode removes the alpha mask entirely instead of obscuring text. A row that
fits gets neither affordance.

Controls are rendered only for horizontal placement. A `start`/`end` strip scrolls natively in the
block direction, and in a fixed block allocation the vertical nav, tablist, and panel body stay
within the group: the tablist and body become their own scroll containers rather than expanding the
host. One horizontal control press travels 80% of the visible row smoothly, or instantly under
`prefers-reduced-motion`; under RTL the physical delta mirrors.

The controls are `aria-hidden="true"` and `tabindex="-1"`: a pointer affordance only, matching
upstream. The strip is already fully keyboard-scrollable without them — the roving `tabindex` puts
every tab one arrow key away and focusing a tab scrolls it into view — so two extra tab stops in the
middle of the strip would buy no capability. They still carry a localized `aria-label`, so the name
is there for automation and for a consumer that chooses to expose them. Pressing one does not move
focus off the tab the user was on.

**Events:**

- `lr-tab-show` (`detail: { name: string }`) — a tab became active via click, keyboard, or `show()`.
  Not fired
  when `active` self-corrects to a valid tab (initial default, or a tab disappearing/becoming
  disabled underneath the current selection).
- `lr-tab-hide` (`detail: { name: string }`) — the outgoing tab, emitted immediately _before_ the
  matching `lr-tab-show`, so a listener that tears down the old panel always runs before the one
  that builds the new one. Not fired when there was no previous selection.
- `lr-activate` (`detail: { value: string }`) — fired on **every** user activation of a navigable
  tab (a click, an Arrow/Home/End key under `activation="auto"`, or Enter/Space under
  `activation="manual"`), whether or not the active tab actually moved. Bubbling, composed, not
  cancelable — it reports that the user picked a tab and gates nothing. `value` is the activated
  tab's panel name, the same identity `lr-tab-show` reports under the key `name`. Use it for the
  repeat pick `lr-tab-show` deliberately stays silent for: "reload that panel". From the keyboard
  that case is otherwise unobservable, because Home on an already-first active tab (or End on an
  already-last one) activates a tab and produces no click at all. When an activation _does_ move the
  tab, `lr-tab-hide` and `lr-tab-show` are emitted first. The programmatic `show()` method is not a
  user activation and never fires it.

**Slots:** default — canonical `<lr-tab>`/`<lr-tab-panel>` pairs. `nav` is the upstream-compatible
projection slot a standalone `<lr-tab>` uses before a hydrated group assigns its private slot.

```html
<!-- element model: renames straight across from wa-/sl- -->
<lr-tab-group placement="start" activation="manual">
  <lr-tab panel="general">General</lr-tab>
  <lr-tab panel="danger" disabled>Danger zone</lr-tab>
  <lr-tab-panel name="general">General settings</lr-tab-panel>
  <lr-tab-panel name="danger">Danger zone</lr-tab-panel>
</lr-tab-group>
```

**CSS parts:** `base` and `tab-group` are aliases on the same root wrapper around the tablist and
panels; `nav` (the row wrapping the
tablist together with the two overflow scroll controls; mirrors the upstream part of the same name),
`tablist` and `tabs` (aliases on the `role="tablist"` row of tab buttons and scroll container),
`body` (wrapper around all panels), `scroll-button` and `scroll-button__base` (aliases shared by
both overflow controls), `scroll-button-start`/`scroll-button--start` and
`scroll-button-end`/`scroll-button--end` (aliases on the individual
controls that scroll the tabs toward their inline start and end — under RTL "start" is the
right-hand one), `scroll-button-glyph` (the chevron wrapper inside a control; this wrapper is what
mirrors under RTL, never the icon), `tab` (a single tab button), `active-tab-indicator` (the selected
tab's directional indicator), and `panel`
(a single `role="tabpanel"` wrapper, one per tab, hidden unless active).
The two controls exist in the DOM whenever the group can have them at all (horizontal `placement`,
no opt-out). Non-overflow and inactive-edge qualifiers are wrapped in `:where()`, so a consumer's
own `::part(scroll-button)` rule can override presentation without `!important`.

**Themeable custom properties:** `--lr-scroll-fade-size` (default `2rem`) — width of the mask fade
at each inline scroll edge of the tablist, painted only while the tablist actually overflows and
only for a horizontal `placement`. `--lr-tab-group-selected-color` (default
`var(--lr-color-brand)`) — text color of the selected tab, scoped to `[aria-selected='true']` only,
so it never repaints a hovered unselected tab. `--lr-tab-group-indicator-color` (default
`var(--lr-color-brand)`) — the selected tab's indicator rule, themeable independently of its text
color (an underline on a `top`/`bottom` strip, an inline edge on a vertical one).
`--lr-tab-group-hover-color` (default `var(--lr-color-text)`) — text color of a hovered, non-disabled
tab, independent of the two selected-state hooks. All three are declared as inline `var()` fallbacks
at the point of use rather than on `:host`, so each can be set on the element _or on any ancestor_ —
the pattern exists because `::part(tab)[aria-selected='true']` is invalid CSS (Shadow Parts forbids
an attribute selector after `::part()`), which previously left overriding the library-wide
`--lr-color-brand`/`--lr-color-text` tokens as the only way to restyle a selected or hovered tab,
repainting everything else that reads them. Unset, each falls back to the token its rule used
before, so rendering is unchanged. The upstream hooks `--indicator-color` (selected indicator),
`--track-color` (resting strip rule), and `--track-width` (resting strip-rule thickness) are read
first, with the Lyra/token values as fallbacks. `--lr-tab-group-vertical-nav-max-inline-size`
(default `var(--lr-size-12rem)`) caps a `start`/`end` nav's logical inline size while still allowing
it to shrink in a constrained allocation. Its inline fallback means it can be set on the group or
an ancestor; long single-line tab labels ellipsize within the cap rather than expanding the group or
starving the panel.

`--lr-tab-group-active-bg` (default `color-mix(in oklab, transparent,
var(--lr-color-mix-partner) var(--lr-color-mix-active))`) and
`--lr-tab-group-active-color` (default
`var(--lr-tab-group-hover-color, var(--lr-color-text))`) style a pressed, non-disabled tab.
The overflowing row's controls have their own hooks:
`--lr-tab-group-scroll-button-hover-color` (default `var(--lr-color-text)`),
`--lr-tab-group-scroll-button-active-bg` (default `color-mix(in oklab, transparent,
var(--lr-color-mix-partner) var(--lr-color-mix-active))`), and
`--lr-tab-group-scroll-button-active-color` (default `var(--lr-color-text)`). Each is an
inline fallback, so a wrapper can retheme the interaction state without affecting ordinary tabs,
selection, or the other control state.

The active panel's mouse-hover preview — a subtler cue than `:focus-visible` for a panel that is
also keyboard-focusable, matching `lr-scroller`'s viewport and `lr-carousel`'s scroll container —
has its own four-longhand outline shape: `--lr-tab-group-panel-hover-outline-width` (default
`var(--lr-border-width-thin)`), `--lr-tab-group-panel-hover-outline-style` (default `solid`),
`--lr-tab-group-panel-hover-outline-color` (default `var(--lr-color-border)`, set to `transparent`
to opt out entirely), and `--lr-tab-group-panel-hover-outline-offset` (default
`var(--lr-focus-ring-offset)`). Unset, all four resolve to the rule's previous literal paint.

Otherwise shared tokens — `--lr-space-xs/-s/-m`,
`--lr-color-border/-text-quiet/-text/-brand`, `--lr-transition-fast`, `--lr-radius`,
`--lr-focus-ring-width/-color/-offset`, `--lr-opacity-disabled`.

**Optional peer deps:** none.

```html
<lr-tab-group active="general">
  <lr-tab panel="general">General</lr-tab>
  <lr-tab panel="advanced" disabled>Advanced</lr-tab>
  <lr-tab-panel name="general">General settings…</lr-tab-panel>
  <lr-tab-panel name="advanced">Advanced settings…</lr-tab-panel>
</lr-tab-group>
<script type="module">
  const group = document.querySelector("lr-tab-group");
  group.addEventListener("lr-tab-show", (e) => console.log(e.detail.name));
  group.show("general");
</script>
```

**Known gotchas:**

- **`inert` on a child excludes its tab from arrow-key navigation, exactly as `disabled` does.** An
  inert element refuses focus outright, so a roving `tabindex` that stepped onto one would leave
  `focus()` a silent no-op and strand the arrow key with focus back on `<body>`. The tab button
  rendered for an inert source child is itself marked `inert`, so the two can never disagree,
  Home/End skip it, and `active` is never resolved to it. Only the child's **own** `inert` counts,
  never an ancestor's: a tab group inside a subtree an open modal has inerted is inert as a whole,
  and treating every tab as unreachable there would reset `active` to `''` and blank every panel for
  as long as the dialog is open.
- Tabs are rebuilt from direct children via a `MutationObserver` — not `slotchange` — because a
  brand-new tab's `slot` name has no matching `<slot>` to fire `slotchange` on until this component
  has already rendered one for it, and neither `slotchange` nor any Lit lifecycle hook observes a
  plain attribute edit on a light-DOM child at all. Text/content and relevant
  accessibility/visibility mutations below a direct `<lr-tab>` refresh that button's flattened
  name; arbitrary nested mutations inside panels remain ignored.
- If two `<lr-tab>` descriptors share the same panel name, the first wins. A second matching panel
  is likewise ignored for projection, keeping selection, focus, events, and ARIA idrefs unambiguous.
- The navigation keys follow `placement`, not the writing mode: a `top`/`bottom` strip uses
  Left/Right (swapped under RTL via `internal/rtl.ts`'s `isRtl()`), and a `start`/`end` strip uses
  Up/Down with no RTL swap, because block flow does not reverse. Only one pair is live at a time —
  there is no set of keys that works for both placements.
- The two overflow controls are `aria-hidden`, so an automated check that looks for a _focusable_
  "scroll tabs" button will not find one. Assert on `[part~="scroll-button"]` (and on the tablist's
  `scrollLeft` moving) instead.

---
