@aceshooting/lyra-ui 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +8 -9
  2. package/custom-elements.json +10463 -4519
  3. package/dist/components/chart/chart.d.ts +46 -1
  4. package/dist/components/chart/chart.js +148 -10
  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 +74 -0
  13. package/dist/components/chart/lite-chart.js +384 -0
  14. package/dist/components/chart/lite-chart.stories.d.ts +12 -0
  15. package/dist/components/chart/lite-chart.stories.js +98 -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 +75 -12
  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 +48 -3
  50. package/dist/components/flag/flag.js +65 -29
  51. package/dist/components/flag/flag.styles.js +1 -1
  52. package/dist/components/flag/language-map.d.ts +11 -0
  53. package/dist/components/flag/language-map.js +12 -1
  54. package/dist/components/gauge/gauge.d.ts +2 -0
  55. package/dist/components/gauge/gauge.js +19 -5
  56. package/dist/components/gauge/gauge.stories.d.ts +2 -0
  57. package/dist/components/gauge/gauge.stories.js +6 -0
  58. package/dist/components/graph/graph-loader.d.ts +28 -0
  59. package/dist/components/graph/graph-loader.js +44 -0
  60. package/dist/components/graph/graph.d.ts +41 -15
  61. package/dist/components/graph/graph.js +146 -71
  62. package/dist/components/graph/graph.stories.d.ts +3 -0
  63. package/dist/components/graph/graph.stories.js +38 -0
  64. package/dist/components/graph/graph.styles.js +6 -1
  65. package/dist/components/heatmap/calendar-grid.d.ts +7 -0
  66. package/dist/components/heatmap/calendar-grid.js +27 -10
  67. package/dist/components/heatmap/heatmap.d.ts +137 -12
  68. package/dist/components/heatmap/heatmap.js +427 -27
  69. package/dist/components/heatmap/heatmap.stories.d.ts +17 -0
  70. package/dist/components/heatmap/heatmap.stories.js +130 -0
  71. package/dist/components/heatmap/heatmap.styles.js +60 -2
  72. package/dist/components/map/map.d.ts +17 -1
  73. package/dist/components/map/map.js +68 -21
  74. package/dist/components/map/map.stories.d.ts +10 -0
  75. package/dist/components/map/map.stories.js +107 -12
  76. package/dist/components/map/map.styles.js +2 -2
  77. package/dist/components/playback/playback.d.ts +6 -2
  78. package/dist/components/playback/playback.js +36 -12
  79. package/dist/components/playback/playback.stories.d.ts +2 -0
  80. package/dist/components/playback/playback.stories.js +6 -0
  81. package/dist/components/playback/playback.styles.js +10 -2
  82. package/dist/components/select/select.d.ts +122 -0
  83. package/dist/components/select/select.js +516 -0
  84. package/dist/components/select/select.stories.d.ts +19 -0
  85. package/dist/components/select/select.stories.js +75 -0
  86. package/dist/components/select/select.styles.d.ts +1 -0
  87. package/dist/components/select/select.styles.js +188 -0
  88. package/dist/components/skeleton/skeleton.js +4 -7
  89. package/dist/components/sparkline/sparkline.d.ts +1 -1
  90. package/dist/components/sparkline/sparkline.js +40 -13
  91. package/dist/components/sparkline/sparkline.stories.d.ts +4 -0
  92. package/dist/components/sparkline/sparkline.stories.js +22 -0
  93. package/dist/components/split/split.d.ts +24 -3
  94. package/dist/components/split/split.js +155 -31
  95. package/dist/components/split/split.stories.d.ts +2 -0
  96. package/dist/components/split/split.stories.js +20 -0
  97. package/dist/components/stat/stat.d.ts +27 -1
  98. package/dist/components/stat/stat.js +56 -3
  99. package/dist/components/stat/stat.stories.d.ts +3 -0
  100. package/dist/components/stat/stat.stories.js +49 -1
  101. package/dist/components/stat/stat.styles.js +44 -0
  102. package/dist/components/table/table.d.ts +64 -2
  103. package/dist/components/table/table.js +213 -17
  104. package/dist/components/table/table.stories.d.ts +6 -0
  105. package/dist/components/table/table.stories.js +45 -0
  106. package/dist/components/table/table.styles.js +51 -4
  107. package/dist/components/time-range/time-range.d.ts +34 -5
  108. package/dist/components/time-range/time-range.js +217 -36
  109. package/dist/components/time-range/time-range.stories.d.ts +2 -0
  110. package/dist/components/time-range/time-range.stories.js +13 -0
  111. package/dist/components/time-range/time-range.styles.js +44 -2
  112. package/dist/components/toast/toast-item.d.ts +14 -2
  113. package/dist/components/toast/toast-item.js +134 -17
  114. package/dist/components/toast/toast-item.styles.js +28 -2
  115. package/dist/components/toast/toast.stories.d.ts +3 -0
  116. package/dist/components/toast/toast.stories.js +41 -1
  117. package/dist/components/toast/toast.styles.js +11 -11
  118. package/dist/components/toast/toaster.d.ts +1 -1
  119. package/dist/components/toast/toaster.js +13 -6
  120. package/dist/components/tree/tree-node.d.ts +13 -7
  121. package/dist/components/tree/tree-node.js +24 -10
  122. package/dist/components/tree/tree.d.ts +64 -15
  123. package/dist/components/tree/tree.js +113 -22
  124. package/dist/components/tree/tree.stories.d.ts +4 -0
  125. package/dist/components/tree/tree.stories.js +31 -1
  126. package/dist/components/tree/update-cascade.d.ts +13 -0
  127. package/dist/components/tree/update-cascade.js +13 -0
  128. package/dist/components/widget/widget.d.ts +15 -1
  129. package/dist/components/widget/widget.js +140 -5
  130. package/dist/components/widget/widget.stories.d.ts +1 -0
  131. package/dist/components/widget/widget.stories.js +10 -0
  132. package/dist/components/widget/widget.styles.js +11 -1
  133. package/dist/components/word-cloud/word-cloud-layout.d.ts +63 -0
  134. package/dist/components/word-cloud/word-cloud-layout.js +145 -0
  135. package/dist/components/word-cloud/word-cloud.d.ts +65 -0
  136. package/dist/components/word-cloud/word-cloud.js +255 -0
  137. package/dist/components/word-cloud/word-cloud.stories.d.ts +10 -0
  138. package/dist/components/word-cloud/word-cloud.stories.js +55 -0
  139. package/dist/components/word-cloud/word-cloud.styles.d.ts +1 -0
  140. package/dist/components/word-cloud/word-cloud.styles.js +54 -0
  141. package/dist/internal/a11y.js +2 -1
  142. package/dist/internal/rtl.d.ts +7 -0
  143. package/dist/internal/rtl.js +9 -0
  144. package/dist/lyra.d.ts +9 -1
  145. package/dist/lyra.js +6 -0
  146. package/llms-full.txt +892 -276
  147. package/llms.txt +12 -9
  148. 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,109 @@ 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.
164
214
  - no `size` variant (small/medium/large) unlike sibling `lyra-toast-item`.
215
+ - `aria-required` reflects `required` immediately; `aria-invalid`, by contrast, only reflects
216
+ **after the field has been touched** (first `blur`) — a `required` field with a validity error
217
+ doesn't look invalid to assistive tech before that, by design (avoids flashing invalid styling on
218
+ first render). Blurring the input (Tab away) now also closes an open listbox, the same as a
219
+ native `<select>`'s popup, not just a click outside or Escape.
220
+ - `dotColor`/`sub`/`group` are read from light-DOM `<lyra-option>` children as before, but are also
221
+ first-class fields on `ComboboxSourceRow` for the async `source` path — an async lookup can drive
222
+ the same grouped/dot/sub-text rendering a static option list can.
165
223
  - 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.
