@aceshooting/lyra-ui 18.0.0 → 18.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/custom-elements.json +1 -1
  3. package/design-tokens.json +1 -1
  4. package/dist/cli/migration-contract.json +1 -1
  5. package/dist/components/conversation/message-actions/message-actions.class.d.ts +2 -1
  6. package/dist/components/forms/combobox/combobox.styles.js +1 -1
  7. package/dist/components/forms/icon-button/icon-button.class.d.ts +18 -9
  8. package/dist/components/forms/select/select.class.d.ts +34 -5
  9. package/dist/components/forms/select/select.class.js +4 -4
  10. package/dist/components/forms/select/select.styles.js +1 -1
  11. package/dist/components/layout/filter-bar/filter-bar.class.d.ts +80 -8
  12. package/dist/components/layout/filter-bar/filter-bar.class.js +11 -3
  13. package/dist/components/layout/filter-bar/filter-bar.styles.js +1 -1
  14. package/dist/components/layout/virtual-list/virtual-list.class.d.ts +47 -16
  15. package/dist/components/layout/virtual-list/virtual-list.class.js +1 -1
  16. package/dist/components/media/map/map.class.d.ts +134 -5
  17. package/dist/components/media/map/map.class.js +48 -21
  18. package/dist/components/media/map/map.styles.js +1 -1
  19. package/dist/components/utility/copy-button/copy-button.class.d.ts +4 -3
  20. package/dist/custom-elements-jsx.d.ts +1 -1
  21. package/dist/events.d.ts +7 -1
  22. package/dist/internal/package-metadata.d.ts +1 -1
  23. package/dist/internal/package-metadata.js +1 -1
  24. package/dist/internal/tokens.styles.js +1 -1
  25. package/dist/lyra.d.ts +1 -1
  26. package/dist/styles/tokens-root.css +1 -1
  27. package/dist/svelte.d.ts +1 -1
  28. package/dist/testing/lyra-tag-event-map.js +1 -1
  29. package/dist/vue.d.ts +1 -1
  30. package/llms/components/lr-combobox.md +8 -4
  31. package/llms/components/lr-filter-bar.md +37 -4
  32. package/llms/components/lr-icon-button.md +15 -7
  33. package/llms/components/lr-map.md +68 -6
  34. package/llms/components/lr-option.md +8 -4
  35. package/llms/components/lr-select.md +30 -5
  36. package/llms/shared.md +41 -0
  37. package/llms/tokens.md +7 -4
  38. package/llms-full.txt +203 -25
  39. package/package.json +1 -1
  40. package/vscode-css-data.json +1 -1
  41. package/vscode-html-data.json +1 -1
  42. package/web-types.json +1 -1
