@c2n/tree 0.0.9
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 +53 -0
- package/dist/tree-item-C6XnvUA1.js +242 -0
- package/dist/tree-item.js +2 -0
- package/dist/tree-types.js +0 -0
- package/dist/tree.js +607 -0
- package/package.json +85 -0
- package/react.d.ts +26 -0
- package/react.js +3 -0
- package/types/src/tree-item.d.ts +153 -0
- package/types/src/tree-item.d.ts.map +1 -0
- package/types/src/tree-types.d.ts +87 -0
- package/types/src/tree-types.d.ts.map +1 -0
- package/types/src/tree.d.ts +176 -0
- package/types/src/tree.d.ts.map +1 -0
- package/vue.d.ts +52 -0
- package/vue.js +3 -0
package/dist/tree.js
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { a as __decorate, i as TreeItem } from "./tree-item-C6XnvUA1.js";
|
|
2
|
+
import { LitElement, html, nothing, unsafeCSS } from "lit";
|
|
3
|
+
import { property, query } from "lit/decorators.js";
|
|
4
|
+
import { repeat } from "lit/directives/repeat.js";
|
|
5
|
+
import { customElement } from "@c2n/core/element-helper.js";
|
|
6
|
+
import { isServer } from "lit-html/is-server.js";
|
|
7
|
+
import { arrayPropertyConverter, jsonPropertyConverter } from "@c2n/core/lit-helper.js";
|
|
8
|
+
//#region src/tree.scss?inline
|
|
9
|
+
var tree_default = "/* ex : var((width: 24px), width, c2-checkbox) returns var(--c2-checkbox-width, 24px) */\n:host {\n display: block;\n box-sizing: border-box;\n background: var(--c2-tree--background,#ffffff);\n font-size: var(--c2-tree--font-size,14px);\n font-family: var(--c2-tree--font-family);\n border-top: var(--c2-tree--border-top);\n border-right: var(--c2-tree--border-right);\n border-bottom: var(--c2-tree--border-bottom);\n border-left: var(--c2-tree--border-left);\n border-top-left-radius: var(--c2-tree--border-top-left-radius,8px);\n border-top-right-radius: var(--c2-tree--border-top-right-radius,8px);\n border-bottom-left-radius: var(--c2-tree--border-bottom-left-radius,8px);\n border-bottom-right-radius: var(--c2-tree--border-bottom-right-radius,8px);\n}\n\n:host([hidden]) {\n display: none;\n}\n\n:host([disabled]) {\n opacity: var(--c2-tree__disabled--opacity,0.38);\n pointer-events: none;\n}\n\n.c2-tree {\n box-sizing: border-box;\n padding-top: var(--c2-tree--padding-top,4px);\n padding-right: var(--c2-tree--padding-right,0px);\n padding-bottom: var(--c2-tree--padding-bottom,4px);\n padding-left: var(--c2-tree--padding-left,0px);\n max-height: var(--c2-tree--max-height);\n overflow: auto;\n}\n\n.empty {\n color: var(--c2-tree__empty--color,#71717a);\n padding: var(--c2-tree__empty--padding,18px);\n text-align: center;\n}";
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/tree.ts
|
|
12
|
+
var TYPEAHEAD_WINDOW = 600;
|
|
13
|
+
/** Caps the type-ahead scan so a keystroke cannot stall on a very large tree. */
|
|
14
|
+
var TYPEAHEAD_SCAN_LIMIT = 2e3;
|
|
15
|
+
var Tree = class Tree extends LitElement {
|
|
16
|
+
constructor(..._args) {
|
|
17
|
+
super(..._args);
|
|
18
|
+
this.items = [];
|
|
19
|
+
this.value = [];
|
|
20
|
+
this.expandedItems = [];
|
|
21
|
+
this.selection = "single";
|
|
22
|
+
this.checkboxSelection = false;
|
|
23
|
+
this.selectionPropagation = "both";
|
|
24
|
+
this.childrenOutline = false;
|
|
25
|
+
this.disabled = false;
|
|
26
|
+
this.expandOnClick = false;
|
|
27
|
+
this.#loaded = /* @__PURE__ */ new Set();
|
|
28
|
+
this.#typeahead = "";
|
|
29
|
+
this.#typeaheadAt = 0;
|
|
30
|
+
this.#syncing = false;
|
|
31
|
+
this.#syncQueued = false;
|
|
32
|
+
this.#handleItemChange = (event) => {
|
|
33
|
+
event.stopPropagation();
|
|
34
|
+
if (this.#syncing || this.#syncQueued) return;
|
|
35
|
+
this.#syncQueued = true;
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
this.#syncQueued = false;
|
|
38
|
+
this.#syncItems();
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
this.#handleClick = (event) => {
|
|
42
|
+
if (this.disabled) return;
|
|
43
|
+
const item = this.#itemFromEvent(event);
|
|
44
|
+
if (!item) return;
|
|
45
|
+
this.dispatchEvent(new CustomEvent("item-click", {
|
|
46
|
+
detail: { node: item.node },
|
|
47
|
+
bubbles: true,
|
|
48
|
+
composed: true
|
|
49
|
+
}));
|
|
50
|
+
if (item.disabled) return;
|
|
51
|
+
this.#focus(item);
|
|
52
|
+
if (this.expandOnClick && item.isBranch) this.#setExpanded(item, !item.expanded);
|
|
53
|
+
if (this.checkboxSelection) return;
|
|
54
|
+
this.#applySelection(item, {
|
|
55
|
+
toggle: event.ctrlKey || event.metaKey,
|
|
56
|
+
range: event.shiftKey
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
this.#handleToggle = (event) => {
|
|
60
|
+
event.stopPropagation();
|
|
61
|
+
if (this.disabled) return;
|
|
62
|
+
const item = this.#itemFromEvent(event);
|
|
63
|
+
if (item) this.#setExpanded(item, !item.expanded);
|
|
64
|
+
};
|
|
65
|
+
this.#handleCheck = (event) => {
|
|
66
|
+
event.stopPropagation();
|
|
67
|
+
if (this.disabled) return;
|
|
68
|
+
const item = this.#itemFromEvent(event);
|
|
69
|
+
if (item) this.#applyCheck(item, event.detail);
|
|
70
|
+
};
|
|
71
|
+
this.#handleKeydown = (event) => {
|
|
72
|
+
if (this.disabled) return;
|
|
73
|
+
const item = this.#itemFromEvent(event) ?? this.#focused;
|
|
74
|
+
if (!item) return;
|
|
75
|
+
const visible = this.visibleItems.filter((row) => !row.disabled);
|
|
76
|
+
const index = visible.indexOf(item);
|
|
77
|
+
switch (event.key) {
|
|
78
|
+
case "ArrowDown": {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
const next = visible[Math.min(index + 1, visible.length - 1)];
|
|
81
|
+
this.#focus(next);
|
|
82
|
+
if (next && event.shiftKey && this.#multiple) this.#applySelection(next, { range: true });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
case "ArrowUp": {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
const previous = visible[Math.max(index - 1, 0)];
|
|
88
|
+
this.#focus(previous);
|
|
89
|
+
if (previous && event.shiftKey && this.#multiple) this.#applySelection(previous, { range: true });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
case "ArrowRight":
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
if (item.isBranch && !item.expanded) this.#setExpanded(item, true);
|
|
95
|
+
else this.#focus(item.childItems.find((child) => !child.disabled));
|
|
96
|
+
return;
|
|
97
|
+
case "ArrowLeft":
|
|
98
|
+
event.preventDefault();
|
|
99
|
+
if (item.expanded) this.#setExpanded(item, false);
|
|
100
|
+
else this.#focus(this.#ancestorsOf(item).pop());
|
|
101
|
+
return;
|
|
102
|
+
case "Home":
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
this.#focus(visible[0]);
|
|
105
|
+
return;
|
|
106
|
+
case "End":
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
this.#focus(visible[visible.length - 1]);
|
|
109
|
+
return;
|
|
110
|
+
case "Enter":
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
if (this.expandOnClick && item.isBranch) this.#setExpanded(item, !item.expanded);
|
|
113
|
+
if (item.href && !item.disabled) item.renderRoot.querySelector("a")?.click();
|
|
114
|
+
else this.#applySelection(item);
|
|
115
|
+
return;
|
|
116
|
+
case " ":
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
if (this.expandOnClick && item.isBranch) this.#setExpanded(item, !item.expanded);
|
|
119
|
+
if (this.checkboxSelection) this.#applyCheck(item, !item.selected);
|
|
120
|
+
else this.#applySelection(item, { toggle: this.#multiple });
|
|
121
|
+
return;
|
|
122
|
+
case "*": {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
const branches = (this.#ancestorsOf(item).pop()?.childItems ?? this.rootItems).filter((row) => row.isBranch && !row.disabled).map((row) => row.value);
|
|
125
|
+
this.expandedItems = [.../* @__PURE__ */ new Set([...this.expandedItems, ...branches])];
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
case "a":
|
|
129
|
+
case "A": if (event.ctrlKey || event.metaKey) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
this.selectAll();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) this.#typeaheadTo(event.key, visible, index);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
static {
|
|
139
|
+
this.styles = unsafeCSS(tree_default);
|
|
140
|
+
}
|
|
141
|
+
/** The row the roving `tabindex` currently rests on. */
|
|
142
|
+
#focused;
|
|
143
|
+
/** Anchor for Shift-extended range selection. */
|
|
144
|
+
#anchor;
|
|
145
|
+
/** Branches whose children have already been requested, so a second expand does not refetch. */
|
|
146
|
+
#loaded;
|
|
147
|
+
#typeahead;
|
|
148
|
+
#typeaheadAt;
|
|
149
|
+
#syncing;
|
|
150
|
+
#syncQueued;
|
|
151
|
+
/** Every row in the tree, in document order, expanded or not. */
|
|
152
|
+
get allItems() {
|
|
153
|
+
const result = [];
|
|
154
|
+
const walk = (items) => {
|
|
155
|
+
for (const item of items) {
|
|
156
|
+
result.push(item);
|
|
157
|
+
walk(item.childItems);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
walk(this.rootItems);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The rows a user can actually see and move to — every row whose ancestors are all expanded, skipping any row
|
|
165
|
+
* `hidden` takes off the page. A filtered-out row must not stay in the keyboard order, or Arrow Down walks
|
|
166
|
+
* onto something nobody can see.
|
|
167
|
+
*/
|
|
168
|
+
get visibleItems() {
|
|
169
|
+
const result = [];
|
|
170
|
+
const walk = (items) => {
|
|
171
|
+
for (const item of items) {
|
|
172
|
+
if (item.hidden) continue;
|
|
173
|
+
result.push(item);
|
|
174
|
+
if (item.expanded) walk(item.childItems);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(this.rootItems);
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The root rows, from whichever mode produced them. Data-driven rows live in this shadow root and slotted
|
|
182
|
+
* rows in the light DOM, so both sources are read; below the root the two modes are identical and
|
|
183
|
+
* `TreeItem.childItems` serves both.
|
|
184
|
+
*/
|
|
185
|
+
get rootItems() {
|
|
186
|
+
if (isServer) return [];
|
|
187
|
+
const slotted = this.rootSlot?.assignedElements({ flatten: true }) ?? [];
|
|
188
|
+
return [...this.container ? [...this.container.children] : [], ...slotted].flatMap((element) => unwrap(element));
|
|
189
|
+
}
|
|
190
|
+
/** The rows that are currently selected, in tree order. */
|
|
191
|
+
getSelectedNodes() {
|
|
192
|
+
return this.allItems.filter((item) => this.value.includes(item.value)).map((item) => item.node);
|
|
193
|
+
}
|
|
194
|
+
/** Expands a row. Unknown or disabled values are ignored. */
|
|
195
|
+
expand(value) {
|
|
196
|
+
const item = this.#itemOf(value);
|
|
197
|
+
if (item) this.#setExpanded(item, true);
|
|
198
|
+
}
|
|
199
|
+
/** Collapses a row. Unknown values are ignored. */
|
|
200
|
+
collapse(value) {
|
|
201
|
+
const item = this.#itemOf(value);
|
|
202
|
+
if (item) this.#setExpanded(item, false);
|
|
203
|
+
}
|
|
204
|
+
/** Expands a collapsed row, or collapses an expanded one. */
|
|
205
|
+
toggle(value) {
|
|
206
|
+
const item = this.#itemOf(value);
|
|
207
|
+
if (item) this.#setExpanded(item, !item.expanded);
|
|
208
|
+
}
|
|
209
|
+
/** Expands every branch already present in the tree. Branches that load on demand are left alone. */
|
|
210
|
+
expandAll() {
|
|
211
|
+
this.expandedItems = this.allItems.filter((item) => item.childItems.length > 0).map((item) => item.value);
|
|
212
|
+
}
|
|
213
|
+
/** Collapses every branch. */
|
|
214
|
+
collapseAll() {
|
|
215
|
+
this.expandedItems = [];
|
|
216
|
+
}
|
|
217
|
+
/** Selects every enabled row. Only meaningful while several rows may be selected. */
|
|
218
|
+
selectAll() {
|
|
219
|
+
if (!this.#multiple) return;
|
|
220
|
+
this.#commitSelection(this.allItems.filter((item) => !item.disabled).map((item) => item.value));
|
|
221
|
+
}
|
|
222
|
+
/** Clears the selection. */
|
|
223
|
+
clearSelection() {
|
|
224
|
+
this.#commitSelection([]);
|
|
225
|
+
}
|
|
226
|
+
/** Moves focus to a row, expanding its ancestors if needed. */
|
|
227
|
+
focusItem(value) {
|
|
228
|
+
const item = this.#itemOf(value);
|
|
229
|
+
if (!item) return;
|
|
230
|
+
for (const ancestor of this.#ancestorsOf(item)) if (!this.expandedItems.includes(ancestor.value)) this.expandedItems = [...this.expandedItems, ancestor.value];
|
|
231
|
+
this.updateComplete.then(() => this.#focus(item));
|
|
232
|
+
}
|
|
233
|
+
/** Drops the cached "already loaded" mark so the next expand runs `loadChildren` again. */
|
|
234
|
+
reload(value) {
|
|
235
|
+
if (value === void 0) this.#loaded.clear();
|
|
236
|
+
else this.#loaded.delete(value);
|
|
237
|
+
}
|
|
238
|
+
get #multiple() {
|
|
239
|
+
return this.selection === "multiple" || this.checkboxSelection;
|
|
240
|
+
}
|
|
241
|
+
#itemOf(value) {
|
|
242
|
+
return this.allItems.find((item) => item.value === value);
|
|
243
|
+
}
|
|
244
|
+
#ancestorsOf(item) {
|
|
245
|
+
if (isServer) return [];
|
|
246
|
+
const result = [];
|
|
247
|
+
let parent = item.parentElement;
|
|
248
|
+
while (parent instanceof TreeItem) {
|
|
249
|
+
result.unshift(parent);
|
|
250
|
+
parent = parent.parentElement;
|
|
251
|
+
}
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
connectedCallback() {
|
|
255
|
+
super.connectedCallback();
|
|
256
|
+
this.setAttribute("role", "tree");
|
|
257
|
+
}
|
|
258
|
+
willUpdate(changed) {
|
|
259
|
+
for (const key of ["value", "expandedItems"]) if (changed.has(key) && typeof this[key] === "string") this[key] = arrayPropertyConverter.fromAttribute(this[key]);
|
|
260
|
+
if (changed.has("items") && !Array.isArray(this.items)) this.items = [];
|
|
261
|
+
}
|
|
262
|
+
updated(changed) {
|
|
263
|
+
this.setAttribute("aria-multiselectable", String(this.#multiple));
|
|
264
|
+
if (this.disabled) this.setAttribute("aria-disabled", "true");
|
|
265
|
+
else this.removeAttribute("aria-disabled");
|
|
266
|
+
if (changed.has("items")) this.#loaded.clear();
|
|
267
|
+
this.#syncItems();
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Rows announce themselves on connect, disconnect and identity changes. A declarative tree fires one per
|
|
271
|
+
* row while it parses, so the resync is coalesced into a microtask — resyncing per event would be quadratic
|
|
272
|
+
* on first paint.
|
|
273
|
+
*/
|
|
274
|
+
#handleItemChange;
|
|
275
|
+
/**
|
|
276
|
+
* Writes the tree's state onto every row: depth, expansion, selection, the derived indeterminate state and
|
|
277
|
+
* the roving `tabindex`. Rows never derive any of this themselves, which keeps one ordered walk authoritative
|
|
278
|
+
* for rendering, ARIA and keyboard movement alike.
|
|
279
|
+
*/
|
|
280
|
+
#syncItems() {
|
|
281
|
+
if (this.#syncing) return;
|
|
282
|
+
this.#syncing = true;
|
|
283
|
+
try {
|
|
284
|
+
const selected = new Set(this.value);
|
|
285
|
+
const expanded = new Set(this.expandedItems);
|
|
286
|
+
const partial = this.checkboxSelection ? this.#derivePartial(selected) : void 0;
|
|
287
|
+
const walk = (items, level) => {
|
|
288
|
+
items.forEach((item, index) => {
|
|
289
|
+
item.level = level;
|
|
290
|
+
item.setSize = items.length;
|
|
291
|
+
item.posInSet = index + 1;
|
|
292
|
+
item.expanded = expanded.has(item.value);
|
|
293
|
+
item.selected = selected.has(item.value);
|
|
294
|
+
item.indeterminate = partial?.has(item.value) ?? false;
|
|
295
|
+
item.checkboxSelection = this.checkboxSelection;
|
|
296
|
+
item.childrenOutline = this.childrenOutline;
|
|
297
|
+
item.selectable = this.selection !== "none" || this.checkboxSelection;
|
|
298
|
+
walk(item.childItems, level + 1);
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
walk(this.rootItems, 0);
|
|
302
|
+
const focusable = this.visibleItems.filter((item) => !item.disabled);
|
|
303
|
+
if (!this.#focused || !focusable.includes(this.#focused)) this.#focused = focusable.find((item) => selected.has(item.value)) ?? focusable[0];
|
|
304
|
+
for (const item of this.allItems) item.tabIndex = item === this.#focused && !this.disabled ? 0 : -1;
|
|
305
|
+
} finally {
|
|
306
|
+
this.#syncing = false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Values of the branches only *some* of whose descendants are selected.
|
|
311
|
+
*
|
|
312
|
+
* Derived on every sync rather than stored, which also repairs the native input clearing its own
|
|
313
|
+
* indeterminate flag when clicked. Disabled rows are left out of the tally entirely, so a branch whose only
|
|
314
|
+
* unselected descendants are disabled reads as fully selected instead of being stuck half-ticked.
|
|
315
|
+
*/
|
|
316
|
+
#derivePartial(selected) {
|
|
317
|
+
const partial = /* @__PURE__ */ new Set();
|
|
318
|
+
const walk = (item) => {
|
|
319
|
+
const children = item.childItems;
|
|
320
|
+
if (children.length === 0) return item.disabled ? {
|
|
321
|
+
total: 0,
|
|
322
|
+
selected: 0
|
|
323
|
+
} : {
|
|
324
|
+
total: 1,
|
|
325
|
+
selected: selected.has(item.value) ? 1 : 0
|
|
326
|
+
};
|
|
327
|
+
let total = 0;
|
|
328
|
+
let hit = 0;
|
|
329
|
+
for (const child of children) {
|
|
330
|
+
const counts = walk(child);
|
|
331
|
+
total += counts.total;
|
|
332
|
+
hit += counts.selected;
|
|
333
|
+
}
|
|
334
|
+
if (hit > 0 && hit < total) partial.add(item.value);
|
|
335
|
+
return {
|
|
336
|
+
total,
|
|
337
|
+
selected: hit
|
|
338
|
+
};
|
|
339
|
+
};
|
|
340
|
+
for (const root of this.rootItems) walk(root);
|
|
341
|
+
return partial;
|
|
342
|
+
}
|
|
343
|
+
/** Every descendant of a row, expanded or not. */
|
|
344
|
+
#descendantsOf(item) {
|
|
345
|
+
const result = [];
|
|
346
|
+
const walk = (items) => {
|
|
347
|
+
for (const child of items) {
|
|
348
|
+
result.push(child);
|
|
349
|
+
walk(child.childItems);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
walk(item.childItems);
|
|
353
|
+
return result;
|
|
354
|
+
}
|
|
355
|
+
async #setExpanded(item, expanded) {
|
|
356
|
+
if (item.disabled || !item.isBranch) return;
|
|
357
|
+
if (expanded === this.expandedItems.includes(item.value)) return;
|
|
358
|
+
if (expanded) {
|
|
359
|
+
const event = new CustomEvent("item-expand", {
|
|
360
|
+
detail: {
|
|
361
|
+
node: item.node,
|
|
362
|
+
loading: item.hasChildren && !this.#loaded.has(item.value)
|
|
363
|
+
},
|
|
364
|
+
bubbles: true,
|
|
365
|
+
composed: true,
|
|
366
|
+
cancelable: true
|
|
367
|
+
});
|
|
368
|
+
this.dispatchEvent(event);
|
|
369
|
+
if (event.defaultPrevented) return;
|
|
370
|
+
await this.#loadIfNeeded(item);
|
|
371
|
+
}
|
|
372
|
+
this.expandedItems = expanded ? [...this.expandedItems, item.value] : this.expandedItems.filter((value) => value !== item.value);
|
|
373
|
+
this.dispatchEvent(new CustomEvent("expansion-change", {
|
|
374
|
+
detail: {
|
|
375
|
+
expandedItems: this.expandedItems,
|
|
376
|
+
node: item.node,
|
|
377
|
+
expanded
|
|
378
|
+
},
|
|
379
|
+
bubbles: false,
|
|
380
|
+
composed: true
|
|
381
|
+
}));
|
|
382
|
+
}
|
|
383
|
+
/** Runs `loadChildren` once per branch, showing a spinner on the row while it is in flight. */
|
|
384
|
+
async #loadIfNeeded(item) {
|
|
385
|
+
if (!this.loadChildren || !item.hasChildren || this.#loaded.has(item.value)) return;
|
|
386
|
+
if (item.childItems.length > 0) return;
|
|
387
|
+
this.#loaded.add(item.value);
|
|
388
|
+
item.loading = true;
|
|
389
|
+
try {
|
|
390
|
+
const children = await this.loadChildren({
|
|
391
|
+
node: item.node,
|
|
392
|
+
level: item.level,
|
|
393
|
+
expanded: true,
|
|
394
|
+
selected: item.selected
|
|
395
|
+
});
|
|
396
|
+
if (children?.length) this.items = replaceChildren(this.items, item.value, children);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
this.#loaded.delete(item.value);
|
|
399
|
+
this.dispatchEvent(new CustomEvent("item-load-error", {
|
|
400
|
+
detail: {
|
|
401
|
+
node: item.node,
|
|
402
|
+
error
|
|
403
|
+
},
|
|
404
|
+
bubbles: true,
|
|
405
|
+
composed: true
|
|
406
|
+
}));
|
|
407
|
+
} finally {
|
|
408
|
+
item.loading = false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
#commitSelection(values) {
|
|
412
|
+
this.value = values;
|
|
413
|
+
this.#syncItems();
|
|
414
|
+
this.dispatchEvent(new CustomEvent("selection-change", {
|
|
415
|
+
detail: {
|
|
416
|
+
value: this.value,
|
|
417
|
+
nodes: this.getSelectedNodes()
|
|
418
|
+
},
|
|
419
|
+
bubbles: false,
|
|
420
|
+
composed: true
|
|
421
|
+
}));
|
|
422
|
+
}
|
|
423
|
+
/** Applies a click or key press to the selection, honouring the range and toggle modifiers. */
|
|
424
|
+
#applySelection(item, options = {}) {
|
|
425
|
+
if (this.selection === "none" && !this.checkboxSelection) return;
|
|
426
|
+
if (item.disabled) return;
|
|
427
|
+
if (!this.#multiple) {
|
|
428
|
+
this.#anchor = item;
|
|
429
|
+
this.#commitSelection(this.value.includes(item.value) ? [] : [item.value]);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (options.range && this.#anchor) {
|
|
433
|
+
const visible = this.visibleItems;
|
|
434
|
+
const from = visible.indexOf(this.#anchor);
|
|
435
|
+
const to = visible.indexOf(item);
|
|
436
|
+
if (from !== -1 && to !== -1) {
|
|
437
|
+
const [start, end] = from < to ? [from, to] : [to, from];
|
|
438
|
+
const range = visible.slice(start, end + 1).filter((row) => !row.disabled);
|
|
439
|
+
this.#commitSelection([.../* @__PURE__ */ new Set([...this.value, ...range.map((row) => row.value)])]);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.#anchor = item;
|
|
444
|
+
if (options.toggle) {
|
|
445
|
+
const next = this.value.includes(item.value) ? this.value.filter((value) => value !== item.value) : [...this.value, item.value];
|
|
446
|
+
this.#commitSelection(next);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.#commitSelection([item.value]);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Ticks or clears a checkbox and carries the change to the row's relatives.
|
|
453
|
+
*
|
|
454
|
+
* A disabled row is never added or removed by a relative: it would otherwise end up in `value` with no way
|
|
455
|
+
* for the user to click it back out.
|
|
456
|
+
*/
|
|
457
|
+
#applyCheck(item, checked) {
|
|
458
|
+
if (item.disabled) return;
|
|
459
|
+
const next = new Set(this.value);
|
|
460
|
+
const propagateDown = this.selectionPropagation === "descendants" || this.selectionPropagation === "both";
|
|
461
|
+
const propagateUp = this.selectionPropagation === "parents" || this.selectionPropagation === "both";
|
|
462
|
+
const affected = [item, ...propagateDown ? this.#descendantsOf(item) : []];
|
|
463
|
+
for (const row of affected) {
|
|
464
|
+
if (row.disabled) continue;
|
|
465
|
+
if (checked) next.add(row.value);
|
|
466
|
+
else next.delete(row.value);
|
|
467
|
+
}
|
|
468
|
+
if (propagateUp) for (const ancestor of this.#ancestorsOf(item).reverse()) {
|
|
469
|
+
const descendants = this.#descendantsOf(ancestor).filter((row) => !row.disabled);
|
|
470
|
+
if (descendants.length > 0 && descendants.every((row) => next.has(row.value))) next.add(ancestor.value);
|
|
471
|
+
else next.delete(ancestor.value);
|
|
472
|
+
}
|
|
473
|
+
this.#commitSelection(this.allItems.filter((row) => next.has(row.value)).map((row) => row.value));
|
|
474
|
+
}
|
|
475
|
+
#focus(item) {
|
|
476
|
+
if (!item) return;
|
|
477
|
+
this.#focused = item;
|
|
478
|
+
for (const row of this.allItems) row.tabIndex = row === item ? 0 : -1;
|
|
479
|
+
item.focus();
|
|
480
|
+
}
|
|
481
|
+
#itemFromEvent(event) {
|
|
482
|
+
const target = event.target;
|
|
483
|
+
return target instanceof Element ? target.closest("c2-tree-item") ?? void 0 : void 0;
|
|
484
|
+
}
|
|
485
|
+
#handleClick;
|
|
486
|
+
#handleToggle;
|
|
487
|
+
#handleCheck;
|
|
488
|
+
#handleKeydown;
|
|
489
|
+
#typeaheadTo(key, visible, index) {
|
|
490
|
+
const now = Date.now();
|
|
491
|
+
this.#typeahead = now - this.#typeaheadAt > TYPEAHEAD_WINDOW ? key : this.#typeahead + key;
|
|
492
|
+
this.#typeaheadAt = now;
|
|
493
|
+
const needle = this.#typeahead.toLowerCase();
|
|
494
|
+
const scan = Math.min(visible.length, TYPEAHEAD_SCAN_LIMIT);
|
|
495
|
+
for (let step = 1; step <= scan; step += 1) {
|
|
496
|
+
const candidate = visible[(index + step) % visible.length];
|
|
497
|
+
if (candidate?.resolvedLabel.toLowerCase().startsWith(needle)) {
|
|
498
|
+
this.#focus(candidate);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** Renders one node and, when it is expanded, its children as its own light children — the shape a
|
|
504
|
+
* declaratively authored tree already has, so one walk serves both modes below the root. */
|
|
505
|
+
#renderNode(node, level) {
|
|
506
|
+
const context = {
|
|
507
|
+
node,
|
|
508
|
+
level,
|
|
509
|
+
expanded: this.expandedItems.includes(node.value),
|
|
510
|
+
selected: this.value.includes(node.value)
|
|
511
|
+
};
|
|
512
|
+
return html`<c2-tree-item
|
|
513
|
+
.value=${node.value}
|
|
514
|
+
.label=${node.label ?? ""}
|
|
515
|
+
.data=${node.data}
|
|
516
|
+
?disabled=${node.disabled ?? false}
|
|
517
|
+
?has-children=${node.hasChildren ?? false}
|
|
518
|
+
>
|
|
519
|
+
${this.renderItem ? html`<span slot="label">${this.renderItem(context)}</span>` : html`${this.renderIcon ? html`<span slot="icon">${this.renderIcon(context)}</span>` : nothing}${this.renderLabel ? html`<span slot="label">${this.renderLabel(context)}</span>` : nothing}${this.renderActions ? html`<span slot="actions">${this.renderActions(context)}</span>` : nothing}`}
|
|
520
|
+
${repeat(node.children ?? [], (child) => child.value, (child) => this.#renderNode(child, level + 1))}
|
|
521
|
+
</c2-tree-item>`;
|
|
522
|
+
}
|
|
523
|
+
render() {
|
|
524
|
+
const empty = this.items.length === 0 && this.childElementCount === 0;
|
|
525
|
+
return html`
|
|
526
|
+
<div
|
|
527
|
+
class="c2-tree"
|
|
528
|
+
part="tree"
|
|
529
|
+
@click=${this.#handleClick}
|
|
530
|
+
@keydown=${this.#handleKeydown}
|
|
531
|
+
@c2-tree-item-toggle=${this.#handleToggle}
|
|
532
|
+
@c2-tree-item-check=${this.#handleCheck}
|
|
533
|
+
@c2-tree-item-change=${this.#handleItemChange}
|
|
534
|
+
>
|
|
535
|
+
${repeat(this.items, (node) => node.value, (node) => this.#renderNode(node, 0))}
|
|
536
|
+
<slot @slotchange=${this.#handleItemChange}></slot>
|
|
537
|
+
${empty ? html`<div class="empty"><slot name="empty">No items</slot></div>` : nothing}
|
|
538
|
+
</div>
|
|
539
|
+
`;
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
__decorate([property({ converter: jsonPropertyConverter })], Tree.prototype, "items", void 0);
|
|
543
|
+
__decorate([property({
|
|
544
|
+
converter: arrayPropertyConverter,
|
|
545
|
+
reflect: true
|
|
546
|
+
})], Tree.prototype, "value", void 0);
|
|
547
|
+
__decorate([property({
|
|
548
|
+
converter: arrayPropertyConverter,
|
|
549
|
+
reflect: true,
|
|
550
|
+
attribute: "expanded-items"
|
|
551
|
+
})], Tree.prototype, "expandedItems", void 0);
|
|
552
|
+
__decorate([property({ type: String })], Tree.prototype, "selection", void 0);
|
|
553
|
+
__decorate([property({
|
|
554
|
+
type: Boolean,
|
|
555
|
+
attribute: "checkbox-selection"
|
|
556
|
+
})], Tree.prototype, "checkboxSelection", void 0);
|
|
557
|
+
__decorate([property({
|
|
558
|
+
type: String,
|
|
559
|
+
attribute: "selection-propagation"
|
|
560
|
+
})], Tree.prototype, "selectionPropagation", void 0);
|
|
561
|
+
__decorate([property({
|
|
562
|
+
type: Boolean,
|
|
563
|
+
reflect: true,
|
|
564
|
+
attribute: "children-outline"
|
|
565
|
+
})], Tree.prototype, "childrenOutline", void 0);
|
|
566
|
+
__decorate([property({
|
|
567
|
+
type: Boolean,
|
|
568
|
+
reflect: true
|
|
569
|
+
})], Tree.prototype, "disabled", void 0);
|
|
570
|
+
__decorate([property({
|
|
571
|
+
type: Boolean,
|
|
572
|
+
attribute: "expand-on-click"
|
|
573
|
+
})], Tree.prototype, "expandOnClick", void 0);
|
|
574
|
+
__decorate([property({ attribute: false })], Tree.prototype, "loadChildren", void 0);
|
|
575
|
+
__decorate([property({ attribute: false })], Tree.prototype, "renderItem", void 0);
|
|
576
|
+
__decorate([property({ attribute: false })], Tree.prototype, "renderLabel", void 0);
|
|
577
|
+
__decorate([property({ attribute: false })], Tree.prototype, "renderIcon", void 0);
|
|
578
|
+
__decorate([property({ attribute: false })], Tree.prototype, "renderActions", void 0);
|
|
579
|
+
__decorate([query(".c2-tree")], Tree.prototype, "container", void 0);
|
|
580
|
+
__decorate([query("slot:not([name])")], Tree.prototype, "rootSlot", void 0);
|
|
581
|
+
Tree = __decorate([customElement("c2-tree")], Tree);
|
|
582
|
+
/**
|
|
583
|
+
* Unwraps the single wrapper element an Astro island puts around a row in the docs site, so the walk sees the
|
|
584
|
+
* row either way. Returns nothing for anything that is not a row.
|
|
585
|
+
*/
|
|
586
|
+
function unwrap(element) {
|
|
587
|
+
if (element instanceof TreeItem) return [element];
|
|
588
|
+
const child = element.firstElementChild;
|
|
589
|
+
return child instanceof TreeItem ? [child] : [];
|
|
590
|
+
}
|
|
591
|
+
/** Rebuilds only the path down to `value`, leaving every untouched branch identical. */
|
|
592
|
+
function replaceChildren(items, value, children) {
|
|
593
|
+
return items.map((node) => {
|
|
594
|
+
if (node.value === value) return {
|
|
595
|
+
...node,
|
|
596
|
+
children
|
|
597
|
+
};
|
|
598
|
+
if (!node.children) return node;
|
|
599
|
+
const next = replaceChildren(node.children, value, children);
|
|
600
|
+
return next.every((child, index) => child === node.children[index]) ? node : {
|
|
601
|
+
...node,
|
|
602
|
+
children: next
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
//#endregion
|
|
607
|
+
export { Tree };
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@c2n/tree",
|
|
3
|
+
"version": "0.0.9",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/tree.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./types/src/tree.d.ts",
|
|
9
|
+
"default": "./dist/tree.js"
|
|
10
|
+
},
|
|
11
|
+
"./tree-item.js": {
|
|
12
|
+
"types": "./types/src/tree-item.d.ts",
|
|
13
|
+
"default": "./dist/tree-item.js"
|
|
14
|
+
},
|
|
15
|
+
"./tree-types.js": {
|
|
16
|
+
"types": "./types/src/tree-types.d.ts",
|
|
17
|
+
"default": "./dist/tree-types.js"
|
|
18
|
+
},
|
|
19
|
+
"./react": {
|
|
20
|
+
"types": "./react.d.ts",
|
|
21
|
+
"default": "./react.js"
|
|
22
|
+
},
|
|
23
|
+
"./vue": {
|
|
24
|
+
"types": "./vue.d.ts",
|
|
25
|
+
"default": "./vue.js"
|
|
26
|
+
},
|
|
27
|
+
"./custom-elements.json": "./custom-elements.json"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"types",
|
|
32
|
+
"react.d.ts",
|
|
33
|
+
"react.js",
|
|
34
|
+
"vue.d.ts",
|
|
35
|
+
"vue.js"
|
|
36
|
+
],
|
|
37
|
+
"keywords": [
|
|
38
|
+
"tree",
|
|
39
|
+
"web component",
|
|
40
|
+
"lit"
|
|
41
|
+
],
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"author": "code2nguyen@gmail.com",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"registry": "https://registry.npmjs.org",
|
|
46
|
+
"access": "public"
|
|
47
|
+
},
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "https://github.com/code2nguyen/web-components.git"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"dev": "vite",
|
|
54
|
+
"build": "wireit",
|
|
55
|
+
"build:only": "vite build",
|
|
56
|
+
"type-check": "wireit"
|
|
57
|
+
},
|
|
58
|
+
"wireit": {
|
|
59
|
+
"type-check": {
|
|
60
|
+
"dependencies": [
|
|
61
|
+
"../../core:build",
|
|
62
|
+
"../checkbox: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/checkbox": "0.0.9",
|
|
76
|
+
"@c2n/core": "0.0.9",
|
|
77
|
+
"@c2n/spinner": "0.0.9",
|
|
78
|
+
"lit": "3.3.3"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@c2n/config": "*"
|
|
82
|
+
},
|
|
83
|
+
"customElements": "custom-elements.json",
|
|
84
|
+
"gitHead": "e4768cce5e0fbd5bc3165cd493a1e8d57cf0fff4"
|
|
85
|
+
}
|