@aceshooting/lyra-ui 1.0.1 → 1.2.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 (150) hide show
  1. package/README.md +9 -10
  2. package/custom-elements.json +15118 -8342
  3. package/dist/components/chart/chart.d.ts +57 -1
  4. package/dist/components/chart/chart.js +202 -12
  5. package/dist/components/chart/chart.stories.d.ts +32 -0
  6. package/dist/components/chart/chart.stories.js +141 -0
  7. package/dist/components/chart/chart.styles.js +16 -0
  8. package/dist/components/chart/histogram.d.ts +2 -0
  9. package/dist/components/chart/histogram.js +18 -2
  10. package/dist/components/chart/line-chart.stories.d.ts +17 -0
  11. package/dist/components/chart/line-chart.stories.js +75 -0
  12. package/dist/components/chart/lite-chart.d.ts +83 -0
  13. package/dist/components/chart/lite-chart.js +424 -0
  14. package/dist/components/chart/lite-chart.stories.d.ts +14 -0
  15. package/dist/components/chart/lite-chart.stories.js +114 -0
  16. package/dist/components/chart/lite-chart.styles.d.ts +1 -0
  17. package/dist/components/chart/lite-chart.styles.js +83 -0
  18. package/dist/components/combobox/combobox.d.ts +9 -0
  19. package/dist/components/combobox/combobox.js +76 -13
  20. package/dist/components/combobox/combobox.stories.d.ts +18 -0
  21. package/dist/components/combobox/combobox.stories.js +75 -0
  22. package/dist/components/date-picker/calendar-core.js +13 -2
  23. package/dist/components/date-picker/date-input.d.ts +9 -1
  24. package/dist/components/date-picker/date-input.js +55 -7
  25. package/dist/components/date-picker/date-input.stories.d.ts +5 -0
  26. package/dist/components/date-picker/date-input.stories.js +36 -0
  27. package/dist/components/date-picker/date-picker.d.ts +11 -3
  28. package/dist/components/date-picker/date-picker.js +87 -37
  29. package/dist/components/date-picker/date-picker.stories.d.ts +4 -0
  30. package/dist/components/date-picker/date-picker.stories.js +12 -0
  31. package/dist/components/date-picker/date-picker.styles.js +1 -1
  32. package/dist/components/empty/empty.d.ts +12 -1
  33. package/dist/components/empty/empty.js +20 -3
  34. package/dist/components/empty/empty.stories.d.ts +2 -0
  35. package/dist/components/empty/empty.stories.js +20 -0
  36. package/dist/components/empty/empty.styles.js +11 -0
  37. package/dist/components/export-button/csv.js +6 -2
  38. package/dist/components/export-button/export-button.d.ts +15 -1
  39. package/dist/components/export-button/export-button.js +89 -12
  40. package/dist/components/export-button/export-button.stories.d.ts +1 -0
  41. package/dist/components/export-button/export-button.stories.js +5 -0
  42. package/dist/components/export-button/export-button.styles.js +11 -3
  43. package/dist/components/file-input/accept.d.ts +8 -2
  44. package/dist/components/file-input/accept.js +9 -3
  45. package/dist/components/file-input/file-input.d.ts +5 -2
  46. package/dist/components/file-input/file-input.js +23 -8
  47. package/dist/components/file-input/file-input.stories.d.ts +2 -0
  48. package/dist/components/file-input/file-input.stories.js +9 -0
  49. package/dist/components/flag/flag.d.ts +69 -3
  50. package/dist/components/flag/flag.js +107 -30
  51. package/dist/components/flag/flag.stories.d.ts +1 -0
  52. package/dist/components/flag/flag.stories.js +14 -0
  53. package/dist/components/flag/flag.styles.js +1 -1
  54. package/dist/components/flag/language-map.d.ts +11 -0
  55. package/dist/components/flag/language-map.js +12 -1
  56. package/dist/components/gauge/gauge.d.ts +2 -0
  57. package/dist/components/gauge/gauge.js +19 -5
  58. package/dist/components/gauge/gauge.stories.d.ts +2 -0
  59. package/dist/components/gauge/gauge.stories.js +6 -0
  60. package/dist/components/graph/graph-loader.d.ts +28 -0
  61. package/dist/components/graph/graph-loader.js +44 -0
  62. package/dist/components/graph/graph.d.ts +41 -15
  63. package/dist/components/graph/graph.js +146 -71
  64. package/dist/components/graph/graph.stories.d.ts +3 -0
  65. package/dist/components/graph/graph.stories.js +38 -0
  66. package/dist/components/graph/graph.styles.js +6 -1
  67. package/dist/components/heatmap/calendar-grid.d.ts +7 -0
  68. package/dist/components/heatmap/calendar-grid.js +27 -10
  69. package/dist/components/heatmap/heatmap.d.ts +149 -12
  70. package/dist/components/heatmap/heatmap.js +445 -28
  71. package/dist/components/heatmap/heatmap.stories.d.ts +22 -0
  72. package/dist/components/heatmap/heatmap.stories.js +155 -0
  73. package/dist/components/heatmap/heatmap.styles.js +60 -2
  74. package/dist/components/map/map.d.ts +17 -1
  75. package/dist/components/map/map.js +68 -21
  76. package/dist/components/map/map.stories.d.ts +10 -0
  77. package/dist/components/map/map.stories.js +107 -12
  78. package/dist/components/map/map.styles.js +2 -2
  79. package/dist/components/playback/playback.d.ts +6 -2
  80. package/dist/components/playback/playback.js +36 -12
  81. package/dist/components/playback/playback.stories.d.ts +2 -0
  82. package/dist/components/playback/playback.stories.js +6 -0
  83. package/dist/components/playback/playback.styles.js +10 -2
  84. package/dist/components/select/select.d.ts +125 -0
  85. package/dist/components/select/select.js +480 -0
  86. package/dist/components/select/select.stories.d.ts +21 -0
  87. package/dist/components/select/select.stories.js +91 -0
  88. package/dist/components/select/select.styles.d.ts +1 -0
  89. package/dist/components/select/select.styles.js +212 -0
  90. package/dist/components/skeleton/skeleton.js +4 -7
  91. package/dist/components/sparkline/sparkline.d.ts +1 -1
  92. package/dist/components/sparkline/sparkline.js +40 -13
  93. package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
  94. package/dist/components/sparkline/sparkline.stories.js +22 -0
  95. package/dist/components/split/split.d.ts +24 -3
  96. package/dist/components/split/split.js +155 -31
  97. package/dist/components/split/split.stories.d.ts +2 -0
  98. package/dist/components/split/split.stories.js +20 -0
  99. package/dist/components/stat/stat.d.ts +43 -1
  100. package/dist/components/stat/stat.js +97 -4
  101. package/dist/components/stat/stat.stories.d.ts +4 -0
  102. package/dist/components/stat/stat.stories.js +66 -1
  103. package/dist/components/stat/stat.styles.js +64 -0
  104. package/dist/components/table/table.d.ts +64 -2
  105. package/dist/components/table/table.js +213 -17
  106. package/dist/components/table/table.stories.d.ts +6 -0
  107. package/dist/components/table/table.stories.js +45 -0
  108. package/dist/components/table/table.styles.js +51 -4
  109. package/dist/components/time-range/time-range.d.ts +34 -5
  110. package/dist/components/time-range/time-range.js +217 -36
  111. package/dist/components/time-range/time-range.stories.d.ts +2 -0
  112. package/dist/components/time-range/time-range.stories.js +13 -0
  113. package/dist/components/time-range/time-range.styles.js +44 -2
  114. package/dist/components/toast/toast-item.d.ts +14 -2
  115. package/dist/components/toast/toast-item.js +134 -17
  116. package/dist/components/toast/toast-item.styles.js +28 -2
  117. package/dist/components/toast/toast.stories.d.ts +3 -0
  118. package/dist/components/toast/toast.stories.js +41 -1
  119. package/dist/components/toast/toast.styles.js +11 -11
  120. package/dist/components/toast/toaster.d.ts +1 -1
  121. package/dist/components/toast/toaster.js +13 -6
  122. package/dist/components/tree/tree-node.d.ts +13 -7
  123. package/dist/components/tree/tree-node.js +24 -10
  124. package/dist/components/tree/tree.d.ts +64 -15
  125. package/dist/components/tree/tree.js +113 -22
  126. package/dist/components/tree/tree.stories.d.ts +4 -0
  127. package/dist/components/tree/tree.stories.js +31 -1
  128. package/dist/components/tree/update-cascade.d.ts +13 -0
  129. package/dist/components/tree/update-cascade.js +13 -0
  130. package/dist/components/widget/widget.d.ts +25 -1
  131. package/dist/components/widget/widget.js +164 -6
  132. package/dist/components/widget/widget.stories.d.ts +3 -0
  133. package/dist/components/widget/widget.stories.js +43 -0
  134. package/dist/components/widget/widget.styles.js +20 -3
  135. package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
  136. package/dist/components/word-cloud/word-cloud-layout.js +145 -0
  137. package/dist/components/word-cloud/word-cloud.d.ts +65 -0
  138. package/dist/components/word-cloud/word-cloud.js +255 -0
  139. package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
  140. package/dist/components/word-cloud/word-cloud.stories.js +55 -0
  141. package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
  142. package/dist/components/word-cloud/word-cloud.styles.js +55 -0
  143. package/dist/internal/a11y.js +2 -1
  144. package/dist/internal/rtl.d.ts +7 -0
  145. package/dist/internal/rtl.js +9 -0
  146. package/dist/lyra.d.ts +9 -1
  147. package/dist/lyra.js +6 -0
  148. package/llms-full.txt +962 -276
  149. package/llms.txt +12 -9
  150. package/package.json +6 -4
package/llms-full.txt CHANGED
@@ -17,10 +17,11 @@ here.
17
17
  ## Contents
18
18
 
19
19
  - **Shared foundation** — `internal/` (base class, form-association mixin, positioning helper, design tokens)
20
- - **v1 core** — `lyra-combobox`/`lyra-option`, `lyra-date-picker`/`lyra-date-input`, `lyra-toast` family, `lyra-sparkline`, `lyra-flag`
21
- - **Dashboard atoms** — `lyra-empty`, `lyra-skeleton`, `lyra-stat`, `lyra-table`, `lyra-gauge`, `lyra-export-button`, `lyra-split`
20
+ - **v1 core** — `lyra-combobox`/`lyra-option`, `lyra-select`, `lyra-date-picker`/`lyra-date-input`, `lyra-toast` family, `lyra-sparkline`, `lyra-flag`
21
+ - **Dashboard atoms** — `lyra-empty`, `lyra-skeleton`, `lyra-stat`, `lyra-table`, `lyra-gauge`, `lyra-export-button`, `lyra-split`, `lyra-widget`, `lyra-word-cloud`
22
22
  - **Temporal & graph** — `lyra-time-range`, `lyra-playback`, `lyra-heatmap`, `lyra-graph`, `lyra-tree`/`lyra-tree-node`
23
- - **Chart family** — `lyra-chart` (core), typed subclasses, `lyra-histogram`, `lyra-box-plot`
23
+ - **Chart family** — `lyra-chart` (core), typed subclasses, `lyra-histogram`, `lyra-box-plot`,
24
+ `lyra-lite-chart` (dependency-free bar/line alternative, no peer deps)
24
25
  - **Map & file-input** — `lyra-map`, `lyra-file-input`
25
26
  - **Cross-cutting notes** — packaging and adoption caveats that apply across the whole library
26
27
 
@@ -41,47 +42,66 @@ Not a custom element itself — infrastructure every `lyra-*` component in the l
41
42
  properties `name: string`, `value: string` (hand-written accessor via Lit's `noAccessor` so
42
43
  `internals.setFormValue()` fires synchronously on assignment, not on the async update cycle),
43
44
  `disabled: boolean` (reflected), `required: boolean` (reflected). Provides