@@ -170,10 +170,14 @@ An async `source` row can carry the same two fields (`start`, `end`) alongside i
170
170
  - `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
171
171
  spelled the same as on `lr-dropdown`/`lr-popup`/`lr-popover`. Unset (the default), the listbox
172
172
  sizes to its own content, clamped between `--lr-size-12rem` and `--lr-size-28rem`, exactly as
173
- before. `sync="width"` drops that content-based clamp (keeping only the outer viewport/
174
- available-space ceiling) so a full-width trigger with short option labels gets a listbox that
175
- aligns to its own edges instead of floating narrower in the middle. Like `placement`, a change
176
- takes effect the next time the listbox opens
173
+ before. `sync="width"` drops that content-based clamp so a full-width trigger with short option
174
+ labels gets a listbox that aligns to its own edges instead of floating narrower in the middle.
175
+ A synced listbox is capped on `--lr-positioner-available-inline-size` **alone** the space the
176
+ positioner actually measured beside the anchor, which still keeps an over-wide trigger from
177
+ pushing it off-screen. `--lr-popover-viewport-clamp` does **not** apply to a synced listbox — its
178
+ 92vw default used to shorten the listbox against its own trigger in exactly the full-width case
179
+ `sync` exists for, and `lr-popup` never applied it either. It still applies with `sync` unset.
180
+ Like `placement`, a change takes effect the next time the listbox opens
177
181
  - `clearable: boolean = false` (reflected) — displays the clear button while there is something to
178
182
  clear on **either** axis this control owns: a committed selection, or _visible_ filter text. See
179
183
  "the clear button covers two axes" below
@@ -82,8 +82,21 @@ until something else registers it, the same trade `icon-button-register.js` docu
82
82
  - `disabled: boolean = false` (reflected) — disables every filter control and reset action.
83
83
  - `loading: boolean = false` (reflected) — shows the status spinner and disables reset while leaving
84
84
  filters editable.
85
- - `hasActiveFilters: boolean` (read-only) — whether any configured filter currently has a value.
86
- Drives the reset button's own disabled state; unaffected by `activeFiltersDisplay`.
85
+ - `hasActiveFilters: boolean` (read-only) — whether any configured filter currently has a value,
86
+ including one sitting at its own declared `defaultValue`. Drives the reset button's own disabled
87
+ state in every `activeFiltersDisplay` mode except `'changed'`; the getter itself is unaffected by
88
+ `activeFiltersDisplay`.
89
+ - `hasChangedFilters: boolean` (read-only) — whether any filter's value differs from its own
90
+ declared `defaultValue`, using the same equality `activeFiltersDisplay: 'changed'` filters its
91
+ chip row on: a `readonly string[]` default compares positionally, everything else compares with
92
+ `Object.is`. This is the counterpart to `hasActiveFilters`, not a synonym — a bar whose filters
93
+ were all declared with non-empty defaults and never touched reads `hasActiveFilters === true` and
94
+ `hasChangedFilters === false`, because a bar whose defaults narrow the view on load has not been
95
+ narrowed by the user. A filter with no declared `defaultValue` counts as changed the moment it
96
+ holds any value at all (there is nothing for it to still equal), and clearing a filter that *does*
97
+ declare one counts as changed too, since `reset()` would restore it — which is the one case where
98
+ this getter and the `'changed'` chip row differ, the row's entries being non-empty by
99
+ construction. Always live, never cached.
87
100
  - `invalidFilterIds: readonly string[]` (read-only) — immutable ids of required filters whose
88
101
  values are unset.
89
102
  - `activeFiltersDisplay: 'all' | 'changed' | 'hidden' = 'all'` (reflected, attribute
@@ -97,6 +110,10 @@ until something else registers it, the same trade `icon-button-register.js` docu
97
110
  at each index), matching this component's only other array-equality precedent (a custom adapter's
98
111
  own `clearValue` comparison); a `'date-range'` value is a single composed string, so it compares
99
112
  like any other string. Removing a chip always clears that filter, unaffected by this property.
113
+ `'changed'` additionally gates the reset button on `hasChangedFilters` instead of
114
+ `hasActiveFilters`, so an untouched defaults-only bar — which renders no chip in this mode — no
115
+ longer offers an enabled reset that would change nothing. Enablement under `'all'` and `'hidden'`
116
+ is unchanged, `disabled`/`loading` still win in every mode, and `reset()` itself is untouched.
100
117
 
101
118
  The composed reset action uses `lr-button`'s default `m` size tier, matching the default rendered
102
119
  height of adjacent select, combobox, input, and date fields instead of introducing a shorter action
@@ -231,8 +248,8 @@ control at all (see **Chip-only filters** below): its value belongs to a widget
231
248
  page, so the bar renders only its active-filter chip and gives it no toolbar cell.
232
249
 
233
250
  Every built-in (non-`'custom'`) filter definition additionally accepts optional `size: LyraSize`,
234
- `icon: unknown` and `labelVisibility: 'visible' | 'hidden'` fields, and every one whose composed
235
- control ships a clear action also accepts `clearable: boolean`. They are forwarded verbatim to that
251
+ `icon: unknown` and `labelVisibility: 'visible' | 'hidden' | 'auto'` fields, and every one whose
252
+ composed control ships a clear action also accepts `clearable: boolean`. They are forwarded verbatim to that
236
253
  control's own same-named property — `icon` into its `start` slot exactly like a choice option's own
237
254
  `icon`, rendered inert and `aria-hidden`; `clearable` reaching `<lr-date-input>` under its own
238
255
  `with-clear` spelling, since that control has no `clearable`. `'text'` also accepts
@@ -251,6 +268,20 @@ so a compact toolbar row still names every field for assistive technology; visua
251
268
  `::part(filter-control-label)` in CSS, the only previous option, removed the accessible name along
252
269
  with the text.
253
270
 
271
+ `labelVisibility: 'auto'` is the width-dependent middle between the two. It renders exactly what
272
+ `'visible'` renders — the same stacked label element, the same accessible name computed from it, no
273
+ `aria-label` and no placeholder fallback — and the bar's own stylesheet visually clips that label
274
+ once the bar's allocation drops below `30rem`. The threshold is a container query on the host, so it
275
+ reads the bar's own allocated width, not the viewport's: the same definitions render labelled across
276
+ a dashboard and unlabelled in a 320px side panel, dialog or split pane, with no host-side breakpoint
277
+ logic. The label element is never removed at any width, so the field's accessible name is identical
278
+ in both states, and `'auto'` deliberately does not route the name onto the control the way
279
+ `'hidden'` does — doing so would name a wide-allocation field twice. A `'checkbox-menu'` filter
280
+ participates through its own trigger label run, the same one `'hidden'` already clips there. The
281
+ threshold is fixed rather than themeable: a CSS container query's prelude cannot read a custom
282
+ property (`var()` is not substituted in an at-rule prelude), so a `--lr-*` hook for it would parse
283
+ and silently never apply.
284
+
254
285
  `'combobox'` also accepts the same `debounce?: number` (ms) `'text'` already had: it coalesces a
255
286
  burst of rapid selection changes (picks, a multi-select toggle, an
256
287
  `allowCustomValue`/`allowCreate` commit, or the clear action) into one delayed commit. Unlike
@@ -280,6 +311,8 @@ makes that text visually hidden — never removed — so the button keeps its ac
280
311
  one case where the hidden label would be the *only* thing the button says (hidden routing, no
281
312
  declared `placeholder`, nothing selected) the label routes to the visible summary instead of being
282
313
  emitted twice, so the trigger's accessible name stays "Teams", never "Teams Teams".
314
+ `labelVisibility: 'auto'` clips that same trigger label run, and only below the `30rem` threshold —
315
+ the label run is always emitted under `'auto'`, since nothing is routed to the summary there.
283
316
 
284
317
  Because its trigger is a button rather than a field, a `required` `'checkbox-menu'` deliberately
285
318
  renders **no** required asterisk and sets **no** `aria-invalid`: the shared required marker has no
@@ -131,15 +131,23 @@ above 24px — see `llms/shared.md`. **`--lr-icon-button-size` is element-scoped
131
131
  token in this section:** the shared token layer re-declares it on every `lr-*` host's own `:host`,
132
132
  so a rule that sets `--lr-icon-button-size` on an ancestor wrapper is reset the moment it crosses
133
133
  into any intervening `lr-*` component and never reaches a `<lr-icon-button>` composed inside it
134
- (e.g. one slotted through `<lr-popover>`). The only two levers that actually reach it are: setting
134
+ (e.g. one slotted through `<lr-popover>`). The three levers that actually reach it are: setting
135
135
  `--lr-icon-button-size` directly on the icon button element itself, where no intervening component
136
- sits between the rule and the property; or setting `--lr-theme-icon-button-size` on an ancestor,
137
- which the shared layer reads through `var()` at every level and which therefore reaches a
136
+ sits between the rule and the property; setting `--lr-icon-button-size-scope` on any ancestor to
137
+ resize one subtree; or setting `--lr-theme-icon-button-size` on an ancestor to resize the whole
138
+ application. The shared layer reads the latter two through `var()` at every level, so both reach a
138
139
  `<lr-icon-button>` composed inside another component, e.g. `<lr-copy-button>`/
139
- `<lr-message-actions>`. Lowering the floor for a dense action row through either lever is safe even
140
- below 24px: a coarse-pointer/no-hover media rule floors the RENDERED hit area back at 2.75rem/44px
141
- regardless of how far the override lowered it, so the control stays comfortably tappable the moment
142
- the pointer reaching it is a finger rather than a mouse. `--lr-icon-button-radius` (default
140
+ `<lr-message-actions>` -- see `llms/shared.md` for the full scope table.
141
+
142
+ Lowering the floor for a dense action row below 24px is safe **only through the two ancestor
143
+ levers**. The coarse-pointer/no-hover media rule reads those, and floors the RENDERED hit area back
144
+ at 2.75rem/44px however far the override lowered it, so the control stays comfortably tappable the
145
+ moment the pointer reaching it is a finger rather than a mouse. It does **not** rescue
146
+ `--lr-icon-button-size` set directly on the element: that declaration comes from the outer tree and
147
+ outranks the shadow tree's own `:host` rule, so the media rule never wins and the rendered hit area
148
+ is exactly the value set -- `--lr-icon-button-size: 1rem` really does render a 16px target under a
149
+ coarse pointer, which fails WCAG 2.2 SC 2.5.8. Prefer `--lr-icon-button-size-scope` when the intent
150
+ is a denser row; reach for the element-scoped name only as a deliberate, localized trade-off. `--lr-icon-button-radius` (default
143
151
  `--lr-radius`) is not re-declared anywhere in the shared layer, so — like every other token below —
144
152
  it inherits normally from an ancestor even through an intervening component; it is the
145
153
  `[part='button']` corner radius, retunable without a `::part(button)` rule — the same
@@ -9,7 +9,7 @@
9
9
  - **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
10
10
  - **Deprecations** none
11
11
  - **Optional peers** `maplibre-gl` — see `llms/peers.md`
12
- - **Themeable via** 22 parts, 8 custom properties — see this component's own `@csspart`/`@cssprop` list below
12
+ - **Themeable via** 26 parts, 8 custom properties — see this component's own `@csspart`/`@cssprop` list below
13
13
  - **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`