224
+ kept on the input) is implemented correctly — a genuine strength, safe to build on. Declaratively-
225
+ selected options (`<lyra-option value="b" selected>`) are seeded into the selection (mirroring
226
+ native `<select><option selected>`) both the first time options are collected **and** for any
227
+ later batch slotted in afterward (e.g. a lazily-populated list appended post-connect) — only the
228
+ `form.reset()` default itself is captured exclusively from that very first pass; a later pick, or
229
+ a later batch of newly-`selected` options, never redefines what a reset restores to.
230
+
231
+ ---
232
+
233
+ ## `lyra-select`
234
+
235
+ A plain closed-list dropdown — a direct `<lyra-*>` counterpart to `<wa-select>`/`<wa-option>`.
236
+ **Form-associated** (hand-rolled internals, not the shared `FormAssociated` mixin — same reasoning
237
+ as `lyra-combobox`, see the shared-foundation notes above). The trigger is a `<button>`, not a text
238
+ input: click/Enter/Space/ArrowDown opens it, and there's no typing-to-filter. Options are
239
+ `<lyra-option value>` children — the same element `<lyra-combobox>` uses — reconciled the same way
240
+ combobox does, and the popup reuses `internal/positioner.ts` for placement.
241
+
242
+ Single-select only, with no `filter`/`source`/`with-clear`/`max-options-visible`/`empty-text`/
243
+ `max-render`/`multiple` surface — reach for `<lyra-combobox>` instead whenever any of those apply.
244
+
245
+ **Properties:**
246
+ - `placeholder: string = ''`
247
+ - `disabled: boolean = false` (reflected)
248
+ - `required: boolean = false` (reflected — enforced via `internals.setValidity()`)
249
+ - `name: string = ''`
250
+ - `label: string = ''`
251
+ - `hint: string = ''`
252
+ - `errorText: string = ''` (attribute `error-text` — static error copy shown below the hint;
253
+ overridden by slotted `error` content when provided)
254
+ - `open: boolean = false` (reflected)
255
+ - `value: string` — a getter/setter; always a single string (no `multiple` mode)
256
+
257
+ **Events:** `change` (native-style — selection changed), `input` (fired alongside `change` on every
258
+ selection change — a native `<select>` doesn't meaningfully distinguish the two either),
259
+ `lyra-show`, `lyra-hide`
260
+
261
+ **Slots:** default (`<lyra-option>` children), `label`, `hint`, `error` (overrides the `errorText`
262
+ attribute when provided)
263
+
264
+ **CSS parts:** `form-control`, `form-control-label`, `trigger`, `listbox`, `option`, `option-dot`
265
+ (the leading status dot, when a row's `dotColor` is set), `option-label`, `option-sub` (a row's
266
+ secondary line, when `sub` is set), `expand-icon`, `error`, `hint`
267
+
268
+ **Themeable custom properties:** shared tokens only.
269
+
270
+ **Optional peer deps:** none.
271
+
272
+ ```html
273
+ <lyra-select id="sel" label="Fruit" placeholder="Pick one…">
274
+ <lyra-option value="a">Apple</lyra-option>
275
+ <lyra-option value="b" selected>Banana</lyra-option>
276
+ </lyra-select>
277
+ <script type="module">
278
+ document.getElementById('sel').addEventListener('change', (e) => console.log(e.target.value));
279
+ </script>
280
+ ```
281
+
282
+ **Known gotchas:**
283
+ - The trigger keeps real DOM focus throughout — the listbox's "active" row is conveyed via
284
+ `aria-activedescendant`, never actual focus, matching the WAI-ARIA "select-only combobox" pattern
285
+ (as opposed to `lyra-combobox`'s editable-input pattern).
286
+ - No typing-to-filter, but a printable keypress still jumps to (while open) or directly selects
287
+ (while closed) the next non-disabled option whose label starts with what's been typed, matching a
288
+ native `<select>`'s own type-ahead; the buffer resets ~500ms after the last keystroke.
289
+ - Declaratively-selected options (`<lyra-option value="b" selected>`) seed the initial selection
290
+ (mirroring native `<select><option selected>`) both the first time options are collected and for
291
+ any later-slotted batch — only that very first pass' declared selection becomes the
292
+ `form.reset()` default, the same rule `lyra-combobox` follows.
293
+ - `aria-required` on the trigger reflects `required` immediately; `aria-invalid` only reflects once
294
+ the trigger has been blurred (touched) at least once, mirroring `lyra-combobox`'s own input.
295
+ Blurring the trigger (Tab away) closes an open listbox, the same as a native `<select>`'s popup.
167
296
 
168
297
  ---
169
298
 
@@ -195,7 +324,8 @@ Inline month-grid calendar, not form-associated (used standalone or embedded ins
195
324
  **Getters:** `selection: { from: Date|null; to: Date|null }`, `valueAsDate: Date | null` (single
196
325
  mode only)
197
326
 
198
- **Methods:** `clear()`, `goToToday()`, `goToDate(date: string | Date)`
327
+ **Methods:** `clear()`, `goToToday()`, `goToDate(date: string | Date)` (clamped to `min`/`max` before
328
+ navigating/focusing)
199
329
 
200
330
  **Events:** `input` (during interaction — for range mode, fires after the first click of a pair),
201
331
  `change` (committed value)
@@ -230,15 +360,22 @@ Text field + calendar popover, **form-associated** via the shared `FormAssociate
230
360
  - `months: 1|2 = 1`
231
361
  - `firstDayOfWeek: string = 'auto'`
232
362
  - `weekdayFormat: 'narrow'|'short'|'long' = 'short'`
363
+ - `disablePast: boolean = false` (attribute `disable-past`)
364
+ - `disableFuture: boolean = false` (attribute `disable-future`)
365
+ - `withOutsideDays: boolean = false` (attribute `with-outside-days`)
366
+
367
+ **Properties (own, continued):**
368
+ - `errorText: string = ''` (attribute `error-text` — static error copy; overridden by slotted
369
+ `error` content when provided)
233
370
 
234
371
  **Methods:** `show()`, `hide()`, `clear()`
235
372
 
236
373
  **Events:** `input`, `change`, `lyra-show`, `lyra-hide`, `lyra-clear`
237
374
 
238
- **Slots:** `label`, `hint`
375
+ **Slots:** `label`, `error` (overrides `errorText`), `hint`
239
376
 
240
377
  **CSS parts:** `form-control`, `form-control-label`, `input-wrapper`, `input`, `clear-button`,
241
- `expand-button`, `expand-icon`, `popup`, `date-picker`, `hint`
378
+ `expand-button`, `expand-icon`, `popup`, `date-picker`, `error`, `hint`
242
379
 
243
380
  **Themeable custom properties:** shared tokens only.
244
381
 
@@ -257,30 +394,30 @@ Text field + calendar popover, **form-associated** via the shared `FormAssociate
257
394
  from the package root**, but worth knowing when reasoning about behavior): `parseISO`, `formatISO`,
258
395
  `isSameDay`, `addMonths`, `clampDate`, `monthMatrix(year, month, firstDayOfWeek)`,
259
396
  `weekdayLabels(firstDayOfWeek, format, locale)`, `monthTitle(year, month, locale)`,
260
- `resolveFirstDayOfWeek(value)`.
397
+ `resolveFirstDayOfWeek(value, locale?)`.
261
398
 
262
399
  **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.
400
+ - `first-day-of-week="auto"` now derives from `locale` when the runtime's `Intl.Locale` exposes
401
+ week-info (`weekInfo`/`getWeekInfo()`, still shifting between engines) `resolveFirstDayOfWeek()`
402
+ only hardcodes Sunday as the fallback when that isn't available or `locale` is unset. A
403
+ French/German-locale user with a supporting runtime now gets the Monday-first grid their OS would
404
+ show; on an older runtime it still falls back to Sunday-first silently. **If you need a guaranteed
405
+ locale-correct week start regardless of runtime**, pass an explicit
406
+ `first-day-of-week="mon"` (accepted values: `sun`/`mon`/`tue`/`wed`/`thu`/`fri`/`sat`).
273
407
  - (date-input only) — opening the calendar doesn't move focus into it (`show()` only flips
274
408
  `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`.
409
+ on the text input. Escape *does* close it from anywhere inside the form control (including the
410
+ nested picker's own day/nav buttons), returning focus to the expand button.
411
+ - (date-input only) typing an unparseable string and blurring/committing sets `badInput` via
412
+ `internals.setValidity()` and reverts the displayed text to the last valid commit, but does not
413
+ reflect `aria-invalid` onto the visible `<input part="input">` itself (unlike `lyra-combobox`,
414
+ which does reflect `aria-invalid`/`aria-required` onto its own input) rely on `data-invalid`
415
+ (a host attribute, toggled the same way) or `reportValidity()` rather than assuming the input's own
416
+ ARIA state.
282
417
  - The grid keyboard pattern (Arrow/PageUp/PageDown/Home/End navigation with correct focus
283
- sequencing) is implemented correctly and safe to rely on.
418
+ sequencing) is implemented correctly and safe to rely on, as is the selected/range-day text color
419
+ (`--lyra-color-on-brand`, not a hardcoded literal — safe to override `--lyra-color-brand` without
420
+ losing contrast on selected-day text).
284
421
 
285
422
  ---
286
423
 
@@ -317,7 +454,8 @@ A single notification.
317
454
 
318
455
  **Properties:**
319
456
  - `duration: number = 5000` (ms; `Infinity` or `<= 0` disables auto-dismiss)
320
- - `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected)
457
+ - `size: 'xs'|'s'|'m'|'l'|'xl' = 'm'` (reflected — drives both `--padding` and the toast's own
458
+ font-size via `:host([size=...])`, from a compact `xs` up to a roomier `xl`)
321
459
  - `variant: 'brand'|'success'|'warning'|'danger'|'neutral' = 'neutral'` (reflected)
322
460
  - `withIcon: boolean = false` (attribute `with-icon`)
323
461
 
@@ -330,15 +468,24 @@ DOM.
330
468
 
331
469
  **CSS parts:** `toast-item`, `accent`, `icon`, `content`, `close-button`
332
470
 
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`,
471
+ **Themeable custom properties:** `--accent-width` (4px), `--show-duration`/`--hide-duration`
472
+ (`var(--lyra-transition-base, 180ms ease-out)` 180ms by default, matching the JS-side show/hide
473
+ animation timer, which is not itself reading this token), `--padding` (`var(--lyra-space-m)`),
474
+ `--font-size` (`1rem`) — both `--padding` and `--font-size` are auto-swapped per `size`, from a
475
+ compact `xs` up to a roomier `xl` — `--accent-color` (defaults to `--lyra-color-border`,
335
476
  auto-swapped per `variant` to `--lyra-color-brand/-success/-warning/-danger`).
336
477
 
337
478
  **Optional peer deps:** none.
338
479
 
339
480
  `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).
481
+ otherwise — re-evaluated on every `variant` change, not just at first render, so reassigning
482
+ `variant` to `danger`/`warning` after creation is announced as an interruption instead of keeping
483
+ its original, now-stale role. Auto-dismiss timer **pauses** on `pointerenter`/`focusin`, **resumes**
484
+ on `pointerleave`/`focusout`, with real elapsed-time bookkeeping (WCAG 2.2.1 timing-adjustable) —
485
+ hover and focus are tracked as independent pause reasons, so releasing only one (e.g. the pointer
486
+ leaves while focus remains, or vice versa) keeps the timer paused until *neither* holds it anymore.
487
+ A `duration` change while the timer is actively counting down reschedules it immediately against
488
+ the new value instead of waiting for the next pause/resume cycle.
342
489
 
343
490
  ### `toast()`
344
491
 
@@ -355,7 +502,9 @@ toast({ message: 'Deleted', variant: 'danger', action: { label: 'Undo', onClick:
355
502
  `toast(input: ToastOptions | string): ToastHandle` where
356
503
  `ToastOptions = ToastCreateOptions & { message: string; placement?: ToastPlacement; action?: { label: string; onClick: (item: LyraToastItem) => void } }`,
357
504
  and `ToastHandle = { item: Promise<LyraToastItem>; dismiss: () => void }`. Lazily mounts (and
358
- re-mounts if removed) a single `<lyra-toast>` region on `document.body`.
505
+ re-mounts if removed) **one singleton `<lyra-toast>` region per distinct `placement`** on
506
+ `document.body` — a `toast()` call targeting one placement never relocates toasts already showing
507
+ at another, since `placement` is a per-call option rather than a single global region's setting.
359
508
 
360
509
  ```html
361
510
  <script type="module">
@@ -369,9 +518,15 @@ re-mounts if removed) a single `<lyra-toast>` region on `document.body`.
369
518
  `role="status" aria-live="polite"` **and** each `lyra-toast-item` independently self-assigns its
370
519
  own `role` (`status`/`alert`) — nesting live regions can cause some screen readers to announce a
371
520
  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.
521
+ - the close button's accessible name is derived from the toast's own message text (`"Close: <first
522
+ 40 chars>…"`, falling back to bare `"Close"` only when the toast has no text content) rather than
523
+ a bare `"Close"` on every instance — useful when several toasts are stacked and a screen-reader or
524
+ switch-access user needs to tell their close buttons apart without activating one first.
525
+ - pause/resume-on-hover/focus (the component's main accessibility differentiator), including the
526
+ independent-hover-vs-focus pause reasons above, now has regression test coverage.
527
+ - `hide()` is idempotent (a second call while already hiding is a no-op) and `[part="close-button"]`
528
+ gets `aria-disabled="true"` once hiding starts, so a stray extra click/Enter during the hide
529
+ animation can't re-enter it.
375
530
  - Prefer the `toast()` helper over manually creating `<lyra-toast>`/`<lyra-toast-item>` — it already
376
531
  handles the singleton-region and remount-if-removed logic.
377
532
 
@@ -409,8 +564,13 @@ the reviewed family (pure CSS cascade, no JS/`getComputedStyle` bridging needed)
409
564
  - Auto-sets `role="img"` + a descriptive `aria-label` ("Trend of N values, last X" / "No data" when
410
565
  empty) — good default, but still just a single summary sentence with no tabular fallback; fine for
411
566
  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.
567
+ - flat data (every value equal, so the auto-computed range spans zero) now renders a centered
568
+ midline/mid-height bars instead of collapsing every point to the bottom edge, and a single-value
569
+ series renders a visible flat line (a zero-length path was previously invisible). A `type="bar"`
570
+ series past 500 points is decimated to at most 500 rendered `<rect>`s (evenly sampled, not
571
+ aggregated/averaged) so an unbounded `values` array can't turn directly into that many shadow-DOM
572
+ nodes; `line`/`area` types have no such cap (they already collapse to one `<path>` regardless of
573
+ point count).
414
574
 
415
575
  ---
416
576
 
@@ -423,8 +583,13 @@ Country/language flag image. Flag artwork ships in a **separate, optional peer p
423
583
  - `country?: string` (ISO 3166-1 alpha-2, e.g. `"fr"` — takes precedence over `language`)
424
584
  - `language?: string` (BCP-47-ish tag, e.g. `"en"`/`"en-US"`, resolved to a representative country
425
585
  via `languageToCountry()`)
426
- - `label?: string` (accessible name / `alt` text**defaults to the bare uppercase code if
427
- omitted**, see gotchas)
586
+ - `src?: string` (a pre-resolved flag image URLtakes precedence over `country`/`language` and
587
+ skips the peer-package lookup/loading-skeleton round trip entirely; mainly useful to avoid even
588
+ the small per-flag async hop when you already have the URL at build time, e.g. from
589
+ `import frUrl from '@aceshooting/lyra-flags/flags/fr.svg?url'`. `label` is effectively required
590
+ alongside `src` since there's no `country`/`language` to derive a fallback `alt` from.)
591
+ - `label?: string` (accessible name / `alt` text — **defaults to the bare uppercase *resolved
592
+ country* code if omitted**, see gotchas)
428
593
  - `round: boolean = false` (reflected — circular crop)
429
594
 
430
595
  **Events:** none.
@@ -433,7 +598,8 @@ Country/language flag image. Flag artwork ships in a **separate, optional peer p
433
598
 
434
599
  **CSS parts:** `image`
435
600
 
436
- **Themeable custom properties:** `--lyra-flag-radius` (default `2px`, non-`round` corner radius).
601
+ **Themeable custom properties:** `--lyra-flag-radius` (default `2px`, non-`round` corner radius);
602
+ also consumes `--lyra-color-border` (the inset ring around the flag image).
437
603
 
438
604
  **Optional peer deps:** `@aceshooting/lyra-flags` — required for the component to actually render an
439
605
  image; without it, renders an empty template (see gotchas).
@@ -454,15 +620,33 @@ pnpm add @aceshooting/lyra-flags # required peer — without it, <lyra-flag> r
454
620
 
455
621
  **Known gotchas:**
456
622
  - 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).
623
+ component still shows a `<lyra-skeleton variant="rect">` placeholder (with `aria-busy="true"` on
624
+ the host) while resolving, then settles into an **empty template** plus a one-time `console.warn`
625
+ once the resolver rejects (lazy `import()`, cached module-wide so the warning fires only once per
626
+ page even with many `<lyra-flag>` instances).
459
627
  - 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
628
+ resolver call, so there's a brief loading-skeleton window on first paint/attribute change — don't
461
629
  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.
630
+ - if `label` is omitted, the accessible name (`alt`) falls back to the **bare uppercase resolved
631
+ country code** for `language="en"` that's `"GB"` (the mapped country), not `"EN"` (the language
632
+ tag itself). A screen reader will typically spell this out letter-by-letter rather than announce a
633
+ country name. Always pass an explicit `label` (e.g. `"France"`) if the flag conveys real
634
+ information rather than being purely decorative.
635
+ - an invalid `country` (anything not matching the ISO 3166-1 alpha-2 shape, e.g. containing `../`)
636
+ is rejected before it ever reaches the peer's `flagUrl()` resolver — treated the same as an
637
+ unknown/missing flag rather than being passed through. `languageToCountry()`'s region-subtag path
638
+ is validated against that same alpha-2 shape too (not just a bare length-2 check), so a malformed
639
+ region such as `"en-01"` correctly falls through to the `LANGUAGE_TO_COUNTRY[base]` lookup instead
640
+ of being accepted verbatim as a country code.
641
+ - rapidly reassigning `country`/`language`/`src` no longer risks a stale resolution overwriting a
642
+ newer one: each resolver call is tagged with a token captured at the time it was kicked off, and a
643
+ `.then()` that fires after a later change (or after the field was cleared) is discarded instead of
644
+ clobbering the rendered image/`loading`.
645
+ - `country`/`language` resolve through `@aceshooting/lyra-flags`'s `flagUrl(code)`, which is itself
646
+ genuinely code-split per flag: using `<lyra-flag country="fr">` anywhere in an app fetches only
647
+ the flags actually requested at runtime (verified with a real Vite build — referencing 2 codes
648
+ shipped ~28 KB total, not all 249). If you already have a flag's URL at build time, `src` skips
649
+ the peer-package round trip (and its loading-skeleton flash) entirely.
466
650
 
467
651
  ---
468
652
 
@@ -475,6 +659,9 @@ First-party "no data" state (no Web Awesome equivalent).
475
659
  **Properties:**
476
660
  - `heading: string = ''`
477
661
  - `description: string = ''`
662
+ - `compact: boolean = false` (reflected) — tighter, left-aligned rendering (less padding, a lighter
663
+ heading weight) for use inside a constrained space like a widget body or table cell, instead of
664
+ the centered/spacious full-page default
478
665
 
479
666
  **Events:** none.
480
667
 
@@ -482,7 +669,7 @@ First-party "no data" state (no Web Awesome equivalent).
482
669
 
483
670
  **CSS parts:** `base`, `icon`, `heading`, `description`, `actions`
484
671
 
485
- **Themeable custom properties:** shared tokens only (`--lyra-space-s/-l`,
672
+ **Themeable custom properties:** shared tokens only (`--lyra-space-xs/-s/-l`,
486
673
  `--lyra-color-text-quiet/-border/-text`).
487
674
 
488
675
  **Optional peer deps:** none.
@@ -495,10 +682,8 @@ First-party "no data" state (no Web Awesome equivalent).
495
682
  ```
496
683
 
497
684
  **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.
685
+ - `[part="base"]` is `role="status" aria-live="polite"`, so a list/table transitioning to empty
686
+ does announce to screen readers no extra wiring needed on the host's part.
502
687
  - Note: correctly works around the classic `:empty`-pseudo-class trap (a wrapper with a `<slot>`
503
688
  inside can never match `:empty`) by tracking real slot assignment in JS (`hasIcon`/`hasActions`) —
504
689
  `lyra-table` reuses this component for its own empty-rows state, and `lyra-stat` (below) now uses
@@ -560,16 +745,27 @@ KPI/stat card — value + unit + label + optional icon/trend/caption.
560
745
  - `goodDirection: 'up'|'down' = 'up'` (attribute `good-direction`) — which trend direction counts
561
746
  as "good"; inverts arrow/color polarity for cost/latency/error-rate-style metrics where a
562
747
  *decrease* is the win.
748
+ - `rows: StatRow[] = []` (attribute: false) — `StatRow { label: string; value: string }`; rendered as
749
+ a simple label/value breakdown list (`[part="rows"]`/`[part="row"]`/`[part="row-label"]`/
750
+ `[part="row-value"]`) beneath the caption, hidden entirely when empty
751
+ - `emphasis: boolean = false` (reflected) — visual emphasis (e.g. for a "headline" stat in a group):
752
+ a brand-colored accent edge, orthogonal to the status `variant`; status semantics still win over
753
+ it — `emphasis` only additionally tints `[part="value"]` brand-colored when `variant` is still
754
+ `'neutral'`, never overriding an actual `success`/`warning`/`danger` value color
563
755
 
564
756
  **Events:** none.
565
757
 
566
758
  **Slots:** default (leading icon), `caption` (rich caption content — overrides the `caption`
567
- attribute when slotted content is provided)
759
+ attribute when slotted content is provided), `spark` (a sparkline, e.g. `<lyra-sparkline
760
+ slot="spark">`, or other compact trend visual — `lyra-stat` only reserves the slot and doesn't
761
+ render one itself)
568
762
 
569
- **CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `caption`
763
+ **CSS parts:** `base`, `icon`, `label`, `value-row`, `value`, `unit`, `trend`, `spark`, `caption`,
764
+ `rows`, `row`, `row-label`, `row-value`
570
765
 
571
766
  **Themeable custom properties:** shared tokens only (`--lyra-color-success/-warning/-danger` drive
572
- the `variant`-colored value text and up/down trend pill).
767
+ the `variant`-colored value text and up/down trend pill; `--lyra-color-brand` drives `emphasis`'s
768
+ accent edge and value tint).
573
769
 
574
770
  **Optional peer deps:** none.
575
771
 
@@ -580,8 +776,12 @@ the `variant`-colored value text and up/down trend pill).
580
776
  ```
581
777
 
582
778
  **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.
779
+ - no `aria-live` region wraps `value`/`trend` — an in-place update after first render still isn't
780
+ proactively announced to screen readers. The trend pill's direction/polarity is no longer
781
+ conveyed by icon rotation/color alone, though: a visually-hidden span now spells it out in plain
782
+ language (e.g. "increased 4.2%, good" / "decreased 2%, bad" / "unchanged"), so a screen reader
783
+ landing on the pill (rather than being live-notified of a change) gets the full meaning, not just
784
+ an `aria-hidden` arrow glyph.
585
785
 
586
786
  ---
587
787
 
@@ -592,7 +792,13 @@ of `rows` — this component only renders and emits intents.
592
792
 
593
793
  **Properties:**
594
794
  - `columns: TableColumn<T>[] = []` (attribute: false) — `{ key, label, sortable?, align?:
595
- 'start'|'end', cell: (row) => unknown }`
795
+ 'start'|'end', priority?: 'medium'|'low', sticky?: boolean, cell: (row) => unknown }`
796
+ `priority` progressively hides that column via a `@container` query as `[part='base']` narrows
797
+ (`'low'` hides first, under a ~900px container width; `'medium'` next, under ~640px; both
798
+ breakpoints are fixed in `table.styles.ts`, not themeable tokens), reversible via
799
+ `[part='reveal-columns-button']` (rendered whenever any column sets a `priority`); `sticky` pins
800
+ that column's header cell/cells to the inline-start edge (`position: sticky`) while the table
801
+ scrolls horizontally
596
802
  - `rows: T[] = []` (attribute: false)
597
803
  - `sortKey: string = ''` (attribute `sort-key`)
598
804
  - `sortDir: 'asc'|'desc' = 'asc'` (attribute `sort-dir`)
@@ -604,13 +810,19 @@ of `rows` — this component only renders and emits intents.
604
810
  - `emptyDescription: string = ''` (attribute `empty-description`)
605
811
  - `noColumnsHeading: string = 'No columns configured'` (attribute `no-columns-heading`)
606
812
  - `noColumnsDescription: string = ''` (attribute `no-columns-description`)
813
+ - `revealColumnsLabel: string = 'Show all columns'` (attribute `reveal-columns-label` — the
814
+ reveal-button's label while `priority`-hidden columns are hidden)
815
+ - `hideColumnsLabel: string = 'Show fewer columns'` (attribute `hide-columns-label` — the same
816
+ button's label once they've been revealed)
607
817
 
608
818
  **Events:** `lyra-sort` (`detail: { key }`, fired on sortable-header activation), `lyra-row-click`
609
819
  (`detail: { row }`), `lyra-load-more` (fired on the "load more" button)
610
820
 
611
821
  **Slots:** none — content comes entirely from `columns`/`rows`.
612
822
 
613
- **CSS parts:** `base`, `table`, `head`, `header-cell`, `row`, `cell`, `more-button`
823
+ **CSS parts:** `base`, `table`, `head`, `header-cell`, `row`, `cell`, `more-button`, `sort-icon` (a
824
+ chevron indicator shown on the active sortable header, rotated per `sortDir`), `reveal-columns-button`
825
+ (shown only when at least one column sets `priority`)
614
826
 
615
827
  **Themeable custom properties:** `--lyra-table-max-height` (default `none`; controls the scrollable
616
828
  body's `max-block-size`).
@@ -638,8 +850,16 @@ body's `max-block-size`).
638
850
  a `cell()` callback if you need multi-select.
639
851
  - no `caption`/`aria-label` property, so the rendered `role="grid"` has no accessible name
640
852
  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.
853
+ - Full roving-tabindex grid keyboard pattern (one `tabindex="0"` stop among header cells, one among
854
+ body rows) Left/Right/Home/End move within the header row, Up/Down/Home/End move within the
855
+ body, Down from the header enters the body's roving stop and Up from the body's first row returns
856
+ to the header, Enter/Space still only sort/activate — a genuine strength versus most siblings in
857
+ this family. A `priority`-hidden header/cell is skipped when computing the visible header stops,
858
+ so arrow-key navigation never strands the roving stop on a hidden column.
859
+ - a `cell()` template can render its own interactive content (a button, link, input, etc.) without
860
+ it being swallowed by row/column activation: clicks and Enter/Space landing on (or bubbling
861
+ through) anything matching `button, a[href], input, select, textarea, [role="button"]` are left
862
+ alone by the table's own delegated `click`/`keydown` handlers.
643
863
 
644
864
  ---
645
865
 
@@ -653,7 +873,8 @@ Dependency-free SVG radial or linear meter (270°-sweep arc math, no charting li
653
873
  - `max: number = 100`
654
874
  - `type: 'radial'|'linear' = 'radial'` (reflected)
655
875
  - `label: string = ''`
656
- - `valueLabel?: string` (attribute: false — overrides only the *visible* text, see gotchas)
876
+ - `valueLabel?: string` (attribute: false — overrides only the *visible* text, see gotchas; an empty
877
+ string is treated the same as unset and falls back to the numeric `value`)
657
878
 
658
879
  **Events:** none.
659
880
 
@@ -675,12 +896,10 @@ gauge-specific custom property exists).
675
896
  ```
676
897
 
677
898
  **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`.
899
+ - setting `valueLabel` (e.g. `"72°F"`) now also sets `aria-valuetext` on the host (in addition to
900
+ changing the visible SVG text), so a screen reader announces your formatted string instead of the
901
+ raw `aria-valuenow` number; the SVG `<text part="value">`/`<text part="label">` elements are
902
+ `aria-hidden="true"` so they're no longer separately exposed inside the same `role="meter"` host.
684
903
  - no color-threshold/variant support: a single fixed `--lyra-color-brand` fill regardless
685
904
  of value (no amber/red-near-capacity idiom built in).
686
905
  - no documented component-specific sizing custom property; host size is fixed em values
@@ -688,6 +907,11 @@ gauge-specific custom property exists).
688
907
  element instead.
689
908
  - Divide-by-zero guarded (`max - min || 1`), and radial/linear share one component via the `type`
690
909
  attribute.
910
+ - a non-finite `value`/`min`/`max` (e.g. left `NaN` mid-binding) no longer leaks a literal `"NaN"`
911
+ into `aria-valuenow`/`aria-valuemax`/the rendered value text — the affected ARIA attribute is
912
+ omitted entirely instead, and a finite `value` is clamped into `[min, max]` before being written
913
+ to `aria-valuenow` (the SVG arc/bar fill was already clamped via `ratio`; the ARIA number now
914
+ matches what's drawn).
691
915
 
692
916
  ---
693
917
 
@@ -698,7 +922,10 @@ opens a small menu).
698
922
 
699
923
  **Properties:**
700
924
  - `rows: Record<string, unknown>[] = []` (attribute: false)
701
- - `columns: CsvColumn[] = []` (attribute: false) — `{ key, label }`
925
+ - `columns: CsvColumn[] = []` (attribute: false) — `{ key, label }`; also acts as a field allow-list
926
+ for **both** export formats when non-empty (CSV always projected through `columns`; JSON now picks
927
+ the same `columns[].key` set rather than dumping every field on `rows`, so it can't leak a field
928
+ CSV would have hidden) — leave `columns` empty to export every field of `rows` as-is in JSON
702
929
  - `filename: string = 'export'`
703
930
  - `formats: ExportFormat[] = ['csv']` (attribute: false; `ExportFormat = 'csv' | 'json'` **only** —
704
931
  no XLSX despite the family sometimes being described as covering it)
@@ -732,7 +959,7 @@ substitute your own server-generated download instead of the built-in client-sid
732
959
  Package-level CSV utilities (used internally, also exported for standalone use — `import {
733
960
  escapeCsvField, buildCsv, downloadBlob } from '@aceshooting/lyra-ui'`):
734
961
  ```ts
735
- escapeCsvField(value: unknown): string // quotes/escapes; neutralizes formula-injection (=,+,-,@,tab,CR) with a leading apostrophe
962
+ 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
963
  buildCsv(rows: Record<string, unknown>[], columns: CsvColumn[]): string // CRLF-joined, header row included
737
964
  downloadBlob(content: string, filename: string, mime: string): void // triggers a browser download
738
965
  ```
@@ -742,6 +969,11 @@ downloadBlob(content: string, filename: string, mime: string): void // trig
742
969
  `'xlsx'` expecting it to work.
743
970
  - CSV formula-injection guarding and the deferred (5s) `URL.revokeObjectURL` (works around Safari
744
971
  cancelling in-flight downloads on immediate revoke) are genuine, safe-to-rely-on strengths.
972
+ - the multi-format menu (`role="menu"`) supports full arrow-key navigation — ArrowUp/ArrowDown move
973
+ between items (opening the menu and seeding the right one focused, if it was closed), Home/End
974
+ jump to the first/last item once open, Escape closes it and returns focus to the trigger button,
975
+ and a completed export also returns focus to the trigger — not just Escape/click-outside as
976
+ before.
745
977
 
746
978
  ---
747
979
 
@@ -758,6 +990,13 @@ auto-inserted between each adjacent pair.
758
990
  - `storageKey?: string` (attribute `storage-key` — persists sizes to `localStorage` under
759
991
  `` `lyra-split:${key}:${panelCount}` ``, scoped by panel count so a stale layout for a different
760
992
  panel count is ignored)
993
+ - `panelConstraints: (PanelConstraint | null)[] = []` (attribute: false) — `PanelConstraint { minPx?:
994
+ number; maxPx?: number }`, index-aligned with `sizes`; a `null`/missing entry (or an omitted bound
995
+ within an entry) leaves that side of that panel purely percent-based (the existing `min`-only
996
+ behavior). `sizes`, the `lyra-resize` payload, and localStorage persistence stay percent-based
997
+ regardless — only the effective drag/keyboard clamp bounds (and the rendered `flex-basis`, via a
998
+ native CSS `clamp()` so a constrained panel stays pinned between its px bounds across container
999
+ resizes with no extra `ResizeObserver`) change for a constrained panel.
761
1000
 
762
1001
  **Events:** `lyra-resize` (`detail: { sizes }`, fired on every drag step/release **and** every
763
1002
  keyboard step)
@@ -779,29 +1018,160 @@ keyboard step)
779
1018
  ```
780
1019
 
781
1020
  Keyboard: focus a divider (`Tab`), then `ArrowRight`/`ArrowLeft` (horizontal) or
782
- `ArrowDown`/`ArrowUp` (vertical) to resize by a fixed 2% step.
1021
+ `ArrowDown`/`ArrowUp` (vertical) to resize by a fixed 2% step — RTL-aware for horizontal layouts
1022
+ (under `direction: rtl`, the forward/backward keys and drag-delta sign both swap so they still track
1023
+ the visually-adjacent panel).
783
1024
 
784
1025
  **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.
1026
+ - `panelCount` now reacts to `slotchange` (not just the initial `connectedCallback()` read), and
1027
+ `ensureSizes()` rebalances existing sizes proportionally when a panel is added or removed after
1028
+ mount instead of discarding the whole layout a conditionally-shown side panel no longer leaves
1029
+ `panelCount`/`sizes`/divider count stale.
1030
+ - divider `aria-valuemax` is now computed per adjacent pair (`sizes[i] + sizes[i+1] - min`) rather
1031
+ than a blanket `100 - min`, so it's accurate for 3+-panel layouts too, not just exactly two panels
1032
+ this formula still only accounts for the plain percent `min`, though: with `panelConstraints`
1033
+ set, a panel's real achievable range can be narrower (or expressed in px) than what
1034
+ `aria-valuemin`/`aria-valuemax` report. Each divider also now has its own `aria-label` ("Resize
1035
+ divider between panel N and panel N+1") distinguishing it from any other divider in a
1036
+ multi-divider layout.
795
1037
  - no feasibility check on `min` vs. panel count (e.g. 3 panels with `min=40` is
796
1038
  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.
1039
+ - concurrent drags are tracked per `pointerId` (not a single scalar), so a multi-touch drag on two
1040
+ different dividers moves both independently instead of the second pointer clobbering the first's
1041
+ drag state; `pointercancel`/`lostpointercapture` (not just `pointerup`) both end a drag.
1042
+ - `localStorage.getItem`/`setItem` calls are now both wrapped in their own `try`/`catch` (in addition
1043
+ to the `JSON.parse` result already being caught), so a blocked or quota-exceeded store fails
1044
+ silently instead of throwing from inside a `pointerup`/`keydown` handler. A persisted layout whose
1045
+ panel count no longer matches, or whose sizes are already below the current `min` floor, is
1046
+ rejected rather than restored.
800
1047
  - Pointer-drag lifecycle (pointer capture, window-listener cleanup on both drag-end and
801
1048
  `disconnectedCallback`) is solid and safe to rely on.
802
1049
 
803
1050
  ---
804
1051
 
1052
+ ## `lyra-widget`
1053
+
1054
+ A titled panel shell with an optional collapse toggle and an optional fullscreen-expand toggle.
1055
+ First-party invention (no Web Awesome equivalent). Fullscreen promotes the same host element in
1056
+ place (a CSS state, not a clone/portal), so slotted content (a chart, a running simulation, scroll
1057
+ position) survives the transition.
1058
+
1059
+ **Properties:**
1060
+ - `label: string = ''`
1061
+ - `sublabel: string = ''`
1062
+ - `collapsible: boolean = false` (reflected — shows the collapse/expand chevron button)
1063
+ - `collapsed: boolean = false` (reflected)
1064
+ - `expandable: boolean = false` (reflected — shows the fullscreen toggle button)
1065
+ - `fullscreen: boolean = false` (reflected)
1066
+
1067
+ **Events:** `lyra-collapse-change` (`detail: boolean`, the new `collapsed` state),
1068
+ `lyra-fullscreen-change` (`detail: boolean`, the new `fullscreen` state — also fired when fullscreen
1069
+ is exited via Escape or a backdrop click, not just the toggle button)
1070
+
1071
+ **Slots:** default (the panel body), `actions` (header action controls, rendered before the
1072
+ collapse/expand buttons)
1073
+
1074
+ **CSS parts:** `base`, `header`, `title`, `label`, `sublabel`, `actions`, `collapse-button`,
1075
+ `fullscreen-button`, `body`, `backdrop`
1076
+
1077
+ **Themeable custom properties:** `--lyra-widget-overlay-color` (default `rgb(0 0 0 / 0.5)` — the
1078
+ fullscreen backdrop scrim color; component-specific since no shared `--wa-*`/`--lyra-*` overlay token
1079
+ exists), plus shared tokens (`--lyra-space-*`, `--lyra-color-border/-surface/-text-quiet`,
1080
+ `--lyra-radius`, `--lyra-shadow`, `--lyra-icon-button-size`, `--lyra-focus-ring-*`).
1081
+
1082
+ **Optional peer deps:** none.
1083
+
1084
+ ```html
1085
+ <lyra-widget label="Load profile" sublabel="Last 7 days" collapsible expandable>
1086
+ <span slot="actions"><button>Refresh</button></span>
1087
+ <div>Panel body content — a chart, a table, anything.</div>
1088
+ </lyra-widget>
1089
+ ```
1090
+
1091
+ While `fullscreen`, `[part="base"]` (not the host itself) takes `role="dialog"` + `aria-modal="true"`
1092
+ (with `aria-label` from `label`, falling back to `"Fullscreen panel"`), document scroll is locked
1093
+ (ref-counted, safe with multiple simultaneously-fullscreen widgets), and Tab/Shift+Tab are bounded
1094
+ to the panel's own focusable content (`actions` slot → collapse/fullscreen buttons → body slot,
1095
+ matching visual tab order — resolved shadow-piercingly, so a slotted custom element's real
1096
+ focusable target inside its own shadow root is found too) so keyboard focus can't escape to page
1097
+ content hidden behind the backdrop. Escape or clicking the backdrop exits fullscreen and returns
1098
+ focus to whichever button triggered it.
1099
+
1100
+ **Known gotchas:**
1101
+ - no size/density variant — header and body padding are fixed.
1102
+ - a reconnect that preserves the same element instance (e.g. a drag-and-drop reparent) restores the
1103
+ scroll lock and the Tab-trap `keydown` listener if `fullscreen` was still `true` across the
1104
+ move — `disconnectedCallback`/`connectedCallback` fire back-to-back with no update in between, so
1105
+ `willUpdate()` alone wouldn't otherwise notice.
1106
+ - `collapsed` hides the body via `hidden` rather than an animated height transition — collapsing is
1107
+ instant, not a slide.
1108
+
1109
+ ---
1110
+
1111
+ ## `lyra-word-cloud`
1112
+
1113
+ Dependency-free SVG word/tag cloud. First-party invention (no Web Awesome equivalent). Lays words
1114
+ out via an outward Archimedean-spiral search — heaviest word placed first, each word spiraling from
1115
+ the center until it clears every word already placed. Unlike sibling `lyra-sparkline`/`lyra-heatmap`
1116
+ (one `role="img"` glyph standing in for an aggregate value), each word here is its own focusable
1117
+ `role="button"`, since the individual words are the meaningful interactive content.
1118
+
1119
+ **Properties:**
1120
+ - `words: WordCloudWord[] = []` (attribute: false) — `{ text: string, weight: number, color?:
1121
+ string, group?: string }`; `weight` drives font size, `color` overrides the palette for that word,
1122
+ `group` shares one palette color across every word with the same `group` value
1123
+ - `minFontSize: number = 12` (attribute `min-font-size`) — px, applied to the lowest-weight word
1124
+ - `maxFontSize: number = 48` (attribute `max-font-size`) — px, applied to the highest-weight word
1125
+ - `scale: 'linear'|'sqrt' = 'linear'` — `sqrt` compresses the weight→font-size mapping so one heavy
1126
+ word doesn't dwarf the rest, matching `lyra-heatmap`'s `scale` property
1127
+ - `orientations: 'horizontal'|'mixed' = 'horizontal'` — `mixed` lets ~25% of words render rotated
1128
+ 90° for denser packing
1129
+ - `palette?: string[]` (attribute: false) — custom categorical colors, cycled by word index (or by
1130
+ `group`); defaults to the `--lyra-word-cloud-color-1..8` tokens
1131
+
1132
+ **Events:** `lyra-word-click` (`detail: { text, weight, group }`, fires on click, or Enter/Space on
1133
+ a focused word)
1134
+
1135
+ **Slots:** none.
1136
+
1137
+ **CSS parts:** `base`, `svg`, `word` (each `<text>`), `empty` (the no-data placeholder)
1138
+
1139
+ **Themeable custom properties:** `--lyra-word-cloud-color-1` through `-8` (the default categorical
1140
+ palette, a data-driven literal exception like `lyra-heatmap`'s scale-ramp endpoints — exposed as
1141
+ retheme-able custom properties instead of hardcoded), plus shared tokens (`--lyra-font`,
1142
+ `--lyra-focus-ring-*`, `--lyra-transition-fast`, `--lyra-color-text-quiet`).
1143
+
1144
+ **Optional peer deps:** none.
1145
+
1146
+ ```html
1147
+ <lyra-word-cloud id="cloud" style="height: 20rem"></lyra-word-cloud>
1148
+ <script type="module">
1149
+ document.getElementById('cloud').words = [
1150
+ { text: 'JavaScript', weight: 90 },
1151
+ { text: 'TypeScript', weight: 75 },
1152
+ { text: 'Lit', weight: 60, group: 'framework' },
1153
+ ];
1154
+ document.getElementById('cloud').addEventListener('lyra-word-click', (e) => console.log(e.detail));
1155
+ </script>
1156
+ ```
1157
+
1158
+ **Known gotchas:**
1159
+ - capped at 150 words (`MAX_WORDS` in `word-cloud-layout.ts`, mirroring `lyra-sparkline`'s
1160
+ `MAX_BARS` DOM-node-count guard) — anything beyond that is silently dropped from layout (not
1161
+ rendered, not an error); a pathological input (e.g. one huge word repeated many times) can also
1162
+ exhaust the spiral search's radius bound and get dropped the same way.
1163
+ - every word is its own tab stop (`tabindex="0"` `role="button"`), not a single roving-tabindex
1164
+ container like `lyra-heatmap` — fine for a modest word count, but tabbing through a cloud near the
1165
+ 150-word cap is a lot of stops; there's no non-visual "get the whole word+weight list at once"
1166
+ alternative yet.
1167
+ - text width is measured via a detached `<canvas>` 2D context (`ctx.measureText`), not the actual
1168
+ rendered SVG glyph metrics — close enough for collision-avoidance spacing, but not pixel-exact.
1169
+ - rotation (`orientations="mixed"`) is genuinely random per layout (`Math.random()`, not seeded), so
1170
+ which words render rotated changes on every re-layout (any `words`/`minFontSize`/`maxFontSize`/
1171
+ `scale`/`orientations` change) — don't rely on rotation being stable across renders.
1172
+
1173
+ ---
1174
+
805
1175
  # Temporal & graph
806
1176
 
807
1177
  ## `lyra-time-range`
@@ -816,17 +1186,24 @@ onto `[min, max]`).
816
1186
  - `end: number = 100`
817
1187
  - `step: number = 1`
818
1188
  - `disabled: boolean = false` (reflected)
1189
+ - `presets: TimeRangePreset[] = []` (attribute: false) — `TimeRangePreset { label: string; start:
1190
+ number; end: number }`; optional discrete presets (e.g. "Last 7 days") rendered as a
1191
+ `[part="presets"]` button row above the track — purely additive, the continuous brush is
1192
+ unaffected and both interaction modes coexist; picking one sets both handles and emits the same
1193
+ `lyra-input`/`lyra-change` pair a committed drag or keyboard step would
819
1194
 
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 }`)
1195
+ **Events:** `lyra-input` (fired continuously while dragging or on each arrow/Home/End/PageUp/
1196
+ PageDown key press, `detail: { start, end }`), `lyra-change` (fired on pointer release /
1197
+ key-up-commit, or when a preset button is clicked, `detail: { start, end }`)
823
1198
 
824
1199
  **Slots:** none.
825
1200
 
826
- **CSS parts:** `base`, `track`, `range`, `handle-start`, `handle-end`
1201
+ **CSS parts:** `base`, `track`, `range`, `handle-start`, `handle-end`, `presets`, `preset-button`
827
1202
 
828
1203
  **Themeable custom properties:** shared tokens only — `--lyra-color-border`, `--lyra-color-brand`,
829
- `--lyra-color-surface`, `--lyra-shadow`. No component-local tokens.
1204
+ `--lyra-color-surface`, `--lyra-shadow` (track/handles), plus (for `presets`) `--lyra-color-text`,
1205
+ `--lyra-color-on-brand` (the active preset button's text), `--lyra-radius`, `--lyra-space-xs/-s`,
1206
+ `--lyra-transition-fast`, `--lyra-focus-ring-*`. No component-local tokens.
830
1207
 
831
1208
  **Optional peer deps:** none.
832
1209
 
@@ -839,15 +1216,27 @@ end }`)
839
1216
  ```
840
1217
 
841
1218
  **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.
1219
+ - Keyboard support now matches the full WAI-ARIA APG slider pattern: ArrowUp/Right and ArrowDown/Left
1220
+ move by `step` (RTL-aware under `direction: rtl` the forward/backward keys swap so they still
1221
+ track the visually-adjacent direction), PageUp/PageDown move by `step * 10`, and Home/End jump to
1222
+ that handle's actual *reachable* bound clamped by the sibling handle's current value, not the
1223
+ component's full `[min, max]` domain, so Home/End on the `end` handle can't jump past `start` (and
1224
+ vice versa). Pointer-drag is RTL-aware the same way (mirrors the drag ratio under `direction:
1225
+ rtl`).
1226
+ - A disabled handle now gets `aria-disabled="true"` in addition to losing `tabindex`a
1227
+ screen-reader user exploring by virtual cursor no longer hears it announced as a live, adjustable
1228
+ slider.
1229
+ - `aria-valuemin`/`aria-valuemax` on each handle report that handle's reachable sub-range (bounded by
1230
+ its sibling), not the full domain — matching what Home/End actually jump to.
1231
+ - No `aria-valuetext`: only raw numeric `aria-valuenow` is exposed (omitted entirely, rather than a
1232
+ literal `"NaN"`, if `start`/`end` is non-finite), no hook for a human-readable (e.g. formatted
1233
+ date/time) equivalent of the mapped domain.
1234
+ - Handles a `min > max` domain, a non-positive/non-finite `step`, and disabled-mid-drag/
1235
+ disconnect-mid-drag correctly (tested) — safe to rely on those edge cases. Concurrent drags are
1236
+ tracked per `pointerId` (not a single scalar), so a two-finger touch — one finger per handle —
1237
+ moves both independently instead of the second pointer hijacking which handle the first pointer's
1238
+ moves apply to; `pointercancel`/`lostpointercapture` (not just `pointerup`) both end a drag, same
1239
+ fix as `lyra-split`.
851
1240
 
852
1241
  ---
853
1242
 
@@ -875,7 +1264,8 @@ tick and manual step)
875
1264
  **CSS parts:** `base`, `play-button`, `slider`
876
1265
 
877
1266
  **Themeable custom properties:** shared tokens only — `--lyra-space-s`, `--lyra-color-border`,
878
- `--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`.
1267
+ `--lyra-color-surface`, `--lyra-color-text`, `--lyra-color-brand`, `--lyra-icon-button-size` (the
1268
+ play button's box).
879
1269
 
880
1270
  **Optional peer deps:** none.
881
1271
 
@@ -888,9 +1278,15 @@ tick and manual step)
888
1278
  ```
