@aceshooting/lyra-ui 17.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 (107) hide show
  1. package/CHANGELOG.md +483 -0
  2. package/README.md +2 -2
  3. package/custom-elements.json +1 -1
  4. package/design-tokens.json +1 -1
  5. package/dist/cli/migration-contract.json +1 -1
  6. package/dist/components/conversation/message-actions/message-actions.class.d.ts +2 -1
  7. package/dist/components/conversation/streaming-text/streaming-text-core.class.d.ts +22 -0
  8. package/dist/components/conversation/streaming-text/streaming-text-core.class.js +1 -0
  9. package/dist/components/conversation/streaming-text/streaming-text.class.d.ts +22 -0
  10. package/dist/components/conversation/streaming-text/streaming-text.class.js +1 -0
  11. package/dist/components/conversation/thread-list/thread-list.class.js +1 -1
  12. package/dist/components/data/table/table.styles.js +1 -1
  13. package/dist/components/forms/combobox/combobox.class.d.ts +12 -2
  14. package/dist/components/forms/combobox/combobox.class.js +2 -2
  15. package/dist/components/forms/combobox/combobox.styles.js +1 -1
  16. package/dist/components/forms/icon-button/icon-button.class.d.ts +18 -9
  17. package/dist/components/forms/select/select.class.d.ts +63 -8
  18. package/dist/components/forms/select/select.class.js +4 -4
  19. package/dist/components/forms/select/select.styles.js +1 -1
  20. package/dist/components/layout/filter-bar/filter-bar.class.d.ts +148 -14
  21. package/dist/components/layout/filter-bar/filter-bar.class.js +12 -4
  22. package/dist/components/layout/filter-bar/filter-bar.styles.js +1 -1
  23. package/dist/components/layout/reorder-list/reorder-list.class.js +1 -1
  24. package/dist/components/layout/virtual-list/virtual-list.class.d.ts +154 -18
  25. package/dist/components/layout/virtual-list/virtual-list.class.js +12 -7
  26. package/dist/components/media/map/map.class.d.ts +269 -14
  27. package/dist/components/media/map/map.class.js +62 -30
  28. package/dist/components/media/map/map.styles.js +1 -1
  29. package/dist/components/utility/copy-button/copy-button.class.d.ts +4 -3
  30. package/dist/custom-elements-jsx.d.ts +1 -1
  31. package/dist/events.d.ts +13 -1
  32. package/dist/internal/package-metadata.d.ts +1 -1
  33. package/dist/internal/package-metadata.js +1 -1
  34. package/dist/internal/tokens.styles.js +1 -1
  35. package/dist/lyra.d.ts +1 -1
  36. package/dist/lyra.js +1 -1
  37. package/dist/styles/tokens-root.css +1 -1
  38. package/dist/svelte.d.ts +1 -1
  39. package/dist/testing/lyra-tag-event-map.js +1 -1
  40. package/dist/translations/ar/retrieval.js +1 -1
  41. package/dist/translations/ar/shared.js +1 -1
  42. package/dist/translations/de/retrieval.js +1 -1
  43. package/dist/translations/de/shared.js +1 -1
  44. package/dist/translations/es/retrieval.js +1 -1
  45. package/dist/translations/es/shared.js +1 -1
  46. package/dist/translations/fa/retrieval.js +1 -1
  47. package/dist/translations/fa/shared.js +1 -1
  48. package/dist/translations/fr/retrieval.js +1 -1
  49. package/dist/translations/fr/shared.js +1 -1
  50. package/dist/translations/he/retrieval.js +1 -1
  51. package/dist/translations/he/shared.js +1 -1
  52. package/dist/translations/it/retrieval.js +1 -1
  53. package/dist/translations/it/shared.js +1 -1
  54. package/dist/translations/ja/retrieval.js +1 -1
  55. package/dist/translations/ja/shared.js +1 -1
  56. package/dist/translations/pt-BR/retrieval.js +1 -1
  57. package/dist/translations/pt-BR/shared.js +1 -1
  58. package/dist/translations/ro/agent-tools.d.ts +1 -0
  59. package/dist/translations/ro/agent-tools.js +1 -0
  60. package/dist/translations/ro/charts.d.ts +1 -0
  61. package/dist/translations/ro/charts.js +1 -0
  62. package/dist/translations/ro/conversation.d.ts +1 -0
  63. package/dist/translations/ro/conversation.js +1 -0
  64. package/dist/translations/ro/data.d.ts +1 -0
  65. package/dist/translations/ro/data.js +1 -0
  66. package/dist/translations/ro/forms.d.ts +1 -0
  67. package/dist/translations/ro/forms.js +1 -0
  68. package/dist/translations/ro/layout.d.ts +1 -0
  69. package/dist/translations/ro/layout.js +1 -0
  70. package/dist/translations/ro/media.d.ts +1 -0
  71. package/dist/translations/ro/media.js +1 -0
  72. package/dist/translations/ro/overlays.d.ts +1 -0
  73. package/dist/translations/ro/overlays.js +1 -0
  74. package/dist/translations/ro/retrieval.d.ts +1 -0
  75. package/dist/translations/ro/retrieval.js +1 -0
  76. package/dist/translations/ro/shared.d.ts +1 -0
  77. package/dist/translations/ro/shared.js +1 -0
  78. package/dist/translations/ro/utility.d.ts +1 -0
  79. package/dist/translations/ro/utility.js +1 -0
  80. package/dist/translations/ro/viewers.d.ts +1 -0
  81. package/dist/translations/ro/viewers.js +5 -0
  82. package/dist/translations/ro.d.ts +1 -0
  83. package/dist/translations/ro.js +1 -0
  84. package/dist/translations/ru/retrieval.js +1 -1
  85. package/dist/translations/ru/shared.js +1 -1
  86. package/dist/translations/zh-CN/retrieval.js +1 -1
  87. package/dist/translations/zh-CN/shared.js +1 -1
  88. package/dist/vue.d.ts +1 -1
  89. package/llms/components/lr-button.md +5 -4
  90. package/llms/components/lr-card.md +2 -1
  91. package/llms/components/lr-combobox.md +11 -0
  92. package/llms/components/lr-filter-bar.md +123 -7
  93. package/llms/components/lr-icon-button.md +17 -8
  94. package/llms/components/lr-map.md +127 -8
  95. package/llms/components/lr-option.md +11 -0
  96. package/llms/components/lr-select.md +45 -3
  97. package/llms/components/lr-streaming-text-core.md +7 -3
  98. package/llms/components/lr-streaming-text.md +7 -2
  99. package/llms/components/lr-virtual-list.md +53 -0
  100. package/llms/shared.md +47 -3
  101. package/llms/tokens.md +7 -4
  102. package/llms-full.txt +465 -36
  103. package/package.json +28 -2
  104. package/registrations.json +1 -1
  105. package/vscode-css-data.json +1 -1
  106. package/vscode-html-data.json +1 -1
  107. package/web-types.json +1 -1