14
14
 
15
15
  ---
@@ -88,6 +88,21 @@ LyraMapLegendPattern }`, where `LyraMapLegendPattern` is `'solid' | 'diagonal' |
88
88
  and their point-icon defaults, and the same validation applies (path data only, at most 8,192
89
89
  characters, positive `viewBox` dimensions). An unusable record is dropped and that row keeps
90
90
  rendering its colour swatch, exactly as a row that supplies no `icon` does.
91
+ A row may finally carry a `group`: the section it belongs to, for a key that describes two
92
+ layers at once and otherwise could not say which rows belong to which. The rule is pinned
93
+ rather than inferred: **consecutive** entries sharing an identical `group` render as one section
94
+ — a visible heading plus a `role="group"` that heading names — an entry with **no** `group`
95
+ keeps its **declared** position rather than being hoisted above or sunk below a section, and a
96
+ `group` that reappears after an interruption opens a *second* section rather than reordering
97
+ rows to merge them. Declaration order is the one thing the legend never rewrites, because the
98
+ order is itself information about the map. `group` is caller-supplied **data**: it renders
99
+ verbatim and is never resolved through the locale catalogue. It is trimmed and bounded to 256
100
+ characters (ellipsized, since it is rendered prose rather than a key matched against
101
+ `point.field`); a non-string, empty or whitespace-only value leaves no `group` property on the
102
+ frozen row at all, so an empty string means "ungrouped" instead of an empty heading. Like the
103
+ row-level `value`, it does not count toward the aggregate label budget — the rendered total is
104
+ already finite and stated: at most one heading per rendered row, so at most 100 of them. A
105
+ section is not a row: the 100-row cap and the `legend-limit` summary count rows, never sections.
91
106
  - readonly `legendProjection: LyraMapLegendProjection` — frozen `{ inputCount, renderedCount,
92
107
  omittedCount, truncatedLabelCount, truncated }` result for the latest assignment. A truncated
