@streamscloud/kit 0.18.0 → 0.19.0
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/dist/ui/tree/cmp.tree-node-view.svelte +16 -15
- package/dist/ui/tree/cmp.tree-node-view.svelte.d.ts +2 -2
- package/dist/ui/tree/cmp.tree.svelte +13 -37
- package/dist/ui/tree/cmp.tree.svelte.d.ts +4 -4
- package/dist/ui/tree/tree-keyboard.svelte.d.ts +9 -0
- package/dist/ui/tree/tree-keyboard.svelte.js +107 -0
- package/dist/ui/tree/tree-node.svelte.d.ts +7 -3
- package/dist/ui/tree/tree-node.svelte.js +32 -16
- package/package.json +1 -1
- package/dist/ui/tree/tree-navigation.svelte.d.ts +0 -16
- package/dist/ui/tree/tree-navigation.svelte.js +0 -102
|
@@ -10,7 +10,7 @@ import IconCheckmarkCircle from '@fluentui/svg-icons/icons/checkmark_circle_20_f
|
|
|
10
10
|
import IconChevronRight from '@fluentui/svg-icons/icons/chevron_right_20_regular.svg?raw';
|
|
11
11
|
import IconMoreVertical from '@fluentui/svg-icons/icons/more_vertical_20_regular.svg?raw';
|
|
12
12
|
import { slide } from 'svelte/transition';
|
|
13
|
-
let { node,
|
|
13
|
+
let { node, keyboard, hideExpandIcon = false, level = 0, showSelectedCheckmark } = $props();
|
|
14
14
|
let actionsOpened = $state(false);
|
|
15
15
|
let dbClickTimeout = undefined;
|
|
16
16
|
let dragExpandTimeout = undefined;
|
|
@@ -65,13 +65,8 @@ const handleDrop = (e) => {
|
|
|
65
65
|
DraggingNodeState.draggingNode = null;
|
|
66
66
|
DraggingNodeState.dragOverNode = null;
|
|
67
67
|
};
|
|
68
|
-
const handlePointerDown = () => {
|
|
69
|
-
nav.setActive(node);
|
|
70
|
-
nav.keyboardActive = false;
|
|
71
|
-
};
|
|
72
68
|
const handleSelectNode = (e) => {
|
|
73
69
|
e.stopPropagation();
|
|
74
|
-
nav.setActive(node);
|
|
75
70
|
clearTimeout(dbClickTimeout);
|
|
76
71
|
if (node.expandOnDoubleClick) {
|
|
77
72
|
dbClickTimeout = window.setTimeout(() => {
|
|
@@ -84,7 +79,6 @@ const handleSelectNode = (e) => {
|
|
|
84
79
|
};
|
|
85
80
|
const handleExpandNode = (e) => {
|
|
86
81
|
e.stopPropagation();
|
|
87
|
-
nav.setActive(node);
|
|
88
82
|
if (node.expanded) {
|
|
89
83
|
node.collapse();
|
|
90
84
|
}
|
|
@@ -116,9 +110,7 @@ const handleMouseLeave = () => {
|
|
|
116
110
|
<div
|
|
117
111
|
class="tree-node"
|
|
118
112
|
style={`--_tree-node--level: ${level}`}
|
|
119
|
-
id={nav.itemId(node)}
|
|
120
113
|
role="treeitem"
|
|
121
|
-
tabindex="-1"
|
|
122
114
|
aria-level={level + 1}
|
|
123
115
|
aria-selected={node.selected}
|
|
124
116
|
aria-expanded={children.length ? node.expanded : undefined}>
|
|
@@ -126,7 +118,6 @@ const handleMouseLeave = () => {
|
|
|
126
118
|
<div
|
|
127
119
|
class="tree-node__leaf"
|
|
128
120
|
class:tree-node__leaf--active={node.selected}
|
|
129
|
-
class:tree-node__leaf--focused={nav.hasFocus && nav.keyboardActive && nav.activeId === node.id}
|
|
130
121
|
class:tree-node__leaf--draggable={node.dragConfig}
|
|
131
122
|
class:tree-node__leaf--expandable={children.length}
|
|
132
123
|
class:tree-node__leaf--drag-over={isDraggedOver}
|
|
@@ -147,7 +138,6 @@ const handleMouseLeave = () => {
|
|
|
147
138
|
class:tree-node__expand-button--active={node.selected || node.involvedIntoActivePath}
|
|
148
139
|
tabindex="-1"
|
|
149
140
|
aria-hidden="true"
|
|
150
|
-
onpointerdown={handlePointerDown}
|
|
151
141
|
onclick={handleExpandNode}>
|
|
152
142
|
<span class="tree-node__expand-icon" class:tree-node__expand-icon--expanded={node.expanded}>
|
|
153
143
|
<Icon src={IconChevronRight} />
|
|
@@ -155,8 +145,15 @@ const handleMouseLeave = () => {
|
|
|
155
145
|
</button>
|
|
156
146
|
{/if}
|
|
157
147
|
|
|
158
|
-
|
|
159
|
-
|
|
148
|
+
<div
|
|
149
|
+
class="tree-node__anchor"
|
|
150
|
+
id={keyboard.anchorId(node)}
|
|
151
|
+
role="button"
|
|
152
|
+
tabindex={keyboard.currentId === node.id ? 0 : -1}
|
|
153
|
+
onclick={handleSelectNode}
|
|
154
|
+
ondblclick={handleExpandOnDoubleClick}
|
|
155
|
+
onfocusin={() => keyboard.setFocused(node.id)}
|
|
156
|
+
onkeydown={(e) => keyboard.handleKeydown(node, e)}>
|
|
160
157
|
{#if !node.hideIcon}
|
|
161
158
|
<div class="tree-node__icon" class:tree-node__icon--active={node.selected}>
|
|
162
159
|
{#if node.icon}
|
|
@@ -201,7 +198,7 @@ const handleMouseLeave = () => {
|
|
|
201
198
|
{#if node.expanded && children.length}
|
|
202
199
|
<div transition:slide={{ duration: transitionDuration.base }} class="tree-node__children" role="group">
|
|
203
200
|
{#each children as child (child.id)}
|
|
204
|
-
<Self node={child}
|
|
201
|
+
<Self node={child} keyboard={keyboard} level={level + 1} hideExpandIcon={hideChildExpandIcon} showSelectedCheckmark={showSelectedCheckmark} />
|
|
205
202
|
{/each}
|
|
206
203
|
</div>
|
|
207
204
|
{/if}
|
|
@@ -240,7 +237,10 @@ const handleMouseLeave = () => {
|
|
|
240
237
|
color: var(--sc-kit--color--text--primary);
|
|
241
238
|
background: var(--sc-kit--color--bg--active);
|
|
242
239
|
}
|
|
243
|
-
.tree-node__leaf
|
|
240
|
+
.tree-node__leaf:focus-within {
|
|
241
|
+
--_tree-node--action-opacity: 1;
|
|
242
|
+
}
|
|
243
|
+
.tree-node__leaf:has(.tree-node__anchor:focus-visible) {
|
|
244
244
|
outline: 2px solid var(--sc-kit--color--border--focus);
|
|
245
245
|
outline-offset: -2px;
|
|
246
246
|
}
|
|
@@ -285,6 +285,7 @@ const handleMouseLeave = () => {
|
|
|
285
285
|
gap: var(--sc-kit--space--2);
|
|
286
286
|
min-width: 0;
|
|
287
287
|
padding: var(--_tree-node--padding-block) 0;
|
|
288
|
+
outline: none;
|
|
288
289
|
}
|
|
289
290
|
.tree-node__icon {
|
|
290
291
|
--sc-kit--icon--size: var(--_tree-node--icon-size);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TreeKeyboard } from './tree-keyboard.svelte';
|
|
2
2
|
import { type TreeNode } from './tree-node.svelte';
|
|
3
3
|
type Props = {
|
|
4
4
|
node: TreeNode;
|
|
5
|
-
|
|
5
|
+
keyboard: TreeKeyboard;
|
|
6
6
|
hideExpandIcon?: boolean;
|
|
7
7
|
level?: number;
|
|
8
8
|
showSelectedCheckmark?: boolean;
|
|
@@ -1,42 +1,21 @@
|
|
|
1
1
|
<script lang="ts">import NodeView from './cmp.tree-node-view.svelte';
|
|
2
|
-
import {
|
|
3
|
-
let { nodes, showSelectedCheckmark,
|
|
2
|
+
import { createTreeKeyboard } from './tree-keyboard.svelte';
|
|
3
|
+
let { nodes, showSelectedCheckmark, header } = $props();
|
|
4
4
|
const nodesComputed = $derived(nodes ? (Array.isArray(nodes) ? nodes : [nodes]) : []);
|
|
5
5
|
const hideExpandIcon = $derived(nodesComputed.every((f) => !f.children.length));
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
nav.keyboardActive = e.currentTarget.matches(':focus-visible');
|
|
10
|
-
};
|
|
11
|
-
const handleKeydown = (e) => {
|
|
12
|
-
// Only the container itself drives tree navigation; a focused descendant (e.g. the actions popover) keeps its own keys.
|
|
13
|
-
if (e.target !== e.currentTarget) {
|
|
14
|
-
return;
|
|
6
|
+
$effect(() => {
|
|
7
|
+
for (const root of nodesComputed) {
|
|
8
|
+
root.setForest(() => nodesComputed);
|
|
15
9
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// to <body> and arrow keys would die. Re-grab the container before the unmount so the single
|
|
19
|
-
// aria-activedescendant focus owner stays put.
|
|
20
|
-
e.currentTarget.focus();
|
|
21
|
-
};
|
|
10
|
+
});
|
|
11
|
+
const keyboard = createTreeKeyboard(() => nodesComputed);
|
|
22
12
|
</script>
|
|
23
13
|
|
|
24
14
|
<div class="tree">
|
|
25
|
-
{@render
|
|
26
|
-
<div
|
|
27
|
-
class="tree__nodes"
|
|
28
|
-
role="tree"
|
|
29
|
-
tabindex="0"
|
|
30
|
-
aria-activedescendant={nav.activeId ? `tree-item-${nav.activeId}` : undefined}
|
|
31
|
-
onfocusin={handleFocusIn}
|
|
32
|
-
onfocusout={(e) => {
|
|
33
|
-
if (!(e.relatedTarget instanceof Node) || !e.currentTarget.contains(e.relatedTarget)) {
|
|
34
|
-
nav.blur();
|
|
35
|
-
}
|
|
36
|
-
}}
|
|
37
|
-
onkeydown={handleKeydown}>
|
|
15
|
+
{@render header?.()}
|
|
16
|
+
<div class="tree__nodes" role="tree">
|
|
38
17
|
{#each nodesComputed as node (node.id)}
|
|
39
|
-
<NodeView node={node}
|
|
18
|
+
<NodeView node={node} keyboard={keyboard} hideExpandIcon={hideExpandIcon} showSelectedCheckmark={showSelectedCheckmark} />
|
|
40
19
|
{/each}
|
|
41
20
|
</div>
|
|
42
21
|
</div>
|
|
@@ -48,9 +27,9 @@ holds its own expanded / selected state, children, optional icon / count / actio
|
|
|
48
27
|
drag config (reorder or move-to-folder). Build the model with `new TreeNode({ … })` and pass the
|
|
49
28
|
root(s) as `nodes`.
|
|
50
29
|
|
|
51
|
-
|
|
52
|
-
↑/↓ move between visible
|
|
53
|
-
Enter/Space select.
|
|
30
|
+
Click selects a node — selection is the single source of truth. Keyboard: the current row is the tab
|
|
31
|
+
stop (native focus + `:focus-visible`), ↑/↓ move between visible rows, →/← expand-or-enter / collapse-or-leave,
|
|
32
|
+
Home/End jump to first/last, Enter/Space select.
|
|
54
33
|
|
|
55
34
|
### CSS Custom Properties
|
|
56
35
|
| Property | Description | Default |
|
|
@@ -67,7 +46,4 @@ Enter/Space select.
|
|
|
67
46
|
}
|
|
68
47
|
.tree :global([contenteditable]) {
|
|
69
48
|
user-select: text;
|
|
70
|
-
}
|
|
71
|
-
.tree__nodes:focus-visible {
|
|
72
|
-
outline: none;
|
|
73
49
|
}</style>
|
|
@@ -6,7 +6,7 @@ type Props = {
|
|
|
6
6
|
/** Show a success checkmark on the selected node when it has no actions. */
|
|
7
7
|
showSelectedCheckmark?: boolean;
|
|
8
8
|
/** Optional content rendered above the nodes (e.g. a header). */
|
|
9
|
-
|
|
9
|
+
header?: Snippet;
|
|
10
10
|
};
|
|
11
11
|
/**
|
|
12
12
|
* Tree — hierarchical, collapsible node list driven by `TreeNode` model instances. Each `TreeNode`
|
|
@@ -14,9 +14,9 @@ type Props = {
|
|
|
14
14
|
* drag config (reorder or move-to-folder). Build the model with `new TreeNode({ … })` and pass the
|
|
15
15
|
* root(s) as `nodes`.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
* ↑/↓ move between visible
|
|
19
|
-
* Enter/Space select.
|
|
17
|
+
* Click selects a node — selection is the single source of truth. Keyboard: the current row is the tab
|
|
18
|
+
* stop (native focus + `:focus-visible`), ↑/↓ move between visible rows, →/← expand-or-enter / collapse-or-leave,
|
|
19
|
+
* Home/End jump to first/last, Enter/Space select.
|
|
20
20
|
*
|
|
21
21
|
* ### CSS Custom Properties
|
|
22
22
|
* | Property | Description | Default |
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TreeNode } from './tree-node.svelte';
|
|
2
|
+
/** Native-focus roving keyboard navigation for the tree (single tab stop + arrows / Home / End / Enter). */
|
|
3
|
+
export declare const createTreeKeyboard: (getRoots: () => readonly TreeNode[]) => {
|
|
4
|
+
anchorId: (node: TreeNode) => string;
|
|
5
|
+
readonly currentId: string | null;
|
|
6
|
+
setFocused: (id: string) => void;
|
|
7
|
+
handleKeydown: (node: TreeNode, e: KeyboardEvent) => void;
|
|
8
|
+
};
|
|
9
|
+
export type TreeKeyboard = ReturnType<typeof createTreeKeyboard>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomNanoid } from '../../core/utils';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
/** Native-focus roving keyboard navigation for the tree (single tab stop + arrows / Home / End / Enter). */
|
|
4
|
+
export const createTreeKeyboard = (getRoots) => {
|
|
5
|
+
const treeId = randomNanoid(6);
|
|
6
|
+
const anchorDomId = (node) => `tree-${treeId}-anchor-${node.id}`;
|
|
7
|
+
let focusedId = $state(null);
|
|
8
|
+
const visible = () => {
|
|
9
|
+
const out = [];
|
|
10
|
+
const walk = (list) => {
|
|
11
|
+
for (const node of list) {
|
|
12
|
+
out.push(node);
|
|
13
|
+
if (node.expanded && node.children.length) {
|
|
14
|
+
walk(node.children);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
walk(getRoots());
|
|
19
|
+
return out;
|
|
20
|
+
};
|
|
21
|
+
const currentId = $derived.by(() => {
|
|
22
|
+
const rows = visible();
|
|
23
|
+
if (focusedId && rows.some((n) => n.id === focusedId)) {
|
|
24
|
+
return focusedId;
|
|
25
|
+
}
|
|
26
|
+
return (rows.find((n) => n.selected) ?? rows[0])?.id ?? null;
|
|
27
|
+
});
|
|
28
|
+
const focusNode = (node) => {
|
|
29
|
+
focusedId = node.id;
|
|
30
|
+
document.getElementById(anchorDomId(node))?.focus();
|
|
31
|
+
};
|
|
32
|
+
const focusAfterTick = async (node) => {
|
|
33
|
+
await tick();
|
|
34
|
+
focusNode(node);
|
|
35
|
+
};
|
|
36
|
+
const step = (node, delta) => {
|
|
37
|
+
const rows = visible();
|
|
38
|
+
const index = rows.findIndex((n) => n.id === node.id);
|
|
39
|
+
if (index < 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const next = rows[Math.min(Math.max(index + delta, 0), rows.length - 1)];
|
|
43
|
+
if (next.id !== node.id) {
|
|
44
|
+
focusNode(next);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
anchorId: anchorDomId,
|
|
49
|
+
get currentId() {
|
|
50
|
+
return currentId;
|
|
51
|
+
},
|
|
52
|
+
setFocused: (id) => {
|
|
53
|
+
focusedId = id;
|
|
54
|
+
},
|
|
55
|
+
handleKeydown: (node, e) => {
|
|
56
|
+
switch (e.key) {
|
|
57
|
+
case 'ArrowDown':
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
step(node, 1);
|
|
60
|
+
break;
|
|
61
|
+
case 'ArrowUp':
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
step(node, -1);
|
|
64
|
+
break;
|
|
65
|
+
case 'ArrowRight':
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
if (node.children.length && !node.expanded) {
|
|
68
|
+
node.expand();
|
|
69
|
+
}
|
|
70
|
+
else if (node.children.length) {
|
|
71
|
+
void focusAfterTick(node.children[0]);
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
case 'ArrowLeft':
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
if (node.expanded && node.children.length) {
|
|
77
|
+
node.collapse();
|
|
78
|
+
}
|
|
79
|
+
else if (node.parent) {
|
|
80
|
+
focusNode(node.parent);
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
case 'Home': {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
const rows = visible();
|
|
86
|
+
if (rows.length) {
|
|
87
|
+
focusNode(rows[0]);
|
|
88
|
+
}
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case 'End': {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
const rows = visible();
|
|
94
|
+
if (rows.length) {
|
|
95
|
+
focusNode(rows[rows.length - 1]);
|
|
96
|
+
}
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case 'Enter':
|
|
100
|
+
case ' ':
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
node.select({ explicitly: true });
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
};
|
|
@@ -45,6 +45,7 @@ export declare class TreeNode {
|
|
|
45
45
|
private _nonSelectable;
|
|
46
46
|
private _children;
|
|
47
47
|
private _parent?;
|
|
48
|
+
private _forestRoots?;
|
|
48
49
|
private _dirty;
|
|
49
50
|
constructor(init: TreeNodeInitializer);
|
|
50
51
|
/** Whether this node has unsaved changes; set via `setDirty` and propagated up to ancestors. */
|
|
@@ -62,6 +63,8 @@ export declare class TreeNode {
|
|
|
62
63
|
select(options?: {
|
|
63
64
|
explicitly?: boolean;
|
|
64
65
|
}): void;
|
|
66
|
+
/** Register the full set of forest roots so single-select clears across sibling roots, not just this node's own root. Set by the `Tree` component. */
|
|
67
|
+
setForest(getRoots: () => readonly TreeNode[]): void;
|
|
65
68
|
setChildren(value: TreeNode[]): void;
|
|
66
69
|
addChild(child: TreeNode): void;
|
|
67
70
|
insertChildAt(index: number, child: TreeNode): void;
|
|
@@ -74,8 +77,9 @@ export declare class TreeNode {
|
|
|
74
77
|
flatten(): TreeNode[];
|
|
75
78
|
canMoveToParent(targetParent: TreeNode): boolean;
|
|
76
79
|
moveToFolder(targetFolder: TreeNode): boolean;
|
|
77
|
-
private
|
|
80
|
+
private _refreshParentRefs;
|
|
78
81
|
private _select;
|
|
79
|
-
private
|
|
80
|
-
private
|
|
82
|
+
private _firstSelectable;
|
|
83
|
+
private _unselectNodeAndChildren;
|
|
84
|
+
private _buildChainToCurrentNode;
|
|
81
85
|
}
|
|
@@ -20,6 +20,7 @@ export class TreeNode {
|
|
|
20
20
|
_nonSelectable;
|
|
21
21
|
_children = $state([]);
|
|
22
22
|
_parent;
|
|
23
|
+
_forestRoots;
|
|
23
24
|
_dirty = $state(false);
|
|
24
25
|
constructor(init) {
|
|
25
26
|
this.id = init.id || this.id;
|
|
@@ -76,9 +77,13 @@ export class TreeNode {
|
|
|
76
77
|
select(options) {
|
|
77
78
|
this._select(options?.explicitly ?? false);
|
|
78
79
|
}
|
|
80
|
+
/** Register the full set of forest roots so single-select clears across sibling roots, not just this node's own root. Set by the `Tree` component. */
|
|
81
|
+
setForest(getRoots) {
|
|
82
|
+
this._forestRoots = getRoots;
|
|
83
|
+
}
|
|
79
84
|
setChildren(value) {
|
|
80
85
|
this._children = value;
|
|
81
|
-
this.
|
|
86
|
+
this._refreshParentRefs(value);
|
|
82
87
|
}
|
|
83
88
|
addChild(child) {
|
|
84
89
|
child._parent = this;
|
|
@@ -105,7 +110,7 @@ export class TreeNode {
|
|
|
105
110
|
}
|
|
106
111
|
}
|
|
107
112
|
notifyHover(mouseOver) {
|
|
108
|
-
const selectionPath = this.
|
|
113
|
+
const selectionPath = this._buildChainToCurrentNode(this);
|
|
109
114
|
for (const item of selectionPath) {
|
|
110
115
|
if (item._onNodeHover) {
|
|
111
116
|
if (mouseOver) {
|
|
@@ -128,7 +133,7 @@ export class TreeNode {
|
|
|
128
133
|
this._expanded = true;
|
|
129
134
|
}
|
|
130
135
|
unselect() {
|
|
131
|
-
this.
|
|
136
|
+
this._unselectNodeAndChildren(this);
|
|
132
137
|
}
|
|
133
138
|
flatten() {
|
|
134
139
|
const result = [];
|
|
@@ -168,26 +173,25 @@ export class TreeNode {
|
|
|
168
173
|
this._dragConfig.onNodeMovedToFolder({ movedNode: this, targetFolder });
|
|
169
174
|
return true;
|
|
170
175
|
}
|
|
171
|
-
|
|
176
|
+
_refreshParentRefs(children) {
|
|
172
177
|
for (const child of children) {
|
|
173
178
|
child._parent = this;
|
|
174
179
|
if (child._children.length) {
|
|
175
|
-
child.
|
|
180
|
+
child._refreshParentRefs(child._children);
|
|
176
181
|
}
|
|
177
182
|
}
|
|
178
183
|
}
|
|
179
184
|
_select(selectedExplicitly) {
|
|
180
185
|
if (this._nonSelectable) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
return this._children[0]._select(selectedExplicitly);
|
|
186
|
+
this._firstSelectable()?._select(selectedExplicitly);
|
|
187
|
+
return;
|
|
185
188
|
}
|
|
186
|
-
|
|
187
|
-
const selectionPath = this.buildChainToCurrentNode(this);
|
|
188
|
-
// unselect previously selected values
|
|
189
|
+
const selectionPath = this._buildChainToCurrentNode(this);
|
|
189
190
|
const root = selectionPath[selectionPath.length - 1];
|
|
190
|
-
|
|
191
|
+
const roots = root._forestRoots?.() ?? [root];
|
|
192
|
+
for (const r of roots) {
|
|
193
|
+
this._unselectNodeAndChildren(r);
|
|
194
|
+
}
|
|
191
195
|
// process selection path
|
|
192
196
|
this._selected = true;
|
|
193
197
|
if (selectionPath.length > 1) {
|
|
@@ -209,7 +213,19 @@ export class TreeNode {
|
|
|
209
213
|
}
|
|
210
214
|
}
|
|
211
215
|
}
|
|
212
|
-
|
|
216
|
+
_firstSelectable() {
|
|
217
|
+
if (!this._nonSelectable) {
|
|
218
|
+
return this;
|
|
219
|
+
}
|
|
220
|
+
for (const child of this._children) {
|
|
221
|
+
const found = child._firstSelectable();
|
|
222
|
+
if (found) {
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
_unselectNodeAndChildren(node) {
|
|
213
229
|
node._involvedIntoActivePath = false;
|
|
214
230
|
node._selected = false;
|
|
215
231
|
const children = node._children;
|
|
@@ -217,10 +233,10 @@ export class TreeNode {
|
|
|
217
233
|
return;
|
|
218
234
|
}
|
|
219
235
|
children.forEach((f) => {
|
|
220
|
-
this.
|
|
236
|
+
this._unselectNodeAndChildren(f);
|
|
221
237
|
});
|
|
222
238
|
}
|
|
223
|
-
|
|
239
|
+
_buildChainToCurrentNode(node) {
|
|
224
240
|
const chain = [];
|
|
225
241
|
let current = node;
|
|
226
242
|
while (current) {
|
package/package.json
CHANGED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { TreeNode } from './tree-node.svelte';
|
|
2
|
-
export declare class TreeNavigation {
|
|
3
|
-
activeId: string | null;
|
|
4
|
-
hasFocus: boolean;
|
|
5
|
-
/** True only while the active node was reached via keyboard — gates the focus ring (mouse selection shows background only). */
|
|
6
|
-
keyboardActive: boolean;
|
|
7
|
-
private _getRoots;
|
|
8
|
-
constructor(getRoots: () => readonly TreeNode[]);
|
|
9
|
-
itemId(node: TreeNode): string;
|
|
10
|
-
focus(): void;
|
|
11
|
-
blur(): void;
|
|
12
|
-
setActive(node: TreeNode): void;
|
|
13
|
-
ensureActive(): void;
|
|
14
|
-
handleKeydown(e: KeyboardEvent): void;
|
|
15
|
-
private _visibleNodes;
|
|
16
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
export class TreeNavigation {
|
|
2
|
-
activeId = $state(null);
|
|
3
|
-
hasFocus = $state(false);
|
|
4
|
-
/** True only while the active node was reached via keyboard — gates the focus ring (mouse selection shows background only). */
|
|
5
|
-
keyboardActive = $state(false);
|
|
6
|
-
_getRoots;
|
|
7
|
-
constructor(getRoots) {
|
|
8
|
-
this._getRoots = getRoots;
|
|
9
|
-
}
|
|
10
|
-
itemId(node) {
|
|
11
|
-
return `tree-item-${node.id}`;
|
|
12
|
-
}
|
|
13
|
-
focus() {
|
|
14
|
-
this.hasFocus = true;
|
|
15
|
-
this.ensureActive();
|
|
16
|
-
}
|
|
17
|
-
blur() {
|
|
18
|
-
this.hasFocus = false;
|
|
19
|
-
}
|
|
20
|
-
setActive(node) {
|
|
21
|
-
this.activeId = node.id;
|
|
22
|
-
}
|
|
23
|
-
ensureActive() {
|
|
24
|
-
const visible = this._visibleNodes();
|
|
25
|
-
if (this.activeId !== null && visible.some((n) => n.id === this.activeId)) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
const selected = visible.find((n) => n.selected);
|
|
29
|
-
this.activeId = (selected ?? visible[0])?.id ?? null;
|
|
30
|
-
}
|
|
31
|
-
handleKeydown(e) {
|
|
32
|
-
const visible = this._visibleNodes();
|
|
33
|
-
if (!visible.length) {
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
this.keyboardActive = true;
|
|
37
|
-
const index = visible.findIndex((n) => n.id === this.activeId);
|
|
38
|
-
const current = index < 0 ? null : visible[index];
|
|
39
|
-
switch (e.key) {
|
|
40
|
-
case 'ArrowDown':
|
|
41
|
-
e.preventDefault();
|
|
42
|
-
this.activeId = visible[index < 0 ? 0 : Math.min(index + 1, visible.length - 1)].id;
|
|
43
|
-
break;
|
|
44
|
-
case 'ArrowUp':
|
|
45
|
-
e.preventDefault();
|
|
46
|
-
this.activeId = visible[index <= 0 ? 0 : index - 1].id;
|
|
47
|
-
break;
|
|
48
|
-
case 'ArrowRight':
|
|
49
|
-
e.preventDefault();
|
|
50
|
-
if (!current) {
|
|
51
|
-
this.activeId = visible[0].id;
|
|
52
|
-
}
|
|
53
|
-
else if (current.children.length) {
|
|
54
|
-
if (current.expanded) {
|
|
55
|
-
this.activeId = current.children[0].id;
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
current.expand();
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
break;
|
|
62
|
-
case 'ArrowLeft':
|
|
63
|
-
e.preventDefault();
|
|
64
|
-
if (!current) {
|
|
65
|
-
this.activeId = visible[0].id;
|
|
66
|
-
}
|
|
67
|
-
else if (current.expanded && current.children.length) {
|
|
68
|
-
current.collapse();
|
|
69
|
-
}
|
|
70
|
-
else if (current.parent && visible.includes(current.parent)) {
|
|
71
|
-
this.activeId = current.parent.id;
|
|
72
|
-
}
|
|
73
|
-
break;
|
|
74
|
-
case 'Home':
|
|
75
|
-
e.preventDefault();
|
|
76
|
-
this.activeId = visible[0].id;
|
|
77
|
-
break;
|
|
78
|
-
case 'End':
|
|
79
|
-
e.preventDefault();
|
|
80
|
-
this.activeId = visible[visible.length - 1].id;
|
|
81
|
-
break;
|
|
82
|
-
case 'Enter':
|
|
83
|
-
case ' ':
|
|
84
|
-
e.preventDefault();
|
|
85
|
-
current?.select({ explicitly: true });
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
_visibleNodes() {
|
|
90
|
-
const result = [];
|
|
91
|
-
const walk = (nodes) => {
|
|
92
|
-
for (const node of nodes) {
|
|
93
|
-
result.push(node);
|
|
94
|
-
if (node.expanded && node.children.length) {
|
|
95
|
-
walk(node.children);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
walk(this._getRoots());
|
|
100
|
-
return result;
|
|
101
|
-
}
|
|
102
|
-
}
|