@timeax/form-palette 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/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
import { Input } from "@/presets/ui/input";
|
|
5
|
+
import { Checkbox } from "@/presets/ui/checkbox";
|
|
6
|
+
import { Badge } from "@/presets/ui/badge";
|
|
7
|
+
import {
|
|
8
|
+
Popover,
|
|
9
|
+
PopoverTrigger,
|
|
10
|
+
PopoverContent,
|
|
11
|
+
} from "@/presets/ui/popover";
|
|
12
|
+
import {
|
|
13
|
+
ChevronDown,
|
|
14
|
+
ChevronRight,
|
|
15
|
+
Search,
|
|
16
|
+
X,
|
|
17
|
+
Folder,
|
|
18
|
+
FolderOpen,
|
|
19
|
+
File,
|
|
20
|
+
Check,
|
|
21
|
+
} from "lucide-react";
|
|
22
|
+
|
|
23
|
+
type TreeKey = string | number;
|
|
24
|
+
|
|
25
|
+
// Updated to support both single and array values
|
|
26
|
+
type TreeValue = TreeKey | TreeKey[] | undefined;
|
|
27
|
+
|
|
28
|
+
type Size = "sm" | "md" | "lg";
|
|
29
|
+
type Density = "compact" | "comfortable" | "loose";
|
|
30
|
+
|
|
31
|
+
export type TreeSelectOption =
|
|
32
|
+
| TreeKey
|
|
33
|
+
| {
|
|
34
|
+
label?: React.ReactNode;
|
|
35
|
+
value?: TreeKey;
|
|
36
|
+
description?: React.ReactNode;
|
|
37
|
+
disabled?: boolean;
|
|
38
|
+
icon?: React.ReactNode;
|
|
39
|
+
children?: TreeSelectOption[];
|
|
40
|
+
[key: string]: any;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type NormalizedTreeItem = {
|
|
44
|
+
key: string;
|
|
45
|
+
value: TreeKey;
|
|
46
|
+
labelNode: React.ReactNode;
|
|
47
|
+
labelText: string;
|
|
48
|
+
description?: React.ReactNode;
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
icon?: React.ReactNode;
|
|
51
|
+
level: number;
|
|
52
|
+
parentValue?: TreeKey;
|
|
53
|
+
path: TreeKey[];
|
|
54
|
+
hasChildren: boolean;
|
|
55
|
+
children: NormalizedTreeItem[];
|
|
56
|
+
raw: TreeSelectOption;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ─────────────────────────────────────────────
|
|
60
|
+
// Helpers
|
|
61
|
+
// ─────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
function capitalizeFirst(label: string): string {
|
|
64
|
+
if (!label) return label;
|
|
65
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeTree(
|
|
69
|
+
opts: readonly TreeSelectOption[] | undefined,
|
|
70
|
+
config: Pick<
|
|
71
|
+
ShadcnTreeSelectVariantProps,
|
|
72
|
+
| "autoCap"
|
|
73
|
+
| "optionLabel"
|
|
74
|
+
| "optionValue"
|
|
75
|
+
| "optionDescription"
|
|
76
|
+
| "optionDisabled"
|
|
77
|
+
| "optionIcon"
|
|
78
|
+
| "optionKey"
|
|
79
|
+
>,
|
|
80
|
+
level = 0,
|
|
81
|
+
parentValue?: TreeKey,
|
|
82
|
+
path: TreeKey[] = []
|
|
83
|
+
): NormalizedTreeItem[] {
|
|
84
|
+
if (!opts || !opts.length) return [];
|
|
85
|
+
|
|
86
|
+
return opts.map((raw, index) => {
|
|
87
|
+
const asObj: any =
|
|
88
|
+
typeof raw === "string" || typeof raw === "number"
|
|
89
|
+
? { label: String(raw), value: raw }
|
|
90
|
+
: raw;
|
|
91
|
+
|
|
92
|
+
const value: TreeKey =
|
|
93
|
+
typeof config.optionValue === "function"
|
|
94
|
+
? config.optionValue(raw)
|
|
95
|
+
: typeof config.optionValue === "string"
|
|
96
|
+
? (asObj[config.optionValue] as TreeKey)
|
|
97
|
+
: (asObj.value ??
|
|
98
|
+
asObj.id ??
|
|
99
|
+
asObj.key ??
|
|
100
|
+
String(index));
|
|
101
|
+
|
|
102
|
+
let labelNode: React.ReactNode =
|
|
103
|
+
typeof config.optionLabel === "function"
|
|
104
|
+
? config.optionLabel(raw)
|
|
105
|
+
: typeof config.optionLabel === "string"
|
|
106
|
+
? asObj[config.optionLabel] ?? asObj.label ?? String(value)
|
|
107
|
+
: asObj.label ?? String(value);
|
|
108
|
+
|
|
109
|
+
if (config.autoCap && typeof labelNode === "string") {
|
|
110
|
+
labelNode = capitalizeFirst(labelNode);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const labelText =
|
|
114
|
+
typeof labelNode === "string"
|
|
115
|
+
? labelNode
|
|
116
|
+
: typeof labelNode === "number"
|
|
117
|
+
? String(labelNode)
|
|
118
|
+
: asObj.labelText ?? String(value);
|
|
119
|
+
|
|
120
|
+
const description: React.ReactNode =
|
|
121
|
+
typeof config.optionDescription === "function"
|
|
122
|
+
? config.optionDescription(raw)
|
|
123
|
+
: typeof config.optionDescription === "string"
|
|
124
|
+
? asObj[config.optionDescription]
|
|
125
|
+
: asObj.description;
|
|
126
|
+
|
|
127
|
+
const disabled: boolean =
|
|
128
|
+
typeof config.optionDisabled === "function"
|
|
129
|
+
? config.optionDisabled(raw)
|
|
130
|
+
: typeof config.optionDisabled === "string"
|
|
131
|
+
? !!asObj[config.optionDisabled]
|
|
132
|
+
: !!asObj.disabled;
|
|
133
|
+
|
|
134
|
+
const icon: React.ReactNode =
|
|
135
|
+
typeof config.optionIcon === "function"
|
|
136
|
+
? config.optionIcon(raw)
|
|
137
|
+
: typeof config.optionIcon === "string"
|
|
138
|
+
? asObj[config.optionIcon]
|
|
139
|
+
: asObj.icon;
|
|
140
|
+
|
|
141
|
+
const key: React.Key =
|
|
142
|
+
typeof config.optionKey === "function"
|
|
143
|
+
? config.optionKey(raw, index)
|
|
144
|
+
: typeof config.optionKey === "string"
|
|
145
|
+
? asObj[config.optionKey] ?? value ?? index
|
|
146
|
+
: asObj.key ?? value ?? index;
|
|
147
|
+
|
|
148
|
+
const childrenRaw: TreeSelectOption[] | undefined = asObj.children;
|
|
149
|
+
|
|
150
|
+
const nextPath = [...path, value];
|
|
151
|
+
|
|
152
|
+
const children = normalizeTree(
|
|
153
|
+
childrenRaw ?? [],
|
|
154
|
+
config,
|
|
155
|
+
level + 1,
|
|
156
|
+
value,
|
|
157
|
+
nextPath
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
key: String(key),
|
|
162
|
+
value,
|
|
163
|
+
labelNode,
|
|
164
|
+
labelText,
|
|
165
|
+
description,
|
|
166
|
+
disabled,
|
|
167
|
+
icon,
|
|
168
|
+
level,
|
|
169
|
+
parentValue,
|
|
170
|
+
path,
|
|
171
|
+
hasChildren: !!children.length,
|
|
172
|
+
children,
|
|
173
|
+
raw,
|
|
174
|
+
};
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function flattenTree(
|
|
179
|
+
nodes: NormalizedTreeItem[]
|
|
180
|
+
): NormalizedTreeItem[] {
|
|
181
|
+
const result: NormalizedTreeItem[] = [];
|
|
182
|
+
|
|
183
|
+
function recurse(list: NormalizedTreeItem[]) {
|
|
184
|
+
for(const node of list) {
|
|
185
|
+
result.push(node);
|
|
186
|
+
if(node.children.length) {
|
|
187
|
+
recurse(node.children);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
recurse(nodes);
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toggleInArray(
|
|
196
|
+
arr: TreeKey[] | undefined,
|
|
197
|
+
key: TreeKey
|
|
198
|
+
): TreeKey[] | undefined {
|
|
199
|
+
const list = arr ?? [];
|
|
200
|
+
const idx = list.findIndex((v) => v === key);
|
|
201
|
+
if (idx === -1) {
|
|
202
|
+
return [...list, key];
|
|
203
|
+
}
|
|
204
|
+
const next = [...list];
|
|
205
|
+
next.splice(idx, 1);
|
|
206
|
+
return next.length ? next : undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function triggerHeight(size?: Size) {
|
|
210
|
+
switch (size) {
|
|
211
|
+
case "sm":
|
|
212
|
+
return "min-h-8 text-xs";
|
|
213
|
+
case "lg":
|
|
214
|
+
return "min-h-11 text-base";
|
|
215
|
+
default:
|
|
216
|
+
return "min-h-9 text-sm";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─────────────────────────────────────────────
|
|
221
|
+
// Props
|
|
222
|
+
// ─────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
export interface ShadcnTreeSelectVariantProps
|
|
225
|
+
extends Pick<
|
|
226
|
+
VariantBaseProps<TreeValue>,
|
|
227
|
+
| "value"
|
|
228
|
+
| "onValue"
|
|
229
|
+
| "error"
|
|
230
|
+
| "disabled"
|
|
231
|
+
| "readOnly"
|
|
232
|
+
| "size"
|
|
233
|
+
| "density"
|
|
234
|
+
> {
|
|
235
|
+
options?: TreeSelectOption[];
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* If true, allows multiple selection (checkboxes).
|
|
239
|
+
* If false, allows single selection (no checkboxes, closes on select).
|
|
240
|
+
* Default: true
|
|
241
|
+
*/
|
|
242
|
+
multiple?: boolean;
|
|
243
|
+
|
|
244
|
+
autoCap?: boolean;
|
|
245
|
+
optionLabel?: string | ((item: TreeSelectOption) => React.ReactNode);
|
|
246
|
+
optionValue?: string | ((item: TreeSelectOption) => TreeKey);
|
|
247
|
+
optionDescription?: string | ((item: TreeSelectOption) => React.ReactNode);
|
|
248
|
+
optionDisabled?: string | ((item: TreeSelectOption) => boolean);
|
|
249
|
+
optionIcon?: string | ((item: TreeSelectOption) => React.ReactNode);
|
|
250
|
+
optionKey?: string | ((item: TreeSelectOption, index: number) => React.Key);
|
|
251
|
+
|
|
252
|
+
searchable?: boolean;
|
|
253
|
+
searchPlaceholder?: string;
|
|
254
|
+
emptyLabel?: React.ReactNode;
|
|
255
|
+
emptySearchText?: React.ReactNode;
|
|
256
|
+
clearable?: boolean;
|
|
257
|
+
placeholder?: React.ReactNode;
|
|
258
|
+
|
|
259
|
+
className?: string;
|
|
260
|
+
triggerClassName?: string;
|
|
261
|
+
contentClassName?: string;
|
|
262
|
+
|
|
263
|
+
renderOption?: (ctx: {
|
|
264
|
+
item: NormalizedTreeItem;
|
|
265
|
+
selected: boolean;
|
|
266
|
+
index: number;
|
|
267
|
+
option: React.ReactNode;
|
|
268
|
+
}) => React.ReactNode;
|
|
269
|
+
|
|
270
|
+
renderValue?: (ctx: {
|
|
271
|
+
selectedItems: NormalizedTreeItem[];
|
|
272
|
+
placeholder?: React.ReactNode;
|
|
273
|
+
}) => React.ReactNode;
|
|
274
|
+
|
|
275
|
+
expandAll?: boolean;
|
|
276
|
+
defaultExpandedValues?: TreeKey[];
|
|
277
|
+
leafOnly?: boolean;
|
|
278
|
+
|
|
279
|
+
// Icons & controls
|
|
280
|
+
leadingIcons?: React.ReactNode[];
|
|
281
|
+
trailingIcons?: React.ReactNode[];
|
|
282
|
+
icon?: React.ReactNode;
|
|
283
|
+
iconGap?: number;
|
|
284
|
+
leadingIconSpacing?: number;
|
|
285
|
+
trailingIconSpacing?: number;
|
|
286
|
+
|
|
287
|
+
leadingControl?: React.ReactNode;
|
|
288
|
+
trailingControl?: React.ReactNode;
|
|
289
|
+
leadingControlClassName?: string;
|
|
290
|
+
trailingControlClassName?: string;
|
|
291
|
+
|
|
292
|
+
joinControls?: boolean;
|
|
293
|
+
extendBoxToControls?: boolean;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
// ─────────────────────────────────────────────
|
|
298
|
+
// Component
|
|
299
|
+
// ─────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
export const ShadcnTreeSelectVariant = React.forwardRef<
|
|
302
|
+
HTMLButtonElement,
|
|
303
|
+
ShadcnTreeSelectVariantProps
|
|
304
|
+
>(function ShadcnTreeSelectVariant(props, _ref) {
|
|
305
|
+
const {
|
|
306
|
+
value,
|
|
307
|
+
onValue,
|
|
308
|
+
error,
|
|
309
|
+
disabled,
|
|
310
|
+
readOnly,
|
|
311
|
+
size,
|
|
312
|
+
density,
|
|
313
|
+
|
|
314
|
+
options,
|
|
315
|
+
multiple = true, // Default to true to match previous behavior
|
|
316
|
+
|
|
317
|
+
autoCap,
|
|
318
|
+
optionLabel,
|
|
319
|
+
optionValue,
|
|
320
|
+
optionDescription,
|
|
321
|
+
optionDisabled,
|
|
322
|
+
optionIcon,
|
|
323
|
+
optionKey,
|
|
324
|
+
|
|
325
|
+
searchable = true,
|
|
326
|
+
searchPlaceholder,
|
|
327
|
+
|
|
328
|
+
emptyLabel,
|
|
329
|
+
emptySearchText,
|
|
330
|
+
|
|
331
|
+
clearable = true,
|
|
332
|
+
placeholder,
|
|
333
|
+
|
|
334
|
+
className,
|
|
335
|
+
triggerClassName,
|
|
336
|
+
contentClassName,
|
|
337
|
+
|
|
338
|
+
renderOption,
|
|
339
|
+
renderValue,
|
|
340
|
+
|
|
341
|
+
expandAll = false,
|
|
342
|
+
defaultExpandedValues,
|
|
343
|
+
leafOnly = false,
|
|
344
|
+
|
|
345
|
+
// Icons & controls
|
|
346
|
+
leadingIcons,
|
|
347
|
+
trailingIcons,
|
|
348
|
+
icon,
|
|
349
|
+
iconGap,
|
|
350
|
+
leadingIconSpacing,
|
|
351
|
+
trailingIconSpacing,
|
|
352
|
+
leadingControl,
|
|
353
|
+
trailingControl,
|
|
354
|
+
leadingControlClassName,
|
|
355
|
+
trailingControlClassName,
|
|
356
|
+
joinControls = true,
|
|
357
|
+
extendBoxToControls = true,
|
|
358
|
+
} = props;
|
|
359
|
+
|
|
360
|
+
const [open, setOpen] = React.useState(false);
|
|
361
|
+
const [query, setQuery] = React.useState("");
|
|
362
|
+
|
|
363
|
+
// Normalize tree
|
|
364
|
+
const tree = React.useMemo(
|
|
365
|
+
() =>
|
|
366
|
+
normalizeTree(options ?? [], {
|
|
367
|
+
autoCap,
|
|
368
|
+
optionLabel,
|
|
369
|
+
optionValue,
|
|
370
|
+
optionDescription,
|
|
371
|
+
optionDisabled,
|
|
372
|
+
optionIcon,
|
|
373
|
+
optionKey,
|
|
374
|
+
}),
|
|
375
|
+
[
|
|
376
|
+
options,
|
|
377
|
+
autoCap,
|
|
378
|
+
optionLabel,
|
|
379
|
+
optionValue,
|
|
380
|
+
optionDescription,
|
|
381
|
+
optionDisabled,
|
|
382
|
+
optionIcon,
|
|
383
|
+
optionKey,
|
|
384
|
+
]
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const allNodesFlat = React.useMemo(
|
|
388
|
+
() => flattenTree(tree),
|
|
389
|
+
[tree]
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// Expanded tracking
|
|
393
|
+
const initialExpanded = React.useMemo(() => {
|
|
394
|
+
if (expandAll) {
|
|
395
|
+
return new Set<TreeKey>(
|
|
396
|
+
allNodesFlat
|
|
397
|
+
.filter((n) => n.hasChildren)
|
|
398
|
+
.map((n) => n.value)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
if (defaultExpandedValues?.length) {
|
|
402
|
+
return new Set<TreeKey>(defaultExpandedValues);
|
|
403
|
+
}
|
|
404
|
+
return new Set<TreeKey>();
|
|
405
|
+
}, [expandAll, defaultExpandedValues, allNodesFlat]);
|
|
406
|
+
|
|
407
|
+
const [expanded, setExpanded] = React.useState<Set<TreeKey>>(initialExpanded);
|
|
408
|
+
|
|
409
|
+
const toggleExpanded = React.useCallback(
|
|
410
|
+
(key: TreeKey) => {
|
|
411
|
+
setExpanded((prev) => {
|
|
412
|
+
const next = new Set(prev);
|
|
413
|
+
if (next.has(key)) {
|
|
414
|
+
next.delete(key);
|
|
415
|
+
} else {
|
|
416
|
+
next.add(key);
|
|
417
|
+
}
|
|
418
|
+
return next;
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
[]
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const displayedNodes = React.useMemo(() => {
|
|
425
|
+
if (query) {
|
|
426
|
+
const q = query.toLowerCase();
|
|
427
|
+
const matchSet = new Set<TreeKey>();
|
|
428
|
+
const checkMatch = (node: NormalizedTreeItem): boolean => {
|
|
429
|
+
const selfMatch = node.labelText.toLowerCase().includes(q);
|
|
430
|
+
const childMatch = node.children.some(checkMatch);
|
|
431
|
+
if (selfMatch || childMatch) {
|
|
432
|
+
matchSet.add(node.value);
|
|
433
|
+
node.path.forEach(p => matchSet.add(p));
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
return false;
|
|
437
|
+
};
|
|
438
|
+
tree.forEach(checkMatch);
|
|
439
|
+
return allNodesFlat.filter(n => matchSet.has(n.value));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return allNodesFlat.filter((node) => {
|
|
443
|
+
if (node.level === 0) return true;
|
|
444
|
+
for (const ancestorKey of node.path) {
|
|
445
|
+
if (!expanded.has(ancestorKey)) return false;
|
|
446
|
+
}
|
|
447
|
+
return true;
|
|
448
|
+
});
|
|
449
|
+
}, [allNodesFlat, query, tree, expanded]);
|
|
450
|
+
|
|
451
|
+
// Selection State Normalization
|
|
452
|
+
const selectedValues = React.useMemo(() => {
|
|
453
|
+
if (value === undefined || value === null) return [];
|
|
454
|
+
return Array.isArray(value) ? value : [value];
|
|
455
|
+
}, [value]);
|
|
456
|
+
|
|
457
|
+
const selectedItems = React.useMemo(
|
|
458
|
+
() =>
|
|
459
|
+
allNodesFlat.filter((node) =>
|
|
460
|
+
selectedValues.includes(node.value)
|
|
461
|
+
),
|
|
462
|
+
[allNodesFlat, selectedValues]
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const isDisabled = disabled || readOnly;
|
|
466
|
+
|
|
467
|
+
const handleToggleValue = React.useCallback(
|
|
468
|
+
(item: NormalizedTreeItem) => {
|
|
469
|
+
if (isDisabled) return;
|
|
470
|
+
|
|
471
|
+
// In leafOnly mode, parents toggle expansion instead of selection
|
|
472
|
+
if (leafOnly && item.hasChildren) {
|
|
473
|
+
toggleExpanded(item.value);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let nextValue: TreeValue;
|
|
478
|
+
|
|
479
|
+
if (multiple) {
|
|
480
|
+
// Multi-select: toggle in array
|
|
481
|
+
nextValue = toggleInArray(selectedValues, item.value);
|
|
482
|
+
} else {
|
|
483
|
+
// Single-select: set value, close popover
|
|
484
|
+
// If clicking same item, do we deselect? usually no for select boxes,
|
|
485
|
+
// but let's allow basic switch.
|
|
486
|
+
nextValue = item.value;
|
|
487
|
+
setOpen(false);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const detail: ChangeDetail = {
|
|
491
|
+
source: "variant",
|
|
492
|
+
raw: item.raw,
|
|
493
|
+
nativeEvent: undefined,
|
|
494
|
+
meta: {
|
|
495
|
+
toggled: item.value,
|
|
496
|
+
selectedValues: Array.isArray(nextValue) ? nextValue : (nextValue ? [nextValue] : []),
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
onValue?.(nextValue, detail);
|
|
501
|
+
},
|
|
502
|
+
[isDisabled, leafOnly, multiple, selectedValues, onValue, toggleExpanded]
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const handleClear = React.useCallback(() => {
|
|
506
|
+
if (!onValue) return;
|
|
507
|
+
const detail: ChangeDetail = {
|
|
508
|
+
source: "variant",
|
|
509
|
+
raw: undefined,
|
|
510
|
+
nativeEvent: undefined,
|
|
511
|
+
meta: { action: "clear" },
|
|
512
|
+
};
|
|
513
|
+
onValue(undefined, detail);
|
|
514
|
+
}, [onValue]);
|
|
515
|
+
|
|
516
|
+
const resolvedLeadingIcons = leadingIcons && leadingIcons.length ? leadingIcons : (icon ? [icon] : []);
|
|
517
|
+
const resolvedTrailingIcons = trailingIcons ?? [];
|
|
518
|
+
const baseIconGap = iconGap ?? 4;
|
|
519
|
+
const leadingGap = leadingIconSpacing ?? baseIconGap;
|
|
520
|
+
const trailingGap = trailingIconSpacing ?? baseIconGap;
|
|
521
|
+
const hasLeadingControl = !!leadingControl;
|
|
522
|
+
const hasTrailingControl = !!trailingControl;
|
|
523
|
+
const hasControls = hasLeadingControl || hasTrailingControl;
|
|
524
|
+
const showClear = clearable && !isDisabled && selectedValues.length > 0;
|
|
525
|
+
|
|
526
|
+
// ─────────────────────────────────────────────
|
|
527
|
+
// Render: Trigger
|
|
528
|
+
// ─────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
const renderDefaultTriggerContent = () => {
|
|
531
|
+
if (!selectedItems.length) {
|
|
532
|
+
return <span className="text-muted-foreground">{placeholder ?? "Select..."}</span>;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Single Select Mode: Just show text
|
|
536
|
+
if (!multiple && selectedItems.length === 1) {
|
|
537
|
+
return <span className="text-foreground">{selectedItems[0].labelNode}</span>;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Multi Select Mode: Badges
|
|
541
|
+
if (selectedItems.length <= 3) {
|
|
542
|
+
return (
|
|
543
|
+
<div className="flex flex-wrap gap-1">
|
|
544
|
+
{selectedItems.map(item => (
|
|
545
|
+
<Badge key={item.key} variant="secondary" className="px-1.5 h-5 text-[10px] font-medium border-border/50 bg-secondary/50">
|
|
546
|
+
{item.labelNode}
|
|
547
|
+
</Badge>
|
|
548
|
+
))}
|
|
549
|
+
</div>
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return (
|
|
554
|
+
<div className="flex items-center gap-1">
|
|
555
|
+
<Badge variant="secondary" className="px-1.5 h-5 text-[10px] bg-secondary/50">
|
|
556
|
+
{selectedItems.length} selected
|
|
557
|
+
</Badge>
|
|
558
|
+
</div>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const triggerContent = renderValue
|
|
563
|
+
? renderValue({ selectedItems, placeholder })
|
|
564
|
+
: renderDefaultTriggerContent();
|
|
565
|
+
|
|
566
|
+
const baseBoxClasses = cn(
|
|
567
|
+
"flex items-center justify-between border-input w-full min-w-0 rounded-md border bg-background px-3 py-2 text-sm shadow-xs ring-offset-background",
|
|
568
|
+
"placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
569
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
570
|
+
"aria-invalid:border-destructive"
|
|
571
|
+
);
|
|
572
|
+
|
|
573
|
+
const TriggerButton = (
|
|
574
|
+
<button
|
|
575
|
+
type="button"
|
|
576
|
+
disabled={isDisabled}
|
|
577
|
+
className={cn(
|
|
578
|
+
triggerHeight(size as Size),
|
|
579
|
+
hasControls && extendBoxToControls
|
|
580
|
+
? "border-none shadow-none focus:outline-none bg-transparent w-full text-left"
|
|
581
|
+
: baseBoxClasses,
|
|
582
|
+
triggerClassName
|
|
583
|
+
)}
|
|
584
|
+
>
|
|
585
|
+
<div className="flex w-full items-center justify-between gap-2 overflow-hidden">
|
|
586
|
+
<div className="flex flex-1 items-center gap-2 overflow-hidden">
|
|
587
|
+
{resolvedLeadingIcons.length > 0 && (
|
|
588
|
+
<span className="flex items-center shrink-0" style={{ columnGap: leadingGap }}>
|
|
589
|
+
{resolvedLeadingIcons.map((node, idx) => <span key={idx}>{node}</span>)}
|
|
590
|
+
</span>
|
|
591
|
+
)}
|
|
592
|
+
<div className="truncate w-full text-left">
|
|
593
|
+
{triggerContent}
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
|
|
597
|
+
<div className="flex items-center gap-1.5 shrink-0">
|
|
598
|
+
{showClear && (
|
|
599
|
+
<div
|
|
600
|
+
role="button"
|
|
601
|
+
onClick={(e) => {
|
|
602
|
+
e.stopPropagation();
|
|
603
|
+
handleClear();
|
|
604
|
+
}}
|
|
605
|
+
className="text-muted-foreground hover:text-foreground p-0.5 rounded-sm hover:bg-muted transition-colors"
|
|
606
|
+
>
|
|
607
|
+
<X className="h-3.5 w-3.5" />
|
|
608
|
+
</div>
|
|
609
|
+
)}
|
|
610
|
+
{resolvedTrailingIcons.length > 0 && (
|
|
611
|
+
<span className="flex items-center" style={{ columnGap: trailingGap }}>
|
|
612
|
+
{resolvedTrailingIcons.map((node, idx) => <span key={idx}>{node}</span>)}
|
|
613
|
+
</span>
|
|
614
|
+
)}
|
|
615
|
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
</button>
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
// ─────────────────────────────────────────────
|
|
622
|
+
// Render: Tree Body
|
|
623
|
+
// ─────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
const TreeBody = (
|
|
626
|
+
<div className="max-h-80 w-full overflow-y-auto overflow-x-hidden py-1">
|
|
627
|
+
{emptyLabel && tree.length === 0 && !query && (
|
|
628
|
+
<div className="px-4 py-3 text-sm text-center text-muted-foreground">
|
|
629
|
+
{emptyLabel}
|
|
630
|
+
</div>
|
|
631
|
+
)}
|
|
632
|
+
{tree.length > 0 && displayedNodes.length === 0 && (
|
|
633
|
+
<div className="px-4 py-3 text-sm text-center text-muted-foreground">
|
|
634
|
+
{emptySearchText ?? "No results found"}
|
|
635
|
+
</div>
|
|
636
|
+
)}
|
|
637
|
+
|
|
638
|
+
{displayedNodes.map((item, index) => {
|
|
639
|
+
const selected = selectedValues.includes(item.value);
|
|
640
|
+
const isExpanded = expanded.has(item.value);
|
|
641
|
+
|
|
642
|
+
return (
|
|
643
|
+
<div
|
|
644
|
+
key={item.key}
|
|
645
|
+
className={cn(
|
|
646
|
+
"relative flex items-center gap-2 px-2 py-1.5 text-sm outline-none select-none",
|
|
647
|
+
item.disabled ? "opacity-50" : "hover:bg-accent hover:text-accent-foreground cursor-pointer",
|
|
648
|
+
selected && !multiple && "bg-accent", // Highlight background if single select
|
|
649
|
+
selected && multiple && "bg-accent/50" // Subtler highlight if multi select (checkbox does work)
|
|
650
|
+
)}
|
|
651
|
+
style={{ paddingLeft: 12 + item.level * 20 }}
|
|
652
|
+
onClick={(e) => {
|
|
653
|
+
e.preventDefault();
|
|
654
|
+
if(!item.disabled) handleToggleValue(item);
|
|
655
|
+
}}
|
|
656
|
+
>
|
|
657
|
+
{/* Guidelines */}
|
|
658
|
+
{item.level > 0 && Array.from({ length: item.level }).map((_, i) => (
|
|
659
|
+
<div
|
|
660
|
+
key={i}
|
|
661
|
+
className="absolute border-l border-border/40 h-full top-0"
|
|
662
|
+
style={{ left: 19 + i * 20 }}
|
|
663
|
+
/>
|
|
664
|
+
))}
|
|
665
|
+
|
|
666
|
+
{/* Expander */}
|
|
667
|
+
<button
|
|
668
|
+
type="button"
|
|
669
|
+
onClick={(e) => {
|
|
670
|
+
e.stopPropagation();
|
|
671
|
+
toggleExpanded(item.value);
|
|
672
|
+
}}
|
|
673
|
+
className={cn(
|
|
674
|
+
"z-10 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors",
|
|
675
|
+
!item.hasChildren && "opacity-0 pointer-events-none"
|
|
676
|
+
)}
|
|
677
|
+
>
|
|
678
|
+
{isExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
679
|
+
</button>
|
|
680
|
+
|
|
681
|
+
{/* Checkbox (Multi Only) */}
|
|
682
|
+
{multiple && (
|
|
683
|
+
<Checkbox
|
|
684
|
+
checked={selected}
|
|
685
|
+
className="shrink-0 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
|
686
|
+
style={{ pointerEvents: 'none' }}
|
|
687
|
+
/>
|
|
688
|
+
)}
|
|
689
|
+
|
|
690
|
+
{/* Icon */}
|
|
691
|
+
{item.icon ? (
|
|
692
|
+
<span className="text-muted-foreground">{item.icon}</span>
|
|
693
|
+
) : (
|
|
694
|
+
item.hasChildren ? (
|
|
695
|
+
isExpanded ? <FolderOpen className="h-4 w-4 text-blue-400/80 fill-blue-400/20" /> : <Folder className="h-4 w-4 text-blue-400/80 fill-blue-400/20" />
|
|
696
|
+
) : (
|
|
697
|
+
<File className="h-4 w-4 text-muted-foreground/60" />
|
|
698
|
+
)
|
|
699
|
+
)}
|
|
700
|
+
|
|
701
|
+
{/* Label */}
|
|
702
|
+
<div className="flex flex-col min-w-0 flex-1">
|
|
703
|
+
<span className="truncate font-medium leading-none">
|
|
704
|
+
{item.labelNode}
|
|
705
|
+
</span>
|
|
706
|
+
{item.description && (
|
|
707
|
+
<span className="text-xs text-muted-foreground truncate mt-0.5">
|
|
708
|
+
{item.description}
|
|
709
|
+
</span>
|
|
710
|
+
)}
|
|
711
|
+
</div>
|
|
712
|
+
|
|
713
|
+
{/* Checkmark (Single Only) */}
|
|
714
|
+
{!multiple && selected && (
|
|
715
|
+
<Check className="h-4 w-4 text-primary ml-auto" />
|
|
716
|
+
)}
|
|
717
|
+
</div>
|
|
718
|
+
);
|
|
719
|
+
})}
|
|
720
|
+
</div>
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const SelectBody = (
|
|
724
|
+
<Popover
|
|
725
|
+
open={open}
|
|
726
|
+
onOpenChange={(next) => {
|
|
727
|
+
setOpen(next);
|
|
728
|
+
if (!next) setQuery("");
|
|
729
|
+
}}
|
|
730
|
+
modal={true}
|
|
731
|
+
>
|
|
732
|
+
<PopoverTrigger asChild>{TriggerButton}</PopoverTrigger>
|
|
733
|
+
<PopoverContent
|
|
734
|
+
className={cn("p-0 w-(--radix-popover-trigger-width) min-w-[300px]", contentClassName)}
|
|
735
|
+
align="start"
|
|
736
|
+
>
|
|
737
|
+
{searchable && (
|
|
738
|
+
<div className="flex items-center border-b px-3 py-2.5">
|
|
739
|
+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
740
|
+
<input
|
|
741
|
+
autoFocus
|
|
742
|
+
className="flex h-4 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
|
743
|
+
value={query}
|
|
744
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
745
|
+
placeholder={searchPlaceholder ?? "Search..."}
|
|
746
|
+
/>
|
|
747
|
+
</div>
|
|
748
|
+
)}
|
|
749
|
+
{TreeBody}
|
|
750
|
+
</PopoverContent>
|
|
751
|
+
</Popover>
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
if (!hasControls) {
|
|
755
|
+
return <div data-slot="tree-select-field" className={cn("w-full", className)}>{SelectBody}</div>;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (joinControls) {
|
|
759
|
+
return (
|
|
760
|
+
<div data-slot="tree-select-field" className={cn("w-full", className)}>
|
|
761
|
+
<div className={cn(
|
|
762
|
+
"flex items-center w-full rounded-md border border-input bg-background shadow-xs focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 ring-offset-background",
|
|
763
|
+
isDisabled && "opacity-50 cursor-not-allowed bg-muted"
|
|
764
|
+
)}>
|
|
765
|
+
{hasLeadingControl && <div className={cn("pl-3 pr-1 text-muted-foreground", leadingControlClassName)}>{leadingControl}</div>}
|
|
766
|
+
<div className="flex-1 min-w-0">{SelectBody}</div>
|
|
767
|
+
{hasTrailingControl && <div className={cn("pr-3 pl-1 text-muted-foreground", trailingControlClassName)}>{trailingControl}</div>}
|
|
768
|
+
</div>
|
|
769
|
+
</div>
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return (
|
|
774
|
+
<div className={cn("flex items-center gap-2 w-full", className)}>
|
|
775
|
+
{hasLeadingControl && leadingControl}
|
|
776
|
+
<div className="flex-1 min-w-0">{SelectBody}</div>
|
|
777
|
+
{hasTrailingControl && trailingControl}
|
|
778
|
+
</div>
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
ShadcnTreeSelectVariant.displayName = "ShadcnTreeSelectVariant";
|
|
783
|
+
|
|
784
|
+
export default ShadcnTreeSelectVariant;
|