@aceshooting/lyra-ui 2.0.0 → 2.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 (224) hide show
  1. package/README.md +87 -14
  2. package/custom-elements.json +11555 -5539
  3. package/dist/components/app-rail/app-rail-item.d.ts +30 -0
  4. package/dist/components/app-rail/app-rail-item.js +68 -0
  5. package/dist/components/app-rail/app-rail-item.styles.d.ts +1 -0
  6. package/dist/components/app-rail/app-rail-item.styles.js +65 -0
  7. package/dist/components/app-rail/app-rail.d.ts +21 -24
  8. package/dist/components/app-rail/app-rail.js +68 -132
  9. package/dist/components/app-rail/app-rail.styles.js +5 -2
  10. package/dist/components/attachment-chip/attachment-chip.d.ts +38 -11
  11. package/dist/components/attachment-chip/attachment-chip.js +73 -29
  12. package/dist/components/attachment-trigger/attachment-trigger.d.ts +13 -0
  13. package/dist/components/attachment-trigger/attachment-trigger.js +13 -1
  14. package/dist/components/attachment-trigger/attachment-trigger.styles.js +28 -6
  15. package/dist/components/chart/bar-chart.js +2 -18
  16. package/dist/components/chart/box-plot.d.ts +25 -1
  17. package/dist/components/chart/box-plot.js +136 -6
  18. package/dist/components/chart/box-plot.styles.js +13 -0
  19. package/dist/components/chart/bubble-chart.js +2 -18
  20. package/dist/components/chart/chart.d.ts +54 -10
  21. package/dist/components/chart/chart.js +182 -39
  22. package/dist/components/chart/doughnut-chart.js +2 -18
  23. package/dist/components/chart/histogram-bin.d.ts +5 -2
  24. package/dist/components/chart/histogram-bin.js +12 -4
  25. package/dist/components/chart/histogram.js +8 -16
  26. package/dist/components/chart/line-chart.js +2 -18
  27. package/dist/components/chart/lite-chart.d.ts +80 -3
  28. package/dist/components/chart/lite-chart.js +250 -53
  29. package/dist/components/chart/pie-chart.js +2 -18
  30. package/dist/components/chart/polar-area-chart.js +2 -18
  31. package/dist/components/chart/radar-chart.js +2 -18
  32. package/dist/components/chart/scatter-chart.js +2 -18
  33. package/dist/components/chat-composer/chat-composer.d.ts +42 -1
  34. package/dist/components/chat-composer/chat-composer.js +84 -3
  35. package/dist/components/chat-composer/chat-composer.styles.js +11 -1
  36. package/dist/components/chat-message/chat-message.d.ts +32 -4
  37. package/dist/components/chat-message/chat-message.js +57 -17
  38. package/dist/components/checkbox/checkbox.d.ts +66 -8
  39. package/dist/components/checkbox/checkbox.js +173 -50
  40. package/dist/components/checkbox/checkbox.styles.js +9 -2
  41. package/dist/components/chip/chip-group.js +8 -2
  42. package/dist/components/chip/chip.js +9 -2
  43. package/dist/components/citation-badge/citation-badge.js +21 -3
  44. package/dist/components/code-block/code-block.d.ts +38 -0
  45. package/dist/components/code-block/code-block.js +72 -7
  46. package/dist/components/code-block/code-loader.d.ts +44 -1
  47. package/dist/components/code-block/code-loader.js +63 -0
  48. package/dist/components/combobox/combobox.d.ts +46 -5
  49. package/dist/components/combobox/combobox.js +175 -35
  50. package/dist/components/combobox/combobox.styles.js +3 -3
  51. package/dist/components/context-meter/context-meter.js +17 -8
  52. package/dist/components/context-meter/context-meter.styles.js +0 -6
  53. package/dist/components/conversation-item/conversation-item.d.ts +13 -16
  54. package/dist/components/conversation-item/conversation-item.js +29 -20
  55. package/dist/components/date-picker/calendar-core.d.ts +24 -0
  56. package/dist/components/date-picker/calendar-core.js +51 -4
  57. package/dist/components/date-picker/date-input.d.ts +77 -10
  58. package/dist/components/date-picker/date-input.js +278 -73
  59. package/dist/components/date-picker/date-picker.d.ts +28 -4
  60. package/dist/components/date-picker/date-picker.js +95 -37
  61. package/dist/components/dialog/confirm.js +3 -6
  62. package/dist/components/dialog/dialog.d.ts +75 -31
  63. package/dist/components/dialog/dialog.js +147 -131
  64. package/dist/components/dialog/dialog.styles.js +46 -2
  65. package/dist/components/dock-panel/dock-panel.d.ts +8 -0
  66. package/dist/components/dock-panel/dock-panel.js +15 -1
  67. package/dist/components/dock-panel/dock-panel.styles.js +15 -1
  68. package/dist/components/document-preview/document-preview.d.ts +15 -7
  69. package/dist/components/document-preview/document-preview.js +29 -13
  70. package/dist/components/empty/empty.d.ts +1 -2
  71. package/dist/components/empty/empty.js +18 -12
  72. package/dist/components/empty/empty.styles.js +0 -3
  73. package/dist/components/export-button/export-button.d.ts +12 -0
  74. package/dist/components/export-button/export-button.js +33 -5
  75. package/dist/components/export-button/export-button.styles.js +5 -1
  76. package/dist/components/file-input/accept.js +2 -0
  77. package/dist/components/file-input/file-input.d.ts +10 -1
  78. package/dist/components/file-input/file-input.js +36 -5
  79. package/dist/components/flag/flag.d.ts +1 -1
  80. package/dist/components/flag/flag.js +15 -1
  81. package/dist/components/flag/flag.styles.js +1 -1
  82. package/dist/components/flag/language-map.d.ts +4 -1
  83. package/dist/components/flag/language-map.js +6 -3
  84. package/dist/components/gauge/gauge.d.ts +10 -1
  85. package/dist/components/gauge/gauge.js +28 -9
  86. package/dist/components/generation-status/generation-status.d.ts +13 -5
  87. package/dist/components/generation-status/generation-status.js +59 -5
  88. package/dist/components/graph/graph.d.ts +37 -3
  89. package/dist/components/graph/graph.js +132 -20
  90. package/dist/components/heatmap/calendar-grid.d.ts +15 -6
  91. package/dist/components/heatmap/calendar-grid.js +17 -6
  92. package/dist/components/heatmap/heatmap-scale.d.ts +5 -1
  93. package/dist/components/heatmap/heatmap-scale.js +8 -2
  94. package/dist/components/heatmap/heatmap.d.ts +166 -25
  95. package/dist/components/heatmap/heatmap.js +372 -89
  96. package/dist/components/json-viewer/json-viewer.d.ts +22 -5
  97. package/dist/components/json-viewer/json-viewer.js +115 -37
  98. package/dist/components/json-viewer/json-viewer.styles.js +2 -1
  99. package/dist/components/kbd/kbd.js +24 -7
  100. package/dist/components/live-region/live-region.d.ts +10 -1
  101. package/dist/components/live-region/live-region.js +42 -6
  102. package/dist/components/map/map.d.ts +24 -1
  103. package/dist/components/map/map.js +89 -24
  104. package/dist/components/markdown/markdown-loader.d.ts +24 -0
  105. package/dist/components/markdown/markdown-loader.js +35 -1
  106. package/dist/components/markdown/markdown.d.ts +46 -8
  107. package/dist/components/markdown/markdown.js +136 -31
  108. package/dist/components/markdown/markdown.styles.js +7 -1
  109. package/dist/components/media-card/media-card.d.ts +25 -17
  110. package/dist/components/media-card/media-card.js +43 -65
  111. package/dist/components/mention-popover/mention-popover.d.ts +4 -1
  112. package/dist/components/mention-popover/mention-popover.js +23 -5
  113. package/dist/components/menu/menu-item.d.ts +32 -3
  114. package/dist/components/menu/menu-item.js +94 -7
  115. package/dist/components/menu/menu-item.styles.js +8 -0
  116. package/dist/components/menu/menu.d.ts +25 -1
  117. package/dist/components/menu/menu.js +99 -3
  118. package/dist/components/menu/menu.styles.js +3 -2
  119. package/dist/components/model-select/model-select.d.ts +48 -3
  120. package/dist/components/model-select/model-select.js +120 -22
  121. package/dist/components/model-select/model-select.styles.js +17 -3
  122. package/dist/components/model-settings-panel/model-settings-panel.d.ts +25 -1
  123. package/dist/components/model-settings-panel/model-settings-panel.js +65 -1
  124. package/dist/components/playback/playback.d.ts +6 -2
  125. package/dist/components/playback/playback.js +72 -29
  126. package/dist/components/playback/playback.styles.js +6 -1
  127. package/dist/components/responsive-panel/responsive-panel.d.ts +29 -16
  128. package/dist/components/responsive-panel/responsive-panel.js +130 -132
  129. package/dist/components/responsive-panel/responsive-panel.styles.js +1 -1
  130. package/dist/components/result-card/result-card.d.ts +8 -2
  131. package/dist/components/result-card/result-card.js +9 -3
  132. package/dist/components/result-card/result-card.styles.js +0 -1
  133. package/dist/components/select/select.d.ts +59 -20
  134. package/dist/components/select/select.js +125 -43
  135. package/dist/components/skeleton/skeleton.d.ts +3 -0
  136. package/dist/components/skeleton/skeleton.js +7 -1
  137. package/dist/components/skeleton/skeleton.styles.js +5 -4
  138. package/dist/components/slider/slider.d.ts +57 -5
  139. package/dist/components/slider/slider.js +195 -80
  140. package/dist/components/source-card/source-card.d.ts +17 -5
  141. package/dist/components/source-card/source-card.js +55 -8
  142. package/dist/components/source-card/source-card.styles.js +1 -1
  143. package/dist/components/source-list/source-list.js +9 -1
  144. package/dist/components/sparkline/sparkline.js +34 -10
  145. package/dist/components/split/split.d.ts +141 -14
  146. package/dist/components/split/split.js +357 -52
  147. package/dist/components/split/split.styles.js +18 -1
  148. package/dist/components/stat/stat.d.ts +10 -4
  149. package/dist/components/stat/stat.js +46 -11
  150. package/dist/components/stream-status/stream-status.d.ts +27 -11
  151. package/dist/components/stream-status/stream-status.js +95 -13
  152. package/dist/components/streaming-text/streaming-text.d.ts +9 -3
  153. package/dist/components/streaming-text/streaming-text.js +25 -10
  154. package/dist/components/streaming-text/streaming-text.styles.js +3 -2
  155. package/dist/components/switch/switch.d.ts +52 -7
  156. package/dist/components/switch/switch.js +111 -40
  157. package/dist/components/switch/switch.styles.js +1 -1
  158. package/dist/components/table/table.d.ts +18 -3
  159. package/dist/components/table/table.js +88 -20
  160. package/dist/components/tabs/tabs.d.ts +23 -0
  161. package/dist/components/tabs/tabs.js +76 -12
  162. package/dist/components/tabs/tabs.styles.js +11 -0
  163. package/dist/components/thinking-panel/thinking-panel.d.ts +3 -1
  164. package/dist/components/thinking-panel/thinking-panel.js +34 -9
  165. package/dist/components/thinking-panel/thinking-panel.styles.js +6 -0
  166. package/dist/components/time-range/time-range.d.ts +59 -2
  167. package/dist/components/time-range/time-range.js +163 -67
  168. package/dist/components/toast/toast-item.d.ts +12 -3
  169. package/dist/components/toast/toast-item.js +90 -53
  170. package/dist/components/toast/toast-item.styles.js +59 -28
  171. package/dist/components/toast/toast.js +17 -5
  172. package/dist/components/toast/toaster.js +10 -1
  173. package/dist/components/tool-approval-dialog/tool-approval-dialog.d.ts +11 -10
  174. package/dist/components/tool-approval-dialog/tool-approval-dialog.js +50 -124
  175. package/dist/components/tool-approval-dialog/tool-approval-dialog.styles.js +1 -1
  176. package/dist/components/tool-call-chip/tool-call-chip.d.ts +18 -2
  177. package/dist/components/tool-call-chip/tool-call-chip.js +86 -25
  178. package/dist/components/tool-param-form/tool-param-form.d.ts +89 -28
  179. package/dist/components/tool-param-form/tool-param-form.js +350 -106
  180. package/dist/components/tool-result-dialog/tool-result-dialog.d.ts +27 -7
  181. package/dist/components/tool-result-dialog/tool-result-dialog.js +80 -115
  182. package/dist/components/tool-result-dialog/tool-result-dialog.styles.js +1 -1
  183. package/dist/components/tool-result-view/registry.d.ts +0 -8
  184. package/dist/components/tool-result-view/registry.js +7 -2
  185. package/dist/components/tool-result-view/tool-result-view.d.ts +6 -3
  186. package/dist/components/tool-result-view/tool-result-view.js +18 -4
  187. package/dist/components/tool-select-dialog/tool-select-dialog.d.ts +11 -11
  188. package/dist/components/tool-select-dialog/tool-select-dialog.js +44 -133
  189. package/dist/components/tool-select-dialog/tool-select-dialog.styles.js +13 -4
  190. package/dist/components/tree/tree-node.d.ts +4 -1
  191. package/dist/components/tree/tree-node.js +4 -1
  192. package/dist/components/tree/tree.d.ts +17 -8
  193. package/dist/components/tree/tree.js +21 -20
  194. package/dist/components/typing-indicator/typing-indicator.d.ts +12 -2
  195. package/dist/components/typing-indicator/typing-indicator.js +16 -4
  196. package/dist/components/typing-indicator/typing-indicator.styles.js +2 -2
  197. package/dist/components/virtual-list/virtual-list.d.ts +56 -19
  198. package/dist/components/virtual-list/virtual-list.js +184 -38
  199. package/dist/components/virtual-list/virtual-list.styles.js +12 -0
  200. package/dist/components/widget/widget.d.ts +5 -4
  201. package/dist/components/widget/widget.js +54 -120
  202. package/dist/components/widget/widget.styles.js +8 -2
  203. package/dist/components/word-cloud/word-cloud-layout.d.ts +7 -0
  204. package/dist/components/word-cloud/word-cloud-layout.js +20 -6
  205. package/dist/components/word-cloud/word-cloud.d.ts +22 -2
  206. package/dist/components/word-cloud/word-cloud.js +45 -6
  207. package/dist/components/word-cloud/word-cloud.styles.js +13 -1
  208. package/dist/internal/anchored-validity.d.ts +1 -0
  209. package/dist/internal/anchored-validity.js +86 -0
  210. package/dist/internal/form-associated.d.ts +7 -0
  211. package/dist/internal/form-associated.js +43 -4
  212. package/dist/internal/numbers.d.ts +12 -0
  213. package/dist/internal/numbers.js +26 -0
  214. package/dist/internal/overlay-manager.d.ts +51 -0
  215. package/dist/internal/overlay-manager.js +523 -0
  216. package/dist/internal/positioner.js +27 -3
  217. package/dist/internal/safe-url.d.ts +12 -0
  218. package/dist/internal/safe-url.js +41 -0
  219. package/dist/internal/tokens.styles.js +17 -11
  220. package/dist/lyra.d.ts +5 -2
  221. package/dist/lyra.js +3 -1
  222. package/llms-full.txt +970 -426
  223. package/llms.txt +5 -4
  224. package/package.json +19 -2
