basuicn 0.1.4 → 0.1.6
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/README.md +96 -96
- package/README_CLI.md +44 -44
- package/package.json +102 -89
- package/registry.json +78 -17
- package/scripts/generate-theme-css.ts +74 -74
- package/scripts/ui-cli.ts +966 -760
- package/dist/assets/index-1YAQdTE0.css +0 -2
- package/dist/assets/index-BsQ6nn74.js +0 -237
- package/dist/favicon.svg +0 -1
- package/dist/icons.svg +0 -24
- package/dist/index.html +0 -13
- package/dist/ui-cli.cjs +0 -532
- package/dist/ui-cli.js +0 -124
package/registry.json
CHANGED
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"path": "src/styles/index.css",
|
|
21
|
-
"content": "@import \"tailwindcss\";\r\n@plugin \"tailwindcss-animate\";\r\n@custom-variant dark (&:where(.dark, .dark *));\r\n\r\n/*\r\n View Transitions API: Tắt animation mặc định (fade cross-dissolve).\r\n ThemeToggle sẽ tự định nghĩa clip-path ripple animation thay thế.\r\n*/\r\n::view-transition-old(root),\r\n::view-transition-new(root) {\r\n animation: none;\r\n mix-blend-mode: normal;\r\n}\r\n\r\n::view-transition-old(root) {\r\n z-index: 1;\r\n}\r\n\r\n::view-transition-new(root) {\r\n z-index: 9999;\r\n}\r\n\r\n\r\n@theme {\r\n --animate-ping: ping 1.5s linear infinite; /* Chỉnh cho nó chạy chậm lại */\r\n\r\n\r\n\r\n --color-background: var(--background);\r\n --color-foreground: var(--foreground);\r\n\r\n --color-primary: var(--primary);\r\n --color-primary-foreground: var(--primary-foreground);\r\n\r\n --color-secondary: var(--secondary);\r\n --color-secondary-foreground: var(--secondary-foreground);\r\n\r\n --color-muted: var(--muted);\r\n --color-muted-foreground: var(--muted-foreground);\r\n\r\n --color-accent: var(--accent);\r\n --color-accent-foreground: var(--accent-foreground);\r\n\r\n --color-switch-background: var(--switch-background);\r\n\r\n --color-border: var(--border);\r\n\r\n --color-success: var(--success);\r\n --color-success-foreground: var(--success-foreground);\r\n\r\n --color-warning: var(--warning);\r\n --color-warning-foreground: var(--warning-foreground);\r\n\r\n --color-destructive: var(--danger);\r\n --color-destructive-foreground: var(--danger-foreground);\r\n\r\n --color-danger: var(--danger);\r\n --color-danger-foreground: var(--danger-foreground);\r\n\r\n --color-ring: var(--ring);\r\n --color-input: var(--input);\r\n\r\n --color-chart-1: var(--chart-1);\r\n --color-chart-2: var(--chart-2);\r\n --color-chart-3: var(--chart-3);\r\n --color-chart-4: var(--chart-4);\r\n --color-chart-5: var(--chart-5);\r\n\r\n --color-popover: var(--popover);\r\n --color-popover-foreground: var(--popover-foreground);\r\n\r\n --color-sidebar: var(--sidebar);\r\n --color-sidebar-foreground: var(--sidebar-foreground);\r\n --color-sidebar-border: var(--sidebar-border);\r\n --color-sidebar-accent: var(--sidebar-accent);\r\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\r\n --color-sidebar-ring: var(--sidebar-ring);\r\n\r\n --radius-sm: 0.125rem;\r\n --radius-md: 0.375rem;\r\n --radius-lg: 0.5rem;\r\n --radius-xl: 1rem;\r\n\r\n /* Z-index scale */\r\n --z-dropdown: 50;\r\n --z-sticky: 100;\r\n --z-overlay: 200;\r\n --z-modal: 300;\r\n --z-popover: 400;\r\n --z-toast: 500;\r\n\r\n --animate-spin-slow: spin 3s linear infinite;\r\n --animate-progress-stripes: progress-stripes 1s linear infinite;\r\n\r\n @keyframes progress-stripes {\r\n from { background-position: 1rem 0; }\r\n to { background-position: 0 0; }\r\n }\r\n}\r\n\r\n@layer base {\r\n :root {\r\n /* GENERATED:theme-start */\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\n --background: #ffffff;\n --foreground: #0f172a;\n --primary: #2f27ce;\n --primary-foreground: #ffffff;\n --secondary: #dedcff;\n --secondary-foreground: #2f27ce;\n --muted: #f8fafc;\n --muted-foreground: #64748b;\n --accent: #f1f5f9;\n --accent-foreground: #0f172a;\n --success: #10b981;\n --success-foreground: #ffffff;\n --warning: #f59e0b;\n --warning-foreground: #ffffff;\n --danger: #ef4444;\n --danger-foreground: #ffffff;\n --destructive: #ef4444;\n --destructive-foreground: #ffffff;\n --border: #e2e8f0;\n --input: #e2e8f0;\n --ring: #2f27ce;\n --popover: #ffffff;\n --popover-foreground: #0f172a;\n /* GENERATED:theme-end */\r\n\r\n /* Non-theme tokens (not managed by applyTheme) */\r\n --switch-background: #cbd5e1;\r\n\r\n --chart-1: #e11d48;\r\n --chart-2: #2f27ce;\r\n --chart-3: #10b981;\r\n --chart-4: #f59e0b;\r\n --chart-5: #8b5cf6;\r\n\r\n --sidebar: #f8fafc;\r\n --sidebar-foreground: #0f172a;\r\n --sidebar-border: #e2e8f0;\r\n --sidebar-accent: #f1f5f9;\r\n --sidebar-accent-foreground: #2f27ce;\r\n --sidebar-ring: #2f27ce;\r\n }\r\n\r\n .dark {\r\n /* Dark Theme - Deep Space Slate (Chuyên nghiệp & Hiện đại) */\r\n --background: #09090b; /* Very deep zinc/slate */\r\n --foreground: #f8fafc;\r\n \r\n --primary: #6366f1; /* Bright Indigo for pop */\r\n --primary-foreground: #ffffff;\r\n \r\n --secondary: #1e293b; /* Slate 800 */\r\n --secondary-foreground: #f8fafc;\r\n \r\n --muted: #0f172a; /* Slate 900 for subdued bg */\r\n --muted-foreground: #94a3b8; /* Slate 400 for text */\r\n \r\n --accent: #1e293b;\r\n --accent-foreground: #f8fafc;\r\n \r\n --switch-background: #334155;\r\n --border: #334155; /* Slate 700 */\r\n \r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n \r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n \r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n\r\n --ring: #6366f1;\r\n --input: #334155;\r\n\r\n --chart-1: #fb7185;\r\n --chart-2: #6366f1;\r\n --chart-3: #34d399;\r\n --chart-4: #fbbf24;\r\n --chart-5: #818cf8;\r\n\r\n --popover: #0f172a;\r\n --popover-foreground: #f8fafc;\r\n\r\n --sidebar: #0f172a;\r\n --sidebar-foreground: #f8fafc;\r\n --sidebar-border: #334155;\r\n --sidebar-accent: #1e293b;\r\n --sidebar-accent-foreground: #818cf8;\r\n --sidebar-ring: #6366f1;\r\n }\r\n}\r\n\r\n\r\n@layer base {\r\n * {\r\n border-color: var(--border);\r\n }\r\n\r\n html, body {\r\n margin: 0;\r\n padding: 0;\r\n background-color: var(--background);\r\n color: var(--foreground);\r\n font-family: 'Inter', system-ui, sans-serif;\r\n overflow: hidden; /* Khóa scroll tổng để dùng nội bộ */\r\n height: 100%;\r\n color-scheme: light;\r\n transition: background-color 0.3s ease, color 0.3s ease;\r\n }\r\n\r\n html.dark {\r\n color-scheme: dark;\r\n }\r\n\r\n /* Fix dải trắng khi mở modal/dialog trong các thư viện (Base UI, Radix) */\r\n body[style*=\"overflow: hidden\"],\r\n body[data-scroll-locked] {\r\n padding-right: 0 !important;\r\n margin-right: 0 !important;\r\n }\r\n\r\n /* Đảm bảo Backdrop luôn phủ kín màn hình bất chấp các tính toán của thư viện */\r\n [data-base-ui-dialog-backdrop],\r\n .base-ui-backdrop,\r\n [role=\"presentation\"] > div[style*=\"fixed\"] {\r\n width: 100vw !important;\r\n height: 100vh !important;\r\n left: 0 !important;\r\n top: 0 !important;\r\n right: 0 !important;\r\n bottom: 0 !important;\r\n }\r\n\r\n /* Custom Scrollbar - Sleek & Modern */\r\n ::-webkit-scrollbar {\r\n width: 8px;\r\n height: 8px;\r\n }\r\n\r\n ::-webkit-scrollbar-track {\r\n background: transparent;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb {\r\n background: #cbd5e1; /* slate-300 */\r\n border-radius: 10px;\r\n border: 2px solid transparent;\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb {\r\n background: #334155; /* slate-700 */\r\n }\r\n\r\n ::-webkit-scrollbar-thumb:hover {\r\n background: #94a3b8; /* slate-400 */\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb:hover {\r\n background: #475569; /* slate-600 */\r\n }\r\n\r\n /* Firefox */\r\n * {\r\n scrollbar-width: thin;\r\n scrollbar-color: #cbd5e1 transparent;\r\n }\r\n\r\n .dark * {\r\n scrollbar-color: #334155 transparent;\r\n }\r\n}\r\n\r\n/* Ensure data-state=\"checked\" always works for background */\r\n[data-state=\"checked\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}\r\n\r\n[data-state=\"indeterminate\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}"
|
|
21
|
+
"content": "@import \"tailwindcss\";\r\n@plugin \"tailwindcss-animate\";\r\n@custom-variant dark (&:where(.dark, .dark *));\r\n\r\n/*\r\n View Transitions API: Tắt animation mặc định (fade cross-dissolve).\r\n ThemeToggle sẽ tự định nghĩa clip-path ripple animation thay thế.\r\n*/\r\n::view-transition-old(root),\r\n::view-transition-new(root) {\r\n animation: none;\r\n mix-blend-mode: normal;\r\n}\r\n\r\n::view-transition-old(root) {\r\n z-index: 1;\r\n}\r\n\r\n::view-transition-new(root) {\r\n z-index: 9999;\r\n}\r\n\r\n\r\n@theme {\r\n --animate-ping: ping 1.5s linear infinite; /* Chỉnh cho nó chạy chậm lại */\r\n\r\n\r\n\r\n --color-background: var(--background);\r\n --color-foreground: var(--foreground);\r\n\r\n --color-primary: var(--primary);\r\n --color-primary-foreground: var(--primary-foreground);\r\n\r\n --color-secondary: var(--secondary);\r\n --color-secondary-foreground: var(--secondary-foreground);\r\n\r\n --color-muted: var(--muted);\r\n --color-muted-foreground: var(--muted-foreground);\r\n\r\n --color-accent: var(--accent);\r\n --color-accent-foreground: var(--accent-foreground);\r\n\r\n --color-switch-background: var(--switch-background);\r\n\r\n --color-border: var(--border);\r\n\r\n --color-success: var(--success);\r\n --color-success-foreground: var(--success-foreground);\r\n\r\n --color-warning: var(--warning);\r\n --color-warning-foreground: var(--warning-foreground);\r\n\r\n --color-destructive: var(--danger);\r\n --color-destructive-foreground: var(--danger-foreground);\r\n\r\n --color-danger: var(--danger);\r\n --color-danger-foreground: var(--danger-foreground);\r\n\r\n --color-ring: var(--ring);\r\n --color-input: var(--input);\r\n\r\n --color-chart-1: var(--chart-1);\r\n --color-chart-2: var(--chart-2);\r\n --color-chart-3: var(--chart-3);\r\n --color-chart-4: var(--chart-4);\r\n --color-chart-5: var(--chart-5);\r\n\r\n --color-popover: var(--popover);\r\n --color-popover-foreground: var(--popover-foreground);\r\n\r\n --color-sidebar: var(--sidebar);\r\n --color-sidebar-foreground: var(--sidebar-foreground);\r\n --color-sidebar-border: var(--sidebar-border);\r\n --color-sidebar-accent: var(--sidebar-accent);\r\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\r\n --color-sidebar-ring: var(--sidebar-ring);\r\n\r\n --radius-sm: 0.125rem;\r\n --radius-md: 0.375rem;\r\n --radius-lg: 0.5rem;\r\n --radius-xl: 1rem;\r\n\r\n /* Z-index scale */\r\n --z-dropdown: 50;\r\n --z-sticky: 100;\r\n --z-overlay: 200;\r\n --z-modal: 300;\r\n --z-popover: 400;\r\n --z-toast: 500;\r\n\r\n --animate-spin-slow: spin 3s linear infinite;\r\n --animate-progress-stripes: progress-stripes 1s linear infinite;\r\n\r\n @keyframes progress-stripes {\r\n from { background-position: 1rem 0; }\r\n to { background-position: 0 0; }\r\n }\r\n}\r\n\r\n@layer base {\r\n :root {\r\n /* GENERATED:theme-start */\r\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\r\n --background: #ffffff;\r\n --foreground: #0f172a;\r\n --primary: #2f27ce;\r\n --primary-foreground: #ffffff;\r\n --secondary: #dedcff;\r\n --secondary-foreground: #2f27ce;\r\n --muted: #f8fafc;\r\n --muted-foreground: #64748b;\r\n --accent: #f1f5f9;\r\n --accent-foreground: #0f172a;\r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n --destructive: #ef4444;\r\n --destructive-foreground: #ffffff;\r\n --border: #e2e8f0;\r\n --input: #e2e8f0;\r\n --ring: #2f27ce;\r\n --popover: #ffffff;\r\n --popover-foreground: #0f172a;\r\n /* GENERATED:theme-end */\r\n\r\n /* Non-theme tokens (not managed by applyTheme) */\r\n --switch-background: #cbd5e1;\r\n\r\n --chart-1: #e11d48;\r\n --chart-2: #2f27ce;\r\n --chart-3: #10b981;\r\n --chart-4: #f59e0b;\r\n --chart-5: #8b5cf6;\r\n\r\n --sidebar: #f8fafc;\r\n --sidebar-foreground: #0f172a;\r\n --sidebar-border: #e2e8f0;\r\n --sidebar-accent: #f1f5f9;\r\n --sidebar-accent-foreground: #2f27ce;\r\n --sidebar-ring: #2f27ce;\r\n }\r\n\r\n .dark {\r\n /* Dark Theme - Deep Space Slate (Chuyên nghiệp & Hiện đại) */\r\n --background: #09090b; /* Very deep zinc/slate */\r\n --foreground: #f8fafc;\r\n \r\n --primary: #6366f1; /* Bright Indigo for pop */\r\n --primary-foreground: #ffffff;\r\n \r\n --secondary: #1e293b; /* Slate 800 */\r\n --secondary-foreground: #f8fafc;\r\n \r\n --muted: #0f172a; /* Slate 900 for subdued bg */\r\n --muted-foreground: #94a3b8; /* Slate 400 for text */\r\n \r\n --accent: #1e293b;\r\n --accent-foreground: #f8fafc;\r\n \r\n --switch-background: #334155;\r\n --border: #334155; /* Slate 700 */\r\n \r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n \r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n \r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n\r\n --ring: #6366f1;\r\n --input: #334155;\r\n\r\n --chart-1: #fb7185;\r\n --chart-2: #6366f1;\r\n --chart-3: #34d399;\r\n --chart-4: #fbbf24;\r\n --chart-5: #818cf8;\r\n\r\n --popover: #0f172a;\r\n --popover-foreground: #f8fafc;\r\n\r\n --sidebar: #0f172a;\r\n --sidebar-foreground: #f8fafc;\r\n --sidebar-border: #334155;\r\n --sidebar-accent: #1e293b;\r\n --sidebar-accent-foreground: #818cf8;\r\n --sidebar-ring: #6366f1;\r\n }\r\n}\r\n\r\n\r\n@layer base {\r\n * {\r\n border-color: var(--border);\r\n }\r\n\r\n html, body {\r\n margin: 0;\r\n padding: 0;\r\n background-color: var(--background);\r\n color: var(--foreground);\r\n font-family: 'Inter', system-ui, sans-serif;\r\n overflow: hidden; /* Khóa scroll tổng để dùng nội bộ */\r\n height: 100%;\r\n color-scheme: light;\r\n transition: background-color 0.3s ease, color 0.3s ease;\r\n }\r\n\r\n html.dark {\r\n color-scheme: dark;\r\n }\r\n\r\n /* Fix dải trắng khi mở modal/dialog trong các thư viện (Base UI, Radix) */\r\n body[style*=\"overflow: hidden\"],\r\n body[data-scroll-locked] {\r\n padding-right: 0 !important;\r\n margin-right: 0 !important;\r\n }\r\n\r\n /* Đảm bảo Backdrop luôn phủ kín màn hình bất chấp các tính toán của thư viện */\r\n [data-base-ui-dialog-backdrop],\r\n .base-ui-backdrop,\r\n [role=\"presentation\"] > div[style*=\"fixed\"] {\r\n width: 100vw !important;\r\n height: 100vh !important;\r\n left: 0 !important;\r\n top: 0 !important;\r\n right: 0 !important;\r\n bottom: 0 !important;\r\n }\r\n\r\n /* Custom Scrollbar - Sleek & Modern */\r\n ::-webkit-scrollbar {\r\n width: 8px;\r\n height: 8px;\r\n }\r\n\r\n ::-webkit-scrollbar-track {\r\n background: transparent;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb {\r\n background: #cbd5e1; /* slate-300 */\r\n border-radius: 10px;\r\n border: 2px solid transparent;\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb {\r\n background: #334155; /* slate-700 */\r\n }\r\n\r\n ::-webkit-scrollbar-thumb:hover {\r\n background: #94a3b8; /* slate-400 */\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb:hover {\r\n background: #475569; /* slate-600 */\r\n }\r\n\r\n /* Firefox */\r\n * {\r\n scrollbar-width: thin;\r\n scrollbar-color: #cbd5e1 transparent;\r\n }\r\n\r\n .dark * {\r\n scrollbar-color: #334155 transparent;\r\n }\r\n}\r\n\r\n/* ─── Reduced Motion ─────────────────────────────────────────────────────────\r\n Respect prefers-reduced-motion for a11y.\r\n Disables all animations and transitions globally when the user's OS\r\n requests reduced motion. Individual components can opt-out via\r\n motion-reduce:* utilities if an animation is essential.\r\n*/\r\n@media (prefers-reduced-motion: reduce) {\r\n *, *::before, *::after {\r\n animation-duration: 0.01ms !important;\r\n animation-iteration-count: 1 !important;\r\n transition-duration: 0.01ms !important;\r\n scroll-behavior: auto !important;\r\n }\r\n}\r\n\r\n/* Ensure data-state=\"checked\" always works for background */\r\n[data-state=\"checked\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}\r\n\r\n[data-state=\"indeterminate\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}"
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"path": "src/lib/theme/themes.ts",
|
|
25
|
-
"content": "// ─── Token Interfaces ─────────────────────────────────────────────────────────\n\nexport interface ThemeColors {\n // Surface\n background: string;\n foreground: string;\n // Brand\n primary: string;\n primaryForeground: string;\n // Secondary surface\n secondary: string;\n secondaryForeground: string;\n // Muted surface\n muted: string;\n mutedForeground: string;\n // Accent / hover surface\n accent: string;\n accentForeground: string;\n // Semantic states\n success: string;\n successForeground: string;\n warning: string;\n warningForeground: string;\n danger: string;\n dangerForeground: string;\n // Form / input\n border: string;\n input: string;\n ring: string;\n // Popover / overlay\n popover: string;\n popoverForeground: string;\n}\n\nexport interface Theme {\n name: string;\n label: string;\n colors: ThemeColors;\n}\n\nexport type BuiltInThemeName = 'indigo' | 'blue' | 'violet' | 'rose' | 'emerald' | 'orange' | 'slate';\n\n// ─── Built-in Themes ──────────────────────────────────────────────────────────\n\nexport const themes: Theme[] = [\n {\n name: 'indigo',\n label: 'Indigo (Default)',\n colors: {\n background: '#ffffff',\n foreground: '#0f172a',\n primary: '#2f27ce',\n primaryForeground: '#ffffff',\n secondary: '#dedcff',\n secondaryForeground: '#2f27ce',\n muted: '#f8fafc',\n mutedForeground: '#64748b',\n accent: '#f1f5f9',\n accentForeground: '#0f172a',\n success: '#10b981',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#e2e8f0',\n input: '#e2e8f0',\n ring: '#2f27ce',\n popover: '#ffffff',\n popoverForeground: '#0f172a',\n },\n },\n {\n name: 'blue',\n label: 'Blue',\n colors: {\n background: '#ffffff',\n foreground: '#0f172a',\n primary: '#3b82f6',\n primaryForeground: '#ffffff',\n secondary: '#dbeafe',\n secondaryForeground: '#1d4ed8',\n muted: '#f8fafc',\n mutedForeground: '#64748b',\n accent: '#eff6ff',\n accentForeground: '#1e40af',\n success: '#10b981',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#e2e8f0',\n input: '#e2e8f0',\n ring: '#3b82f6',\n popover: '#ffffff',\n popoverForeground: '#0f172a',\n },\n },\n {\n name: 'violet',\n label: 'Violet',\n colors: {\n background: '#ffffff',\n foreground: '#1e1b4b',\n primary: '#7c3aed',\n primaryForeground: '#ffffff',\n secondary: '#ede9fe',\n secondaryForeground: '#5b21b6',\n muted: '#f5f3ff',\n mutedForeground: '#6d28d9',\n accent: '#f5f3ff',\n accentForeground: '#4c1d95',\n success: '#10b981',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#ddd6fe',\n input: '#ddd6fe',\n ring: '#7c3aed',\n popover: '#ffffff',\n popoverForeground: '#1e1b4b',\n },\n },\n {\n name: 'rose',\n label: 'Rose',\n colors: {\n background: '#ffffff',\n foreground: '#18181b',\n primary: '#e11d48',\n primaryForeground: '#ffffff',\n secondary: '#ffe4e6',\n secondaryForeground: '#9f1239',\n muted: '#fff1f2',\n mutedForeground: '#64748b',\n accent: '#fff1f2',\n accentForeground: '#881337',\n success: '#10b981',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#fecdd3',\n input: '#fecdd3',\n ring: '#e11d48',\n popover: '#ffffff',\n popoverForeground: '#18181b',\n },\n },\n {\n name: 'emerald',\n label: 'Emerald',\n colors: {\n background: '#ffffff',\n foreground: '#064e3b',\n primary: '#059669',\n primaryForeground: '#ffffff',\n secondary: '#d1fae5',\n secondaryForeground: '#065f46',\n muted: '#ecfdf5',\n mutedForeground: '#064748b',\n accent: '#ecfdf5',\n accentForeground: '#065f46',\n success: '#10b981',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#a7f3d0',\n input: '#a7f3d0',\n ring: '#059669',\n popover: '#ffffff',\n popoverForeground: '#064e3b',\n },\n },\n {\n name: 'orange',\n label: 'Orange',\n colors: {\n background: '#ffffff',\n foreground: '#1c1917',\n primary: '#ea580c',\n primaryForeground: '#ffffff',\n secondary: '#ffedd5',\n secondaryForeground: '#9a3412',\n muted: '#fff7ed',\n mutedForeground: '#64748b',\n accent: '#fff7ed',\n accentForeground: '#7c2d12',\n success: '#10b981',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#fed7aa',\n input: '#fed7aa',\n ring: '#ea580c',\n popover: '#ffffff',\n popoverForeground: '#1c1917',\n },\n },\n {\n name: 'slate',\n label: 'Slate',\n colors: {\n background: '#ffffff',\n foreground: '#0f172a',\n primary: '#475569',\n primaryForeground: '#ffffff',\n secondary: '#f1f5f9',\n secondaryForeground: '#0f172a',\n muted: '#f8fafc',\n mutedForeground: '#64748b',\n accent: '#f1f5f9',\n accentForeground: '#0f172a',\n success: '#10b981',\n successForeground: '#ffffff',\n warning: '#f59e0b',\n warningForeground: '#ffffff',\n danger: '#ef4444',\n dangerForeground: '#ffffff',\n border: '#e2e8f0',\n input: '#e2e8f0',\n ring: '#475569',\n popover: '#ffffff',\n popoverForeground: '#0f172a',\n },\n },\n];\n\n// ─── Apply Theme ──────────────────────────────────────────────────────────────\n\nconst THEME_STYLE_ID = 'basuicn-theme';\n\n/**\n * Applies a theme by injecting a <style> tag at the START of <head>.\n *\n * Why <style> tag instead of element.style.setProperty():\n * Inline styles have the highest CSS specificity and would override\n * .dark { } class rules, breaking dark mode. A <style> tag injected\n * before the app's CSS bundle has lower specificity than .dark { }\n * rules defined later in the bundle — so dark mode always wins.\n */\nexport function applyTheme(theme: Theme) {\n if (typeof window === 'undefined') return;\n if (!theme?.colors) return;\n const { colors: c } = theme;\n\n const css = `\n:root:not(.dark) {\n --background: ${c.background};\n --foreground: ${c.foreground};\n --primary: ${c.primary};\n --primary-foreground: ${c.primaryForeground};\n --secondary: ${c.secondary};\n --secondary-foreground: ${c.secondaryForeground};\n --muted: ${c.muted};\n --muted-foreground: ${c.mutedForeground};\n --accent: ${c.accent};\n --accent-foreground: ${c.accentForeground};\n --success: ${c.success};\n --success-foreground: ${c.successForeground};\n --warning: ${c.warning};\n --warning-foreground: ${c.warningForeground};\n --danger: ${c.danger};\n --danger-foreground: ${c.dangerForeground};\n --destructive: ${c.danger};\n --destructive-foreground: ${c.dangerForeground};\n --border: ${c.border};\n --input: ${c.input};\n --ring: ${c.ring};\n --popover: ${c.popover};\n --popover-foreground: ${c.popoverForeground};\n}`.trim();\n\n let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;\n if (!styleEl) {\n styleEl = document.createElement('style');\n styleEl.id = THEME_STYLE_ID;\n document.head.appendChild(styleEl);\n }\n styleEl.textContent = css;\n}\n\n// ─── CSS Variable Generator ───────────────────────────────────────────────────\n\n/**\n * Converts a Theme's colors into a CSS `:root { }` block string.\n * Used by scripts to generate or sync CSS.\n */\nexport function toCssVars(theme: Theme): string {\n const { colors: c } = theme;\n const vars: [string, string][] = [\n ['--background', c.background],\n ['--foreground', c.foreground],\n ['--primary', c.primary],\n ['--primary-foreground', c.primaryForeground],\n ['--secondary', c.secondary],\n ['--secondary-foreground', c.secondaryForeground],\n ['--muted', c.muted],\n ['--muted-foreground', c.mutedForeground],\n ['--accent', c.accent],\n ['--accent-foreground', c.accentForeground],\n ['--success', c.success],\n ['--success-foreground', c.successForeground],\n ['--warning', c.warning],\n ['--warning-foreground', c.warningForeground],\n ['--danger', c.danger],\n ['--danger-foreground', c.dangerForeground],\n ['--destructive', c.danger],\n ['--destructive-foreground', c.dangerForeground],\n ['--border', c.border],\n ['--input', c.input],\n ['--ring', c.ring],\n ['--popover', c.popover],\n ['--popover-foreground', c.popoverForeground],\n ];\n const body = vars.map(([k, v]) => ` ${k}: ${v};`).join('\\n');\n return `:root {\\n${body}\\n }`;\n}\n\n// ─── Custom Theme Factory ─────────────────────────────────────────────────────\n\n/**\n * Creates a custom theme by merging overrides with the default (indigo) theme.\n *\n * @example\n * const myTheme = createTheme('brand', 'My Brand', { primary: '#ff6b35' });\n */\nexport function createTheme(\n name: string,\n label: string,\n colors: Partial<ThemeColors>\n): Theme {\n const base = themes[0]; // indigo as default base\n return {\n name,\n label,\n colors: { ...base.colors, ...colors },\n };\n}\n"
|
|
25
|
+
"content": "// ─── Token Interfaces ─────────────────────────────────────────────────────────\r\n\r\nexport interface ThemeColors {\r\n // Surface\r\n background: string;\r\n foreground: string;\r\n // Brand\r\n primary: string;\r\n primaryForeground: string;\r\n // Secondary surface\r\n secondary: string;\r\n secondaryForeground: string;\r\n // Muted surface\r\n muted: string;\r\n mutedForeground: string;\r\n // Accent / hover surface\r\n accent: string;\r\n accentForeground: string;\r\n // Semantic states\r\n success: string;\r\n successForeground: string;\r\n warning: string;\r\n warningForeground: string;\r\n danger: string;\r\n dangerForeground: string;\r\n // Form / input\r\n border: string;\r\n input: string;\r\n ring: string;\r\n // Popover / overlay\r\n popover: string;\r\n popoverForeground: string;\r\n}\r\n\r\nexport interface Theme {\r\n name: string;\r\n label: string;\r\n colors: ThemeColors;\r\n}\r\n\r\nexport type BuiltInThemeName = 'indigo' | 'blue' | 'violet' | 'rose' | 'emerald' | 'orange' | 'slate';\r\n\r\n// ─── Built-in Themes ──────────────────────────────────────────────────────────\r\n\r\nexport const themes: Theme[] = [\r\n {\r\n name: 'indigo',\r\n label: 'Indigo (Default)',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#0f172a',\r\n primary: '#2f27ce',\r\n primaryForeground: '#ffffff',\r\n secondary: '#dedcff',\r\n secondaryForeground: '#2f27ce',\r\n muted: '#f8fafc',\r\n mutedForeground: '#64748b',\r\n accent: '#f1f5f9',\r\n accentForeground: '#0f172a',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#e2e8f0',\r\n input: '#e2e8f0',\r\n ring: '#2f27ce',\r\n popover: '#ffffff',\r\n popoverForeground: '#0f172a',\r\n },\r\n },\r\n {\r\n name: 'blue',\r\n label: 'Blue',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#0f172a',\r\n primary: '#3b82f6',\r\n primaryForeground: '#ffffff',\r\n secondary: '#dbeafe',\r\n secondaryForeground: '#1d4ed8',\r\n muted: '#f8fafc',\r\n mutedForeground: '#64748b',\r\n accent: '#eff6ff',\r\n accentForeground: '#1e40af',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#e2e8f0',\r\n input: '#e2e8f0',\r\n ring: '#3b82f6',\r\n popover: '#ffffff',\r\n popoverForeground: '#0f172a',\r\n },\r\n },\r\n {\r\n name: 'violet',\r\n label: 'Violet',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#1e1b4b',\r\n primary: '#7c3aed',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ede9fe',\r\n secondaryForeground: '#5b21b6',\r\n muted: '#f5f3ff',\r\n mutedForeground: '#6d28d9',\r\n accent: '#f5f3ff',\r\n accentForeground: '#4c1d95',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#ddd6fe',\r\n input: '#ddd6fe',\r\n ring: '#7c3aed',\r\n popover: '#ffffff',\r\n popoverForeground: '#1e1b4b',\r\n },\r\n },\r\n {\r\n name: 'rose',\r\n label: 'Rose',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#18181b',\r\n primary: '#e11d48',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffe4e6',\r\n secondaryForeground: '#9f1239',\r\n muted: '#fff1f2',\r\n mutedForeground: '#64748b',\r\n accent: '#fff1f2',\r\n accentForeground: '#881337',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#fecdd3',\r\n input: '#fecdd3',\r\n ring: '#e11d48',\r\n popover: '#ffffff',\r\n popoverForeground: '#18181b',\r\n },\r\n },\r\n {\r\n name: 'emerald',\r\n label: 'Emerald',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#064e3b',\r\n primary: '#059669',\r\n primaryForeground: '#ffffff',\r\n secondary: '#d1fae5',\r\n secondaryForeground: '#065f46',\r\n muted: '#ecfdf5',\r\n mutedForeground: '#064748b',\r\n accent: '#ecfdf5',\r\n accentForeground: '#065f46',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#a7f3d0',\r\n input: '#a7f3d0',\r\n ring: '#059669',\r\n popover: '#ffffff',\r\n popoverForeground: '#064e3b',\r\n },\r\n },\r\n {\r\n name: 'orange',\r\n label: 'Orange',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#1c1917',\r\n primary: '#ea580c',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffedd5',\r\n secondaryForeground: '#9a3412',\r\n muted: '#fff7ed',\r\n mutedForeground: '#64748b',\r\n accent: '#fff7ed',\r\n accentForeground: '#7c2d12',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#fed7aa',\r\n input: '#fed7aa',\r\n ring: '#ea580c',\r\n popover: '#ffffff',\r\n popoverForeground: '#1c1917',\r\n },\r\n },\r\n {\r\n name: 'slate',\r\n label: 'Slate',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#0f172a',\r\n primary: '#475569',\r\n primaryForeground: '#ffffff',\r\n secondary: '#f1f5f9',\r\n secondaryForeground: '#0f172a',\r\n muted: '#f8fafc',\r\n mutedForeground: '#64748b',\r\n accent: '#f1f5f9',\r\n accentForeground: '#0f172a',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#e2e8f0',\r\n input: '#e2e8f0',\r\n ring: '#475569',\r\n popover: '#ffffff',\r\n popoverForeground: '#0f172a',\r\n },\r\n },\r\n];\r\n\r\n// ─── Apply Theme ──────────────────────────────────────────────────────────────\r\n\r\nconst THEME_STYLE_ID = 'basuicn-theme';\r\n\r\n/**\r\n * Applies a theme by injecting a <style> tag at the START of <head>.\r\n *\r\n * Why <style> tag instead of element.style.setProperty():\r\n * Inline styles have the highest CSS specificity and would override\r\n * .dark { } class rules, breaking dark mode. A <style> tag injected\r\n * before the app's CSS bundle has lower specificity than .dark { }\r\n * rules defined later in the bundle — so dark mode always wins.\r\n */\r\nexport function applyTheme(theme: Theme) {\r\n if (typeof window === 'undefined') return;\r\n if (!theme?.colors) return;\r\n const { colors: c } = theme;\r\n\r\n const css = `\r\n:root:not(.dark) {\r\n --background: ${c.background};\r\n --foreground: ${c.foreground};\r\n --primary: ${c.primary};\r\n --primary-foreground: ${c.primaryForeground};\r\n --secondary: ${c.secondary};\r\n --secondary-foreground: ${c.secondaryForeground};\r\n --muted: ${c.muted};\r\n --muted-foreground: ${c.mutedForeground};\r\n --accent: ${c.accent};\r\n --accent-foreground: ${c.accentForeground};\r\n --success: ${c.success};\r\n --success-foreground: ${c.successForeground};\r\n --warning: ${c.warning};\r\n --warning-foreground: ${c.warningForeground};\r\n --danger: ${c.danger};\r\n --danger-foreground: ${c.dangerForeground};\r\n --destructive: ${c.danger};\r\n --destructive-foreground: ${c.dangerForeground};\r\n --border: ${c.border};\r\n --input: ${c.input};\r\n --ring: ${c.ring};\r\n --popover: ${c.popover};\r\n --popover-foreground: ${c.popoverForeground};\r\n}`.trim();\r\n\r\n let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;\r\n if (!styleEl) {\r\n styleEl = document.createElement('style');\r\n styleEl.id = THEME_STYLE_ID;\r\n document.head.appendChild(styleEl);\r\n }\r\n styleEl.textContent = css;\r\n}\r\n\r\n// ─── CSS Variable Generator ───────────────────────────────────────────────────\r\n\r\n/**\r\n * Converts a Theme's colors into a CSS `:root { }` block string.\r\n * Used by scripts to generate or sync CSS.\r\n */\r\nexport function toCssVars(theme: Theme): string {\r\n const { colors: c } = theme;\r\n const vars: [string, string][] = [\r\n ['--background', c.background],\r\n ['--foreground', c.foreground],\r\n ['--primary', c.primary],\r\n ['--primary-foreground', c.primaryForeground],\r\n ['--secondary', c.secondary],\r\n ['--secondary-foreground', c.secondaryForeground],\r\n ['--muted', c.muted],\r\n ['--muted-foreground', c.mutedForeground],\r\n ['--accent', c.accent],\r\n ['--accent-foreground', c.accentForeground],\r\n ['--success', c.success],\r\n ['--success-foreground', c.successForeground],\r\n ['--warning', c.warning],\r\n ['--warning-foreground', c.warningForeground],\r\n ['--danger', c.danger],\r\n ['--danger-foreground', c.dangerForeground],\r\n ['--destructive', c.danger],\r\n ['--destructive-foreground', c.dangerForeground],\r\n ['--border', c.border],\r\n ['--input', c.input],\r\n ['--ring', c.ring],\r\n ['--popover', c.popover],\r\n ['--popover-foreground', c.popoverForeground],\r\n ];\r\n const body = vars.map(([k, v]) => ` ${k}: ${v};`).join('\\n');\r\n return `:root {\\n${body}\\n }`;\r\n}\r\n\r\n// ─── Custom Theme Factory ─────────────────────────────────────────────────────\r\n\r\n/**\r\n * Creates a custom theme by merging overrides with the default (indigo) theme.\r\n *\r\n * @example\r\n * const myTheme = createTheme('brand', 'My Brand', { primary: '#ff6b35' });\r\n */\r\nexport function createTheme(\r\n name: string,\r\n label: string,\r\n colors: Partial<ThemeColors>\r\n): Theme {\r\n const base = themes[0]; // indigo as default base\r\n return {\r\n name,\r\n label,\r\n colors: { ...base.colors, ...colors },\r\n };\r\n}\r\n"
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"path": "src/lib/theme/ThemeProvider.tsx",
|
|
29
|
-
"content": "'use client';\n\nimport { createContext, useContext, useState, useEffect, type ReactNode } from 'react';\nimport { themes, applyTheme, createTheme, type Theme, type ThemeColors, type BuiltInThemeName } from './themes';\n\nconst STORAGE_KEY = 'ui-theme';\n\n/**\n * Union type for autocomplete suggestions while allowing any string.\n */\nexport type ThemeName = BuiltInThemeName | (string & {});\n\ninterface ThemeContextValue {\n currentTheme: Theme;\n setTheme: (name: ThemeName) => void;\n /** Register and switch to a custom theme */\n setCustomTheme: (name: string, label: string, colors: Partial<ThemeColors>) => void;\n themes: Theme[];\n}\n\nconst ThemeContext = createContext<ThemeContextValue>({\n currentTheme: themes[0],\n setTheme: () => {},\n setCustomTheme: () => {},\n themes,\n});\n\ninterface ThemeProviderProps {\n children: ReactNode;\n defaultTheme?: ThemeName;\n}\n\nconst getStoredTheme = (allThemes: Theme[], defaultThemeName?: ThemeName): Theme => {\n if (typeof window === 'undefined') return allThemes[0];\n // defaultTheme prop in code takes priority over localStorage\n if (defaultThemeName) {\n return allThemes.find(t => t.name === defaultThemeName) ?? allThemes[0];\n }\n try {\n const saved = localStorage.getItem(STORAGE_KEY);\n if (saved) {\n return allThemes.find(t => t.name === saved) ?? allThemes[0];\n }\n return allThemes[0];\n } catch {\n return allThemes[0];\n }\n};\n\nexport function ThemeProvider({ children, defaultTheme }: ThemeProviderProps) {\n const [allThemes, setAllThemes] = useState<Theme[]>(themes);\n const [currentTheme, setCurrentTheme] = useState<Theme>(() => getStoredTheme(themes, defaultTheme));\n\n useEffect(() => {\n applyTheme(currentTheme);\n }, [currentTheme]);\n\n const setTheme = (name: ThemeName) => {\n const found = allThemes.find(t => t.name === name);\n if (!found) return;\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\n setCurrentTheme(found);\n };\n\n const setCustomTheme = (name: string, label: string, colors: Partial<ThemeColors>) => {\n const custom = createTheme(name, label, colors);\n setAllThemes(prev => {\n const exists = prev.findIndex(t => t.name === name);\n return exists >= 0\n ? prev.map(t => t.name === name ? custom : t)\n : [...prev, custom];\n });\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\n setCurrentTheme(custom);\n };\n\n return (\n <ThemeContext.Provider value={{ currentTheme, setTheme, setCustomTheme, themes: allThemes }}>\n {children}\n </ThemeContext.Provider>\n );\n}\n\nexport const useTheme = () => useContext(ThemeContext);\n"
|
|
29
|
+
"content": "'use client';\r\n\r\nimport { createContext, useContext, useState, useEffect, type ReactNode } from 'react';\r\nimport { themes, applyTheme, createTheme, type Theme, type ThemeColors, type BuiltInThemeName } from './themes';\r\n\r\nconst STORAGE_KEY = 'ui-theme';\r\n\r\n/**\r\n * Union type for autocomplete suggestions while allowing any string.\r\n */\r\nexport type ThemeName = BuiltInThemeName | (string & {});\r\n\r\ninterface ThemeContextValue {\r\n currentTheme: Theme;\r\n setTheme: (name: ThemeName) => void;\r\n /** Register and switch to a custom theme */\r\n setCustomTheme: (name: string, label: string, colors: Partial<ThemeColors>) => void;\r\n themes: Theme[];\r\n}\r\n\r\nconst ThemeContext = createContext<ThemeContextValue>({\r\n currentTheme: themes[0],\r\n setTheme: () => {},\r\n setCustomTheme: () => {},\r\n themes,\r\n});\r\n\r\ninterface ThemeProviderProps {\r\n children: ReactNode;\r\n defaultTheme?: ThemeName;\r\n}\r\n\r\nconst getStoredTheme = (allThemes: Theme[], defaultThemeName?: ThemeName): Theme => {\r\n if (typeof window === 'undefined') return allThemes[0];\r\n // defaultTheme prop in code takes priority over localStorage\r\n if (defaultThemeName) {\r\n return allThemes.find(t => t.name === defaultThemeName) ?? allThemes[0];\r\n }\r\n try {\r\n const saved = localStorage.getItem(STORAGE_KEY);\r\n if (saved) {\r\n return allThemes.find(t => t.name === saved) ?? allThemes[0];\r\n }\r\n return allThemes[0];\r\n } catch {\r\n return allThemes[0];\r\n }\r\n};\r\n\r\nexport function ThemeProvider({ children, defaultTheme }: ThemeProviderProps) {\r\n const [allThemes, setAllThemes] = useState<Theme[]>(themes);\r\n const [currentTheme, setCurrentTheme] = useState<Theme>(() => getStoredTheme(themes, defaultTheme));\r\n\r\n useEffect(() => {\r\n applyTheme(currentTheme);\r\n }, [currentTheme]);\r\n\r\n const setTheme = (name: ThemeName) => {\r\n const found = allThemes.find(t => t.name === name);\r\n if (!found) return;\r\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\r\n setCurrentTheme(found);\r\n };\r\n\r\n const setCustomTheme = (name: string, label: string, colors: Partial<ThemeColors>) => {\r\n const custom = createTheme(name, label, colors);\r\n setAllThemes(prev => {\r\n const exists = prev.findIndex(t => t.name === name);\r\n return exists >= 0\r\n ? prev.map(t => t.name === name ? custom : t)\r\n : [...prev, custom];\r\n });\r\n try { localStorage.setItem(STORAGE_KEY, name); } catch { /* SSR / quota */ }\r\n setCurrentTheme(custom);\r\n };\r\n\r\n return (\r\n <ThemeContext.Provider value={{ currentTheme, setTheme, setCustomTheme, themes: allThemes }}>\r\n {children}\r\n </ThemeContext.Provider>\r\n );\r\n}\r\n\r\nexport const useTheme = () => useContext(ThemeContext);\r\n"
|
|
30
30
|
}
|
|
31
31
|
]
|
|
32
32
|
},
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"files": [
|
|
70
70
|
{
|
|
71
71
|
"path": "src/components/ui/alert-dialog/AlertDialog.tsx",
|
|
72
|
-
"content": "import * as React from 'react';\r\nimport { AlertDialog as BaseAlertDialog } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst alertDialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-
|
|
72
|
+
"content": "import * as React from 'react';\r\nimport { AlertDialog as BaseAlertDialog } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst alertDialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\r\n content:\r\n 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0 data-close:zoom-out-95 data-open:zoom-in-95 sm:rounded-lg',\r\n header: 'flex flex-col space-y-2 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-2',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst AlertDialog = BaseAlertDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst AlertDialogTrigger = BaseAlertDialog.Trigger;\r\n\r\n/* ─── Close (wraps BaseAlertDialog.Close for cancel buttons) ─── */\r\nconst AlertDialogClose = BaseAlertDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\nconst AlertDialogContent = React.forwardRef<\r\n HTMLDivElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Popup>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, children, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Portal>\r\n <BaseAlertDialog.Backdrop className={slots.overlay()} />\r\n <BaseAlertDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n </BaseAlertDialog.Popup>\r\n </BaseAlertDialog.Portal>\r\n );\r\n});\r\nAlertDialogContent.displayName = 'AlertDialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst AlertDialogHeader = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n});\r\nAlertDialogHeader.displayName = 'AlertDialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst AlertDialogFooter = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n});\r\nAlertDialogFooter.displayName = 'AlertDialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst AlertDialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <BaseAlertDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nAlertDialogTitle.displayName = 'AlertDialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst AlertDialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Description\r\n ref={ref}\r\n className={slots.description({ className })}\r\n {...props}\r\n />\r\n );\r\n});\r\nAlertDialogDescription.displayName = 'AlertDialogDescription';\r\n\r\nexport {\r\n AlertDialog,\r\n AlertDialogTrigger,\r\n AlertDialogContent,\r\n AlertDialogHeader,\r\n AlertDialogFooter,\r\n AlertDialogTitle,\r\n AlertDialogDescription,\r\n AlertDialogClose,\r\n alertDialogVariants,\r\n};\r\n"
|
|
73
73
|
}
|
|
74
74
|
]
|
|
75
75
|
},
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"files": [
|
|
98
98
|
{
|
|
99
99
|
"path": "src/components/ui/autocomplete/Autocomplete.tsx",
|
|
100
|
-
"content": "import * as React from 'react';\nimport { Combobox as BaseCombobox } from '@base-ui/react';\nimport { Check, X, Loader2 } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst autocompleteVariants = tv({\n slots: {\n root: 'flex flex-col gap-1.5 w-full',\n inputContainer: 'flex items-center min-h-10 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\n input: 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-md border border-border bg-background text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-top:slide-in-from-bottom-2',\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\n },\n});\n\nexport interface AutocompleteOption {\n label: string;\n value: string;\n}\n\nexport interface AutocompleteProps {\n options: AutocompleteOption[];\n label?: string;\n placeholder?: string;\n value?: string;\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n isLoading?: boolean;\n className?: string;\n emptyText?: string;\n leftIcon?: React.ReactNode;\n}\n\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\n ({\n options,\n label,\n placeholder,\n value,\n defaultValue,\n onValueChange,\n isLoading,\n className,\n emptyText = 'No results found.',\n leftIcon,\n }, ref) => {\n const [inputValue, setInputValue] = React.useState('');\n const [open, setOpen] = React.useState(false);\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\n\n const activeValue = value !== undefined ? value : internalValue;\n\n const handleValueChange = (newVal: string | null) => {\n if (value === undefined) setInternalValue(newVal);\n if (newVal !== null) onValueChange?.(newVal);\n };\n\n const handleInputValueChange = (val: string) => {\n setInputValue(val);\n // Chỉ mở popup khi người dùng đang gõ\n setOpen(val.length > 0);\n };\n\n // Block mọi lần mở từ focus/click — chỉ cho phép đóng từ bên ngoài (click-outside, select)\n const handleOpenChange = (newOpen: boolean) => {\n if (!newOpen) setOpen(false);\n };\n\n const handleClear = (e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n handleValueChange(null);\n setInputValue('');\n setOpen(false);\n };\n\n const filteredOptions = React.useMemo(() => {\n if (!inputValue) return options;\n if (activeValue) {\n const selected = options.find(o => o.value === activeValue);\n if (selected && inputValue === selected.label) return options;\n }\n return options.filter(o =>\n o.label.toLowerCase().includes(inputValue.toLowerCase())\n );\n }, [options, inputValue, activeValue]);\n\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\n\n return (\n <BaseCombobox.Root\n value={activeValue}\n onValueChange={handleValueChange}\n onInputValueChange={handleInputValueChange}\n open={open}\n onOpenChange={handleOpenChange}\n autoHighlight\n itemToStringLabel={(val: string) => options.find(o => o.value === val)?.label ?? val}\n >\n <div className={root({ className })}>\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\n\n <div className=\"relative w-full group\">\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\n {leftIcon && (\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\n {leftIcon}\n </div>\n )}\n\n {isLoading ? (\n <Loader2 className=\"absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground\" />\n ) : activeValue && (\n <button\n type=\"button\"\n aria-label=\"Clear selection\"\n onClick={handleClear}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors\"\n >\n <X className=\"h-3.5 w-3.5\" />\n </button>\n )}\n\n <BaseCombobox.Input\n ref={ref}\n placeholder={placeholder}\n className={cn(input(), (isLoading || activeValue) && 'pr-8')}\n />\n </BaseCombobox.InputGroup>\n\n <BaseCombobox.Portal>\n <BaseCombobox.Positioner\n anchor={inputGroupRef}\n sideOffset={4}\n style={{ width: 'var(--anchor-width)' }}\n >\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\n {filteredOptions.length === 0 ? (\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\n ) : (\n filteredOptions.map((option) => (\n <BaseCombobox.Item\n key={option.value}\n value={option.value}\n className={item()}\n >\n <BaseCombobox.ItemIndicator className={indicator()}>\n <Check className=\"h-4 w-4\" />\n </BaseCombobox.ItemIndicator>\n {option.label}\n </BaseCombobox.Item>\n ))\n )}\n </BaseCombobox.List>\n </BaseCombobox.Popup>\n </BaseCombobox.Positioner>\n </BaseCombobox.Portal>\n </div>\n </div>\n </BaseCombobox.Root>\n );\n }\n);\n\nAutocomplete.displayName = 'Autocomplete';\n\nexport { Autocomplete };\n"
|
|
100
|
+
"content": "import * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst autocompleteVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex items-center min-h-10 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-md border border-border bg-background text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nexport interface AutocompleteOption {\r\n label: string;\r\n value: string;\r\n}\r\n\r\nexport interface AutocompleteProps {\r\n options: AutocompleteOption[];\r\n label?: string;\r\n placeholder?: string;\r\n value?: string;\r\n defaultValue?: string;\r\n onValueChange?: (value: string) => void;\r\n isLoading?: boolean;\r\n className?: string;\r\n emptyText?: string;\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\r\n ({\r\n options,\r\n label,\r\n placeholder,\r\n value,\r\n defaultValue,\r\n onValueChange,\r\n isLoading,\r\n className,\r\n emptyText = 'No results found.',\r\n leftIcon,\r\n }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) setInternalValue(newVal);\r\n if (newVal !== null) onValueChange?.(newVal);\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n setInputValue(val);\r\n // Khi base-ui cập nhật input sau khi chọn item, không mở lại popup\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n // Chỉ mở popup khi người dùng đang gõ\r\n setOpen(val.length > 0);\r\n };\r\n\r\n // Block mọi lần mở từ focus/click — chỉ cho phép đóng từ bên ngoài (click-outside, select)\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (!newOpen) setOpen(false);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(null);\r\n setInputValue('');\r\n setOpen(false);\r\n };\r\n\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue) return options;\r\n if (activeValue) {\r\n const selected = options.find(o => o.value === activeValue);\r\n if (selected && inputValue === selected.label) return options;\r\n }\r\n return options.filter(o =>\r\n o.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n onInputValueChange={handleInputValueChange}\r\n open={open}\r\n onOpenChange={handleOpenChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find(o => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\r\n\r\n <div className=\"relative w-full group\">\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n\r\n {isLoading ? (\r\n <Loader2 className=\"absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground\" />\r\n ) : activeValue && (\r\n <button\r\n type=\"button\"\r\n aria-label=\"Clear selection\"\r\n onClick={handleClear}\r\n className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </button>\r\n )}\r\n\r\n <BaseCombobox.Input\r\n ref={ref}\r\n placeholder={placeholder}\r\n className={cn(input(), (isLoading || activeValue) && 'pr-8')}\r\n />\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)' }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : (\r\n filteredOptions.map((option) => (\r\n <BaseCombobox.Item\r\n key={option.value}\r\n value={option.value}\r\n className={item()}\r\n >\r\n <BaseCombobox.ItemIndicator className={indicator()}>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseCombobox.ItemIndicator>\r\n {option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nAutocomplete.displayName = 'Autocomplete';\r\n\r\nexport { Autocomplete };\r\n"
|
|
101
101
|
}
|
|
102
102
|
]
|
|
103
103
|
},
|
|
@@ -167,7 +167,7 @@
|
|
|
167
167
|
"files": [
|
|
168
168
|
{
|
|
169
169
|
"path": "src/components/ui/calendar/Calendar.tsx",
|
|
170
|
-
"content": "'use client';\n\nimport * as React from 'react';\nimport { DayPicker, type DateRange, type Matcher } from 'react-day-picker';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport * as locales from 'react-day-picker/locale';\n\nimport 'react-day-picker/dist/style.css';\n\nconst calendarVariants = tv({\n base: 'rdp-custom',\n variants: {\n size: {\n sm: '[&_.rdp-day]:h-7 [&_.rdp-day]:w-7 [&_.rdp-day]:text-xs',\n md: '[&_.rdp-day]:h-9 [&_.rdp-day]:w-9 [&_.rdp-day]:text-sm',\n lg: '[&_.rdp-day]:h-11 [&_.rdp-day]:w-11 [&_.rdp-day]:text-base',\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\nconst wrapperVariants = tv({\n base: 'inline-block rounded-xl border border-border bg-background p-3 shadow-sm',\n});\n\nexport type CalendarMode = 'single' | 'range' | 'multiple';\n\n/** Props for the Calendar component */\nexport interface CalendarProps extends VariantProps<typeof calendarVariants> {\n /** Selection mode: single date, date range, or multiple dates */\n mode?: CalendarMode;\n /** Currently selected value (Date, DateRange, or Date[] depending on mode) */\n selected?: Date | DateRange | Date[];\n /** Callback fired when the selection changes */\n onSelect?: (value: Date | DateRange | Date[] | undefined) => void;\n /** Disable all dates before today */\n disablePastDates?: boolean;\n /** Disable all dates after today */\n disableFutureDates?: boolean;\n /** Disable the entire calendar */\n disabled?: boolean;\n /** Locale key from react-day-picker/locale (defaults to 'enUS') */\n locale?: keyof typeof locales;\n className?: string;\n /** Additional class name for the outer wrapper */\n wrapperClassName?: string;\n /** Number of months to display side by side */\n numberOfMonths?: number;\n /** Show days from adjacent months */\n showOutsideDays?: boolean;\n}\n\nconst Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(({\n mode = 'single',\n selected,\n onSelect,\n disablePastDates = false,\n disableFutureDates = false,\n disabled = false,\n locale = 'enUS',\n className,\n wrapperClassName,\n size,\n numberOfMonths = 1,\n showOutsideDays = true,\n}, ref) => {\n const getDisabled = (): Matcher | Matcher[] | undefined => {\n if (disabled) return true;\n if (disablePastDates && disableFutureDates) return () => true;\n if (disablePastDates) return { before: new Date() };\n if (disableFutureDates) return { after: new Date() };\n return undefined;\n };\n\n const commonProps = {\n locale: locales[locale as keyof typeof locales],\n disabled: getDisabled(),\n numberOfMonths,\n showOutsideDays,\n className: calendarVariants({ size, className }),\n };\n\n return (\n <div ref={ref} className={wrapperVariants({ className: wrapperClassName })}>\n {mode === 'range' ? (\n <DayPicker\n {...commonProps}\n mode=\"range\"\n selected={selected as DateRange | undefined}\n onSelect={(d) => onSelect?.(d)}\n />\n ) : mode === 'multiple' ? (\n <DayPicker\n {...commonProps}\n mode=\"multiple\"\n selected={selected as Date[] | undefined}\n onSelect={(d) => onSelect?.(d)}\n />\n ) : (\n <DayPicker\n {...commonProps}\n mode=\"single\"\n selected={selected as Date | undefined}\n onSelect={(d) => onSelect?.(d)}\n />\n )}\n </div>\n );\n});\n\nCalendar.displayName = 'Calendar';\n\nexport { Calendar };\n"
|
|
170
|
+
"content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { DayPicker, type DateRange, type Matcher } from 'react-day-picker';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport * as locales from 'react-day-picker/locale';\r\n\r\nimport 'react-day-picker/dist/style.css';\r\n\r\nconst calendarVariants = tv({\r\n base: 'rdp-custom',\r\n variants: {\r\n size: {\r\n sm: '[&_.rdp-day]:h-7 [&_.rdp-day]:w-7 [&_.rdp-day]:text-xs',\r\n md: '[&_.rdp-day]:h-9 [&_.rdp-day]:w-9 [&_.rdp-day]:text-sm',\r\n lg: '[&_.rdp-day]:h-11 [&_.rdp-day]:w-11 [&_.rdp-day]:text-base',\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\nconst wrapperVariants = tv({\r\n base: 'inline-block rounded-xl border border-border bg-background p-3 shadow-sm',\r\n});\r\n\r\nexport type CalendarMode = 'single' | 'range' | 'multiple';\r\n\r\n/** Props for the Calendar component */\r\nexport interface CalendarProps extends VariantProps<typeof calendarVariants> {\r\n /** Selection mode: single date, date range, or multiple dates */\r\n mode?: CalendarMode;\r\n /** Currently selected value (Date, DateRange, or Date[] depending on mode) */\r\n selected?: Date | DateRange | Date[];\r\n /** Callback fired when the selection changes */\r\n onSelect?: (value: Date | DateRange | Date[] | undefined) => void;\r\n /** Disable all dates before today */\r\n disablePastDates?: boolean;\r\n /** Disable all dates after today */\r\n disableFutureDates?: boolean;\r\n /** Disable the entire calendar */\r\n disabled?: boolean;\r\n /** Locale key from react-day-picker/locale (defaults to 'enUS') */\r\n locale?: keyof typeof locales;\r\n className?: string;\r\n /** Additional class name for the outer wrapper */\r\n wrapperClassName?: string;\r\n /** Number of months to display side by side */\r\n numberOfMonths?: number;\r\n /** Show days from adjacent months */\r\n showOutsideDays?: boolean;\r\n}\r\n\r\nconst Calendar = React.forwardRef<HTMLDivElement, CalendarProps>(({\r\n mode = 'single',\r\n selected,\r\n onSelect,\r\n disablePastDates = false,\r\n disableFutureDates = false,\r\n disabled = false,\r\n locale = 'enUS',\r\n className,\r\n wrapperClassName,\r\n size,\r\n numberOfMonths = 1,\r\n showOutsideDays = true,\r\n}, ref) => {\r\n const getDisabled = (): Matcher | Matcher[] | undefined => {\r\n if (disabled) return true;\r\n if (disablePastDates && disableFutureDates) return () => true;\r\n if (disablePastDates) return { before: new Date() };\r\n if (disableFutureDates) return { after: new Date() };\r\n return undefined;\r\n };\r\n\r\n const commonProps = {\r\n locale: locales[locale as keyof typeof locales],\r\n disabled: getDisabled(),\r\n numberOfMonths,\r\n showOutsideDays,\r\n className: calendarVariants({ size, className }),\r\n };\r\n\r\n return (\r\n <div ref={ref} className={wrapperVariants({ className: wrapperClassName })}>\r\n {mode === 'range' ? (\r\n <DayPicker\r\n {...commonProps}\r\n mode=\"range\"\r\n selected={selected as DateRange | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n />\r\n ) : mode === 'multiple' ? (\r\n <DayPicker\r\n {...commonProps}\r\n mode=\"multiple\"\r\n selected={selected as Date[] | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n />\r\n ) : (\r\n <DayPicker\r\n {...commonProps}\r\n mode=\"single\"\r\n selected={selected as Date | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n />\r\n )}\r\n </div>\r\n );\r\n});\r\n\r\nCalendar.displayName = 'Calendar';\r\n\r\nexport { Calendar };\r\n"
|
|
171
171
|
}
|
|
172
172
|
]
|
|
173
173
|
},
|
|
@@ -184,6 +184,21 @@
|
|
|
184
184
|
}
|
|
185
185
|
]
|
|
186
186
|
},
|
|
187
|
+
"carousel": {
|
|
188
|
+
"name": "carousel",
|
|
189
|
+
"dependencies": [
|
|
190
|
+
"keen-slider",
|
|
191
|
+
"tailwind-variants",
|
|
192
|
+
"lucide-react"
|
|
193
|
+
],
|
|
194
|
+
"internalDependencies": [],
|
|
195
|
+
"files": [
|
|
196
|
+
{
|
|
197
|
+
"path": "src/components/ui/carousel/Carousel.tsx",
|
|
198
|
+
"content": "import * as React from 'react';\r\nimport { useKeenSlider, type KeenSliderOptions, type KeenSliderPlugin, type KeenSliderInstance } from 'keen-slider/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronLeft, ChevronRight } from 'lucide-react';\r\nimport 'keen-slider/keen-slider.min.css';\r\n\r\n// ─── Variants ─────────────────────────────────────────────────────────────────\r\n\r\nconst carouselVariants = tv({\r\n slots: {\r\n root: 'relative w-full select-none',\r\n viewport: 'keen-slider overflow-hidden rounded-xl',\r\n slide: 'keen-slider__slide',\r\n arrow: [\r\n 'absolute top-1/2 -translate-y-1/2 z-10',\r\n 'flex items-center justify-center',\r\n 'h-9 w-9 rounded-full',\r\n 'bg-background/80 backdrop-blur-sm border border-border shadow-md',\r\n 'text-foreground transition-all duration-150',\r\n 'hover:bg-background hover:scale-105',\r\n 'disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:scale-100',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n ],\r\n dotsWrapper: 'flex justify-center gap-1.5 mt-3',\r\n dot: [\r\n 'h-1.5 rounded-full bg-border transition-all duration-300',\r\n 'hover:bg-muted-foreground cursor-pointer',\r\n ],\r\n },\r\n});\r\n\r\nconst { root, viewport, slide, arrow, dotsWrapper, dot } = carouselVariants();\r\n\r\n// ─── Context ──────────────────────────────────────────────────────────────────\r\n\r\ninterface CarouselContextValue {\r\n instanceRef: React.MutableRefObject<KeenSliderInstance | null>;\r\n currentSlide: number;\r\n slideCount: number;\r\n loop: boolean;\r\n}\r\n\r\nconst CarouselContext = React.createContext<CarouselContextValue | null>(null);\r\n\r\nfunction useCarousel() {\r\n const ctx = React.useContext(CarouselContext);\r\n if (!ctx) throw new Error('useCarousel must be used within <Carousel>');\r\n return ctx;\r\n}\r\n\r\n// ─── AutoPlay plugin ──────────────────────────────────────────────────────────\r\n\r\nexport function AutoPlayPlugin(interval = 3000): KeenSliderPlugin {\r\n return (slider) => {\r\n let timeout: ReturnType<typeof setTimeout>;\r\n let mouseOver = false;\r\n\r\n const clearNext = () => clearTimeout(timeout);\r\n const next = () => {\r\n clearNext();\r\n timeout = setTimeout(() => {\r\n if (!mouseOver) slider.next();\r\n }, interval);\r\n };\r\n\r\n slider.on('created', () => {\r\n slider.container.addEventListener('mouseover', () => { mouseOver = true; clearNext(); });\r\n slider.container.addEventListener('mouseout', () => { mouseOver = false; next(); });\r\n next();\r\n });\r\n slider.on('dragStarted', clearNext);\r\n slider.on('animationEnded', next);\r\n slider.on('updated', next);\r\n slider.on('destroyed', clearNext);\r\n };\r\n}\r\n\r\n// ─── WheelControls plugin ────────────────────────────────────────────────────\r\n\r\nexport const WheelControlsPlugin: KeenSliderPlugin = (slider) => {\r\n let touchTimeout: ReturnType<typeof setTimeout>;\r\n let position = { x: 0, y: 0 };\r\n let wheelActive = false;\r\n\r\n const dispatch = (e: WheelEvent, name: string) => {\r\n position.x -= e.deltaX;\r\n position.y -= e.deltaY;\r\n slider.container.dispatchEvent(new CustomEvent(name, { detail: { x: position.x, y: position.y } }));\r\n };\r\n\r\n const wheelStart = (e: WheelEvent) => {\r\n position = { x: e.pageX, y: e.pageY };\r\n dispatch(e, 'ksDragStart');\r\n };\r\n\r\n const wheel = (e: WheelEvent) => {\r\n dispatch(e, 'ksDrag');\r\n };\r\n\r\n const wheelEnd = (e: WheelEvent) => {\r\n dispatch(e, 'ksDragEnd');\r\n };\r\n\r\n const eventWheel = (e: WheelEvent) => {\r\n if (!wheelActive) {\r\n wheelStart(e);\r\n wheelActive = true;\r\n }\r\n wheel(e);\r\n clearTimeout(touchTimeout);\r\n touchTimeout = setTimeout(() => {\r\n wheelActive = false;\r\n wheelEnd(e);\r\n }, 50);\r\n };\r\n\r\n slider.on('created', () => {\r\n slider.container.addEventListener('wheel', eventWheel, { passive: true });\r\n });\r\n slider.on('destroyed', () => {\r\n slider.container.removeEventListener('wheel', eventWheel);\r\n });\r\n};\r\n\r\n// ─── MutationPlugin ───────────────────────────────────────────────────────────\r\n\r\nexport const MutationPlugin: KeenSliderPlugin = (slider) => {\r\n const observer = new MutationObserver((mutations) => {\r\n mutations.forEach(() => slider.update());\r\n });\r\n slider.on('created', () => {\r\n observer.observe(slider.container, { childList: true });\r\n });\r\n slider.on('destroyed', () => observer.disconnect());\r\n};\r\n\r\n// ─── Carousel3DPlugin ─────────────────────────────────────────────────────────\r\n\r\n/**\r\n * 3-D rotating carousel plugin.\r\n * Wrap the `keen-slider` div inside a perspective scene:\r\n * <div style={{ perspective: '1000px' }}>\r\n * <div ref={sliderRef} style={{ transformStyle: 'preserve-3d' }} …>\r\n * {slides}\r\n * </div>\r\n * </div>\r\n *\r\n * @param depth translateZ radius in px (default 280). For N slides of width W:\r\n * depth ≈ (W / 2) / Math.tan(Math.PI / N)\r\n */\r\nexport function Carousel3DPlugin(depth = 280): KeenSliderPlugin {\r\n return (slider) => {\r\n function applyRotation() {\r\n const deg = 360 * slider.track.details.progress;\r\n slider.container.style.transform = `translateZ(-${depth}px) rotateY(${-deg}deg)`;\r\n }\r\n\r\n slider.on('created', () => {\r\n const perSlide = 360 / slider.slides.length;\r\n slider.slides.forEach((el, i) => {\r\n el.style.transform = `rotateY(${perSlide * i}deg) translateZ(${depth}px)`;\r\n });\r\n applyRotation();\r\n });\r\n\r\n slider.on('detailsChanged', applyRotation);\r\n };\r\n}\r\n\r\n// ─── ResizePlugin ─────────────────────────────────────────────────────────────\r\n\r\nexport const ResizePlugin: KeenSliderPlugin = (slider) => {\r\n const observer = new ResizeObserver(() => slider.update());\r\n slider.on('created', () => observer.observe(slider.container));\r\n slider.on('destroyed', () => observer.disconnect());\r\n};\r\n\r\n// ─── Root ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselProps {\r\n children: React.ReactNode;\r\n /** Loop back to start after last slide */\r\n loop?: boolean;\r\n /** Auto-advance interval in ms; `false` to disable */\r\n autoPlay?: number | false;\r\n /** Initial slide index */\r\n initial?: number;\r\n /** Slides to show per view */\r\n slidesPerView?: number;\r\n /** Gap between slides in px */\r\n spacing?: number;\r\n /** Drag / swipe enabled */\r\n drag?: boolean;\r\n /** Vertical orientation — must also provide `height` */\r\n vertical?: boolean;\r\n /** Explicit height for the viewport (required for vertical mode) e.g. \"320px\" */\r\n height?: string;\r\n /** Scroll mode */\r\n mode?: 'snap' | 'free' | 'free-snap';\r\n /** Enable mouse-wheel navigation */\r\n wheelControls?: boolean;\r\n /** Watch DOM mutations and auto-update */\r\n mutationObserver?: boolean;\r\n /** Breakpoint overrides — keyed by media query string */\r\n breakpoints?: KeenSliderOptions['breakpoints'];\r\n /** Called when the active slide changes */\r\n onSlideChange?: (index: number) => void;\r\n /** Called on every position change — provides progress 0-1 */\r\n onDetailsChanged?: (progress: number, rel: number) => void;\r\n /** Called once the slider is ready */\r\n onCreated?: (instance: CarouselContextValue['instanceRef']['current']) => void;\r\n /** Extra className applied to the inner viewport div */\r\n viewportClassName?: string;\r\n className?: string;\r\n}\r\n\r\nconst Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(\r\n (\r\n {\r\n children,\r\n loop = false,\r\n autoPlay = false,\r\n initial = 0,\r\n slidesPerView = 1,\r\n spacing = 16,\r\n drag = true,\r\n vertical = false,\r\n height,\r\n mode = 'snap',\r\n wheelControls = false,\r\n mutationObserver = false,\r\n breakpoints,\r\n onSlideChange,\r\n onDetailsChanged,\r\n onCreated,\r\n viewportClassName,\r\n className,\r\n },\r\n ref\r\n ) => {\r\n const [currentSlide, setCurrentSlide] = React.useState(initial);\r\n const [slideCount, setSlideCount] = React.useState(0);\r\n\r\n const plugins: KeenSliderPlugin[] = [];\r\n if (autoPlay !== false) plugins.push(AutoPlayPlugin(autoPlay));\r\n if (wheelControls) plugins.push(WheelControlsPlugin);\r\n if (mutationObserver) plugins.push(MutationPlugin);\r\n plugins.push(ResizePlugin);\r\n\r\n const [sliderRef, instanceRef] = useKeenSlider<HTMLDivElement>(\r\n {\r\n loop,\r\n initial,\r\n drag,\r\n vertical,\r\n mode,\r\n breakpoints,\r\n slides: { perView: slidesPerView, spacing },\r\n slideChanged(s) {\r\n setCurrentSlide(s.track.details.rel);\r\n onSlideChange?.(s.track.details.rel);\r\n },\r\n detailsChanged(s) {\r\n onDetailsChanged?.(s.track.details.progress, s.track.details.rel);\r\n },\r\n created(s) {\r\n setSlideCount(s.track.details.slides.length);\r\n onCreated?.(s);\r\n },\r\n updated(s) {\r\n setSlideCount(s.track.details.slides.length);\r\n },\r\n },\r\n plugins\r\n );\r\n\r\n // Separate CarouselSlide children from navigation children\r\n const slides: React.ReactNode[] = [];\r\n const navigation: React.ReactNode[] = [];\r\n React.Children.forEach(children, (child) => {\r\n if (React.isValidElement(child) && (child.type as { displayName?: string }).displayName === 'CarouselSlide') {\r\n slides.push(child);\r\n } else {\r\n navigation.push(child);\r\n }\r\n });\r\n\r\n return (\r\n <CarouselContext.Provider value={{ instanceRef, currentSlide, slideCount, loop }}>\r\n <div ref={ref} className={root({ className })} data-testid=\"carousel\">\r\n <div\r\n ref={sliderRef}\r\n className={cn(viewport(), viewportClassName)}\r\n style={height ? { height } : undefined}\r\n >\r\n {slides}\r\n </div>\r\n {navigation}\r\n </div>\r\n </CarouselContext.Provider>\r\n );\r\n }\r\n);\r\nCarousel.displayName = 'Carousel';\r\n\r\n// ─── CarouselSlide ────────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselSlideProps extends React.HTMLAttributes<HTMLDivElement> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst CarouselSlide = React.forwardRef<HTMLDivElement, CarouselSlideProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <div ref={ref} className={slide({ className })} {...props}>\r\n {children}\r\n </div>\r\n )\r\n);\r\nCarouselSlide.displayName = 'CarouselSlide';\r\n\r\n// ─── CarouselPrev / CarouselNext ──────────────────────────────────────────────\r\n\r\nexport interface CarouselArrowProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselPrev = React.forwardRef<HTMLButtonElement, CarouselArrowProps>(\r\n ({ className, ...props }, ref) => {\r\n const { instanceRef, currentSlide, loop } = useCarousel();\r\n const disabled = !loop && currentSlide === 0;\r\n\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Previous slide\"\r\n disabled={disabled}\r\n onClick={() => instanceRef.current?.prev()}\r\n className={arrow({ className: cn('left-2', className) })}\r\n {...props}\r\n >\r\n <ChevronLeft className=\"h-4 w-4\" />\r\n </button>\r\n );\r\n }\r\n);\r\nCarouselPrev.displayName = 'CarouselPrev';\r\n\r\nconst CarouselNext = React.forwardRef<HTMLButtonElement, CarouselArrowProps>(\r\n ({ className, ...props }, ref) => {\r\n const { instanceRef, currentSlide, slideCount, loop } = useCarousel();\r\n const disabled = !loop && currentSlide === slideCount - 1;\r\n\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Next slide\"\r\n disabled={disabled}\r\n onClick={() => instanceRef.current?.next()}\r\n className={arrow({ className: cn('right-2', className) })}\r\n {...props}\r\n >\r\n <ChevronRight className=\"h-4 w-4\" />\r\n </button>\r\n );\r\n }\r\n);\r\nCarouselNext.displayName = 'CarouselNext';\r\n\r\n// ─── CarouselDots ─────────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselDotsProps extends React.HTMLAttributes<HTMLDivElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselDots = React.forwardRef<HTMLDivElement, CarouselDotsProps>(\r\n ({ className, ...props }, ref) => {\r\n const { instanceRef, currentSlide, slideCount } = useCarousel();\r\n\r\n if (slideCount === 0) return null;\r\n\r\n return (\r\n <div ref={ref} className={dotsWrapper({ className })} role=\"tablist\" aria-label=\"Carousel navigation\" {...props}>\r\n {Array.from({ length: slideCount }).map((_, i) => (\r\n <button\r\n key={i}\r\n role=\"tab\"\r\n aria-selected={i === currentSlide}\r\n aria-label={`Go to slide ${i + 1}`}\r\n onClick={() => instanceRef.current?.moveToIdx(i)}\r\n className={dot({\r\n className: cn(\r\n i === currentSlide\r\n ? 'w-6 bg-primary'\r\n : 'w-1.5 bg-border hover:bg-muted-foreground'\r\n ),\r\n })}\r\n />\r\n ))}\r\n </div>\r\n );\r\n }\r\n);\r\nCarouselDots.displayName = 'CarouselDots';\r\n\r\n// ─── CarouselProgress ─────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselProgressProps extends React.HTMLAttributes<HTMLDivElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselProgress = React.forwardRef<HTMLDivElement, CarouselProgressProps>(\r\n ({ className, ...props }, ref) => {\r\n const { currentSlide, slideCount } = useCarousel();\r\n\r\n const pct = slideCount > 1\r\n ? Math.round((currentSlide / (slideCount - 1)) * 100)\r\n : 100;\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"progressbar\"\r\n aria-valuenow={pct}\r\n aria-valuemin={0}\r\n aria-valuemax={100}\r\n aria-label=\"Carousel progress\"\r\n className={cn('w-full h-1 bg-border rounded-full overflow-hidden mt-3', className)}\r\n {...props}\r\n >\r\n <div\r\n className=\"h-full bg-primary rounded-full transition-all duration-300\"\r\n style={{ width: `${pct}%` }}\r\n />\r\n </div>\r\n );\r\n }\r\n);\r\nCarouselProgress.displayName = 'CarouselProgress';\r\n\r\n// ─── CarouselCounter ──────────────────────────────────────────────────────────\r\n\r\nexport interface CarouselCounterProps extends React.HTMLAttributes<HTMLSpanElement> {\r\n className?: string;\r\n}\r\n\r\nconst CarouselCounter = React.forwardRef<HTMLSpanElement, CarouselCounterProps>(\r\n ({ className, ...props }, ref) => {\r\n const { currentSlide, slideCount } = useCarousel();\r\n\r\n return (\r\n <span\r\n ref={ref}\r\n aria-live=\"polite\"\r\n aria-label=\"Slide counter\"\r\n className={cn('text-xs text-muted-foreground tabular-nums', className)}\r\n {...props}\r\n >\r\n {currentSlide + 1} / {slideCount}\r\n </span>\r\n );\r\n }\r\n);\r\nCarouselCounter.displayName = 'CarouselCounter';\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Carousel,\r\n CarouselSlide,\r\n CarouselPrev,\r\n CarouselNext,\r\n CarouselDots,\r\n CarouselProgress,\r\n CarouselCounter,\r\n useCarousel,\r\n};\r\n"
|
|
199
|
+
}
|
|
200
|
+
]
|
|
201
|
+
},
|
|
187
202
|
"checkbox": {
|
|
188
203
|
"name": "checkbox",
|
|
189
204
|
"dependencies": [
|
|
@@ -239,7 +254,7 @@
|
|
|
239
254
|
"files": [
|
|
240
255
|
{
|
|
241
256
|
"path": "src/components/ui/context-menu/ContextMenu.tsx",
|
|
242
|
-
"content": "'use client';\n\nimport * as React from 'react';\nimport * as ReactDOM from 'react-dom';\nimport { tv } from 'tailwind-variants';\nimport { Check, Circle } from 'lucide-react';\n\nconst contextMenuVariants = tv({\n slots: {\n content:\n 'fixed z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95',\n item: 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n checkboxItem:\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\n radioItem:\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\n label: 'px-2 py-1.5 text-sm font-semibold',\n separator: '-mx-1 my-1 h-px bg-border',\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\n indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\n },\n});\n\nconst styles = contextMenuVariants();\n\n// ─── Context ─────────────────────────────────────────────────────────────────\n\ninterface ContextMenuState {\n open: boolean;\n position: { x: number; y: number };\n}\n\nconst ContextMenuContext = React.createContext<{\n state: ContextMenuState;\n close: () => void;\n}>({ state: { open: false, position: { x: 0, y: 0 } }, close: () => {} });\n\n// ─── Root ─────────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuProps {\n children: React.ReactNode;\n}\n\nconst ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {\n const [state, setState] = React.useState<ContextMenuState>({\n open: false,\n position: { x: 0, y: 0 },\n });\n\n const close = React.useCallback(() => setState(s => ({ ...s, open: false })), []);\n\n // Close on outside click / second right-click / scroll\n React.useEffect(() => {\n if (!state.open) return;\n const handleClose = () => close();\n document.addEventListener('click', handleClose, { capture: true });\n document.addEventListener('contextmenu', handleClose, { capture: true });\n document.addEventListener('scroll', handleClose, { capture: true, passive: true });\n return () => {\n document.removeEventListener('click', handleClose, { capture: true });\n document.removeEventListener('contextmenu', handleClose, { capture: true });\n document.removeEventListener('scroll', handleClose, { capture: true });\n };\n }, [state.open, close]);\n\n return (\n <ContextMenuContext.Provider value={{ state, close }}>\n {React.Children.map(children, child => {\n if (React.isValidElement(child) && child.type === ContextMenuTrigger) {\n return React.cloneElement(\n child as React.ReactElement<{ onContextMenu?: (e: React.MouseEvent) => void }>,\n {\n onContextMenu: (e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setState({ open: true, position: { x: e.clientX, y: e.clientY } });\n },\n }\n );\n }\n return child;\n })}\n </ContextMenuContext.Provider>\n );\n};\nContextMenu.displayName = 'ContextMenu';\n\n// ─── Trigger ──────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {}\n\nconst ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(\n ({ children, ...props }, ref) => (\n <div ref={ref} {...props}>{children}</div>\n )\n);\nContextMenuTrigger.displayName = 'ContextMenuTrigger';\n\n// ─── Content ──────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {}\n\nconst ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(\n ({ className, children, ...props }, ref) => {\n const { state, close } = React.useContext(ContextMenuContext);\n const contentRef = React.useRef<HTMLDivElement>(null);\n\n // Merge refs\n const mergedRef = React.useCallback(\n (node: HTMLDivElement | null) => {\n (contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\n },\n [ref]\n );\n\n // Focus first item on open\n React.useEffect(() => {\n if (state.open) {\n const first = contentRef.current?.querySelector<HTMLElement>('[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]');\n first?.focus();\n }\n }, [state.open]);\n\n // Keyboard navigation\n const handleKeyDown = React.useCallback(\n (e: React.KeyboardEvent<HTMLDivElement>) => {\n const items = Array.from(\n contentRef.current?.querySelectorAll<HTMLElement>(\n '[role=\"menuitem\"]:not([disabled]),[role=\"menuitemcheckbox\"]:not([disabled]),[role=\"menuitemradio\"]:not([disabled])'\n ) ?? []\n );\n const current = document.activeElement as HTMLElement;\n const idx = items.indexOf(current);\n\n if (e.key === 'ArrowDown') {\n e.preventDefault();\n items[(idx + 1) % items.length]?.focus();\n } else if (e.key === 'ArrowUp') {\n e.preventDefault();\n items[(idx - 1 + items.length) % items.length]?.focus();\n } else if (e.key === 'Escape') {\n e.preventDefault();\n close();\n } else if (e.key === 'Tab') {\n e.preventDefault();\n close();\n } else if (e.key === 'Home') {\n e.preventDefault();\n items[0]?.focus();\n } else if (e.key === 'End') {\n e.preventDefault();\n items[items.length - 1]?.focus();\n }\n },\n [close]\n );\n\n if (!state.open) return null;\n\n // Clamp position to viewport\n const vw = typeof window !== 'undefined' ? window.innerWidth : 0;\n const vh = typeof window !== 'undefined' ? window.innerHeight : 0;\n const MENU_W = 192; // ~min-w-[8rem] generous estimate\n const MENU_H = 300;\n const x = Math.min(state.position.x, vw - MENU_W - 8);\n const y = Math.min(state.position.y, vh - MENU_H - 8);\n\n return ReactDOM.createPortal(\n <div\n ref={mergedRef}\n className={styles.content({ className })}\n style={{ top: y, left: x }}\n role=\"menu\"\n aria-orientation=\"vertical\"\n onKeyDown={handleKeyDown}\n {...props}\n >\n {children}\n </div>,\n document.body\n );\n }\n);\nContextMenuContent.displayName = 'ContextMenuContent';\n\n// ─── Item ─────────────────────────────────────────────────────────────────────\n\nexport interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {\n inset?: boolean;\n disabled?: boolean;\n}\n\nconst ContextMenuItem = React.forwardRef<HTMLDivElement, ContextMenuItemProps>(\n ({ className, inset, disabled, onClick, ...props }, ref) => {\n const { close } = React.useContext(ContextMenuContext);\n return (\n <div\n ref={ref}\n role=\"menuitem\"\n tabIndex={disabled ? undefined : -1}\n aria-disabled={disabled || undefined}\n className={styles.item({\n className: `${inset ? 'pl-8' : ''} ${disabled ? 'opacity-50 pointer-events-none' : ''} ${className ?? ''}`,\n })}\n onClick={(e) => {\n if (disabled) return;\n onClick?.(e);\n close();\n }}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n if (!disabled) {\n onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);\n close();\n }\n }\n }}\n {...props}\n />\n );\n }\n);\nContextMenuItem.displayName = 'ContextMenuItem';\n\n// ─── CheckboxItem ─────────────────────────────────────────────────────────────\n\nexport interface ContextMenuCheckboxItemProps extends React.HTMLAttributes<HTMLDivElement> {\n checked?: boolean;\n onCheckedChange?: (checked: boolean) => void;\n}\n\nconst ContextMenuCheckboxItem = React.forwardRef<HTMLDivElement, ContextMenuCheckboxItemProps>(\n ({ className, children, checked, onCheckedChange, ...props }, ref) => (\n <div\n ref={ref}\n role=\"menuitemcheckbox\"\n tabIndex={-1}\n aria-checked={checked}\n className={styles.checkboxItem({ className })}\n onClick={() => onCheckedChange?.(!checked)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n onCheckedChange?.(!checked);\n }\n }}\n {...props}\n >\n <span className={styles.indicatorWrapper()}>\n {checked && <Check className=\"h-4 w-4\" />}\n </span>\n {children}\n </div>\n )\n);\nContextMenuCheckboxItem.displayName = 'ContextMenuCheckboxItem';\n\n// ─── RadioGroup + RadioItem ───────────────────────────────────────────────────\n\nconst ContextMenuRadioContext = React.createContext<{\n value?: string;\n onValueChange?: (value: string) => void;\n}>({});\n\nexport interface ContextMenuRadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n value?: string;\n onValueChange?: (value: string) => void;\n}\n\nconst ContextMenuRadioGroup = React.forwardRef<HTMLDivElement, ContextMenuRadioGroupProps>(\n ({ value, onValueChange, ...props }, ref) => (\n <ContextMenuRadioContext.Provider value={{ value, onValueChange }}>\n <div ref={ref} role=\"group\" {...props} />\n </ContextMenuRadioContext.Provider>\n )\n);\nContextMenuRadioGroup.displayName = 'ContextMenuRadioGroup';\n\nexport interface ContextMenuRadioItemProps extends React.HTMLAttributes<HTMLDivElement> {\n value: string;\n}\n\nconst ContextMenuRadioItem = React.forwardRef<HTMLDivElement, ContextMenuRadioItemProps>(\n ({ className, children, value, ...props }, ref) => {\n const ctx = React.useContext(ContextMenuRadioContext);\n const isChecked = ctx.value === value;\n return (\n <div\n ref={ref}\n role=\"menuitemradio\"\n tabIndex={-1}\n aria-checked={isChecked}\n className={styles.radioItem({ className })}\n onClick={() => ctx.onValueChange?.(value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n ctx.onValueChange?.(value);\n }\n }}\n {...props}\n >\n <span className={styles.indicatorWrapper()}>\n {isChecked && <Circle className=\"h-2 w-2 fill-current\" />}\n </span>\n {children}\n </div>\n );\n }\n);\nContextMenuRadioItem.displayName = 'ContextMenuRadioItem';\n\n// ─── Label ────────────────────────────────────────────────────────────────────\n\nconst ContextMenuLabel = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className })} {...props} />\n )\n);\nContextMenuLabel.displayName = 'ContextMenuLabel';\n\n// ─── Separator ────────────────────────────────────────────────────────────────\n\nconst ContextMenuSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\n ({ className, ...props }, ref) => (\n <div ref={ref} role=\"separator\" className={styles.separator({ className })} {...props} />\n )\n);\nContextMenuSeparator.displayName = 'ContextMenuSeparator';\n\n// ─── Shortcut ─────────────────────────────────────────────────────────────────\n\nconst ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span aria-hidden=\"true\" className={styles.shortcut({ className })} {...props} />\n);\nContextMenuShortcut.displayName = 'ContextMenuShortcut';\n\nexport {\n ContextMenu,\n ContextMenuTrigger,\n ContextMenuContent,\n ContextMenuItem,\n ContextMenuCheckboxItem,\n ContextMenuRadioGroup,\n ContextMenuRadioItem,\n ContextMenuLabel,\n ContextMenuSeparator,\n ContextMenuShortcut,\n contextMenuVariants,\n};\n"
|
|
257
|
+
"content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport * as ReactDOM from 'react-dom';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Check, Circle } from 'lucide-react';\r\n\r\nconst contextMenuVariants = tv({\r\n slots: {\r\n content:\r\n 'fixed z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95',\r\n item: 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n checkboxItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\r\n radioItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground',\r\n label: 'px-2 py-1.5 text-sm font-semibold',\r\n separator: '-mx-1 my-1 h-px bg-border',\r\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\r\n indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nconst styles = contextMenuVariants();\r\n\r\n// ─── Context ─────────────────────────────────────────────────────────────────\r\n\r\ninterface ContextMenuState {\r\n open: boolean;\r\n position: { x: number; y: number };\r\n}\r\n\r\nconst ContextMenuContext = React.createContext<{\r\n state: ContextMenuState;\r\n close: () => void;\r\n}>({ state: { open: false, position: { x: 0, y: 0 } }, close: () => {} });\r\n\r\n// ─── Root ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuProps {\r\n children: React.ReactNode;\r\n}\r\n\r\nconst ContextMenu: React.FC<ContextMenuProps> = ({ children }) => {\r\n const [state, setState] = React.useState<ContextMenuState>({\r\n open: false,\r\n position: { x: 0, y: 0 },\r\n });\r\n\r\n const close = React.useCallback(() => setState(s => ({ ...s, open: false })), []);\r\n\r\n // Close on outside click / second right-click / scroll\r\n React.useEffect(() => {\r\n if (!state.open) return;\r\n const handleClose = () => close();\r\n document.addEventListener('click', handleClose, { capture: true });\r\n document.addEventListener('contextmenu', handleClose, { capture: true });\r\n document.addEventListener('scroll', handleClose, { capture: true, passive: true });\r\n return () => {\r\n document.removeEventListener('click', handleClose, { capture: true });\r\n document.removeEventListener('contextmenu', handleClose, { capture: true });\r\n document.removeEventListener('scroll', handleClose, { capture: true });\r\n };\r\n }, [state.open, close]);\r\n\r\n return (\r\n <ContextMenuContext.Provider value={{ state, close }}>\r\n {React.Children.map(children, child => {\r\n if (React.isValidElement(child) && child.type === ContextMenuTrigger) {\r\n return React.cloneElement(\r\n child as React.ReactElement<{ onContextMenu?: (e: React.MouseEvent) => void }>,\r\n {\r\n onContextMenu: (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setState({ open: true, position: { x: e.clientX, y: e.clientY } });\r\n },\r\n }\r\n );\r\n }\r\n return child;\r\n })}\r\n </ContextMenuContext.Provider>\r\n );\r\n};\r\nContextMenu.displayName = 'ContextMenu';\r\n\r\n// ─── Trigger ──────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {}\r\n\r\nconst ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(\r\n ({ children, ...props }, ref) => (\r\n <div ref={ref} {...props}>{children}</div>\r\n )\r\n);\r\nContextMenuTrigger.displayName = 'ContextMenuTrigger';\r\n\r\n// ─── Content ──────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {}\r\n\r\nconst ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(\r\n ({ className, children, ...props }, ref) => {\r\n const { state, close } = React.useContext(ContextMenuContext);\r\n const contentRef = React.useRef<HTMLDivElement>(null);\r\n\r\n // Merge refs\r\n const mergedRef = React.useCallback(\r\n (node: HTMLDivElement | null) => {\r\n (contentRef as React.MutableRefObject<HTMLDivElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;\r\n },\r\n [ref]\r\n );\r\n\r\n // Focus first item on open\r\n React.useEffect(() => {\r\n if (state.open) {\r\n const first = contentRef.current?.querySelector<HTMLElement>('[role=\"menuitem\"],[role=\"menuitemcheckbox\"],[role=\"menuitemradio\"]');\r\n first?.focus();\r\n }\r\n }, [state.open]);\r\n\r\n // Keyboard navigation\r\n const handleKeyDown = React.useCallback(\r\n (e: React.KeyboardEvent<HTMLDivElement>) => {\r\n const items = Array.from(\r\n contentRef.current?.querySelectorAll<HTMLElement>(\r\n '[role=\"menuitem\"]:not([disabled]),[role=\"menuitemcheckbox\"]:not([disabled]),[role=\"menuitemradio\"]:not([disabled])'\r\n ) ?? []\r\n );\r\n const current = document.activeElement as HTMLElement;\r\n const idx = items.indexOf(current);\r\n\r\n if (e.key === 'ArrowDown') {\r\n e.preventDefault();\r\n items[(idx + 1) % items.length]?.focus();\r\n } else if (e.key === 'ArrowUp') {\r\n e.preventDefault();\r\n items[(idx - 1 + items.length) % items.length]?.focus();\r\n } else if (e.key === 'Escape') {\r\n e.preventDefault();\r\n close();\r\n } else if (e.key === 'Tab') {\r\n e.preventDefault();\r\n close();\r\n } else if (e.key === 'Home') {\r\n e.preventDefault();\r\n items[0]?.focus();\r\n } else if (e.key === 'End') {\r\n e.preventDefault();\r\n items[items.length - 1]?.focus();\r\n }\r\n },\r\n [close]\r\n );\r\n\r\n if (!state.open) return null;\r\n\r\n // Clamp position to viewport\r\n const vw = typeof window !== 'undefined' ? window.innerWidth : 0;\r\n const vh = typeof window !== 'undefined' ? window.innerHeight : 0;\r\n const MENU_W = 192; // ~min-w-[8rem] generous estimate\r\n const MENU_H = 300;\r\n const x = Math.min(state.position.x, vw - MENU_W - 8);\r\n const y = Math.min(state.position.y, vh - MENU_H - 8);\r\n\r\n return ReactDOM.createPortal(\r\n <div\r\n ref={mergedRef}\r\n className={styles.content({ className })}\r\n style={{ top: y, left: x }}\r\n role=\"menu\"\r\n aria-orientation=\"vertical\"\r\n onKeyDown={handleKeyDown}\r\n {...props}\r\n >\r\n {children}\r\n </div>,\r\n document.body\r\n );\r\n }\r\n);\r\nContextMenuContent.displayName = 'ContextMenuContent';\r\n\r\n// ─── Item ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {\r\n inset?: boolean;\r\n disabled?: boolean;\r\n}\r\n\r\nconst ContextMenuItem = React.forwardRef<HTMLDivElement, ContextMenuItemProps>(\r\n ({ className, inset, disabled, onClick, ...props }, ref) => {\r\n const { close } = React.useContext(ContextMenuContext);\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"menuitem\"\r\n tabIndex={disabled ? undefined : -1}\r\n aria-disabled={disabled || undefined}\r\n className={styles.item({\r\n className: `${inset ? 'pl-8' : ''} ${disabled ? 'opacity-50 pointer-events-none' : ''} ${className ?? ''}`,\r\n })}\r\n onClick={(e) => {\r\n if (disabled) return;\r\n onClick?.(e);\r\n close();\r\n }}\r\n onKeyDown={(e) => {\r\n if (e.key === 'Enter' || e.key === ' ') {\r\n e.preventDefault();\r\n if (!disabled) {\r\n onClick?.(e as unknown as React.MouseEvent<HTMLDivElement>);\r\n close();\r\n }\r\n }\r\n }}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nContextMenuItem.displayName = 'ContextMenuItem';\r\n\r\n// ─── CheckboxItem ─────────────────────────────────────────────────────────────\r\n\r\nexport interface ContextMenuCheckboxItemProps extends React.HTMLAttributes<HTMLDivElement> {\r\n checked?: boolean;\r\n onCheckedChange?: (checked: boolean) => void;\r\n}\r\n\r\nconst ContextMenuCheckboxItem = React.forwardRef<HTMLDivElement, ContextMenuCheckboxItemProps>(\r\n ({ className, children, checked, onCheckedChange, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n role=\"menuitemcheckbox\"\r\n tabIndex={-1}\r\n aria-checked={checked}\r\n className={styles.checkboxItem({ className })}\r\n onClick={() => onCheckedChange?.(!checked)}\r\n onKeyDown={(e) => {\r\n if (e.key === 'Enter' || e.key === ' ') {\r\n e.preventDefault();\r\n onCheckedChange?.(!checked);\r\n }\r\n }}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n {checked && <Check className=\"h-4 w-4\" />}\r\n </span>\r\n {children}\r\n </div>\r\n )\r\n);\r\nContextMenuCheckboxItem.displayName = 'ContextMenuCheckboxItem';\r\n\r\n// ─── RadioGroup + RadioItem ───────────────────────────────────────────────────\r\n\r\nconst ContextMenuRadioContext = React.createContext<{\r\n value?: string;\r\n onValueChange?: (value: string) => void;\r\n}>({});\r\n\r\nexport interface ContextMenuRadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {\r\n value?: string;\r\n onValueChange?: (value: string) => void;\r\n}\r\n\r\nconst ContextMenuRadioGroup = React.forwardRef<HTMLDivElement, ContextMenuRadioGroupProps>(\r\n ({ value, onValueChange, ...props }, ref) => (\r\n <ContextMenuRadioContext.Provider value={{ value, onValueChange }}>\r\n <div ref={ref} role=\"group\" {...props} />\r\n </ContextMenuRadioContext.Provider>\r\n )\r\n);\r\nContextMenuRadioGroup.displayName = 'ContextMenuRadioGroup';\r\n\r\nexport interface ContextMenuRadioItemProps extends React.HTMLAttributes<HTMLDivElement> {\r\n value: string;\r\n}\r\n\r\nconst ContextMenuRadioItem = React.forwardRef<HTMLDivElement, ContextMenuRadioItemProps>(\r\n ({ className, children, value, ...props }, ref) => {\r\n const ctx = React.useContext(ContextMenuRadioContext);\r\n const isChecked = ctx.value === value;\r\n return (\r\n <div\r\n ref={ref}\r\n role=\"menuitemradio\"\r\n tabIndex={-1}\r\n aria-checked={isChecked}\r\n className={styles.radioItem({ className })}\r\n onClick={() => ctx.onValueChange?.(value)}\r\n onKeyDown={(e) => {\r\n if (e.key === 'Enter' || e.key === ' ') {\r\n e.preventDefault();\r\n ctx.onValueChange?.(value);\r\n }\r\n }}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n {isChecked && <Circle className=\"h-2 w-2 fill-current\" />}\r\n </span>\r\n {children}\r\n </div>\r\n );\r\n }\r\n);\r\nContextMenuRadioItem.displayName = 'ContextMenuRadioItem';\r\n\r\n// ─── Label ────────────────────────────────────────────────────────────────────\r\n\r\nconst ContextMenuLabel = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className })} {...props} />\r\n )\r\n);\r\nContextMenuLabel.displayName = 'ContextMenuLabel';\r\n\r\n// ─── Separator ────────────────────────────────────────────────────────────────\r\n\r\nconst ContextMenuSeparator = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<'div'>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} role=\"separator\" className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nContextMenuSeparator.displayName = 'ContextMenuSeparator';\r\n\r\n// ─── Shortcut ─────────────────────────────────────────────────────────────────\r\n\r\nconst ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span aria-hidden=\"true\" className={styles.shortcut({ className })} {...props} />\r\n);\r\nContextMenuShortcut.displayName = 'ContextMenuShortcut';\r\n\r\nexport {\r\n ContextMenu,\r\n ContextMenuTrigger,\r\n ContextMenuContent,\r\n ContextMenuItem,\r\n ContextMenuCheckboxItem,\r\n ContextMenuRadioGroup,\r\n ContextMenuRadioItem,\r\n ContextMenuLabel,\r\n ContextMenuSeparator,\r\n ContextMenuShortcut,\r\n contextMenuVariants,\r\n};\r\n"
|
|
243
258
|
}
|
|
244
259
|
]
|
|
245
260
|
},
|
|
@@ -273,7 +288,7 @@
|
|
|
273
288
|
"files": [
|
|
274
289
|
{
|
|
275
290
|
"path": "src/components/ui/dialog/Dialog.tsx",
|
|
276
|
-
"content": "import * as React from 'react';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst dialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0! z-50 bg-black/30 backdrop-blur-sm data-
|
|
291
|
+
"content": "import * as React from 'react';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst dialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0! z-50 bg-black/30 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\r\n content:\r\n 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0 data-close:zoom-out-95 data-open:zoom-in-95',\r\n header: 'flex flex-col space-y-1.5 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-auto',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n close:\r\n 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:pointer-events-none data-open:bg-accent data-open:text-muted-foreground',\r\n },\r\n variants: {\r\n size: {\r\n default: {\r\n content: 'max-w-lg sm:rounded-lg',\r\n },\r\n fullScreen: {\r\n content:\r\n 'inset-0 left-0 top-0 translate-x-0 translate-y-0 max-w-none h-full rounded-none border-none',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'default',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Dialog = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DialogTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DialogClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup + default X button) ─── */\r\ninterface DialogContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof dialogVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(\r\n ({ className, children, size, ...props }, ref) => {\r\n const slots = dialogVariants({ size });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n <BaseDialog.Close className={slots.close()}>\r\n <X className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Close</span>\r\n </BaseDialog.Close>\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDialogContent.displayName = 'DialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n },\r\n);\r\nDialogHeader.displayName = 'DialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDialogFooter.displayName = 'DialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst DialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDialogTitle.displayName = 'DialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDialogDescription.displayName = 'DialogDescription';\r\n\r\nexport {\r\n Dialog,\r\n DialogTrigger,\r\n DialogContent,\r\n DialogHeader,\r\n DialogFooter,\r\n DialogTitle,\r\n DialogDescription,\r\n DialogClose,\r\n dialogVariants,\r\n};\r\n"
|
|
277
292
|
}
|
|
278
293
|
]
|
|
279
294
|
},
|
|
@@ -288,7 +303,7 @@
|
|
|
288
303
|
"files": [
|
|
289
304
|
{
|
|
290
305
|
"path": "src/components/ui/drawer/Drawer.tsx",
|
|
291
|
-
"content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/40
|
|
306
|
+
"content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/40 data-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0',\r\n panel: [\r\n 'fixed z-50 bg-background shadow-2xl flex flex-col',\r\n 'data-starting:animate-in data-ending:animate-out duration-300',\r\n 'outline-none overflow-hidden m-0 p-0 max-w-full max-h-full border-none',\r\n ],\r\n header:\r\n 'flex items-center justify-between px-6 py-4 border-b border-border/50 shrink-0',\r\n title: 'text-base font-semibold text-foreground',\r\n description: 'text-sm text-muted-foreground mt-0.5',\r\n body: 'flex-1 overflow-y-auto px-6 py-4',\r\n footer: 'px-6 py-4 border-t border-border/50 shrink-0',\r\n close:\r\n 'rounded-sm opacity-70 hover:opacity-100 transition-opacity ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',\r\n },\r\n variants: {\r\n direction: {\r\n left: {\r\n panel:\r\n 'inset-y-0 left-0 h-full data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full transition-transform',\r\n },\r\n right: {\r\n panel:\r\n 'inset-y-0 right-0 h-full data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full transition-transform',\r\n },\r\n top: {\r\n panel:\r\n 'inset-x-0 top-0 w-full data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full transition-transform',\r\n },\r\n bottom: {\r\n panel:\r\n 'inset-x-0 bottom-0 w-full data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full transition-transform',\r\n },\r\n },\r\n size: {\r\n sm: {},\r\n md: {},\r\n lg: {},\r\n full: {},\r\n },\r\n backdropBlur: {\r\n true: { overlay: 'backdrop-blur-sm' },\r\n false: { overlay: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { direction: 'left', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'left', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'left', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'left', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'right', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'right', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'right', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'right', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'top', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'top', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'top', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'top', size: 'full', class: { panel: 'h-full' } },\r\n { direction: 'bottom', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'bottom', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'bottom', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'bottom', size: 'full', class: { panel: 'h-full' } },\r\n ],\r\n defaultVariants: {\r\n direction: 'right',\r\n size: 'md',\r\n backdropBlur: true,\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Drawer = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DrawerTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DrawerClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\ninterface DrawerContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof drawerVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ className, children, direction, size, backdropBlur, ...props }, ref) => {\r\n const slots = drawerVariants({ direction, size, backdropBlur });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.panel({ className })} {...props}>\r\n {children}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDrawerContent.displayName = 'DrawerContent';\r\n\r\n/* ─── Header (includes close button by default) ─── */\r\ninterface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\r\n hideClose?: boolean;\r\n}\r\n\r\nconst DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(\r\n ({ className, children, hideClose, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <div ref={ref} className={slots.header({ className })} {...props}>\r\n <div>{children}</div>\r\n {!hideClose && (\r\n <BaseDialog.Close className={slots.close()} aria-label=\"Close\">\r\n <X className=\"h-4 w-4\" />\r\n </BaseDialog.Close>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\nDrawerHeader.displayName = 'DrawerHeader';\r\n\r\n/* ─── Title ─── */\r\nconst DrawerTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDrawerTitle.displayName = 'DrawerTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DrawerDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDrawerDescription.displayName = 'DrawerDescription';\r\n\r\n/* ─── Body (scrollable content area) ─── */\r\nconst DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.body({ className })} {...props} />;\r\n },\r\n);\r\nDrawerBody.displayName = 'DrawerBody';\r\n\r\n/* ─── Footer ─── */\r\nconst DrawerFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDrawerFooter.displayName = 'DrawerFooter';\r\n\r\nexport {\r\n Drawer,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n drawerVariants,\r\n};\r\nexport type { DrawerContentProps, DrawerHeaderProps };\r\n"
|
|
292
307
|
}
|
|
293
308
|
]
|
|
294
309
|
},
|
|
@@ -336,6 +351,22 @@
|
|
|
336
351
|
}
|
|
337
352
|
]
|
|
338
353
|
},
|
|
354
|
+
"menu-bar": {
|
|
355
|
+
"name": "menu-bar",
|
|
356
|
+
"dependencies": [
|
|
357
|
+
"@base-ui/react",
|
|
358
|
+
"react-router-dom",
|
|
359
|
+
"tailwind-variants",
|
|
360
|
+
"lucide-react"
|
|
361
|
+
],
|
|
362
|
+
"internalDependencies": [],
|
|
363
|
+
"files": [
|
|
364
|
+
{
|
|
365
|
+
"path": "src/components/ui/menu-bar/MenuBar.tsx",
|
|
366
|
+
"content": "import * as React from 'react';\nimport { Menu as BaseMenu } from '@base-ui/react';\nimport { useNavigate, useMatch } from 'react-router-dom';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRight, ExternalLink } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n/* ─── Types ─────────────────────────────────────────────────────────────── */\n\n/** How the menu item behaves when clicked */\nexport type MenuBarItemType = 'link' | 'button' | 'modal' | 'external';\n\n/** Config for a single item inside a menu */\nexport interface MenuBarItemConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** @default 'button' */\n type?: MenuBarItemType;\n /** Route path for type='link', full URL for type='external' */\n href?: string;\n /** Called on click for type='button' | 'modal', and as fallback for 'link' | 'external' */\n onClick?: () => void;\n shortcut?: string;\n disabled?: boolean;\n /** Renders a separator line before this item */\n separator?: boolean;\n /** Nested items — renders as a flyout submenu (unlimited depth) */\n children?: MenuBarItemConfig[];\n}\n\n/** Config for one top-level menu (trigger + its dropdown) */\nexport interface MenuBarMenuConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n items: MenuBarItemConfig[];\n disabled?: boolean;\n}\n\n/* ─── Variants ──────────────────────────────────────────────────────────── */\n\nconst menuBarVariants = tv({\n slots: {\n root: 'flex items-center gap-0.5 rounded-md border border-border bg-background p-1',\n trigger:\n 'inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',\n content:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n item:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n itemActive: 'bg-accent/50 font-medium',\n subTrigger:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n subContent:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n separator: '-mx-1 my-1 h-px bg-border',\n label: 'px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\n },\n});\n\nconst styles = menuBarVariants();\n\n/* ─── MenuBar ───────────────────────────────────────────────────────────── */\n\nexport interface MenuBarProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBar = React.forwardRef<HTMLDivElement, MenuBarProps>(({ className, ...props }, ref) => (\n <div ref={ref} role=\"menubar\" className={styles.root({ className })} {...props} />\n));\nMenuBar.displayName = 'MenuBar';\n\n/* ─── MenuBarMenu ───────────────────────────────────────────────────────── */\n\nconst MenuBarMenu = BaseMenu.Root;\n\n/* ─── MenuBarTrigger ────────────────────────────────────────────────────── */\n\nexport interface MenuBarTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarTrigger = React.forwardRef<HTMLButtonElement, MenuBarTriggerProps>(\n ({ className, ...props }, ref) => (\n <BaseMenu.Trigger\n ref={ref as React.Ref<HTMLButtonElement>}\n className={styles.trigger({ className })}\n {...props}\n />\n )\n);\nMenuBarTrigger.displayName = 'MenuBarTrigger';\n\n/* ─── MenuBarContent ────────────────────────────────────────────────────── */\n\nexport interface MenuBarContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n side?: 'top' | 'right' | 'bottom' | 'left';\n align?: 'start' | 'center' | 'end';\n sideOffset?: number;\n}\n\nconst MenuBarContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarContentProps\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarContent.displayName = 'MenuBarContent';\n\n/* ─── MenuBarItem ───────────────────────────────────────────────────────── */\n\nexport interface MenuBarItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\n className?: string;\n /** Applies active/highlighted styling (e.g. current route) */\n active?: boolean;\n}\n\nconst MenuBarItem = React.forwardRef<React.ComponentRef<typeof BaseMenu.Item>, MenuBarItemProps>(\n ({ className, active, children, ...props }, ref) => (\n <BaseMenu.Item\n ref={ref}\n className={styles.item({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n >\n {children}\n </BaseMenu.Item>\n )\n);\nMenuBarItem.displayName = 'MenuBarItem';\n\n/* ─── MenuBarSeparator ──────────────────────────────────────────────────── */\n\nexport interface MenuBarSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarSeparator = React.forwardRef<HTMLDivElement, MenuBarSeparatorProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.separator({ className })} {...props} />\n )\n);\nMenuBarSeparator.displayName = 'MenuBarSeparator';\n\n/* ─── MenuBarLabel ──────────────────────────────────────────────────────── */\n\nexport interface MenuBarLabelProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarLabel = React.forwardRef<HTMLDivElement, MenuBarLabelProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className })} {...props} />\n )\n);\nMenuBarLabel.displayName = 'MenuBarLabel';\n\n/* ─── MenuBarShortcut ───────────────────────────────────────────────────── */\n\nconst MenuBarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={styles.shortcut({ className })} {...props} />\n);\nMenuBarShortcut.displayName = 'MenuBarShortcut';\n\n/* ─── MenuBarSub ────────────────────────────────────────────────────────── */\n\nconst MenuBarSub = BaseMenu.SubmenuRoot;\n\n/* ─── MenuBarSubTrigger ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubTrigger = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\n MenuBarSubTriggerProps\n>(({ className, children, ...props }, ref) => (\n <BaseMenu.SubmenuTrigger ref={ref} className={styles.subTrigger({ className })} {...props}>\n {children}\n <ChevronRight className=\"ml-auto\" />\n </BaseMenu.SubmenuTrigger>\n));\nMenuBarSubTrigger.displayName = 'MenuBarSubTrigger';\n\n/* ─── MenuBarSubContent ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarSubContentProps\n>(({ className, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner sideOffset={-4}>\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarSubContent.displayName = 'MenuBarSubContent';\n\n/* ─── MenuBarGroup ──────────────────────────────────────────────────────── */\n\nconst MenuBarGroup = BaseMenu.Group;\n\n/* ─── Config-driven layer ───────────────────────────────────────────────── */\n\n/**\n * Internal recursive renderer for MenuBarItemConfig.\n * Handles all 4 item types, separators, and unlimited submenu depth.\n */\nconst MenuBarItemRenderer = ({ item }: { item: MenuBarItemConfig }) => {\n const navigate = useNavigate();\n const isLinkType = item.type === 'link' && !!item.href;\n const match = useMatch(isLinkType ? item.href! : '__NO_MATCH__');\n const isActive = isLinkType && !!match;\n\n const handleClick = React.useCallback(() => {\n if (item.type === 'link' && item.href) {\n navigate(item.href);\n } else if (item.type === 'external' && item.href) {\n window.open(item.href, '_blank', 'noopener,noreferrer');\n } else {\n item.onClick?.();\n }\n }, [item, navigate]);\n\n if (item.children && item.children.length > 0) {\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarSub>\n <MenuBarSubTrigger disabled={item.disabled}>\n {item.icon}\n {item.label}\n </MenuBarSubTrigger>\n <MenuBarSubContent>\n {item.children.map((child) => (\n <MenuBarItemRenderer key={child.id} item={child} />\n ))}\n </MenuBarSubContent>\n </MenuBarSub>\n </>\n );\n }\n\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarItem active={isActive} onClick={handleClick} disabled={item.disabled}>\n {item.icon}\n {item.label}\n {item.shortcut && <MenuBarShortcut>{item.shortcut}</MenuBarShortcut>}\n {item.type === 'external' && <ExternalLink className=\"ml-auto !size-3 opacity-50\" />}\n </MenuBarItem>\n </>\n );\n};\n\n/** Props for the config-driven MenuBarNav component */\nexport interface MenuBarNavProps extends Omit<MenuBarProps, 'children'> {\n /** Array of top-level menus, each with nested items supporting unlimited depth */\n menus: MenuBarMenuConfig[];\n}\n\n/**\n * Config-driven menu bar. Pass a `menus` array and it renders everything —\n * triggers, dropdowns, submenus, separators, active link states.\n *\n * @example\n * ```tsx\n * <MenuBarNav menus={[\n * {\n * id: 'file', label: 'File',\n * items: [\n * { id: 'new', label: 'New', type: 'button', onClick: handleNew, shortcut: '⌘N' },\n * { id: 'open', label: 'Open', type: 'link', href: '/open' },\n * { id: 'sep', label: '---', separator: true, ... },\n * ],\n * },\n * ]} />\n * ```\n */\nconst MenuBarNav = React.forwardRef<HTMLDivElement, MenuBarNavProps>(\n ({ menus, className, ...props }, ref) => (\n <MenuBar ref={ref} className={className} {...props}>\n {menus.map((menu) => (\n <MenuBarMenu key={menu.id}>\n <MenuBarTrigger disabled={menu.disabled}>\n {menu.icon}\n {menu.label}\n </MenuBarTrigger>\n <MenuBarContent>\n {menu.items.map((item) => (\n <MenuBarItemRenderer key={item.id} item={item} />\n ))}\n </MenuBarContent>\n </MenuBarMenu>\n ))}\n </MenuBar>\n )\n);\nMenuBarNav.displayName = 'MenuBarNav';\n\n/* ─── Exports ───────────────────────────────────────────────────────────── */\n\nexport {\n menuBarVariants,\n // Primitive API\n MenuBar,\n MenuBarMenu,\n MenuBarTrigger,\n MenuBarContent,\n MenuBarItem,\n MenuBarSeparator,\n MenuBarLabel,\n MenuBarShortcut,\n MenuBarSub,\n MenuBarSubTrigger,\n MenuBarSubContent,\n MenuBarGroup,\n // Config-driven API\n MenuBarNav,\n};\n"
|
|
367
|
+
}
|
|
368
|
+
]
|
|
369
|
+
},
|
|
339
370
|
"pagination": {
|
|
340
371
|
"name": "pagination",
|
|
341
372
|
"dependencies": [
|
|
@@ -360,7 +391,7 @@
|
|
|
360
391
|
"files": [
|
|
361
392
|
{
|
|
362
393
|
"path": "src/components/ui/popover/Popover.tsx",
|
|
363
|
-
"content": "'use client';\n\nimport * as React from 'react';\nimport { Popover as BasePopover } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst popoverVariants = tv({\n slots: {\n popup: 'z-50 w-72 rounded-md border border-border bg-background p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-ending:animate-out data-ending:fade-out-0 data-ending:zoom-out-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',\n arrow: 'fill-popover stroke-border stroke-[1px]',\n },\n});\n\nconst { popup, arrow } = popoverVariants();\n\n// ─── Compound Components ─────────────────────────────────────────────────────\n\nconst Popover = BasePopover.Root;\n\nconst PopoverTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef<typeof BasePopover.Trigger>\n>(({ children, render, ...props }, ref) => (\n <BasePopover.Trigger\n ref={ref}\n render={render ?? (React.isValidElement(children) ? children : undefined)}\n {...props}\n >\n {React.isValidElement(children) ? undefined : children}\n </BasePopover.Trigger>\n));\nPopoverTrigger.displayName = 'PopoverTrigger';\n\nexport interface PopoverContentProps\n extends React.ComponentPropsWithoutRef<typeof BasePopover.Popup> {\n /** Side offset from the trigger (default: 4) */\n sideOffset?: number;\n /** Side to display the popover (default: 'bottom') */\n side?: 'top' | 'right' | 'bottom' | 'left';\n /** Alignment relative to the trigger (default: 'center') */\n align?: 'start' | 'center' | 'end';\n /** Show the arrow indicator */\n showArrow?: boolean;\n}\n\nconst PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(\n ({ className, sideOffset = 4, side = 'bottom', align = 'center', showArrow = true, children, ...props }, ref) => (\n <BasePopover.Portal>\n <BasePopover.Positioner sideOffset={sideOffset} side={side} align={align}>\n <BasePopover.Popup ref={ref} className={cn(popup(), className)} {...props}>\n {showArrow && <BasePopover.Arrow className={arrow()} />}\n {children}\n </BasePopover.Popup>\n </BasePopover.Positioner>\n </BasePopover.Portal>\n )\n);\nPopoverContent.displayName = 'PopoverContent';\n\nconst PopoverClose = BasePopover.Close;\n\nexport { Popover, PopoverTrigger, PopoverContent, PopoverClose, popoverVariants };\n"
|
|
394
|
+
"content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst popoverVariants = tv({\r\n slots: {\r\n popup: 'z-50 w-72 rounded-md border border-border bg-background p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-ending:animate-out data-ending:fade-out-0 data-ending:zoom-out-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',\r\n arrow: 'fill-popover stroke-border stroke-[1px]',\r\n },\r\n});\r\n\r\nconst { popup, arrow } = popoverVariants();\r\n\r\n// ─── Compound Components ─────────────────────────────────────────────────────\r\n\r\nconst Popover = BasePopover.Root;\r\n\r\nconst PopoverTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ComponentPropsWithoutRef<typeof BasePopover.Trigger>\r\n>(({ children, render, ...props }, ref) => (\r\n <BasePopover.Trigger\r\n ref={ref}\r\n render={render ?? (React.isValidElement(children) ? children : undefined)}\r\n {...props}\r\n >\r\n {React.isValidElement(children) ? undefined : children}\r\n </BasePopover.Trigger>\r\n));\r\nPopoverTrigger.displayName = 'PopoverTrigger';\r\n\r\nexport interface PopoverContentProps\r\n extends React.ComponentPropsWithoutRef<typeof BasePopover.Popup> {\r\n /** Side offset from the trigger (default: 4) */\r\n sideOffset?: number;\r\n /** Side to display the popover (default: 'bottom') */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger (default: 'center') */\r\n align?: 'start' | 'center' | 'end';\r\n /** Show the arrow indicator */\r\n showArrow?: boolean;\r\n}\r\n\r\nconst PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(\r\n ({ className, sideOffset = 4, side = 'bottom', align = 'center', showArrow = true, children, ...props }, ref) => (\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner sideOffset={sideOffset} side={side} align={align}>\r\n <BasePopover.Popup ref={ref} className={cn(popup(), className)} {...props}>\r\n {showArrow && <BasePopover.Arrow className={arrow()} />}\r\n {children}\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n )\r\n);\r\nPopoverContent.displayName = 'PopoverContent';\r\n\r\nconst PopoverClose = BasePopover.Close;\r\n\r\nexport { Popover, PopoverTrigger, PopoverContent, PopoverClose, popoverVariants };\r\n"
|
|
364
395
|
}
|
|
365
396
|
]
|
|
366
397
|
},
|
|
@@ -432,7 +463,7 @@
|
|
|
432
463
|
"files": [
|
|
433
464
|
{
|
|
434
465
|
"path": "src/components/ui/rate/Rate.tsx",
|
|
435
|
-
"content": "'use client';\n\nimport * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { Star } from 'lucide-react';\n\nconst rateVariants = tv({\n slots: {\n root: 'inline-flex items-center gap-0.5',\n star: 'relative cursor-pointer transition-transform duration-100 hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-sm',\n starIcon: 'transition-colors duration-150',\n },\n variants: {\n size: {\n sm: { star: 'w-4 h-4', starIcon: 'w-4 h-4' },\n md: { star: 'w-6 h-6', starIcon: 'w-6 h-6' },\n lg: { star: 'w-8 h-8', starIcon: 'w-8 h-8' },\n xl: { star: 'w-10 h-10', starIcon: 'w-10 h-10' },\n },\n readonly: {\n true: { star: 'cursor-default hover:scale-100' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\n/** Props for the Rate component */\nexport interface RateProps extends VariantProps<typeof rateVariants> {\n /** Controlled rating value */\n value?: number;\n /** Default rating value (uncontrolled) */\n defaultValue?: number;\n /** Callback fired when the rating changes */\n onChange?: (value: number) => void;\n /** Total number of stars */\n count?: number;\n /** Allow half-star precision */\n allowHalf?: boolean;\n /** Allow clicking the current value to reset to 0 */\n allowClear?: boolean;\n /** Display as read-only (no interaction) */\n readonly?: boolean;\n /** Disable the rating component */\n disabled?: boolean;\n /** Custom element to render instead of the default star icon */\n character?: React.ReactNode;\n /** Tailwind text color class for filled stars */\n activeColor?: string;\n /** Tailwind text color class for empty stars */\n inactiveColor?: string;\n className?: string;\n /** Accessible label for the rating group */\n 'aria-label'?: string;\n /**\n * Custom label for individual stars.\n * Receives the star index (1-based) and returns a string.\n * Defaults to \"1 star\", \"2 stars\", etc.\n */\n getStarLabel?: (index: number) => string;\n}\n\nconst Rate = React.forwardRef<HTMLDivElement, RateProps>(({\n value: controlledValue,\n defaultValue = 0,\n onChange,\n count = 5,\n allowHalf = false,\n allowClear = true,\n readonly = false,\n disabled = false,\n character,\n activeColor = 'text-amber-400',\n inactiveColor = 'text-muted-foreground/30',\n size,\n className,\n 'aria-label': ariaLabel,\n getStarLabel,\n}, ref) => {\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = React.useState(defaultValue);\n const [hoverValue, setHoverValue] = React.useState<number | null>(null);\n\n const value = isControlled ? controlledValue! : internalValue;\n\n const handleChange = (newVal: number) => {\n if (readonly || disabled) return;\n const next = allowClear && newVal === value ? 0 : newVal;\n if (!isControlled) setInternalValue(next);\n onChange?.(next);\n };\n\n const defaultGetStarLabel = (index: number) =>\n index === 1 ? '1 star' : `${index} stars`;\n\n const resolveLabel = getStarLabel ?? defaultGetStarLabel;\n\n const getStarFraction = (starIndex: number, displayValue: number): number => {\n const full = starIndex + 1;\n const half = starIndex + 0.5;\n if (displayValue >= full) return 1;\n if (allowHalf && displayValue >= half) return 0.5;\n return 0;\n };\n\n const slots = rateVariants({ size, readonly: readonly || disabled });\n const display = hoverValue ?? value;\n\n return (\n <div\n ref={ref}\n className={slots.root({ className })}\n role=\"radiogroup\"\n aria-label={ariaLabel || 'Rating'}\n >\n {Array.from({ length: count }, (_, i) => {\n const fraction = getStarFraction(i, display);\n const full = i + 1;\n const half = i + 0.5;\n\n const renderStar = (frac: number) => {\n if (character) {\n if (frac === 0.5) {\n return (\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\n <span className={`absolute inset-0 overflow-hidden ${inactiveColor}`}>{character}</span>\n <span className=\"absolute inset-0 overflow-hidden w-1/2\" style={{ color: 'inherit' }}>\n <span className={activeColor}>{character}</span>\n </span>\n </span>\n );\n }\n return <span className={frac === 1 ? activeColor : inactiveColor}>{character}</span>;\n }\n\n if (frac === 0.5) {\n return (\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\n <Star className={`${slots.starIcon()} ${inactiveColor}`} fill=\"currentColor\" />\n <span\n className=\"absolute inset-0 overflow-hidden\"\n style={{ width: '50%' }}\n aria-hidden=\"true\"\n >\n <Star className={`${slots.starIcon()} ${activeColor}`} fill=\"currentColor\" />\n </span>\n </span>\n );\n }\n\n return (\n <Star\n className={`${slots.starIcon()} ${frac === 1 ? activeColor : inactiveColor}`}\n fill=\"currentColor\"\n />\n );\n };\n\n return (\n <button\n key={i}\n type=\"button\"\n role=\"radio\"\n aria-checked={full <= value}\n aria-label={resolveLabel(full)}\n disabled={disabled}\n className={slots.star()}\n onMouseMove={(e) => {\n if (readonly || disabled) return;\n if (allowHalf) {\n const rect = e.currentTarget.getBoundingClientRect();\n const isLeft = e.clientX - rect.left < rect.width / 2;\n setHoverValue(isLeft ? half : full);\n } else {\n setHoverValue(full);\n }\n }}\n onMouseLeave={() => setHoverValue(null)}\n onClick={(e) => {\n if (readonly || disabled) return;\n if (allowHalf) {\n const rect = e.currentTarget.getBoundingClientRect();\n const isLeft = e.clientX - rect.left < rect.width / 2;\n handleChange(isLeft ? half : full);\n } else {\n handleChange(full);\n }\n }}\n onKeyDown={(e) => {\n if (readonly || disabled) return;\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\n e.preventDefault();\n handleChange(Math.min(count, value + (allowHalf ? 0.5 : 1)));\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\n e.preventDefault();\n handleChange(Math.max(0, value - (allowHalf ? 0.5 : 1)));\n }\n }}\n >\n {renderStar(fraction)}\n </button>\n );\n })}\n </div>\n );\n});\n\nRate.displayName = 'Rate';\n\nexport { Rate };\n"
|
|
466
|
+
"content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Star } from 'lucide-react';\r\n\r\nconst rateVariants = tv({\r\n slots: {\r\n root: 'inline-flex items-center gap-0.5',\r\n star: 'relative cursor-pointer transition-transform duration-100 hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-sm',\r\n starIcon: 'transition-colors duration-150',\r\n },\r\n variants: {\r\n size: {\r\n sm: { star: 'w-4 h-4', starIcon: 'w-4 h-4' },\r\n md: { star: 'w-6 h-6', starIcon: 'w-6 h-6' },\r\n lg: { star: 'w-8 h-8', starIcon: 'w-8 h-8' },\r\n xl: { star: 'w-10 h-10', starIcon: 'w-10 h-10' },\r\n },\r\n readonly: {\r\n true: { star: 'cursor-default hover:scale-100' },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Rate component */\r\nexport interface RateProps extends VariantProps<typeof rateVariants> {\r\n /** Controlled rating value */\r\n value?: number;\r\n /** Default rating value (uncontrolled) */\r\n defaultValue?: number;\r\n /** Callback fired when the rating changes */\r\n onChange?: (value: number) => void;\r\n /** Total number of stars */\r\n count?: number;\r\n /** Allow half-star precision */\r\n allowHalf?: boolean;\r\n /** Allow clicking the current value to reset to 0 */\r\n allowClear?: boolean;\r\n /** Display as read-only (no interaction) */\r\n readonly?: boolean;\r\n /** Disable the rating component */\r\n disabled?: boolean;\r\n /** Custom element to render instead of the default star icon */\r\n character?: React.ReactNode;\r\n /** Tailwind text color class for filled stars */\r\n activeColor?: string;\r\n /** Tailwind text color class for empty stars */\r\n inactiveColor?: string;\r\n className?: string;\r\n /** Accessible label for the rating group */\r\n 'aria-label'?: string;\r\n /**\r\n * Custom label for individual stars.\r\n * Receives the star index (1-based) and returns a string.\r\n * Defaults to \"1 star\", \"2 stars\", etc.\r\n */\r\n getStarLabel?: (index: number) => string;\r\n}\r\n\r\nconst Rate = React.forwardRef<HTMLDivElement, RateProps>(({\r\n value: controlledValue,\r\n defaultValue = 0,\r\n onChange,\r\n count = 5,\r\n allowHalf = false,\r\n allowClear = true,\r\n readonly = false,\r\n disabled = false,\r\n character,\r\n activeColor = 'text-amber-400',\r\n inactiveColor = 'text-muted-foreground/30',\r\n size,\r\n className,\r\n 'aria-label': ariaLabel,\r\n getStarLabel,\r\n}, ref) => {\r\n const isControlled = controlledValue !== undefined;\r\n const [internalValue, setInternalValue] = React.useState(defaultValue);\r\n const [hoverValue, setHoverValue] = React.useState<number | null>(null);\r\n\r\n const value = isControlled ? controlledValue! : internalValue;\r\n\r\n const handleChange = (newVal: number) => {\r\n if (readonly || disabled) return;\r\n const next = allowClear && newVal === value ? 0 : newVal;\r\n if (!isControlled) setInternalValue(next);\r\n onChange?.(next);\r\n };\r\n\r\n const defaultGetStarLabel = (index: number) =>\r\n index === 1 ? '1 star' : `${index} stars`;\r\n\r\n const resolveLabel = getStarLabel ?? defaultGetStarLabel;\r\n\r\n const getStarFraction = (starIndex: number, displayValue: number): number => {\r\n const full = starIndex + 1;\r\n const half = starIndex + 0.5;\r\n if (displayValue >= full) return 1;\r\n if (allowHalf && displayValue >= half) return 0.5;\r\n return 0;\r\n };\r\n\r\n const slots = rateVariants({ size, readonly: readonly || disabled });\r\n const display = hoverValue ?? value;\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n className={slots.root({ className })}\r\n role=\"radiogroup\"\r\n aria-label={ariaLabel || 'Rating'}\r\n >\r\n {Array.from({ length: count }, (_, i) => {\r\n const fraction = getStarFraction(i, display);\r\n const full = i + 1;\r\n const half = i + 0.5;\r\n\r\n const renderStar = (frac: number) => {\r\n if (character) {\r\n if (frac === 0.5) {\r\n return (\r\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\r\n <span className={`absolute inset-0 overflow-hidden ${inactiveColor}`}>{character}</span>\r\n <span className=\"absolute inset-0 overflow-hidden w-1/2\" style={{ color: 'inherit' }}>\r\n <span className={activeColor}>{character}</span>\r\n </span>\r\n </span>\r\n );\r\n }\r\n return <span className={frac === 1 ? activeColor : inactiveColor}>{character}</span>;\r\n }\r\n\r\n if (frac === 0.5) {\r\n return (\r\n <span className=\"relative inline-flex items-center justify-center w-full h-full\">\r\n <Star className={`${slots.starIcon()} ${inactiveColor}`} fill=\"currentColor\" />\r\n <span\r\n className=\"absolute inset-0 overflow-hidden\"\r\n style={{ width: '50%' }}\r\n aria-hidden=\"true\"\r\n >\r\n <Star className={`${slots.starIcon()} ${activeColor}`} fill=\"currentColor\" />\r\n </span>\r\n </span>\r\n );\r\n }\r\n\r\n return (\r\n <Star\r\n className={`${slots.starIcon()} ${frac === 1 ? activeColor : inactiveColor}`}\r\n fill=\"currentColor\"\r\n />\r\n );\r\n };\r\n\r\n return (\r\n <button\r\n key={i}\r\n type=\"button\"\r\n role=\"radio\"\r\n aria-checked={full <= value}\r\n aria-label={resolveLabel(full)}\r\n disabled={disabled}\r\n className={slots.star()}\r\n onMouseMove={(e) => {\r\n if (readonly || disabled) return;\r\n if (allowHalf) {\r\n const rect = e.currentTarget.getBoundingClientRect();\r\n const isLeft = e.clientX - rect.left < rect.width / 2;\r\n setHoverValue(isLeft ? half : full);\r\n } else {\r\n setHoverValue(full);\r\n }\r\n }}\r\n onMouseLeave={() => setHoverValue(null)}\r\n onClick={(e) => {\r\n if (readonly || disabled) return;\r\n if (allowHalf) {\r\n const rect = e.currentTarget.getBoundingClientRect();\r\n const isLeft = e.clientX - rect.left < rect.width / 2;\r\n handleChange(isLeft ? half : full);\r\n } else {\r\n handleChange(full);\r\n }\r\n }}\r\n onKeyDown={(e) => {\r\n if (readonly || disabled) return;\r\n if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {\r\n e.preventDefault();\r\n handleChange(Math.min(count, value + (allowHalf ? 0.5 : 1)));\r\n } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {\r\n e.preventDefault();\r\n handleChange(Math.max(0, value - (allowHalf ? 0.5 : 1)));\r\n }\r\n }}\r\n >\r\n {renderStar(fraction)}\r\n </button>\r\n );\r\n })}\r\n </div>\r\n );\r\n});\r\n\r\nRate.displayName = 'Rate';\r\n\r\nexport { Rate };\r\n"
|
|
436
467
|
}
|
|
437
468
|
]
|
|
438
469
|
},
|
|
@@ -445,7 +476,7 @@
|
|
|
445
476
|
"files": [
|
|
446
477
|
{
|
|
447
478
|
"path": "src/components/ui/scroll-area/ScrollArea.tsx",
|
|
448
|
-
"content": "'use client';\n\nimport * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst scrollAreaVariants = tv({\n slots: {\n root: 'relative overflow-hidden',\n viewport: 'h-full w-full rounded-[inherit] [&>div]:!block',\n scrollbar: 'flex touch-none select-none transition-colors',\n thumb: 'relative rounded-full bg-border hover:bg-muted-foreground/30 transition-colors',\n },\n variants: {\n size: {\n sm: { scrollbar: '', thumb: '' },\n md: {},\n lg: {},\n },\n orientation: {\n vertical: {\n scrollbar: 'h-full w-2.5 border-l border-l-transparent p-[1px]',\n thumb: 'flex-1',\n },\n horizontal: {\n scrollbar: 'h-2.5 flex-col border-t border-t-transparent p-[1px]',\n thumb: '',\n },\n },\n },\n defaultVariants: {\n size: 'md',\n orientation: 'vertical',\n },\n});\n\n/** Props for the ScrollArea component */\nexport interface ScrollAreaProps\n extends React.HTMLAttributes<HTMLDivElement>,\n Omit<VariantProps<typeof scrollAreaVariants>, 'orientation'> {\n /** Scroll direction: vertical, horizontal, or both */\n orientation?: 'vertical' | 'horizontal' | 'both';\n /** Accessible label for the scrollable region */\n 'aria-label'?: string;\n}\n\nconst ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(\n ({ className, children, orientation = 'vertical', size, 'aria-label': ariaLabel, ...props }, ref) => {\n const { root, viewport } = scrollAreaVariants({ size });\n\n const overflowClass =\n orientation === 'both'\n ? 'overflow-auto'\n : orientation === 'horizontal'\n ? 'overflow-x-auto overflow-y-hidden'\n : 'overflow-y-auto overflow-x-hidden';\n\n return (\n <div\n ref={ref}\n className={root({ className })}\n role=\"region\"\n aria-label={ariaLabel}\n {...props}\n >\n <div\n className={viewport({\n className: `${overflowClass} scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent`,\n })}\n >\n {children}\n </div>\n </div>\n );\n }\n);\nScrollArea.displayName = 'ScrollArea';\n\n/** Props for the ScrollBar component */\nexport interface ScrollBarProps extends React.HTMLAttributes<HTMLDivElement> {\n /** Scrollbar axis direction */\n orientation?: 'vertical' | 'horizontal';\n}\n\nconst ScrollBar = React.forwardRef<HTMLDivElement, ScrollBarProps>(\n ({ className, orientation = 'vertical', ...props }, ref) => {\n const { scrollbar, thumb } = scrollAreaVariants({ orientation });\n return (\n <div ref={ref} className={scrollbar({ className })} {...props}>\n <div className={thumb()} />\n </div>\n );\n }\n);\nScrollBar.displayName = 'ScrollBar';\n\nexport { ScrollArea, ScrollBar, scrollAreaVariants };\n"
|
|
479
|
+
"content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst scrollAreaVariants = tv({\r\n slots: {\r\n root: 'relative overflow-hidden',\r\n viewport: 'h-full w-full rounded-[inherit] [&>div]:!block',\r\n scrollbar: 'flex touch-none select-none transition-colors',\r\n thumb: 'relative rounded-full bg-border hover:bg-muted-foreground/30 transition-colors',\r\n },\r\n variants: {\r\n size: {\r\n sm: { scrollbar: '', thumb: '' },\r\n md: {},\r\n lg: {},\r\n },\r\n orientation: {\r\n vertical: {\r\n scrollbar: 'h-full w-2.5 border-l border-l-transparent p-[1px]',\r\n thumb: 'flex-1',\r\n },\r\n horizontal: {\r\n scrollbar: 'h-2.5 flex-col border-t border-t-transparent p-[1px]',\r\n thumb: '',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n orientation: 'vertical',\r\n },\r\n});\r\n\r\n/** Props for the ScrollArea component */\r\nexport interface ScrollAreaProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n Omit<VariantProps<typeof scrollAreaVariants>, 'orientation'> {\r\n /** Scroll direction: vertical, horizontal, or both */\r\n orientation?: 'vertical' | 'horizontal' | 'both';\r\n /** Accessible label for the scrollable region */\r\n 'aria-label'?: string;\r\n}\r\n\r\nconst ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(\r\n ({ className, children, orientation = 'vertical', size, 'aria-label': ariaLabel, ...props }, ref) => {\r\n const { root, viewport } = scrollAreaVariants({ size });\r\n\r\n const overflowClass =\r\n orientation === 'both'\r\n ? 'overflow-auto'\r\n : orientation === 'horizontal'\r\n ? 'overflow-x-auto overflow-y-hidden'\r\n : 'overflow-y-auto overflow-x-hidden';\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n className={root({ className })}\r\n role=\"region\"\r\n aria-label={ariaLabel}\r\n {...props}\r\n >\r\n <div\r\n className={viewport({\r\n className: `${overflowClass} scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent`,\r\n })}\r\n >\r\n {children}\r\n </div>\r\n </div>\r\n );\r\n }\r\n);\r\nScrollArea.displayName = 'ScrollArea';\r\n\r\n/** Props for the ScrollBar component */\r\nexport interface ScrollBarProps extends React.HTMLAttributes<HTMLDivElement> {\r\n /** Scrollbar axis direction */\r\n orientation?: 'vertical' | 'horizontal';\r\n}\r\n\r\nconst ScrollBar = React.forwardRef<HTMLDivElement, ScrollBarProps>(\r\n ({ className, orientation = 'vertical', ...props }, ref) => {\r\n const { scrollbar, thumb } = scrollAreaVariants({ orientation });\r\n return (\r\n <div ref={ref} className={scrollbar({ className })} {...props}>\r\n <div className={thumb()} />\r\n </div>\r\n );\r\n }\r\n);\r\nScrollBar.displayName = 'ScrollBar';\r\n\r\nexport { ScrollArea, ScrollBar, scrollAreaVariants };\r\n"
|
|
449
480
|
}
|
|
450
481
|
]
|
|
451
482
|
},
|
|
@@ -486,15 +517,15 @@
|
|
|
486
517
|
"files": [
|
|
487
518
|
{
|
|
488
519
|
"path": "src/components/ui/sheet/Sheet.tsx",
|
|
489
|
-
"content": "import * as React from 'react';\r\nimport {\r\n Drawer as SheetRoot,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n type DrawerContentProps,\r\n} from '../drawer/Drawer';\r\n\r\n/** Sheet is a Drawer that defaults to direction=\"right\" and size=\"lg\". */\r\n\r\nconst Sheet = SheetRoot;\r\nconst SheetTrigger = DrawerTrigger;\r\nconst SheetClose = DrawerClose;\r\n\r\nconst SheetContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ direction = 'right', size = 'lg', ...props }, ref) => (\r\n <DrawerContent ref={ref} direction={direction} size={size} {...props} />\r\n ),\r\n);\r\nSheetContent.displayName = 'SheetContent';\r\n\r\nconst SheetHeader = DrawerHeader;\r\nconst SheetTitle = DrawerTitle;\r\nconst SheetDescription = DrawerDescription;\r\nconst SheetBody = DrawerBody;\r\nconst SheetFooter = DrawerFooter;\r\n\r\nexport {\r\n Sheet,\r\n SheetTrigger,\r\n SheetContent,\r\n SheetHeader,\r\n SheetTitle,\r\n SheetDescription,\r\n SheetBody,\r\n SheetFooter,\r\n SheetClose,\r\n};\r\n"
|
|
520
|
+
"content": "import * as React from 'react';\r\nimport {\r\n Drawer as SheetRoot,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n type DrawerContentProps,\r\n} from '../drawer/Drawer';\r\n\r\n/** Sheet is a Drawer that defaults to direction=\"right\" and size=\"lg\". */\r\n\r\nconst Sheet = SheetRoot;\r\nconst SheetTrigger = DrawerTrigger;\r\nconst SheetClose = DrawerClose;\r\n\r\nconst SheetContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ direction = 'right', size = 'lg', backdropBlur = false, ...props }, ref) => (\r\n <DrawerContent ref={ref} direction={direction} size={size} backdropBlur={backdropBlur} {...props} />\r\n ),\r\n);\r\nSheetContent.displayName = 'SheetContent';\r\n\r\nconst SheetHeader = DrawerHeader;\r\nconst SheetTitle = DrawerTitle;\r\nconst SheetDescription = DrawerDescription;\r\nconst SheetBody = DrawerBody;\r\nconst SheetFooter = DrawerFooter;\r\n\r\nexport {\r\n Sheet,\r\n SheetTrigger,\r\n SheetContent,\r\n SheetHeader,\r\n SheetTitle,\r\n SheetDescription,\r\n SheetBody,\r\n SheetFooter,\r\n SheetClose,\r\n};\r\n"
|
|
490
521
|
}
|
|
491
522
|
]
|
|
492
523
|
},
|
|
493
524
|
"sidebar": {
|
|
494
525
|
"name": "sidebar",
|
|
495
526
|
"dependencies": [
|
|
496
|
-
"react-router-dom",
|
|
497
527
|
"lucide-react",
|
|
528
|
+
"react-router-dom",
|
|
498
529
|
"tailwind-variants",
|
|
499
530
|
"@base-ui/react"
|
|
500
531
|
],
|
|
@@ -504,7 +535,23 @@
|
|
|
504
535
|
"files": [
|
|
505
536
|
{
|
|
506
537
|
"path": "src/components/ui/sidebar/Sidebar.tsx",
|
|
507
|
-
"content": "import * as React from 'react';\r\nimport { NavLink } from 'react-router-dom';\r\nimport { PanelLeft, ChevronRight, ChevronsUpDown } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/Tooltip';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Constants ────────────────────────────────────────────────────────────────\r\n\r\nconst SIDEBAR_WIDTH_DEFAULT = 256; // px\r\nconst SIDEBAR_WIDTH_MIN = 160; // px\r\nconst SIDEBAR_WIDTH_MAX = 480; // px\r\nconst SIDEBAR_WIDTH_ICON = '4rem';\r\nconst MOBILE_BREAKPOINT = 768;\r\n\r\n// ─── Context ──────────────────────────────────────────────────────────────────\r\n\r\ntype SidebarState = 'expanded' | 'collapsed';\r\n\r\ninterface SidebarContextValue {\r\n state: SidebarState;\r\n open: boolean;\r\n setOpen: (open: boolean) => void;\r\n toggleSidebar: () => void;\r\n isMobile: boolean;\r\n openMobile: boolean;\r\n setOpenMobile: (open: boolean) => void;\r\n sidebarWidth: number;\r\n setSidebarWidth: (w: number) => void;\r\n}\r\n\r\nconst SidebarContext = React.createContext<SidebarContextValue | null>(null);\r\n\r\nexport function useSidebar() {\r\n const ctx = React.useContext(SidebarContext);\r\n if (!ctx) throw new Error('useSidebar must be used within SidebarProvider');\r\n return ctx;\r\n}\r\n\r\n// ─── Provider ─────────────────────────────────────────────────────────────────\r\n\r\n/** Props for the SidebarProvider that manages sidebar state (open/collapsed, mobile, width) */\r\nexport interface SidebarProviderProps {\r\n children: React.ReactNode;\r\n /** Initial open state for uncontrolled usage (default: true) */\r\n defaultOpen?: boolean;\r\n /** Controlled open state */\r\n open?: boolean;\r\n /** Callback fired when the sidebar opens or closes */\r\n onOpenChange?: (open: boolean) => void;\r\n className?: string;\r\n style?: React.CSSProperties;\r\n}\r\n\r\nconst SidebarProvider = React.forwardRef<HTMLDivElement, SidebarProviderProps>(\r\n ({ children, defaultOpen = true, open: controlledOpen, onOpenChange, className, style }, ref) => {\r\n const [isMobile, setIsMobile] = React.useState(false);\r\n const [openMobile, setOpenMobile] = React.useState(false);\r\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\r\n const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT);\r\n\r\n const isControlled = controlledOpen !== undefined;\r\n const open = isControlled ? controlledOpen! : internalOpen;\r\n\r\n React.useEffect(() => {\r\n const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n check();\r\n window.addEventListener('resize', check);\r\n return () => window.removeEventListener('resize', check);\r\n }, []);\r\n\r\n const setOpen = React.useCallback(\r\n (val: boolean) => {\r\n if (!isControlled) setInternalOpen(val);\r\n onOpenChange?.(val);\r\n },\r\n [isControlled, onOpenChange]\r\n );\r\n\r\n const toggleSidebar = React.useCallback(() => {\r\n if (isMobile) setOpenMobile((v) => !v);\r\n else setOpen(!open);\r\n }, [isMobile, open, setOpen]);\r\n\r\n React.useEffect(() => {\r\n const onKey = (e: KeyboardEvent) => {\r\n if ((e.metaKey || e.ctrlKey) && e.key === 'b') {\r\n e.preventDefault();\r\n toggleSidebar();\r\n }\r\n };\r\n window.addEventListener('keydown', onKey);\r\n return () => window.removeEventListener('keydown', onKey);\r\n }, [toggleSidebar]);\r\n\r\n const state: SidebarState = open ? 'expanded' : 'collapsed';\r\n\r\n return (\r\n <SidebarContext.Provider\r\n value={{ state, open, setOpen, toggleSidebar, isMobile, openMobile, setOpenMobile, sidebarWidth, setSidebarWidth }}\r\n >\r\n <div\r\n ref={ref}\r\n data-sidebar-state={state}\r\n style={\r\n {\r\n '--sidebar-width': `${sidebarWidth}px`,\r\n '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\r\n ...style,\r\n } as React.CSSProperties\r\n }\r\n className={cn('group/sidebar-wrapper flex min-h-screen w-full has-data-[variant=inset]:bg-muted/30', className)}\r\n >\r\n {children}\r\n </div>\r\n </SidebarContext.Provider>\r\n );\r\n }\r\n);\r\nSidebarProvider.displayName = 'SidebarProvider';\r\n\r\n// ─── SidebarTrigger ───────────────────────────────────────────────────────────\r\n\r\nconst SidebarTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ButtonHTMLAttributes<HTMLButtonElement>\r\n>(({ className, onClick, ...props }, ref) => {\r\n const { toggleSidebar } = useSidebar();\r\n return (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n data-sidebar=\"trigger\"\r\n onClick={(e) => {\r\n toggleSidebar();\r\n onClick?.(e);\r\n }}\r\n className={cn('inline-flex items-center justify-center h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', className)}\r\n title=\"Toggle Sidebar (⌘B)\"\r\n {...props}\r\n >\r\n <PanelLeft className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Toggle Sidebar</span>\r\n </button>\r\n );\r\n});\r\nSidebarTrigger.displayName = 'SidebarTrigger';\r\n\r\n// ─── Sidebar ──────────────────────────────────────────────────────────────────\r\n\r\n/** Props for the Sidebar panel */\r\nexport interface SidebarProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Which side of the viewport the sidebar attaches to */\r\n side?: 'left' | 'right';\r\n /** Visual variant: default border, floating card, or inset with background */\r\n variant?: 'sidebar' | 'floating' | 'inset';\r\n /** Collapse behavior: slide offcanvas, shrink to icons, or non-collapsible */\r\n collapsible?: 'offcanvas' | 'icon' | 'none';\r\n}\r\n\r\nconst Sidebar = React.forwardRef<HTMLElement, SidebarProps>(\r\n ({ side = 'left', variant = 'sidebar', collapsible = 'icon', className, children, ...props }, ref) => {\r\n const { state, isMobile, openMobile, setOpenMobile, sidebarWidth } = useSidebar();\r\n\r\n if (collapsible === 'none') {\r\n return (\r\n <aside\r\n ref={ref}\r\n className={cn('flex h-screen flex-col bg-sidebar border-r border-sidebar-border', className)}\r\n style={{ width: SIDEBAR_WIDTH_DEFAULT }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n\r\n if (isMobile) {\r\n return (\r\n <>\r\n {openMobile && (\r\n <div\r\n className=\"fixed inset-0 z-40 bg-black/50 backdrop-blur-sm\"\r\n onClick={() => setOpenMobile(false)}\r\n />\r\n )}\r\n <aside\r\n ref={ref}\r\n className={cn(\r\n 'fixed inset-y-0 z-50 flex flex-col bg-sidebar border-r border-sidebar-border shadow-xl',\r\n 'transition-transform duration-300 ease-in-out',\r\n side === 'left' ? 'left-0' : 'right-0',\r\n openMobile\r\n ? 'translate-x-0'\r\n : side === 'left'\r\n ? '-translate-x-full'\r\n : 'translate-x-full',\r\n className\r\n )}\r\n style={{ width: sidebarWidth }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n </>\r\n );\r\n }\r\n\r\n return (\r\n <aside\r\n ref={ref}\r\n data-state={state}\r\n data-collapsible={state === 'collapsed' ? collapsible : ''}\r\n data-variant={variant}\r\n data-side={side}\r\n className={cn(\r\n 'group relative flex h-screen flex-col bg-sidebar text-sidebar-foreground',\r\n 'border-r border-dashed border-sidebar-border',\r\n state === 'collapsed' && 'will-change-[width] transition-[width] duration-300 ease-in-out',\r\n 'overflow-hidden shrink-0',\r\n state === 'collapsed' && collapsible === 'icon' ? 'w-(--sidebar-width-icon)' : 'w-(--sidebar-width)',\r\n className\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n);\r\nSidebar.displayName = 'Sidebar';\r\n\r\n// ─── SidebarRail — drag handle để resize ──────────────────────────────────────\r\n\r\nconst SidebarRail = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state, setSidebarWidth, sidebarWidth } = useSidebar();\r\n const isDragging = React.useRef(false);\r\n const startX = React.useRef(0);\r\n const startWidth = React.useRef(0);\r\n\r\n const onMouseDown = React.useCallback(\r\n (e: React.MouseEvent) => {\r\n if (state === 'collapsed') return;\r\n isDragging.current = true;\r\n startX.current = e.clientX;\r\n startWidth.current = sidebarWidth;\r\n document.body.style.cursor = 'col-resize';\r\n document.body.style.userSelect = 'none';\r\n },\r\n [state, sidebarWidth]\r\n );\r\n\r\n React.useEffect(() => {\r\n const onMouseMove = (e: MouseEvent) => {\r\n if (!isDragging.current) return;\r\n const delta = e.clientX - startX.current;\r\n const next = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, startWidth.current + delta));\r\n setSidebarWidth(next);\r\n };\r\n\r\n const onMouseUp = () => {\r\n if (!isDragging.current) return;\r\n isDragging.current = false;\r\n document.body.style.cursor = '';\r\n document.body.style.userSelect = '';\r\n };\r\n\r\n window.addEventListener('mousemove', onMouseMove);\r\n window.addEventListener('mouseup', onMouseUp);\r\n return () => {\r\n window.removeEventListener('mousemove', onMouseMove);\r\n window.removeEventListener('mouseup', onMouseUp);\r\n };\r\n }, [setSidebarWidth]);\r\n\r\n if (state === 'collapsed') return null;\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"rail\"\r\n aria-label=\"Resize sidebar\"\r\n onMouseDown={onMouseDown}\r\n className={cn(\r\n 'absolute inset-y-0 right-0 z-20 w-1 cursor-col-resize',\r\n 'group/rail flex items-center justify-center',\r\n 'after:absolute after:inset-y-0 after:right-0 after:w-1',\r\n 'hover:after:bg-primary/50 transition-colors duration-150',\r\n className\r\n )}\r\n {...props}\r\n >\r\n </div>\r\n );\r\n }\r\n);\r\nSidebarRail.displayName = 'SidebarRail';\r\n\r\n// ─── SidebarInset ─────────────────────────────────────────────────────────────\r\n\r\nconst SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('relative flex flex-1 flex-col overflow-hidden min-w-0 bg-background', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarInset.displayName = 'SidebarInset';\r\n\r\n// ─── Layout: Header / Content / Footer ───────────────────────────────────────\r\n\r\nconst SidebarHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"header\"\r\n className={cn('flex flex-col gap-2 p-2 shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarHeader.displayName = 'SidebarHeader';\r\n\r\nconst SidebarFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { isMobile } = useSidebar();\r\n if (isMobile) {\r\n return null;\r\n }\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"footer\"\r\n className={cn('flex flex-col gap-2 p-2 mt-auto shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n}\r\n);\r\nSidebarFooter.displayName = 'SidebarFooter';\r\n\r\nconst SidebarContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"content\"\r\n className={cn('flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden py-2', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarContent.displayName = 'SidebarContent';\r\n\r\nconst SidebarSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"separator\"\r\n className={cn('mx-2 h-px border-t border-sidebar-border', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarSeparator.displayName = 'SidebarSeparator';\r\n\r\n// ─── Group ────────────────────────────────────────────────────────────────────\r\n\r\nconst SidebarGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group\"\r\n className={cn('relative flex flex-col w-full min-w-0 px-2 py-1', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarGroup.displayName = 'SidebarGroup';\r\n\r\nconst SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group-label\"\r\n className={cn(\r\n 'flex h-8 shrink-0 items-center rounded-md px-2',\r\n 'text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wider',\r\n 'transition-all duration-200 overflow-hidden whitespace-nowrap select-none',\r\n state === 'collapsed' ? 'opacity-0 h-0 mb-0 hidden' : 'opacity-100',\r\n className\r\n )}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSidebarGroupLabel.displayName = 'SidebarGroupLabel';\r\n\r\nconst SidebarGroupContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group-content\"\r\n className={cn('w-full', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarGroupContent.displayName = 'SidebarGroupContent';\r\n\r\n// ─── Menu ─────────────────────────────────────────────────────────────────────\r\n\r\nconst SidebarMenu = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\r\n ({ className, ...props }, ref) => (\r\n <ul\r\n ref={ref}\r\n data-sidebar=\"menu\"\r\n className={cn('flex flex-col gap-0.5 list-none m-0 p-0 w-full', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenu.displayName = 'SidebarMenu';\r\n\r\nconst SidebarMenuItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\r\n ({ className, ...props }, ref) => (\r\n <li\r\n ref={ref}\r\n data-sidebar=\"menu-item\"\r\n className={cn('group/menu-item relative', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenuItem.displayName = 'SidebarMenuItem';\r\n\r\n// ─── SidebarMenuButton ────────────────────────────────────────────────────────\r\n\r\nconst menuButtonVariants = tv({\r\n base: [\r\n 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md',\r\n 'text-sm font-medium outline-none ring-sidebar-ring transition-all duration-150',\r\n 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',\r\n 'focus-visible:ring-2 active:bg-sidebar-accent/80',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n 'group-has-data-[sidebar=menu-action]/menu-item:pr-8',\r\n // Data state active\r\n 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[active=true]:font-semibold',\r\n ],\r\n variants: {\r\n size: {\r\n sm: 'h-7 text-xs px-2',\r\n md: 'h-9 px-2',\r\n lg: 'h-11 text-base px-3',\r\n },\r\n collapsed: {\r\n true: 'justify-center px-0',\r\n false: 'justify-start',\r\n },\r\n },\r\n defaultVariants: { size: 'md', collapsed: false },\r\n});\r\n\r\n/** Props for a sidebar menu button */\r\nexport interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n /** Render as child element instead of a button */\r\n asChild?: boolean;\r\n /** Marks the button as the currently active item */\r\n isActive?: boolean;\r\n /** Tooltip text shown when the sidebar is collapsed */\r\n tooltip?: string;\r\n /** Button size variant */\r\n size?: 'sm' | 'md' | 'lg';\r\n}\r\n\r\nconst SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(\r\n ({ className, isActive = false, tooltip, size = 'md', children, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const button = (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n data-sidebar=\"menu-button\"\r\n data-active={isActive}\r\n data-size={size}\r\n className={menuButtonVariants({ size, collapsed: isCollapsed, className })}\r\n {...props}\r\n >\r\n {isCollapsed\r\n ? React.Children.toArray(children)[0]\r\n : children}\r\n </button>\r\n );\r\n\r\n if (isCollapsed && tooltip) {\r\n return (\r\n <Tooltip>\r\n <TooltipTrigger render={button} />\r\n <TooltipContent side=\"right\">{tooltip}</TooltipContent>\r\n </Tooltip>\r\n );\r\n }\r\n\r\n return button;\r\n }\r\n);\r\nSidebarMenuButton.displayName = 'SidebarMenuButton';\r\n\r\n// ─── SidebarNavLink — wraps React Router NavLink ─────────────────────────────\r\n\r\n/** Props for a sidebar navigation link (wraps React Router NavLink) */\r\nexport interface SidebarNavLinkProps {\r\n /** Route path for the link */\r\n to: string;\r\n /** Icon rendered before the label */\r\n icon?: React.ReactNode;\r\n /** Display text for the link; also used as tooltip when collapsed */\r\n label: string;\r\n /** Match route exactly (React Router `end` prop) */\r\n end?: boolean;\r\n /** Badge element rendered after the label */\r\n badge?: React.ReactNode;\r\n /** Link size variant */\r\n size?: 'sm' | 'md' | 'lg';\r\n className?: string;\r\n}\r\n\r\nconst SidebarNavLink: React.FC<SidebarNavLinkProps> = ({\r\n to,\r\n icon,\r\n label,\r\n end = false,\r\n badge,\r\n size = 'md',\r\n className,\r\n}) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const link = (\r\n <NavLink\r\n to={to}\r\n end={end}\r\n className={({ isActive }) => cn(\r\n menuButtonVariants({ size, collapsed: isCollapsed, className }),\r\n isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold' : 'text-sidebar-foreground/70'\r\n )}\r\n >\r\n {icon && (\r\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">\r\n {icon}\r\n </span>\r\n )}\r\n {!isCollapsed && (\r\n <>\r\n <span className=\"flex-1 truncate\">{label}</span>\r\n {badge && <span className=\"ml-auto shrink-0\">{badge}</span>}\r\n </>\r\n )}\r\n </NavLink>\r\n );\r\n\r\n if (isCollapsed && label) {\r\n return (\r\n <Tooltip>\r\n <TooltipTrigger render={link} />\r\n <TooltipContent side=\"right\">{label}</TooltipContent>\r\n </Tooltip>\r\n );\r\n }\r\n\r\n return link;\r\n};\r\nSidebarNavLink.displayName = 'SidebarNavLink';\r\n\r\n// ─── SidebarMenuCollapsible — nhóm có sub-items ───────────────────────────────\r\n\r\n/** Props for a collapsible sidebar menu group with sub-items */\r\nexport interface SidebarMenuCollapsibleProps {\r\n /** Unique identifier for the group */\r\n id: string;\r\n /** Icon displayed next to the group label */\r\n icon: React.ReactNode;\r\n /** Display text for the collapsible group header */\r\n label: string;\r\n children: React.ReactNode;\r\n /** Whether the group is initially expanded */\r\n defaultOpen?: boolean;\r\n /** When true, the group auto-expands and shows an active indicator */\r\n isChildActive?: boolean;\r\n}\r\n\r\nconst SidebarMenuCollapsible: React.FC<SidebarMenuCollapsibleProps> = ({\r\n icon,\r\n label,\r\n children,\r\n defaultOpen = false,\r\n isChildActive = false,\r\n}) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const [isOpen, setIsOpen] = React.useState(defaultOpen || isChildActive);\r\n const prevOpenRef = React.useRef(isOpen);\r\n\r\n // Khi sidebar collapse → đóng tất cả sub-menu, ghi nhớ state\r\n // Khi sidebar expand → khôi phục state cũ\r\n React.useEffect(() => {\r\n if (isCollapsed) {\r\n prevOpenRef.current = isOpen;\r\n setIsOpen(false);\r\n } else {\r\n setIsOpen(prevOpenRef.current);\r\n }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [isCollapsed]);\r\n\r\n // Khi có child active, mở group\r\n React.useEffect(() => {\r\n if (isChildActive && !isCollapsed) {\r\n setIsOpen(true);\r\n prevOpenRef.current = true;\r\n }\r\n }, [isChildActive, isCollapsed]);\r\n\r\n const trigger = (\r\n <button\r\n type=\"button\"\r\n aria-expanded={isOpen}\r\n data-active={isChildActive && isCollapsed}\r\n onClick={() => {\r\n if (!isCollapsed) {\r\n const next = !isOpen;\r\n setIsOpen(next);\r\n prevOpenRef.current = next;\r\n }\r\n }}\r\n className={menuButtonVariants({\r\n collapsed: isCollapsed,\r\n className:\r\n isChildActive && isCollapsed\r\n ? 'text-sidebar-accent-foreground'\r\n : 'text-sidebar-foreground/70',\r\n })}\r\n >\r\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">{icon}</span>\r\n {!isCollapsed && (\r\n <>\r\n <span className=\"flex-1 truncate text-left\">{label}</span>\r\n <ChevronRight\r\n className={cn(\r\n 'ml-auto h-3.5 w-3.5 shrink-0 text-sidebar-foreground/40',\r\n 'transition-transform duration-200',\r\n isOpen && 'rotate-90'\r\n )}\r\n />\r\n </>\r\n )}\r\n </button>\r\n );\r\n\r\n return (\r\n <>\r\n {isCollapsed ? (\r\n <Tooltip>\r\n <TooltipTrigger render={trigger} />\r\n <TooltipContent side=\"right\">{label}</TooltipContent>\r\n </Tooltip>\r\n ) : (\r\n trigger\r\n )}\r\n\r\n {/* Sub-items với animation mượt - Sử dụng SidebarMenuSub (ul) để hợp lệ HTML */}\r\n <SidebarMenuSub\r\n className={cn(\r\n 'overflow-hidden transition-all duration-200 ease-in-out',\r\n !isCollapsed && isOpen ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'\r\n )}\r\n >\r\n {children}\r\n </SidebarMenuSub>\r\n </>\r\n );\r\n};\r\nSidebarMenuCollapsible.displayName = 'SidebarMenuCollapsible';\r\n\r\n// ─── SidebarMenuSub ───────────────────────────────────────────────────────────\r\n\r\nconst SidebarMenuSub = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n if (state === 'collapsed') return null;\r\n return (\r\n <ul\r\n ref={ref}\r\n data-sidebar=\"menu-sub\"\r\n className={cn('mx-3.5 flex min-w-0 flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 list-none', className)}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSidebarMenuSub.displayName = 'SidebarMenuSub';\r\n\r\nconst SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\r\n (props, ref) => <li ref={ref} {...props} />\r\n);\r\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\r\n\r\n// ─── Badge & Skeleton ─────────────────────────────────────────────────────────\r\n\r\nconst SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"menu-badge\"\r\n className={cn('ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenuBadge.displayName = 'SidebarMenuBadge';\r\n\r\nconst SidebarMenuSkeleton: React.FC<{ showIcon?: boolean }> = ({ showIcon = true }) => (\r\n <div className=\"flex h-9 items-center gap-2 rounded-md px-2\">\r\n {showIcon && <div className=\"h-4 w-4 rounded bg-sidebar-accent animate-pulse shrink-0\" />}\r\n <div className=\"h-4 flex-1 rounded bg-sidebar-accent animate-pulse\" />\r\n </div>\r\n);\r\n\r\n// ─── User Menu Popover (shadcn style) ─────────────────────────────────────────\r\n\r\ninterface UserMenuPopoverProps {\r\n name: string;\r\n email: string;\r\n avatar?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst UserMenuPopover: React.FC<UserMenuPopoverProps> = ({ name, email, avatar, children }) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n const [open, setOpen] = React.useState(false);\r\n\r\n const trigger = (\r\n <BasePopover.Trigger\r\n render={\r\n <button\r\n type=\"button\"\r\n data-active={open}\r\n className={cn(\r\n menuButtonVariants({ size: 'lg', collapsed: isCollapsed }),\r\n 'data-[active=true]:bg-sidebar-accent'\r\n )}\r\n >\r\n <img\r\n src={avatar || 'https://i.pravatar.cc/100'}\r\n alt={name}\r\n className=\"w-8 h-8 rounded-lg shrink-0 object-cover border border-sidebar-border\"\r\n />\r\n {!isCollapsed && (\r\n <>\r\n <div className=\"flex-1 text-left overflow-hidden grid\">\r\n <span className=\"text-sm font-semibold truncate leading-tight\">{name}</span>\r\n <span className=\"text-xs text-sidebar-foreground/50 truncate leading-tight\">{email}</span>\r\n </div>\r\n <ChevronsUpDown className=\"ml-auto h-4 w-4 shrink-0 text-sidebar-foreground/40\" />\r\n </>\r\n )}\r\n </button>\r\n }\r\n />\r\n );\r\n\r\n return (\r\n <BasePopover.Root open={open} onOpenChange={setOpen}>\r\n {isCollapsed ? (\r\n <Tooltip>\r\n <TooltipTrigger render={trigger} />\r\n <TooltipContent side=\"right\">{name}</TooltipContent>\r\n </Tooltip>\r\n ) : (\r\n trigger\r\n )}\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner side=\"right\" align=\"end\" sideOffset={8}>\r\n <BasePopover.Popup\r\n className={cn(\r\n 'z-50 w-64 rounded-xl border border-border bg-popover shadow-xl outline-none p-1',\r\n 'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95',\r\n 'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95'\r\n )}\r\n >\r\n {/* User info header */}\r\n <div className=\"flex items-center gap-3 p-3 pb-2 border-b border-border/50\">\r\n <img\r\n src={avatar || 'https://i.pravatar.cc/100'}\r\n alt={name}\r\n className=\"w-10 h-10 rounded-lg object-cover border border-border\"\r\n />\r\n <div className=\"flex-1 overflow-hidden\">\r\n <p className=\"text-sm font-semibold truncate\">{name}</p>\r\n <p className=\"text-xs text-muted-foreground truncate\">{email}</p>\r\n </div>\r\n </div>\r\n\r\n {/* Menu items */}\r\n <div className=\"py-1\">{children}</div>\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n );\r\n};\r\n\r\n// ─── UserMenuItem (item trong popover) ───────────────────────────────────────\r\n\r\ninterface UserMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n icon?: React.ReactNode;\r\n destructive?: boolean;\r\n}\r\n\r\nconst UserMenuItem: React.FC<UserMenuItemProps> = ({ icon, children, destructive, className, ...props }) => (\r\n <button\r\n type=\"button\"\r\n className={cn(\r\n 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',\r\n 'hover:bg-muted outline-none focus-visible:bg-muted',\r\n destructive ? 'text-destructive hover:text-destructive' : 'text-foreground',\r\n className\r\n )}\r\n {...props}\r\n >\r\n {icon && <span className=\"shrink-0 h-4 w-4 flex items-center justify-center\">{icon}</span>}\r\n {children}\r\n </button>\r\n);\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n SidebarProvider,\r\n SidebarTrigger,\r\n Sidebar,\r\n SidebarRail,\r\n SidebarInset,\r\n SidebarHeader,\r\n SidebarFooter,\r\n SidebarContent,\r\n SidebarSeparator,\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarMenuButton,\r\n SidebarNavLink,\r\n SidebarMenuCollapsible,\r\n SidebarMenuSub,\r\n SidebarMenuSubItem,\r\n SidebarMenuBadge,\r\n SidebarMenuSkeleton,\r\n UserMenuPopover,\r\n UserMenuItem,\r\n};\r\n"
|
|
538
|
+
"content": "// Sidebar — barrel re-export\r\n// Split into: SidebarContext, SidebarLayout, SidebarMenu, SidebarUserMenu\r\n\r\nexport { useSidebar, SidebarProvider } from './SidebarContext';\r\nexport type { SidebarProviderProps } from './SidebarContext';\r\n\r\nexport {\r\n SidebarTrigger,\r\n Sidebar,\r\n SidebarRail,\r\n SidebarInset,\r\n SidebarHeader,\r\n SidebarFooter,\r\n SidebarContent,\r\n SidebarSeparator,\r\n} from './SidebarLayout';\r\nexport type { SidebarProps } from './SidebarLayout';\r\n\r\nexport {\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarMenuButton,\r\n SidebarNavLink,\r\n SidebarMenuCollapsible,\r\n SidebarMenuSub,\r\n SidebarMenuSubItem,\r\n SidebarMenuBadge,\r\n SidebarMenuSkeleton,\r\n} from './SidebarMenu';\r\nexport type { SidebarMenuButtonProps, SidebarNavLinkProps, SidebarMenuCollapsibleProps } from './SidebarMenu';\r\n\r\nexport { UserMenuPopover, UserMenuItem } from './SidebarUserMenu';\r\n"
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
"path": "src/components/ui/sidebar/SidebarContext.tsx",
|
|
542
|
+
"content": "import * as React from 'react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n// ─── Constants ────────────────────────────────────────────────────────────────\r\n\r\nexport const SIDEBAR_WIDTH_DEFAULT = 256; // px\r\nexport const SIDEBAR_WIDTH_MIN = 160; // px\r\nexport const SIDEBAR_WIDTH_MAX = 480; // px\r\nexport const SIDEBAR_WIDTH_ICON = '4rem';\r\nexport const MOBILE_BREAKPOINT = 768;\r\n\r\n// ─── Context ──────────────────────────────────────────────────────────────────\r\n\r\nexport type SidebarState = 'expanded' | 'collapsed';\r\n\r\nexport interface SidebarContextValue {\r\n state: SidebarState;\r\n open: boolean;\r\n setOpen: (open: boolean) => void;\r\n toggleSidebar: () => void;\r\n isMobile: boolean;\r\n openMobile: boolean;\r\n setOpenMobile: (open: boolean) => void;\r\n sidebarWidth: number;\r\n setSidebarWidth: (w: number) => void;\r\n}\r\n\r\nexport const SidebarContext = React.createContext<SidebarContextValue | null>(null);\r\n\r\nexport function useSidebar() {\r\n const ctx = React.useContext(SidebarContext);\r\n if (!ctx) throw new Error('useSidebar must be used within SidebarProvider');\r\n return ctx;\r\n}\r\n\r\n// ─── Provider ─────────────────────────────────────────────────────────────────\r\n\r\n/** Props for the SidebarProvider that manages sidebar state (open/collapsed, mobile, width) */\r\nexport interface SidebarProviderProps {\r\n children: React.ReactNode;\r\n /** Initial open state for uncontrolled usage (default: true) */\r\n defaultOpen?: boolean;\r\n /** Controlled open state */\r\n open?: boolean;\r\n /** Callback fired when the sidebar opens or closes */\r\n onOpenChange?: (open: boolean) => void;\r\n className?: string;\r\n style?: React.CSSProperties;\r\n}\r\n\r\nexport const SidebarProvider = React.forwardRef<HTMLDivElement, SidebarProviderProps>(\r\n ({ children, defaultOpen = true, open: controlledOpen, onOpenChange, className, style }, ref) => {\r\n const [isMobile, setIsMobile] = React.useState(false);\r\n const [openMobile, setOpenMobile] = React.useState(false);\r\n const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\r\n const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT);\r\n\r\n const isControlled = controlledOpen !== undefined;\r\n const open = isControlled ? controlledOpen! : internalOpen;\r\n\r\n React.useEffect(() => {\r\n const check = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);\r\n check();\r\n window.addEventListener('resize', check);\r\n return () => window.removeEventListener('resize', check);\r\n }, []);\r\n\r\n const setOpen = React.useCallback(\r\n (val: boolean) => {\r\n if (!isControlled) setInternalOpen(val);\r\n onOpenChange?.(val);\r\n },\r\n [isControlled, onOpenChange]\r\n );\r\n\r\n const toggleSidebar = React.useCallback(() => {\r\n if (isMobile) setOpenMobile((v) => !v);\r\n else setOpen(!open);\r\n }, [isMobile, open, setOpen]);\r\n\r\n React.useEffect(() => {\r\n const onKey = (e: KeyboardEvent) => {\r\n if ((e.metaKey || e.ctrlKey) && e.key === 'b') {\r\n e.preventDefault();\r\n toggleSidebar();\r\n }\r\n };\r\n window.addEventListener('keydown', onKey);\r\n return () => window.removeEventListener('keydown', onKey);\r\n }, [toggleSidebar]);\r\n\r\n const state: SidebarState = open ? 'expanded' : 'collapsed';\r\n\r\n return (\r\n <SidebarContext.Provider\r\n value={{ state, open, setOpen, toggleSidebar, isMobile, openMobile, setOpenMobile, sidebarWidth, setSidebarWidth }}\r\n >\r\n <div\r\n ref={ref}\r\n data-sidebar-state={state}\r\n style={\r\n {\r\n '--sidebar-width': `${sidebarWidth}px`,\r\n '--sidebar-width-icon': SIDEBAR_WIDTH_ICON,\r\n ...style,\r\n } as React.CSSProperties\r\n }\r\n className={cn('group/sidebar-wrapper flex min-h-screen w-full has-data-[variant=inset]:bg-muted/30', className)}\r\n >\r\n {children}\r\n </div>\r\n </SidebarContext.Provider>\r\n );\r\n }\r\n);\r\nSidebarProvider.displayName = 'SidebarProvider';\r\n"
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
"path": "src/components/ui/sidebar/SidebarLayout.tsx",
|
|
546
|
+
"content": "import * as React from 'react';\r\nimport { PanelLeft } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar, SIDEBAR_WIDTH_DEFAULT, SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX } from './SidebarContext';\r\n\r\n// ─── SidebarTrigger ───────────────────────────────────────────────────────────\r\n\r\nexport const SidebarTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ButtonHTMLAttributes<HTMLButtonElement>\r\n>(({ className, onClick, ...props }, ref) => {\r\n const { toggleSidebar } = useSidebar();\r\n return (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n data-sidebar=\"trigger\"\r\n onClick={(e) => {\r\n toggleSidebar();\r\n onClick?.(e);\r\n }}\r\n className={cn('inline-flex items-center justify-center h-8 w-8 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', className)}\r\n title=\"Toggle Sidebar (⌘B)\"\r\n {...props}\r\n >\r\n <PanelLeft className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Toggle Sidebar</span>\r\n </button>\r\n );\r\n});\r\nSidebarTrigger.displayName = 'SidebarTrigger';\r\n\r\n// ─── Sidebar ──────────────────────────────────────────────────────────────────\r\n\r\n/** Props for the Sidebar panel */\r\nexport interface SidebarProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Which side of the viewport the sidebar attaches to */\r\n side?: 'left' | 'right';\r\n /** Visual variant: default border, floating card, or inset with background */\r\n variant?: 'sidebar' | 'floating' | 'inset';\r\n /** Collapse behavior: slide offcanvas, shrink to icons, or non-collapsible */\r\n collapsible?: 'offcanvas' | 'icon' | 'none';\r\n}\r\n\r\nexport const Sidebar = React.forwardRef<HTMLElement, SidebarProps>(\r\n ({ side = 'left', variant = 'sidebar', collapsible = 'icon', className, children, ...props }, ref) => {\r\n const { state, isMobile, openMobile, setOpenMobile, sidebarWidth } = useSidebar();\r\n\r\n if (collapsible === 'none') {\r\n return (\r\n <aside\r\n ref={ref}\r\n className={cn('flex h-screen flex-col bg-sidebar border-r border-sidebar-border', className)}\r\n style={{ width: SIDEBAR_WIDTH_DEFAULT }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n\r\n if (isMobile) {\r\n return (\r\n <>\r\n {openMobile && (\r\n <div\r\n className=\"fixed inset-0 z-40 bg-black/50 backdrop-blur-sm motion-safe:transition-opacity\"\r\n onClick={() => setOpenMobile(false)}\r\n />\r\n )}\r\n <aside\r\n ref={ref}\r\n className={cn(\r\n 'fixed inset-y-0 z-50 flex flex-col bg-sidebar border-r border-sidebar-border shadow-xl',\r\n 'motion-safe:transition-transform motion-safe:duration-300 motion-safe:ease-in-out',\r\n side === 'left' ? 'left-0' : 'right-0',\r\n openMobile\r\n ? 'translate-x-0'\r\n : side === 'left'\r\n ? '-translate-x-full'\r\n : 'translate-x-full',\r\n className\r\n )}\r\n style={{ width: sidebarWidth }}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n </>\r\n );\r\n }\r\n\r\n return (\r\n <aside\r\n ref={ref}\r\n data-state={state}\r\n data-collapsible={state === 'collapsed' ? collapsible : ''}\r\n data-variant={variant}\r\n data-side={side}\r\n className={cn(\r\n 'group relative flex h-screen flex-col bg-sidebar text-sidebar-foreground',\r\n 'border-r border-dashed border-sidebar-border',\r\n state === 'collapsed' && 'will-change-[width] motion-safe:transition-[width] motion-safe:duration-300 motion-safe:ease-in-out',\r\n 'overflow-hidden shrink-0',\r\n state === 'collapsed' && collapsible === 'icon' ? 'w-(--sidebar-width-icon)' : 'w-(--sidebar-width)',\r\n className\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </aside>\r\n );\r\n }\r\n);\r\nSidebar.displayName = 'Sidebar';\r\n\r\n// ─── SidebarRail — drag handle để resize ──────────────────────────────────────\r\n\r\nexport const SidebarRail = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state, setSidebarWidth, sidebarWidth } = useSidebar();\r\n const isDragging = React.useRef(false);\r\n const startX = React.useRef(0);\r\n const startWidth = React.useRef(0);\r\n\r\n const onMouseDown = React.useCallback(\r\n (e: React.MouseEvent) => {\r\n if (state === 'collapsed') return;\r\n isDragging.current = true;\r\n startX.current = e.clientX;\r\n startWidth.current = sidebarWidth;\r\n document.body.style.cursor = 'col-resize';\r\n document.body.style.userSelect = 'none';\r\n },\r\n [state, sidebarWidth]\r\n );\r\n\r\n React.useEffect(() => {\r\n const onMouseMove = (e: MouseEvent) => {\r\n if (!isDragging.current) return;\r\n const delta = e.clientX - startX.current;\r\n const next = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, startWidth.current + delta));\r\n setSidebarWidth(next);\r\n };\r\n\r\n const onMouseUp = () => {\r\n if (!isDragging.current) return;\r\n isDragging.current = false;\r\n document.body.style.cursor = '';\r\n document.body.style.userSelect = '';\r\n };\r\n\r\n window.addEventListener('mousemove', onMouseMove);\r\n window.addEventListener('mouseup', onMouseUp);\r\n return () => {\r\n window.removeEventListener('mousemove', onMouseMove);\r\n window.removeEventListener('mouseup', onMouseUp);\r\n };\r\n }, [setSidebarWidth]);\r\n\r\n if (state === 'collapsed') return null;\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"rail\"\r\n aria-label=\"Resize sidebar\"\r\n onMouseDown={onMouseDown}\r\n className={cn(\r\n 'absolute inset-y-0 right-0 z-20 w-1 cursor-col-resize',\r\n 'group/rail flex items-center justify-center',\r\n 'after:absolute after:inset-y-0 after:right-0 after:w-1',\r\n 'hover:after:bg-primary/50 motion-safe:transition-colors motion-safe:duration-150',\r\n className\r\n )}\r\n {...props}\r\n >\r\n </div>\r\n );\r\n }\r\n);\r\nSidebarRail.displayName = 'SidebarRail';\r\n\r\n// ─── SidebarInset ─────────────────────────────────────────────────────────────\r\n\r\nexport const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('relative flex flex-1 flex-col overflow-hidden min-w-0 bg-background', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarInset.displayName = 'SidebarInset';\r\n\r\n// ─── Layout: Header / Content / Footer ───────────────────────────────────────\r\n\r\nexport const SidebarHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"header\"\r\n className={cn('flex flex-col gap-2 p-2 shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarHeader.displayName = 'SidebarHeader';\r\n\r\nexport const SidebarFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { isMobile } = useSidebar();\r\n if (isMobile) {\r\n return null;\r\n }\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"footer\"\r\n className={cn('flex flex-col gap-2 p-2 mt-auto shrink-0', className)}\r\n {...props}\r\n />\r\n )\r\n}\r\n);\r\nSidebarFooter.displayName = 'SidebarFooter';\r\n\r\nexport const SidebarContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"content\"\r\n className={cn('flex flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden py-2', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarContent.displayName = 'SidebarContent';\r\n\r\nexport const SidebarSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"separator\"\r\n className={cn('mx-2 h-px border-t border-sidebar-border', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarSeparator.displayName = 'SidebarSeparator';\r\n"
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
"path": "src/components/ui/sidebar/SidebarMenu.tsx",
|
|
550
|
+
"content": "import * as React from 'react';\r\nimport { NavLink } from 'react-router-dom';\r\nimport { ChevronRight } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/Tooltip';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar } from './SidebarContext';\r\n\r\n// ─── Group ────────────────────────────────────────────────────────────────────\r\n\r\nexport const SidebarGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group\"\r\n className={cn('relative flex flex-col w-full min-w-0 px-2 py-1', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarGroup.displayName = 'SidebarGroup';\r\n\r\nexport const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n return (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group-label\"\r\n className={cn(\r\n 'flex h-8 shrink-0 items-center rounded-md px-2',\r\n 'text-xs font-semibold text-sidebar-foreground/50 uppercase tracking-wider',\r\n 'motion-safe:transition-all motion-safe:duration-200 overflow-hidden whitespace-nowrap select-none',\r\n state === 'collapsed' ? 'opacity-0 h-0 mb-0 hidden' : 'opacity-100',\r\n className\r\n )}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSidebarGroupLabel.displayName = 'SidebarGroupLabel';\r\n\r\nexport const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"group-content\"\r\n className={cn('w-full', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarGroupContent.displayName = 'SidebarGroupContent';\r\n\r\n// ─── Menu ─────────────────────────────────────────────────────────────────────\r\n\r\nexport const SidebarMenu = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\r\n ({ className, ...props }, ref) => (\r\n <ul\r\n ref={ref}\r\n data-sidebar=\"menu\"\r\n className={cn('flex flex-col gap-0.5 list-none m-0 p-0 w-full', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenu.displayName = 'SidebarMenu';\r\n\r\nexport const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\r\n ({ className, ...props }, ref) => (\r\n <li\r\n ref={ref}\r\n data-sidebar=\"menu-item\"\r\n className={cn('group/menu-item relative', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenuItem.displayName = 'SidebarMenuItem';\r\n\r\n// ─── SidebarMenuButton ────────────────────────────────────────────────────────\r\n\r\nexport const menuButtonVariants = tv({\r\n base: [\r\n 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md',\r\n 'text-sm font-medium outline-none ring-sidebar-ring motion-safe:transition-all motion-safe:duration-150',\r\n 'hover:bg-accent hover:text-accent-foreground',\r\n 'focus-visible:ring-2 active:bg-accent/80',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n 'group-has-data-[sidebar=menu-action]/menu-item:pr-8',\r\n // Data state active\r\n 'data-[active=true]:bg-accent data-[active=true]:text-accent-foreground data-[active=true]:font-semibold',\r\n ],\r\n variants: {\r\n size: {\r\n sm: 'h-7 text-xs px-2',\r\n md: 'h-9 px-2',\r\n lg: 'h-11 text-base px-3',\r\n },\r\n collapsed: {\r\n true: 'justify-center px-0',\r\n false: 'justify-start',\r\n },\r\n },\r\n defaultVariants: { size: 'md', collapsed: false },\r\n});\r\n\r\n/** Props for a sidebar menu button */\r\nexport interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n /** Render as child element instead of a button */\r\n asChild?: boolean;\r\n /** Marks the button as the currently active item */\r\n isActive?: boolean;\r\n /** Tooltip text shown when the sidebar is collapsed */\r\n tooltip?: string;\r\n /** Button size variant */\r\n size?: 'sm' | 'md' | 'lg';\r\n}\r\n\r\nexport const SidebarMenuButton = React.forwardRef<HTMLButtonElement, SidebarMenuButtonProps>(\r\n ({ className, isActive = false, tooltip, size = 'md', children, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const button = (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n data-sidebar=\"menu-button\"\r\n data-active={isActive}\r\n data-size={size}\r\n className={menuButtonVariants({ size, collapsed: isCollapsed, className })}\r\n {...props}\r\n >\r\n {isCollapsed\r\n ? React.Children.toArray(children)[0]\r\n : children}\r\n </button>\r\n );\r\n\r\n if (isCollapsed && tooltip) {\r\n return (\r\n <Tooltip>\r\n <TooltipTrigger render={button} />\r\n <TooltipContent side=\"right\">{tooltip}</TooltipContent>\r\n </Tooltip>\r\n );\r\n }\r\n\r\n return button;\r\n }\r\n);\r\nSidebarMenuButton.displayName = 'SidebarMenuButton';\r\n\r\n// ─── SidebarNavLink — wraps React Router NavLink ─────────────────────────────\r\n\r\n/** Props for a sidebar navigation link (wraps React Router NavLink) */\r\nexport interface SidebarNavLinkProps {\r\n /** Route path for the link */\r\n to: string;\r\n /** Icon rendered before the label */\r\n icon?: React.ReactNode;\r\n /** Display text for the link; also used as tooltip when collapsed */\r\n label: string;\r\n /** Match route exactly (React Router `end` prop) */\r\n end?: boolean;\r\n /** Badge element rendered after the label */\r\n badge?: React.ReactNode;\r\n /** Link size variant */\r\n size?: 'sm' | 'md' | 'lg';\r\n className?: string;\r\n}\r\n\r\nexport const SidebarNavLink: React.FC<SidebarNavLinkProps> = ({\r\n to,\r\n icon,\r\n label,\r\n end = false,\r\n badge,\r\n size = 'md',\r\n className,\r\n}) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const link = (\r\n <NavLink\r\n to={to}\r\n end={end}\r\n className={({ isActive }) => cn(\r\n menuButtonVariants({ size, collapsed: isCollapsed, className }),\r\n isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold' : 'text-sidebar-foreground/70'\r\n )}\r\n >\r\n {icon && (\r\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">\r\n {icon}\r\n </span>\r\n )}\r\n {!isCollapsed && (\r\n <>\r\n <span className=\"flex-1 truncate\">{label}</span>\r\n {badge && <span className=\"ml-auto shrink-0\">{badge}</span>}\r\n </>\r\n )}\r\n </NavLink>\r\n );\r\n\r\n if (isCollapsed && label) {\r\n return (\r\n <Tooltip>\r\n <TooltipTrigger render={link} />\r\n <TooltipContent side=\"right\">{label}</TooltipContent>\r\n </Tooltip>\r\n );\r\n }\r\n\r\n return link;\r\n};\r\nSidebarNavLink.displayName = 'SidebarNavLink';\r\n\r\n// ─── SidebarMenuCollapsible — nhóm có sub-items ───────────────────────────────\r\n\r\n/** Props for a collapsible sidebar menu group with sub-items */\r\nexport interface SidebarMenuCollapsibleProps {\r\n /** Unique identifier for the group */\r\n id: string;\r\n /** Icon displayed next to the group label */\r\n icon: React.ReactNode;\r\n /** Display text for the collapsible group header */\r\n label: string;\r\n children: React.ReactNode;\r\n /** Whether the group is initially expanded */\r\n defaultOpen?: boolean;\r\n /** When true, the group auto-expands and shows an active indicator */\r\n isChildActive?: boolean;\r\n}\r\n\r\nexport const SidebarMenuCollapsible: React.FC<SidebarMenuCollapsibleProps> = ({\r\n icon,\r\n label,\r\n children,\r\n defaultOpen = false,\r\n isChildActive = false,\r\n}) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n\r\n const [isOpen, setIsOpen] = React.useState(defaultOpen || isChildActive);\r\n const prevOpenRef = React.useRef(isOpen);\r\n\r\n // Khi sidebar collapse → đóng tất cả sub-menu, ghi nhớ state\r\n // Khi sidebar expand → khôi phục state cũ\r\n React.useEffect(() => {\r\n if (isCollapsed) {\r\n prevOpenRef.current = isOpen;\r\n setIsOpen(false);\r\n } else {\r\n setIsOpen(prevOpenRef.current);\r\n }\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [isCollapsed]);\r\n\r\n // Khi có child active, mở group\r\n React.useEffect(() => {\r\n if (isChildActive && !isCollapsed) {\r\n setIsOpen(true);\r\n prevOpenRef.current = true;\r\n }\r\n }, [isChildActive, isCollapsed]);\r\n\r\n const trigger = (\r\n <button\r\n type=\"button\"\r\n aria-expanded={isOpen}\r\n data-active={isChildActive && isCollapsed}\r\n onClick={() => {\r\n if (!isCollapsed) {\r\n const next = !isOpen;\r\n setIsOpen(next);\r\n prevOpenRef.current = next;\r\n }\r\n }}\r\n className={menuButtonVariants({\r\n collapsed: isCollapsed,\r\n className:\r\n isChildActive && isCollapsed\r\n ? 'text-sidebar-accent-foreground'\r\n : 'text-sidebar-foreground/70',\r\n })}\r\n >\r\n <span className=\"shrink-0 flex h-4 w-4 items-center justify-center\">{icon}</span>\r\n {!isCollapsed && (\r\n <>\r\n <span className=\"flex-1 truncate text-left\">{label}</span>\r\n <ChevronRight\r\n className={cn(\r\n 'ml-auto h-3.5 w-3.5 shrink-0 text-sidebar-foreground/40',\r\n 'motion-safe:transition-transform motion-safe:duration-200',\r\n isOpen && 'rotate-90'\r\n )}\r\n />\r\n </>\r\n )}\r\n </button>\r\n );\r\n\r\n return (\r\n <>\r\n {isCollapsed ? (\r\n <Tooltip>\r\n <TooltipTrigger render={trigger} />\r\n <TooltipContent side=\"right\">{label}</TooltipContent>\r\n </Tooltip>\r\n ) : (\r\n trigger\r\n )}\r\n\r\n {/* Sub-items với animation mượt - Sử dụng SidebarMenuSub (ul) để hợp lệ HTML */}\r\n <SidebarMenuSub\r\n className={cn(\r\n 'overflow-hidden motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-in-out',\r\n !isCollapsed && isOpen ? 'max-h-[800px] opacity-100' : 'max-h-0 opacity-0'\r\n )}\r\n >\r\n {children}\r\n </SidebarMenuSub>\r\n </>\r\n );\r\n};\r\nSidebarMenuCollapsible.displayName = 'SidebarMenuCollapsible';\r\n\r\n// ─── SidebarMenuSub ───────────────────────────────────────────────────────────\r\n\r\nexport const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.HTMLAttributes<HTMLUListElement>>(\r\n ({ className, ...props }, ref) => {\r\n const { state } = useSidebar();\r\n if (state === 'collapsed') return null;\r\n return (\r\n <ul\r\n ref={ref}\r\n data-sidebar=\"menu-sub\"\r\n className={cn('mx-3.5 flex min-w-0 flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 list-none', className)}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSidebarMenuSub.displayName = 'SidebarMenuSub';\r\n\r\nexport const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTMLLIElement>>(\r\n (props, ref) => <li ref={ref} {...props} />\r\n);\r\nSidebarMenuSubItem.displayName = 'SidebarMenuSubItem';\r\n\r\n// ─── Badge & Skeleton ─────────────────────────────────────────────────────────\r\n\r\nexport const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n data-sidebar=\"menu-badge\"\r\n className={cn('ml-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nSidebarMenuBadge.displayName = 'SidebarMenuBadge';\r\n\r\nexport const SidebarMenuSkeleton: React.FC<{ showIcon?: boolean }> = ({ showIcon = true }) => (\r\n <div className=\"flex h-9 items-center gap-2 rounded-md px-2\">\r\n {showIcon && <div className=\"h-4 w-4 rounded bg-sidebar-accent motion-safe:animate-pulse shrink-0\" />}\r\n <div className=\"h-4 flex-1 rounded bg-sidebar-accent motion-safe:animate-pulse\" />\r\n </div>\r\n);\r\n"
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
"path": "src/components/ui/sidebar/SidebarUserMenu.tsx",
|
|
554
|
+
"content": "import * as React from 'react';\r\nimport { ChevronsUpDown } from 'lucide-react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip/Tooltip';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useSidebar } from './SidebarContext';\r\nimport { menuButtonVariants } from './SidebarMenu';\r\n\r\n// ─── User Menu Popover (shadcn style) ─────────────────────────────────────────\r\n\r\ninterface UserMenuPopoverProps {\r\n name: string;\r\n email: string;\r\n avatar?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nexport const UserMenuPopover: React.FC<UserMenuPopoverProps> = ({ name, email, avatar, children }) => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n const [open, setOpen] = React.useState(false);\r\n\r\n const trigger = (\r\n <BasePopover.Trigger\r\n render={\r\n <button\r\n type=\"button\"\r\n data-active={open}\r\n className={cn(\r\n menuButtonVariants({ size: 'lg', collapsed: isCollapsed }),\r\n 'data-[active=true]:bg-sidebar-accent'\r\n )}\r\n >\r\n <img\r\n src={avatar || 'https://i.pravatar.cc/100'}\r\n alt={name}\r\n className=\"w-8 h-8 rounded-lg shrink-0 object-cover border border-sidebar-border\"\r\n />\r\n {!isCollapsed && (\r\n <>\r\n <div className=\"flex-1 text-left overflow-hidden grid\">\r\n <span className=\"text-sm font-semibold truncate leading-tight\">{name}</span>\r\n <span className=\"text-xs text-sidebar-foreground/50 truncate leading-tight\">{email}</span>\r\n </div>\r\n <ChevronsUpDown className=\"ml-auto h-4 w-4 shrink-0 text-sidebar-foreground/40\" />\r\n </>\r\n )}\r\n </button>\r\n }\r\n />\r\n );\r\n\r\n return (\r\n <BasePopover.Root open={open} onOpenChange={setOpen}>\r\n {isCollapsed ? (\r\n <Tooltip>\r\n <TooltipTrigger render={trigger} />\r\n <TooltipContent side=\"right\">{name}</TooltipContent>\r\n </Tooltip>\r\n ) : (\r\n trigger\r\n )}\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner side=\"right\" align=\"end\" sideOffset={8}>\r\n <BasePopover.Popup\r\n className={cn(\r\n 'z-50 w-64 rounded-xl border border-border bg-popover shadow-xl outline-none p-1',\r\n 'motion-safe:data-open:animate-in motion-safe:data-open:fade-in-0 motion-safe:data-open:zoom-in-95',\r\n 'motion-safe:data-closed:animate-out motion-safe:data-closed:fade-out-0 motion-safe:data-closed:zoom-out-95'\r\n )}\r\n >\r\n {/* User info header */}\r\n <div className=\"flex items-center gap-3 p-3 pb-2 border-b border-border/50\">\r\n <img\r\n src={avatar || 'https://i.pravatar.cc/100'}\r\n alt={name}\r\n className=\"w-10 h-10 rounded-lg object-cover border border-border\"\r\n />\r\n <div className=\"flex-1 overflow-hidden\">\r\n <p className=\"text-sm font-semibold truncate\">{name}</p>\r\n <p className=\"text-xs text-muted-foreground truncate\">{email}</p>\r\n </div>\r\n </div>\r\n\r\n {/* Menu items */}\r\n <div className=\"py-1\">{children}</div>\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n );\r\n};\r\n\r\n// ─── UserMenuItem (item trong popover) ───────────────────────────────────────\r\n\r\ninterface UserMenuItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n icon?: React.ReactNode;\r\n destructive?: boolean;\r\n}\r\n\r\nexport const UserMenuItem: React.FC<UserMenuItemProps> = ({ icon, children, destructive, className, ...props }) => (\r\n <button\r\n type=\"button\"\r\n className={cn(\r\n 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm motion-safe:transition-colors',\r\n 'hover:bg-muted outline-none focus-visible:bg-muted',\r\n destructive ? 'text-destructive hover:text-destructive' : 'text-foreground',\r\n className\r\n )}\r\n {...props}\r\n >\r\n {icon && <span className=\"shrink-0 h-4 w-4 flex items-center justify-center\">{icon}</span>}\r\n {children}\r\n </button>\r\n);\r\n"
|
|
508
555
|
}
|
|
509
556
|
]
|
|
510
557
|
},
|
|
@@ -531,7 +578,7 @@
|
|
|
531
578
|
"files": [
|
|
532
579
|
{
|
|
533
580
|
"path": "src/components/ui/slider/Slider.tsx",
|
|
534
|
-
"content": "import * as React from 'react';\r\nimport { Slider as BaseSlider } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst sliderVariants = tv({\r\n slots: {\r\n root: 'relative flex w-full touch-none select-none items-center py-4 data-disabled:opacity-50 data-disabled:cursor-not-allowed',\r\n control: 'relative flex w-full items-center'
|
|
581
|
+
"content": "import * as React from 'react';\r\nimport { Slider as BaseSlider } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst sliderVariants = tv({\r\n slots: {\r\n root: 'relative flex w-full touch-none select-none items-center py-4 data-disabled:opacity-50 data-disabled:cursor-not-allowed',\r\n control: 'relative flex w-full items-center',\r\n track: 'relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary data-disabled:bg-muted',\r\n indicator: 'absolute h-full bg-primary data-disabled:bg-muted-foreground/30',\r\n thumb: 'block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 data-disabled:border-muted-foreground data-disabled:bg-muted data-disabled:pointer-events-none',\r\n }\r\n});\r\n\r\nconst { root, control, track, indicator, thumb } = sliderVariants();\r\n\r\n/** Props for the Slider component */\r\nexport interface SliderProps extends React.ComponentPropsWithoutRef<typeof BaseSlider.Root> {\r\n className?: string;\r\n /** Hiển thị tooltip số khi kéo / hover thumb */\r\n showTooltip?: boolean;\r\n}\r\n\r\nconst toArray = (v: unknown): number[] => {\r\n if (Array.isArray(v)) return v as number[];\r\n if (typeof v === 'number') return [v];\r\n return [0];\r\n};\r\n\r\nconst Slider = React.forwardRef<React.ElementRef<typeof BaseSlider.Root>, SliderProps>(\r\n ({ className, showTooltip, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {\r\n const isControlled = valueProp !== undefined;\r\n const [internalValues, setInternalValues] = React.useState<number[]>(\r\n () => toArray(isControlled ? valueProp : defaultValue)\r\n );\r\n\r\n // Sync khi prop value thay đổi từ bên ngoài (controlled)\r\n React.useEffect(() => {\r\n if (isControlled) setInternalValues(toArray(valueProp));\r\n }, [isControlled, valueProp]);\r\n\r\n const currentValues = isControlled ? toArray(valueProp) : internalValues;\r\n\r\n const handleValueChange: NonNullable<SliderProps['onValueChange']> = (newValues, eventDetails) => {\r\n if (!isControlled) setInternalValues(toArray(newValues));\r\n onValueChange?.(newValues, eventDetails);\r\n };\r\n\r\n return (\r\n <BaseSlider.Root\r\n ref={ref}\r\n className={root({ className })}\r\n aria-label={props['aria-label'] ?? 'Slider'}\r\n value={valueProp}\r\n defaultValue={defaultValue}\r\n onValueChange={handleValueChange}\r\n {...props}\r\n >\r\n <BaseSlider.Control className={control()}>\r\n <BaseSlider.Track className={track()}>\r\n <BaseSlider.Indicator className={indicator()} />\r\n </BaseSlider.Track>\r\n\r\n {currentValues.map((val, index) => (\r\n <BaseSlider.Thumb\r\n key={index}\r\n className={cn(thumb(), showTooltip && 'relative group')}\r\n >\r\n {showTooltip && (\r\n <span className=\"\r\n absolute -top-9 left-1/2 -translate-x-1/2\r\n min-w-[28px] text-center\r\n bg-primary text-primary-foreground\r\n text-xs font-medium leading-none\r\n px-1.5 py-1 rounded shadow-sm\r\n opacity-0 group-hover:opacity-100 group-data-[dragging]:opacity-100\r\n transition-opacity duration-150\r\n pointer-events-none select-none\r\n \">\r\n {val}\r\n </span>\r\n )}\r\n </BaseSlider.Thumb>\r\n ))}\r\n </BaseSlider.Control>\r\n </BaseSlider.Root>\r\n );\r\n }\r\n);\r\nSlider.displayName = 'Slider';\r\n\r\nexport { Slider };\r\n"
|
|
535
582
|
}
|
|
536
583
|
]
|
|
537
584
|
},
|
|
@@ -645,7 +692,21 @@
|
|
|
645
692
|
"files": [
|
|
646
693
|
{
|
|
647
694
|
"path": "src/components/ui/tooltip/Tooltip.tsx",
|
|
648
|
-
"content": "'use client';\n\nimport * as React from 'react';\nimport { Tooltip as BaseTooltip } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\nconst tooltipVariants = tv({\n slots: {\n popup: 'z-50 overflow-hidden rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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',\n arrow: 'fill-popover',\n },\n});\n\nconst { popup, arrow } = tooltipVariants();\n\n// ─── Compound Components ─────────────────────────────────────────────────────\n\n/** Wrap multiple Tooltip instances in a shared provider for better performance */\nconst TooltipProvider = BaseTooltip.Provider;\n\nconst Tooltip = BaseTooltip.Root;\n\nconst TooltipTrigger = React.forwardRef<\n HTMLButtonElement,\n React.ComponentPropsWithoutRef<typeof BaseTooltip.Trigger>\n>(({ children, render, ...props }, ref) => (\n <BaseTooltip.Trigger\n ref={ref}\n render={render ?? (React.isValidElement(children) ? children : undefined)}\n {...props}\n >\n {React.isValidElement(children) ? undefined : children}\n </BaseTooltip.Trigger>\n));\nTooltipTrigger.displayName = 'TooltipTrigger';\n\nexport interface TooltipContentProps\n extends React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup> {\n /** Side offset from the trigger (default: 4) */\n sideOffset?: number;\n /** Side to display the tooltip (default: 'top') */\n side?: 'top' | 'right' | 'bottom' | 'left';\n /** Alignment relative to the trigger (default: 'center') */\n align?: 'start' | 'center' | 'end';\n /** Show the arrow indicator */\n showArrow?: boolean;\n}\n\nconst TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(\n ({ className, sideOffset = 4, side = 'top', align = 'center', showArrow = true, children, ...props }, ref) => (\n <BaseTooltip.Portal>\n <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseTooltip.Popup ref={ref} className={cn(popup(), className)} role=\"tooltip\" {...props}>\n {showArrow && <BaseTooltip.Arrow className={arrow()} />}\n {children}\n </BaseTooltip.Popup>\n </BaseTooltip.Positioner>\n </BaseTooltip.Portal>\n )\n);\nTooltipContent.displayName = 'TooltipContent';\n\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, tooltipVariants };\n"
|
|
695
|
+
"content": "'use client';\r\n\r\nimport * as React from 'react';\r\nimport { Tooltip as BaseTooltip } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst tooltipVariants = tv({\r\n slots: {\r\n popup: 'z-50 overflow-hidden rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-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',\r\n arrow: 'fill-popover',\r\n },\r\n});\r\n\r\nconst { popup, arrow } = tooltipVariants();\r\n\r\n// ─── Compound Components ─────────────────────────────────────────────────────\r\n\r\n/** Wrap multiple Tooltip instances in a shared provider for better performance */\r\nconst TooltipProvider = BaseTooltip.Provider;\r\n\r\nconst Tooltip = BaseTooltip.Root;\r\n\r\nconst TooltipTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n React.ComponentPropsWithoutRef<typeof BaseTooltip.Trigger>\r\n>(({ children, render, ...props }, ref) => (\r\n <BaseTooltip.Trigger\r\n ref={ref}\r\n render={render ?? (React.isValidElement(children) ? children : undefined)}\r\n {...props}\r\n >\r\n {React.isValidElement(children) ? undefined : children}\r\n </BaseTooltip.Trigger>\r\n));\r\nTooltipTrigger.displayName = 'TooltipTrigger';\r\n\r\nexport interface TooltipContentProps\r\n extends React.ComponentPropsWithoutRef<typeof BaseTooltip.Popup> {\r\n /** Side offset from the trigger (default: 4) */\r\n sideOffset?: number;\r\n /** Side to display the tooltip (default: 'top') */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger (default: 'center') */\r\n align?: 'start' | 'center' | 'end';\r\n /** Show the arrow indicator */\r\n showArrow?: boolean;\r\n}\r\n\r\nconst TooltipContent = React.forwardRef<HTMLDivElement, TooltipContentProps>(\r\n ({ className, sideOffset = 4, side = 'top', align = 'center', showArrow = true, children, ...props }, ref) => (\r\n <BaseTooltip.Portal>\r\n <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseTooltip.Popup ref={ref} className={cn(popup(), className)} role=\"tooltip\" {...props}>\r\n {showArrow && <BaseTooltip.Arrow className={arrow()} />}\r\n {children}\r\n </BaseTooltip.Popup>\r\n </BaseTooltip.Positioner>\r\n </BaseTooltip.Portal>\r\n )\r\n);\r\nTooltipContent.displayName = 'TooltipContent';\r\n\r\nexport { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, tooltipVariants };\r\n"
|
|
696
|
+
}
|
|
697
|
+
]
|
|
698
|
+
},
|
|
699
|
+
"typography": {
|
|
700
|
+
"name": "typography",
|
|
701
|
+
"dependencies": [
|
|
702
|
+
"tailwind-variants",
|
|
703
|
+
"lucide-react"
|
|
704
|
+
],
|
|
705
|
+
"internalDependencies": [],
|
|
706
|
+
"files": [
|
|
707
|
+
{
|
|
708
|
+
"path": "src/components/ui/typography/Typography.tsx",
|
|
709
|
+
"content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Copy, Check, ExternalLink } from 'lucide-react';\r\n\r\n// ─── Shared text style variants ───────────────────────────────────────────────\r\n\r\nconst textVariants = tv({\r\n base: '',\r\n variants: {\r\n size: {\r\n xs: 'text-xs',\r\n sm: 'text-sm',\r\n md: 'text-base',\r\n lg: 'text-lg',\r\n xl: 'text-xl',\r\n '2xl': 'text-2xl',\r\n '3xl': 'text-3xl',\r\n },\r\n weight: {\r\n thin: 'font-thin',\r\n light: 'font-light',\r\n normal: 'font-normal',\r\n medium: 'font-medium',\r\n semibold: 'font-semibold',\r\n bold: 'font-bold',\r\n extrabold: 'font-extrabold',\r\n },\r\n color: {\r\n default: 'text-foreground',\r\n muted: 'text-muted-foreground',\r\n primary: 'text-primary',\r\n success: 'text-success',\r\n warning: 'text-warning',\r\n danger: 'text-danger',\r\n inherit: 'text-inherit',\r\n },\r\n align: {\r\n left: 'text-left',\r\n center: 'text-center',\r\n right: 'text-right',\r\n justify: 'text-justify',\r\n },\r\n leading: {\r\n none: 'leading-none',\r\n tight: 'leading-tight',\r\n normal: 'leading-normal',\r\n relaxed: 'leading-relaxed',\r\n loose: 'leading-loose',\r\n },\r\n tracking: {\r\n tighter: 'tracking-tighter',\r\n tight: 'tracking-tight',\r\n normal: 'tracking-normal',\r\n wide: 'tracking-wide',\r\n widest: 'tracking-widest',\r\n },\r\n },\r\n defaultVariants: {\r\n color: 'default',\r\n },\r\n});\r\n\r\n// ─── useCopy hook ─────────────────────────────────────────────────────────────\r\n\r\nfunction useCopy() {\r\n const [copied, setCopied] = React.useState(false);\r\n const copy = React.useCallback(async (text: string) => {\r\n try {\r\n await navigator.clipboard.writeText(text);\r\n setCopied(true);\r\n setTimeout(() => setCopied(false), 2000);\r\n } catch { /* noop */ }\r\n }, []);\r\n return { copied, copy };\r\n}\r\n\r\n// ─── Text ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface TextProps\r\n extends Omit<React.HTMLAttributes<HTMLElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** HTML tag to render — default `span` */\r\n as?: React.ElementType;\r\n /** Bold */\r\n strong?: boolean;\r\n /** Italic */\r\n italic?: boolean;\r\n /** Underline */\r\n underline?: boolean;\r\n /** Strikethrough */\r\n strikethrough?: boolean;\r\n /** Gradient text (primary → indigo) */\r\n gradient?: boolean;\r\n /** Highlighted mark background */\r\n mark?: boolean;\r\n /** Single-line truncate with ellipsis */\r\n truncate?: boolean;\r\n /** Multi-line clamp (number of lines) */\r\n lines?: 1 | 2 | 3 | 4 | 5;\r\n /** Tabular numbers — fixed-width digits */\r\n numeric?: boolean;\r\n /** Inline code styling */\r\n code?: boolean;\r\n /** Show copy icon on hover, copies text content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst LINES_MAP: Record<number, string> = {\r\n 1: 'line-clamp-1',\r\n 2: 'line-clamp-2',\r\n 3: 'line-clamp-3',\r\n 4: 'line-clamp-4',\r\n 5: 'line-clamp-5',\r\n};\r\n\r\nconst Text = React.forwardRef<HTMLElement, TextProps>(\r\n (\r\n {\r\n as: Tag = 'span',\r\n size,\r\n weight,\r\n color,\r\n align,\r\n leading,\r\n tracking,\r\n strong,\r\n italic,\r\n underline,\r\n strikethrough,\r\n gradient,\r\n mark,\r\n truncate,\r\n lines,\r\n numeric,\r\n code,\r\n copyable,\r\n className,\r\n children,\r\n onClick,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n const handleClick = (e: React.MouseEvent<HTMLElement>) => {\r\n if (copyable && elRef.current) {\r\n copy(elRef.current.innerText);\r\n }\r\n onClick?.(e);\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading, tracking }),\r\n strong && 'font-bold',\r\n italic && 'italic',\r\n underline && 'underline underline-offset-2',\r\n strikethrough && 'line-through',\r\n gradient && 'bg-gradient-to-r from-primary to-indigo-500 bg-clip-text text-transparent',\r\n mark && 'bg-warning/20 text-warning-foreground rounded px-0.5',\r\n truncate && 'block max-w-full truncate',\r\n lines && cn('block', LINES_MAP[lines]),\r\n numeric && 'tabular-nums',\r\n code && 'font-mono text-[0.9em] bg-muted rounded px-1 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-1.5',\r\n className,\r\n )}\r\n onClick={handleClick}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-60 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-3.5 h-3.5 text-success\" />\r\n : <Copy className=\"w-3.5 h-3.5\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nText.displayName = 'Text';\r\n\r\n// ─── Heading ──────────────────────────────────────────────────────────────────\r\n\r\nconst HEADING_SIZE: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {\r\n 1: 'text-4xl font-extrabold tracking-tight',\r\n 2: 'text-3xl font-bold tracking-tight',\r\n 3: 'text-2xl font-semibold tracking-tight',\r\n 4: 'text-xl font-semibold',\r\n 5: 'text-lg font-medium',\r\n 6: 'text-base font-medium',\r\n};\r\n\r\nexport interface HeadingProps\r\n extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Heading level 1–6, also sets default size (default: 2) */\r\n level?: 1 | 2 | 3 | 4 | 5 | 6;\r\n /** Show copy icon on hover */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(\r\n ({ level = 2, size, weight, color = 'default', align, className, copyable, children, ...props }, ref) => {\r\n const Tag = `h${level}` as React.ElementType;\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLHeadingElement>(null);\r\n const mergedRef = (node: HTMLHeadingElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n HEADING_SIZE[level],\r\n textVariants({ size, weight, color, align }),\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-2',\r\n className,\r\n )}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-50 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-4 h-4 text-success\" />\r\n : <Copy className=\"w-4 h-4\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nHeading.displayName = 'Heading';\r\n\r\n// ─── Paragraph ────────────────────────────────────────────────────────────────\r\n\r\nexport interface ParagraphProps\r\n extends Omit<React.HTMLAttributes<HTMLParagraphElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Larger intro-text styling */\r\n lead?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Paragraph = React.forwardRef<HTMLParagraphElement, ParagraphProps>(\r\n ({ lead, size, weight, color = 'default', align, leading = 'relaxed', className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading }),\r\n lead && 'text-xl text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nParagraph.displayName = 'Paragraph';\r\n\r\n// ─── Lead ────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LeadProps extends React.HTMLAttributes<HTMLParagraphElement> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Lead = React.forwardRef<HTMLParagraphElement, LeadProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-xl text-muted-foreground leading-relaxed', className)}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nLead.displayName = 'Lead';\r\n\r\n// ─── Blockquote ───────────────────────────────────────────────────────────────\r\n\r\nexport interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement> {\r\n /** Citation source text shown below the quote */\r\n cite?: string;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Blockquote = React.forwardRef<HTMLQuoteElement, BlockquoteProps>(\r\n ({ cite, className, children, ...props }, ref) => (\r\n <figure className=\"my-1\">\r\n <blockquote\r\n ref={ref}\r\n className={cn(\r\n 'border-l-4 border-primary pl-4 py-1 italic text-muted-foreground leading-relaxed',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </blockquote>\r\n {cite && (\r\n <figcaption className=\"mt-2 pl-4 text-sm text-muted-foreground/70 not-italic\">\r\n — {cite}\r\n </figcaption>\r\n )}\r\n </figure>\r\n )\r\n);\r\nBlockquote.displayName = 'Blockquote';\r\n\r\n// ─── Code (inline) ────────────────────────────────────────────────────────────\r\n\r\nexport interface CodeProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Copy content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Code = React.forwardRef<HTMLElement, CodeProps>(\r\n ({ copyable, className, children, ...props }, ref) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <code\r\n ref={mergedRef}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n className={cn(\r\n 'font-mono text-[0.875em] bg-muted text-foreground rounded px-1.5 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer hover:bg-muted/70 transition-colors group/code inline-flex items-center gap-1',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/code:opacity-60 transition-opacity\">\r\n {copied\r\n ? <Check className=\"w-3 h-3 text-success inline\" />\r\n : <Copy className=\"w-3 h-3 inline\" />\r\n }\r\n </span>\r\n )}\r\n </code>\r\n );\r\n }\r\n);\r\nCode.displayName = 'Code';\r\n\r\n// ─── Kbd ──────────────────────────────────────────────────────────────────────\r\n\r\nexport interface KbdProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Array of keys to display; joined with `+` separator */\r\n keys?: string[];\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst KbdKey = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <kbd\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={cn(\r\n 'inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5',\r\n 'font-mono text-xs font-medium',\r\n 'bg-background border border-border rounded shadow-[0_2px_0_0_hsl(var(--border))]',\r\n 'text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </kbd>\r\n )\r\n);\r\nKbdKey.displayName = 'KbdKey';\r\n\r\nconst Kbd = React.forwardRef<HTMLSpanElement, KbdProps>(\r\n ({ keys, className, children, ...props }, ref) => {\r\n const items = keys ?? (children ? [children] : []);\r\n\r\n return (\r\n <span ref={ref} className={cn('inline-flex items-center gap-0.5', className)} {...props}>\r\n {items.map((key, i) => (\r\n <React.Fragment key={i}>\r\n {i > 0 && <span className=\"text-[10px] text-muted-foreground/60 px-0.5\">+</span>}\r\n <KbdKey>{key}</KbdKey>\r\n </React.Fragment>\r\n ))}\r\n </span>\r\n );\r\n }\r\n);\r\nKbd.displayName = 'Kbd';\r\n\r\n// ─── Link ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\r\n /** Open in new tab with rel=\"noopener noreferrer\" */\r\n external?: boolean;\r\n /** Underline behaviour — default `hover` */\r\n underline?: 'always' | 'hover' | 'none';\r\n /** Color variant */\r\n color?: 'primary' | 'muted' | 'danger' | 'foreground';\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Link = React.forwardRef<HTMLAnchorElement, LinkProps>(\r\n ({ external, underline = 'hover', color = 'primary', className, children, ...props }, ref) => (\r\n <a\r\n ref={ref}\r\n target={external ? '_blank' : props.target}\r\n rel={external ? 'noopener noreferrer' : props.rel}\r\n className={cn(\r\n 'inline-flex items-center gap-0.5 transition-colors',\r\n color === 'primary' && 'text-primary',\r\n color === 'muted' && 'text-muted-foreground',\r\n color === 'danger' && 'text-danger',\r\n color === 'foreground' && 'text-foreground',\r\n underline === 'always' && 'underline underline-offset-2',\r\n underline === 'hover' && 'hover:underline underline-offset-2',\r\n underline === 'none' && 'no-underline',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {external && <ExternalLink className=\"w-3 h-3 shrink-0 opacity-70\" />}\r\n </a>\r\n )\r\n);\r\nLink.displayName = 'Link';\r\n\r\n// ─── Mark ─────────────────────────────────────────────────────────────────────\r\n\r\nconst markVariants = tv({\r\n base: 'rounded px-0.5 py-px font-medium',\r\n variants: {\r\n variant: {\r\n default: 'bg-warning/25 text-warning-foreground',\r\n primary: 'bg-primary/15 text-primary',\r\n success: 'bg-success/15 text-success',\r\n warning: 'bg-warning/25 text-warning-foreground',\r\n danger: 'bg-danger/15 text-danger',\r\n },\r\n },\r\n defaultVariants: { variant: 'default' },\r\n});\r\n\r\nexport interface MarkProps\r\n extends React.HTMLAttributes<HTMLElement>,\r\n VariantProps<typeof markVariants> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Mark = React.forwardRef<HTMLElement, MarkProps>(\r\n ({ variant, className, children, ...props }, ref) => (\r\n <mark\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={markVariants({ variant, className })}\r\n {...props}\r\n >\r\n {children}\r\n </mark>\r\n )\r\n);\r\nMark.displayName = 'Mark';\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Text,\r\n Heading,\r\n Paragraph,\r\n Lead,\r\n Blockquote,\r\n Code,\r\n Kbd,\r\n KbdKey,\r\n Link,\r\n Mark,\r\n textVariants,\r\n markVariants,\r\n};\r\n"
|
|
649
710
|
}
|
|
650
711
|
]
|
|
651
712
|
}
|