93
108
  projection renders a localized visible `1–N of M items` summary rather than silently claiming
@@ -100,6 +115,21 @@ omittedCount, truncatedLabelCount, truncated }` result for the latest assignment
100
115
  roving tabindex: a 100-row interactive legend contributes 100 tab stops, exactly as a 100-series
101
116
  `lr-chart` legend does. Each interactive row also grows to the shared `--lr-icon-button-size`
102
117
  hit-area floor (WCAG 2.5.8), which the panel's existing `max-block-size` and scrolling contain.
118
+ - `legendCollapsible: boolean = false` (attribute `legend-collapsible`, reflected) — opt-in: renders
119
+ a `legend-disclosure` `button` inside the panel that collapses the key down to its header, so a
120
+ large legend stops permanently covering part of the map. Unset, the panel renders exactly what it
121
+ rendered before — no button, no `id` minted on the row list, and no `hidden` attribute anywhere.
122
+ Collapsing hides the gradient bar, the rows, the `legend-limit` summary and the trailing `legend`
123
+ slot; the `legend-start` slot and the disclosure itself stay visible, so a slotted header survives
124
+ the collapse and the control that restores the key is never what the collapse hides.
125
+ - `legendOpen: boolean = true` (attribute `legend-open`, reflected) — whether a `legendCollapsible`
126
+ panel is expanded. It defaults **open**, so adding only `legendCollapsible` never hides an existing
127
+ key, and it does nothing at all while `legendCollapsible` is unset. Because it is a
128
+ `true`-defaulting boolean it uses a custom attribute converter, so `legend-open="false"` parses —
129
+ the bare presence-based boolean form cannot express `false` at all — and the reflection follows the
130
+ same converter: open (the default) reflects as an **absent** attribute and collapsed reflects as
131
+ `legend-open="false"`. It is controlled public state and survives a disconnect/reconnect. Assigning
132
+ it programmatically reconciles the rendered panel and emits nothing.
103
133
  - `hiddenCategories: readonly string[] = []` (attribute: false) — the complete controlled set of
104
134
  muted category keys, mirroring `lr-chart`'s `hiddenDatasets`. Clone-owned and frozen; non-string,
105
135
  empty, whitespace-only and duplicate entries are dropped (first occurrence wins, matching
@@ -388,8 +418,8 @@ payload beside the map.
388
418
  `LyraMapHeatmapZoomValue`, `LyraMapLineOptions`, `LyraMapPointOptions`, `LyraMapPointRadiusOptions`,
389
419
  `LyraMapPointRadiusInterpolation`, `LyraMapPointIcon`, `LyraMapPointIconMode`,
390
420
  `LyraMapPointIconLineCap`, `LyraMapPointIconLineJoin`, `LyraMapMarker`, `LyraMapMarkerActivationDetail`,
391
- `LyraMapMarkerActivationSource`, `LyraMapLegendToggleDetail`, `LyraMapStyleSpecification`, and
392
- `LyraMapInstance`.
421
+ `LyraMapMarkerActivationSource`, `LyraMapLegendToggleDetail`, `LyraMapLegendPanelToggleDetail`,
422
+ `LyraMapStyleSpecification`, and `LyraMapInstance`.
393
423
  The former `LegendEntry`, `ChoroplethLayer`, `GeoJsonDataLayer`, and `MapMarker` names are removed
394
424
  in v9 rather than retained as aliases.
395
425
 
@@ -415,6 +445,13 @@ the set and assign its own value instead. There is deliberately no second, confi
415
445
  committed state is `hiddenCategories`, which the host already observes, so a paired before/after
416
446
  vocabulary would be permanent public surface nobody asked for. The event is a DOM-interaction
417
447
  proposal only, so a programmatic `hiddenCategories` assignment reconciles without emitting it.
448
+ Also `lr-map-legend-panel-toggle` (**cancelable**; frozen `LyraMapLegendPanelToggleDetail { open }` —
449
+ the proposed `legendOpen` value), fired once when the `legendCollapsible` disclosure is activated by
450
+ pointer or by Enter/Space. It is the *panel's* disclosure, not a *category's* visibility, so it
451
+ deliberately does not reuse `lr-map-legend-toggle`. `preventDefault()` is the same genuine veto:
452
+ `legendOpen` is not written, the rendered rows and the disclosure's `aria-expanded` do not change,
453
+ so a host can own the open state and assign its own value from `event.detail.open`. A programmatic
454
+ `legendOpen` assignment reconciles without emitting it, so a controlled host cannot loop.
418
455
  Also `lr-map-marker-activate` (non-cancelable; frozen `LyraMapMarkerActivationDetail { id, lngLat,
419
456
  marker, source }`; `id` is the trimmed explicit identity or `undefined`, `marker` is the accepted
420
457
  declarative snapshot, and `source` is `'pointer' | 'keyboard'`), and `lr-map-click`
@@ -437,9 +474,14 @@ markup as described above.
437
474
 
438
475
  **Slots:** `legend` — custom legend content, rendered inside the legend panel's own layout so it
439
476
  stays positioned with the map instead of floating beside it. Supplying it opens the panel even
440
- when `legend` and `legendGradient` are both empty.
441
-
442
- **CSS parts:** `base`, `container`, `legend`, `legend-swatch`, `legend-toggle`,
477
+ when `legend` and `legendGradient` are both empty. `legend-start` — the same extension point at the
478
+ **top** of the panel: it renders ahead of the gradient bar and every projected row, where `legend`
479
+ renders after them, so a host-authored panel header is no longer forced to be a footer. Content in
480
+ it alone opens the panel too, and neither slot is ever made interactive by `legendInteractive`,
481
+ which only reaches rows projected from `legend`.
482
+
483
+ **CSS parts:** `base`, `container`, `legend`, `legend-disclosure`, `legend-disclosure-icon`,
484
+ `legend-group`, `legend-group-heading`, `legend-swatch`, `legend-toggle`,
443
485
  `legend-toggle-hidden`, `legend-gradient`, `legend-lo`,
444
486
  `legend-hi`, `legend-limit`, `marker`, `popup`,
445
487
  `popup-content`, `popup-close-button`, `attribution`, `attribution-toggle`, `navigation`,
@@ -477,6 +519,26 @@ quiet text token, so the label keeps AA contrast rather than fading with the who
477
519
  forced colors the hidden row falls back to `line-through`, which survives a system-color collapse.
478
520
  Each activation is announced through the shared light-DOM polite live region, using the same
479
521
  `legendTypeShown`/`legendTypeHidden` strings `lr-graph-legend` announces with.
522
+ Under `legendCollapsible`, `legend-disclosure` is a native `button` whose **visible localized text
523
+ is its accessible name** and whose `aria-expanded` renders the literal `"true"`/`"false"` — never
524
+ omitted — with `aria-controls` naming the row list in the same shadow root (idrefs do not cross
525
+ shadow boundaries, so this is deliberately not an idref into the light DOM). It carries the shared
526
+ `--lr-icon-button-size` hit-area floor, and `legend-disclosure-icon` is the decorative chevron it
527
+ rotates: the shared icon set ships one right-pointing glyph and asks callers to rotate the wrapping
528
+ part, so collapsed points along the reading direction and expanded points down in both directions.
529
+ The rotation runs on `--lr-transition-fast`, which the token layer already flattens under
530
+ `prefers-reduced-motion`. A collapsed panel hides its gradient, rows, `legend-limit` summary and
531
+ trailing `legend` slot with the plain `hidden` attribute, so they leave layout, the accessibility
532
+ tree and the tab order together.
533
+ When at least one entry carries a `group`, each consecutive run renders inside `legend-group`
534
+ (`role="group"`, named by its own `legend-group-heading` through `aria-labelledby`) and **each run
535
+ gets its own `role="list"`**, because a `list` may only own `listitem`s — a `group` sitting directly
536
+ inside the outer list is an `aria-required-children`/`aria-required-parent` violation. The outer
537
+ container therefore drops its own `role` in that case; a legend with no groups keeps the single
538
+ `role="list"` it has always had. Row `aria-posinset`/`aria-setsize` stay whole-key values inside a
539
+ section: `aria-setsize` already reports the *input* count so a bounded key stays honest, and a
540
+ dropped row carries no attributable group, so a section adds a labelled sub-region without
541
+ renumbering the key.
480
542
  `legend-limit` is the localized bounded-projection summary. The five peer-chrome parts project
481
543
  stable Lyra names onto MapLibre-generated DOM without erasing peer-supplied part tokens;
482
544
  `marker` retains a 24px minimum target in both axes even when a peer/custom marker has no intrinsic
@@ -170,10 +170,14 @@ An async `source` row can carry the same two fields (`start`, `end`) alongside i
170
170
  - `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