@@ -1,22 +1,26 @@
1
1
  import { type TemplateResult, type PropertyValues } from 'lit';
2
2
  import { LyraElement } from '../../internal/lyra-element.js';
3
+ /** Largest accepted overscan on either side of the visible range. This keeps
4
+ * an accidental huge value from defeating virtualization. */
5
+ export declare const MAX_OVERSCAN_ROWS = 100;
3
6
  /** `lyra-visible-range-changed` detail -- the current visible (non-overscanned) item index range. */
4
7
  export interface VirtualListRange {
5
8
  start: number;
6
9
  end: number;
7
10
  }
8
- /** Placeholder for a later phase's sticky-header sectioning feature -- see the class doc. */
11
+ /** A visible group label anchored to the first row in its group. */
9
12
  export interface VirtualListGroup {
10
13
  key: string | number;
11
14
  label?: string;
12
15
  startIndex: number;
13
16
  }
17
+ type VirtualListKey = string | number;
14
18
  /**
15
19
  * `<lyra-virtual-list>` — a generic windowed/virtualized list host. Renders
16
20
  * only the items within the current viewport (plus `overscan` padding rows
17
21
  * on each side) as real DOM, regardless of how large `items` is, so a
18
- * multi-thousand-row chat history sidebar (or, in a later phase, a long
19
- * message thread) stays cheap to scroll.
22
+ * multi-thousand-row chat history sidebar or long message thread stays cheap
23
+ * to scroll.
20
24
  *
21
25
  * Content is entirely caller-supplied: `renderItem(item, index)` returns
22
26
  * whatever `lit-html` value should represent that row (typically a
@@ -46,12 +50,18 @@ export interface VirtualListGroup {
46
50
  * avoids maintaining two parallel rendering strategies for a difference
47
51
  * that's otherwise just "how is row *i*'s height looked up").
48
52
  *
49
- * The `offsets` array is rebuilt on every update. For the list sizes this
50
- * component is meant for (a scrollable history sidebar, realistically
51
- * hundreds to a few thousand rows) that's a trivial `O(n)` arithmetic loop,
52
- * even at scroll-driven ~60fps; it is *not* the right approach for a
53
- * hundred-thousand-row list without further work (e.g. a Fenwick/segment
54
- * tree for `O(log n)` offset queries+updates), which is out of scope here.
53
+ * The `offsets` array is rebuilt only when `items`, `row-height`, or
54
+ * `keyFunction` change, or a row's measured height changes -- not on every
55
+ * update, so a pure scroll-position tick (potentially every rAF while
56
+ * scrolling) only re-runs the cheap range/visibility math in
57
+ * `computeRange()`, never the `O(n)` offsets rebuild (which, in
58
+ * `row-height="auto"` mode, also means a `keyFunction` call per item). For
59
+ * the list sizes this component is meant for (a scrollable history sidebar,
60
+ * realistically hundreds to a few thousand rows) that rebuild is a trivial
61
+ * `O(n)` arithmetic loop even when it does run; it is *not* the right
62
+ * approach for a hundred-thousand-row list without further work (e.g. a
63
+ * Fenwick/segment tree for `O(log n)` offset queries+updates), which is out
64
+ * of scope here.
55
65
  *
56
66
  * **Accessibility.** The scroll container is `role="list"` and each rendered
57
67
  * row is `role="listitem"`, deliberately *not* `listbox`/`option` — this
@@ -70,10 +80,10 @@ export interface VirtualListGroup {
70
80
  * focusable content of its own is otherwise unreachable by keyboard (native
71
81
  * arrow/Page Up/Page Down scrolling included).
72
82
  *
73
- * **Grouping.** `groups` is accepted for forward API compatibility with a
74
- * later phase's sticky-header sectioning feature (paired with a
75
- * `groupByRecency()` helper that doesn't exist yet) but is not read or
76
- * rendered by this pass — every row still renders flat, in `items` order.
83
+ * **Grouping.** When supplied, `groups` renders a labeled group marker at the
84
+ * corresponding `startIndex`. Group markers are positioned independently of
85
+ * the row window, so they remain available when the first row in a group is
86
+ * outside the current overscanned range.
77
87
  *
78
88
  * @customElement lyra-virtual-list
79
89
  * @event lyra-load-more - Fired once per approach to the bottom of the list
@@ -90,6 +100,7 @@ export interface VirtualListGroup {
90
100
  * @csspart base - The scrollable container (`role="list"`).
91
101
  * @csspart spacer - The full-content-height inner element that gives the
92
102
  * container its true scrollable extent.
103
+ * @csspart group - A positioned group label.
93
104
  * @csspart row - One rendered row's absolutely-positioned wrapper
94
105
  * (`role="listitem"`); `renderItem`'s return value renders inside it.
95
106
  */
