@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.
Files changed (109) hide show
  1. package/.scaffold-cache.json +537 -0
  2. package/package.json +42 -0
  3. package/src/.scaffold-cache.json +544 -0
  4. package/src/adapters/axios.ts +117 -0
  5. package/src/adapters/index.ts +91 -0
  6. package/src/adapters/inertia.ts +187 -0
  7. package/src/core/adapter-registry.ts +87 -0
  8. package/src/core/bound/bind-host.ts +14 -0
  9. package/src/core/bound/observe-bound-field.ts +172 -0
  10. package/src/core/bound/wait-for-bound-field.ts +57 -0
  11. package/src/core/context.ts +23 -0
  12. package/src/core/core-provider.tsx +818 -0
  13. package/src/core/core-root.tsx +72 -0
  14. package/src/core/core-shell.tsx +44 -0
  15. package/src/core/errors/error-strip.tsx +71 -0
  16. package/src/core/errors/index.ts +2 -0
  17. package/src/core/errors/map-error-bag.ts +51 -0
  18. package/src/core/errors/map-zod.ts +39 -0
  19. package/src/core/hooks/use-button.ts +220 -0
  20. package/src/core/hooks/use-core-context.ts +20 -0
  21. package/src/core/hooks/use-core-utility.ts +0 -0
  22. package/src/core/hooks/use-core.ts +13 -0
  23. package/src/core/hooks/use-field.ts +497 -0
  24. package/src/core/hooks/use-optional-field.ts +28 -0
  25. package/src/core/index.ts +0 -0
  26. package/src/core/registry/binder-registry.ts +82 -0
  27. package/src/core/registry/field-registry.ts +187 -0
  28. package/src/core/test.tsx +17 -0
  29. package/src/global.d.ts +14 -0
  30. package/src/index.ts +68 -0
  31. package/src/input/index.ts +4 -0
  32. package/src/input/input-field.tsx +854 -0
  33. package/src/input/input-layout-graph.ts +230 -0
  34. package/src/input/input-props.ts +190 -0
  35. package/src/lib/get-global-countries.ts +87 -0
  36. package/src/lib/utils.ts +6 -0
  37. package/src/presets/index.ts +0 -0
  38. package/src/presets/shadcn-preset.ts +0 -0
  39. package/src/presets/shadcn-variants/checkbox.tsx +849 -0
  40. package/src/presets/shadcn-variants/chips.tsx +756 -0
  41. package/src/presets/shadcn-variants/color.tsx +284 -0
  42. package/src/presets/shadcn-variants/custom.tsx +227 -0
  43. package/src/presets/shadcn-variants/date.tsx +796 -0
  44. package/src/presets/shadcn-variants/file.tsx +764 -0
  45. package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
  46. package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
  47. package/src/presets/shadcn-variants/number.tsx +176 -0
  48. package/src/presets/shadcn-variants/password.tsx +737 -0
  49. package/src/presets/shadcn-variants/phone.tsx +628 -0
  50. package/src/presets/shadcn-variants/radio.tsx +578 -0
  51. package/src/presets/shadcn-variants/select.tsx +956 -0
  52. package/src/presets/shadcn-variants/slider.tsx +622 -0
  53. package/src/presets/shadcn-variants/text.tsx +343 -0
  54. package/src/presets/shadcn-variants/textarea.tsx +66 -0
  55. package/src/presets/shadcn-variants/toggle.tsx +218 -0
  56. package/src/presets/shadcn-variants/treeselect.tsx +784 -0
  57. package/src/presets/ui/badge.tsx +46 -0
  58. package/src/presets/ui/button.tsx +60 -0
  59. package/src/presets/ui/calendar.tsx +214 -0
  60. package/src/presets/ui/checkbox.tsx +115 -0
  61. package/src/presets/ui/custom.tsx +0 -0
  62. package/src/presets/ui/dialog.tsx +141 -0
  63. package/src/presets/ui/field.tsx +246 -0
  64. package/src/presets/ui/input-mask.tsx +739 -0
  65. package/src/presets/ui/input-otp.tsx +77 -0
  66. package/src/presets/ui/input.tsx +1011 -0
  67. package/src/presets/ui/label.tsx +22 -0
  68. package/src/presets/ui/number.tsx +1370 -0
  69. package/src/presets/ui/popover.tsx +46 -0
  70. package/src/presets/ui/radio-group.tsx +43 -0
  71. package/src/presets/ui/scroll-area.tsx +56 -0
  72. package/src/presets/ui/select.tsx +190 -0
  73. package/src/presets/ui/separator.tsx +28 -0
  74. package/src/presets/ui/slider.tsx +61 -0
  75. package/src/presets/ui/switch.tsx +32 -0
  76. package/src/presets/ui/textarea.tsx +634 -0
  77. package/src/presets/ui/time-dropdowns.tsx +350 -0
  78. package/src/schema/adapter.ts +217 -0
  79. package/src/schema/core.ts +429 -0
  80. package/src/schema/field-map.ts +0 -0
  81. package/src/schema/field.ts +224 -0
  82. package/src/schema/index.ts +0 -0
  83. package/src/schema/input-field.ts +260 -0
  84. package/src/schema/presets.ts +0 -0
  85. package/src/schema/variant.ts +216 -0
  86. package/src/variants/core/checkbox.tsx +54 -0
  87. package/src/variants/core/chips.tsx +22 -0
  88. package/src/variants/core/color.tsx +16 -0
  89. package/src/variants/core/custom.tsx +18 -0
  90. package/src/variants/core/date.tsx +25 -0
  91. package/src/variants/core/file.tsx +9 -0
  92. package/src/variants/core/keyvalue.tsx +12 -0
  93. package/src/variants/core/multiselect.tsx +28 -0
  94. package/src/variants/core/number.tsx +115 -0
  95. package/src/variants/core/password.tsx +35 -0
  96. package/src/variants/core/phone.tsx +16 -0
  97. package/src/variants/core/radio.tsx +38 -0
  98. package/src/variants/core/select.tsx +15 -0
  99. package/src/variants/core/slider.tsx +55 -0
  100. package/src/variants/core/text.tsx +114 -0
  101. package/src/variants/core/textarea.tsx +22 -0
  102. package/src/variants/core/toggle.tsx +50 -0
  103. package/src/variants/core/treeselect.tsx +11 -0
  104. package/src/variants/helpers/selection-summary.tsx +236 -0
  105. package/src/variants/index.ts +75 -0
  106. package/src/variants/registry.ts +38 -0
  107. package/src/variants/select-shared.ts +0 -0
  108. package/src/variants/shared.ts +126 -0
  109. package/tsconfig.json +14 -0
