@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,291 @@
1
+ import { Input } from "../input/input";
2
+ import { Search, ChevronDown, X, Check } from "lucide-react";
3
+ import { useState, useRef, useEffect, useMemo, useCallback } from "react";
4
+ import type { ReactNode } from "react";
5
+ import { motion, AnimatePresence } from "framer-motion";
6
+
7
+ export interface SearchSelectOption {
8
+ value: string;
9
+ label: string;
10
+ icon?: ReactNode;
11
+ }
12
+
13
+ export interface SearchSelectProps {
14
+ value?: string;
15
+ onChange?: (value: string) => void;
16
+ options: SearchSelectOption[];
17
+ placeholder?: string;
18
+ searchPlaceholder?: string;
19
+ position?: "relative" | "absolute";
20
+ /** Direction the dropdown opens. Default: "down". */
21
+ direction?: "down" | "up";
22
+ /** Trigger button size. Default: "default". */
23
+ size?: "default" | "sm";
24
+ renderOption?: (option: SearchSelectOption, isActive: boolean, isSelected: boolean) => ReactNode;
25
+ allowClear?: boolean;
26
+ }
27
+
28
+ export function SearchSelect({
29
+ value,
30
+ onChange,
31
+ options,
32
+ placeholder = "Wybierz...",
33
+ searchPlaceholder = "Szukaj...",
34
+ position = "relative",
35
+ direction = "down",
36
+ size = "default",
37
+ renderOption,
38
+ allowClear = true,
39
+ }: SearchSelectProps) {
40
+ const [isOpen, setIsOpen] = useState(false);
41
+ const [query, setQuery] = useState("");
42
+ const [activeIndex, setActiveIndex] = useState(0);
43
+
44
+ const containerRef = useRef<HTMLDivElement>(null);
45
+ const inputRef = useRef<HTMLInputElement>(null);
46
+
47
+ const selectedOption = useMemo(
48
+ () => options.find((o) => o.value === value),
49
+ [options, value],
50
+ );
51
+
52
+ const filtered = useMemo(() => {
53
+ if (!query.trim()) return options;
54
+ const q = query.toLowerCase().trim();
55
+ return options.filter((o) => o.label.toLowerCase().includes(q));
56
+ }, [options, query]);
57
+
58
+ useEffect(() => {
59
+ setActiveIndex((prev) => (filtered.length === 0 ? 0 : Math.min(prev, filtered.length - 1)));
60
+ }, [filtered.length]);
61
+
62
+ // Click outside
63
+ useEffect(() => {
64
+ if (!isOpen) return;
65
+ const handler = (e: MouseEvent) => {
66
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
67
+ setIsOpen(false);
68
+ setQuery("");
69
+ }
70
+ };
71
+ document.addEventListener("mousedown", handler);
72
+ return () => document.removeEventListener("mousedown", handler);
73
+ }, [isOpen]);
74
+
75
+ // Scroll active into view
76
+ useEffect(() => {
77
+ if (!isOpen) return;
78
+ const el = containerRef.current?.querySelector("[data-active='true']");
79
+ if (el) el.scrollIntoView({ block: "nearest" });
80
+ }, [activeIndex, isOpen]);
81
+
82
+ const select = useCallback(
83
+ (val: string) => {
84
+ onChange?.(val);
85
+ setIsOpen(false);
86
+ setQuery("");
87
+ },
88
+ [onChange],
89
+ );
90
+
91
+ const clear = useCallback(() => {
92
+ onChange?.("");
93
+ setIsOpen(false);
94
+ setQuery("");
95
+ }, [onChange]);
96
+
97
+ const open = () => {
98
+ setIsOpen(true);
99
+ setActiveIndex(0);
100
+ setQuery("");
101
+ requestAnimationFrame(() => inputRef.current?.focus());
102
+ };
103
+
104
+ const handleKeyDown = (e: React.KeyboardEvent) => {
105
+ switch (e.key) {
106
+ case "ArrowDown":
107
+ e.preventDefault();
108
+ setActiveIndex((prev) => (filtered.length === 0 ? 0 : (prev + 1) % filtered.length));
109
+ break;
110
+ case "ArrowUp":
111
+ e.preventDefault();
112
+ setActiveIndex((prev) => (filtered.length === 0 ? 0 : (prev - 1 + filtered.length) % filtered.length));
113
+ break;
114
+ case "Enter":
115
+ e.preventDefault();
116
+ if (filtered.length > 0 && activeIndex < filtered.length) {
117
+ select(filtered[activeIndex].value);
118
+ }
119
+ break;
120
+ case "Escape":
121
+ e.preventDefault();
122
+ setIsOpen(false);
123
+ setQuery("");
124
+ break;
125
+ }
126
+ };
127
+
128
+ const defaultRenderOption = (opt: SearchSelectOption, isActive: boolean, isSelected: boolean) => (
129
+ <div className="flex items-center gap-2.5">
130
+ {opt.icon && <span className="shrink-0 text-muted-foreground">{opt.icon}</span>}
131
+ <span className={`flex-1 truncate text-sm ${isActive ? "text-primary font-medium" : ""}`}>
132
+ {opt.label}
133
+ </span>
134
+ {isSelected && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
135
+ </div>
136
+ );
137
+
138
+ const optionRenderer = renderOption ?? defaultRenderOption;
139
+
140
+ const isAbsolute = position === "absolute";
141
+ const isUp = direction === "up";
142
+ const triggerHeight = size === "sm" ? "h-8 text-xs px-2.5" : "h-10 md:h-9 text-base md:text-sm px-3";
143
+
144
+ return (
145
+ <div ref={containerRef} className={isAbsolute ? "relative" : ""}>
146
+ {/* Trigger */}
147
+ {!isOpen ? (
148
+ <button
149
+ type="button"
150
+ onClick={open}
151
+ className={`flex w-full items-center justify-between rounded-md border border-input bg-transparent ${triggerHeight} shadow-xs transition-colors hover:bg-muted/50`}
152
+ >
153
+ {selectedOption ? (
154
+ <div className="flex items-center gap-2 min-w-0">
155
+ {selectedOption.icon && <span className="shrink-0">{selectedOption.icon}</span>}
156
+ <span className="truncate">{selectedOption.label}</span>
157
+ </div>
158
+ ) : (
159
+ <span className="text-muted-foreground">{placeholder}</span>
160
+ )}
161
+ <div className="flex items-center gap-1 shrink-0">
162
+ {allowClear && value && (
163
+ <span
164
+ role="button"
165
+ onClick={(e) => {
166
+ e.stopPropagation();
167
+ clear();
168
+ }}
169
+ className="rounded-full p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors"
170
+ >
171
+ <X className="h-3 w-3" />
172
+ </span>
173
+ )}
174
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
175
+ </div>
176
+ </button>
177
+ ) : !isAbsolute ? (
178
+ /* Inline search — relative mode */
179
+ <div className="rounded-md border border-input overflow-hidden">
180
+ <div className="p-1.5">
181
+ <Input
182
+ ref={inputRef}
183
+ icon={Search}
184
+ size="sm"
185
+ value={query}
186
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
187
+ setQuery(e.target.value);
188
+ setActiveIndex(0);
189
+ }}
190
+ onKeyDown={handleKeyDown}
191
+ placeholder={searchPlaceholder}
192
+ className="border-0 shadow-none focus-visible:ring-0 focus-visible:border-transparent"
193
+ />
194
+ </div>
195
+ {filtered.length > 0 && (
196
+ <div className="border-t border-border max-h-[192px] overflow-y-auto">
197
+ {filtered.map((opt, i) => (
198
+ <button
199
+ key={opt.value}
200
+ type="button"
201
+ data-active={i === activeIndex}
202
+ onClick={() => select(opt.value)}
203
+ onMouseEnter={() => setActiveIndex(i)}
204
+ className={`flex w-full items-center px-3 py-2 text-left transition-colors ${
205
+ i === activeIndex ? "bg-primary/10" : "hover:bg-muted"
206
+ }`}
207
+ >
208
+ {optionRenderer(opt, i === activeIndex, opt.value === value)}
209
+ </button>
210
+ ))}
211
+ </div>
212
+ )}
213
+ {filtered.length === 0 && query && (
214
+ <div className="border-t border-border px-3 py-3 text-center text-xs text-muted-foreground">
215
+ Brak wyników
216
+ </div>
217
+ )}
218
+ </div>
219
+ ) : (
220
+ /* Trigger stays visible in absolute mode */
221
+ <button
222
+ type="button"
223
+ onClick={() => { setIsOpen(false); setQuery(""); }}
224
+ className={`flex w-full items-center justify-between rounded-md border border-ring bg-transparent ${triggerHeight} shadow-xs ring-[3px] ring-ring/50`}
225
+ >
226
+ {selectedOption ? (
227
+ <div className="flex items-center gap-2 min-w-0">
228
+ {selectedOption.icon && <span className="shrink-0">{selectedOption.icon}</span>}
229
+ <span className="truncate">{selectedOption.label}</span>
230
+ </div>
231
+ ) : (
232
+ <span className="text-muted-foreground">{placeholder}</span>
233
+ )}
234
+ <ChevronDown className="h-3.5 w-3.5 text-muted-foreground rotate-180 transition-transform" />
235
+ </button>
236
+ )}
237
+
238
+ {/* Absolute dropdown */}
239
+ <AnimatePresence>
240
+ {isOpen && isAbsolute && (
241
+ <motion.div
242
+ initial={{ opacity: 0, y: isUp ? 4 : -4 }}
243
+ animate={{ opacity: 1, y: 0 }}
244
+ exit={{ opacity: 0, y: isUp ? 4 : -4 }}
245
+ transition={{ duration: 0.12 }}
246
+ className={`absolute left-0 right-0 z-50 rounded-md border border-input bg-card shadow-lg overflow-hidden ${isUp ? "bottom-full mb-1" : "mt-1"}`}
247
+ >
248
+ <div className="p-1.5">
249
+ <Input
250
+ ref={inputRef}
251
+ icon={Search}
252
+ size="sm"
253
+ value={query}
254
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
255
+ setQuery(e.target.value);
256
+ setActiveIndex(0);
257
+ }}
258
+ onKeyDown={handleKeyDown}
259
+ placeholder={searchPlaceholder}
260
+ className="border-0 shadow-none focus-visible:ring-0 focus-visible:border-transparent"
261
+ />
262
+ </div>
263
+ {filtered.length > 0 && (
264
+ <div className="border-t border-border max-h-[192px] overflow-y-auto">
265
+ {filtered.map((opt, i) => (
266
+ <button
267
+ key={opt.value}
268
+ type="button"
269
+ data-active={i === activeIndex}
270
+ onClick={() => select(opt.value)}
271
+ onMouseEnter={() => setActiveIndex(i)}
272
+ className={`flex w-full items-center px-3 py-2 text-left transition-colors ${
273
+ i === activeIndex ? "bg-primary/10" : "hover:bg-muted"
274
+ }`}
275
+ >
276
+ {optionRenderer(opt, i === activeIndex, opt.value === value)}
277
+ </button>
278
+ ))}
279
+ </div>
280
+ )}
281
+ {filtered.length === 0 && query && (
282
+ <div className="border-t border-border px-3 py-3 text-center text-xs text-muted-foreground">
283
+ Brak wyników
284
+ </div>
285
+ )}
286
+ </motion.div>
287
+ )}
288
+ </AnimatePresence>
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,26 @@
1
+ import { Separator as SeparatorPrimitive } from "radix-ui";
2
+ import { cn } from "../../lib/utils";
3
+ import type { SeparatorProps } from "../types";
4
+
5
+ /**
6
+ * Separator — DS wrapper nad Radix Separator.
7
+ */
8
+ export function Separator({
9
+ className,
10
+ orientation = "horizontal",
11
+ decorative = true,
12
+ ...props
13
+ }: SeparatorProps) {
14
+ return (
15
+ <SeparatorPrimitive.Root
16
+ data-slot="separator"
17
+ decorative={decorative}
18
+ orientation={orientation}
19
+ className={cn(
20
+ "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
21
+ className,
22
+ )}
23
+ {...props}
24
+ />
25
+ );
26
+ }