44
- `checkValidity()`/`reportValidity()` (delegate to `internals`), `formResetCallback()` (sets
45
- `value = ''`), `formDisabledCallback(disabled)`. Only `lyra-date-input` uses this mixin;
46
- `lyra-combobox` hand-rolls an equivalent independently (see its gotchas).
45
+ `checkValidity()`/`reportValidity()` (delegate to `internals`), `formDisabledCallback(disabled)`,
46
+ and a real `updateValidity()` that calls `internals.setValidity({ valueMissing: true }, ...)`
47
+ whenever `required` is set and `value === ''` (re-run on every `value`/`required` change and once
48
+ from `connectedCallback()`) — so `checkValidity()`/`reportValidity()`/`:invalid`/`:user-invalid`
49
+ now reflect real constraint state instead of always reporting valid. `formResetCallback()`
50
+ restores the value captured from the element's original `value` *content attribute* (native
51
+ `defaultValue` semantics), not a hardcoded blank — only a later `setAttribute('value', ...)` or
52
+ declarative markup updates that captured default; assigning the `.value` IDL property never does.
53
+ Only `lyra-date-input` uses this mixin; `lyra-combobox` and `lyra-select` each hand-roll an
54
+ equivalent independently instead (same `setValidity`/default-capture behavior — `lyra-combobox`
55
+ needs its own copy because its value can be an array in `multiple` mode, `lyra-select` because its
56
+ default is captured from a declared-`selected` `<lyra-option>` rather than a `value` content
57
+ attribute — see each component's own gotchas for where its copy diverges from the mixin).
47
58
  - **`positioner.ts` → `place(anchor, popup, opts?): () => void`** — thin wrapper over
48
59
  `@floating-ui/dom`'s `computePosition` + `autoUpdate`. Forces `strategy: 'fixed'` (to match the
49
60
  popup's own `position:fixed` CSS — otherwise it lands offset by the page scroll position),
50
61
  middleware `offset(opts.offset ?? 4)`, `flip()`, `shift({ padding: 8 })`, default
51
62
  `placement: 'bottom-start'`. Returns a cleanup function that stops the `autoUpdate` loop — call it
52
- in `disconnectedCallback()`. Used by `lyra-combobox`, `lyra-date-input`, `lyra-export-button`.
63
+ in `disconnectedCallback()`. Used by `lyra-combobox`, `lyra-select`, `lyra-date-input`,
64
+ `lyra-export-button`.
53
65
  - **`tokens.styles.ts` → `tokens`** (a `css` template prepended to every component's stylesheet) —
54
66
  the whole design-token layer. Every value chains through a `--wa-*` Web Awesome token first, with
55
67
  a hard-coded fallback, e.g. `--lyra-color-brand: var(--wa-color-brand-fill-loud, #0969da)`. Full
56
68
  set: `--lyra-color-surface / -text / -text-quiet / -border / -brand / -brand-quiet / -success /
57
- -warning / -danger`, `--lyra-space-xs / -s / -m / -l`, `--lyra-radius`, `--lyra-shadow`,
58
- `--lyra-font`. Also sets `:host([hidden]) { display: none !important; }` and an inherited
59
- `box-sizing: border-box` reset.
69
+ -warning / -danger`, `--lyra-color-on-brand` (text/icon color for content painted on a solid
70
+ brand-fill background, e.g. selected calendar days not itself light/dark-swapped, since it's
71
+ white on both fills), `--lyra-space-xs / -s / -m / -l`, `--lyra-radius`, `--lyra-shadow`,
72
+ `--lyra-font`, `--lyra-transition-fast` / `-base` (motion timing every animating component should
73
+ share), `--lyra-opacity-disabled` (one shared disabled-state opacity), `--lyra-focus-ring-width` /
74
+ `-color` / `-offset` (every `:focus-visible` rule should reference these), `--lyra-icon-button-size`
75
+ (minimum tappable box for icon-only buttons; the glyph itself doesn't grow). Also sets
76
+ `:host([hidden]) { display: none !important; }`, an inherited `box-sizing: border-box` reset, and
77
+ a `prefers-color-scheme: dark` fallback that re-points the color tokens' hard-coded defaults at a
78
+ dark palette (only takes effect when no real `--wa-*` theme value is present).
60
79
  - **`prefix.ts`** — `LYRA_PREFIX = 'lyra'`; `tag(name)` → `` `lyra-${name}` ``; `defineElement(name,
61
80
  ctor)` — idempotent `customElements.define`, safe if a module is evaluated twice.
62
81
  - **`a11y.ts`** — `nextId(scope)`: monotonic id generator (e.g. `nextId('combobox-list')` →
63
82
  `"lyra-combobox-list-3"`); `srOnly`: a visually-hidden-but-AT-visible CSS class (used by
64
- `lyra-skeleton`'s "Loading…" text).
83
+ `lyra-skeleton`'s "Loading…" text and `lyra-stat`'s trend announcement).
84
+ - **`icons.ts`** — the shared inline-SVG icon set (`chevronIcon`, `closeIcon`, `playIcon`,
85
+ `pauseIcon`, `calendarIcon`, `expandIcon`), replacing the literal Unicode/emoji glyphs components
86
+ used previously. One 24×24 viewBox/stroke-width per icon, rendered at `1em` so each inherits the
87
+ caller's font size; none bake in a direction — callers rotate the wrapping `part` element via CSS
88
+ for "up"/"open"/etc.
89
+ - **`scroll-lock.ts` → `lockScroll(): () => void`** — ref-counted `document.documentElement`
90
+ scroll lock (used by `lyra-widget`'s fullscreen mode); safe to acquire/release more than once
91
+ concurrently, restores the original `overflow` only once the last outstanding lock releases.
92
+ - **`rtl.ts` → `isRtl(el): boolean`** — reads `getComputedStyle(el).direction === 'rtl'`, for the
93
+ pointer/keyboard math (drag ratios, arrow-key direction) in `lyra-split` and `lyra-time-range`
94
+ that reasons about physical left/right and can't rely on logical CSS properties alone.
65
95
 
66
96
  **Known gotchas:**
67
- - `required` is currently inert for real validation. `FormAssociated` never calls
68
- `internals.setValidity(...)`; `ElementInternals` defaults to "valid" until that's called.
69
- `checkValidity()`/`reportValidity()` always return `true`. Concretely:
70
- `<form><lyra-date-input name="d" required></lyra-date-input></form>`; `form.reportValidity()`
71
- returns `true` and the form submits with the field empty no native bubble, no
72
- `:invalid`/`:user-invalid` match, no `aria-invalid`. This affects both `lyra-date-input` (via the
73
- mixin) and `lyra-combobox` (via its own hand-rolled copy). **Do not rely on `required` for
74
- constraint validation yet** validate in application code until this is fixed.
75
- - `formResetCallback()` always resets to blank (`''` / `[]`), not the field's
76
- originally-constructed value. `form.reset()` on `<lyra-date-input value="2026-07-15">` clears it
77
- to `''` instead of restoring `"2026-07-15"` the way a native input's `defaultValue` would.
78
- - only `lyra-date-input` actually uses the shared mixin; `lyra-combobox` independently
79
- hand-rolls the same internals/validity/reset logic (needed because its value can be an array in
80
- `multiple` mode) — the two copies can silently diverge, and the `setValidity` gap above has to be
81
- fixed in both places.
82
- - no shared label/input association helper: `<label part="form-control-label">` in both
83
- `lyra-combobox` and `lyra-date-input` is a DOM sibling of the input with no `id`/`for` pairing, so
84
- clicking the visible label text doesn't focus the field the way a native `<label for>` would.
97
+ - `formResetCallback()`'s restore-to-constructed-default behavior (both here and in
98
+ `lyra-combobox`'s hand-rolled copy) only captures the default from the `value` *content
99
+ attribute*/declarative markup — a plain property assignment (`el.value = 'x'`) never redefines
100
+ what `form.reset()` restores to, matching native `defaultValue`/`defaultSelected` semantics but
101
+ worth knowing if you expected "whatever was last set" to survive a reset.
102
+ - no shared label/input association helper as such, but `lyra-combobox` and `lyra-date-input` each
103
+ now pair their own `<label part="form-control-label" for=...>` with a matching `id` on the input,
104
+ so clicking the visible label text does focus the field.
85
105
 
86
106
  ---
87
107
 
@@ -99,27 +119,47 @@ shared `FormAssociated` mixin — see gotchas).
99
119
  - `multiple: boolean = false` (reflected)
100
120
  - `placeholder: string = ''`
101
121
  - `disabled: boolean = false` (reflected)
102
- - `required: boolean = false` (reflected — **inert**, see gotchas)
122
+ - `required: boolean = false` (reflected — enforced via `internals.setValidity()`; also reflected as
123
+ `aria-required` on `<input part="combobox-input">` immediately, and `aria-invalid` once the field
124
+ has been touched, see gotchas)
103
125
  - `name: string = ''`
104
126
  - `label: string = ''`
105
127
  - `hint: string = ''`
128
+ - `errorText: string = ''` (attribute `error-text` — static error copy shown below the hint;
129
+ overridden by slotted `error` content when provided)
106
130
  - `open: boolean = false` (reflected)
107
131
  - `withClear: boolean = false` (attribute **`with-clear`** — not `clearable`, see gotchas)
108
132
  - `maxOptionsVisible: number = 3` (attribute `max-options-visible` — caps how many selected tags
109
133
  show before collapsing to `+N`)
110
134
  - `emptyText: string = 'No results'` (attribute `empty-text`)
111
135
  - `filter: OptionFilter | null = null` (attribute: false — `(option, query) => boolean`; default
112
- matches `label`/`searchText` case-insensitively)
136
+ matches `label`/`searchText` case-insensitively; ignored while `source` is set)
137
+ - `source: ComboboxSource | null = null` (attribute: false — `(query: string) =>
138
+ Promise<ComboboxSourceRow[]>`; when set, replaces the light-DOM `<lyra-option>` list with an async
139
+ lookup, debounced ~200ms after each keystroke and re-run on clear/pick. A "Loading…" row is shown
140
+ while a call is in flight; a stale in-flight call that resolves after a newer one (or after
141
+ disconnect) is dropped via a monotonic token)
142
+ - `maxRender: number = 200` (attribute `max-render` — caps how many rows render at once, always
143
+ keeping the current selection visible even if it's outside the cap; the excess shows as an
144
+ "+N more — refine your search" row instead of being dropped silently)
113
145
  - `value: string | string[]` — a getter/setter: plain `string` in single mode, `string[]` in
114
146
  `multiple` mode
115
147
 
148
+ `ComboboxSourceRow = { value: string; label: string; sub?: string; dotColor?: string; group?:
149
+ string; disabled?: boolean }` — the row shape both the async `source` and the light-DOM
150
+ `<lyra-option>` path normalize to internally.
151
+
116
152
  **Events:** `change` (native-style — selection changed), `input` (native-style — typed or selection
117
153
  changed), `lyra-show`, `lyra-hide`, `lyra-clear`
118
154
 
119
- **Slots:** default (`<lyra-option>` children), `label`, `hint`
155
+ **Slots:** default (`<lyra-option>` children), `label`, `hint`, `error` (overrides the `errorText`
156
+ attribute when provided)
120
157
 
121
158
  **CSS parts:** `form-control`, `form-control-label`, `combobox`, `tags`, `tag`,
122
- `tag__remove-button`, `combobox-input`, `clear-button`, `expand-icon`, `listbox`, `option`, `hint`
159
+ `tag__remove-button`, `combobox-input`, `clear-button`, `expand-icon`, `listbox`, `option`,
160
+ `option-dot` (the leading status dot, when a row's `dotColor` is set), `option-label`, `option-sub`
161
+ (a row's secondary line, when `sub` is set), `option-overflow` (the "+N more" indicator from
162
+ `maxRender`), `error`, `hint`
123
163
 
124
164
  **Themeable custom properties:** shared tokens only.
125
165
 
@@ -130,10 +170,14 @@ changed), `lyra-show`, `lyra-hide`, `lyra-clear`
130
170
  **Properties:**
131
171
  - `value: string = ''`
132
172
  - `disabled: boolean = false`
133
- - `selected: boolean = false` (reflected — set by the parent combobox)
173
+ - `selected: boolean = false` (reflected — set by the parent combobox, but also **read** on initial
174
+ mount, see below)
134
175
  - `group: string = ''` (section header)
135
176
  - `searchText: string = ''` (attribute `search-text` — extra text the filter matches beyond the
136
177
  visible label)
178
+ - `sub: string = ''` (optional secondary line rendered under the label, e.g. a status/date summary)
179
+ - `dotColor: string = ''` (attribute `dot-color` — optional CSS color for a small leading status
180
+ dot, any valid CSS color)
137
181
  - `label` is a **read-only getter**: explicit `label` attribute wins, else trimmed `textContent`.
138
182
 
139
183
  ```html
@@ -146,24 +190,117 @@ changed), `lyra-show`, `lyra-hide`, `lyra-clear`
146
190
  </script>
147
191
  ```
148
192
 
193
+ ```html
194
+ <!-- Async data source instead of light-DOM <lyra-option> children: -->
195
+ <lyra-combobox id="cb2" label="Fruit (async)" with-clear></lyra-combobox>
196
+ <script type="module">
197
+ document.getElementById('cb2').source = async (query) => {
198
+ const rows = await fetchFruit(query); // your own lookup
199
+ return rows.map((r) => ({ value: r.id, label: r.name }));
200
+ };
201
+ </script>
202
+ ```
203
+
149
204
  Multi-select submits as **repeated `FormData` entries** (not a joined string) —
150
- `new FormData(form).getAll(name)` behaves like a native multi-value control.
205
+ `new FormData(form).getAll(name)` behaves like a native multi-value control. An unnamed multi-select
206
+ (`multiple` with no `name`) contributes nothing to the form at all, matching a nameless native
207
+ `<select multiple>`, rather than falling back to a shared literal key that could collide with
208
+ another unnamed combobox in the same form.
151
209
 
152
210
  **Known gotchas:**
153
- - `required` is declared but completely inert: never read for validity (see the shared
154
- `internal/` gotchas above) **and** never reflected as `aria-required` onto the actual
155
- `<input part="combobox-input">` — a screen-reader user gets zero indication the field is
156
- mandatory even before the validity fix lands.
157
- - declaratively-selected options are **ignored on initial render**:
158
- `<lyra-option value="b" selected>` in markup never seeds the combobox's selection — only the
159
- combobox's own state pushes down to options, never the reverse. Set `.value` programmatically
160
- after mount instead of relying on the `selected` attribute in markup.
161
211
  - naming drift from Web Awesome parity: the shipped attribute is **`with-clear`**, not
162
212
  `clearable` (which is what `<wa-combobox>` uses) — don't copy-paste WA docs assuming a 1:1
163
213
  attribute name here.
214
+ - a host-level `aria-label` attribute on `<lyra-combobox>` now takes priority over `label`/
215
+ `placeholder`/`"Combobox"` when resolving the accessible name on `[part="combobox-input"]` —
216
+ previously it was silently ignored. Matches the same fallback on `<lyra-select>`.
164
217
  - no `size` variant (small/medium/large) unlike sibling `lyra-toast-item`.
218
+ - `aria-required` reflects `required` immediately; `aria-invalid`, by contrast, only reflects
219
+ **after the field has been touched** (first `blur`) — a `required` field with a validity error
220
+ doesn't look invalid to assistive tech before that, by design (avoids flashing invalid styling on
221
+ first render). Blurring the input (Tab away) now also closes an open listbox, the same as a
222
+ native `<select>`'s popup, not just a click outside or Escape.
223
+ - `dotColor`/`sub`/`group` are read from light-DOM `<lyra-option>` children as before, but are also
224
+ first-class fields on `ComboboxSourceRow` for the async `source` path — an async lookup can drive
225
+ the same grouped/dot/sub-text rendering a static option list can.
165
226
  - Full ARIA 1.2 combobox pattern (`role=combobox`, roving `aria-activedescendant`, real DOM focus
166
- kept on the input) is implemented correctly — a genuine strength, safe to build on.
227
+ kept on the input) is implemented correctly — a genuine strength, safe to build on. Declaratively-
228
+ selected options (`<lyra-option value="b" selected>`) are seeded into the selection (mirroring
229
+ native `<select><option selected>`) both the first time options are collected **and** for any
230
+ later batch slotted in afterward (e.g. a lazily-populated list appended post-connect) — only the
231
+ `form.reset()` default itself is captured exclusively from that very first pass; a later pick, or
232
+ a later batch of newly-`selected` options, never redefines what a reset restores to.
233
+
234
+ ---
235
+
236
+ ## `lyra-select`
237
+
238
+ A plain closed-list dropdown — a direct `<lyra-*>` counterpart to `<wa-select>`/`<wa-option>`.
239
+ **Form-associated** (hand-rolled internals, not the shared `FormAssociated` mixin — same reasoning
240
+ as `lyra-combobox`, see the shared-foundation notes above). The trigger is a `<button>`, not a text
241
+ input: click/Enter/Space/ArrowDown opens it, and there's no typing-to-filter. Options are
242
+ `<lyra-option value>` children — the same element `<lyra-combobox>` uses — reconciled the same way
243
+ combobox does, and the popup reuses `internal/positioner.ts` for placement.
244
+
245
+ Single-select only, with no `filter`/`source`/`with-clear`/`max-options-visible`/`empty-text`/
246
+ `max-render`/`multiple` surface — reach for `<lyra-combobox>` instead whenever any of those apply.
247
+
248
+ **Properties:**
249
+ - `placeholder: string = ''`
250
+ - `disabled: boolean = false` (reflected)
251
+ - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
252
+ - `name: string = ''`
253
+ - `label: string = ''`
254
+ - `hint: string = ''`
255
+ - `errorText: string = ''` (attribute `error-text` — static error copy shown below the hint;
256
+ overridden by slotted `error` content when provided)
257
+ - `open: boolean = false` (reflected)
258
+ - `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected — same scale as `lyra-toast-item`'s `size`, for
259
+ compact toolbar placements that don't fit the default trigger height)
260
+ - `value: string` — a getter/setter; always a single string (no `multiple` mode)
261
+
262
+ **Events:** `change` (native-style — selection changed), `input` (fired alongside `change` on every
263
+ selection change — a native `<select>` doesn't meaningfully distinguish the two either),
264
+ `lyra-show`, `lyra-hide`
265
+
266
+ **Slots:** default (`<lyra-option>` children), `label`, `hint`, `error` (overrides the `errorText`
267
+ attribute when provided)
268
+
269
+ **CSS parts:** `form-control`, `form-control-label`, `trigger`, `listbox`, `option`, `option-dot`
270
+ (the leading status dot, when a row's `dotColor` is set), `option-label`, `option-sub` (a row's
271
+ secondary line, when `sub` is set), `expand-icon`, `error`, `hint`
272
+
273
+ **Themeable custom properties:** shared tokens only.
274
+
275
+ **Optional peer deps:** none.
276
+
277
+ ```html
278
+ <lyra-select id="sel" label="Fruit" placeholder="Pick one…">
279
+ <lyra-option value="a">Apple</lyra-option>
280
+ <lyra-option value="b" selected>Banana</lyra-option>
281
+ </lyra-select>
282
+ <script type="module">
283
+ document.getElementById('sel').addEventListener('change', (e) => console.log(e.target.value));
284
+ </script>
285
+ ```
286
+
287
+ **Known gotchas:**
288
+ - The trigger keeps real DOM focus throughout — the listbox's "active" row is conveyed via
289
+ `aria-activedescendant`, never actual focus, matching the WAI-ARIA "select-only combobox" pattern
290
+ (as opposed to `lyra-combobox`'s editable-input pattern).
291
+ - No typing-to-filter, but a printable keypress still jumps to (while open) or directly selects
292
+ (while closed) the next non-disabled option whose label starts with what's been typed, matching a
293
+ native `<select>`'s own type-ahead; the buffer resets ~500ms after the last keystroke.
294
+ - Declaratively-selected options (`<lyra-option value="b" selected>`) seed the initial selection
295
+ (mirroring native `<select><option selected>`) both the first time options are collected and for
296
+ any later-slotted batch — only that very first pass' declared selection becomes the
297
+ `form.reset()` default, the same rule `lyra-combobox` follows.
298
+ - `aria-required` on the trigger reflects `required` immediately; `aria-invalid` only reflects once
299
+ the trigger has been blurred (touched) at least once, mirroring `lyra-combobox`'s own input.
300
+ Blurring the trigger (Tab away) closes an open listbox, the same as a native `<select>`'s popup.
301
+ - The trigger's accessible name now checks a host-level `aria-label` attribute first, before falling
302
+ back to `label`/`placeholder`/`"Select"` — a plain `aria-label` on `<lyra-select>` is no longer
303
+ silently ignored.
167
304
 
168
305
  ---
169
306
 
@@ -195,7 +332,8 @@ Inline month-grid calendar, not form-associated (used standalone or embedded ins
195
332
  **Getters:** `selection: { from: Date|null; to: Date|null }`, `valueAsDate: Date | null` (single
196
333
  mode only)
197
334
 
198
- **Methods:** `clear()`, `goToToday()`, `goToDate(date: string | Date)`
335
+ **Methods:** `clear()`, `goToToday()`, `goToDate(date: string | Date)` (clamped to `min`/`max` before
336
+ navigating/focusing)
199
337
 
200
338
  **Events:** `input` (during interaction — for range mode, fires after the first click of a pair),
201
339
  `change` (committed value)
@@ -230,15 +368,22 @@ Text field + calendar popover, **form-associated** via the shared `FormAssociate
230
368
  - `months: 1|2 = 1`
231
369
  - `firstDayOfWeek: string = 'auto'`
232
370
  - `weekdayFormat: 'narrow'|'short'|'long' = 'short'`
371
+ - `disablePast: boolean = false` (attribute `disable-past`)
372
+ - `disableFuture: boolean = false` (attribute `disable-future`)
373
+ - `withOutsideDays: boolean = false` (attribute `with-outside-days`)
374
+
375
+ **Properties (own, continued):**
376
+ - `errorText: string = ''` (attribute `error-text` — static error copy; overridden by slotted
377
+ `error` content when provided)
233
378
 
234
379
  **Methods:** `show()`, `hide()`, `clear()`
235
380
 
236
381
  **Events:** `input`, `change`, `lyra-show`, `lyra-hide`, `lyra-clear`
237
382
 
238
- **Slots:** `label`, `hint`
383
+ **Slots:** `label`, `error` (overrides `errorText`), `hint`
239
384
 
240
385
  **CSS parts:** `form-control`, `form-control-label`, `input-wrapper`, `input`, `clear-button`,
241
- `expand-button`, `expand-icon`, `popup`, `date-picker`, `hint`
386
+ `expand-button`, `expand-icon`, `popup`, `date-picker`, `error`, `hint`
242
387
 
243
388
  **Themeable custom properties:** shared tokens only.
244
389
 
@@ -257,30 +402,30 @@ Text field + calendar popover, **form-associated** via the shared `FormAssociate
257
402
  from the package root**, but worth knowing when reasoning about behavior): `parseISO`, `formatISO`,
258
403
  `isSameDay`, `addMonths`, `clampDate`, `monthMatrix(year, month, firstDayOfWeek)`,
259
404
  `weekdayLabels(firstDayOfWeek, format, locale)`, `monthTitle(year, month, locale)`,
260
- `resolveFirstDayOfWeek(value)`.
405
+ `resolveFirstDayOfWeek(value, locale?)`.
261
406
 
262
407
  **Known gotchas:**
263
- - `first-day-of-week="auto"` does **not** derive from `locale` despite the name:
264
- `resolveFirstDayOfWeek()` hardcodes `0` (Sunday) whenever the value is `'auto'`, even though
265
- `locale` is already used elsewhere (weekday labels, month title). A French/German-locale user gets
266
- a Sunday-first grid instead of the Monday-first layout their OS would show. **Workaround:** pass
267
- an explicit `first-day-of-week="mon"` (accepted values: `sun`/`mon`/`tue`/`wed`/`thu`/`fri`/`sat`)
268
- if you need locale-correct week starts.
269
- - (date-input only) — the calendar popover has **no Escape-to-close**: only
270
- `Alt+ArrowDown` opens it; there's no keydown handling to `hide()` on Escape, unlike `lyra-combobox`
271
- in the same package. A keyboard user must Tab all the way through the grid/nav/clear buttons or
272
- click elsewhere to dismiss it.
408
+ - `first-day-of-week="auto"` now derives from `locale` when the runtime's `Intl.Locale` exposes
409
+ week-info (`weekInfo`/`getWeekInfo()`, still shifting between engines) `resolveFirstDayOfWeek()`
410
+ only hardcodes Sunday as the fallback when that isn't available or `locale` is unset. A
411
+ French/German-locale user with a supporting runtime now gets the Monday-first grid their OS would
412
+ show; on an older runtime it still falls back to Sunday-first silently. **If you need a guaranteed
413
+ locale-correct week start regardless of runtime**, pass an explicit
414
+ `first-day-of-week="mon"` (accepted values: `sun`/`mon`/`tue`/`wed`/`thu`/`fri`/`sat`).
273
415
  - (date-input only) — opening the calendar doesn't move focus into it (`show()` only flips
274
416
  `open`) — a keyboard user opening via `Alt+ArrowDown` lands on a visible popover with focus still
275
- on the text input.
276
- - one hardcoded, non-token color: selected/range-start/range-end day text is
277
- `color: #fff` in `date-picker.styles.ts`if you override
278
- `--lyra-color-brand`/`--wa-color-brand-fill-loud` to something light, contrast on selected-day
279
- text isn't guaranteed.
280
- - (date-input only) shares the mixin's reset-to-blank defect: `form.reset()` always clears to
281
- `''` rather than restoring the originally-constructed `value`.
417
+ on the text input. Escape *does* close it from anywhere inside the form control (including the
418
+ nested picker's own day/nav buttons), returning focus to the expand button.
419
+ - (date-input only) typing an unparseable string and blurring/committing sets `badInput` via
420
+ `internals.setValidity()` and reverts the displayed text to the last valid commit, but does not
421
+ reflect `aria-invalid` onto the visible `<input part="input">` itself (unlike `lyra-combobox`,
422
+ which does reflect `aria-invalid`/`aria-required` onto its own input) rely on `data-invalid`
423
+ (a host attribute, toggled the same way) or `reportValidity()` rather than assuming the input's own
424
+ ARIA state.
282
425
  - The grid keyboard pattern (Arrow/PageUp/PageDown/Home/End navigation with correct focus
283
- sequencing) is implemented correctly and safe to rely on.
426
+ sequencing) is implemented correctly and safe to rely on, as is the selected/range-day text color
427
+ (`--lyra-color-on-brand`, not a hardcoded literal — safe to override `--lyra-color-brand` without
428
+ losing contrast on selected-day text).
284
429
 
285
430
  ---
286
431
 
@@ -317,7 +462,8 @@ A single notification.
317
462
 
318
463
  **Properties:**
319
464
  - `duration: number = 5000` (ms; `Infinity` or `<= 0` disables auto-dismiss)
320
- - `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected)
465
+ - `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected — drives both `--padding` and the toast's own
466
+ font-size via `:host([size=...])`, from a compact `xs` up to a roomier `xl`)
321
467
  - `variant: 'brand'|'success'|'warning'|'danger'|'neutral' = 'neutral'` (reflected)
322
468
  - `withIcon: boolean = false` (attribute `with-icon`)
323
469
 
@@ -330,15 +476,24 @@ DOM.
330
476
 
331
477
  **CSS parts:** `toast-item`, `accent`, `icon`, `content`, `close-button`
332
478
 
333
- **Themeable custom properties:** `--accent-width` (4px), `--show-duration`/`--hide-duration` (250ms
334
- each), `--padding` (`var(--lyra-space-m)`), `--accent-color` (defaults to `--lyra-color-border`,
479
+ **Themeable custom properties:** `--accent-width` (4px), `--show-duration`/`--hide-duration`
480
+ (`var(--lyra-transition-base, 180ms ease-out)` 180ms by default, matching the JS-side show/hide
481
+ animation timer, which is not itself reading this token), `--padding` (`var(--lyra-space-m)`),
482
+ `--font-size` (`1rem`) — both `--padding` and `--font-size` are auto-swapped per `size`, from a
483
+ compact `xs` up to a roomier `xl` — `--accent-color` (defaults to `--lyra-color-border`,
335
484
  auto-swapped per `variant` to `--lyra-color-brand/-success/-warning/-danger`).
336
485
 
337
486
  **Optional peer deps:** none.
338
487
 
339
488
  `role` is chosen automatically per `variant`: `"alert"` for `danger`/`warning`, `"status"`
340
- otherwise. Auto-dismiss timer **pauses** on `pointerenter`/`focusin`, **resumes** on
341
- `pointerleave`/`focusout`, with real elapsed-time bookkeeping (WCAG 2.2.1 timing-adjustable).
489
+ otherwise — re-evaluated on every `variant` change, not just at first render, so reassigning
490
+ `variant` to `danger`/`warning` after creation is announced as an interruption instead of keeping
491
+ its original, now-stale role. Auto-dismiss timer **pauses** on `pointerenter`/`focusin`, **resumes**
492
+ on `pointerleave`/`focusout`, with real elapsed-time bookkeeping (WCAG 2.2.1 timing-adjustable) —
493
+ hover and focus are tracked as independent pause reasons, so releasing only one (e.g. the pointer
494
+ leaves while focus remains, or vice versa) keeps the timer paused until *neither* holds it anymore.
495
+ A `duration` change while the timer is actively counting down reschedules it immediately against
496
+ the new value instead of waiting for the next pause/resume cycle.
342
497
 
343
498
  ### `toast()`
344
499
 
@@ -355,7 +510,9 @@ toast({ message: 'Deleted', variant: 'danger', action: { label: 'Undo', onClick:
355
510
  `toast(input: ToastOptions | string): ToastHandle` where
356
511
  `ToastOptions = ToastCreateOptions & { message: string; placement?: ToastPlacement; action?: { label: string; onClick: (item: LyraToastItem) => void } }`,
357
512
  and `ToastHandle = { item: Promise<LyraToastItem>; dismiss: () => void }`. Lazily mounts (and
358
- re-mounts if removed) a single `<lyra-toast>` region on `document.body`.
513
+ re-mounts if removed) **one singleton `<lyra-toast>` region per distinct `placement`** on
514
+ `document.body` — a `toast()` call targeting one placement never relocates toasts already showing
515
+ at another, since `placement` is a per-call option rather than a single global region's setting.
359
516
 
360
517
  ```html
361
518
  <script type="module">
@@ -369,9 +526,15 @@ re-mounts if removed) a single `<lyra-toast>` region on `document.body`.
369
526
  `role="status" aria-live="polite"` **and** each `lyra-toast-item` independently self-assigns its
370
527
  own `role` (`status`/`alert`) — nesting live regions can cause some screen readers to announce a
371
528
  new toast twice. Plausible from the code, unverified against real AT.
372
- - pause/resume-on-hover/focus (the component's main accessibility differentiator) has no
373
- regression test coverage don't assume a future refactor won't silently break it; test it
374
- yourself if it's load-bearing for your app.
529
+ - the close button's accessible name is derived from the toast's own message text (`"Close: <first
530
+ 40 chars>…"`, falling back to bare `"Close"` only when the toast has no text content) rather than
531
+ a bare `"Close"` on every instance — useful when several toasts are stacked and a screen-reader or
532
+ switch-access user needs to tell their close buttons apart without activating one first.
533
+ - pause/resume-on-hover/focus (the component's main accessibility differentiator), including the
534
+ independent-hover-vs-focus pause reasons above, now has regression test coverage.
535
+ - `hide()` is idempotent (a second call while already hiding is a no-op) and `[part="close-button"]`
536
+ gets `aria-disabled="true"` once hiding starts, so a stray extra click/Enter during the hide
537
+ animation can't re-enter it.
375
538
  - Prefer the `toast()` helper over manually creating `<lyra-toast>`/`<lyra-toast-item>` — it already
376
539
  handles the singleton-region and remount-if-removed logic.
377
540
 
@@ -409,8 +572,13 @@ the reviewed family (pure CSS cascade, no JS/`getComputedStyle` bridging needed)
409
572
  - Auto-sets `role="img"` + a descriptive `aria-label` ("Trend of N values, last X" / "No data" when
410
573
  empty) — good default, but still just a single summary sentence with no tabular fallback; fine for
411
574
  a glance-value sparkline, worth reconsidering if it's ever the *sole* representation of a metric.
412
- - `min`/`max` overrides and the `bar`/`area` render types are lightly tested no correctness
413
- concerns found, just be aware coverage is thin there.
575
+ - flat data (every value equal, so the auto-computed range spans zero) now renders a centered
576
+ midline/mid-height bars instead of collapsing every point to the bottom edge, and a single-value
577
+ series renders a visible flat line (a zero-length path was previously invisible). A `type="bar"`
578
+ series past 500 points is decimated to at most 500 rendered `<rect>`s (evenly sampled, not
579
+ aggregated/averaged) so an unbounded `values` array can't turn directly into that many shadow-DOM
580
+ nodes; `line`/`area` types have no such cap (they already collapse to one `<path>` regardless of
581
+ point count).
414
582
 
415
583
  ---
416
584
 
@@ -423,9 +591,17 @@ Country/language flag image. Flag artwork ships in a **separate, optional peer p
423
591
  - `country?: string` (ISO 3166-1 alpha-2, e.g. `"fr"` — takes precedence over `language`)
424
592
  - `language?: string` (BCP-47-ish tag, e.g. `"en"`/`"en-US"`, resolved to a representative country
425
593
  via `languageToCountry()`)
426
- - `label?: string` (accessible name / `alt` text**defaults to the bare uppercase code if
427
- omitted**, see gotchas)
594
+ - `src?: string` (a pre-resolved flag image URLtakes precedence over `country`/`language` and
595
+ skips the peer-package lookup/loading-skeleton round trip entirely; mainly useful to avoid even
596
+ the small per-flag async hop when you already have the URL at build time, e.g. from
597
+ `import frUrl from '@aceshooting/lyra-flags/flags/fr.svg?url'`. `label` is effectively required
598
+ alongside `src` since there's no `country`/`language` to derive a fallback `alt` from.)
599
+ - `label?: string` (accessible name / `alt` text — **defaults to a localized, human-readable region
600
+ name derived from the *resolved country* code via `Intl.DisplayNames` if omitted**, see gotchas)
428
601
  - `round: boolean = false` (reflected — circular crop)
602
+ - `detailed: boolean = false` (reflected — requests the pristine, pre-optimization source SVG
603
+ instead of the default icon-optimized one; a no-op for the majority of codes whose source art was
604
+ never large enough to need optimizing. Has no effect when `src` is set. See gotchas.)
429
605
 
430
606
  **Events:** none.
431
607
 
@@ -433,7 +609,8 @@ Country/language flag image. Flag artwork ships in a **separate, optional peer p
433
609
 
434
610
  **CSS parts:** `image`
435
611
 
436
- **Themeable custom properties:** `--lyra-flag-radius` (default `2px`, non-`round` corner radius).
612
+ **Themeable custom properties:** `--lyra-flag-radius` (default `2px`, non-`round` corner radius);
613
+ also consumes `--lyra-color-border` (the inset ring around the flag image).
437
614
 
438
615
  **Optional peer deps:** `@aceshooting/lyra-flags` — required for the component to actually render an
439
616
  image; without it, renders an empty template (see gotchas).
@@ -454,15 +631,41 @@ pnpm add @aceshooting/lyra-flags # required peer — without it, <lyra-flag> r
454
631
 
455
632
  **Known gotchas:**
456
633
  - Requires the optional peer `@aceshooting/lyra-flags` to actually render an image; without it the
457
- component renders an **empty template** plus a one-time `console.warn` (lazy `import()`, cached
458
- module-wide so the warning fires only once per page even with many `<lyra-flag>` instances).
634
+ component still shows a `<lyra-skeleton variant="rect">` placeholder (with `aria-busy="true"` on
635
+ the host) while resolving, then settles into an **empty template** plus a one-time `console.warn`
636
+ once the resolver rejects (lazy `import()`, cached module-wide so the warning fires only once per
637
+ page even with many `<lyra-flag>` instances).
459
638
  - Rendering is async even when the peer *is* installed: `src` resolves after an `import()` +
460
- resolver call, so there's a brief empty-render window on first paint/attribute change — don't
639
+ resolver call, so there's a brief loading-skeleton window on first paint/attribute change — don't
461
640
  assume the `<img>` exists synchronously right after setting `country`/`language`.
462
- - if `label` is omitted, the accessible name (`alt`) falls back to the **bare uppercase
463
- ISO code** (e.g. `"FR"`, `"DE"`) a screen reader will typically spell this out letter-by-letter
464
- rather than announce a country name. Always pass an explicit `label` (e.g. `"France"`) if the flag
465
- conveys real information rather than being purely decorative.
641
+ - if `label` is omitted, the accessible name (`alt`) falls back to a localized region name via
642
+ `Intl.DisplayNames([navigator.language], { type: 'region' })` (e.g. `"United Kingdom"`) instead of
643
+ a bare code for `language="en"` that's derived from `"GB"` (the mapped country), not `"EN"` (the
644
+ language tag itself). Falls back further to the bare uppercase code if `Intl.DisplayNames` throws
645
+ for an unrecognized region or isn't available. You can still pass an explicit `label` (e.g.
646
+ `"France"`) to override the derived name.
647
+ - an invalid `country` (anything not matching the ISO 3166-1 alpha-2 shape, e.g. containing `../`)
648
+ is rejected before it ever reaches the peer's `flagUrl()` resolver — treated the same as an
649
+ unknown/missing flag rather than being passed through. `languageToCountry()`'s region-subtag path
650
+ is validated against that same alpha-2 shape too (not just a bare length-2 check), so a malformed
651
+ region such as `"en-01"` correctly falls through to the `LANGUAGE_TO_COUNTRY[base]` lookup instead
652
+ of being accepted verbatim as a country code.
653
+ - rapidly reassigning `country`/`language`/`src` no longer risks a stale resolution overwriting a
654
+ newer one: each resolver call is tagged with a token captured at the time it was kicked off, and a
655
+ `.then()` that fires after a later change (or after the field was cleared) is discarded instead of
656
+ clobbering the rendered image/`loading`.
657
+ - `country`/`language` resolve through `@aceshooting/lyra-flags`'s `flagUrl(code)`, which is itself
658
+ genuinely code-split per flag: using `<lyra-flag country="fr">` anywhere in an app fetches only
659
+ the flags actually requested at runtime (verified with a real Vite build — referencing 2 codes
660
+ shipped ~28 KB total, not all 249). If you already have a flag's URL at build time, `src` skips
661
+ the peer-package round trip (and its loading-skeleton flash) entirely.
662
+ - 65 of `@aceshooting/lyra-flags`' 249 flags (any whose design includes a detailed coat of
663
+ arms/seal/emblem, e.g. `es`, `pt`, `sv`) ship a second, pristine full-detail source SVG alongside
664
+ the default icon-optimized one (`flagUrl(code, { variant: 'detailed' })`, or `detailed` on
665
+ `<lyra-flag>`) — the default variant is the one `country`/`language` resolve to unless `detailed`
666
+ is set, and is what almost every consumer wants (an icon-scale rendering, ~65% smaller on average
667
+ for the 65 affected codes, no visible fidelity loss at that scale). `detailed` is a safe no-op for
668
+ the other 184 codes — same file either way.
466
669
 
467
670
  ---
468
671
 
@@ -475,6 +678,9 @@ First-party "no data" state (no Web Awesome equivalent).
475
678
  **Properties:**
476
679
  - `heading: string = ''`
477
680
  - `description: string = ''`
681
+ - `compact: boolean = false` (reflected) — tighter, left-aligned rendering (less padding, a lighter
682
+ heading weight) for use inside a constrained space like a widget body or table cell, instead of
683
+ the centered/spacious full-page default
478
684
 
479
685
  **Events:** none.
480
686
 
@@ -482,7 +688,7 @@ First-party "no data" state (no Web Awesome equivalent).
482
688
 
483
689
  **CSS parts:** `base`, `icon`, `heading`, `description`, `actions`
484
690
 
485
- **Themeable custom properties:** shared tokens only (`--lyra-space-s/-l`,
691
+ **Themeable custom properties:** shared tokens only (`--lyra-space-xs/-s/-l`,
486
692
  `--lyra-color-text-quiet/-border/-text`).
487
693
 
488
694
  **Optional peer deps:** none.
@@ -495,10 +701,8 @@ First-party "no data" state (no Web Awesome equivalent).
495
701
  ```
496
702
 
497
703
  **Known gotchas:**
498
- - no live-region semantics: a plain role-less `<div>`, so a list/table transitioning to
499
- empty announces nothing to screen readers (unlike sibling `lyra-skeleton`'s `role="status"`).
500
- - no compact/density variant; fixed `padding: var(--lyra-space-l)` and 2rem icon sizing
501
- are tuned for full-page use, awkward embedded in a card or narrow container.
704
+ - `[part="base"]` is `role="status" aria-live="polite"`, so a list/table transitioning to empty
705
+ does announce to screen readers no extra wiring needed on the host's part.
502
706
  - Note: correctly works around the classic `:empty`-pseudo-class trap (a wrapper with a `<slot>`
503
707
  inside can never match `:empty`) by tracking real slot assignment in JS (`hasIcon`/`hasActions`) —
504
708
  `lyra-table` reuses this component for its own empty-rows state, and `lyra-stat` (below) now uses
@@ -560,16 +764,38 @@ KPI/stat card — value + unit + label + optional icon/trend/caption.
560
764
  - `goodDirection: 'up'|'down' = 'up'` (attribute `good-direction`) — which trend direction counts
561
765
  as "good"; inverts arrow/color polarity for cost/latency/error-rate-style metrics where a
562
766
  *decrease* is the win.
767
+ - `rows: StatRow[] = []` (attribute: false) — `StatRow { label: string; value: string }`; rendered as
768
+ a simple label/value breakdown list (`[part="rows"]`/`[part="row"]`/`[part="row-label"]`/
769
+ `[part="row-value"]`) beneath the caption, hidden entirely when empty
770
+ - `emphasis: boolean = false` (reflected) — visual emphasis (e.g. for a "headline" stat in a group):
771
+ a brand-colored accent edge, orthogonal to the status `variant`; status semantics still win over
772
+ it — `emphasis` only additionally tints `[part="value"]` brand-colored when `variant` is still
773
+ `'neutral'`, never overriding an actual `success`/`warning`/`danger` value color
774
+ - `exactValue: string = ''` (attribute `exact-value`) — rendered as a `title` attribute on
775
+ `[part="value"]` for a hover tooltip (e.g. `value="$1.2K" exact-value="$1,204.37"`); also gives
776
+ `[part="value"]` `tabindex="0"` (only when set) so the tooltip is reachable by keyboard focus, not
777
+ just hover
778
+ - `sub: string = ''` — a secondary line distinct from `caption`, e.g. a comparison-period label,
779
+ rendered as `[part="sub"]` between the trend pill and the caption; hidden entirely when unset
780
+ - `prose: boolean = false` (reflected) — CSS-only variant that shrinks/lightens `[part="value"]` and
781
+ hides `[part="unit"]`, for rendering a loading/status message in place of a numeric value
782
+ - `compact: boolean = false` (reflected) — tighter card padding; same convention as `lyra-empty`'s and
783
+ `lyra-widget`'s `compact`
563
784
 
564
785
  **Events:** none.
565
786
 
566
787
  **Slots:** default (leading icon), `caption` (rich caption content — overrides the `caption`
567
- attribute when slotted content is provided)
788
+ attribute when slotted content is provided), `spark` (a sparkline, e.g. `<lyra-sparkline
789
+ slot="spark">`, or other compact trend visual — `lyra-stat` only reserves the slot and doesn't
790
+ render one itself), `sub` (rich sub-line content — overrides the `sub` attribute when slotted content
791
+ is provided)
568
792
 
569
- **CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `caption`
793
+ **CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `sub`, `spark`,
794
+ `caption`, `rows`, `row`, `row-label`, `row-value`
570
795
 
571
796
  **Themeable custom properties:** shared tokens only (`--lyra-color-success/-warning/-danger` drive
572
- the `variant`-colored value text and up/down trend pill).
797
+ the `variant`-colored value text and up/down trend pill; `--lyra-color-brand` drives `emphasis`'s
798
+ accent edge and value tint).
573
799
 
574
800
  **Optional peer deps:** none.
575
801
 
@@ -580,8 +806,12 @@ the `variant`-colored value text and up/down trend pill).
580
806
  ```
581
807
 
582
808
  **Known gotchas:**
583
- - no `aria-live` for in-place `value`/`trend` updates a value/trend change after first
584
- render is not announced to screen readers.
809
+ - no `aria-live` region wraps `value`/`trend` — an in-place update after first render still isn't
810
+ proactively announced to screen readers. The trend pill's direction/polarity is no longer
811
+ conveyed by icon rotation/color alone, though: a visually-hidden span now spells it out in plain
812
+ language (e.g. "increased 4.2%, good" / "decreased 2%, bad" / "unchanged"), so a screen reader
813
+ landing on the pill (rather than being live-notified of a change) gets the full meaning, not just
814
+ an `aria-hidden` arrow glyph.
585
815
 
586
816
  ---
587
817
 
@@ -592,7 +822,13 @@ of `rows` — this component only renders and emits intents.
592
822
 
593
823
  **Properties:**
594
824
  - `columns: TableColumn<T>[] = []` (attribute: false) — `{ key, label, sortable?, align?:
595
- 'start'|'end', cell: (row) => unknown }`
825
+ 'start'|'end', priority?: 'medium'|'low', sticky?: boolean, cell: (row) => unknown }`
826
+ `priority` progressively hides that column via a `@container` query as `[part='base']` narrows
827
+ (`'low'` hides first, under a ~900px container width; `'medium'` next, under ~640px; both
828
+ breakpoints are fixed in `table.styles.ts`, not themeable tokens), reversible via
829
+ `[part='reveal-columns-button']` (rendered whenever any column sets a `priority`); `sticky` pins
830
+ that column's header cell/cells to the inline-start edge (`position: sticky`) while the table
831
+ scrolls horizontally
596
832
  - `rows: T[] = []` (attribute: false)
597
833
  - `sortKey: string = ''` (attribute `sort-key`)
598
834
  - `sortDir: 'asc'|'desc' = 'asc'` (attribute `sort-dir`)
@@ -604,13 +840,19 @@ of `rows` — this component only renders and emits intents.
604
840
  - `emptyDescription: string = ''` (attribute `empty-description`)
605
841
  - `noColumnsHeading: string = 'No columns configured'` (attribute `no-columns-heading`)
606
842
  - `noColumnsDescription: string = ''` (attribute `no-columns-description`)
843
+ - `revealColumnsLabel: string = 'Show all columns'` (attribute `reveal-columns-label` — the
844
+ reveal-button's label while `priority`-hidden columns are hidden)
845
+ - `hideColumnsLabel: string = 'Show fewer columns'` (attribute `hide-columns-label` — the same
846
+ button's label once they've been revealed)
607
847
 
608
848
  **Events:** `lyra-sort` (`detail: { key }`, fired on sortable-header activation), `lyra-row-click`
609
849
  (`detail: { row }`), `lyra-load-more` (fired on the "load more" button)
610
850
 
611
851
  **Slots:** none — content comes entirely from `columns`/`rows`.
612
852
 
613
- **CSS parts:** `base`, `table`, `head`, `header-cell`, `row`, `cell`, `more-button`
853
+ **CSS parts:** `base`, `table`, `head`, `header-cell`, `row`, `cell`, `more-button`, `sort-icon` (a
854
+ chevron indicator shown on the active sortable header, rotated per `sortDir`), `reveal-columns-button`
855
+ (shown only when at least one column sets `priority`)
614
856
 
615
857
  **Themeable custom properties:** `--lyra-table-max-height` (default `none`; controls the scrollable
616
858
  body's `max-block-size`).
@@ -638,8 +880,16 @@ body's `max-block-size`).
638
880
  a `cell()` callback if you need multi-select.
639
881
  - no `caption`/`aria-label` property, so the rendered `role="grid"` has no accessible name
640
882
  distinguishing it from any other grid on the page.
641
- - Keyboard equivalents (Enter/Space) for both header-sort and row-activate are implemented and
642
- solida genuine strength versus most siblings in this family.
883
+ - Full roving-tabindex grid keyboard pattern (one `tabindex="0"` stop among header cells, one among
884
+ body rows) Left/Right/Home/End move within the header row, Up/Down/Home/End move within the
885
+ body, Down from the header enters the body's roving stop and Up from the body's first row returns
886
+ to the header, Enter/Space still only sort/activate — a genuine strength versus most siblings in
887
+ this family. A `priority`-hidden header/cell is skipped when computing the visible header stops,
888
+ so arrow-key navigation never strands the roving stop on a hidden column.
889
+ - a `cell()` template can render its own interactive content (a button, link, input, etc.) without
890
+ it being swallowed by row/column activation: clicks and Enter/Space landing on (or bubbling
891
+ through) anything matching `button, a[href], input, select, textarea, [role="button"]` are left
892
+ alone by the table's own delegated `click`/`keydown` handlers.
643
893
 
644
894
  ---
645
895
 
@@ -653,7 +903,8 @@ Dependency-free SVG radial or linear meter (270°-sweep arc math, no charting li
653
903
  - `max: number = 100`
654
904
  - `type: 'radial'|'linear' = 'radial'` (reflected)
655
905
  - `label: string = ''`
656
- - `valueLabel?: string` (attribute: false — overrides only the *visible* text, see gotchas)
906
+ - `valueLabel?: string` (attribute: false — overrides only the *visible* text, see gotchas; an empty
907
+ string is treated the same as unset and falls back to the numeric `value`)
657
908
 
658
909
  **Events:** none.
659
910
 
@@ -675,12 +926,10 @@ gauge-specific custom property exists).
675
926
  ```
676
927
 
677
928
  **Known gotchas:**
678
- - no `aria-valuetext`: setting `valueLabel` (e.g. `"72°F"`) changes the visible SVG
679
- text but never reaches the accessibility tree `aria-valuenow` always carries the bare numeric
680
- `value`. A screen reader announces the raw number, not your formatted string.
681
- - the SVG `<text>` elements aren't `aria-hidden`, so they're exposed inside the same
682
- `role="meter"` host that already carries `aria-valuenow`/`aria-label` — risks double/mismatched
683
- announcements when `valueLabel` differs from the raw `value`.
929
+ - setting `valueLabel` (e.g. `"72°F"`) now also sets `aria-valuetext` on the host (in addition to
930
+ changing the visible SVG text), so a screen reader announces your formatted string instead of the
931
+ raw `aria-valuenow` number; the SVG `<text part="value">`/`<text part="label">` elements are
932
+ `aria-hidden="true"` so they're no longer separately exposed inside the same `role="meter"` host.
684
933
  - no color-threshold/variant support: a single fixed `--lyra-color-brand` fill regardless
685
934
  of value (no amber/red-near-capacity idiom built in).
686
935
  - no documented component-specific sizing custom property; host size is fixed em values
@@ -688,6 +937,11 @@ gauge-specific custom property exists).
688
937
  element instead.
689
938
  - Divide-by-zero guarded (`max - min || 1`), and radial/linear share one component via the `type`
690
939
  attribute.
940
+ - a non-finite `value`/`min`/`max` (e.g. left `NaN` mid-binding) no longer leaks a literal `"NaN"`
941
+ into `aria-valuenow`/`aria-valuemax`/the rendered value text — the affected ARIA attribute is
942
+ omitted entirely instead, and a finite `value` is clamped into `[min, max]` before being written
943
+ to `aria-valuenow` (the SVG arc/bar fill was already clamped via `ratio`; the ARIA number now
944
+ matches what's drawn).
691
945
 
692
946
  ---
693
947
 
@@ -698,7 +952,10 @@ opens a small menu).
698
952
 
699
953
  **Properties:**
700
954
  - `rows: Record<string, unknown>[] = []` (attribute: false)
701
- - `columns: CsvColumn[] = []` (attribute: false) — `{ key, label }`
955
+ - `columns: CsvColumn[] = []` (attribute: false) — `{ key, label }`; also acts as a field allow-list
956
+ for **both** export formats when non-empty (CSV always projected through `columns`; JSON now picks
957
+ the same `columns[].key` set rather than dumping every field on `rows`, so it can't leak a field
958
+ CSV would have hidden) — leave `columns` empty to export every field of `rows` as-is in JSON
702
959
  - `filename: string = 'export'`
703
960
  - `formats: ExportFormat[] = ['csv']` (attribute: false; `ExportFormat = 'csv' | 'json'` **only** —
704
961
  no XLSX despite the family sometimes being described as covering it)
@@ -732,7 +989,7 @@ substitute your own server-generated download instead of the built-in client-sid
732
989
  Package-level CSV utilities (used internally, also exported for standalone use — `import {
733
990
  escapeCsvField, buildCsv, downloadBlob } from '@aceshooting/lyra-ui'`):
734
991
  ```ts
735
- escapeCsvField(value: unknown): string // quotes/escapes; neutralizes formula-injection (=,+,-,@,tab,CR) with a leading apostrophe
992
+ escapeCsvField(value: unknown): string // quotes/escapes; neutralizes formula-injection (=,+,@,tab,CR) with a leading apostrophe — a bare leading '-' is deliberately left alone (OWASP guidance: it's not itself formula syntax, and guarding it would mangle ordinary negative numbers)
736
993
  buildCsv(rows: Record<string, unknown>[], columns: CsvColumn[]): string // CRLF-joined, header row included
737
994
  downloadBlob(content: string, filename: string, mime: string): void // triggers a browser download
738
995
  ```
@@ -742,6 +999,11 @@ downloadBlob(content: string, filename: string, mime: string): void // trig
742
999
  `'xlsx'` expecting it to work.
743
1000
  - CSV formula-injection guarding and the deferred (5s) `URL.revokeObjectURL` (works around Safari
744
1001
  cancelling in-flight downloads on immediate revoke) are genuine, safe-to-rely-on strengths.
1002
+ - the multi-format menu (`role="menu"`) supports full arrow-key navigation — ArrowUp/ArrowDown move
1003
+ between items (opening the menu and seeding the right one focused, if it was closed), Home/End
1004
+ jump to the first/last item once open, Escape closes it and returns focus to the trigger button,
1005
+ and a completed export also returns focus to the trigger — not just Escape/click-outside as
1006
+ before.
745
1007
 
746
1008
  ---
747
1009
 
@@ -758,6 +1020,13 @@ auto-inserted between each adjacent pair.
758
1020
  - `storageKey?: string` (attribute `storage-key` — persists sizes to `localStorage` under
759
1021
  `` `lyra-split:${key}:${panelCount}` ``, scoped by panel count so a stale layout for a different
760
1022
  panel count is ignored)
1023
+ - `panelConstraints: (PanelConstraint | null)[] = []` (attribute: false) — `PanelConstraint { minPx?:
1024
+ number; maxPx?: number }`, index-aligned with `sizes`; a `null`/missing entry (or an omitted bound
1025
+ within an entry) leaves that side of that panel purely percent-based (the existing `min`-only
1026
+ behavior). `sizes`, the `lyra-resize` payload, and localStorage persistence stay percent-based
1027
+ regardless — only the effective drag/keyboard clamp bounds (and the rendered `flex-basis`, via a
1028
+ native CSS `clamp()` so a constrained panel stays pinned between its px bounds across container
1029
+ resizes with no extra `ResizeObserver`) change for a constrained panel.
761
1030
 
762
1031
  **Events:** `lyra-resize` (`detail: { sizes }`, fired on every drag step/release **and** every
763
1032
  keyboard step)
@@ -779,29 +1048,168 @@ keyboard step)
779
1048
  ```
780
1049
 
781
1050
  Keyboard: focus a divider (`Tab`), then `ArrowRight`/`ArrowLeft` (horizontal) or
782
- `ArrowDown`/`ArrowUp` (vertical) to resize by a fixed 2% step.
1051
+ `ArrowDown`/`ArrowUp` (vertical) to resize by a fixed 2% step — RTL-aware for horizontal layouts
1052
+ (under `direction: rtl`, the forward/backward keys and drag-delta sign both swap so they still track
1053
+ the visually-adjacent panel).
783
1054
 
784
1055
  **Known gotchas:**
785
- - `panelCount` is computed **once**, in `connectedCallback()`, from
786
- `this.children.length`. Adding/removing a panel after mount (e.g. conditionally showing a side
787
- panel) leaves `panelCount`/`sizes`/divider count silently stalethere's no
788
- `slotchange`/`MutationObserver` to react to it. If your panel set can change, re-create the
789
- `<lyra-split>` rather than mutating its children live.
790
- - divider `aria-valuemax` (reported as `100 - min`) is only correct for exactly **two**
791
- panels. With 3+ panels the real achievable max for divider `i` is `sizes[i] + sizes[i+1] - min`
792
- (resizing only trades size between the adjacent pair) e.g. three equal 33.3% panels with
793
- `min=10` all report `aria-valuemax="90"` though the true achievable value is ~56.6. Don't trust
794
- the reported max for 3+-panel layouts.
1056
+ - `panelCount` now reacts to `slotchange` (not just the initial `connectedCallback()` read), and
1057
+ `ensureSizes()` rebalances existing sizes proportionally when a panel is added or removed after
1058
+ mount instead of discarding the whole layout a conditionally-shown side panel no longer leaves
1059
+ `panelCount`/`sizes`/divider count stale.
1060
+ - divider `aria-valuemax` is now computed per adjacent pair (`sizes[i] + sizes[i+1] - min`) rather
1061
+ than a blanket `100 - min`, so it's accurate for 3+-panel layouts too, not just exactly two panels
1062
+ this formula still only accounts for the plain percent `min`, though: with `panelConstraints`
1063
+ set, a panel's real achievable range can be narrower (or expressed in px) than what
1064
+ `aria-valuemin`/`aria-valuemax` report. Each divider also now has its own `aria-label` ("Resize
1065
+ divider between panel N and panel N+1") distinguishing it from any other divider in a
1066
+ multi-divider layout.
795
1067
  - no feasibility check on `min` vs. panel count (e.g. 3 panels with `min=40` is
796
1068
  unsatisfiable) — result is a silently frozen splitter with no warning.
797
- - `localStorage.getItem`/`setItem` calls themselves are unguarded (only the `JSON.parse`
798
- result is try/caught) a blocked or quota-exceeded store can throw synchronously from inside a
799
- `pointerup`/`keydown` handler.
1069
+ - concurrent drags are tracked per `pointerId` (not a single scalar), so a multi-touch drag on two
1070
+ different dividers moves both independently instead of the second pointer clobbering the first's
1071
+ drag state; `pointercancel`/`lostpointercapture` (not just `pointerup`) both end a drag.
1072
+ - `localStorage.getItem`/`setItem` calls are now both wrapped in their own `try`/`catch` (in addition
1073
+ to the `JSON.parse` result already being caught), so a blocked or quota-exceeded store fails
1074
+ silently instead of throwing from inside a `pointerup`/`keydown` handler. A persisted layout whose
1075
+ panel count no longer matches, or whose sizes are already below the current `min` floor, is
1076
+ rejected rather than restored.
800
1077
  - Pointer-drag lifecycle (pointer capture, window-listener cleanup on both drag-end and
801
1078
  `disconnectedCallback`) is solid and safe to rely on.
802
1079
 
803
1080
  ---
804
1081
 
1082
+ ## `lyra-widget`
1083
+
1084
+ A titled panel shell with an optional collapse toggle and an optional fullscreen-expand toggle.
1085
+ First-party invention (no Web Awesome equivalent). Fullscreen promotes the same host element in
1086
+ place (a CSS state, not a clone/portal), so slotted content (a chart, a running simulation, scroll
1087
+ position) survives the transition.
1088
+
1089
+ **Properties:**
1090
+ - `label: string = ''`
1091
+ - `sublabel: string = ''`
1092
+ - `collapsible: boolean = false` (reflected — shows the collapse/expand chevron button)
1093
+ - `collapsed: boolean = false` (reflected)
1094
+ - `expandable: boolean = false` (reflected — shows the fullscreen toggle button)
1095
+ - `fullscreen: boolean = false` (reflected)
1096
+ - `fullscreenInset: string = ''` (attribute `fullscreen-inset`) — raw CSS `inset` shorthand applied to
1097
+ `[part="base"]` and `[part="backdrop"]` while fullscreen instead of the default
1098
+ `var(--lyra-space-l)` on every side, e.g. `"0 0 0 240px"` to leave a 240px persistent
1099
+ sidebar/toolbar visible during fullscreen
1100
+ - `compact: boolean = false` (reflected) — tighter header/body padding, same convention as
1101
+ `lyra-empty`'s `compact`
1102
+
1103
+ **Events:** `lyra-collapse-change` (`detail: boolean`, the new `collapsed` state),
1104
+ `lyra-fullscreen-change` (`detail: boolean`, the new `fullscreen` state — also fired when fullscreen
1105
+ is exited via Escape or a backdrop click, not just the toggle button)
1106
+
1107
+ **Slots:** default (the panel body), `actions` (header action controls, rendered before the
1108
+ collapse/expand buttons)
1109
+
1110
+ **CSS parts:** `base`, `header`, `title`, `label`, `sublabel`, `actions`, `collapse-button`,
1111
+ `fullscreen-button`, `body`, `backdrop`
1112
+
1113
+ **Themeable custom properties:** `--lyra-widget-overlay-color` (default `rgb(0 0 0 / 0.5)` — the
1114
+ fullscreen backdrop scrim color; component-specific since no shared `--wa-*`/`--lyra-*` overlay token
1115
+ exists), plus shared tokens (`--lyra-space-*`, `--lyra-color-border/-surface/-text-quiet`,
1116
+ `--lyra-radius`, `--lyra-shadow`, `--lyra-icon-button-size`, `--lyra-focus-ring-*`).
1117
+
1118
+ **Optional peer deps:** none.
1119
+
1120
+ ```html
1121
+ <lyra-widget label="Load profile" sublabel="Last 7 days" collapsible expandable>
1122
+ <span slot="actions"><button>Refresh</button></span>
1123
+ <div>Panel body content — a chart, a table, anything.</div>
1124
+ </lyra-widget>
1125
+ ```
1126
+
1127
+ While `fullscreen`, `[part="base"]` (not the host itself) takes `role="dialog"` + `aria-modal="true"`
1128
+ (with `aria-label` from `label`, falling back to `"Fullscreen panel"`), document scroll is locked
1129
+ (ref-counted, safe with multiple simultaneously-fullscreen widgets), and Tab/Shift+Tab are bounded
1130
+ to the panel's own focusable content (`actions` slot → collapse/fullscreen buttons → body slot,
1131
+ matching visual tab order — resolved shadow-piercingly, so a slotted custom element's real
1132
+ focusable target inside its own shadow root is found too) so keyboard focus can't escape to page
1133
+ content hidden behind the backdrop. Escape or clicking the backdrop exits fullscreen and returns
1134
+ focus to whichever button triggered it. Set `fullscreen-inset` (e.g. `"0 0 0 240px"`) to reserve
1135
+ space for a persistent sidebar/toolbar that should stay visible instead of being covered by the
1136
+ fullscreen panel/backdrop — it overrides the default `var(--lyra-space-l)` inset on every side for
1137
+ both `[part="base"]` and `[part="backdrop"]`. Set `compact` for tighter header/body padding.
1138
+
1139
+ **Known gotchas:**
1140
+ - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
1141
+ scroll lock and the Tab-trap `keydown` listener if `fullscreen` was still `true` across the
1142
+ move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
1143
+ `willUpdate()` alone wouldn't otherwise notice.
1144
+ - `collapsed` hides the body via `hidden` rather than an animated height transition — collapsing is
1145
+ instant, not a slide.
1146
+
1147
+ ---
1148
+
1149
+ ## `lyra-word-cloud`
1150
+
1151
+ Dependency-free SVG word/tag cloud. First-party invention (no Web Awesome equivalent). Lays words
1152
+ out via an outward Archimedean-spiral search — heaviest word placed first, each word spiraling from
1153
+ the center until it clears every word already placed. Unlike sibling `lyra-sparkline`/`lyra-heatmap`
1154
+ (one `role="img"` glyph standing in for an aggregate value), each word here is its own focusable
1155
+ `role="button"`, since the individual words are the meaningful interactive content.
1156
+
1157
+ **Properties:**
1158
+ - `words: WordCloudWord[] = []` (attribute: false) — `{ text: string, weight: number, color?:
1159
+ string, group?: string }`; `weight` drives font size, `color` overrides the palette for that word,
1160
+ `group` shares one palette color across every word with the same `group` value
1161
+ - `minFontSize: number = 12` (attribute `min-font-size`) — px, applied to the lowest-weight word
1162
+ - `maxFontSize: number = 48` (attribute `max-font-size`) — px, applied to the highest-weight word
1163
+ - `scale: 'linear'|'sqrt' = 'linear'` — `sqrt` compresses the weight→font-size mapping so one heavy
1164
+ word doesn't dwarf the rest, matching `lyra-heatmap`'s `scale` property
1165
+ - `orientations: 'horizontal'|'mixed' = 'horizontal'` — `mixed` lets ~25% of words render rotated
1166
+ 90° for denser packing
1167
+ - `palette?: string[]` (attribute: false) — custom categorical colors, cycled by word index (or by
1168
+ `group`); defaults to the `--lyra-word-cloud-color-1..8` tokens
1169
+
1170
+ **Events:** `lyra-word-click` (`detail: { text, weight, group }`, fires on click, or Enter/Space on
1171
+ a focused word)
1172
+
1173
+ **Slots:** none.
1174
+
1175
+ **CSS parts:** `base`, `svg`, `word` (each `<text>`), `empty` (the no-data placeholder)
1176
+
1177
+ **Themeable custom properties:** `--lyra-word-cloud-color-1` through `-8` (the default categorical
1178
+ palette, a data-driven literal exception like `lyra-heatmap`'s scale-ramp endpoints — exposed as
1179
+ retheme-able custom properties instead of hardcoded), plus shared tokens (`--lyra-font`,
1180
+ `--lyra-focus-ring-*`, `--lyra-transition-fast`, `--lyra-color-text-quiet`).
1181
+
1182
+ **Optional peer deps:** none.
1183
+
1184
+ ```html
1185
+ <lyra-word-cloud id="cloud" style="height: 20rem"></lyra-word-cloud>
1186
+ <script type="module">
1187
+ document.getElementById('cloud').words = [
1188
+ { text: 'JavaScript', weight: 90 },
1189
+ { text: 'TypeScript', weight: 75 },
1190
+ { text: 'Lit', weight: 60, group: 'framework' },
1191
+ ];
1192
+ document.getElementById('cloud').addEventListener('lyra-word-click', (e) => console.log(e.detail));
1193
+ </script>
1194
+ ```
1195
+
1196
+ **Known gotchas:**
1197
+ - capped at 150 words (`MAX_WORDS` in `word-cloud-layout.ts`, mirroring `lyra-sparkline`'s
1198
+ `MAX_BARS` DOM-node-count guard) — anything beyond that is silently dropped from layout (not
1199
+ rendered, not an error); a pathological input (e.g. one huge word repeated many times) can also
1200
+ exhaust the spiral search's radius bound and get dropped the same way.
1201
+ - every word is its own tab stop (`tabindex="0"` `role="button"`), not a single roving-tabindex
1202
+ container like `lyra-heatmap` — fine for a modest word count, but tabbing through a cloud near the
1203
+ 150-word cap is a lot of stops; there's no non-visual "get the whole word+weight list at once"
1204
+ alternative yet.
1205
+ - text width is measured via a detached `<canvas>` 2D context (`ctx.measureText`), not the actual
1206
+ rendered SVG glyph metrics — close enough for collision-avoidance spacing, but not pixel-exact.
1207
+ - rotation (`orientations="mixed"`) is genuinely random per layout (`Math.random()`, not seeded), so
1208
+ which words render rotated changes on every re-layout (any `words`/`minFontSize`/`maxFontSize`/
1209
+ `scale`/`orientations` change) — don't rely on rotation being stable across renders.
1210
+
1211
+ ---
1212
+
805
1213
  # Temporal & graph
806
1214
 
807
1215
  ## `lyra-time-range`
@@ -816,17 +1224,24 @@ onto `[min, max]`).
816
1224
  - `end: number = 100`
817
1225
  - `step: number = 1`
818
1226
  - `disabled: boolean = false` (reflected)
1227
+ - `presets: TimeRangePreset[] = []` (attribute: false) — `TimeRangePreset { label: string; start:
1228
+ number; end: number }`; optional discrete presets (e.g. "Last 7 days") rendered as a
1229
+ `[part="presets"]` button row above the track — purely additive, the continuous brush is
1230
+ unaffected and both interaction modes coexist; picking one sets both handles and emits the same
1231
+ `lyra-input`/`lyra-change` pair a committed drag or keyboard step would
819
1232
 
820
- **Events:** `lyra-input` (fired continuously while dragging or on arrow-key press, `detail: {
821
- start, end }`), `lyra-change` (fired on pointer release / arrow-key-up — commit, `detail: { start,
822
- end }`)
1233
+ **Events:** `lyra-input` (fired continuously while dragging or on each arrow/Home/End/PageUp/
1234
+ PageDown key press, `detail: { start, end }`), `lyra-change` (fired on pointer release /
1235
+ key-up-commit, or when a preset button is clicked, `detail: { start, end }`)
823
1236
 
824
1237
  **Slots:** none.
825
1238
 
826
- **CSS parts:** `base`, `track`, `range`, `handle-start`, `handle-end`
1239
+ **CSS parts:** `base`, `track`, `range`, `handle-start`, `handle-end`, `presets`, `preset-button`
827
1240
 
828
1241
  **Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-brand`,
829
- `--lyra-color-surface`, `--lyra-shadow`. No component-local tokens.
1242
+ `--lyra-color-surface`, `--lyra-shadow` (track/handles), plus (for `presets`) `--lyra-color-text`,
1243
+ `--lyra-color-on-brand` (the active preset button's text), `--lyra-radius`, `--lyra-space-xs/-s`,
1244
+ `--lyra-transition-fast`, `--lyra-focus-ring-*`. No component-local tokens.
830
1245
 
831
1246
  **Optional peer deps:** none.
832
1247
 
@@ -839,15 +1254,27 @@ end }`)
839
1254
  ```
840
1255
 
841
1256
  **Known gotchas:**
842
- - Keyboard support is ArrowUp/Right and ArrowDown/Left only (moves by `step`) — no
843
- Home/End/PageUp/PageDown, unlike native `<input type=range>` or the WAI-ARIA slider pattern it
844
- otherwise follows.
845
- - A disabled handle loses `tabindex` but never gets `aria-disabled` a screen-reader user
846
- exploring by virtual cursor still hears it announced as a live, adjustable slider.
847
- - No `aria-valuetext`: only raw numeric `aria-valuenow` is exposed, no hook for a human-readable
848
- (e.g. formatted date/time) equivalent of the mapped domain.
849
- - Handles a `min > max` domain and disabled-mid-drag/disconnect-mid-drag correctly (tested)safe
850
- to rely on those edge cases.
1257
+ - Keyboard support now matches the full WAI-ARIA APG slider pattern: ArrowUp/Right and ArrowDown/Left
1258
+ move by `step` (RTL-aware under `direction: rtl` the forward/backward keys swap so they still
1259
+ track the visually-adjacent direction), PageUp/PageDown move by `step * 10`, and Home/End jump to
1260
+ that handle's actual *reachable* bound clamped by the sibling handle's current value, not the
1261
+ component's full `[min, max]` domain, so Home/End on the `end` handle can't jump past `start` (and
1262
+ vice versa). Pointer-drag is RTL-aware the same way (mirrors the drag ratio under `direction:
1263
+ rtl`).
1264
+ - A disabled handle now gets `aria-disabled="true"` in addition to losing `tabindex`a
1265
+ screen-reader user exploring by virtual cursor no longer hears it announced as a live, adjustable
1266
+ slider.
1267
+ - `aria-valuemin`/`aria-valuemax` on each handle report that handle's reachable sub-range (bounded by
1268
+ its sibling), not the full domain — matching what Home/End actually jump to.
1269
+ - No `aria-valuetext`: only raw numeric `aria-valuenow` is exposed (omitted entirely, rather than a
1270
+ literal `"NaN"`, if `start`/`end` is non-finite), no hook for a human-readable (e.g. formatted
1271
+ date/time) equivalent of the mapped domain.
1272
+ - Handles a `min > max` domain, a non-positive/non-finite `step`, and disabled-mid-drag/
1273
+ disconnect-mid-drag correctly (tested) — safe to rely on those edge cases. Concurrent drags are
1274
+ tracked per `pointerId` (not a single scalar), so a two-finger touch — one finger per handle —
1275
+ moves both independently instead of the second pointer hijacking which handle the first pointer's
1276
+ moves apply to; `pointercancel`/`lostpointercapture` (not just `pointerup`) both end a drag, same
1277
+ fix as `lyra-split`.
851
1278
 