@@ -373,10 +373,11 @@ box no matter what tier or override is in play.
373
373
  For toggle buttons, host `aria-pressed="true|false|mixed"` reaches the focused native button —
374
374
  BUTTONS only. A link button (`href` set) never receives it, because `role="link"` does not support
375
375
  `aria-pressed`; remove `href` and the same host attribute starts reaching the `<button>` that
376
- replaces the anchor. Navigation can use the global
377
- `aria-current="page|step|location|date|time|true|false"`, which does reach the anchor. Both states
378
- update reactively, including native `ariaPressed`/`ariaCurrent` property assignments, attribute
379
- removal and `href` changes. Empty or unsupported tokens are omitted. The native button/link role
376
+ replaces the anchor. That link carve-out arrived in **16.0.0** — before it, `aria-pressed` was
377
+ forwarded onto the anchor as well, asserting a state `role="link"` does not have. Navigation can
378
+ use the global `aria-current="page|step|location|date|time|true|false"`, which does reach the
379
+ anchor. Both states update reactively, including native `ariaPressed`/`ariaCurrent` property
380
+ assignments, attribute removal and `href` changes. Empty or unsupported tokens are omitted. The native button/link role
380
381
  remains unchanged for an enabled control: use pressed state for button toggles and current state
381
382
  for navigation links. A DISABLED link button drops `href` (so it genuinely cannot navigate) and
382
383
  therefore also drops the anchor's implicit role, so it renders an explicit `role="link"` — without
@@ -73,7 +73,8 @@ to `<wa-card>`'s contract, staying slot-compatible with `lr-result-card` where t
73
73
  - `aria-pressed` and `aria-current` (attributes only) — forwarded reactively onto the native
74
74
  control the card actually renders, the same mechanism `<lr-button>` and `<lr-icon-button>` use.
75
75
  `aria-pressed` accepts `'true' | 'false' | 'mixed'` and reaches the `activation-button` only —
76
- `link` has no pressed state, so a linked card never receives it. The global `aria-current`
76
+ `link` has no pressed state, so a linked card never receives it the same **16.0.0** carve-out
77
+ `<lr-button>` and `<lr-icon-button>` took. The global `aria-current`
77
78
  accepts `'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'` and reaches both the
78
79
  activation button and the stretched link. Anything outside those sets is dropped rather than
79
80
  passed through, so a typo never reaches the accessibility tree. This is what lets a single-select
@@ -167,6 +167,17 @@ An async `source` row can carry the same two fields (`start`, `end`) alongside i
167
167
  default; an explicit instance value always wins. There is deliberately no `hoist` alias here:
168
168
  unlike on `lr-select`, where it is Shoelace's established spelling, it would be a boolean
169
169
  defaulting to `true`, so its attribute could only ever express the value the control already has
