@adia-ai/web-components 0.6.32 → 0.6.34
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.
- package/CHANGELOG.md +44 -0
- package/components/accordion/accordion.css +2 -2
- package/components/action-list/action-list.css +2 -2
- package/components/agent-artifact/agent-artifact.css +31 -31
- package/components/agent-feedback-bar/agent-feedback-bar.css +10 -10
- package/components/agent-questions/agent-questions.css +57 -57
- package/components/agent-reasoning/agent-reasoning.css +62 -62
- package/components/agent-suggestions/agent-suggestions.css +4 -4
- package/components/agent-trace/agent-trace.css +53 -53
- package/components/alert/alert.css +41 -41
- package/components/avatar/avatar.css +27 -27
- package/components/badge/badge.css +27 -27
- package/components/block/block.css +16 -16
- package/components/breadcrumb/breadcrumb.css +23 -23
- package/components/button/button.css +101 -91
- package/components/calendar-grid/calendar-grid.a2ui.json +136 -0
- package/components/calendar-grid/calendar-grid.css +226 -0
- package/components/calendar-grid/calendar-grid.d.ts +37 -0
- package/components/calendar-grid/calendar-grid.js +17 -0
- package/components/calendar-grid/calendar-grid.yaml +116 -0
- package/components/calendar-grid/class.js +300 -0
- package/components/calendar-picker/calendar-picker.css +139 -139
- package/components/canvas/canvas.css +12 -12
- package/components/card/card.css +83 -83
- package/components/chart/chart.css +224 -224
- package/components/chart-legend/chart-legend.css +26 -26
- package/components/check/check.css +40 -40
- package/components/code/code.css +125 -125
- package/components/col/col.css +15 -15
- package/components/color-picker/color-picker.css +55 -55
- package/components/combobox/class.js +861 -0
- package/components/combobox/combobox.a2ui.json +363 -0
- package/components/combobox/combobox.css +244 -0
- package/components/combobox/combobox.d.ts +113 -0
- package/components/combobox/combobox.examples.md +59 -0
- package/components/combobox/combobox.js +17 -0
- package/components/combobox/combobox.test.js +181 -0
- package/components/combobox/combobox.yaml +369 -0
- package/components/command/command.css +90 -90
- package/components/date-range-picker/class.js +775 -0
- package/components/date-range-picker/date-range-picker.a2ui.json +300 -0
- package/components/date-range-picker/date-range-picker.css +178 -0
- package/components/date-range-picker/date-range-picker.d.ts +82 -0
- package/components/date-range-picker/date-range-picker.examples.md +37 -0
- package/components/date-range-picker/date-range-picker.js +17 -0
- package/components/date-range-picker/date-range-picker.test.js +387 -0
- package/components/date-range-picker/date-range-picker.yaml +285 -0
- package/components/datetime-picker/class.js +706 -0
- package/components/datetime-picker/datetime-picker.a2ui.json +334 -0
- package/components/datetime-picker/datetime-picker.css +150 -0
- package/components/datetime-picker/datetime-picker.d.ts +86 -0
- package/components/datetime-picker/datetime-picker.examples.md +46 -0
- package/components/datetime-picker/datetime-picker.js +17 -0
- package/components/datetime-picker/datetime-picker.test.js +454 -0
- package/components/datetime-picker/datetime-picker.yaml +332 -0
- package/components/demo-toggle/demo-toggle.css +27 -27
- package/components/description-list/description-list.css +18 -18
- package/components/divider/divider.css +24 -24
- package/components/embed/embed.css +6 -6
- package/components/empty-state/empty-state.css +27 -27
- package/components/feed/feed.css +12 -12
- package/components/field/field.css +37 -28
- package/components/field/field.test.js +32 -0
- package/components/fields/fields.css +5 -5
- package/components/grid/grid.css +5 -5
- package/components/heatmap/heatmap.css +63 -63
- package/components/icon/icon.css +12 -12
- package/components/image/image.css +14 -14
- package/components/index.js +8 -0
- package/components/input/input.css +66 -66
- package/components/inspector/inspector.css +6 -6
- package/components/integration-card/class.js +410 -0
- package/components/integration-card/integration-card.a2ui.json +268 -0
- package/components/integration-card/integration-card.css +169 -0
- package/components/integration-card/integration-card.d.ts +63 -0
- package/components/integration-card/integration-card.examples.md +41 -0
- package/components/integration-card/integration-card.js +17 -0
- package/components/integration-card/integration-card.test.js +306 -0
- package/components/integration-card/integration-card.yaml +280 -0
- package/components/kbd/kbd.css +32 -32
- package/components/link/link.css +12 -12
- package/components/list/list.css +8 -8
- package/components/list-window/class.js +688 -0
- package/components/list-window/list-window.a2ui.json +277 -0
- package/components/list-window/list-window.css +124 -0
- package/components/list-window/list-window.d.ts +84 -0
- package/components/list-window/list-window.examples.md +73 -0
- package/components/list-window/list-window.js +17 -0
- package/components/list-window/list-window.test.js +303 -0
- package/components/list-window/list-window.yaml +270 -0
- package/components/menu/menu.css +8 -8
- package/components/modal/modal.css +43 -43
- package/components/nav/nav.css +40 -40
- package/components/nav-group/nav-group.css +52 -52
- package/components/nav-item/nav-item.css +44 -44
- package/components/noodles/noodles.css +31 -31
- package/components/option-card/option-card.css +69 -69
- package/components/otp-input/otp-input.css +30 -30
- package/components/page/page.css +18 -18
- package/components/pagination/pagination.css +61 -61
- package/components/pane/pane.css +57 -57
- package/components/pipeline-status/pipeline-status.css +65 -65
- package/components/popover/popover.css +17 -17
- package/components/progress/progress.css +23 -23
- package/components/progress-row/progress-row.css +17 -17
- package/components/radio/radio.css +39 -39
- package/components/range/range.css +55 -55
- package/components/rating/rating.css +28 -28
- package/components/richtext/richtext.css +133 -133
- package/components/row/row.css +19 -19
- package/components/search/search.css +5 -5
- package/components/segment/segment.css +24 -24
- package/components/segmented/segmented.css +25 -25
- package/components/select/select.css +84 -84
- package/components/skeleton/skeleton.css +14 -14
- package/components/slider/slider.css +46 -46
- package/components/spinner/class.js +69 -0
- package/components/spinner/spinner.a2ui.json +197 -0
- package/components/spinner/spinner.css +165 -0
- package/components/spinner/spinner.d.ts +26 -0
- package/components/spinner/spinner.examples.md +26 -0
- package/components/spinner/spinner.js +17 -0
- package/components/spinner/spinner.test.js +234 -0
- package/components/spinner/spinner.yaml +230 -0
- package/components/stack/stack.css +11 -11
- package/components/stat/stat.css +25 -25
- package/components/step-progress/step-progress.css +20 -20
- package/components/stepper/stepper.css +29 -29
- package/components/stream/stream.css +12 -12
- package/components/swatch/swatch.css +68 -68
- package/components/swiper/swiper.css +57 -57
- package/components/switch/switch.css +52 -52
- package/components/table/class.js +9 -0
- package/components/table/table.a2ui.json +1 -1
- package/components/table/table.css +162 -162
- package/components/table/table.d.ts +1 -1
- package/components/table/table.test.js +53 -0
- package/components/table/table.yaml +13 -1
- package/components/table-toolbar/table-toolbar.css +32 -32
- package/components/tabs/tabs.css +51 -51
- package/components/tag/tag.css +48 -48
- package/components/text/text.css +44 -44
- package/components/textarea/textarea.css +46 -46
- package/components/time-picker/class.js +693 -0
- package/components/time-picker/time-picker.a2ui.json +267 -0
- package/components/time-picker/time-picker.css +122 -0
- package/components/time-picker/time-picker.d.ts +75 -0
- package/components/time-picker/time-picker.examples.md +35 -0
- package/components/time-picker/time-picker.js +17 -0
- package/components/time-picker/time-picker.test.js +287 -0
- package/components/time-picker/time-picker.yaml +256 -0
- package/components/timeline/timeline.css +50 -50
- package/components/toast/toast.css +58 -58
- package/components/toggle-group/toggle-group.css +6 -6
- package/components/toggle-scheme/toggle-scheme.css +2 -2
- package/components/toolbar/toolbar.css +17 -17
- package/components/tooltip/tooltip.css +2 -2
- package/components/tree/tree.css +37 -37
- package/components/upload/upload.css +49 -49
- package/dist/icons-manifest.js +3 -3
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +121 -83
- package/package.json +1 -1
- package/styles/components.css +8 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-side-effect class export for `<list-window-ui>`.
|
|
3
|
+
*
|
|
4
|
+
* Importing this file gives you the class without auto-registering the tag.
|
|
5
|
+
* Useful for test isolation, subclassing with tag-name override, or selective
|
|
6
|
+
* composition.
|
|
7
|
+
*
|
|
8
|
+
* The auto-register path stays at `@adia-ai/web-components/components/list-window`
|
|
9
|
+
* (which imports this file + calls `defineIfFree()`).
|
|
10
|
+
*
|
|
11
|
+
* @see ../../USAGE.md#registration--auto-vs-explicit
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* <list-window-ui> — Virtualized / windowed list primitive.
|
|
16
|
+
*
|
|
17
|
+
* <list-window-ui id="chat" item-size="56" overscan="5" pin-bottom
|
|
18
|
+
* style="height:480px">
|
|
19
|
+
* <template>
|
|
20
|
+
* <row-ui>
|
|
21
|
+
* <avatar-ui alt="${item.author}" src="${item.avatar}"></avatar-ui>
|
|
22
|
+
* <col-ui>
|
|
23
|
+
* <text-ui weight="medium">${item.author}</text-ui>
|
|
24
|
+
* <text-ui>${item.body}</text-ui>
|
|
25
|
+
* </col-ui>
|
|
26
|
+
* </row-ui>
|
|
27
|
+
* </template>
|
|
28
|
+
* <div slot="empty">No messages yet.</div>
|
|
29
|
+
* </list-window-ui>
|
|
30
|
+
* <script>
|
|
31
|
+
* document.getElementById('chat').items = bigArray; // 10k+ rows; ~12 rendered
|
|
32
|
+
* </script>
|
|
33
|
+
*
|
|
34
|
+
* Stamping strategy: imperative document.createElement for row materialization
|
|
35
|
+
* (per component-implementation-patterns.md Strategy 3 — dynamic-length lists,
|
|
36
|
+
* state-preserving diff for scroll position + focus). `static parts` carries
|
|
37
|
+
* the wrapper shell (phantom spacer + window region + sentinels).
|
|
38
|
+
*
|
|
39
|
+
* Per ADR-0033 (light-DOM substrate): no shadow root, no ::part, no
|
|
40
|
+
* ::slotted. Slots are decorative `slot=""` attributes; positioning is by
|
|
41
|
+
* CSS rules matching tag + ancestor + DOM order inside @scope (list-window-ui).
|
|
42
|
+
*
|
|
43
|
+
* See SPEC-022 for the full contract.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { UIElement } from '../../core/element.js';
|
|
47
|
+
|
|
48
|
+
export class UIListWindow extends UIElement {
|
|
49
|
+
static properties = {
|
|
50
|
+
items: { type: Array, default: [], reflect: false },
|
|
51
|
+
itemSize: { type: Number, default: 0, reflect: true, attribute: 'item-size' },
|
|
52
|
+
itemSizeRem: { type: Number, default: 0, reflect: true, attribute: 'item-size-rem' },
|
|
53
|
+
estimatedSize: { type: Number, default: 48, reflect: true, attribute: 'estimated-size' },
|
|
54
|
+
overscan: { type: Number, default: 3, reflect: true },
|
|
55
|
+
direction: { type: String, default: 'vertical', reflect: true },
|
|
56
|
+
pinBottom: { type: Boolean, default: false, reflect: true, attribute: 'pin-bottom' },
|
|
57
|
+
startIndex: { type: Number, default: 0, reflect: false, attribute: 'start-index' },
|
|
58
|
+
loading: { type: Boolean, default: false, reflect: true },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
static parts = {
|
|
62
|
+
phantom: '<div data-phantom></div>',
|
|
63
|
+
window: '<div data-window></div>',
|
|
64
|
+
sentinelTop: '<div data-sentinel="top"></div>',
|
|
65
|
+
sentinelBottom: '<div data-sentinel="bottom"></div>',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
static template = () => null;
|
|
69
|
+
|
|
70
|
+
// — Instance fields ———————————————————————————————————————————
|
|
71
|
+
|
|
72
|
+
#phantom = null;
|
|
73
|
+
#window = null;
|
|
74
|
+
#sentinelTop = null;
|
|
75
|
+
#sentinelBottom = null;
|
|
76
|
+
|
|
77
|
+
// Render function (set via .render = fn). Mutually exclusive with the
|
|
78
|
+
// <template> child path. Property name shadows the lifecycle render()
|
|
79
|
+
// hook intentionally — the base class invokes render() via prototype
|
|
80
|
+
// dispatch, while the consumer-set `render` field is read off the
|
|
81
|
+
// instance in #materialize(). Guarded with a typeof check there.
|
|
82
|
+
#renderFn = null;
|
|
83
|
+
|
|
84
|
+
// Optional key-fn for DOM reconciliation. Defaults to identity.
|
|
85
|
+
// (`items[i] || i` — array indices are stable when items is stable.)
|
|
86
|
+
#keyFn = (item, idx) => (item && typeof item === 'object' && 'id' in item ? item.id : idx);
|
|
87
|
+
|
|
88
|
+
// Row DOM cache keyed by row index. Persists across re-renders so we
|
|
89
|
+
// can detach/reattach without losing focus state on stable rows.
|
|
90
|
+
#rowCache = new Map();
|
|
91
|
+
|
|
92
|
+
// Offset cache for variable-height mode. Map<index, height>.
|
|
93
|
+
// Capped (LRU eviction) at 100_000 entries per SPEC-022 §OD-002 lean.
|
|
94
|
+
#measurementCache = new Map();
|
|
95
|
+
#MEASURE_CACHE_CAP = 100_000;
|
|
96
|
+
|
|
97
|
+
// Last known visible range — emitted with `range-change`.
|
|
98
|
+
#lastStart = -1;
|
|
99
|
+
#lastEnd = -1;
|
|
100
|
+
|
|
101
|
+
// Last scroll position; tracked so #onScroll can compare deltas and
|
|
102
|
+
// suppress the `scroll-end` event during programmatic scrolls.
|
|
103
|
+
#lastScrollPos = 0;
|
|
104
|
+
|
|
105
|
+
// Whether the previous render saw the host scrolled to the bottom.
|
|
106
|
+
// Captured pre-mutation so a subsequent `render()` (triggered by
|
|
107
|
+
// items[] reassignment) knows to re-pin even though totalSize has
|
|
108
|
+
// already grown. Updated by both `#onScroll` and post-render below.
|
|
109
|
+
#lastWasAtBottom = true;
|
|
110
|
+
|
|
111
|
+
// ResizeObserver for variable-height rows.
|
|
112
|
+
#resizeObserver = null;
|
|
113
|
+
|
|
114
|
+
// IntersectionObserver for sentinels (scroll-start / scroll-end).
|
|
115
|
+
#intersectionObserver = null;
|
|
116
|
+
|
|
117
|
+
// Stable scroll handler reference (Lifecycle Axis 4 contract).
|
|
118
|
+
#onScroll = () => {
|
|
119
|
+
this.#materialize();
|
|
120
|
+
const pos = this.#scrollPos();
|
|
121
|
+
this.#lastScrollPos = pos;
|
|
122
|
+
this.#lastWasAtBottom = this.#isAtBottom();
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Stable click handler reference for `item-click` dispatch.
|
|
126
|
+
#onClick = (e) => {
|
|
127
|
+
const row = e.target.closest?.('[data-row]');
|
|
128
|
+
if (!row || row.parentElement !== this.#window) return;
|
|
129
|
+
const index = Number(row.dataset.index);
|
|
130
|
+
if (!Number.isInteger(index) || index < 0) return;
|
|
131
|
+
const item = this.items?.[index];
|
|
132
|
+
if (item == null) return;
|
|
133
|
+
this.dispatchEvent(new CustomEvent('item-click', {
|
|
134
|
+
detail: { item, index },
|
|
135
|
+
bubbles: true,
|
|
136
|
+
}));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Stable keydown handler for keyboard scroll.
|
|
140
|
+
#onKeydown = (e) => {
|
|
141
|
+
// Only handle when the host itself is focused (not when focus is
|
|
142
|
+
// inside a row's interactive descendant — the row's keydown takes
|
|
143
|
+
// priority then).
|
|
144
|
+
if (e.target !== this) return;
|
|
145
|
+
const rowH = this.#fixedRowSize() || this.estimatedSize || 48;
|
|
146
|
+
const viewport = this.#viewportSize();
|
|
147
|
+
let delta = 0;
|
|
148
|
+
switch (e.key) {
|
|
149
|
+
case 'ArrowDown': delta = +rowH; break;
|
|
150
|
+
case 'ArrowUp': delta = -rowH; break;
|
|
151
|
+
case 'PageDown': delta = +viewport; break;
|
|
152
|
+
case 'PageUp': delta = -viewport; break;
|
|
153
|
+
case ' ': delta = +viewport; break;
|
|
154
|
+
case 'Home': e.preventDefault(); this.scrollToTop(); return;
|
|
155
|
+
case 'End': e.preventDefault(); this.scrollToBottom(); return;
|
|
156
|
+
default: return;
|
|
157
|
+
}
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
const pos = this.#scrollPos();
|
|
160
|
+
this.#setScrollPos(pos + delta);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Sentinel observer callback.
|
|
164
|
+
#onSentinel = (entries) => {
|
|
165
|
+
for (const entry of entries) {
|
|
166
|
+
if (!entry.isIntersecting) continue;
|
|
167
|
+
const which = entry.target?.dataset?.sentinel;
|
|
168
|
+
if (which === 'top') {
|
|
169
|
+
const idx = this.#lastStart >= 0 ? this.#lastStart : 0;
|
|
170
|
+
this.dispatchEvent(new CustomEvent('scroll-start', {
|
|
171
|
+
detail: { index: idx },
|
|
172
|
+
bubbles: true,
|
|
173
|
+
}));
|
|
174
|
+
} else if (which === 'bottom') {
|
|
175
|
+
const items = this.items || [];
|
|
176
|
+
const idx = this.#lastEnd >= 0 ? this.#lastEnd : items.length - 1;
|
|
177
|
+
this.dispatchEvent(new CustomEvent('scroll-end', {
|
|
178
|
+
detail: { index: idx },
|
|
179
|
+
bubbles: true,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Per-row resize observer — refines variable-height offset cache.
|
|
186
|
+
#onResize = (entries) => {
|
|
187
|
+
if (this.itemSize > 0) return; // fixed-size mode skips measurement
|
|
188
|
+
let changed = false;
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
const row = entry.target;
|
|
191
|
+
if (!row || row.parentElement !== this.#window) continue;
|
|
192
|
+
const idx = Number(row.dataset.index);
|
|
193
|
+
if (!Number.isInteger(idx)) continue;
|
|
194
|
+
const dim = this.direction === 'horizontal' ? entry.contentRect.width : entry.contentRect.height;
|
|
195
|
+
const next = Math.max(1, Math.round(dim));
|
|
196
|
+
const prev = this.#measurementCache.get(idx);
|
|
197
|
+
if (prev === next) continue;
|
|
198
|
+
this.#measurementCache.set(idx, next);
|
|
199
|
+
this.dispatchEvent(new CustomEvent('measure', {
|
|
200
|
+
detail: { index: idx, height: next },
|
|
201
|
+
bubbles: true,
|
|
202
|
+
}));
|
|
203
|
+
changed = true;
|
|
204
|
+
}
|
|
205
|
+
if (changed) {
|
|
206
|
+
this.#evictMeasurementCache();
|
|
207
|
+
this.#updatePhantomSize();
|
|
208
|
+
this.#materialize();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// — Lifecycle ——————————————————————————————————————————————
|
|
213
|
+
|
|
214
|
+
connected() {
|
|
215
|
+
this.setAttribute('role', this.getAttribute('role') || 'list');
|
|
216
|
+
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
|
|
217
|
+
|
|
218
|
+
this.#phantom = this.ensure('phantom');
|
|
219
|
+
this.#window = this.ensure('window');
|
|
220
|
+
this.#sentinelTop = this.ensure('sentinelTop');
|
|
221
|
+
this.#sentinelBottom = this.ensure('sentinelBottom');
|
|
222
|
+
|
|
223
|
+
// Observer setup — both are isolated to non-self targets so they
|
|
224
|
+
// need explicit teardown in disconnected().
|
|
225
|
+
if (typeof IntersectionObserver !== 'undefined') {
|
|
226
|
+
this.#intersectionObserver = new IntersectionObserver(this.#onSentinel, {
|
|
227
|
+
root: this,
|
|
228
|
+
rootMargin: '0px',
|
|
229
|
+
threshold: 0,
|
|
230
|
+
});
|
|
231
|
+
this.#intersectionObserver.observe(this.#sentinelTop);
|
|
232
|
+
this.#intersectionObserver.observe(this.#sentinelBottom);
|
|
233
|
+
}
|
|
234
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
235
|
+
this.#resizeObserver = new ResizeObserver(this.#onResize);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.addEventListener('scroll', this.#onScroll, { passive: true });
|
|
239
|
+
this.addEventListener('click', this.#onClick);
|
|
240
|
+
this.addEventListener('keydown', this.#onKeydown);
|
|
241
|
+
|
|
242
|
+
// Initial scroll-to-index, if requested.
|
|
243
|
+
if (this.startIndex > 0) {
|
|
244
|
+
// Defer to next frame — phantom needs to be sized first.
|
|
245
|
+
queueMicrotask(() => this.scrollToIndex(this.startIndex));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
disconnected() {
|
|
250
|
+
this.removeEventListener('scroll', this.#onScroll);
|
|
251
|
+
this.removeEventListener('click', this.#onClick);
|
|
252
|
+
this.removeEventListener('keydown', this.#onKeydown);
|
|
253
|
+
this.#intersectionObserver?.disconnect();
|
|
254
|
+
this.#intersectionObserver = null;
|
|
255
|
+
this.#resizeObserver?.disconnect();
|
|
256
|
+
this.#resizeObserver = null;
|
|
257
|
+
this.#rowCache.clear();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The base-class `render()` hook re-runs on every signal change
|
|
261
|
+
// (items, itemSize, overscan, direction, loading, etc). Each pass:
|
|
262
|
+
//
|
|
263
|
+
// 1. Surface state attrs ([empty], aria-* counts, aria-busy).
|
|
264
|
+
// 2. Resize the phantom spacer to reflect total scroll height.
|
|
265
|
+
// 3. Maintain pin-bottom invariant before re-materializing.
|
|
266
|
+
// 4. Re-materialize the visible window.
|
|
267
|
+
render() {
|
|
268
|
+
if (!this.#window) return; // pre-connect; render() can fire from
|
|
269
|
+
// setup-time signal touch.
|
|
270
|
+
|
|
271
|
+
const items = Array.isArray(this.items) ? this.items : [];
|
|
272
|
+
|
|
273
|
+
// Pin-bottom check uses the LAST-seen "at bottom" state (tracked on
|
|
274
|
+
// scroll) so we still re-pin after an items[] append even though
|
|
275
|
+
// totalSize has already grown by the time we get here. The very
|
|
276
|
+
// first render uses the live position because there's no history
|
|
277
|
+
// yet — and isAtBottom returns true for empty/zero-total lists,
|
|
278
|
+
// which is what we want (pin a freshly-mounted thread).
|
|
279
|
+
const wasPinned = this.pinBottom && (this.#lastWasAtBottom || this.#isAtBottom());
|
|
280
|
+
|
|
281
|
+
if (items.length === 0) {
|
|
282
|
+
this.setAttribute('empty', '');
|
|
283
|
+
} else {
|
|
284
|
+
this.removeAttribute('empty');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
this.setAttribute('aria-rowcount', String(items.length));
|
|
288
|
+
if (this.loading) this.setAttribute('aria-busy', 'true');
|
|
289
|
+
else this.removeAttribute('aria-busy');
|
|
290
|
+
|
|
291
|
+
this.#updatePhantomSize();
|
|
292
|
+
this.#materialize();
|
|
293
|
+
|
|
294
|
+
if (wasPinned) this.scrollToBottom();
|
|
295
|
+
this.#lastWasAtBottom = this.#isAtBottom();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// — Public API ———————————————————————————————————————————————
|
|
299
|
+
|
|
300
|
+
/** Get-or-set the consumer's row render function. Setting triggers
|
|
301
|
+
* a re-materialization. When unset, falls back to the <template>
|
|
302
|
+
* child path or a minimal default row.
|
|
303
|
+
*
|
|
304
|
+
* NB — this prop SHADOWS the base-class render() lifecycle hook by
|
|
305
|
+
* name. `host.render = fn` writes to an instance field that the
|
|
306
|
+
* internal #materialize path reads; the base class still invokes
|
|
307
|
+
* the prototype `render()` method (defined above) via reactive
|
|
308
|
+
* effect. The two paths are completely separate.
|
|
309
|
+
*/
|
|
310
|
+
set renderRow(fn) {
|
|
311
|
+
this.#renderFn = typeof fn === 'function' ? fn : null;
|
|
312
|
+
this.#rowCache.clear();
|
|
313
|
+
this.#materialize();
|
|
314
|
+
}
|
|
315
|
+
get renderRow() {
|
|
316
|
+
return this.#renderFn;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Stable key-fn for DOM reconciliation. */
|
|
320
|
+
set keyFn(fn) {
|
|
321
|
+
this.#keyFn = typeof fn === 'function' ? fn : ((item, idx) => idx);
|
|
322
|
+
this.#rowCache.clear();
|
|
323
|
+
this.#materialize();
|
|
324
|
+
}
|
|
325
|
+
get keyFn() { return this.#keyFn; }
|
|
326
|
+
|
|
327
|
+
/** Scroll to a specific item index. */
|
|
328
|
+
scrollToIndex(idx, { behavior = 'auto', align = 'start' } = {}) {
|
|
329
|
+
const items = this.items || [];
|
|
330
|
+
if (!items.length) return;
|
|
331
|
+
const i = Math.max(0, Math.min(items.length - 1, idx | 0));
|
|
332
|
+
const offset = this.#offsetForIndex(i);
|
|
333
|
+
const viewport = this.#viewportSize();
|
|
334
|
+
const rowSize = this.#rowSizeForIndex(i);
|
|
335
|
+
let target = offset;
|
|
336
|
+
if (align === 'center') target = offset - (viewport / 2) + (rowSize / 2);
|
|
337
|
+
else if (align === 'end') target = offset - viewport + rowSize;
|
|
338
|
+
this.#setScrollPos(Math.max(0, target), behavior);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
scrollToTop() { this.#setScrollPos(0); }
|
|
342
|
+
scrollToBottom() {
|
|
343
|
+
const total = this.#totalSize();
|
|
344
|
+
this.#setScrollPos(Math.max(0, total));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Currently rendered window. */
|
|
348
|
+
getVisibleRange() {
|
|
349
|
+
return { startIndex: this.#lastStart, endIndex: this.#lastEnd };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Reset measurement cache from a given index (use when item content
|
|
353
|
+
* mutates and the previous measurements are stale). */
|
|
354
|
+
invalidateMeasurements(fromIndex = 0) {
|
|
355
|
+
if (fromIndex <= 0) {
|
|
356
|
+
this.#measurementCache.clear();
|
|
357
|
+
} else {
|
|
358
|
+
for (const key of [...this.#measurementCache.keys()]) {
|
|
359
|
+
if (key >= fromIndex) this.#measurementCache.delete(key);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
this.#updatePhantomSize();
|
|
363
|
+
this.#materialize();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// — Internals ——————————————————————————————————————————————
|
|
367
|
+
|
|
368
|
+
#fixedRowSize() {
|
|
369
|
+
if (this.itemSize > 0) return this.itemSize;
|
|
370
|
+
if (this.itemSizeRem > 0) {
|
|
371
|
+
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
372
|
+
return this.itemSizeRem * root;
|
|
373
|
+
}
|
|
374
|
+
return 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#rowSizeForIndex(idx) {
|
|
378
|
+
const fixed = this.#fixedRowSize();
|
|
379
|
+
if (fixed > 0) return fixed;
|
|
380
|
+
const measured = this.#measurementCache.get(idx);
|
|
381
|
+
if (measured != null) return measured;
|
|
382
|
+
return this.estimatedSize || 48;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#offsetForIndex(idx) {
|
|
386
|
+
const fixed = this.#fixedRowSize();
|
|
387
|
+
if (fixed > 0) return idx * fixed;
|
|
388
|
+
let off = 0;
|
|
389
|
+
for (let i = 0; i < idx; i++) off += this.#rowSizeForIndex(i);
|
|
390
|
+
return off;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#totalSize() {
|
|
394
|
+
const items = Array.isArray(this.items) ? this.items : [];
|
|
395
|
+
const n = items.length;
|
|
396
|
+
if (n === 0) return 0;
|
|
397
|
+
const fixed = this.#fixedRowSize();
|
|
398
|
+
if (fixed > 0) return n * fixed;
|
|
399
|
+
// Variable-height: sum measured + estimated for unmeasured.
|
|
400
|
+
let total = 0;
|
|
401
|
+
const est = this.estimatedSize || 48;
|
|
402
|
+
for (let i = 0; i < n; i++) {
|
|
403
|
+
total += this.#measurementCache.get(i) ?? est;
|
|
404
|
+
}
|
|
405
|
+
return total;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#viewportSize() {
|
|
409
|
+
if (this.direction === 'horizontal') return this.clientWidth || 0;
|
|
410
|
+
return this.clientHeight || 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#scrollPos() {
|
|
414
|
+
return this.direction === 'horizontal' ? this.scrollLeft : this.scrollTop;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
#setScrollPos(pos, behavior = 'auto') {
|
|
418
|
+
if (this.direction === 'horizontal') {
|
|
419
|
+
this.scrollTo({ left: pos, behavior });
|
|
420
|
+
} else {
|
|
421
|
+
this.scrollTo({ top: pos, behavior });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
#isAtBottom() {
|
|
426
|
+
const pos = this.#scrollPos();
|
|
427
|
+
const viewport = this.#viewportSize();
|
|
428
|
+
const total = this.#totalSize();
|
|
429
|
+
// Consider "at bottom" within 1px of total to handle sub-pixel rounding.
|
|
430
|
+
return pos + viewport >= total - 1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#updatePhantomSize() {
|
|
434
|
+
if (!this.#phantom) return;
|
|
435
|
+
const total = this.#totalSize();
|
|
436
|
+
if (this.direction === 'horizontal') {
|
|
437
|
+
this.#phantom.style.width = `${total}px`;
|
|
438
|
+
this.#phantom.style.height = '1px';
|
|
439
|
+
} else {
|
|
440
|
+
this.#phantom.style.height = `${total}px`;
|
|
441
|
+
this.#phantom.style.width = '1px';
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Compute the visible-row range given current scroll position. */
|
|
446
|
+
#computeRange() {
|
|
447
|
+
const items = Array.isArray(this.items) ? this.items : [];
|
|
448
|
+
const n = items.length;
|
|
449
|
+
if (n === 0) return { start: 0, end: 0 };
|
|
450
|
+
const viewport = this.#viewportSize();
|
|
451
|
+
const pos = this.#scrollPos();
|
|
452
|
+
const fixed = this.#fixedRowSize();
|
|
453
|
+
let start, end;
|
|
454
|
+
if (fixed > 0) {
|
|
455
|
+
start = Math.floor(pos / fixed);
|
|
456
|
+
end = Math.ceil((pos + viewport) / fixed);
|
|
457
|
+
} else {
|
|
458
|
+
// Variable-height: walk the offset cache.
|
|
459
|
+
const est = this.estimatedSize || 48;
|
|
460
|
+
let off = 0;
|
|
461
|
+
start = 0;
|
|
462
|
+
for (let i = 0; i < n; i++) {
|
|
463
|
+
const h = this.#measurementCache.get(i) ?? est;
|
|
464
|
+
if (off + h > pos) { start = i; break; }
|
|
465
|
+
off += h;
|
|
466
|
+
}
|
|
467
|
+
end = start;
|
|
468
|
+
let visible = 0;
|
|
469
|
+
for (let i = start; i < n; i++) {
|
|
470
|
+
const h = this.#measurementCache.get(i) ?? est;
|
|
471
|
+
visible += h;
|
|
472
|
+
end = i + 1;
|
|
473
|
+
if (visible >= viewport) break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const over = Math.max(0, this.overscan | 0);
|
|
477
|
+
start = Math.max(0, start - over);
|
|
478
|
+
end = Math.min(n, end + over);
|
|
479
|
+
return { start, end };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Acquire / re-position / release row DOM for the current range. */
|
|
483
|
+
#materialize() {
|
|
484
|
+
if (!this.#window) return;
|
|
485
|
+
const items = Array.isArray(this.items) ? this.items : [];
|
|
486
|
+
const n = items.length;
|
|
487
|
+
const { start, end } = this.#computeRange();
|
|
488
|
+
|
|
489
|
+
// Position the window region at the current scroll-offset.
|
|
490
|
+
const offset = this.#offsetForIndex(start);
|
|
491
|
+
if (this.direction === 'horizontal') {
|
|
492
|
+
this.#window.style.transform = `translateX(${offset}px)`;
|
|
493
|
+
} else {
|
|
494
|
+
this.#window.style.transform = `translateY(${offset}px)`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Loading state — emit N skeleton rows in lieu of real data.
|
|
498
|
+
if (this.loading) {
|
|
499
|
+
this.#renderLoadingState(end - start);
|
|
500
|
+
} else {
|
|
501
|
+
this.#reconcileRows(items, start, end);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Emit range-change when the visible window shifts.
|
|
505
|
+
if (start !== this.#lastStart || end !== this.#lastEnd) {
|
|
506
|
+
this.#lastStart = start;
|
|
507
|
+
this.#lastEnd = end;
|
|
508
|
+
this.dispatchEvent(new CustomEvent('range-change', {
|
|
509
|
+
detail: {
|
|
510
|
+
startIndex: start,
|
|
511
|
+
endIndex: end,
|
|
512
|
+
items: items.slice(start, end),
|
|
513
|
+
},
|
|
514
|
+
bubbles: true,
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Sentinel positioning — top sentinel sits at the start of the
|
|
519
|
+
// window region; bottom sentinel sits at the end. Both inside the
|
|
520
|
+
// phantom so the IntersectionObserver fires when they enter the
|
|
521
|
+
// host viewport.
|
|
522
|
+
void n; // sentinels are static-positioned in CSS; this is a no-op
|
|
523
|
+
// hook in case we later move them imperatively.
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
#reconcileRows(items, start, end) {
|
|
527
|
+
if (!this.#window) return;
|
|
528
|
+
|
|
529
|
+
// Detach all current row nodes — we'll re-append in order. (We
|
|
530
|
+
// unobserve from the resize observer first to avoid spurious
|
|
531
|
+
// measurement events during detach.)
|
|
532
|
+
const surviving = new Set();
|
|
533
|
+
const renderFn = this.#renderFn ?? this.#templateRenderer();
|
|
534
|
+
for (let i = start; i < end; i++) {
|
|
535
|
+
const item = items[i];
|
|
536
|
+
const key = this.#keyFn(item, i);
|
|
537
|
+
surviving.add(key);
|
|
538
|
+
let row = this.#rowCache.get(key);
|
|
539
|
+
if (!row) {
|
|
540
|
+
row = this.#stampRow(item, i, renderFn);
|
|
541
|
+
this.#rowCache.set(key, row);
|
|
542
|
+
} else {
|
|
543
|
+
// Update index dataset + ARIA on re-use; don't re-stamp content.
|
|
544
|
+
row.dataset.index = String(i);
|
|
545
|
+
row.setAttribute('aria-rowindex', String(i + 1));
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Build the in-order rendered list.
|
|
550
|
+
const desired = [];
|
|
551
|
+
for (let i = start; i < end; i++) {
|
|
552
|
+
const item = items[i];
|
|
553
|
+
const key = this.#keyFn(item, i);
|
|
554
|
+
const row = this.#rowCache.get(key);
|
|
555
|
+
if (row) desired.push(row);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Drop cached rows that are no longer in the visible window.
|
|
559
|
+
for (const [key, row] of this.#rowCache) {
|
|
560
|
+
if (!surviving.has(key)) {
|
|
561
|
+
this.#resizeObserver?.unobserve(row);
|
|
562
|
+
if (row.parentElement === this.#window) row.remove();
|
|
563
|
+
this.#rowCache.delete(key);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Re-attach surviving rows in order (in-place diff).
|
|
568
|
+
let cursor = this.#window.firstChild;
|
|
569
|
+
for (const row of desired) {
|
|
570
|
+
if (row !== cursor) this.#window.insertBefore(row, cursor);
|
|
571
|
+
cursor = row.nextSibling;
|
|
572
|
+
}
|
|
573
|
+
// Trim anything beyond the desired list (paranoia; should be empty
|
|
574
|
+
// after the cache-eviction pass above).
|
|
575
|
+
while (cursor) {
|
|
576
|
+
const next = cursor.nextSibling;
|
|
577
|
+
cursor.remove();
|
|
578
|
+
cursor = next;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// (Re-)observe rows for variable-height measurement.
|
|
582
|
+
if (this.itemSize <= 0 && this.itemSizeRem <= 0 && this.#resizeObserver) {
|
|
583
|
+
for (const row of desired) this.#resizeObserver.observe(row);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
#renderLoadingState(count) {
|
|
588
|
+
if (!this.#window) return;
|
|
589
|
+
this.#window.replaceChildren();
|
|
590
|
+
this.#rowCache.clear();
|
|
591
|
+
const max = Math.max(1, Math.min(20, count | 0 || 5));
|
|
592
|
+
const userLoading = this.querySelector(':scope > template[slot="loading"]');
|
|
593
|
+
for (let i = 0; i < max; i++) {
|
|
594
|
+
const row = document.createElement('div');
|
|
595
|
+
row.setAttribute('data-row', '');
|
|
596
|
+
row.setAttribute('data-skeleton-row', '');
|
|
597
|
+
row.dataset.index = String(i);
|
|
598
|
+
if (userLoading?.content) {
|
|
599
|
+
row.appendChild(userLoading.content.cloneNode(true));
|
|
600
|
+
} else {
|
|
601
|
+
const sk = document.createElement('skeleton-ui');
|
|
602
|
+
row.appendChild(sk);
|
|
603
|
+
}
|
|
604
|
+
this.#window.appendChild(row);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
#stampRow(item, index, renderFn) {
|
|
609
|
+
const row = document.createElement('div');
|
|
610
|
+
row.setAttribute('data-row', '');
|
|
611
|
+
row.setAttribute('role', 'listitem');
|
|
612
|
+
row.dataset.index = String(index);
|
|
613
|
+
row.setAttribute('aria-rowindex', String(index + 1));
|
|
614
|
+
let content = null;
|
|
615
|
+
if (typeof renderFn === 'function') {
|
|
616
|
+
try { content = renderFn(item, index); }
|
|
617
|
+
catch (err) { console.warn('[list-window-ui] render() threw:', err); }
|
|
618
|
+
}
|
|
619
|
+
if (content instanceof Node) {
|
|
620
|
+
row.appendChild(content);
|
|
621
|
+
} else if (content != null) {
|
|
622
|
+
row.textContent = String(content);
|
|
623
|
+
} else if (item != null && typeof item === 'object' && 'text' in item) {
|
|
624
|
+
// Sensible default — render an objects-with-a-text-field item via
|
|
625
|
+
// <list-item-ui> (matches the A2UI catalog default-renderer rule).
|
|
626
|
+
const li = document.createElement('list-item-ui');
|
|
627
|
+
li.setAttribute('text', String(item.text));
|
|
628
|
+
if ('description' in item) li.setAttribute('description', String(item.description));
|
|
629
|
+
if ('icon' in item) li.setAttribute('icon', String(item.icon));
|
|
630
|
+
row.appendChild(li);
|
|
631
|
+
} else if (item != null) {
|
|
632
|
+
row.textContent = String(item);
|
|
633
|
+
}
|
|
634
|
+
return row;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** When the consumer authored a <template> child (no [slot]), return a
|
|
638
|
+
* function that clones the template with naive ${item.field}
|
|
639
|
+
* substitution. The expression syntax is intentionally minimal —
|
|
640
|
+
* consumers who need richer rendering should set host.renderRow = fn. */
|
|
641
|
+
#templateRenderer() {
|
|
642
|
+
const tpl = this.querySelector(':scope > template:not([slot])');
|
|
643
|
+
if (!tpl) return null;
|
|
644
|
+
// Cache the parsed template string between calls.
|
|
645
|
+
if (this.#cachedTemplateText == null) {
|
|
646
|
+
this.#cachedTemplateText = tpl.innerHTML;
|
|
647
|
+
}
|
|
648
|
+
const text = this.#cachedTemplateText;
|
|
649
|
+
return (item, _idx) => {
|
|
650
|
+
const rendered = text.replace(/\$\{item\.([a-zA-Z0-9_]+)\}/g, (_, key) => {
|
|
651
|
+
const v = item && typeof item === 'object' ? item[key] : '';
|
|
652
|
+
return v == null ? '' : escapeHTML(String(v));
|
|
653
|
+
});
|
|
654
|
+
const frag = document.createElement('template');
|
|
655
|
+
frag.innerHTML = rendered;
|
|
656
|
+
return frag.content.cloneNode(true);
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
#cachedTemplateText = null;
|
|
660
|
+
|
|
661
|
+
/** LRU-cap the measurement cache. Per SPEC-022 §OD-002 lean: 100_000
|
|
662
|
+
* entries ≈ 1.6 MB on a 1M list — bounded, but acceptable on
|
|
663
|
+
* mid-tier devices. */
|
|
664
|
+
#evictMeasurementCache() {
|
|
665
|
+
if (this.#measurementCache.size <= this.#MEASURE_CACHE_CAP) return;
|
|
666
|
+
const drop = this.#measurementCache.size - this.#MEASURE_CACHE_CAP;
|
|
667
|
+
let dropped = 0;
|
|
668
|
+
for (const key of this.#measurementCache.keys()) {
|
|
669
|
+
this.#measurementCache.delete(key);
|
|
670
|
+
if (++dropped >= drop) break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Tiny HTML-escape — the template literal renderer (#templateRenderer
|
|
676
|
+
// above) is the only call site; it interpolates string values from the
|
|
677
|
+
// consumer's items[] into raw HTML, so we escape to keep XSS surface
|
|
678
|
+
// area small. Consumers needing rich markup should set `host.renderRow`
|
|
679
|
+
// (function path) which receives the raw item and can return Nodes.
|
|
680
|
+
function escapeHTML(s) {
|
|
681
|
+
return s.replace(/[&<>"']/g, (c) => ({
|
|
682
|
+
'&': '&',
|
|
683
|
+
'<': '<',
|
|
684
|
+
'>': '>',
|
|
685
|
+
'"': '"',
|
|
686
|
+
"'": ''',
|
|
687
|
+
})[c]);
|
|
688
|
+
}
|