@@ -105,26 +116,48 @@ export declare class LyraVirtualList extends LyraElement {
105
116
  * scroll position and any per-row DOM state can attach to the wrong row
106
117
  * across a mutation (same caveat as `<lyra-table>`'s `rowKey`). */
107
118
  keyFunction?: (item: unknown, index: number) => string | number;
108
- /** Reserved for a later phase's sticky-header sectioning — accepted but not yet rendered. See the class doc. */
119
+ /** Group labels positioned at their first row's `startIndex`. Invalid or
120
+ * duplicate indexes are ignored during rendering. */
109
121
  groups?: VirtualListGroup[];
110
122
  /** `'auto'` (default) measures each row's real height via `ResizeObserver`;
111
123
  * a numeric string fixes every row to that many pixels. */
112
124
  rowHeight: string;
113
- /** Extra rows rendered beyond the visible viewport on each side, to reduce blank-frame risk during fast scrolling. */
125
+ /** Extra rows rendered beyond the visible viewport on each side, to reduce
126
+ * blank-frame risk during fast scrolling. Normalized to a whole number in
127
+ * the inclusive range 0–`MAX_OVERSCAN_ROWS`; non-finite values use the
128
+ * default. */
114
129
  overscan: number;
115
- /** When set and it matches a row's `keyFunction` result, that row is smoothly scrolled into view whenever this changes. */
116
- activeId: string;
130
+ /** When set and it matches a row's typed `keyFunction` result, that row is
131
+ * smoothly scrolled into view whenever this changes. Attribute values are
132
+ * strings; assign the property for a numeric key. */
133
+ activeId: VirtualListKey | '';
117
134
  loading: boolean;
118
135
  /** When true, scrolling near the bottom fires `lyra-load-more`. */
119
136
  hasMore: boolean;
120
137
  private containerScrollTop;
121
138
  private viewportHeight;
122
- /** `offsets[i]` = row `i`'s pixel top; `offsets[items.length]` = total content height. Rebuilt every update. */
139
+ /** `offsets[i]` = row `i`'s pixel top; `offsets[items.length]` = total content height. Rebuilt only when `offsetsDirty` is set -- see `willUpdate()`. */
123
140
  private offsets;
124
141
  /** Parsed `rowHeight`: a positive pixel number, or `null` for `'auto'` (measured) mode. */
125
142
  private fixedRowHeight;
126
- /** `row-height="auto"` per-row measured heights, keyed by `String(keyFunction(item, index))`. */
143
+ /** `row-height="auto"` per-row measured heights, keyed by
144
+ * keyFunction result. Pruned to the current `items`'
145
+ * live keys whenever `items` changes (see `recomputeOffsets()`), so a
146
+ * long-lived instance handed many wholly different `items` arrays over
147
+ * its life doesn't grow this map without bound. */
127
148
  private readonly measuredHeights;
149
+ /** True whenever `offsets` needs rebuilding before the next render --
150
+ * set initially and whenever `items`/`rowHeight`/`keyFunction` change or
151
+ * a row's measured height changes, but *not* on a pure scroll-position
152
+ * update, so the `O(n)` rebuild (including a `keyFunction` call per item
153
+ * in `row-height="auto"` mode) only runs when something that actually
154
+ * affects row heights or ordering changed. */
155
+ private offsetsDirty;
156
+ /** Set alongside `offsetsDirty` specifically when `items` changed (not
157
+ * just `rowHeight`/`keyFunction`/a measurement) -- consumed by the next
158
+ * `recomputeOffsets()` call to prune `measuredHeights` entries for keys
159
+ * no longer present in `items`. */
160
+ private itemsChangedPendingPrune;
128
161
  private renderStart;
129
162
  private renderEnd;
130
163
  private visibleStart;
@@ -137,6 +170,8 @@ export declare class LyraVirtualList extends LyraElement {
137
170
  private rowResizeObserver?;
138
171
  private containerResizeObserver?;
139
172
  private readonly observedRows;
173
+ private readonly observedRowKeys;
174
+ private readonly observedRowIndices;
140
175
  private scrollRafId?;
141
176
  private pendingScrollTop;
142
177
  connectedCallback(): void;
@@ -164,6 +199,7 @@ export declare class LyraVirtualList extends LyraElement {
164
199
  private emitRangeChangeIfNeeded;
165
200
  private maybeFireLoadMore;
166
201
  private renderRow;
202
+ private renderGroups;
167
203
  render(): TemplateResult;
168
204
  }
169
205
  declare global {
@@ -171,3 +207,4 @@ declare global {
171
207
  'lyra-virtual-list': LyraVirtualList;
172
208
  }
173
209
  }
210
+ export {};
@@ -16,15 +16,43 @@ import { styles } from './virtual-list.styles.js';
16
16
  * chat-list row that the initial scrollbar/spacer size doesn't jump wildly
17
17
  * once real measurements arrive. Irrelevant in fixed-`row-height` mode. */
18
18
  const DEFAULT_ROW_ESTIMATE_PX = 48;
19
+ const DEFAULT_OVERSCAN_ROWS = 6;
20
+ /** Largest accepted overscan on either side of the visible range. This keeps
21
+ * an accidental huge value from defeating virtualization. */
22
+ export const MAX_OVERSCAN_ROWS = 100;
23
+ function normalizeOverscan(value) {
24
+ if (value === null)
25
+ return DEFAULT_OVERSCAN_ROWS;
26
+ const numeric = typeof value === 'number' ? value : Number(value);
27
+ if (!Number.isFinite(numeric))
28
+ return DEFAULT_OVERSCAN_ROWS;
29
+ return Math.min(MAX_OVERSCAN_ROWS, Math.max(0, Math.floor(numeric)));
30
+ }
31
+ const overscanConverter = {
32
+ fromAttribute(value) {
33
+ return normalizeOverscan(value);
34
+ },
35
+ };
19
36
  function prefersReducedMotion() {
20
37
  return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
21
38
  }
39
+ /** A typed key is used in maps and active-row matching; this token is only for
40
+ * DOM attributes, where every value is necessarily a string. */
41
+ function domKeyToken(key) {
42
+ if (typeof key === 'number') {
43
+ if (Number.isNaN(key))
44
+ return 'number:NaN';
45
+ if (Object.is(key, -0))
46
+ return 'number:-0';
47
+ }
48
+ return `${typeof key}:${String(key)}`;
49
+ }
22
50
  /**
23
51
  * `<lyra-virtual-list>` — a generic windowed/virtualized list host. Renders
24
52
  * only the items within the current viewport (plus `overscan` padding rows
25
53
  * on each side) as real DOM, regardless of how large `items` is, so a
26
- * multi-thousand-row chat history sidebar (or, in a later phase, a long
27
- * message thread) stays cheap to scroll.
54
+ * multi-thousand-row chat history sidebar or long message thread stays cheap
55
+ * to scroll.
28
56
  *
29
57
  * Content is entirely caller-supplied: `renderItem(item, index)` returns
30
58
  * whatever `lit-html` value should represent that row (typically a
@@ -54,12 +82,18 @@ function prefersReducedMotion() {
54
82
  * avoids maintaining two parallel rendering strategies for a difference
55
83
  * that's otherwise just "how is row *i*'s height looked up").
56
84
  *
57
- * The `offsets` array is rebuilt on every update. For the list sizes this
58
- * component is meant for (a scrollable history sidebar, realistically
59
- * hundreds to a few thousand rows) that's a trivial `O(n)` arithmetic loop,
60
- * even at scroll-driven ~60fps; it is *not* the right approach for a
61
- * hundred-thousand-row list without further work (e.g. a Fenwick/segment
62
- * tree for `O(log n)` offset queries+updates), which is out of scope here.
85
+ * The `offsets` array is rebuilt only when `items`, `row-height`, or
86
+ * `keyFunction` change, or a row's measured height changes -- not on every
87
+ * update, so a pure scroll-position tick (potentially every rAF while
88
+ * scrolling) only re-runs the cheap range/visibility math in
89
+ * `computeRange()`, never the `O(n)` offsets rebuild (which, in
90
+ * `row-height="auto"` mode, also means a `keyFunction` call per item). For
91
+ * the list sizes this component is meant for (a scrollable history sidebar,
92
+ * realistically hundreds to a few thousand rows) that rebuild is a trivial
93
+ * `O(n)` arithmetic loop even when it does run; it is *not* the right
94
+ * approach for a hundred-thousand-row list without further work (e.g. a
95
+ * Fenwick/segment tree for `O(log n)` offset queries+updates), which is out
96
+ * of scope here.
63
97
  *
64
98
  * **Accessibility.** The scroll container is `role="list"` and each rendered
65
99
  * row is `role="listitem"`, deliberately *not* `listbox`/`option` — this
@@ -78,10 +112,10 @@ function prefersReducedMotion() {
78
112
  * focusable content of its own is otherwise unreachable by keyboard (native
79
113
  * arrow/Page Up/Page Down scrolling included).
80
114
  *
81
- * **Grouping.** `groups` is accepted for forward API compatibility with a
82
- * later phase's sticky-header sectioning feature (paired with a
83
- * `groupByRecency()` helper that doesn't exist yet) but is not read or
84
- * rendered by this pass — every row still renders flat, in `items` order.
115
+ * **Grouping.** When supplied, `groups` renders a labeled group marker at the
116
+ * corresponding `startIndex`. Group markers are positioned independently of
117
+ * the row window, so they remain available when the first row in a group is
118
+ * outside the current overscanned range.
85
119
  *
86
120
  * @customElement lyra-virtual-list
87
121
  * @event lyra-load-more - Fired once per approach to the bottom of the list
@@ -98,6 +132,7 @@ function prefersReducedMotion() {
98
132
  * @csspart base - The scrollable container (`role="list"`).
99
133
  * @csspart spacer - The full-content-height inner element that gives the
100
134
  * container its true scrollable extent.
135
+ * @csspart group - A positioned group label.
101
136
  * @csspart row - One rendered row's absolutely-positioned wrapper
102
137
  * (`role="listitem"`); `renderItem`'s return value renders inside it.
103
138
  */
@@ -111,9 +146,14 @@ export class LyraVirtualList extends LyraElement {
111
146
  /** `'auto'` (default) measures each row's real height via `ResizeObserver`;
112
147
  * a numeric string fixes every row to that many pixels. */
113
148
  this.rowHeight = 'auto';
114
- /** Extra rows rendered beyond the visible viewport on each side, to reduce blank-frame risk during fast scrolling. */
115
- this.overscan = 6;
116
- /** When set and it matches a row's `keyFunction` result, that row is smoothly scrolled into view whenever this changes. */
149
+ /** Extra rows rendered beyond the visible viewport on each side, to reduce
150
+ * blank-frame risk during fast scrolling. Normalized to a whole number in
151
+ * the inclusive range 0–`MAX_OVERSCAN_ROWS`; non-finite values use the
152
+ * default. */
153
+ this.overscan = DEFAULT_OVERSCAN_ROWS;
154
+ /** When set and it matches a row's typed `keyFunction` result, that row is
155
+ * smoothly scrolled into view whenever this changes. Attribute values are
156
+ * strings; assign the property for a numeric key. */
117
157
  this.activeId = '';
118
158
  this.loading = false;
119
159
  /** When true, scrolling near the bottom fires `lyra-load-more`. */
@@ -124,12 +164,28 @@ export class LyraVirtualList extends LyraElement {
124
164
  // scrolls itself; [part="base"] does).
125
165
  this.containerScrollTop = 0;
126
166
  this.viewportHeight = 0;
127
- /** `offsets[i]` = row `i`'s pixel top; `offsets[items.length]` = total content height. Rebuilt every update. */
167
+ /** `offsets[i]` = row `i`'s pixel top; `offsets[items.length]` = total content height. Rebuilt only when `offsetsDirty` is set -- see `willUpdate()`. */
128
168
  this.offsets = [0];
129
169
  /** Parsed `rowHeight`: a positive pixel number, or `null` for `'auto'` (measured) mode. */
130
170
  this.fixedRowHeight = null;
131
- /** `row-height="auto"` per-row measured heights, keyed by `String(keyFunction(item, index))`. */
171
+ /** `row-height="auto"` per-row measured heights, keyed by
172
+ * keyFunction result. Pruned to the current `items`'
173
+ * live keys whenever `items` changes (see `recomputeOffsets()`), so a
174
+ * long-lived instance handed many wholly different `items` arrays over
175
+ * its life doesn't grow this map without bound. */
132
176
  this.measuredHeights = new Map();
177
+ /** True whenever `offsets` needs rebuilding before the next render --
178
+ * set initially and whenever `items`/`rowHeight`/`keyFunction` change or
179
+ * a row's measured height changes, but *not* on a pure scroll-position
180
+ * update, so the `O(n)` rebuild (including a `keyFunction` call per item
181
+ * in `row-height="auto"` mode) only runs when something that actually
182
+ * affects row heights or ordering changed. */
183
+ this.offsetsDirty = true;
184
+ /** Set alongside `offsetsDirty` specifically when `items` changed (not
185
+ * just `rowHeight`/`keyFunction`/a measurement) -- consumed by the next
186
+ * `recomputeOffsets()` call to prune `measuredHeights` entries for keys
187
+ * no longer present in `items`. */
188
+ this.itemsChangedPendingPrune = false;
133
189
  this.renderStart = 0;
134
190
  this.renderEnd = -1;
135
191
  this.visibleStart = 0;
@@ -140,6 +196,8 @@ export class LyraVirtualList extends LyraElement {
140
196
  this.loadMoreArmed = true;
141
197
  this.isFirstUpdate = true;
142
198
  this.observedRows = new Map();
199
+ this.observedRowKeys = new WeakMap();
200
+ this.observedRowIndices = new WeakMap();
143
201
  this.pendingScrollTop = null;
144
202
  this.onScroll = (e) => {
145
203
  this.pendingScrollTop = e.currentTarget.scrollTop;
@@ -159,10 +217,15 @@ export class LyraVirtualList extends LyraElement {
159
217
  this.onRowsResized = (entries) => {
160
218
  if (this.fixedRowHeight != null)
161
219
  return;
220
+ const base = this.renderRoot.querySelector('[part="base"]');
221
+ const oldScrollTop = base?.scrollTop ?? this.containerScrollTop;
222
+ let scrollAdjustment = 0;
162
223
  let changed = false;
163
224
  for (const entry of entries) {
164
- const key = entry.target.dataset.rowKey;
165
- if (key === undefined)
225
+ const row = entry.target;
226
+ const key = this.observedRowKeys.get(row);
227
+ const index = this.observedRowIndices.get(row);
228
+ if (key === undefined || index === undefined)
166
229
  continue;
167
230
  const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height;
168
231
  const prev = this.measuredHeights.get(key);
@@ -170,11 +233,25 @@ export class LyraVirtualList extends LyraElement {
170
233
  // isn't worth a full offsets rebuild + re-render.
171
234
  if (prev === undefined || Math.abs(prev - height) > 0.5) {
172
235
  this.measuredHeights.set(key, height);
236
+ // Keep the first visible row anchored while a row fully above it
237
+ // changes size. Otherwise auto-height measurement makes the viewport
238
+ // jump as soon as an earlier row is laid out.
239
+ const oldHeight = prev ?? DEFAULT_ROW_ESTIMATE_PX;
240
+ if (this.offsets[index + 1] <= oldScrollTop)
241
+ scrollAdjustment += height - oldHeight;
173
242
  changed = true;
174
243
  }
175
244
  }
176
- if (changed)
245
+ if (changed) {
246
+ if (base && scrollAdjustment !== 0) {
247
+ const nextScrollTop = Math.max(0, oldScrollTop + scrollAdjustment);
248
+ base.scrollTop = nextScrollTop;
249
+ this.containerScrollTop = nextScrollTop;
250
+ this.pendingScrollTop = null;
251
+ }
252
+ this.offsetsDirty = true;
177
253
  this.requestUpdate();
254
+ }
178
255
  };
179
256
  }
180
257
  static { this.styles = [LyraElement.styles, styles]; }
@@ -184,9 +261,15 @@ export class LyraVirtualList extends LyraElement {
184
261
  // firstUpdated() only ever fires once per element instance -- a
185
262
  // disconnect/reconnect (e.g. a reparenting drag) needs its own
186
263
  // re-attach here, since the container observer/scroll listener were
187
- // torn down in disconnectedCallback below.
188
- if (this.hasUpdated)
264
+ // torn down in disconnectedCallback below. syncRowObservers() is called
265
+ // directly here (rather than left for the next Lit render) because a
266
+ // reconnect that doesn't also change some other reactive property never
267
+ // triggers one, which would otherwise leave every already-rendered row
268
+ // permanently unwatched by the freshly created ResizeObserver above.
269
+ if (this.hasUpdated) {
189
270
  this.attachContainerListeners();
271
+ this.syncRowObservers();
272
+ }
190
273
  }
191
274
  disconnectedCallback() {
192
275
  super.disconnectedCallback();
@@ -206,10 +289,21 @@ export class LyraVirtualList extends LyraElement {
206
289
  }
207
290
  willUpdate(changed) {
208
291
  this.isFirstUpdate = !this.hasUpdated;
292
+ if (changed.has('items') || changed.has('rowHeight') || changed.has('keyFunction')) {
293
+ this.offsetsDirty = true;
294
+ }
295
+ if (changed.has('items')) {
296
+ this.itemsChangedPendingPrune = true;
297
+ }
209
298
  if (changed.has('rowHeight')) {
210
299
  this.fixedRowHeight = this.parseRowHeight(this.rowHeight);
211
300
  }
212
- this.recomputeOffsets();
301
+ if (this.offsetsDirty) {
302
+ this.recomputeOffsets();
303
+ this.offsetsDirty = false;
304
+ }
305
+ // Always cheap: just arithmetic over the already-current offsets +
306
+ // scroll/viewport state, so this runs on a pure scroll-driven update too.
213
307
  this.computeRange();
214
308
  }
215
309
  updated(changed) {
@@ -230,19 +324,40 @@ export class LyraVirtualList extends LyraElement {
230
324
  return Number.isFinite(n) && n > 0 ? n : null;
231
325
  }
232
326
  keyOf(item, index) {
233
- return this.keyFunction ? this.keyFunction(item, index) : index;
327
+ const key = this.keyFunction ? this.keyFunction(item, index) : index;
328
+ return typeof key === 'string' || typeof key === 'number' ? key : index;
234
329
  }
235
330
  recomputeOffsets() {
236
331
  const n = this.items.length;
237
332
  const offsets = new Array(n + 1);
238
333
  offsets[0] = 0;
334
+ // Only build the live-keys set (and only when in row-height="auto" mode,
335
+ // where measuredHeights is actually populated) when items itself changed
336
+ // -- a measurement-only or rowHeight/keyFunction-only recompute has no
337
+ // stale entries to prune, so skipping this keeps those cases as cheap as
338
+ // before.
339
+ const pruneStale = this.itemsChangedPendingPrune && this.fixedRowHeight == null;
340
+ const liveKeys = pruneStale ? new Set() : null;
239
341
  for (let i = 0; i < n; i++) {
240
- const h = this.fixedRowHeight ??
241
- this.measuredHeights.get(String(this.keyOf(this.items[i], i))) ??
242
- DEFAULT_ROW_ESTIMATE_PX;
342
+ let h;
343
+ if (this.fixedRowHeight != null) {
344
+ h = this.fixedRowHeight;
345
+ }
346
+ else {
347
+ const key = this.keyOf(this.items[i], i);
348
+ liveKeys?.add(key);
349
+ h = this.measuredHeights.get(key) ?? DEFAULT_ROW_ESTIMATE_PX;
350
+ }
243
351
  offsets[i + 1] = offsets[i] + h;
244
352
  }
245
353
  this.offsets = offsets;
354
+ this.itemsChangedPendingPrune = false;
355
+ if (liveKeys) {
356
+ for (const key of this.measuredHeights.keys()) {
357
+ if (!liveKeys.has(key))
358
+ this.measuredHeights.delete(key);
359
+ }
360
+ }
246
361
  }
247
362
  /** First item index whose bottom edge is at/after `offset`. */
248
363
  findIndexAtOrAfter(offset) {
@@ -285,8 +400,12 @@ export class LyraVirtualList extends LyraElement {
285
400
  const viewBottom = viewTop + this.viewportHeight;
286
401
  this.visibleStart = this.findIndexAtOrAfter(viewTop);
287
402
  this.visibleEnd = this.findIndexAtOrBefore(viewBottom);
288
- this.renderStart = Math.max(0, this.visibleStart - this.overscan);
289
- this.renderEnd = Math.min(n - 1, this.visibleEnd + this.overscan);
403
+ // Property assignments bypass Lit's attribute converter, so normalize at
404
+ // the arithmetic boundary too. This preserves virtualization even when
405
+ // JavaScript writes Infinity, NaN, a negative, or an excessive number.
406
+ const overscan = normalizeOverscan(this.overscan);
407
+ this.renderStart = Math.max(0, this.visibleStart - overscan);
408
+ this.renderEnd = Math.min(n - 1, this.visibleEnd + overscan);
290
409
  }
291
410
  attachContainerListeners() {
292
411
  const base = this.renderRoot.querySelector('[part="base"]');
@@ -328,9 +447,13 @@ export class LyraVirtualList extends LyraElement {
328
447
  }
329
448
  const current = new Map();
330
449
  this.renderRoot.querySelectorAll('[part="row"]').forEach((el) => {
331
- const key = el.dataset.rowKey;
332
- if (key !== undefined)
333
- current.set(key, el);
450
+ const index = Number(el.getAttribute('aria-posinset')) - 1;
451
+ if (!Number.isInteger(index) || index < 0 || index >= this.items.length)
452
+ return;
453
+ const key = this.keyOf(this.items[index], index);
454
+ current.set(key, el);
455
+ this.observedRowKeys.set(el, key);
456
+ this.observedRowIndices.set(el, index);
334
457
  });
335
458
  for (const [key, el] of this.observedRows) {
336
459
  if (current.get(key) !== el) {
@@ -346,9 +469,9 @@ export class LyraVirtualList extends LyraElement {
346
469
  }
347
470
  }
348
471
  scrollActiveIntoView() {
349
- if (!this.activeId)
472
+ if (this.activeId === '')
350
473
  return;
351
- const index = this.items.findIndex((item, i) => String(this.keyOf(item, i)) === this.activeId);
474
+ const index = this.items.findIndex((item, i) => Object.is(this.keyOf(item, i), this.activeId));
352
475
  if (index < 0)
353
476
  return;
354
477
  const base = this.renderRoot.querySelector('[part="base"]');
@@ -392,14 +515,14 @@ export class LyraVirtualList extends LyraElement {
392
515
  this.emit('lyra-load-more');
393
516
  }
394
517
  renderRow(item, index, total) {
395
- const key = String(this.keyOf(item, index));
518
+ const key = this.keyOf(item, index);
396
519
  const top = this.offsets[index] ?? 0;
397
- const isActive = this.activeId !== '' && key === this.activeId;
520
+ const isActive = this.activeId !== '' && Object.is(key, this.activeId);
398
521
  return html `
399
522
  <div
400
523
  part="row"
401
524
  role="listitem"
402
- data-row-key=${key}
525
+ data-row-key=${domKeyToken(key)}
403
526
  aria-setsize=${total}
404
527
  aria-posinset=${index + 1}
405
528
  aria-current=${isActive ? 'true' : nothing}
@@ -409,6 +532,28 @@ export class LyraVirtualList extends LyraElement {
409
532
  </div>
410
533
  `;
411
534
  }
535
+ renderGroups() {
536
+ const seen = new Set();
537
+ return (this.groups ?? [])
538
+ .filter((group) => {
539
+ const index = group.startIndex;
540
+ if (!Number.isInteger(index) || index < 0 || index >= this.items.length || seen.has(index))
541
+ return false;
542
+ seen.add(index);
543
+ return true;
544
+ })
545
+ .sort((a, b) => a.startIndex - b.startIndex)
546
+ .map((group) => html `
547
+ <div
548
+ part="group"
549
+ role="heading"
550
+ aria-level="2"
551
+ style=${styleMap({ transform: `translateY(${this.offsets[group.startIndex] ?? 0}px)` })}
552
+ >
553
+ ${group.label ?? String(group.key)}
554
+ </div>
555
+ `);
556
+ }
412
557
  render() {
413
558
  const n = this.items.length;
414
559
  const totalHeight = this.offsets[n] ?? 0;
@@ -419,6 +564,7 @@ export class LyraVirtualList extends LyraElement {
419
564
  return html `
420
565
  <div part="base" role="list" tabindex="0" aria-busy=${this.loading ? 'true' : nothing}>
421
566
  <div part="spacer" style=${styleMap({ height: `${totalHeight}px` })}>
567
+ ${this.renderGroups()}
422
568
  ${repeat(windowed, (w) => this.keyOf(w.item, w.index), (w) => this.renderRow(w.item, w.index, n))}
423
569
  </div>
424
570
  </div>
@@ -441,7 +587,7 @@ __decorate([
441
587
  property({ attribute: 'row-height' })
442
588
  ], LyraVirtualList.prototype, "rowHeight", void 0);
443
589
  __decorate([
444
- property({ type: Number })
590
+ property({ converter: overscanConverter })
445
591
  ], LyraVirtualList.prototype, "overscan", void 0);
446
592
  __decorate([
447
593
  property({ attribute: 'active-id' })
@@ -38,6 +38,18 @@ export const styles = css `
38
38
  re-render -- hinting the compositor avoids a full repaint per frame. */
39
39
  will-change: transform;
40
40
  }
41
+ [part='group'] {
42
+ position: absolute;
43
+ inset-inline: 0;
44
+ inset-block-start: 0;
45
+ z-index: 1;
46
+ padding: var(--lyra-space-xs) var(--lyra-space-s);
47
+ background: var(--lyra-color-surface);
48
+ color: var(--lyra-color-text-quiet);
49
+ font-size: 0.875em;
50
+ font-weight: 600;
51
+ pointer-events: none;
52
+ }
41
53
  :host([loading]) [part='base'] {
42
54
  cursor: progress;
43
55
  }
@@ -42,18 +42,19 @@ export declare class LyraWidget extends LyraElement {
42
42
  compact: boolean;
43
43
  private hasActionsSlot;
44
44
  private releaseScrollLock?;
45
- private lastTrigger?;
45
+ private overlayHandle?;
46
+ private explicitTrigger?;
46
47
  private readonly bodyId;
47
48
  protected willUpdate(changed: PropertyValues): void;
48
49
  protected updated(changed: PropertyValues): void;
49
50
  connectedCallback(): void;
50
51
  disconnectedCallback(): void;
52
+ private activateFullscreenOverlay;
53
+ private deactivateFullscreenOverlay;
51
54
  private onActionsSlotChange;
52
55
  private toggleCollapsed;
53
56
  private toggleFullscreen;
54
- private onDocKeyDown;
55
- private getFocusableElements;
56
- private getActiveElement;
57
+ private dismissFullscreen;
57
58
  private onBackdropClick;
58
59
  render(): TemplateResult;
59
60
  }