@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,291 @@
1
+ import * as React from "react";
2
+ import styled from "styled-components";
3
+ import { Icon } from "@sproutsocial/seeds-react-icon";
4
+ import { focusRing } from "@sproutsocial/seeds-react-mixins";
5
+ import { Tree, type TreeProps } from "./Tree";
6
+ import { TreeItem } from "./TreeItem";
7
+ import { filterTree } from "./Common/filterTree";
8
+ import { computeTreeNavigation, treeItemDomId } from "./Common/treeNavigation";
9
+ import type { TreeItemData } from "./Common/types";
10
+
11
+ const Root = styled.div`
12
+ display: flex;
13
+ flex-direction: column;
14
+ gap: ${({ theme }) => theme.space[300]};
15
+ `;
16
+
17
+ const InputGroup = styled.div`
18
+ display: flex;
19
+ align-items: center;
20
+ gap: ${({ theme }) => theme.space[200]};
21
+ padding: ${({ theme }) => theme.space[200]} ${({ theme }) => theme.space[300]};
22
+ border-radius: ${({ theme }) => theme.radii[500]};
23
+ border: 1px solid ${({ theme }) => theme.colors.form.border.base};
24
+ background: ${({ theme }) => theme.colors.form.background.base};
25
+ color: ${({ theme }) => theme.colors.icon.base};
26
+
27
+ &:focus-within {
28
+ ${focusRing}
29
+ }
30
+ `;
31
+
32
+ const ComboboxInput = styled.input`
33
+ flex: 1;
34
+ border: none;
35
+ background: transparent;
36
+ outline: none;
37
+ font-family: ${({ theme }) => theme.fontFamily};
38
+ ${({ theme }) => theme.typography[300]}
39
+ color: ${({ theme }) => theme.colors.text.body};
40
+
41
+ &::placeholder {
42
+ color: ${({ theme }) => theme.colors.text.subtext};
43
+ }
44
+ `;
45
+
46
+ const EmptyState = styled.div`
47
+ padding: ${({ theme }) => theme.space[400]};
48
+ text-align: center;
49
+ color: ${({ theme }) => theme.colors.text.subtext};
50
+ ${({ theme }) => theme.typography[300]}
51
+ `;
52
+
53
+ const NAV_KEYS = new Set([
54
+ "ArrowDown",
55
+ "ArrowUp",
56
+ "ArrowLeft",
57
+ "ArrowRight",
58
+ "Home",
59
+ "End",
60
+ "Enter",
61
+ ]);
62
+
63
+ export type TreeComboboxProps = Omit<
64
+ TreeProps,
65
+ | "children"
66
+ | "focusedId"
67
+ | "onFocusedIdChange"
68
+ | "defaultFocusedId"
69
+ | "manageDomFocus"
70
+ | "aria-label"
71
+ | "aria-labelledby"
72
+ > & {
73
+ items: ReadonlyArray<TreeItemData>;
74
+ /** Placeholder for the combobox input. */
75
+ placeholder?: string;
76
+ /** Text shown when the query has no matches. */
77
+ emptyText?: string;
78
+ /** Accessible name for the combobox. One of `aria-label` / `aria-labelledby` is required. */
79
+ "aria-label"?: string;
80
+ "aria-labelledby"?: string;
81
+ /** Controlled query value. */
82
+ query?: string;
83
+ /** Uncontrolled initial query. */
84
+ defaultQuery?: string;
85
+ onQueryChange?: (query: string) => void;
86
+ };
87
+
88
+ /**
89
+ * Combobox + tree pattern. Implements the WAI-ARIA combobox pattern with a
90
+ * tree as the popup (`aria-haspopup="tree"`). DOM focus stays on the input;
91
+ * `aria-activedescendant` points at the active treeitem, which the input's
92
+ * arrow-key handler moves through the visible tree. Selection and expansion
93
+ * reuse the underlying Tree's logic — Enter on the input synthesizes a click
94
+ * on the active row, ArrowRight/Left compute the new expanded set and pass
95
+ * it through Tree's controlled `expanded` prop.
96
+ */
97
+ export function TreeCombobox(props: TreeComboboxProps) {
98
+ const {
99
+ items,
100
+ placeholder = "Search...",
101
+ emptyText = "No results found.",
102
+ query: queryProp,
103
+ defaultQuery = "",
104
+ onQueryChange,
105
+ selectionMode = "none",
106
+ selectableNodes = "all",
107
+ defaultExpanded,
108
+ expanded: expandedProp,
109
+ onExpandedChange,
110
+ defaultSelected,
111
+ selected: selectedProp,
112
+ onSelectionChange,
113
+ renderSelectionIndicator,
114
+ id,
115
+ className,
116
+ } = props;
117
+ const ariaLabel = props["aria-label"];
118
+ const ariaLabelledBy = props["aria-labelledby"];
119
+
120
+ const [uncontrolledQuery, setUncontrolledQuery] =
121
+ React.useState(defaultQuery);
122
+ const query = queryProp ?? uncontrolledQuery;
123
+ const setQuery = (next: string) => {
124
+ if (queryProp === undefined) setUncontrolledQuery(next);
125
+ onQueryChange?.(next);
126
+ };
127
+
128
+ // Track the user's last "real" expansion state so it can be restored when
129
+ // the query clears, matching the prior SearchableTree behavior.
130
+ const [userExpanded, setUserExpanded] = React.useState<string[]>(() => [
131
+ ...(defaultExpanded ?? expandedProp ?? []),
132
+ ]);
133
+
134
+ const { items: visibleItems, forceExpanded } = React.useMemo(
135
+ () => filterTree([...items], query),
136
+ [items, query]
137
+ );
138
+
139
+ const isFiltering = query.trim().length > 0;
140
+ const effectiveExpanded = isFiltering
141
+ ? Array.from(new Set([...userExpanded, ...forceExpanded]))
142
+ : expandedProp ?? userExpanded;
143
+
144
+ const handleExpandedChange = (next: string[]) => {
145
+ if (!isFiltering) {
146
+ setUserExpanded(next);
147
+ }
148
+ onExpandedChange?.(next);
149
+ };
150
+
151
+ const [focusedId, setFocusedId] = React.useState<string | null>(null);
152
+
153
+ const treeRef = React.useRef<HTMLUListElement>(null);
154
+ const reactId = React.useId();
155
+ const treeDomId = id ? `${id}-tree` : `${reactId}-tree`;
156
+
157
+ const findTreeItemEl = (itemId: string): HTMLElement | null =>
158
+ treeRef.current?.querySelector<HTMLElement>(
159
+ `[role="treeitem"][data-treeitem-id="${CSS.escape(itemId)}"]`
160
+ ) ?? null;
161
+
162
+ const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
163
+ if (e.key === "Escape") {
164
+ if (query.length > 0) {
165
+ e.preventDefault();
166
+ setQuery("");
167
+ setFocusedId(null);
168
+ }
169
+ return;
170
+ }
171
+ if (!NAV_KEYS.has(e.key)) return;
172
+
173
+ const currentEl = focusedId ? findTreeItemEl(focusedId) : null;
174
+ const meta =
175
+ currentEl && focusedId
176
+ ? {
177
+ id: focusedId,
178
+ hasChildren: currentEl.getAttribute("aria-expanded") !== null,
179
+ isExpanded: currentEl.getAttribute("aria-expanded") === "true",
180
+ level: parseInt(currentEl.getAttribute("aria-level") ?? "1", 10),
181
+ }
182
+ : null;
183
+
184
+ // Enter: replay a row click on the active treeitem so we get TreeItem's
185
+ // exact selection/expansion semantics (single vs multi, leaves-only,
186
+ // branch-vs-leaf) for free.
187
+ if (e.key === "Enter") {
188
+ if (!currentEl) return;
189
+ e.preventDefault();
190
+ const row = currentEl.querySelector<HTMLElement>("[data-treeitem-row]");
191
+ row?.click();
192
+ return;
193
+ }
194
+
195
+ const result = computeTreeNavigation(
196
+ treeRef.current,
197
+ currentEl,
198
+ e.key,
199
+ meta
200
+ );
201
+ if (result.preventDefault) e.preventDefault();
202
+
203
+ if (result.expandToggle) {
204
+ const current = new Set(effectiveExpanded);
205
+ const willOpen =
206
+ result.expandToggle.next ?? !current.has(result.expandToggle.id);
207
+ if (willOpen) current.add(result.expandToggle.id);
208
+ else current.delete(result.expandToggle.id);
209
+ handleExpandedChange(Array.from(current));
210
+ }
211
+ if (result.nextFocusedId) {
212
+ setFocusedId(result.nextFocusedId);
213
+ }
214
+ };
215
+
216
+ // If a filter change drops the active item out of the visible set, the
217
+ // Tree's recovery layout-effect rebases focusedId via onFocusedIdChange.
218
+ // We accept that and don't reset focusedId on filter ourselves — clearing
219
+ // happens explicitly on Escape.
220
+
221
+ const showEmpty = visibleItems.length === 0;
222
+
223
+ return (
224
+ <Root className={className}>
225
+ <InputGroup>
226
+ <Icon name="magnifying-glass-outline" size="small" aria-hidden />
227
+ <ComboboxInput
228
+ type="text"
229
+ role="combobox"
230
+ id={id}
231
+ aria-label={ariaLabel}
232
+ aria-labelledby={ariaLabelledBy}
233
+ aria-expanded={!showEmpty}
234
+ aria-controls={treeDomId}
235
+ aria-haspopup="tree"
236
+ aria-autocomplete="list"
237
+ aria-activedescendant={
238
+ focusedId ? treeItemDomId(focusedId) : undefined
239
+ }
240
+ placeholder={placeholder}
241
+ value={query}
242
+ onChange={(e) => setQuery(e.target.value)}
243
+ onKeyDown={handleInputKeyDown}
244
+ />
245
+ </InputGroup>
246
+
247
+ <Tree
248
+ ref={treeRef}
249
+ aria-label={ariaLabel}
250
+ aria-labelledby={ariaLabelledBy}
251
+ id={treeDomId}
252
+ manageDomFocus={false}
253
+ selectionMode={selectionMode}
254
+ selectableNodes={selectableNodes}
255
+ expanded={effectiveExpanded}
256
+ onExpandedChange={handleExpandedChange}
257
+ selected={selectedProp}
258
+ defaultSelected={defaultSelected}
259
+ onSelectionChange={onSelectionChange}
260
+ renderSelectionIndicator={renderSelectionIndicator}
261
+ focusedId={focusedId}
262
+ onFocusedIdChange={setFocusedId}
263
+ >
264
+ {visibleItems.map((item) => (
265
+ <TreeNode key={item.id} item={item} />
266
+ ))}
267
+ </Tree>
268
+
269
+ {showEmpty ? (
270
+ <EmptyState role="status" aria-live="polite">
271
+ {emptyText}
272
+ </EmptyState>
273
+ ) : null}
274
+ </Root>
275
+ );
276
+ }
277
+
278
+ function TreeNode({ item }: { item: TreeItemData }) {
279
+ return (
280
+ <TreeItem
281
+ id={item.id}
282
+ label={item.label}
283
+ icon={item.icon}
284
+ disabled={item.disabled}
285
+ >
286
+ {item.children?.map((child) => (
287
+ <TreeNode key={child.id} item={child} />
288
+ ))}
289
+ </TreeItem>
290
+ );
291
+ }
@@ -0,0 +1,218 @@
1
+ import * as React from "react";
2
+ import { Icon } from "@sproutsocial/seeds-react-icon";
3
+ import { TreeItemContext, useTreeContext } from "./Common/treeContext";
4
+ import { useTreeKeyboard } from "./Common/useTreeKeyboard";
5
+ import { treeItemDomId } from "./Common/treeNavigation";
6
+ import {
7
+ TreeItemChevron,
8
+ TreeItemEl,
9
+ TreeItemGroup,
10
+ TreeItemIcon,
11
+ TreeItemLabel,
12
+ TreeItemRow,
13
+ } from "./TreeStyles";
14
+
15
+ export type TreeItemProps = {
16
+ id: string;
17
+ label: React.ReactNode;
18
+ icon?: React.ReactNode;
19
+ disabled?: boolean;
20
+ children?: React.ReactNode;
21
+ /**
22
+ * Optional override for the row click target. When provided, this is used
23
+ * as the accessible name for the row (otherwise the rendered `label` is).
24
+ */
25
+ "aria-label"?: string;
26
+ };
27
+
28
+ function hasTreeItemChildren(children: React.ReactNode): boolean {
29
+ let found = false;
30
+ React.Children.forEach(children, (child) => {
31
+ if (found) return;
32
+ if (!React.isValidElement(child)) return;
33
+ const childType = child.type;
34
+ if (childType === React.Fragment) {
35
+ found = hasTreeItemChildren(
36
+ (child as React.ReactElement<{ children?: React.ReactNode }>).props
37
+ .children
38
+ );
39
+ return;
40
+ }
41
+ // Any other element child counts as a tree item. Consumers should only
42
+ // place TreeItems (or components that render one) inside a TreeItem.
43
+ found = true;
44
+ });
45
+ return found;
46
+ }
47
+
48
+ export function TreeItem(props: TreeItemProps) {
49
+ const { id, label, icon, disabled = false, children } = props;
50
+ const ctx = useTreeContext();
51
+ const { level } = React.useContext(TreeItemContext);
52
+
53
+ const hasChildren = React.useMemo(
54
+ () => hasTreeItemChildren(children),
55
+ [children]
56
+ );
57
+
58
+ const isExpanded = ctx.expanded.has(id);
59
+ const isSelected = ctx.selected.has(id);
60
+ const selectionMode = ctx.selectionMode;
61
+
62
+ // Roving tabindex: this item is tabbable when it's the focused id. When the
63
+ // host (e.g. a combobox) drives focus externally, no treeitem is tabbable.
64
+ const itemRef = React.useRef<HTMLLIElement>(null);
65
+ const isTabbable = ctx.manageDomFocus && ctx.focusedId === id;
66
+
67
+ // On first mount of the very first item, register ourselves as the initial
68
+ // roving target if nothing else has claimed it. Skipped when the host owns
69
+ // focus — the host decides when (if ever) to set an initial active id.
70
+ React.useEffect(() => {
71
+ if (!ctx.manageDomFocus) return;
72
+ if (ctx.focusedId !== null) return;
73
+ const root = ctx.rootRef.current;
74
+ if (!root) return;
75
+ const first = root.querySelector<HTMLElement>('[role="treeitem"]');
76
+ if (first === itemRef.current) {
77
+ ctx.setFocusedId(id);
78
+ }
79
+ // We only want this on mount; deps intentionally minimal.
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, []);
82
+
83
+ const handleKeyDown = useTreeKeyboard(ctx);
84
+
85
+ const handleRowClick = (e: React.MouseEvent) => {
86
+ if (disabled) return;
87
+ // Ignore clicks that originated on the chevron — it owns its own handler.
88
+ const target = e.target as HTMLElement;
89
+ if (target.closest("[data-tree-chevron]")) return;
90
+
91
+ ctx.setFocusedId(id);
92
+ if (ctx.manageDomFocus) itemRef.current?.focus();
93
+
94
+ if (hasChildren && selectionMode === "none") {
95
+ ctx.toggleExpanded(id);
96
+ return;
97
+ }
98
+ if (hasChildren && ctx.selectableNodes === "leaves") {
99
+ ctx.toggleExpanded(id);
100
+ return;
101
+ }
102
+ if (selectionMode !== "none") {
103
+ ctx.toggleSelected(id, hasChildren);
104
+ }
105
+ };
106
+
107
+ const handleChevronClick = (e: React.MouseEvent) => {
108
+ e.stopPropagation();
109
+ if (disabled) return;
110
+ ctx.setFocusedId(id);
111
+ if (ctx.manageDomFocus) itemRef.current?.focus();
112
+ ctx.toggleExpanded(id);
113
+ };
114
+
115
+ const ariaSelected =
116
+ selectionMode === "single" && ctx.selectableNodes !== "leaves"
117
+ ? isSelected
118
+ : undefined;
119
+ const ariaChecked =
120
+ selectionMode === "multiple"
121
+ ? isSelected
122
+ : selectionMode === "single" && ctx.selectableNodes === "leaves"
123
+ ? isSelected
124
+ : undefined;
125
+
126
+ const groupId = `${id}__group`;
127
+ const labelId = `${id}__label`;
128
+
129
+ return (
130
+ <TreeItemEl
131
+ ref={itemRef}
132
+ id={treeItemDomId(id)}
133
+ role="treeitem"
134
+ data-treeitem-id={id}
135
+ data-treeitem-active={
136
+ !ctx.manageDomFocus && ctx.focusedId === id ? true : undefined
137
+ }
138
+ aria-level={level}
139
+ aria-expanded={hasChildren ? isExpanded : undefined}
140
+ aria-selected={ariaSelected}
141
+ aria-checked={ariaChecked}
142
+ aria-disabled={disabled || undefined}
143
+ aria-labelledby={labelId}
144
+ aria-owns={hasChildren && isExpanded ? groupId : undefined}
145
+ tabIndex={isTabbable ? 0 : -1}
146
+ onKeyDown={(e) => {
147
+ if (!ctx.manageDomFocus) return;
148
+ handleKeyDown(e, {
149
+ id,
150
+ hasChildren,
151
+ isExpanded,
152
+ isDisabled: disabled,
153
+ level,
154
+ });
155
+ }}
156
+ onClick={(e) => {
157
+ // Branch treeitems contain descendant LIs whose row clicks bubble up.
158
+ // Only handle the click if it originated within this LI's own row —
159
+ // not within a descendant treeitem.
160
+ const target = e.target as HTMLElement;
161
+ const nearestTreeitem = target.closest('[role="treeitem"]');
162
+ if (nearestTreeitem !== e.currentTarget) return;
163
+ handleRowClick(e);
164
+ }}
165
+ onMouseDown={(e) => {
166
+ // In external-focus (combobox) mode, mousedown on a tabindex=-1
167
+ // treeitem would still steal DOM focus from the host input. Prevent
168
+ // it so focus stays where the host put it; onClick still fires.
169
+ if (!ctx.manageDomFocus) e.preventDefault();
170
+ }}
171
+ onFocus={() => {
172
+ if (!ctx.manageDomFocus) return;
173
+ if (!ctx.hasFocused) ctx.setHasFocused(true);
174
+ if (ctx.focusedId !== id) ctx.setFocusedId(id);
175
+ }}
176
+ >
177
+ <TreeItemRow
178
+ data-treeitem-row
179
+ $level={level}
180
+ $selected={isSelected}
181
+ $disabled={disabled}
182
+ >
183
+ {ctx.renderSelectionIndicator &&
184
+ !(ctx.selectableNodes === "leaves" && hasChildren)
185
+ ? ctx.renderSelectionIndicator({
186
+ selected: isSelected,
187
+ disabled,
188
+ selectionMode,
189
+ })
190
+ : null}
191
+
192
+ {icon != null ? <TreeItemIcon aria-hidden>{icon}</TreeItemIcon> : null}
193
+ <TreeItemLabel id={labelId}>{label}</TreeItemLabel>
194
+
195
+ {hasChildren ? (
196
+ <TreeItemChevron
197
+ data-tree-chevron
198
+ aria-hidden
199
+ $expanded={isExpanded}
200
+ onClick={handleChevronClick}
201
+ >
202
+ <Icon name="chevron-down-outline" size="small" />
203
+ </TreeItemChevron>
204
+ ) : null}
205
+ </TreeItemRow>
206
+
207
+ {hasChildren ? (
208
+ <TreeItemGroup role="group" id={groupId} hidden={!isExpanded}>
209
+ <TreeItemContext.Provider value={{ level: level + 1 }}>
210
+ {children}
211
+ </TreeItemContext.Provider>
212
+ </TreeItemGroup>
213
+ ) : null}
214
+ </TreeItemEl>
215
+ );
216
+ }
217
+
218
+ TreeItem.displayName = "TreeItem";
@@ -0,0 +1,114 @@
1
+ import styled, { css } from "styled-components";
2
+
3
+ export const TreeRoot = styled.ul`
4
+ list-style: none;
5
+ margin: 0;
6
+ padding: 0;
7
+ font-family: ${({ theme }) => theme.fontFamily};
8
+ ${({ theme }) => theme.typography[300]}
9
+ color: ${({ theme }) => theme.colors.text.body};
10
+ `;
11
+
12
+ export const TreeItemRow = styled.div<{
13
+ $level: number;
14
+ $selected: boolean;
15
+ $disabled: boolean;
16
+ }>`
17
+ display: flex;
18
+ align-items: center;
19
+ gap: ${({ theme }) => theme.space[300]};
20
+ padding: ${({ theme }) => theme.space[300]};
21
+ padding-left: ${({ theme, $level }) =>
22
+ `calc(${theme.space[300]} + ${$level - 1} * ${theme.space[500]})`};
23
+ border-radius: ${({ theme }) => theme.radii[400]};
24
+ cursor: pointer;
25
+ user-select: none;
26
+ background: transparent;
27
+ transition: background-color ${({ theme }) => theme.duration.fast}
28
+ ${({ theme }) => theme.easing.ease_in};
29
+
30
+ &:hover {
31
+ background: ${({ theme }) => theme.colors.listItem.background.hover};
32
+ }
33
+
34
+ ${({ $selected, theme }) =>
35
+ $selected &&
36
+ css`
37
+ background: ${theme.colors.listItem.background.hover};
38
+ font-weight: ${theme.fontWeights.semibold};
39
+ `}
40
+
41
+ ${({ $disabled }) =>
42
+ $disabled &&
43
+ css`
44
+ opacity: 0.4;
45
+ cursor: not-allowed;
46
+ pointer-events: none;
47
+ `}
48
+ `;
49
+
50
+ export const TreeItemEl = styled.li`
51
+ list-style: none;
52
+ outline: none;
53
+
54
+ /*
55
+ * Tree rows stack tightly, so the standard outset Seeds focusRing bleeds
56
+ * into adjacent rows. Use an inset outline so the ring is drawn just inside
57
+ * the row's edge and never overlaps siblings or children.
58
+ *
59
+ * The same ring is drawn when [data-treeitem-active] is set so combobox
60
+ * hosts that drive the tree via aria-activedescendant (no real DOM focus)
61
+ * still get a visible "active" indicator on the row.
62
+ */
63
+ &:focus-visible > ${TreeItemRow}, &[data-treeitem-active] > ${TreeItemRow} {
64
+ outline: 2px solid
65
+ ${({ theme }) => theme.colors.button.primary.background.base};
66
+ outline-offset: -2px;
67
+ }
68
+ `;
69
+
70
+ export const TreeItemGroup = styled.ul`
71
+ list-style: none;
72
+ margin: 0;
73
+ padding: 0;
74
+ `;
75
+
76
+ export const TreeItemLabel = styled.span`
77
+ flex: 1;
78
+ min-width: 0;
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ white-space: nowrap;
82
+ `;
83
+
84
+ export const TreeItemIcon = styled.span`
85
+ display: inline-flex;
86
+ align-items: center;
87
+ flex-shrink: 0;
88
+ `;
89
+
90
+ export const TreeItemChevron = styled.button.attrs({
91
+ type: "button",
92
+ tabIndex: -1,
93
+ })<{ $expanded: boolean }>`
94
+ display: inline-flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ flex-shrink: 0;
98
+ width: ${({ theme }) => theme.space[500]};
99
+ height: ${({ theme }) => theme.space[500]};
100
+ padding: 0;
101
+ border: none;
102
+ background: transparent;
103
+ color: ${({ theme }) => theme.colors.icon.base};
104
+ cursor: pointer;
105
+ border-radius: ${({ theme }) => theme.radii[300]};
106
+ transition: transform ${({ theme }) => theme.duration.fast}
107
+ ${({ theme }) => theme.easing.ease_in};
108
+ transform: ${({ $expanded }) =>
109
+ $expanded ? "rotate(0deg)" : "rotate(-90deg)"};
110
+
111
+ &:hover {
112
+ background: ${({ theme }) => theme.colors.listItem.background.hover};
113
+ }
114
+ `;