889
1279
 
890
1280
  **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.
1281
+ - `index` is now re-clamped into `[0, length)` as soon as `length` shrinks (in `willUpdate()`, not
1282
+ waiting for the next `tick()`/`goTo()`/`next()`/`previous()` call) — setting `el.length = 2` while
1283
+ `el.index = 7` immediately pulls `index` back to `1`, and playback auto-pauses if `length` drops
1284
+ to `<= 1` while playing (the play button and slider would otherwise both become disabled with no
1285
+ way to stop it — both are `?disabled` whenever `length <= 1`, not just the button).
1286
+ - `intervalMs` is live-reactive mid-playback: ticking is a self-rescheduling `setTimeout` (not one
1287
+ long-lived `setInterval`), so `intervalMs` is re-read fresh before every tick — changing
1288
+ `interval-ms` while `playing` takes effect on the very next step instead of only after a
1289
+ pause/play cycle.
894
1290
  - No `aria-valuetext`/visible "N of M" position label on the range input.
895
1291
  - Calling `play()`/`pause()` programmatically (not via the button) gives no `aria-live`
896
1292
  announcement of the Play/Pause state change.
@@ -899,31 +1295,65 @@ tick and manual step)
899
1295
 
900
1296
  ## `lyra-heatmap`
901
1297
 
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.
1298
+ A Canvas-rendered heatmap with a DPR-aware, resize-aware redraw loop, in one of two `mode`s:
1299
+ `"matrix"` (defaulta `rowLabels` × `colLabels` grid of `values`) or `"calendar"` (a
1300
+ GitHub-style Sunday–Saturday × week grid built from `days`, colored by quartile bucket rather than
1301
+ the matrix mode's continuous ramp). Every cell is independently addressable despite being
1302
+ canvas-drawn (no per-cell DOM node): a `pointermove` hit-test over the canvas shows `[part="tooltip"]`
1303
+ with that cell's label + value; the canvas is `tabindex="0"` with arrow-key roving focus (a stroked
1304
+ ring redrawn over the focused cell on every draw, plus `[part="live-region"]` announcing it); and a
1305
+ click, or Enter/Space on the focused cell, fires `lyra-cell-click`.
906
1306
 
