@arcote.tech/arc-ds 0.4.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/package.json +42 -0
- package/src/ds/avatar/avatar.tsx +86 -0
- package/src/ds/badge/badge.tsx +61 -0
- package/src/ds/bento-card/bento-card.tsx +70 -0
- package/src/ds/bento-grid/bento-grid.tsx +52 -0
- package/src/ds/box/box.tsx +96 -0
- package/src/ds/button/button.tsx +191 -0
- package/src/ds/card-modal/card-modal.tsx +161 -0
- package/src/ds/display-mode.tsx +32 -0
- package/src/ds/ds-provider.tsx +85 -0
- package/src/ds/form/field.tsx +124 -0
- package/src/ds/form/fields/checkbox-select-field.tsx +326 -0
- package/src/ds/form/fields/index.ts +14 -0
- package/src/ds/form/fields/search-select-field.tsx +41 -0
- package/src/ds/form/fields/select-field.tsx +42 -0
- package/src/ds/form/fields/suggestion-list-field.tsx +43 -0
- package/src/ds/form/fields/tag-field.tsx +39 -0
- package/src/ds/form/fields/text-field.tsx +35 -0
- package/src/ds/form/fields/textarea-field.tsx +81 -0
- package/src/ds/form/form-part.tsx +79 -0
- package/src/ds/form/form.tsx +299 -0
- package/src/ds/form/index.tsx +5 -0
- package/src/ds/form/message.tsx +14 -0
- package/src/ds/input/input.tsx +115 -0
- package/src/ds/merge-variants.ts +26 -0
- package/src/ds/search-select/search-select.tsx +291 -0
- package/src/ds/separator/separator.tsx +26 -0
- package/src/ds/suggestion-list/suggestion-list.tsx +406 -0
- package/src/ds/tag-list/tag-list.tsx +87 -0
- package/src/ds/tooltip/tooltip.tsx +33 -0
- package/src/ds/transitions.ts +12 -0
- package/src/ds/types.ts +131 -0
- package/src/index.ts +115 -0
- package/src/layout/drag-handle.tsx +117 -0
- package/src/layout/dynamic-slot.tsx +95 -0
- package/src/layout/expandable-panel.tsx +57 -0
- package/src/layout/layout.tsx +323 -0
- package/src/layout/overlay-provider.tsx +103 -0
- package/src/layout/overlay.tsx +33 -0
- package/src/layout/router.tsx +101 -0
- package/src/layout/scroll-nav.tsx +121 -0
- package/src/layout/slot-render-context.tsx +14 -0
- package/src/layout/sub-nav-shell.tsx +41 -0
- package/src/layout/toolbar-expand.tsx +70 -0
- package/src/layout/transitions.ts +12 -0
- package/src/layout/use-expandable.ts +59 -0
- package/src/lib/utils.ts +6 -0
- package/src/ui/tooltip.tsx +59 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { Input } from "../../input/input";
|
|
2
|
+
import { Search, ChevronDown, Check } from "lucide-react";
|
|
3
|
+
import { useState, useRef, useEffect, useMemo, useCallback, useContext } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
6
|
+
import { FormFieldContext } from "../field";
|
|
7
|
+
|
|
8
|
+
export interface CheckboxSelectOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
icon?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CheckboxSelectFieldProps {
|
|
15
|
+
label?: ReactNode;
|
|
16
|
+
value?: string[];
|
|
17
|
+
onChange?: (value: string[]) => void;
|
|
18
|
+
options: CheckboxSelectOption[];
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
searchPlaceholder?: string;
|
|
21
|
+
position?: "relative" | "absolute";
|
|
22
|
+
className?: string;
|
|
23
|
+
renderOption?: (option: CheckboxSelectOption, isActive: boolean, isSelected: boolean) => ReactNode;
|
|
24
|
+
/** Rendered as the last item inside the dropdown (e.g. "Add new" link). */
|
|
25
|
+
footerAction?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function CheckboxSelectField({
|
|
29
|
+
label,
|
|
30
|
+
value = [],
|
|
31
|
+
onChange,
|
|
32
|
+
options,
|
|
33
|
+
placeholder = "Wybierz...",
|
|
34
|
+
searchPlaceholder = "Szukaj...",
|
|
35
|
+
position = "relative",
|
|
36
|
+
className,
|
|
37
|
+
renderOption,
|
|
38
|
+
footerAction,
|
|
39
|
+
}: CheckboxSelectFieldProps) {
|
|
40
|
+
const fieldCtx = useContext(FormFieldContext);
|
|
41
|
+
const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
|
|
42
|
+
|
|
43
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
44
|
+
const [query, setQuery] = useState("");
|
|
45
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
46
|
+
|
|
47
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
49
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
50
|
+
|
|
51
|
+
const selectedCount = value.length;
|
|
52
|
+
|
|
53
|
+
const triggerLabel = useMemo(() => {
|
|
54
|
+
if (selectedCount === 0) return null;
|
|
55
|
+
if (selectedCount === options.length && options.length > 0) return "Wszystkie";
|
|
56
|
+
|
|
57
|
+
const selected = options.filter((o) => value.includes(o.value));
|
|
58
|
+
const joined = selected.map((o) => o.label).join(", ");
|
|
59
|
+
|
|
60
|
+
// Estimate if text fits — rough heuristic based on char count
|
|
61
|
+
if (joined.length <= 36) return joined;
|
|
62
|
+
|
|
63
|
+
// Show first item + count
|
|
64
|
+
const first = selected[0]?.label ?? "";
|
|
65
|
+
const rest = selected.length - 1;
|
|
66
|
+
return rest > 0 ? `${first} +${rest}` : first;
|
|
67
|
+
}, [value, options, selectedCount]);
|
|
68
|
+
|
|
69
|
+
const filtered = useMemo(() => {
|
|
70
|
+
if (!query.trim()) return options;
|
|
71
|
+
const q = query.toLowerCase().trim();
|
|
72
|
+
return options.filter((o) => o.label.toLowerCase().includes(q));
|
|
73
|
+
}, [options, query]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
setActiveIndex((prev) =>
|
|
77
|
+
filtered.length === 0 ? 0 : Math.min(prev, filtered.length - 1),
|
|
78
|
+
);
|
|
79
|
+
}, [filtered.length]);
|
|
80
|
+
|
|
81
|
+
// Click outside
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!isOpen) return;
|
|
84
|
+
const handler = (e: MouseEvent) => {
|
|
85
|
+
if (
|
|
86
|
+
containerRef.current &&
|
|
87
|
+
!containerRef.current.contains(e.target as Node)
|
|
88
|
+
) {
|
|
89
|
+
setIsOpen(false);
|
|
90
|
+
setQuery("");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
document.addEventListener("mousedown", handler);
|
|
94
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
95
|
+
}, [isOpen]);
|
|
96
|
+
|
|
97
|
+
// Scroll active into view
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!isOpen) return;
|
|
100
|
+
const el = containerRef.current?.querySelector("[data-active='true']");
|
|
101
|
+
if (el) el.scrollIntoView({ block: "nearest" });
|
|
102
|
+
}, [activeIndex, isOpen]);
|
|
103
|
+
|
|
104
|
+
const toggle = useCallback(
|
|
105
|
+
(val: string) => {
|
|
106
|
+
const next = value.includes(val)
|
|
107
|
+
? value.filter((v) => v !== val)
|
|
108
|
+
: [...value, val];
|
|
109
|
+
onChange?.(next);
|
|
110
|
+
},
|
|
111
|
+
[value, onChange],
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const selectAll = useCallback(() => {
|
|
115
|
+
onChange?.(options.map((o) => o.value));
|
|
116
|
+
}, [options, onChange]);
|
|
117
|
+
|
|
118
|
+
const deselectAll = useCallback(() => {
|
|
119
|
+
onChange?.([]);
|
|
120
|
+
}, [onChange]);
|
|
121
|
+
|
|
122
|
+
const open = () => {
|
|
123
|
+
setIsOpen(true);
|
|
124
|
+
setActiveIndex(0);
|
|
125
|
+
setQuery("");
|
|
126
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
130
|
+
switch (e.key) {
|
|
131
|
+
case "ArrowDown":
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
setActiveIndex((prev) =>
|
|
134
|
+
filtered.length === 0 ? 0 : (prev + 1) % filtered.length,
|
|
135
|
+
);
|
|
136
|
+
break;
|
|
137
|
+
case "ArrowUp":
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
setActiveIndex((prev) =>
|
|
140
|
+
filtered.length === 0
|
|
141
|
+
? 0
|
|
142
|
+
: (prev - 1 + filtered.length) % filtered.length,
|
|
143
|
+
);
|
|
144
|
+
break;
|
|
145
|
+
case "Enter":
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
if (filtered.length > 0 && activeIndex < filtered.length) {
|
|
148
|
+
toggle(filtered[activeIndex].value);
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
case "Escape":
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
setIsOpen(false);
|
|
154
|
+
setQuery("");
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const defaultRenderOption = (
|
|
160
|
+
opt: CheckboxSelectOption,
|
|
161
|
+
isActive: boolean,
|
|
162
|
+
isSelected: boolean,
|
|
163
|
+
) => (
|
|
164
|
+
<div className="flex items-center gap-2.5">
|
|
165
|
+
<div
|
|
166
|
+
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
|
|
167
|
+
isSelected
|
|
168
|
+
? "border-primary bg-primary"
|
|
169
|
+
: "border-input bg-transparent"
|
|
170
|
+
}`}
|
|
171
|
+
>
|
|
172
|
+
{isSelected && <Check className="h-3 w-3 text-primary-foreground" />}
|
|
173
|
+
</div>
|
|
174
|
+
{opt.icon && (
|
|
175
|
+
<span className="shrink-0 text-muted-foreground">{opt.icon}</span>
|
|
176
|
+
)}
|
|
177
|
+
<span
|
|
178
|
+
className={`flex-1 truncate text-sm ${isActive ? "text-primary font-medium" : ""}`}
|
|
179
|
+
>
|
|
180
|
+
{opt.label}
|
|
181
|
+
</span>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const optionRenderer = renderOption ?? defaultRenderOption;
|
|
186
|
+
const isAbsolute = position === "absolute";
|
|
187
|
+
|
|
188
|
+
const dropdownContent = (
|
|
189
|
+
<>
|
|
190
|
+
<div className="p-1.5">
|
|
191
|
+
<Input
|
|
192
|
+
ref={inputRef}
|
|
193
|
+
icon={Search}
|
|
194
|
+
size="sm"
|
|
195
|
+
value={query}
|
|
196
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
197
|
+
setQuery(e.target.value);
|
|
198
|
+
setActiveIndex(0);
|
|
199
|
+
}}
|
|
200
|
+
onKeyDown={handleKeyDown}
|
|
201
|
+
placeholder={searchPlaceholder}
|
|
202
|
+
className="border-0 shadow-none focus-visible:ring-0 focus-visible:border-transparent"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
{/* Select all / deselect all */}
|
|
206
|
+
<div className="flex items-center justify-between border-t border-border px-3 py-1.5">
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={selectAll}
|
|
210
|
+
className="text-[11px] text-primary hover:underline"
|
|
211
|
+
>
|
|
212
|
+
Zaznacz wszystkie
|
|
213
|
+
</button>
|
|
214
|
+
<button
|
|
215
|
+
type="button"
|
|
216
|
+
onClick={deselectAll}
|
|
217
|
+
className="text-[11px] text-muted-foreground hover:underline"
|
|
218
|
+
>
|
|
219
|
+
Odznacz wszystkie
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
{filtered.length > 0 && (
|
|
223
|
+
<div className="border-t border-border max-h-[240px] overflow-y-auto">
|
|
224
|
+
{filtered.map((opt, i) => (
|
|
225
|
+
<button
|
|
226
|
+
key={opt.value}
|
|
227
|
+
type="button"
|
|
228
|
+
data-active={i === activeIndex}
|
|
229
|
+
onClick={() => toggle(opt.value)}
|
|
230
|
+
onMouseEnter={() => setActiveIndex(i)}
|
|
231
|
+
className={`flex w-full items-center px-3 py-2 text-left transition-colors ${
|
|
232
|
+
i === activeIndex ? "bg-primary/10" : "hover:bg-muted"
|
|
233
|
+
}`}
|
|
234
|
+
>
|
|
235
|
+
{optionRenderer(
|
|
236
|
+
opt,
|
|
237
|
+
i === activeIndex,
|
|
238
|
+
value.includes(opt.value),
|
|
239
|
+
)}
|
|
240
|
+
</button>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
{filtered.length === 0 && query && (
|
|
245
|
+
<div className="border-t border-border px-3 py-3 text-center text-xs text-muted-foreground">
|
|
246
|
+
Brak wyników
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
{footerAction && (
|
|
250
|
+
<div className="border-t border-border px-3 py-2">
|
|
251
|
+
{footerAction}
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
</>
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<div className={className}>
|
|
259
|
+
{label && (
|
|
260
|
+
<label className="block text-xs font-medium text-muted-foreground mb-1">
|
|
261
|
+
{label}
|
|
262
|
+
</label>
|
|
263
|
+
)}
|
|
264
|
+
<div ref={containerRef} className={isAbsolute ? "relative" : ""}>
|
|
265
|
+
{/* Trigger */}
|
|
266
|
+
{!isOpen ? (
|
|
267
|
+
<button
|
|
268
|
+
ref={triggerRef}
|
|
269
|
+
type="button"
|
|
270
|
+
onClick={open}
|
|
271
|
+
className="flex w-full items-center justify-between rounded-md border border-input bg-transparent px-3 h-10 md:h-9 text-base md:text-sm shadow-xs transition-colors hover:bg-muted/50"
|
|
272
|
+
>
|
|
273
|
+
{triggerLabel ? (
|
|
274
|
+
<span className="truncate">{triggerLabel}</span>
|
|
275
|
+
) : (
|
|
276
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
277
|
+
)}
|
|
278
|
+
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
279
|
+
</button>
|
|
280
|
+
) : !isAbsolute ? (
|
|
281
|
+
/* Inline — relative mode */
|
|
282
|
+
<div className="rounded-md border border-input overflow-hidden">
|
|
283
|
+
{dropdownContent}
|
|
284
|
+
</div>
|
|
285
|
+
) : (
|
|
286
|
+
/* Trigger stays visible in absolute mode */
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
onClick={() => {
|
|
290
|
+
setIsOpen(false);
|
|
291
|
+
setQuery("");
|
|
292
|
+
}}
|
|
293
|
+
className="flex w-full items-center justify-between rounded-md border border-ring bg-transparent px-3 h-10 md:h-9 text-base md:text-sm shadow-xs ring-[3px] ring-ring/50"
|
|
294
|
+
>
|
|
295
|
+
{triggerLabel ? (
|
|
296
|
+
<span className="truncate">{triggerLabel}</span>
|
|
297
|
+
) : (
|
|
298
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
299
|
+
)}
|
|
300
|
+
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground rotate-180 transition-transform" />
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
|
|
304
|
+
{/* Absolute dropdown */}
|
|
305
|
+
<AnimatePresence>
|
|
306
|
+
{isOpen && isAbsolute && (
|
|
307
|
+
<motion.div
|
|
308
|
+
initial={{ opacity: 0, y: -4 }}
|
|
309
|
+
animate={{ opacity: 1, y: 0 }}
|
|
310
|
+
exit={{ opacity: 0, y: -4 }}
|
|
311
|
+
transition={{ duration: 0.12 }}
|
|
312
|
+
className="absolute left-0 right-0 z-50 mt-1 rounded-md border border-input bg-card shadow-lg overflow-hidden"
|
|
313
|
+
>
|
|
314
|
+
{dropdownContent}
|
|
315
|
+
</motion.div>
|
|
316
|
+
)}
|
|
317
|
+
</AnimatePresence>
|
|
318
|
+
</div>
|
|
319
|
+
{hasError && (
|
|
320
|
+
<p className="mt-1 text-xs text-destructive">
|
|
321
|
+
{fieldCtx.messages[0]}
|
|
322
|
+
</p>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { TextField } from "./text-field";
|
|
2
|
+
export type { TextFieldProps } from "./text-field";
|
|
3
|
+
export { TextareaField } from "./textarea-field";
|
|
4
|
+
export type { TextareaFieldProps } from "./textarea-field";
|
|
5
|
+
export { TagField } from "./tag-field";
|
|
6
|
+
export type { TagFieldProps } from "./tag-field";
|
|
7
|
+
export { SelectField } from "./select-field";
|
|
8
|
+
export type { SelectFieldProps } from "./select-field";
|
|
9
|
+
export { SuggestionListField } from "./suggestion-list-field";
|
|
10
|
+
export type { SuggestionListFieldProps } from "./suggestion-list-field";
|
|
11
|
+
export { SearchSelectField } from "./search-select-field";
|
|
12
|
+
export type { SearchSelectFieldProps } from "./search-select-field";
|
|
13
|
+
export { CheckboxSelectField } from "./checkbox-select-field";
|
|
14
|
+
export type { CheckboxSelectFieldProps, CheckboxSelectOption } from "./checkbox-select-field";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { SearchSelect } from "../../search-select/search-select";
|
|
4
|
+
import type { SearchSelectProps } from "../../search-select/search-select";
|
|
5
|
+
import { FormFieldContext } from "../field";
|
|
6
|
+
|
|
7
|
+
export type SearchSelectFieldProps = Omit<SearchSelectProps, "value" | "onChange"> & {
|
|
8
|
+
label?: ReactNode;
|
|
9
|
+
value?: string;
|
|
10
|
+
onChange?: (value: string) => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function SearchSelectField({
|
|
15
|
+
label,
|
|
16
|
+
value,
|
|
17
|
+
onChange,
|
|
18
|
+
className,
|
|
19
|
+
...rest
|
|
20
|
+
}: SearchSelectFieldProps) {
|
|
21
|
+
const fieldCtx = useContext(FormFieldContext);
|
|
22
|
+
const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className={className}>
|
|
26
|
+
{label && (
|
|
27
|
+
<label className="block text-sm font-medium text-muted-foreground mb-1.5">
|
|
28
|
+
{label}
|
|
29
|
+
</label>
|
|
30
|
+
)}
|
|
31
|
+
<SearchSelect
|
|
32
|
+
value={value}
|
|
33
|
+
onChange={(val) => onChange?.(val)}
|
|
34
|
+
{...rest}
|
|
35
|
+
/>
|
|
36
|
+
{hasError && (
|
|
37
|
+
<p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
import { inputVariants } from "../../input/input";
|
|
5
|
+
import { FormFieldContext } from "../field";
|
|
6
|
+
|
|
7
|
+
export interface SelectFieldProps {
|
|
8
|
+
label?: ReactNode;
|
|
9
|
+
value?: string;
|
|
10
|
+
onChange?: (value: string) => void;
|
|
11
|
+
options: { value: string; label: string }[];
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SelectField({ label, value, onChange, options, placeholder, className }: SelectFieldProps) {
|
|
17
|
+
const fieldCtx = useContext(FormFieldContext);
|
|
18
|
+
const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className={className}>
|
|
22
|
+
{label && (
|
|
23
|
+
<label className="block text-sm font-medium text-muted-foreground mb-1.5">
|
|
24
|
+
{label}
|
|
25
|
+
</label>
|
|
26
|
+
)}
|
|
27
|
+
<select
|
|
28
|
+
value={value ?? ""}
|
|
29
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
30
|
+
className={cn(inputVariants({ size: "default" }), "appearance-none cursor-pointer bg-transparent")}
|
|
31
|
+
>
|
|
32
|
+
{placeholder && <option value="">{placeholder}</option>}
|
|
33
|
+
{options.map((opt) => (
|
|
34
|
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
35
|
+
))}
|
|
36
|
+
</select>
|
|
37
|
+
{hasError && (
|
|
38
|
+
<p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { SuggestionList } from "../../suggestion-list/suggestion-list";
|
|
4
|
+
import type { SuggestionListProps } from "../../suggestion-list/suggestion-list";
|
|
5
|
+
import { FormFieldContext } from "../field";
|
|
6
|
+
|
|
7
|
+
export type SuggestionListFieldProps<T> = Omit<SuggestionListProps<T>, "items" | "onChange"> & {
|
|
8
|
+
label?: ReactNode;
|
|
9
|
+
value?: T[];
|
|
10
|
+
onChange?: (value: T[]) => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function SuggestionListField<T>({
|
|
15
|
+
label,
|
|
16
|
+
value = [],
|
|
17
|
+
onChange,
|
|
18
|
+
className,
|
|
19
|
+
...rest
|
|
20
|
+
}: SuggestionListFieldProps<T>) {
|
|
21
|
+
const fieldCtx = useContext(FormFieldContext);
|
|
22
|
+
const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
|
|
23
|
+
|
|
24
|
+
const showLabel = label && !(rest.initialCloud && (!value || value.length === 0));
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className={className}>
|
|
28
|
+
{showLabel && (
|
|
29
|
+
<label className="block text-sm font-medium text-muted-foreground mb-1.5">
|
|
30
|
+
{label}
|
|
31
|
+
</label>
|
|
32
|
+
)}
|
|
33
|
+
<SuggestionList<T>
|
|
34
|
+
items={value}
|
|
35
|
+
onChange={(items) => onChange?.(items)}
|
|
36
|
+
{...rest}
|
|
37
|
+
/>
|
|
38
|
+
{hasError && (
|
|
39
|
+
<p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { TagList } from "../../tag-list/tag-list";
|
|
4
|
+
import { FormFieldContext } from "../field";
|
|
5
|
+
|
|
6
|
+
export interface TagFieldProps {
|
|
7
|
+
label?: ReactNode;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
value?: string[];
|
|
10
|
+
onChange?: (value: string[]) => void;
|
|
11
|
+
max?: number;
|
|
12
|
+
addLabel?: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TagField({ label, placeholder, value = [], onChange, max, addLabel, className }: TagFieldProps) {
|
|
17
|
+
const fieldCtx = useContext(FormFieldContext);
|
|
18
|
+
const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className={className}>
|
|
22
|
+
{label && (
|
|
23
|
+
<label className="block text-sm font-medium text-muted-foreground mb-1.5">
|
|
24
|
+
{label}
|
|
25
|
+
</label>
|
|
26
|
+
)}
|
|
27
|
+
<TagList
|
|
28
|
+
tags={value}
|
|
29
|
+
onChange={(tags) => onChange?.(tags)}
|
|
30
|
+
placeholder={placeholder}
|
|
31
|
+
max={max}
|
|
32
|
+
addLabel={addLabel}
|
|
33
|
+
/>
|
|
34
|
+
{hasError && (
|
|
35
|
+
<p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Input } from "../../input/input";
|
|
4
|
+
import { FormFieldContext } from "../field";
|
|
5
|
+
|
|
6
|
+
export interface TextFieldProps {
|
|
7
|
+
label?: ReactNode;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
onChange?: (value: string) => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TextField({ label, placeholder, value, onChange, className }: TextFieldProps) {
|
|
15
|
+
const fieldCtx = useContext(FormFieldContext);
|
|
16
|
+
const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={className}>
|
|
20
|
+
{label && (
|
|
21
|
+
<label className="block text-sm font-medium text-muted-foreground mb-1.5">
|
|
22
|
+
{label}
|
|
23
|
+
</label>
|
|
24
|
+
)}
|
|
25
|
+
<Input
|
|
26
|
+
value={value}
|
|
27
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange?.(e.target.value)}
|
|
28
|
+
placeholder={placeholder}
|
|
29
|
+
/>
|
|
30
|
+
{hasError && (
|
|
31
|
+
<p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useContext, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { cn } from "../../../lib/utils";
|
|
4
|
+
import { FormFieldContext } from "../field";
|
|
5
|
+
|
|
6
|
+
export interface TextareaFieldProps {
|
|
7
|
+
label?: ReactNode;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
onChange?: (value: string) => void;
|
|
11
|
+
rows?: number;
|
|
12
|
+
maxHeight?: number;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function TextareaField({ label, placeholder, value, onChange, rows = 1, maxHeight, className }: TextareaFieldProps) {
|
|
17
|
+
const fieldCtx = useContext(FormFieldContext);
|
|
18
|
+
const hasError = fieldCtx?.errors && fieldCtx.messages?.length > 0;
|
|
19
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
20
|
+
const isComposing = useRef(false);
|
|
21
|
+
|
|
22
|
+
// Sync external value → DOM
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!ref.current) return;
|
|
25
|
+
if (ref.current.innerText !== (value ?? "")) {
|
|
26
|
+
ref.current.innerText = value ?? "";
|
|
27
|
+
}
|
|
28
|
+
}, [value]);
|
|
29
|
+
|
|
30
|
+
const handleInput = useCallback(() => {
|
|
31
|
+
if (isComposing.current) return;
|
|
32
|
+
const text = ref.current?.innerText ?? "";
|
|
33
|
+
onChange?.(text);
|
|
34
|
+
}, [onChange]);
|
|
35
|
+
|
|
36
|
+
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
const text = e.clipboardData.getData("text/plain");
|
|
39
|
+
document.execCommand("insertText", false, text);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const minHeight = `${rows * 1.5 + 1}rem`;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={className}>
|
|
46
|
+
{label && (
|
|
47
|
+
<label className="block text-sm font-medium text-muted-foreground mb-1.5">
|
|
48
|
+
{label}
|
|
49
|
+
</label>
|
|
50
|
+
)}
|
|
51
|
+
<div
|
|
52
|
+
ref={ref}
|
|
53
|
+
contentEditable
|
|
54
|
+
role="textbox"
|
|
55
|
+
aria-multiline="true"
|
|
56
|
+
onInput={handleInput}
|
|
57
|
+
onPaste={handlePaste}
|
|
58
|
+
onCompositionStart={() => { isComposing.current = true; }}
|
|
59
|
+
onCompositionEnd={() => {
|
|
60
|
+
isComposing.current = false;
|
|
61
|
+
handleInput();
|
|
62
|
+
}}
|
|
63
|
+
data-placeholder={placeholder}
|
|
64
|
+
className={cn(
|
|
65
|
+
"w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-base md:text-sm shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
66
|
+
"whitespace-pre-wrap break-words",
|
|
67
|
+
maxHeight && "overflow-y-auto",
|
|
68
|
+
"empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground empty:before:pointer-events-none",
|
|
69
|
+
)}
|
|
70
|
+
style={{
|
|
71
|
+
minHeight,
|
|
72
|
+
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
|
|
73
|
+
}}
|
|
74
|
+
suppressContentEditableWarning
|
|
75
|
+
/>
|
|
76
|
+
{hasError && (
|
|
77
|
+
<p className="mt-1 text-xs text-destructive">{fieldCtx.messages[0]}</p>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|