@timeax/form-palette 0.0.3 → 0.0.5

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.
Files changed (120) hide show
  1. package/{src/schema/adapter.ts → dist/adapters.d.mts} +118 -43
  2. package/dist/adapters.d.ts +292 -0
  3. package/dist/adapters.js +13283 -0
  4. package/dist/adapters.js.map +1 -0
  5. package/dist/adapters.mjs +13269 -0
  6. package/dist/adapters.mjs.map +1 -0
  7. package/dist/index.d.mts +3744 -0
  8. package/dist/index.d.ts +3744 -0
  9. package/dist/index.js +43014 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/index.mjs +42965 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/package.json +22 -7
  14. package/.scaffold-cache.json +0 -537
  15. package/src/.scaffold-cache.json +0 -544
  16. package/src/adapters/axios.ts +0 -117
  17. package/src/adapters/index.ts +0 -91
  18. package/src/adapters/inertia.ts +0 -187
  19. package/src/core/adapter-registry.ts +0 -87
  20. package/src/core/bound/bind-host.ts +0 -14
  21. package/src/core/bound/observe-bound-field.ts +0 -172
  22. package/src/core/bound/wait-for-bound-field.ts +0 -57
  23. package/src/core/context.ts +0 -23
  24. package/src/core/core-provider.tsx +0 -818
  25. package/src/core/core-root.tsx +0 -72
  26. package/src/core/core-shell.tsx +0 -44
  27. package/src/core/errors/error-strip.tsx +0 -71
  28. package/src/core/errors/index.ts +0 -2
  29. package/src/core/errors/map-error-bag.ts +0 -51
  30. package/src/core/errors/map-zod.ts +0 -39
  31. package/src/core/hooks/use-button.ts +0 -220
  32. package/src/core/hooks/use-core-context.ts +0 -20
  33. package/src/core/hooks/use-core-utility.ts +0 -0
  34. package/src/core/hooks/use-core.ts +0 -13
  35. package/src/core/hooks/use-field.ts +0 -497
  36. package/src/core/hooks/use-optional-field.ts +0 -28
  37. package/src/core/index.ts +0 -0
  38. package/src/core/registry/binder-registry.ts +0 -82
  39. package/src/core/registry/field-registry.ts +0 -187
  40. package/src/core/test.tsx +0 -17
  41. package/src/global.d.ts +0 -14
  42. package/src/index.ts +0 -68
  43. package/src/input/index.ts +0 -4
  44. package/src/input/input-field.tsx +0 -854
  45. package/src/input/input-layout-graph.ts +0 -230
  46. package/src/input/input-props.ts +0 -190
  47. package/src/lib/get-global-countries.ts +0 -87
  48. package/src/lib/utils.ts +0 -6
  49. package/src/presets/index.ts +0 -0
  50. package/src/presets/shadcn-preset.ts +0 -0
  51. package/src/presets/shadcn-variants/checkbox.tsx +0 -849
  52. package/src/presets/shadcn-variants/chips.tsx +0 -756
  53. package/src/presets/shadcn-variants/color.tsx +0 -284
  54. package/src/presets/shadcn-variants/custom.tsx +0 -227
  55. package/src/presets/shadcn-variants/date.tsx +0 -796
  56. package/src/presets/shadcn-variants/file.tsx +0 -764
  57. package/src/presets/shadcn-variants/keyvalue.tsx +0 -556
  58. package/src/presets/shadcn-variants/multiselect.tsx +0 -1132
  59. package/src/presets/shadcn-variants/number.tsx +0 -176
  60. package/src/presets/shadcn-variants/password.tsx +0 -737
  61. package/src/presets/shadcn-variants/phone.tsx +0 -628
  62. package/src/presets/shadcn-variants/radio.tsx +0 -578
  63. package/src/presets/shadcn-variants/select.tsx +0 -956
  64. package/src/presets/shadcn-variants/slider.tsx +0 -622
  65. package/src/presets/shadcn-variants/text.tsx +0 -343
  66. package/src/presets/shadcn-variants/textarea.tsx +0 -66
  67. package/src/presets/shadcn-variants/toggle.tsx +0 -218
  68. package/src/presets/shadcn-variants/treeselect.tsx +0 -784
  69. package/src/presets/ui/badge.tsx +0 -46
  70. package/src/presets/ui/button.tsx +0 -60
  71. package/src/presets/ui/calendar.tsx +0 -214
  72. package/src/presets/ui/checkbox.tsx +0 -115
  73. package/src/presets/ui/custom.tsx +0 -0
  74. package/src/presets/ui/dialog.tsx +0 -141
  75. package/src/presets/ui/field.tsx +0 -246
  76. package/src/presets/ui/input-mask.tsx +0 -739
  77. package/src/presets/ui/input-otp.tsx +0 -77
  78. package/src/presets/ui/input.tsx +0 -1011
  79. package/src/presets/ui/label.tsx +0 -22
  80. package/src/presets/ui/number.tsx +0 -1370
  81. package/src/presets/ui/popover.tsx +0 -46
  82. package/src/presets/ui/radio-group.tsx +0 -43
  83. package/src/presets/ui/scroll-area.tsx +0 -56
  84. package/src/presets/ui/select.tsx +0 -190
  85. package/src/presets/ui/separator.tsx +0 -28
  86. package/src/presets/ui/slider.tsx +0 -61
  87. package/src/presets/ui/switch.tsx +0 -32
  88. package/src/presets/ui/textarea.tsx +0 -634
  89. package/src/presets/ui/time-dropdowns.tsx +0 -350
  90. package/src/schema/core.ts +0 -429
  91. package/src/schema/field-map.ts +0 -0
  92. package/src/schema/field.ts +0 -224
  93. package/src/schema/index.ts +0 -0
  94. package/src/schema/input-field.ts +0 -260
  95. package/src/schema/presets.ts +0 -0
  96. package/src/schema/variant.ts +0 -216
  97. package/src/variants/core/checkbox.tsx +0 -54
  98. package/src/variants/core/chips.tsx +0 -22
  99. package/src/variants/core/color.tsx +0 -16
  100. package/src/variants/core/custom.tsx +0 -18
  101. package/src/variants/core/date.tsx +0 -25
  102. package/src/variants/core/file.tsx +0 -9
  103. package/src/variants/core/keyvalue.tsx +0 -12
  104. package/src/variants/core/multiselect.tsx +0 -28
  105. package/src/variants/core/number.tsx +0 -115
  106. package/src/variants/core/password.tsx +0 -35
  107. package/src/variants/core/phone.tsx +0 -16
  108. package/src/variants/core/radio.tsx +0 -38
  109. package/src/variants/core/select.tsx +0 -15
  110. package/src/variants/core/slider.tsx +0 -55
  111. package/src/variants/core/text.tsx +0 -114
  112. package/src/variants/core/textarea.tsx +0 -22
  113. package/src/variants/core/toggle.tsx +0 -50
  114. package/src/variants/core/treeselect.tsx +0 -11
  115. package/src/variants/helpers/selection-summary.tsx +0 -236
  116. package/src/variants/index.ts +0 -75
  117. package/src/variants/registry.ts +0 -38
  118. package/src/variants/select-shared.ts +0 -0
  119. package/src/variants/shared.ts +0 -126
  120. package/tsconfig.json +0 -14
