@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,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;
|