@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,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
|
+
}
|