@@ -0,0 +1,756 @@
1
+ // src/presets/shadcn-variants/chips.tsx
2
+
3
+ import * as React from "react";
4
+
5
+ import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
6
+ import type { ShadcnTextVariantProps } from "@/presets/shadcn-variants/text";
7
+ import { Input } from "@/presets/ui/input";
8
+ import { Textarea } from "@/presets/ui/textarea";
9
+ import { cn } from "@/lib/utils";
10
+ import { X } from "lucide-react";
11
+
12
+ type ChipsValue = string[] | undefined;
13
+ type BaseProps = VariantBaseProps<ChipsValue>;
14
+
15
+ /**
16
+ * How we split text into chips when committing.
17
+ */
18
+ export type ChipsSeparator =
19
+ | string
20
+ | RegExp
21
+ | (string | RegExp)[];
22
+
23
+ /**
24
+ * Placement of chips relative to the entry control.
25
+ *
26
+ * - "inline" → inside the same visual box (Input) or in the textarea toolbox.
27
+ * - "below" → chips rendered as a block underneath the field.
28
+ */
29
+ export type ChipsPlacement = "inline" | "below";
30
+
31
+ /**
32
+ * Actions reported via ChangeDetail.meta.
33
+ */
34
+ export type ChipsChangeAction = "add" | "remove" | "clear";
35
+
36
+ /**
37
+ * Extra metadata sent with onValue() via ChangeDetail.
38
+ */
39
+ export interface ChipsChangeMeta {
40
+ action: ChipsChangeAction;
41
+ added?: string[];
42
+ removed?: string[];
43
+ chips: string[];
44
+ }
45
+
46
+ /**
47
+ * Chips-only props, on top of the injected ones.
48
+ */
49
+ export interface ChipsVariantProps {
50
+ /**
51
+ * Placeholder shown when there are no chips and input is empty.
52
+ */
53
+ placeholder?: string;
54
+
55
+ /**
56
+ * Separators used to split raw input into chips.
57
+ *
58
+ * - string → split on that string
59
+ * - RegExp → split with regex
60
+ * - array → try each in order
61
+ *
62
+ * Default: [",", ";"]
63
+ */
64
+ separators?: ChipsSeparator;
65
+
66
+ /**
67
+ * When true, pressing Enter commits the current input as chips.
68
+ * Default: true
69
+ */
70
+ addOnEnter?: boolean;
71
+
72
+ /**
73
+ * When true, pressing Tab commits the current input as chips.
74
+ * Default: true
75
+ */
76
+ addOnTab?: boolean;
77
+
78
+ /**
79
+ * When true, blurring the field commits any remaining input as chips.
80
+ * Default: true
81
+ */
82
+ addOnBlur?: boolean;
83
+
84
+ /**
85
+ * When false, duplicate chips are ignored.
86
+ * Default: false
87
+ */
88
+ allowDuplicates?: boolean;
89
+
90
+ /**
91
+ * Maximum number of chips allowed.
92
+ * Undefined → unlimited.
93
+ */
94
+ maxChips?: number;
95
+
96
+ /**
97
+ * When true, Backspace on empty input removes the last chip.
98
+ * Default: true
99
+ */
100
+ backspaceRemovesLast?: boolean;
101
+
102
+ /**
103
+ * Show a small clear-all button.
104
+ * Default: false
105
+ */
106
+ clearable?: boolean;
107
+
108
+ /**
109
+ * Called when chips are added.
110
+ */
111
+ onAddChips?(added: string[], next: string[]): void;
112
+
113
+ /**
114
+ * Called when chips are removed.
115
+ */
116
+ onRemoveChips?(removed: string[], next: string[]): void;
117
+
118
+ /**
119
+ * Optional custom chip renderer.
120
+ *
121
+ * If provided, you are responsible for calling onRemove(index)
122
+ * from your UI when you want to remove a chip.
123
+ */
124
+ renderChip?(
125
+ chip: string,
126
+ index: number,
127
+ ctx: {
128
+ remove(): void;
129
+ chips: string[];
130
+ },
131
+ ): React.ReactNode;
132
+
133
+ /**
134
+ * Optional custom overflow chip renderer.
135
+ *
136
+ * Receives the hidden count and the full chip list.
137
+ */
138
+ renderOverflowChip?(
139
+ hiddenCount: number,
140
+ chips: string[],
141
+ ): React.ReactNode;
142
+
143
+ /**
144
+ * Max number of chips to *render*.
145
+ * Extra chips are summarized as "+N more".
146
+ */
147
+ maxVisibleChips?: number;
148
+
149
+ /**
150
+ * Max number of characters to *display* per chip.
151
+ * The underlying value is not truncated.
152
+ */
153
+ maxChipChars?: number;
154
+
155
+ /**
156
+ * CSS max-width for chip labels (e.g. 160 or "12rem").
157
+ */
158
+ maxChipWidth?: number | string;
159
+
160
+ /**
161
+ * When true, the entry control is a Textarea instead of Input.
162
+ * Good for comment-style chip entry.
163
+ */
164
+ textareaMode?: boolean;
165
+
166
+ /**
167
+ * Where chips are rendered relative to the entry.
168
+ *
169
+ * Default:
170
+ * - Input mode → "inline"
171
+ * - Textarea mode → "inline"
172
+ */
173
+ placement?: ChipsPlacement;
174
+
175
+ // UI hooks
176
+ className?: string; // outer wrapper
177
+ chipsClassName?: string; // <div> that holds all chips
178
+ chipClassName?: string; // each chip container
179
+ chipLabelClassName?: string; // inner label span
180
+ chipRemoveClassName?: string; // remove "x" button/span
181
+ inputClassName?: string; // entry text input / textarea overrides
182
+ }
183
+
184
+ /**
185
+ * We still type against ShadcnTextVariantProps so chips can reuse
186
+ * size/density/icon props etc. We take control of:
187
+ * - type / value / onValue
188
+ * - leadingControl / trailingControl
189
+ */
190
+ type TextUiProps = Omit<
191
+ ShadcnTextVariantProps,
192
+ | "type"
193
+ | "inputMode"
194
+ | "leadingControl"
195
+ | "trailingControl"
196
+ | "value"
197
+ | "onValue"
198
+ >;
199
+
200
+ /**
201
+ * Full props for the Shadcn-based chips variant.
202
+ */
203
+ export type ShadcnChipsVariantProps = TextUiProps &
204
+ ChipsVariantProps &
205
+ Pick<BaseProps, "value" | "onValue" | "error">;
206
+
207
+ // ─────────────────────────────────────────────
208
+ // Helpers
209
+ // ─────────────────────────────────────────────
210
+
211
+ function normalizeSeparators(sep?: ChipsSeparator): (string | RegExp)[] {
212
+ if (!sep) return [",", ";"];
213
+ if (Array.isArray(sep)) return sep;
214
+ return [sep];
215
+ }
216
+
217
+ function splitIntoTokens(raw: string, sep?: ChipsSeparator): string[] {
218
+ const separators = normalizeSeparators(sep);
219
+ let acc: string[] = [raw];
220
+
221
+ for (const s of separators) {
222
+ const next: string[] = [];
223
+ for (const chunk of acc) {
224
+ if (!chunk) continue;
225
+ if (typeof s === "string") {
226
+ next.push(...chunk.split(s));
227
+ } else {
228
+ next.push(...chunk.split(s));
229
+ }
230
+ }
231
+ acc = next;
232
+ }
233
+
234
+ return acc
235
+ .map((t) => t.trim())
236
+ .filter((t) => t.length > 0);
237
+ }
238
+
239
+ // ─────────────────────────────────────────────
240
+ // Component
241
+ // ─────────────────────────────────────────────
242
+
243
+ export const ShadcnChipsVariant = React.forwardRef<
244
+ HTMLInputElement | HTMLTextAreaElement,
245
+ ShadcnChipsVariantProps
246
+ >(function ShadcnChipsVariant(props, ref) {
247
+ const {
248
+ // variant base bits
249
+ value,
250
+ onValue,
251
+ error,
252
+
253
+ // chips behaviour
254
+ placeholder,
255
+ separators,
256
+ addOnEnter = true,
257
+ addOnTab = true,
258
+ addOnBlur = true,
259
+ allowDuplicates = false,
260
+ maxChips,
261
+ backspaceRemovesLast = true,
262
+ clearable = false,
263
+ onAddChips,
264
+ onRemoveChips,
265
+ renderChip,
266
+ renderOverflowChip,
267
+ maxVisibleChips,
268
+ maxChipChars,
269
+ maxChipWidth,
270
+ textareaMode = false,
271
+ placement,
272
+
273
+ // UI classNames
274
+ className,
275
+ chipsClassName,
276
+ chipClassName,
277
+ chipLabelClassName,
278
+ chipRemoveClassName,
279
+ inputClassName,
280
+
281
+ // rest of text UI bits (size, density, icons, etc.)
282
+ ...restTextProps
283
+ } = props;
284
+
285
+ const chips = React.useMemo(() => value ?? [], [value]);
286
+ const hasChips = chips.length > 0;
287
+
288
+ const [inputText, setInputText] = React.useState("");
289
+
290
+ // ─────────────────────────────────────────────
291
+ // Value emit
292
+ // ─────────────────────────────────────────────
293
+
294
+ const emitChange = React.useCallback(
295
+ (
296
+ nextChips: string[],
297
+ meta: Omit<ChipsChangeMeta, "chips">,
298
+ ) => {
299
+ const detail: ChangeDetail<ChipsChangeMeta> = {
300
+ source: "variant",
301
+ raw: nextChips,
302
+ nativeEvent: undefined,
303
+ meta: {
304
+ ...meta,
305
+ chips: nextChips,
306
+ },
307
+ };
308
+ onValue?.(nextChips.length ? nextChips : undefined, detail);
309
+ },
310
+ [onValue],
311
+ );
312
+
313
+ const commitFromRaw = React.useCallback(
314
+ (raw: string) => {
315
+ const tokens = splitIntoTokens(raw, separators);
316
+ if (!tokens.length) return;
317
+
318
+ let next = [...chips];
319
+ const added: string[] = [];
320
+
321
+ for (const token of tokens) {
322
+ if (!allowDuplicates && next.includes(token)) continue;
323
+ if (typeof maxChips === "number" && next.length >= maxChips) {
324
+ break;
325
+ }
326
+ next.push(token);
327
+ added.push(token);
328
+ }
329
+
330
+ if (!added.length) return;
331
+
332
+ emitChange(next, { action: "add", added });
333
+ onAddChips?.(added, next);
334
+ setInputText("");
335
+ },
336
+ [chips, separators, allowDuplicates, maxChips, emitChange, onAddChips],
337
+ );
338
+
339
+ const handleRemoveAt = React.useCallback(
340
+ (index: number) => {
341
+ if (index < 0 || index >= chips.length) return;
342
+ const removed = [chips[index]];
343
+ const next = chips.filter((_, i) => i !== index);
344
+
345
+ emitChange(next, { action: "remove", removed });
346
+ onRemoveChips?.(removed, next);
347
+ },
348
+ [chips, emitChange, onRemoveChips],
349
+ );
350
+
351
+ const handleClear = React.useCallback(
352
+ (ev?: React.MouseEvent) => {
353
+ ev?.preventDefault();
354
+ ev?.stopPropagation();
355
+ if (!chips.length) return;
356
+ emitChange([], { action: "clear", removed: [...chips] });
357
+ onRemoveChips?.([...chips], []);
358
+ setInputText("");
359
+ },
360
+ [chips, emitChange, onRemoveChips],
361
+ );
362
+
363
+ // ─────────────────────────────────────────────
364
+ // Entry events (Input or Textarea)
365
+ // ─────────────────────────────────────────────
366
+
367
+ const handleEntryChange = React.useCallback(
368
+ (
369
+ event:
370
+ | React.ChangeEvent<HTMLInputElement>
371
+ | React.ChangeEvent<HTMLTextAreaElement>,
372
+ ) => {
373
+ const next = event.target.value ?? "";
374
+ setInputText(next);
375
+ },
376
+ [],
377
+ );
378
+
379
+ const handleEntryKeyDown = React.useCallback(
380
+ (
381
+ event:
382
+ | React.KeyboardEvent<HTMLInputElement>
383
+ | React.KeyboardEvent<HTMLTextAreaElement>,
384
+ ) => {
385
+ const key = event.key;
386
+
387
+ if (key === "Enter" && addOnEnter) {
388
+ event.preventDefault();
389
+ if (inputText.trim().length) {
390
+ commitFromRaw(inputText);
391
+ }
392
+ return;
393
+ }
394
+
395
+ if (key === "Tab" && addOnTab && inputText.trim().length) {
396
+ event.preventDefault();
397
+ commitFromRaw(inputText);
398
+ return;
399
+ }
400
+
401
+ // Backspace on empty input → remove last chip
402
+ if (
403
+ key === "Backspace" &&
404
+ backspaceRemovesLast &&
405
+ !inputText.length &&
406
+ chips.length
407
+ ) {
408
+ event.preventDefault();
409
+ handleRemoveAt(chips.length - 1);
410
+ return;
411
+ }
412
+ },
413
+ [
414
+ inputText,
415
+ addOnEnter,
416
+ addOnTab,
417
+ backspaceRemovesLast,
418
+ chips.length,
419
+ commitFromRaw,
420
+ handleRemoveAt,
421
+ ],
422
+ );
423
+
424
+ const handleEntryBlur = React.useCallback(
425
+ (
426
+ event:
427
+ | React.FocusEvent<HTMLInputElement>
428
+ | React.FocusEvent<HTMLTextAreaElement>,
429
+ ) => {
430
+ if (addOnBlur && inputText.trim().length) {
431
+ commitFromRaw(inputText);
432
+ }
433
+
434
+ // Forward to host onBlur if provided in restTextProps
435
+ const anyProps = restTextProps as any;
436
+ const hostOnBlur = anyProps?.onBlur as
437
+ | ((e: typeof event) => void)
438
+ | undefined;
439
+ hostOnBlur?.(event);
440
+ },
441
+ [addOnBlur, inputText, commitFromRaw, restTextProps],
442
+ );
443
+
444
+ const effectivePlaceholder =
445
+ placeholder ?? (hasChips ? "" : "Add item…");
446
+
447
+ // ─────────────────────────────────────────────
448
+ // Chip rendering (maxVisible / overflow / truncation)
449
+ // ─────────────────────────────────────────────
450
+
451
+ let visibleChips = chips;
452
+ let hiddenCount = 0;
453
+
454
+ if (
455
+ typeof maxVisibleChips === "number" &&
456
+ maxVisibleChips > 0 &&
457
+ chips.length > maxVisibleChips
458
+ ) {
459
+ visibleChips = chips.slice(0, maxVisibleChips);
460
+ hiddenCount = chips.length - visibleChips.length;
461
+ }
462
+
463
+ const maxWidthStyle: React.CSSProperties | undefined =
464
+ maxChipWidth !== undefined
465
+ ? {
466
+ maxWidth:
467
+ typeof maxChipWidth === "number"
468
+ ? `${maxChipWidth}px`
469
+ : maxChipWidth,
470
+ }
471
+ : undefined;
472
+
473
+ const baseChipClasses = textareaMode
474
+ ? "inline-flex min-w-0 gap-1 items-center justify-between rounded-md bg-muted px-2 py-2 text-muted-foreground"
475
+ : "inline-flex max-w-full items-center gap-1 rounded bg-muted px-2 py-0.5 text-muted-foreground hover:bg-muted/80";
476
+
477
+ const baseRemoveClasses = textareaMode
478
+ ? "cursor-pointer text-[16px] opacity-70 hover:opacity-100 mt-0.5"
479
+ : "cursor-pointer text-[16px] opacity-70 hover:opacity-100";
480
+
481
+ const chipNodes = visibleChips.map((chip, index) => {
482
+ if (renderChip) {
483
+ return (
484
+ <React.Fragment key={`${chip}-${index}`}>
485
+ {renderChip(chip, index, {
486
+ remove: () => handleRemoveAt(index),
487
+ chips,
488
+ })}
489
+ </React.Fragment>
490
+ );
491
+ }
492
+
493
+ let label = chip;
494
+ if (
495
+ typeof maxChipChars === "number" &&
496
+ maxChipChars > 0 &&
497
+ label.length > maxChipChars
498
+ ) {
499
+ label = label.slice(0, maxChipChars) + "…";
500
+ }
501
+
502
+ return (
503
+ <button
504
+ key={`${chip}-${index}`}
505
+ type="button"
506
+ className={cn(baseChipClasses, chipClassName)}
507
+ onClick={(e) => {
508
+ e.preventDefault();
509
+ }}
510
+ data-slot="chip"
511
+ >
512
+ <span
513
+ className={cn(
514
+ "truncate",
515
+ chipLabelClassName,
516
+ )}
517
+ style={maxWidthStyle}
518
+ >
519
+ {label}
520
+ </span>
521
+ <span
522
+ className={cn(baseRemoveClasses, chipRemoveClassName)}
523
+ onClick={(e) => {
524
+ e.preventDefault();
525
+ e.stopPropagation();
526
+ handleRemoveAt(index);
527
+ }}
528
+ aria-hidden="true"
529
+ >
530
+ <X size={16} />
531
+ </span>
532
+ </button>
533
+ );
534
+ });
535
+
536
+ if (hiddenCount > 0) {
537
+ const defaultOverflow = (
538
+ <span
539
+ className={cn(
540
+ baseChipClasses,
541
+ "cursor-default",
542
+ chipClassName,
543
+ )}
544
+ data-slot="chip-overflow"
545
+ >
546
+ +{hiddenCount} more
547
+ </span>
548
+ );
549
+
550
+ const node =
551
+ renderOverflowChip?.(hiddenCount, chips) ?? defaultOverflow;
552
+
553
+ chipNodes.push(
554
+ <React.Fragment key="__overflow">
555
+ {node}
556
+ </React.Fragment>,
557
+ );
558
+ }
559
+
560
+ // ─────────────────────────────────────────────
561
+ // Placement (inline vs below)
562
+ // ─────────────────────────────────────────────
563
+
564
+ const effectivePlacement: ChipsPlacement = textareaMode
565
+ ? (placement ?? "inline")
566
+ : (placement ?? "inline");
567
+
568
+ const inlinePlacement = effectivePlacement === "inline";
569
+
570
+ // Input-mode inline controls (inside the Input frame)
571
+ let leadingControl: React.ReactNode | undefined;
572
+ let trailingControl: React.ReactNode | undefined;
573
+
574
+ // Below-the-field block (both modes)
575
+ let chipsBelowBlock: React.ReactNode | undefined;
576
+
577
+ // Textarea-mode upper toolbox (instead of leadingControl/trailingControl)
578
+ let textareaUpperControl: React.ReactNode | undefined;
579
+ let textareaUpperClassName: string | undefined;
580
+
581
+ if (hasChips) {
582
+ if (textareaMode) {
583
+ if (inlinePlacement) {
584
+ // chips live in the upper toolbox row, single-line row by default
585
+ textareaUpperControl = (
586
+ <div
587
+ data-slot="chips-upper"
588
+ className={cn(
589
+ "flex items-center gap-1 text-xs",
590
+ chipsClassName,
591
+ )}
592
+ >
593
+ {chipNodes}
594
+ {clearable && (
595
+ <button
596
+ type="button"
597
+ onClick={handleClear}
598
+ className="ml-auto inline-flex h-6 px-2 items-center justify-center rounded-full text-[0.72rem] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
599
+ data-slot="chips-clear"
600
+ >
601
+ Clear
602
+ </button>
603
+ )}
604
+ </div>
605
+ );
606
+ textareaUpperClassName = chipsClassName;
607
+ } else {
608
+ // textareaMode + placement=below → block under the textarea box
609
+ chipsBelowBlock = (
610
+ <div
611
+ className={cn(
612
+ "mt-2 flex items-center gap-2 text-xs",
613
+ chipsClassName,
614
+ )}
615
+ data-slot="chips-list-below"
616
+ >
617
+ {chipNodes}
618
+ {clearable && (
619
+ <button
620
+ type="button"
621
+ onClick={handleClear}
622
+ className="self-start inline-flex h-6 px-2 items-center justify-center rounded-full text-[0.72rem] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
623
+ data-slot="chips-clear"
624
+ >
625
+ Clear
626
+ </button>
627
+ )}
628
+ </div>
629
+ );
630
+ }
631
+ } else {
632
+ // INPUT MODE
633
+ if (inlinePlacement) {
634
+ leadingControl = (
635
+ <div
636
+ className={cn(
637
+ "flex min-w-0 flex-row items-center gap-1 pr-1 py-1 text-xs pl-2",
638
+ chipsClassName,
639
+ )}
640
+ data-slot="chips-list"
641
+ >
642
+ {chipNodes}
643
+ </div>
644
+ );
645
+
646
+ if (clearable) {
647
+ trailingControl = (
648
+ <div
649
+ className="flex h-full items-center pr-1"
650
+ data-slot="chips-trailing"
651
+ >
652
+ <button
653
+ type="button"
654
+ onClick={handleClear}
655
+ className="inline-flex h-6 w-6 items-center justify-center rounded-full text-xs text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
656
+ data-slot="chips-clear"
657
+ aria-label="Clear chips"
658
+ >
659
+ ×
660
+ </button>
661
+ </div>
662
+ );
663
+ }
664
+ } else {
665
+ chipsBelowBlock = (
666
+ <div
667
+ className={cn(
668
+ "mt-1 flex flex-row items-center gap-1 text-xs",
669
+ chipsClassName,
670
+ )}
671
+ data-slot="chips-list-below"
672
+ >
673
+ {chipNodes}
674
+ {clearable && (
675
+ <button
676
+ type="button"
677
+ onClick={handleClear}
678
+ className="inline-flex h-6 px-2 items-center justify-center rounded-full text-[0.72rem] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
679
+ data-slot="chips-clear"
680
+ >
681
+ Clear
682
+ </button>
683
+ )}
684
+ </div>
685
+ );
686
+ }
687
+ }
688
+ }
689
+
690
+ const joinControls = !textareaMode && inlinePlacement && hasChips;
691
+ const extendBoxToControls = !textareaMode && inlinePlacement && hasChips;
692
+
693
+ // ─────────────────────────────────────────────
694
+ // Entry control (Input vs Textarea)
695
+ // ─────────────────────────────────────────────
696
+
697
+ return (
698
+ <div className={className} data-slot="chips-field">
699
+ {textareaMode ? (
700
+ <>
701
+ <Textarea
702
+ ref={ref as any}
703
+ {...restTextProps}
704
+ value={inputText}
705
+ onChange={handleEntryChange}
706
+ onKeyDown={handleEntryKeyDown as any}
707
+ onBlur={handleEntryBlur as any}
708
+ extendBoxToToolbox={effectivePlacement === "inline"}
709
+ placeholder={effectivePlaceholder}
710
+ // textarea-specific defaults
711
+ autoResize={true}
712
+ rows={1}
713
+ upperControl={textareaUpperControl}
714
+ upperControlClassName={textareaUpperClassName}
715
+ inputClassName={inputClassName}
716
+ aria-invalid={error ? "true" : undefined}
717
+ />
718
+ {!inlinePlacement && hasChips && chipsBelowBlock}
719
+ </>
720
+ ) : (
721
+ <>
722
+ <Input
723
+ ref={ref as any}
724
+ {...restTextProps}
725
+ type="text"
726
+ // The Input's value is the *draft* text, not the chips.
727
+ value={inputText}
728
+ onChange={handleEntryChange as any}
729
+ onKeyDown={handleEntryKeyDown as any}
730
+ onBlur={handleEntryBlur as any}
731
+ placeholder={effectivePlaceholder}
732
+ // ONLY pass controls when chips are inline
733
+ leadingControl={inlinePlacement ? leadingControl : undefined}
734
+ trailingControl={inlinePlacement ? trailingControl : undefined}
735
+ // Only flip into "group box" mode when there are chips inline
736
+ joinControls={joinControls}
737
+ extendBoxToControls={extendBoxToControls}
738
+ inputClassName={cn(
739
+ "min-w-[4ch] flex-1 py-0",
740
+ inlinePlacement &&
741
+ hasChips &&
742
+ "bg-transparent border-none shadow-none outline-none",
743
+ inputClassName,
744
+ )}
745
+ aria-invalid={error ? "true" : undefined}
746
+ />
747
+ {!inlinePlacement && hasChips && chipsBelowBlock}
748
+ </>
749
+ )}
750
+ </div>
751
+ );
752
+ });
753
+
754
+ ShadcnChipsVariant.displayName = "ShadcnChipsVariant";
755
+
756
+ export default ShadcnChipsVariant;