171
171
  spelled the same as on `lr-dropdown`/`lr-popup`/`lr-popover`. Unset (the default), the listbox
172
172
  sizes to its own content, clamped between `--lr-size-12rem` and `--lr-size-28rem`, exactly as
173
- before. `sync="width"` drops that content-based clamp (keeping only the outer viewport/
174
- available-space ceiling) so a full-width trigger with short option labels gets a listbox that
175
- aligns to its own edges instead of floating narrower in the middle. Like `placement`, a change
176
- takes effect the next time the listbox opens
173
+ before. `sync="width"` drops that content-based clamp so a full-width trigger with short option
174
+ labels gets a listbox that aligns to its own edges instead of floating narrower in the middle.
175
+ A synced listbox is capped on `--lr-positioner-available-inline-size` **alone** the space the
176
+ positioner actually measured beside the anchor, which still keeps an over-wide trigger from
177
+ pushing it off-screen. `--lr-popover-viewport-clamp` does **not** apply to a synced listbox — its
178
+ 92vw default used to shorten the listbox against its own trigger in exactly the full-width case
179
+ `sync` exists for, and `lr-popup` never applied it either. It still applies with `sync` unset.
180
+ Like `placement`, a change takes effect the next time the listbox opens
177
181
  - `clearable: boolean = false` (reflected) — displays the clear button while there is something to
