@zeyos/zx 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +15 -0
- package/dist/types/compat/base.d.ts +156 -0
- package/dist/types/compat/globals.d.ts +20 -0
- package/dist/types/compat/index.d.ts +128 -0
- package/dist/types/compat/map/basic.d.ts +175 -0
- package/dist/types/compat/map/data.d.ts +102 -0
- package/dist/types/compat/map/date-time.d.ts +80 -0
- package/dist/types/compat/map/factory.d.ts +9 -0
- package/dist/types/compat/map/form.d.ts +98 -0
- package/dist/types/compat/map/helpers.d.ts +12 -0
- package/dist/types/compat/map/http.d.ts +36 -0
- package/dist/types/compat/map/layout.d.ts +106 -0
- package/dist/types/compat/map/options.d.ts +80 -0
- package/dist/types/compat/map/select.d.ts +95 -0
- package/dist/types/compat/map/stubs.d.ts +32 -0
- package/dist/types/compat/map/utilities.d.ts +37 -0
- package/dist/types/compat-entry.d.ts +5 -0
- package/dist/types/components/badge/badge.d.ts +46 -0
- package/dist/types/components/breadcrumb/breadcrumb.d.ts +200 -0
- package/dist/types/components/button/button.d.ts +42 -0
- package/dist/types/components/check-button/check-button.d.ts +98 -0
- package/dist/types/components/checklist/checklist.d.ts +143 -0
- package/dist/types/components/checklist/field-adapter.d.ts +5 -0
- package/dist/types/components/context-menu/context-menu.d.ts +130 -0
- package/dist/types/components/copy/copy-button.d.ts +48 -0
- package/dist/types/components/copy/copy-input.d.ts +91 -0
- package/dist/types/components/data-filter/data-filter.d.ts +250 -0
- package/dist/types/components/data-filter/filter-core.d.ts +52 -0
- package/dist/types/components/date-picker/date-picker.d.ts +137 -0
- package/dist/types/components/date-picker/date-range-picker.d.ts +350 -0
- package/dist/types/components/date-picker/field-adapter.d.ts +6 -0
- package/dist/types/components/date-picker/month-picker.d.ts +75 -0
- package/dist/types/components/date-picker/range-field-adapter.d.ts +7 -0
- package/dist/types/components/date-picker/time-picker.d.ts +127 -0
- package/dist/types/components/datebox/date-range-box.d.ts +218 -0
- package/dist/types/components/datebox/datebox.d.ts +160 -0
- package/dist/types/components/datebox/field-adapter.d.ts +5 -0
- package/dist/types/components/dialog/dialog.d.ts +284 -0
- package/dist/types/components/dropdown/dropdown.d.ts +118 -0
- package/dist/types/components/empty-state/empty-state.d.ts +38 -0
- package/dist/types/components/field/field.d.ts +231 -0
- package/dist/types/components/field-upload/field-adapter.d.ts +5 -0
- package/dist/types/components/field-upload/field-upload.d.ts +213 -0
- package/dist/types/components/fieldset/fieldset.d.ts +125 -0
- package/dist/types/components/finder/finder.d.ts +252 -0
- package/dist/types/components/form/form.d.ts +194 -0
- package/dist/types/components/groupbox/groupbox.d.ts +89 -0
- package/dist/types/components/layout/layout.d.ts +132 -0
- package/dist/types/components/loading/inline-loading.d.ts +86 -0
- package/dist/types/components/loading/progress-bar.d.ts +168 -0
- package/dist/types/components/loading/spinner.d.ts +34 -0
- package/dist/types/components/master-panel/master-panel.d.ts +112 -0
- package/dist/types/components/menu-button/menu-button.d.ts +142 -0
- package/dist/types/components/message/message.d.ts +212 -0
- package/dist/types/components/modal/modal.d.ts +105 -0
- package/dist/types/components/multi-value-editor/field-adapter.d.ts +5 -0
- package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +66 -0
- package/dist/types/components/navigation-bar/navigation-bar.d.ts +229 -0
- package/dist/types/components/number-field/field-adapter.d.ts +5 -0
- package/dist/types/components/number-field/number-field.d.ts +292 -0
- package/dist/types/components/pagination/pagination.d.ts +267 -0
- package/dist/types/components/panel/panel.d.ts +194 -0
- package/dist/types/components/permission/permission.d.ts +93 -0
- package/dist/types/components/rating/field-adapter.d.ts +5 -0
- package/dist/types/components/rating/rating.d.ts +213 -0
- package/dist/types/components/search/search.d.ts +98 -0
- package/dist/types/components/select/field-adapter.d.ts +5 -0
- package/dist/types/components/select/filter.d.ts +11 -0
- package/dist/types/components/select/select.d.ts +235 -0
- package/dist/types/components/skeleton/skeleton.d.ts +97 -0
- package/dist/types/components/slider/field-adapter.d.ts +5 -0
- package/dist/types/components/slider/slider.d.ts +216 -0
- package/dist/types/components/split-view/split-view.d.ts +335 -0
- package/dist/types/components/stepper/stepper.d.ts +314 -0
- package/dist/types/components/stepper/steps.d.ts +131 -0
- package/dist/types/components/tabbox/tabbox.d.ts +230 -0
- package/dist/types/components/table/sort.d.ts +49 -0
- package/dist/types/components/table/table.d.ts +872 -0
- package/dist/types/components/tag-picker/field-adapter.d.ts +5 -0
- package/dist/types/components/tag-picker/tag-picker.d.ts +375 -0
- package/dist/types/components/timebox/field-adapter.d.ts +5 -0
- package/dist/types/components/timebox/timebox.d.ts +121 -0
- package/dist/types/components/toggle/field-adapter.d.ts +5 -0
- package/dist/types/components/toggle/toggle.d.ts +94 -0
- package/dist/types/components/toolbar/toolbar.d.ts +230 -0
- package/dist/types/components/tooltip/tooltip.d.ts +183 -0
- package/dist/types/components/tree/hierarchy.d.ts +113 -0
- package/dist/types/components/tree/tree.d.ts +383 -0
- package/dist/types/components/truncate/truncate.d.ts +61 -0
- package/dist/types/components/value-list/field-adapter.d.ts +5 -0
- package/dist/types/components/value-list/value-list.d.ts +143 -0
- package/dist/types/core/breakpoint.d.ts +68 -0
- package/dist/types/core/component.d.ts +115 -0
- package/dist/types/core/date.d.ts +50 -0
- package/dist/types/core/dom.d.ts +86 -0
- package/dist/types/core/export.d.ts +70 -0
- package/dist/types/core/fontawesome.d.ts +107 -0
- package/dist/types/core/format.d.ts +107 -0
- package/dist/types/core/http.d.ts +122 -0
- package/dist/types/core/i18n.d.ts +35 -0
- package/dist/types/core/icons.d.ts +137 -0
- package/dist/types/core/keyboard.d.ts +72 -0
- package/dist/types/core/position.d.ts +45 -0
- package/dist/types/core/storage.d.ts +46 -0
- package/dist/types/core/util.d.ts +117 -0
- package/dist/types/elements/define.d.ts +73 -0
- package/dist/types/elements/reflect.d.ts +27 -0
- package/dist/types/index.d.ts +79 -0
- package/dist/types/zeyos/connect.d.ts +65 -0
- package/dist/types/zeyos/form.d.ts +140 -0
- package/dist/types/zeyos/icons.d.ts +104 -0
- package/dist/types/zeyos/index.d.ts +12 -0
- package/dist/types/zeyos/modules.d.ts +110 -0
- package/dist/types/zeyos/query.d.ts +157 -0
- package/dist/types/zeyos/schema.d.ts +79 -0
- package/dist/types/zeyos/select.d.ts +167 -0
- package/dist/types/zeyos/table.d.ts +205 -0
- package/dist/zx-compat.esm.js +375 -39
- package/dist/zx-compat.esm.min.js +1 -1
- package/dist/zx-compat.global.js +372 -73
- package/dist/zx-compat.global.min.js +3 -3
- package/dist/zx-zeyos.esm.js +355 -49
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +68 -6
- package/dist/zx.esm.js +359 -73
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +359 -73
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +48 -2
- package/docs/llms.md +31 -0
- package/package.json +37 -9
- package/src/compat/base.js +291 -0
- package/src/compat/globals.js +165 -0
- package/src/compat/index.js +84 -0
- package/src/compat/map/basic.js +464 -0
- package/src/compat/map/data.js +232 -0
- package/src/compat/map/date-time.js +177 -0
- package/src/compat/map/factory.js +45 -0
- package/src/compat/map/form.js +330 -0
- package/src/compat/map/helpers.js +46 -0
- package/src/compat/map/http.js +117 -0
- package/src/compat/map/layout.js +256 -0
- package/src/compat/map/options.js +267 -0
- package/src/compat/map/select.js +198 -0
- package/src/compat/map/stubs.js +26 -0
- package/src/compat/map/utilities.js +113 -0
- package/src/compat-entry.js +11 -0
- package/src/components/badge/badge.css +147 -0
- package/src/components/badge/badge.js +92 -0
- package/src/components/breadcrumb/breadcrumb.css +105 -0
- package/src/components/breadcrumb/breadcrumb.js +331 -0
- package/src/components/button/button.css +104 -0
- package/src/components/button/button.js +76 -0
- package/src/components/check-button/check-button.css +66 -0
- package/src/components/check-button/check-button.js +167 -0
- package/src/components/checklist/checklist.css +65 -0
- package/src/components/checklist/checklist.js +282 -0
- package/src/components/checklist/field-adapter.js +46 -0
- package/src/components/context-menu/context-menu.css +54 -0
- package/src/components/context-menu/context-menu.js +334 -0
- package/src/components/copy/copy-button.css +27 -0
- package/src/components/copy/copy-button.js +78 -0
- package/src/components/copy/copy-input.css +59 -0
- package/src/components/copy/copy-input.js +123 -0
- package/src/components/data-filter/data-filter.css +53 -0
- package/src/components/data-filter/data-filter.js +400 -0
- package/src/components/data-filter/filter-core.js +128 -0
- package/src/components/date-picker/date-picker.css +181 -0
- package/src/components/date-picker/date-picker.js +500 -0
- package/src/components/date-picker/date-range-picker.css +264 -0
- package/src/components/date-picker/date-range-picker.js +990 -0
- package/src/components/date-picker/field-adapter.js +36 -0
- package/src/components/date-picker/month-picker.js +223 -0
- package/src/components/date-picker/range-field-adapter.js +31 -0
- package/src/components/date-picker/time-picker.css +191 -0
- package/src/components/date-picker/time-picker.js +406 -0
- package/src/components/datebox/date-range-box.css +128 -0
- package/src/components/datebox/date-range-box.js +492 -0
- package/src/components/datebox/datebox.css +108 -0
- package/src/components/datebox/datebox.js +358 -0
- package/src/components/datebox/field-adapter.js +34 -0
- package/src/components/dialog/dialog.css +74 -0
- package/src/components/dialog/dialog.js +450 -0
- package/src/components/dropdown/dropdown.css +24 -0
- package/src/components/dropdown/dropdown.js +288 -0
- package/src/components/empty-state/empty-state.css +80 -0
- package/src/components/empty-state/empty-state.js +92 -0
- package/src/components/field/field.css +127 -0
- package/src/components/field/field.js +605 -0
- package/src/components/field-upload/field-adapter.js +21 -0
- package/src/components/field-upload/field-upload.css +108 -0
- package/src/components/field-upload/field-upload.js +475 -0
- package/src/components/fieldset/fieldset.css +37 -0
- package/src/components/fieldset/fieldset.js +307 -0
- package/src/components/finder/finder.css +98 -0
- package/src/components/finder/finder.js +509 -0
- package/src/components/form/form.css +32 -0
- package/src/components/form/form.js +302 -0
- package/src/components/groupbox/groupbox.css +51 -0
- package/src/components/groupbox/groupbox.js +199 -0
- package/src/components/layout/layout.css +117 -0
- package/src/components/layout/layout.js +136 -0
- package/src/components/loading/inline-loading.css +34 -0
- package/src/components/loading/inline-loading.js +140 -0
- package/src/components/loading/progress-bar.css +115 -0
- package/src/components/loading/progress-bar.js +259 -0
- package/src/components/loading/spinner.css +58 -0
- package/src/components/loading/spinner.js +57 -0
- package/src/components/master-panel/master-panel.css +51 -0
- package/src/components/master-panel/master-panel.js +203 -0
- package/src/components/menu-button/menu-button.css +49 -0
- package/src/components/menu-button/menu-button.js +294 -0
- package/src/components/message/message.css +122 -0
- package/src/components/message/message.js +499 -0
- package/src/components/modal/modal.css +47 -0
- package/src/components/modal/modal.js +203 -0
- package/src/components/multi-value-editor/field-adapter.js +44 -0
- package/src/components/multi-value-editor/multi-value-editor.css +56 -0
- package/src/components/multi-value-editor/multi-value-editor.js +170 -0
- package/src/components/navigation-bar/navigation-bar.css +130 -0
- package/src/components/navigation-bar/navigation-bar.js +450 -0
- package/src/components/number-field/field-adapter.js +40 -0
- package/src/components/number-field/number-field.css +77 -0
- package/src/components/number-field/number-field.js +545 -0
- package/src/components/pagination/pagination.css +95 -0
- package/src/components/pagination/pagination.js +481 -0
- package/src/components/panel/panel.css +123 -0
- package/src/components/panel/panel.js +336 -0
- package/src/components/permission/permission.css +47 -0
- package/src/components/permission/permission.js +198 -0
- package/src/components/rating/field-adapter.js +34 -0
- package/src/components/rating/rating.css +97 -0
- package/src/components/rating/rating.js +395 -0
- package/src/components/search/search.css +70 -0
- package/src/components/search/search.js +193 -0
- package/src/components/select/field-adapter.js +24 -0
- package/src/components/select/filter.js +39 -0
- package/src/components/select/select.css +180 -0
- package/src/components/select/select.js +663 -0
- package/src/components/skeleton/skeleton.css +76 -0
- package/src/components/skeleton/skeleton.js +139 -0
- package/src/components/slider/field-adapter.js +36 -0
- package/src/components/slider/slider.css +181 -0
- package/src/components/slider/slider.js +399 -0
- package/src/components/split-view/split-view.css +139 -0
- package/src/components/split-view/split-view.js +806 -0
- package/src/components/stepper/stepper.css +234 -0
- package/src/components/stepper/stepper.js +459 -0
- package/src/components/stepper/steps.js +131 -0
- package/src/components/tabbox/tabbox.css +281 -0
- package/src/components/tabbox/tabbox.js +484 -0
- package/src/components/table/sort.js +85 -0
- package/src/components/table/table.css +364 -0
- package/src/components/table/table.js +1711 -0
- package/src/components/tag-picker/field-adapter.js +35 -0
- package/src/components/tag-picker/tag-picker.css +169 -0
- package/src/components/tag-picker/tag-picker.js +837 -0
- package/src/components/timebox/field-adapter.js +24 -0
- package/src/components/timebox/timebox.css +51 -0
- package/src/components/timebox/timebox.js +276 -0
- package/src/components/toggle/field-adapter.js +24 -0
- package/src/components/toggle/toggle.css +66 -0
- package/src/components/toggle/toggle.js +150 -0
- package/src/components/toolbar/toolbar.css +81 -0
- package/src/components/toolbar/toolbar.js +511 -0
- package/src/components/tooltip/tooltip.css +36 -0
- package/src/components/tooltip/tooltip.js +420 -0
- package/src/components/tree/hierarchy.js +151 -0
- package/src/components/tree/tree.css +99 -0
- package/src/components/tree/tree.js +784 -0
- package/src/components/truncate/truncate.css +20 -0
- package/src/components/truncate/truncate.js +92 -0
- package/src/components/value-list/field-adapter.js +24 -0
- package/src/components/value-list/value-list.css +108 -0
- package/src/components/value-list/value-list.js +336 -0
- package/src/core/breakpoint.js +150 -0
- package/src/core/component.js +292 -0
- package/src/core/date.js +216 -0
- package/src/core/dom.js +260 -0
- package/src/core/export.js +204 -0
- package/src/core/fontawesome.js +177 -0
- package/src/core/format.js +283 -0
- package/src/core/http.js +246 -0
- package/src/core/i18n.js +83 -0
- package/src/core/icons.js +283 -0
- package/src/core/keyboard.js +240 -0
- package/src/core/position.js +177 -0
- package/src/core/storage.js +175 -0
- package/src/core/util.js +336 -0
- package/src/elements/define.js +715 -0
- package/src/elements/reflect.js +79 -0
- package/src/index.js +131 -0
- package/src/zeyos/connect.js +142 -0
- package/src/zeyos/form.js +254 -0
- package/src/zeyos/icons.js +92 -0
- package/src/zeyos/index.js +21 -0
- package/src/zeyos/modules.js +269 -0
- package/src/zeyos/query.js +199 -0
- package/src/zeyos/schema.js +474 -0
- package/src/zeyos/select.js +175 -0
- package/src/zeyos/table.js +272 -0
- package/styles/base.css +124 -0
- package/styles/icons.css +34 -0
- package/styles/tokens/dark.css +84 -0
- package/styles/tokens/density.css +9 -0
- package/styles/tokens/global.css +80 -0
- package/styles/tokens/modules.css +109 -0
- package/styles/tokens/semantic.css +49 -0
- package/styles/zx.css +61 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import { Component } from '../../core/component.js';
|
|
2
|
+
import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
|
|
3
|
+
import { icon } from '../../core/icons.js';
|
|
4
|
+
import { typeahead } from '../../core/keyboard.js';
|
|
5
|
+
import { uid } from '../../core/util.js';
|
|
6
|
+
import {
|
|
7
|
+
createReaders, descendants, filterTree, findNode, flattenVisible, isBranch, pathTo, walk
|
|
8
|
+
} from './hierarchy.js';
|
|
9
|
+
|
|
10
|
+
/** @typedef {import('./hierarchy.js').TreeNode} TreeNode */
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} TreeOptions
|
|
13
|
+
* @property {TreeNode[]} [items=[]] Nested nodes.
|
|
14
|
+
* @property {string|((node: TreeNode) => unknown)} [valueKey='ID'] Node ID property or reader.
|
|
15
|
+
* @property {string|((node: TreeNode) => string)} [labelKey='name'] Node label property or reader.
|
|
16
|
+
* @property {string} [childrenKey='children'] Property holding child nodes.
|
|
17
|
+
* @property {unknown[]} [expanded=[]] Initially expanded node IDs.
|
|
18
|
+
* @property {false|'single'|'multi'} [selection='single'] Selection mode.
|
|
19
|
+
* @property {unknown[]} [selected=[]] Initially selected node IDs.
|
|
20
|
+
* @property {boolean} [checkboxes=false] Whether to render tri-state checkboxes.
|
|
21
|
+
* @property {unknown[]} [checked=[]] Initially checked node IDs.
|
|
22
|
+
* @property {boolean} [icons=true] Whether to render folder and file icons.
|
|
23
|
+
* @property {((node: TreeNode) => Node|string)|null} [renderLabel=null] Label renderer.
|
|
24
|
+
* @property {((node: TreeNode) => Promise<TreeNode[]>|TreeNode[])|null} [load=null] Lazy child loader.
|
|
25
|
+
* @property {string} [filter=''] Label substring the tree is filtered by.
|
|
26
|
+
* @property {string} [label='Tree'] Accessible name of the tree.
|
|
27
|
+
* @property {string} [emptyText='Nothing to show'] Text shown when the tree is empty.
|
|
28
|
+
* @property {number|string|null} [height=null] Scroll height in pixels or any CSS length.
|
|
29
|
+
* @property {Record<string, string>|Record<string, Record<string, string>>} [msg] Localized messages.
|
|
30
|
+
* @property {(event: CustomEvent<{node: TreeNode, id: unknown, ids: unknown[]}>) => void} [onselect] Selection listener.
|
|
31
|
+
* @property {(event: CustomEvent<{node: TreeNode, id: unknown}>) => void} [onexpand] Expand listener.
|
|
32
|
+
* @property {(event: CustomEvent<{node: TreeNode, id: unknown}>) => void} [oncollapse] Collapse listener.
|
|
33
|
+
* @property {(event: CustomEvent<{node: TreeNode, id: unknown}>) => void} [onactivate] Activation listener.
|
|
34
|
+
* @property {(event: CustomEvent<{ids: unknown[], node: TreeNode|null}>) => void} [oncheck] Check listener.
|
|
35
|
+
* @property {(event: CustomEvent<{error: Error, node: TreeNode, id: unknown}>) => void} [onerror] Loader-failure listener.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Hierarchical tree implementing the APG tree pattern.
|
|
40
|
+
*
|
|
41
|
+
* Rows are rendered as a flat list carrying `aria-level` / `aria-setsize` / `aria-posinset`
|
|
42
|
+
* rather than nested `role="group"` containers — the flattened variant the APG allows. It keeps
|
|
43
|
+
* roving tabindex, arrow-key movement, and virtualization-friendly re-rendering simple.
|
|
44
|
+
*
|
|
45
|
+
* @fires TreeView#select
|
|
46
|
+
* @fires TreeView#expand
|
|
47
|
+
* @fires TreeView#collapse
|
|
48
|
+
* @fires TreeView#activate
|
|
49
|
+
* @fires TreeView#check
|
|
50
|
+
* @fires TreeView#error
|
|
51
|
+
* @extends {Component<TreeOptions>}
|
|
52
|
+
*/
|
|
53
|
+
export class TreeView extends Component {
|
|
54
|
+
static cssName = 'tree';
|
|
55
|
+
|
|
56
|
+
/** @type {TreeOptions} */
|
|
57
|
+
static defaults = {
|
|
58
|
+
items: [],
|
|
59
|
+
valueKey: 'ID',
|
|
60
|
+
labelKey: 'name',
|
|
61
|
+
childrenKey: 'children',
|
|
62
|
+
expanded: [],
|
|
63
|
+
selection: 'single',
|
|
64
|
+
selected: [],
|
|
65
|
+
checkboxes: false,
|
|
66
|
+
checked: [],
|
|
67
|
+
icons: true,
|
|
68
|
+
renderLabel: null,
|
|
69
|
+
load: null,
|
|
70
|
+
filter: '',
|
|
71
|
+
label: 'Tree',
|
|
72
|
+
emptyText: 'Nothing to show',
|
|
73
|
+
height: null
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** @returns {HTMLElement} */
|
|
77
|
+
render() {
|
|
78
|
+
// render() runs inside the base constructor, before class-field initializers would run.
|
|
79
|
+
this._items = [];
|
|
80
|
+
this._rows = [];
|
|
81
|
+
this._expanded = new Set();
|
|
82
|
+
this._selected = new Set();
|
|
83
|
+
this._checked = new Set();
|
|
84
|
+
/** @type {Map<unknown, Promise<boolean>>} In-flight lazy loads, keyed by node ID. */
|
|
85
|
+
this._loading = new Map();
|
|
86
|
+
this._focusId = null;
|
|
87
|
+
this._filter = '';
|
|
88
|
+
this._createdRoot = this.el === null;
|
|
89
|
+
this._snapshot = this._createdRoot ? null : snapshotTarget(this.el);
|
|
90
|
+
|
|
91
|
+
const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
|
|
92
|
+
this.el = root;
|
|
93
|
+
this._readers = createReaders(this.options);
|
|
94
|
+
this._childrenKey = this.options.childrenKey ?? 'children';
|
|
95
|
+
|
|
96
|
+
root.setAttribute('role', 'tree');
|
|
97
|
+
root.setAttribute('aria-label', String(this.options.label ?? 'Tree'));
|
|
98
|
+
root.tabIndex = 0;
|
|
99
|
+
if (this.options.selection === 'multi') root.setAttribute('aria-multiselectable', 'true');
|
|
100
|
+
if (this.options.height !== null) {
|
|
101
|
+
root.style.blockSize = typeof this.options.height === 'number'
|
|
102
|
+
? `${this.options.height}px`
|
|
103
|
+
: String(this.options.height);
|
|
104
|
+
root.style.overflowY = 'auto';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this._items = cloneNodes(this.options.items);
|
|
108
|
+
for (const id of toIterable(this.options.expanded)) this._expanded.add(id);
|
|
109
|
+
for (const id of toIterable(this.options.selected)) this._selected.add(id);
|
|
110
|
+
// The initial tab stop belongs on the first selected node, not always on row zero.
|
|
111
|
+
const [firstSelected] = this._selected;
|
|
112
|
+
if (firstSelected !== undefined) {
|
|
113
|
+
this._focusId = firstSelected;
|
|
114
|
+
for (const node of this.getPath(firstSelected).slice(0, -1)) {
|
|
115
|
+
this._expanded.add(this._readers.value(node));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
for (const id of toIterable(this.options.checked)) this._applyCheck(id, true, { silent: true });
|
|
119
|
+
|
|
120
|
+
// Typeahead searches from the row after the focused one, so repeating a letter walks through
|
|
121
|
+
// every match instead of sticking on the first.
|
|
122
|
+
this._handleTypeahead = typeahead(
|
|
123
|
+
() => this._rotatedRows().map((row) => this._readers.label(row.node)),
|
|
124
|
+
(_label, index) => this._focusNodeId(this._rotatedRows()[index]?.id)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
this.listen(root, 'keydown', (event) => this._onKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
128
|
+
this.listen(root, 'click', (event) => this._onClick(/** @type {MouseEvent} */ (event)));
|
|
129
|
+
this.listen(root, 'dblclick', (event) => this._onDoubleClick(/** @type {MouseEvent} */ (event)));
|
|
130
|
+
this.listen(root, 'focus', () => {
|
|
131
|
+
// Focusing the container hands focus straight to the active row.
|
|
132
|
+
if (this._rows.length > 0 && !this._rowElement(this._focusId)) this._focusRow(0);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Runs the same expansion logic as setFilter() so an initial filter behaves identically.
|
|
136
|
+
if (String(this.options.filter ?? '') !== '') this.setFilter(this.options.filter);
|
|
137
|
+
else this._renderRows();
|
|
138
|
+
return root;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* --------------------------------------------------------------------- data -- */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Replaces the whole tree.
|
|
145
|
+
* @param {TreeNode[]} items Nested nodes.
|
|
146
|
+
* @returns {this}
|
|
147
|
+
*/
|
|
148
|
+
setItems(items) {
|
|
149
|
+
this._items = cloneNodes(items);
|
|
150
|
+
this._renderRows();
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Returns the working copy of the tree, including lazily loaded children.
|
|
156
|
+
* @returns {TreeNode[]}
|
|
157
|
+
*/
|
|
158
|
+
getItems() {
|
|
159
|
+
return this._items;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Looks a node up by ID.
|
|
164
|
+
* @param {unknown} id Node ID.
|
|
165
|
+
* @returns {TreeNode|null}
|
|
166
|
+
*/
|
|
167
|
+
getNode(id) {
|
|
168
|
+
return findNode(this._items, id, this._readers);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Returns the chain of nodes from a root down to the given node.
|
|
173
|
+
* @param {unknown} id Node ID.
|
|
174
|
+
* @returns {TreeNode[]}
|
|
175
|
+
*/
|
|
176
|
+
getPath(id) {
|
|
177
|
+
return pathTo(this._items, id, this._readers);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Filters the tree by a label substring, keeping the ancestors of every match.
|
|
182
|
+
* @param {string} query Search text; an empty string restores the full tree.
|
|
183
|
+
* @returns {this}
|
|
184
|
+
*/
|
|
185
|
+
setFilter(query) {
|
|
186
|
+
this._filter = String(query ?? '');
|
|
187
|
+
if (this._filter !== '') {
|
|
188
|
+
// A filtered tree is only useful expanded, so every surviving branch opens.
|
|
189
|
+
for (const node of this._visibleItems()) {
|
|
190
|
+
walk([node], this._readers, (child) => this._expanded.add(this._readers.value(child)));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this._renderRows();
|
|
194
|
+
return this;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---------------------------------------------------------------- expansion -- */
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Expands one branch, loading its children first when a lazy loader is configured.
|
|
201
|
+
* @param {unknown} id Node ID.
|
|
202
|
+
* @returns {Promise<this>}
|
|
203
|
+
* @fires TreeView#expand
|
|
204
|
+
*/
|
|
205
|
+
async expand(id) {
|
|
206
|
+
const node = this.getNode(id);
|
|
207
|
+
if (!node || !isBranch(node, this._readers)) return this;
|
|
208
|
+
const loaded = this._readers.children(node);
|
|
209
|
+
const needsLoad = Boolean(this.options.load) && (!loaded || loaded.length === 0);
|
|
210
|
+
// An ID can already be in the expanded set without its children having been fetched
|
|
211
|
+
// (`expanded` option, expandAll, `*`, filtering) — that still has to trigger the loader.
|
|
212
|
+
if (this._expanded.has(id) && !needsLoad) return this;
|
|
213
|
+
const ok = await this._ensureChildren(node, id);
|
|
214
|
+
if (!ok) return this;
|
|
215
|
+
this._expanded.add(id);
|
|
216
|
+
this._renderRows();
|
|
217
|
+
this.emit('expand', { node, id });
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Collapses one branch.
|
|
223
|
+
* @param {unknown} id Node ID.
|
|
224
|
+
* @returns {this}
|
|
225
|
+
* @fires TreeView#collapse
|
|
226
|
+
*/
|
|
227
|
+
collapse(id) {
|
|
228
|
+
if (!this._expanded.delete(id)) return this;
|
|
229
|
+
const node = this.getNode(id);
|
|
230
|
+
this._renderRows();
|
|
231
|
+
if (node) this.emit('collapse', { node, id });
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Expands or collapses one branch.
|
|
237
|
+
* @param {unknown} id Node ID.
|
|
238
|
+
* @returns {Promise<this>}
|
|
239
|
+
*/
|
|
240
|
+
async toggle(id) {
|
|
241
|
+
if (this._expanded.has(id)) return this.collapse(id);
|
|
242
|
+
return this.expand(id);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Expands every already-loaded branch.
|
|
247
|
+
* @returns {this}
|
|
248
|
+
*/
|
|
249
|
+
expandAll() {
|
|
250
|
+
walk(this._items, this._readers, (node) => {
|
|
251
|
+
if (isBranch(node, this._readers)) this._expanded.add(this._readers.value(node));
|
|
252
|
+
});
|
|
253
|
+
this._renderRows();
|
|
254
|
+
return this;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Collapses every branch. @returns {this} */
|
|
258
|
+
collapseAll() {
|
|
259
|
+
this._expanded.clear();
|
|
260
|
+
this._renderRows();
|
|
261
|
+
return this;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Expands every ancestor of a node so the node becomes visible.
|
|
266
|
+
* @param {unknown} id Node ID.
|
|
267
|
+
* @returns {this}
|
|
268
|
+
*/
|
|
269
|
+
reveal(id) {
|
|
270
|
+
const chain = this.getPath(id);
|
|
271
|
+
for (const node of chain.slice(0, -1)) this._expanded.add(this._readers.value(node));
|
|
272
|
+
this._renderRows();
|
|
273
|
+
return this;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ---------------------------------------------------------------- selection -- */
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Selects one node, replacing the selection in single mode.
|
|
280
|
+
* @param {unknown} id Node ID.
|
|
281
|
+
* @param {{silent?: boolean, additive?: boolean}} [options={}] Selection behavior.
|
|
282
|
+
* @returns {this}
|
|
283
|
+
* @fires TreeView#select
|
|
284
|
+
*/
|
|
285
|
+
select(id, { silent = false, additive = false } = {}) {
|
|
286
|
+
if (this.options.selection === false) return this;
|
|
287
|
+
const node = this.getNode(id);
|
|
288
|
+
if (!node) return this;
|
|
289
|
+
if (this.options.selection === 'multi' && additive) {
|
|
290
|
+
if (this._selected.has(id)) this._selected.delete(id);
|
|
291
|
+
else this._selected.add(id);
|
|
292
|
+
} else {
|
|
293
|
+
this._selected.clear();
|
|
294
|
+
this._selected.add(id);
|
|
295
|
+
}
|
|
296
|
+
this._syncRows();
|
|
297
|
+
if (!silent) this.emit('select', { node, id, ids: this.getSelection() });
|
|
298
|
+
return this;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Returns the selected node IDs.
|
|
303
|
+
* @returns {unknown[]}
|
|
304
|
+
*/
|
|
305
|
+
getSelection() {
|
|
306
|
+
return [...this._selected];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Replaces the selection.
|
|
311
|
+
* @param {unknown[]} ids Node IDs.
|
|
312
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress `select`.
|
|
313
|
+
* @returns {this}
|
|
314
|
+
*/
|
|
315
|
+
setSelection(ids, { silent = true } = {}) {
|
|
316
|
+
const list = toIterable(ids);
|
|
317
|
+
// Single mode must never render two aria-selected rows.
|
|
318
|
+
this._selected = new Set(this.options.selection === 'multi' ? list : list.slice(0, 1));
|
|
319
|
+
this._syncRows();
|
|
320
|
+
if (!silent) {
|
|
321
|
+
const [first] = this._selected;
|
|
322
|
+
this.emit('select', { node: this.getNode(first) ?? null, id: first, ids: this.getSelection() });
|
|
323
|
+
}
|
|
324
|
+
return this;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Clears the selection. @returns {this} */
|
|
328
|
+
clearSelection() {
|
|
329
|
+
this._selected.clear();
|
|
330
|
+
this._syncRows();
|
|
331
|
+
return this;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* ------------------------------------------------------------------ checking -- */
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Returns the checked node IDs.
|
|
338
|
+
* @param {{leavesOnly?: boolean}} [options={}] Set `leavesOnly` to skip fully checked branches.
|
|
339
|
+
* @returns {unknown[]}
|
|
340
|
+
*/
|
|
341
|
+
getChecked({ leavesOnly = false } = {}) {
|
|
342
|
+
if (!leavesOnly) return [...this._checked];
|
|
343
|
+
return [...this._checked].filter((id) => {
|
|
344
|
+
const node = this.getNode(id);
|
|
345
|
+
return node !== null && !isBranch(node, this._readers);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Replaces the checked set, propagating each ID down its sub-tree.
|
|
351
|
+
* @param {unknown[]} ids Node IDs.
|
|
352
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress `check`.
|
|
353
|
+
* @returns {this}
|
|
354
|
+
* @fires TreeView#check
|
|
355
|
+
*/
|
|
356
|
+
setChecked(ids, { silent = true } = {}) {
|
|
357
|
+
this._checked.clear();
|
|
358
|
+
for (const id of toIterable(ids)) this._applyCheck(id, true, { silent: true });
|
|
359
|
+
this._syncRows();
|
|
360
|
+
if (!silent) this.emit('check', { ids: this.getChecked(), node: null });
|
|
361
|
+
return this;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Checks or unchecks one node and its sub-tree.
|
|
366
|
+
* @param {unknown} id Node ID.
|
|
367
|
+
* @param {boolean} checked Next state.
|
|
368
|
+
* @param {{silent?: boolean}} [options={}] Set `silent` to suppress `check`.
|
|
369
|
+
* @returns {this}
|
|
370
|
+
* @fires TreeView#check
|
|
371
|
+
*/
|
|
372
|
+
check(id, checked, { silent = false } = {}) {
|
|
373
|
+
const node = this._applyCheck(id, checked, { silent: true });
|
|
374
|
+
this._syncRows();
|
|
375
|
+
if (!silent && node) this.emit('check', { ids: this.getChecked(), node });
|
|
376
|
+
return this;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* ----------------------------------------------------------------- focusing -- */
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Moves DOM focus to a node, revealing it first.
|
|
383
|
+
* @param {unknown} id Node ID.
|
|
384
|
+
* @returns {this}
|
|
385
|
+
*/
|
|
386
|
+
focusNode(id) {
|
|
387
|
+
this.reveal(id);
|
|
388
|
+
const index = this._rows.findIndex((row) => Object.is(row.id, id));
|
|
389
|
+
if (index >= 0) this._focusRow(index);
|
|
390
|
+
return this;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* ---------------------------------------------------------------- rendering -- */
|
|
394
|
+
|
|
395
|
+
/** @returns {TreeNode[]} */
|
|
396
|
+
_visibleItems() {
|
|
397
|
+
if (this._filter === '') return this._items;
|
|
398
|
+
const needle = this._filter.trim().toLocaleLowerCase();
|
|
399
|
+
if (needle === '') return this._items;
|
|
400
|
+
return filterTree(
|
|
401
|
+
this._items,
|
|
402
|
+
this._readers,
|
|
403
|
+
(node) => this._readers.label(node).toLocaleLowerCase().includes(needle),
|
|
404
|
+
this._childrenKey
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Rebuilds every row from the current tree, expansion set, and filter. @returns {void} */
|
|
409
|
+
_renderRows() {
|
|
410
|
+
const items = this._visibleItems();
|
|
411
|
+
this._rows = flattenVisible(items, this._readers, this._expanded);
|
|
412
|
+
|
|
413
|
+
if (this._rows.length === 0) {
|
|
414
|
+
this.el.replaceChildren(h('div', { class: 'zx-tree__empty' },
|
|
415
|
+
this._message('tree.empty', this.options.emptyText)));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
this.el.replaceChildren(...this._rows.map((row) => this._renderRow(row)));
|
|
419
|
+
this._syncRows();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {{node: TreeNode, id: unknown, level: number, posinset: number, setsize: number}} row Row spec.
|
|
424
|
+
* @returns {HTMLElement}
|
|
425
|
+
*/
|
|
426
|
+
_renderRow(row) {
|
|
427
|
+
const branch = isBranch(row.node, this._readers);
|
|
428
|
+
const expanded = branch && this._expanded.has(row.id);
|
|
429
|
+
const element = h('div', {
|
|
430
|
+
class: 'zx-tree__row',
|
|
431
|
+
role: 'treeitem',
|
|
432
|
+
id: uid('zx-tree-item'),
|
|
433
|
+
tabIndex: -1,
|
|
434
|
+
ariaLevel: String(row.level),
|
|
435
|
+
ariaSetSize: String(row.setsize),
|
|
436
|
+
ariaPosInSet: String(row.posinset),
|
|
437
|
+
dataset: { level: String(row.level) }
|
|
438
|
+
});
|
|
439
|
+
element.style.setProperty('--zx-tree-level', String(row.level - 1));
|
|
440
|
+
|
|
441
|
+
const twisty = h('span', { class: 'zx-tree__twisty', ariaHidden: 'true' },
|
|
442
|
+
branch ? icon('chevron-right', { size: 12 }) : null);
|
|
443
|
+
twisty.dataset.action = 'twisty';
|
|
444
|
+
element.append(twisty);
|
|
445
|
+
|
|
446
|
+
if (this.options.checkboxes) {
|
|
447
|
+
const box = /** @type {HTMLInputElement} */ (h('input', {
|
|
448
|
+
class: 'zx-tree__checkbox',
|
|
449
|
+
type: 'checkbox',
|
|
450
|
+
tabIndex: -1,
|
|
451
|
+
ariaHidden: 'true'
|
|
452
|
+
}));
|
|
453
|
+
box.dataset.action = 'checkbox';
|
|
454
|
+
element.append(box);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (this.options.icons) {
|
|
458
|
+
element.append(h('span', { class: 'zx-tree__icon', ariaHidden: 'true' },
|
|
459
|
+
icon(branch ? (expanded ? 'folder-open' : 'folder') : 'file', { size: 14 })));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const content = this.options.renderLabel
|
|
463
|
+
? this.options.renderLabel(row.node)
|
|
464
|
+
: this._readers.label(row.node);
|
|
465
|
+
element.append(h('span', { class: 'zx-tree__label' }, content));
|
|
466
|
+
|
|
467
|
+
if (row.node.badge != null) {
|
|
468
|
+
element.append(h('span', { class: 'zx-tree__badge' }, String(row.node.badge)));
|
|
469
|
+
}
|
|
470
|
+
return element;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Applies expansion, selection, check, loading, and roving-tabindex state to every row. */
|
|
474
|
+
_syncRows() {
|
|
475
|
+
const elements = this.el.querySelectorAll('.zx-tree__row');
|
|
476
|
+
let focusIndex = this._rows.findIndex((row) => Object.is(row.id, this._focusId));
|
|
477
|
+
if (focusIndex < 0) focusIndex = 0;
|
|
478
|
+
|
|
479
|
+
elements.forEach((element, index) => {
|
|
480
|
+
const row = this._rows[index];
|
|
481
|
+
if (!row) return;
|
|
482
|
+
const branch = isBranch(row.node, this._readers);
|
|
483
|
+
const expanded = branch && this._expanded.has(row.id);
|
|
484
|
+
if (branch) element.setAttribute('aria-expanded', String(expanded));
|
|
485
|
+
else element.removeAttribute('aria-expanded');
|
|
486
|
+
if (this.options.selection !== false) {
|
|
487
|
+
element.setAttribute('aria-selected', String(this._selected.has(row.id)));
|
|
488
|
+
}
|
|
489
|
+
element.toggleAttribute('data-branch', branch);
|
|
490
|
+
element.toggleAttribute('data-loading', this._loading.has(row.id));
|
|
491
|
+
element.tabIndex = index === focusIndex ? 0 : -1;
|
|
492
|
+
|
|
493
|
+
const box = /** @type {HTMLInputElement|null} */ (element.querySelector('.zx-tree__checkbox'));
|
|
494
|
+
if (box) {
|
|
495
|
+
// Always resolve against the unfiltered tree: a filtered clone has pruned children and
|
|
496
|
+
// would report "checked" for a branch with unchecked hidden descendants.
|
|
497
|
+
const state = this._checkState(this.getNode(row.id) ?? row.node);
|
|
498
|
+
box.checked = state === 'checked';
|
|
499
|
+
box.indeterminate = state === 'partial';
|
|
500
|
+
element.setAttribute('aria-checked', state === 'partial' ? 'mixed' : String(box.checked));
|
|
501
|
+
}
|
|
502
|
+
const glyph = element.querySelector('.zx-tree__icon');
|
|
503
|
+
if (glyph && this.options.icons) {
|
|
504
|
+
glyph.replaceChildren(icon(branch ? (expanded ? 'folder-open' : 'folder') : 'file', { size: 14 }));
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
this._focusId = this._rows[focusIndex]?.id ?? null;
|
|
508
|
+
// The container only takes focus while it is empty; otherwise a row owns the tab stop.
|
|
509
|
+
this.el.tabIndex = this._rows.length === 0 ? 0 : -1;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/* -------------------------------------------------------------- interaction -- */
|
|
513
|
+
|
|
514
|
+
/** @param {MouseEvent} event @returns {void} */
|
|
515
|
+
_onClick(event) {
|
|
516
|
+
const element = /** @type {Element} */ (event.target).closest('.zx-tree__row');
|
|
517
|
+
if (!element) return;
|
|
518
|
+
const index = this._rowIndex(element);
|
|
519
|
+
if (index < 0) return;
|
|
520
|
+
const row = this._rows[index];
|
|
521
|
+
this._focusRow(index);
|
|
522
|
+
|
|
523
|
+
const action = /** @type {Element} */ (event.target).closest('[data-action]');
|
|
524
|
+
if (action?.dataset.action === 'twisty') {
|
|
525
|
+
// toggle() re-renders every row, so focus has to be put back on this one afterwards.
|
|
526
|
+
if (isBranch(row.node, this._readers)) void this.toggle(row.id).then(() => this._focusNodeId(row.id));
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const node = this.getNode(row.id) ?? row.node;
|
|
530
|
+
if (action?.dataset.action === 'checkbox') {
|
|
531
|
+
this.check(row.id, this._checkState(node) !== 'checked');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (this.options.checkboxes && this.options.selection === false) {
|
|
535
|
+
this.check(row.id, this._checkState(node) !== 'checked');
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
this.select(row.id, { additive: event.ctrlKey || event.metaKey });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @param {MouseEvent} event @returns {void} */
|
|
542
|
+
_onDoubleClick(event) {
|
|
543
|
+
const element = /** @type {Element} */ (event.target).closest('.zx-tree__row');
|
|
544
|
+
if (!element) return;
|
|
545
|
+
const row = this._rows[this._rowIndex(element)];
|
|
546
|
+
if (!row) return;
|
|
547
|
+
if (isBranch(row.node, this._readers)) {
|
|
548
|
+
void this.toggle(row.id).then(() => this._focusNodeId(row.id));
|
|
549
|
+
} else {
|
|
550
|
+
this.emit('activate', { node: row.node, id: row.id });
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
555
|
+
_onKeydown(event) {
|
|
556
|
+
const index = this._rows.findIndex((row) => Object.is(row.id, this._focusId));
|
|
557
|
+
if (index < 0 || this._rows.length === 0) return;
|
|
558
|
+
const row = this._rows[index];
|
|
559
|
+
const branch = isBranch(row.node, this._readers);
|
|
560
|
+
const expanded = branch && this._expanded.has(row.id);
|
|
561
|
+
|
|
562
|
+
switch (event.key) {
|
|
563
|
+
case 'ArrowDown':
|
|
564
|
+
event.preventDefault();
|
|
565
|
+
this._focusRow(Math.min(this._rows.length - 1, index + 1));
|
|
566
|
+
return;
|
|
567
|
+
case 'ArrowUp':
|
|
568
|
+
event.preventDefault();
|
|
569
|
+
this._focusRow(Math.max(0, index - 1));
|
|
570
|
+
return;
|
|
571
|
+
case 'ArrowRight':
|
|
572
|
+
event.preventDefault();
|
|
573
|
+
// Re-find the row by ID after the async expand: positional indices can have shifted.
|
|
574
|
+
if (branch && !expanded) void this.expand(row.id).then(() => this._focusNodeId(row.id));
|
|
575
|
+
else if (expanded) this._focusRow(Math.min(this._rows.length - 1, index + 1));
|
|
576
|
+
return;
|
|
577
|
+
case 'ArrowLeft': {
|
|
578
|
+
event.preventDefault();
|
|
579
|
+
if (expanded) {
|
|
580
|
+
this.collapse(row.id);
|
|
581
|
+
this._focusNodeId(row.id);
|
|
582
|
+
} else if (row.parent) {
|
|
583
|
+
this._focusNodeId(this._readers.value(row.parent));
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
case 'Home':
|
|
588
|
+
event.preventDefault();
|
|
589
|
+
this._focusRow(0);
|
|
590
|
+
return;
|
|
591
|
+
case 'End':
|
|
592
|
+
event.preventDefault();
|
|
593
|
+
this._focusRow(this._rows.length - 1);
|
|
594
|
+
return;
|
|
595
|
+
case 'Enter':
|
|
596
|
+
event.preventDefault();
|
|
597
|
+
if (this.options.selection !== false) this.select(row.id);
|
|
598
|
+
this.emit('activate', { node: row.node, id: row.id });
|
|
599
|
+
return;
|
|
600
|
+
case ' ':
|
|
601
|
+
event.preventDefault();
|
|
602
|
+
if (this.options.checkboxes) {
|
|
603
|
+
this.check(row.id, this._checkState(this.getNode(row.id) ?? row.node) !== 'checked');
|
|
604
|
+
}
|
|
605
|
+
else if (this.options.selection !== false) {
|
|
606
|
+
this.select(row.id, { additive: this.options.selection === 'multi' });
|
|
607
|
+
}
|
|
608
|
+
return;
|
|
609
|
+
case '*':
|
|
610
|
+
event.preventDefault();
|
|
611
|
+
this._expandSiblings(row);
|
|
612
|
+
return;
|
|
613
|
+
default:
|
|
614
|
+
this._handleTypeahead(event);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Expands every sibling branch of a row, the APG `*` shortcut.
|
|
620
|
+
* @param {{parent: TreeNode|null}} row Focused row.
|
|
621
|
+
* @returns {void}
|
|
622
|
+
*/
|
|
623
|
+
_expandSiblings(row) {
|
|
624
|
+
const siblings = row.parent ? this._readers.children(row.parent) ?? [] : this._visibleItems();
|
|
625
|
+
const focused = this._focusId;
|
|
626
|
+
for (const node of siblings) {
|
|
627
|
+
if (isBranch(node, this._readers)) this._expanded.add(this._readers.value(node));
|
|
628
|
+
}
|
|
629
|
+
this._renderRows();
|
|
630
|
+
this._focusNodeId(focused);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* ------------------------------------------------------------------ internals -- */
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Loads a branch's children through the configured loader, once.
|
|
637
|
+
* @param {TreeNode} node Branch node.
|
|
638
|
+
* @param {unknown} id Node ID.
|
|
639
|
+
* @returns {Promise<void>}
|
|
640
|
+
*/
|
|
641
|
+
async _ensureChildren(node, id) {
|
|
642
|
+
const loaded = this._readers.children(node);
|
|
643
|
+
if (!this.options.load || (loaded && loaded.length > 0)) return true;
|
|
644
|
+
// One in-flight request per branch: concurrent expansions must not call the loader twice.
|
|
645
|
+
if (this._loading.has(id)) {
|
|
646
|
+
await this._loading.get(id);
|
|
647
|
+
return Array.isArray(this._readers.children(node));
|
|
648
|
+
}
|
|
649
|
+
const request = (async () => {
|
|
650
|
+
try {
|
|
651
|
+
const children = await this.options.load(node);
|
|
652
|
+
node[this._childrenKey] = Array.isArray(children) ? children : [];
|
|
653
|
+
// A branch that turns out to be empty must stop advertising children.
|
|
654
|
+
if (node[this._childrenKey].length === 0) node.hasChildren = false;
|
|
655
|
+
return true;
|
|
656
|
+
} catch (error) {
|
|
657
|
+
this.emit('error', { error, node, id });
|
|
658
|
+
return false;
|
|
659
|
+
} finally {
|
|
660
|
+
this._loading.delete(id);
|
|
661
|
+
}
|
|
662
|
+
})();
|
|
663
|
+
this._loading.set(id, request);
|
|
664
|
+
this._syncRows();
|
|
665
|
+
const ok = await request;
|
|
666
|
+
this._syncRows();
|
|
667
|
+
return ok;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* @param {unknown} id Node ID.
|
|
672
|
+
* @param {boolean} checked Next state.
|
|
673
|
+
* @param {{silent?: boolean}} options Behavior.
|
|
674
|
+
* @returns {TreeNode|null}
|
|
675
|
+
*/
|
|
676
|
+
_applyCheck(id, checked, { silent }) {
|
|
677
|
+
const node = this.getNode(id);
|
|
678
|
+
if (!node) return null;
|
|
679
|
+
const ids = [id, ...descendants(node, this._readers).map((child) => this._readers.value(child))];
|
|
680
|
+
for (const value of ids) {
|
|
681
|
+
if (checked) this._checked.add(value);
|
|
682
|
+
else this._checked.delete(value);
|
|
683
|
+
}
|
|
684
|
+
if (!silent) this._syncRows();
|
|
685
|
+
return node;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Derives a node's tri-state check state from its descendants.
|
|
690
|
+
* @param {TreeNode} node Node to inspect.
|
|
691
|
+
* @returns {'checked'|'partial'|'unchecked'}
|
|
692
|
+
*/
|
|
693
|
+
_checkState(node) {
|
|
694
|
+
const children = this._readers.children(node);
|
|
695
|
+
if (!children || children.length === 0) {
|
|
696
|
+
return this._checked.has(this._readers.value(node)) ? 'checked' : 'unchecked';
|
|
697
|
+
}
|
|
698
|
+
const states = children.map((child) => this._checkState(child));
|
|
699
|
+
if (states.every((state) => state === 'checked')) return 'checked';
|
|
700
|
+
if (states.every((state) => state === 'unchecked')) return 'unchecked';
|
|
701
|
+
return 'partial';
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* The visible rows starting just after the focused one, wrapping around.
|
|
706
|
+
* @returns {{node: TreeNode, id: unknown}[]}
|
|
707
|
+
*/
|
|
708
|
+
_rotatedRows() {
|
|
709
|
+
const from = this._rows.findIndex((row) => Object.is(row.id, this._focusId));
|
|
710
|
+
if (from < 0) return this._rows;
|
|
711
|
+
return [...this._rows.slice(from + 1), ...this._rows.slice(0, from + 1)];
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** @param {Element} element @returns {number} */
|
|
715
|
+
_rowIndex(element) {
|
|
716
|
+
return [...this.el.querySelectorAll('.zx-tree__row')].indexOf(element);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** @param {unknown} id @returns {Element|null} */
|
|
720
|
+
_rowElement(id) {
|
|
721
|
+
const index = this._rows.findIndex((row) => Object.is(row.id, id));
|
|
722
|
+
return index < 0 ? null : this.el.querySelectorAll('.zx-tree__row')[index] ?? null;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** @param {number} index @returns {void} */
|
|
726
|
+
_focusRow(index) {
|
|
727
|
+
const row = this._rows[index];
|
|
728
|
+
if (!row) return;
|
|
729
|
+
this._focusId = row.id;
|
|
730
|
+
this._syncRows();
|
|
731
|
+
const element = /** @type {HTMLElement|null} */ (this._rowElement(row.id));
|
|
732
|
+
element?.focus();
|
|
733
|
+
element?.scrollIntoView({ block: 'nearest' });
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** @param {unknown} id @returns {void} */
|
|
737
|
+
_focusNodeId(id) {
|
|
738
|
+
if (id === undefined) return;
|
|
739
|
+
const index = this._rows.findIndex((row) => Object.is(row.id, id));
|
|
740
|
+
if (index >= 0) this._focusRow(index);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** @param {string} key @param {string} fallback @returns {string} */
|
|
744
|
+
_message(key, fallback) {
|
|
745
|
+
const message = this.msg(key);
|
|
746
|
+
return message === key ? fallback : message;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Restores an enhanced target to the markup it had before the takeover. @returns {void} */
|
|
750
|
+
destroy() {
|
|
751
|
+
const root = this.el;
|
|
752
|
+
super.destroy();
|
|
753
|
+
if (!this._createdRoot && root) restoreTarget(root, this._snapshot);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Selection change. @event TreeView#select @type {CustomEvent<{node: TreeNode, id: unknown, ids: unknown[]}>} */
|
|
758
|
+
/** Branch expanded. @event TreeView#expand @type {CustomEvent<{node: TreeNode, id: unknown}>} */
|
|
759
|
+
/** Branch collapsed. @event TreeView#collapse @type {CustomEvent<{node: TreeNode, id: unknown}>} */
|
|
760
|
+
/** Node activated by Enter or double-click. @event TreeView#activate @type {CustomEvent<{node: TreeNode, id: unknown}>} */
|
|
761
|
+
/** Checked set changed. @event TreeView#check @type {CustomEvent<{ids: unknown[], node: TreeNode|null}>} */
|
|
762
|
+
/** A lazy loader rejected. @event TreeView#error @type {CustomEvent<{error: Error, node: TreeNode, id: unknown}>} */
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Copies the caller's nodes so lazily loaded children never mutate the passed-in data.
|
|
766
|
+
* @param {TreeNode[]} nodes Source nodes.
|
|
767
|
+
* @returns {TreeNode[]}
|
|
768
|
+
*/
|
|
769
|
+
function cloneNodes(nodes) {
|
|
770
|
+
if (!Array.isArray(nodes)) return [];
|
|
771
|
+
return nodes.map((node) => {
|
|
772
|
+
if (node === null || typeof node !== 'object') return node;
|
|
773
|
+
const copy = { ...node };
|
|
774
|
+
for (const [key, value] of Object.entries(copy)) {
|
|
775
|
+
if (Array.isArray(value)) copy[key] = cloneNodes(value);
|
|
776
|
+
}
|
|
777
|
+
return copy;
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** @param {unknown} value @returns {unknown[]} */
|
|
782
|
+
function toIterable(value) {
|
|
783
|
+
return Array.isArray(value) ? value : [];
|
|
784
|
+
}
|