@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.
Files changed (49) hide show
  1. package/package.json +42 -0
  2. package/src/ds/avatar/avatar.tsx +86 -0
  3. package/src/ds/badge/badge.tsx +61 -0
  4. package/src/ds/bento-card/bento-card.tsx +70 -0
  5. package/src/ds/bento-grid/bento-grid.tsx +52 -0
  6. package/src/ds/box/box.tsx +96 -0
  7. package/src/ds/button/button.tsx +191 -0
  8. package/src/ds/card-modal/card-modal.tsx +161 -0
  9. package/src/ds/display-mode.tsx +32 -0
  10. package/src/ds/ds-provider.tsx +85 -0
  11. package/src/ds/form/field.tsx +124 -0
  12. package/src/ds/form/fields/checkbox-select-field.tsx +326 -0
  13. package/src/ds/form/fields/index.ts +14 -0
  14. package/src/ds/form/fields/search-select-field.tsx +41 -0
  15. package/src/ds/form/fields/select-field.tsx +42 -0
  16. package/src/ds/form/fields/suggestion-list-field.tsx +43 -0
  17. package/src/ds/form/fields/tag-field.tsx +39 -0
  18. package/src/ds/form/fields/text-field.tsx +35 -0
  19. package/src/ds/form/fields/textarea-field.tsx +81 -0
  20. package/src/ds/form/form-part.tsx +79 -0
  21. package/src/ds/form/form.tsx +299 -0
  22. package/src/ds/form/index.tsx +5 -0
  23. package/src/ds/form/message.tsx +14 -0
  24. package/src/ds/input/input.tsx +115 -0
  25. package/src/ds/merge-variants.ts +26 -0
  26. package/src/ds/search-select/search-select.tsx +291 -0
  27. package/src/ds/separator/separator.tsx +26 -0
  28. package/src/ds/suggestion-list/suggestion-list.tsx +406 -0
  29. package/src/ds/tag-list/tag-list.tsx +87 -0
  30. package/src/ds/tooltip/tooltip.tsx +33 -0
  31. package/src/ds/transitions.ts +12 -0
  32. package/src/ds/types.ts +131 -0
  33. package/src/index.ts +115 -0
  34. package/src/layout/drag-handle.tsx +117 -0
  35. package/src/layout/dynamic-slot.tsx +95 -0
  36. package/src/layout/expandable-panel.tsx +57 -0
  37. package/src/layout/layout.tsx +323 -0
  38. package/src/layout/overlay-provider.tsx +103 -0
  39. package/src/layout/overlay.tsx +33 -0
  40. package/src/layout/router.tsx +101 -0
  41. package/src/layout/scroll-nav.tsx +121 -0
  42. package/src/layout/slot-render-context.tsx +14 -0
  43. package/src/layout/sub-nav-shell.tsx +41 -0
  44. package/src/layout/toolbar-expand.tsx +70 -0
  45. package/src/layout/transitions.ts +12 -0
  46. package/src/layout/use-expandable.ts +59 -0
  47. package/src/lib/utils.ts +6 -0
  48. package/src/ui/tooltip.tsx +59 -0
  49. 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
+ }