sonance-brand-mcp 1.0.0
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/dist/assets/BRAND_GUIDELINES.md +361 -0
- package/dist/assets/components/accordion.tsx +141 -0
- package/dist/assets/components/alert.tsx +78 -0
- package/dist/assets/components/autocomplete.tsx +246 -0
- package/dist/assets/components/avatar.tsx +105 -0
- package/dist/assets/components/badge.tsx +64 -0
- package/dist/assets/components/breadcrumbs.tsx +149 -0
- package/dist/assets/components/button.tsx +50 -0
- package/dist/assets/components/calendar.tsx +145 -0
- package/dist/assets/components/card.tsx +70 -0
- package/dist/assets/components/checkbox-group.tsx +170 -0
- package/dist/assets/components/checkbox.tsx +58 -0
- package/dist/assets/components/code.tsx +175 -0
- package/dist/assets/components/date-input.tsx +113 -0
- package/dist/assets/components/date-picker.tsx +114 -0
- package/dist/assets/components/date-range-picker.tsx +133 -0
- package/dist/assets/components/dialog.tsx +158 -0
- package/dist/assets/components/divider.tsx +50 -0
- package/dist/assets/components/drawer.tsx +149 -0
- package/dist/assets/components/dropdown.tsx +213 -0
- package/dist/assets/components/form.tsx +190 -0
- package/dist/assets/components/image.tsx +173 -0
- package/dist/assets/components/input-otp.tsx +176 -0
- package/dist/assets/components/input.tsx +37 -0
- package/dist/assets/components/kbd.tsx +126 -0
- package/dist/assets/components/link.tsx +99 -0
- package/dist/assets/components/listbox.tsx +174 -0
- package/dist/assets/components/navbar.tsx +212 -0
- package/dist/assets/components/number-input.tsx +204 -0
- package/dist/assets/components/pagination.tsx +191 -0
- package/dist/assets/components/popover.tsx +111 -0
- package/dist/assets/components/progress.tsx +119 -0
- package/dist/assets/components/radio-group.tsx +123 -0
- package/dist/assets/components/range-calendar.tsx +206 -0
- package/dist/assets/components/scroll-shadow.tsx +131 -0
- package/dist/assets/components/select.tsx +183 -0
- package/dist/assets/components/skeleton.tsx +114 -0
- package/dist/assets/components/slider.tsx +155 -0
- package/dist/assets/components/spacer.tsx +72 -0
- package/dist/assets/components/spinner.tsx +77 -0
- package/dist/assets/components/switch.tsx +64 -0
- package/dist/assets/components/table.tsx +122 -0
- package/dist/assets/components/tabs.tsx +127 -0
- package/dist/assets/components/textarea.tsx +38 -0
- package/dist/assets/components/theme-toggle.tsx +81 -0
- package/dist/assets/components/time-input.tsx +176 -0
- package/dist/assets/components/toast.tsx +193 -0
- package/dist/assets/components/tooltip.tsx +92 -0
- package/dist/assets/components/user.tsx +171 -0
- package/dist/assets/globals.css +483 -0
- package/dist/assets/logo-manifest.json +65 -0
- package/dist/assets/utils.ts +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +447 -0
- package/package.json +49 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useMemo } from "react";
|
|
4
|
+
import { Search, X, ChevronDown, Check, Loader2 } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
interface AutocompleteOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AutocompleteProps {
|
|
15
|
+
options: AutocompleteOption[];
|
|
16
|
+
value?: string;
|
|
17
|
+
defaultValue?: string;
|
|
18
|
+
onValueChange?: (value: string) => void;
|
|
19
|
+
onInputChange?: (input: string) => void;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
label?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
loading?: boolean;
|
|
25
|
+
allowCustomValue?: boolean;
|
|
26
|
+
emptyMessage?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function Autocomplete({
|
|
31
|
+
options,
|
|
32
|
+
value: controlledValue,
|
|
33
|
+
defaultValue = "",
|
|
34
|
+
onValueChange,
|
|
35
|
+
onInputChange,
|
|
36
|
+
placeholder = "Search...",
|
|
37
|
+
label,
|
|
38
|
+
error,
|
|
39
|
+
disabled = false,
|
|
40
|
+
loading = false,
|
|
41
|
+
allowCustomValue = false,
|
|
42
|
+
emptyMessage = "No results found",
|
|
43
|
+
className,
|
|
44
|
+
}: AutocompleteProps) {
|
|
45
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
46
|
+
const [inputValue, setInputValue] = useState("");
|
|
47
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
48
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
49
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
50
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
51
|
+
|
|
52
|
+
const value = controlledValue ?? internalValue;
|
|
53
|
+
const selectedOption = options.find((opt) => opt.value === value);
|
|
54
|
+
|
|
55
|
+
// Filter options based on input
|
|
56
|
+
const filteredOptions = useMemo(() => {
|
|
57
|
+
if (!inputValue) return options;
|
|
58
|
+
const searchTerm = inputValue.toLowerCase();
|
|
59
|
+
return options.filter(
|
|
60
|
+
(opt) =>
|
|
61
|
+
opt.label.toLowerCase().includes(searchTerm) ||
|
|
62
|
+
opt.description?.toLowerCase().includes(searchTerm)
|
|
63
|
+
);
|
|
64
|
+
}, [options, inputValue]);
|
|
65
|
+
|
|
66
|
+
// Sync input value with selected option
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (selectedOption && !isOpen) {
|
|
69
|
+
setInputValue(selectedOption.label);
|
|
70
|
+
}
|
|
71
|
+
}, [selectedOption, isOpen]);
|
|
72
|
+
|
|
73
|
+
const handleSelect = (optionValue: string) => {
|
|
74
|
+
setInternalValue(optionValue);
|
|
75
|
+
onValueChange?.(optionValue);
|
|
76
|
+
setIsOpen(false);
|
|
77
|
+
setHighlightedIndex(-1);
|
|
78
|
+
|
|
79
|
+
const option = options.find((opt) => opt.value === optionValue);
|
|
80
|
+
if (option) {
|
|
81
|
+
setInputValue(option.label);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
86
|
+
const newValue = e.target.value;
|
|
87
|
+
setInputValue(newValue);
|
|
88
|
+
onInputChange?.(newValue);
|
|
89
|
+
setIsOpen(true);
|
|
90
|
+
setHighlightedIndex(0);
|
|
91
|
+
|
|
92
|
+
if (allowCustomValue) {
|
|
93
|
+
setInternalValue(newValue);
|
|
94
|
+
onValueChange?.(newValue);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handleClear = () => {
|
|
99
|
+
setInputValue("");
|
|
100
|
+
setInternalValue("");
|
|
101
|
+
onValueChange?.("");
|
|
102
|
+
inputRef.current?.focus();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
106
|
+
if (!isOpen) {
|
|
107
|
+
if (e.key === "ArrowDown" || e.key === "Enter") {
|
|
108
|
+
setIsOpen(true);
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
switch (e.key) {
|
|
114
|
+
case "ArrowDown":
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
setHighlightedIndex((prev) =>
|
|
117
|
+
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
|
118
|
+
);
|
|
119
|
+
break;
|
|
120
|
+
case "ArrowUp":
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
|
|
123
|
+
break;
|
|
124
|
+
case "Enter":
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
|
|
127
|
+
handleSelect(filteredOptions[highlightedIndex].value);
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
case "Escape":
|
|
131
|
+
setIsOpen(false);
|
|
132
|
+
setHighlightedIndex(-1);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Close on click outside
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
140
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
141
|
+
setIsOpen(false);
|
|
142
|
+
if (selectedOption) {
|
|
143
|
+
setInputValue(selectedOption.label);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
149
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
150
|
+
}, [selectedOption]);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div ref={containerRef} className={cn("w-full", className)}>
|
|
154
|
+
{label && (
|
|
155
|
+
<label className="mb-2 block text-xs font-medium uppercase tracking-widest text-foreground-muted">
|
|
156
|
+
{label}
|
|
157
|
+
</label>
|
|
158
|
+
)}
|
|
159
|
+
<div className="relative">
|
|
160
|
+
<div className="relative">
|
|
161
|
+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-foreground-muted" />
|
|
162
|
+
<input
|
|
163
|
+
ref={inputRef}
|
|
164
|
+
type="text"
|
|
165
|
+
value={inputValue}
|
|
166
|
+
onChange={handleInputChange}
|
|
167
|
+
onFocus={() => setIsOpen(true)}
|
|
168
|
+
onKeyDown={handleKeyDown}
|
|
169
|
+
placeholder={placeholder}
|
|
170
|
+
disabled={disabled}
|
|
171
|
+
className={cn(
|
|
172
|
+
"w-full border border-input-border bg-input py-3 pl-10 pr-10",
|
|
173
|
+
"text-foreground placeholder:text-input-placeholder",
|
|
174
|
+
"transition-colors duration-200",
|
|
175
|
+
"focus:border-input-focus focus:outline-none",
|
|
176
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
177
|
+
error && "border-error",
|
|
178
|
+
isOpen && "border-input-focus"
|
|
179
|
+
)}
|
|
180
|
+
/>
|
|
181
|
+
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
|
182
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin text-foreground-muted" />}
|
|
183
|
+
{inputValue && !loading && (
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
onClick={handleClear}
|
|
187
|
+
className="p-0.5 hover:text-foreground text-foreground-muted"
|
|
188
|
+
>
|
|
189
|
+
<X className="h-4 w-4" />
|
|
190
|
+
</button>
|
|
191
|
+
)}
|
|
192
|
+
<ChevronDown
|
|
193
|
+
className={cn(
|
|
194
|
+
"h-4 w-4 text-foreground-muted transition-transform",
|
|
195
|
+
isOpen && "rotate-180"
|
|
196
|
+
)}
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{isOpen && (
|
|
202
|
+
<div className="absolute z-50 mt-1 w-full border border-border bg-card shadow-lg max-h-60 overflow-auto">
|
|
203
|
+
{filteredOptions.length === 0 ? (
|
|
204
|
+
<div className="px-4 py-3 text-sm text-foreground-muted text-center">
|
|
205
|
+
{emptyMessage}
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
filteredOptions.map((option, index) => (
|
|
209
|
+
<button
|
|
210
|
+
key={option.value}
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => !option.disabled && handleSelect(option.value)}
|
|
213
|
+
disabled={option.disabled}
|
|
214
|
+
className={cn(
|
|
215
|
+
"flex w-full items-center justify-between px-4 py-2.5 text-left",
|
|
216
|
+
"transition-colors duration-150",
|
|
217
|
+
option.disabled && "cursor-not-allowed opacity-50",
|
|
218
|
+
index === highlightedIndex && "bg-secondary-hover",
|
|
219
|
+
option.value === value
|
|
220
|
+
? "bg-primary text-primary-foreground"
|
|
221
|
+
: "text-foreground hover:bg-secondary-hover"
|
|
222
|
+
)}
|
|
223
|
+
>
|
|
224
|
+
<div>
|
|
225
|
+
<div className="text-sm">{option.label}</div>
|
|
226
|
+
{option.description && (
|
|
227
|
+
<div className={cn(
|
|
228
|
+
"text-xs mt-0.5",
|
|
229
|
+
option.value === value ? "text-primary-foreground/70" : "text-foreground-muted"
|
|
230
|
+
)}>
|
|
231
|
+
{option.description}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
{option.value === value && <Check className="h-4 w-4" />}
|
|
236
|
+
</button>
|
|
237
|
+
))
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
{error && <p className="mt-1 text-sm text-error">{error}</p>}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { User } from "lucide-react";
|
|
4
|
+
import { cn } from "@/lib/utils";
|
|
5
|
+
|
|
6
|
+
const avatarVariants = cva(
|
|
7
|
+
"relative inline-flex shrink-0 items-center justify-center overflow-hidden bg-background-secondary",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
size: {
|
|
11
|
+
xs: "h-6 w-6 text-xs",
|
|
12
|
+
sm: "h-8 w-8 text-sm",
|
|
13
|
+
md: "h-10 w-10 text-base",
|
|
14
|
+
lg: "h-12 w-12 text-lg",
|
|
15
|
+
xl: "h-16 w-16 text-xl",
|
|
16
|
+
},
|
|
17
|
+
shape: {
|
|
18
|
+
circle: "rounded-full",
|
|
19
|
+
square: "rounded-sm",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
size: "md",
|
|
24
|
+
shape: "circle",
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
interface AvatarProps
|
|
30
|
+
extends React.HTMLAttributes<HTMLDivElement>,
|
|
31
|
+
VariantProps<typeof avatarVariants> {
|
|
32
|
+
src?: string;
|
|
33
|
+
alt?: string;
|
|
34
|
+
fallback?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
|
|
38
|
+
({ className, size, shape, src, alt, fallback, ...props }, ref) => {
|
|
39
|
+
const initials = fallback
|
|
40
|
+
? fallback
|
|
41
|
+
.split(" ")
|
|
42
|
+
.map((n) => n[0])
|
|
43
|
+
.join("")
|
|
44
|
+
.toUpperCase()
|
|
45
|
+
.slice(0, 2)
|
|
46
|
+
: null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
ref={ref}
|
|
51
|
+
className={cn(avatarVariants({ size, shape }), className)}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{src ? (
|
|
55
|
+
<img
|
|
56
|
+
src={src}
|
|
57
|
+
alt={alt || "Avatar"}
|
|
58
|
+
className="h-full w-full object-cover"
|
|
59
|
+
/>
|
|
60
|
+
) : initials ? (
|
|
61
|
+
<span className="font-medium text-foreground-muted">{initials}</span>
|
|
62
|
+
) : (
|
|
63
|
+
<User className="h-1/2 w-1/2 text-foreground-muted" />
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
Avatar.displayName = "Avatar";
|
|
71
|
+
|
|
72
|
+
// Avatar Group
|
|
73
|
+
interface AvatarGroupProps {
|
|
74
|
+
children: React.ReactNode;
|
|
75
|
+
max?: number;
|
|
76
|
+
size?: VariantProps<typeof avatarVariants>["size"];
|
|
77
|
+
className?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function AvatarGroup({ children, max, size = "md", className }: AvatarGroupProps) {
|
|
81
|
+
const avatars = Array.isArray(children) ? children : [children];
|
|
82
|
+
const displayAvatars = max ? avatars.slice(0, max) : avatars;
|
|
83
|
+
const excess = max ? Math.max(0, avatars.length - max) : 0;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className={cn("flex -space-x-2", className)}>
|
|
87
|
+
{displayAvatars.map((avatar, index) => (
|
|
88
|
+
<div key={index} className="ring-2 ring-background rounded-full">
|
|
89
|
+
{avatar}
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
{excess > 0 && (
|
|
93
|
+
<div
|
|
94
|
+
className={cn(
|
|
95
|
+
avatarVariants({ size, shape: "circle" }),
|
|
96
|
+
"ring-2 ring-background bg-background-tertiary"
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<span className="text-xs font-medium text-foreground-muted">+{excess}</span>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
const badgeVariants = cva(
|
|
5
|
+
"inline-flex items-center font-medium transition-colors",
|
|
6
|
+
{
|
|
7
|
+
variants: {
|
|
8
|
+
variant: {
|
|
9
|
+
default: "bg-background-secondary text-foreground border border-border",
|
|
10
|
+
primary: "bg-primary text-primary-foreground",
|
|
11
|
+
secondary: "bg-secondary-hover text-secondary-foreground border border-border",
|
|
12
|
+
success: "bg-success text-white",
|
|
13
|
+
error: "bg-error text-white",
|
|
14
|
+
warning: "bg-warning text-white",
|
|
15
|
+
info: "bg-info text-white",
|
|
16
|
+
outline: "border border-border text-foreground bg-transparent",
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
sm: "px-2 py-0.5 text-xs",
|
|
20
|
+
md: "px-2.5 py-0.5 text-xs",
|
|
21
|
+
lg: "px-3 py-1 text-sm",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
size: "md",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
interface BadgeProps
|
|
32
|
+
extends React.HTMLAttributes<HTMLSpanElement>,
|
|
33
|
+
VariantProps<typeof badgeVariants> {}
|
|
34
|
+
|
|
35
|
+
export function Badge({ className, variant, size, ...props }: BadgeProps) {
|
|
36
|
+
return (
|
|
37
|
+
<span className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Chip variant with optional close button
|
|
42
|
+
interface ChipProps extends BadgeProps {
|
|
43
|
+
onClose?: () => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function Chip({ className, variant, size, onClose, children, ...props }: ChipProps) {
|
|
47
|
+
return (
|
|
48
|
+
<span className={cn(badgeVariants({ variant, size }), "gap-1", className)} {...props}>
|
|
49
|
+
{children}
|
|
50
|
+
{onClose && (
|
|
51
|
+
<button
|
|
52
|
+
onClick={onClose}
|
|
53
|
+
className="ml-1 rounded-full p-0.5 hover:bg-black/10 dark:hover:bg-white/10"
|
|
54
|
+
>
|
|
55
|
+
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
56
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
57
|
+
</svg>
|
|
58
|
+
<span className="sr-only">Remove</span>
|
|
59
|
+
</button>
|
|
60
|
+
)}
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
import { ChevronRight, Home } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
interface BreadcrumbItem {
|
|
8
|
+
label: string;
|
|
9
|
+
href?: string;
|
|
10
|
+
icon?: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface BreadcrumbsProps {
|
|
14
|
+
items: BreadcrumbItem[];
|
|
15
|
+
separator?: React.ReactNode;
|
|
16
|
+
showHome?: boolean;
|
|
17
|
+
homeHref?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Breadcrumbs({
|
|
22
|
+
items,
|
|
23
|
+
separator,
|
|
24
|
+
showHome = false,
|
|
25
|
+
homeHref = "/",
|
|
26
|
+
className,
|
|
27
|
+
}: BreadcrumbsProps) {
|
|
28
|
+
const separatorElement = separator || (
|
|
29
|
+
<ChevronRight className="h-4 w-4 text-foreground-muted" />
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const allItems = showHome
|
|
33
|
+
? [{ label: "Home", href: homeHref, icon: <Home className="h-4 w-4" /> }, ...items]
|
|
34
|
+
: items;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<nav aria-label="Breadcrumb" className={cn("flex items-center", className)}>
|
|
38
|
+
<ol className="flex items-center gap-2">
|
|
39
|
+
{allItems.map((item, index) => {
|
|
40
|
+
const isLast = index === allItems.length - 1;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<li key={index} className="flex items-center gap-2">
|
|
44
|
+
{index > 0 && (
|
|
45
|
+
<span className="text-foreground-muted" aria-hidden="true">
|
|
46
|
+
{separatorElement}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
{isLast ? (
|
|
50
|
+
<span
|
|
51
|
+
className="flex items-center gap-1.5 text-sm font-medium text-foreground"
|
|
52
|
+
aria-current="page"
|
|
53
|
+
>
|
|
54
|
+
{item.icon}
|
|
55
|
+
{item.label}
|
|
56
|
+
</span>
|
|
57
|
+
) : (
|
|
58
|
+
<a
|
|
59
|
+
href={item.href}
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex items-center gap-1.5 text-sm text-foreground-muted",
|
|
62
|
+
"hover:text-foreground transition-colors"
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{item.icon}
|
|
66
|
+
{item.label}
|
|
67
|
+
</a>
|
|
68
|
+
)}
|
|
69
|
+
</li>
|
|
70
|
+
);
|
|
71
|
+
})}
|
|
72
|
+
</ol>
|
|
73
|
+
</nav>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Individual breadcrumb components for more control
|
|
78
|
+
export const BreadcrumbList = forwardRef<
|
|
79
|
+
HTMLOListElement,
|
|
80
|
+
React.OlHTMLAttributes<HTMLOListElement>
|
|
81
|
+
>(({ className, ...props }, ref) => (
|
|
82
|
+
<ol
|
|
83
|
+
ref={ref}
|
|
84
|
+
className={cn("flex items-center gap-2 flex-wrap", className)}
|
|
85
|
+
{...props}
|
|
86
|
+
/>
|
|
87
|
+
));
|
|
88
|
+
|
|
89
|
+
BreadcrumbList.displayName = "BreadcrumbList";
|
|
90
|
+
|
|
91
|
+
export const BreadcrumbItem = forwardRef<
|
|
92
|
+
HTMLLIElement,
|
|
93
|
+
React.LiHTMLAttributes<HTMLLIElement>
|
|
94
|
+
>(({ className, ...props }, ref) => (
|
|
95
|
+
<li
|
|
96
|
+
ref={ref}
|
|
97
|
+
className={cn("flex items-center gap-2", className)}
|
|
98
|
+
{...props}
|
|
99
|
+
/>
|
|
100
|
+
));
|
|
101
|
+
|
|
102
|
+
BreadcrumbItem.displayName = "BreadcrumbItem";
|
|
103
|
+
|
|
104
|
+
export const BreadcrumbLink = forwardRef<
|
|
105
|
+
HTMLAnchorElement,
|
|
106
|
+
React.AnchorHTMLAttributes<HTMLAnchorElement>
|
|
107
|
+
>(({ className, ...props }, ref) => (
|
|
108
|
+
<a
|
|
109
|
+
ref={ref}
|
|
110
|
+
className={cn(
|
|
111
|
+
"text-sm text-foreground-muted hover:text-foreground transition-colors",
|
|
112
|
+
className
|
|
113
|
+
)}
|
|
114
|
+
{...props}
|
|
115
|
+
/>
|
|
116
|
+
));
|
|
117
|
+
|
|
118
|
+
BreadcrumbLink.displayName = "BreadcrumbLink";
|
|
119
|
+
|
|
120
|
+
export const BreadcrumbPage = forwardRef<
|
|
121
|
+
HTMLSpanElement,
|
|
122
|
+
React.HTMLAttributes<HTMLSpanElement>
|
|
123
|
+
>(({ className, ...props }, ref) => (
|
|
124
|
+
<span
|
|
125
|
+
ref={ref}
|
|
126
|
+
aria-current="page"
|
|
127
|
+
className={cn("text-sm font-medium text-foreground", className)}
|
|
128
|
+
{...props}
|
|
129
|
+
/>
|
|
130
|
+
));
|
|
131
|
+
|
|
132
|
+
BreadcrumbPage.displayName = "BreadcrumbPage";
|
|
133
|
+
|
|
134
|
+
export const BreadcrumbSeparator = forwardRef<
|
|
135
|
+
HTMLSpanElement,
|
|
136
|
+
React.HTMLAttributes<HTMLSpanElement>
|
|
137
|
+
>(({ className, children, ...props }, ref) => (
|
|
138
|
+
<span
|
|
139
|
+
ref={ref}
|
|
140
|
+
aria-hidden="true"
|
|
141
|
+
className={cn("text-foreground-muted", className)}
|
|
142
|
+
{...props}
|
|
143
|
+
>
|
|
144
|
+
{children || <ChevronRight className="h-4 w-4" />}
|
|
145
|
+
</span>
|
|
146
|
+
));
|
|
147
|
+
|
|
148
|
+
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
|
149
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
import { forwardRef } from "react";
|
|
4
|
+
|
|
5
|
+
const buttonVariants = cva(
|
|
6
|
+
"inline-flex items-center justify-center font-medium uppercase tracking-wide transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
primary:
|
|
11
|
+
"bg-primary text-primary-foreground hover:bg-primary-hover active:bg-primary-active",
|
|
12
|
+
secondary:
|
|
13
|
+
"bg-secondary text-secondary-foreground border border-border hover:bg-secondary-hover hover:border-border-hover",
|
|
14
|
+
ghost: "text-foreground hover:bg-secondary-hover",
|
|
15
|
+
inverted:
|
|
16
|
+
"bg-sonance-white text-sonance-charcoal hover:opacity-90",
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
sm: "h-9 px-4 text-xs",
|
|
20
|
+
md: "h-11 px-6 text-sm",
|
|
21
|
+
lg: "h-14 px-8 text-sm",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "primary",
|
|
26
|
+
size: "md",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
interface ButtonProps
|
|
32
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
33
|
+
VariantProps<typeof buttonVariants> {}
|
|
34
|
+
|
|
35
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
36
|
+
({ className, variant, size, ...props }, ref) => {
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
40
|
+
ref={ref}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
Button.displayName = "Button";
|
|
48
|
+
|
|
49
|
+
export { buttonVariants };
|
|
50
|
+
|