@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.
@@ -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
+ };