178
182
  clear on **either** axis this control owns: a committed selection, or _visible_ filter text. See
179
183
  "the clear button covers two axes" below
@@ -85,7 +85,11 @@ exactly like the multi-option case, until the trigger is actually activated.
85
85
 
86
86
  **Properties:**
87
87
 
88
- - `placeholder: string = ''`
88
+ - `placeholder: string = ''` — text shown on the trigger while nothing is selected, and the
89
+ trigger's accessible name when neither a host `aria-label` nor a `label` supplies one. One
90
+ exception to "an empty selection always shows this": while `loading` is `true` the trigger shows
91
+ the localized `loading` text instead — see `loading` below. The accessible name is unaffected
92
+ either way
89
93
  - `disabled: boolean = false` (reflected)
90
94
  - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
91
95
  - `name: string = ''`
@@ -124,6 +128,17 @@ exactly like the multi-option case, until the trigger is actually activated.
124
128
  authored, the listbox is actually placed with the cascading `--lr-positioning-strategy` custom
125
129
  property honored ahead of that default — see the listbox's own **Themeable custom properties**
126
130
  below
131
+ - `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
132
+ spelled and typed the same as on `lr-popup`/`lr-popover`/`lr-dropdown`/`lr-combobox`
133
+ (`'width' | 'height' | 'both'`). Unset (the default), the listbox sizes to its own content,
134
+ clamped between `--lr-size-12rem` and `min(--lr-popover-viewport-clamp, --lr-size-28rem)`,
135
+ exactly as before. `sync="width"` drops that content-based clamp so a full-width trigger with
136
+ short option labels gets a listbox that aligns to its own edges instead of floating narrower in
137
+ the middle. A synced listbox is capped on `--lr-positioner-available-inline-size` **alone** — the
138
+ space the positioner actually measured beside the trigger, which still keeps an over-wide trigger
139
+ from pushing it off-screen; `--lr-popover-viewport-clamp` does **not** apply to a synced listbox,
140
+ and still does with `sync` unset. Assignment while open repositions in place without closing;
141
+ unsetting it releases the inline width the positioner wrote
127
142
  - `showUnknownOption: boolean = false` (attribute `show-unknown-option`, reflected) — appends every
128
143
  committed value that no `<lr-option>` claims to the end of the listbox as a synthetic, badged,
129
144
  keyboard-reachable, re-selectable row. Off by default
@@ -138,9 +153,16 @@ exactly like the multi-option case, until the trigger is actually activated.
138
153
  `loading` placeholder in the trigger label or the relevant `multiple` tag instead of the raw
139
154
  value, with no `notInCatalog`/`[part='unknown-value']` badge and no synthetic
140
155
  `showUnknownOption` listbox row — "not yet resolved" is a different state from "known to be
141
- missing". A value already matching a live option is unaffected. Never mutates
142
- `value`/`selectedOptions` itself, and does not itself disable the trigger pair it with
143
- `disabled` when the control should also be non-interactive while pending
156
+ missing". It covers an **empty** selection too: with nothing selected a create form whose
157
+ catalogue is still being fetched, or an edit form whose saved selection is legitimately empty
158
+ the trigger renders that same localized text in place of `placeholder`, from the same `loading`
159
+ message key, so both halves of a pending state read the same words and one
160
+ `registerLyraLocale()` translation reaches both. Nothing to re-localize in the consuming app.
161
+ With `loading` false an empty selection renders `placeholder` exactly as before. The trigger's
162
+ accessible name never changes for this: a host `aria-label` wins, then `label`, then
163
+ `placeholder`, then the localized `select` fallback. A value already matching a live option is
164
+ unaffected. Never mutates `value`/`selectedOptions` itself, and does not itself disable the
165
+ trigger — pair it with `disabled` when the control should also be non-interactive while pending
144
166
  - `filled: boolean = false` (reflected) — Shoelace alias for the filled trigger treatment
145
167
  - `autofocus: boolean = false` / `title: string = ''` — forwarded to the internal trigger
146
168
  - `multiple: boolean = false` (reflected) — several options selectable at once; see "Multi-select"
@@ -199,7 +221,10 @@ a still-unmatched value then renders the localized `loading` placeholder instead
199
221
  with no `unknown-value` badge and no synthetic `showUnknownOption` row, since it is not yet known
200
222
  to be missing. Once the matching option mounts, the real label renders on the next render with no
201
223
  `value`/`selectedOptions` re-assignment needed, whether or not `loading` is also flipped back to
202
- `false`.
224
+ `false`. The same flag covers the other half of that state: with **nothing** selected the trigger
225
+ renders the same localized `loading` text in place of `placeholder`, so a consumer never has to
226
+ hand-write a conditional placeholder bound to the same flag and re-localize, in its own catalogue,
227
+ the string this control already owns.
203
228
 
204
229
  **Methods:** `focus(options?)`, `blur()`, and `click()` forward to the internal trigger button.
205
230
  `show()` and `hide()` return `Promise<void>` and resolve after `lr-after-show`/`lr-after-hide` once
package/llms/shared.md CHANGED
@@ -1166,6 +1166,47 @@ code. See each control's own reference page for its exact pair.
1166
1166
  `lr-select`. It is a _floor_, not a fixed size. Keep the resolved value **at or above 24px**
1167
1167
  (WCAG 2.2 SC 2.5.8 target size); the default leaves headroom. Lowering it below that shrinks
1168
1168
  every affordance in the library at once.
1169
+ - **`--lr-icon-button-size-scope`** resizes icon buttons for **one subtree** instead of the whole
1170
+ application. Set it on any wrapper; it inherits past intervening components and reaches every
1171
+ icon-only control below it. Three names, three scopes — pick by how far you want the change to
1172
+ reach:
1173
+
1174
+ | Property | Scope | Set it on |
1175
+ | --- | --- | --- |
1176
+ | `--lr-theme-icon-button-size` | application-wide | `:root`, or any ancestor |
1177
+ | `--lr-icon-button-size-scope` | one subtree | the wrapper you want affected |
1178
+ | `--lr-icon-button-size` | one element | the icon-only control itself |
1179
+
1180
+ `--lr-theme-icon-button-size` wins wherever both ancestor inputs are set. `--lr-icon-button-size`
1181
+ is **element-scoped on purpose** and is not a wrapper knob: every component re-declares it on its
1182
+ own host so the touch-target floor can apply per element, so a value set on a wrapper is replaced
1183
+ at the first component in between and never reaches anything nested inside one. That behaviour is
1184
+ unchanged — use `--lr-icon-button-size-scope` for the wrapper case.
1185
+
1186
+ ```css
1187
+ /* A dense toolbar, without touching the rest of the app. */
1188
+ .message-toolbar {
1189
+ --lr-icon-button-size-scope: 1.75rem;
1190
+ }
1191
+ ```
1192
+
1193
+ The coarse-pointer floor still applies to both ancestor inputs: on a touch device (`hover: none`
1194
+ or `pointer: coarse`) a resolved value below `2.75rem` is raised back to it, so the new subtree
1195
+ knob is not a way around WCAG 2.2 SC 2.5.8 — a deliberately dense desktop toolbar still becomes
1196
+ tappable on a phone.
1197
+ - **`--lr-color-surface-overlay` follows `--lr-theme-color-surface-default` in both modes.** It is
1198
+ the panel colour behind every floating surface — dropdowns, listboxes, menus, toasts, popovers,
1199
+ dialogs, and the `lr-app-rail` mobile drawer. In light mode it resolves straight to
1200
+ `--lr-color-surface`, so a re-skinned page surface carries them all with it. Dark mode cannot
1201
+ resolve to the page surface — panel and page would be the same near-black, and an open dialog
1202
+ would read as a scrim with text floating on it and no panel at all — so it is **derived** from
1203
+ the page surface instead: `color-mix(in srgb, var(--lr-color-surface) 85%, #8bade2)`, which lifts
1204
+ the panel a fixed amount above whatever the base happens to be. One
1205
+ `--lr-theme-color-surface-default` override therefore re-skins every floating surface in dark
1206
+ mode too, and the elevation delta survives the re-skin. At the built-in dark base the pair still
1207
+ resolves to the same panel colour it always has, so no existing dark theme moves.
1208
+ `--lr-theme-color-surface-overlay` still wins outright when you set it — reach for it only when
1209
+ you want a panel colour unrelated to the page surface.
1169
1210
  - **Aligning your own content next to a checkbox or radio.** `--lr-checkbox-label-indent` /
1170
1211
  `--lr-radio-label-indent` publish the label offset, but custom properties inherit _down_, not
1171
1212
  sideways, so a sibling node in your tree cannot read them off the control. Compute the same
package/llms/tokens.md CHANGED
@@ -52,8 +52,11 @@ ordinary per-component property: a rule that sets one of them directly on an anc
52
52
  at the first intervening `lr-*` component and never reaches a nested target — even though
53
53
  setting it directly on the target element itself still works, which is what makes the failure
54
54
  look arbitrary rather than systematic. Reach through a subtree with `--lr-theme-focus-ring-*`,
55
- `--lr-theme-icon-button-size`, `--lr-theme-otp-input-segment-size`, or
56
- `--lr-theme-popover-viewport-clamp` instead. Every other `--lr-<component>-*` token — including
55
+ `--lr-theme-otp-input-segment-size`, or `--lr-theme-popover-viewport-clamp` instead.
56
+ `--lr-icon-button-size` additionally has a dedicated subtree-scoped input,
57
+ `--lr-icon-button-size-scope`, which an ancestor rule can set without reaching for the
58
+ application-wide `--lr-theme-icon-button-size`; the theme input still wins over it when both
59
+ are set. Every other `--lr-<component>-*` token — including
57
60
  the rest of `lr-icon-button`'s own (`-radius`, `-background`, `-color`, `-border`, and their
58
61
  `-hover`/`-active` variants) — is not re-declared anywhere in the shared layer and inherits
