Skip to content

Commit 6a5b1b9

Browse files
committed
Move abstract tree logic to a separate class
1 parent c12ad85 commit 6a5b1b9

File tree

7 files changed

+188
-157
lines changed

7 files changed

+188
-157
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ A mandatory attribute `name` is used by the `<cbx-tree>` component to construct
187187

188188
### `nohover`
189189

190-
By default, items in the `<cbx-tree>` component grab focus and get highlighted when pointer hovers over them, similarly to options in the `<select>` element’s dropdown. A Boolean attribute `nohover` makes the `<cbx-tree>` deactivate this behaviour, so that items only become selected when clicked or focused by keyboard navigation (similarly to options in a `<select>` with the `multiple` attribute specified).
190+
By default, items in the `<cbx-tree>` component grab focus and get highlighted when pointer hovers over them, similarly to options in the `<select>` element’s dropdown. A Boolean attribute `nohover` makes the `<cbx-tree>` component deactivate this behaviour, so that items only become selected when clicked or focused by keyboard navigation (similarly to options in a `<select>` with the `multiple` attribute specified).
191191

192192
```html
193193
<cbx-tree name="reading-list[]" nohover></cbx-tree>

dist/cbx-tree.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/cbx-tree.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

src/cbx-tree.mjs

Lines changed: 36 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,14 @@
1+
import {Tree} from './tree.mjs';
12
import {treeTemplate} from './templating.mjs';
2-
import {unprefixId, assertRawTreeValid} from './helpers.mjs';
3+
import {unprefixId} from './helpers.mjs';
34
import css from './cbx-tree.css?inline';
45

6+
/** @import {CbxRawTreeItem, CbxTreeItem, CbxTreeMap} from './tree.mjs' */
7+
58
const stylesheet = new CSSStyleSheet();
69
stylesheet.replaceSync(css);
710

811

9-
/**
10-
* Raw user-defined data for a single item of the tree
11-
* @typedef {object} CbxRawTreeItem
12-
* @property {string} title - Item title
13-
* @property {string} value - Item checkbox’s value, unique within the entire tree
14-
* @property {string} [icon] - Item icon’s URL
15-
* @property {boolean} [checked] - Item selection state
16-
* @property {boolean} [collapsed] - Whether a children subtree is collapsed
17-
* @property {CbxRawTreeItem[] | null} [children] - A list of child items, or `null` if subtree isn’t fetched yet
18-
*/
19-
20-
/**
21-
* Internal representation for a single item of the tree
22-
* @typedef {object} CbxTreeItem
23-
* @property {string} id - Item identifier, unique within the entire tree
24-
* @property {string} title - Item title
25-
* @property {string} value - Item checkbox’s value, unique within the entire tree
26-
* @property {string} [icon] - Item icon’s URL
27-
* @property {'checked' | 'unchecked' | 'indeterminate'} state - Computed state of the item’s selection
28-
* @property {boolean} [collapsed] - Whether a children subtree is collapsed
29-
* @property {CbxTreeMap | null} [children] - A map of child items, or `null` if subtree isn’t fetched yet
30-
*/
31-
32-
/**
33-
* Map ids to corresponding tree items
34-
* @typedef {Map<string, CbxTreeItem>} CbxTreeMap
35-
*/
36-
3712
export default class CbxTree extends HTMLElement {
3813
static get formAssociated() {
3914
return true;
@@ -49,11 +24,8 @@ export default class CbxTree extends HTMLElement {
4924
/** @type {ElementInternals} */
5025
#internals;
5126

52-
/** @type {CbxTreeMap} */
53-
#tree = new Map();
54-
55-
/** @type {Set<string>} */
56-
#selection = new Set();
27+
/** @type {Tree} */
28+
#tree;
5729

5830
/** @type {AbortController | null} */
5931
#hoverEventCtrl = null;
@@ -83,8 +55,8 @@ export default class CbxTree extends HTMLElement {
8355
get formData() {
8456
const data = new FormData();
8557
const {name} = this;
86-
this.#selection.forEach((id) => {
87-
const value = this.#getItem(id)?.value;
58+
this.#tree.selection.forEach((id) => {
59+
const value = this.#tree.getItem(id)?.value;
8860
if (value !== undefined) {
8961
data.append(name, value);
9062
}
@@ -145,7 +117,7 @@ export default class CbxTree extends HTMLElement {
145117
}
146118

147119
this.#shadowRoot.addEventListener('change', (e) => this.#onChange(e));
148-
this.#shadowRoot.addEventListener('click', (e) => this.#onItemToggle(e));
120+
this.#shadowRoot.addEventListener('pointerdown', (e) => this.#onItemToggle(e));
149121
this.addEventListener('focus', () => this.#onFocus());
150122
this.#shadowRoot.addEventListener('keydown', (e) => this.#onKeyDown(e));
151123
this.#toggleHoverListener();
@@ -191,9 +163,10 @@ export default class CbxTree extends HTMLElement {
191163
}
192164
}
193165

194-
#onItemToggle({target}) {
195-
if (target.part.contains('toggle')) {
196-
this.#toggleItem(target.closest('[part="item"]'));
166+
#onItemToggle(event) {
167+
if (event.isPrimary && event.target.part.contains('toggle')) {
168+
this.#toggleItem(event.target.closest('[part="item"]'));
169+
event.preventDefault(); // prevent toggle button from grabbing focus
197170
}
198171
}
199172

@@ -265,7 +238,9 @@ export default class CbxTree extends HTMLElement {
265238
}
266239

267240
#onPointerOver({target}) {
268-
const label = target.closest('[part="label"]');
241+
const label = target.part.contains('toggle') ?
242+
target.closest('[part="item"]').querySelector('[part="label"]') :
243+
target.closest('[part="label"]');
269244
this.#focusLabel(label, true);
270245
}
271246

@@ -283,66 +258,30 @@ export default class CbxTree extends HTMLElement {
283258
}
284259

285260
#render() {
286-
this.#shadowRoot.setHTMLUnsafe(treeTemplate(this.#tree));
261+
this.#shadowRoot.setHTMLUnsafe(treeTemplate(this.#tree.tree));
287262
const checkboxes = this.#shadowRoot.querySelectorAll('[part="checkbox"]');
288263
[...checkboxes].forEach((checkbox) => {
289-
const state = this.#getItem(unprefixId(checkbox.id))?.state;
264+
const state = this.#tree.getItem(unprefixId(checkbox.id))?.state;
290265
checkbox.checked = state === 'checked';
291266
checkbox.indeterminate = state === 'indeterminate';
292267
});
293268
this.#focusedLabel = this.#shadowRoot.querySelector('[part="label"]');
294269
}
295270

296-
/**
297-
* Convert raw tree data to internal tree representation
298-
* @param {CbxRawTreeItem[]} rawTree - Raw tree data
299-
* @param {string} parentId - Identifier of a parent item (the case of building a subtree)
300-
* @returns {CbxTreeMap}
301-
*/
302-
#buildTree(rawTree, parentId) {
303-
return new Map(rawTree.map((rawItem, index) => {
304-
const id = parentId ? `${parentId}:${index}` : String(index);
305-
if (rawItem.checked) {
306-
this.#selection.add(id);
307-
}
308-
/** @type {CbxTreeItem} */
309-
const item = {
310-
id,
311-
title: rawItem.title,
312-
value: rawItem.value,
313-
icon: rawItem.icon,
314-
collapsed: rawItem.children?.length ? !!rawItem.collapsed : (rawItem.children === null ? true : undefined),
315-
children: rawItem.children ? this.#buildTree(rawItem.children, id) : rawItem.children,
316-
};
317-
Object.defineProperty(item, 'state', {
318-
get: () => {
319-
if (this.#selection.has(item.id)) {
320-
return 'checked';
321-
}
322-
if (!item.children?.size) {
323-
return 'unchecked';
324-
}
325-
return this.#calcItemState(item);
326-
},
327-
});
328-
return [id, item];
329-
}));
330-
}
331-
332271
async #requestSubtree(parentId) {
333272
if (typeof this.subtreeProvider !== 'function') {
334273
return;
335274
}
336-
const parentItem = this.#getItem(parentId);
275+
const parentItem = this.#tree.getItem(parentId);
337276
if (parentItem?.children !== null) {
338277
return;
339278
}
340279
const itemElement = this.#shadowRoot.getElementById(`item_${parentId}`);
341280
itemElement.inert = true;
342281
try {
343282
const subtree = await this.subtreeProvider(parentItem.value);
344-
assertRawTreeValid(subtree);
345-
parentItem.children = this.#buildTree(subtree, parentItem.id);
283+
Tree.assertRawTreeValid(subtree);
284+
this.#tree.setSubtree(parentItem, subtree);
346285
} finally {
347286
itemElement.inert = false;
348287
}
@@ -357,35 +296,6 @@ export default class CbxTree extends HTMLElement {
357296
this.#refreshFormValue();
358297
}
359298

360-
/**
361-
* Get item object reference by item id
362-
* @param {string} id - Item identifier
363-
* @returns {CbxTreeItem | undefined}
364-
*/
365-
#getItem(id) {
366-
const parts = id.split(':');
367-
return parts.slice(1).reduce((item, part) => item?.children?.get(`${item?.id}:${part}`), this.#tree.get(parts[0]));
368-
}
369-
370-
/**
371-
* Determine item state based on the states of its children
372-
* @param {CbxTreeItem} item
373-
* @returns {'checked' | 'unchecked' | 'indeterminate'}
374-
*/
375-
#calcItemState(item) {
376-
const childrenStates = new Set([...item.children.values()].map(({state}) => state));
377-
if (childrenStates.has('indeterminate')) {
378-
return 'indeterminate';
379-
}
380-
if (!childrenStates.has('checked')) {
381-
return 'unchecked';
382-
}
383-
if (!childrenStates.has('unchecked')) {
384-
return 'checked';
385-
}
386-
return 'indeterminate';
387-
}
388-
389299
/**
390300
* Check/uncheck all items of a the tree or a subtree
391301
* @param {boolean} isChecked
@@ -397,7 +307,7 @@ export default class CbxTree extends HTMLElement {
397307
}
398308
const method = isChecked ? 'add' : 'delete';
399309
tree.forEach((item, id) => {
400-
this.#selection[method](id);
310+
this.#tree.selection[method](id);
401311
const checkbox = this.#shadowRoot.getElementById(`cbx_${id}`);
402312
checkbox.checked = isChecked;
403313
checkbox.indeterminate = false;
@@ -421,7 +331,7 @@ export default class CbxTree extends HTMLElement {
421331
*/
422332
#syncDescendants(item) {
423333
if (item.children) {
424-
this.#setAllChecked(this.#selection.has(item.id), item.children);
334+
this.#setAllChecked(this.#tree.selection.has(item.id), item.children);
425335
}
426336
}
427337

@@ -430,12 +340,12 @@ export default class CbxTree extends HTMLElement {
430340
* @param {CbxTreeItem} item
431341
*/
432342
#syncAncestors(item) {
433-
if (this.#tree.has(item.id)) { // top-level item
343+
if (this.#tree.tree.has(item.id)) { // top-level item
434344
return;
435345
}
436-
const parentItem = this.#getItem(item.id.slice(0, item.id.lastIndexOf(':')));
437-
const state = this.#calcItemState(parentItem);
438-
this.#selection[state === 'checked' ? 'add' : 'delete'](parentItem.id);
346+
const parentItem = this.#tree.getParentItem(item.id);
347+
const state = this.#tree.calcItemState(parentItem);
348+
this.#tree.selection[state === 'checked' ? 'add' : 'delete'](parentItem.id);
439349
const checkbox = this.#shadowRoot.getElementById(`cbx_${parentItem.id}`);
440350
checkbox.checked = state === 'checked';
441351
checkbox.indeterminate = state === 'indeterminate';
@@ -453,8 +363,8 @@ export default class CbxTree extends HTMLElement {
453363
#toggleItemChecked(checkbox) {
454364
const id = unprefixId(checkbox.id);
455365
const method = checkbox.checked ? 'add' : 'delete';
456-
this.#selection[method](id);
457-
const item = this.#getItem(id);
366+
this.#tree.selection[method](id);
367+
const item = this.#tree.getItem(id);
458368
// Order of synchronisation matters (descendants first, then ancestors)
459369
this.#syncDescendants(item);
460370
this.#syncAncestors(item);
@@ -473,7 +383,7 @@ export default class CbxTree extends HTMLElement {
473383
if (isExpanding) {
474384
this.#requestSubtree(id);
475385
}
476-
const item = this.#getItem(id);
386+
const item = this.#tree.getItem(id);
477387
item.collapsed = !isExpanding;
478388
this.#focusedLabel = itemElement.querySelector('[part="label"]');
479389
this.#refreshFormValue();
@@ -593,30 +503,14 @@ export default class CbxTree extends HTMLElement {
593503
const contentJSON = this.textContent.trim() || '[]';
594504
try {
595505
const tree = JSON.parse(contentJSON);
596-
assertRawTreeValid(tree);
506+
Tree.assertRawTreeValid(tree);
597507
return tree;
598508
} catch {
599509
console.error(new DOMException('<cbx-tree> contents must be a valid JSON array representation', 'DataError'));
600510
return [];
601511
}
602512
}
603513

604-
/**
605-
* Convert internal representation of a tree back to its raw format
606-
* @param {CbxTreeMap} tree
607-
* @returns {CbxRawTreeItem[]}
608-
*/
609-
#toRaw(tree = this.#tree) {
610-
return [...tree.values()].map((item) => ({
611-
title: item.title,
612-
value: item.value,
613-
icon: item.icon,
614-
checked: this.#selection.has(item.id),
615-
collapsed: item.collapsed === true ? true : undefined,
616-
children: item.children ? this.#toRaw(item.children) : item.children,
617-
}));
618-
}
619-
620514

621515
// === Public interface ===
622516

@@ -625,15 +519,14 @@ export default class CbxTree extends HTMLElement {
625519
* @param {CbxRawTreeItem[]} treeData
626520
*/
627521
setData(treeData) {
628-
assertRawTreeValid(treeData);
629-
this.#selection.clear();
630-
this.#tree = this.#buildTree(treeData);
522+
Tree.assertRawTreeValid(treeData);
523+
this.#tree = new Tree(treeData);
631524
this.#render();
632525
this.#refreshFormValue();
633526
}
634527

635528
toJSON() {
636-
return this.#toRaw();
529+
return this.#tree.toRaw();
637530
}
638531

639532
/**
@@ -644,7 +537,7 @@ export default class CbxTree extends HTMLElement {
644537
if (checked === undefined) {
645538
checked = !!this.#shadowRoot.querySelector('[part="checkbox"]:not(:checked)');
646539
}
647-
this.#setAllChecked(checked, this.#tree);
540+
this.#setAllChecked(checked, this.#tree.tree);
648541
this.#refreshFormValue();
649542
}
650543

@@ -663,7 +556,7 @@ export default class CbxTree extends HTMLElement {
663556
return;
664557
}
665558
itemElement.ariaExpanded = ariaExpanded;
666-
const item = this.#getItem(unprefixId(itemElement.id));
559+
const item = this.#tree.getItem(unprefixId(itemElement.id));
667560
item.collapsed = !isExpanding;
668561
});
669562
this.#refreshFormValue();

src/helpers.mjs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,3 @@
44
* @returns string
55
*/
66
export const unprefixId = (id) => id?.slice(id.indexOf('_') + 1);
7-
8-
/**
9-
* A loose assertion for the argument to be a valid raw tree
10-
* @param {*} rawTree
11-
*/
12-
export const assertRawTreeValid = (rawTree) => {
13-
if (!Array.isArray(rawTree)) { // cheap and cheerful (kind of)
14-
throw new TypeError('Tree data must be an array of tree items');
15-
}
16-
};

src/templating.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/** @import {CbxTreeItem, CbxTreeMap} from './cbx-tree.mjs' */
1+
/** @import {CbxTreeItem, CbxTreeMap} from './tree.mjs' */
22

33
const sanitize = (unsafeStr) => ['&', '"'].reduce((str, char) => str.replaceAll(char, `&#${char.charCodeAt(0)};`), unsafeStr);
44

0 commit comments

Comments
 (0)