907
1307
  **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`)
1308
+ - `rowLabels: string[] = []` (attribute: false — matrix mode only)
1309
+ - `colLabels: string[] = []` (attribute: false — matrix mode only)
1310
+ - `values: number[][] = []` (attribute: false — matrix mode only) — `-1` or any non-finite value is
1311
+ the "no data" sentinel; ragged/sparse rows are safe (`?? -1`)
1312
+ - `cellSize: number = 22` (attribute `cell-size` — matrix mode only, ignored when `fitToWidth` is
1313
+ set)
1314
+ - `fitToWidth: boolean = false` (attribute `fit-to-width` — matrix mode only; derives `cellSize` from
1315
+ the host's measured `clientWidth` on every draw/resize instead of the fixed `cell-size`, so the
1316
+ grid actually fills the available width — see gotchas for the default, non-`fit-to-width` behavior)
913
1317
  - `valueLabel: string = 'value'` (attribute `value-label`)
914
- - `scale: 'linear' | 'sqrt' = 'linear'`
915
-
916
- **Events:** none dispatched.
1318
+ - `scale: 'linear' | 'sqrt' = 'linear'` (matrix mode only — calendar mode always buckets by quartile)
1319
+ - `mode: 'matrix' | 'calendar' = 'matrix'`
1320
+ - `days: CalendarDay[] = []` (attribute: false — calendar mode only) — `CalendarDay { date:
1321
+ string /* ISO yyyy-mm-dd */; value: number }`; need not be sorted or contiguous, and an entry whose
1322
+ `date` doesn't parse is dropped rather than poisoning the whole grid
1323
+ - `bucketCount: number = 5` (attribute `bucket-count` — calendar mode only; non-finite values fall
1324
+ back to 5 with a one-time console warning, and the count is floored to an integer >= 2)
1325
+ - `annotations: HeatmapAnnotation[] = []` (attribute: false) — `HeatmapAnnotation { row?: number;
1326
+ col?: number; date?: string; label?: string }`: matrix mode matches by `row`/`col`, calendar mode
1327
+ by `date` (whichever pair matches the active `mode`; the other fields are ignored). Draws a
1328
+ stroked ring over the matching cell; an annotation with a `label` also gets its own
1329
+ `[part="legend-annotation"]` entry in the legend.
1330
+
1331
+ **Events:** `lyra-cell-click` (fired on click, or Enter/Space on the keyboard-focused cell —
1332
+ `detail: { row, col, value }` in matrix mode, `detail: { date, value }` in calendar mode)
917
1333
 
918
1334
  **Slots:** none.
919
1335
 
920
- **CSS parts:** `base`, `canvas`, `legend`
1336
+ **CSS parts:** `base`, `canvas`, `tooltip` (hover tooltip, positioned over the hovered cell),
1337
+ `live-region` (visually-hidden `role="status" aria-live="polite"` element announcing the
1338
+ keyboard-focused cell), `legend`, `legend-lo`, `legend-hi`, `legend-annotation` (one per labeled
1339
+ `annotations` entry)
921
1340
 
922
1341
  **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`.
