@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,406 @@
|
|
|
1
|
+
import { Input } from "../input/input";
|
|
2
|
+
import { Plus, X, Search, ChevronRight, Pencil } 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 InitialCloudConfig {
|
|
8
|
+
count: number;
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
description?: ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SuggestionListProps<T> {
|
|
14
|
+
items: T[];
|
|
15
|
+
onChange: (items: T[]) => void;
|
|
16
|
+
suggestions: T[];
|
|
17
|
+
|
|
18
|
+
getKey?: (item: T) => string;
|
|
19
|
+
getSearchLabel?: (item: T) => string;
|
|
20
|
+
getIcon?: (item: T) => ReactNode;
|
|
21
|
+
|
|
22
|
+
renderItem?: (item: T, onRemove: () => void) => ReactNode;
|
|
23
|
+
renderSuggestion?: (item: T, isActive: boolean) => ReactNode;
|
|
24
|
+
|
|
25
|
+
allowCustom?: boolean;
|
|
26
|
+
allowDuplicates?: boolean;
|
|
27
|
+
createCustom?: (text: string) => T;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
max?: number;
|
|
30
|
+
initialCloud?: InitialCloudConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const defaultGetKey = <T,>(item: T) => String(item);
|
|
34
|
+
const defaultGetSearchLabel = <T,>(item: T) => String(item);
|
|
35
|
+
const defaultCreateCustom = <T,>(text: string) => text as unknown as T;
|
|
36
|
+
|
|
37
|
+
export function SuggestionList<T>({
|
|
38
|
+
items,
|
|
39
|
+
onChange,
|
|
40
|
+
suggestions,
|
|
41
|
+
getKey = defaultGetKey,
|
|
42
|
+
getSearchLabel = defaultGetSearchLabel,
|
|
43
|
+
getIcon,
|
|
44
|
+
renderItem,
|
|
45
|
+
renderSuggestion,
|
|
46
|
+
allowCustom = true,
|
|
47
|
+
allowDuplicates = false,
|
|
48
|
+
createCustom = defaultCreateCustom,
|
|
49
|
+
placeholder = "Wyszukaj...",
|
|
50
|
+
max,
|
|
51
|
+
initialCloud,
|
|
52
|
+
}: SuggestionListProps<T>) {
|
|
53
|
+
const [inputValue, setInputValue] = useState("");
|
|
54
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
55
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
56
|
+
// Once user interacts (adds/removes or clicks "more"), we leave cloud mode permanently
|
|
57
|
+
const [cloudDismissed, setCloudDismissed] = useState(false);
|
|
58
|
+
|
|
59
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
61
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
62
|
+
|
|
63
|
+
const safeItems = Array.isArray(items) ? items : [];
|
|
64
|
+
|
|
65
|
+
const selectedKeys = useMemo(
|
|
66
|
+
() => new Set(safeItems.map(getKey)),
|
|
67
|
+
[safeItems, getKey],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const filtered = useMemo(() => {
|
|
71
|
+
const query = inputValue.toLowerCase().trim();
|
|
72
|
+
return suggestions.filter((s) => {
|
|
73
|
+
if (!allowDuplicates && selectedKeys.has(getKey(s))) return false;
|
|
74
|
+
if (!query) return true;
|
|
75
|
+
return getSearchLabel(s).toLowerCase().includes(query);
|
|
76
|
+
});
|
|
77
|
+
}, [suggestions, inputValue, selectedKeys, getKey, getSearchLabel, allowDuplicates]);
|
|
78
|
+
|
|
79
|
+
const showCustomOption =
|
|
80
|
+
allowCustom &&
|
|
81
|
+
inputValue.trim() &&
|
|
82
|
+
!filtered.some(
|
|
83
|
+
(s) => getSearchLabel(s).toLowerCase() === inputValue.trim().toLowerCase(),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const totalOptions = filtered.length + (showCustomOption ? 1 : 0);
|
|
87
|
+
|
|
88
|
+
// Show cloud: initial state, no items, not dismissed, config provided
|
|
89
|
+
const showCloud = initialCloud && safeItems.length === 0 && !cloudDismissed && !isEditing;
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
setActiveIndex((prev) => (totalOptions === 0 ? 0 : Math.min(prev, totalOptions - 1)));
|
|
93
|
+
}, [totalOptions]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!isEditing) return;
|
|
97
|
+
const handler = (e: MouseEvent) => {
|
|
98
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
99
|
+
setIsEditing(false);
|
|
100
|
+
setInputValue("");
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
document.addEventListener("mousedown", handler);
|
|
104
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
105
|
+
}, [isEditing]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!isEditing || !dropdownRef.current) return;
|
|
109
|
+
const active = dropdownRef.current.querySelector("[data-active='true']");
|
|
110
|
+
if (active) {
|
|
111
|
+
active.scrollIntoView({ block: "nearest" });
|
|
112
|
+
}
|
|
113
|
+
}, [activeIndex, isEditing]);
|
|
114
|
+
|
|
115
|
+
const addItem = useCallback(
|
|
116
|
+
(item: T) => {
|
|
117
|
+
if (max && safeItems.length >= max) return;
|
|
118
|
+
onChange([...safeItems, item]);
|
|
119
|
+
setInputValue("");
|
|
120
|
+
setActiveIndex(0);
|
|
121
|
+
setCloudDismissed(true);
|
|
122
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
123
|
+
},
|
|
124
|
+
[safeItems, onChange, max],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const removeItemByIndex = useCallback(
|
|
128
|
+
(index: number) => {
|
|
129
|
+
onChange(safeItems.filter((_, i) => i !== index));
|
|
130
|
+
},
|
|
131
|
+
[safeItems, onChange],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const selectActive = useCallback(() => {
|
|
135
|
+
if (totalOptions === 0) return;
|
|
136
|
+
if (activeIndex < filtered.length) {
|
|
137
|
+
addItem(filtered[activeIndex]);
|
|
138
|
+
} else if (showCustomOption) {
|
|
139
|
+
addItem(createCustom(inputValue.trim()));
|
|
140
|
+
}
|
|
141
|
+
}, [activeIndex, filtered, showCustomOption, addItem, createCustom, inputValue, totalOptions]);
|
|
142
|
+
|
|
143
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
144
|
+
switch (e.key) {
|
|
145
|
+
case "ArrowDown":
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
setActiveIndex((prev) => (totalOptions === 0 ? 0 : (prev + 1) % totalOptions));
|
|
148
|
+
break;
|
|
149
|
+
case "ArrowUp":
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
setActiveIndex((prev) => (totalOptions === 0 ? 0 : (prev - 1 + totalOptions) % totalOptions));
|
|
152
|
+
break;
|
|
153
|
+
case "Enter":
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
selectActive();
|
|
156
|
+
break;
|
|
157
|
+
case "Escape":
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
setIsEditing(false);
|
|
160
|
+
setInputValue("");
|
|
161
|
+
break;
|
|
162
|
+
case "Backspace":
|
|
163
|
+
if (!inputValue && safeItems.length > 0) {
|
|
164
|
+
onChange(safeItems.slice(0, -1));
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const openEditing = () => {
|
|
171
|
+
if (max && safeItems.length >= max) return;
|
|
172
|
+
setCloudDismissed(true);
|
|
173
|
+
setIsEditing(true);
|
|
174
|
+
setActiveIndex(0);
|
|
175
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const isAtMax = max !== undefined && safeItems.length >= max;
|
|
179
|
+
|
|
180
|
+
const defaultRenderItem = (item: T, onRemove: () => void) => (
|
|
181
|
+
<div className="flex w-full items-center gap-3 px-3.5 py-2.5">
|
|
182
|
+
{getIcon && (
|
|
183
|
+
<span className="shrink-0 text-muted-foreground">{getIcon(item)}</span>
|
|
184
|
+
)}
|
|
185
|
+
<span className="flex-1 text-sm">{getSearchLabel(item)}</span>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
onClick={(e) => {
|
|
189
|
+
e.stopPropagation();
|
|
190
|
+
onRemove();
|
|
191
|
+
}}
|
|
192
|
+
className="shrink-0 rounded-full p-1 text-muted-foreground/50 transition-colors hover:bg-muted hover:text-foreground"
|
|
193
|
+
>
|
|
194
|
+
<X className="h-3.5 w-3.5" />
|
|
195
|
+
</button>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const defaultRenderSuggestion = (item: T, isActive: boolean) => (
|
|
200
|
+
<div className="flex items-center gap-2.5">
|
|
201
|
+
{getIcon && (
|
|
202
|
+
<span className="shrink-0 text-muted-foreground">{getIcon(item)}</span>
|
|
203
|
+
)}
|
|
204
|
+
<span className={`block truncate text-sm ${isActive ? "text-primary font-medium" : ""}`}>
|
|
205
|
+
{getSearchLabel(item)}
|
|
206
|
+
</span>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const itemRenderer = renderItem ?? defaultRenderItem;
|
|
211
|
+
const suggestionRenderer = renderSuggestion ?? defaultRenderSuggestion;
|
|
212
|
+
|
|
213
|
+
const addFromCloud = useCallback(
|
|
214
|
+
(item: T) => {
|
|
215
|
+
if (max && safeItems.length >= max) return;
|
|
216
|
+
onChange([...safeItems, item]);
|
|
217
|
+
setInputValue("");
|
|
218
|
+
setActiveIndex(0);
|
|
219
|
+
setCloudDismissed(true);
|
|
220
|
+
setIsEditing(true);
|
|
221
|
+
requestAnimationFrame(() => inputRef.current?.focus());
|
|
222
|
+
},
|
|
223
|
+
[safeItems, onChange, max],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// ── Cloud view ──────────────────────────────────────────────
|
|
227
|
+
if (showCloud) {
|
|
228
|
+
const cloudCount = initialCloud.count;
|
|
229
|
+
const availableSuggestions = allowDuplicates
|
|
230
|
+
? suggestions
|
|
231
|
+
: suggestions.filter((s) => !selectedKeys.has(getKey(s)));
|
|
232
|
+
const visibleSuggestions = availableSuggestions.slice(0, cloudCount);
|
|
233
|
+
const hasMore = availableSuggestions.length > cloudCount;
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<motion.div
|
|
237
|
+
initial={{ opacity: 0, y: 8 }}
|
|
238
|
+
animate={{ opacity: 1, y: 0 }}
|
|
239
|
+
transition={{ duration: 0.2 }}
|
|
240
|
+
className="space-y-3"
|
|
241
|
+
>
|
|
242
|
+
{(initialCloud.label || initialCloud.description) && (
|
|
243
|
+
<div className="text-center space-y-1 px-2">
|
|
244
|
+
{initialCloud.label && (
|
|
245
|
+
<p className="text-sm font-medium">{initialCloud.label}</p>
|
|
246
|
+
)}
|
|
247
|
+
{initialCloud.description && (
|
|
248
|
+
<p className="text-xs text-muted-foreground">{initialCloud.description}</p>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
<div className="flex flex-wrap gap-2 justify-center">
|
|
253
|
+
{visibleSuggestions.map((item, i) => (
|
|
254
|
+
<motion.button
|
|
255
|
+
key={getKey(item)}
|
|
256
|
+
type="button"
|
|
257
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
258
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
259
|
+
transition={{ duration: 0.15, delay: i * 0.03 }}
|
|
260
|
+
onClick={() => addFromCloud(item)}
|
|
261
|
+
className="inline-flex items-center gap-1.5 rounded-xl border border-border bg-card px-3 py-1.5 text-sm transition-all hover:border-primary/40 hover:bg-primary/5 hover:text-primary hover:shadow-sm active:scale-95"
|
|
262
|
+
>
|
|
263
|
+
{getIcon && (
|
|
264
|
+
<span className="text-muted-foreground">{getIcon(item)}</span>
|
|
265
|
+
)}
|
|
266
|
+
{getSearchLabel(item)}
|
|
267
|
+
</motion.button>
|
|
268
|
+
))}
|
|
269
|
+
{(hasMore || allowCustom) && (
|
|
270
|
+
<motion.button
|
|
271
|
+
type="button"
|
|
272
|
+
initial={{ opacity: 0, scale: 0.9 }}
|
|
273
|
+
animate={{ opacity: 1, scale: 1 }}
|
|
274
|
+
transition={{ duration: 0.15, delay: visibleSuggestions.length * 0.03 }}
|
|
275
|
+
onClick={openEditing}
|
|
276
|
+
className="inline-flex items-center gap-1.5 rounded-xl border border-dashed border-muted-foreground/30 px-3 py-1.5 text-sm text-muted-foreground transition-all hover:border-primary/40 hover:bg-primary/5 hover:text-primary active:scale-95"
|
|
277
|
+
>
|
|
278
|
+
{allowCustom ? (
|
|
279
|
+
<>
|
|
280
|
+
<Pencil className="h-3 w-3" />
|
|
281
|
+
Własny
|
|
282
|
+
</>
|
|
283
|
+
) : (
|
|
284
|
+
<>
|
|
285
|
+
Więcej
|
|
286
|
+
<ChevronRight className="h-3 w-3" />
|
|
287
|
+
</>
|
|
288
|
+
)}
|
|
289
|
+
</motion.button>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
</motion.div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── List view ───────────────────────────────────────────────
|
|
297
|
+
return (
|
|
298
|
+
<div ref={containerRef} className="rounded-xl border border-border overflow-hidden">
|
|
299
|
+
{/* Items */}
|
|
300
|
+
<AnimatePresence initial={false}>
|
|
301
|
+
{safeItems.map((item, index) => (
|
|
302
|
+
<motion.div
|
|
303
|
+
key={`${getKey(item)}-${index}`}
|
|
304
|
+
initial={{ height: 0, opacity: 0 }}
|
|
305
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
306
|
+
exit={{ height: 0, opacity: 0 }}
|
|
307
|
+
transition={{ duration: 0.15 }}
|
|
308
|
+
className="overflow-hidden"
|
|
309
|
+
>
|
|
310
|
+
<div className="w-full border-b border-border last:border-b-0">
|
|
311
|
+
{itemRenderer(item, () => removeItemByIndex(index))}
|
|
312
|
+
</div>
|
|
313
|
+
</motion.div>
|
|
314
|
+
))}
|
|
315
|
+
</AnimatePresence>
|
|
316
|
+
|
|
317
|
+
{/* Placeholder / Input + Dropdown */}
|
|
318
|
+
{!isAtMax && (
|
|
319
|
+
<div className={safeItems.length > 0 ? "border-t border-border" : ""}>
|
|
320
|
+
{isEditing ? (
|
|
321
|
+
<>
|
|
322
|
+
<div className="p-1.5">
|
|
323
|
+
<Input
|
|
324
|
+
ref={inputRef}
|
|
325
|
+
icon={Search}
|
|
326
|
+
size="sm"
|
|
327
|
+
value={inputValue}
|
|
328
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
329
|
+
setInputValue(e.target.value);
|
|
330
|
+
setActiveIndex(0);
|
|
331
|
+
}}
|
|
332
|
+
onKeyDown={handleKeyDown}
|
|
333
|
+
placeholder={placeholder}
|
|
334
|
+
className="border-0 shadow-none focus-visible:ring-0 focus-visible:border-transparent"
|
|
335
|
+
/>
|
|
336
|
+
</div>
|
|
337
|
+
<AnimatePresence>
|
|
338
|
+
{totalOptions > 0 && (
|
|
339
|
+
<motion.div
|
|
340
|
+
ref={dropdownRef}
|
|
341
|
+
initial={{ height: 0, opacity: 0 }}
|
|
342
|
+
animate={{ height: "auto", opacity: 1 }}
|
|
343
|
+
exit={{ height: 0, opacity: 0 }}
|
|
344
|
+
transition={{ duration: 0.15 }}
|
|
345
|
+
className="overflow-hidden border-t border-border"
|
|
346
|
+
>
|
|
347
|
+
<div className="max-h-[192px] overflow-y-auto">
|
|
348
|
+
{filtered.map((item, i) => (
|
|
349
|
+
<button
|
|
350
|
+
key={`suggestion-${getKey(item)}-${i}`}
|
|
351
|
+
type="button"
|
|
352
|
+
data-active={i === activeIndex}
|
|
353
|
+
onClick={() => addItem(item)}
|
|
354
|
+
onMouseEnter={() => setActiveIndex(i)}
|
|
355
|
+
className={`flex w-full items-center px-3 py-2 text-left transition-colors ${
|
|
356
|
+
i === activeIndex ? "bg-primary/10" : "hover:bg-muted"
|
|
357
|
+
}`}
|
|
358
|
+
>
|
|
359
|
+
{suggestionRenderer(item, i === activeIndex)}
|
|
360
|
+
</button>
|
|
361
|
+
))}
|
|
362
|
+
{showCustomOption && (
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
data-active={activeIndex === filtered.length}
|
|
366
|
+
onClick={() => addItem(createCustom(inputValue.trim()))}
|
|
367
|
+
onMouseEnter={() => setActiveIndex(filtered.length)}
|
|
368
|
+
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
|
369
|
+
activeIndex === filtered.length ? "bg-primary/10" : "hover:bg-muted"
|
|
370
|
+
}`}
|
|
371
|
+
>
|
|
372
|
+
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
373
|
+
<span>
|
|
374
|
+
Dodaj <span className="font-medium text-primary">"{inputValue.trim()}"</span>
|
|
375
|
+
</span>
|
|
376
|
+
</button>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
</motion.div>
|
|
380
|
+
)}
|
|
381
|
+
</AnimatePresence>
|
|
382
|
+
</>
|
|
383
|
+
) : (
|
|
384
|
+
<button
|
|
385
|
+
type="button"
|
|
386
|
+
onClick={openEditing}
|
|
387
|
+
className="flex w-full items-center gap-2 px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
|
388
|
+
>
|
|
389
|
+
<Plus className="h-3.5 w-3.5" />
|
|
390
|
+
{placeholder}
|
|
391
|
+
</button>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
|
|
396
|
+
{/* Counter */}
|
|
397
|
+
{max && (
|
|
398
|
+
<div className="border-t border-border px-3 py-1.5">
|
|
399
|
+
<p className="text-xs text-muted-foreground">
|
|
400
|
+
{safeItems.length}/{max}
|
|
401
|
+
</p>
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Input } from "../input/input";
|
|
2
|
+
import { Plus, X } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
export interface TagListProps {
|
|
7
|
+
tags: string[];
|
|
8
|
+
onChange: (tags: string[]) => void;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
max?: number;
|
|
11
|
+
addLabel?: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function TagList({ tags, onChange, placeholder, max, addLabel = "Dodaj" }: TagListProps) {
|
|
15
|
+
const [input, setInput] = useState("");
|
|
16
|
+
|
|
17
|
+
const addTag = () => {
|
|
18
|
+
const trimmed = input.trim();
|
|
19
|
+
if (!trimmed || tags.includes(trimmed)) return;
|
|
20
|
+
if (max && tags.length >= max) return;
|
|
21
|
+
onChange([...tags, trimmed]);
|
|
22
|
+
setInput("");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const removeTag = (tag: string) => {
|
|
26
|
+
onChange(tags.filter((t) => t !== tag));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
30
|
+
if (e.key === "Enter") {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
addTag();
|
|
33
|
+
}
|
|
34
|
+
if (e.key === "Backspace" && !input && tags.length > 0) {
|
|
35
|
+
onChange(tags.slice(0, -1));
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const isAtMax = max !== undefined && tags.length >= max;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-2">
|
|
43
|
+
{tags.length > 0 && (
|
|
44
|
+
<div className="flex flex-wrap gap-2">
|
|
45
|
+
{tags.map((tag) => (
|
|
46
|
+
<span
|
|
47
|
+
key={tag}
|
|
48
|
+
className="group inline-flex items-center gap-1.5 rounded-lg bg-primary/10 border border-primary/20 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/15"
|
|
49
|
+
>
|
|
50
|
+
{tag}
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => removeTag(tag)}
|
|
53
|
+
className="rounded-full p-0.5 hover:bg-primary/20 transition-colors"
|
|
54
|
+
>
|
|
55
|
+
<X className="h-3 w-3" />
|
|
56
|
+
</button>
|
|
57
|
+
</span>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
{!isAtMax && (
|
|
62
|
+
<div className="flex gap-2">
|
|
63
|
+
<Input
|
|
64
|
+
value={input}
|
|
65
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInput(e.target.value)}
|
|
66
|
+
onKeyDown={handleKeyDown}
|
|
67
|
+
placeholder={placeholder}
|
|
68
|
+
className="flex-1"
|
|
69
|
+
/>
|
|
70
|
+
<button
|
|
71
|
+
onClick={addTag}
|
|
72
|
+
disabled={!input.trim()}
|
|
73
|
+
className="flex items-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
|
|
74
|
+
>
|
|
75
|
+
<Plus className="h-4 w-4" />
|
|
76
|
+
{addLabel}
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
{max && (
|
|
81
|
+
<p className="text-xs text-muted-foreground">
|
|
82
|
+
{tags.length}/{max}
|
|
83
|
+
</p>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Tooltip as ShadcnTooltip,
|
|
3
|
+
TooltipContent,
|
|
4
|
+
TooltipTrigger,
|
|
5
|
+
} from "../../ui/tooltip";
|
|
6
|
+
import type { TooltipProps } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tooltip — DS wrapper nad shadcn Tooltip.
|
|
10
|
+
*
|
|
11
|
+
* Jeśli `content` jest falsy, renderuje children bez wrappera.
|
|
12
|
+
* Nadpisywalny przez DesignSystemProvider.
|
|
13
|
+
*/
|
|
14
|
+
export function Tooltip({
|
|
15
|
+
children,
|
|
16
|
+
content,
|
|
17
|
+
side = "bottom",
|
|
18
|
+
className,
|
|
19
|
+
}: TooltipProps) {
|
|
20
|
+
if (!content) return children;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<ShadcnTooltip>
|
|
24
|
+
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
25
|
+
<TooltipContent
|
|
26
|
+
side={side}
|
|
27
|
+
className={className ?? "max-w-52 text-center"}
|
|
28
|
+
>
|
|
29
|
+
{content}
|
|
30
|
+
</TooltipContent>
|
|
31
|
+
</ShadcnTooltip>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DS layout transition presets.
|
|
3
|
+
* Jedno miejsce do tuningu animacji — wszystkie layout komponenty importują stąd.
|
|
4
|
+
*/
|
|
5
|
+
export const dsTransitions = {
|
|
6
|
+
/** Szybki spring — expand/collapse paneli, nav overflow. */
|
|
7
|
+
snappy: { type: "spring" as const, stiffness: 500, damping: 35 },
|
|
8
|
+
/** Łagodniejszy spring — layout morphing, Box. */
|
|
9
|
+
smooth: { type: "spring" as const, stiffness: 400, damping: 30 },
|
|
10
|
+
/** Prosty fade — overlay. */
|
|
11
|
+
fade: { duration: 0.15 },
|
|
12
|
+
} as const;
|
package/src/ds/types.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Display Modes — uniwersalne layout hints
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export type DisplayMode = "default" | "compact" | "minimal" | "expanded";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Component overrides — pełna zamiana komponentu
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
/** Mapa nazw komponentów DS → ich implementacje. */
|
|
14
|
+
export interface DSComponentMap {
|
|
15
|
+
Box: ComponentType<BoxProps>;
|
|
16
|
+
Button: ComponentType<ButtonProps>;
|
|
17
|
+
Tooltip: ComponentType<TooltipProps>;
|
|
18
|
+
Badge: ComponentType<BadgeProps>;
|
|
19
|
+
Separator: ComponentType<SeparatorProps>;
|
|
20
|
+
Avatar: ComponentType<AvatarProps>;
|
|
21
|
+
Input: ComponentType<InputProps>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Partial override — moduł nadpisuje tylko wybrane komponenty. */
|
|
25
|
+
export type DSComponentOverrides = Partial<DSComponentMap>;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Variant overrides — merge z domyślnymi CVA wariantami
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export type CVAVariantOverride = Record<string, Record<string, string>>;
|
|
32
|
+
|
|
33
|
+
export type DSVariantOverrides = Partial<
|
|
34
|
+
Record<keyof DSComponentMap, CVAVariantOverride>
|
|
35
|
+
>;
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Button
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export interface ButtonProps {
|
|
42
|
+
icon?: ComponentType<{ className?: string }>;
|
|
43
|
+
label?: ReactNode;
|
|
44
|
+
right?: ReactNode;
|
|
45
|
+
tooltip?: ReactNode;
|
|
46
|
+
isActive?: boolean;
|
|
47
|
+
displayMode?: DisplayMode;
|
|
48
|
+
variant?:
|
|
49
|
+
| "default"
|
|
50
|
+
| "secondary"
|
|
51
|
+
| "ghost"
|
|
52
|
+
| "outline"
|
|
53
|
+
| "destructive"
|
|
54
|
+
| "link";
|
|
55
|
+
size?: "default" | "sm" | "xs" | "lg" | "icon" | "icon-sm" | "icon-xs";
|
|
56
|
+
className?: string;
|
|
57
|
+
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
58
|
+
disabled?: boolean;
|
|
59
|
+
asChild?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Tooltip
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export interface TooltipProps {
|
|
67
|
+
children: ReactElement;
|
|
68
|
+
content?: ReactNode;
|
|
69
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Badge
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export interface BadgeProps {
|
|
78
|
+
children?: ReactNode;
|
|
79
|
+
variant?: "default" | "secondary" | "destructive" | "outline" | "ghost";
|
|
80
|
+
display?: DisplayMode;
|
|
81
|
+
asChild?: boolean;
|
|
82
|
+
className?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Separator
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export interface SeparatorProps {
|
|
90
|
+
orientation?: "horizontal" | "vertical";
|
|
91
|
+
decorative?: boolean;
|
|
92
|
+
className?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Avatar
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
export interface AvatarProps {
|
|
100
|
+
src?: string;
|
|
101
|
+
fallback?: ReactNode;
|
|
102
|
+
size?: "default" | "sm" | "lg" | "xs";
|
|
103
|
+
display?: DisplayMode;
|
|
104
|
+
className?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Input
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
export interface InputProps extends Omit<
|
|
112
|
+
React.InputHTMLAttributes<HTMLInputElement>,
|
|
113
|
+
"size"
|
|
114
|
+
> {
|
|
115
|
+
icon?: ComponentType<{ className?: string }>;
|
|
116
|
+
iconSide?: "left" | "right";
|
|
117
|
+
size?: "default" | "sm" | "xs" | "lg";
|
|
118
|
+
display?: DisplayMode;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Box
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
export interface BoxProps {
|
|
126
|
+
variant?: "default" | "ghost";
|
|
127
|
+
displayMode?: DisplayMode;
|
|
128
|
+
layoutId?: string;
|
|
129
|
+
className?: string;
|
|
130
|
+
children?: ReactNode;
|
|
131
|
+
}
|