170
+ - `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
171
+ spelled the same as on `lr-dropdown`/`lr-popup`/`lr-popover`. Unset (the default), the listbox
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 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
170
181
  - `clearable: boolean = false` (reflected) — displays the clear button while there is something to
171
182
  clear on **either** axis this control owns: a committed selection, or _visible_ filter text. See
172
183
  "the clear button covers two axes" below
@@ -21,7 +21,10 @@ Dashboard filter row that composes Lyra inputs and removable chips, with reset a
21
21
  Choice option entries must expose string `value` and `label` data fields; malformed entries are
22
22
  omitted independently, while supplied empty strings remain valid. A custom definition requires its
23
23
  adapter and a callable `render`; a rejected definition does not reserve its filter ID. Valid
24
- siblings remain available. Exceptions thrown by an admitted trusted renderer still propagate.
24
+ siblings remain available. Exceptions thrown by an admitted trusted renderer still propagate. A
25
+ chip-only definition requires neither options nor an adapter — a stable filter ID and a label are
26
+ the whole schema, since a malformed `formatValue`/`isEmpty` still has a correct fallback and so is
27
+ guarded where it is used rather than rejected outright.
25
28
 
26
29
  **Lean registration entry.** `components/layout/filter-bar/filter-bar.js` (the default entry)
27
30
  eagerly imports every composed control this bar could possibly render — `<lr-select>`,
@@ -42,6 +45,7 @@ the filter `type`s actually declared:
42
45
  | `'checkbox-menu'` | `components/overlays/overlay/dropdown.js` **and** `components/layout/menu/dropdown-item.js` |
43
46
  | `'date'` / `'date-range'` | `components/forms/date-picker/date-input.js` |
44
47
  | `'text'` | `components/forms/input/input.js` |
48
+ | `'chip'` | none — renders no control |
45
49
 
46
50
  Two more are unconditional regardless of which filter `type`s are declared: `<lr-button>` renders
47
51
  the reset action on every bar, and `<lr-chip>`/`<lr-chip-group>` render the active-filter row
@@ -78,8 +82,21 @@ until something else registers it, the same trade `icon-button-register.js` docu
78
82
  - `disabled: boolean = false` (reflected) — disables every filter control and reset action.
79
83
  - `loading: boolean = false` (reflected) — shows the status spinner and disables reset while leaving
80
84
  filters editable.
81
- - `hasActiveFilters: boolean` (read-only) — whether any configured filter currently has a value.
82
- 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.
83
100
  - `invalidFilterIds: readonly string[]` (read-only) — immutable ids of required filters whose
84
101
  values are unset.
85
102
  - `activeFiltersDisplay: 'all' | 'changed' | 'hidden' = 'all'` (reflected, attribute
@@ -93,6 +110,10 @@ until something else registers it, the same trade `icon-button-register.js` docu
93
110
  at each index), matching this component's only other array-equality precedent (a custom adapter's
94
111
  own `clearValue` comparison); a `'date-range'` value is a single composed string, so it compares
95
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.
96
117
 
97
118
  The composed reset action uses `lr-button`'s default `m` size tier, matching the default rendered
98
119
  height of adjacent select, combobox, input, and date fields instead of introducing a shorter action
@@ -222,11 +243,13 @@ same-named counterparts (with `combobox`'s `multiple` opting into a multi-value
222
243
  `'date'`/`'date-range'` both map to `<lr-date-input>` (single vs. `mode="range"`), and `'text'` maps
223
244
  to `<lr-input>` for an open-ended free-text query rather than a closed choice set. A `'text'`
224
245
  filter's value is the raw query string, verbatim, and its chip shows exactly that string — the same
225
- text the user typed, not a truncated or normalized form.
246
+ text the user typed, not a truncated or normalized form. `'chip'` is the one type that renders no
247
+ control at all (see **Chip-only filters** below): its value belongs to a widget elsewhere on the
248
+ page, so the bar renders only its active-filter chip and gives it no toolbar cell.
226
249
 
227
250
  Every built-in (non-`'custom'`) filter definition additionally accepts optional `size: LyraSize`,
228
- `icon: unknown` and `labelVisibility: 'visible' | 'hidden'` fields, and every one whose composed
229
- 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
230
253
  control's own same-named property — `icon` into its `start` slot exactly like a choice option's own
231
254
  `icon`, rendered inert and `aria-hidden`; `clearable` reaching `<lr-date-input>` under its own
232
255
  `with-clear` spelling, since that control has no `clearable`. `'text'` also accepts
@@ -245,6 +268,20 @@ so a compact toolbar row still names every field for assistive technology; visua
245
268
  `::part(filter-control-label)` in CSS, the only previous option, removed the accessible name along
246
269
  with the text.
247
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
+
248
285
  `'combobox'` also accepts the same `debounce?: number` (ms) `'text'` already had: it coalesces a
249
286
  burst of rapid selection changes (picks, a multi-select toggle, an
250
287
  `allowCustomValue`/`allowCreate` commit, or the clear action) into one delayed commit. Unlike
@@ -274,6 +311,8 @@ makes that text visually hidden — never removed — so the button keeps its ac
274
311
  one case where the hidden label would be the *only* thing the button says (hidden routing, no
275
312
  declared `placeholder`, nothing selected) the label routes to the visible summary instead of being
276
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.
277
316
 
278
317
  Because its trigger is a button rather than a field, a `required` `'checkbox-menu'` deliberately
279
318
  renders **no** required asterisk and sets **no** `aria-invalid`: the shared required marker has no
@@ -438,12 +477,89 @@ const filters: LyraFilterBarFilterDefinition[] = [
438
477
  The custom renderer returns a Lit `TemplateResult`; the filter bar places it in its
439
478
  `filter-control` part and re-renders it whenever the controlled value or validation state changes.
440
479
 
480
+ ### Chip-only filters
481
+
482
+ Use `type: 'chip'` when the value is already owned by a widget elsewhere on the page — a calendar
483
+ heatmap cell, a map selection, a chart brush — and the bar's job is only to *show* that the filter
484
+ is applied and to let the user take it off. Unlike `type: 'custom'`, which still renders a control
485
+ inside the toolbar, a chip-only filter renders **no control and no toolbar cell at all**: no `field`
486
+ wrapper is emitted for it, so `lr-filter-bar::part(field)` and `::part(field-<filterId>)` never
487
+ match one and a bar whose filters are *all* chip-only shows no empty column — its `controls` row
488
+ still holds the reset button (the "clear all" action such a bar needs), the `end` slot, and the
489
+ loading spinner, exactly like a bar with no filters at all.
490
+
491
+ Everything else is unchanged from any other filter type. The value lives in `value` under its own
492
+ filter ID, rides every `lr-input`/`lr-reset` detail, counts toward `hasActiveFilters` (so it enables
493
+ the reset button) and toward `invalidFilterIds` when the definition is `required`, renders a
494
+ removable active-filter chip subject to `activeFiltersDisplay`, and is cleared both by removing that
495
+ chip and by `reset()`. A `required` chip-only filter is honoured in **bookkeeping only**: it joins
496
+ `invalidFilterIds`, fails `checkValidity()` and moves `lr-validity-change`, but renders no inline
497
+ error, because the bar renders no element of its own for it — the owning widget is responsible for
498
+ its own error affordance. The inherited `placeholder` is inert here for the same reason it is for
499
+ `type: 'custom'`: there is no field to place it in.
500
+
501
+ A chip-only definition adds three optional fields of its own:
502
+
503
+ ```ts
504
+ type: 'chip';
505
+ formatValue?: (value: LyraFilterBarFieldValue, locale: string) => string;
506
+ clearValue?: LyraFilterBarFieldValue;
507
+ isEmpty?: (value: LyraFilterBarFieldValue) => boolean;
508
+ ```
509
+
510
+ `formatValue` produces the chip's text, and its `locale` argument is the bar's `effectiveLocale` —
511
+ the same locale every built-in type's own chip formatting and a custom adapter's `formatValue`
512
+ already receive, and the reason a chip-only value (normally an already-formatted string such as a
513
+ localized date) can be localized by the caller. That output is caller data, so — like a filter's
514
+ own label — the bar never routes it through its own localization. Omitted, the fallback ladder is
515
+ the one a custom adapter's omitted `formatValue` uses: a string array renders as a localized
516
+ conjunction list, anything else renders verbatim through `String(value)`, and an unset value renders
517
+ as the empty string. Verbatim is exact: a chip-only value is never run through the date branch that
518
+ localizes a `'date'`/`'date-range'` chip, so an ISO day is not silently reformatted and a value
519
+ containing a slash is not mangled.
520
+
521
+ `clearValue` is what a chip removal (and `clearFilter()`) writes, defaulting to the empty string —
522
+ what every non-multi built-in type writes. Declare an empty array for an array-valued chip-only
523
+ filter. `isEmpty` overrides the built-in emptiness rule (absent, `false`, the empty string and the
524
+ empty array are empty; everything else is set). **A domain sentinel must pair the two**: declaring
525
+ a sentinel clear value without a matching `isEmpty` leaves the bar reading the "cleared" value as
526
+ still set, so it keeps rendering a chip for it — the identical pairing a custom adapter's own
527
+ `clearValue`/`isEmpty` documents. With the pair declared, the sentinel is never stored in `value`
528
+ (cleared keys are omitted) and an absent key reads back as the sentinel for the owning widget.
529
+
530
+ ```ts
531
+ const filters: LyraFilterBarFilterDefinition[] = [
532
+ { filterId: "query", label: "Query", type: "text" },
533
+ {
534
+ filterId: "day",
535
+ label: "Day",
536
+ type: "chip",
537
+ formatValue: (value, locale) =>
538
+ new Intl.DateTimeFormat(locale, {
539
+ dateStyle: "medium",
540
+ timeZone: "UTC",
541
+ }).format(new Date(`${String(value)}T00:00:00Z`)),
542
+ },
543
+ ];
544
+
545
+ // The calendar heatmap beside the bar owns the value; the bar only shows and removes it.
546
+ heatmap.addEventListener("app-select-day", (event) => {
547
+ const { isoDate } = (event as CustomEvent<{ isoDate: string }>).detail;
548
+ bar.value = { ...bar.value, day: isoDate };
549
+ });
550
+ bar.addEventListener("lr-input", (event) => {
551
+ const { value } = (event as LyraFilterBarInputEvent).detail;
552
+ heatmap.selectedDay = (value["day"] as string | undefined) ?? "";
553
+ });
554
+ ```
555
+
441
556
  **TypeScript:** `LyraFilterBar<Defs extends readonly LyraFilterBarFilterDefinition[] =
