@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,1132 @@
|
|
|
1
|
+
// src/presets/shadcn-variants/multi-select.tsx
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { Input } from "@/presets/ui/input";
|
|
7
|
+
import { Checkbox } from "@/presets/ui/checkbox";
|
|
8
|
+
import {
|
|
9
|
+
Popover,
|
|
10
|
+
PopoverTrigger,
|
|
11
|
+
PopoverContent,
|
|
12
|
+
} from "@/presets/ui/popover";
|
|
13
|
+
import { ChevronDown, Search, X } from "lucide-react";
|
|
14
|
+
import { removeSelectValue, SelectionSummary } from "@/variants/helpers/selection-summary";
|
|
15
|
+
|
|
16
|
+
type SelectPrimitive = string | number;
|
|
17
|
+
|
|
18
|
+
type Size = "sm" | "md" | "lg";
|
|
19
|
+
type Density = "compact" | "comfortable" | "loose";
|
|
20
|
+
|
|
21
|
+
export type MultiSelectOption =
|
|
22
|
+
| SelectPrimitive
|
|
23
|
+
| {
|
|
24
|
+
label?: React.ReactNode;
|
|
25
|
+
value?: SelectPrimitive;
|
|
26
|
+
description?: React.ReactNode;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
icon?: React.ReactNode;
|
|
29
|
+
[key: string]: any;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type NormalizedMultiItem = {
|
|
33
|
+
key: string;
|
|
34
|
+
value: SelectPrimitive;
|
|
35
|
+
labelNode: React.ReactNode;
|
|
36
|
+
labelText: string;
|
|
37
|
+
description?: React.ReactNode;
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
icon?: React.ReactNode;
|
|
40
|
+
raw: MultiSelectOption;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface ShadcnMultiSelectVariantProps
|
|
44
|
+
extends Pick<
|
|
45
|
+
VariantBaseProps<SelectPrimitive[] | undefined>,
|
|
46
|
+
| "value"
|
|
47
|
+
| "onValue"
|
|
48
|
+
| "error"
|
|
49
|
+
| "disabled"
|
|
50
|
+
| "readOnly"
|
|
51
|
+
| "size"
|
|
52
|
+
| "density"
|
|
53
|
+
> {
|
|
54
|
+
/**
|
|
55
|
+
* Options for the multi-select.
|
|
56
|
+
*
|
|
57
|
+
* You can pass:
|
|
58
|
+
* - primitives: ["ng", "gh", "ke"]
|
|
59
|
+
* - objects: [{ label, value, ...extra }]
|
|
60
|
+
*/
|
|
61
|
+
options?: MultiSelectOption[];
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Automatically capitalise the first letter of the label
|
|
65
|
+
* (when the resolved label is a string).
|
|
66
|
+
*/
|
|
67
|
+
autoCap?: boolean;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How to read the label from each option.
|
|
71
|
+
*
|
|
72
|
+
* - string → key on the option object
|
|
73
|
+
* - function → custom mapper
|
|
74
|
+
* - omitted → tries `label`, else String(value)
|
|
75
|
+
*/
|
|
76
|
+
optionLabel?: string | ((item: MultiSelectOption) => React.ReactNode);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How to read the value from each option.
|
|
80
|
+
*
|
|
81
|
+
* - string → key on the option object
|
|
82
|
+
* - function → custom mapper
|
|
83
|
+
* - omitted → uses `value`, or `id`, or `key`, or index
|
|
84
|
+
*/
|
|
85
|
+
optionValue?: string | ((item: MultiSelectOption) => SelectPrimitive);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Optional description line under the label.
|
|
89
|
+
*/
|
|
90
|
+
optionDescription?: string | ((item: MultiSelectOption) => React.ReactNode);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How to determine if an option is disabled.
|
|
94
|
+
*/
|
|
95
|
+
optionDisabled?: string | ((item: MultiSelectOption) => boolean);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How to extract an icon for each option.
|
|
99
|
+
*
|
|
100
|
+
* - string → key on the option object (default "icon")
|
|
101
|
+
* - function → custom mapper
|
|
102
|
+
*/
|
|
103
|
+
optionIcon?: string | ((item: MultiSelectOption) => React.ReactNode);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* How to compute the React key for each option.
|
|
107
|
+
*/
|
|
108
|
+
optionKey?: string | ((item: MultiSelectOption, index: number) => React.Key);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Enable inline search inside the dropdown.
|
|
112
|
+
*/
|
|
113
|
+
searchable?: boolean;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Placeholder for the search input.
|
|
117
|
+
*/
|
|
118
|
+
searchPlaceholder?: string;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Text to show when search yields no results.
|
|
122
|
+
*/
|
|
123
|
+
emptySearchText?: React.ReactNode;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Placeholder when nothing is selected.
|
|
127
|
+
*/
|
|
128
|
+
placeholder?: React.ReactNode;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Show a small clear button in the trigger when any value is selected.
|
|
132
|
+
*/
|
|
133
|
+
clearable?: boolean;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether to show a "Select all" row.
|
|
137
|
+
*/
|
|
138
|
+
showSelectAll?: boolean;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Label for the "Select all" row.
|
|
142
|
+
* Default: "Select all".
|
|
143
|
+
*/
|
|
144
|
+
selectAllLabel?: React.ReactNode;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Where to place the "Select all" row.
|
|
148
|
+
* Default: "top".
|
|
149
|
+
*/
|
|
150
|
+
selectAllPosition?: "top" | "bottom";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Custom renderer for each option row (checkbox + label).
|
|
154
|
+
*/
|
|
155
|
+
renderOption?: (ctx: {
|
|
156
|
+
item: NormalizedMultiItem;
|
|
157
|
+
selected: boolean;
|
|
158
|
+
index: number;
|
|
159
|
+
option: React.ReactNode; // prebuilt row you can wrap
|
|
160
|
+
}) => React.ReactNode;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Custom renderer for the trigger summary.
|
|
164
|
+
*/
|
|
165
|
+
renderValue?: (ctx: {
|
|
166
|
+
selectedItems: NormalizedMultiItem[];
|
|
167
|
+
placeholder?: React.ReactNode;
|
|
168
|
+
}) => React.ReactNode;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Custom renderer for the checkbox.
|
|
172
|
+
*
|
|
173
|
+
* - item: the option item (or null for "select all")
|
|
174
|
+
* - selected: whether this row is currently fully selected
|
|
175
|
+
* - indeterminate: partially selected (used for "select all")
|
|
176
|
+
* - isSelectAll: true for the "select all" row
|
|
177
|
+
*/
|
|
178
|
+
renderCheckbox?: (ctx: {
|
|
179
|
+
item: NormalizedMultiItem | null;
|
|
180
|
+
selected: boolean;
|
|
181
|
+
indeterminate: boolean;
|
|
182
|
+
isSelectAll: boolean;
|
|
183
|
+
}) => React.ReactNode;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Max height (in px) for the dropdown list before scrolling.
|
|
187
|
+
* Default: 260.
|
|
188
|
+
*/
|
|
189
|
+
maxListHeight?: number;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Wrapper class for the whole variant.
|
|
193
|
+
*/
|
|
194
|
+
className?: string;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Extra classes for the trigger button.
|
|
198
|
+
*/
|
|
199
|
+
triggerClassName?: string;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extra classes for the popover content.
|
|
203
|
+
*/
|
|
204
|
+
contentClassName?: string;
|
|
205
|
+
|
|
206
|
+
// ─────────────────────────────────────────────
|
|
207
|
+
// Icons & controls (parity with single select)
|
|
208
|
+
// ─────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One or more icons displayed inside the trigger, on the left.
|
|
212
|
+
*
|
|
213
|
+
* If not provided and `icon` is set, that single icon
|
|
214
|
+
* is treated as `leadingIcons[0]`.
|
|
215
|
+
*/
|
|
216
|
+
leadingIcons?: React.ReactNode[];
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Icons displayed on the right side of the trigger,
|
|
220
|
+
* near the clear button / chevron area.
|
|
221
|
+
*/
|
|
222
|
+
trailingIcons?: React.ReactNode[];
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Convenience single-icon prop for the left side.
|
|
226
|
+
*/
|
|
227
|
+
icon?: React.ReactNode;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Base gap between icons and text.
|
|
231
|
+
* Defaults to 4px-ish via `gap-1`.
|
|
232
|
+
*/
|
|
233
|
+
iconGap?: number;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Extra spacing to apply between leading icons and the text.
|
|
237
|
+
*/
|
|
238
|
+
leadingIconSpacing?: number;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Extra spacing to apply between trailing icons and the clear button.
|
|
242
|
+
*/
|
|
243
|
+
trailingIconSpacing?: number;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Arbitrary React node rendered before the select (e.g. a button).
|
|
247
|
+
*/
|
|
248
|
+
leadingControl?: React.ReactNode;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Arbitrary React node rendered after the select (e.g. a button).
|
|
252
|
+
*/
|
|
253
|
+
trailingControl?: React.ReactNode;
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Extra classes for the leading control wrapper.
|
|
257
|
+
*/
|
|
258
|
+
leadingControlClassName?: string;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Extra classes for the trailing control wrapper.
|
|
262
|
+
*/
|
|
263
|
+
trailingControlClassName?: string;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* If true and there are controls, the select trigger + controls share
|
|
267
|
+
* a single visual box (borders, radius, focus states).
|
|
268
|
+
*/
|
|
269
|
+
joinControls?: boolean;
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* When joinControls is true, whether the box styling extends over controls
|
|
273
|
+
* (true) or controls are visually separate (false).
|
|
274
|
+
*/
|
|
275
|
+
extendBoxToControls?: boolean;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─────────────────────────────────────────────
|
|
279
|
+
// Helpers
|
|
280
|
+
// ─────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
function capitalizeFirst(label: string): string {
|
|
283
|
+
if (!label) return label;
|
|
284
|
+
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeOptions(
|
|
288
|
+
opts: readonly MultiSelectOption[] | undefined,
|
|
289
|
+
config: Pick<
|
|
290
|
+
ShadcnMultiSelectVariantProps,
|
|
291
|
+
| "autoCap"
|
|
292
|
+
| "optionLabel"
|
|
293
|
+
| "optionValue"
|
|
294
|
+
| "optionDescription"
|
|
295
|
+
| "optionDisabled"
|
|
296
|
+
| "optionKey"
|
|
297
|
+
| "optionIcon"
|
|
298
|
+
>
|
|
299
|
+
): NormalizedMultiItem[] {
|
|
300
|
+
if (!opts || !opts.length) return [];
|
|
301
|
+
|
|
302
|
+
return opts.map((raw, index) => {
|
|
303
|
+
const asObj: any =
|
|
304
|
+
typeof raw === "string" || typeof raw === "number"
|
|
305
|
+
? { label: String(raw), value: raw }
|
|
306
|
+
: raw;
|
|
307
|
+
|
|
308
|
+
const value: SelectPrimitive =
|
|
309
|
+
typeof config.optionValue === "function"
|
|
310
|
+
? config.optionValue(raw)
|
|
311
|
+
: typeof config.optionValue === "string"
|
|
312
|
+
? (asObj[config.optionValue] as SelectPrimitive)
|
|
313
|
+
: (asObj.value ??
|
|
314
|
+
asObj.id ??
|
|
315
|
+
asObj.key ??
|
|
316
|
+
String(index));
|
|
317
|
+
|
|
318
|
+
let labelNode: React.ReactNode =
|
|
319
|
+
typeof config.optionLabel === "function"
|
|
320
|
+
? config.optionLabel(raw)
|
|
321
|
+
: typeof config.optionLabel === "string"
|
|
322
|
+
? asObj[config.optionLabel] ?? asObj.label ?? String(value)
|
|
323
|
+
: asObj.label ?? String(value);
|
|
324
|
+
|
|
325
|
+
if (config.autoCap && typeof labelNode === "string") {
|
|
326
|
+
labelNode = capitalizeFirst(labelNode);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const labelText =
|
|
330
|
+
typeof labelNode === "string"
|
|
331
|
+
? labelNode
|
|
332
|
+
: typeof labelNode === "number"
|
|
333
|
+
? String(labelNode)
|
|
334
|
+
: asObj.labelText ?? String(value);
|
|
335
|
+
|
|
336
|
+
const description: React.ReactNode =
|
|
337
|
+
typeof config.optionDescription === "function"
|
|
338
|
+
? config.optionDescription(raw)
|
|
339
|
+
: typeof config.optionDescription === "string"
|
|
340
|
+
? asObj[config.optionDescription]
|
|
341
|
+
: asObj.description;
|
|
342
|
+
|
|
343
|
+
const disabled: boolean =
|
|
344
|
+
typeof config.optionDisabled === "function"
|
|
345
|
+
? config.optionDisabled(raw)
|
|
346
|
+
: typeof config.optionDisabled === "string"
|
|
347
|
+
? !!asObj[config.optionDisabled]
|
|
348
|
+
: !!asObj.disabled;
|
|
349
|
+
|
|
350
|
+
const icon: React.ReactNode =
|
|
351
|
+
typeof config.optionIcon === "function"
|
|
352
|
+
? config.optionIcon(raw)
|
|
353
|
+
: typeof config.optionIcon === "string"
|
|
354
|
+
? asObj[config.optionIcon]
|
|
355
|
+
: asObj.icon;
|
|
356
|
+
|
|
357
|
+
const key: React.Key =
|
|
358
|
+
typeof config.optionKey === "function"
|
|
359
|
+
? config.optionKey(raw, index)
|
|
360
|
+
: typeof config.optionKey === "string"
|
|
361
|
+
? asObj[config.optionKey] ?? value ?? index
|
|
362
|
+
: asObj.key ?? value ?? index;
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
key: String(key),
|
|
366
|
+
value,
|
|
367
|
+
labelNode,
|
|
368
|
+
labelText,
|
|
369
|
+
description,
|
|
370
|
+
disabled,
|
|
371
|
+
icon,
|
|
372
|
+
raw,
|
|
373
|
+
};
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function triggerHeight(size?: Size) {
|
|
378
|
+
switch (size) {
|
|
379
|
+
case "sm":
|
|
380
|
+
return "h-8 text-xs";
|
|
381
|
+
case "lg":
|
|
382
|
+
return "h-11 text-base";
|
|
383
|
+
default:
|
|
384
|
+
return "h-9 text-sm";
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function triggerPadding(density?: Density) {
|
|
389
|
+
switch (density) {
|
|
390
|
+
case "compact":
|
|
391
|
+
return "py-1";
|
|
392
|
+
case "loose":
|
|
393
|
+
return "py-2";
|
|
394
|
+
case "comfortable":
|
|
395
|
+
default:
|
|
396
|
+
return "py-1.5";
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function summarizeSelection(
|
|
401
|
+
selectedItems: NormalizedMultiItem[],
|
|
402
|
+
placeholder?: React.ReactNode
|
|
403
|
+
): React.ReactNode {
|
|
404
|
+
if (!selectedItems.length) {
|
|
405
|
+
return (
|
|
406
|
+
<span className="truncate text-muted-foreground">
|
|
407
|
+
{placeholder ?? "Select options…"}
|
|
408
|
+
</span>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (selectedItems.length === 1) {
|
|
413
|
+
return (
|
|
414
|
+
<span className="truncate">
|
|
415
|
+
{selectedItems[0].labelNode}
|
|
416
|
+
</span>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (selectedItems.length === 2) {
|
|
421
|
+
return (
|
|
422
|
+
<span className="truncate">
|
|
423
|
+
{selectedItems[0].labelNode}
|
|
424
|
+
{", "}
|
|
425
|
+
{selectedItems[1].labelNode}
|
|
426
|
+
</span>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const first = selectedItems[0];
|
|
431
|
+
const restCount = selectedItems.length - 1;
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<span className="truncate">
|
|
435
|
+
{first.labelNode}
|
|
436
|
+
{", "}
|
|
437
|
+
<span className="text-muted-foreground">
|
|
438
|
+
+{restCount} more
|
|
439
|
+
</span>
|
|
440
|
+
</span>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─────────────────────────────────────────────
|
|
445
|
+
// Component
|
|
446
|
+
// ─────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
export const ShadcnMultiSelectVariant = React.forwardRef<
|
|
449
|
+
HTMLButtonElement,
|
|
450
|
+
ShadcnMultiSelectVariantProps
|
|
451
|
+
>(function ShadcnMultiSelectVariant(props, _ref) {
|
|
452
|
+
const {
|
|
453
|
+
value,
|
|
454
|
+
onValue,
|
|
455
|
+
error,
|
|
456
|
+
disabled,
|
|
457
|
+
readOnly,
|
|
458
|
+
size,
|
|
459
|
+
density,
|
|
460
|
+
|
|
461
|
+
options,
|
|
462
|
+
|
|
463
|
+
autoCap,
|
|
464
|
+
optionLabel,
|
|
465
|
+
optionValue,
|
|
466
|
+
optionDescription,
|
|
467
|
+
optionDisabled,
|
|
468
|
+
optionIcon,
|
|
469
|
+
optionKey,
|
|
470
|
+
|
|
471
|
+
searchable,
|
|
472
|
+
searchPlaceholder,
|
|
473
|
+
emptySearchText,
|
|
474
|
+
|
|
475
|
+
placeholder,
|
|
476
|
+
clearable,
|
|
477
|
+
|
|
478
|
+
showSelectAll,
|
|
479
|
+
selectAllLabel,
|
|
480
|
+
selectAllPosition = "top",
|
|
481
|
+
|
|
482
|
+
renderOption,
|
|
483
|
+
renderValue,
|
|
484
|
+
renderCheckbox,
|
|
485
|
+
|
|
486
|
+
maxListHeight = 260,
|
|
487
|
+
|
|
488
|
+
className,
|
|
489
|
+
triggerClassName,
|
|
490
|
+
contentClassName,
|
|
491
|
+
|
|
492
|
+
// Icons & controls
|
|
493
|
+
leadingIcons,
|
|
494
|
+
trailingIcons,
|
|
495
|
+
icon,
|
|
496
|
+
iconGap,
|
|
497
|
+
leadingIconSpacing,
|
|
498
|
+
trailingIconSpacing,
|
|
499
|
+
leadingControl,
|
|
500
|
+
trailingControl,
|
|
501
|
+
leadingControlClassName,
|
|
502
|
+
trailingControlClassName,
|
|
503
|
+
joinControls = true,
|
|
504
|
+
extendBoxToControls = true,
|
|
505
|
+
} = props;
|
|
506
|
+
|
|
507
|
+
const [open, setOpen] = React.useState(false);
|
|
508
|
+
const [query, setQuery] = React.useState("");
|
|
509
|
+
|
|
510
|
+
const items = React.useMemo(
|
|
511
|
+
() =>
|
|
512
|
+
normalizeOptions(options ?? [], {
|
|
513
|
+
autoCap,
|
|
514
|
+
optionLabel,
|
|
515
|
+
optionValue,
|
|
516
|
+
optionDescription,
|
|
517
|
+
optionDisabled,
|
|
518
|
+
optionKey,
|
|
519
|
+
optionIcon,
|
|
520
|
+
}),
|
|
521
|
+
[
|
|
522
|
+
options,
|
|
523
|
+
autoCap,
|
|
524
|
+
optionLabel,
|
|
525
|
+
optionValue,
|
|
526
|
+
optionDescription,
|
|
527
|
+
optionDisabled,
|
|
528
|
+
optionKey,
|
|
529
|
+
optionIcon,
|
|
530
|
+
]
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const selectedValues = React.useMemo(
|
|
534
|
+
() => new Set<SelectPrimitive>((value ?? []) as SelectPrimitive[]),
|
|
535
|
+
[value]
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
const selectedItems = React.useMemo(
|
|
539
|
+
() => items.filter((it) => selectedValues.has(it.value)),
|
|
540
|
+
[items, selectedValues]
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
const filteredItems = React.useMemo(() => {
|
|
544
|
+
if (!query) return items;
|
|
545
|
+
const q = query.toLowerCase();
|
|
546
|
+
return items.filter((it) =>
|
|
547
|
+
it.labelText.toLowerCase().includes(q)
|
|
548
|
+
);
|
|
549
|
+
}, [items, query]);
|
|
550
|
+
|
|
551
|
+
const selectableItems = React.useMemo(
|
|
552
|
+
() => items.filter((it) => !it.disabled),
|
|
553
|
+
[items]
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const allSelectableValues = React.useMemo(
|
|
557
|
+
() => new Set<SelectPrimitive>(selectableItems.map((it) => it.value)),
|
|
558
|
+
[selectableItems]
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const allSelected =
|
|
562
|
+
selectableItems.length > 0 &&
|
|
563
|
+
selectableItems.every((it) => selectedValues.has(it.value));
|
|
564
|
+
|
|
565
|
+
const someSelected =
|
|
566
|
+
selectableItems.length > 0 &&
|
|
567
|
+
!allSelected &&
|
|
568
|
+
selectableItems.some((it) => selectedValues.has(it.value));
|
|
569
|
+
|
|
570
|
+
const heightCls = triggerHeight(size as Size | undefined);
|
|
571
|
+
const padCls = triggerPadding(density as Density | undefined);
|
|
572
|
+
|
|
573
|
+
const showClear = clearable && (value?.length ?? 0) > 0;
|
|
574
|
+
|
|
575
|
+
const disabledTrigger = disabled || readOnly;
|
|
576
|
+
|
|
577
|
+
const handleToggleValue = React.useCallback(
|
|
578
|
+
(primitive: SelectPrimitive) => {
|
|
579
|
+
if (!onValue || disabled || readOnly) return;
|
|
580
|
+
|
|
581
|
+
const current = (value ?? []) as SelectPrimitive[];
|
|
582
|
+
const isSelected = current.some((v) => v === primitive);
|
|
583
|
+
|
|
584
|
+
let next: SelectPrimitive[];
|
|
585
|
+
if (isSelected) {
|
|
586
|
+
next = current.filter((v) => v !== primitive);
|
|
587
|
+
} else {
|
|
588
|
+
next = [...current, primitive];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const final = next.length ? next : undefined;
|
|
592
|
+
|
|
593
|
+
const detail: ChangeDetail = {
|
|
594
|
+
source: "variant",
|
|
595
|
+
raw: {
|
|
596
|
+
type: "toggle",
|
|
597
|
+
value: primitive,
|
|
598
|
+
next: final,
|
|
599
|
+
},
|
|
600
|
+
nativeEvent: undefined,
|
|
601
|
+
meta: undefined,
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
onValue(final as any, detail);
|
|
605
|
+
},
|
|
606
|
+
[onValue, value, disabled, readOnly]
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
const handleSelectAll = React.useCallback(() => {
|
|
610
|
+
if (!onValue || disabled || readOnly) return;
|
|
611
|
+
|
|
612
|
+
const current = (value ?? []) as SelectPrimitive[];
|
|
613
|
+
|
|
614
|
+
const allSelectableArr = Array.from(allSelectableValues);
|
|
615
|
+
|
|
616
|
+
const currentlyAllSelected =
|
|
617
|
+
allSelectableArr.length > 0 &&
|
|
618
|
+
allSelectableArr.every((v) => selectedValues.has(v));
|
|
619
|
+
|
|
620
|
+
let next: SelectPrimitive[];
|
|
621
|
+
|
|
622
|
+
if (currentlyAllSelected) {
|
|
623
|
+
// unselect all selectable ones, keep others (if any)
|
|
624
|
+
next = current.filter((v) => !allSelectableValues.has(v));
|
|
625
|
+
} else {
|
|
626
|
+
// union of existing + all selectable
|
|
627
|
+
const merged = new Set<SelectPrimitive>(current);
|
|
628
|
+
for (const v of allSelectableArr) merged.add(v);
|
|
629
|
+
next = Array.from(merged);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const final = next.length ? next : undefined;
|
|
633
|
+
|
|
634
|
+
const detail: ChangeDetail = {
|
|
635
|
+
source: "variant",
|
|
636
|
+
raw: {
|
|
637
|
+
type: "select-all",
|
|
638
|
+
next: final,
|
|
639
|
+
},
|
|
640
|
+
nativeEvent: undefined,
|
|
641
|
+
meta: {
|
|
642
|
+
allSelected: !currentlyAllSelected,
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
onValue(final as any, detail);
|
|
647
|
+
}, [
|
|
648
|
+
onValue,
|
|
649
|
+
value,
|
|
650
|
+
disabled,
|
|
651
|
+
readOnly,
|
|
652
|
+
allSelectableValues,
|
|
653
|
+
selectedValues,
|
|
654
|
+
]);
|
|
655
|
+
|
|
656
|
+
const handleClearAll = React.useCallback(() => {
|
|
657
|
+
if (!onValue || disabled || readOnly) return;
|
|
658
|
+
|
|
659
|
+
const detail: ChangeDetail = {
|
|
660
|
+
source: "variant",
|
|
661
|
+
raw: {
|
|
662
|
+
type: "clear",
|
|
663
|
+
},
|
|
664
|
+
nativeEvent: undefined,
|
|
665
|
+
meta: undefined,
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
onValue(undefined as any, detail);
|
|
669
|
+
}, [onValue, disabled, readOnly]);
|
|
670
|
+
|
|
671
|
+
const triggerSummary = renderValue
|
|
672
|
+
? renderValue({ selectedItems, placeholder })
|
|
673
|
+
: (
|
|
674
|
+
<SelectionSummary
|
|
675
|
+
selectedItems={selectedItems}
|
|
676
|
+
placeholder={placeholder}
|
|
677
|
+
onRemoveValue={(item) => {
|
|
678
|
+
// whatever you already do to unselect a single value
|
|
679
|
+
// e.g. toggleValue(value) if it adds/removes from the set
|
|
680
|
+
// toggleValue(value);
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
const updated = removeSelectValue(
|
|
684
|
+
selectedValues as unknown as SelectPrimitive[],
|
|
685
|
+
item.value
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
const detail: ChangeDetail = {
|
|
689
|
+
source: "variant",
|
|
690
|
+
raw: item,
|
|
691
|
+
nativeEvent: undefined,
|
|
692
|
+
meta: { action: "remove", removed: value },
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
onValue?.(updated, detail);
|
|
696
|
+
}}
|
|
697
|
+
/>
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// ─────────────────────────────────────────────
|
|
701
|
+
// Icons setup (same semantics as select variant)
|
|
702
|
+
// ─────────────────────────────────────────────
|
|
703
|
+
|
|
704
|
+
const resolvedLeadingIcons: React.ReactNode[] = (() => {
|
|
705
|
+
if (leadingIcons && leadingIcons.length) return leadingIcons;
|
|
706
|
+
if (icon) return [icon];
|
|
707
|
+
return [];
|
|
708
|
+
})();
|
|
709
|
+
|
|
710
|
+
const resolvedTrailingIcons: React.ReactNode[] = trailingIcons ?? [];
|
|
711
|
+
|
|
712
|
+
const baseIconGap = iconGap ?? 4;
|
|
713
|
+
const leadingGap = leadingIconSpacing ?? baseIconGap;
|
|
714
|
+
const trailingGap = trailingIconSpacing ?? baseIconGap;
|
|
715
|
+
|
|
716
|
+
const hasLeadingIcons = resolvedLeadingIcons.length > 0;
|
|
717
|
+
const hasTrailingIcons = resolvedTrailingIcons.length > 0;
|
|
718
|
+
|
|
719
|
+
const hasLeadingControl = !!leadingControl;
|
|
720
|
+
const hasTrailingControl = !!trailingControl;
|
|
721
|
+
const hasControls = hasLeadingControl || hasTrailingControl;
|
|
722
|
+
|
|
723
|
+
const makeCheckboxNode = React.useCallback(
|
|
724
|
+
(opts: {
|
|
725
|
+
item: NormalizedMultiItem | null;
|
|
726
|
+
selected: boolean;
|
|
727
|
+
indeterminate: boolean;
|
|
728
|
+
isSelectAll: boolean;
|
|
729
|
+
}) => {
|
|
730
|
+
if (renderCheckbox) {
|
|
731
|
+
return renderCheckbox(opts);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return (
|
|
735
|
+
<Checkbox
|
|
736
|
+
className="mr-2 mt-0.5"
|
|
737
|
+
checked={
|
|
738
|
+
opts.indeterminate
|
|
739
|
+
? "none"
|
|
740
|
+
: opts.selected
|
|
741
|
+
}
|
|
742
|
+
aria-hidden="true"
|
|
743
|
+
// purely visual; click handled on row button
|
|
744
|
+
onCheckedChange={() => { }}
|
|
745
|
+
/>
|
|
746
|
+
);
|
|
747
|
+
},
|
|
748
|
+
[renderCheckbox]
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const baseBoxClasses = cn(
|
|
752
|
+
"border-input w-full min-w-0 rounded-md border bg-transparent shadow-xs",
|
|
753
|
+
"transition-[color,box-shadow] outline-none",
|
|
754
|
+
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
|
755
|
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
// Trigger button body (icons + summary + clear + trailing icons + chevron)
|
|
759
|
+
const triggerButton = (
|
|
760
|
+
<button
|
|
761
|
+
ref={_ref}
|
|
762
|
+
type="button"
|
|
763
|
+
disabled={disabledTrigger}
|
|
764
|
+
className={cn(
|
|
765
|
+
"flex w-full items-center justify-between rounded-md border border-input bg-background px-3 text-left shadow-xs",
|
|
766
|
+
"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring",
|
|
767
|
+
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
|
768
|
+
heightCls,
|
|
769
|
+
padCls,
|
|
770
|
+
hasControls &&
|
|
771
|
+
joinControls &&
|
|
772
|
+
extendBoxToControls &&
|
|
773
|
+
"border-none shadow-none focus-visible:ring-0 focus-visible:outline-none",
|
|
774
|
+
triggerClassName
|
|
775
|
+
)}
|
|
776
|
+
>
|
|
777
|
+
<div className="flex w-full items-center justify-between gap-2">
|
|
778
|
+
{/* Left side: leading icons + summary */}
|
|
779
|
+
<div className="flex min-w-0 items-center grow gap-2">
|
|
780
|
+
{hasLeadingIcons && (
|
|
781
|
+
<span
|
|
782
|
+
className="flex items-center gap-1 shrink-0"
|
|
783
|
+
style={{ columnGap: leadingGap }}
|
|
784
|
+
data-slot="leading-icons"
|
|
785
|
+
>
|
|
786
|
+
{resolvedLeadingIcons.map((node, idx) => (
|
|
787
|
+
<span
|
|
788
|
+
key={idx}
|
|
789
|
+
className="flex items-center justify-center"
|
|
790
|
+
>
|
|
791
|
+
{node}
|
|
792
|
+
</span>
|
|
793
|
+
))}
|
|
794
|
+
</span>
|
|
795
|
+
)}
|
|
796
|
+
|
|
797
|
+
<div className="min-w-0 flex-1">
|
|
798
|
+
{triggerSummary}
|
|
799
|
+
</div>
|
|
800
|
+
</div>
|
|
801
|
+
|
|
802
|
+
{/* Right side: clear + trailing icons + chevron */}
|
|
803
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
804
|
+
{showClear && (
|
|
805
|
+
<button
|
|
806
|
+
type="button"
|
|
807
|
+
aria-label="Clear selection"
|
|
808
|
+
onClick={(e) => {
|
|
809
|
+
e.stopPropagation();
|
|
810
|
+
e.preventDefault();
|
|
811
|
+
handleClearAll();
|
|
812
|
+
}}
|
|
813
|
+
className="flex h-4 w-4 items-center justify-center rounded hover:bg-muted"
|
|
814
|
+
data-slot="clear"
|
|
815
|
+
>
|
|
816
|
+
<X className="h-3 w-3 pointer-events-none" />
|
|
817
|
+
</button>
|
|
818
|
+
)}
|
|
819
|
+
|
|
820
|
+
{hasTrailingIcons && (
|
|
821
|
+
<span
|
|
822
|
+
className="flex items-center gap-1"
|
|
823
|
+
style={{ columnGap: trailingGap }}
|
|
824
|
+
data-slot="trailing-icons"
|
|
825
|
+
>
|
|
826
|
+
{resolvedTrailingIcons.map((node, idx) => (
|
|
827
|
+
<span
|
|
828
|
+
key={idx}
|
|
829
|
+
className="flex items-center justify-center"
|
|
830
|
+
>
|
|
831
|
+
{node}
|
|
832
|
+
</span>
|
|
833
|
+
))}
|
|
834
|
+
</span>
|
|
835
|
+
)}
|
|
836
|
+
|
|
837
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
838
|
+
</div>
|
|
839
|
+
</div>
|
|
840
|
+
</button>
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
// Core multi-select element (Popover + list)
|
|
844
|
+
const MultiSelectCore = (
|
|
845
|
+
<Popover
|
|
846
|
+
open={open && !disabledTrigger}
|
|
847
|
+
onOpenChange={(next) => {
|
|
848
|
+
if (disabledTrigger) return;
|
|
849
|
+
setOpen(next);
|
|
850
|
+
if (!next) setQuery("");
|
|
851
|
+
}}
|
|
852
|
+
>
|
|
853
|
+
<PopoverTrigger asChild>
|
|
854
|
+
{triggerButton}
|
|
855
|
+
</PopoverTrigger>
|
|
856
|
+
|
|
857
|
+
<PopoverContent
|
|
858
|
+
className={cn(
|
|
859
|
+
"w-(--radix-popover-trigger-width) p-0",
|
|
860
|
+
contentClassName
|
|
861
|
+
)}
|
|
862
|
+
align="start"
|
|
863
|
+
>
|
|
864
|
+
{/* Search bar */}
|
|
865
|
+
{searchable && (
|
|
866
|
+
<div className="p-2 border-b border-border">
|
|
867
|
+
<Input
|
|
868
|
+
autoFocus
|
|
869
|
+
icon={<Search className="size-4" />}
|
|
870
|
+
value={query}
|
|
871
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
872
|
+
placeholder={
|
|
873
|
+
searchPlaceholder ?? "Search options…"
|
|
874
|
+
}
|
|
875
|
+
size={size}
|
|
876
|
+
density={density}
|
|
877
|
+
/>
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
880
|
+
|
|
881
|
+
<div
|
|
882
|
+
className="py-1 overflow-auto"
|
|
883
|
+
style={{ maxHeight: maxListHeight }}
|
|
884
|
+
>
|
|
885
|
+
{/* Optional "Select all" at top */}
|
|
886
|
+
{showSelectAll &&
|
|
887
|
+
selectAllPosition === "top" && (
|
|
888
|
+
<button
|
|
889
|
+
type="button"
|
|
890
|
+
className={cn(
|
|
891
|
+
"flex w-full items-center px-2 py-1.5 text-sm",
|
|
892
|
+
"hover:bg-muted/70",
|
|
893
|
+
"disabled:cursor-not-allowed disabled:opacity-50"
|
|
894
|
+
)}
|
|
895
|
+
onClick={handleSelectAll}
|
|
896
|
+
>
|
|
897
|
+
{makeCheckboxNode({
|
|
898
|
+
item: null,
|
|
899
|
+
selected: allSelected,
|
|
900
|
+
indeterminate: someSelected,
|
|
901
|
+
isSelectAll: true,
|
|
902
|
+
})}
|
|
903
|
+
<span className="truncate">
|
|
904
|
+
{selectAllLabel ?? "Select all"}
|
|
905
|
+
</span>
|
|
906
|
+
</button>
|
|
907
|
+
)}
|
|
908
|
+
|
|
909
|
+
{/* Options */}
|
|
910
|
+
{filteredItems.length === 0 ? (
|
|
911
|
+
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
|
912
|
+
{emptySearchText ?? "No results found"}
|
|
913
|
+
</div>
|
|
914
|
+
) : (
|
|
915
|
+
filteredItems.map((item, index) => {
|
|
916
|
+
const selected = selectedValues.has(
|
|
917
|
+
item.value
|
|
918
|
+
);
|
|
919
|
+
|
|
920
|
+
const row = (
|
|
921
|
+
<button
|
|
922
|
+
key={item.key}
|
|
923
|
+
type="button"
|
|
924
|
+
className={cn(
|
|
925
|
+
"flex w-full items-start gap-2 px-2 py-1.5 text-sm",
|
|
926
|
+
"hover:bg-muted/70",
|
|
927
|
+
item.disabled &&
|
|
928
|
+
"opacity-50 cursor-not-allowed"
|
|
929
|
+
)}
|
|
930
|
+
onClick={() => {
|
|
931
|
+
if (item.disabled) return;
|
|
932
|
+
handleToggleValue(item.value);
|
|
933
|
+
}}
|
|
934
|
+
>
|
|
935
|
+
{makeCheckboxNode({
|
|
936
|
+
item,
|
|
937
|
+
selected,
|
|
938
|
+
indeterminate: false,
|
|
939
|
+
isSelectAll: false,
|
|
940
|
+
})}
|
|
941
|
+
|
|
942
|
+
<div className="flex flex-1 items-start gap-2">
|
|
943
|
+
{item.icon && (
|
|
944
|
+
<span className="mt-0.5 shrink-0">
|
|
945
|
+
{item.icon}
|
|
946
|
+
</span>
|
|
947
|
+
)}
|
|
948
|
+
<div className="flex flex-col">
|
|
949
|
+
<span>{item.labelNode}</span>
|
|
950
|
+
{item.description && (
|
|
951
|
+
<span className="text-xs text-muted-foreground">
|
|
952
|
+
{item.description}
|
|
953
|
+
</span>
|
|
954
|
+
)}
|
|
955
|
+
</div>
|
|
956
|
+
</div>
|
|
957
|
+
</button>
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
if (!renderOption) return row;
|
|
961
|
+
|
|
962
|
+
return renderOption({
|
|
963
|
+
item,
|
|
964
|
+
selected,
|
|
965
|
+
index,
|
|
966
|
+
option: row,
|
|
967
|
+
});
|
|
968
|
+
})
|
|
969
|
+
)}
|
|
970
|
+
|
|
971
|
+
{/* Optional "Select all" at bottom */}
|
|
972
|
+
{showSelectAll &&
|
|
973
|
+
selectAllPosition === "bottom" && (
|
|
974
|
+
<button
|
|
975
|
+
type="button"
|
|
976
|
+
className={cn(
|
|
977
|
+
"mt-1 flex w-full items-center px-2 py-1.5 text-sm border-t border-border",
|
|
978
|
+
"hover:bg-muted/70",
|
|
979
|
+
"disabled:cursor-not-allowed disabled:opacity-50"
|
|
980
|
+
)}
|
|
981
|
+
onClick={handleSelectAll}
|
|
982
|
+
>
|
|
983
|
+
{makeCheckboxNode({
|
|
984
|
+
item: null,
|
|
985
|
+
selected: allSelected,
|
|
986
|
+
indeterminate: someSelected,
|
|
987
|
+
isSelectAll: true,
|
|
988
|
+
})}
|
|
989
|
+
<span className="truncate">
|
|
990
|
+
{selectAllLabel ?? "Select all"}
|
|
991
|
+
</span>
|
|
992
|
+
</button>
|
|
993
|
+
)}
|
|
994
|
+
</div>
|
|
995
|
+
</PopoverContent>
|
|
996
|
+
</Popover>
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
// ─────────────────────────────────────────────
|
|
1000
|
+
// Layout modes (mirroring select variant)
|
|
1001
|
+
// ─────────────────────────────────────────────
|
|
1002
|
+
|
|
1003
|
+
// CASE 1: no controls → just the multi-select
|
|
1004
|
+
if (!hasControls) {
|
|
1005
|
+
return (
|
|
1006
|
+
<div
|
|
1007
|
+
data-slot="select-field"
|
|
1008
|
+
data-multi="true"
|
|
1009
|
+
className={cn(
|
|
1010
|
+
"w-full",
|
|
1011
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
1012
|
+
className
|
|
1013
|
+
)}
|
|
1014
|
+
aria-disabled={disabled || undefined}
|
|
1015
|
+
aria-invalid={error ? "true" : undefined}
|
|
1016
|
+
>
|
|
1017
|
+
{MultiSelectCore}
|
|
1018
|
+
</div>
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// CASE 2: controls + joinControls → shared single box
|
|
1023
|
+
if (joinControls) {
|
|
1024
|
+
const groupClassName = cn(
|
|
1025
|
+
"flex items-stretch w-full",
|
|
1026
|
+
extendBoxToControls &&
|
|
1027
|
+
cn(
|
|
1028
|
+
"relative",
|
|
1029
|
+
baseBoxClasses // ring via :focus-within
|
|
1030
|
+
),
|
|
1031
|
+
!extendBoxToControls &&
|
|
1032
|
+
"relative border-none shadow-none bg-transparent",
|
|
1033
|
+
className
|
|
1034
|
+
);
|
|
1035
|
+
|
|
1036
|
+
return (
|
|
1037
|
+
<div
|
|
1038
|
+
data-slot="select-field"
|
|
1039
|
+
data-multi="true"
|
|
1040
|
+
className="w-full"
|
|
1041
|
+
aria-disabled={disabled || undefined}
|
|
1042
|
+
aria-invalid={error ? "true" : undefined}
|
|
1043
|
+
>
|
|
1044
|
+
<div
|
|
1045
|
+
className={groupClassName}
|
|
1046
|
+
data-slot="select-group"
|
|
1047
|
+
data-disabled={disabled ? "true" : "false"}
|
|
1048
|
+
>
|
|
1049
|
+
{hasLeadingControl && (
|
|
1050
|
+
<div
|
|
1051
|
+
className={cn(
|
|
1052
|
+
"flex items-center px-2",
|
|
1053
|
+
leadingControlClassName
|
|
1054
|
+
)}
|
|
1055
|
+
data-slot="leading-control"
|
|
1056
|
+
>
|
|
1057
|
+
{leadingControl}
|
|
1058
|
+
</div>
|
|
1059
|
+
)}
|
|
1060
|
+
|
|
1061
|
+
<div
|
|
1062
|
+
className={cn(
|
|
1063
|
+
"flex-1 min-w-0 flex items-stretch"
|
|
1064
|
+
)}
|
|
1065
|
+
data-slot="select-region"
|
|
1066
|
+
>
|
|
1067
|
+
{MultiSelectCore}
|
|
1068
|
+
</div>
|
|
1069
|
+
|
|
1070
|
+
{hasTrailingControl && (
|
|
1071
|
+
<div
|
|
1072
|
+
className={cn(
|
|
1073
|
+
"flex items-center px-2",
|
|
1074
|
+
trailingControlClassName
|
|
1075
|
+
)}
|
|
1076
|
+
data-slot="trailing-control"
|
|
1077
|
+
>
|
|
1078
|
+
{trailingControl}
|
|
1079
|
+
</div>
|
|
1080
|
+
)}
|
|
1081
|
+
</div>
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// CASE 3: controls present, but separate (no joined box)
|
|
1087
|
+
return (
|
|
1088
|
+
<div
|
|
1089
|
+
data-slot="select-field"
|
|
1090
|
+
data-multi="true"
|
|
1091
|
+
className={cn(
|
|
1092
|
+
"flex items-stretch w-full",
|
|
1093
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
1094
|
+
className
|
|
1095
|
+
)}
|
|
1096
|
+
aria-disabled={disabled || undefined}
|
|
1097
|
+
aria-invalid={error ? "true" : undefined}
|
|
1098
|
+
>
|
|
1099
|
+
{hasLeadingControl && (
|
|
1100
|
+
<div
|
|
1101
|
+
className={cn(
|
|
1102
|
+
"flex items-center mr-1",
|
|
1103
|
+
leadingControlClassName
|
|
1104
|
+
)}
|
|
1105
|
+
data-slot="leading-control"
|
|
1106
|
+
>
|
|
1107
|
+
{leadingControl}
|
|
1108
|
+
</div>
|
|
1109
|
+
)}
|
|
1110
|
+
|
|
1111
|
+
<div className="flex-1 min-w-0" data-slot="select-region">
|
|
1112
|
+
{MultiSelectCore}
|
|
1113
|
+
</div>
|
|
1114
|
+
|
|
1115
|
+
{hasTrailingControl && (
|
|
1116
|
+
<div
|
|
1117
|
+
className={cn(
|
|
1118
|
+
"flex items-center ml-1",
|
|
1119
|
+
trailingControlClassName
|
|
1120
|
+
)}
|
|
1121
|
+
data-slot="trailing-control"
|
|
1122
|
+
>
|
|
1123
|
+
{trailingControl}
|
|
1124
|
+
</div>
|
|
1125
|
+
)}
|
|
1126
|
+
</div>
|
|
1127
|
+
);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
ShadcnMultiSelectVariant.displayName = "ShadcnMultiSelectVariant";
|
|
1131
|
+
|
|
1132
|
+
export default ShadcnMultiSelectVariant;
|