@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 +21 -0
- package/README.md +51 -0
- package/dist/virtual-list-types.js +0 -0
- package/dist/virtual-list.js +688 -0
- package/package.json +86 -0
- package/react.d.ts +24 -0
- package/react.js +3 -0
- package/types/src/virtual-list-types.d.ts +55 -0
- package/types/src/virtual-list-types.d.ts.map +1 -0
- package/types/src/virtual-list.d.ts +218 -0
- package/types/src/virtual-list.d.ts.map +1 -0
- package/vue.d.ts +48 -0
- package/vue.js +3 -0
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,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":"AAAA,OAAO,EAAE,UAAU,EAA4B,KAAK,cAAc,EAAE,KAAK,cAAc,EAAE,MAAM,KAAK,CAAA;AAGpG,OAAO,KAAK,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAA;AAGhG,OAAO,EAAqD,KAAK,SAAS,EAAE,MAAM,0BAA0B,CAAA;AAE5G,OAAO,KAAK,EACV,qBAAqB,EAErB,0BAA0B,EAC1B,uBAAuB,EACvB,kBAAkB,EAClB,kCAAkC,EAClC,qCAAqC,EACrC,wBAAwB,EACxB,sBAAsB,EACvB,MAAM,yBAAyB,CAAA;AAEhC,OAAO,gBAAgB,CAAA;AACvB,OAAO,cAAc,CAAA;AACrB,OAAO,iBAAiB,CAAA;AAaxB,yEAAyE;AACzE,MAAM,WAAW,mBAAmB;IAClC,kBAAkB,EAAE,WAAW,CAAC,qCAAqC,CAAC,CAAA;IACtE,YAAY,EAAE,WAAW,CAAC,0BAA0B,CAAC,CAAA;IACrD,eAAe,EAAE,WAAW,CAAC,kCAAkC,CAAC,CAAA;CACjE;AAED,MAAM,WAAW,WAAW;IAC1B,gBAAgB,EAAE,qBAAqB,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAA;IACzE,mBAAmB,EAAE,wBAAwB,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAA;CAChF;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuGG;AACH,qBACa,WAAY,SAAQ,UAAU;;IACzC,OAAgB,MAAM,0BAAoB;IAEtB,OAAO,CAAC,QAAQ,CAAqB;IACjC,OAAO,CAAC,kBAAkB,CAAmB;IAErE,6EAA6E;IAC7B,KAAK,EAAE,OAAO,EAAE,CAAK;IAErE,wGAAwG;IACrD,OAAO,SAAK;IAE/D,kHAAkH;IAC5D,UAAU,SAAK;IAErE,iDAAiD;IACW,gBAAgB,SAAK;IAEjF,yDAAyD;IACA,aAAa,SAAK;IAE3E,qEAAqE;IACrC,UAAU,CAAC,EAAE,uBAAuB,CAAA;IAEpE,4DAA4D;IAC5B,OAAO,CAAC,EAAE,kBAAkB,CAAA;IAE5D,iEAAiE;IACjC,UAAU,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,KAAK,MAAM,CAAA;IAE/E,gHAAgH;IAChF,UAAU,CAAC,EAAE,qBAAqB,CAAA;IAElE,sDAAsD;IACzB,UAAU,UAAQ;IAE/C,qFAAqF;IAC1C,MAAM,SAAK;IAEtD,qGAAqG;IACxB,YAAY,EAAE,MAAM,EAAE,CAAK;IAExG,iGAAiG;IACrC,eAAe,SAAI;IAE/E,kGAAkG;IACxC,cAAc,SAAM;IAE9E,gDAAgD;IACa,iBAAiB,SAAW;IAEzF,2EAA2E;IAC9C,SAAS,UAAQ;IAE9C,kFAAkF;IACH,IAAI,CAAC,EAAE,SAAS,CAAA;IAE/F,mGAAmG;IACvE,SAAS,EAAE,wBAAwB,CAAS;IAExE,4FAA4F;IAC5B,KAAK,EAAE,MAAM,EAAE,CAAK;IAEpF,oFAAoF;IACxD,OAAO,EAAE,sBAAsB,CAAS;IAEpE,yEAAyE;IACnB,UAAU,SAAK;IAErE,kEAAkE;IACtC,QAAQ,SAAI;IAExC,+DAA+D;IACH,gBAAgB,SAAM;IAElF,kEAAkE;IACb,SAAS,SAAM;IAEpE,sFAAsF;IAC1C,OAAO,UAAQ;IAE3D,+CAA+C;IACnB,KAAK,SAAK;IAEtC,oDAAoD;IACI,YAAY,SAAa;IAEjF,iEAAiE;IACJ,gBAAgB,SAAe;IAEnF,OAAO,CAAC,UAAU,CAAQ;IAC1B,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,YAAY,CAAI;IA0BjC,gHAAgH;IAChH,IAAI,SAAS,IAAI,MAAM,CAEtB;IAED,+CAA+C;IAC/C,IAAI,aAAa,IAAI,OAAO,CAI3B;IAED,uDAAuD;IACvD,IAAI,SAAS,IAAI,OAAO,CAEvB;IAkBD,sFAAsF;IACtF,gBAAgB,IAAI,OAAO,EAAE;IAU7B,8EAA8E;IAC9E,SAAS;IAUT,cAAc;IAId,8EAA8E;IAC9E,aAAa,CAAC,KAAK,EAAE,MAAM;IAI3B,uEAAuE;IACvE,SAAS,CAAC,KAAK,EAAE,MAAM;IAIvB;;;;OAIG;IACH,OAAO;IASE,oBAAoB;cAMV,UAAU,CAAC,OAAO,EAAE,cAAc,CAAC,IAAI,CAAC;cAmCxC,OAAO,CAAC,OAAO,EAAE,cAAc;IAoBzC,MAAM;CAschB;AAED,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,qBAAqB;QAC7B,iBAAiB,EAAE,WAAW,CAAA;KAC/B;CACF"}
|
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