852
1279
  ---
853
1280
 
@@ -875,7 +1302,8 @@ tick and manual step)
875
1302
  **CSS parts:** `base`, `play-button`, `slider`
876
1303
 
877
1304
  **Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-color-border`,
878
- `--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`.
1305
+ `--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`, `--lyra-icon-button-size` (the
1306
+ play button's box).
879
1307
 
880
1308
  **Optional peer deps:** none.
881
1309
 
@@ -888,9 +1316,15 @@ tick and manual step)
888
1316
  ```
889
1317
 
890
1318
  **Known gotchas:**
891
- - `index` is never re-clamped when `length` shrinks below it — e.g. setting `el.length = 2` while
892
- `el.index = 7` leaves `index` out of range until the next `tick()`/`goTo()`/`next()`/`previous()`
893
- call happens to correct it.
1319
+ - `index` is now re-clamped into `[0, length)` as soon as `length` shrinks (in `willUpdate()`, not
1320
+ waiting for the next `tick()`/`goTo()`/`next()`/`previous()` call) — setting `el.length = 2` while
1321
+ `el.index = 7` immediately pulls `index` back to `1`, and playback auto-pauses if `length` drops
1322
+ to `<= 1` while playing (the play button and slider would otherwise both become disabled with no
1323
+ way to stop it — both are `?disabled` whenever `length <= 1`, not just the button).
1324
+ - `intervalMs` is live-reactive mid-playback: ticking is a self-rescheduling `setTimeout` (not one
1325
+ long-lived `setInterval`), so `intervalMs` is re-read fresh before every tick — changing
1326
+ `interval-ms` while `playing` takes effect on the very next step instead of only after a
1327
+ pause/play cycle.
894
1328
  - No `aria-valuetext`/visible "N of M" position label on the range input.
