@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,556 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
import { Button } from "@/presets/ui/button";
|
|
6
|
+
import { Input } from "@/presets/ui/input";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogHeader,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogDescription,
|
|
14
|
+
} from "@/presets/ui/dialog";
|
|
15
|
+
import { X, Plus, MoreHorizontal, Tag, PenLine } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
type Size = "sm" | "md" | "lg";
|
|
18
|
+
type Density = "compact" | "comfortable" | "loose";
|
|
19
|
+
|
|
20
|
+
export type KeyValueMap = Record<string, string>;
|
|
21
|
+
|
|
22
|
+
export interface KV {
|
|
23
|
+
key: string;
|
|
24
|
+
value: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ShadcnKeyValueVariantProps
|
|
28
|
+
extends Pick<
|
|
29
|
+
VariantBaseProps<KeyValueMap | undefined>,
|
|
30
|
+
"value" | "onValue" | "error" | "disabled" | "readOnly" | "size" | "density"
|
|
31
|
+
> {
|
|
32
|
+
min?: number;
|
|
33
|
+
max?: number;
|
|
34
|
+
minVisible?: number;
|
|
35
|
+
maxVisible?: number;
|
|
36
|
+
showAddButton?: boolean;
|
|
37
|
+
showMenuButton?: boolean;
|
|
38
|
+
placeholder?: React.ReactNode;
|
|
39
|
+
dialogTitle?: React.ReactNode;
|
|
40
|
+
keyLabel?: React.ReactNode;
|
|
41
|
+
valueLabel?: React.ReactNode;
|
|
42
|
+
submitLabel?: React.ReactNode;
|
|
43
|
+
moreLabel?: (count: number) => React.ReactNode;
|
|
44
|
+
emptyLabel?: React.ReactNode;
|
|
45
|
+
className?: string;
|
|
46
|
+
chipsClassName?: string;
|
|
47
|
+
chipClassName?: string;
|
|
48
|
+
renderChip?: (ctx: {
|
|
49
|
+
pair: KV;
|
|
50
|
+
index: number;
|
|
51
|
+
onEdit: () => void;
|
|
52
|
+
onRemove: () => void;
|
|
53
|
+
defaultChip: React.ReactNode;
|
|
54
|
+
}) => React.ReactNode;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────
|
|
58
|
+
// Helpers
|
|
59
|
+
// ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function mapToItems(map: KeyValueMap | undefined): KV[] {
|
|
62
|
+
if (!map) return [];
|
|
63
|
+
return Object.entries(map).map(([key, value]) => ({
|
|
64
|
+
key,
|
|
65
|
+
value: value ?? "",
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function itemsToMap(items: KV[]): KeyValueMap {
|
|
70
|
+
const out: KeyValueMap = {};
|
|
71
|
+
for (const { key, value } of items) {
|
|
72
|
+
if (!key) continue;
|
|
73
|
+
out[key] = value;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clampVisible(
|
|
79
|
+
total: number,
|
|
80
|
+
minVisible: number,
|
|
81
|
+
maxVisible: number
|
|
82
|
+
): number {
|
|
83
|
+
if (total === 0) return 0;
|
|
84
|
+
const clampedMax = Math.max(minVisible, maxVisible);
|
|
85
|
+
return Math.min(total, clampedMax);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sizeClasses(size?: Size) {
|
|
89
|
+
switch (size) {
|
|
90
|
+
case "sm":
|
|
91
|
+
return "text-xs min-h-[2rem]";
|
|
92
|
+
case "lg":
|
|
93
|
+
return "text-sm min-h-[2.75rem]";
|
|
94
|
+
case "md":
|
|
95
|
+
default:
|
|
96
|
+
return "text-sm min-h-[2.5rem]";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function densityPadding(density?: Density) {
|
|
101
|
+
switch (density) {
|
|
102
|
+
case "compact":
|
|
103
|
+
return "py-1 px-2 gap-1.5";
|
|
104
|
+
case "loose":
|
|
105
|
+
return "py-3 px-3 gap-3";
|
|
106
|
+
case "comfortable":
|
|
107
|
+
default:
|
|
108
|
+
return "py-2 px-3 gap-2";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function defaultMoreLabel(count: number): React.ReactNode {
|
|
113
|
+
return `+${count} more`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─────────────────────────────────────────────
|
|
117
|
+
// Component
|
|
118
|
+
// ─────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export const ShadcnKeyValueVariant = React.forwardRef<
|
|
121
|
+
HTMLDivElement,
|
|
122
|
+
ShadcnKeyValueVariantProps
|
|
123
|
+
>(function ShadcnKeyValueVariant(props, _ref) {
|
|
124
|
+
const {
|
|
125
|
+
value,
|
|
126
|
+
onValue,
|
|
127
|
+
error,
|
|
128
|
+
disabled,
|
|
129
|
+
readOnly,
|
|
130
|
+
size,
|
|
131
|
+
density,
|
|
132
|
+
|
|
133
|
+
min = 0,
|
|
134
|
+
max = Infinity,
|
|
135
|
+
minVisible = 0,
|
|
136
|
+
maxVisible = 6,
|
|
137
|
+
|
|
138
|
+
showAddButton = true,
|
|
139
|
+
showMenuButton = true,
|
|
140
|
+
|
|
141
|
+
placeholder,
|
|
142
|
+
dialogTitle = "Edit Item",
|
|
143
|
+
keyLabel = "Key",
|
|
144
|
+
valueLabel = "Value",
|
|
145
|
+
submitLabel = "Save Changes",
|
|
146
|
+
moreLabel = defaultMoreLabel,
|
|
147
|
+
emptyLabel = "No items added",
|
|
148
|
+
|
|
149
|
+
className,
|
|
150
|
+
chipsClassName,
|
|
151
|
+
chipClassName,
|
|
152
|
+
renderChip,
|
|
153
|
+
} = props;
|
|
154
|
+
|
|
155
|
+
const isDisabled = disabled || readOnly;
|
|
156
|
+
|
|
157
|
+
const items: KV[] = React.useMemo(
|
|
158
|
+
() => mapToItems(value),
|
|
159
|
+
[value]
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
const [dialogOpen, setDialogOpen] = React.useState(false);
|
|
163
|
+
const [editingIndex, setEditingIndex] = React.useState<number | null>(
|
|
164
|
+
null
|
|
165
|
+
);
|
|
166
|
+
const [draft, setDraft] = React.useState<KV>({ key: "", value: "" });
|
|
167
|
+
|
|
168
|
+
const canAdd = items.length < max;
|
|
169
|
+
const canDelete = items.length > min;
|
|
170
|
+
|
|
171
|
+
// visible vs overflow
|
|
172
|
+
const visibleCount = clampVisible(
|
|
173
|
+
items.length,
|
|
174
|
+
minVisible,
|
|
175
|
+
maxVisible
|
|
176
|
+
);
|
|
177
|
+
const visibleItems = items.slice(0, visibleCount);
|
|
178
|
+
const overflowCount = Math.max(0, items.length - visibleCount);
|
|
179
|
+
|
|
180
|
+
// ────────────────────────────────
|
|
181
|
+
// Change Logic
|
|
182
|
+
// ────────────────────────────────
|
|
183
|
+
|
|
184
|
+
const commitItems = React.useCallback(
|
|
185
|
+
(next: KV[], meta: ChangeDetail["meta"]) => {
|
|
186
|
+
if (!onValue) return;
|
|
187
|
+
|
|
188
|
+
const nextMap = itemsToMap(next);
|
|
189
|
+
const detail: ChangeDetail = {
|
|
190
|
+
source: "variant",
|
|
191
|
+
raw: next,
|
|
192
|
+
nativeEvent: undefined,
|
|
193
|
+
meta,
|
|
194
|
+
};
|
|
195
|
+
onValue(nextMap, detail);
|
|
196
|
+
},
|
|
197
|
+
[onValue]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const openForNew = React.useCallback(() => {
|
|
201
|
+
if (isDisabled || !canAdd) return;
|
|
202
|
+
setEditingIndex(null);
|
|
203
|
+
setDraft({ key: "", value: "" });
|
|
204
|
+
setDialogOpen(true);
|
|
205
|
+
}, [isDisabled, canAdd]);
|
|
206
|
+
|
|
207
|
+
const openForEdit = React.useCallback(
|
|
208
|
+
(index: number) => {
|
|
209
|
+
if (isDisabled) return;
|
|
210
|
+
const item = items[index];
|
|
211
|
+
if (!item) return;
|
|
212
|
+
setEditingIndex(index);
|
|
213
|
+
setDraft(item);
|
|
214
|
+
setDialogOpen(true);
|
|
215
|
+
},
|
|
216
|
+
[isDisabled, items]
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const handleDelete = React.useCallback(() => {
|
|
220
|
+
if (editingIndex == null) return;
|
|
221
|
+
if (!canDelete) return;
|
|
222
|
+
|
|
223
|
+
const next = items.slice();
|
|
224
|
+
next.splice(editingIndex, 1);
|
|
225
|
+
|
|
226
|
+
setDialogOpen(false);
|
|
227
|
+
commitItems(next, {
|
|
228
|
+
action: "delete",
|
|
229
|
+
index: editingIndex,
|
|
230
|
+
});
|
|
231
|
+
}, [editingIndex, items, canDelete, commitItems]);
|
|
232
|
+
|
|
233
|
+
const handleSubmit = React.useCallback(() => {
|
|
234
|
+
const trimmedKey = draft.key.trim();
|
|
235
|
+
const trimmedValue = draft.value;
|
|
236
|
+
|
|
237
|
+
if (!trimmedKey) return;
|
|
238
|
+
|
|
239
|
+
let next = items.slice();
|
|
240
|
+
|
|
241
|
+
if (editingIndex != null) {
|
|
242
|
+
// edit
|
|
243
|
+
next[editingIndex] = { key: trimmedKey, value: trimmedValue };
|
|
244
|
+
} else {
|
|
245
|
+
// add / upsert
|
|
246
|
+
const existingIndex = next.findIndex(
|
|
247
|
+
(kv) => kv.key === trimmedKey
|
|
248
|
+
);
|
|
249
|
+
if (existingIndex !== -1) {
|
|
250
|
+
next[existingIndex] = {
|
|
251
|
+
key: trimmedKey,
|
|
252
|
+
value: trimmedValue,
|
|
253
|
+
};
|
|
254
|
+
} else {
|
|
255
|
+
if (!canAdd) return;
|
|
256
|
+
next.push({ key: trimmedKey, value: trimmedValue });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
setDialogOpen(false);
|
|
261
|
+
commitItems(next, {
|
|
262
|
+
action: editingIndex != null ? "edit" : "add",
|
|
263
|
+
index: editingIndex ?? next.length - 1,
|
|
264
|
+
});
|
|
265
|
+
}, [draft, items, editingIndex, canAdd, commitItems]);
|
|
266
|
+
|
|
267
|
+
const handleQuickRemove = React.useCallback(
|
|
268
|
+
(index: number) => {
|
|
269
|
+
if (isDisabled || !canDelete) return;
|
|
270
|
+
const next = items.slice();
|
|
271
|
+
next.splice(index, 1);
|
|
272
|
+
commitItems(next, { action: "delete", index });
|
|
273
|
+
},
|
|
274
|
+
[isDisabled, canDelete, items, commitItems]
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// ────────────────────────────────
|
|
278
|
+
// Visuals
|
|
279
|
+
// ────────────────────────────────
|
|
280
|
+
|
|
281
|
+
const sizeCls = sizeClasses(size as Size | undefined);
|
|
282
|
+
const densityCls = densityPadding(density as Density | undefined);
|
|
283
|
+
|
|
284
|
+
const renderChipNode = (pair: KV, index: number) => {
|
|
285
|
+
const baseChip = (
|
|
286
|
+
<button
|
|
287
|
+
type="button"
|
|
288
|
+
key={index}
|
|
289
|
+
className={cn(
|
|
290
|
+
"group inline-flex items-center gap-1.5 rounded-md",
|
|
291
|
+
"bg-secondary/50 border border-transparent",
|
|
292
|
+
"px-2 py-1 text-xs transition-all duration-200",
|
|
293
|
+
"hover:bg-secondary hover:border-border/50 hover:shadow-sm",
|
|
294
|
+
"animate-in fade-in zoom-in-95 fill-mode-both",
|
|
295
|
+
isDisabled && "opacity-50 cursor-not-allowed",
|
|
296
|
+
chipClassName
|
|
297
|
+
)}
|
|
298
|
+
onClick={() => openForEdit(index)}
|
|
299
|
+
disabled={isDisabled}
|
|
300
|
+
>
|
|
301
|
+
<span className="font-semibold text-foreground truncate max-w-[120px]">
|
|
302
|
+
{pair.key}
|
|
303
|
+
</span>
|
|
304
|
+
<span className="text-muted-foreground/40">:</span>
|
|
305
|
+
<span className="text-muted-foreground truncate max-w-[120px]">
|
|
306
|
+
{pair.value}
|
|
307
|
+
</span>
|
|
308
|
+
|
|
309
|
+
{canDelete && !isDisabled && (
|
|
310
|
+
<div
|
|
311
|
+
role="button"
|
|
312
|
+
tabIndex={0}
|
|
313
|
+
className={cn(
|
|
314
|
+
"ml-1 flex h-4 w-4 items-center justify-center rounded-full",
|
|
315
|
+
"text-muted-foreground/60 opacity-0 transition-all",
|
|
316
|
+
"hover:bg-destructive hover:text-destructive-foreground",
|
|
317
|
+
"group-hover:opacity-100",
|
|
318
|
+
"focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring"
|
|
319
|
+
)}
|
|
320
|
+
onClick={(e) => {
|
|
321
|
+
e.stopPropagation();
|
|
322
|
+
handleQuickRemove(index);
|
|
323
|
+
}}
|
|
324
|
+
onKeyDown={(e) => {
|
|
325
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
326
|
+
e.stopPropagation();
|
|
327
|
+
handleQuickRemove(index);
|
|
328
|
+
}
|
|
329
|
+
}}
|
|
330
|
+
aria-label={`Remove ${pair.key}`}
|
|
331
|
+
>
|
|
332
|
+
<X className="h-3 w-3" />
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</button>
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
if (!renderChip) return baseChip;
|
|
339
|
+
|
|
340
|
+
return renderChip({
|
|
341
|
+
pair,
|
|
342
|
+
index,
|
|
343
|
+
onEdit: () => openForEdit(index),
|
|
344
|
+
onRemove: () => handleQuickRemove(index),
|
|
345
|
+
defaultChip: baseChip,
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const hasItems = items.length > 0;
|
|
350
|
+
|
|
351
|
+
// ────────────────────────────────
|
|
352
|
+
// Dialog
|
|
353
|
+
// ────────────────────────────────
|
|
354
|
+
|
|
355
|
+
const ManageDialog = (
|
|
356
|
+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
|
357
|
+
<DialogContent className="sm:max-w-[425px]">
|
|
358
|
+
<DialogHeader>
|
|
359
|
+
<DialogTitle className="flex items-center gap-2">
|
|
360
|
+
<PenLine className="h-4 w-4 text-muted-foreground" />
|
|
361
|
+
{dialogTitle}
|
|
362
|
+
</DialogTitle>
|
|
363
|
+
<DialogDescription>
|
|
364
|
+
{editingIndex !== null ? "Modify the existing key-value pair." : "Add a new key-value pair to the list."}
|
|
365
|
+
</DialogDescription>
|
|
366
|
+
</DialogHeader>
|
|
367
|
+
|
|
368
|
+
<div className="grid gap-4 py-4">
|
|
369
|
+
<div className="grid grid-cols-4 items-center gap-4">
|
|
370
|
+
<label className="text-right text-sm font-medium text-muted-foreground">
|
|
371
|
+
{keyLabel}
|
|
372
|
+
</label>
|
|
373
|
+
<Input
|
|
374
|
+
value={draft.key}
|
|
375
|
+
onChange={(e) =>
|
|
376
|
+
setDraft((prev) => ({
|
|
377
|
+
...prev,
|
|
378
|
+
key: e.target.value,
|
|
379
|
+
}))
|
|
380
|
+
}
|
|
381
|
+
className="col-span-3"
|
|
382
|
+
autoFocus
|
|
383
|
+
disabled={isDisabled}
|
|
384
|
+
placeholder="e.g. Color"
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
<div className="grid grid-cols-4 items-center gap-4">
|
|
388
|
+
<label className="text-right text-sm font-medium text-muted-foreground">
|
|
389
|
+
{valueLabel}
|
|
390
|
+
</label>
|
|
391
|
+
<Input
|
|
392
|
+
value={draft.value}
|
|
393
|
+
onChange={(e) =>
|
|
394
|
+
setDraft((prev) => ({
|
|
395
|
+
...prev,
|
|
396
|
+
value: e.target.value,
|
|
397
|
+
}))
|
|
398
|
+
}
|
|
399
|
+
className="col-span-3"
|
|
400
|
+
disabled={isDisabled}
|
|
401
|
+
placeholder="e.g. Blue"
|
|
402
|
+
onKeyDown={(e) => {
|
|
403
|
+
if (e.key === 'Enter') handleSubmit();
|
|
404
|
+
}}
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<DialogFooter className="flex sm:justify-between flex-row items-center">
|
|
410
|
+
<div>
|
|
411
|
+
{editingIndex != null && canDelete && (
|
|
412
|
+
<Button
|
|
413
|
+
type="button"
|
|
414
|
+
variant="destructive"
|
|
415
|
+
size="sm"
|
|
416
|
+
onClick={handleDelete}
|
|
417
|
+
disabled={isDisabled}
|
|
418
|
+
>
|
|
419
|
+
Delete
|
|
420
|
+
</Button>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
<div className="flex gap-2">
|
|
425
|
+
<Button
|
|
426
|
+
type="button"
|
|
427
|
+
variant="outline"
|
|
428
|
+
size="sm"
|
|
429
|
+
onClick={() => setDialogOpen(false)}
|
|
430
|
+
>
|
|
431
|
+
Cancel
|
|
432
|
+
</Button>
|
|
433
|
+
<Button
|
|
434
|
+
type="button"
|
|
435
|
+
size="sm"
|
|
436
|
+
onClick={handleSubmit}
|
|
437
|
+
disabled={isDisabled}
|
|
438
|
+
>
|
|
439
|
+
{submitLabel}
|
|
440
|
+
</Button>
|
|
441
|
+
</div>
|
|
442
|
+
</DialogFooter>
|
|
443
|
+
</DialogContent>
|
|
444
|
+
</Dialog>
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
// ────────────────────────────────
|
|
448
|
+
// Render
|
|
449
|
+
// ────────────────────────────────
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div
|
|
453
|
+
className={cn(
|
|
454
|
+
"group/container w-full",
|
|
455
|
+
isDisabled && "opacity-60 cursor-not-allowed",
|
|
456
|
+
className
|
|
457
|
+
)}
|
|
458
|
+
aria-disabled={isDisabled}
|
|
459
|
+
aria-invalid={error ? "true" : undefined}
|
|
460
|
+
>
|
|
461
|
+
{/* Container mimicking an Input */}
|
|
462
|
+
<div
|
|
463
|
+
className={cn(
|
|
464
|
+
"relative flex w-full flex-wrap items-center rounded-md border border-input bg-background transition-all",
|
|
465
|
+
// Focus within styles to mimic Input focus
|
|
466
|
+
!isDisabled && "focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
|
|
467
|
+
densityCls,
|
|
468
|
+
sizeCls,
|
|
469
|
+
chipsClassName
|
|
470
|
+
)}
|
|
471
|
+
>
|
|
472
|
+
{hasItems ? (
|
|
473
|
+
<>
|
|
474
|
+
{visibleItems.map((pair, index) =>
|
|
475
|
+
renderChipNode(pair, index)
|
|
476
|
+
)}
|
|
477
|
+
|
|
478
|
+
{overflowCount > 0 && (
|
|
479
|
+
<button
|
|
480
|
+
type="button"
|
|
481
|
+
className={cn(
|
|
482
|
+
"inline-flex h-6 items-center gap-1 rounded-full",
|
|
483
|
+
"bg-muted px-2 text-[11px] font-medium text-muted-foreground",
|
|
484
|
+
"hover:bg-muted/80 hover:text-foreground transition-colors"
|
|
485
|
+
)}
|
|
486
|
+
onClick={() => {
|
|
487
|
+
setDialogOpen(true);
|
|
488
|
+
setEditingIndex(null);
|
|
489
|
+
setDraft({ key: "", value: "" });
|
|
490
|
+
}}
|
|
491
|
+
disabled={isDisabled}
|
|
492
|
+
>
|
|
493
|
+
{moreLabel(overflowCount)}
|
|
494
|
+
</button>
|
|
495
|
+
)}
|
|
496
|
+
</>
|
|
497
|
+
) : (
|
|
498
|
+
<div className="flex items-center gap-2 text-muted-foreground/60 select-none">
|
|
499
|
+
<Tag className="h-3.5 w-3.5" />
|
|
500
|
+
<span className="text-sm">{placeholder ?? emptyLabel}</span>
|
|
501
|
+
</div>
|
|
502
|
+
)}
|
|
503
|
+
|
|
504
|
+
{/* Inline Add Button */}
|
|
505
|
+
{showAddButton && canAdd && !isDisabled && (
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={openForNew}
|
|
509
|
+
className={cn(
|
|
510
|
+
"inline-flex h-6 items-center gap-1 rounded-full",
|
|
511
|
+
"border border-dashed border-muted-foreground/30 px-2",
|
|
512
|
+
"text-[11px] font-medium text-muted-foreground",
|
|
513
|
+
"hover:border-primary/50 hover:bg-accent hover:text-accent-foreground transition-all",
|
|
514
|
+
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
|
515
|
+
)}
|
|
516
|
+
>
|
|
517
|
+
<Plus className="h-3 w-3" />
|
|
518
|
+
<span>Add</span>
|
|
519
|
+
</button>
|
|
520
|
+
)}
|
|
521
|
+
|
|
522
|
+
{/* Menu/Manage Button */}
|
|
523
|
+
{showMenuButton && hasItems && !isDisabled && (
|
|
524
|
+
<div className="ml-auto pl-1">
|
|
525
|
+
<button
|
|
526
|
+
type="button"
|
|
527
|
+
onClick={() => {
|
|
528
|
+
// Default behavior: open "Add New"
|
|
529
|
+
setDialogOpen(true);
|
|
530
|
+
setEditingIndex(null);
|
|
531
|
+
setDraft({ key: "", value: "" });
|
|
532
|
+
}}
|
|
533
|
+
className="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
534
|
+
aria-label="Add another"
|
|
535
|
+
>
|
|
536
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
537
|
+
</button>
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
{/* Error Message Support (Optional usage) */}
|
|
543
|
+
{error && typeof error === 'string' && (
|
|
544
|
+
<p className="mt-1.5 text-xs font-medium text-destructive">
|
|
545
|
+
{error}
|
|
546
|
+
</p>
|
|
547
|
+
)}
|
|
548
|
+
|
|
549
|
+
{ManageDialog}
|
|
550
|
+
</div>
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
ShadcnKeyValueVariant.displayName = "ShadcnKeyValueVariant";
|
|
555
|
+
|
|
556
|
+
export default ShadcnKeyValueVariant;
|