@edux-design/tree-select 0.0.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 ADDED
@@ -0,0 +1,58 @@
1
+ # @edux-design/tree-select
2
+
3
+ Tree-select component for hierarchical navigation and selection. Supports checkbox or chevron controls, drag-and-drop reordering, and add actions at any level.
4
+
5
+ ---
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pnpm add @edux-design/tree-select @edux-design/forms @edux-design/icons @edux-design/utils
11
+ # or
12
+ npm install @edux-design/tree-select @edux-design/forms @edux-design/icons @edux-design/utils
13
+ ```
14
+
15
+ Peer deps also include `react@^19.1.0`, `react-dom@^19.1.0`.
16
+
17
+ ---
18
+
19
+ ## Usage
20
+
21
+ ```jsx
22
+ import { TreeSelect } from "@edux-design/tree-select";
23
+
24
+ const data = [
25
+ {
26
+ id: "docs",
27
+ label: "Documentation",
28
+ checked: true,
29
+ children: [
30
+ { id: "intro", label: "Introduction" },
31
+ { id: "install", label: "Installation", indeterminate: true },
32
+ ],
33
+ },
34
+ ];
35
+
36
+ export function Example() {
37
+ return (
38
+ <TreeSelect
39
+ data={data}
40
+ control="checkbox"
41
+ defaultExpandedIds={["docs"]}
42
+ onCheck={(node, nextChecked) => console.log(node, nextChecked)}
43
+ />
44
+ );
45
+ }
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ pnpm --filter @edux-design/tree-select lint
54
+ pnpm --filter @edux-design/tree-select check-types
55
+ pnpm --filter @edux-design/tree-select build
56
+ ```
57
+
58
+ Storybook examples live in `src/demos/TreeSelect.stories.jsx`.
@@ -0,0 +1,23 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ declare function TreeSelect({ data, control, className, itemClassName, draggable, onReorder, onDataChange, onExpandedChange, expandedIds, defaultExpandedIds, onCheck, onAdd, addLabel, showRootAdd, rootAddLabel, renderLabel, expandOnLabelClick, }: {
4
+ data: any;
5
+ control?: string;
6
+ className: any;
7
+ itemClassName: any;
8
+ draggable?: boolean;
9
+ onReorder: any;
10
+ onDataChange: any;
11
+ onExpandedChange: any;
12
+ expandedIds: any;
13
+ defaultExpandedIds?: any[];
14
+ onCheck: any;
15
+ onAdd: any;
16
+ addLabel?: string;
17
+ showRootAdd?: boolean;
18
+ rootAddLabel?: string;
19
+ renderLabel: any;
20
+ expandOnLabelClick?: boolean;
21
+ }): react_jsx_runtime.JSX.Element;
22
+
23
+ export { TreeSelect };
@@ -0,0 +1,23 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+
3
+ declare function TreeSelect({ data, control, className, itemClassName, draggable, onReorder, onDataChange, onExpandedChange, expandedIds, defaultExpandedIds, onCheck, onAdd, addLabel, showRootAdd, rootAddLabel, renderLabel, expandOnLabelClick, }: {
4
+ data: any;
5
+ control?: string;
6
+ className: any;
7
+ itemClassName: any;
8
+ draggable?: boolean;
9
+ onReorder: any;
10
+ onDataChange: any;
11
+ onExpandedChange: any;
12
+ expandedIds: any;
13
+ defaultExpandedIds?: any[];
14
+ onCheck: any;
15
+ onAdd: any;
16
+ addLabel?: string;
17
+ showRootAdd?: boolean;
18
+ rootAddLabel?: string;
19
+ renderLabel: any;
20
+ expandOnLabelClick?: boolean;
21
+ }): react_jsx_runtime.JSX.Element;
22
+
23
+ export { TreeSelect };
package/dist/index.js ADDED
@@ -0,0 +1,425 @@
1
+ var __create = Object.create;
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __getProtoOf = Object.getPrototypeOf;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
+
29
+ // src/index.js
30
+ var index_exports = {};
31
+ __export(index_exports, {
32
+ TreeSelect: () => TreeSelect
33
+ });
34
+ module.exports = __toCommonJS(index_exports);
35
+
36
+ // src/elements/TreeSelect.jsx
37
+ var import_react = __toESM(require("react"));
38
+ var import_core = require("@dnd-kit/core");
39
+ var import_sortable = require("@dnd-kit/sortable");
40
+ var import_utilities = require("@dnd-kit/utilities");
41
+ var import_forms = require("@edux-design/forms");
42
+ var import_icons = require("@edux-design/icons");
43
+ var import_utils = require("@edux-design/utils");
44
+ var import_jsx_runtime = require("react/jsx-runtime");
45
+ var normalizeNodes = (data) => {
46
+ if (!data) return [];
47
+ if (Array.isArray(data)) return data;
48
+ return [data];
49
+ };
50
+ var collectNodeInfo = (nodes, parentId = null, map = /* @__PURE__ */ new Map()) => {
51
+ nodes.forEach((node, index) => {
52
+ const nodeId = String(node.id);
53
+ map.set(nodeId, { parentId, index, node });
54
+ if (Array.isArray(node.children)) {
55
+ collectNodeInfo(node.children, nodeId, map);
56
+ }
57
+ });
58
+ return map;
59
+ };
60
+ var reorderArray = (list, fromIndex, toIndex) => {
61
+ const next = list.slice();
62
+ const [moved] = next.splice(fromIndex, 1);
63
+ next.splice(toIndex, 0, moved);
64
+ return next;
65
+ };
66
+ var reorderTree = (nodes, parentId, fromIndex, toIndex) => {
67
+ if (parentId === null) {
68
+ return reorderArray(nodes, fromIndex, toIndex);
69
+ }
70
+ let didReorder = false;
71
+ const next = nodes.map((node) => {
72
+ const nodeId = String(node.id);
73
+ if (nodeId === parentId) {
74
+ const children = Array.isArray(node.children) ? node.children : [];
75
+ didReorder = true;
76
+ return {
77
+ ...node,
78
+ children: reorderArray(children, fromIndex, toIndex)
79
+ };
80
+ }
81
+ if (!Array.isArray(node.children)) return node;
82
+ const nextChildren = reorderTree(
83
+ node.children,
84
+ parentId,
85
+ fromIndex,
86
+ toIndex
87
+ );
88
+ if (nextChildren === node.children) return node;
89
+ didReorder = true;
90
+ return { ...node, children: nextChildren };
91
+ });
92
+ return didReorder ? next : nodes;
93
+ };
94
+ var getNodeLabel = (node, renderLabel) => {
95
+ if (renderLabel) return renderLabel(node);
96
+ return node.label ?? node.title ?? String(node.id);
97
+ };
98
+ var TreeItem = ({
99
+ node,
100
+ depth,
101
+ control,
102
+ draggable,
103
+ expandedIds,
104
+ onToggle,
105
+ onCheck,
106
+ onAdd,
107
+ renderLabel,
108
+ itemClassName,
109
+ addLabel,
110
+ expandOnLabelClick
111
+ }) => {
112
+ const nodeId = String(node.id);
113
+ const hasChildren = Array.isArray(node.children) && node.children.length > 0;
114
+ const isExpanded = expandedIds.has(nodeId);
115
+ const {
116
+ attributes,
117
+ listeners,
118
+ setNodeRef,
119
+ transform,
120
+ transition,
121
+ isDragging
122
+ } = (0, import_sortable.useSortable)({ id: nodeId, disabled: !draggable });
123
+ const style = {
124
+ transform: import_utilities.CSS.Transform.toString(transform),
125
+ transition
126
+ };
127
+ const label = getNodeLabel(node, renderLabel);
128
+ const handleToggle = (0, import_react.useCallback)(() => {
129
+ if (hasChildren) onToggle(nodeId);
130
+ }, [hasChildren, onToggle, nodeId]);
131
+ const handleCheck = (event) => {
132
+ if (!onCheck) return;
133
+ onCheck(node, event.target.checked);
134
+ };
135
+ const handleAdd = () => {
136
+ onAdd == null ? void 0 : onAdd(nodeId);
137
+ };
138
+ const checkboxProps = {};
139
+ if (typeof node.checked === "boolean") {
140
+ checkboxProps.checked = node.checked;
141
+ }
142
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
143
+ "li",
144
+ {
145
+ ref: setNodeRef,
146
+ style,
147
+ role: "treeitem",
148
+ "aria-expanded": hasChildren ? isExpanded : void 0,
149
+ "aria-level": depth + 1,
150
+ className: (0, import_utils.cx)("group", isDragging && "opacity-60"),
151
+ children: [
152
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
153
+ "div",
154
+ {
155
+ className: (0, import_utils.cx)(
156
+ "flex items-center gap-2 rounded-md px-2 py-2 text-sm",
157
+ "text-fg-base transition-colors",
158
+ "hover:bg-bg-minimal",
159
+ itemClassName
160
+ ),
161
+ style: { paddingLeft: depth * 16 + 8 },
162
+ children: [
163
+ draggable ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
164
+ "button",
165
+ {
166
+ type: "button",
167
+ "data-tree-drag-handle": true,
168
+ className: (0, import_utils.cx)(
169
+ "inline-flex h-7 w-7 items-center justify-center rounded-md",
170
+ "text-fg-muted hover:bg-bg-subtle"
171
+ ),
172
+ ...attributes,
173
+ ...listeners,
174
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_icons.Draghandle, { className: "h-4 w-4", "aria-hidden": "true" })
175
+ }
176
+ ) : null,
177
+ control === "chevron" || hasChildren ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
178
+ "button",
179
+ {
180
+ type: "button",
181
+ "data-tree-toggle": true,
182
+ className: (0, import_utils.cx)(
183
+ "inline-flex h-7 w-7 items-center justify-center rounded-md",
184
+ "text-fg-muted transition-transform",
185
+ hasChildren ? "hover:bg-bg-subtle" : "opacity-40"
186
+ ),
187
+ onClick: handleToggle,
188
+ "aria-label": isExpanded ? "Collapse" : "Expand",
189
+ children: hasChildren ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
190
+ import_icons.Chevron,
191
+ {
192
+ className: (0, import_utils.cx)(
193
+ "h-4 w-4 transition-transform duration-200",
194
+ isExpanded && "rotate-90"
195
+ )
196
+ }
197
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "h-4 w-4", "aria-hidden": "true" })
198
+ }
199
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "h-7 w-7", "aria-hidden": "true" }),
200
+ control === "checkbox" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_forms.Field, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
201
+ import_forms.Checkbox,
202
+ {
203
+ "aria-label": typeof label === "string" ? label : "Select",
204
+ indeterminate: Boolean(node.indeterminate),
205
+ plus: Boolean(node.plus),
206
+ disabled: Boolean(node.disabled),
207
+ onChange: handleCheck,
208
+ ...checkboxProps
209
+ }
210
+ ) }) : null,
211
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
212
+ "button",
213
+ {
214
+ type: "button",
215
+ className: (0, import_utils.cx)(
216
+ "flex-1 text-left",
217
+ "text-fg-base hover:text-fg-strong",
218
+ expandOnLabelClick && hasChildren && "cursor-pointer"
219
+ ),
220
+ onClick: expandOnLabelClick ? handleToggle : void 0,
221
+ children: label
222
+ }
223
+ ),
224
+ onAdd ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
225
+ "button",
226
+ {
227
+ type: "button",
228
+ "data-tree-add": true,
229
+ className: (0, import_utils.cx)(
230
+ "rounded-md border border-border-subtle px-2 py-1 text-xs",
231
+ "text-fg-muted hover:border-border-base hover:text-fg-base"
232
+ ),
233
+ onClick: handleAdd,
234
+ children: addLabel
235
+ }
236
+ ) : null
237
+ ]
238
+ }
239
+ ),
240
+ hasChildren ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
241
+ "div",
242
+ {
243
+ className: (0, import_utils.cx)(
244
+ "grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
245
+ isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
246
+ ),
247
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "overflow-hidden", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
248
+ TreeList,
249
+ {
250
+ nodes: node.children,
251
+ depth: depth + 1,
252
+ control,
253
+ draggable,
254
+ expandedIds,
255
+ onToggle,
256
+ onCheck,
257
+ onAdd,
258
+ renderLabel,
259
+ itemClassName,
260
+ addLabel,
261
+ expandOnLabelClick
262
+ }
263
+ ) })
264
+ }
265
+ ) : null
266
+ ]
267
+ }
268
+ );
269
+ };
270
+ var TreeList = ({
271
+ nodes,
272
+ depth,
273
+ control,
274
+ draggable,
275
+ expandedIds,
276
+ onToggle,
277
+ onCheck,
278
+ onAdd,
279
+ renderLabel,
280
+ itemClassName,
281
+ addLabel,
282
+ expandOnLabelClick
283
+ }) => {
284
+ const items = nodes.map((node) => String(node.id));
285
+ const content = nodes.map((node) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
286
+ TreeItem,
287
+ {
288
+ node,
289
+ depth,
290
+ control,
291
+ draggable,
292
+ expandedIds,
293
+ onToggle,
294
+ onCheck,
295
+ onAdd,
296
+ renderLabel,
297
+ itemClassName,
298
+ addLabel,
299
+ expandOnLabelClick
300
+ },
301
+ String(node.id)
302
+ ));
303
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sortable.SortableContext, { items, strategy: import_sortable.verticalListSortingStrategy, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { role: depth === 0 ? "tree" : "group", className: "space-y-1", children: content }) });
304
+ };
305
+ function TreeSelect({
306
+ data,
307
+ control = "checkbox",
308
+ className,
309
+ itemClassName,
310
+ draggable = true,
311
+ onReorder,
312
+ onDataChange,
313
+ onExpandedChange,
314
+ expandedIds,
315
+ defaultExpandedIds = [],
316
+ onCheck,
317
+ onAdd,
318
+ addLabel = "Add",
319
+ showRootAdd = false,
320
+ rootAddLabel = "Add root",
321
+ renderLabel,
322
+ expandOnLabelClick = true
323
+ }) {
324
+ const nodes = (0, import_react.useMemo)(() => normalizeNodes(data), [data]);
325
+ const [internalExpanded, setInternalExpanded] = (0, import_react.useState)(
326
+ new Set(defaultExpandedIds.map(String))
327
+ );
328
+ const isControlled = Array.isArray(expandedIds);
329
+ const expandedSet = (0, import_react.useMemo)(() => {
330
+ if (isControlled) {
331
+ return new Set(expandedIds.map(String));
332
+ }
333
+ return internalExpanded;
334
+ }, [expandedIds, internalExpanded, isControlled]);
335
+ const nodeInfo = (0, import_react.useMemo)(() => collectNodeInfo(nodes), [nodes]);
336
+ const handleToggle = (0, import_react.useCallback)(
337
+ (nodeId) => {
338
+ const next = new Set(expandedSet);
339
+ if (next.has(nodeId)) {
340
+ next.delete(nodeId);
341
+ } else {
342
+ next.add(nodeId);
343
+ }
344
+ if (!isControlled) {
345
+ setInternalExpanded(next);
346
+ }
347
+ onExpandedChange == null ? void 0 : onExpandedChange(Array.from(next));
348
+ },
349
+ [expandedSet, isControlled, onExpandedChange]
350
+ );
351
+ const sensors = (0, import_core.useSensors)(
352
+ (0, import_core.useSensor)(import_core.PointerSensor),
353
+ (0, import_core.useSensor)(import_core.KeyboardSensor, { coordinateGetter: import_sortable.sortableKeyboardCoordinates })
354
+ );
355
+ const handleDragEnd = (0, import_react.useCallback)(
356
+ (event) => {
357
+ const { active, over } = event || {};
358
+ if (!active || !over) return;
359
+ if (active.id === over.id) return;
360
+ const activeInfo = nodeInfo.get(String(active.id));
361
+ const overInfo = nodeInfo.get(String(over.id));
362
+ if (!activeInfo || !overInfo) return;
363
+ if (activeInfo.parentId !== overInfo.parentId) return;
364
+ const parentId = activeInfo.parentId;
365
+ const fromIndex = activeInfo.index;
366
+ const toIndex = overInfo.index;
367
+ if (fromIndex === toIndex) return;
368
+ const nextData = reorderTree(nodes, parentId, fromIndex, toIndex);
369
+ onReorder == null ? void 0 : onReorder({
370
+ parentId,
371
+ fromIndex,
372
+ toIndex,
373
+ activeId: String(active.id),
374
+ overId: String(over.id)
375
+ });
376
+ onDataChange == null ? void 0 : onDataChange(nextData);
377
+ },
378
+ [nodeInfo, nodes, onReorder, onDataChange]
379
+ );
380
+ const content = /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
381
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
382
+ TreeList,
383
+ {
384
+ nodes,
385
+ depth: 0,
386
+ control,
387
+ draggable,
388
+ expandedIds: expandedSet,
389
+ onToggle: handleToggle,
390
+ onCheck,
391
+ onAdd,
392
+ renderLabel,
393
+ itemClassName,
394
+ addLabel,
395
+ expandOnLabelClick
396
+ }
397
+ ),
398
+ onAdd && showRootAdd ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "mt-2 px-2", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
399
+ "button",
400
+ {
401
+ type: "button",
402
+ "data-tree-add-root": true,
403
+ className: (0, import_utils.cx)(
404
+ "rounded-md border border-border-subtle px-2 py-1 text-xs",
405
+ "text-fg-muted hover:border-border-base hover:text-fg-base"
406
+ ),
407
+ onClick: () => onAdd(null),
408
+ children: rootAddLabel
409
+ }
410
+ ) }) : null
411
+ ] });
412
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: (0, import_utils.cx)("w-full", className), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
413
+ import_core.DndContext,
414
+ {
415
+ sensors,
416
+ collisionDetection: import_core.closestCenter,
417
+ onDragEnd: handleDragEnd,
418
+ children: content
419
+ }
420
+ ) });
421
+ }
422
+ // Annotate the CommonJS export names for ESM import in node:
423
+ 0 && (module.exports = {
424
+ TreeSelect
425
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,401 @@
1
+ // src/elements/TreeSelect.jsx
2
+ import React, { useCallback, useMemo, useState } from "react";
3
+ import {
4
+ DndContext,
5
+ PointerSensor,
6
+ KeyboardSensor,
7
+ useSensor,
8
+ useSensors,
9
+ closestCenter
10
+ } from "@dnd-kit/core";
11
+ import {
12
+ SortableContext,
13
+ useSortable,
14
+ verticalListSortingStrategy,
15
+ sortableKeyboardCoordinates
16
+ } from "@dnd-kit/sortable";
17
+ import { CSS } from "@dnd-kit/utilities";
18
+ import { Field, Checkbox } from "@edux-design/forms";
19
+ import { Chevron, Draghandle } from "@edux-design/icons";
20
+ import { cx } from "@edux-design/utils";
21
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
22
+ var normalizeNodes = (data) => {
23
+ if (!data) return [];
24
+ if (Array.isArray(data)) return data;
25
+ return [data];
26
+ };
27
+ var collectNodeInfo = (nodes, parentId = null, map = /* @__PURE__ */ new Map()) => {
28
+ nodes.forEach((node, index) => {
29
+ const nodeId = String(node.id);
30
+ map.set(nodeId, { parentId, index, node });
31
+ if (Array.isArray(node.children)) {
32
+ collectNodeInfo(node.children, nodeId, map);
33
+ }
34
+ });
35
+ return map;
36
+ };
37
+ var reorderArray = (list, fromIndex, toIndex) => {
38
+ const next = list.slice();
39
+ const [moved] = next.splice(fromIndex, 1);
40
+ next.splice(toIndex, 0, moved);
41
+ return next;
42
+ };
43
+ var reorderTree = (nodes, parentId, fromIndex, toIndex) => {
44
+ if (parentId === null) {
45
+ return reorderArray(nodes, fromIndex, toIndex);
46
+ }
47
+ let didReorder = false;
48
+ const next = nodes.map((node) => {
49
+ const nodeId = String(node.id);
50
+ if (nodeId === parentId) {
51
+ const children = Array.isArray(node.children) ? node.children : [];
52
+ didReorder = true;
53
+ return {
54
+ ...node,
55
+ children: reorderArray(children, fromIndex, toIndex)
56
+ };
57
+ }
58
+ if (!Array.isArray(node.children)) return node;
59
+ const nextChildren = reorderTree(
60
+ node.children,
61
+ parentId,
62
+ fromIndex,
63
+ toIndex
64
+ );
65
+ if (nextChildren === node.children) return node;
66
+ didReorder = true;
67
+ return { ...node, children: nextChildren };
68
+ });
69
+ return didReorder ? next : nodes;
70
+ };
71
+ var getNodeLabel = (node, renderLabel) => {
72
+ if (renderLabel) return renderLabel(node);
73
+ return node.label ?? node.title ?? String(node.id);
74
+ };
75
+ var TreeItem = ({
76
+ node,
77
+ depth,
78
+ control,
79
+ draggable,
80
+ expandedIds,
81
+ onToggle,
82
+ onCheck,
83
+ onAdd,
84
+ renderLabel,
85
+ itemClassName,
86
+ addLabel,
87
+ expandOnLabelClick
88
+ }) => {
89
+ const nodeId = String(node.id);
90
+ const hasChildren = Array.isArray(node.children) && node.children.length > 0;
91
+ const isExpanded = expandedIds.has(nodeId);
92
+ const {
93
+ attributes,
94
+ listeners,
95
+ setNodeRef,
96
+ transform,
97
+ transition,
98
+ isDragging
99
+ } = useSortable({ id: nodeId, disabled: !draggable });
100
+ const style = {
101
+ transform: CSS.Transform.toString(transform),
102
+ transition
103
+ };
104
+ const label = getNodeLabel(node, renderLabel);
105
+ const handleToggle = useCallback(() => {
106
+ if (hasChildren) onToggle(nodeId);
107
+ }, [hasChildren, onToggle, nodeId]);
108
+ const handleCheck = (event) => {
109
+ if (!onCheck) return;
110
+ onCheck(node, event.target.checked);
111
+ };
112
+ const handleAdd = () => {
113
+ onAdd == null ? void 0 : onAdd(nodeId);
114
+ };
115
+ const checkboxProps = {};
116
+ if (typeof node.checked === "boolean") {
117
+ checkboxProps.checked = node.checked;
118
+ }
119
+ return /* @__PURE__ */ jsxs(
120
+ "li",
121
+ {
122
+ ref: setNodeRef,
123
+ style,
124
+ role: "treeitem",
125
+ "aria-expanded": hasChildren ? isExpanded : void 0,
126
+ "aria-level": depth + 1,
127
+ className: cx("group", isDragging && "opacity-60"),
128
+ children: [
129
+ /* @__PURE__ */ jsxs(
130
+ "div",
131
+ {
132
+ className: cx(
133
+ "flex items-center gap-2 rounded-md px-2 py-2 text-sm",
134
+ "text-fg-base transition-colors",
135
+ "hover:bg-bg-minimal",
136
+ itemClassName
137
+ ),
138
+ style: { paddingLeft: depth * 16 + 8 },
139
+ children: [
140
+ draggable ? /* @__PURE__ */ jsx(
141
+ "button",
142
+ {
143
+ type: "button",
144
+ "data-tree-drag-handle": true,
145
+ className: cx(
146
+ "inline-flex h-7 w-7 items-center justify-center rounded-md",
147
+ "text-fg-muted hover:bg-bg-subtle"
148
+ ),
149
+ ...attributes,
150
+ ...listeners,
151
+ children: /* @__PURE__ */ jsx(Draghandle, { className: "h-4 w-4", "aria-hidden": "true" })
152
+ }
153
+ ) : null,
154
+ control === "chevron" || hasChildren ? /* @__PURE__ */ jsx(
155
+ "button",
156
+ {
157
+ type: "button",
158
+ "data-tree-toggle": true,
159
+ className: cx(
160
+ "inline-flex h-7 w-7 items-center justify-center rounded-md",
161
+ "text-fg-muted transition-transform",
162
+ hasChildren ? "hover:bg-bg-subtle" : "opacity-40"
163
+ ),
164
+ onClick: handleToggle,
165
+ "aria-label": isExpanded ? "Collapse" : "Expand",
166
+ children: hasChildren ? /* @__PURE__ */ jsx(
167
+ Chevron,
168
+ {
169
+ className: cx(
170
+ "h-4 w-4 transition-transform duration-200",
171
+ isExpanded && "rotate-90"
172
+ )
173
+ }
174
+ ) : /* @__PURE__ */ jsx("span", { className: "h-4 w-4", "aria-hidden": "true" })
175
+ }
176
+ ) : /* @__PURE__ */ jsx("span", { className: "h-7 w-7", "aria-hidden": "true" }),
177
+ control === "checkbox" ? /* @__PURE__ */ jsx(Field, { children: /* @__PURE__ */ jsx(
178
+ Checkbox,
179
+ {
180
+ "aria-label": typeof label === "string" ? label : "Select",
181
+ indeterminate: Boolean(node.indeterminate),
182
+ plus: Boolean(node.plus),
183
+ disabled: Boolean(node.disabled),
184
+ onChange: handleCheck,
185
+ ...checkboxProps
186
+ }
187
+ ) }) : null,
188
+ /* @__PURE__ */ jsx(
189
+ "button",
190
+ {
191
+ type: "button",
192
+ className: cx(
193
+ "flex-1 text-left",
194
+ "text-fg-base hover:text-fg-strong",
195
+ expandOnLabelClick && hasChildren && "cursor-pointer"
196
+ ),
197
+ onClick: expandOnLabelClick ? handleToggle : void 0,
198
+ children: label
199
+ }
200
+ ),
201
+ onAdd ? /* @__PURE__ */ jsx(
202
+ "button",
203
+ {
204
+ type: "button",
205
+ "data-tree-add": true,
206
+ className: cx(
207
+ "rounded-md border border-border-subtle px-2 py-1 text-xs",
208
+ "text-fg-muted hover:border-border-base hover:text-fg-base"
209
+ ),
210
+ onClick: handleAdd,
211
+ children: addLabel
212
+ }
213
+ ) : null
214
+ ]
215
+ }
216
+ ),
217
+ hasChildren ? /* @__PURE__ */ jsx(
218
+ "div",
219
+ {
220
+ className: cx(
221
+ "grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out",
222
+ isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
223
+ ),
224
+ children: /* @__PURE__ */ jsx("div", { className: "overflow-hidden", children: /* @__PURE__ */ jsx(
225
+ TreeList,
226
+ {
227
+ nodes: node.children,
228
+ depth: depth + 1,
229
+ control,
230
+ draggable,
231
+ expandedIds,
232
+ onToggle,
233
+ onCheck,
234
+ onAdd,
235
+ renderLabel,
236
+ itemClassName,
237
+ addLabel,
238
+ expandOnLabelClick
239
+ }
240
+ ) })
241
+ }
242
+ ) : null
243
+ ]
244
+ }
245
+ );
246
+ };
247
+ var TreeList = ({
248
+ nodes,
249
+ depth,
250
+ control,
251
+ draggable,
252
+ expandedIds,
253
+ onToggle,
254
+ onCheck,
255
+ onAdd,
256
+ renderLabel,
257
+ itemClassName,
258
+ addLabel,
259
+ expandOnLabelClick
260
+ }) => {
261
+ const items = nodes.map((node) => String(node.id));
262
+ const content = nodes.map((node) => /* @__PURE__ */ jsx(
263
+ TreeItem,
264
+ {
265
+ node,
266
+ depth,
267
+ control,
268
+ draggable,
269
+ expandedIds,
270
+ onToggle,
271
+ onCheck,
272
+ onAdd,
273
+ renderLabel,
274
+ itemClassName,
275
+ addLabel,
276
+ expandOnLabelClick
277
+ },
278
+ String(node.id)
279
+ ));
280
+ return /* @__PURE__ */ jsx(SortableContext, { items, strategy: verticalListSortingStrategy, children: /* @__PURE__ */ jsx("ul", { role: depth === 0 ? "tree" : "group", className: "space-y-1", children: content }) });
281
+ };
282
+ function TreeSelect({
283
+ data,
284
+ control = "checkbox",
285
+ className,
286
+ itemClassName,
287
+ draggable = true,
288
+ onReorder,
289
+ onDataChange,
290
+ onExpandedChange,
291
+ expandedIds,
292
+ defaultExpandedIds = [],
293
+ onCheck,
294
+ onAdd,
295
+ addLabel = "Add",
296
+ showRootAdd = false,
297
+ rootAddLabel = "Add root",
298
+ renderLabel,
299
+ expandOnLabelClick = true
300
+ }) {
301
+ const nodes = useMemo(() => normalizeNodes(data), [data]);
302
+ const [internalExpanded, setInternalExpanded] = useState(
303
+ new Set(defaultExpandedIds.map(String))
304
+ );
305
+ const isControlled = Array.isArray(expandedIds);
306
+ const expandedSet = useMemo(() => {
307
+ if (isControlled) {
308
+ return new Set(expandedIds.map(String));
309
+ }
310
+ return internalExpanded;
311
+ }, [expandedIds, internalExpanded, isControlled]);
312
+ const nodeInfo = useMemo(() => collectNodeInfo(nodes), [nodes]);
313
+ const handleToggle = useCallback(
314
+ (nodeId) => {
315
+ const next = new Set(expandedSet);
316
+ if (next.has(nodeId)) {
317
+ next.delete(nodeId);
318
+ } else {
319
+ next.add(nodeId);
320
+ }
321
+ if (!isControlled) {
322
+ setInternalExpanded(next);
323
+ }
324
+ onExpandedChange == null ? void 0 : onExpandedChange(Array.from(next));
325
+ },
326
+ [expandedSet, isControlled, onExpandedChange]
327
+ );
328
+ const sensors = useSensors(
329
+ useSensor(PointerSensor),
330
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
331
+ );
332
+ const handleDragEnd = useCallback(
333
+ (event) => {
334
+ const { active, over } = event || {};
335
+ if (!active || !over) return;
336
+ if (active.id === over.id) return;
337
+ const activeInfo = nodeInfo.get(String(active.id));
338
+ const overInfo = nodeInfo.get(String(over.id));
339
+ if (!activeInfo || !overInfo) return;
340
+ if (activeInfo.parentId !== overInfo.parentId) return;
341
+ const parentId = activeInfo.parentId;
342
+ const fromIndex = activeInfo.index;
343
+ const toIndex = overInfo.index;
344
+ if (fromIndex === toIndex) return;
345
+ const nextData = reorderTree(nodes, parentId, fromIndex, toIndex);
346
+ onReorder == null ? void 0 : onReorder({
347
+ parentId,
348
+ fromIndex,
349
+ toIndex,
350
+ activeId: String(active.id),
351
+ overId: String(over.id)
352
+ });
353
+ onDataChange == null ? void 0 : onDataChange(nextData);
354
+ },
355
+ [nodeInfo, nodes, onReorder, onDataChange]
356
+ );
357
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
358
+ /* @__PURE__ */ jsx(
359
+ TreeList,
360
+ {
361
+ nodes,
362
+ depth: 0,
363
+ control,
364
+ draggable,
365
+ expandedIds: expandedSet,
366
+ onToggle: handleToggle,
367
+ onCheck,
368
+ onAdd,
369
+ renderLabel,
370
+ itemClassName,
371
+ addLabel,
372
+ expandOnLabelClick
373
+ }
374
+ ),
375
+ onAdd && showRootAdd ? /* @__PURE__ */ jsx("div", { className: "mt-2 px-2", children: /* @__PURE__ */ jsx(
376
+ "button",
377
+ {
378
+ type: "button",
379
+ "data-tree-add-root": true,
380
+ className: cx(
381
+ "rounded-md border border-border-subtle px-2 py-1 text-xs",
382
+ "text-fg-muted hover:border-border-base hover:text-fg-base"
383
+ ),
384
+ onClick: () => onAdd(null),
385
+ children: rootAddLabel
386
+ }
387
+ ) }) : null
388
+ ] });
389
+ return /* @__PURE__ */ jsx("div", { className: cx("w-full", className), children: /* @__PURE__ */ jsx(
390
+ DndContext,
391
+ {
392
+ sensors,
393
+ collisionDetection: closestCenter,
394
+ onDragEnd: handleDragEnd,
395
+ children: content
396
+ }
397
+ ) });
398
+ }
399
+ export {
400
+ TreeSelect
401
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@edux-design/tree-select",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "sideEffects": [
6
+ "**/*.css"
7
+ ],
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.mjs",
15
+ "require": "./dist/index.js",
16
+ "types": "./dist/index.d.ts"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "lint": "eslint . --max-warnings 0",
21
+ "build": "tsup src/index.js --format esm,cjs --dts",
22
+ "dev": "tsup src/index.js --watch --format esm,cjs --dts",
23
+ "check-types": "tsc --noEmit"
24
+ },
25
+ "dependencies": {
26
+ "@dnd-kit/core": "6.3.1",
27
+ "@dnd-kit/sortable": "^8.0.0",
28
+ "@dnd-kit/utilities": "^3.2.2",
29
+ "@edux-design/forms": "*",
30
+ "@edux-design/icons": "*",
31
+ "@edux-design/utils": "*"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=18",
35
+ "react-dom": ">=18"
36
+ },
37
+ "devDependencies": {
38
+ "tsup": "^8.5.0",
39
+ "typescript": "^5.9.2"
40
+ }
41
+ }