895
1329
  - Calling `play()`/`pause()` programmatically (not via the button) gives no `aria-live`
896
1330
  announcement of the Play/Pause state change.
@@ -899,31 +1333,70 @@ tick and manual step)
899
1333
 
900
1334
  ## `lyra-heatmap`
901
1335
 
902
- A Canvas-rendered **matrix** heatmap (rows × cols grid) with a DPR-aware, resize-aware redraw loop.
903
- **Matrix layout only despite the family sometimes being described as covering "matrix + calendar
904
- heatmap," no calendar/day-grid layout exists in the shipped source.** Do not assume a
905
- calendar-heatmap mode is available; it would need to be built as a new variant.
1336
+ A Canvas-rendered heatmap with a DPR-aware, resize-aware redraw loop, in one of two `mode`s:
1337
+ `"matrix"` (defaulta `rowLabels` × `colLabels` grid of `values`) or `"calendar"` (a
1338
+ GitHub-style Sunday–Saturday × week grid built from `days`, colored by quartile bucket rather than
1339
+ the matrix mode's continuous ramp). Every cell is independently addressable despite being
1340
+ canvas-drawn (no per-cell DOM node): a `pointermove` hit-test over the canvas shows `[part="tooltip"]`
1341
+ with that cell's label + value; the canvas is `tabindex="0"` with arrow-key roving focus (a stroked
1342
+ ring redrawn over the focused cell on every draw, plus `[part="live-region"]` announcing it); and a
1343
+ click, or Enter/Space on the focused cell, fires `lyra-cell-click`.
906
1344
 