1342
+ `--lyra-heatmap-scale-hi` (default `#0969da`) — the sequential color-ramp endpoints (matrix mode) or
1343
+ quartile-bucket ramp endpoints (calendar mode), resolved via `getComputedStyle` each draw (any valid
1344
+ CSS color syntax — hex/rgb/hsl/oklch/named — works, resolved through a scratch canvas).
1345
+ `--lyra-heatmap-no-data-fill` (default `rgba(128,128,128,0.25)` — the no-data cell fill, same
1346
+ resolve-via-`getComputedStyle` pattern), `--lyra-heatmap-label-font` (default `10px sans-serif` — the
1347
+ canvas-drawn axis/month/weekday label font), `--lyra-heatmap-focus-ring-color` (default
1348
+ `var(--lyra-focus-ring-color)` — the canvas-drawn ring stroked around the keyboard-focused cell;
1349
+ also reused by `[part="canvas"]`'s own `:focus-visible` outline so the two stay visually in sync),
1350
+ `--lyra-heatmap-annotation-color` (default `var(--lyra-color-danger)` — the canvas-drawn ring
1351
+ stroked around an annotated cell, deliberately not one of the sequential ramp colors so it stays
1352
+ visible regardless of what it's drawn over). `--lyra-heatmap-tooltip-bg` (default
1353
+ `var(--lyra-color-surface)`) and `--lyra-heatmap-tooltip-text` (default `var(--lyra-color-text)`) —
1354
+ unlike the canvas-drawn tokens above, `[part="tooltip"]` is a real DOM element and consumes these
1355
+ directly, no `getComputedStyle` bridging needed. Also consumes `--lyra-color-text-quiet` (axis label
1356
+ color) and `--lyra-space-xs`.
927
1357
 
928
1358
  **Optional peer deps:** none.
929
1359
 
@@ -937,20 +1367,36 @@ resolved through a scratch canvas). Also consumes `--lyra-color-text-quiet` (axi
937
1367
  </script>
938
1368
  ```
939
1369
 
1370
+ ```html
1371
+ <!-- Calendar mode: a GitHub-contributions-style day grid -->
1372
+ <lyra-heatmap mode="calendar" value-label="commits"></lyra-heatmap>
1373
+ <script>
1374
+ document.querySelector('lyra-heatmap').days = [
1375
+ { date: '2026-01-01', value: 3 },
1376
+ { date: '2026-01-02', value: 0 },
1377
+ // ...
1378
+ ];
1379
+ </script>
1380
+ ```
1381
+
940
1382
  **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`.
1383
+ - the `ResizeObserver` only actually resizes the drawn grid in matrix mode **when
1384
+ `fit-to-width` is set**. Without it (the default), `draw()` sizes the canvas as
1385
+ `PAD_LEFT + cols * cellSize`, never from the host's measured width, so a container-resize redraw is
1386
+ a geometric no-op; the stylesheet's `canvas { inline-size: 100% }` is also dead code in that case,
1387
+ since `draw()` unconditionally sets an inline `canvas.style.width/height` that wins over it.
1388
+ Calendar mode ignores `fit-to-width` entirely its canvas is always sized from the computed
1389
+ `weekCount`.
1390
+ - the host itself is unconditionally `role="img"` with a dimensions+range summary `aria-label`
1391
+ (calendar mode: a day-count + range summary instead), same as before but `[part="canvas"]`
1392
+ inside it is now a real focusable, keyboard-operable, per-cell-interactive control (roving
1393
+ arrow-key focus, `[part="live-region"]` announcements, `lyra-cell-click`). `role="img"` is
1394
+ documented (ARIA) to flatten its subtree to a single image for some assistive tech, which is in
1395
+ tension with a focusable descendant inside it — plausible from the code, unverified against real
1396
+ AT.
1397
+ - `NaN`/non-finite cell values in matrix mode are correctly treated as no-data now (alongside `-1`),
1398
+ and repeated DPR crossings (moving the window across displays with different pixel ratios) no
1399
+ longer leak a `MediaQueryList` listener per crossing — both previously-known issues are fixed.
954
1400
 
955
1401
  ---
956
1402
 
@@ -966,8 +1412,14 @@ A force-directed node-link diagram with pan/zoom/drag, built on `d3-force`.
966
1412
  dropped)
