@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.
Files changed (54) hide show
  1. package/package.json +2 -2
  2. package/src/assets/isotipo-cortexa-dark.png +0 -0
  3. package/src/assets/isotipo-cortexa-light.png +0 -0
  4. package/src/components/ai/ai-chat.tsx +597 -0
  5. package/src/components/branding/brand-logo.tsx +77 -0
  6. package/src/components/data-display/icons.tsx +81 -0
  7. package/src/components/data-display/profile-avatar.tsx +154 -0
  8. package/src/components/data-display/typography.tsx +46 -0
  9. package/src/components/feedback/empty-state.tsx +63 -0
  10. package/src/components/feedback/loading-state.tsx +93 -0
  11. package/src/components/feedback/module-skeleton.tsx +76 -0
  12. package/src/components/feedback/notification.tsx +111 -0
  13. package/src/components/feedback/skeleton.tsx +9 -0
  14. package/src/components/feedback/spinner.tsx +18 -0
  15. package/src/components/feedback/status-badge.tsx +44 -0
  16. package/src/components/feedback/sync-status-badge.tsx +54 -0
  17. package/src/components/feedback/sync-status-bar.tsx +92 -0
  18. package/src/components/feedback/toaster.tsx +36 -0
  19. package/src/components/forms/searchable-select.tsx +206 -0
  20. package/src/components/forms/select.tsx +142 -0
  21. package/src/components/layout/app-shell.tsx +44 -0
  22. package/src/components/layout/form-section.tsx +21 -0
  23. package/src/components/layout/page-header.tsx +21 -0
  24. package/src/components/layout/theme-toggle.tsx +33 -0
  25. package/src/components/navigation/breadcrumb.tsx +87 -0
  26. package/src/components/navigation/header-user-menu.tsx +108 -0
  27. package/src/components/navigation/navbar.tsx +30 -0
  28. package/src/components/navigation/page-breadcrumb.tsx +44 -0
  29. package/src/components/navigation/sidebar.tsx +104 -0
  30. package/src/components/navigation/steps.tsx +82 -0
  31. package/src/components/overlays/dialog.tsx +94 -0
  32. package/src/components/overlays/drawer.tsx +85 -0
  33. package/src/components/overlays/dropdown-menu.tsx +179 -0
  34. package/src/components/overlays/sheet.tsx +110 -0
  35. package/src/components/primitives/alert.tsx +43 -0
  36. package/src/components/primitives/avatar.tsx +41 -0
  37. package/src/components/primitives/badge.tsx +26 -0
  38. package/src/components/primitives/button.tsx +49 -0
  39. package/src/components/primitives/card.tsx +97 -0
  40. package/src/components/primitives/checkbox.tsx +52 -0
  41. package/src/components/primitives/input.tsx +23 -0
  42. package/src/components/primitives/label.tsx +18 -0
  43. package/src/components/primitives/radio-group.tsx +57 -0
  44. package/src/components/primitives/separator.tsx +23 -0
  45. package/src/components/primitives/switch.tsx +75 -0
  46. package/src/components/primitives/textarea.tsx +18 -0
  47. package/src/components/tables/data-table.tsx +214 -0
  48. package/src/components/tables/data-table.types.ts +9 -0
  49. package/src/components/tables/table-row-actions.tsx +61 -0
  50. package/src/components/tables/table.tsx +88 -0
  51. package/src/declarations.d.ts +14 -0
  52. package/src/index.ts +50 -0
  53. package/src/lib/cn.ts +6 -0
  54. 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
+ }