442
557
  readonly LyraFilterBarFilterDefinition[]>` — `value` and the `lr-input`/`lr-reset` detail `value`
443
558
  narrow to a record keyed per `filterId`, whose value type follows that filter's own definition (a
444
559
  `'select'`, a non-`multiple` `'combobox'`, `'text'`, `'date'`, and `'date-range'` narrow to
445
560
  `string`; a `'checkbox-menu'` and a `multiple: true` `'combobox'` narrow to `readonly string[]`; a
446
- `'custom'` filter keeps the full unconstrained field value). Declare the schema with `as const
561
+ `'custom'` filter keeps the full unconstrained field value, and so does a `'chip'` filter, whose
562
+ value is owned by a widget this component never renders). Declare the schema with `as const
447
563
  satisfies readonly LyraFilterBarFilterDefinition[]` and type the element as
448
564
  `LyraFilterBar<typeof FILTERS>` to pick it up. Types only; the runtime is unchanged, and an untyped
449
565
  `<lr-filter-bar>` keeps today's `LyraFilterBarValue` (`Readonly<Record<string, string | readonly
@@ -69,7 +69,8 @@ button whose purpose isn't generic.
69
69
  Host `aria-haspopup` and `aria-expanded` values are forwarded reactively to the shadow-internal
70
70
  native button. `aria-pressed` (`true`, `false`, `mixed`) supports icon-only toggle actions such as