967
1413
  - `width: number = 800`
968
1414
  - `height: number = 600`
969
- - `chargeStrength: number = -300` (attribute `charge-strength`)
970
- - `linkDistance: number = 100` (attribute `link-distance`)
1415
+ - `chargeStrength: number = -300` (attribute `charge-strength` — live-reactive, see gotchas)
1416
+ - `linkDistance: number = 100` (attribute `link-distance` — live-reactive, see gotchas)
1417
+ - `minZoom: number = 0.1` (attribute `min-zoom`)
1418
+ - `maxZoom: number = 8` (attribute `max-zoom`)
1419
+ - `seed?: number` (attribute: false) — when set, seeds each node's initial x/y deterministically
1420
+ (keyed by node **id**, not array index/order) instead of `forceSimulation()`'s own random start,
1421
+ and settles the simulation synchronously instead of animating the settle (same effect
1422
+ `prefers-reduced-motion` has, see gotchas)
971
1423
 
972
1424
  **Events:** `lyra-node-click` (`detail: { id }`), `lyra-link-click` (`detail: { source, target }`)
973
1425
 
@@ -994,17 +1446,36 @@ install with `pnpm add d3-force d3-drag d3-zoom d3-selection`).
994
1446
 
995
1447
  **Known gotchas:**
996
1448
  - 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.
1449
+ continuously while dragging via `alphaTarget(0.3)`) writes node/link positions straight onto the
1450
+ already-rendered DOM via `setAttribute()` rather than reassigning `simNodes`/`simLinks` (that
1451
+ reassignment — and the Lit re-render/`applyInteractions()` re-scan it used to force on every tick
1452
+ now only happens once per structural `nodes`/`links` change). Still a noticeable cost building up
1453
+ the initial layout or while a node is actively being dragged, just no longer once per tick on an
1454
+ otherwise-settled graph.
1455
+ - `chargeStrength`/`linkDistance` **are** live-reactive post-mount now (retuned on the existing
1456
+ force objects and the simulation nudged via `alpha(0.3).restart()`) no need to also touch
1457
+ `nodes`/`links` to see the effect.
1458
+ - zoom is bounded via `minZoom`/`maxZoom` (`d3-zoom`'s `.scaleExtent(...)`, live-reactive); pan/
1459
+ zoom/drag are still pointer-only with no keyboard equivalent. Links (`<line part="link">`) are now
1460
+ keyboard-operable too (`tabindex="0"`, `role="button"`, `aria-label`, Enter/Space), matching nodes.
1461
+ - while the `d3-force`/`d3-drag`/`d3-zoom`/`d3-selection` peers are resolving, the host shows a
1462
+ `<lyra-skeleton>` sized to `width`/`height` with `aria-busy="true"` — but if they fail to load
1463
+ (not installed), it still settles into a permanently empty `<svg>` (0 nodes/0 links) plus a
1464
+ one-time console warning, same as before; the skeleton only covers the loading window itself.
1465
+ - `GraphNode.color` is sanitized (rejects `;`/`{`/`}`) before being written into the
1466
+ `--lyra-node-fill` inline custom property, so an untrusted color string can't break out of that
1467
+ CSS declaration.
1468
+ - a structural `nodes`/`links` change now carries over each already-settled node's position (and any
1469
+ in-progress drag) by id when rebuilding the simulation, instead of discarding every node's (x, y)
1470
+ and re-running the whole ~300-tick random-start settle from scratch — only genuinely new ids get a
1471
+ fresh start. Handy for a streaming/incrementally-updated graph, whose existing layout no longer
1472
+ jumps every time a node/link is appended.
1473
+ - under `prefers-reduced-motion: reduce`, or whenever `seed` is set, the simulation converges
1474
+ synchronously (ticked in a loop down to `alphaMin` before first paint) instead of animating over
1475
+ ~300 rendered frames; user-initiated motion (dragging a node) is unaffected either way.
1476
+ - the `<svg part="svg">` now carries `role="group"` and an `aria-label` summarizing the node/link
1477
+ counts (e.g. "Node-link diagram with 5 nodes and 4 links"), and node `<text part="label">`s are
1478
+ `aria-hidden="true"` (their content is already covered by each node's own `aria-label`).
1008
1479
 
1009
1480
  ---
1010
1481
 
@@ -1016,14 +1487,30 @@ recursively renders itself and its own nested children.
1016
1487
 
1017
1488
  ### `lyra-tree`
1018
1489
 
1490
+ Implements the full WAI-ARIA treeitem keyboard pattern: a single roving `tabindex` (tracked as
1491
+ `activeId`, pushed down to every `<lyra-tree-node>` including nested ones) and
1492
+ ArrowUp/Down/Right/Left/Home/End/Enter/Space handled by one delegated `keydown` listener (native
1493
+ `KeyboardEvent`s are `composed: true` and bubble across shadow-DOM boundaries, so a press inside a
1494
+ deeply-nested node's own shadow root still reaches it).
1495
+
1019
1496
  **Properties:**
1020
1497
  - `data: TreeItem[] = []` (attribute: false) — `TreeItem { id: string; label: string; children?:
1021
1498
  TreeItem[]; badge?: string | number }`
1499
+ - `label: string = ''` — accessible name for the tree; `role="tree"` lives on an internal
1500
+ `[part="base"]` element, not the host, so this is the only way to label it (`aria-label`/
1501
+ `aria-labelledby` set directly on `<lyra-tree>` wouldn't reach it)
1502
+
1503
+ **Keyboard:** ArrowDown/ArrowUp move the roving focus to the next/previous *visible* node.
1504
+ ArrowRight expands a collapsed node (focus stays put; a second ArrowRight then steps into the first
1505
+ child) or moves into an already-expanded node's first child. ArrowLeft collapses an expanded node, or
1506
+ moves focus to its parent. Home/End jump to the first/last visible node. Enter/Space activate
1507
+ `select()` on the focused node.
1022
1508
 
1023
1509
  **Methods:** `expandAll()`, `collapseAll()` (both recursive, properly sequenced around Lit's render
1024
1510
  cycle).
1025
1511
 
1026
- **Events:** none dispatched directly (see `lyra-tree-node` below — they bubble up).
1512
+ **Events:** none dispatched directly (see `lyra-tree-node` below — they bubble up and are also
1513
+ observed internally to keep the roving `activeId` in sync with clicks).
1027
1514
 
1028
1515
  **Slots:** default (holds the `<lyra-tree-node>` elements it manages).
1029
1516
 
@@ -1036,15 +1523,24 @@ cycle).
1036
1523
 
1037
1524
  ### `lyra-tree-node`
1038
1525
 
1039
- Normally set internally by `lyra-tree`, but a public element.
1526
+ Normally set internally by `lyra-tree`, but a public element. `role="treeitem"` (plus
1527
+ `aria-expanded`/`aria-level`/`aria-setsize`/`aria-posinset` and the roving `tabindex`, driven by
1528
+ `<lyra-tree>`) live on the *host* element itself, not an internal row `<div>` — so this node's own
1529
+ nested children (rendered in its own shadow root as further `role="group"` content) are genuine DOM
1530
+ descendants of the treeitem, matching the WAI-ARIA treeitem pattern's containment expectation.
1040
1531
 
1041
1532
  **Properties:**
1042
1533
  - `item: TreeItem` (required, attribute: false)
1043
1534
  - `depth: number = 0`
1044
1535
  - `expanded: boolean = false` (reflected)
1045
1536
 
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.
1537
+ **Methods:** `expand()`, `collapse()` (each a no-op if already in that state, or a leaf), `select()`
1538
+ (fires `lyra-node-select`).
1539
+
1540
+ **Events:** `lyra-node-toggle` (`detail: { id, expanded }`, fired by `expand()`/`collapse()` — via
1541
+ the toggle button or ArrowRight/ArrowLeft), `lyra-node-select` (`detail: { id }`, fired by `select()`
1542
+ — via clicking the label or Enter/Space) — dispatched from `lyra-tree-node`, bubble/compose up
1543
+ through `lyra-tree`'s light DOM.
1048
1544
 
1049
1545
  **Slots:** none.
1050
1546
 
@@ -1065,20 +1561,17 @@ indentation), plus the shared tokens listed above.
1065
1561
  ```
1066
1562
 
1067
1563
  **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`).
