torch-glare 2.3.0 → 2.4.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/README.md +8 -0
- package/apps/lib/components/ActionButton.tsx +7 -12
- package/apps/lib/components/ActionsGroup.tsx +4 -15
- package/apps/lib/components/AlertDialog.tsx +173 -191
- package/apps/lib/components/Avatar.tsx +13 -16
- package/apps/lib/components/Badge.tsx +7 -3
- package/apps/lib/components/BadgeField.tsx +76 -71
- package/apps/lib/components/Breadcrumb.tsx +49 -85
- package/apps/lib/components/Button.tsx +20 -19
- package/apps/lib/components/ButtonGroup.tsx +31 -41
- package/apps/lib/components/Calendar.tsx +51 -29
- package/apps/lib/components/Card.tsx +2 -13
- package/apps/lib/components/ChartBlockTool.ts +17 -52
- package/apps/lib/components/Checkbox.tsx +9 -16
- package/apps/lib/components/ContextMenu.tsx +110 -106
- package/apps/lib/components/CountBadge.tsx +4 -6
- package/apps/lib/components/DataTable.tsx +196 -216
- package/apps/lib/components/DataViews/DataViewRadio.tsx +10 -8
- package/apps/lib/components/DataViews/DataViewsConfigPanel.tsx +21 -55
- package/apps/lib/components/DataViews/DataViewsHeader.tsx +1 -3
- package/apps/lib/components/DataViews/DataViewsLayout.tsx +5 -12
- package/apps/lib/components/DataViews/FilterPanel.tsx +173 -150
- package/apps/lib/components/DataViews/HeaderSearch.tsx +1 -5
- package/apps/lib/components/DataViews/InboxView.tsx +22 -68
- package/apps/lib/components/DataViews/InboxViewCard.tsx +68 -77
- package/apps/lib/components/DataViews/KanbanView.tsx +8 -25
- package/apps/lib/components/DataViews/PanelControls.tsx +3 -13
- package/apps/lib/components/DataViews/SettingsPanel.tsx +83 -89
- package/apps/lib/components/DataViews/TableView.tsx +8 -28
- package/apps/lib/components/DataViews/TreeView.tsx +12 -40
- package/apps/lib/components/DataViews/badgeAdapter.ts +32 -28
- package/apps/lib/components/DataViews/fieldRenderers.tsx +106 -141
- package/apps/lib/components/DataViews/filters/DatePickerRangeFilter.tsx +87 -0
- package/apps/lib/components/DataViews/filters/DateRangePopover.tsx +39 -32
- package/apps/lib/components/DataViews/filters/PresetChips.tsx +15 -15
- package/apps/lib/components/DataViews/filters/RangeSliderWithInputs.tsx +69 -58
- package/apps/lib/components/DataViews/index.ts +26 -26
- package/apps/lib/components/DataViews/tree/TreeDrawer.tsx +12 -16
- package/apps/lib/components/DataViews/tree/TreeSidebar.tsx +28 -31
- package/apps/lib/components/DataViews/types.ts +111 -114
- package/apps/lib/components/DatePicker.tsx +198 -191
- package/apps/lib/components/Dialog.tsx +92 -105
- package/apps/lib/components/Divider.tsx +21 -24
- package/apps/lib/components/Drawer.tsx +79 -139
- package/apps/lib/components/DropdownMenu.tsx +144 -181
- package/apps/lib/components/FieldHint.tsx +4 -11
- package/apps/lib/components/Form.tsx +146 -155
- package/apps/lib/components/FormStepper.tsx +9 -39
- package/apps/lib/components/HeaderBar.tsx +119 -0
- package/apps/lib/components/ImageAttachment.tsx +111 -73
- package/apps/lib/components/InnerLabelField.tsx +20 -16
- package/apps/lib/components/Input.tsx +171 -172
- package/apps/lib/components/InputField.tsx +44 -45
- package/apps/lib/components/InputOTP.tsx +68 -69
- package/apps/lib/components/Label.tsx +31 -35
- package/apps/lib/components/LabelField.tsx +4 -5
- package/apps/lib/components/LabeledCheckBox.tsx +8 -12
- package/apps/lib/components/LabeledRadio.tsx +7 -5
- package/apps/lib/components/LinkButton.tsx +2 -9
- package/apps/lib/components/LoginButton.tsx +4 -9
- package/apps/lib/components/PasswordLevel.tsx +5 -5
- package/apps/lib/components/Popover.tsx +15 -33
- package/apps/lib/components/ProfileMenu.tsx +67 -72
- package/apps/lib/components/Radio.tsx +1 -5
- package/apps/lib/components/RadioCard.tsx +9 -28
- package/apps/lib/components/ScrollArea.tsx +45 -43
- package/apps/lib/components/SearchField.tsx +49 -39
- package/apps/lib/components/SearchableSelect.tsx +46 -52
- package/apps/lib/components/SearchableTable.tsx +199 -219
- package/apps/lib/components/SearchableTree.tsx +469 -0
- package/apps/lib/components/SearchableTreeDialog.tsx +493 -0
- package/apps/lib/components/SectionBlock.tsx +4 -16
- package/apps/lib/components/Select.tsx +53 -80
- package/apps/lib/components/SimpleSelect.tsx +196 -187
- package/apps/lib/components/Skeleton.tsx +9 -12
- package/apps/lib/components/SlideDatePicker.tsx +215 -166
- package/apps/lib/components/SpinLoading.tsx +33 -49
- package/apps/lib/components/Stepper.tsx +13 -56
- package/apps/lib/components/Switch.tsx +8 -10
- package/apps/lib/components/TabFormItem.tsx +3 -12
- package/apps/lib/components/TabSwitch.tsx +7 -34
- package/apps/lib/components/Table.tsx +60 -75
- package/apps/lib/components/TableDnDWrapper.ts +15 -48
- package/apps/lib/components/TextEditor.tsx +93 -104
- package/apps/lib/components/Textarea.tsx +8 -12
- package/apps/lib/components/Timeline.tsx +21 -41
- package/apps/lib/components/Toast.tsx +83 -83
- package/apps/lib/components/Toggle.tsx +112 -114
- package/apps/lib/components/ToggleButton.tsx +1 -1
- package/apps/lib/components/Tooltip.tsx +24 -19
- package/apps/lib/components/TransparentLabel.tsx +61 -62
- package/apps/lib/components/TreeDropDown.tsx +86 -52
- package/apps/lib/components/TreeFolder/TreeFolder.tsx +15 -48
- package/apps/lib/components/TreeFolder/TreeFolderBreadcrumb.tsx +16 -21
- package/apps/lib/components/TreeFolder/TreeFolderRow.tsx +9 -20
- package/apps/lib/components/TreeFolder/TreeFolderStyles.tsx +3 -3
- package/apps/lib/components/TreeFolder/icons.tsx +14 -17
- package/apps/lib/components/TreeFolder/index.ts +15 -8
- package/apps/lib/components/TreeFolder/treeFolderUtils.ts +47 -52
- package/apps/lib/components/TreeFolder/types.ts +32 -32
- package/apps/lib/components/TreeFolder/useTreeFolderDnD.ts +117 -123
- package/apps/lib/hooks/useActiveTreeItem.ts +52 -55
- package/apps/lib/hooks/useClickOutside.ts +21 -20
- package/apps/lib/hooks/useDataViewsState.ts +86 -80
- package/apps/lib/hooks/useIsMobile.ts +12 -12
- package/apps/lib/hooks/useResize.ts +54 -65
- package/apps/lib/hooks/useTagSelection.ts +201 -201
- package/apps/lib/layouts/CNLayout.tsx +181 -140
- package/apps/lib/layouts/DataViewCard.tsx +4 -18
- package/apps/lib/layouts/FieldSection.tsx +2 -6
- package/apps/lib/layouts/TreeSubLayout.tsx +24 -38
- package/apps/lib/providers/ThemeProvider.tsx +8 -23
- package/apps/lib/registry.json +1004 -0
- package/apps/lib/tsconfig.json +9 -0
- package/apps/lib/utils/cn.ts +1 -1
- package/apps/lib/utils/dataViews/columnUtils.ts +46 -44
- package/apps/lib/utils/dataViews/fieldUtils.ts +70 -71
- package/apps/lib/utils/dataViews/nestedDataUtils.tsx +129 -122
- package/apps/lib/utils/dataViews/pathUtils.ts +74 -67
- package/apps/lib/utils/dataViews/rangeUtils.ts +121 -112
- package/apps/lib/utils/dataViews/treeUtils.ts +204 -211
- package/apps/lib/utils/dateFormat.ts +64 -54
- package/apps/lib/utils/markdownParser.ts +2 -7
- package/apps/lib/utils/resize.ts +21 -25
- package/apps/lib/utils/types.ts +13 -13
- package/dist/bin/index.js +8 -2
- package/dist/bin/index.js.map +1 -1
- package/dist/src/shared/getAvailableFiles.d.ts.map +1 -1
- package/dist/src/shared/getAvailableFiles.js +6 -1
- package/dist/src/shared/getAvailableFiles.js.map +1 -1
- package/dist/src/shared/loadRegistry.d.ts +7 -0
- package/dist/src/shared/loadRegistry.d.ts.map +1 -0
- package/dist/src/shared/loadRegistry.js +31 -0
- package/dist/src/shared/loadRegistry.js.map +1 -0
- package/dist/src/shared/resolveInstallPlan.d.ts +12 -0
- package/dist/src/shared/resolveInstallPlan.d.ts.map +1 -0
- package/dist/src/shared/resolveInstallPlan.js +39 -0
- package/dist/src/shared/resolveInstallPlan.js.map +1 -0
- package/dist/src/types/main.d.ts +24 -0
- package/dist/src/types/main.d.ts.map +1 -1
- package/docs/README.md +10 -2
- package/docs/components/action-button.md +30 -24
- package/docs/components/actions-group.md +26 -20
- package/docs/components/alert-dialog.md +45 -45
- package/docs/components/avatar.md +6 -2
- package/docs/components/badge-field.md +6 -2
- package/docs/components/badge.md +7 -1
- package/docs/components/breadcrumb.md +15 -40
- package/docs/components/button-group.md +13 -10
- package/docs/components/button.md +58 -33
- package/docs/components/calendar.md +26 -19
- package/docs/components/card.md +19 -16
- package/docs/components/chart-block-tool.md +128 -0
- package/docs/components/checkbox.md +16 -8
- package/docs/components/cn-layout.md +7 -1
- package/docs/components/context-menu.md +47 -24
- package/docs/components/count-badge.md +7 -1
- package/docs/components/data-table.md +45 -33
- package/docs/components/data-views-config-panel.md +18 -14
- package/docs/components/data-views-layout.md +20 -17
- package/docs/components/date-picker.md +30 -23
- package/docs/components/dialog.md +43 -42
- package/docs/components/divider.md +10 -4
- package/docs/components/drawer.md +8 -33
- package/docs/components/dropdown-menu.md +51 -32
- package/docs/components/field-hint.md +37 -25
- package/docs/components/field-section.md +9 -3
- package/docs/components/form-stepper.md +6 -0
- package/docs/components/form.md +17 -20
- package/docs/components/header-bar.md +187 -0
- package/docs/components/image-attachment.md +33 -41
- package/docs/components/inbox-view.md +16 -5
- package/docs/components/inner-label-field.md +34 -24
- package/docs/components/input-field.md +18 -10
- package/docs/components/input-otp.md +14 -11
- package/docs/components/input.md +22 -16
- package/docs/components/kanban-view.md +15 -5
- package/docs/components/label-field.md +32 -22
- package/docs/components/label.md +43 -32
- package/docs/components/labeled-check-box.md +16 -8
- package/docs/components/labeled-radio.md +18 -11
- package/docs/components/link-button.md +7 -1
- package/docs/components/login-button.md +8 -2
- package/docs/components/password-level.md +37 -24
- package/docs/components/popover.md +26 -23
- package/docs/components/profile-menu.md +38 -27
- package/docs/components/radio-card.md +16 -9
- package/docs/components/radio.md +16 -8
- package/docs/components/scroll-area.md +6 -2
- package/docs/components/search-field.md +14 -6
- package/docs/components/searchable-select.md +19 -14
- package/docs/components/searchable-table.md +60 -41
- package/docs/components/searchable-tree-dialog.md +190 -0
- package/docs/components/searchable-tree.md +200 -0
- package/docs/components/section-block.md +130 -4
- package/docs/components/select.md +23 -18
- package/docs/components/simple-select.md +14 -10
- package/docs/components/skeleton.md +43 -33
- package/docs/components/slide-date-picker.md +37 -29
- package/docs/components/spin-loading.md +32 -23
- package/docs/components/stepper.md +6 -0
- package/docs/components/switch.md +14 -6
- package/docs/components/tab-form-item.md +7 -1
- package/docs/components/tab-switch.md +13 -6
- package/docs/components/table-dnd-wrapper.md +105 -0
- package/docs/components/table-view.md +15 -5
- package/docs/components/table.md +28 -28
- package/docs/components/text-editor.md +19 -13
- package/docs/components/textarea.md +15 -7
- package/docs/components/timeline.md +6 -0
- package/docs/components/toast.md +35 -26
- package/docs/components/toggle-button.md +24 -21
- package/docs/components/toggle.md +23 -15
- package/docs/components/tooltip.md +42 -30
- package/docs/components/transparent-label.md +29 -20
- package/docs/components/tree-drop-down.md +31 -23
- package/docs/components/tree-sub-layout.md +6 -2
- package/docs/components/tree-view.md +16 -5
- package/docs/explanation/architecture.md +54 -0
- package/docs/explanation/design-system.md +52 -0
- package/docs/how-to/data-views-from-backend-response.md +7 -4
- package/docs/how-to/guides.md +24 -18
- package/docs/migration/changelog.md +38 -0
- package/docs/reference/cli.md +87 -0
- package/docs/reference/components.md +11 -12
- package/docs/reference/hooks.md +8 -8
- package/docs/reference/providers.md +22 -22
- package/docs/reference/tailwind-plugins.md +2 -2
- package/docs/reference/theme.md +85 -0
- package/docs/reference/types.md +4 -4
- package/docs/reference/utilities.md +19 -26
- package/docs/tutorials/building-first-form.md +20 -13
- package/docs/tutorials/component-composition.md +34 -50
- package/docs/tutorials/getting-started.md +2 -2
- package/docs/tutorials/theming-basics.md +13 -10
- package/package.json +31 -3
- package/dist/src/commands/block.d.ts +0 -5
- package/dist/src/commands/block.d.ts.map +0 -1
- package/dist/src/commands/block.js +0 -255
- package/dist/src/commands/block.js.map +0 -1
- package/dist/src/shared/configureFonts.d.ts +0 -6
- package/dist/src/shared/configureFonts.d.ts.map +0 -1
- package/dist/src/shared/configureFonts.js +0 -106
- package/dist/src/shared/configureFonts.js.map +0 -1
- package/dist/src/shared/configureGlobalCss.d.ts +0 -6
- package/dist/src/shared/configureGlobalCss.d.ts.map +0 -1
- package/dist/src/shared/configureGlobalCss.js +0 -154
- package/dist/src/shared/configureGlobalCss.js.map +0 -1
- package/dist/src/shared/configureTailwind.d.ts +0 -7
- package/dist/src/shared/configureTailwind.d.ts.map +0 -1
- package/dist/src/shared/configureTailwind.js +0 -163
- package/dist/src/shared/configureTailwind.js.map +0 -1
- package/dist/src/shared/detectFramework.d.ts +0 -23
- package/dist/src/shared/detectFramework.d.ts.map +0 -1
- package/dist/src/shared/detectFramework.js +0 -119
- package/dist/src/shared/detectFramework.js.map +0 -1
- package/dist/src/shared/installBaseUtils.d.ts +0 -5
- package/dist/src/shared/installBaseUtils.d.ts.map +0 -1
- package/dist/src/shared/installBaseUtils.js +0 -79
- package/dist/src/shared/installBaseUtils.js.map +0 -1
- package/dist/src/shared/resolveAliases.d.ts +0 -24
- package/dist/src/shared/resolveAliases.d.ts.map +0 -1
- package/dist/src/shared/resolveAliases.js +0 -98
- package/dist/src/shared/resolveAliases.js.map +0 -1
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { cn } from "../utils/cn";
|
|
5
|
+
import { Themes } from "../utils/types";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
|
|
7
|
+
import { Icon, Input, Group } from "./Input";
|
|
8
|
+
import { Button, LoadingIcon } from "./Button";
|
|
9
|
+
import { useClickOutside } from "../hooks/useClickOutside";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SearchableTree — a field that opens an anchored dropdown to pick a node from
|
|
13
|
+
* a tree.
|
|
14
|
+
*
|
|
15
|
+
* Same data model and tree rendering as SearchableTreeDialog, but instead of a
|
|
16
|
+
* centered modal it behaves like SearchableSelect: a trigger field that opens a
|
|
17
|
+
* Popover anchored below it, containing a search bar + the scrollable tree.
|
|
18
|
+
*
|
|
19
|
+
* Interaction (matches the Figma `Search.Overlay` design):
|
|
20
|
+
* • A FOLDER row (a node with children) toggles expand/collapse on click and
|
|
21
|
+
* reads de-emphasized — unless `selectableFolders` is set, in which case it
|
|
22
|
+
* selects + closes like a leaf.
|
|
23
|
+
* • A LEAF row (no children) selects the node and closes the dropdown.
|
|
24
|
+
* Nesting is shown with indentation + a vertical connector rail per level
|
|
25
|
+
* (no chevron). Search filters matching nodes and keeps/expands ancestors.
|
|
26
|
+
*
|
|
27
|
+
* Accepts either NESTED data (each node exposes its children via getNodeChildren)
|
|
28
|
+
* or FLAT data (pass `parentIdKey` and it builds the tree internally).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface Props<T> {
|
|
32
|
+
/** Tree nodes. Nested by default; flat when `parentIdKey` is provided. */
|
|
33
|
+
nodes: T[];
|
|
34
|
+
getNodeId: (node: T) => string;
|
|
35
|
+
getNodeLabel: (node: T) => ReactNode;
|
|
36
|
+
/** Returns a node's children (nested mode). Ignored when `parentIdKey` is set. */
|
|
37
|
+
getNodeChildren?: (node: T) => T[] | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* When set, `nodes` is treated as a FLAT list and the tree is built from this
|
|
40
|
+
* key (each item's parent id). Root items have a nullish parent id.
|
|
41
|
+
*/
|
|
42
|
+
parentIdKey?: keyof T & string;
|
|
43
|
+
|
|
44
|
+
/** Controlled selected node (optional). */
|
|
45
|
+
value?: T | null;
|
|
46
|
+
onSelect?: (node: T) => void;
|
|
47
|
+
|
|
48
|
+
/** Plain-text label used for search matching; defaults to String(getNodeLabel). */
|
|
49
|
+
getSearchText?: (node: T) => string;
|
|
50
|
+
|
|
51
|
+
/** Field placeholder shown when nothing is selected (the field is the search input). */
|
|
52
|
+
placeholder?: string;
|
|
53
|
+
/** Section heading shown above the tree rows in the dropdown. */
|
|
54
|
+
title?: string;
|
|
55
|
+
|
|
56
|
+
size?: "XS" | "S" | "M";
|
|
57
|
+
variant?: "SystemStyle" | "PresentationStyle";
|
|
58
|
+
icon?: ReactNode;
|
|
59
|
+
theme?: Themes;
|
|
60
|
+
dir?: string;
|
|
61
|
+
className?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Expand every node by default when the dropdown opens. Defaults to `true` so
|
|
64
|
+
* children are visible as soon as the tree opens (matches the Figma design).
|
|
65
|
+
*/
|
|
66
|
+
defaultExpanded?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* When true, folder rows (nodes with children) are ALSO selectable: clicking
|
|
69
|
+
* any node — folder or leaf — selects it and closes the dropdown. When false
|
|
70
|
+
* (default), folders only toggle expand/collapse and only leaves are selectable.
|
|
71
|
+
*/
|
|
72
|
+
selectableFolders?: boolean;
|
|
73
|
+
/** Max height (px) of the scrollable tree body before it scrolls (default 320). */
|
|
74
|
+
maxBodyHeight?: number;
|
|
75
|
+
|
|
76
|
+
// --- Async (optional; static `nodes` still works) ---
|
|
77
|
+
/** When true (default), filters locally. Set false for server-side search. */
|
|
78
|
+
filterClientSide?: boolean;
|
|
79
|
+
/** Debounced as the user types — refetch your data here (server search). */
|
|
80
|
+
onSearchChange?: (query: string) => void;
|
|
81
|
+
searchDebounceMs?: number;
|
|
82
|
+
/** Whether a fetch is in flight; shows a loading indicator. */
|
|
83
|
+
loading?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Normalized internal node — always nested with resolved id/label/children. */
|
|
87
|
+
interface TreeNode<T> {
|
|
88
|
+
id: string;
|
|
89
|
+
label: ReactNode;
|
|
90
|
+
searchText: string;
|
|
91
|
+
raw: T;
|
|
92
|
+
children: TreeNode<T>[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function SearchableTree<T>({
|
|
96
|
+
nodes,
|
|
97
|
+
getNodeId,
|
|
98
|
+
getNodeLabel,
|
|
99
|
+
getNodeChildren,
|
|
100
|
+
parentIdKey,
|
|
101
|
+
value,
|
|
102
|
+
onSelect,
|
|
103
|
+
getSearchText,
|
|
104
|
+
placeholder = "Select…",
|
|
105
|
+
title = "Select an item",
|
|
106
|
+
size = "M",
|
|
107
|
+
variant = "PresentationStyle",
|
|
108
|
+
icon,
|
|
109
|
+
theme,
|
|
110
|
+
dir,
|
|
111
|
+
className,
|
|
112
|
+
defaultExpanded = true,
|
|
113
|
+
selectableFolders = false,
|
|
114
|
+
maxBodyHeight = 320,
|
|
115
|
+
filterClientSide = true,
|
|
116
|
+
onSearchChange,
|
|
117
|
+
searchDebounceMs = 300,
|
|
118
|
+
loading = false,
|
|
119
|
+
}: Props<T>) {
|
|
120
|
+
const [open, setOpen] = useState(false);
|
|
121
|
+
const [search, setSearch] = useState("");
|
|
122
|
+
const [searching, setSearching] = useState(false);
|
|
123
|
+
const [dropdownWidth, setDropdownWidth] = useState(0);
|
|
124
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
125
|
+
const popoverContentRef = useRef<HTMLDivElement>(null);
|
|
126
|
+
|
|
127
|
+
// Close when clicking outside both the trigger and the popover surface.
|
|
128
|
+
const groupRef = useClickOutside<HTMLDivElement>((e) => {
|
|
129
|
+
const target = e?.target as Node;
|
|
130
|
+
if (!groupRef.current?.contains(target) && !popoverContentRef.current?.contains(target)) {
|
|
131
|
+
setOpen(false);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const searchTextOf = (node: T) => getSearchText?.(node) ?? String(getNodeLabel(node) ?? "");
|
|
136
|
+
|
|
137
|
+
// Normalize raw data (nested OR flat) into a uniform nested TreeNode shape.
|
|
138
|
+
const tree = useMemo<TreeNode<T>[]>(() => {
|
|
139
|
+
const toNode = (raw: T, children: TreeNode<T>[]): TreeNode<T> => ({
|
|
140
|
+
id: getNodeId(raw),
|
|
141
|
+
label: getNodeLabel(raw),
|
|
142
|
+
searchText: searchTextOf(raw),
|
|
143
|
+
raw,
|
|
144
|
+
children,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Flat mode: build nesting from parentIdKey.
|
|
148
|
+
if (parentIdKey) {
|
|
149
|
+
const byId = new Map<string, TreeNode<T>>();
|
|
150
|
+
const roots: TreeNode<T>[] = [];
|
|
151
|
+
for (const raw of nodes) byId.set(getNodeId(raw), toNode(raw, []));
|
|
152
|
+
for (const raw of nodes) {
|
|
153
|
+
const node = byId.get(getNodeId(raw))!;
|
|
154
|
+
const parentId = raw[parentIdKey] as unknown as string | null | undefined;
|
|
155
|
+
const parent = parentId != null ? byId.get(String(parentId)) : undefined;
|
|
156
|
+
if (parent) parent.children.push(node);
|
|
157
|
+
else roots.push(node);
|
|
158
|
+
}
|
|
159
|
+
return roots;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Nested mode: recurse via getNodeChildren.
|
|
163
|
+
const build = (raw: T): TreeNode<T> => toNode(raw, (getNodeChildren?.(raw) ?? []).map(build));
|
|
164
|
+
return nodes.map(build);
|
|
165
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
166
|
+
}, [nodes, parentIdKey, getNodeChildren]);
|
|
167
|
+
|
|
168
|
+
// Filter the tree by the query, keeping ancestors of any match. In server-side
|
|
169
|
+
// mode the data is already filtered, so we render it as-is.
|
|
170
|
+
const visibleTree = useMemo<TreeNode<T>[]>(() => {
|
|
171
|
+
const q = search.trim().toLowerCase();
|
|
172
|
+
if (!filterClientSide || !q) return tree;
|
|
173
|
+
const filter = (list: TreeNode<T>[]): TreeNode<T>[] =>
|
|
174
|
+
list.reduce<TreeNode<T>[]>((acc, node) => {
|
|
175
|
+
const kids = filter(node.children);
|
|
176
|
+
const self = node.searchText.toLowerCase().includes(q);
|
|
177
|
+
if (self || kids.length) acc.push({ ...node, children: kids });
|
|
178
|
+
return acc;
|
|
179
|
+
}, []);
|
|
180
|
+
return filter(tree);
|
|
181
|
+
}, [tree, search, filterClientSide]);
|
|
182
|
+
|
|
183
|
+
// Expand-state: when searching (or defaultExpanded), expand everything so the
|
|
184
|
+
// matched paths are visible; otherwise honor user toggles.
|
|
185
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
186
|
+
const forceExpand = (filterClientSide && search.trim().length > 0) || defaultExpanded;
|
|
187
|
+
const isExpanded = (id: string) => forceExpand || expanded.has(id);
|
|
188
|
+
const toggle = (id: string) =>
|
|
189
|
+
setExpanded((prev) => {
|
|
190
|
+
const next = new Set(prev);
|
|
191
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
192
|
+
return next;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Debounced server search.
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!onSearchChange) return;
|
|
198
|
+
const t = setTimeout(() => onSearchChange(search.trim()), searchDebounceMs);
|
|
199
|
+
return () => clearTimeout(t);
|
|
200
|
+
}, [search, onSearchChange, searchDebounceMs]);
|
|
201
|
+
|
|
202
|
+
// Reset the query when the dropdown closes.
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!open) setSearch("");
|
|
205
|
+
}, [open]);
|
|
206
|
+
|
|
207
|
+
const selectedId = value != null ? getNodeId(value) : undefined;
|
|
208
|
+
|
|
209
|
+
// Leaf rows select + close; folder rows toggle expand/collapse (handled inline).
|
|
210
|
+
const handleSelect = (node: TreeNode<T>) => {
|
|
211
|
+
onSelect?.(node.raw);
|
|
212
|
+
setSearch("");
|
|
213
|
+
setSearching(false);
|
|
214
|
+
setOpen(false);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Folder rows when `selectableFolders` is on: behave just like a leaf —
|
|
218
|
+
// select the node and close the dropdown so it shows in the field.
|
|
219
|
+
const handleSelectFolder = (node: TreeNode<T>) => {
|
|
220
|
+
handleSelect(node);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Field value: the live query while typing, otherwise the selected label.
|
|
224
|
+
const selectedLabel = value != null ? getNodeLabel(value) : "";
|
|
225
|
+
const displayValue = searching ? search : typeof selectedLabel === "string" ? selectedLabel : "";
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Popover open={open}>
|
|
229
|
+
<PopoverTrigger asChild>
|
|
230
|
+
<Group
|
|
231
|
+
dir={dir}
|
|
232
|
+
data-theme={theme}
|
|
233
|
+
variant={variant}
|
|
234
|
+
size={size === "XS" ? "S" : size}
|
|
235
|
+
ref={groupRef as never}
|
|
236
|
+
onFocus={(e: React.FocusEvent<HTMLDivElement>) =>
|
|
237
|
+
setDropdownWidth(e.currentTarget.offsetWidth)
|
|
238
|
+
}
|
|
239
|
+
className={cn("flex w-full items-center gap-1 p-1", className)}
|
|
240
|
+
>
|
|
241
|
+
{icon && <Icon>{icon}</Icon>}
|
|
242
|
+
{/* The field itself is the search input — typing filters the tree. */}
|
|
243
|
+
<Input
|
|
244
|
+
ref={inputRef}
|
|
245
|
+
placeholder={placeholder}
|
|
246
|
+
value={displayValue}
|
|
247
|
+
onChange={(e) => {
|
|
248
|
+
setSearch(e.target.value);
|
|
249
|
+
setSearching(true);
|
|
250
|
+
if (!open) setOpen(true);
|
|
251
|
+
}}
|
|
252
|
+
onFocus={() => {
|
|
253
|
+
setSearching(true);
|
|
254
|
+
setSearch("");
|
|
255
|
+
setDropdownWidth(groupRef.current?.offsetWidth ?? 0);
|
|
256
|
+
setOpen(true);
|
|
257
|
+
}}
|
|
258
|
+
onBlur={() => setSearching(false)}
|
|
259
|
+
className={cn("min-w-[100px] flex-1", {
|
|
260
|
+
"!h-[18px]": size === "XS",
|
|
261
|
+
"!h-[22px]": size === "S",
|
|
262
|
+
"!h-[24px]": size === "M",
|
|
263
|
+
})}
|
|
264
|
+
/>
|
|
265
|
+
{/* Chevron toggle — boxed icon button matching the Select component. */}
|
|
266
|
+
<Button
|
|
267
|
+
as="span"
|
|
268
|
+
buttonType="icon"
|
|
269
|
+
tabIndex={-1}
|
|
270
|
+
aria-label={open ? "Close" : "Open"}
|
|
271
|
+
onMouseDown={(e: React.MouseEvent) => {
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
if (open) {
|
|
274
|
+
setOpen(false);
|
|
275
|
+
} else {
|
|
276
|
+
inputRef.current?.focus();
|
|
277
|
+
setDropdownWidth(groupRef.current?.offsetWidth ?? 0);
|
|
278
|
+
setOpen(true);
|
|
279
|
+
}
|
|
280
|
+
}}
|
|
281
|
+
className={cn(
|
|
282
|
+
"shrink-0 h-[32px] w-[32px] rounded-[4px]",
|
|
283
|
+
open && "bg-background-presentation-action-hover text-white",
|
|
284
|
+
)}
|
|
285
|
+
>
|
|
286
|
+
<i
|
|
287
|
+
className={cn(
|
|
288
|
+
"ri-arrow-down-s-line text-[20px] transition-all duration-100 ease-in-out",
|
|
289
|
+
open && "rotate-180",
|
|
290
|
+
)}
|
|
291
|
+
/>
|
|
292
|
+
</Button>
|
|
293
|
+
</Group>
|
|
294
|
+
</PopoverTrigger>
|
|
295
|
+
|
|
296
|
+
<PopoverContent
|
|
297
|
+
dir={dir}
|
|
298
|
+
data-theme={theme}
|
|
299
|
+
ref={popoverContentRef}
|
|
300
|
+
variant={variant}
|
|
301
|
+
align="start"
|
|
302
|
+
sideOffset={4}
|
|
303
|
+
style={{ width: dropdownWidth || undefined }}
|
|
304
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
305
|
+
onWheel={(e) => e.stopPropagation()}
|
|
306
|
+
className="p-0 border-0 bg-transparent shadow-none rounded-[14px] flex flex-col gap-0 overflow-visible"
|
|
307
|
+
>
|
|
308
|
+
{/* SearchResult body — frosted surface holding the "Tree" label + rows. */}
|
|
309
|
+
<div className="flex w-full items-start">
|
|
310
|
+
<div
|
|
311
|
+
className="flex-1 min-w-px overflow-auto scrollbar-hide rounded-[14px] py-[8px] px-[4px] bg-[rgba(61,64,69,0.72)] backdrop-blur-[21px] shadow-[0_0_32px_2px_rgba(0,0,0,0.20),0_0_48px_2px_rgba(0,0,0,0.05)]"
|
|
312
|
+
style={{ maxHeight: maxBodyHeight }}
|
|
313
|
+
>
|
|
314
|
+
{visibleTree.length > 0 ? (
|
|
315
|
+
<div className="flex flex-col gap-[4px]">
|
|
316
|
+
{/* "Tree" section label. */}
|
|
317
|
+
{title && (
|
|
318
|
+
<div className="flex items-end pt-[4px] px-[12px]">
|
|
319
|
+
<span className="typography-body-small-medium text-content-presentation-global-primary-light">
|
|
320
|
+
{title}
|
|
321
|
+
</span>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
{/* Rows — translucent fill, 1px gaps, rounded + clipped group. */}
|
|
326
|
+
<div className="flex flex-col gap-px overflow-clip rounded-[10px]">
|
|
327
|
+
<TreeLevel
|
|
328
|
+
nodes={visibleTree}
|
|
329
|
+
depth={0}
|
|
330
|
+
selectedId={selectedId}
|
|
331
|
+
selectableFolders={selectableFolders}
|
|
332
|
+
isExpanded={isExpanded}
|
|
333
|
+
onToggle={toggle}
|
|
334
|
+
onSelect={handleSelect}
|
|
335
|
+
onSelectFolder={handleSelectFolder}
|
|
336
|
+
/>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
) : (
|
|
340
|
+
!loading && (
|
|
341
|
+
<div className="px-3 py-6 text-center typography-body-small-regular text-white-alpha-75">
|
|
342
|
+
No results found
|
|
343
|
+
</div>
|
|
344
|
+
)
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{loading && (
|
|
348
|
+
<div className="flex items-center justify-center py-3">
|
|
349
|
+
<LoadingIcon size="M" />
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</PopoverContent>
|
|
355
|
+
</Popover>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
SearchableTree.displayName = "SearchableTree";
|
|
360
|
+
|
|
361
|
+
/* -------------------------------------------------------------------------- */
|
|
362
|
+
/* Recursive tree renderer */
|
|
363
|
+
/* -------------------------------------------------------------------------- */
|
|
364
|
+
|
|
365
|
+
function TreeLevel<T>({
|
|
366
|
+
nodes,
|
|
367
|
+
depth,
|
|
368
|
+
selectedId,
|
|
369
|
+
selectableFolders = false,
|
|
370
|
+
isExpanded,
|
|
371
|
+
onToggle,
|
|
372
|
+
onSelect,
|
|
373
|
+
onSelectFolder,
|
|
374
|
+
}: {
|
|
375
|
+
nodes: TreeNode<T>[];
|
|
376
|
+
depth: number;
|
|
377
|
+
selectedId?: string;
|
|
378
|
+
selectableFolders?: boolean;
|
|
379
|
+
isExpanded: (id: string) => boolean;
|
|
380
|
+
onToggle: (id: string) => void;
|
|
381
|
+
onSelect: (node: TreeNode<T>) => void;
|
|
382
|
+
onSelectFolder: (node: TreeNode<T>) => void;
|
|
383
|
+
}) {
|
|
384
|
+
return (
|
|
385
|
+
<>
|
|
386
|
+
{nodes.map((node) => {
|
|
387
|
+
const hasChildren = node.children.length > 0;
|
|
388
|
+
const open = isExpanded(node.id);
|
|
389
|
+
const selected = selectedId === node.id;
|
|
390
|
+
// Leaf rows always select + close. Folder rows either select + close
|
|
391
|
+
// (selectableFolders) or just toggle expand/collapse.
|
|
392
|
+
const onRowClick = () =>
|
|
393
|
+
!hasChildren
|
|
394
|
+
? onSelect(node)
|
|
395
|
+
: selectableFolders
|
|
396
|
+
? onSelectFolder(node)
|
|
397
|
+
: onToggle(node.id);
|
|
398
|
+
// Whether THIS row participates in the selected/hover highlight.
|
|
399
|
+
const highlightable = !hasChildren || selectableFolders;
|
|
400
|
+
return (
|
|
401
|
+
<div key={node.id} className="flex flex-col gap-px">
|
|
402
|
+
{/* item-container — full-width row with the design's grey fill. */}
|
|
403
|
+
<div
|
|
404
|
+
role="button"
|
|
405
|
+
tabIndex={0}
|
|
406
|
+
aria-expanded={hasChildren ? open : undefined}
|
|
407
|
+
onClick={onRowClick}
|
|
408
|
+
onKeyDown={(e) => {
|
|
409
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
410
|
+
e.preventDefault();
|
|
411
|
+
onRowClick();
|
|
412
|
+
}
|
|
413
|
+
}}
|
|
414
|
+
className={cn(
|
|
415
|
+
"group flex items-center px-[2px] cursor-pointer bg-[rgba(184,192,204,0.36)]",
|
|
416
|
+
)}
|
|
417
|
+
>
|
|
418
|
+
{/* Connector rails — one full-height vertical line per ancestor level. */}
|
|
419
|
+
{Array.from({ length: depth }).map((_, i) => (
|
|
420
|
+
<div
|
|
421
|
+
key={i}
|
|
422
|
+
aria-hidden
|
|
423
|
+
className="flex h-[33px] shrink-0 items-center overflow-clip px-[14px]"
|
|
424
|
+
>
|
|
425
|
+
<div className="h-full w-px shrink-0 bg-white-alpha-40" />
|
|
426
|
+
</div>
|
|
427
|
+
))}
|
|
428
|
+
|
|
429
|
+
{/* Padding + label — inner row that highlights on hover/select. */}
|
|
430
|
+
<div className="flex flex-1 min-w-px flex-col items-start py-[2px]">
|
|
431
|
+
<div
|
|
432
|
+
className={cn(
|
|
433
|
+
"flex w-full items-center gap-2 px-[12px] py-[4px] rounded-[8px]",
|
|
434
|
+
"transition-all duration-300 ease-in-out",
|
|
435
|
+
"text-content-presentation-global-primary-light",
|
|
436
|
+
// Selectable rows (leaves, or folders when selectableFolders)
|
|
437
|
+
// highlight on hover/select; non-selectable folders only toggle,
|
|
438
|
+
// so they read as de-emphasized (opacity-50) and get no highlight.
|
|
439
|
+
!highlightable && "opacity-50",
|
|
440
|
+
highlightable &&
|
|
441
|
+
"group-hover:bg-white-50 group-hover:text-black-1000 group-hover:shadow-[0_0_16px_0_rgba(0,0,0,0.36)]",
|
|
442
|
+
highlightable && selected && "bg-white-alpha-75 text-black-1000",
|
|
443
|
+
)}
|
|
444
|
+
>
|
|
445
|
+
<span className="flex-1 min-w-px truncate typography-body-medium-regular">
|
|
446
|
+
{node.label}
|
|
447
|
+
</span>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
{hasChildren && open && (
|
|
453
|
+
<TreeLevel
|
|
454
|
+
nodes={node.children}
|
|
455
|
+
depth={depth + 1}
|
|
456
|
+
selectedId={selectedId}
|
|
457
|
+
selectableFolders={selectableFolders}
|
|
458
|
+
isExpanded={isExpanded}
|
|
459
|
+
onToggle={onToggle}
|
|
460
|
+
onSelect={onSelect}
|
|
461
|
+
onSelectFolder={onSelectFolder}
|
|
462
|
+
/>
|
|
463
|
+
)}
|
|
464
|
+
</div>
|
|
465
|
+
);
|
|
466
|
+
})}
|
|
467
|
+
</>
|
|
468
|
+
);
|
|
469
|
+
}
|