907
1345
  **Properties:**
908
- - `rowLabels: string[] = []` (attribute: false)
909
- - `colLabels: string[] = []` (attribute: false)
910
- - `values: number[][] = []` (attribute: false) — `-1` is the "no data" sentinel; ragged/sparse rows
911
- are safe (`?? -1`)
912
- - `cellSize: number = 22` (attribute `cell-size`)
1346
+ - `rowLabels: string[] = []` (attribute: false — matrix mode only)
1347
+ - `colLabels: string[] = []` (attribute: false — matrix mode only)
1348
+ - `values: number[][] = []` (attribute: false — matrix mode only) — `-1` or any non-finite value is
1349
+ the "no data" sentinel; ragged/sparse rows are safe (`?? -1`)
1350
+ - `cellSize: number = 22` (attribute `cell-size` — matrix mode only, ignored when `fitToWidth` is
1351
+ set)
1352
+ - `fitToWidth: boolean = false` (attribute `fit-to-width` — matrix mode only; derives `cellSize` from
1353
+ the host's measured `clientWidth` on every draw/resize instead of the fixed `cell-size`, so the
1354
+ grid actually fills the available width — see gotchas for the default, non-`fit-to-width` behavior)
913
1355
  - `valueLabel: string = 'value'` (attribute `value-label`)