1564
+ - all four previously-known ARIA gaps in this pair are fixed: the treeitem row is now genuinely
1565
+ keyboard-operable with a roving tabindex and full arrow-key navigation (not just the expand/collapse
1566
+ button); the expanded-children `role="group"` is now a real DOM descendant of its `role="treeitem"`
1567
+ host rather than a shadow-DOM sibling; by-id reconciliation (preserving `expanded` state across
1568
+ data reassignment) now applies at every depth via a keyed `repeat()`, not just depth 0; and
1569
+ `role="tree"` now has an accessible name via the new `label` property.
1570
+ - `lyra-tree`'s `getUpdateComplete()` cascades into every currently-known descendant
1571
+ `<lyra-tree-node>`'s own `updateComplete` (see `update-cascade.ts`) so that code awaiting the
1572
+ tree's `updateComplete` (e.g. after `focusNode()`) doesn't run before an arbitrarily-nested node has
1573
+ actually finished rendering its pushed-down `activeId`/`tabIndex` one more pending update per
1574
+ depth level, otherwise.
1082
1575
 
1083
1576
  ---
1084
1577
 
@@ -1091,9 +1584,9 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1091
1584
  `wa-chart` `config` property).
1092
1585
 
1093
1586
  **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)
1587
+ - `type: LyraChartType = 'line'` — `LyraChartType = 'line' | 'bar' | 'scatter' | 'pie' | 'doughnut' |
1588
+ 'radar' | 'polarArea' | 'bubble'` — every type string the typed subclasses below lock `type` to is
1589
+ already a first-class member, so `<lyra-chart type="pie">` needs no subclass or cast to work
1097
1590
  - `labels: string[] = []` (attribute: false)
1098
1591
  - `datasets: Series[] = []` (attribute: false) — `Series { label: string; data?: (number|null)[];
1099
1592
  points?: {x,y,label?}[]; color?: string|string[]; fill?: boolean; width?: number; dash?: boolean;
@@ -1107,13 +1600,23 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1107
1600
  - `yLabel: string = ''` (attribute `y-label`)
1108
1601
  - `y2Label: string = ''` (attribute `y2-label`)
1109
1602
  - `beginAtZero: boolean = true` (attribute `begin-at-zero`)
1603
+ - `horizontal: boolean = false` — sets `options.indexAxis = 'y'`, Chart.js's own mechanism for
1604
+ horizontal bars (also flips `line`/`area` types onto a horizontal category axis)
1605
+ - `stacked: boolean = false` — stacks the `x`/`y`(/`y2`) scale entries `buildScales()` returns; only
1606
+ meaningful for `bar`/`line` types (scatter/bubble's linear `x` scale and the radial `r` scale used
1607
+ by radar/polar-area are out of scope)
1110
1608
  - `config?: Partial<ChartConfiguration>` (attribute: false) — deep-merged over the generated
1111
1609
  config; any nested key wins without clobbering sibling generated keys
1112
1610
 
1113
- **Methods:** `resetZoom()`
1611
+ **Methods:** `resetZoom()` (reset any active zoom/pan to the original view), `refreshTheme()`
1612
+ (forces a redraw so the `--lyra-chart-*` tokens below are re-read from the current computed style —
1613
+ the escape hatch for a consumer's own theme-toggle handler to call when it flips something, e.g. a
1614
+ `data-theme` attribute, that doesn't otherwise change any `lyra-chart` property)
1114
1615
 
1115
1616
  **Events:** `lyra-zoom` (`detail: { zoomed: boolean }`, fired on zoom-complete and on
1116
- `resetZoom()`)
1617
+ `resetZoom()`), `lyra-point-click` (fired when a click lands on, or nearest to — intersect-only —
1618
+ a data point/segment, for any chart type, not just bar; `detail: { datasetIndex: number, index:
1619
+ number, label: string | undefined, value: unknown }`)
1117
1620
 
1118
1621
  **Slots:** none.
1119
1622
 
@@ -1121,8 +1624,13 @@ Chart.js wrapper every other `lyra-*-chart` tag subclasses; supports both a simp
1121
1624
 
1122
1625
  **Themeable custom properties:** `--lyra-chart-height` (set programmatically on the host from the
1123
1626
  `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`.
1627
+ properties only cascade downward); `--lyra-chart-grid-color` (default `var(--lyra-color-border)`),
1628
+ `--lyra-chart-tick-color` (default `var(--lyra-color-text-quiet)`), `--lyra-chart-legend-color`
1629
+ (default `var(--lyra-color-text)`), `--lyra-chart-tooltip-bg` (default `var(--lyra-color-surface)`),
1630
+ `--lyra-chart-tooltip-text` (default `var(--lyra-color-text)`) — each resolved fresh via
1631
+ `getComputedStyle` on every draw (Chart.js renders to canvas, not the DOM, so it can't consume CSS
1632
+ `var()` directly), driving the grid lines, tick labels, legend text, and tooltip background/text
1633
+ respectively; plus shared `--lyra-space-xs`.
1126
1634
 
1127
1635
  **Optional peer deps:** `chart.js`, `chartjs-plugin-zoom` (lazy-loaded once per page via
1128
1636
  `chart-loader.ts`; registers only the tree-shaken controller/element/scale subset used).
@@ -1137,28 +1645,105 @@ properties only cascade downward); shared tokens `--lyra-space-xs`, `--lyra-colo
1137
1645
  ```
1138
1646
 
1139
1647
  **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`.
1648
+ - `type` at the `lyra-chart` (core) level is completely unchecked — `buildConfig()` passes it
1649
+ straight to Chart.js as-is, so `<lyra-chart type="pie">` already works today with no subclass
1650
+ needed. Each typed subclass below locks its *own* `type` via a real prototype accessor (see the
1651
+ typed-subclasses section) a genuine runtime lock, not just a compile-time default.
1652
+ - no global theme-broadcast event exists to auto-retheme an already-drawn chart on a theme switch
1653
+ a consumer flipping something upstream (e.g. a `data-theme` attribute) that doesn't otherwise
1654
+ change any `lyra-chart` property must call `refreshTheme()` itself to make Chart.js re-read the
1655
+ `--lyra-chart-*` tokens above.
1656
+ - generated `scales` are type-appropriate: no scale at all for `lyra-pie-chart`/
1657
+ `lyra-doughnut-chart`, and a single radial `r` scale (respecting `beginAtZero`) for
1658
+ `lyra-radar-chart`/`lyra-polar-area-chart`, instead of always generating the cartesian `x`/`y`/`y2`
1659
+ block. `xLabel`/`yLabel`/`y2Label` are still silently inert for all four of those subclasses (a
1660
+ radial scale and "no scale" both have nowhere to put an axis title) reach a titled radial scale
1661
+ only via raw `config`.
1153
1662
  - No `chartjs-plugin-annotation` is registered by default — reachable only by importing it