71
71
  mute, favorite, or pin, and reaches the `<button>` rendering only — a link icon-button (`href` set)
72
- never receives it, since `role="link"` has no pressed state; `aria-current` (`page`, `step`,
72
+ never receives it, since `role="link"` has no pressed state (that carve-out arrived in **16.0.0**;
73
+ earlier versions forwarded it onto the anchor too); `aria-current` (`page`, `step`,
73
74
  `location`, `date`, `time`, `true`, `false`) is global and supports current-item icon buttons such
74
75
  as an active nav/pagination target in both renderings. Both follow attribute
75
76
  changes, removal, and button/link replacement without changing the native role of an enabled
@@ -130,15 +131,23 @@ above 24px — see `llms/shared.md`. **`--lr-icon-button-size` is element-scoped
130
131
  token in this section:** the shared token layer re-declares it on every `lr-*` host's own `:host`,
131
132
  so a rule that sets `--lr-icon-button-size` on an ancestor wrapper is reset the moment it crosses
132
133
  into any intervening `lr-*` component and never reaches a `<lr-icon-button>` composed inside it
133
- (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
134
135
  `--lr-icon-button-size` directly on the icon button element itself, where no intervening component
135
- sits between the rule and the property; or setting `--lr-theme-icon-button-size` on an ancestor,
136
- 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
137
139
  `<lr-icon-button>` composed inside another component, e.g. `<lr-copy-button>`/
138
- `<lr-message-actions>`. Lowering the floor for a dense action row through either lever is safe even
139
- below 24px: a coarse-pointer/no-hover media rule floors the RENDERED hit area back at 2.75rem/44px
140
- regardless of how far the override lowered it, so the control stays comfortably tappable the moment
141
- 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
142
151
  `--lr-radius`) is not re-declared anywhere in the shared layer, so — like every other token below —
143
152
  it inherits normally from an ancestor even through an intervening component; it is the
144
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** 20 parts, 6 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
  ---
@@ -72,18 +72,72 @@ LyraMapLegendPattern }`, where `LyraMapLegendPattern` is `'solid' | 'diagonal' |
72
72
  'crosshatch'`. Pattern is required so color is never the sole category cue. At most 100 valid
73
73
  rows, 256 characters per label, and 8,192 aggregate label characters are retained; colors are
74
74
  bounded before validation. The overlay scrolls within the map allocation.
75
+ A row may also carry its own `value`: the category key that row stands for — the same string a
76
+ `point.colors`/`point.icons` entry matches against `point.field`/`point.iconField`. It is
77
+ trimmed, bounded to 256 characters (sliced, never ellipsized, so a bounded key still matches) and
78
+ **retained** in the canonical readback; a non-string, empty or whitespace-only key leaves no
79
+ `value` property on the frozen row at all. It is what makes a row operable under
80
+ `legendInteractive`, and it does not count toward the label budget.
75
81
  A row may also carry `icon`, deliberately the same record a `point.icons` entry uses — hand the
76
82
  legend the very icon object its point layer renders and the key shows the symbol drawn on the
77
- map instead of describing it in colour alone. The point layer's category key (`value`) is
78
- accepted so a pass-through needs no reshaping, and is left out of the canonical readback;
83
+ map instead of describing it in colour alone. The **icon record's own** `value` is a separate
84
+ field from the row-level one above: it is accepted so a pass-through needs no reshaping, and it
85
+ is still left out of the canonical readback. A row's key is never derived from its glyph's, which
86
+ would silently make a row interactive that the author never marked;
79
87
  `path`, `viewBox`, `mode`, `strokeWidth`, `lineCap` and `lineJoin` keep their point-icon meaning
80
88
  and their point-icon defaults, and the same validation applies (path data only, at most 8,192
81
89
  characters, positive `viewBox` dimensions). An unusable record is dropped and that row keeps
82
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.
83
106
  - readonly `legendProjection: LyraMapLegendProjection` — frozen `{ inputCount, renderedCount,
84
107
  omittedCount, truncatedLabelCount, truncated }` result for the latest assignment. A truncated
85
108
  projection renders a localized visible `1–N of M items` summary rather than silently claiming
86
109
  the bounded rows are complete.
110
+ - `legendInteractive: boolean = false` (attribute `legend-interactive`, reflected) — opt-in: turns
111
+ every legend row that carries a `value` into a keyboard-operable visibility toggle, and leaves a
112
+ row without one inert. Unset, the legend renders exactly the read-only key it rendered before —
113
+ no `button`, no extra attribute, and no extra MapLibre paint key. Each toggle is an independently
114
+ tabbable native `button`, so Enter and Space are the platform's own activation and there is no
115
+ roving tabindex: a 100-row interactive legend contributes 100 tab stops, exactly as a 100-series
116
+ `lr-chart` legend does. Each interactive row also grows to the shared `--lr-icon-button-size`
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.
133
+ - `hiddenCategories: readonly string[] = []` (attribute: false) — the complete controlled set of
134
+ muted category keys, mirroring `lr-chart`'s `hiddenDatasets`. Clone-owned and frozen; non-string,
135
+ empty, whitespace-only and duplicate entries are dropped (first occurrence wins, matching
136
+ `point.colors`), and at most 100 keys are retained. Honoured on the **first** render and the
137
+ first MapLibre paint, not only after a user toggle, so a host can restore a saved selection. An
138
+ empty array deliberately means every category is visible. It is controlled public state and
139
+ survives a disconnect/reconnect. A hidden key that matches no category is harmless. Assigning it
140
+ programmatically emits nothing.
87
141
  - `choropleth?: LyraMapChoroplethLayer` (attribute: false) — `LyraMapChoroplethLayer { sourceId:
88
142
  string; geojson: GeoJSON.FeatureCollection; field: string; stops: [number, string][]; interpolation?:
89
143
  'linear' | 'logarithmic' | 'step'; stepBaseColor?: string }` (interpolated
@@ -364,7 +418,8 @@ payload beside the map.
364
418
  `LyraMapHeatmapZoomValue`, `LyraMapLineOptions`, `LyraMapPointOptions`, `LyraMapPointRadiusOptions`,
365
419
  `LyraMapPointRadiusInterpolation`, `LyraMapPointIcon`, `LyraMapPointIconMode`,
366
420
  `LyraMapPointIconLineCap`, `LyraMapPointIconLineJoin`, `LyraMapMarker`, `LyraMapMarkerActivationDetail`,
367
- `LyraMapMarkerActivationSource`, `LyraMapStyleSpecification`, and `LyraMapInstance`.
421
+ `LyraMapMarkerActivationSource`, `LyraMapLegendToggleDetail`, `LyraMapLegendPanelToggleDetail`,
422
+ `LyraMapStyleSpecification`, and `LyraMapInstance`.
368
423
  The former `LegendEntry`, `ChoroplethLayer`, `GeoJsonDataLayer`, and `MapMarker` names are removed
369
424
  in v9 rather than retained as aliases.
370
425
 
@@ -380,7 +435,24 @@ shared `maplibre-gl` import without constructing a map or allocating a WebGL con
380
435
  an element.
381
436
 
382
437
  **Events:** `lr-map-load` (fired once, after the underlying map's own `'load'`),
383
- `lr-map-marker-activate` (non-cancelable; frozen `LyraMapMarkerActivationDetail { id, lngLat,
438
+ `lr-map-legend-toggle` (**cancelable**; frozen `LyraMapLegendToggleDetail { value, visible,
439
+ hiddenCategories }` — the activated row's category key, its proposed visibility, and the complete
440
+ proposed hidden set in the order it would be committed, with the array detached and frozen so a
441
+ listener cannot mutate the component's state through it). `preventDefault()` is a genuine veto, not
442
+ a notification: `hiddenCategories` is not written, the row's `aria-pressed` does not change, the
443
+ MapLibre paint is untouched, and nothing is announced — which is exactly what a host needs to own
444
+ the set and assign its own value instead. There is deliberately no second, confirmation event: the
445
+ committed state is `hiddenCategories`, which the host already observes, so a paired before/after
446
+ vocabulary would be permanent public surface nobody asked for. The event is a DOM-interaction
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.
455
+ Also `lr-map-marker-activate` (non-cancelable; frozen `LyraMapMarkerActivationDetail { id, lngLat,
384
456
  marker, source }`; `id` is the trimmed explicit identity or `undefined`, `marker` is the accepted
385
457
  declarative snapshot, and `source` is `'pointer' | 'keyboard'`), and `lr-map-click`
386
458
  (frozen `detail: { readonly lngLat: readonly [lng, lat], readonly feature?, readonly origin?,
@@ -402,9 +474,15 @@ markup as described above.
402
474
 
403
475
  **Slots:** `legend` — custom legend content, rendered inside the legend panel's own layout so it
404
476
  stays positioned with the map instead of floating beside it. Supplying it opens the panel even
405
- when `legend` and `legendGradient` are both empty.
406
-
407
- **CSS parts:** `base`, `container`, `legend`, `legend-swatch`, `legend-gradient`, `legend-lo`,
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`,
485
+ `legend-toggle-hidden`, `legend-gradient`, `legend-lo`,
408
486
  `legend-hi`, `legend-limit`, `marker`, `popup`,
409
487
  `popup-content`, `popup-close-button`, `attribution`, `attribution-toggle`, `navigation`,
410
488
  `zoom-in`, `zoom-out`, `compass`, `scale`, `error`.
@@ -429,6 +507,38 @@ visible label carries its meaning. That association is advertised only while it
429
507
  the optional `maplibre-gl` peer is still loading, and after any failure, there is no map
430
508
  container in the tree, so `legend` withholds `aria-controls` rather than leaving a dangling
431
509
  idref.
510
+ Under `legendInteractive`, a row carrying a `value` wraps its swatch and label in a
511
+ `legend-toggle` `button` with `aria-pressed` rendered as the literal `"true"`/`"false"` — never
512
+ omitted, because a missing attribute reports "not a toggle button" rather than "unpressed". The
513
+ button's accessible name is its own visible label (caller-supplied data, so deliberately not
514
+ localized), scoped by the legend's own localized group name; no `aria-label` restates the state,
515
+ which would make assistive tech announce it twice. A hidden row's button additionally carries the
516
+ `legend-toggle-hidden` token — state lives in the part name, so `::part(legend-toggle-hidden)` is a
517
+ reachable hook — and dims only its `aria-hidden` swatch while re-colouring the label through the
518
+ quiet text token, so the label keeps AA contrast rather than fading with the whole button. In
519
+ forced colors the hidden row falls back to `line-through`, which survives a system-color collapse.
520
+ Each activation is announced through the shared light-DOM polite live region, using the same
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.
432
542
  `legend-limit` is the localized bounded-projection summary. The five peer-chrome parts project
433
543
  stable Lyra names onto MapLibre-generated DOM without erasing peer-supplied part tokens;
434
544
  `marker` retains a 24px minimum target in both axes even when a peer/custom marker has no intrinsic
@@ -460,6 +570,15 @@ integration; there is no declarative controls property.
460
570
  - `--lr-map-choropleth-fill-opacity` (default `0.75`) — fill opacity for the declarative
461
571
  `choropleth` layer and polygon fills in every `dataLayers` entry. It intentionally inherits from
462
572
  an ancestor, so one scoped declaration rethemes every nested map without setting each host.
573
+ - `--lr-map-hidden-category-opacity` (default `0.15`) — opacity a category listed in
574
+ `hiddenCategories` is muted to in the rendered MapLibre paint: `circle-opacity` and
575
+ `circle-stroke-opacity` on the points layer and `icon-opacity` on the point-icon symbol layer.
576
+ Like the fill-opacity token it is read from the resolved cascade on every paint, because MapLibre
577
+ draws to a WebGL canvas the CSS cascade never reaches, and it inherits from an ancestor. A
578
+ `kind: 'heatmap'` entry is out of scope: a density surface has no per-category field to mute.
579
+ - `--lr-map-legend-hidden-swatch-opacity` (default `0.5`) — opacity of a hidden interactive legend
580
+ row's decorative swatch. Only the `aria-hidden` swatch dims; the label re-colours through
581
+ `--lr-color-text-quiet` instead, so it never drops below 4.5:1.
463
582
  - `--lr-map-popup-close-button-hover-bg` (default `var(--lr-color-brand-quiet)`) and
464
583
  `--lr-map-popup-close-button-hover-color` (default `var(--lr-color-brand)`) — hover background
465
584
  and foreground of `popup-close-button`.
@@ -167,6 +167,17 @@ An async `source` row can carry the same two fields (`start`, `end`) alongside i
167
167
  default; an explicit instance value always wins. There is deliberately no `hoist` alias here:
168
168
  unlike on `lr-select`, where it is Shoelace's established spelling, it would be a boolean
169
169
  defaulting to `true`, so its attribute could only ever express the value the control already has
170
+ - `sync?: PlaceSync` (reflected) — copies the trigger's width, height, or both onto the listbox,
171
+ spelled the same as on `lr-dropdown`/`lr-popup`/`lr-popover`. Unset (the default), the listbox
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 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
170
181
  - `clearable: boolean = false` (reflected) — displays the clear button while there is something to
171
182
  clear on **either** axis this control owns: a committed selection, or _visible_ filter text. See
172
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,13 +128,41 @@ 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
130
145
  - `getUnknownLabel?: (value: string) => string` (attribute: false) — renders the label for a
131
146
  committed value that matches no option, everywhere it appears (trigger, `multiple` tag, synthetic
132
147
  row). `getTag` cannot serve this case: it is handed a matched option and there is none. A blank
133
- return falls back to the raw value
148
+ return falls back to the raw value. Not consulted while `loading` suppresses that same value —
149
+ see below
150
+ - `loading: boolean = false` (reflected) — whether a committed value's real label may still be
151
+ pending because its `<lr-option>` catalog hasn't arrived yet (e.g. an async fetch still in
152
+ flight). While `true`, a committed value that currently matches no option renders the localized
153
+ `loading` placeholder in the trigger label or the relevant `multiple` tag instead of the raw
154
+ value, with no `notInCatalog`/`[part='unknown-value']` badge and no synthetic
155
+ `showUnknownOption` listbox row — "not yet resolved" is a different state from "known to be
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
134
166
  - `filled: boolean = false` (reflected) — Shoelace alias for the filled trigger treatment
135
167
  - `autofocus: boolean = false` / `title: string = ''` — forwarded to the internal trigger
136
168
  - `multiple: boolean = false` (reflected) — several options selectable at once; see "Multi-select"
@@ -182,7 +214,17 @@ from a removed option, or a programmatic assignment with a typo) still commits
182
214
  stays fully reachable through `value`/`selectedOptions` — but renders a dashed/italic
