@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,887 @@
1
+ // src/Tree.tsx
2
+ import * as React from "react";
3
+
4
+ // src/Common/treeContext.ts
5
+ import { createContext, useContext } from "react";
6
+ var TreeContext = createContext(null);
7
+ function useTreeContext() {
8
+ const ctx = useContext(TreeContext);
9
+ if (!ctx) {
10
+ throw new Error("TreeItem must be rendered inside a <Tree>.");
11
+ }
12
+ return ctx;
13
+ }
14
+ var TreeItemContext = createContext({
15
+ level: 1
16
+ });
17
+
18
+ // src/Common/useTreeState.ts
19
+ import { useCallback, useMemo, useState } from "react";
20
+ function useTreeState(opts) {
21
+ const {
22
+ selectionMode,
23
+ selectableNodes,
24
+ defaultExpanded,
25
+ expanded: expandedProp,
26
+ onExpandedChange,
27
+ defaultSelected,
28
+ selected: selectedProp,
29
+ onSelectionChange
30
+ } = opts;
31
+ const [uncontrolledExpanded, setUncontrolledExpanded] = useState(
32
+ () => new Set(defaultExpanded ?? [])
33
+ );
34
+ const [uncontrolledSelected, setUncontrolledSelected] = useState(
35
+ () => new Set(defaultSelected ?? [])
36
+ );
37
+ const expanded = useMemo(
38
+ () => expandedProp ? new Set(expandedProp) : uncontrolledExpanded,
39
+ [expandedProp, uncontrolledExpanded]
40
+ );
41
+ const selected = useMemo(
42
+ () => selectedProp ? new Set(selectedProp) : uncontrolledSelected,
43
+ [selectedProp, uncontrolledSelected]
44
+ );
45
+ const toggleExpanded = useCallback(
46
+ (id, next) => {
47
+ const current = expandedProp ? new Set(expandedProp) : uncontrolledExpanded;
48
+ const willOpen = next ?? !current.has(id);
49
+ const updated = new Set(current);
50
+ if (willOpen) {
51
+ updated.add(id);
52
+ } else {
53
+ updated.delete(id);
54
+ }
55
+ if (!expandedProp) {
56
+ setUncontrolledExpanded(updated);
57
+ }
58
+ onExpandedChange?.(Array.from(updated));
59
+ },
60
+ [expandedProp, uncontrolledExpanded, onExpandedChange]
61
+ );
62
+ const toggleSelected = useCallback(
63
+ (id, hasChildren) => {
64
+ if (selectionMode === "none") return;
65
+ if (selectableNodes === "leaves" && hasChildren) return;
66
+ const current = selectedProp ? new Set(selectedProp) : uncontrolledSelected;
67
+ const updated = /* @__PURE__ */ new Set();
68
+ if (selectionMode === "single") {
69
+ if (!current.has(id)) {
70
+ updated.add(id);
71
+ }
72
+ } else {
73
+ current.forEach((v) => updated.add(v));
74
+ if (updated.has(id)) {
75
+ updated.delete(id);
76
+ } else {
77
+ updated.add(id);
78
+ }
79
+ }
80
+ if (!selectedProp) {
81
+ setUncontrolledSelected(updated);
82
+ }
83
+ onSelectionChange?.(Array.from(updated));
84
+ },
85
+ [
86
+ selectionMode,
87
+ selectableNodes,
88
+ selectedProp,
89
+ uncontrolledSelected,
90
+ onSelectionChange
91
+ ]
92
+ );
93
+ return {
94
+ expanded,
95
+ toggleExpanded,
96
+ selected,
97
+ toggleSelected
98
+ };
99
+ }
100
+
101
+ // src/TreeStyles.tsx
102
+ import styled, { css } from "styled-components";
103
+ var TreeRoot = styled.ul`
104
+ list-style: none;
105
+ margin: 0;
106
+ padding: 0;
107
+ font-family: ${({ theme }) => theme.fontFamily};
108
+ ${({ theme }) => theme.typography[300]}
109
+ color: ${({ theme }) => theme.colors.text.body};
110
+ `;
111
+ var TreeItemRow = styled.div`
112
+ display: flex;
113
+ align-items: center;
114
+ gap: ${({ theme }) => theme.space[300]};
115
+ padding: ${({ theme }) => theme.space[300]};
116
+ padding-left: ${({ theme, $level }) => `calc(${theme.space[300]} + ${$level - 1} * ${theme.space[500]})`};
117
+ border-radius: ${({ theme }) => theme.radii[400]};
118
+ cursor: pointer;
119
+ user-select: none;
120
+ background: transparent;
121
+ transition: background-color ${({ theme }) => theme.duration.fast}
122
+ ${({ theme }) => theme.easing.ease_in};
123
+
124
+ &:hover {
125
+ background: ${({ theme }) => theme.colors.listItem.background.hover};
126
+ }
127
+
128
+ ${({ $selected, theme }) => $selected && css`
129
+ background: ${theme.colors.listItem.background.hover};
130
+ font-weight: ${theme.fontWeights.semibold};
131
+ `}
132
+
133
+ ${({ $disabled }) => $disabled && css`
134
+ opacity: 0.4;
135
+ cursor: not-allowed;
136
+ pointer-events: none;
137
+ `}
138
+ `;
139
+ var TreeItemEl = styled.li`
140
+ list-style: none;
141
+ outline: none;
142
+
143
+ /*
144
+ * Tree rows stack tightly, so the standard outset Seeds focusRing bleeds
145
+ * into adjacent rows. Use an inset outline so the ring is drawn just inside
146
+ * the row's edge and never overlaps siblings or children.
147
+ *
148
+ * The same ring is drawn when [data-treeitem-active] is set so combobox
149
+ * hosts that drive the tree via aria-activedescendant (no real DOM focus)
150
+ * still get a visible "active" indicator on the row.
151
+ */
152
+ &:focus-visible > ${TreeItemRow}, &[data-treeitem-active] > ${TreeItemRow} {
153
+ outline: 2px solid
154
+ ${({ theme }) => theme.colors.button.primary.background.base};
155
+ outline-offset: -2px;
156
+ }
157
+ `;
158
+ var TreeItemGroup = styled.ul`
159
+ list-style: none;
160
+ margin: 0;
161
+ padding: 0;
162
+ `;
163
+ var TreeItemLabel = styled.span`
164
+ flex: 1;
165
+ min-width: 0;
166
+ overflow: hidden;
167
+ text-overflow: ellipsis;
168
+ white-space: nowrap;
169
+ `;
170
+ var TreeItemIcon = styled.span`
171
+ display: inline-flex;
172
+ align-items: center;
173
+ flex-shrink: 0;
174
+ `;
175
+ var TreeItemChevron = styled.button.attrs({
176
+ type: "button",
177
+ tabIndex: -1
178
+ })`
179
+ display: inline-flex;
180
+ align-items: center;
181
+ justify-content: center;
182
+ flex-shrink: 0;
183
+ width: ${({ theme }) => theme.space[500]};
184
+ height: ${({ theme }) => theme.space[500]};
185
+ padding: 0;
186
+ border: none;
187
+ background: transparent;
188
+ color: ${({ theme }) => theme.colors.icon.base};
189
+ cursor: pointer;
190
+ border-radius: ${({ theme }) => theme.radii[300]};
191
+ transition: transform ${({ theme }) => theme.duration.fast}
192
+ ${({ theme }) => theme.easing.ease_in};
193
+ transform: ${({ $expanded }) => $expanded ? "rotate(0deg)" : "rotate(-90deg)"};
194
+
195
+ &:hover {
196
+ background: ${({ theme }) => theme.colors.listItem.background.hover};
197
+ }
198
+ `;
199
+
200
+ // src/Tree.tsx
201
+ import { jsx } from "react/jsx-runtime";
202
+ var Tree = React.forwardRef(function Tree2(props, forwardedRef) {
203
+ const {
204
+ children,
205
+ selectionMode = "none",
206
+ selectableNodes = "all",
207
+ defaultExpanded,
208
+ expanded: expandedProp,
209
+ onExpandedChange,
210
+ defaultSelected,
211
+ selected: selectedProp,
212
+ onSelectionChange,
213
+ renderSelectionIndicator,
214
+ defaultFocusedId,
215
+ focusedId: focusedIdProp,
216
+ onFocusedIdChange,
217
+ manageDomFocus = true,
218
+ className,
219
+ id
220
+ } = props;
221
+ const ariaLabel = props["aria-label"];
222
+ const ariaLabelledBy = props["aria-labelledby"];
223
+ const innerRef = React.useRef(null);
224
+ React.useImperativeHandle(forwardedRef, () => innerRef.current, []);
225
+ const { expanded, toggleExpanded, selected, toggleSelected } = useTreeState({
226
+ selectionMode,
227
+ selectableNodes,
228
+ defaultExpanded,
229
+ expanded: expandedProp,
230
+ onExpandedChange,
231
+ defaultSelected,
232
+ selected: selectedProp,
233
+ onSelectionChange
234
+ });
235
+ const isFocusedControlled = focusedIdProp !== void 0;
236
+ const [internalFocusedId, setInternalFocusedId] = React.useState(defaultFocusedId ?? null);
237
+ const focusedId = isFocusedControlled ? focusedIdProp ?? null : internalFocusedId;
238
+ const setFocusedId = React.useCallback(
239
+ (nextId) => {
240
+ if (!isFocusedControlled) setInternalFocusedId(nextId);
241
+ onFocusedIdChange?.(nextId);
242
+ },
243
+ [isFocusedControlled, onFocusedIdChange]
244
+ );
245
+ const [hasFocused, setHasFocused] = React.useState(false);
246
+ React.useLayoutEffect(() => {
247
+ const root = innerRef.current;
248
+ if (!root || focusedId === null) return;
249
+ const stillExists = root.querySelector(
250
+ `[role="treeitem"][data-treeitem-id="${CSS.escape(focusedId)}"]`
251
+ );
252
+ if (stillExists) return;
253
+ const firstVisible = root.querySelector('[role="treeitem"]');
254
+ setFocusedId(firstVisible?.dataset.treeitemId ?? null);
255
+ });
256
+ const ctxValue = React.useMemo(
257
+ () => ({
258
+ focusedId,
259
+ setFocusedId,
260
+ expanded,
261
+ toggleExpanded,
262
+ selected,
263
+ toggleSelected,
264
+ selectionMode,
265
+ selectableNodes,
266
+ renderSelectionIndicator,
267
+ rootRef: innerRef,
268
+ hasFocused,
269
+ setHasFocused,
270
+ manageDomFocus
271
+ }),
272
+ [
273
+ focusedId,
274
+ setFocusedId,
275
+ expanded,
276
+ toggleExpanded,
277
+ selected,
278
+ toggleSelected,
279
+ selectionMode,
280
+ selectableNodes,
281
+ renderSelectionIndicator,
282
+ hasFocused,
283
+ manageDomFocus
284
+ ]
285
+ );
286
+ return /* @__PURE__ */ jsx(TreeContext.Provider, { value: ctxValue, children: /* @__PURE__ */ jsx(TreeItemContext.Provider, { value: { level: 1 }, children: /* @__PURE__ */ jsx(
287
+ TreeRoot,
288
+ {
289
+ ref: innerRef,
290
+ role: "tree",
291
+ id,
292
+ className,
293
+ "aria-label": ariaLabel,
294
+ "aria-labelledby": ariaLabelledBy,
295
+ "aria-multiselectable": selectionMode === "multiple" ? true : void 0,
296
+ children
297
+ }
298
+ ) }) });
299
+ });
300
+
301
+ // src/TreeItem.tsx
302
+ import * as React2 from "react";
303
+ import { Icon } from "@sproutsocial/seeds-react-icon";
304
+
305
+ // src/Common/useTreeKeyboard.ts
306
+ import { useCallback as useCallback3, useRef as useRef2 } from "react";
307
+
308
+ // src/Common/treeNavigation.ts
309
+ function getVisibleTreeItems(root) {
310
+ if (!root) return [];
311
+ const all = Array.from(
312
+ root.querySelectorAll('[role="treeitem"]')
313
+ );
314
+ return all.filter((el) => {
315
+ if (el.getAttribute("aria-disabled") === "true") return false;
316
+ let parent = el.parentElement;
317
+ while (parent && parent !== root) {
318
+ if (parent.getAttribute("role") === "group" && parent.hasAttribute("hidden")) {
319
+ return false;
320
+ }
321
+ parent = parent.parentElement;
322
+ }
323
+ return true;
324
+ });
325
+ }
326
+ function getTreeItemId(el) {
327
+ return el.dataset.treeitemId ?? null;
328
+ }
329
+ function treeItemDomId(itemId) {
330
+ return `${itemId}__item`;
331
+ }
332
+ function computeTreeNavigation(root, currentEl, key, meta) {
333
+ const visible = getVisibleTreeItems(root);
334
+ if (visible.length === 0) return { preventDefault: false };
335
+ const idOf = (el) => {
336
+ if (!el) return void 0;
337
+ return getTreeItemId(el) ?? void 0;
338
+ };
339
+ if (!currentEl || !meta) {
340
+ if (key === "ArrowDown" || key === "Home") {
341
+ return { preventDefault: true, nextFocusedId: idOf(visible[0]) };
342
+ }
343
+ if (key === "ArrowUp" || key === "End") {
344
+ return {
345
+ preventDefault: true,
346
+ nextFocusedId: idOf(visible[visible.length - 1])
347
+ };
348
+ }
349
+ return { preventDefault: false };
350
+ }
351
+ const currentIndex = visible.indexOf(currentEl);
352
+ if (currentIndex === -1) return { preventDefault: false };
353
+ const clamp = (i) => Math.max(0, Math.min(visible.length - 1, i));
354
+ switch (key) {
355
+ case "ArrowDown":
356
+ return {
357
+ preventDefault: true,
358
+ nextFocusedId: idOf(visible[clamp(currentIndex + 1)])
359
+ };
360
+ case "ArrowUp":
361
+ return {
362
+ preventDefault: true,
363
+ nextFocusedId: idOf(visible[clamp(currentIndex - 1)])
364
+ };
365
+ case "ArrowRight": {
366
+ if (meta.hasChildren && !meta.isExpanded) {
367
+ return {
368
+ preventDefault: true,
369
+ expandToggle: { id: meta.id, next: true }
370
+ };
371
+ }
372
+ if (meta.hasChildren && meta.isExpanded) {
373
+ return {
374
+ preventDefault: true,
375
+ nextFocusedId: idOf(visible[clamp(currentIndex + 1)])
376
+ };
377
+ }
378
+ return { preventDefault: true };
379
+ }
380
+ case "ArrowLeft": {
381
+ if (meta.hasChildren && meta.isExpanded) {
382
+ return {
383
+ preventDefault: true,
384
+ expandToggle: { id: meta.id, next: false }
385
+ };
386
+ }
387
+ let parent = currentEl.parentElement;
388
+ while (parent && parent !== root) {
389
+ if (parent.getAttribute("role") === "group") {
390
+ const parentItem = parent.parentElement;
391
+ if (parentItem?.getAttribute("role") === "treeitem") {
392
+ return {
393
+ preventDefault: true,
394
+ nextFocusedId: idOf(parentItem)
395
+ };
396
+ }
397
+ }
398
+ parent = parent.parentElement;
399
+ }
400
+ return { preventDefault: true };
401
+ }
402
+ case "Home":
403
+ return { preventDefault: true, nextFocusedId: idOf(visible[0]) };
404
+ case "End":
405
+ return {
406
+ preventDefault: true,
407
+ nextFocusedId: idOf(visible[visible.length - 1])
408
+ };
409
+ case "Enter":
410
+ case " ":
411
+ return {
412
+ preventDefault: true,
413
+ selectToggle: { id: meta.id, hasChildren: meta.hasChildren }
414
+ };
415
+ case "*": {
416
+ const siblings = Array.from(
417
+ currentEl.parentElement?.children ?? []
418
+ ).filter(
419
+ (el) => el instanceof HTMLElement && el.getAttribute("role") === "treeitem"
420
+ );
421
+ const toExpand = siblings.filter((s) => s.getAttribute("aria-expanded") === "false").map((s) => getTreeItemId(s)).filter((id) => id !== null);
422
+ return { preventDefault: true, expandSiblings: toExpand };
423
+ }
424
+ }
425
+ return { preventDefault: false };
426
+ }
427
+
428
+ // src/Common/useTreeKeyboard.ts
429
+ var TYPEAHEAD_TIMEOUT_MS = 500;
430
+ function focusItem(el) {
431
+ if (!el) return;
432
+ el.focus();
433
+ }
434
+ function useTreeKeyboard(ctx) {
435
+ const typeaheadBufferRef = useRef2("");
436
+ const typeaheadTimerRef = useRef2(null);
437
+ const handleTypeahead = useCallback3(
438
+ (char, current, visible) => {
439
+ if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
440
+ typeaheadBufferRef.current = (typeaheadBufferRef.current + char).toLowerCase();
441
+ typeaheadTimerRef.current = setTimeout(() => {
442
+ typeaheadBufferRef.current = "";
443
+ }, TYPEAHEAD_TIMEOUT_MS);
444
+ const buffer = typeaheadBufferRef.current;
445
+ const currentIndex = visible.indexOf(current);
446
+ const ordered = [
447
+ ...visible.slice(currentIndex + 1),
448
+ ...visible.slice(0, currentIndex + 1)
449
+ ];
450
+ const match = ordered.find(
451
+ (el) => (el.textContent ?? "").trim().toLowerCase().startsWith(buffer)
452
+ );
453
+ if (match) {
454
+ const id = getTreeItemId(match);
455
+ if (id) ctx.setFocusedId(id);
456
+ focusItem(match);
457
+ }
458
+ },
459
+ [ctx]
460
+ );
461
+ return useCallback3(
462
+ (e, itemMeta) => {
463
+ if (itemMeta.isDisabled) return;
464
+ if (e.target !== e.currentTarget) return;
465
+ const root = ctx.rootRef.current;
466
+ const current = e.currentTarget;
467
+ const result = computeTreeNavigation(root, current, e.key, {
468
+ id: itemMeta.id,
469
+ hasChildren: itemMeta.hasChildren,
470
+ isExpanded: itemMeta.isExpanded,
471
+ level: itemMeta.level
472
+ });
473
+ if (result.preventDefault) e.preventDefault();
474
+ if (result.expandToggle) {
475
+ ctx.toggleExpanded(result.expandToggle.id, result.expandToggle.next);
476
+ }
477
+ if (result.selectToggle) {
478
+ ctx.toggleSelected(
479
+ result.selectToggle.id,
480
+ result.selectToggle.hasChildren
481
+ );
482
+ }
483
+ if (result.expandSiblings) {
484
+ result.expandSiblings.forEach((id) => ctx.toggleExpanded(id, true));
485
+ }
486
+ if (result.nextFocusedId) {
487
+ ctx.setFocusedId(result.nextFocusedId);
488
+ if (ctx.manageDomFocus) {
489
+ const next = root?.querySelector(
490
+ `[data-treeitem-id="${CSS.escape(result.nextFocusedId)}"]`
491
+ );
492
+ focusItem(next ?? void 0);
493
+ }
494
+ }
495
+ if (!result.preventDefault && ctx.manageDomFocus && e.key.length === 1 && /\S/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
496
+ handleTypeahead(e.key, current, getVisibleTreeItems(root));
497
+ }
498
+ },
499
+ [ctx, handleTypeahead]
500
+ );
501
+ }
502
+
503
+ // src/TreeItem.tsx
504
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
505
+ function hasTreeItemChildren(children) {
506
+ let found = false;
507
+ React2.Children.forEach(children, (child) => {
508
+ if (found) return;
509
+ if (!React2.isValidElement(child)) return;
510
+ const childType = child.type;
511
+ if (childType === React2.Fragment) {
512
+ found = hasTreeItemChildren(
513
+ child.props.children
514
+ );
515
+ return;
516
+ }
517
+ found = true;
518
+ });
519
+ return found;
520
+ }
521
+ function TreeItem(props) {
522
+ const { id, label, icon, disabled = false, children } = props;
523
+ const ctx = useTreeContext();
524
+ const { level } = React2.useContext(TreeItemContext);
525
+ const hasChildren = React2.useMemo(
526
+ () => hasTreeItemChildren(children),
527
+ [children]
528
+ );
529
+ const isExpanded = ctx.expanded.has(id);
530
+ const isSelected = ctx.selected.has(id);
531
+ const selectionMode = ctx.selectionMode;
532
+ const itemRef = React2.useRef(null);
533
+ const isTabbable = ctx.manageDomFocus && ctx.focusedId === id;
534
+ React2.useEffect(() => {
535
+ if (!ctx.manageDomFocus) return;
536
+ if (ctx.focusedId !== null) return;
537
+ const root = ctx.rootRef.current;
538
+ if (!root) return;
539
+ const first = root.querySelector('[role="treeitem"]');
540
+ if (first === itemRef.current) {
541
+ ctx.setFocusedId(id);
542
+ }
543
+ }, []);
544
+ const handleKeyDown = useTreeKeyboard(ctx);
545
+ const handleRowClick = (e) => {
546
+ if (disabled) return;
547
+ const target = e.target;
548
+ if (target.closest("[data-tree-chevron]")) return;
549
+ ctx.setFocusedId(id);
550
+ if (ctx.manageDomFocus) itemRef.current?.focus();
551
+ if (hasChildren && selectionMode === "none") {
552
+ ctx.toggleExpanded(id);
553
+ return;
554
+ }
555
+ if (hasChildren && ctx.selectableNodes === "leaves") {
556
+ ctx.toggleExpanded(id);
557
+ return;
558
+ }
559
+ if (selectionMode !== "none") {
560
+ ctx.toggleSelected(id, hasChildren);
561
+ }
562
+ };
563
+ const handleChevronClick = (e) => {
564
+ e.stopPropagation();
565
+ if (disabled) return;
566
+ ctx.setFocusedId(id);
567
+ if (ctx.manageDomFocus) itemRef.current?.focus();
568
+ ctx.toggleExpanded(id);
569
+ };
570
+ const ariaSelected = selectionMode === "single" && ctx.selectableNodes !== "leaves" ? isSelected : void 0;
571
+ const ariaChecked = selectionMode === "multiple" ? isSelected : selectionMode === "single" && ctx.selectableNodes === "leaves" ? isSelected : void 0;
572
+ const groupId = `${id}__group`;
573
+ const labelId = `${id}__label`;
574
+ return /* @__PURE__ */ jsxs(
575
+ TreeItemEl,
576
+ {
577
+ ref: itemRef,
578
+ id: treeItemDomId(id),
579
+ role: "treeitem",
580
+ "data-treeitem-id": id,
581
+ "data-treeitem-active": !ctx.manageDomFocus && ctx.focusedId === id ? true : void 0,
582
+ "aria-level": level,
583
+ "aria-expanded": hasChildren ? isExpanded : void 0,
584
+ "aria-selected": ariaSelected,
585
+ "aria-checked": ariaChecked,
586
+ "aria-disabled": disabled || void 0,
587
+ "aria-labelledby": labelId,
588
+ "aria-owns": hasChildren && isExpanded ? groupId : void 0,
589
+ tabIndex: isTabbable ? 0 : -1,
590
+ onKeyDown: (e) => {
591
+ if (!ctx.manageDomFocus) return;
592
+ handleKeyDown(e, {
593
+ id,
594
+ hasChildren,
595
+ isExpanded,
596
+ isDisabled: disabled,
597
+ level
598
+ });
599
+ },
600
+ onClick: (e) => {
601
+ const target = e.target;
602
+ const nearestTreeitem = target.closest('[role="treeitem"]');
603
+ if (nearestTreeitem !== e.currentTarget) return;
604
+ handleRowClick(e);
605
+ },
606
+ onMouseDown: (e) => {
607
+ if (!ctx.manageDomFocus) e.preventDefault();
608
+ },
609
+ onFocus: () => {
610
+ if (!ctx.manageDomFocus) return;
611
+ if (!ctx.hasFocused) ctx.setHasFocused(true);
612
+ if (ctx.focusedId !== id) ctx.setFocusedId(id);
613
+ },
614
+ children: [
615
+ /* @__PURE__ */ jsxs(
616
+ TreeItemRow,
617
+ {
618
+ "data-treeitem-row": true,
619
+ $level: level,
620
+ $selected: isSelected,
621
+ $disabled: disabled,
622
+ children: [
623
+ ctx.renderSelectionIndicator && !(ctx.selectableNodes === "leaves" && hasChildren) ? ctx.renderSelectionIndicator({
624
+ selected: isSelected,
625
+ disabled,
626
+ selectionMode
627
+ }) : null,
628
+ icon != null ? /* @__PURE__ */ jsx2(TreeItemIcon, { "aria-hidden": true, children: icon }) : null,
629
+ /* @__PURE__ */ jsx2(TreeItemLabel, { id: labelId, children: label }),
630
+ hasChildren ? /* @__PURE__ */ jsx2(
631
+ TreeItemChevron,
632
+ {
633
+ "data-tree-chevron": true,
634
+ "aria-hidden": true,
635
+ $expanded: isExpanded,
636
+ onClick: handleChevronClick,
637
+ children: /* @__PURE__ */ jsx2(Icon, { name: "chevron-down-outline", size: "small" })
638
+ }
639
+ ) : null
640
+ ]
641
+ }
642
+ ),
643
+ hasChildren ? /* @__PURE__ */ jsx2(TreeItemGroup, { role: "group", id: groupId, hidden: !isExpanded, children: /* @__PURE__ */ jsx2(TreeItemContext.Provider, { value: { level: level + 1 }, children }) }) : null
644
+ ]
645
+ }
646
+ );
647
+ }
648
+ TreeItem.displayName = "TreeItem";
649
+
650
+ // src/TreeCombobox.tsx
651
+ import * as React3 from "react";
652
+ import styled2 from "styled-components";
653
+ import { Icon as Icon2 } from "@sproutsocial/seeds-react-icon";
654
+ import { focusRing } from "@sproutsocial/seeds-react-mixins";
655
+
656
+ // src/Common/filterTree.ts
657
+ function filterTree(items, query) {
658
+ const normalized = query.trim().toLowerCase();
659
+ if (!normalized) {
660
+ return { items: [...items], forceExpanded: [] };
661
+ }
662
+ const forceExpanded = /* @__PURE__ */ new Set();
663
+ const walk = (node) => {
664
+ const matches = node.label.toLowerCase().includes(normalized);
665
+ const filteredChildren = (node.children ?? []).map(walk).filter((c) => c !== null);
666
+ if (filteredChildren.length > 0 && node.children) {
667
+ forceExpanded.add(node.id);
668
+ }
669
+ if (matches || filteredChildren.length > 0) {
670
+ return {
671
+ ...node,
672
+ children: node.children ? filteredChildren : void 0
673
+ };
674
+ }
675
+ return null;
676
+ };
677
+ const filtered = items.map(walk).filter((n) => n !== null);
678
+ return { items: filtered, forceExpanded: Array.from(forceExpanded) };
679
+ }
680
+
681
+ // src/TreeCombobox.tsx
682
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
683
+ var Root = styled2.div`
684
+ display: flex;
685
+ flex-direction: column;
686
+ gap: ${({ theme }) => theme.space[300]};
687
+ `;
688
+ var InputGroup = styled2.div`
689
+ display: flex;
690
+ align-items: center;
691
+ gap: ${({ theme }) => theme.space[200]};
692
+ padding: ${({ theme }) => theme.space[200]} ${({ theme }) => theme.space[300]};
693
+ border-radius: ${({ theme }) => theme.radii[500]};
694
+ border: 1px solid ${({ theme }) => theme.colors.form.border.base};
695
+ background: ${({ theme }) => theme.colors.form.background.base};
696
+ color: ${({ theme }) => theme.colors.icon.base};
697
+
698
+ &:focus-within {
699
+ ${focusRing}
700
+ }
701
+ `;
702
+ var ComboboxInput = styled2.input`
703
+ flex: 1;
704
+ border: none;
705
+ background: transparent;
706
+ outline: none;
707
+ font-family: ${({ theme }) => theme.fontFamily};
708
+ ${({ theme }) => theme.typography[300]}
709
+ color: ${({ theme }) => theme.colors.text.body};
710
+
711
+ &::placeholder {
712
+ color: ${({ theme }) => theme.colors.text.subtext};
713
+ }
714
+ `;
715
+ var EmptyState = styled2.div`
716
+ padding: ${({ theme }) => theme.space[400]};
717
+ text-align: center;
718
+ color: ${({ theme }) => theme.colors.text.subtext};
719
+ ${({ theme }) => theme.typography[300]}
720
+ `;
721
+ var NAV_KEYS = /* @__PURE__ */ new Set([
722
+ "ArrowDown",
723
+ "ArrowUp",
724
+ "ArrowLeft",
725
+ "ArrowRight",
726
+ "Home",
727
+ "End",
728
+ "Enter"
729
+ ]);
730
+ function TreeCombobox(props) {
731
+ const {
732
+ items,
733
+ placeholder = "Search...",
734
+ emptyText = "No results found.",
735
+ query: queryProp,
736
+ defaultQuery = "",
737
+ onQueryChange,
738
+ selectionMode = "none",
739
+ selectableNodes = "all",
740
+ defaultExpanded,
741
+ expanded: expandedProp,
742
+ onExpandedChange,
743
+ defaultSelected,
744
+ selected: selectedProp,
745
+ onSelectionChange,
746
+ renderSelectionIndicator,
747
+ id,
748
+ className
749
+ } = props;
750
+ const ariaLabel = props["aria-label"];
751
+ const ariaLabelledBy = props["aria-labelledby"];
752
+ const [uncontrolledQuery, setUncontrolledQuery] = React3.useState(defaultQuery);
753
+ const query = queryProp ?? uncontrolledQuery;
754
+ const setQuery = (next) => {
755
+ if (queryProp === void 0) setUncontrolledQuery(next);
756
+ onQueryChange?.(next);
757
+ };
758
+ const [userExpanded, setUserExpanded] = React3.useState(() => [
759
+ ...defaultExpanded ?? expandedProp ?? []
760
+ ]);
761
+ const { items: visibleItems, forceExpanded } = React3.useMemo(
762
+ () => filterTree([...items], query),
763
+ [items, query]
764
+ );
765
+ const isFiltering = query.trim().length > 0;
766
+ const effectiveExpanded = isFiltering ? Array.from(/* @__PURE__ */ new Set([...userExpanded, ...forceExpanded])) : expandedProp ?? userExpanded;
767
+ const handleExpandedChange = (next) => {
768
+ if (!isFiltering) {
769
+ setUserExpanded(next);
770
+ }
771
+ onExpandedChange?.(next);
772
+ };
773
+ const [focusedId, setFocusedId] = React3.useState(null);
774
+ const treeRef = React3.useRef(null);
775
+ const reactId = React3.useId();
776
+ const treeDomId = id ? `${id}-tree` : `${reactId}-tree`;
777
+ const findTreeItemEl = (itemId) => treeRef.current?.querySelector(
778
+ `[role="treeitem"][data-treeitem-id="${CSS.escape(itemId)}"]`
779
+ ) ?? null;
780
+ const handleInputKeyDown = (e) => {
781
+ if (e.key === "Escape") {
782
+ if (query.length > 0) {
783
+ e.preventDefault();
784
+ setQuery("");
785
+ setFocusedId(null);
786
+ }
787
+ return;
788
+ }
789
+ if (!NAV_KEYS.has(e.key)) return;
790
+ const currentEl = focusedId ? findTreeItemEl(focusedId) : null;
791
+ const meta = currentEl && focusedId ? {
792
+ id: focusedId,
793
+ hasChildren: currentEl.getAttribute("aria-expanded") !== null,
794
+ isExpanded: currentEl.getAttribute("aria-expanded") === "true",
795
+ level: parseInt(currentEl.getAttribute("aria-level") ?? "1", 10)
796
+ } : null;
797
+ if (e.key === "Enter") {
798
+ if (!currentEl) return;
799
+ e.preventDefault();
800
+ const row = currentEl.querySelector("[data-treeitem-row]");
801
+ row?.click();
802
+ return;
803
+ }
804
+ const result = computeTreeNavigation(
805
+ treeRef.current,
806
+ currentEl,
807
+ e.key,
808
+ meta
809
+ );
810
+ if (result.preventDefault) e.preventDefault();
811
+ if (result.expandToggle) {
812
+ const current = new Set(effectiveExpanded);
813
+ const willOpen = result.expandToggle.next ?? !current.has(result.expandToggle.id);
814
+ if (willOpen) current.add(result.expandToggle.id);
815
+ else current.delete(result.expandToggle.id);
816
+ handleExpandedChange(Array.from(current));
817
+ }
818
+ if (result.nextFocusedId) {
819
+ setFocusedId(result.nextFocusedId);
820
+ }
821
+ };
822
+ const showEmpty = visibleItems.length === 0;
823
+ return /* @__PURE__ */ jsxs2(Root, { className, children: [
824
+ /* @__PURE__ */ jsxs2(InputGroup, { children: [
825
+ /* @__PURE__ */ jsx3(Icon2, { name: "magnifying-glass-outline", size: "small", "aria-hidden": true }),
826
+ /* @__PURE__ */ jsx3(
827
+ ComboboxInput,
828
+ {
829
+ type: "text",
830
+ role: "combobox",
831
+ id,
832
+ "aria-label": ariaLabel,
833
+ "aria-labelledby": ariaLabelledBy,
834
+ "aria-expanded": !showEmpty,
835
+ "aria-controls": treeDomId,
836
+ "aria-haspopup": "tree",
837
+ "aria-autocomplete": "list",
838
+ "aria-activedescendant": focusedId ? treeItemDomId(focusedId) : void 0,
839
+ placeholder,
840
+ value: query,
841
+ onChange: (e) => setQuery(e.target.value),
842
+ onKeyDown: handleInputKeyDown
843
+ }
844
+ )
845
+ ] }),
846
+ /* @__PURE__ */ jsx3(
847
+ Tree,
848
+ {
849
+ ref: treeRef,
850
+ "aria-label": ariaLabel,
851
+ "aria-labelledby": ariaLabelledBy,
852
+ id: treeDomId,
853
+ manageDomFocus: false,
854
+ selectionMode,
855
+ selectableNodes,
856
+ expanded: effectiveExpanded,
857
+ onExpandedChange: handleExpandedChange,
858
+ selected: selectedProp,
859
+ defaultSelected,
860
+ onSelectionChange,
861
+ renderSelectionIndicator,
862
+ focusedId,
863
+ onFocusedIdChange: setFocusedId,
864
+ children: visibleItems.map((item) => /* @__PURE__ */ jsx3(TreeNode, { item }, item.id))
865
+ }
866
+ ),
867
+ showEmpty ? /* @__PURE__ */ jsx3(EmptyState, { role: "status", "aria-live": "polite", children: emptyText }) : null
868
+ ] });
869
+ }
870
+ function TreeNode({ item }) {
871
+ return /* @__PURE__ */ jsx3(
872
+ TreeItem,
873
+ {
874
+ id: item.id,
875
+ label: item.label,
876
+ icon: item.icon,
877
+ disabled: item.disabled,
878
+ children: item.children?.map((child) => /* @__PURE__ */ jsx3(TreeNode, { item: child }, child.id))
879
+ }
880
+ );
881
+ }
882
+ export {
883
+ Tree,
884
+ TreeCombobox,
885
+ TreeItem
886
+ };
887
+ //# sourceMappingURL=index.js.map