914
- - `scale: 'linear' | 'sqrt' = 'linear'`
915
-
916
- **Events:** none dispatched.
1356
+ - `scale: 'linear' | 'sqrt' = 'linear'` (matrix mode only — calendar mode always buckets by quartile)
1357
+ - `mode: 'matrix' | 'calendar' = 'matrix'`
1358
+ - `days: CalendarDay[] = []` (attribute: false — calendar mode only) — `CalendarDay { date:
1359
+ string /* ISO yyyy-mm-dd */; value: number }`; need not be sorted or contiguous, and an entry whose
1360
+ `date` doesn't parse is dropped rather than poisoning the whole grid
1361
+ - `bucketCount: number = 5` (attribute `bucket-count` — calendar mode only; non-finite values fall
1362
+ back to 5 with a one-time console warning, and the count is floored to an integer >= 2)
1363
+ - `annotations: HeatmapAnnotation[] = []` (attribute: false) — `HeatmapAnnotation { row?: number;
1364
+ col?: number; date?: string; label?: string }`: matrix mode matches by `row`/`col`, calendar mode
1365
+ by `date` (whichever pair matches the active `mode`; the other fields are ignored). Draws a
1366
+ stroked ring over the matching cell; an annotation with a `label` also gets its own
1367
+ `[part="legend-annotation"]` entry in the legend.
1368
+ - `cellText?: (pos: MatrixCellPos | CalendarCellPos, value: number) => string` (attribute: false) —
1369
+ formats the per-cell hover tooltip and keyboard live-region announcement text; receives the cell
1370
+ position (`{ row, col }` in matrix mode, `{ week, weekday }` in calendar mode) and its value.
1371
+ Unset (the default) falls back to the built-in English "Row X, Col Y: value" / "Mon DD: value"
1372
+ template — additive, not breaking.
1373
+
1374
+ **Events:** `lyra-cell-click` (fired on click, or Enter/Space on the keyboard-focused cell —
1375
+ `detail: { row, col, value }` in matrix mode, `detail: { date, value }` in calendar mode)
917
1376
 
918
1377
  **Slots:** none.
919
1378
 
920
- **CSS parts:** `base`, `canvas`, `legend`
1379
+ **CSS parts:** `base`, `canvas`, `tooltip` (hover tooltip, positioned over the hovered cell),
1380
+ `live-region` (visually-hidden `role="status" aria-live="polite"` element announcing the
1381
+ keyboard-focused cell), `legend`, `legend-lo`, `legend-hi`, `legend-annotation` (one per labeled
1382
+ `annotations` entry)
921
1383
 
922
1384
  **Themeable custom properties:** `--lyra-heatmap-scale-lo` (default `#cde2fb`),
923
- `--lyra-heatmap-scale-hi` (default `#0969da`) — the sequential color-ramp endpoints, resolved via
924
- `getComputedStyle` each draw (any valid CSS color syntax — hex/rgb/hsl/oklch/named — works,
925
- resolved through a scratch canvas). Also consumes `--lyra-color-text-quiet` (axis label color) and
926
- `--lyra-space-xs`.
1385
+ `--lyra-heatmap-scale-hi` (default `#0969da`) — the sequential color-ramp endpoints (matrix mode) or
1386
+ quartile-bucket ramp endpoints (calendar mode), resolved via `getComputedStyle` each draw (any valid
1387
+ CSS color syntax — hex/rgb/hsl/oklch/named — works, resolved through a scratch canvas).
1388
+ `--lyra-heatmap-no-data-fill` (default `rgba(128,128,128,0.25)` — the no-data cell fill, same
1389
+ resolve-via-`getComputedStyle` pattern), `--lyra-heatmap-label-font` (default `10px sans-serif` — the
1390
+ canvas-drawn axis/month/weekday label font), `--lyra-heatmap-focus-ring-color` (default
1391
+ `var(--lyra-focus-ring-color)` — the canvas-drawn ring stroked around the keyboard-focused cell;
1392
+ also reused by `[part="canvas"]`'s own `:focus-visible` outline so the two stay visually in sync),
1393
+ `--lyra-heatmap-annotation-color` (default `var(--lyra-color-danger)` — the canvas-drawn ring
1394
+ stroked around an annotated cell, deliberately not one of the sequential ramp colors so it stays
1395
+ visible regardless of what it's drawn over). `--lyra-heatmap-tooltip-bg` (default
1396
+ `var(--lyra-color-surface)`) and `--lyra-heatmap-tooltip-text` (default `var(--lyra-color-text)`) —
1397
+ unlike the canvas-drawn tokens above, `[part="tooltip"]` is a real DOM element and consumes these
1398
+ directly, no `getComputedStyle` bridging needed. Also consumes `--lyra-color-text-quiet` (axis label
1399
+ color) and `--lyra-space-xs`.
927
1400
 
928
1401
  **Optional peer deps:** none.
929
1402
 
@@ -937,20 +1410,39 @@ resolved through a scratch canvas). Also consumes `--lyra-color-text-quiet` (axi
937
1410
  </script>
938
1411
  ```
939
1412
 
1413
+ ```html
1414
+ <!-- Calendar mode: a GitHub-contributions-style day grid -->
1415
+ <lyra-heatmap mode="calendar" value-label="commits"></lyra-heatmap>
1416
+ <script>
1417
+ document.querySelector('lyra-heatmap').days = [
1418
+ { date: '2026-01-01', value: 3 },
1419
+ { date: '2026-01-02', value: 0 },
1420
+ // ...
1421
+ ];
1422
+ </script>
1423
+ ```
1424
+
940
1425
  **Known gotchas:**
941
- - the `ResizeObserver` is wired but effectively inert: `draw()` always sizes the canvas
942
- as `PAD_LEFT + cols * cellSize`, never from the host's measured width, so a container resize
943
- redraws identical dimensions. The stylesheet's `canvas { inline-size: 100% }` is also dead code
944
- `draw()` unconditionally sets an inline `canvas.style.width/height` on every redraw, and inline
945
- style always wins over that external rule.
946
- - `NaN`/non-finite cell values are **not** treated as no-data (only `-1` is): `NaN < 0`
947
- is `false`, so it falls into the normal color-ramp branch, produces an invalid `fillStyle`, and
948
- that cell silently keeps whatever color was left over from the previous loop iteration.
949
- - moving the browser window repeatedly across displays with different pixel ratios
950
- leaks one stale `MediaQueryList` listener per DPR crossing (each `onDprChange` creates a new
951
- listener without removing the previous one).
952
- - Per-cell values are not individually inspectable by assistive tech the whole grid is one
953
- `role="img"` with only a dimensions+range summary `aria-label`.
1426
+ - the `ResizeObserver` only actually resizes the drawn grid in matrix mode **when
1427
+ `fit-to-width` is set**. Without it (the default), `draw()` sizes the canvas as
1428
+ `PAD_LEFT + cols * cellSize`, never from the host's measured width, so a container-resize redraw is
1429
+ a geometric no-op; the stylesheet's `canvas { inline-size: 100% }` is also dead code in that case,
1430
+ since `draw()` unconditionally sets an inline `canvas.style.width/height` that wins over it.
1431
+ Calendar mode ignores `fit-to-width` entirely its canvas is always sized from the computed
1432
+ `weekCount`.
1433
+ - the host is `role="group"` (not `role="img"`) with a dimensions+range summary `aria-label`
1434
+ (calendar mode: a day-count + range summary instead) `[part="canvas"]` inside it is a real
1435
+ focusable, keyboard-operable, per-cell-interactive control (roving arrow-key focus,
1436
+ `[part="live-region"]` announcements, `lyra-cell-click`), and `role="img"` is documented (ARIA) to
1437
+ flatten its subtree to a single image for some assistive tech, which conflicted with that
1438
+ focusable descendant fixed, matching `lyra-lite-chart`/`lyra-word-cloud`'s existing `role="group"`
1439
+ pattern.
1440
+ - `NaN`/non-finite cell values in matrix mode are correctly treated as no-data now (alongside `-1`),
1441
+ and repeated DPR crossings (moving the window across displays with different pixel ratios) no
1442
+ longer leak a `MediaQueryList` listener per crossing — both previously-known issues are fixed.
1443
+ - calendar mode's date labels (used by the default `cellText` template and the tooltip/live-region
1444
+ text) now format via the runtime locale (`toLocaleString(undefined, ...)`) instead of a hardcoded
1445
+ `'en'` — fixed.
954
1446
 
955
1447
  ---
956
1448
 
@@ -966,8 +1458,14 @@ A force-directed node-link diagram with pan/zoom/drag, built on `d3-force`.
966
1458
  dropped)
967
1459
  - `width: number = 800`
968
1460
  - `height: number = 600`
969
- - `chargeStrength: number = -300` (attribute `charge-strength`)
970
- - `linkDistance: number = 100` (attribute `link-distance`)
1461
+ - `chargeStrength: number = -300` (attribute `charge-strength` — live-reactive, see gotchas)
1462
+ - `linkDistance: number = 100` (attribute `link-distance` — live-reactive, see gotchas)
1463
+ - `minZoom: number = 0.1` (attribute `min-zoom`)
1464
+ - `maxZoom: number = 8` (attribute `max-zoom`)
1465
+ - `seed?: number` — when set, seeds each node's initial x/y deterministically
1466
+ (keyed by node **id**, not array index/order) instead of `forceSimulation()`'s own random start,
1467
+ and settles the simulation synchronously instead of animating the settle (same effect
1468
+ `prefers-reduced-motion` has, see gotchas)
971
1469
 
972
1470
  **Events:** `lyra-node-click` (`detail: { id }`), `lyra-link-click` (`detail: { source, target }`)
973
1471
 
@@ -994,17 +1492,36 @@ install with `pnpm add d3-force d3-drag d3-zoom d3-selection`).
994
1492
 
995
1493
  **Known gotchas:**
996
1494
  - per-tick full re-render is expensive: every d3-force tick (up to ~300 by default,
997
- continuously while dragging via `alphaTarget(0.3)`) replaces `simNodes`/`simLinks` with fresh
998
- array references, forcing Lit to re-diff the entire node/link template and re-run
999
- `applyInteractions()`. Noticeable cost for graphs of a few hundred+ nodes.
1000
- - `chargeStrength`/`linkDistance` are **not** live-reactive: changing them post-mount
1001
- does nothing until `nodes`/`links` also change (only those two trigger `rebuildSimulation()`).
1002
- - zoom has no `.scaleExtent(...)` (unbounded in/out); pan/zoom/drag are pointer-only
1003
- with no keyboard equivalent. Links (`<line part="link">`) are click-only — no
1004
- `role`/`tabindex`/`aria-label`, unlike nodes which are properly keyboard-operable
1005
- (`tabindex="0"`, `role="button"`, Enter/Space).
1006
- - No visible fallback if the d3 peers fail to load — just a permanently empty `<svg>` plus a
1007
- one-time console warning.
1495
+ continuously while dragging via `alphaTarget(0.3)`) writes node/link positions straight onto the
1496
+ already-rendered DOM via `setAttribute()` rather than reassigning `simNodes`/`simLinks` (that
1497
+ reassignment — and the Lit re-render/`applyInteractions()` re-scan it used to force on every tick
1498
+ now only happens once per structural `nodes`/`links` change). Still a noticeable cost building up
1499
+ the initial layout or while a node is actively being dragged, just no longer once per tick on an
1500
+ otherwise-settled graph.
1501
+ - `chargeStrength`/`linkDistance` **are** live-reactive post-mount now (retuned on the existing
1502
+ force objects and the simulation nudged via `alpha(0.3).restart()`) no need to also touch
1503
+ `nodes`/`links` to see the effect.
1504
+ - zoom is bounded via `minZoom`/`maxZoom` (`d3-zoom`'s `.scaleExtent(...)`, live-reactive); pan/
1505
+ zoom/drag are still pointer-only with no keyboard equivalent. Links (`<line part="link">`) are now
1506
+ keyboard-operable too (`tabindex="0"`, `role="button"`, `aria-label`, Enter/Space), matching nodes.
1507
+ - while the `d3-force`/`d3-drag`/`d3-zoom`/`d3-selection` peers are resolving, the host shows a
1508
+ `<lyra-skeleton>` sized to `width`/`height` with `aria-busy="true"` — but if they fail to load
1509
+ (not installed), it still settles into a permanently empty `<svg>` (0 nodes/0 links) plus a
1510
+ one-time console warning, same as before; the skeleton only covers the loading window itself.
1511
+ - `GraphNode.color` is sanitized (rejects `;`/`{`/`}`) before being written into the
1512
+ `--lyra-node-fill` inline custom property, so an untrusted color string can't break out of that
1513
+ CSS declaration.
1514
+ - a structural `nodes`/`links` change now carries over each already-settled node's position (and any
1515
+ in-progress drag) by id when rebuilding the simulation, instead of discarding every node's (x, y)
1516
+ and re-running the whole ~300-tick random-start settle from scratch — only genuinely new ids get a
1517
+ fresh start. Handy for a streaming/incrementally-updated graph, whose existing layout no longer
1518
+ jumps every time a node/link is appended.
1519
+ - under `prefers-reduced-motion: reduce`, or whenever `seed` is set, the simulation converges
1520
+ synchronously (ticked in a loop down to `alphaMin` before first paint) instead of animating over
1521
+ ~300 rendered frames; user-initiated motion (dragging a node) is unaffected either way.
1522
+ - the `<svg part="svg">` now carries `role="group"` and an `aria-label` summarizing the node/link
1523
+ counts (e.g. "Node-link diagram with 5 nodes and 4 links"), and node `<text part="label">`s are
1524
+ `aria-hidden="true"` (their content is already covered by each node's own `aria-label`).
1008
1525
 
1009
1526
  ---
1010
1527
 
@@ -1016,14 +1533,30 @@ recursively renders itself and its own nested children.
1016
1533
 
1017
1534
  ### `lyra-tree`
1018
1535
 
1536
+ Implements the full WAI-ARIA treeitem keyboard pattern: a single roving `tabindex` (tracked as
1537
+ `activeId`, pushed down to every `<lyra-tree-node>` including nested ones) and
1538
+ ArrowUp/Down/Right/Left/Home/End/Enter/Space handled by one delegated `keydown` listener (native
1539
+ `KeyboardEvent`s are `composed: true` and bubble across shadow-DOM boundaries, so a press inside a
1540
+ deeply-nested node's own shadow root still reaches it).
1541
+
1019
1542
  **Properties:**
1020
1543
  - `data: TreeItem[] = []` (attribute: false) — `TreeItem { id: string; label: string; children?:
1021
1544
  TreeItem[]; badge?: string | number }`
1545
+ - `label: string = ''` — accessible name for the tree; `role="tree"` lives on an internal
1546
+ `[part="base"]` element, not the host, so this is the only way to label it (`aria-label`/
1547
+ `aria-labelledby` set directly on `<lyra-tree>` wouldn't reach it)
1548
+
1549
+ **Keyboard:** ArrowDown/ArrowUp move the roving focus to the next/previous *visible* node.
1550
+ ArrowRight expands a collapsed node (focus stays put; a second ArrowRight then steps into the first
1551
+ child) or moves into an already-expanded node's first child. ArrowLeft collapses an expanded node, or
1552
+ moves focus to its parent. Home/End jump to the first/last visible node. Enter/Space activate
1553
+ `select()` on the focused node.
1022
1554
 
1023
1555
  **Methods:** `expandAll()`, `collapseAll()` (both recursive, properly sequenced around Lit's render
1024
1556
  cycle).
1025
1557
 
1026
- **Events:** none dispatched directly (see `lyra-tree-node` below — they bubble up).
1558
+ **Events:** none dispatched directly (see `lyra-tree-node` below — they bubble up and are also
1559
+ observed internally to keep the roving `activeId` in sync with clicks).
1027
1560
 
1028
1561
  **Slots:** default (holds the `<lyra-tree-node>` elements it manages).
1029
1562
 
@@ -1036,15 +1569,29 @@ cycle).
1036
1569
 
1037
1570
  ### `lyra-tree-node`
1038
1571
 
1039
- Normally set internally by `lyra-tree`, but a public element.
1572
+ Normally set internally by `lyra-tree`, but a public element. `role="treeitem"` (plus
1573
+ `aria-expanded`/`aria-level`/`aria-setsize`/`aria-posinset` and the roving `tabindex`, driven by
1574
+ `<lyra-tree>`) live on the *host* element itself, not an internal row `<div>` — so this node's own
1575
+ nested children (rendered in its own shadow root as further `role="group"` content) are genuine DOM
1576
+ descendants of the treeitem, matching the WAI-ARIA treeitem pattern's containment expectation.
1040
1577
 
1041
1578
  **Properties:**
1042
1579
  - `item: TreeItem` (required, attribute: false)
1043
1580
  - `depth: number = 0`
1044
1581
  - `expanded: boolean = false` (reflected)
1582
+ - `activeId: string | null = null` (attribute: false) — the id of the tree's roving-tabindex-focused
1583
+ item, pushed down from `<lyra-tree>`; normally set internally, not by consumers
1584
+ - `setSize: number = 1`, `posInSet: number = 1` (attribute: false) — this node's `aria-setsize`/
1585
+ `aria-posinset` values among its siblings, pushed down from `<lyra-tree>`; normally set internally,
1586
+ not by consumers
1045
1587
 
1046
- **Events:** `lyra-node-toggle` (`detail: { id, expanded }`), `lyra-node-select` (`detail: { id }`) —
1047
- dispatched from `lyra-tree-node`, bubble/compose up through `lyra-tree`'s light DOM.
1588
+ **Methods:** `expand()`, `collapse()` (each a no-op if already in that state, or a leaf), `select()`
1589
+ (fires `lyra-node-select`).
1590
+
1591
+ **Events:** `lyra-node-toggle` (`detail: { id, expanded }`, fired by `expand()`/`collapse()` — via
1592
+ the toggle button or ArrowRight/ArrowLeft), `lyra-node-select` (`detail: { id }`, fired by `select()`
1593
+ — via clicking the label or Enter/Space) — dispatched from `lyra-tree-node`, bubble/compose up
1594
+ through `lyra-tree`'s light DOM.
1048
1595
 
1049
1596
  **Slots:** none.
1050
1597
 
@@ -1065,20 +1612,17 @@ indentation), plus the shared tokens listed above.
1065
1612
  ```
1066
1613
 
1067
1614
  **Known gotchas:**
1068
- - the treeitem row has **zero keyboard interaction**: `role="treeitem"` has no
1069
- `tabindex` and no keydown handler, so it never enters the Tab order. The only keyboard-operable
1070
- control at all is the expand/collapse `<button>` and that's removed (`tabindex="-1"`,
1071
- `aria-hidden`) for leaf nodes. `lyra-node-select` fires only from the label's `@click`, with no
1072
- keyboard equivalent, and there is no roving-tabindex/arrow-key navigation model.
1073
- - `role="group"` (the expanded-children wrapper) is a shadow-DOM **sibling**, not a DOM
1074
- descendant, of its `role="treeitem"` row violates the WAI-ARIA treeitem pattern's
1075
- DOM-containment expectation. (The library's own axe test does not catch this axe's structural
1076
- rules accept a group as a valid child of `role="tree"` regardless of true ownership.)
1077
- - by-id reconciliation (which preserves `expanded` state across data reassignment)
1078
- only applies at depth 0. Nested `children` arrays are rendered via a plain `.map()` with no keyed
1079
- `repeat()`, so reordering/inserting into a nested array can silently attach the wrong node's
1080
- `expanded` state.
1081
- - `role="tree"` has no accessible name (`aria-label`/`aria-labelledby`).
1615
+ - all four previously-known ARIA gaps in this pair are fixed: the treeitem row is now genuinely
1616
+ keyboard-operable with a roving tabindex and full arrow-key navigation (not just the expand/collapse
1617
+ button); the expanded-children `role="group"` is now a real DOM descendant of its `role="treeitem"`
1618
+ host rather than a shadow-DOM sibling; by-id reconciliation (preserving `expanded` state across
1619
+ data reassignment) now applies at every depth via a keyed `repeat()`, not just depth 0; and
1620
+ `role="tree"` now has an accessible name via the new `label` property.
1621
+ - `lyra-tree`'s `getUpdateComplete()` cascades into every currently-known descendant
1622
+ `<lyra-tree-node>`'s own `updateComplete` (see `update-cascade.ts`) so that code awaiting the
1623
+ tree's `updateComplete` (e.g. after `focusNode()`) doesn't run before an arbitrarily-nested node has
1624
+ actually finished rendering its pushed-down `activeId`/`tabIndex` one more pending update per
1625
+ depth level, otherwise.
1082
1626
 