183
215
  `[part='unknown-value']` badge next to the trigger label, or on the relevant `multiple`-mode tag,
184
216
  instead of an unexplained bare label, mirroring `<lr-model-select>`'s synthetic "not in catalog"
185
- stale-value row — see `--lr-select-unknown-value-border-style`/`-color` below.
217
+ stale-value row — see `--lr-select-unknown-value-border-style`/`-color` below. Set `loading` while
218
+ that same value's own `<lr-option>` catalog simply hasn't arrived yet (unlike `<lr-combobox>`,
219
+ `<lr-select>` has no async `source` of its own, so this is consumer-driven rather than automatic):
220
+ a still-unmatched value then renders the localized `loading` placeholder instead of the raw value,
221
+ with no `unknown-value` badge and no synthetic `showUnknownOption` row, since it is not yet known
222
+ to be missing. Once the matching option mounts, the real label renders on the next render with no
223
+ `value`/`selectedOptions` re-assignment needed, whether or not `loading` is also flipped back to
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.
186
228
 
187
229
  **Methods:** `focus(options?)`, `blur()`, and `click()` forward to the internal trigger button.
188
230
  `show()` and `hide()` return `Promise<void>` and resolve after `lr-after-show`/`lr-after-hide` once
@@ -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** `dompurify`, `katex`, `marked`, `shiki` — see `llms/peers.md`
12
- - **Themeable via** 2 parts, 2 custom properties — see this component's own `@csspart`/`@cssprop` list below
12
+ - **Themeable via** 13 parts, 2 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
  ---
@@ -59,8 +59,12 @@ share one implementation.
59
59
 
60
60
  **Slots:** none — content renders from `content`, not a slot.
61
61
 
62
- **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`) identical to
63
- `<lr-streaming-text>`'s own.
62
+ **CSS parts:** `base`, `cursor` (only rendered while `streaming` is `true`), plus every part
63
+ `<lr-markdown-core>` documents forwarded verbatim from the composed `<lr-markdown-core>` in
64
+ Markdown mode via `exportparts`: `content`, `heading`, `paragraph`, `list`, `code-block`,
65
+ `inline-code`, `link`, `table`, `blockquote`, `img`, `math` — the identical forwarded list
66
+ `<lr-streaming-text>` documents, since `<lr-markdown>` and `<lr-markdown-core>` share the same
67
+ documented part vocabulary.
64
68
 
65
69
  **Themeable custom properties:** `--lr-inline-cursor-width` (default `var(--lr-size-0-125rem)`) and
66
70
  `--lr-inline-cursor-height` (default `var(--lr-size-1em)`) — the same shared inline-cursor tokens