@sproutsocial/seeds-react-tree 0.3.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/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +112 -0
- package/README.md +51 -0
- package/dist/esm/index.js +887 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/index.d.mts +102 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +926 -0
- package/dist/index.js.map +1 -0
- package/jest.config.js +12 -0
- package/package.json +61 -0
- package/src/Common/filterTree.ts +51 -0
- package/src/Common/treeContext.ts +52 -0
- package/src/Common/treeNavigation.ts +171 -0
- package/src/Common/types.ts +23 -0
- package/src/Common/useTreeKeyboard.ts +124 -0
- package/src/Common/useTreeState.ts +107 -0
- package/src/Tree.stories.tsx +231 -0
- package/src/Tree.tsx +181 -0
- package/src/TreeCombobox.stories.tsx +100 -0
- package/src/TreeCombobox.tsx +291 -0
- package/src/TreeItem.tsx +218 -0
- package/src/TreeStyles.tsx +114 -0
- package/src/__tests__/Tree.test.tsx +199 -0
- package/src/__tests__/TreeCombobox.test.tsx +230 -0
- package/src/index.ts +10 -0
- package/src/storyData.tsx +166 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +12 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
import type { TreeSelectableNodes, TreeSelectionMode } from "./types";
|
|
3
|
+
|
|
4
|
+
type UseTreeStateOptions = {
|
|
5
|
+
selectionMode: TreeSelectionMode;
|
|
6
|
+
selectableNodes: TreeSelectableNodes;
|
|
7
|
+
defaultExpanded?: ReadonlyArray<string>;
|
|
8
|
+
expanded?: ReadonlyArray<string>;
|
|
9
|
+
onExpandedChange?: (expanded: string[]) => void;
|
|
10
|
+
defaultSelected?: ReadonlyArray<string>;
|
|
11
|
+
selected?: ReadonlyArray<string>;
|
|
12
|
+
onSelectionChange?: (selected: string[]) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function useTreeState(opts: UseTreeStateOptions) {
|
|
16
|
+
const {
|
|
17
|
+
selectionMode,
|
|
18
|
+
selectableNodes,
|
|
19
|
+
defaultExpanded,
|
|
20
|
+
expanded: expandedProp,
|
|
21
|
+
onExpandedChange,
|
|
22
|
+
defaultSelected,
|
|
23
|
+
selected: selectedProp,
|
|
24
|
+
onSelectionChange,
|
|
25
|
+
} = opts;
|
|
26
|
+
|
|
27
|
+
const [uncontrolledExpanded, setUncontrolledExpanded] = useState<Set<string>>(
|
|
28
|
+
() => new Set(defaultExpanded ?? [])
|
|
29
|
+
);
|
|
30
|
+
const [uncontrolledSelected, setUncontrolledSelected] = useState<Set<string>>(
|
|
31
|
+
() => new Set(defaultSelected ?? [])
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const expanded = useMemo(
|
|
35
|
+
() => (expandedProp ? new Set(expandedProp) : uncontrolledExpanded),
|
|
36
|
+
[expandedProp, uncontrolledExpanded]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const selected = useMemo(
|
|
40
|
+
() => (selectedProp ? new Set(selectedProp) : uncontrolledSelected),
|
|
41
|
+
[selectedProp, uncontrolledSelected]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const toggleExpanded = useCallback(
|
|
45
|
+
(id: string, next?: boolean) => {
|
|
46
|
+
const current = expandedProp
|
|
47
|
+
? new Set(expandedProp)
|
|
48
|
+
: uncontrolledExpanded;
|
|
49
|
+
const willOpen = next ?? !current.has(id);
|
|
50
|
+
const updated = new Set(current);
|
|
51
|
+
if (willOpen) {
|
|
52
|
+
updated.add(id);
|
|
53
|
+
} else {
|
|
54
|
+
updated.delete(id);
|
|
55
|
+
}
|
|
56
|
+
if (!expandedProp) {
|
|
57
|
+
setUncontrolledExpanded(updated);
|
|
58
|
+
}
|
|
59
|
+
onExpandedChange?.(Array.from(updated));
|
|
60
|
+
},
|
|
61
|
+
[expandedProp, uncontrolledExpanded, onExpandedChange]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const toggleSelected = useCallback(
|
|
65
|
+
(id: string, hasChildren: boolean) => {
|
|
66
|
+
if (selectionMode === "none") return;
|
|
67
|
+
if (selectableNodes === "leaves" && hasChildren) return;
|
|
68
|
+
|
|
69
|
+
const current = selectedProp
|
|
70
|
+
? new Set(selectedProp)
|
|
71
|
+
: uncontrolledSelected;
|
|
72
|
+
const updated = new Set<string>();
|
|
73
|
+
|
|
74
|
+
if (selectionMode === "single") {
|
|
75
|
+
if (!current.has(id)) {
|
|
76
|
+
updated.add(id);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
current.forEach((v) => updated.add(v));
|
|
80
|
+
if (updated.has(id)) {
|
|
81
|
+
updated.delete(id);
|
|
82
|
+
} else {
|
|
83
|
+
updated.add(id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!selectedProp) {
|
|
88
|
+
setUncontrolledSelected(updated);
|
|
89
|
+
}
|
|
90
|
+
onSelectionChange?.(Array.from(updated));
|
|
91
|
+
},
|
|
92
|
+
[
|
|
93
|
+
selectionMode,
|
|
94
|
+
selectableNodes,
|
|
95
|
+
selectedProp,
|
|
96
|
+
uncontrolledSelected,
|
|
97
|
+
onSelectionChange,
|
|
98
|
+
]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
expanded,
|
|
103
|
+
toggleExpanded,
|
|
104
|
+
selected,
|
|
105
|
+
toggleSelected,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { Tree } from "./Tree";
|
|
4
|
+
import { TreeItem } from "./TreeItem";
|
|
5
|
+
import {
|
|
6
|
+
checkboxIndicator,
|
|
7
|
+
radioIndicator,
|
|
8
|
+
SelectionReadout,
|
|
9
|
+
} from "./storyData";
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof Tree> = {
|
|
12
|
+
title: "Really Under Development/Tree",
|
|
13
|
+
component: Tree,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj<typeof Tree>;
|
|
18
|
+
|
|
19
|
+
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
20
|
+
<div style={{ width: 400 }}>{children}</div>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export const Basic: Story = {
|
|
24
|
+
name: "Basic",
|
|
25
|
+
render: () => (
|
|
26
|
+
<Wrapper>
|
|
27
|
+
<Tree aria-label="File explorer" defaultExpanded={["src", "components"]}>
|
|
28
|
+
<TreeItem id="src" label="src">
|
|
29
|
+
<TreeItem id="components" label="components">
|
|
30
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
31
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
32
|
+
<TreeItem id="Tree.tsx" label="Tree.tsx" />
|
|
33
|
+
</TreeItem>
|
|
34
|
+
<TreeItem id="hooks" label="hooks">
|
|
35
|
+
<TreeItem id="useTree.ts" label="useTree.ts" />
|
|
36
|
+
<TreeItem id="useKey.ts" label="useKey.ts" />
|
|
37
|
+
</TreeItem>
|
|
38
|
+
<TreeItem id="index.ts" label="index.ts" />
|
|
39
|
+
</TreeItem>
|
|
40
|
+
<TreeItem id="package.json" label="package.json" />
|
|
41
|
+
<TreeItem id="tsconfig.json" label="tsconfig.json" />
|
|
42
|
+
</Tree>
|
|
43
|
+
</Wrapper>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const SingleSelection: Story = {
|
|
48
|
+
name: "Single selection (radio indicator)",
|
|
49
|
+
render: () => (
|
|
50
|
+
<Wrapper>
|
|
51
|
+
<Tree
|
|
52
|
+
aria-label="Pick a file"
|
|
53
|
+
selectionMode="single"
|
|
54
|
+
renderSelectionIndicator={radioIndicator}
|
|
55
|
+
defaultExpanded={["src", "components"]}
|
|
56
|
+
defaultSelected={["Button.tsx"]}
|
|
57
|
+
>
|
|
58
|
+
<TreeItem id="src" label="src">
|
|
59
|
+
<TreeItem id="components" label="components">
|
|
60
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
61
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
62
|
+
<TreeItem id="Tree.tsx" label="Tree.tsx" />
|
|
63
|
+
</TreeItem>
|
|
64
|
+
<TreeItem id="index.ts" label="index.ts" />
|
|
65
|
+
</TreeItem>
|
|
66
|
+
</Tree>
|
|
67
|
+
</Wrapper>
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const MultipleSelection: Story = {
|
|
72
|
+
name: "Multiple selection (checkbox indicator)",
|
|
73
|
+
render: () => (
|
|
74
|
+
<Wrapper>
|
|
75
|
+
<Tree
|
|
76
|
+
aria-label="Pick files"
|
|
77
|
+
selectionMode="multiple"
|
|
78
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
79
|
+
defaultExpanded={["src", "components"]}
|
|
80
|
+
defaultSelected={["Button.tsx", "Tree.tsx"]}
|
|
81
|
+
>
|
|
82
|
+
<TreeItem id="src" label="src">
|
|
83
|
+
<TreeItem id="components" label="components">
|
|
84
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
85
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
86
|
+
<TreeItem id="Tree.tsx" label="Tree.tsx" />
|
|
87
|
+
</TreeItem>
|
|
88
|
+
<TreeItem id="index.ts" label="index.ts" />
|
|
89
|
+
</TreeItem>
|
|
90
|
+
</Tree>
|
|
91
|
+
</Wrapper>
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const LeavesOnlySelectable: Story = {
|
|
96
|
+
name: "Selectable nodes: leaves only",
|
|
97
|
+
render: () => (
|
|
98
|
+
<Wrapper>
|
|
99
|
+
<Tree
|
|
100
|
+
aria-label="Pick a file"
|
|
101
|
+
selectionMode="single"
|
|
102
|
+
selectableNodes="leaves"
|
|
103
|
+
renderSelectionIndicator={radioIndicator}
|
|
104
|
+
defaultExpanded={["src", "components"]}
|
|
105
|
+
>
|
|
106
|
+
<TreeItem id="src" label="src">
|
|
107
|
+
<TreeItem id="components" label="components">
|
|
108
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
109
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
110
|
+
<TreeItem id="Tree.tsx" label="Tree.tsx" />
|
|
111
|
+
</TreeItem>
|
|
112
|
+
<TreeItem id="index.ts" label="index.ts" />
|
|
113
|
+
</TreeItem>
|
|
114
|
+
</Tree>
|
|
115
|
+
</Wrapper>
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const ControlledExpansion: Story = {
|
|
120
|
+
name: "Controlled expansion",
|
|
121
|
+
render: () => {
|
|
122
|
+
const [expanded, setExpanded] = React.useState<string[]>(["src"]);
|
|
123
|
+
return (
|
|
124
|
+
<Wrapper>
|
|
125
|
+
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
|
126
|
+
<button onClick={() => setExpanded(["src", "components", "hooks"])}>
|
|
127
|
+
Expand all
|
|
128
|
+
</button>
|
|
129
|
+
<button onClick={() => setExpanded([])}>Collapse all</button>
|
|
130
|
+
</div>
|
|
131
|
+
<Tree
|
|
132
|
+
aria-label="Controlled tree"
|
|
133
|
+
expanded={expanded}
|
|
134
|
+
onExpandedChange={setExpanded}
|
|
135
|
+
>
|
|
136
|
+
<TreeItem id="src" label="src">
|
|
137
|
+
<TreeItem id="components" label="components">
|
|
138
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
139
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
140
|
+
</TreeItem>
|
|
141
|
+
<TreeItem id="hooks" label="hooks">
|
|
142
|
+
<TreeItem id="useTree.ts" label="useTree.ts" />
|
|
143
|
+
</TreeItem>
|
|
144
|
+
</TreeItem>
|
|
145
|
+
</Tree>
|
|
146
|
+
</Wrapper>
|
|
147
|
+
);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const WithSelectionDisplay: Story = {
|
|
152
|
+
name: "With selection display",
|
|
153
|
+
render: () => {
|
|
154
|
+
const [selected, setSelected] = React.useState<string[]>(["Button.tsx"]);
|
|
155
|
+
return (
|
|
156
|
+
<Wrapper>
|
|
157
|
+
<Tree
|
|
158
|
+
aria-label="Pick a file"
|
|
159
|
+
selectionMode="single"
|
|
160
|
+
renderSelectionIndicator={radioIndicator}
|
|
161
|
+
defaultExpanded={["src", "components"]}
|
|
162
|
+
selected={selected}
|
|
163
|
+
onSelectionChange={setSelected}
|
|
164
|
+
>
|
|
165
|
+
<TreeItem id="src" label="src">
|
|
166
|
+
<TreeItem id="components" label="components">
|
|
167
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
168
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
169
|
+
<TreeItem id="Tree.tsx" label="Tree.tsx" />
|
|
170
|
+
</TreeItem>
|
|
171
|
+
<TreeItem id="hooks" label="hooks">
|
|
172
|
+
<TreeItem id="useTree.ts" label="useTree.ts" />
|
|
173
|
+
<TreeItem id="useKey.ts" label="useKey.ts" />
|
|
174
|
+
</TreeItem>
|
|
175
|
+
<TreeItem id="index.ts" label="index.ts" />
|
|
176
|
+
</TreeItem>
|
|
177
|
+
</Tree>
|
|
178
|
+
<SelectionReadout selected={selected} />
|
|
179
|
+
</Wrapper>
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const MultiSelectionDisplay: Story = {
|
|
185
|
+
name: "With selection display (multi-select)",
|
|
186
|
+
render: () => {
|
|
187
|
+
const [selected, setSelected] = React.useState<string[]>([]);
|
|
188
|
+
return (
|
|
189
|
+
<Wrapper>
|
|
190
|
+
<Tree
|
|
191
|
+
aria-label="Pick files"
|
|
192
|
+
selectionMode="multiple"
|
|
193
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
194
|
+
defaultExpanded={["src", "components"]}
|
|
195
|
+
selected={selected}
|
|
196
|
+
onSelectionChange={setSelected}
|
|
197
|
+
>
|
|
198
|
+
<TreeItem id="src" label="src">
|
|
199
|
+
<TreeItem id="components" label="components">
|
|
200
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
201
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" />
|
|
202
|
+
<TreeItem id="Tree.tsx" label="Tree.tsx" />
|
|
203
|
+
</TreeItem>
|
|
204
|
+
<TreeItem id="index.ts" label="index.ts" />
|
|
205
|
+
</TreeItem>
|
|
206
|
+
</Tree>
|
|
207
|
+
<SelectionReadout selected={selected} />
|
|
208
|
+
</Wrapper>
|
|
209
|
+
);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const DisabledItems: Story = {
|
|
214
|
+
name: "Disabled items",
|
|
215
|
+
render: () => (
|
|
216
|
+
<Wrapper>
|
|
217
|
+
<Tree
|
|
218
|
+
aria-label="Tree with disabled items"
|
|
219
|
+
selectionMode="single"
|
|
220
|
+
renderSelectionIndicator={radioIndicator}
|
|
221
|
+
defaultExpanded={["src"]}
|
|
222
|
+
>
|
|
223
|
+
<TreeItem id="src" label="src">
|
|
224
|
+
<TreeItem id="Button.tsx" label="Button.tsx" />
|
|
225
|
+
<TreeItem id="Modal.tsx" label="Modal.tsx" disabled />
|
|
226
|
+
<TreeItem id="Tree.tsx" label="Tree.tsx" />
|
|
227
|
+
</TreeItem>
|
|
228
|
+
</Tree>
|
|
229
|
+
</Wrapper>
|
|
230
|
+
),
|
|
231
|
+
};
|
package/src/Tree.tsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
TreeContext,
|
|
4
|
+
TreeItemContext,
|
|
5
|
+
type TreeContextValue,
|
|
6
|
+
} from "./Common/treeContext";
|
|
7
|
+
import { useTreeState } from "./Common/useTreeState";
|
|
8
|
+
import type {
|
|
9
|
+
TreeSelectableNodes,
|
|
10
|
+
TreeSelectionIndicator,
|
|
11
|
+
TreeSelectionMode,
|
|
12
|
+
} from "./Common/types";
|
|
13
|
+
import { TreeRoot } from "./TreeStyles";
|
|
14
|
+
|
|
15
|
+
export type TreeProps = {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
/** Accessible name for the tree. One of `aria-label` / `aria-labelledby` is required. */
|
|
18
|
+
"aria-label"?: string;
|
|
19
|
+
"aria-labelledby"?: string;
|
|
20
|
+
|
|
21
|
+
selectionMode?: TreeSelectionMode;
|
|
22
|
+
selectableNodes?: TreeSelectableNodes;
|
|
23
|
+
|
|
24
|
+
defaultExpanded?: ReadonlyArray<string>;
|
|
25
|
+
expanded?: ReadonlyArray<string>;
|
|
26
|
+
onExpandedChange?: (expanded: string[]) => void;
|
|
27
|
+
|
|
28
|
+
defaultSelected?: ReadonlyArray<string>;
|
|
29
|
+
selected?: ReadonlyArray<string>;
|
|
30
|
+
onSelectionChange?: (selected: string[]) => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Optional render function for a per-item selection indicator (e.g. radio or
|
|
34
|
+
* checkbox). Returns null/undefined for no indicator.
|
|
35
|
+
*/
|
|
36
|
+
renderSelectionIndicator?: TreeSelectionIndicator;
|
|
37
|
+
|
|
38
|
+
/** Item id to receive focus first; defaults to the first treeitem in DOM order. */
|
|
39
|
+
defaultFocusedId?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Controlled focused id. When set, the host owns which item is "active" —
|
|
42
|
+
* the roving tabindex target in DOM-focus mode, or the `aria-activedescendant`
|
|
43
|
+
* target in `manageDomFocus={false}` mode.
|
|
44
|
+
*/
|
|
45
|
+
focusedId?: string | null;
|
|
46
|
+
onFocusedIdChange?: (id: string | null) => void;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* When `false`, Tree does not put treeitems in the tab order and does not
|
|
50
|
+
* call `.focus()` on them. Used by `TreeCombobox` so DOM focus can stay on
|
|
51
|
+
* its input while `focusedId` drives `aria-activedescendant`. Defaults to
|
|
52
|
+
* `true`.
|
|
53
|
+
*/
|
|
54
|
+
manageDomFocus?: boolean;
|
|
55
|
+
|
|
56
|
+
className?: string;
|
|
57
|
+
id?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const Tree = React.forwardRef<HTMLUListElement, TreeProps>(function Tree(
|
|
61
|
+
props,
|
|
62
|
+
forwardedRef
|
|
63
|
+
) {
|
|
64
|
+
const {
|
|
65
|
+
children,
|
|
66
|
+
selectionMode = "none",
|
|
67
|
+
selectableNodes = "all",
|
|
68
|
+
defaultExpanded,
|
|
69
|
+
expanded: expandedProp,
|
|
70
|
+
onExpandedChange,
|
|
71
|
+
defaultSelected,
|
|
72
|
+
selected: selectedProp,
|
|
73
|
+
onSelectionChange,
|
|
74
|
+
renderSelectionIndicator,
|
|
75
|
+
defaultFocusedId,
|
|
76
|
+
focusedId: focusedIdProp,
|
|
77
|
+
onFocusedIdChange,
|
|
78
|
+
manageDomFocus = true,
|
|
79
|
+
className,
|
|
80
|
+
id,
|
|
81
|
+
} = props;
|
|
82
|
+
|
|
83
|
+
const ariaLabel = props["aria-label"];
|
|
84
|
+
const ariaLabelledBy = props["aria-labelledby"];
|
|
85
|
+
|
|
86
|
+
const innerRef = React.useRef<HTMLUListElement>(null);
|
|
87
|
+
React.useImperativeHandle(forwardedRef, () => innerRef.current!, []);
|
|
88
|
+
|
|
89
|
+
const { expanded, toggleExpanded, selected, toggleSelected } = useTreeState({
|
|
90
|
+
selectionMode,
|
|
91
|
+
selectableNodes,
|
|
92
|
+
defaultExpanded,
|
|
93
|
+
expanded: expandedProp,
|
|
94
|
+
onExpandedChange,
|
|
95
|
+
defaultSelected,
|
|
96
|
+
selected: selectedProp,
|
|
97
|
+
onSelectionChange,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const isFocusedControlled = focusedIdProp !== undefined;
|
|
101
|
+
const [internalFocusedId, setInternalFocusedId] = React.useState<
|
|
102
|
+
string | null
|
|
103
|
+
>(defaultFocusedId ?? null);
|
|
104
|
+
const focusedId = isFocusedControlled
|
|
105
|
+
? focusedIdProp ?? null
|
|
106
|
+
: internalFocusedId;
|
|
107
|
+
|
|
108
|
+
const setFocusedId = React.useCallback(
|
|
109
|
+
(nextId: string | null) => {
|
|
110
|
+
if (!isFocusedControlled) setInternalFocusedId(nextId);
|
|
111
|
+
onFocusedIdChange?.(nextId);
|
|
112
|
+
},
|
|
113
|
+
[isFocusedControlled, onFocusedIdChange]
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const [hasFocused, setHasFocused] = React.useState(false);
|
|
117
|
+
|
|
118
|
+
// If the focused id points at an item that no longer exists in the DOM
|
|
119
|
+
// (e.g. it was filtered out by a wrapper), reset it to the first visible
|
|
120
|
+
// item so the tree always has a tabbable target. Without this, Tab into
|
|
121
|
+
// the tree lands nowhere and the user has to click to recover.
|
|
122
|
+
React.useLayoutEffect(() => {
|
|
123
|
+
const root = innerRef.current;
|
|
124
|
+
if (!root || focusedId === null) return;
|
|
125
|
+
const stillExists = root.querySelector(
|
|
126
|
+
`[role="treeitem"][data-treeitem-id="${CSS.escape(focusedId)}"]`
|
|
127
|
+
);
|
|
128
|
+
if (stillExists) return;
|
|
129
|
+
const firstVisible = root.querySelector<HTMLElement>('[role="treeitem"]');
|
|
130
|
+
setFocusedId(firstVisible?.dataset.treeitemId ?? null);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const ctxValue = React.useMemo<TreeContextValue>(
|
|
134
|
+
() => ({
|
|
135
|
+
focusedId,
|
|
136
|
+
setFocusedId,
|
|
137
|
+
expanded,
|
|
138
|
+
toggleExpanded,
|
|
139
|
+
selected,
|
|
140
|
+
toggleSelected,
|
|
141
|
+
selectionMode,
|
|
142
|
+
selectableNodes,
|
|
143
|
+
renderSelectionIndicator,
|
|
144
|
+
rootRef: innerRef,
|
|
145
|
+
hasFocused,
|
|
146
|
+
setHasFocused,
|
|
147
|
+
manageDomFocus,
|
|
148
|
+
}),
|
|
149
|
+
[
|
|
150
|
+
focusedId,
|
|
151
|
+
setFocusedId,
|
|
152
|
+
expanded,
|
|
153
|
+
toggleExpanded,
|
|
154
|
+
selected,
|
|
155
|
+
toggleSelected,
|
|
156
|
+
selectionMode,
|
|
157
|
+
selectableNodes,
|
|
158
|
+
renderSelectionIndicator,
|
|
159
|
+
hasFocused,
|
|
160
|
+
manageDomFocus,
|
|
161
|
+
]
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<TreeContext.Provider value={ctxValue}>
|
|
166
|
+
<TreeItemContext.Provider value={{ level: 1 }}>
|
|
167
|
+
<TreeRoot
|
|
168
|
+
ref={innerRef}
|
|
169
|
+
role="tree"
|
|
170
|
+
id={id}
|
|
171
|
+
className={className}
|
|
172
|
+
aria-label={ariaLabel}
|
|
173
|
+
aria-labelledby={ariaLabelledBy}
|
|
174
|
+
aria-multiselectable={selectionMode === "multiple" ? true : undefined}
|
|
175
|
+
>
|
|
176
|
+
{children}
|
|
177
|
+
</TreeRoot>
|
|
178
|
+
</TreeItemContext.Provider>
|
|
179
|
+
</TreeContext.Provider>
|
|
180
|
+
);
|
|
181
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import styled from "styled-components";
|
|
3
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
4
|
+
import { TreeCombobox } from "./TreeCombobox";
|
|
5
|
+
import {
|
|
6
|
+
collections,
|
|
7
|
+
fileTree,
|
|
8
|
+
radioIndicator,
|
|
9
|
+
checkboxIndicator,
|
|
10
|
+
SelectionReadout,
|
|
11
|
+
} from "./storyData";
|
|
12
|
+
|
|
13
|
+
const meta: Meta<typeof TreeCombobox> = {
|
|
14
|
+
title: "Really Under Development/Tree/TreeCombobox",
|
|
15
|
+
component: TreeCombobox,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
19
|
+
type Story = StoryObj<typeof TreeCombobox>;
|
|
20
|
+
|
|
21
|
+
const Card = styled.div`
|
|
22
|
+
width: 480px;
|
|
23
|
+
padding: ${({ theme }) => theme.space[400]};
|
|
24
|
+
border-radius: ${({ theme }) => theme.radii[500]};
|
|
25
|
+
border: 1px solid ${({ theme }) => theme.colors.container.border.base};
|
|
26
|
+
background: ${({ theme }) => theme.colors.container.background.base};
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
export const Basic: Story = {
|
|
30
|
+
name: "Basic",
|
|
31
|
+
render: () => (
|
|
32
|
+
<Card>
|
|
33
|
+
<TreeCombobox
|
|
34
|
+
aria-label="File search"
|
|
35
|
+
items={fileTree}
|
|
36
|
+
placeholder="Search files..."
|
|
37
|
+
/>
|
|
38
|
+
</Card>
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const SingleSelect: Story = {
|
|
43
|
+
name: "Single select",
|
|
44
|
+
render: () => (
|
|
45
|
+
<Card>
|
|
46
|
+
<TreeCombobox
|
|
47
|
+
aria-label="Pick a collection"
|
|
48
|
+
items={collections}
|
|
49
|
+
placeholder="Search collections..."
|
|
50
|
+
selectionMode="single"
|
|
51
|
+
renderSelectionIndicator={radioIndicator}
|
|
52
|
+
defaultExpanded={["customer-care"]}
|
|
53
|
+
defaultSelected={["questions"]}
|
|
54
|
+
/>
|
|
55
|
+
</Card>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const WithSelectionDisplay: Story = {
|
|
60
|
+
name: "With selection display",
|
|
61
|
+
render: () => {
|
|
62
|
+
const [selected, setSelected] = React.useState<string[]>(["questions"]);
|
|
63
|
+
return (
|
|
64
|
+
<Card>
|
|
65
|
+
<TreeCombobox
|
|
66
|
+
aria-label="Pick a collection"
|
|
67
|
+
items={collections}
|
|
68
|
+
placeholder="Search collections..."
|
|
69
|
+
selectionMode="single"
|
|
70
|
+
renderSelectionIndicator={radioIndicator}
|
|
71
|
+
defaultExpanded={["customer-care"]}
|
|
72
|
+
selected={selected}
|
|
73
|
+
onSelectionChange={setSelected}
|
|
74
|
+
/>
|
|
75
|
+
<SelectionReadout selected={selected} />
|
|
76
|
+
</Card>
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const MultiSelect: Story = {
|
|
82
|
+
name: "Multi-select",
|
|
83
|
+
render: () => {
|
|
84
|
+
const [selected, setSelected] = React.useState<string[]>([]);
|
|
85
|
+
return (
|
|
86
|
+
<Card>
|
|
87
|
+
<TreeCombobox
|
|
88
|
+
aria-label="Pick files"
|
|
89
|
+
items={fileTree}
|
|
90
|
+
selectionMode="multiple"
|
|
91
|
+
renderSelectionIndicator={checkboxIndicator}
|
|
92
|
+
placeholder="Search..."
|
|
93
|
+
selected={selected}
|
|
94
|
+
onSelectionChange={setSelected}
|
|
95
|
+
/>
|
|
96
|
+
<SelectionReadout selected={selected} />
|
|
97
|
+
</Card>
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
};
|