@cortexasystem/ui 1.0.0 → 1.0.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 +2 -2
- package/src/assets/isotipo-cortexa-dark.png +0 -0
- package/src/assets/isotipo-cortexa-light.png +0 -0
- package/src/components/ai/ai-chat.tsx +597 -0
- package/src/components/branding/brand-logo.tsx +77 -0
- package/src/components/data-display/icons.tsx +81 -0
- package/src/components/data-display/profile-avatar.tsx +154 -0
- package/src/components/data-display/typography.tsx +46 -0
- package/src/components/feedback/empty-state.tsx +63 -0
- package/src/components/feedback/loading-state.tsx +93 -0
- package/src/components/feedback/module-skeleton.tsx +76 -0
- package/src/components/feedback/notification.tsx +111 -0
- package/src/components/feedback/skeleton.tsx +9 -0
- package/src/components/feedback/spinner.tsx +18 -0
- package/src/components/feedback/status-badge.tsx +44 -0
- package/src/components/feedback/sync-status-badge.tsx +54 -0
- package/src/components/feedback/sync-status-bar.tsx +92 -0
- package/src/components/feedback/toaster.tsx +36 -0
- package/src/components/forms/searchable-select.tsx +206 -0
- package/src/components/forms/select.tsx +142 -0
- package/src/components/layout/app-shell.tsx +44 -0
- package/src/components/layout/form-section.tsx +21 -0
- package/src/components/layout/page-header.tsx +21 -0
- package/src/components/layout/theme-toggle.tsx +33 -0
- package/src/components/navigation/breadcrumb.tsx +87 -0
- package/src/components/navigation/header-user-menu.tsx +108 -0
- package/src/components/navigation/navbar.tsx +30 -0
- package/src/components/navigation/page-breadcrumb.tsx +44 -0
- package/src/components/navigation/sidebar.tsx +104 -0
- package/src/components/navigation/steps.tsx +82 -0
- package/src/components/overlays/dialog.tsx +94 -0
- package/src/components/overlays/drawer.tsx +85 -0
- package/src/components/overlays/dropdown-menu.tsx +179 -0
- package/src/components/overlays/sheet.tsx +110 -0
- package/src/components/primitives/alert.tsx +43 -0
- package/src/components/primitives/avatar.tsx +41 -0
- package/src/components/primitives/badge.tsx +26 -0
- package/src/components/primitives/button.tsx +49 -0
- package/src/components/primitives/card.tsx +97 -0
- package/src/components/primitives/checkbox.tsx +52 -0
- package/src/components/primitives/input.tsx +23 -0
- package/src/components/primitives/label.tsx +18 -0
- package/src/components/primitives/radio-group.tsx +57 -0
- package/src/components/primitives/separator.tsx +23 -0
- package/src/components/primitives/switch.tsx +75 -0
- package/src/components/primitives/textarea.tsx +18 -0
- package/src/components/tables/data-table.tsx +214 -0
- package/src/components/tables/data-table.types.ts +9 -0
- package/src/components/tables/table-row-actions.tsx +61 -0
- package/src/components/tables/table.tsx +88 -0
- package/src/declarations.d.ts +14 -0
- package/src/index.ts +50 -0
- package/src/lib/cn.ts +6 -0
- package/src/providers/theme-provider.tsx +90 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { AlertCircle, CheckCircle2, Clock3, Loader2 } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type StatusTone = "success" | "warning" | "danger" | "info" | "loading";
|
|
6
|
+
|
|
7
|
+
interface StatusBadgeProps {
|
|
8
|
+
children: string;
|
|
9
|
+
tone?: StatusTone;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const toneStyles: Record<StatusTone, string> = {
|
|
14
|
+
success: "border-transparent bg-[var(--color-success-bg)] text-[var(--color-success)]",
|
|
15
|
+
warning: "border-transparent bg-[var(--color-warning-bg)] text-[var(--color-warning)]",
|
|
16
|
+
danger: "border-transparent bg-destructive/10 text-destructive",
|
|
17
|
+
info: "border-transparent bg-[var(--color-info-bg)] text-[var(--color-accent-blue)]",
|
|
18
|
+
loading: "border-transparent bg-muted text-muted-foreground"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const toneIcons = {
|
|
22
|
+
success: CheckCircle2,
|
|
23
|
+
warning: Clock3,
|
|
24
|
+
danger: AlertCircle,
|
|
25
|
+
info: CheckCircle2,
|
|
26
|
+
loading: Loader2
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
export function StatusBadge({ children, tone = "info", className }: StatusBadgeProps) {
|
|
30
|
+
const Icon = toneIcons[tone];
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<span
|
|
34
|
+
className={cn(
|
|
35
|
+
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
|
36
|
+
toneStyles[tone],
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
<Icon className={cn("h-3.5 w-3.5", tone === "loading" && "animate-spin")} />
|
|
41
|
+
{children}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AlertCircle, CheckCircle2, Clock3, Loader2, WifiOff } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
|
|
5
|
+
type SyncStatus = "synced" | "pending" | "syncing" | "conflict" | "offline";
|
|
6
|
+
|
|
7
|
+
interface SyncStatusBadgeProps {
|
|
8
|
+
className?: string;
|
|
9
|
+
status: SyncStatus;
|
|
10
|
+
label?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const badgeStyles: Record<SyncStatus, string> = {
|
|
14
|
+
synced: "border-transparent bg-[var(--color-success-bg)] text-[var(--color-success)]",
|
|
15
|
+
pending: "border-transparent bg-[var(--color-warning-bg)] text-[var(--color-warning)]",
|
|
16
|
+
syncing: "border-transparent bg-[var(--color-info-bg)] text-[var(--color-accent-blue)]",
|
|
17
|
+
conflict: "border-transparent bg-destructive/10 text-destructive",
|
|
18
|
+
offline: "border-transparent bg-muted text-muted-foreground"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const badgeIcons = {
|
|
22
|
+
synced: CheckCircle2,
|
|
23
|
+
pending: Clock3,
|
|
24
|
+
syncing: Loader2,
|
|
25
|
+
conflict: AlertCircle,
|
|
26
|
+
offline: WifiOff
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
const badgeLabels: Record<SyncStatus, string> = {
|
|
30
|
+
synced: "Sincronizado",
|
|
31
|
+
pending: "Pendiente",
|
|
32
|
+
syncing: "Sincronizando",
|
|
33
|
+
conflict: "Conflicto",
|
|
34
|
+
offline: "Offline"
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function SyncStatusBadge({ className, status, label }: SyncStatusBadgeProps) {
|
|
38
|
+
const Icon = badgeIcons[status];
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<span
|
|
42
|
+
className={cn(
|
|
43
|
+
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
|
44
|
+
badgeStyles[status],
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<Icon className={cn("h-3.5 w-3.5", status === "syncing" && "animate-spin")} />
|
|
49
|
+
{label ?? badgeLabels[status]}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type { SyncStatus, SyncStatusBadgeProps };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AlertTriangle, CheckCircle2, Loader2, RefreshCw, WifiOff, type LucideIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
|
|
5
|
+
type SyncBarState = "synced" | "pending" | "syncing" | "conflict" | "offline";
|
|
6
|
+
|
|
7
|
+
interface SyncStatusBarProps {
|
|
8
|
+
className?: string;
|
|
9
|
+
state: SyncBarState;
|
|
10
|
+
pendingCount?: number;
|
|
11
|
+
errorCount?: number;
|
|
12
|
+
syncingCount?: number;
|
|
13
|
+
onClick?: () => void;
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type SyncBarConfig = {
|
|
18
|
+
icon: LucideIcon;
|
|
19
|
+
text: string;
|
|
20
|
+
className: string;
|
|
21
|
+
spinning?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function pluralize(count: number, singular: string, plural: string) {
|
|
25
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SyncStatusBar({
|
|
29
|
+
className,
|
|
30
|
+
state,
|
|
31
|
+
pendingCount = 0,
|
|
32
|
+
errorCount = 0,
|
|
33
|
+
syncingCount = 0,
|
|
34
|
+
onClick,
|
|
35
|
+
label
|
|
36
|
+
}: SyncStatusBarProps) {
|
|
37
|
+
const config: Record<SyncBarState, SyncBarConfig> = {
|
|
38
|
+
conflict: {
|
|
39
|
+
icon: AlertTriangle,
|
|
40
|
+
text: label ?? `${pluralize(errorCount || 1, "operacion", "operaciones")} con error`,
|
|
41
|
+
className: "bg-destructive/15 text-destructive"
|
|
42
|
+
},
|
|
43
|
+
offline: {
|
|
44
|
+
icon: WifiOff,
|
|
45
|
+
text:
|
|
46
|
+
label ??
|
|
47
|
+
(pendingCount > 0
|
|
48
|
+
? `Offline · ${pluralize(pendingCount, "pendiente", "pendientes")}`
|
|
49
|
+
: "Offline · sin pendientes"),
|
|
50
|
+
className: "bg-[var(--color-warning-bg)] text-[var(--color-warning)]"
|
|
51
|
+
},
|
|
52
|
+
syncing: {
|
|
53
|
+
icon: Loader2,
|
|
54
|
+
text: label ?? `Sincronizando ${pluralize(syncingCount || 1, "operacion", "operaciones")}...`,
|
|
55
|
+
className: "bg-[var(--color-info-bg)] text-[var(--color-accent-blue)]",
|
|
56
|
+
spinning: true
|
|
57
|
+
},
|
|
58
|
+
pending: {
|
|
59
|
+
icon: RefreshCw,
|
|
60
|
+
text: label ?? `${pluralize(pendingCount || 1, "pendiente", "pendientes")} por sincronizar`,
|
|
61
|
+
className: "bg-[var(--color-warning-bg)] text-[var(--color-warning)]"
|
|
62
|
+
},
|
|
63
|
+
synced: {
|
|
64
|
+
icon: CheckCircle2,
|
|
65
|
+
text: label ?? "Todo sincronizado",
|
|
66
|
+
className: "bg-[var(--color-success-bg)] text-[var(--color-success)]"
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const current = config[state];
|
|
71
|
+
const Icon = current.icon;
|
|
72
|
+
const isClickable = typeof onClick === "function";
|
|
73
|
+
const Component = isClickable ? "button" : "div";
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Component
|
|
77
|
+
{...(isClickable ? { type: "button", onClick } : {})}
|
|
78
|
+
className={cn(
|
|
79
|
+
"flex h-8 w-full items-center justify-center gap-1.5 px-4 text-xs font-medium transition-opacity",
|
|
80
|
+
current.className,
|
|
81
|
+
isClickable ? "cursor-pointer hover:opacity-85 active:opacity-70" : "cursor-default",
|
|
82
|
+
className
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<Icon className={cn("h-3.5 w-3.5", current.spinning && "animate-spin")} />
|
|
86
|
+
<span>{current.text}</span>
|
|
87
|
+
{isClickable ? <span className="ml-1 text-[10px] opacity-60">→</span> : null}
|
|
88
|
+
</Component>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type { SyncBarState, SyncStatusBarProps };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { LoaderCircle } from "lucide-react";
|
|
2
|
+
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
|
3
|
+
|
|
4
|
+
import { useTheme } from "../../providers/theme-provider";
|
|
5
|
+
|
|
6
|
+
export function Toaster(props: ToasterProps) {
|
|
7
|
+
const { resolvedTheme } = useTheme();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Sonner
|
|
11
|
+
theme={resolvedTheme}
|
|
12
|
+
position="top-right"
|
|
13
|
+
richColors
|
|
14
|
+
className="toaster group"
|
|
15
|
+
icons={{
|
|
16
|
+
loading: <LoaderCircle className="h-4 w-4 animate-spin" />
|
|
17
|
+
}}
|
|
18
|
+
closeButton
|
|
19
|
+
toastOptions={{
|
|
20
|
+
classNames: {
|
|
21
|
+
toast:
|
|
22
|
+
"group toast group-[.toaster]:rounded-xl group-[.toaster]:border group-[.toaster]:border-border group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:shadow-xl",
|
|
23
|
+
title: "text-sm font-semibold",
|
|
24
|
+
description: "group-[.toast]:text-muted-foreground",
|
|
25
|
+
actionButton:
|
|
26
|
+
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:rounded-md",
|
|
27
|
+
cancelButton:
|
|
28
|
+
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:rounded-md",
|
|
29
|
+
closeButton:
|
|
30
|
+
"pointer-events-auto cursor-pointer rounded-md border border-border bg-background text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
|
31
|
+
}
|
|
32
|
+
}}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Check, ChevronDown, ChevronUp, Loader2, Search } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { Input } from "../primitives/input";
|
|
6
|
+
|
|
7
|
+
export interface SearchableSelectOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
sublabel?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchableSelectProps {
|
|
14
|
+
options: SearchableSelectOption[];
|
|
15
|
+
value: string;
|
|
16
|
+
onValueChange: (value: string) => void;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
searchPlaceholder?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
loading?: boolean;
|
|
21
|
+
emptyMessage?: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalize(str: string): string {
|
|
27
|
+
return str
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.normalize("NFD")
|
|
30
|
+
.replace(/[\u0300-\u036f]/g, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const SearchableSelect = React.forwardRef<HTMLButtonElement, SearchableSelectProps>(
|
|
34
|
+
(
|
|
35
|
+
{
|
|
36
|
+
options,
|
|
37
|
+
value,
|
|
38
|
+
onValueChange,
|
|
39
|
+
placeholder = "Select an option...",
|
|
40
|
+
searchPlaceholder = "Search...",
|
|
41
|
+
disabled = false,
|
|
42
|
+
loading = false,
|
|
43
|
+
emptyMessage = "No results found.",
|
|
44
|
+
id,
|
|
45
|
+
className
|
|
46
|
+
},
|
|
47
|
+
ref
|
|
48
|
+
) => {
|
|
49
|
+
const [open, setOpen] = React.useState(false);
|
|
50
|
+
const [search, setSearch] = React.useState("");
|
|
51
|
+
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
|
52
|
+
const searchRef = React.useRef<HTMLInputElement>(null);
|
|
53
|
+
|
|
54
|
+
const selected = React.useMemo(() => options.find((option) => option.value === value) ?? null, [options, value]);
|
|
55
|
+
|
|
56
|
+
const filtered = React.useMemo(() => {
|
|
57
|
+
if (!search.trim()) {
|
|
58
|
+
return options;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const query = normalize(search);
|
|
62
|
+
|
|
63
|
+
return options.filter(
|
|
64
|
+
(option) => normalize(option.label).includes(query) || normalize(option.sublabel ?? "").includes(query)
|
|
65
|
+
);
|
|
66
|
+
}, [options, search]);
|
|
67
|
+
|
|
68
|
+
function handleOpen() {
|
|
69
|
+
if (disabled || loading) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setOpen((previous) => !previous);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleSelect(optionValue: string) {
|
|
77
|
+
onValueChange(optionValue);
|
|
78
|
+
setOpen(false);
|
|
79
|
+
setSearch("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleKeyDown(event: React.KeyboardEvent) {
|
|
83
|
+
if (event.key === "Escape") {
|
|
84
|
+
setOpen(false);
|
|
85
|
+
setSearch("");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
if (!open) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleMouseDown(event: MouseEvent) {
|
|
95
|
+
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
|
96
|
+
setOpen(false);
|
|
97
|
+
setSearch("");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
document.addEventListener("mousedown", handleMouseDown);
|
|
102
|
+
return () => document.removeEventListener("mousedown", handleMouseDown);
|
|
103
|
+
}, [open]);
|
|
104
|
+
|
|
105
|
+
React.useEffect(() => {
|
|
106
|
+
if (!open) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const timer = setTimeout(() => searchRef.current?.focus(), 50);
|
|
111
|
+
return () => clearTimeout(timer);
|
|
112
|
+
}, [open]);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div ref={wrapperRef} className={cn("relative w-full", className)} onKeyDown={handleKeyDown}>
|
|
116
|
+
<button
|
|
117
|
+
ref={ref}
|
|
118
|
+
id={id}
|
|
119
|
+
type="button"
|
|
120
|
+
role="combobox"
|
|
121
|
+
aria-expanded={open}
|
|
122
|
+
aria-haspopup="listbox"
|
|
123
|
+
disabled={disabled || loading}
|
|
124
|
+
onClick={handleOpen}
|
|
125
|
+
className={cn(
|
|
126
|
+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-left text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{loading ? (
|
|
130
|
+
<span className="flex items-center gap-2 text-muted-foreground">
|
|
131
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
132
|
+
Loading...
|
|
133
|
+
</span>
|
|
134
|
+
) : selected ? (
|
|
135
|
+
<span className="flex items-center gap-2 truncate">
|
|
136
|
+
{selected.sublabel ? (
|
|
137
|
+
<span className="shrink-0 font-mono text-xs text-muted-foreground">{selected.sublabel}</span>
|
|
138
|
+
) : null}
|
|
139
|
+
<span className="truncate">{selected.label}</span>
|
|
140
|
+
</span>
|
|
141
|
+
) : (
|
|
142
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<span className="ml-2 shrink-0 opacity-50">
|
|
146
|
+
{open ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
|
147
|
+
</span>
|
|
148
|
+
</button>
|
|
149
|
+
|
|
150
|
+
{open ? (
|
|
151
|
+
<div
|
|
152
|
+
role="listbox"
|
|
153
|
+
className="animate-in fade-in-0 zoom-in-95 absolute z-50 mt-1 w-full rounded-md border border-border bg-popover text-popover-foreground shadow-md"
|
|
154
|
+
>
|
|
155
|
+
<div className="sticky top-0 flex items-center gap-2 border-b border-border bg-popover px-3 py-2">
|
|
156
|
+
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
157
|
+
<Input
|
|
158
|
+
ref={searchRef}
|
|
159
|
+
type="text"
|
|
160
|
+
value={search}
|
|
161
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
162
|
+
placeholder={searchPlaceholder}
|
|
163
|
+
className="h-7 border-0 bg-transparent px-0 text-sm shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground"
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<ul className="max-h-60 overflow-y-auto overscroll-contain p-1">
|
|
168
|
+
{filtered.length === 0 ? (
|
|
169
|
+
<li className="px-4 py-3 text-center text-xs text-muted-foreground">{emptyMessage}</li>
|
|
170
|
+
) : (
|
|
171
|
+
filtered.map((option) => {
|
|
172
|
+
const isSelected = option.value === value;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<li
|
|
176
|
+
key={option.value}
|
|
177
|
+
role="option"
|
|
178
|
+
aria-selected={isSelected}
|
|
179
|
+
onClick={() => handleSelect(option.value)}
|
|
180
|
+
className={cn(
|
|
181
|
+
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
|
182
|
+
isSelected && "bg-accent/50 font-medium"
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
186
|
+
{isSelected ? <Check className="h-4 w-4" /> : null}
|
|
187
|
+
</span>
|
|
188
|
+
<span className="flex items-center gap-2 truncate">
|
|
189
|
+
{option.sublabel ? (
|
|
190
|
+
<span className="shrink-0 font-mono text-xs text-muted-foreground">{option.sublabel}</span>
|
|
191
|
+
) : null}
|
|
192
|
+
<span className="truncate">{option.label}</span>
|
|
193
|
+
</span>
|
|
194
|
+
</li>
|
|
195
|
+
);
|
|
196
|
+
})
|
|
197
|
+
)}
|
|
198
|
+
</ul>
|
|
199
|
+
</div>
|
|
200
|
+
) : null}
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
SearchableSelect.displayName = "SearchableSelect";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as SelectPrimitive from "@radix-ui/react-select";
|
|
3
|
+
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../../lib/cn";
|
|
6
|
+
|
|
7
|
+
const Select = SelectPrimitive.Root;
|
|
8
|
+
const SelectGroup = SelectPrimitive.Group;
|
|
9
|
+
const SelectValue = SelectPrimitive.Value;
|
|
10
|
+
|
|
11
|
+
const SelectTrigger = React.forwardRef<
|
|
12
|
+
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
13
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
14
|
+
>(({ className, children, ...props }, ref) => (
|
|
15
|
+
<SelectPrimitive.Trigger
|
|
16
|
+
ref={ref}
|
|
17
|
+
className={cn(
|
|
18
|
+
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
|
19
|
+
className
|
|
20
|
+
)}
|
|
21
|
+
{...props}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
<SelectPrimitive.Icon asChild>
|
|
25
|
+
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
26
|
+
</SelectPrimitive.Icon>
|
|
27
|
+
</SelectPrimitive.Trigger>
|
|
28
|
+
));
|
|
29
|
+
|
|
30
|
+
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
|
31
|
+
|
|
32
|
+
const SelectScrollUpButton = React.forwardRef<
|
|
33
|
+
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
|
34
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
|
35
|
+
>(({ className, ...props }, ref) => (
|
|
36
|
+
<SelectPrimitive.ScrollUpButton ref={ref} className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
|
|
37
|
+
<ChevronUp className="h-4 w-4" />
|
|
38
|
+
</SelectPrimitive.ScrollUpButton>
|
|
39
|
+
));
|
|
40
|
+
|
|
41
|
+
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
|
42
|
+
|
|
43
|
+
const SelectScrollDownButton = React.forwardRef<
|
|
44
|
+
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
|
45
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
|
46
|
+
>(({ className, ...props }, ref) => (
|
|
47
|
+
<SelectPrimitive.ScrollDownButton
|
|
48
|
+
ref={ref}
|
|
49
|
+
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
<ChevronDown className="h-4 w-4" />
|
|
53
|
+
</SelectPrimitive.ScrollDownButton>
|
|
54
|
+
));
|
|
55
|
+
|
|
56
|
+
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
|
57
|
+
|
|
58
|
+
const SelectContent = React.forwardRef<
|
|
59
|
+
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
60
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
61
|
+
>(({ className, children, position = "popper", ...props }, ref) => (
|
|
62
|
+
<SelectPrimitive.Portal>
|
|
63
|
+
<SelectPrimitive.Content
|
|
64
|
+
ref={ref}
|
|
65
|
+
className={cn(
|
|
66
|
+
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
|
67
|
+
position === "popper" &&
|
|
68
|
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
69
|
+
className
|
|
70
|
+
)}
|
|
71
|
+
position={position}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
<SelectScrollUpButton />
|
|
75
|
+
<SelectPrimitive.Viewport
|
|
76
|
+
className={cn(
|
|
77
|
+
"p-1",
|
|
78
|
+
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</SelectPrimitive.Viewport>
|
|
83
|
+
<SelectScrollDownButton />
|
|
84
|
+
</SelectPrimitive.Content>
|
|
85
|
+
</SelectPrimitive.Portal>
|
|
86
|
+
));
|
|
87
|
+
|
|
88
|
+
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
|
89
|
+
|
|
90
|
+
const SelectLabel = React.forwardRef<
|
|
91
|
+
React.ElementRef<typeof SelectPrimitive.Label>,
|
|
92
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
|
93
|
+
>(({ className, ...props }, ref) => (
|
|
94
|
+
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
|
|
95
|
+
));
|
|
96
|
+
|
|
97
|
+
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
|
98
|
+
|
|
99
|
+
const SelectItem = React.forwardRef<
|
|
100
|
+
React.ElementRef<typeof SelectPrimitive.Item>,
|
|
101
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
|
102
|
+
>(({ className, children, ...props }, ref) => (
|
|
103
|
+
<SelectPrimitive.Item
|
|
104
|
+
ref={ref}
|
|
105
|
+
className={cn(
|
|
106
|
+
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
107
|
+
className
|
|
108
|
+
)}
|
|
109
|
+
{...props}
|
|
110
|
+
>
|
|
111
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
112
|
+
<SelectPrimitive.ItemIndicator>
|
|
113
|
+
<Check className="h-4 w-4" />
|
|
114
|
+
</SelectPrimitive.ItemIndicator>
|
|
115
|
+
</span>
|
|
116
|
+
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
117
|
+
</SelectPrimitive.Item>
|
|
118
|
+
));
|
|
119
|
+
|
|
120
|
+
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
|
121
|
+
|
|
122
|
+
const SelectSeparator = React.forwardRef<
|
|
123
|
+
React.ElementRef<typeof SelectPrimitive.Separator>,
|
|
124
|
+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
|
125
|
+
>(({ className, ...props }, ref) => (
|
|
126
|
+
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
|
127
|
+
));
|
|
128
|
+
|
|
129
|
+
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
Select,
|
|
133
|
+
SelectGroup,
|
|
134
|
+
SelectValue,
|
|
135
|
+
SelectTrigger,
|
|
136
|
+
SelectContent,
|
|
137
|
+
SelectLabel,
|
|
138
|
+
SelectItem,
|
|
139
|
+
SelectSeparator,
|
|
140
|
+
SelectScrollUpButton,
|
|
141
|
+
SelectScrollDownButton
|
|
142
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { Drawer, DrawerContent } from "../overlays/drawer";
|
|
4
|
+
import { Navbar } from "../navigation/navbar";
|
|
5
|
+
import { Sidebar, type SidebarGroup } from "../navigation/sidebar";
|
|
6
|
+
|
|
7
|
+
interface AppShellProps {
|
|
8
|
+
brand?: ReactNode;
|
|
9
|
+
actions?: ReactNode;
|
|
10
|
+
sidebarGroups: SidebarGroup[];
|
|
11
|
+
sidebarUser?: {
|
|
12
|
+
name: string;
|
|
13
|
+
role?: string;
|
|
14
|
+
initials?: string;
|
|
15
|
+
};
|
|
16
|
+
sidebarFooterAction?: {
|
|
17
|
+
label: string;
|
|
18
|
+
onClick?: () => void;
|
|
19
|
+
};
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function AppShell({
|
|
24
|
+
brand,
|
|
25
|
+
actions,
|
|
26
|
+
sidebarGroups,
|
|
27
|
+
sidebarUser,
|
|
28
|
+
sidebarFooterAction,
|
|
29
|
+
children
|
|
30
|
+
}: AppShellProps) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="min-h-screen bg-background">
|
|
35
|
+
<Navbar brand={brand} actions={actions} onMenuClick={() => setOpen(true)} />
|
|
36
|
+
<Drawer open={open} onOpenChange={setOpen} direction="left">
|
|
37
|
+
<DrawerContent className="h-full w-72 rounded-none border-r border-border p-0">
|
|
38
|
+
<Sidebar brand={brand} groups={sidebarGroups} user={sidebarUser} footerAction={sidebarFooterAction} />
|
|
39
|
+
</DrawerContent>
|
|
40
|
+
</Drawer>
|
|
41
|
+
<main className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6">{children}</main>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../primitives/card";
|
|
4
|
+
|
|
5
|
+
interface FormSectionProps {
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function FormSection({ title, description, children }: FormSectionProps) {
|
|
12
|
+
return (
|
|
13
|
+
<Card>
|
|
14
|
+
<CardHeader>
|
|
15
|
+
<CardTitle>{title}</CardTitle>
|
|
16
|
+
{description ? <CardDescription>{description}</CardDescription> : null}
|
|
17
|
+
</CardHeader>
|
|
18
|
+
<CardContent>{children}</CardContent>
|
|
19
|
+
</Card>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface PageHeaderProps {
|
|
4
|
+
title: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
action?: ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex items-start justify-between gap-4 sm:flex-row sm:items-center">
|
|
12
|
+
<div>
|
|
13
|
+
<h1 className="text-2xl font-semibold text-foreground" style={{ fontFamily: "var(--font-display)" }}>
|
|
14
|
+
{title}
|
|
15
|
+
</h1>
|
|
16
|
+
{description ? <p className="mt-0.5 text-sm text-muted-foreground">{description}</p> : null}
|
|
17
|
+
</div>
|
|
18
|
+
{action ? <div>{action}</div> : null}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|