1154
1663
  separately and using the raw `config` passthrough (Chart.js's registry is a global singleton).
1664
+ - while the `chart.js` peer is resolving, the host shows a `<lyra-skeleton variant="rect">` with
1665
+ `aria-busy="true"` (same lazy-load pattern as `lyra-graph`/`lyra-map`/`lyra-flag`); Chart.js's own
1666
+ ~1s draw-in animation is skipped under `prefers-reduced-motion: reduce`. The raw `config`
1667
+ passthrough is deep-merged with `__proto__`/`constructor`/`prototype` keys skipped unconditionally,
1668
+ so a JSON-sourced `config` (e.g. parsed from an API response) can't reach up and pollute
1669
+ `Object.prototype` through the merge.
1670
+
1671
+ ---
1672
+
1673
+ ## `lyra-lite-chart`
1674
+
1675
+ A dependency-free bar/line chart — plain SVG/DOM rendering, zero peer dependencies (unlike
1676
+ `lyra-chart`, which wraps `chart.js`). For a project whose architecture forbids a charting
1677
+ dependency outright: covers grouped/stacked bars, multi-series lines, per-point click, and hover
1678
+ tooltips (native SVG `<title>`, no positioning JS) — not a full `lyra-chart` replacement (no
1679
+ zoom/pan, no pie/doughnut/radar/scatter/bubble types, no horizontal/dual-y-axis, no raw-config
1680
+ passthrough). Not a subclass of `LyraChart`.
1681
+
1682
+ **Properties:**
1683
+ - `type: LyraLiteChartType = 'bar'` — `'bar' | 'line'`
1684
+ - `labels: string[] = []` (attribute: false)
1685
+ - `datasets: LiteSeries[] = []` (attribute: false) — `LiteSeries { label: string; data:
1686
+ (number|null)[]; color?: string }`
1687
+ - `legend: boolean = false`
1688
+ - `height: string = '280px'`
1689
+ - `xLabel: string = ''` (attribute `x-label`)
1690
+ - `yLabel: string = ''` (attribute `y-label`)
1691
+ - `beginAtZero: boolean = true` (attribute `begin-at-zero`)
1692
+ - `stacked: boolean = false` — sums each category's bars into one segmented bar instead of grouping
1693
+ them side by side; ignored for `type="line"`
1694
+
1695
+ **Events:** `lyra-point-click` — fired when a bar/point is activated (click, or Enter/Space while
1696
+ focused). `detail: { datasetIndex: number, index: number, label: string | undefined, value: number
1697
+ | null }` — same shape as `lyra-chart`'s `lyra-point-click`.
1698
+
1699
+ **Slots:** none.
1700
+
1701
+ **CSS parts:** `base`, `grid-line`, `axis-label`, `axis-title`, `bar`, `line`, `point`, `legend`,
1702
+ `legend-item`, `legend-swatch`.
1703
+
1704
+ **Themeable custom properties:** `--lyra-chart-height` (same host-level property as `lyra-chart`);
1705
+ `--lyra-chart-grid-color`, `--lyra-chart-tick-color`, `--lyra-chart-legend-color` — same token
1706
+ *names* as `lyra-chart`, so a host already theming `lyra-chart` themes this for free. Unlike
1707
+ `lyra-chart` (canvas-rendered, needs `getComputedStyle`-based re-theming on every draw), this is
1708
+ plain SVG/DOM and reads these via native CSS `var()` — no JS-side resolution step, and no
1709
+ `refreshTheme()` method needed (there's nothing to go stale).
1710
+
1711
+ **Optional peer deps:** none. This is the point of the component.
1712
+
1713
+ ```html
1714
+ <lyra-lite-chart type="bar" stacked legend x-label="Week" y-label="Commits"></lyra-lite-chart>
1715
+ <script>
1716
+ const c = document.querySelector('lyra-lite-chart');
1717
+ c.labels = ['W1', 'W2', 'W3', 'W4'];
1718
+ c.datasets = [
1719
+ { label: 'Docs', data: [4, 6, 3, 8] },
1720
+ { label: 'Bugs', data: [3, 2, 5, 4] },
1721
+ ];
1722
+ </script>
1723
+ ```
1724
+
1725
+ **Known gotchas:**
1726
+ - No `horizontal` mode (unlike `lyra-chart`) — deliberately cut from scope, not a stub: bars are
1727
+ always vertical.
1728
+ - No dual y-axis (`Series.axis: 'y2'`) — every series shares one y-axis/domain.
1729
+ - Series colors default to a fixed built-in 8-color categorical palette (round-robin by dataset
1730
+ index) when `color` is unset — not configurable beyond passing `color` per series.
1731
+ - Bar/point elements are real focusable DOM nodes (`tabindex="0" role="button"`, each with its own
1732
+ `aria-label`), so the `<svg>` itself uses `role="group"`, not `role="img"` — an "img" role would
1733
+ conflict with genuinely interactive descendants (axe's `nested-interactive` rule).
1734
+ - Tick values use a standard "nice numbers" (1/2/5 × 10ⁿ) rounding step, not exact data min/max —
1735
+ intentional (readable axis labels), matches how most charting libraries pick tick steps.
1155
1736
 
1156
1737
  ---
1157
1738
 
1158
1739
  ## 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
1740
 
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`).
1741
+ Each is `LyraChart` with `type` locked to a fixed value via a real `get`/`set` accessor installed on
1742
+ its own prototype (`declare type: '…'` narrows the TS type at compile time; the runtime lock is the
1743
+ `Object.defineProperty` pair alongside it — the same pattern `lyra-histogram`'s own locked `type`
1744
+ below uses), not merely a class-field default a later assignment could still override. All other
1745
+ properties/events/parts/tokens are identical to `lyra-chart` above. Same optional peer deps
1746
+ (`chart.js`, `chartjs-plugin-zoom`).
1162
1747
 
1163
1748
  ```html
1164
1749
  <lyra-bar-chart legend></lyra-bar-chart>
@@ -1173,12 +1758,12 @@ are identical to `lyra-chart` above. Same optional peer deps (`chart.js`, `chart
1173
1758
  ```
1174
1759
 
1175
1760
  **Known gotchas (in addition to the core `lyra-chart` list above):**
1761
+ - `type` truly is locked per subclass: `<lyra-pie-chart type="bar">` or `el.type = 'bar'` at runtime
1762
+ is a genuine no-op (the accessor's setter silently ignores the write), not a footgun like a plain
1763
+ overridden class-field default would be.
1176
1764
  - `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`).
1765
+ is typed as `{x, y, label?}[]` with no `r` field — cast the array through `as unknown as
1766
+ Series['points']` (or a local `BubblePoint` type) when constructing bubble data.
1182
1767
 
1183
1768
  ---
1184
1769
 
@@ -1191,8 +1776,11 @@ Bins `values` into `bins` equal-width buckets and renders as a bar chart (extend
1191
1776
  - `bins: number = 10`
1192
1777
  - `values: number[] = []` (attribute: false)
1193
1778
  - `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`.
1779
+ - `labels`/`datasets`/`type` are **derived, read-only** (installed as getter/setter pairs on the
1780
+ prototype; direct writes are silently ignored) — `labels`/`datasets` are computed from
1781
+ `values`/`bins`, and `type` always reads back `'bar'` regardless of any assignment. Unlike the
1782
+ typed `lyra-*-chart` subclasses above (whose "locked" `type` is enforced only by convention),
1783
+ `lyra-histogram`'s lock is a real getter/setter — `el.type = 'line'` is a genuine no-op here.
1196
1784
  - All other `LyraChart` properties (`legend`, `area`, `zoom`, `config`, `height`, etc.) are
1197
1785
  inherited and usable.
1198
1786
 
@@ -1206,10 +1794,8 @@ Bins `values` into `bins` equal-width buckets and renders as a bar chart (extend
1206
1794
  ```
1207
1795
 
1208
1796
  **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`.**
1797
+ - `bins <= 0` no longer crashes: `binValues()` now returns an empty bucket array for `binCount <= 0`
1798
+ (or empty `values`), so the histogram just renders with no bars instead of throwing.
1213
1799
 
1214
1800
  ---
1215
1801
 
@@ -1273,7 +1859,16 @@ raw `map` escape hatch for anything unexposed.
1273
1859
  (discrete swatch rows only, no continuous gradient bar)
1274
1860
  - `choropleth?: ChoroplethLayer` (attribute: false) — `ChoroplethLayer { sourceId: string; geojson:
1275
1861
  GeoJSON.FeatureCollection; field: string; stops: [number, string][] }` (interpolated
1276
- fill-color expression from `field`'s value against `stops`)
1862
+ fill-color expression from `field`'s value against `stops`; `stops` must contain at least one
1863
+ `[value, color]` pair — an empty array is ignored, leaving whatever fill layer already exists, if
1864
+ any, untouched, rather than being applied)
1865
+ - `markers: MapMarker[] = []` (attribute: false) — `MapMarker { id?: string; lngLat: [number,
1866
+ number]; color?: string; label?: string; html?: string }`; reconciled by `id` (falling back to a
1867
+ `lng,lat` key when `id` is omitted) so an unchanged marker isn't torn down and recreated on every
1868
+ `markers` reassignment — its `lngLat` **and** its popup content (`html`/`label`, in that
1869
+ precedence) are both updated in place, and the popup is removed if a later update sets neither.
1870
+ `html` is rendered via `Popup.setHTML()` — **raw markup, inline event handlers included** — only
1871
+ pass trusted content, sanitize anything derived from user input first.
1277
1872
 
1278
1873
  **Getters:** `map` → the raw `maplibregl.Map` instance.
1279
1874
 
@@ -1303,26 +1898,32 @@ exists and was hit)
1303
1898
  stops: [[0, '#cde2fb'], [100, '#0969da']],
1304
1899
  };
1305
1900
  m.legend = [{ color: '#cde2fb', label: 'Low' }, { color: '#0969da', label: 'High' }];
1901
+ m.markers = [{ lngLat: [2.29, 48.86], label: 'Eiffel Tower' }];
1306
1902
  m.addEventListener('lyra-map-click', (e) => console.log(e.detail.feature?.properties));
1307
1903
  </script>
1308
1904
  ```
1309
1905
 
1310
1906
  **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.
1907
+ - clearing or swapping the choropleth no longer leaks the old layer: setting `choropleth =
1908
+ undefined`, or changing `choropleth.sourceId` to a different value, now calls `removeLayer`/
1909
+ `removeSource` on whatever was previously applied before adding the new one (or nothing, if
1910
+ cleared).
1911
+ - `mapStyle` changes after construction now call `setStyle()` (in addition to `center`/`zoom`
1912
+ already calling `setCenter`/`setZoom`) the choropleth is automatically re-applied once the new
1913
+ style's own `'style.load'` fires, since a style change wipes every layer/source maplibre-gl knows
1914
+ about.
1915
+ - Point markers now have a declarative API (`markers`, above) with popup support — the `.map` escape
1916
+ hatch and manual `new maplibregl.Marker()` are no longer the only way to place pins.
1324
1917
  - No click-select visual highlight on choropleth features (no `setFeatureState` call) — clicking
1325
- only fires the event, no built-in visual feedback.
1918
+ only fires the event, no built-in visual feedback. Popups are still only reachable declaratively
1919
+ through `markers`' `html`/`label` — a choropleth-feature click still has no built-in popup, only the
1920
+ raw `lyra-map-click` event.
1921
+ - `LegendEntry.color` is validated against a strict CSS-color-syntax allowlist before being applied
1922
+ to the legend swatch's `background`, rejecting anything that isn't recognizable color syntax
1923
+ (notably `url(...)`, which `background` also accepts and would otherwise fetch as soon as the
1924
+ swatch renders).
1925
+ - while the `maplibre-gl` peer is resolving, the host shows a `<lyra-skeleton variant="rect">` with
1926
+ `aria-busy="true"` in place of the map container.
1326
1927
 
1327
1928
  ---
1328
1929
 
@@ -1334,21 +1935,29 @@ parsing (that's left entirely to the host).
1334
1935
  **Properties:**
1335
1936
  - `multiple: boolean = false` (reflected)
1336
1937
  - `disabled: boolean = false` (reflected)
1337
- - `accept: string = ''` — **see gotchas: decorative on the drop path**
1938
+ - `accept: string = ''` — a native-`accept`-style string (`.csv,.xlsx`, `text/csv`, `image/*`, or any
1939
+ comma-separated mix); now enforced on **both** the native picker dialog and the drag-drop path, see
1940
+ gotchas
1338
1941
  - `allowedMimeTypes: string[] = []` (attribute: false) — exact MIME-string allowlist
1339
1942
  - `forbiddenMimeTypes: string[] = []` (attribute: false) — exact MIME-string denylist, checked
1340
1943
  **before** (and takes precedence over) `allowedMimeTypes`
1944
+ - `maxFileSize: number = 0` (attribute `max-file-size` — bytes; `0` disables the check)
1341
1945
  - `label: string = 'Drop files here or click to browse'`
1342
1946
 
1343
1947
  **Methods:** `openPicker()` — programmatically opens the native file dialog.
1344
1948
 
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[]`)
1949
+ **Events:** `lyra-files` (`detail: { files: File[], rejected: RejectedFile[] }`, fired on both drop
1950
+ and manual file-picker selection) — `RejectedFile = { file: File; reason: 'type' | 'count' | 'size'
1951
+ }`: `'type'` from `accept`/`allowedMimeTypes`/`forbiddenMimeTypes`, `'count'` when a single-file
1952
+ input (`multiple` unset) receives more than one file (in which case *all* files are rejected, none
1953
+ accepted), `'size'` from `maxFileSize`.
1347
1954
 
1348
1955
  **Slots:** default slot — custom dropzone content, overrides the `label` attribute text when
1349
- provided.
1956
+ provided. The accessible name always comes from `label` regardless, so icon-only slot content still
1957
+ announces correctly.
1350
1958
 
1351
- **CSS parts:** `base`, `input`
1959
+ **CSS parts:** `base`, `input`, `status` (a visually-hidden `role="status" aria-live="polite"`
1960
+ element carrying the drag accept/reject announcement)
1352
1961
 
1353
1962
  **Themeable custom properties:** shared tokens only — `--lyra-space-xs`, `--lyra-space-l`,
1354
1963
  `--lyra-color-border`, `--lyra-radius`, `--lyra-color-surface`, `--lyra-color-text-quiet`,
@@ -1360,7 +1969,7 @@ provided.
1360
1969
  <lyra-file-input multiple accept=".csv,.xlsx" allowed-mime-types='["text/csv"]'></lyra-file-input>
1361
1970
  <script>
1362
1971
  document.querySelector('lyra-file-input').addEventListener('lyra-files', (e) => {
1363
- console.log('accepted:', e.detail.files, 'rejected:', e.detail.rejected);
1972
+ console.log('accepted:', e.detail.files, 'rejected:', e.detail.rejected); // rejected[i].reason
1364
1973
  });
1365
1974
  </script>
1366
1975
  ```
@@ -1369,25 +1978,24 @@ Note: `allowedMimeTypes`/`forbiddenMimeTypes` are complex properties (`attribute
1369
1978
  them via JS (`el.allowedMimeTypes = [...]`), not as a JSON string attribute; the snippet above is
1370
1979
  illustrative of intent only.
1371
1980
 
1981
+ `accept.ts` exports `matchesAccept(file, accept, assumeExtensionMatch?)` (internal — not
1982
+ re-exported from the package root) — parses the same three `accept` forms the browser's native
1983
+ picker accepts (extension, exact MIME, `type/*` wildcard) and reports whether a `File` matches. Used
1984
+ both for the drop path's real rejection and for the dragenter preview, where a `DataTransferItem` (no
1985
+ `.name`, only `.type`) can't evaluate an extension pattern yet — `assumeExtensionMatch: true` treats
1986
+ that as a possible match during preview so the drag-over UI doesn't flash a false "reject" state for
1987
+ an extension-only `accept` list.
1988
+
1372
1989
  **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
1990
  - 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
1991
  - Dragged folders aren't specially detected (no `webkitGetAsEntry()` check) — likely surfaces as a
1390
1992
  phantom zero-byte `File` with an empty MIME type rather than a clear rejection.
1993
+ - `maxFileSize`/`accept` extension patterns can't be evaluated during the dragenter preview (no real
1994
+ `File.size`/`.name` available yet from a `DataTransferItem`) — the live preview state (border/
1995
+ background color, `status` announcement) is therefore only a best-effort hint; the authoritative
1996
+ accept/reject decision (and `rejected[].reason`) is always the one made at actual drop time.
1997
+ - the click/keyboard-to-browse path is correctly operable (`role="button"`, `tabindex` 0/-1 by
1998
+ disabled, `aria-disabled`, Enter/Space handling).
1391
1999
 
1392
2000
  ---
1393
2001
 
@@ -1395,7 +2003,15 @@ illustrative of intent only.
1395
2003
 
1396
2004
  These apply across the whole library rather than to any one component:
1397
2005
 
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.
2006
+ - **Packaging.** `custom-elements.json` is now in `package.json`'s `files` allowlist and
2007
+ regenerated by a `prepack` script (`pnpm run manifest`) before publish, so a manifest-driven IDE/
2008
+ tooling integration (e.g. custom-elements-manifest-based autocomplete) does see an up-to-date copy
2009
+ in the published tarball.
2010
+ - **Lazy-loaded optional peers share one pattern.** `lyra-chart`/`lyra-histogram`/`lyra-box-plot`,
2011
+ `lyra-graph`, `lyra-map`, and `lyra-flag` each show a `<lyra-skeleton>` placeholder (with
2012
+ `aria-busy="true"` on the host) while their respective optional peer dependency resolves, then
2013
+ settle into their normal render — or, if the peer genuinely isn't installed, an empty/fallback
2014
+ render plus a one-time `console.warn` (deduped module-wide, not per-instance).
2015
+ - **RTL.** Components generally rely on CSS logical properties to flip layout for free; `lyra-split`
2016
+ and `lyra-time-range` additionally consult `internal/rtl.ts`'s `isRtl()` to mirror the physical
2017
+ drag-direction/arrow-key math that logical properties alone don't cover.