59
62
  normally from an ancestor.
@@ -120,7 +123,7 @@ normally from an ancestor.
120
123
  | `--lr-color-success-on-normal` | `--lr-theme-color-success-on-normal` | `var(--lr-ramp-neutral-05)` | dark: `var(--lr-theme-color-success-on-normal, var(--lr-ramp-neutral-95))` |
121
124
  | `--lr-color-success-on-quiet` | `--lr-theme-color-success-on-quiet` | `var(--lr-ramp-neutral-05)` | dark: `var(--lr-theme-color-success-on-quiet, var(--lr-ramp-neutral-95))` |
122
125
  | `--lr-color-surface` | `--lr-theme-color-surface-default` | `#fff` | dark: `var(--lr-theme-color-surface-default, #1a1a1a)`<br>forcedColors: `Canvas` |
123
- | `--lr-color-surface-overlay` | `--lr-theme-color-surface-overlay` | `var(--lr-color-surface)` | dark: `var(--lr-theme-color-surface-overlay, #2b3038)` |
126
+ | `--lr-color-surface-overlay` | `--lr-theme-color-surface-overlay` | `var(--lr-color-surface)` | dark: `var(--lr-theme-color-surface-overlay, color-mix(in srgb, var(--lr-color-surface) 85%, #8bade2))` |
124
127
  | `--lr-color-surface-raised` | `--lr-theme-color-surface-raised` | `#f6f8fa` | dark: `var(--lr-theme-color-surface-raised, #22272e)`<br>forcedColors: `Canvas` |