1083
1627
  ---
1084
1628
 
@@ -1091,9 +1635,9 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1091
1635
  `wa-chart` `config` property).
1092
1636
 
1093
1637
  **Properties:**
1094
- - `type: LyraChartType = 'line'` — `LyraChartType = 'line' | 'bar' | 'scatter'` (narrow TS union;
1095
- other Chart.js type strings — `'pie'`, `'doughnut'`, etc. work at runtime unchecked, which is
1096
- exactly how the typed subclasses below achieve their locked type)
1638
+ - `type: LyraChartType = 'line'` — `LyraChartType = 'line' | 'bar' | 'scatter' | 'pie' | 'doughnut' |
1639
+ 'radar' | 'polarArea' | 'bubble'` — every type string the typed subclasses below lock `type` to is
1640
+ already a first-class member, so `<lyra-chart type="pie">` needs no subclass or cast to work
1097
1641
  - `labels: string[] = []` (attribute: false)
1098
1642
  - `datasets: Series[] = []` (attribute: false) — `Series { label: string; data?: (number|null)[];
1099
1643
  points?: {x,y,label?}[]; color?: string|string[]; fill?: boolean; width?: number; dash?: boolean;
@@ -1107,13 +1651,23 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1107
1651
  - `yLabel: string = ''` (attribute `y-label`)
1108
1652
  - `y2Label: string = ''` (attribute `y2-label`)
1109
1653
  - `beginAtZero: boolean = true` (attribute `begin-at-zero`)
1654
+ - `horizontal: boolean = false` — sets `options.indexAxis = 'y'`, Chart.js's own mechanism for
1655
+ horizontal bars (also flips `line`/`area` types onto a horizontal category axis)
1656
+ - `stacked: boolean = false` — stacks the `x`/`y`(/`y2`) scale entries `buildScales()` returns; only
1657
+ meaningful for `bar`/`line` types (scatter/bubble's linear `x` scale and the radial `r` scale used
1658
+ by radar/polar-area are out of scope)
1110
1659
  - `config?: Partial<ChartConfiguration>` (attribute: false) — deep-merged over the generated
1111
1660
  config; any nested key wins without clobbering sibling generated keys
1112
1661
 
1113
- **Methods:** `resetZoom()`
1662
+ **Methods:** `resetZoom()` (reset any active zoom/pan to the original view), `refreshTheme()`
1663
+ (forces a redraw so the `--lyra-chart-*` tokens below are re-read from the current computed style —
1664
+ the escape hatch for a consumer's own theme-toggle handler to call when it flips something, e.g. a
1665
+ `data-theme` attribute, that doesn't otherwise change any `lyra-chart` property)
1114
1666
 
1115
1667
  **Events:** `lyra-zoom` (`detail: { zoomed: boolean }`, fired on zoom-complete and on
1116
- `resetZoom()`)
1668
+ `resetZoom()`), `lyra-point-click` (fired when a click lands on, or nearest to — intersect-only —
1669
+ a data point/segment, for any chart type, not just bar; `detail: { datasetIndex: number, index:
1670
+ number, label: string | undefined, value: unknown }`)
1117
1671
 
1118
1672
  **Slots:** none.
1119
1673
 
@@ -1121,8 +1675,13 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1121
1675
 
1122
1676
  **Themeable custom properties:** `--lyra-chart-height` (set programmatically on the host from the
1123
1677
  `height` property — must be read from the host, not a shadow-tree descendant, since custom
1124
- properties only cascade downward); shared tokens `--lyra-space-xs`, `--lyra-color-border`,
1125
- `--lyra-color-surface`, `--lyra-color-text`.
1678
+ properties only cascade downward); `--lyra-chart-grid-color` (default `var(--lyra-color-border)`),
1679
+ `--lyra-chart-tick-color` (default `var(--lyra-color-text-quiet)`), `--lyra-chart-legend-color`
1680
+ (default `var(--lyra-color-text)`), `--lyra-chart-tooltip-bg` (default `var(--lyra-color-surface)`),
1681
+ `--lyra-chart-tooltip-text` (default `var(--lyra-color-text)`) — each resolved fresh via
1682
+ `getComputedStyle` on every draw (Chart.js renders to canvas, not the DOM, so it can't consume CSS
1683
+ `var()` directly), driving the grid lines, tick labels, legend text, and tooltip background/text
1684
+ respectively; plus shared `--lyra-space-xs`.
1126
1685
 
1127
1686
  **Optional peer deps:** `chart.js`, `chartjs-plugin-zoom` (lazy-loaded once per page via
1128
1687
  `chart-loader.ts`; registers only the tree-shaken controller/element/scale subset used).
@@ -1137,28 +1696,124 @@ properties only cascade downward); shared tokens `--lyra-space-xs`, `--lyra-colo
1137
1696
  ```
1138
1697
 
1139
1698
  **Known gotchas:**
1140
- - "locked" `type` in the typed subclasses (below) is **not actually enforced**:
1141
- `<lyra-pie-chart type="bar">` or `el.type = 'bar'` at runtime silently turns it into a bar chart.
1142
- Conversely, `<lyra-chart type="pie">` already works today with no subclass needed at all
1143
- `buildConfig()` passes `type` straight through unchecked.
1144
- - the `lyra-theme` window-event listener (meant for re-theming on theme switch) is
1145
- dead code: nothing in the library ever dispatches that event, and even if it were dispatched, no
1146
- `--lyra-*`/`getComputedStyle` color resolution happens anywhere in this component Chart.js
1147
- always renders grid/tick/legend/tooltip text with its own hardcoded palette, which can read as
1148
- low-contrast in dark mode.
1149
- - generated `scales` config is cartesian (`x`/`y`/`y2`) only: for `lyra-radar-chart`/
1150
- `lyra-polar-area-chart` (which need a single radial `r` scale) and for `lyra-pie-chart`/
1151
- `lyra-doughnut-chart` (no scale at all), the inherited `xLabel`/`yLabel`/`beginAtZero`/`y2Label`
1152
- properties are silently inert with no warning. Reach the radial scale only via raw `config`.
1699
+ - `type` at the `lyra-chart` (core) level is completely unchecked — `buildConfig()` passes it
1700
+ straight to Chart.js as-is, so `<lyra-chart type="pie">` already works today with no subclass
1701
+ needed. Each typed subclass below locks its *own* `type` via a real prototype accessor (see the
1702
+ typed-subclasses section) a genuine runtime lock, not just a compile-time default.
1703
+ - no global theme-broadcast event exists to auto-retheme an already-drawn chart on a theme switch
1704
+ a consumer flipping something upstream (e.g. a `data-theme` attribute) that doesn't otherwise
1705
+ change any `lyra-chart` property must call `refreshTheme()` itself to make Chart.js re-read the
1706
+ `--lyra-chart-*` tokens above.
1707
+ - generated `scales` are type-appropriate: no scale at all for `lyra-pie-chart`/
1708
+ `lyra-doughnut-chart`, and a single radial `r` scale (respecting `beginAtZero`) for
1709
+ `lyra-radar-chart`/`lyra-polar-area-chart`, instead of always generating the cartesian `x`/`y`/`y2`
1710
+ block. `xLabel`/`yLabel`/`y2Label` are still silently inert for all four of those subclasses (a
1711
+ radial scale and "no scale" both have nowhere to put an axis title) reach a titled radial scale
1712
+ only via raw `config`.
1153
1713
  - No `chartjs-plugin-annotation` is registered by default — reachable only by importing it
1154
1714
  separately and using the raw `config` passthrough (Chart.js's registry is a global singleton).
1715
+ - while the `chart.js` peer is resolving, the host shows a `<lyra-skeleton variant="rect">` with
1716
+ `aria-busy="true"` (same lazy-load pattern as `lyra-graph`/`lyra-map`/`lyra-flag`); Chart.js's own
1717
+ ~1s draw-in animation is skipped under `prefers-reduced-motion: reduce`. The raw `config`
1718
+ passthrough is deep-merged with `__proto__`/`constructor`/`prototype` keys skipped unconditionally,
1719
+ so a JSON-sourced `config` (e.g. parsed from an API response) can't reach up and pollute
1720
+ `Object.prototype` through the merge.
1721
+ - lazy-redraw + memoization: an `IntersectionObserver` gates `draw()` — while the host is scrolled
1722
+ off-screen, property changes that would otherwise trigger a Chart.js redraw are skipped (and a
1723
+ single redraw fires once it re-enters the viewport). Independently, a content-signature check
1724
+ (`type`, `labels`, `datasets`, `legend`, `area`, `xLabel`, `yLabel`, `y2Label`, `beginAtZero`,
1725
+ `horizontal`, `stacked`, `config`) skips calling into Chart.js when none of those have actually
1726
+ changed since the last draw (e.g. an unrelated property/state update, or a bare `requestUpdate()`).
1727
+ `refreshTheme()` calls `draw()` directly and is unaffected by either gate — it always redraws, since
1728
+ a theme change isn't reflected in any tracked property.
1729
+
1730
+ ---
1731
+
1732
+ ## `lyra-lite-chart`
1733
+
1734
+ A dependency-free bar/line chart — plain SVG/DOM rendering, zero peer dependencies (unlike
1735
+ `lyra-chart`, which wraps `chart.js`). For a project whose architecture forbids a charting
1736
+ dependency outright: covers grouped/stacked bars, multi-series lines, per-point click, and hover
1737
+ tooltips (native SVG `<title>`, no positioning JS) — not a full `lyra-chart` replacement (no
1738
+ zoom/pan, no pie/doughnut/radar/scatter/bubble types, no horizontal/dual-y-axis, no raw-config
1739
+ passthrough). Not a subclass of `LyraChart`.
1740
+
1741
+ **Properties:**
1742
+ - `type: LyraLiteChartType = 'bar'` — `'bar' | 'line'`
1743
+ - `labels: string[] = []` (attribute: false)
1744
+ - `datasets: LiteSeries[] = []` (attribute: false) — `LiteSeries { label: string; data:
1745
+ (number|null)[]; color?: string }`
1746
+ - `legend: boolean = false`
1747
+ - `height: string = '280px'`
1748
+ - `xLabel: string = ''` (attribute `x-label`)
1749
+ - `yLabel: string = ''` (attribute `y-label`)
1750
+ - `beginAtZero: boolean = true` (attribute `begin-at-zero`)
1751
+ - `stacked: boolean = false` — sums each category's bars into one segmented bar instead of grouping
1752
+ them side by side; ignored for `type="line"`
1753
+ - `tickFormat?: (value: number) => string` (attribute: false) — formats a y-axis tick value for
1754
+ display (e.g. `(v) => \`$${v.toFixed(2)}\`` for currency, or a duration formatter for `"42s"`).
1755
+ Falls back to the built-in "nice numbers" formatter when unset.
1756
+
1757
+ **Events:** `lyra-point-click` — fired when a bar/point is activated (click, or Enter/Space while
1758
+ focused). `detail: { datasetIndex: number, index: number, label: string | undefined, value: number
1759
+ | null }` — same shape as `lyra-chart`'s `lyra-point-click`.
1760
+
1761
+ **Performance:** rendering is `IntersectionObserver`-gated and content-signature-memoized — `render()`
1762
+ skips recomputing the grid/marks (and reuses the previous `TemplateResult`) while the host is scrolled
1763
+ off-screen, or when none of the content-affecting properties (`type`, `labels`, `datasets`, `legend`,
1764
+ `xLabel`, `yLabel`, `beginAtZero`, `stacked`, or the measured plot size) have changed since the last
1765
+ render. A `tickFormat` identity change alone (with everything else unchanged) does not force a redraw —
1766
+ in practice `tickFormat` is stable across a component's lifetime, and any data change that would make a
1767
+ different `tickFormat` output visible already changes `datasets`/`labels` too.
1768
+
1769
+ **Slots:** none.
1770
+
1771
+ **CSS parts:** `base`, `grid-line`, `axis-label`, `axis-title`, `bar`, `line`, `point`, `legend`,
1772
+ `legend-item`, `legend-swatch`.
1773
+
1774
+ **Themeable custom properties:** `--lyra-chart-height` (same host-level property as `lyra-chart`);
1775
+ `--lyra-chart-grid-color`, `--lyra-chart-tick-color`, `--lyra-chart-legend-color` — same token
1776
+ *names* as `lyra-chart`, so a host already theming `lyra-chart` themes this for free. Unlike
1777
+ `lyra-chart` (canvas-rendered, needs `getComputedStyle`-based re-theming on every draw), this is
1778
+ plain SVG/DOM and reads these via native CSS `var()` — no JS-side resolution step, and no
1779
+ `refreshTheme()` method needed (there's nothing to go stale).
1780
+
1781
+ **Optional peer deps:** none. This is the point of the component.
1782
+
1783
+ ```html
1784
+ <lyra-lite-chart type="bar" stacked legend x-label="Week" y-label="Commits"></lyra-lite-chart>
1785
+ <script>
1786
+ const c = document.querySelector('lyra-lite-chart');
1787
+ c.labels = ['W1', 'W2', 'W3', 'W4'];
1788
+ c.datasets = [
1789
+ { label: 'Docs', data: [4, 6, 3, 8] },
1790
+ { label: 'Bugs', data: [3, 2, 5, 4] },
1791
+ ];
1792
+ </script>
1793
+ ```
1794
+
1795
+ **Known gotchas:**
1796
+ - No `horizontal` mode (unlike `lyra-chart`) — deliberately cut from scope, not a stub: bars are
1797
+ always vertical.
1798
+ - No dual y-axis (`Series.axis: 'y2'`) — every series shares one y-axis/domain.
1799
+ - Series colors default to a fixed built-in 8-color categorical palette (round-robin by dataset
1800
+ index) when `color` is unset — not configurable beyond passing `color` per series.
1801
+ - Bar/point elements are real focusable DOM nodes (`tabindex="0" role="button"`, each with its own
1802
+ `aria-label`), so the `<svg>` itself uses `role="group"`, not `role="img"` — an "img" role would
1803
+ conflict with genuinely interactive descendants (axe's `nested-interactive` rule).
1804
+ - Tick values use a standard "nice numbers" (1/2/5 × 10ⁿ) rounding step, not exact data min/max —
1805
+ intentional (readable axis labels), matches how most charting libraries pick tick steps.
1155
1806
 
1156
1807
  ---
1157
1808
 
1158
1809
  ## Typed subclasses: `lyra-line-chart`, `lyra-bar-chart`, `lyra-pie-chart`, `lyra-doughnut-chart`, `lyra-radar-chart`, `lyra-polar-area-chart`, `lyra-bubble-chart`, `lyra-scatter-chart`
1159
1810
 
1160
- Each is `LyraChart` with `type` overridden to a fixed value; all other properties/events/parts/tokens
1161
- are identical to `lyra-chart` above. Same optional peer deps (`chart.js`, `chartjs-plugin-zoom`).
1811
+ Each is `LyraChart` with `type` locked to a fixed value via a real `get`/`set` accessor installed on
1812
+ its own prototype (`declare type: '…'` narrows the TS type at compile time; the runtime lock is the
1813
+ `Object.defineProperty` pair alongside it — the same pattern `lyra-histogram`'s own locked `type`
1814
+ below uses), not merely a class-field default a later assignment could still override. All other
1815
+ properties/events/parts/tokens are identical to `lyra-chart` above. Same optional peer deps
1816
+ (`chart.js`, `chartjs-plugin-zoom`).
1162
1817
 
1163
1818
  ```html
1164
1819
  <lyra-bar-chart legend></lyra-bar-chart>
@@ -1173,12 +1828,12 @@ are identical to `lyra-chart` above. Same optional peer deps (`chart.js`, `chart
1173
1828
  ```
1174
1829
 
1175
1830
  **Known gotchas (in addition to the core `lyra-chart` list above):**
1831
+ - `type` truly is locked per subclass: `<lyra-pie-chart type="bar">` or `el.type = 'bar'` at runtime
1832
+ is a genuine no-op (the accessor's setter silently ignores the write), not a footgun like a plain
1833
+ overridden class-field default would be.
1176
1834
  - `lyra-bubble-chart` needs `Series.points` entries with an `x`/`y`/`r` triple, but `Series.points`
1177
- is typed as `{x, y, label?}[]` with no `r` field — cast through `unknown` (or a local
1178
- `BubblePoint` type) when constructing bubble data.
1179
- - Reading `chart.type` back in TypeScript for the 5 non-`line`/`bar`/`scatter` subclasses yields a
1180
- value that, by `LyraChartType`'s own declaration, is never a legal member of that type (each is
1181
- set via `as unknown as LyraChartType`).
1835
+ is typed as `{x, y, label?}[]` with no `r` field — cast the array through `as unknown as
1836
+ Series['points']` (or a local `BubblePoint` type) when constructing bubble data.
1182
1837
 
1183
1838
  ---
1184
1839
 
@@ -1191,8 +1846,11 @@ Bins `values` into `bins` equal-width buckets and renders as a bar chart (extend
1191
1846
  - `bins: number = 10`
1192
1847
  - `values: number[] = []` (attribute: false)
1193
1848
  - `label: string = 'Frequency'`
1194
- - `labels`/`datasets` are **derived, read-only** (installed as getter/setter pairs on the
1195
- prototype; direct writes are silently ignored) — computed from `values`/`bins`.
1849
+ - `labels`/`datasets`/`type` are **derived, read-only** (installed as getter/setter pairs on the
1850
+ prototype; direct writes are silently ignored) — `labels`/`datasets` are computed from
1851
+ `values`/`bins`, and `type` always reads back `'bar'` regardless of any assignment. Unlike the
1852
+ typed `lyra-*-chart` subclasses above (whose "locked" `type` is enforced only by convention),
1853
+ `lyra-histogram`'s lock is a real getter/setter — `el.type = 'line'` is a genuine no-op here.
1196
1854
  - All other `LyraChart` properties (`legend`, `area`, `zoom`, `config`, `height`, etc.) are
1197
1855
  inherited and usable.
1198
1856
 
@@ -1206,10 +1864,8 @@ Bins `values` into `bins` equal-width buckets and renders as a bar chart (extend
1206
1864
  ```
1207
1865
 
1208
1866
  **Known gotchas:**
1209
- - **`bins <= 0` crashes** on non-empty `values`: `bins=0` throws `TypeError` inside
1210
- `binValues()` (bucket index resolves to `-1`, `buckets[-1].count++` fails); negative `bins` throws
1211
- `RangeError` even earlier (`Array.from({length: -1})`). No guard exists anywhere in the chain —
1212
- **always pass `bins >= 1`.**
1867
+ - `bins <= 0` no longer crashes: `binValues()` now returns an empty bucket array for `binCount <= 0`
1868
+ (or empty `values`), so the histogram just renders with no bars instead of throwing.
1213
1869
 
1214
1870
  ---
1215
1871
 
@@ -1273,7 +1929,16 @@ raw `map` escape hatch for anything unexposed.
1273
1929
  (discrete swatch rows only, no continuous gradient bar)
1274
1930
  - `choropleth?: ChoroplethLayer` (attribute: false) — `ChoroplethLayer { sourceId: string; geojson:
1275
1931
  GeoJSON.FeatureCollection; field: string; stops: [number, string][] }` (interpolated
1276
- fill-color expression from `field`'s value against `stops`)
1932
+ fill-color expression from `field`'s value against `stops`; `stops` must contain at least one
1933
+ `[value, color]` pair — an empty array is ignored, leaving whatever fill layer already exists, if
1934
+ any, untouched, rather than being applied)
1935
+ - `markers: MapMarker[] = []` (attribute: false) — `MapMarker { id?: string; lngLat: [number,
1936
+ number]; color?: string; label?: string; html?: string }`; reconciled by `id` (falling back to a
1937
+ `lng,lat` key when `id` is omitted) so an unchanged marker isn't torn down and recreated on every
1938
+ `markers` reassignment — its `lngLat` **and** its popup content (`html`/`label`, in that
1939
+ precedence) are both updated in place, and the popup is removed if a later update sets neither.
1940
+ `html` is rendered via `Popup.setHTML()` — **raw markup, inline event handlers included** — only
1941
+ pass trusted content, sanitize anything derived from user input first.
1277
1942
 
1278
1943
  **Getters:** `map` → the raw `maplibregl.Map` instance.
1279
1944
 
@@ -1303,26 +1968,32 @@ exists and was hit)
1303
1968
  stops: [[0, '#cde2fb'], [100, '#0969da']],
1304
1969
  };