@@ -1,956 +0,0 @@
1
- import * as React from "react";
2
- import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
3
- import { cn } from "@/lib/utils";
4
- import {
5
- Select,
6
- SelectTrigger,
7
- SelectContent,
8
- SelectItem,
9
- } from "@/presets/ui/select";
10
- import { Input } from "@/presets/ui/input";
11
- import { Search, X } from "lucide-react";
12
-
13
- type SelectPrimitive = string | number;
14
-
15
- type Size = "sm" | "md" | "lg";
16
- type Density = "compact" | "comfortable" | "loose";
17
-
18
- export type SelectOption =
19
- | SelectPrimitive
20
- | {
21
- label?: React.ReactNode;
22
- value?: SelectPrimitive;
23
- description?: React.ReactNode;
24
- disabled?: boolean;
25
- [key: string]: any;
26
- };
27
-
28
- type NormalizedSelectItem = {
29
- key: string;
30
- value: SelectPrimitive;
31
- labelNode: React.ReactNode;
32
- labelText: string;
33
- description?: React.ReactNode;
34
- disabled?: boolean;
35
- icon?: React.ReactNode;
36
- raw: SelectOption;
37
- };
38
-
39
- /**
40
- * Shadcn-based Select variant.
41
- */
42
- export interface ShadcnSelectVariantProps
43
- extends Pick<
44
- VariantBaseProps<SelectPrimitive | undefined>,
45
- "value" | "onValue" | "error" | "disabled" | "readOnly" | "size" | "density"
46
- > {
47
- /**
48
- * Options for the select.
49
- *
50
- * You can pass:
51
- * - primitives: ["ng", "gh", "ke"]
52
- * - objects: [{ label, value, ...extra }]
53
- */
54
- options?: SelectOption[];
55
-
56
- /**
57
- * Automatically capitalise the first letter of the label
58
- * (when the resolved label is a string).
59
- */
60
- autoCap?: boolean;
61
-
62
- /**
63
- * How to read the label from each option.
64
- *
65
- * - string → key on the option object
66
- * - function → custom mapper
67
- * - omitted → tries `label`, else String(value)
68
- */
69
- optionLabel?: string | ((item: SelectOption) => React.ReactNode);
70
-
71
- /**
72
- * How to read the value from each option.
73
- *
74
- * - string → key on the option object
75
- * - function → custom mapper
76
- * - omitted → uses `value`, or `id`, or `key`, or index
77
- */
78
- optionValue?: string | ((item: SelectOption) => SelectPrimitive);
79
-
80
- /**
81
- * Optional description line under the label.
82
- */
83
- optionDescription?: string | ((item: SelectOption) => React.ReactNode);
84
-
85
- /**
86
- * How to determine if an option is disabled.
87
- */
88
- optionDisabled?: string | ((item: SelectOption) => boolean);
89
-
90
- /**
91
- * How to extract an icon for each option.
92
- *
93
- * - string → key on the option object (default "icon")
94
- * - function → custom mapper
95
- */
96
- optionIcon?: string | ((item: SelectOption) => React.ReactNode);
97
-
98
- /**
99
- * How to compute the React key for each option.
100
- */
101
- optionKey?: string | ((item: SelectOption, index: number) => React.Key);
102
-
103
- /**
104
- * Enable inline search inside the dropdown.
105
- */
106
- searchable?: boolean;
107
-
108
- /**
109
- * Placeholder for the search input.
110
- */
111
- searchPlaceholder?: string;
112
-
113
- /**
114
- * Label shown when there are no options available at all.
115
- *
116
- * If omitted, falls back to `emptySearchText` or a default message.
117
- */
118
- emptyLabel?: React.ReactNode;
119
-
120
- /**
121
- * Text to show when search yields no results
122
- * (but there *are* options in general).
123
- */
124
- emptySearchText?: React.ReactNode;
125
-
126
- /**
127
- * Show a small clear button in the trigger when a value is selected.
128
- */
129
- clearable?: boolean;
130
-
131
- /**
132
- * Placeholder when nothing is selected.
133
- */
134
- placeholder?: React.ReactNode;
135
-
136
- /**
137
- * Wrapper class for the whole variant.
138
- */
139
- className?: string;
140
-
141
- /**
142
- * Extra classes for the SelectTrigger.
143
- */
144
- triggerClassName?: string;
145
-
146
- /**
147
- * Extra classes for the SelectContent popover.
148
- */
149
- contentClassName?: string;
150
-
151
- /**
152
- * Custom renderer for each option row.
153
- */
154
- renderOption?: (ctx: {
155
- item: NormalizedSelectItem;
156
- selected: boolean;
157
- index: number;
158
- option: React.ReactNode; // prebuilt <SelectItem> you can wrap
159
- }) => React.ReactNode;
160
-
161
- /**
162
- * Custom renderer for the trigger value.
163
- */
164
- renderValue?: (ctx: {
165
- selectedItem: NormalizedSelectItem | null;
166
- placeholder?: React.ReactNode;
167
- }) => React.ReactNode;
168
-
169
- // ─────────────────────────────────────────────
170
- // Icons & controls (mirror text variant concepts)
171
- // ─────────────────────────────────────────────
172
-
173
- /**
174
- * One or more icons displayed inside the trigger, on the left.
175
- *
176
- * If not provided and `icon` is set, that single icon
177
- * is treated as `leadingIcons[0]`.
178
- */
179
- leadingIcons?: React.ReactNode[];
180
-
181
- /**
182
- * Icons displayed on the right side of the trigger,
183
- * near the clear button / chevron area.
184
- */
185
- trailingIcons?: React.ReactNode[];
186
-
187
- /**
188
- * Convenience single-icon prop for the left side.
189
- */
190
- icon?: React.ReactNode;
191
-
192
- /**
193
- * Base gap between icons and text.
194
- * Defaults to 4px-ish via `gap-1`.
195
- */
196
- iconGap?: number;
197
-
198
- /**
199
- * Extra spacing to apply between leading icons and the text.
200
- */
201
- leadingIconSpacing?: number;
202
-
203
- /**
204
- * Extra spacing to apply between trailing icons and the clear button.
205
- */
206
- trailingIconSpacing?: number;
207
-
208
- /**
209
- * Arbitrary React node rendered before the select (e.g. a button).
210
- */
211
- leadingControl?: React.ReactNode;
212
-
213
- /**
214
- * Arbitrary React node rendered after the select (e.g. a button).
215
- */
216
- trailingControl?: React.ReactNode;
217
-
218
- /**
219
- * Extra classes for the leading control wrapper.
220
- */
221
- leadingControlClassName?: string;
222
-
223
- /**
224
- * Extra classes for the trailing control wrapper.
225
- */
226
- trailingControlClassName?: string;
227
-
228
- /**
229
- * If true and there are controls, the select trigger + controls share
230
- * a single visual box (borders, radius, focus states).
231
- */
232
- joinControls?: boolean;
233
-
234
- /**
235
- * When joinControls is true, whether the box styling extends over controls
236
- * (true) or controls are visually separate (false).
237
- */
238
- extendBoxToControls?: boolean;
239
-
240
- // ─────────────────────────────────────────────
241
- // Virtual-scroll-ish incremental rendering
242
- // ─────────────────────────────────────────────
243
-
244
- /**
245
- * Enable incremental rendering for large option lists.
246
- *
247
- * When true, only a page of options is rendered initially,
248
- * and more are revealed as the user scrolls down.
249
- */
250
- virtualScroll?: boolean;
251
-
252
- /**
253
- * Number of options to render per "page" when virtualScroll is enabled.
254
- * Default: 50.
255
- */
256
- virtualScrollPageSize?: number;
257
-
258
- /**
259
- * Distance from the bottom (in px) at which the next page loads.
260
- * Default: 48px.
261
- */
262
- virtualScrollThreshold?: number;
263
- }
264
-
265
- // ─────────────────────────────────────────────
266
- // Helpers
267
- // ─────────────────────────────────────────────
268
-
269
- function capitalizeFirst(label: string): string {
270
- if (!label) return label;
271
- return label.charAt(0).toUpperCase() + label.slice(1);
272
- }
273
-
274
- function normalizeOptions(
275
- opts: readonly SelectOption[] | undefined,
276
- config: Pick<
277
- ShadcnSelectVariantProps,
278
- | "autoCap"
279
- | "optionLabel"
280
- | "optionValue"
281
- | "optionDescription"
282
- | "optionDisabled"
283
- | "optionKey"
284
- | "optionIcon"
285
- >
286
- ): NormalizedSelectItem[] {
287
- if (!opts || !opts.length) return [];
288
-
289
- return opts.map((raw, index) => {
290
- const asObj: any =
291
- typeof raw === "string" || typeof raw === "number"
292
- ? { label: String(raw), value: raw }
293
- : raw;
294
-
295
- const value: SelectPrimitive =
296
- typeof config.optionValue === "function"
297
- ? config.optionValue(raw)
298
- : typeof config.optionValue === "string"
299
- ? (asObj[config.optionValue] as SelectPrimitive)
300
- : (asObj.value ??
301
- asObj.id ??
302
- asObj.key ??
303
- String(index));
304
-
305
- let labelNode: React.ReactNode =
306
- typeof config.optionLabel === "function"
307
- ? config.optionLabel(raw)
308
- : typeof config.optionLabel === "string"
309
- ? asObj[config.optionLabel] ?? asObj.label ?? String(value)
310
- : asObj.label ?? String(value);
311
-
312
- if (config.autoCap && typeof labelNode === "string") {
313
- labelNode = capitalizeFirst(labelNode);
314
- }
315
-
316
- const labelText =
317
- typeof labelNode === "string"
318
- ? labelNode
319
- : typeof labelNode === "number"
320
- ? String(labelNode)
321
- : asObj.labelText ?? String(value);
322
-
323
- const description: React.ReactNode =
324
- typeof config.optionDescription === "function"
325
- ? config.optionDescription(raw)
326
- : typeof config.optionDescription === "string"
327
- ? asObj[config.optionDescription]
328
- : asObj.description;
329
-
330
- const disabled: boolean =
331
- typeof config.optionDisabled === "function"
332
- ? config.optionDisabled(raw)
333
- : typeof config.optionDisabled === "string"
334
- ? !!asObj[config.optionDisabled]
335
- : !!asObj.disabled;
336
-
337
- const icon: React.ReactNode =
338
- typeof config.optionIcon === "function"
339
- ? config.optionIcon(raw)
340
- : typeof config.optionIcon === "string"
341
- ? asObj[config.optionIcon]
342
- : asObj.icon;
343
-
344
- const key: React.Key =
345
- typeof config.optionKey === "function"
346
- ? config.optionKey(raw, index)
347
- : typeof config.optionKey === "string"
348
- ? asObj[config.optionKey] ?? value ?? index
349
- : asObj.key ?? value ?? index;
350
-
351
- return {
352
- key: String(key),
353
- value,
354
- labelNode,
355
- labelText,
356
- description,
357
- disabled,
358
- icon,
359
- raw,
360
- };
361
- });
362
- }
363
-
364
- function triggerHeight(size?: Size) {
365
- switch (size) {
366
- case "sm":
367
- return "h-8 text-xs";
368
- case "lg":
369
- return "h-11 text-base";
370
- default:
371
- return "h-9 text-sm";
372
- }
373
- }
374
-
375
- function triggerPadding(density?: Density) {
376
- switch (density) {
377
- case "compact":
378
- return "py-1";
379
- case "loose":
380
- return "py-2";
381
- case "comfortable":
382
- default:
383
- return "py-1.5";
384
- }
385
- }
386
-
387
- // ─────────────────────────────────────────────
388
- // Component
389
- // ─────────────────────────────────────────────
390
-
391
- export const ShadcnSelectVariant = React.forwardRef<
392
- HTMLButtonElement,
393
- ShadcnSelectVariantProps
394
- >(function ShadcnSelectVariant(props, _ref) {
395
- const {
396
- value,
397
- onValue,
398
- error,
399
- disabled,
400
- readOnly,
401
- size,
402
- density,
403
-
404
- options,
405
-
406
- autoCap,
407
- optionLabel,
408
- optionValue,
409
- optionDescription,
410
- optionDisabled,
411
- optionIcon,
412
- optionKey,
413
-
414
- searchable,
415
- searchPlaceholder,
416
-
417
- emptyLabel,
418
- emptySearchText,
419
-
420
- clearable,
421
-
422
- placeholder,
423
-
424
- className,
425
- triggerClassName,
426
- contentClassName,
427
-
428
- renderOption,
429
- renderValue,
430
-
431
- // Icons & controls
432
- leadingIcons,
433
- trailingIcons,
434
- icon,
435
- iconGap,
436
- leadingIconSpacing,
437
- trailingIconSpacing,
438
- leadingControl,
439
- trailingControl,
440
- leadingControlClassName,
441
- trailingControlClassName,
442
- joinControls = true,
443
- extendBoxToControls = true,
444
-
445
- // Virtual scroll / incremental render
446
- virtualScroll = false,
447
- virtualScrollPageSize = 50,
448
- virtualScrollThreshold = 48,
449
- } = props;
450
-
451
- const [open, setOpen] = React.useState(false);
452
- const [query, setQuery] = React.useState("");
453
-
454
- const items = React.useMemo(
455
- () =>
456
- normalizeOptions(options ?? [], {
457
- autoCap,
458
- optionLabel,
459
- optionValue,
460
- optionDescription,
461
- optionDisabled,
462
- optionKey,
463
- optionIcon,
464
- }),
465
- [
466
- options,
467
- autoCap,
468
- optionLabel,
469
- optionValue,
470
- optionDescription,
471
- optionDisabled,
472
- optionKey,
473
- optionIcon,
474
- ]
475
- );
476
-
477
- const valueMap = React.useMemo(() => {
478
- const map = new Map<string, SelectPrimitive>();
479
- for (const item of items) {
480
- map.set(String(item.value), item.value);
481
- }
482
- return map;
483
- }, [items]);
484
-
485
- const selectedItem =
486
- value == null
487
- ? null
488
- : items.find((it) => it.value === value) ?? null;
489
-
490
- const filteredItems = React.useMemo(() => {
491
- if (!query) return items;
492
- const q = query.toLowerCase();
493
- return items.filter((it) =>
494
- it.labelText.toLowerCase().includes(q)
495
- );
496
- }, [items, query]);
497
-
498
- // ─────────────────────────────────────────────
499
- // Incremental render state
500
- // ─────────────────────────────────────────────
501
-
502
- const [visibleCount, setVisibleCount] = React.useState(() =>
503
- virtualScroll
504
- ? Math.min(virtualScrollPageSize, filteredItems.length)
505
- : filteredItems.length
506
- );
507
-
508
- const listRef = React.useRef<HTMLDivElement | null>(null);
509
-
510
- // Reset visibleCount when list / filter / toggle changes
511
- React.useEffect(() => {
512
- if (!virtualScroll) {
513
- setVisibleCount(filteredItems.length);
514
- return;
515
- }
516
-
517
- setVisibleCount(
518
- Math.min(virtualScrollPageSize, filteredItems.length)
519
- );
520
- }, [virtualScroll, filteredItems.length, virtualScrollPageSize]);
521
-
522
- const handleListScroll = React.useCallback(() => {
523
- if (!virtualScroll) return;
524
- const el = listRef.current;
525
- if (!el) return;
526
-
527
- const { scrollTop, scrollHeight, clientHeight } = el;
528
- const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
529
-
530
- if (distanceFromBottom <= virtualScrollThreshold) {
531
- setVisibleCount((prev) => {
532
- if (prev >= filteredItems.length) return prev;
533
- const next = prev + virtualScrollPageSize;
534
- return Math.min(next, filteredItems.length);
535
- });
536
- }
537
- }, [virtualScroll, filteredItems.length, virtualScrollPageSize, virtualScrollThreshold]);
538
-
539
- const renderedItems = React.useMemo(
540
- () =>
541
- virtualScroll
542
- ? filteredItems.slice(0, visibleCount)
543
- : filteredItems,
544
- [filteredItems, visibleCount, virtualScroll]
545
- );
546
-
547
- const handleChange = React.useCallback(
548
- (rawKey: string) => {
549
- if (!onValue) return;
550
-
551
- const primitive =
552
- valueMap.get(rawKey) ??
553
- (rawKey as unknown as SelectPrimitive);
554
-
555
- const item =
556
- items.find(
557
- (it) => String(it.value) === String(primitive)
558
- ) ?? null;
559
-
560
- const detail: ChangeDetail = {
561
- source: "variant",
562
- raw: item?.raw ?? primitive,
563
- nativeEvent: undefined,
564
- meta: undefined,
565
- };
566
-
567
- onValue(primitive, detail);
568
- },
569
- [onValue, valueMap, items]
570
- );
571
-
572
- const currentKey =
573
- selectedItem != null ? String(selectedItem.value) : "";
574
-
575
- const heightCls = triggerHeight(size as Size | undefined);
576
- const padCls = triggerPadding(density as Density | undefined);
577
-
578
- const showClear = clearable && value != null;
579
-
580
- // ─────────────────────────────────────────────
581
- // Icons setup (similar to text variant)
582
- // ─────────────────────────────────────────────
583
-
584
- const resolvedLeadingIcons: React.ReactNode[] = (() => {
585
- if (leadingIcons && leadingIcons.length) return leadingIcons;
586
- if (icon) return [icon];
587
- return [];
588
- })();
589
-
590
- const resolvedTrailingIcons: React.ReactNode[] = trailingIcons ?? [];
591
-
592
- const baseIconGap = iconGap ?? 4;
593
- const leadingGap = leadingIconSpacing ?? baseIconGap;
594
- const trailingGap = trailingIconSpacing ?? baseIconGap;
595
-
596
- const hasLeadingIcons = resolvedLeadingIcons.length > 0;
597
- const hasTrailingIcons = resolvedTrailingIcons.length > 0;
598
-
599
- const hasLeadingControl = !!leadingControl;
600
- const hasTrailingControl = !!trailingControl;
601
- const hasControls = hasLeadingControl || hasTrailingControl;
602
-
603
- const triggerInner = renderValue ? (
604
- renderValue({
605
- selectedItem,
606
- placeholder,
607
- })
608
- ) : selectedItem ? (
609
- <span className="truncate flex items-center gap-2">
610
- {selectedItem.icon && (
611
- <span className="shrink-0">
612
- {selectedItem.icon}
613
- </span>
614
- )}
615
- <span className="truncate">{selectedItem.labelNode}</span>
616
- </span>
617
- ) : (
618
- <span className="truncate text-muted-foreground">
619
- {placeholder ?? "Select..."}
620
- </span>
621
- );
622
-
623
- const baseBoxClasses = cn(
624
- "border-input w-full min-w-0 rounded-md border bg-transparent shadow-xs",
625
- "transition-[color,box-shadow] outline-none",
626
- "focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
627
- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
628
- );
629
-
630
- // Trigger content (inner layout: icons + label + clear + trailing icons)
631
- const TriggerBody = (
632
- <SelectTrigger
633
- onPointerDown={(e) => {
634
- if (e.target instanceof HTMLButtonElement) {
635
- if (
636
- e.target.getAttribute("data-slot") ==
637
- "clear"
638
- ) {
639
- e.preventDefault();
640
- e.stopPropagation();
641
- if (!onValue) return;
642
- const detail: ChangeDetail = {
643
- source: "variant",
644
- raw: undefined,
645
- nativeEvent: undefined,
646
- meta: { action: "clear" },
647
- };
648
- onValue(undefined, detail);
649
- }
650
- }
651
- }}
652
- className={cn(
653
- "w-full justify-between",
654
- heightCls,
655
- padCls,
656
- hasControls && joinControls && extendBoxToControls
657
- ? "border-none shadow-none focus:ring-0 focus:outline-none"
658
- : "",
659
- triggerClassName
660
- )}
661
- >
662
- <div className="flex w-full items-center justify-between gap-2">
663
- {/* Left side: leading icons + label */}
664
- <div className="flex min-w-0 items-center gap-2">
665
- {hasLeadingIcons && (
666
- <span
667
- className="flex items-center gap-1 shrink-0"
668
- style={{ columnGap: leadingGap }}
669
- data-slot="leading-icons"
670
- >
671
- {resolvedLeadingIcons.map((node, idx) => (
672
- <span
673
- key={idx}
674
- className="flex items-center justify-center"
675
- >
676
- {node}
677
- </span>
678
- ))}
679
- </span>
680
- )}
681
- <div className="min-w-0 flex-1">
682
- {triggerInner}
683
- </div>
684
- </div>
685
-
686
- {/* Right side: clear button + trailing icons */}
687
- <div className="flex items-center gap-1 shrink-0">
688
- {showClear && (
689
- <button
690
- data-slot={"clear"}
691
- type="button"
692
- aria-label="Clear selection"
693
- className="flex h-4 w-4 items-center justify-center rounded hover:bg-muted"
694
- >
695
- <X className="h-3 w-3 pointer-events-none" />
696
- </button>
697
- )}
698
-
699
- {hasTrailingIcons && (
700
- <span
701
- className="flex items-center gap-1"
702
- style={{ columnGap: trailingGap }}
703
- data-slot="trailing-icons"
704
- >
705
- {resolvedTrailingIcons.map((node, idx) => (
706
- <span
707
- key={idx}
708
- className="flex items-center justify-center"
709
- >
710
- {node}
711
- </span>
712
- ))}
713
- </span>
714
- )}
715
- </div>
716
- </div>
717
- </SelectTrigger>
718
- );
719
-
720
- const SelectWithTrigger = (
721
- <Select
722
- value={currentKey}
723
- onValueChange={handleChange}
724
- disabled={disabled || readOnly}
725
- open={open}
726
- onOpenChange={(nextOpen) => {
727
- setOpen(nextOpen);
728
- if (!nextOpen) setQuery("");
729
- }}
730
- >
731
- {TriggerBody}
732
-
733
- <SelectContent
734
- className={cn("min-w-[8rem]", contentClassName)}
735
- >
736
- {searchable && (
737
- <div className="p-1">
738
- <Input
739
- autoFocus
740
- icon={<Search className="size-4" />}
741
- value={query}
742
- onChange={(e) =>
743
- setQuery(e.target.value)
744
- }
745
- placeholder={
746
- searchPlaceholder ?? "Search..."
747
- }
748
- size={size}
749
- density={density}
750
- />
751
- </div>
752
- )}
753
-
754
- {/* CASE 1: no options at all */}
755
- {items.length === 0 ? (
756
- <div className="px-2 py-1.5 text-xs text-muted-foreground">
757
- {emptyLabel ??
758
- emptySearchText ??
759
- "No options available"}
760
- </div>
761
- ) : /* CASE 2: have options, but search filters everything out */ filteredItems.length === 0 ? (
762
- <div className="px-2 py-1.5 text-xs text-muted-foreground">
763
- {emptySearchText ?? "No results found"}
764
- </div>
765
- ) : (
766
- // CASE 3: normal list, possibly virtually paged
767
- <div
768
- ref={listRef}
769
- className="max-h-60 overflow-auto"
770
- onScroll={handleListScroll}
771
- >
772
- {renderedItems.map((item, index) => {
773
- const optionNode = (
774
- <SelectItem
775
- key={item.key}
776
- value={String(item.value)}
777
- disabled={item.disabled}
778
- >
779
- <div className="flex items-start gap-2">
780
- {item.icon && (
781
- <span className="mt-0.5 shrink-0">
782
- {item.icon}
783
- </span>
784
- )}
785
- <div className="flex flex-col">
786
- <span>{item.labelNode}</span>
787
- {item.description && (
788
- <span className="text-xs text-muted-foreground">
789
- {item.description}
790
- </span>
791
- )}
792
- </div>
793
- </div>
794
- </SelectItem>
795
- );
796
-
797
- if (!renderOption) return optionNode;
798
-
799
- return renderOption({
800
- item,
801
- selected:
802
- selectedItem != null &&
803
- String(selectedItem.value) ===
804
- String(item.value),
805
- index,
806
- option: optionNode,
807
- });
808
- })}
809
-
810
- {virtualScroll &&
811
- renderedItems.length <
812
- filteredItems.length && (
813
- <div className="px-2 py-1 text-[10px] text-muted-foreground text-center">
814
- Scroll to load more…
815
- </div>
816
- )}
817
- </div>
818
- )}
819
- </SelectContent>
820
- </Select>
821
- );
822
-
823
- // ─────────────────────────────────────────────
824
- // Layout modes:
825
- // - no controls
826
- // - controls + joinControls
827
- // - controls, separate boxes
828
- // ─────────────────────────────────────────────
829
-
830
- // CASE 1: no controls → just the select
831
- if (!hasControls) {
832
- return (
833
- <div
834
- data-slot="select-field"
835
- className={cn(
836
- "w-full",
837
- disabled && "opacity-50 cursor-not-allowed",
838
- className
839
- )}
840
- aria-disabled={disabled || undefined}
841
- aria-invalid={error ? "true" : undefined}
842
- >
843
- {SelectWithTrigger}
844
- </div>
845
- );
846
- }
847
-
848
- // CASE 2: controls + joinControls → share single box like text variant
849
- if (joinControls) {
850
- const groupClassName = cn(
851
- "flex items-stretch w-full",
852
- extendBoxToControls &&
853
- cn(
854
- "relative",
855
- baseBoxClasses // ring via :focus-within
856
- ),
857
- !extendBoxToControls &&
858
- "relative border-none shadow-none bg-transparent",
859
- className
860
- );
861
-
862
- return (
863
- <div
864
- data-slot="select-field"
865
- className="w-full"
866
- aria-disabled={disabled || undefined}
867
- aria-invalid={error ? "true" : undefined}
868
- >
869
- <div
870
- className={groupClassName}
871
- data-slot="select-group"
872
- data-disabled={disabled ? "true" : "false"}
873
- >
874
- {hasLeadingControl && (
875
- <div
876
- className={cn(
877
- "flex items-center px-2",
878
- leadingControlClassName
879
- )}
880
- data-slot="leading-control"
881
- >
882
- {leadingControl}
883
- </div>
884
- )}
885
-
886
- <div
887
- className={cn(
888
- "flex-1 min-w-0 flex items-stretch"
889
- )}
890
- data-slot="select-region"
891
- >
892
- {SelectWithTrigger}
893
- </div>
894
-
895
- {hasTrailingControl && (
896
- <div
897
- className={cn(
898
- "flex items-center px-2",
899
- trailingControlClassName
900
- )}
901
- data-slot="trailing-control"
902
- >
903
- {trailingControl}
904
- </div>
905
- )}
906
- </div>
907
- </div>
908
- );
909
- }
910
-
911
- // CASE 3: controls present, but separate (no joined box)
912
- return (
913
- <div
914
- data-slot="select-field"
915
- className={cn(
916
- "flex items-stretch w-full",
917
- disabled && "opacity-50 cursor-not-allowed",
918
- className
919
- )}
920
- aria-disabled={disabled || undefined}
921
- aria-invalid={error ? "true" : undefined}
922
- >
923
- {hasLeadingControl && (
924
- <div
925
- className={cn(
926
- "flex items-center mr-1",
927
- leadingControlClassName
928
- )}
929
- data-slot="leading-control"
930
- >
931
- {leadingControl}
932
- </div>
933
- )}
934
-
935
- <div className="flex-1 min-w-0">
936
- {SelectWithTrigger}
937
- </div>
938
-
939
- {hasTrailingControl && (
940
- <div
941
- className={cn(
942
- "flex items-center ml-1",
943
- trailingControlClassName
944
- )}
945
- data-slot="trailing-control"
946
- >
947
- {trailingControl}
948
- </div>
949
- )}
950
- </div>
951
- );
952
- });
953
-
954
- ShadcnSelectVariant.displayName = "ShadcnSelectVariant";
955
-
956
- export default ShadcnSelectVariant;