125
128
  | `--lr-color-text` | `--lr-theme-color-text-normal` | `#1a1a1a` | dark: `var(--lr-theme-color-text-normal, #f2f2f2)`<br>forcedColors: `CanvasText` |
126
129
  | `--lr-color-text-quiet` | `--lr-theme-color-text-quiet` | `#6b7280` | dark: `var(--lr-theme-color-text-quiet, #9aa1ac)`<br>forcedColors: `CanvasText` |
@@ -175,7 +178,7 @@ normally from an ancestor.
175
178
  | `--lr-graph-cat-7` | `--lr-theme-graph-cat-7` | `#52d6e8` | dark: `var(--lr-theme-graph-cat-7, #79e2ef)`<br>forcedColors: `Highlight` |
176
179
  | `--lr-graph-cat-8` | `--lr-theme-graph-cat-8` | `#c9d1d9` | dark: `var(--lr-theme-graph-cat-8, #e4e7eb)`<br>forcedColors: `LinkText` |
177
180
  | `--lr-hover-brightness` | `--lr-theme-hover-brightness` | `1.08` | — |
178
- | `--lr-icon-button-size` | `--lr-theme-icon-button-size` | `2.5rem` | — |
181
+ | `--lr-icon-button-size` | `--lr-theme-icon-button-size` | `var(--lr-icon-button-size-scope, 2.5rem)` | — |
179
182
  | `--lr-layer-base` | `--lr-theme-z-index-base` | `0` | — |
180
183
  | `--lr-layer-content` | `--lr-theme-z-index-content` | `1` | — |
181
184
  | `--lr-layer-dropdown` | `--lr-theme-z-index-dropdown` | `900` | — |