@c2n/virtual-list 0.0.7

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Nguyen Thai Vinh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @c2n/virtual-list
2
+
3
+ `<c2-virtual-list>` renders only the rows you can see. It windows `items` to the visible range plus an overscan
4
+ margin, so 50 000 rows cost the same DOM as twenty — and every visible row is a real `c2-list-item`, so the markup,
5
+ slots, selection styling and theming are the ones you already know from `c2-list`.
6
+
7
+ ```bash
8
+ npm install @c2n/virtual-list
9
+ ```
10
+
11
+ ```html
12
+ <c2-virtual-list
13
+ style="height: 320px"
14
+ aria-label="People"
15
+ searchable
16
+ highlight
17
+ selection="single"
18
+ item-key="id"
19
+ label-field="name"
20
+ description-field="team"
21
+ ></c2-virtual-list>
22
+ ```
23
+
24
+ ```js
25
+ import '@c2n/virtual-list'
26
+
27
+ const list = document.querySelector('c2-virtual-list')
28
+ list.items = people
29
+ list.addEventListener('selection-change', (event) => console.log(event.detail.value, event.detail.items))
30
+ ```
31
+
32
+ - **Search** — `searchable` adds a field above the list; `search` is also a plain property, so an external input can
33
+ drive it. `search-fields` narrows what a query is matched against, `matcher` replaces the matching outright,
34
+ `min-search-length` and `search-debounce` tune when it runs, and `highlight` wraps what matched in `<mark>`.
35
+ - **Sorting** — `sort="score:desc"`, or the `sort` property with an optional `comparator`, orders the rows.
36
+ - **Selection** — `selection="single|multiple"` with `value` as the array of keys; ⌘/Ctrl-click toggles, shift-click
37
+ extends, ⌘/Ctrl-A selects everything. `item-key` decides identity.
38
+ - **Async data** — set `dataSource` to `{ getItems({ start, count, search, sort }) }` and the list holds nothing: it
39
+ asks for one `block-size` block at a time as rows scroll into view, and hands the query and the sort to the server.
40
+ Rows whose block has not arrived render as skeletons.
41
+ - **Keyboard and a11y** — the rows are a `listbox` of `option`s with a roving tab stop, `aria-posinset`/`aria-setsize`
42
+ (most rows are not in the DOM), arrow keys, Home/End, PageUp/PageDown and Enter/Space. Set `aria-label` on the host
43
+ to name the list.
44
+
45
+ Give the host a height (or `--c2-virtual-list--max-height`). Windowing needs a **uniform** row height, which comes
46
+ from `--c2-virtual-list__item--height` and is re-measured from the first rendered row; `item-height` is only the
47
+ first-paint estimate. Two-line rows have to raise the variable.
48
+
49
+ Theming is the usual `--c2-virtual-list__<part>--<property>` set: the root box, `search--*` and `search-field--*`
50
+ (which drive the composed `c2-text-field`), `item--height`, `highlight--*`, `skeleton--*`,
51
+ `state--*` and `footer--*`. Everything inside a row is themed through `c2-list-item`'s own variables.
File without changes
@@ -0,0 +1,688 @@
1
+ import { LitElement, html, nothing, unsafeCSS } from "lit";
2
+ import { property, query, state } from "lit/decorators.js";
3
+ import { customElement } from "@c2n/core/element-helper.js";
4
+ import { arrayPropertyConverter, jsonPropertyConverter } from "@c2n/core/lit-helper.js";
5
+ import { defaultCompare, getFieldValue, sortEntryConverter } from "@c2n/core/data-helper.js";
6
+ import { VirtualScrollController } from "@c2n/core/controllers/virtual-scroll.js";
7
+ import "@c2n/list-item";
8
+ import "@c2n/spinner";
9
+ import "@c2n/text-field";
10
+ //#region src/virtual-list.scss?inline
11
+ var virtual_list_default = "/* ex : var((width: 24px), width, c2-checkbox) returns var(--c2-checkbox-width, 24px) */\n:host {\n display: flex;\n flex-direction: column;\n box-sizing: border-box;\n min-height: 0;\n overflow: hidden;\n background: var(--c2-virtual-list--background,#ffffff);\n color: var(--c2-virtual-list--color,#18181b);\n font-size: var(--c2-virtual-list--font-size,14px);\n max-height: var(--c2-virtual-list--max-height,none);\n border-top: var(--c2-virtual-list--border-top);\n border-right: var(--c2-virtual-list--border-right);\n border-bottom: var(--c2-virtual-list--border-bottom);\n border-left: var(--c2-virtual-list--border-left);\n border-top-left-radius: var(--c2-virtual-list--border-top-left-radius,8px);\n border-top-right-radius: var(--c2-virtual-list--border-top-right-radius,8px);\n border-bottom-left-radius: var(--c2-virtual-list--border-bottom-left-radius,8px);\n border-bottom-right-radius: var(--c2-virtual-list--border-bottom-right-radius,8px);\n box-shadow: var(--c2-virtual-list--box-shadow);\n}\n\n:host([hidden]) {\n display: none;\n}\n\n.search {\n display: flex;\n align-items: center;\n gap: var(--c2-virtual-list__search--gap,8px);\n flex: none;\n background: var(--c2-virtual-list__search--background,transparent);\n padding: var(--c2-virtual-list__search--padding,8px);\n border-bottom: var(--c2-virtual-list__search--border-bottom,1px solid #e4e4e7);\n}\n\n.search[hidden] {\n display: none;\n}\n\n.search-field {\n flex: 1 1 auto;\n min-width: 0;\n --c2-text-field--background: var(--c2-virtual-list__search-field--background,#ffffff);\n --c2-text-field--border-top: var(--c2-virtual-list__search-field--border,1px solid #e4e4e7);\n --c2-text-field--border-right: var(--c2-virtual-list__search-field--border,1px solid #e4e4e7);\n --c2-text-field--border-bottom: var(--c2-virtual-list__search-field--border,1px solid #e4e4e7);\n --c2-text-field--border-left: var(--c2-virtual-list__search-field--border,1px solid #e4e4e7);\n --c2-text-field--border-top-left-radius: var(--c2-virtual-list__search-field--border-radius,6px);\n --c2-text-field--border-top-right-radius: var(--c2-virtual-list__search-field--border-radius,6px);\n --c2-text-field--border-bottom-left-radius: var(--c2-virtual-list__search-field--border-radius,6px);\n --c2-text-field--border-bottom-right-radius: var(--c2-virtual-list__search-field--border-radius,6px);\n --c2-text-field--min-height: var(--c2-virtual-list__search-field--min-height,32px);\n}\n\n.viewport {\n flex: 1 1 auto;\n min-height: 0;\n overflow: auto;\n overscroll-behavior: contain;\n overflow-anchor: none;\n padding: var(--c2-virtual-list__viewport--padding,4px);\n}\n\n.items {\n display: flex;\n flex-direction: column;\n}\n\n.item {\n flex: none;\n box-sizing: border-box;\n height: var(--c2-virtual-list__item--height,36px);\n}\n\n.spacer {\n flex: none;\n}\n\nmark {\n background: var(--c2-virtual-list__highlight--background,#fef08a);\n color: var(--c2-virtual-list__highlight--color,inherit);\n font-weight: var(--c2-virtual-list__highlight--font-weight,600);\n border-radius: var(--c2-virtual-list__highlight--border-radius,4px);\n}\n\n.skeleton {\n display: block;\n width: 60%;\n height: 0.7em;\n background: var(--c2-virtual-list__skeleton--background,#f4f4f5);\n border-radius: var(--c2-virtual-list__skeleton--border-radius,4px);\n}\n\n.state {\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n color: var(--c2-virtual-list__state--color,#71717a);\n padding: var(--c2-virtual-list__state--padding,32px 12px);\n font-size: var(--c2-virtual-list__state--font-size,14px);\n}\n\n.state--error {\n color: var(--c2-virtual-list__state__error--color,rgb(211, 21, 16));\n}\n\n.footer {\n flex: none;\n background: var(--c2-virtual-list__footer--background,transparent);\n padding: var(--c2-virtual-list__footer--padding,8px 12px);\n border-top: var(--c2-virtual-list__footer--border-top,1px solid #e4e4e7);\n}\n\n.footer[hidden] {\n display: none;\n}";
12
+ //#endregion
13
+ //#region \0@oxc-project+runtime@0.148.0/helpers/esm/decorate.js
14
+ function __decorate(decorators, target, key, desc) {
15
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
16
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
17
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
18
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
19
+ }
20
+ //#endregion
21
+ //#region src/virtual-list.ts
22
+ /** How long a typed run is treated as one typeahead query, matching `c2-list`. */
23
+ var TYPEAHEAD_WINDOW = 600;
24
+ /** Fields tried in order when no `label-field` is given, so a list of plain objects renders without configuration. */
25
+ var IMPLICIT_LABEL_FIELDS = [
26
+ "label",
27
+ "name",
28
+ "title",
29
+ "value"
30
+ ];
31
+ function escapeRegExp(value) {
32
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
33
+ }
34
+ var VirtualList = class VirtualList extends LitElement {
35
+ constructor(..._args) {
36
+ super(..._args);
37
+ this.items = [];
38
+ this.itemKey = "";
39
+ this.labelField = "";
40
+ this.descriptionField = "";
41
+ this.disabledField = "";
42
+ this.searchable = false;
43
+ this.search = "";
44
+ this.searchFields = [];
45
+ this.minSearchLength = 1;
46
+ this.searchDebounce = 200;
47
+ this.searchPlaceholder = "Search";
48
+ this.highlight = false;
49
+ this.selection = "none";
50
+ this.value = [];
51
+ this.virtual = "auto";
52
+ this.itemHeight = 36;
53
+ this.overscan = 6;
54
+ this.virtualThreshold = 100;
55
+ this.blockSize = 100;
56
+ this.loading = false;
57
+ this.error = "";
58
+ this.emptyMessage = "No items";
59
+ this.noResultsMessage = "No matches";
60
+ this.hasToolbar = false;
61
+ this.hasFooter = false;
62
+ this.remoteTotal = -1;
63
+ this.focusedIndex = 0;
64
+ this.#visibleItems = [];
65
+ this.#blocks = /* @__PURE__ */ new Map();
66
+ this.#pendingBlocks = /* @__PURE__ */ new Set();
67
+ this.#measuredItemHeight = 0;
68
+ this.#selectedKeysSet = /* @__PURE__ */ new Set();
69
+ this.#selectionAnchor = -1;
70
+ this.#requestToken = 0;
71
+ this.#pendingFocus = false;
72
+ this.#pendingScrollTop = false;
73
+ this.#typeahead = "";
74
+ this.#virtualizer = new VirtualScrollController(this, {
75
+ scrollElement: () => this.viewport,
76
+ itemCount: () => this.itemCount,
77
+ itemHeight: () => this.#itemHeightPx,
78
+ overscan: () => this.overscan,
79
+ enabled: () => this.isVirtualized
80
+ });
81
+ this.#handleSearchInput = (event) => {
82
+ this.#scheduleSearch(event.target.value);
83
+ };
84
+ this.#handleSearchClear = () => {
85
+ clearTimeout(this.#searchTimer);
86
+ this.search = "";
87
+ };
88
+ this.#handleSearchKeyDown = (event) => {
89
+ if (event.key !== "ArrowDown" && event.key !== "Enter") return;
90
+ if (this.itemCount === 0) return;
91
+ event.preventDefault();
92
+ this.#moveFocus(this.focusedIndex);
93
+ };
94
+ this.#handleClick = (event) => {
95
+ const row = event.target?.closest?.("c2-list-item");
96
+ if (!(row instanceof HTMLElement) || !row.classList.contains("item")) return;
97
+ const index = Number(row.dataset.index);
98
+ if (!Number.isInteger(index)) return;
99
+ const item = this.#itemAt(index);
100
+ if (item === void 0 || this.#isDisabled(item)) return;
101
+ this.focusedIndex = index;
102
+ this.#activate(index, item, event.shiftKey, event.metaKey || event.ctrlKey);
103
+ };
104
+ this.#handleKeyDown = (event) => {
105
+ const count = this.itemCount;
106
+ if (count === 0) return;
107
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a" && this.selection === "multiple") {
108
+ event.preventDefault();
109
+ this.selectAll();
110
+ return;
111
+ }
112
+ const viewportRows = Math.max(1, Math.floor((this.viewport?.clientHeight ?? 0) / this.#itemHeightPx) - 1);
113
+ let index = this.focusedIndex;
114
+ switch (event.key) {
115
+ case "ArrowDown":
116
+ index = Math.min(count - 1, index + 1);
117
+ break;
118
+ case "ArrowUp":
119
+ index = Math.max(0, index - 1);
120
+ break;
121
+ case "Home":
122
+ index = 0;
123
+ break;
124
+ case "End":
125
+ index = count - 1;
126
+ break;
127
+ case "PageDown":
128
+ index = Math.min(count - 1, index + viewportRows);
129
+ break;
130
+ case "PageUp":
131
+ index = Math.max(0, index - viewportRows);
132
+ break;
133
+ case "Enter":
134
+ case " ": {
135
+ const item = this.#itemAt(index);
136
+ if (item === void 0 || this.#isDisabled(item)) return;
137
+ event.preventDefault();
138
+ this.#activate(index, item, event.shiftKey, event.metaKey || event.ctrlKey);
139
+ return;
140
+ }
141
+ default:
142
+ if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
143
+ event.preventDefault();
144
+ if (this.searchable) this.#typeIntoSearchField(event.key);
145
+ else this.#typeaheadTo(event.key);
146
+ }
147
+ return;
148
+ }
149
+ event.preventDefault();
150
+ this.#moveFocus(index);
151
+ };
152
+ }
153
+ static {
154
+ this.styles = unsafeCSS(virtual_list_default);
155
+ }
156
+ /** The items on show: `items` after search and sort. Empty while a `dataSource` is in charge. */
157
+ #visibleItems;
158
+ #blocks;
159
+ #pendingBlocks;
160
+ #measuredItemHeight;
161
+ /** `value` as a set, rebuilt only when the array identity changes, so a big selection costs one lookup per row. */
162
+ #selectedKeysOf;
163
+ #selectedKeysSet;
164
+ #selectionAnchor;
165
+ #requestToken;
166
+ #pendingFocus;
167
+ #pendingScrollTop;
168
+ #searchTimer;
169
+ #typeahead;
170
+ #typeaheadTimer;
171
+ #virtualizer;
172
+ /** Number of items on show right now: the filtered `items`, or the `dataSource` total for the current query. */
173
+ get itemCount() {
174
+ return this.dataSource ? Math.max(0, this.remoteTotal) : this.#visibleItems.length;
175
+ }
176
+ /** Whether the rows are currently windowed. */
177
+ get isVirtualized() {
178
+ if (this.virtual === "never") return false;
179
+ if (this.virtual === "always") return true;
180
+ return this.itemCount > this.virtualThreshold;
181
+ }
182
+ /** Whether a query is currently narrowing the list. */
183
+ get searching() {
184
+ return this.search.trim().length >= this.minSearchLength;
185
+ }
186
+ get #itemHeightPx() {
187
+ return this.#measuredItemHeight || this.itemHeight;
188
+ }
189
+ get #selectedKeys() {
190
+ if (this.#selectedKeysOf !== this.value) {
191
+ this.#selectedKeysOf = this.value;
192
+ this.#selectedKeysSet = new Set(this.value);
193
+ }
194
+ return this.#selectedKeysSet;
195
+ }
196
+ get #isBootstrapping() {
197
+ return Boolean(this.dataSource) && this.remoteTotal < 0 && !this.error;
198
+ }
199
+ /** The items currently selected. Only the loaded ones when a `dataSource` is used. */
200
+ getSelectedItems() {
201
+ const keys = this.#selectedKeys;
202
+ const selected = [];
203
+ for (let index = 0; index < this.itemCount; index++) {
204
+ const item = this.#itemAt(index);
205
+ if (item !== void 0 && keys.has(this.#keyAt(index, item))) selected.push(item);
206
+ }
207
+ return selected;
208
+ }
209
+ /** Selects every loaded item. Only meaningful with `selection="multiple"`. */
210
+ selectAll() {
211
+ if (this.selection !== "multiple") return;
212
+ const keys = [];
213
+ for (let index = 0; index < this.itemCount; index++) {
214
+ const item = this.#itemAt(index);
215
+ if (item !== void 0) keys.push(this.#keyAt(index, item));
216
+ }
217
+ this.#commitSelection(keys);
218
+ }
219
+ clearSelection() {
220
+ this.#commitSelection([]);
221
+ }
222
+ /** Scrolls the item at `index` into view, aligning it to the closest edge. */
223
+ scrollToIndex(index) {
224
+ this.#virtualizer.scrollToIndex(index);
225
+ }
226
+ /** Moves the roving focus to `index`, scrolling it into view first. */
227
+ focusItem(index) {
228
+ this.#moveFocus(index);
229
+ }
230
+ /**
231
+ * Re-reads the data. With a `dataSource` that drops every cached block and asks for the visible one again;
232
+ * otherwise it re-runs the search and the sort — which is what an `items` array mutated in place needs, since
233
+ * Lit only sees a new array.
234
+ */
235
+ refresh() {
236
+ if (this.dataSource) {
237
+ this.#resetRemote();
238
+ return;
239
+ }
240
+ this.#rebuildVisibleItems();
241
+ this.requestUpdate();
242
+ }
243
+ disconnectedCallback() {
244
+ super.disconnectedCallback();
245
+ clearTimeout(this.#searchTimer);
246
+ clearTimeout(this.#typeaheadTimer);
247
+ }
248
+ willUpdate(changed) {
249
+ if (typeof this.value === "string") this.value = arrayPropertyConverter.fromAttribute(this.value);
250
+ if (typeof this.searchFields === "string") this.searchFields = arrayPropertyConverter.fromAttribute(this.searchFields);
251
+ const queryChanged = changed.has("search") || changed.has("minSearchLength");
252
+ const sortChanged = changed.has("sort") || changed.has("comparator");
253
+ if (this.dataSource) {
254
+ if (queryChanged || sortChanged || changed.has("dataSource")) this.#resetRemote();
255
+ } else if (queryChanged || sortChanged || changed.has("items") || changed.has("searchFields") || changed.has("matcher") || changed.has("labelField") || changed.has("descriptionField")) {
256
+ this.#rebuildVisibleItems();
257
+ if (queryChanged || sortChanged) this.#pendingScrollTop = true;
258
+ }
259
+ if (queryChanged && changed.get("search") !== void 0) this.dispatchEvent(new CustomEvent("search-change", {
260
+ bubbles: true,
261
+ composed: true,
262
+ detail: {
263
+ search: this.search,
264
+ matchCount: this.dataSource ? this.remoteTotal : this.#visibleItems.length
265
+ }
266
+ }));
267
+ }
268
+ updated(changed) {
269
+ super.updated(changed);
270
+ if (this.#pendingScrollTop) {
271
+ this.#pendingScrollTop = false;
272
+ if (this.viewport) this.viewport.scrollTop = 0;
273
+ }
274
+ this.#measureItemHeight();
275
+ if (this.#pendingFocus) {
276
+ const row = this.renderRoot.querySelector(".item[tabindex=\"0\"]");
277
+ if (row) {
278
+ this.#pendingFocus = false;
279
+ row.focus();
280
+ }
281
+ } else this.#syncFocusToWindow();
282
+ if (this.dataSource) this.#ensureBlocks();
283
+ }
284
+ render() {
285
+ const range = this.#virtualizer.range;
286
+ const count = this.itemCount;
287
+ const hasSearchBar = this.searchable || this.hasToolbar;
288
+ return html`
289
+ <div class="search" part="search" ?hidden=${!hasSearchBar}>
290
+ ${this.searchable ? html`<slot name="search">${this.#renderSearchField()}</slot>` : nothing}
291
+ <slot name="toolbar" @slotchange=${(event) => this.hasToolbar = this.#slotHasContent(event)}></slot>
292
+ </div>
293
+ <div class="viewport" part="viewport">
294
+ <div
295
+ class="items"
296
+ part="items"
297
+ role="listbox"
298
+ aria-multiselectable=${this.selection === "multiple" ? "true" : nothing}
299
+ aria-busy=${this.loading || this.#isBootstrapping ? "true" : "false"}
300
+ aria-label=${this.ariaLabel ?? "Items"}
301
+ @click=${this.#handleClick}
302
+ @keydown=${this.#handleKeyDown}
303
+ >
304
+ ${range.paddingTop > 0 ? html`<div class="spacer" role="presentation" style="height:${range.paddingTop}px"></div>` : nothing}
305
+ ${this.#renderRows(range.start, Math.min(range.end, count))}
306
+ ${range.paddingBottom > 0 ? html`<div class="spacer" role="presentation" style="height:${range.paddingBottom}px"></div>` : nothing}
307
+ </div>
308
+ ${this.#renderState(count)}
309
+ </div>
310
+ <div class="footer" part="footer" ?hidden=${!this.hasFooter}>
311
+ <slot name="footer" @slotchange=${(event) => this.hasFooter = this.#slotHasContent(event)}></slot>
312
+ </div>
313
+ `;
314
+ }
315
+ #renderSearchField() {
316
+ return html`
317
+ <c2-text-field
318
+ class="search-field"
319
+ type="search"
320
+ clearable
321
+ .value=${this.search}
322
+ placeholder=${this.searchPlaceholder}
323
+ aria-label=${this.searchPlaceholder}
324
+ @input=${this.#handleSearchInput}
325
+ @clear=${this.#handleSearchClear}
326
+ @keydown=${this.#handleSearchKeyDown}
327
+ ></c2-text-field>
328
+ `;
329
+ }
330
+ #renderState(count) {
331
+ if (this.error) return html`<div class="state state--error" part="state"><slot name="error">${this.error}</slot></div>`;
332
+ if (count > 0) return nothing;
333
+ if (this.loading || this.#isBootstrapping) return html`<div class="state" part="state">
334
+ <slot name="loading"><c2-spinner></c2-spinner></slot>
335
+ </div>`;
336
+ if (this.searching) return html`<div class="state" part="state"><slot name="no-results">${this.noResultsMessage}</slot></div>`;
337
+ return html`<div class="state" part="state"><slot name="empty">${this.emptyMessage}</slot></div>`;
338
+ }
339
+ #renderRows(start, end) {
340
+ const rows = [];
341
+ for (let index = start; index < end; index++) rows.push(this.#renderRow(index));
342
+ return rows;
343
+ }
344
+ #renderRow(index) {
345
+ const item = this.#itemAt(index);
346
+ if (item === void 0) return html`
347
+ <c2-list-item class="item" part="item" .applyContext=${true} .disabled=${true} aria-busy="true" data-index=${index}>
348
+ <span class="skeleton" part="skeleton"></span>
349
+ </c2-list-item>
350
+ `;
351
+ const key = this.#keyAt(index, item);
352
+ const selected = this.selection !== "none" && this.#selectedKeys.has(key);
353
+ const joinedBefore = selected && this.#isSelectedAt(index - 1);
354
+ const joinedAfter = selected && this.#isSelectedAt(index + 1);
355
+ return html`
356
+ <c2-list-item
357
+ class="item"
358
+ part=${`item${selected ? " item-selected" : ""}`}
359
+ data-index=${index}
360
+ ?joined-before=${joinedBefore}
361
+ ?joined-after=${joinedAfter}
362
+ .applyContext=${true}
363
+ .value=${key}
364
+ .data=${item}
365
+ .disabled=${this.#isDisabled(item)}
366
+ .selected=${selected}
367
+ tabindex=${index === this.focusedIndex ? 0 : -1}
368
+ aria-posinset=${index + 1}
369
+ aria-setsize=${this.itemCount}
370
+ >${this.#renderContent(item, index)}</c2-list-item
371
+ >
372
+ `;
373
+ }
374
+ #renderContent(item, index) {
375
+ if (this.renderItem) {
376
+ const context = {
377
+ item,
378
+ index,
379
+ search: this.search
380
+ };
381
+ return this.renderItem(context);
382
+ }
383
+ const description = this.descriptionField ? this.#textOf(getFieldValue(item, this.descriptionField)) : "";
384
+ return html`${this.#decorate(this.#labelOf(item))}${description ? html`<span slot="description">${this.#decorate(description)}</span>` : nothing}`;
385
+ }
386
+ /** Wraps every occurrence of the query in `<mark>`, so a match is visible without the reader hunting for it. */
387
+ #decorate(text) {
388
+ if (!this.highlight || !this.searching || !text) return text;
389
+ const query = this.search.trim();
390
+ const parts = text.split(new RegExp(`(${escapeRegExp(query)})`, "ig"));
391
+ if (parts.length === 1) return text;
392
+ return parts.map((part, index) => index % 2 === 1 ? html`<mark part="highlight">${part}</mark>` : part);
393
+ }
394
+ /** Search, then sort — the order a reader expects: they filter first and only then ask how it is ordered. */
395
+ #rebuildVisibleItems() {
396
+ const source = Array.isArray(this.items) ? this.items : [];
397
+ const next = this.searching ? source.filter((item) => this.#matches(item)) : source.slice();
398
+ this.#visibleItems = this.#applySort(next);
399
+ }
400
+ #matches(item) {
401
+ const query = this.search.trim().toLowerCase();
402
+ if (this.matcher) return this.matcher(item, query);
403
+ for (const value of this.#searchableValues(item)) if (value.toLowerCase().includes(query)) return true;
404
+ return false;
405
+ }
406
+ /** The strings a query is tested against: the declared fields, else the labelled ones, else everything scalar. */
407
+ #searchableValues(item) {
408
+ const fields = this.searchFields.length ? this.searchFields : [this.labelField, this.descriptionField].filter(Boolean);
409
+ if (fields.length) return fields.map((field) => this.#textOf(getFieldValue(item, field)));
410
+ if (item === null || typeof item !== "object") return [this.#textOf(item)];
411
+ return Object.values(item).filter((value) => typeof value === "string" || typeof value === "number").map((value) => String(value));
412
+ }
413
+ #applySort(items) {
414
+ const sort = this.sort;
415
+ if (!sort?.field) return items;
416
+ const compare = this.comparator ?? defaultCompare;
417
+ const direction = sort.direction === "desc" ? -1 : 1;
418
+ return items.sort((a, b) => direction * compare(getFieldValue(a, sort.field), getFieldValue(b, sort.field)));
419
+ }
420
+ #itemAt(index) {
421
+ if (!this.dataSource) return this.#visibleItems[index];
422
+ return this.#blocks.get(Math.floor(index / this.blockSize))?.[index % this.blockSize];
423
+ }
424
+ #keyAt(index, item) {
425
+ if (!this.itemKey) return String(index);
426
+ const key = getFieldValue(item, this.itemKey);
427
+ return key === void 0 || key === null ? String(index) : String(key);
428
+ }
429
+ /** Whether the row at `index` is selected — false for one a `dataSource` has not delivered, which cannot be. */
430
+ #isSelectedAt(index) {
431
+ if (index < 0 || index >= this.itemCount) return false;
432
+ const item = this.#itemAt(index);
433
+ return item !== void 0 && this.#selectedKeys.has(this.#keyAt(index, item));
434
+ }
435
+ #isDisabled(item) {
436
+ return this.disabledField ? Boolean(getFieldValue(item, this.disabledField)) : false;
437
+ }
438
+ #textOf(value) {
439
+ return value === void 0 || value === null ? "" : String(value);
440
+ }
441
+ /** A row's primary text: the declared field, the first conventional one, or the item itself for a list of strings. */
442
+ #labelOf(item) {
443
+ if (this.labelField) return this.#textOf(getFieldValue(item, this.labelField));
444
+ if (item === null || typeof item !== "object") return this.#textOf(item);
445
+ const record = item;
446
+ for (const field of IMPLICIT_LABEL_FIELDS) if (record[field] !== void 0) return this.#textOf(record[field]);
447
+ return "";
448
+ }
449
+ #resetRemote() {
450
+ this.#blocks.clear();
451
+ this.#pendingBlocks.clear();
452
+ this.#requestToken++;
453
+ this.remoteTotal = -1;
454
+ this.#pendingScrollTop = true;
455
+ }
456
+ /** Asks the source for whatever block the visible window needs and has not got. */
457
+ #ensureBlocks() {
458
+ const source = this.dataSource;
459
+ if (!source || this.error) return;
460
+ const range = this.#virtualizer.range;
461
+ const first = this.remoteTotal < 0 ? 0 : Math.floor(range.start / this.blockSize);
462
+ const last = this.remoteTotal < 0 ? 0 : Math.floor(Math.max(range.start, range.end - 1) / this.blockSize);
463
+ for (let block = first; block <= last; block++) {
464
+ if (this.#blocks.has(block) || this.#pendingBlocks.has(block)) continue;
465
+ this.#pendingBlocks.add(block);
466
+ const token = this.#requestToken;
467
+ source.getItems({
468
+ start: block * this.blockSize,
469
+ count: this.blockSize,
470
+ search: this.searching ? this.search.trim() : "",
471
+ sort: this.sort
472
+ }).then((result) => {
473
+ if (token !== this.#requestToken) return;
474
+ this.#blocks.set(block, result.items ?? []);
475
+ if (result.total !== void 0) this.remoteTotal = result.total;
476
+ else if (this.remoteTotal < 0) this.remoteTotal = (result.items ?? []).length;
477
+ this.requestUpdate();
478
+ }).catch((reason) => {
479
+ if (token !== this.#requestToken) return;
480
+ this.error = reason instanceof Error ? reason.message : String(reason);
481
+ }).finally(() => {
482
+ this.#pendingBlocks.delete(block);
483
+ });
484
+ }
485
+ }
486
+ #handleSearchInput;
487
+ #handleSearchClear;
488
+ #handleSearchKeyDown;
489
+ #scheduleSearch(next) {
490
+ clearTimeout(this.#searchTimer);
491
+ this.#searchTimer = setTimeout(() => {
492
+ this.search = next;
493
+ }, Math.max(0, this.searchDebounce));
494
+ }
495
+ /** Sends a character typed on the list to the search field, so typing anywhere in the component starts a search. */
496
+ #typeIntoSearchField(character) {
497
+ const field = this.searchFieldElement;
498
+ if (!field) return;
499
+ field.value = `${field.value}${character}`;
500
+ field.focus();
501
+ this.#scheduleSearch(field.value);
502
+ }
503
+ /** The `c2-list` behaviour for a list with no search field: jump to the next row whose label starts with the run. */
504
+ #typeaheadTo(character) {
505
+ clearTimeout(this.#typeaheadTimer);
506
+ this.#typeahead += character.toLowerCase();
507
+ this.#typeaheadTimer = setTimeout(() => this.#typeahead = "", TYPEAHEAD_WINDOW);
508
+ const count = this.itemCount;
509
+ const scan = Math.min(count, 2e3);
510
+ for (let step = 1; step <= scan; step++) {
511
+ const index = (this.focusedIndex + step) % count;
512
+ const item = this.#itemAt(index);
513
+ if (item === void 0) continue;
514
+ if (this.#labelOf(item).toLowerCase().startsWith(this.#typeahead)) {
515
+ this.#moveFocus(index);
516
+ return;
517
+ }
518
+ }
519
+ }
520
+ #handleClick;
521
+ #activate(index, item, range, additive) {
522
+ const key = this.#keyAt(index, item);
523
+ this.dispatchEvent(new CustomEvent("item-click", {
524
+ bubbles: true,
525
+ composed: true,
526
+ detail: {
527
+ item,
528
+ index,
529
+ key
530
+ }
531
+ }));
532
+ if (this.selection === "none") return;
533
+ if (this.selection === "single") {
534
+ this.#selectionAnchor = index;
535
+ this.#commitSelection([key]);
536
+ return;
537
+ }
538
+ if (range && this.#selectionAnchor >= 0) {
539
+ this.#commitSelection(this.#keysBetween(this.#selectionAnchor, index));
540
+ return;
541
+ }
542
+ this.#selectionAnchor = index;
543
+ if (additive) this.#commitSelection(this.value.includes(key) ? this.value.filter((entry) => entry !== key) : [...this.value, key]);
544
+ else this.#commitSelection([key]);
545
+ }
546
+ #keysBetween(from, to) {
547
+ const [start, end] = from <= to ? [from, to] : [to, from];
548
+ const keys = [];
549
+ for (let index = start; index <= end; index++) {
550
+ const item = this.#itemAt(index);
551
+ if (item !== void 0) keys.push(this.#keyAt(index, item));
552
+ }
553
+ return keys;
554
+ }
555
+ #commitSelection(next) {
556
+ if (next.length === this.value.length && next.every((key, index) => key === this.value[index])) return;
557
+ this.value = next;
558
+ this.dispatchEvent(new CustomEvent("selection-change", {
559
+ bubbles: false,
560
+ composed: true,
561
+ detail: {
562
+ value: [...next],
563
+ items: this.getSelectedItems()
564
+ }
565
+ }));
566
+ }
567
+ #handleKeyDown;
568
+ #moveFocus(index) {
569
+ const count = this.itemCount;
570
+ if (count === 0) return;
571
+ this.focusedIndex = Math.min(Math.max(index, 0), count - 1);
572
+ this.#pendingFocus = true;
573
+ this.#virtualizer.scrollToIndex(this.focusedIndex);
574
+ }
575
+ /**
576
+ * Keeps the roving tab stop inside the rendered window. Scrolling far away would otherwise leave no row with
577
+ * `tabindex="0"`, and the list would drop out of the tab order entirely.
578
+ */
579
+ #syncFocusToWindow() {
580
+ const { start, end } = this.#virtualizer.range;
581
+ if (end <= start) return;
582
+ const clamped = Math.min(Math.max(this.focusedIndex, start), end - 1);
583
+ if (clamped === this.focusedIndex) return;
584
+ if (this.shadowRoot?.activeElement) this.#pendingFocus = true;
585
+ this.focusedIndex = clamped;
586
+ }
587
+ #measureItemHeight() {
588
+ const row = this.renderRoot.querySelector(".item");
589
+ if (!row) return;
590
+ const height = row.getBoundingClientRect().height;
591
+ if (height > 0 && Math.abs(height - this.#measuredItemHeight) > .01) {
592
+ this.#measuredItemHeight = height;
593
+ this.requestUpdate();
594
+ }
595
+ }
596
+ #slotHasContent(event) {
597
+ return event.target.assignedNodes({ flatten: true }).some((node) => node.nodeType !== Node.TEXT_NODE || Boolean(node.textContent?.trim()));
598
+ }
599
+ };
600
+ __decorate([query(".viewport")], VirtualList.prototype, "viewport", void 0);
601
+ __decorate([query(".search-field")], VirtualList.prototype, "searchFieldElement", void 0);
602
+ __decorate([property({ converter: jsonPropertyConverter })], VirtualList.prototype, "items", void 0);
603
+ __decorate([property({
604
+ type: String,
605
+ attribute: "item-key"
606
+ })], VirtualList.prototype, "itemKey", void 0);
607
+ __decorate([property({
608
+ type: String,
609
+ attribute: "label-field"
610
+ })], VirtualList.prototype, "labelField", void 0);
611
+ __decorate([property({
612
+ type: String,
613
+ attribute: "description-field"
614
+ })], VirtualList.prototype, "descriptionField", void 0);
615
+ __decorate([property({
616
+ type: String,
617
+ attribute: "disabled-field"
618
+ })], VirtualList.prototype, "disabledField", void 0);
619
+ __decorate([property({ attribute: false })], VirtualList.prototype, "renderItem", void 0);
620
+ __decorate([property({ attribute: false })], VirtualList.prototype, "matcher", void 0);
621
+ __decorate([property({ attribute: false })], VirtualList.prototype, "comparator", void 0);
622
+ __decorate([property({ attribute: false })], VirtualList.prototype, "dataSource", void 0);
623
+ __decorate([property({ type: Boolean })], VirtualList.prototype, "searchable", void 0);
624
+ __decorate([property({
625
+ type: String,
626
+ reflect: true
627
+ })], VirtualList.prototype, "search", void 0);
628
+ __decorate([property({
629
+ converter: arrayPropertyConverter,
630
+ attribute: "search-fields"
631
+ })], VirtualList.prototype, "searchFields", void 0);
632
+ __decorate([property({
633
+ type: Number,
634
+ attribute: "min-search-length"
635
+ })], VirtualList.prototype, "minSearchLength", void 0);
636
+ __decorate([property({
637
+ type: Number,
638
+ attribute: "search-debounce"
639
+ })], VirtualList.prototype, "searchDebounce", void 0);
640
+ __decorate([property({
641
+ type: String,
642
+ attribute: "search-placeholder"
643
+ })], VirtualList.prototype, "searchPlaceholder", void 0);
644
+ __decorate([property({ type: Boolean })], VirtualList.prototype, "highlight", void 0);
645
+ __decorate([property({
646
+ converter: sortEntryConverter,
647
+ attribute: "sort",
648
+ reflect: true
649
+ })], VirtualList.prototype, "sort", void 0);
650
+ __decorate([property({ type: String })], VirtualList.prototype, "selection", void 0);
651
+ __decorate([property({
652
+ converter: arrayPropertyConverter,
653
+ reflect: true
654
+ })], VirtualList.prototype, "value", void 0);
655
+ __decorate([property({ type: String })], VirtualList.prototype, "virtual", void 0);
656
+ __decorate([property({
657
+ type: Number,
658
+ attribute: "item-height"
659
+ })], VirtualList.prototype, "itemHeight", void 0);
660
+ __decorate([property({ type: Number })], VirtualList.prototype, "overscan", void 0);
661
+ __decorate([property({
662
+ type: Number,
663
+ attribute: "virtual-threshold"
664
+ })], VirtualList.prototype, "virtualThreshold", void 0);
665
+ __decorate([property({
666
+ type: Number,
667
+ attribute: "block-size"
668
+ })], VirtualList.prototype, "blockSize", void 0);
669
+ __decorate([property({
670
+ type: Boolean,
671
+ reflect: true
672
+ })], VirtualList.prototype, "loading", void 0);
673
+ __decorate([property({ type: String })], VirtualList.prototype, "error", void 0);
674
+ __decorate([property({
675
+ type: String,
676
+ attribute: "empty-message"
677
+ })], VirtualList.prototype, "emptyMessage", void 0);
678
+ __decorate([property({
679
+ type: String,
680
+ attribute: "no-results-message"
681
+ })], VirtualList.prototype, "noResultsMessage", void 0);
682
+ __decorate([state()], VirtualList.prototype, "hasToolbar", void 0);
683
+ __decorate([state()], VirtualList.prototype, "hasFooter", void 0);
684
+ __decorate([state()], VirtualList.prototype, "remoteTotal", void 0);
685
+ __decorate([state()], VirtualList.prototype, "focusedIndex", void 0);
686
+ VirtualList = __decorate([customElement("c2-virtual-list")], VirtualList);
687
+ //#endregion
688
+ export { VirtualList };
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@c2n/virtual-list",
3
+ "version": "0.0.7",
4
+ "type": "module",
5
+ "main": "dist/virtual-list.js",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./types/src/virtual-list.d.ts",
9
+ "default": "./dist/virtual-list.js"
10
+ },
11
+ "./virtual-list-types.js": {
12
+ "types": "./types/src/virtual-list-types.d.ts",
13
+ "default": "./dist/virtual-list-types.js"
14
+ },
15
+ "./react": {
16
+ "types": "./react.d.ts",
17
+ "default": "./react.js"
18
+ },
19
+ "./vue": {
20
+ "types": "./vue.d.ts",
21
+ "default": "./vue.js"
22
+ },
23
+ "./custom-elements.json": "./custom-elements.json"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "types",
28
+ "react.d.ts",
29
+ "react.js",
30
+ "vue.d.ts",
31
+ "vue.js"
32
+ ],
33
+ "keywords": [
34
+ "virtual-list",
35
+ "virtual scroll",
36
+ "virtualized",
37
+ "search",
38
+ "web component",
39
+ "lit"
40
+ ],
41
+ "license": "MIT",
42
+ "author": "code2nguyen@gmail.com",
43
+ "publishConfig": {
44
+ "registry": "https://registry.npmjs.org",
45
+ "access": "public"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/code2nguyen/web-components.git"
50
+ },
51
+ "scripts": {
52
+ "dev": "vite",
53
+ "build": "wireit",
54
+ "build:only": "vite build",
55
+ "type-check": "wireit"
56
+ },
57
+ "wireit": {
58
+ "type-check": {
59
+ "dependencies": [
60
+ "../../core:build",
61
+ "../list-item:build",
62
+ "../text-field:build",
63
+ "../spinner:build"
64
+ ],
65
+ "command": "tsc -p tsconfig.lib.json --composite false"
66
+ },
67
+ "build": {
68
+ "dependencies": [
69
+ "type-check"
70
+ ],
71
+ "command": "vite build"
72
+ }
73
+ },
74
+ "dependencies": {
75
+ "@c2n/core": "0.0.7",
76
+ "@c2n/list-item": "0.0.7",
77
+ "@c2n/spinner": "0.0.7",
78
+ "@c2n/text-field": "0.0.7",
79
+ "lit": "3.3.3"
80
+ },
81
+ "devDependencies": {
82
+ "@c2n/config": "*"
83
+ },
84
+ "customElements": "custom-elements.json",
85
+ "gitHead": "c8db398a27f7cd417d665fdf5da455fee2d4e910"
86
+ }
package/react.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ // GENERATED by @c2n/framework-types. Do not edit by hand.
2
+ //
3
+ // JSX types for the custom elements of @c2n/virtual-list. Import once, anywhere in the program:
4
+ //
5
+ // import '@c2n/virtual-list/react'
6
+ //
7
+ // Register the elements at module scope before React renders, or React writes an object prop as an
8
+ // attribute and it stringifies.
9
+
10
+ import type { DetailedHTMLProps, HTMLAttributes } from 'react'
11
+ import type { VirtualList } from '@c2n/virtual-list'
12
+
13
+ /** Standard React host-element attributes plus the element's own public properties. */
14
+ type C2Props<T> = DetailedHTMLProps<HTMLAttributes<T>, T> & Partial<Omit<T, keyof HTMLElement>>
15
+
16
+ declare module 'react' {
17
+ namespace JSX {
18
+ interface IntrinsicElements {
19
+ 'c2-virtual-list': C2Props<VirtualList>
20
+ }
21
+ }
22
+ }
23
+
24
+ export {}
package/react.js ADDED
@@ -0,0 +1,3 @@
1
+ // GENERATED by @c2n/framework-types. Do not edit by hand.
2
+ // Types only — see the matching .d.ts.
3
+ export {}
@@ -0,0 +1,55 @@
1
+ import type { SortEntry } from '@c2n/core/data-helper.js';
2
+ export type { SortDirection, SortEntry } from '@c2n/core/data-helper.js';
3
+ export type VirtualListSelectionMode = 'none' | 'single' | 'multiple';
4
+ export type VirtualListVirtualMode = 'auto' | 'always' | 'never';
5
+ export interface VirtualListItemContext {
6
+ /** The item being rendered. `undefined` while its `dataSource` block is still loading. */
7
+ item: unknown;
8
+ /** Index of the item in the list as it is shown — after search and sort. */
9
+ index: number;
10
+ /** The query the list is filtered by right now, so a renderer can highlight it itself. */
11
+ search: string;
12
+ }
13
+ /** Returns anything Lit can render: a `TemplateResult`, a string, a number, a node. */
14
+ export type VirtualListItemRenderer = (context: VirtualListItemContext) => unknown;
15
+ /** Decides whether an item survives the current query. Replaces the built-in field matching entirely. */
16
+ export type VirtualListMatcher = (item: unknown, query: string) => boolean;
17
+ export interface VirtualListItemsRequest {
18
+ /** Index of the first item requested. */
19
+ start: number;
20
+ /** Number of items requested. */
21
+ count: number;
22
+ /** The query the server should filter by. Empty when the list is not being searched. */
23
+ search: string;
24
+ /** The sort the server should apply. */
25
+ sort?: SortEntry;
26
+ }
27
+ export interface VirtualListItemsResult {
28
+ items: unknown[];
29
+ /** Total number of items on the server *for this query*. Required on the first response so the scrollbar can be sized. */
30
+ total?: number;
31
+ }
32
+ /**
33
+ * Lazy item source: the list asks for one block of items at a time as they scroll into view, instead of holding the
34
+ * whole dataset in `items`. Searching and sorting are delegated to the source.
35
+ */
36
+ export interface VirtualListDataSource {
37
+ getItems(request: VirtualListItemsRequest): Promise<VirtualListItemsResult>;
38
+ }
39
+ export interface VirtualListSelectionChangeEventDetail {
40
+ /** Keys of the selected items (`item-key` field, or the item index when no `item-key` is set). */
41
+ value: string[];
42
+ /** The selected items themselves; only the loaded ones when a `dataSource` is used. */
43
+ items: unknown[];
44
+ }
45
+ export interface VirtualListItemEventDetail {
46
+ item: unknown;
47
+ index: number;
48
+ key: string;
49
+ }
50
+ export interface VirtualListSearchChangeEventDetail {
51
+ search: string;
52
+ /** How many items match, or `-1` while a `dataSource` has not reported a total yet. */
53
+ matchCount: number;
54
+ }
55
+ //# sourceMappingURL=virtual-list-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"virtual-list-types.d.ts","sourceRoot":"","sources":["../../src/virtual-list-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAA;AAEzD,YAAY,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAA;AAExE,MAAM,MAAM,wBAAwB,GAAG,MAAM,GAAG,QAAQ,GAAG,UAAU,CAAA;AACrE,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAA;AAEhE,MAAM,WAAW,sBAAsB;IACrC,0FAA0F;IAC1F,IAAI,EAAE,OAAO,CAAA;IACb,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAA;IACb,0FAA0F;IAC1F,MAAM,EAAE,MAAM,CAAA;CACf;AAED,uFAAuF;AACvF,MAAM,MAAM,uBAAuB,GAAG,CAAC,OAAO,EAAE,sBAAsB,KAAK,OAAO,CAAA;AAElF,yGAAyG;AACzG,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAA;AAE1E,MAAM,WAAW,uBAAuB;IACtC,yCAAyC;IACzC,KAAK,EAAE,MAAM,CAAA;IACb,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAA;IACb,wFAAwF;IACxF,MAAM,EAAE,MAAM,CAAA;IACd,wCAAwC;IACxC,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,OAAO,EAAE,CAAA;IAChB,0HAA0H;IAC1H,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,OAAO,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAA;CAC5E;AAED,MAAM,WAAW,qCAAqC;IACpD,kGAAkG;IAClG,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,uFAAuF;IACvF,KAAK,EAAE,OAAO,EAAE,CAAA;CACjB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,OAAO,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,kCAAkC;IACjD,MAAM,EAAE,MAAM,CAAA;IACd,uFAAuF;IACvF,UAAU,EAAE,MAAM,CAAA;CACnB"}
@@ -0,0 +1,218 @@
1
+ import { LitElement, type PropertyValues, type TemplateResult } from 'lit';
2
+ import type { TypedAddEventListener, TypedRemoveEventListener } from '@c2n/core/event-helper.js';
3
+ import { type SortEntry } from '@c2n/core/data-helper.js';
4
+ import type { VirtualListDataSource, VirtualListItemEventDetail, VirtualListItemRenderer, VirtualListMatcher, VirtualListSearchChangeEventDetail, VirtualListSelectionChangeEventDetail, VirtualListSelectionMode, VirtualListVirtualMode } from './virtual-list-types.js';
5
+ import '@c2n/list-item';
6
+ import '@c2n/spinner';
7
+ import '@c2n/text-field';
8
+ /** Events fired by {@link VirtualList}, keyed for `addEventListener`. */
9
+ export interface VirtualListEventMap {
10
+ 'selection-change': CustomEvent<VirtualListSelectionChangeEventDetail>;
11
+ 'item-click': CustomEvent<VirtualListItemEventDetail>;
12
+ 'search-change': CustomEvent<VirtualListSearchChangeEventDetail>;
13
+ }
14
+ export interface VirtualList {
15
+ addEventListener: TypedAddEventListener<VirtualList, VirtualListEventMap>;
16
+ removeEventListener: TypedRemoveEventListener<VirtualList, VirtualListEventMap>;
17
+ }
18
+ /**
19
+ * A long list that only renders what you can see. It windows `items` to the visible range plus an overscan margin with
20
+ * the same `VirtualScrollController` the data grid uses, so 50 000 rows cost the same DOM as 20, and it renders every
21
+ * visible row as a real `c2-list-item` — the same markup, slots, selection styling and theme as `c2-list`.
22
+ *
23
+ * ```html
24
+ * <c2-virtual-list
25
+ * style="height: 320px"
26
+ * searchable
27
+ * highlight
28
+ * selection="single"
29
+ * item-key="id"
30
+ * label-field="name"
31
+ * description-field="team"
32
+ * ></c2-virtual-list>
33
+ * <script type="module">
34
+ * document.querySelector('c2-virtual-list').items = people
35
+ * </script>
36
+ * ```
37
+ *
38
+ * **Give the host a height** (or `--c2-virtual-list--max-height`) — the list scrolls inside it.
39
+ *
40
+ * **Uniform item height.** Windowing needs every row to be the same height. The height comes from
41
+ * `--c2-virtual-list__item--height` and is re-measured from the first rendered row, so a theme change is picked up;
42
+ * `item-height` is only the estimate used for the first paint. A list with two-line rows must raise the variable.
43
+ *
44
+ * A run of adjacent selected rows squares the corners between them, so it reads as one block rather than a stack of
45
+ * separate pills — the same `joined-before` / `joined-after` contract `c2-list` uses.
46
+ *
47
+ * **Search.** `searchable` adds a search field above the list; `search` is also a plain property, so an external input
48
+ * can drive it instead. Matching runs over `search-fields` (falling back to `label-field` and `description-field`, then
49
+ * to every string value of the item), and `matcher` replaces that logic outright. `highlight` wraps the matched text in
50
+ * `<mark>`. With a `dataSource` the query is sent to the server instead and the local matcher is never used.
51
+ *
52
+ * Set `aria-label` on the host to name the list; it is mirrored onto the inner `role="listbox"`, which falls back to
53
+ * `Items`.
54
+ *
55
+ * @tag c2-virtual-list
56
+ *
57
+ * @slot search - Replaces the built-in search field. Set `search` yourself from its events.
58
+ * @slot toolbar - Extra controls beside the search field, for a filter chip or a count. Hidden when empty.
59
+ * @slot footer - Bar below the list, for a total or a pager. Hidden when empty.
60
+ * @slot empty - Replaces the built-in "no items" message.
61
+ * @slot no-results - Replaces the built-in "no matches" message shown while a search is active.
62
+ * @slot loading - Replaces the built-in spinner shown while the first items load.
63
+ * @slot error - Replaces the built-in message shown when `error` is set.
64
+ *
65
+ * @internalcomponent c2-list-item
66
+ * @internalcomponent c2-text-field
67
+ * @internalcomponent c2-spinner
68
+ *
69
+ * @event {CustomEvent<VirtualListSelectionChangeEventDetail>} selection-change - Fired after the user changes the selection. `detail.value` is the array of selected keys, `detail.items` the matching items. Does not bubble: several components fire `selection-change`, so a listener belongs on the element itself rather than on an ancestor.
70
+ * @event {CustomEvent<VirtualListItemEventDetail>} item-click - Fired when a row is clicked, before the selection is applied.
71
+ * @event {CustomEvent<VirtualListSearchChangeEventDetail>} search-change - Fired after the query settles, with the number of items that match.
72
+ *
73
+ * @cssproperty {color} [--c2-virtual-list--background=#ffffff]
74
+ * @cssproperty {color} [--c2-virtual-list--color=#18181b]
75
+ * @cssproperty {font-size} [--c2-virtual-list--font-size=14px]
76
+ * @cssproperty {pixel} [--c2-virtual-list--max-height=none] - Caps the height when the host is not sized itself; the list scrolls.
77
+ *
78
+ * @cssproperty {border} --c2-virtual-list--border-top
79
+ * @cssproperty {border} --c2-virtual-list--border-right
80
+ * @cssproperty {border} --c2-virtual-list--border-bottom
81
+ * @cssproperty {border} --c2-virtual-list--border-left
82
+ *
83
+ * @cssproperty {border-radius} [--c2-virtual-list--border-top-left-radius=8px]
84
+ * @cssproperty {border-radius} [--c2-virtual-list--border-top-right-radius=8px]
85
+ * @cssproperty {border-radius} [--c2-virtual-list--border-bottom-left-radius=8px]
86
+ * @cssproperty {border-radius} [--c2-virtual-list--border-bottom-right-radius=8px]
87
+ *
88
+ * @cssproperty {box-shadow} --c2-virtual-list--box-shadow
89
+ *
90
+ * @cssproperty {color} [--c2-virtual-list__search--background=transparent]
91
+ * @cssproperty {padding} [--c2-virtual-list__search--padding=8px]
92
+ * @cssproperty {pixel} [--c2-virtual-list__search--gap=8px]
93
+ * @cssproperty {border} [--c2-virtual-list__search--border-bottom=1px solid #e4e4e7]
94
+ *
95
+ * @cssproperty {color} [--c2-virtual-list__search-field--background=#ffffff] - Themes the built-in `c2-text-field`.
96
+ * @cssproperty {border} [--c2-virtual-list__search-field--border=1px solid #e4e4e7]
97
+ * @cssproperty {border-radius} [--c2-virtual-list__search-field--border-radius=6px]
98
+ * @cssproperty {pixel} [--c2-virtual-list__search-field--min-height=32px]
99
+ *
100
+ * @cssproperty {pixel} [--c2-virtual-list__viewport--padding=4px]
101
+ *
102
+ * @cssproperty {pixel} [--c2-virtual-list__item--height=36px] - Row height; windowing needs it uniform.
103
+ *
104
+ *
105
+ * @cssproperty {color} [--c2-virtual-list__highlight--background=#fef08a]
106
+ * @cssproperty {color} [--c2-virtual-list__highlight--color=inherit]
107
+ * @cssproperty {font-weight} [--c2-virtual-list__highlight--font-weight=600]
108
+ * @cssproperty {border-radius} [--c2-virtual-list__highlight--border-radius=4px]
109
+ *
110
+ * @cssproperty {color} [--c2-virtual-list__skeleton--background=#f4f4f5] - Placeholder shown in rows whose `dataSource` block is still loading.
111
+ * @cssproperty {border-radius} [--c2-virtual-list__skeleton--border-radius=4px]
112
+ *
113
+ * @cssproperty {color} [--c2-virtual-list__state--color=#71717a] - Colour of the empty, no-results and loading messages.
114
+ * @cssproperty {padding} [--c2-virtual-list__state--padding=32px 12px]
115
+ * @cssproperty {font-size} [--c2-virtual-list__state--font-size=14px]
116
+ * @cssproperty {color} [--c2-virtual-list__state__error--color=rgb(211, 21, 16)]
117
+ *
118
+ * @cssproperty {color} [--c2-virtual-list__footer--background=transparent]
119
+ * @cssproperty {padding} [--c2-virtual-list__footer--padding=8px 12px]
120
+ * @cssproperty {border} [--c2-virtual-list__footer--border-top=1px solid #e4e4e7]
121
+ */
122
+ export declare class VirtualList extends LitElement {
123
+ #private;
124
+ static styles: import("lit").CSSResult;
125
+ private viewport;
126
+ private searchFieldElement;
127
+ /** The items to display. An array in the property, JSON in the attribute. */
128
+ items: unknown[];
129
+ /** Field used as the identity of an item, for selection and typeahead. Falls back to the item index. */
130
+ itemKey: string;
131
+ /** Field read for a row's primary text. Defaults to `label`, `name`, `title` or `value`, then the item itself. */
132
+ labelField: string;
133
+ /** Field read for a row's second, muted line. */
134
+ descriptionField: string;
135
+ /** Field whose truthy value makes a row unselectable. */
136
+ disabledField: string;
137
+ /** Renders a row's content, replacing the label/description pair. */
138
+ renderItem?: VirtualListItemRenderer;
139
+ /** Replaces the built-in field matching while searching. */
140
+ matcher?: VirtualListMatcher;
141
+ /** Client-side sort comparator for the `sort` field's values. */
142
+ comparator?: (a: unknown, b: unknown) => number;
143
+ /** Lazy item source used instead of `items`; the list requests one block at a time as rows scroll into view. */
144
+ dataSource?: VirtualListDataSource;
145
+ /** Shows the built-in search field above the list. */
146
+ searchable: boolean;
147
+ /** The query the list is filtered by. Set by the built-in field, or from outside. */
148
+ search: string;
149
+ /** Fields the query is matched against: an array in the property, `;`-separated in the attribute. */
150
+ searchFields: string[];
151
+ /** Queries shorter than this are ignored, so the list is not filtered on the first keystroke. */
152
+ minSearchLength: number;
153
+ /** Milliseconds the built-in field waits after the last keystroke before the query is applied. */
154
+ searchDebounce: number;
155
+ /** Placeholder of the built-in search field. */
156
+ searchPlaceholder: string;
157
+ /** Wraps the matched part of a row's text in `<mark part="highlight">`. */
158
+ highlight: boolean;
159
+ /** The sort: `SortEntry` in the property, `field:asc` in the `sort` attribute. */
160
+ sort?: SortEntry;
161
+ /** `single` selects one row at a time, `multiple` supports ⌘/ctrl-click and shift-click ranges. */
162
+ selection: VirtualListSelectionMode;
163
+ /** Keys of the selected items: an array in the property, `;`-separated in the attribute. */
164
+ value: string[];
165
+ /** `auto` windows past `virtual-threshold` items; `always` and `never` force it. */
166
+ virtual: VirtualListVirtualMode;
167
+ /** Item height in pixels used before the first row has been measured. */
168
+ itemHeight: number;
169
+ /** Rows rendered above and below the viewport while windowing. */
170
+ overscan: number;
171
+ /** Item count past which `virtual="auto"` starts windowing. */
172
+ virtualThreshold: number;
173
+ /** Number of items the list asks a `dataSource` for at a time. */
174
+ blockSize: number;
175
+ /** Shows the loading state; implied while a `dataSource` resolves its first block. */
176
+ loading: boolean;
177
+ /** Shows the error state with this message. */
178
+ error: string;
179
+ /** Message shown when there are no items at all. */
180
+ emptyMessage: string;
181
+ /** Message shown when a search is active and nothing matches. */
182
+ noResultsMessage: string;
183
+ private hasToolbar;
184
+ private hasFooter;
185
+ private remoteTotal;
186
+ private focusedIndex;
187
+ /** Number of items on show right now: the filtered `items`, or the `dataSource` total for the current query. */
188
+ get itemCount(): number;
189
+ /** Whether the rows are currently windowed. */
190
+ get isVirtualized(): boolean;
191
+ /** Whether a query is currently narrowing the list. */
192
+ get searching(): boolean;
193
+ /** The items currently selected. Only the loaded ones when a `dataSource` is used. */
194
+ getSelectedItems(): unknown[];
195
+ /** Selects every loaded item. Only meaningful with `selection="multiple"`. */
196
+ selectAll(): void;
197
+ clearSelection(): void;
198
+ /** Scrolls the item at `index` into view, aligning it to the closest edge. */
199
+ scrollToIndex(index: number): void;
200
+ /** Moves the roving focus to `index`, scrolling it into view first. */
201
+ focusItem(index: number): void;
202
+ /**
203
+ * Re-reads the data. With a `dataSource` that drops every cached block and asks for the visible one again;
204
+ * otherwise it re-runs the search and the sort — which is what an `items` array mutated in place needs, since
205
+ * Lit only sees a new array.
206
+ */
207
+ refresh(): void;
208
+ disconnectedCallback(): void;
209
+ protected willUpdate(changed: PropertyValues<this>): void;
210
+ protected updated(changed: PropertyValues): void;
211
+ render(): TemplateResult<1>;
212
+ }
213
+ declare global {
214
+ interface HTMLElementTagNameMap {
215
+ 'c2-virtual-list': VirtualList;
216
+ }
217
+ }
218
+ //# sourceMappingURL=virtual-list.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"virtual-list.d.ts","sourceRoot":"","sources":["../../src/virtual-list.ts"],"names":[],"mappings":"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"}
package/vue.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ // GENERATED by @c2n/framework-types. Do not edit by hand.
2
+ //
3
+ // Vue template types for the custom elements of @c2n/virtual-list. Import once, anywhere in the program:
4
+ //
5
+ // import '@c2n/virtual-list/vue'
6
+ //
7
+ // Tell the compiler about the tags as well, or every c2-* tag is resolved as a Vue component and renders
8
+ // nothing: template.compilerOptions.isCustomElement = (tag) => tag.startsWith('c2-') in vite.config.ts.
9
+
10
+ import type { DefineComponent, HTMLAttributes } from 'vue'
11
+ import type { VirtualList, VirtualListEventMap } from '@c2n/virtual-list'
12
+
13
+ /** The element's own public properties, plus every attribute Vue understands on a host element. */
14
+ type C2Props<T> = Partial<Omit<T, keyof HTMLElement>> & HTMLAttributes
15
+
16
+ declare module 'vue' {
17
+ interface GlobalComponents {
18
+ 'c2-virtual-list': DefineComponent<
19
+ C2Props<VirtualList> & {
20
+ 'item-key'?: unknown
21
+ 'label-field'?: unknown
22
+ 'description-field'?: unknown
23
+ 'disabled-field'?: unknown
24
+ 'search-fields'?: unknown
25
+ 'min-search-length'?: unknown
26
+ 'search-debounce'?: unknown
27
+ 'search-placeholder'?: unknown
28
+ 'item-height'?: unknown
29
+ 'virtual-threshold'?: unknown
30
+ 'block-size'?: unknown
31
+ 'empty-message'?: unknown
32
+ 'no-results-message'?: unknown
33
+ onSearchChange?: (event: VirtualListEventMap['search-change']) => void
34
+ onItemClick?: (event: VirtualListEventMap['item-click']) => void
35
+ onSelectionChange?: (event: VirtualListEventMap['selection-change']) => void
36
+ }
37
+ >
38
+ }
39
+ }
40
+
41
+ declare module '@vue/runtime-dom' {
42
+ interface HTMLAttributes {
43
+ /** Vue's own definition omits it, and slotting a plain element into a component needs it. */
44
+ slot?: string
45
+ }
46
+ }
47
+
48
+ export {}
package/vue.js ADDED
@@ -0,0 +1,3 @@
1
+ // GENERATED by @c2n/framework-types. Do not edit by hand.
2
+ // Types only — see the matching .d.ts.
3
+ export {}