1305
1970
  m.legend = [{ color: '#cde2fb', label: 'Low' }, { color: '#0969da', label: 'High' }];
1971
+ m.markers = [{ lngLat: [2.29, 48.86], label: 'Eiffel Tower' }];
1306
1972
  m.addEventListener('lyra-map-click', (e) => console.log(e.detail.feature?.properties));
1307
1973
  </script>
1308
1974
  ```
1309
1975
 
1310
1976
  **Known gotchas:**
1311
- - clearing or swapping the choropleth **leaks the old layer**: `applyChoropleth()`
1312
- only ever adds or updates; setting `choropleth = undefined`, or changing `choropleth.sourceId` to
1313
- a different value, is a silent no-op the previously added GeoJSON source and fill layer keep
1314
- rendering forever (there is no `removeLayer`/`removeSource` call anywhere).
1315
- - `mapStyle` is reactive but changing it after construction is a **silent no-op** —
1316
- unlike `center`/`zoom`, which do call `setCenter`/`setZoom` on change, there's no `setStyle()`
1317
- call wired up.
1318
- - No declarative point-marker API — only the single choropleth polygon-fill layer exists; placing
1319
- pins/points requires dropping to the `.map` escape hatch and calling `new maplibregl.Marker()`
1320
- manually.
1321
- - No built-in popup rendering (despite sometimes being described as covering "legend/popup/
1322
- choropleth") — only the raw `lyra-map-click` event; hand-roll your own `maplibregl.Popup` on
1323
- click.
1977
+ - clearing or swapping the choropleth no longer leaks the old layer: setting `choropleth =
1978
+ undefined`, or changing `choropleth.sourceId` to a different value, now calls `removeLayer`/
1979
+ `removeSource` on whatever was previously applied before adding the new one (or nothing, if
1980
+ cleared).
1981
+ - `mapStyle` changes after construction now call `setStyle()` (in addition to `center`/`zoom`
1982
+ already calling `setCenter`/`setZoom`) the choropleth is automatically re-applied once the new
1983
+ style's own `'style.load'` fires, since a style change wipes every layer/source maplibre-gl knows
1984
+ about.
1985
+ - Point markers now have a declarative API (`markers`, above) with popup support — the `.map` escape
1986
+ hatch and manual `new maplibregl.Marker()` are no longer the only way to place pins.
1324
1987
  - No click-select visual highlight on choropleth features (no `setFeatureState` call) — clicking
1325
- only fires the event, no built-in visual feedback.
1988
+ only fires the event, no built-in visual feedback. Popups are still only reachable declaratively
1989
+ through `markers`' `html`/`label` — a choropleth-feature click still has no built-in popup, only the
1990
+ raw `lyra-map-click` event.
1991
+ - `LegendEntry.color` is validated against a strict CSS-color-syntax allowlist before being applied
1992
+ to the legend swatch's `background`, rejecting anything that isn't recognizable color syntax
1993
+ (notably `url(...)`, which `background` also accepts and would otherwise fetch as soon as the
1994
+ swatch renders).
1995
+ - while the `maplibre-gl` peer is resolving, the host shows a `<lyra-skeleton variant="rect">` with
1996
+ `aria-busy="true"` in place of the map container.
1326
1997
 
1327
1998
  ---
1328
1999
 
@@ -1334,21 +2005,29 @@ parsing (that's left entirely to the host).
1334
2005
  **Properties:**
1335
2006
  - `multiple: boolean = false` (reflected)
1336
2007
  - `disabled: boolean = false` (reflected)
1337
- - `accept: string = ''` — **see gotchas: decorative on the drop path**
2008
+ - `accept: string = ''` — a native-`accept`-style string (`.csv,.xlsx`, `text/csv`, `image/*`, or any
2009
+ comma-separated mix); now enforced on **both** the native picker dialog and the drag-drop path, see
2010
+ gotchas
1338
2011
  - `allowedMimeTypes: string[] = []` (attribute: false) — exact MIME-string allowlist
1339
2012
  - `forbiddenMimeTypes: string[] = []` (attribute: false) — exact MIME-string denylist, checked
1340
2013
  **before** (and takes precedence over) `allowedMimeTypes`
2014
+ - `maxFileSize: number = 0` (attribute `max-file-size` — bytes; `0` disables the check)
1341
2015
  - `label: string = 'Drop files here or click to browse'`
1342
2016
 
1343
2017
  **Methods:** `openPicker()` — programmatically opens the native file dialog.
1344
2018
 
1345
- **Events:** `lyra-files` (`detail: { files: File[], rejected: File[] }`, fired on both drop and
1346
- manual file-picker selection — `rejected` carries no reason code, just the raw rejected `File[]`)
2019
+ **Events:** `lyra-files` (`detail: { files: File[], rejected: RejectedFile[] }`, fired on both drop
2020
+ and manual file-picker selection) — `RejectedFile = { file: File; reason: 'type' | 'count' | 'size'
2021
+ }`: `'type'` from `accept`/`allowedMimeTypes`/`forbiddenMimeTypes`, `'count'` when a single-file
2022
+ input (`multiple` unset) receives more than one file (in which case *all* files are rejected, none
2023
+ accepted), `'size'` from `maxFileSize`.
1347
2024
 
1348
2025
  **Slots:** default slot — custom dropzone content, overrides the `label` attribute text when
1349
- provided.
2026
+ provided. The accessible name always comes from `label` regardless, so icon-only slot content still
2027
+ announces correctly.
1350
2028
 
1351
- **CSS parts:** `base`, `input`
2029
+ **CSS parts:** `base`, `input`, `status` (a visually-hidden `role="status" aria-live="polite"`
2030
+ element carrying the drag accept/reject announcement)
1352
2031
 
1353
2032
  **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-space-l`,
1354
2033
  `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`, `--lyra-color-text-quiet`,
@@ -1360,7 +2039,7 @@ provided.
1360
2039
  <lyra-file-input multiple accept=".csv,.xlsx" allowed-mime-types='["text/csv"]'></lyra-file-input>
1361
2040
  <script>
1362
2041
  document.querySelector('lyra-file-input').addEventListener('lyra-files', (e) => {
1363
- console.log('accepted:', e.detail.files, 'rejected:', e.detail.rejected);
2042
+ console.log('accepted:', e.detail.files, 'rejected:', e.detail.rejected); // rejected[i].reason
1364
2043
  });
1365
2044
  </script>
1366
2045
  ```
@@ -1369,25 +2048,24 @@ Note: `allowedMimeTypes`/`forbiddenMimeTypes` are complex properties (`attribute
1369
2048
  them via JS (`el.allowedMimeTypes = [...]`), not as a JSON string attribute; the snippet above is
1370
2049
  illustrative of intent only.
1371
2050
 
2051
+ `accept.ts` exports `matchesAccept(file, accept, assumeExtensionMatch?)` (internal — not
2052
+ re-exported from the package root) — parses the same three `accept` forms the browser's native
2053
+ picker accepts (extension, exact MIME, `type/*` wildcard) and reports whether a `File` matches. Used
2054
+ both for the drop path's real rejection and for the dragenter preview, where a `DataTransferItem` (no
2055
+ `.name`, only `.type`) can't evaluate an extension pattern yet — `assumeExtensionMatch: true` treats
2056
+ that as a possible match during preview so the drag-over UI doesn't flash a false "reject" state for
2057
+ an extension-only `accept` list.
2058
+
1372
2059
  **Known gotchas:**
1373
- - **`accept` only constrains the native file-picker dialog; it is silently ignored on
1374
- the drag-drop path.** `isAllowed()` checks only `allowedMimeTypes`/`forbiddenMimeTypes` (exact
1375
- MIME-string match — no extension or `image/*`-wildcard support like native `accept` has);
1376
- `this.accept` itself is never read anywhere else in the component. Concretely:
1377
- `<lyra-file-input accept=".csv,.xlsx">` with no `allowedMimeTypes` set will silently **accept** a
1378
- dropped `.png` (fires `lyra-files` with the png in `files`, not `rejected`) even though the UI
1379
- visually implies only CSV/XLSX are allowed. **To actually enforce type restriction on drop, you
1380
- must also set `allowedMimeTypes` (and/or `forbiddenMimeTypes`) — `accept` alone is not
1381
- sufficient.**
1382
- - No max-file-size validation (no `maxFileSize` property) — a host wanting a byte-limit must
1383
- intercept `lyra-files` and re-filter itself.
1384
2060
  - No paste-from-clipboard support (no `paste`-event handling of `e.clipboardData.files`).
1385
- - No `aria-live` announcement of the drag accept/reject state (`data-drag-state` attribute drives
1386
- border/background color only, invisible to screen readers mid-drag) — the click/keyboard-to-
1387
- browse path is unaffected and correctly operable (`role="button"`, `tabindex` 0/-1 by disabled,
1388
- `aria-disabled`, Enter/Space handling).
1389
2061
  - Dragged folders aren't specially detected (no `webkitGetAsEntry()` check) — likely surfaces as a
1390
2062
  phantom zero-byte `File` with an empty MIME type rather than a clear rejection.
2063
+ - `maxFileSize`/`accept` extension patterns can't be evaluated during the dragenter preview (no real
2064
+ `File.size`/`.name` available yet from a `DataTransferItem`) — the live preview state (border/
2065
+ background color, `status` announcement) is therefore only a best-effort hint; the authoritative
2066
+ accept/reject decision (and `rejected[].reason`) is always the one made at actual drop time.
2067
+ - the click/keyboard-to-browse path is correctly operable (`role="button"`, `tabindex` 0/-1 by
2068
+ disabled, `aria-disabled`, Enter/Space handling).
1391
2069
 
1392
2070
  ---
1393
2071
 
@@ -1395,7 +2073,15 @@ illustrative of intent only.
1395
2073
 
1396
2074
  These apply across the whole library rather than to any one component:
1397
2075
 
1398
- - **Packaging gap.** `custom-elements.json` is currently gitignored and missing from
1399
- `package.json`'s `files` allowlist, with no `prepack`/publish CI step regenerating it so a
1400
- manifest-driven IDE/tooling integration (e.g. custom-elements-manifest-based autocomplete) may
1401
- not see it in a published tarball until that packaging gap is fixed.
2076
+ - **Packaging.** `custom-elements.json` is now in `package.json`'s `files` allowlist and
2077
+ regenerated by a `prepack` script (`pnpm run manifest`) before publish, so a manifest-driven IDE/
2078
+ tooling integration (e.g. custom-elements-manifest-based autocomplete) does see an up-to-date copy
2079
+ in the published tarball.
2080
+ - **Lazy-loaded optional peers share one pattern.** `lyra-chart`/`lyra-histogram`/`lyra-box-plot`,
2081
+ `lyra-graph`, `lyra-map`, and `lyra-flag` each show a `<lyra-skeleton>` placeholder (with
2082
+ `aria-busy="true"` on the host) while their respective optional peer dependency resolves, then
2083
+ settle into their normal render — or, if the peer genuinely isn't installed, an empty/fallback
2084
+ render plus a one-time `console.warn` (deduped module-wide, not per-instance).
2085
+ - **RTL.** Components generally rely on CSS logical properties to flip layout for free; `lyra-split`
2086
+ and `lyra-time-range` additionally consult `internal/rtl.ts`'s `isRtl()` to mirror the physical
2087
+ drag-direction/arrow-key math that logical properties alone don't cover.