basuicn 0.1.0 → 0.1.3

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/registry.json CHANGED
@@ -18,7 +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 --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 /* Light Theme - Clean White */\r\n --background: #ffffff;\r\n --foreground: #0f172a;\r\n \r\n --primary: #2f27ce;\r\n --primary-foreground: #ffffff;\r\n \r\n --secondary: #dedcff; /* Vẫn giữ nét màu Light cũ nhưng tinh chỉnh nhẹ */\r\n --secondary-foreground: #2f27ce;\r\n \r\n --muted: #f8fafc;\r\n --muted-foreground: #64748b;\r\n \r\n --accent: #f1f5f9;\r\n --accent-foreground: #0f172a;\r\n \r\n --switch-background: #cbd5e1;\r\n --border: #e2e8f0;\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 --popover: #ffffff;\r\n --popover-foreground: #0f172a;\r\n\r\n --sidebar: #f8fafc;\r\n --sidebar-foreground: #0f172a;\r\n --sidebar-border: #7a7a7a;\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 */\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}"
22
+ },
23
+ {
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"
26
+ },
27
+ {
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"
22
30
  }
23
31
  ]
24
32
  },
@@ -34,21 +42,20 @@
34
42
  "files": [
35
43
  {
36
44
  "path": "src/components/ui/accordion/Accordion.tsx",
37
- "content": "import * as React from 'react';\r\nimport { Accordion as BaseAccordion } from '@base-ui/react';\r\nimport { ChevronDown } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst accordionVariants = tv({\r\n slots: {\r\n root: 'w-full',\r\n item: 'border-b border-border/50 last:border-0',\r\n header: 'flex',\r\n trigger: 'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:text-primary hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 [&[data-panel-open]>svg]:rotate-180',\r\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\r\n }\r\n});\r\n\r\nconst { root, item, header, trigger, panel } = accordionVariants();\r\n\r\nexport const Accordion = React.forwardRef<React.ElementRef<typeof BaseAccordion.Root>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Root>, 'className'> & { className?: string }>(\r\n ({ className, ...props }, ref) => (\r\n <BaseAccordion.Root ref={ref} className={root({ className })} {...props} />\r\n )\r\n)\r\nAccordion.displayName = 'Accordion';\r\n\r\nexport const AccordionItem = React.forwardRef<React.ElementRef<typeof BaseAccordion.Item>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Item>, 'className'> & { className?: string }>(\r\n ({ className, ...props }, ref) => (\r\n <BaseAccordion.Item ref={ref} className={item({ className })} {...props} />\r\n )\r\n)\r\nAccordionItem.displayName = 'AccordionItem';\r\n\r\nexport const AccordionTrigger = React.forwardRef<React.ElementRef<typeof BaseAccordion.Trigger>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Trigger>, 'className'> & { className?: string; hideChevron?: boolean }>(\r\n ({ className, children, hideChevron, ...props }, ref) => (\r\n <BaseAccordion.Header className={header()}>\r\n <BaseAccordion.Trigger ref={ref} className={trigger({ className })} {...props}>\r\n {children}\r\n {!hideChevron && <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />}\r\n </BaseAccordion.Trigger>\r\n </BaseAccordion.Header>\r\n )\r\n)\r\nAccordionTrigger.displayName = 'AccordionTrigger';\r\n\r\nexport const AccordionContent = React.forwardRef<React.ElementRef<typeof BaseAccordion.Panel>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Panel>, 'className'> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <BaseAccordion.Panel ref={ref} className={panel({ className })} {...props}>\r\n <div className=\"pb-4 pt-0\">{children}</div>\r\n </BaseAccordion.Panel>\r\n )\r\n)\r\nAccordionContent.displayName = 'AccordionContent';\r\n"
45
+ "content": "import * as React from 'react';\r\nimport { Accordion as BaseAccordion } from '@base-ui/react';\r\nimport { ChevronDown } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst accordionVariants = tv({\r\n slots: {\r\n root: 'w-full',\r\n item: 'border-b border-border/50 last:border-0',\r\n header: 'flex',\r\n trigger: 'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:text-primary hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 [&[data-panel-open]>svg]:rotate-180',\r\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\r\n }\r\n});\r\n\r\nconst { root, item, header, trigger, panel } = accordionVariants();\r\n\r\nexport const Accordion = React.forwardRef<React.ElementRef<typeof BaseAccordion.Root>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Root>, 'className'> & { className?: string }>(\r\n ({ className, ...props }, ref) => (\r\n <BaseAccordion.Root ref={ref} className={root({ className })} {...props} />\r\n )\r\n)\r\nAccordion.displayName = 'Accordion';\r\n\r\nexport const AccordionItem = React.forwardRef<React.ElementRef<typeof BaseAccordion.Item>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Item>, 'className'> & { className?: string }>(\r\n ({ className, ...props }, ref) => (\r\n <BaseAccordion.Item ref={ref} className={item({ className })} {...props} />\r\n )\r\n)\r\nAccordionItem.displayName = 'AccordionItem';\r\n\r\nexport const AccordionTrigger = React.forwardRef<React.ElementRef<typeof BaseAccordion.Trigger>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Trigger>, 'className'> & { className?: string; /** Hide the default chevron icon */hideChevron?: boolean }>(\r\n ({ className, children, hideChevron, ...props }, ref) => (\r\n <BaseAccordion.Header className={header()}>\r\n <BaseAccordion.Trigger ref={ref} className={trigger({ className })} {...props}>\r\n {children}\r\n {!hideChevron && <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />}\r\n </BaseAccordion.Trigger>\r\n </BaseAccordion.Header>\r\n )\r\n)\r\nAccordionTrigger.displayName = 'AccordionTrigger';\r\n\r\nexport const AccordionContent = React.forwardRef<React.ElementRef<typeof BaseAccordion.Panel>, Omit<React.ComponentPropsWithoutRef<typeof BaseAccordion.Panel>, 'className'> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <BaseAccordion.Panel ref={ref} className={panel({ className })} {...props}>\r\n <div className=\"pb-4 pt-0\">{children}</div>\r\n </BaseAccordion.Panel>\r\n )\r\n)\r\nAccordionContent.displayName = 'AccordionContent';\r\n"
38
46
  }
39
47
  ]
40
48
  },
41
49
  "alert": {
42
50
  "name": "alert",
43
51
  "dependencies": [
44
- "lucide-react",
45
52
  "tailwind-variants"
46
53
  ],
47
54
  "internalDependencies": [],
48
55
  "files": [
49
56
  {
50
57
  "path": "src/components/ui/alert/Alert.tsx",
51
- "content": "import * as React from 'react';\r\nimport { AlertCircle, CheckCircle2, ChevronRight, Info, XCircle } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\nconst alertVariants = tv({\r\n base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',\r\n variants: {\r\n variant: {\r\n default: 'bg-background text-foreground',\r\n destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',\r\n success: 'border-success/50 text-success dark:border-success [&>svg]:text-success',\r\n warning: 'border-warning/50 text-warning dark:border-warning [&>svg]:text-warning',\r\n info: 'border-blue-500/50 text-blue-500 dark:border-blue-500 [&>svg]:text-blue-500',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\nconst Alert = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>>(\r\n ({ className, variant, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n role=\"alert\"\r\n className={alertVariants({ variant, className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlert.displayName = 'Alert';\r\n\r\nconst AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(\r\n ({ className, ...props }, ref) => (\r\n <h5\r\n ref={ref}\r\n className={cn('mb-1 font-medium leading-none tracking-tight', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlertTitle.displayName = 'AlertTitle';\r\n\r\nconst AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('text-sm [&_p]:leading-relaxed', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlertDescription.displayName = 'AlertDescription';\r\n\r\nexport { Alert, AlertTitle, AlertDescription };\r\n"
58
+ "content": "import * as React from 'react';\r\n// Icons can be passed as children by the consumer\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst alertVariants = tv({\r\n base: 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',\r\n variants: {\r\n variant: {\r\n default: 'bg-background text-foreground',\r\n destructive: 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',\r\n success: 'border-success/50 text-success dark:border-success [&>svg]:text-success',\r\n warning: 'border-warning/50 text-warning dark:border-warning [&>svg]:text-warning',\r\n info: 'border-blue-500/50 text-blue-500 dark:border-blue-500 [&>svg]:text-blue-500',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\n/** Props for the Alert component */\r\ntype AlertProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>;\r\n\r\nconst Alert = React.forwardRef<HTMLDivElement, AlertProps>(\r\n ({ className, variant, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n role=\"alert\"\r\n className={alertVariants({ variant, className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlert.displayName = 'Alert';\r\n\r\nconst AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(\r\n ({ className, ...props }, ref) => (\r\n <h5\r\n ref={ref}\r\n className={cn('mb-1 font-medium leading-none tracking-tight', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlertTitle.displayName = 'AlertTitle';\r\n\r\nconst AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('text-sm [&_p]:leading-relaxed', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nAlertDescription.displayName = 'AlertDescription';\r\n\r\nexport { Alert, AlertTitle, AlertDescription };\r\n"
52
59
  }
53
60
  ]
54
61
  },
@@ -62,7 +69,35 @@
62
69
  "files": [
63
70
  {
64
71
  "path": "src/components/ui/alert-dialog/AlertDialog.tsx",
65
- "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: 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0',\r\n content: '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-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0 data-ending:zoom-out-95 data-starting: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\nconst { overlay, content, header, footer, title, description } = alertDialogVariants();\r\n\r\nexport interface AlertDialogProps extends React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Root> {\r\n trigger?: React.ReactNode;\r\n headerTitle?: string;\r\n headerDescription?: string;\r\n cancelContent?: React.ReactNode;\r\n actionContent?: React.ReactNode;\r\n}\r\n\r\nconst AlertDialog = React.forwardRef<React.ElementRef<typeof BaseAlertDialog.Root>, AlertDialogProps>(\r\n ({ trigger, headerTitle, headerDescription, cancelContent, actionContent, ...props }, ref) => {\r\n return (\r\n <BaseAlertDialog.Root {...props}>\r\n {trigger && <BaseAlertDialog.Trigger render={trigger as React.ReactElement} />}\r\n <BaseAlertDialog.Portal>\r\n <BaseAlertDialog.Backdrop className={overlay()} />\r\n <BaseAlertDialog.Popup className={content()}>\r\n {(headerTitle || headerDescription) && (\r\n <div className={header()}>\r\n {headerTitle && <BaseAlertDialog.Title className={title()}>{headerTitle}</BaseAlertDialog.Title>}\r\n {headerDescription && <BaseAlertDialog.Description className={description()}>{headerDescription}</BaseAlertDialog.Description>}\r\n </div>\r\n )}\r\n \r\n <div className={footer()}>\r\n {cancelContent && (\r\n <BaseAlertDialog.Close render={cancelContent as React.ReactElement} />\r\n )}\r\n {actionContent && (\r\n actionContent\r\n )}\r\n </div>\r\n </BaseAlertDialog.Popup>\r\n </BaseAlertDialog.Portal>\r\n </BaseAlertDialog.Root>\r\n );\r\n }\r\n);\r\n\r\nAlertDialog.displayName = 'AlertDialog';\r\n\r\nexport { AlertDialog };\r\n"
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-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting: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-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0 data-ending:zoom-out-95 data-starting: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
+ }
74
+ ]
75
+ },
76
+ "aspect-ratio": {
77
+ "name": "aspect-ratio",
78
+ "dependencies": [
79
+ "tailwind-variants"
80
+ ],
81
+ "internalDependencies": [],
82
+ "files": [
83
+ {
84
+ "path": "src/components/ui/aspect-ratio/AspectRatio.tsx",
85
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst aspectRatioVariants = tv({\r\n base: 'relative w-full overflow-hidden',\r\n});\r\n\r\n/** Props for the AspectRatio component */\r\nexport interface AspectRatioProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof aspectRatioVariants> {\r\n /** Width-to-height ratio (e.g. 16/9, 4/3, 1) */\r\n ratio?: number;\r\n}\r\n\r\nconst AspectRatio = React.forwardRef<HTMLDivElement, AspectRatioProps>(\r\n ({ className, ratio = 1, style, children, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={aspectRatioVariants({ className })}\r\n style={{ paddingBottom: `${(1 / ratio) * 100}%`, ...style }}\r\n {...props}\r\n >\r\n <div className=\"absolute inset-0\">{children}</div>\r\n </div>\r\n )\r\n);\r\nAspectRatio.displayName = 'AspectRatio';\r\n\r\nexport { AspectRatio, aspectRatioVariants };\r\n"
86
+ }
87
+ ]
88
+ },
89
+ "autocomplete": {
90
+ "name": "autocomplete",
91
+ "dependencies": [
92
+ "@base-ui/react",
93
+ "lucide-react",
94
+ "tailwind-variants"
95
+ ],
96
+ "internalDependencies": [],
97
+ "files": [
98
+ {
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"
66
101
  }
67
102
  ]
68
103
  },
@@ -75,7 +110,7 @@
75
110
  "files": [
76
111
  {
77
112
  "path": "src/components/ui/avatar/Avatar.tsx",
78
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst avatarVariants = tv({\r\n base: 'relative flex shrink-0 overflow-hidden rounded-full items-center justify-center bg-secondary text-secondary-foreground outline-none',\r\n variants: {\r\n size: {\r\n sm: 'h-8 w-8 text-xs',\r\n md: 'h-10 w-10 text-sm',\r\n lg: 'h-12 w-12 text-base',\r\n xl: 'h-16 w-16 text-lg',\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\nexport interface AvatarProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof avatarVariants> {\r\n src?: string;\r\n alt?: string;\r\n fallback?: string;\r\n}\r\n\r\nconst Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(\r\n ({ className, size, src, alt, fallback, ...props }, ref) => {\r\n const [hasError, setHasError] = React.useState(false);\r\n\r\n return (\r\n <div ref={ref} className={avatarVariants({ size, className })} {...props}>\r\n {src && !hasError ? (\r\n <img\r\n src={src}\r\n alt={alt || \"Avatar\"}\r\n className=\"aspect-square h-full w-full object-cover\"\r\n onError={() => setHasError(true)}\r\n />\r\n ) : (\r\n <span className=\"font-medium uppercase tracking-wider\">\r\n {fallback || (alt ? alt.substring(0, 2) : '??')}\r\n </span>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\nAvatar.displayName = 'Avatar';\r\n\r\nexport { Avatar };\r\n"
113
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst avatarVariants = tv({\r\n base: 'relative flex shrink-0 overflow-hidden rounded-full items-center justify-center bg-secondary text-secondary-foreground outline-none',\r\n variants: {\r\n size: {\r\n sm: 'h-8 w-8 text-xs',\r\n md: 'h-10 w-10 text-sm',\r\n lg: 'h-12 w-12 text-base',\r\n xl: 'h-16 w-16 text-lg',\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Avatar component */\r\nexport interface AvatarProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof avatarVariants> {\r\n /** Image URL for the avatar */\r\n src?: string;\r\n /** Alt text for the image; first 2 chars used as fallback if no `fallback` prop */\r\n alt?: string;\r\n /** Text shown when the image fails to load or is not provided */\r\n fallback?: string;\r\n}\r\n\r\nconst Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(\r\n ({ className, size, src, alt, fallback, ...props }, ref) => {\r\n const [hasError, setHasError] = React.useState(false);\r\n\r\n return (\r\n <div ref={ref} className={avatarVariants({ size, className })} {...props}>\r\n {src && !hasError ? (\r\n <img\r\n src={src}\r\n alt={alt || \"Avatar\"}\r\n className=\"aspect-square h-full w-full object-cover\"\r\n onError={() => setHasError(true)}\r\n />\r\n ) : (\r\n <span className=\"font-medium uppercase tracking-wider\">\r\n {fallback || (alt ? alt.substring(0, 2) : '??')}\r\n </span>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\nAvatar.displayName = 'Avatar';\r\n\r\nexport { Avatar };\r\n"
79
114
  }
80
115
  ]
81
116
  },
@@ -88,7 +123,21 @@
88
123
  "files": [
89
124
  {
90
125
  "path": "src/components/ui/badge/Badge.tsx",
91
- "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst badgeVariants = tv({\n base: 'inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 w-fit',\n variants: {\n variant: {\n default: 'border-transparent bg-primary text-primary-foreground shadow-sm',\n secondary: 'border-transparent bg-secondary text-secondary-foreground shadow-sm',\n outline: 'border-border text-foreground hover:bg-muted',\n success: 'border-transparent bg-success text-success-foreground shadow-sm',\n warning: 'border-transparent bg-warning text-warning-foreground shadow-sm',\n danger: 'border-transparent bg-danger text-danger-foreground shadow-sm',\n\n // Soft variants\n 'soft-primary': 'border-transparent bg-primary/10 text-primary',\n 'soft-success': 'border-transparent bg-success/10 text-success',\n 'soft-warning': 'border-transparent bg-warning/10 text-warning',\n 'soft-danger': 'border-transparent bg-danger/10 text-danger',\n\n // Glass variant\n glass: 'border border-white/20 bg-white/10 text-foreground backdrop-blur-md shadow-sm',\n\n // Gradient variant\n gradient: 'border-transparent bg-gradient-to-r from-primary to-indigo-500 text-white shadow-sm',\n },\n size: {\n sm: 'text-[10px] px-2 py-0.5 leading-4',\n md: 'text-xs px-2.5 py-0.5 leading-5',\n lg: 'text-sm px-3 py-1 leading-6',\n }\n },\n defaultVariants: {\n variant: 'default',\n size: 'md',\n }\n});\n\nexport interface BadgeProps\n extends React.HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof badgeVariants> {\n pulse?: boolean;\n}\n\nconst Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(\n ({ className, variant, size, pulse, children, ...props }, ref) => {\n return (\n <span ref={ref} className={badgeVariants({ variant, size, className })} {...props}>\n {pulse && (\n <span className=\"relative grid place-items-center h-2 w-2 mr-1.5\">\n <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-75\"></span>\n <span className=\"relative inline-flex rounded-full h-1.5 w-1.5 bg-current\"></span>\n </span>\n )}\n {children}\n </span>\n );\n }\n);\nBadge.displayName = 'Badge';\n\nexport { Badge, badgeVariants };\n"
126
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst badgeVariants = tv({\r\n base: 'inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 w-fit',\r\n variants: {\r\n variant: {\r\n default: 'border-transparent bg-primary text-primary-foreground shadow-sm',\r\n secondary: 'border-transparent bg-secondary text-secondary-foreground shadow-sm',\r\n outline: 'border-border text-foreground hover:bg-muted',\r\n success: 'border-transparent bg-success text-success-foreground shadow-sm',\r\n warning: 'border-transparent bg-warning text-warning-foreground shadow-sm',\r\n danger: 'border-transparent bg-danger text-danger-foreground shadow-sm',\r\n\r\n // Soft variants\r\n 'soft-primary': 'border-transparent bg-primary/10 text-primary',\r\n 'soft-success': 'border-transparent bg-success/10 text-success',\r\n 'soft-warning': 'border-transparent bg-warning/10 text-warning',\r\n 'soft-danger': 'border-transparent bg-danger/10 text-danger',\r\n\r\n // Glass variant\r\n glass: 'border border-white/20 bg-white/10 text-foreground backdrop-blur-md shadow-sm',\r\n\r\n // Gradient variant\r\n gradient: 'border-transparent bg-gradient-to-r from-primary to-indigo-500 text-white shadow-sm',\r\n },\r\n size: {\r\n sm: 'text-[10px] px-2 py-0.5 leading-4',\r\n md: 'text-xs px-2.5 py-0.5 leading-5',\r\n lg: 'text-sm px-3 py-1 leading-6',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n size: 'md',\r\n }\r\n});\r\n\r\n/** Props for the Badge component */\r\nexport interface BadgeProps\r\n extends React.HTMLAttributes<HTMLSpanElement>,\r\n VariantProps<typeof badgeVariants> {\r\n /** Show a pulsing dot indicator before the badge content */\r\n pulse?: boolean;\r\n}\r\n\r\nconst Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(\r\n ({ className, variant, size, pulse, children, ...props }, ref) => {\r\n return (\r\n <span ref={ref} className={badgeVariants({ variant, size, className })} {...props}>\r\n {pulse && (\r\n <span className=\"relative grid place-items-center h-2 w-2 mr-1.5\">\r\n <span className=\"animate-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-75\"></span>\r\n <span className=\"relative inline-flex rounded-full h-1.5 w-1.5 bg-current\"></span>\r\n </span>\r\n )}\r\n {children}\r\n </span>\r\n );\r\n }\r\n);\r\nBadge.displayName = 'Badge';\r\n\r\nexport { Badge, badgeVariants };\r\n"
127
+ }
128
+ ]
129
+ },
130
+ "breadcrumb": {
131
+ "name": "breadcrumb",
132
+ "dependencies": [
133
+ "tailwind-variants",
134
+ "lucide-react"
135
+ ],
136
+ "internalDependencies": [],
137
+ "files": [
138
+ {
139
+ "path": "src/components/ui/breadcrumb/Breadcrumb.tsx",
140
+ "content": "import * as React from 'react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { ChevronRight, MoreHorizontal } from 'lucide-react';\r\n\r\nconst breadcrumbVariants = tv({\r\n slots: {\r\n nav: '',\r\n list: 'flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5',\r\n item: 'inline-flex items-center gap-1.5',\r\n link: 'transition-colors hover:text-foreground',\r\n page: 'font-medium text-foreground',\r\n separator: 'text-muted-foreground/60 [&>svg]:w-3.5 [&>svg]:h-3.5',\r\n ellipsis: 'flex h-9 w-9 items-center justify-center',\r\n },\r\n});\r\n\r\nconst { nav, list, item, link, page, separator, ellipsis } = breadcrumbVariants();\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbProps extends React.ComponentPropsWithoutRef<'nav'> {}\r\n\r\nconst Breadcrumb = React.forwardRef<HTMLElement, BreadcrumbProps>(\r\n ({ className, ...props }, ref) => (\r\n <nav ref={ref} aria-label=\"breadcrumb\" className={nav({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumb.displayName = 'Breadcrumb';\r\n\r\n/* ─── List ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbListProps extends React.ComponentPropsWithoutRef<'ol'> {}\r\n\r\nconst BreadcrumbList = React.forwardRef<HTMLOListElement, BreadcrumbListProps>(\r\n ({ className, ...props }, ref) => (\r\n <ol ref={ref} className={list({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumbList.displayName = 'BreadcrumbList';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbItemProps extends React.ComponentPropsWithoutRef<'li'> {}\r\n\r\nconst BreadcrumbItem = React.forwardRef<HTMLLIElement, BreadcrumbItemProps>(\r\n ({ className, ...props }, ref) => (\r\n <li ref={ref} className={item({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumbItem.displayName = 'BreadcrumbItem';\r\n\r\n/* ─── Link ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the BreadcrumbLink component */\r\nexport interface BreadcrumbLinkProps extends React.ComponentPropsWithoutRef<'a'> {\r\n /** Render as a child span instead of an anchor element */\r\n asChild?: boolean;\r\n}\r\n\r\nconst BreadcrumbLink = React.forwardRef<HTMLAnchorElement, BreadcrumbLinkProps>(\r\n ({ className, asChild, ...props }, ref) => {\r\n if (asChild) {\r\n return <span ref={ref as React.Ref<HTMLSpanElement>} className={link({ className })} {...(props as React.HTMLAttributes<HTMLSpanElement>)} />;\r\n }\r\n return <a ref={ref} className={link({ className })} {...props} />;\r\n }\r\n);\r\nBreadcrumbLink.displayName = 'BreadcrumbLink';\r\n\r\n/* ─── Page (current) ────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbPageProps extends React.ComponentPropsWithoutRef<'span'> {}\r\n\r\nconst BreadcrumbPage = React.forwardRef<HTMLSpanElement, BreadcrumbPageProps>(\r\n ({ className, ...props }, ref) => (\r\n <span ref={ref} role=\"link\" aria-disabled=\"true\" aria-current=\"page\" className={page({ className })} {...props} />\r\n )\r\n);\r\nBreadcrumbPage.displayName = 'BreadcrumbPage';\r\n\r\n/* ─── Separator ─────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbSeparatorProps extends React.ComponentPropsWithoutRef<'li'> {}\r\n\r\nconst BreadcrumbSeparator = React.forwardRef<HTMLLIElement, BreadcrumbSeparatorProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <li ref={ref} role=\"presentation\" aria-hidden=\"true\" className={separator({ className })} {...props}>\r\n {children ?? <ChevronRight />}\r\n </li>\r\n )\r\n);\r\nBreadcrumbSeparator.displayName = 'BreadcrumbSeparator';\r\n\r\n/* ─── Ellipsis ──────────────────────────────────────────────────────── */\r\n\r\nexport interface BreadcrumbEllipsisProps extends React.ComponentPropsWithoutRef<'span'> {}\r\n\r\nconst BreadcrumbEllipsis = React.forwardRef<HTMLSpanElement, BreadcrumbEllipsisProps>(\r\n ({ className, ...props }, ref) => (\r\n <span ref={ref} role=\"presentation\" aria-hidden=\"true\" className={ellipsis({ className })} {...props}>\r\n <MoreHorizontal className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">More</span>\r\n </span>\r\n )\r\n);\r\nBreadcrumbEllipsis.displayName = 'BreadcrumbEllipsis';\r\n\r\nexport {\r\n Breadcrumb,\r\n BreadcrumbList,\r\n BreadcrumbItem,\r\n BreadcrumbLink,\r\n BreadcrumbPage,\r\n BreadcrumbSeparator,\r\n BreadcrumbEllipsis,\r\n breadcrumbVariants,\r\n};\r\n"
92
141
  }
93
142
  ]
94
143
  },
@@ -102,13 +151,9 @@
102
151
  "spinner"
103
152
  ],
104
153
  "files": [
105
- {
106
- "path": "src/components/ui/button/Button.test.tsx",
107
- "content": "import { render, screen } from '@testing-library/react';\nimport { describe, it, expect } from 'vitest';\nimport { Button } from './Button';\n\ndescribe('Button', () => {\n it('renders correctly with default props', () => {\n render(<Button>Click me</Button>);\n const button = screen.getByRole('button', { name: /click me/i });\n expect(button).toBeInTheDocument();\n });\n\n it('renders loading spinner when isLoading is true', () => {\n render(<Button isLoading>Submit</Button>);\n const button = screen.getByRole('button', { name: /submit/i });\n \n // Nút phải bị disable\n expect(button).toBeDisabled();\n \n // Spinner (có role=\"status\") phải xuất hiện\n const spinner = screen.getByRole('status');\n expect(spinner).toBeInTheDocument();\n });\n\n it('renders correctly with different variants', () => {\n const { container } = render(<Button variant=\"danger\">Delete</Button>);\n const button = container.firstChild as HTMLElement;\n expect(button.className).toContain('bg-danger');\n });\n});\n"
108
- },
109
154
  {
110
155
  "path": "src/components/ui/button/Button.tsx",
111
- "content": "import * as React from 'react';\r\nimport { Button as BaseButton } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Spinner } from '../spinner/Spinner';\r\n\r\nconst buttonVariants = tv({\r\n base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-30 disabled:hover:bg-transparent data-open:bg-muted cursor-pointer disabled:cursor-not-allowed',\r\n variants: {\r\n variant: {\r\n solid: 'bg-primary text-primary-foreground hover:bg-primary/70 shadow-sm',\r\n outline: 'border border-border bg-transparent hover:bg-secondary/90 hover:text-foreground',\r\n ghost: 'hover:bg-secondary/90 hover:text-foreground',\r\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/70 shadow-sm',\r\n danger: 'bg-danger text-danger-foreground hover:bg-danger/70 shadow-sm',\r\n link: 'text-primary underline-offset-4 hover:underline h-auto px-0 py-0 font-normal',\r\n // Kính mờ tối — trên nền tối\r\n glass: 'bg-white/15 backdrop-blur-md border border-white/30 text-white hover:bg-white/25 hover:border-white/50 shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_4px_20px_rgba(0,0,0,0.2)] transition-all',\r\n // ─── Glossy Bubble Variants ───────────────────────────────────────────────\r\n // Gradient from white highlight (top-left) → tinted color (bottom-right)\r\n // + inset top border = hiệu ứng gương bong bóng xà phòng\r\n 'glass-white': 'bg-gradient-to-br from-white/70 to-slate-100/60 backdrop-blur-md border border-black/5 text-slate-700 hover:from-white/85 hover:to-slate-100/70 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-amber': 'bg-gradient-to-br from-white/70 to-amber-300/40 backdrop-blur-sm border border-amber-100/80 text-amber-700 hover:from-white/85 hover:to-amber-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-green': 'bg-gradient-to-br from-white/70 to-emerald-300/40 backdrop-blur-sm border border-emerald-100/80 text-emerald-700 hover:from-white/85 hover:to-emerald-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-purple': 'bg-gradient-to-br from-white/70 to-violet-300/40 backdrop-blur-sm border border-violet-100/80 text-violet-700 hover:from-white/85 hover:to-violet-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-pink': 'bg-gradient-to-br from-white/70 to-pink-300/40 backdrop-blur-sm border border-pink-100/80 text-pink-700 hover:from-white/85 hover:to-pink-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n },\r\n size: {\r\n sm: 'h-8 px-3 text-xs',\r\n md: 'h-10 px-4 py-2',\r\n lg: 'h-11 px-8',\r\n icon: 'h-10 w-10',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'solid',\r\n size: 'md',\r\n },\r\n});\r\n\r\nexport interface ButtonProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseButton>, 'className'>,\r\n VariantProps<typeof buttonVariants> {\r\n leftIcon?: React.ReactNode;\r\n rightIcon?: React.ReactNode;\r\n isLoading?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Button = React.forwardRef<React.ElementRef<typeof BaseButton>, ButtonProps>(\r\n ({ className, variant, size, leftIcon, rightIcon, isLoading, children, ...props }, ref) => {\r\n return (\r\n <BaseButton\r\n ref={ref}\r\n className={buttonVariants({ variant, size, className: className || '' })}\r\n disabled={isLoading || props.disabled}\r\n {...props}\r\n >\r\n {isLoading && <Spinner size=\"xs\" className=\"mr-2\" />}\r\n {!isLoading && leftIcon && <span className=\"mr-2\">{leftIcon}</span>}\r\n {children}\r\n {!isLoading && rightIcon && <span className=\"ml-2\">{rightIcon}</span>}\r\n </BaseButton>\r\n );\r\n }\r\n);\r\nButton.displayName = 'Button';\r\n\r\nexport { Button, buttonVariants };\r\n"
156
+ "content": "import * as React from 'react';\r\nimport { Button as BaseButton } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Spinner } from '../spinner/Spinner';\r\n\r\nconst buttonVariants = tv({\r\n base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-30 disabled:hover:bg-transparent data-open:bg-muted cursor-pointer disabled:cursor-not-allowed',\r\n variants: {\r\n variant: {\r\n solid: 'bg-primary text-primary-foreground hover:bg-primary/70 shadow-sm',\r\n outline: 'border border-border bg-transparent hover:bg-secondary/90 hover:text-foreground',\r\n ghost: 'hover:bg-secondary/90 hover:text-foreground',\r\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/70 shadow-sm',\r\n danger: 'bg-danger text-danger-foreground hover:bg-danger/70 shadow-sm',\r\n link: 'text-primary underline-offset-4 hover:underline h-auto px-0 py-0 font-normal',\r\n // Kính mờ tối — trên nền tối\r\n glass: 'bg-white/15 backdrop-blur-md border border-white/30 text-white hover:bg-white/25 hover:border-white/50 shadow-[inset_0_1px_0_rgba(255,255,255,0.7),0_4px_20px_rgba(0,0,0,0.2)] transition-all',\r\n // ─── Glossy Bubble Variants ───────────────────────────────────────────────\r\n // Gradient from white highlight (top-left) → tinted color (bottom-right)\r\n // + inset top border = hiệu ứng gương bong bóng xà phòng\r\n 'glass-white': 'bg-gradient-to-br from-white/70 to-slate-100/60 backdrop-blur-md border border-black/5 text-slate-700 hover:from-white/85 hover:to-slate-100/70 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-amber': 'bg-gradient-to-br from-white/70 to-amber-300/40 backdrop-blur-sm border border-amber-100/80 text-amber-700 hover:from-white/85 hover:to-amber-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-green': 'bg-gradient-to-br from-white/70 to-emerald-300/40 backdrop-blur-sm border border-emerald-100/80 text-emerald-700 hover:from-white/85 hover:to-emerald-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-purple': 'bg-gradient-to-br from-white/70 to-violet-300/40 backdrop-blur-sm border border-violet-100/80 text-violet-700 hover:from-white/85 hover:to-violet-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n 'glass-pink': 'bg-gradient-to-br from-white/70 to-pink-300/40 backdrop-blur-sm border border-pink-100/80 text-pink-700 hover:from-white/85 hover:to-pink-300/60 shadow-[0_4px_12px_rgba(0,0,0,0.1),inset_0_1px_0_rgba(255,255,255,0.8)] transition-all',\r\n },\r\n size: {\r\n sm: 'h-8 px-3 text-xs',\r\n md: 'h-10 px-4 py-2',\r\n lg: 'h-11 px-8',\r\n icon: 'h-10 w-10',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'solid',\r\n size: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Button component */\r\nexport interface ButtonProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseButton>, 'className'>,\r\n VariantProps<typeof buttonVariants> {\r\n /** Icon rendered before the button label */\r\n leftIcon?: React.ReactNode;\r\n /** Icon rendered after the button label */\r\n rightIcon?: React.ReactNode;\r\n /** Shows a loading spinner and disables interaction */\r\n isLoading?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Button = React.forwardRef<React.ElementRef<typeof BaseButton>, ButtonProps>(\r\n ({ className, variant, size, leftIcon, rightIcon, isLoading, children, ...props }, ref) => {\r\n return (\r\n <BaseButton\r\n ref={ref}\r\n className={buttonVariants({ variant, size, className: className || '' })}\r\n disabled={isLoading || props.disabled}\r\n {...props}\r\n >\r\n {isLoading && <Spinner size=\"xs\" className=\"mr-2\" />}\r\n {!isLoading && leftIcon && <span className=\"mr-2\">{leftIcon}</span>}\r\n {children}\r\n {!isLoading && rightIcon && <span className=\"ml-2\">{rightIcon}</span>}\r\n </BaseButton>\r\n );\r\n }\r\n);\r\nButton.displayName = 'Button';\r\n\r\nexport { Button, buttonVariants };\r\n"
112
157
  }
113
158
  ]
114
159
  },
@@ -122,18 +167,20 @@
122
167
  "files": [
123
168
  {
124
169
  "path": "src/components/ui/calendar/Calendar.tsx",
125
- "content": "import * 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\nexport interface CalendarProps extends VariantProps<typeof calendarVariants> {\r\n mode?: CalendarMode;\r\n selected?: Date | DateRange | Date[];\r\n onSelect?: (value: Date | DateRange | Date[] | undefined) => void;\r\n disablePastDates?: boolean;\r\n disableFutureDates?: boolean;\r\n disabled?: boolean;\r\n locale?: keyof typeof locales;\r\n className?: string;\r\n wrapperClassName?: string;\r\n numberOfMonths?: number;\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 = 'vi',\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 return (\r\n <div ref={ref} className={wrapperVariants({ className: wrapperClassName })}>\r\n {mode === 'single' && (\r\n <DayPicker\r\n locale={locales[locale as keyof typeof locales]}\r\n mode=\"single\"\r\n selected={selected as Date | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n disabled={getDisabled()}\r\n numberOfMonths={numberOfMonths}\r\n showOutsideDays={showOutsideDays}\r\n className={calendarVariants({ size, className })}\r\n />\r\n )}\r\n {mode === 'range' && (\r\n <DayPicker\r\n locale={locales[locale as keyof typeof locales]}\r\n mode=\"range\"\r\n selected={selected as DateRange | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n disabled={getDisabled()}\r\n numberOfMonths={numberOfMonths}\r\n showOutsideDays={showOutsideDays}\r\n className={calendarVariants({ size, className })}\r\n />\r\n )}\r\n {mode === 'multiple' && (\r\n <DayPicker\r\n locale={locales[locale as keyof typeof locales]}\r\n mode=\"multiple\"\r\n selected={selected as Date[] | undefined}\r\n onSelect={(d) => onSelect?.(d)}\r\n disabled={getDisabled()}\r\n numberOfMonths={numberOfMonths}\r\n showOutsideDays={showOutsideDays}\r\n className={calendarVariants({ size, className })}\r\n />\r\n )}\r\n </div>\r\n );\r\n});\r\n\r\nCalendar.displayName = 'Calendar';\r\n\r\nexport { Calendar };\r\n"
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"
126
171
  }
127
172
  ]
128
173
  },
129
174
  "card": {
130
175
  "name": "card",
131
- "dependencies": [],
176
+ "dependencies": [
177
+ "tailwind-variants"
178
+ ],
132
179
  "internalDependencies": [],
133
180
  "files": [
134
181
  {
135
182
  "path": "src/components/ui/card/Card.tsx",
136
- "content": "import * as React from 'react';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\nconst Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('rounded-xl border border-border bg-card text-card-foreground shadow-sm', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCard.displayName = 'Card';\r\n\r\nconst CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('flex flex-col space-y-1.5 p-6 border-b border-border/50', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardHeader.displayName = 'CardHeader';\r\n\r\nconst CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(\r\n ({ className, ...props }, ref) => (\r\n <h3\r\n ref={ref}\r\n className={cn('text-lg font-semibold leading-none tracking-tight text-primary', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardTitle.displayName = 'CardTitle';\r\n\r\nconst CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\r\n ({ className, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-sm text-muted-foreground', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardDescription.displayName = 'CardDescription';\r\n\r\nconst CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={cn('p-6 pt-6', className)} {...props} />\r\n )\r\n);\r\nCardContent.displayName = 'CardContent';\r\n\r\nconst CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('flex items-center p-6 pt-0', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardFooter.displayName = 'CardFooter';\r\n\r\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };\r\n"
183
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst cardVariants = tv({\r\n base: 'rounded-xl border border-border bg-card text-card-foreground shadow-sm',\r\n variants: {\r\n padding: {\r\n none: '',\r\n sm: 'p-4',\r\n md: 'p-6',\r\n },\r\n },\r\n defaultVariants: {\r\n padding: 'none',\r\n },\r\n});\r\n\r\n/** Props for the Card component */\r\nexport interface CardProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof cardVariants> {}\r\n\r\nconst Card = React.forwardRef<HTMLDivElement, CardProps>(\r\n ({ className, padding, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cardVariants({ padding, className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nCard.displayName = 'Card';\r\n\r\nconst CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('flex flex-col space-y-1.5 p-6 border-b border-border/50', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardHeader.displayName = 'CardHeader';\r\n\r\nconst CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(\r\n ({ className, ...props }, ref) => (\r\n <h3\r\n ref={ref}\r\n className={cn('text-lg font-semibold leading-none tracking-tight text-primary', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardTitle.displayName = 'CardTitle';\r\n\r\nconst CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(\r\n ({ className, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-sm text-muted-foreground', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardDescription.displayName = 'CardDescription';\r\n\r\nconst CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={cn('p-6 pt-6', className)} {...props} />\r\n )\r\n);\r\nCardContent.displayName = 'CardContent';\r\n\r\nconst CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n className={cn('flex items-center p-6 pt-0', className)}\r\n {...props}\r\n />\r\n )\r\n);\r\nCardFooter.displayName = 'CardFooter';\r\n\r\nexport { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent, cardVariants };\r\n"
137
184
  }
138
185
  ]
139
186
  },
@@ -148,7 +195,7 @@
148
195
  "files": [
149
196
  {
150
197
  "path": "src/components/ui/checkbox/Checkbox.tsx",
151
- "content": "import * as React from 'react';\r\nimport { Checkbox as BaseCheckbox } from '@base-ui/react';\r\nimport { Check, Minus } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\nconst checkboxVariants = tv({\r\n slots: {\r\n root: 'group flex shrink-0 items-center justify-center rounded border transition-all outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 border-border bg-background dark:data-checked:bg-primary/90 dark:data-checked:border-primary/90 dark:data-indeterminate:bg-primary/90 dark:data-indeterminate:border-primary/90',\r\n indicator: 'dark:text-primary-foreground text-primary flex items-center justify-center',\r\n icon: 'h-full w-full stroke-[4]',\r\n },\r\n variants: {\r\n size: {\r\n sm: { root: 'h-4 w-4' },\r\n md: { root: 'h-5 w-5' },\r\n lg: { root: 'h-6 w-6' },\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md'\r\n }\r\n});\r\n\r\nconst { root, indicator, icon } = checkboxVariants();\r\n\r\nexport interface CheckboxProps\r\n extends Omit<BaseCheckbox.Root.Props, 'className'>,\r\n VariantProps<typeof checkboxVariants> {\r\n label?: string;\r\n className?: string;\r\n}\r\n\r\nconst Checkbox = React.forwardRef<React.ElementRef<typeof BaseCheckbox.Root>, CheckboxProps>(\r\n ({ className, size = 'md', label, id, indeterminate, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const checkboxId = id || defaultId;\r\n const { root, indicator, icon } = checkboxVariants({ size });\r\n\r\n return (\r\n <div className={cn(\"flex items-center gap-2\", props.disabled && \"opacity-50 cursor-not-allowed\")}>\r\n <BaseCheckbox.Root\r\n ref={ref}\r\n id={checkboxId}\r\n className={root({ className: cn(!props.disabled&&'cursor-pointer', className) })}\r\n indeterminate={indeterminate}\r\n {...props}\r\n >\r\n <BaseCheckbox.Indicator className={indicator()}>\r\n {indeterminate ? (\r\n <Minus className={icon()} />\r\n ) : (\r\n <Check className={icon()} />\r\n )}\r\n </BaseCheckbox.Indicator>\r\n </BaseCheckbox.Root>\r\n {label && (\r\n <label\r\n htmlFor={checkboxId}\r\n className={cn(\"text-sm font-medium leading-none select-none\", props.disabled && \"opacity-50 cursor-not-allowed\")}\r\n >\r\n {label}\r\n </label>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nCheckbox.displayName = 'Checkbox';\r\n\r\nexport { Checkbox };\r\n"
198
+ "content": "import * as React from 'react';\r\nimport { Checkbox as BaseCheckbox } from '@base-ui/react';\r\nimport { Check, Minus } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst checkboxVariants = tv({\r\n slots: {\r\n root: 'group flex shrink-0 items-center justify-center rounded border transition-all outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed border-border bg-background dark:data-checked:bg-primary/90 dark:data-checked:border-primary/90 dark:data-indeterminate:bg-primary/90 dark:data-indeterminate:border-primary/90',\r\n indicator: 'dark:text-primary-foreground text-primary flex items-center justify-center',\r\n icon: 'h-full w-full stroke-[4]',\r\n },\r\n variants: {\r\n size: {\r\n sm: { root: 'h-4 w-4' },\r\n md: { root: 'h-5 w-5' },\r\n lg: { root: 'h-6 w-6' },\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md'\r\n }\r\n});\r\n\r\nconst { root, indicator, icon } = checkboxVariants();\r\n\r\n/** Props for the Checkbox component */\r\nexport interface CheckboxProps\r\n extends Omit<BaseCheckbox.Root.Props, 'className'>,\r\n VariantProps<typeof checkboxVariants> {\r\n /** Text label displayed next to the checkbox */\r\n label?: string;\r\n className?: string;\r\n}\r\n\r\nconst Checkbox = React.forwardRef<React.ElementRef<typeof BaseCheckbox.Root>, CheckboxProps>(\r\n ({ className, size = 'md', label, id, indeterminate, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const checkboxId = id || defaultId;\r\n const { root, indicator, icon } = checkboxVariants({ size });\r\n\r\n return (\r\n <div className={cn(\"flex items-center gap-2\", props.disabled && \"opacity-50 cursor-not-allowed\")}>\r\n <BaseCheckbox.Root\r\n ref={ref}\r\n id={checkboxId}\r\n className={root({ className: cn(!props.disabled&&'cursor-pointer', className) })}\r\n indeterminate={indeterminate}\r\n {...props}\r\n >\r\n <BaseCheckbox.Indicator className={indicator()}>\r\n {indeterminate ? (\r\n <Minus className={icon()} />\r\n ) : (\r\n <Check className={icon()} />\r\n )}\r\n </BaseCheckbox.Indicator>\r\n </BaseCheckbox.Root>\r\n {label && (\r\n <label\r\n htmlFor={checkboxId}\r\n className={cn(\"text-sm font-medium leading-none select-none\", props.disabled ? \"cursor-not-allowed\" : \"cursor-pointer\")}\r\n >\r\n {label}\r\n </label>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nCheckbox.displayName = 'Checkbox';\r\n\r\nexport { Checkbox };\r\n"
152
199
  }
153
200
  ]
154
201
  },
@@ -163,7 +210,7 @@
163
210
  "files": [
164
211
  {
165
212
  "path": "src/components/ui/collapsible/Collapsible.tsx",
166
- "content": "import * as React from 'react';\r\nimport { Collapsible as BaseCollapsible } from '@base-ui/react';\r\nimport { ChevronDown } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst collapsibleVariants = tv({\r\n slots: {\r\n root: 'w-full',\r\n trigger: 'flex w-full items-center justify-between py-3 px-4 text-sm font-medium rounded-md border border-border bg-background hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary [&[data-panel-open]>svg]:rotate-180',\r\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\r\n content: 'pt-2',\r\n }\r\n});\r\n\r\nexport interface CollapsibleProps {\r\n trigger: React.ReactNode;\r\n children: React.ReactNode;\r\n defaultOpen?: boolean;\r\n open?: boolean;\r\n onOpenChange?: (open: boolean) => void;\r\n className?: string;\r\n triggerClassName?: string;\r\n}\r\n\r\nconst Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(({\r\n trigger,\r\n children,\r\n defaultOpen,\r\n open,\r\n onOpenChange,\r\n className,\r\n triggerClassName,\r\n}, ref) => {\r\n const { root, trigger: triggerCls, panel, content } = collapsibleVariants();\r\n\r\n return (\r\n <BaseCollapsible.Root\r\n ref={ref}\r\n className={root({ className })}\r\n defaultOpen={defaultOpen}\r\n open={open}\r\n onOpenChange={onOpenChange}\r\n >\r\n <BaseCollapsible.Trigger className={triggerCls({ className: triggerClassName })}>\r\n {trigger}\r\n <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\r\n </BaseCollapsible.Trigger>\r\n <BaseCollapsible.Panel className={panel()}>\r\n <div className={content()}>\r\n {children}\r\n </div>\r\n </BaseCollapsible.Panel>\r\n </BaseCollapsible.Root>\r\n );\r\n});\r\n\r\nCollapsible.displayName = 'Collapsible';\r\n\r\nexport { Collapsible };\r\n"
213
+ "content": "import * as React from 'react';\r\nimport { Collapsible as BaseCollapsible } from '@base-ui/react';\r\nimport { ChevronDown } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst collapsibleVariants = tv({\r\n slots: {\r\n root: 'w-full',\r\n trigger: 'flex w-full items-center justify-between py-3 px-4 text-sm font-medium rounded-md border border-border bg-background hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary [&[data-panel-open]>svg]:rotate-180',\r\n panel: 'overflow-hidden text-sm data-[open]:animate-accordion-down data-[closed]:animate-accordion-up transition-all',\r\n content: 'pt-2',\r\n }\r\n});\r\n\r\n/** Props for the Collapsible component */\r\nexport interface CollapsibleProps {\r\n /** Content rendered inside the trigger button */\r\n trigger: React.ReactNode;\r\n children: React.ReactNode;\r\n /** Whether the panel is open by default (uncontrolled) */\r\n defaultOpen?: boolean;\r\n /** Controlled open state */\r\n open?: boolean;\r\n /** Callback fired when the open state changes */\r\n onOpenChange?: (open: boolean) => void;\r\n className?: string;\r\n /** Additional class name applied to the trigger button */\r\n triggerClassName?: string;\r\n}\r\n\r\nconst Collapsible = React.forwardRef<HTMLDivElement, CollapsibleProps>(({\r\n trigger,\r\n children,\r\n defaultOpen,\r\n open,\r\n onOpenChange,\r\n className,\r\n triggerClassName,\r\n}, ref) => {\r\n const { root, trigger: triggerCls, panel, content } = collapsibleVariants();\r\n\r\n return (\r\n <BaseCollapsible.Root\r\n ref={ref}\r\n className={root({ className })}\r\n defaultOpen={defaultOpen}\r\n open={open}\r\n onOpenChange={onOpenChange}\r\n >\r\n <BaseCollapsible.Trigger className={triggerCls({ className: triggerClassName })}>\r\n {trigger}\r\n <ChevronDown className=\"h-4 w-4 shrink-0 transition-transform duration-200\" />\r\n </BaseCollapsible.Trigger>\r\n <BaseCollapsible.Panel className={panel()}>\r\n <div className={content()}>\r\n {children}\r\n </div>\r\n </BaseCollapsible.Panel>\r\n </BaseCollapsible.Root>\r\n );\r\n});\r\n\r\nCollapsible.displayName = 'Collapsible';\r\n\r\nexport { Collapsible };\r\n"
167
214
  }
168
215
  ]
169
216
  },
@@ -178,7 +225,21 @@
178
225
  "files": [
179
226
  {
180
227
  "path": "src/components/ui/combobox/ComboBox.tsx",
181
- "content": "import * as React from 'react';\nimport { Combobox as BaseCombobox } from '@base-ui/react';\nimport { Check, ChevronDown, X, Loader2 } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@lib/utils/cn';\n\nconst comboboxVariants = tv({\n slots: {\n root: 'flex flex-col gap-1.5 w-full',\n inputContainer: 'flex flex-wrap items-center gap-1.5 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 min-w-[120px] 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-left:slide-in-from-right-2 data-side-right:slide-in-from-left-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 chip: 'inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground outline-none focus:ring-1 focus:ring-primary',\n chipRemove: 'hover:bg-primary/20 rounded-full p-0.5 transition-colors cursor-pointer',\n actionsHeader: 'flex items-center gap-1 p-1 border-b border-border sticky top-0 bg-background z-10',\n actionButton: 'flex-1 text-[10px] uppercase tracking-wider font-bold py-1.5 px-2 rounded-sm hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors text-center',\n }\n});\n\nexport interface ComboBoxOption {\n label: string;\n value: string;\n}\n\nexport interface ComboBoxProps {\n options: ComboBoxOption[];\n label?: string;\n placeholder?: string;\n value?: string | string[];\n defaultValue?: string | string[];\n onValueChange?: (value: string | string[]) => void;\n multiple?: boolean;\n isLoading?: boolean;\n className?: string;\n autocomplete?: boolean;\n emptyText?: string;\n selectAllText?: string;\n clearAllText?: string;\n leftIcon?: React.ReactNode;\n}\n\nconst ComboBox = React.forwardRef<HTMLInputElement, ComboBoxProps>(\n ({ options, label, placeholder, value, defaultValue, onValueChange, multiple, isLoading, className, autocomplete = true, emptyText = 'Không tìm thấy kết quả.', selectAllText = 'Chọn tất cả', clearAllText = 'Xóa tất cả', leftIcon }, ref) => {\n const [inputValue, setInputValue] = React.useState('');\n const [internalValue, setInternalValue] = React.useState<string | string[] | null>(defaultValue || (multiple ? [] : null));\n\n const activeValue = value !== undefined ? value : internalValue;\n\n const handleValueChange = (newVal: string | string[] | null) => {\n if (value === undefined) {\n setInternalValue(newVal);\n }\n if (newVal !== null) {\n onValueChange?.(newVal);\n }\n };\n\n const handleClear = (e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n handleValueChange(multiple ? [] : null);\n setInputValue('');\n };\n\n const hasValue = multiple\n ? Array.isArray(activeValue) && activeValue.length > 0\n : !!activeValue;\n\n // Lọc options theo text người dùng đang gõ\n const filteredOptions = React.useMemo(() => {\n if (!inputValue || !autocomplete) return options;\n // Khi đã có value được chọn, input hiển thị label → không filter theo label đó\n if (!multiple && activeValue) {\n const selectedOption = options.find((o) => o.value === activeValue);\n if (selectedOption && inputValue === selectedOption.label) return options;\n }\n return options.filter(opt =>\n opt.label.toLowerCase().includes(inputValue.toLowerCase())\n );\n }, [options, inputValue, autocomplete, multiple, activeValue]);\n\n const { root, inputContainer, input, popup, item, indicator, chip, chipRemove, actionsHeader, actionButton } = comboboxVariants();\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\n\n return (\n <BaseCombobox.Root\n value={activeValue}\n onValueChange={handleValueChange}\n multiple={multiple}\n onInputValueChange={setInputValue}\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 {multiple ? (\n <BaseCombobox.Chips className=\"flex flex-wrap items-center gap-1.5 flex-1 w-full min-w-0\">\n {Array.isArray(activeValue) && activeValue.map((val) => {\n const option = options.find(o => o.value === val);\n return (\n <BaseCombobox.Chip key={val} className={chip()}>\n {option?.label || val}\n <BaseCombobox.ChipRemove className={chipRemove()}>\n <X className=\"h-3 w-3\" />\n </BaseCombobox.ChipRemove>\n </BaseCombobox.Chip>\n );\n })}\n <BaseCombobox.Input\n ref={ref}\n readOnly={!autocomplete}\n placeholder={Array.isArray(activeValue) && activeValue.length > 0 ? '' : placeholder}\n className={input()}\n />\n </BaseCombobox.Chips>\n ) : (\n <BaseCombobox.Input\n ref={ref}\n readOnly={!autocomplete}\n placeholder={placeholder}\n className={cn(input(), !autocomplete && 'cursor-pointer')}\n />\n )}\n\n {hasValue && (\n <button\n type=\"button\"\n onClick={handleClear}\n className=\"p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors mr-1\"\n >\n <X className=\"h-3.5 w-3.5\" />\n </button>\n )}\n\n <BaseCombobox.Trigger className=\"text-muted-foreground transition-transform group-data-open:rotate-180 ml-auto\">\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\n </BaseCombobox.Trigger>\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 {multiple && options.length > 0 && (\n <div className={actionsHeader()}>\n <button\n type=\"button\"\n aria-label={selectAllText}\n onClick={(e) => {\n e.preventDefault();\n handleValueChange(options.map((o) => o.value));\n }}\n className={actionButton()}\n >\n {selectAllText}\n </button>\n <div className=\"w-px h-3 bg-border\" />\n <button\n type=\"button\"\n aria-label={clearAllText}\n onClick={(e) => {\n e.preventDefault();\n handleValueChange([]);\n }}\n className={actionButton()}\n >\n {clearAllText}\n </button>\n </div>\n )}\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\nComboBox.displayName = 'ComboBox';\n\nexport { ComboBox };\n"
228
+ "content": "import * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, ChevronDown, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst comboboxVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex flex-wrap items-center gap-1.5 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 min-w-[120px] 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-left:slide-in-from-right-2 data-side-right:slide-in-from-left-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 chip: 'inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground outline-none focus:ring-1 focus:ring-primary',\r\n chipRemove: 'hover:bg-primary/20 rounded-full p-0.5 transition-colors cursor-pointer',\r\n actionsHeader: 'flex items-center gap-1 p-1 border-b border-border sticky top-0 bg-background z-10',\r\n actionButton: 'flex-1 text-[10px] uppercase tracking-wider font-bold py-1.5 px-2 rounded-sm hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors text-center',\r\n }\r\n});\r\n\r\n/** A single option in the ComboBox dropdown */\r\nexport interface ComboBoxOption {\r\n /** Display text for the option */\r\n label: string;\r\n /** Unique value identifying the option */\r\n value: string;\r\n}\r\n\r\n/** Props for the ComboBox component */\r\nexport interface ComboBoxProps {\r\n /** Array of selectable options */\r\n options: ComboBoxOption[];\r\n /** Label text displayed above the combobox */\r\n label?: string;\r\n placeholder?: string;\r\n /** Controlled selected value (string for single, string[] for multiple) */\r\n value?: string | string[];\r\n /** Initial value for uncontrolled usage */\r\n defaultValue?: string | string[];\r\n /** Callback fired when the selected value changes */\r\n onValueChange?: (value: string | string[]) => void;\r\n /** Enable multi-select mode with chip display */\r\n multiple?: boolean;\r\n /** Shows a loading spinner on the dropdown trigger */\r\n isLoading?: boolean;\r\n className?: string;\r\n /** Enable type-ahead filtering of options (default: true) */\r\n autocomplete?: boolean;\r\n /** Text shown when no options match the filter */\r\n emptyText?: string;\r\n /** Label for the \"select all\" action in multi-select mode */\r\n selectAllText?: string;\r\n /** Label for the \"clear all\" action in multi-select mode */\r\n clearAllText?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst ComboBox = React.forwardRef<HTMLInputElement, ComboBoxProps>(\r\n ({ options, label, placeholder, value, defaultValue, onValueChange, multiple, isLoading, className, autocomplete = true, emptyText = 'No results found.', selectAllText = 'Select all', clearAllText = 'Clear all', leftIcon }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [internalValue, setInternalValue] = React.useState<string | string[] | null>(defaultValue || (multiple ? [] : null));\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | string[] | null) => {\r\n if (value === undefined) {\r\n setInternalValue(newVal);\r\n }\r\n if (newVal !== null) {\r\n onValueChange?.(newVal);\r\n }\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(multiple ? [] : null);\r\n setInputValue('');\r\n };\r\n\r\n const hasValue = multiple\r\n ? Array.isArray(activeValue) && activeValue.length > 0\r\n : !!activeValue;\r\n\r\n // Lọc options theo text người dùng đang gõ\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue || !autocomplete) return options;\r\n // Khi đã có value được chọn, input hiển thị label → không filter theo label đó\r\n if (!multiple && activeValue) {\r\n const selectedOption = options.find((o) => o.value === activeValue);\r\n if (selectedOption && inputValue === selectedOption.label) return options;\r\n }\r\n return options.filter(opt =>\r\n opt.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, autocomplete, multiple, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator, chip, chipRemove, actionsHeader, actionButton } = comboboxVariants();\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n multiple={multiple}\r\n onInputValueChange={setInputValue}\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 {multiple ? (\r\n <BaseCombobox.Chips className=\"flex flex-wrap items-center gap-1.5 flex-1 w-full min-w-0\">\r\n {Array.isArray(activeValue) && activeValue.map((val) => {\r\n const option = options.find(o => o.value === val);\r\n return (\r\n <BaseCombobox.Chip key={val} className={chip()}>\r\n {option?.label || val}\r\n <BaseCombobox.ChipRemove className={chipRemove()}>\r\n <X className=\"h-3 w-3\" />\r\n </BaseCombobox.ChipRemove>\r\n </BaseCombobox.Chip>\r\n );\r\n })}\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={Array.isArray(activeValue) && activeValue.length > 0 ? '' : placeholder}\r\n className={input()}\r\n />\r\n </BaseCombobox.Chips>\r\n ) : (\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={placeholder}\r\n className={cn(input(), !autocomplete && 'cursor-pointer')}\r\n />\r\n )}\r\n\r\n {hasValue && (\r\n <button\r\n type=\"button\"\r\n aria-label=\"Clear selection\"\r\n onClick={handleClear}\r\n className=\"p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors mr-1\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </button>\r\n )}\r\n\r\n <BaseCombobox.Trigger className=\"text-muted-foreground transition-transform group-data-open:rotate-180 ml-auto\">\r\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\r\n </BaseCombobox.Trigger>\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 {multiple && options.length > 0 && (\r\n <div className={actionsHeader()}>\r\n <button\r\n type=\"button\"\r\n aria-label={selectAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange(options.map((o) => o.value));\r\n }}\r\n className={actionButton()}\r\n >\r\n {selectAllText}\r\n </button>\r\n <div className=\"w-px h-3 bg-border\" />\r\n <button\r\n type=\"button\"\r\n aria-label={clearAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange([]);\r\n }}\r\n className={actionButton()}\r\n >\r\n {clearAllText}\r\n </button>\r\n </div>\r\n )}\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\nComboBox.displayName = 'ComboBox';\r\n\r\nexport { ComboBox };\r\n"
229
+ }
230
+ ]
231
+ },
232
+ "context-menu": {
233
+ "name": "context-menu",
234
+ "dependencies": [
235
+ "tailwind-variants",
236
+ "lucide-react"
237
+ ],
238
+ "internalDependencies": [],
239
+ "files": [
240
+ {
241
+ "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"
182
243
  }
183
244
  ]
184
245
  },
@@ -197,7 +258,7 @@
197
258
  "files": [
198
259
  {
199
260
  "path": "src/components/ui/datepicker/DatePicker.tsx",
200
- "content": "import * as React from 'react';\nimport { Popover as BasePopover } from '@base-ui/react';\nimport { DayPicker, type DateRange } from 'react-day-picker';\nimport { format } from 'date-fns';\nimport { Calendar as CalendarIcon, ChevronDown, Clock } from 'lucide-react';\nimport { tv } from 'tailwind-variants';\nimport * as locales from 'react-day-picker/locale';\n\nimport 'react-day-picker/dist/style.css';\nimport { Button } from '../button/Button';\n\n// ---------- types ----------\n\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\nexport type TimePickerStyle = 'input' | 'select';\n\ninterface TimeParts {\n h: string;\n m: string;\n s: string;\n}\n\nexport interface DatePickerProps {\n mode?: DatePickerMode;\n /** single → Date | range → DateRange | time-only → dùng timeValue */\n date?: Date | DateRange;\n onDateChange?: (date: Date | DateRange | undefined) => void;\n onChange?: (date: Date | DateRange | undefined) => void;\n /** Chỉ dùng khi mode='time-only' */\n timeValue?: string;\n onTimeChange?: (time: string) => void;\n label?: string;\n placeholder?: string;\n disablePastDates?: boolean;\n showTime?: boolean;\n timeFormat?: TimeFormat;\n timePickerStyle?: TimePickerStyle;\n disabled?: boolean;\n className?: string;\n description?: string;\n error?: string;\n}\n\n// ---------- helpers ----------\n\nconst DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\n\nfunction parseTimeParts(timeStr: string): TimeParts {\n const [h = '00', m = '00', s = '00'] = timeStr.split(':');\n return {\n h: h.padStart(2, '0'),\n m: m.padStart(2, '0'),\n s: s.padStart(2, '0'),\n };\n}\n\nfunction buildTimeString(parts: TimeParts, fmt: TimeFormat): string {\n if (fmt === 'HH') return parts.h;\n if (fmt === 'HH:mm') return `${parts.h}:${parts.m}`;\n return `${parts.h}:${parts.m}:${parts.s}`;\n}\n\nfunction applyTimeToDate(base: Date, parts: TimeParts): Date {\n const d = new Date(base);\n d.setHours(Number(parts.h), Number(parts.m), Number(parts.s), 0);\n return d;\n}\n\nfunction dateToTimeParts(d: Date): TimeParts {\n return {\n h: d.getHours().toString().padStart(2, '0'),\n m: d.getMinutes().toString().padStart(2, '0'),\n s: d.getSeconds().toString().padStart(2, '0'),\n };\n}\n\nfunction formatDateDisplay(d: Date, showTime: boolean, fmt: TimeFormat): string {\n const datePart = format(d, 'dd/MM/yyyy');\n if (!showTime) return datePart;\n if (fmt === 'HH') return `${datePart} ${format(d, 'HH')}h`;\n if (fmt === 'HH:mm') return `${datePart} ${format(d, 'HH:mm')}`;\n return `${datePart} ${format(d, 'HH:mm:ss')}`;\n}\n\nfunction padOptions(count: number) {\n return Array.from({ length: count }, (_, i) => ({\n label: i.toString().padStart(2, '0'),\n value: i.toString().padStart(2, '0'),\n }));\n}\n\nconst hoursOptions = padOptions(24);\nconst minutesOptions = padOptions(60);\nconst secondsOptions = padOptions(60);\n\n// ---------- styles ----------\n\nconst popoverContent = tv({\n base: 'z-50 rounded-xl border border-border bg-background text-foreground shadow-xl outline-none data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\n});\n\n// ---------- sub-components ----------\n\ninterface NativeSelectProps {\n value: string;\n options: { label: string; value: string }[];\n onChange: (val: string) => void;\n 'aria-label'?: string;\n}\n\nconst NativeScrollSelect: React.FC<NativeSelectProps> = ({ value, options, onChange, 'aria-label': ariaLabel }) => (\n <select\n aria-label={ariaLabel}\n value={value}\n onChange={(e) => onChange(e.target.value)}\n className=\"h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none\"\n >\n {options.map((o) => (\n <option key={o.value} value={o.value}>{o.label}</option>\n ))}\n </select>\n);\n\ninterface TimePickerProps {\n parts: TimeParts;\n onChange: (parts: TimeParts) => void;\n timeFormat: TimeFormat;\n timePickerStyle: TimePickerStyle;\n}\n\nconst TimePicker: React.FC<TimePickerProps> = ({ parts, onChange, timeFormat, timePickerStyle }) => {\n const showMinutes = timeFormat === 'HH:mm' || timeFormat === 'HH:mm:ss';\n const showSeconds = timeFormat === 'HH:mm:ss';\n\n if (timePickerStyle === 'input') {\n const inputType = timeFormat === 'HH:mm:ss' ? 'time' : 'time';\n\n // Native time input — giả lập với step\n const step = showSeconds ? 1 : 60;\n const rawValue = showSeconds\n ? `${parts.h}:${parts.m}:${parts.s}`\n : `${parts.h}:${parts.m}`;\n\n return (\n <input\n type=\"time\"\n value={rawValue}\n step={step}\n onChange={(e) => {\n const [h = '00', m = '00', s = '00'] = e.target.value.split(':');\n onChange({ h: h.padStart(2, '0'), m: m.padStart(2, '0'), s: s.padStart(2, '0') });\n }}\n className=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus:border-primary focus:outline-none\"\n />\n );\n }\n\n return (\n <div className=\"flex items-center gap-1.5\">\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Giờ\"\n value={parts.h}\n options={hoursOptions}\n onChange={(val) => onChange({ ...parts, h: val })}\n />\n </div>\n {showMinutes && (\n <>\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Phút\"\n value={parts.m}\n options={minutesOptions}\n onChange={(val) => onChange({ ...parts, m: val })}\n />\n </div>\n </>\n )}\n {showSeconds && (\n <>\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Giây\"\n value={parts.s}\n options={secondsOptions}\n onChange={(val) => onChange({ ...parts, s: val })}\n />\n </div>\n </>\n )}\n </div>\n );\n};\n\n// ---------- main component ----------\n\nexport const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(({\n mode = 'single',\n date,\n onDateChange,\n onChange,\n timeValue,\n onTimeChange,\n label,\n placeholder = 'Chọn ngày...',\n disablePastDates = false,\n showTime = false,\n timeFormat = 'HH:mm:ss',\n timePickerStyle = 'select',\n disabled = false,\n className,\n description,\n error,\n}, ref) => {\n const [open, setOpen] = React.useState(false);\n const triggerRef = React.useRef<HTMLButtonElement>(null);\n\n // Khởi tạo parts từ date prop hoặc timeValue\n const initParts = React.useMemo<TimeParts>(() => {\n if (mode === 'time-only' && timeValue) return parseTimeParts(timeValue);\n if (date instanceof Date) return dateToTimeParts(date);\n return DEFAULT_TIME;\n }, []);\n\n const [timeParts, setTimeParts] = React.useState<TimeParts>(initParts);\n\n // Sync khi prop thay đổi từ ngoài\n React.useEffect(() => {\n if (mode === 'time-only' && timeValue) {\n setTimeParts(parseTimeParts(timeValue));\n } else if (date instanceof Date) {\n setTimeParts(dateToTimeParts(date));\n }\n }, [date, timeValue, mode]);\n\n // Gọi callback khi timeParts thay đổi\n const handlePartsChange = (newParts: TimeParts) => {\n setTimeParts(newParts);\n if (mode === 'time-only') {\n onTimeChange?.(buildTimeString(newParts, timeFormat));\n return;\n }\n if (date instanceof Date) {\n const newDate = applyTimeToDate(date, newParts);\n onDateChange?.(newDate);\n onChange?.(newDate);\n }\n };\n\n const handleDateSelect = (selectedDate: Date | DateRange | Date[] | undefined) => {\n if (!selectedDate) {\n onDateChange?.(undefined);\n onChange?.(undefined);\n return;\n }\n if (mode === 'single' && showTime && selectedDate instanceof Date) {\n const newDate = applyTimeToDate(selectedDate, timeParts);\n onDateChange?.(newDate);\n onChange?.(newDate);\n } else {\n // Because of our mode checking, we can be confident here\n onDateChange?.(selectedDate as DateRange);\n onChange?.(selectedDate as DateRange);\n }\n };\n\n // ---------- render trigger label ----------\n const triggerLabel = React.useMemo(() => {\n if (mode === 'time-only') {\n const val = timeValue ?? buildTimeString(timeParts, timeFormat);\n if (!val || val === '00' || val === '00:00' || val === '00:00:00')\n return <span className=\"text-muted-foreground\">{placeholder || 'Chọn giờ...'}</span>;\n return <span>{val}</span>;\n }\n\n if (!date) return <span className=\"text-muted-foreground\">{placeholder}</span>;\n\n if (mode === 'single' && date instanceof Date) {\n return <span>{formatDateDisplay(date, showTime, timeFormat)}</span>;\n }\n\n if (mode === 'range') {\n const range = date as DateRange;\n if (range.from && range.to) {\n return (\n <span>\n {format(range.from, 'dd/MM/yyyy')} – {format(range.to, 'dd/MM/yyyy')}\n </span>\n );\n }\n if (range.from) return <span>{format(range.from, 'dd/MM/yyyy')} –</span>;\n }\n\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\n }, [date, mode, showTime, timeFormat, timeValue, timeParts, placeholder]);\n\n const isTimeMode = mode === 'time-only';\n const needsTimePicker = isTimeMode || (mode === 'single' && showTime);\n\n return (\n <div ref={ref} className={`flex flex-col gap-1.5 w-full ${className || ''}`}>\n {label && (\n <label className=\"text-sm font-medium text-foreground leading-none\">\n {label}\n </label>\n )}\n\n <BasePopover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>\n <BasePopover.Trigger\n render={\n <button\n ref={triggerRef}\n type=\"button\"\n disabled={disabled}\n className={[\n 'flex h-10 w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm',\n 'ring-offset-background transition-shadow',\n 'hover:border-primary focus:border-primary focus:outline-none',\n 'disabled:cursor-not-allowed disabled:opacity-50',\n error ? 'border-danger focus:border-danger' : 'border-border',\n 'group',\n ].join(' ')}\n >\n {isTimeMode ? (\n <Clock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n ) : (\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n )}\n <div className=\"flex-1 truncate text-left\">{triggerLabel}</div>\n <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-open:rotate-180\" />\n </button>\n }\n />\n\n <BasePopover.Portal>\n <BasePopover.Positioner anchor={triggerRef} sideOffset={6} className=\"z-50\">\n <BasePopover.Popup className={popoverContent()}>\n {!isTimeMode && mode === 'single' && (\n <div className=\"p-2 flex justify-center\">\n <DayPicker\n mode=\"single\"\n locale={locales.vi}\n selected={date as Date | undefined}\n onSelect={(d) => handleDateSelect(d)}\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\n className=\"rdp-custom\"\n />\n </div>\n )}\n {!isTimeMode && mode === 'range' && (\n <div className=\"p-2 flex justify-center\">\n <DayPicker\n mode=\"range\"\n locale={locales.vi}\n selected={date as DateRange | undefined}\n onSelect={(d) => handleDateSelect(d)}\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\n className=\"rdp-custom\"\n />\n </div>\n )}\n\n {/* Time picker */}\n {needsTimePicker && (\n <div className={`border-t border-border p-3 flex flex-col gap-2 ${isTimeMode ? 'border-t-0' : ''}`}>\n <div className=\"flex items-center gap-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide\">\n <Clock className=\"w-3.5 h-3.5\" />\n <span>\n {timeFormat === 'HH' ? 'Chọn giờ' : timeFormat === 'HH:mm' ? 'Giờ : Phút' : 'Giờ : Phút : Giây'}\n </span>\n </div>\n <TimePicker\n parts={timeParts}\n onChange={handlePartsChange}\n timeFormat={timeFormat}\n timePickerStyle={timePickerStyle}\n />\n </div>\n )}\n\n {/* Footer actions */}\n <div className=\"flex items-center justify-between gap-2 p-3 border-t border-border\">\n <button\n type=\"button\"\n onClick={() => {\n if (mode === 'time-only') {\n setTimeParts(DEFAULT_TIME);\n onTimeChange?.('');\n } else {\n onDateChange?.(undefined);\n }\n }}\n className=\"text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline\"\n >\n Xóa\n </button>\n <Button size=\"sm\" onClick={() => setOpen(false)}>\n Xác nhận\n </Button>\n </div>\n </BasePopover.Popup>\n </BasePopover.Positioner>\n </BasePopover.Portal>\n </BasePopover.Root>\n {description && !error && (\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n </div>\n );\n});\n\nDatePicker.displayName = \"DatePicker\";\n"
261
+ "content": "import * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { DayPicker, type DateRange } from 'react-day-picker';\r\nimport { format } from 'date-fns';\r\nimport { Calendar as CalendarIcon, ChevronDown, Clock } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport * as locales from 'react-day-picker/locale';\r\n\r\nimport 'react-day-picker/dist/style.css';\r\nimport { Button } from '../button/Button';\r\n\r\n// ---------- types ----------\r\n\r\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\r\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\r\nexport type TimePickerStyle = 'input' | 'select';\r\n\r\ninterface TimeParts {\r\n h: string;\r\n m: string;\r\n s: string;\r\n}\r\n\r\n/** Props for the DatePicker component */\r\nexport interface DatePickerProps {\r\n /** Picker mode: single date, date range, or time-only */\r\n mode?: DatePickerMode;\r\n /** Selected date (Date for single, DateRange for range) */\r\n date?: Date | DateRange;\r\n /** Callback fired when the date changes */\r\n onDateChange?: (date: Date | DateRange | undefined) => void;\r\n /** Alternative callback (alias for onDateChange) */\r\n onChange?: (date: Date | DateRange | undefined) => void;\r\n /** Current time string, only used when mode is 'time-only' */\r\n timeValue?: string;\r\n /** Callback fired when the time value changes (time-only mode) */\r\n onTimeChange?: (time: string) => void;\r\n /** Label text displayed above the picker */\r\n label?: string;\r\n /** Placeholder text when no date is selected */\r\n placeholder?: string;\r\n /** Disable all dates before today */\r\n disablePastDates?: boolean;\r\n /** Show time picker alongside the calendar */\r\n showTime?: boolean;\r\n /** Time format: hours only, hours:minutes, or hours:minutes:seconds */\r\n timeFormat?: TimeFormat;\r\n /** Time picker UI style: native input or dropdown selects */\r\n timePickerStyle?: TimePickerStyle;\r\n /** Disable the entire picker */\r\n disabled?: boolean;\r\n className?: string;\r\n /** Helper text displayed below the picker */\r\n description?: string;\r\n /** Error message displayed below the picker (replaces description) */\r\n error?: string;\r\n}\r\n\r\n// ---------- helpers ----------\r\n\r\nconst DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\r\n\r\nfunction parseTimeParts(timeStr: string): TimeParts {\r\n const [h = '00', m = '00', s = '00'] = timeStr.split(':');\r\n return {\r\n h: h.padStart(2, '0'),\r\n m: m.padStart(2, '0'),\r\n s: s.padStart(2, '0'),\r\n };\r\n}\r\n\r\nfunction buildTimeString(parts: TimeParts, fmt: TimeFormat): string {\r\n if (fmt === 'HH') return parts.h;\r\n if (fmt === 'HH:mm') return `${parts.h}:${parts.m}`;\r\n return `${parts.h}:${parts.m}:${parts.s}`;\r\n}\r\n\r\nfunction applyTimeToDate(base: Date, parts: TimeParts): Date {\r\n const d = new Date(base);\r\n d.setHours(Number(parts.h), Number(parts.m), Number(parts.s), 0);\r\n return d;\r\n}\r\n\r\nfunction dateToTimeParts(d: Date): TimeParts {\r\n return {\r\n h: d.getHours().toString().padStart(2, '0'),\r\n m: d.getMinutes().toString().padStart(2, '0'),\r\n s: d.getSeconds().toString().padStart(2, '0'),\r\n };\r\n}\r\n\r\nfunction formatDateDisplay(d: Date, showTime: boolean, fmt: TimeFormat): string {\r\n const datePart = format(d, 'dd/MM/yyyy');\r\n if (!showTime) return datePart;\r\n if (fmt === 'HH') return `${datePart} ${format(d, 'HH')}h`;\r\n if (fmt === 'HH:mm') return `${datePart} ${format(d, 'HH:mm')}`;\r\n return `${datePart} ${format(d, 'HH:mm:ss')}`;\r\n}\r\n\r\nfunction padOptions(count: number) {\r\n return Array.from({ length: count }, (_, i) => ({\r\n label: i.toString().padStart(2, '0'),\r\n value: i.toString().padStart(2, '0'),\r\n }));\r\n}\r\n\r\nconst hoursOptions = padOptions(24);\r\nconst minutesOptions = padOptions(60);\r\nconst secondsOptions = padOptions(60);\r\n\r\n// ---------- styles ----------\r\n\r\nconst popoverContent = tv({\r\n base: 'z-50 rounded-xl border border-border bg-background text-foreground shadow-xl outline-none data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n});\r\n\r\n// ---------- sub-components ----------\r\n\r\ninterface NativeSelectProps {\r\n value: string;\r\n options: { label: string; value: string }[];\r\n onChange: (val: string) => void;\r\n 'aria-label'?: string;\r\n}\r\n\r\nconst NativeScrollSelect: React.FC<NativeSelectProps> = ({ value, options, onChange, 'aria-label': ariaLabel }) => (\r\n <select\r\n aria-label={ariaLabel}\r\n value={value}\r\n onChange={(e) => onChange(e.target.value)}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n >\r\n {options.map((o) => (\r\n <option key={o.value} value={o.value}>{o.label}</option>\r\n ))}\r\n </select>\r\n);\r\n\r\ninterface TimePickerProps {\r\n parts: TimeParts;\r\n onChange: (parts: TimeParts) => void;\r\n timeFormat: TimeFormat;\r\n timePickerStyle: TimePickerStyle;\r\n}\r\n\r\nconst TimePicker: React.FC<TimePickerProps> = ({ parts, onChange, timeFormat, timePickerStyle }) => {\r\n const showMinutes = timeFormat === 'HH:mm' || timeFormat === 'HH:mm:ss';\r\n const showSeconds = timeFormat === 'HH:mm:ss';\r\n\r\n if (timePickerStyle === 'input') {\r\n const inputType = timeFormat === 'HH:mm:ss' ? 'time' : 'time';\r\n\r\n // Native time input — giả lập với step\r\n const step = showSeconds ? 1 : 60;\r\n const rawValue = showSeconds\r\n ? `${parts.h}:${parts.m}:${parts.s}`\r\n : `${parts.h}:${parts.m}`;\r\n\r\n return (\r\n <input\r\n type=\"time\"\r\n value={rawValue}\r\n step={step}\r\n onChange={(e) => {\r\n const [h = '00', m = '00', s = '00'] = e.target.value.split(':');\r\n onChange({ h: h.padStart(2, '0'), m: m.padStart(2, '0'), s: s.padStart(2, '0') });\r\n }}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n />\r\n );\r\n }\r\n\r\n return (\r\n <div className=\"flex items-center gap-1.5\">\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Hours\"\r\n value={parts.h}\r\n options={hoursOptions}\r\n onChange={(val) => onChange({ ...parts, h: val })}\r\n />\r\n </div>\r\n {showMinutes && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Minutes\"\r\n value={parts.m}\r\n options={minutesOptions}\r\n onChange={(val) => onChange({ ...parts, m: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n {showSeconds && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Seconds\"\r\n value={parts.s}\r\n options={secondsOptions}\r\n onChange={(val) => onChange({ ...parts, s: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n );\r\n};\r\n\r\n// ---------- main component ----------\r\n\r\nexport const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(({\r\n mode = 'single',\r\n date,\r\n onDateChange,\r\n onChange,\r\n timeValue,\r\n onTimeChange,\r\n label,\r\n placeholder = 'Select date...',\r\n disablePastDates = false,\r\n showTime = false,\r\n timeFormat = 'HH:mm:ss',\r\n timePickerStyle = 'select',\r\n disabled = false,\r\n className,\r\n description,\r\n error,\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n // Khởi tạo parts từ date prop hoặc timeValue\r\n const initParts = React.useMemo<TimeParts>(() => {\r\n if (mode === 'time-only' && timeValue) return parseTimeParts(timeValue);\r\n if (date instanceof Date) return dateToTimeParts(date);\r\n return DEFAULT_TIME;\r\n }, []);\r\n\r\n const [timeParts, setTimeParts] = React.useState<TimeParts>(initParts);\r\n\r\n // Sync khi prop thay đổi từ ngoài\r\n React.useEffect(() => {\r\n if (mode === 'time-only' && timeValue) {\r\n setTimeParts(parseTimeParts(timeValue));\r\n } else if (date instanceof Date) {\r\n setTimeParts(dateToTimeParts(date));\r\n }\r\n }, [date, timeValue, mode]);\r\n\r\n // Gọi callback khi timeParts thay đổi\r\n const handlePartsChange = (newParts: TimeParts) => {\r\n setTimeParts(newParts);\r\n if (mode === 'time-only') {\r\n onTimeChange?.(buildTimeString(newParts, timeFormat));\r\n return;\r\n }\r\n if (date instanceof Date) {\r\n const newDate = applyTimeToDate(date, newParts);\r\n onDateChange?.(newDate);\r\n onChange?.(newDate);\r\n }\r\n };\r\n\r\n const handleDateSelect = (selectedDate: Date | DateRange | Date[] | undefined) => {\r\n if (!selectedDate) {\r\n onDateChange?.(undefined);\r\n onChange?.(undefined);\r\n return;\r\n }\r\n if (mode === 'single' && showTime && selectedDate instanceof Date) {\r\n const newDate = applyTimeToDate(selectedDate, timeParts);\r\n onDateChange?.(newDate);\r\n onChange?.(newDate);\r\n } else {\r\n // Because of our mode checking, we can be confident here\r\n onDateChange?.(selectedDate as DateRange);\r\n onChange?.(selectedDate as DateRange);\r\n }\r\n };\r\n\r\n // ---------- render trigger label ----------\r\n const triggerLabel = React.useMemo(() => {\r\n if (mode === 'time-only') {\r\n const val = timeValue ?? buildTimeString(timeParts, timeFormat);\r\n if (!val || val === '00' || val === '00:00' || val === '00:00:00')\r\n return <span className=\"text-muted-foreground\">{placeholder || 'Select time...'}</span>;\r\n return <span>{val}</span>;\r\n }\r\n\r\n if (!date) return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n\r\n if (mode === 'single' && date instanceof Date) {\r\n return <span>{formatDateDisplay(date, showTime, timeFormat)}</span>;\r\n }\r\n\r\n if (mode === 'range') {\r\n const range = date as DateRange;\r\n if (range.from && range.to) {\r\n return (\r\n <span>\r\n {format(range.from, 'dd/MM/yyyy')} – {format(range.to, 'dd/MM/yyyy')}\r\n </span>\r\n );\r\n }\r\n if (range.from) return <span>{format(range.from, 'dd/MM/yyyy')} –</span>;\r\n }\r\n\r\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n }, [date, mode, showTime, timeFormat, timeValue, timeParts, placeholder]);\r\n\r\n const isTimeMode = mode === 'time-only';\r\n const needsTimePicker = isTimeMode || (mode === 'single' && showTime);\r\n\r\n return (\r\n <div ref={ref} className={`flex flex-col gap-1.5 w-full ${className || ''}`}>\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground leading-none\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n <BasePopover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>\r\n <BasePopover.Trigger\r\n render={\r\n <button\r\n ref={triggerRef}\r\n type=\"button\"\r\n disabled={disabled}\r\n className={[\r\n 'flex h-10 w-full items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm',\r\n 'ring-offset-background transition-shadow',\r\n 'hover:border-primary focus:border-primary focus:outline-none',\r\n 'disabled:cursor-not-allowed disabled:opacity-50',\r\n error ? 'border-danger focus:border-danger' : 'border-border',\r\n 'group',\r\n ].join(' ')}\r\n >\r\n {isTimeMode ? (\r\n <Clock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n ) : (\r\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n )}\r\n <div className=\"flex-1 truncate text-left\">{triggerLabel}</div>\r\n <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-open:rotate-180\" />\r\n </button>\r\n }\r\n />\r\n\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner anchor={triggerRef} sideOffset={6} className=\"z-50\">\r\n <BasePopover.Popup className={popoverContent()}>\r\n {!isTimeMode && mode === 'single' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"single\"\r\n locale={locales.vi}\r\n selected={date as Date | undefined}\r\n onSelect={(d) => handleDateSelect(d)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n {!isTimeMode && mode === 'range' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"range\"\r\n locale={locales.vi}\r\n selected={date as DateRange | undefined}\r\n onSelect={(d) => handleDateSelect(d)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n\r\n {/* Time picker */}\r\n {needsTimePicker && (\r\n <div className={`border-t border-border p-3 flex flex-col gap-2 ${isTimeMode ? 'border-t-0' : ''}`}>\r\n <div className=\"flex items-center gap-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide\">\r\n <Clock className=\"w-3.5 h-3.5\" />\r\n <span>\r\n {timeFormat === 'HH' ? 'Select hour' : timeFormat === 'HH:mm' ? 'Hour : Minute' : 'Hour : Minute : Second'}\r\n </span>\r\n </div>\r\n <TimePicker\r\n parts={timeParts}\r\n onChange={handlePartsChange}\r\n timeFormat={timeFormat}\r\n timePickerStyle={timePickerStyle}\r\n />\r\n </div>\r\n )}\r\n\r\n {/* Footer actions */}\r\n <div className=\"flex items-center justify-between gap-2 p-3 border-t border-border\">\r\n <button\r\n type=\"button\"\r\n onClick={() => {\r\n if (mode === 'time-only') {\r\n setTimeParts(DEFAULT_TIME);\r\n onTimeChange?.('');\r\n } else {\r\n onDateChange?.(undefined);\r\n }\r\n }}\r\n className=\"text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline\"\r\n >\r\n Clear\r\n </button>\r\n <Button size=\"sm\" onClick={() => setOpen(false)}>\r\n Confirm\r\n </Button>\r\n </div>\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n});\r\n\r\nDatePicker.displayName = \"DatePicker\";\r\n"
201
262
  }
202
263
  ]
203
264
  },
@@ -212,7 +273,7 @@
212
273
  "files": [
213
274
  {
214
275
  "path": "src/components/ui/dialog/Dialog.tsx",
215
- "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: 'fixed inset-0! z-50 bg-black/30 backdrop-blur-sm data-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0',\r\n content: '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-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0 data-ending:zoom-out-95 data-starting: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: '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-starting:bg-accent data-starting: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: '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\nconst { overlay, content, header, footer, title, description, close } = dialogVariants();\r\n\r\nexport interface DialogProps extends React.ComponentPropsWithoutRef<typeof BaseDialog.Root>, VariantProps<typeof dialogVariants> {\r\n trigger?: React.ReactNode;\r\n headerTitle?: string;\r\n headerDescription?: string;\r\n children?: React.ReactNode;\r\n footerContent?: React.ReactNode;\r\n contentClassName?: string;\r\n}\r\n\r\nconst Dialog = React.forwardRef<React.ElementRef<typeof BaseDialog.Root>, DialogProps>(\r\n ({ trigger, headerTitle, headerDescription, children, footerContent, size, contentClassName, ...props }, ref) => {\r\n const slots = dialogVariants({ size });\r\n\r\n return (\r\n <BaseDialog.Root {...props}>\r\n {trigger && <BaseDialog.Trigger render={trigger as React.ReactElement} />}\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup className={slots.content({ className: contentClassName })}>\r\n {(headerTitle || headerDescription) && (\r\n <div className={slots.header()}>\r\n {headerTitle && <BaseDialog.Title className={slots.title()}>{headerTitle}</BaseDialog.Title>}\r\n {headerDescription && <BaseDialog.Description className={slots.description()}>{headerDescription}</BaseDialog.Description>}\r\n </div>\r\n )}\r\n {children}\r\n {footerContent && <div className={slots.footer()}>{footerContent}</div>}\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 </BaseDialog.Root>\r\n );\r\n }\r\n);\r\n\r\nDialog.displayName = 'Dialog';\r\n\r\nexport { Dialog };\r\n"
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-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting: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-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0 data-ending:zoom-out-95 data-starting: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-starting:bg-accent data-starting: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"
216
277
  }
217
278
  ]
218
279
  },
@@ -227,7 +288,22 @@
227
288
  "files": [
228
289
  {
229
290
  "path": "src/components/ui/drawer/Drawer.tsx",
230
- "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\nimport { cn } from '@lib/utils/cn';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay: 'fixed inset-0 z-50 bg-black/40 backdrop-blur-sm 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: '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: '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: '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: '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: '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: '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 },\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-[480px]' } },\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-[480px]' } },\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-[480px]' } },\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-[480px]' } },\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 },\r\n});\r\n\r\nexport interface DrawerProps extends VariantProps<typeof drawerVariants> {\r\n open?: boolean;\r\n onOpenChange?: (open: boolean) => void;\r\n trigger?: React.ReactNode;\r\n title?: string;\r\n description?: string;\r\n children?: React.ReactNode;\r\n footerContent?: React.ReactNode;\r\n hideClose?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst Drawer = React.forwardRef<HTMLDivElement, DrawerProps>(({\r\n open: controlledOpen,\r\n onOpenChange,\r\n trigger,\r\n title,\r\n description,\r\n children,\r\n footerContent,\r\n direction = 'right',\r\n size = 'md',\r\n hideClose = false,\r\n className,\r\n}, ref) => {\r\n const [internalOpen, setInternalOpen] = React.useState(false);\r\n const isControlled = controlledOpen !== undefined;\r\n const isOpen = isControlled ? controlledOpen : internalOpen;\r\n\r\n const handleOpenChange = (val: boolean) => {\r\n if (!isControlled) setInternalOpen(val);\r\n onOpenChange?.(val);\r\n };\r\n\r\n const slots = drawerVariants({ direction, size });\r\n\r\n return (\r\n <BaseDialog.Root open={isOpen} onOpenChange={handleOpenChange}>\r\n {trigger && (\r\n <BaseDialog.Trigger\r\n render={<span style={{ display: 'contents' }}>{trigger}</span>}\r\n />\r\n )}\r\n\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={cn(slots.panel({ className }))}>\r\n {/* Header */}\r\n {(title || description || !hideClose) && (\r\n <div className={slots.header()}>\r\n <div>\r\n {title && <BaseDialog.Title className={slots.title()}>{title}</BaseDialog.Title>}\r\n {description && <BaseDialog.Description className={slots.description()}>{description}</BaseDialog.Description>}\r\n </div>\r\n {!hideClose && (\r\n <BaseDialog.Close\r\n className={slots.close()}\r\n aria-label=\"Đóng\"\r\n >\r\n <X className=\"h-4 w-4\" />\r\n </BaseDialog.Close>\r\n )}\r\n </div>\r\n )}\r\n\r\n {/* Body */}\r\n <div className={slots.body()}>{children}</div>\r\n\r\n {/* Footer */}\r\n {footerContent && (\r\n <div className={slots.footer()}>{footerContent}</div>\r\n )}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n </BaseDialog.Root>\r\n );\r\n});\r\n\r\nDrawer.displayName = 'Drawer';\r\n\r\nexport { Drawer };\r\n"
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 backdrop-blur-sm 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 },\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-[480px]' } },\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-[480px]' } },\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-[480px]' } },\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-[480px]' } },\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 },\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, ...props }, ref) => {\r\n const slots = drawerVariants({ direction, size });\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
+ }
293
+ ]
294
+ },
295
+ "dropdown-menu": {
296
+ "name": "dropdown-menu",
297
+ "dependencies": [
298
+ "@base-ui/react",
299
+ "tailwind-variants",
300
+ "lucide-react"
301
+ ],
302
+ "internalDependencies": [],
303
+ "files": [
304
+ {
305
+ "path": "src/components/ui/dropdown-menu/DropdownMenu.tsx",
306
+ "content": "import * as React from 'react';\r\nimport { Menu as BaseMenu } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Check, ChevronRight, Circle } from 'lucide-react';\r\n\r\nconst dropdownMenuVariants = tv({\r\n slots: {\r\n content:\r\n '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 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 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 data-disabled:pointer-events-none data-disabled:opacity-50 [&_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 data-disabled:pointer-events-none data-disabled:opacity-50',\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 data-disabled:pointer-events-none data-disabled:opacity-50',\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 subTrigger:\r\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-open:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n subContent:\r\n 'z-50 min-w-[8rem] 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 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 indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nconst styles = dropdownMenuVariants();\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenu = BaseMenu.Root;\r\n\r\n/* ─── Trigger ───────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuTriggerProps\r\n extends React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger> { }\r\n\r\nconst DropdownMenuTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n DropdownMenuTriggerProps\r\n>(({ ...props }, ref) => <BaseMenu.Trigger ref={ref as React.Ref<HTMLButtonElement>} {...props} />);\r\nDropdownMenuTrigger.displayName = 'DropdownMenuTrigger';\r\n\r\n/* ─── Content ───────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuContent component */\r\nexport interface DropdownMenuContentProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n /** Which side of the trigger to render the menu */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger */\r\n align?: 'start' | 'center' | 'end';\r\n /** Distance in px between the trigger and the menu */\r\n sideOffset?: number;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuContentProps\r\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuContent.displayName = 'DropdownMenuContent';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuItem component */\r\nexport interface DropdownMenuItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Item>,\r\n DropdownMenuItemProps\r\n>(({ className, inset, ...props }, ref) => (\r\n <BaseMenu.Item\r\n ref={ref}\r\n className={styles.item({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n />\r\n));\r\nDropdownMenuItem.displayName = 'DropdownMenuItem';\r\n\r\n/* ─── CheckboxItem ──────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuCheckboxItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.CheckboxItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuCheckboxItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.CheckboxItem>,\r\n DropdownMenuCheckboxItemProps\r\n>(({ className, children, checked, ...props }, ref) => (\r\n <BaseMenu.CheckboxItem\r\n ref={ref}\r\n className={styles.checkboxItem({ className })}\r\n checked={checked}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.CheckboxItemIndicator>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseMenu.CheckboxItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.CheckboxItem>\r\n));\r\nDropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';\r\n\r\n/* ─── RadioGroup ────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuRadioGroup = BaseMenu.RadioGroup;\r\n\r\n/* ─── RadioItem ─────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuRadioItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.RadioItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuRadioItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.RadioItem>,\r\n DropdownMenuRadioItemProps\r\n>(({ className, children, ...props }, ref) => (\r\n <BaseMenu.RadioItem ref={ref} className={styles.radioItem({ className })} {...props}>\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.RadioItemIndicator>\r\n <Circle className=\"h-2 w-2 fill-current\" />\r\n </BaseMenu.RadioItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.RadioItem>\r\n));\r\nDropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';\r\n\r\n/* ─── Label ─────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuLabel component */\r\nexport interface DropdownMenuLabelProps extends React.ComponentPropsWithoutRef<'div'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n}\r\n\r\nconst DropdownMenuLabel = React.forwardRef<HTMLDivElement, DropdownMenuLabelProps>(\r\n ({ className, inset, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })} {...props} />\r\n )\r\n);\r\nDropdownMenuLabel.displayName = 'DropdownMenuLabel';\r\n\r\n/* ─── Separator ─────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuSeparatorProps extends React.ComponentPropsWithoutRef<'div'> { }\r\n\r\nconst DropdownMenuSeparator = React.forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nDropdownMenuSeparator.displayName = 'DropdownMenuSeparator';\r\n\r\n/* ─── Shortcut ──────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span className={styles.shortcut({ className })} {...props} />\r\n);\r\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\r\n\r\n/* ─── Sub ───────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuSub = BaseMenu.SubmenuRoot;\r\n\r\n/** Props for the DropdownMenuSubTrigger component */\r\nexport interface DropdownMenuSubTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubTrigger = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\r\n DropdownMenuSubTriggerProps\r\n>(({ className, inset, children, ...props }, ref) => (\r\n <BaseMenu.SubmenuTrigger\r\n ref={ref}\r\n className={styles.subTrigger({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n {children}\r\n <ChevronRight className=\"ml-auto\" />\r\n </BaseMenu.SubmenuTrigger>\r\n));\r\nDropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';\r\n\r\nexport interface DropdownMenuSubContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuSubContentProps\r\n>(({ className, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner sideOffset={-4}>\r\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuSubContent.displayName = 'DropdownMenuSubContent';\r\n\r\n/* ─── Group ─────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuGroup = BaseMenu.Group;\r\n\r\nexport {\r\n DropdownMenu,\r\n DropdownMenuTrigger,\r\n DropdownMenuContent,\r\n DropdownMenuItem,\r\n DropdownMenuCheckboxItem,\r\n DropdownMenuRadioGroup,\r\n DropdownMenuRadioItem,\r\n DropdownMenuLabel,\r\n DropdownMenuSeparator,\r\n DropdownMenuShortcut,\r\n DropdownMenuSub,\r\n DropdownMenuSubTrigger,\r\n DropdownMenuSubContent,\r\n DropdownMenuGroup,\r\n dropdownMenuVariants,\r\n};\r\n"
231
307
  }
232
308
  ]
233
309
  },
@@ -240,7 +316,7 @@
240
316
  "files": [
241
317
  {
242
318
  "path": "src/components/ui/form/Form.tsx",
243
- "content": "import * as React from 'react';\nimport { cn } from '@lib/utils/cn';\nimport {\n Controller,\n FormProvider,\n useFormContext,\n} from 'react-hook-form';\nimport type {\n ControllerProps,\n FieldPath,\n FieldValues,\n} from 'react-hook-form';\n\nconst Form = FormProvider;\n\ntype FormFieldContextValue<\n TFieldValues extends FieldValues = FieldValues,\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n> = {\n name: TName;\n};\n\nconst FormFieldContext = React.createContext<FormFieldContextValue | null>(null);\n\nconst FormField = <\n TFieldValues extends FieldValues = FieldValues,\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\n>({\n ...props\n}: ControllerProps<TFieldValues, TName>) => {\n return (\n <FormFieldContext.Provider value={{ name: props.name }}>\n <Controller {...props} />\n </FormFieldContext.Provider>\n );\n};\n\nconst useFormField = () => {\n const fieldContext = React.useContext(FormFieldContext);\n const itemContext = React.useContext(FormItemContext);\n const { getFieldState, formState } = useFormContext();\n\n if (!fieldContext) {\n throw new Error('useFormField must be used within <FormField>');\n }\n\n const fieldState = getFieldState(fieldContext.name, formState);\n\n if (!itemContext) {\n throw new Error('useFormField should be used within <FormItem>');\n }\n\n const { id } = itemContext;\n\n return {\n id,\n name: fieldContext.name,\n formItemId: `${id}-form-item`,\n formDescriptionId: `${id}-form-item-description`,\n formMessageId: `${id}-form-item-message`,\n ...fieldState,\n };\n};\n\ntype FormItemContextValue = {\n id: string;\n};\n\nconst FormItemContext = React.createContext<FormItemContextValue | null>(null);\n\nconst FormItem = React.forwardRef<\n HTMLDivElement,\n React.HTMLAttributes<HTMLDivElement>\n>(({ className, ...props }, ref) => {\n const id = React.useId();\n\n return (\n <FormItemContext.Provider value={{ id }}>\n <div ref={ref} className={cn('space-y-2', className)} {...props} />\n </FormItemContext.Provider>\n );\n});\nFormItem.displayName = 'FormItem';\n\nconst FormLabel = React.forwardRef<\n HTMLLabelElement,\n React.LabelHTMLAttributes<HTMLLabelElement>\n>(({ className, ...props }, ref) => {\n const { formItemId } = useFormField();\n\n return (\n <label\n ref={ref}\n htmlFor={formItemId}\n className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}\n {...props}\n />\n );\n});\nFormLabel.displayName = 'FormLabel';\n\nconst FormControl = React.forwardRef<\n React.ElementRef<'div'>,\n React.HTMLAttributes<HTMLDivElement>\n>(({ ...props }, ref) => {\n const { error, formItemId, formDescriptionId, formMessageId } = useFormField();\n\n return (\n <div\n ref={ref}\n id={formItemId}\n aria-describedby={\n !error\n ? `${formDescriptionId}`\n : `${formDescriptionId} ${formMessageId}`\n }\n aria-invalid={!!error}\n {...props}\n />\n );\n});\nFormControl.displayName = 'FormControl';\n\nconst FormDescription = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, ...props }, ref) => {\n const { formDescriptionId } = useFormField();\n\n return (\n <p\n ref={ref}\n id={formDescriptionId}\n className={cn('text-[0.8rem] text-muted-foreground', className)}\n {...props}\n />\n );\n});\nFormDescription.displayName = 'FormDescription';\n\nconst FormMessage = React.forwardRef<\n HTMLParagraphElement,\n React.HTMLAttributes<HTMLParagraphElement>\n>(({ className, children, ...props }, ref) => {\n const { error, formMessageId } = useFormField();\n const body = error ? String(error?.message) : children;\n\n if (!body) {\n return null;\n }\n\n return (\n <p\n ref={ref}\n id={formMessageId}\n className={cn('text-[0.8rem] font-medium text-danger', className)}\n {...props}\n >\n {body}\n </p>\n );\n});\nFormMessage.displayName = 'FormMessage';\n\nexport {\n useFormField,\n Form,\n FormItem,\n FormLabel,\n FormControl,\n FormDescription,\n FormMessage,\n FormField,\n};\n"
319
+ "content": "import * as React from 'react';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport {\r\n Controller,\r\n FormProvider,\r\n useFormContext,\r\n} from 'react-hook-form';\r\nimport type {\r\n ControllerProps,\r\n FieldPath,\r\n FieldValues,\r\n} from 'react-hook-form';\r\n\r\nconst Form = FormProvider;\r\n\r\ntype FormFieldContextValue<\r\n TFieldValues extends FieldValues = FieldValues,\r\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\r\n> = {\r\n name: TName;\r\n};\r\n\r\nconst FormFieldContext = React.createContext<FormFieldContextValue | null>(null);\r\n\r\n/** Connects a form field to react-hook-form via Controller and provides field context */\r\nconst FormField = <\r\n TFieldValues extends FieldValues = FieldValues,\r\n TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>\r\n>({\r\n ...props\r\n}: ControllerProps<TFieldValues, TName>) => {\r\n return (\r\n <FormFieldContext.Provider value={{ name: props.name }}>\r\n <Controller {...props} />\r\n </FormFieldContext.Provider>\r\n );\r\n};\r\n\r\nconst useFormField = () => {\r\n const fieldContext = React.useContext(FormFieldContext);\r\n const itemContext = React.useContext(FormItemContext);\r\n const { getFieldState, formState } = useFormContext();\r\n\r\n if (!fieldContext) {\r\n throw new Error('useFormField must be used within <FormField>');\r\n }\r\n\r\n const fieldState = getFieldState(fieldContext.name, formState);\r\n\r\n if (!itemContext) {\r\n throw new Error('useFormField should be used within <FormItem>');\r\n }\r\n\r\n const { id } = itemContext;\r\n\r\n return {\r\n id,\r\n name: fieldContext.name,\r\n formItemId: `${id}-form-item`,\r\n formDescriptionId: `${id}-form-item-description`,\r\n formMessageId: `${id}-form-item-message`,\r\n ...fieldState,\r\n };\r\n};\r\n\r\ntype FormItemContextValue = {\r\n id: string;\r\n};\r\n\r\nconst FormItemContext = React.createContext<FormItemContextValue | null>(null);\r\n\r\n/** Container for a single form field; provides a unique ID via context */\r\nconst FormItem = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const id = React.useId();\r\n\r\n return (\r\n <FormItemContext.Provider value={{ id }}>\r\n <div ref={ref} className={cn('space-y-2', className)} {...props} />\r\n </FormItemContext.Provider>\r\n );\r\n});\r\nFormItem.displayName = 'FormItem';\r\n\r\n/** Label that auto-associates with its parent FormItem's control via htmlFor */\r\nconst FormLabel = React.forwardRef<\r\n HTMLLabelElement,\r\n React.LabelHTMLAttributes<HTMLLabelElement>\r\n>(({ className, ...props }, ref) => {\r\n const { formItemId } = useFormField();\r\n\r\n return (\r\n <label\r\n ref={ref}\r\n htmlFor={formItemId}\r\n className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}\r\n {...props}\r\n />\r\n );\r\n});\r\nFormLabel.displayName = 'FormLabel';\r\n\r\n/** Wrapper for the form input; applies aria-describedby and aria-invalid attributes */\r\nconst FormControl = React.forwardRef<\r\n React.ElementRef<'div'>,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ ...props }, ref) => {\r\n const { error, formItemId, formDescriptionId, formMessageId } = useFormField();\r\n\r\n return (\r\n <div\r\n ref={ref}\r\n id={formItemId}\r\n aria-describedby={\r\n !error\r\n ? `${formDescriptionId}`\r\n : `${formDescriptionId} ${formMessageId}`\r\n }\r\n aria-invalid={!!error}\r\n {...props}\r\n />\r\n );\r\n});\r\nFormControl.displayName = 'FormControl';\r\n\r\n/** Helper text displayed below a form control */\r\nconst FormDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n React.HTMLAttributes<HTMLParagraphElement>\r\n>(({ className, ...props }, ref) => {\r\n const { formDescriptionId } = useFormField();\r\n\r\n return (\r\n <p\r\n ref={ref}\r\n id={formDescriptionId}\r\n className={cn('text-[0.8rem] text-muted-foreground', className)}\r\n {...props}\r\n />\r\n );\r\n});\r\nFormDescription.displayName = 'FormDescription';\r\n\r\n/** Displays the field's validation error message, or custom children as fallback */\r\nconst FormMessage = React.forwardRef<\r\n HTMLParagraphElement,\r\n React.HTMLAttributes<HTMLParagraphElement>\r\n>(({ className, children, ...props }, ref) => {\r\n const { error, formMessageId } = useFormField();\r\n const body = error ? String(error?.message) : children;\r\n\r\n if (!body) {\r\n return null;\r\n }\r\n\r\n return (\r\n <p\r\n ref={ref}\r\n id={formMessageId}\r\n className={cn('text-[0.8rem] font-medium text-danger', className)}\r\n {...props}\r\n >\r\n {body}\r\n </p>\r\n );\r\n});\r\nFormMessage.displayName = 'FormMessage';\r\n\r\nexport {\r\n useFormField,\r\n Form,\r\n FormItem,\r\n FormLabel,\r\n FormControl,\r\n FormDescription,\r\n FormMessage,\r\n FormField,\r\n};\r\n"
244
320
  }
245
321
  ]
246
322
  },
@@ -256,26 +332,21 @@
256
332
  "files": [
257
333
  {
258
334
  "path": "src/components/ui/input/Input.tsx",
259
- "content": "import * as React from 'react';\r\nimport { Input as BaseInput, Field as BaseField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport * as Icon from \"@components/ui/icons\";\r\nimport { Toggle } from '@/components/ui/toggle/Toggle';\r\n\r\nconst inputVariants = tv({\r\n base: 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\r\n variants: {\r\n variant: {\r\n default: '',\r\n filled: 'bg-accent border-transparent focus:border-primary',\r\n flushed: 'border-b-2 border-transparent border-b-border rounded-none px-0 focus:outline-none focus:ring-0 focus:border-transparent focus:border-b-primary bg-transparent',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\nexport interface InputProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseInput>, 'className'>, VariantProps<typeof inputVariants> {\r\n label?: string;\r\n error?: string;\r\n description?: string;\r\n icon?: React.ReactNode;\r\n endIcon?: React.ReactNode;\r\n placeholder?: string;\r\n className?: string;\r\n}\r\n\r\nconst Input = React.forwardRef<React.ElementRef<typeof BaseInput>, InputProps>(\r\n ({ className, variant, label, error, description, icon, endIcon, id, type, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const inputId = id || defaultId;\r\n const [showPassword, setShowPassword] = React.useState(false);\r\n\r\n const isPassword = type === 'password';\r\n const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;\r\n\r\n return (\r\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <BaseField.Label htmlFor={inputId} className=\"text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\r\n {label}\r\n </BaseField.Label>\r\n )}\r\n <div className=\"relative\">\r\n {icon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {icon}\r\n </div>\r\n )}\r\n <BaseField.Control render={<BaseInput\r\n ref={ref}\r\n id={inputId}\r\n type={inputType}\r\n className={cn(\r\n inputVariants({ variant }),\r\n icon && 'pl-9',\r\n (isPassword || endIcon) && 'pr-10',\r\n error && 'border-danger focus:border-danger',\r\n className\r\n )}\r\n {...props}\r\n />} />\r\n {isPassword ? (\r\n <Toggle\r\n type=\"button\"\r\n variant=\"ghost\"\r\n size=\"icon\"\r\n className=\"absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground\"\r\n pressed={showPassword}\r\n onPressedChange={setShowPassword}\r\n aria-label={showPassword ? 'Ẩn mật khẩu' : 'Hiện mật khẩu'}\r\n >\r\n {showPassword ? <Icon.EyeOff className=\"h-4 w-4\" /> : <Icon.Eye className=\"h-4 w-4\" />}\r\n </Toggle>\r\n ) : endIcon ? (\r\n <div className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {endIcon}\r\n </div>\r\n ) : null}\r\n </div>\r\n {description && !error && (\r\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\r\n {description}\r\n </BaseField.Description>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">\r\n {error}\r\n </p>\r\n )}\r\n </BaseField.Root>\r\n );\r\n }\r\n);\r\nInput.displayName = 'Input';\r\n\r\nexport { Input };\r\n"
335
+ "content": "import * as React from 'react';\r\nimport { Input as BaseInput, Field as BaseField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport * as Icon from \"@/components/ui/icons\";\r\nimport { Toggle } from '@/components/ui/toggle/Toggle';\r\n\r\nconst inputVariants = tv({\r\n base: 'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\r\n variants: {\r\n variant: {\r\n default: '',\r\n filled: 'bg-accent border-transparent focus:border-primary',\r\n flushed: 'border-b-2 border-transparent border-b-border rounded-none px-0 focus:outline-none focus:ring-0 focus:border-transparent focus:border-b-primary bg-transparent',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\n/** Props for the Input component */\r\nexport interface InputProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseInput>, 'className'>, VariantProps<typeof inputVariants> {\r\n /** Label text displayed above the input */\r\n label?: string;\r\n /** Error message displayed below the input; also applies danger styling */\r\n error?: string;\r\n /** Helper text displayed below the input (hidden when error is present) */\r\n description?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n icon?: React.ReactNode;\r\n /** Icon rendered at the end (right side) of the input; ignored for password type */\r\n endIcon?: React.ReactNode;\r\n placeholder?: string;\r\n className?: string;\r\n}\r\n\r\nconst Input = React.forwardRef<React.ElementRef<typeof BaseInput>, InputProps>(\r\n ({ className, variant, label, error, description, icon, endIcon, id, type, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const inputId = id || defaultId;\r\n const [showPassword, setShowPassword] = React.useState(false);\r\n\r\n const isPassword = type === 'password';\r\n const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;\r\n\r\n return (\r\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <BaseField.Label htmlFor={inputId} className=\"text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\r\n {label}\r\n </BaseField.Label>\r\n )}\r\n <div className=\"relative\">\r\n {icon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {icon}\r\n </div>\r\n )}\r\n <BaseField.Control render={<BaseInput\r\n ref={ref}\r\n id={inputId}\r\n type={inputType}\r\n className={cn(\r\n inputVariants({ variant }),\r\n icon && 'pl-9',\r\n (isPassword || endIcon) && 'pr-10',\r\n error && 'border-danger focus:border-danger',\r\n className\r\n )}\r\n {...props}\r\n />} />\r\n {isPassword ? (\r\n <Toggle\r\n type=\"button\"\r\n variant=\"ghost\"\r\n size=\"icon\"\r\n className=\"absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground\"\r\n pressed={showPassword}\r\n onPressedChange={setShowPassword}\r\n aria-label={showPassword ? 'Hide password' : 'Show password'}\r\n >\r\n {showPassword ? <Icon.EyeOff className=\"h-4 w-4\" /> : <Icon.Eye className=\"h-4 w-4\" />}\r\n </Toggle>\r\n ) : endIcon ? (\r\n <div className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {endIcon}\r\n </div>\r\n ) : null}\r\n </div>\r\n {description && !error && (\r\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\r\n {description}\r\n </BaseField.Description>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">\r\n {error}\r\n </p>\r\n )}\r\n </BaseField.Root>\r\n );\r\n }\r\n);\r\nInput.displayName = 'Input';\r\n\r\nexport { Input };\r\n"
260
336
  }
261
337
  ]
262
338
  },
263
- "layout": {
264
- "name": "layout",
265
- "dependencies": [],
339
+ "pagination": {
340
+ "name": "pagination",
341
+ "dependencies": [
342
+ "tailwind-variants",
343
+ "lucide-react"
344
+ ],
266
345
  "internalDependencies": [],
267
346
  "files": [
268
347
  {
269
- "path": "src/components/ui/layout/components/Route.tsx",
270
- "content": ""
271
- },
272
- {
273
- "path": "src/components/ui/layout/DashboardLayout.tsx",
274
- "content": "import * as React from 'react';\r\nimport { Outlet, useLocation, useNavigate } from 'react-router-dom';\r\nimport { ComboBox } from '../combobox/ComboBox';\r\nimport { flattenSearchableRoutes } from '../../../../routes';\r\n\r\nimport * as Icon from \"@components/ui/icons\";\r\nimport { cn } from '@lib/utils/cn';\r\nimport { ThemeToggle } from '../ThemeToggle';\r\nimport { Tooltip } from '../tooltip/Tooltip';\r\nimport {\r\n SidebarProvider,\r\n Sidebar,\r\n SidebarRail,\r\n SidebarHeader,\r\n SidebarContent,\r\n SidebarFooter,\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarNavLink,\r\n SidebarMenuCollapsible,\r\n SidebarSeparator,\r\n SidebarTrigger,\r\n SidebarInset,\r\n UserMenuPopover,\r\n UserMenuItem,\r\n useSidebar,\r\n} from '../sidebar/Sidebar';\r\nimport { ROUTES } from '../../../../routes';\r\n\r\n// ─── SidebarNavItem ──────────────────────────────────────────────────────────\r\n\r\ninterface SidebarNavItemProps {\r\n route: any;\r\n parentPath?: string;\r\n isCollapsed: boolean;\r\n}\r\n\r\nconst SidebarNavItem: React.FC<SidebarNavItemProps> = ({ route, parentPath = '', isCollapsed }) => {\r\n const location = useLocation();\r\n \r\n // Tính toán path tuyệt đối\r\n const absolutePath = [parentPath, route.prefix, route.path]\r\n .filter(Boolean)\r\n .join('/')\r\n .replace(/\\/+/g, '/');\r\n\r\n const hasChildren = route.children && route.children.length > 0;\r\n\r\n // Kiểm tra xem có node con nào đang active không (để tự động mở group)\r\n const isAnyChildActive = React.useMemo(() => {\r\n if (!hasChildren) return false;\r\n const checkActive = (items: any[]): boolean => {\r\n return items.some(item => {\r\n const itemAbsPath = [absolutePath, item.prefix, item.path]\r\n .filter(Boolean)\r\n .join('/')\r\n .replace(/\\/+/g, '/');\r\n if (location.pathname === itemAbsPath) return true;\r\n if (item.children) return checkActive(item.children);\r\n return false;\r\n });\r\n };\r\n return checkActive(route.children);\r\n }, [hasChildren, route.children, absolutePath, location.pathname]);\r\n\r\n if (hasChildren) {\r\n return (\r\n <SidebarMenuItem>\r\n <SidebarMenuCollapsible\r\n id={route.label}\r\n icon={route.icon}\r\n label={route.label}\r\n isChildActive={isAnyChildActive}\r\n >\r\n {route.children.map((child: any, index: number) => (\r\n <SidebarNavItem \r\n key={index} \r\n route={child} \r\n parentPath={absolutePath} \r\n isCollapsed={isCollapsed} \r\n />\r\n ))}\r\n </SidebarMenuCollapsible>\r\n </SidebarMenuItem>\r\n );\r\n }\r\n\r\n // Nếu không có children nhưng có element -> Render Link\r\n if (route.element) {\r\n return (\r\n <SidebarMenuItem>\r\n <SidebarNavLink\r\n to={absolutePath === '' ? '/' : absolutePath}\r\n icon={route.icon}\r\n label={route.label}\r\n end={route.end}\r\n size=\"sm\"\r\n badge={\r\n route.badge && !isCollapsed ? (\r\n <span className=\"text-[10px] bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium leading-none\">\r\n {route.badge}\r\n </span>\r\n ) : undefined\r\n }\r\n />\r\n </SidebarMenuItem>\r\n );\r\n }\r\n\r\n return null;\r\n};\r\n\r\n// ─── App Sidebar ──────────────────────────────────────────────────────────────\r\n\r\nconst AppSidebar: React.FC = () => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n const location = useLocation();\r\n\r\n return (\r\n <Sidebar collapsible=\"icon\">\r\n <SidebarRail />\r\n {/* Header */}\r\n <SidebarHeader className=\"border-b border-sidebar-border\">\r\n <SidebarMenu>\r\n <SidebarMenuItem>\r\n <div\r\n className={cn(\r\n 'flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200',\r\n isCollapsed && 'justify-center'\r\n )}\r\n >\r\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm\">\r\n <span className=\"text-primary-foreground font-bold text-sm select-none\">UI</span>\r\n </div>\r\n {!isCollapsed && (\r\n <div className=\"overflow-hidden min-w-0\">\r\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">UI Library</p>\r\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">Component Showcase</p>\r\n </div>\r\n )}\r\n </div>\r\n </SidebarMenuItem>\r\n </SidebarMenu>\r\n </SidebarHeader>\r\n\r\n {/* Content */}\r\n <SidebarContent>\r\n {['overview', 'general', 'forms', 'complex', 'overlays'].map((cat) => {\r\n const catRoutes = ROUTES.filter(r => r.category === cat);\r\n if (catRoutes.length === 0) return null;\r\n\r\n return (\r\n <SidebarGroup key={cat}>\r\n <SidebarGroupLabel className=\"capitalize\">{cat}</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n {catRoutes.map((route, idx) => (\r\n <SidebarNavItem \r\n key={idx} \r\n route={route} \r\n isCollapsed={isCollapsed} \r\n />\r\n ))}\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n <SidebarSeparator className=\"mt-2 opacity-50\" />\r\n </SidebarGroup>\r\n );\r\n })}\r\n </SidebarContent>\r\n\r\n {/* Footer — User menu */}\r\n <SidebarFooter className=\"border-t border-sidebar-border pb-2\">\r\n <SidebarMenu>\r\n <SidebarMenuItem>\r\n <UserMenuPopover\r\n name=\"admin2\"\r\n email=\"admin@example.com\"\r\n avatar=\"https://i.pravatar.cc/100\"\r\n >\r\n <UserMenuItem icon={<Icon.Sparkles className=\"w-4 h-4\" />}>\r\n Upgrade to Pro\r\n </UserMenuItem>\r\n <div className=\"h-px bg-border/50 my-1\" />\r\n <UserMenuItem icon={<Icon.BadgeCheck className=\"w-4 h-4\" />}>\r\n Account\r\n </UserMenuItem>\r\n <UserMenuItem icon={<Icon.BillingIcon className=\"w-4 h-4\" />}>\r\n Billing\r\n </UserMenuItem>\r\n <UserMenuItem icon={<Icon.BellIcon className=\"w-4 h-4\" />}>\r\n Notifications\r\n </UserMenuItem>\r\n <div className=\"h-px bg-border/50 my-1\" />\r\n <UserMenuItem icon={<Icon.LogOut className=\"w-4 h-4\" />} destructive>\r\n Log out\r\n </UserMenuItem>\r\n </UserMenuPopover>\r\n </SidebarMenuItem>\r\n </SidebarMenu>\r\n </SidebarFooter>\r\n </Sidebar>\r\n );\r\n};\r\n\r\n// ─── Header Search ───────────────────────────────────────────────────────────\r\n\r\nconst HeaderSearch: React.FC = () => {\r\n const navigate = useNavigate();\r\n const searchItems = React.useMemo(() => flattenSearchableRoutes(), []);\r\n const inputRef = React.useRef<HTMLInputElement>(null);\r\n\r\n // Ctrl + K to focus search\r\n React.useEffect(() => {\r\n const handleKeyDown = (e: KeyboardEvent) => {\r\n if ((e.ctrlKey || e.metaKey) && e.key === 'k') {\r\n e.preventDefault();\r\n inputRef.current?.focus();\r\n }\r\n };\r\n window.addEventListener('keydown', handleKeyDown);\r\n return () => window.removeEventListener('keydown', handleKeyDown);\r\n }, []);\r\n\r\n return (\r\n <div className=\"flex-1 max-w-sm mx-4 relative group hidden md:block\">\r\n <ComboBox\r\n ref={inputRef}\r\n options={searchItems}\r\n placeholder=\"Tìm kiếm component... (Ctrl + K)\"\r\n leftIcon={<Icon.Search className=\"w-4 h-4\" />}\r\n className=\"w-full h-9 min-h-[36px]\" // Tinh chỉnh chiều cao cho gọn\r\n onValueChange={(val) => {\r\n if (typeof val === 'string') {\r\n navigate(val);\r\n }\r\n }}\r\n />\r\n\r\n <div className=\"absolute right-10 top-1/2 -translate-y-1/2 flex items-center gap-1 px-1.5 py-0.5 rounded border border-border bg-muted/50 text-[10px] font-medium text-muted-foreground pointer-events-none group-focus-within:opacity-0 transition-opacity\">\r\n <span className=\"text-[8px]\">⌘</span>K\r\n </div>\r\n </div>\r\n );\r\n};\r\n\r\n// ─── Header ───────────────────────────────────────────────────────────────────\r\n\r\nconst Header: React.FC = () => {\r\n const location = useLocation();\r\n const segments = location.pathname.replace(/^\\//, '').split('/').filter(Boolean);\r\n\r\n return (\r\n <header className=\"h-[60px] bg-background/95 backdrop-blur-sm border-b border-border/50 flex items-center px-4 gap-3 sticky top-0 z-30\">\r\n <SidebarTrigger />\r\n <div className=\"h-4 w-px bg-border/60\" />\r\n <nav className=\"flex items-center gap-1 text-sm flex-1 min-w-0\">\r\n <span className=\"text-muted-foreground hover:text-foreground transition-colors cursor-default\">\r\n Home\r\n </span>\r\n {segments.map((seg, i) => (\r\n <React.Fragment key={i}>\r\n <Icon.ChevronRight className=\"w-3.5 h-3.5 text-muted-foreground/40 shrink-0\" />\r\n <span\r\n className={\r\n i === segments.length - 1\r\n ? 'text-foreground font-medium capitalize truncate'\r\n : 'text-muted-foreground capitalize'\r\n }\r\n >\r\n {seg.replace(/-/g, ' ')}\r\n </span>\r\n </React.Fragment>\r\n ))}\r\n </nav>\r\n\r\n <HeaderSearch />\r\n\r\n <div className=\"flex items-center gap-2 shrink-0\">\r\n <ThemeToggle />\r\n <img\r\n src=\"https://i.pravatar.cc/100\"\r\n alt=\"avatar\"\r\n className=\"w-8 h-8 rounded-full object-cover border border-border cursor-pointer\"\r\n />\r\n </div>\r\n </header>\r\n );\r\n};\r\n\r\n// ─── DashboardLayout ──────────────────────────────────────────────────────────\r\n\r\nexport const DashboardLayout = React.forwardRef<HTMLDivElement, { children?: React.ReactNode }>(({ children }, ref) => {\r\n return (\r\n <div ref={ref} className=\"h-full w-full\">\r\n <SidebarProvider>\r\n <AppSidebar />\r\n <SidebarInset>\r\n <Header />\r\n <main className=\"flex-1 overflow-y-auto bg-muted/10\">\r\n <div className=\"p-6 h-[calc(100vh-60px)] overflow-auto\">\r\n {children ? children : <Outlet />}\r\n </div>\r\n </main>\r\n </SidebarInset>\r\n </SidebarProvider>\r\n </div>\r\n );\r\n});\r\n\r\nDashboardLayout.displayName = \"DashboardLayout\";\r\n"
275
- },
276
- {
277
- "path": "src/components/ui/layout/LayoutSample.tsx",
278
- "content": "import * as React from 'react';\r\nimport { Outlet, useLocation } from 'react-router-dom';\r\n\r\nimport * as Icon from \"@components/ui/icons\";\r\nimport { cn } from '@lib/utils/cn';\r\nimport { ThemeToggle } from '../ThemeToggle';\r\nimport { Tooltip } from '../tooltip/Tooltip';\r\nimport {\r\n SidebarProvider,\r\n Sidebar,\r\n SidebarRail,\r\n SidebarHeader,\r\n SidebarContent,\r\n SidebarFooter,\r\n SidebarGroup,\r\n SidebarGroupLabel,\r\n SidebarGroupContent,\r\n SidebarMenu,\r\n SidebarMenuItem,\r\n SidebarNavLink,\r\n SidebarMenuCollapsible,\r\n SidebarSeparator,\r\n SidebarTrigger,\r\n SidebarInset,\r\n UserMenuPopover,\r\n UserMenuItem,\r\n useSidebar,\r\n} from '../sidebar/Sidebar';\r\nimport { ROUTES } from '../../../../routes';\r\n\r\n// ─── Nav Config ───────────────────────────────────────────────────────────────\r\n\r\nconst COLLAPSIBLE_GROUPS = [\r\n { id: 'general', label: 'General', icon: <Icon.BookOpen className=\"w-4 h-4\" />, defaultOpen: true },\r\n { id: 'forms', label: 'Forms', icon: <Icon.Users className=\"w-4 h-4\" />, defaultOpen: false },\r\n { id: 'complex', label: 'Complex', icon: <Icon.CreditCard className=\"w-4 h-4\" />, defaultOpen: false },\r\n { id: 'overlays', label: 'Overlays', icon: <Icon.ShieldCheck className=\"w-4 h-4\" />, defaultOpen: false },\r\n] as const;\r\n\r\n// ─── App Sidebar ──────────────────────────────────────────────────────────────\r\n\r\nconst AppSidebar: React.FC = () => {\r\n const { state } = useSidebar();\r\n const isCollapsed = state === 'collapsed';\r\n const location = useLocation();\r\n\r\n // ─── Memoized Nav Config ───────────────────────────────────────────────────\r\n const navOverview = React.useMemo(() => \r\n ROUTES.filter((r) => r.category === 'overview').map((r) => ({\r\n to: r.path,\r\n end: r.end,\r\n icon: r.icon,\r\n label: r.label,\r\n })), \r\n []);\r\n\r\n const navCollapsibles = React.useMemo(() => \r\n COLLAPSIBLE_GROUPS.map((group) => ({\r\n id: group.id,\r\n icon: group.icon,\r\n label: group.label,\r\n defaultOpen: group.defaultOpen,\r\n items: ROUTES.filter((r) => r.category === group.id).map((r) => ({\r\n to: r.path,\r\n icon: r.icon,\r\n label: r.label,\r\n badge: r.badge,\r\n })),\r\n })),\r\n []);\r\n\r\n return (\r\n <Sidebar collapsible=\"icon\">\r\n <SidebarRail />\r\n {/* Header */}\r\n <SidebarHeader className=\"border-b border-sidebar-border\">\r\n <SidebarMenu>\r\n <SidebarMenuItem>\r\n <div\r\n className={cn(\r\n 'flex items-center gap-3 px-2 py-2 rounded-md transition-all duration-200',\r\n isCollapsed && 'justify-center'\r\n )}\r\n >\r\n <div className=\"w-8 h-8 bg-primary rounded-lg flex items-center justify-center shrink-0 shadow-sm\">\r\n <span className=\"text-primary-foreground font-bold text-sm select-none\">UI</span>\r\n </div>\r\n {!isCollapsed && (\r\n <div className=\"overflow-hidden min-w-0\">\r\n <p className=\"text-sm font-semibold text-foreground truncate leading-tight\">UI Library</p>\r\n <p className=\"text-xs text-muted-foreground truncate leading-tight\">Component Showcase</p>\r\n </div>\r\n )}\r\n </div>\r\n </SidebarMenuItem>\r\n </SidebarMenu>\r\n </SidebarHeader>\r\n\r\n {/* Content */}\r\n <SidebarContent>\r\n {/* Overview */}\r\n <SidebarGroup>\r\n <SidebarGroupLabel>Overview</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n {navOverview.map((item) => (\r\n <SidebarMenuItem key={item.to}>\r\n <SidebarNavLink to={item.to} end={item.end} icon={item.icon} label={item.label} />\r\n </SidebarMenuItem>\r\n ))}\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </SidebarGroup>\r\n\r\n <SidebarSeparator />\r\n\r\n {/* Components Collapsible Groups */}\r\n <SidebarGroup>\r\n <SidebarGroupLabel>Components</SidebarGroupLabel>\r\n <SidebarGroupContent>\r\n <SidebarMenu>\r\n {navCollapsibles.map((group) => {\r\n // Detect xem có child nào đang active không\r\n const isChildActive = group.items.some((item) =>\r\n location.pathname === item.to || location.pathname.startsWith(item.to + '/')\r\n );\r\n\r\n return (\r\n <SidebarMenuItem key={group.id}>\r\n <SidebarMenuCollapsible\r\n id={group.id}\r\n icon={group.icon}\r\n label={group.label}\r\n defaultOpen={group.defaultOpen}\r\n isChildActive={isChildActive}\r\n >\r\n {group.items.map((item) => (\r\n <SidebarNavLink\r\n key={item.to}\r\n to={item.to}\r\n icon={item.icon}\r\n label={item.label}\r\n size=\"sm\"\r\n badge={\r\n 'badge' in item && item.badge && !isCollapsed ? (\r\n <span className=\"text-[10px] bg-primary text-primary-foreground rounded px-1.5 py-0.5 font-medium leading-none\">\r\n {item.badge}\r\n </span>\r\n ) : undefined\r\n }\r\n />\r\n ))}\r\n </SidebarMenuCollapsible>\r\n </SidebarMenuItem>\r\n );\r\n })}\r\n </SidebarMenu>\r\n </SidebarGroupContent>\r\n </SidebarGroup>\r\n </SidebarContent>\r\n\r\n {/* Footer — User menu */}\r\n <SidebarFooter className=\"border-t border-sidebar-border pb-2\">\r\n <SidebarMenu>\r\n <SidebarMenuItem>\r\n <UserMenuPopover\r\n name=\"admin2\"\r\n email=\"admin@example.com\"\r\n avatar=\"https://i.pravatar.cc/100\"\r\n >\r\n <UserMenuItem icon={<Icon.Sparkles className=\"w-4 h-4\" />}>\r\n Upgrade to Pro\r\n </UserMenuItem>\r\n <div className=\"h-px bg-border/50 my-1\" />\r\n <UserMenuItem icon={<Icon.BadgeCheck className=\"w-4 h-4\" />}>\r\n Account\r\n </UserMenuItem>\r\n <UserMenuItem icon={<Icon.BillingIcon className=\"w-4 h-4\" />}>\r\n Billing\r\n </UserMenuItem>\r\n <UserMenuItem icon={<Icon.BellIcon className=\"w-4 h-4\" />}>\r\n Notifications\r\n </UserMenuItem>\r\n <div className=\"h-px bg-border/50 my-1\" />\r\n <UserMenuItem icon={<Icon.LogOut className=\"w-4 h-4\" />} destructive>\r\n Log out\r\n </UserMenuItem>\r\n </UserMenuPopover>\r\n </SidebarMenuItem>\r\n </SidebarMenu>\r\n </SidebarFooter>\r\n </Sidebar>\r\n );\r\n};\r\n\r\n// ─── Header ───────────────────────────────────────────────────────────────────\r\n\r\nconst Header: React.FC = () => {\r\n const location = useLocation();\r\n const segments = location.pathname.replace(/^\\//, '').split('/').filter(Boolean);\r\n\r\n return (\r\n <header className=\"h-[60px] bg-background/95 backdrop-blur-sm border-b border-border/50 flex items-center px-4 gap-3 sticky top-0 z-30\">\r\n <SidebarTrigger />\r\n <div className=\"h-4 w-px bg-border/60\" />\r\n <nav className=\"flex items-center gap-1 text-sm flex-1 min-w-0\">\r\n <span className=\"text-muted-foreground hover:text-foreground transition-colors cursor-default\">\r\n Home\r\n </span>\r\n {segments.map((seg, i) => (\r\n <React.Fragment key={i}>\r\n <Icon.ChevronRight className=\"w-3.5 h-3.5 text-muted-foreground/40 shrink-0\" />\r\n <span\r\n className={\r\n i === segments.length - 1\r\n ? 'text-foreground font-medium capitalize truncate'\r\n : 'text-muted-foreground capitalize'\r\n }\r\n >\r\n {seg.replace(/-/g, ' ')}\r\n </span>\r\n </React.Fragment>\r\n ))}\r\n </nav>\r\n <div className=\"flex items-center gap-2 shrink-0\">\r\n <ThemeToggle />\r\n <img\r\n src=\"https://i.pravatar.cc/100\"\r\n alt=\"avatar\"\r\n className=\"w-8 h-8 rounded-full object-cover border border-border cursor-pointer\"\r\n />\r\n </div>\r\n </header>\r\n );\r\n};\r\n\r\n// ─── DashboardLayout ──────────────────────────────────────────────────────────\r\n\r\nexport const LayoutSample = React.forwardRef<HTMLDivElement, { children?: React.ReactNode }>(({ children }, ref) => {\r\n return (\r\n <div ref={ref} className=\"h-full w-full\">\r\n <SidebarProvider>\r\n <AppSidebar />\r\n <SidebarInset>\r\n <Header />\r\n <main className=\"flex-1 overflow-y-auto bg-muted/10\">\r\n <div className=\"p-6 h-[calc(100vh-60px)] overflow-auto\">\r\n {children ? children : <Outlet />}\r\n </div>\r\n </main>\r\n </SidebarInset>\r\n </SidebarProvider>\r\n </div>\r\n );\r\n});\r\n\r\nLayoutSample.displayName = \"LayoutSample\";\r\n"
348
+ "path": "src/components/ui/pagination/Pagination.tsx",
349
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';\r\n\r\nconst paginationVariants = tv({\r\n slots: {\r\n nav: 'mx-auto flex w-full justify-center',\r\n list: 'flex flex-row items-center gap-1',\r\n item: '',\r\n link: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 cursor-pointer hover:bg-accent hover:text-accent-foreground h-10 w-10',\r\n ellipsis: 'flex h-10 w-10 items-center justify-center',\r\n },\r\n variants: {\r\n active: {\r\n true: { link: 'border border-border bg-background shadow-sm' },\r\n false: {},\r\n },\r\n size: {\r\n sm: { link: 'h-8 w-8 text-xs' },\r\n md: { link: 'h-10 w-10 text-sm' },\r\n lg: { link: 'h-12 w-12 text-base' },\r\n },\r\n },\r\n defaultVariants: {\r\n active: false,\r\n size: 'md',\r\n },\r\n});\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationProps extends React.ComponentPropsWithoutRef<'nav'> {}\r\n\r\nconst Pagination = React.forwardRef<HTMLElement, PaginationProps>(\r\n ({ className, ...props }, ref) => {\r\n const { nav } = paginationVariants();\r\n return <nav ref={ref} role=\"navigation\" aria-label=\"pagination\" className={nav({ className })} {...props} />;\r\n }\r\n);\r\nPagination.displayName = 'Pagination';\r\n\r\n/* ─── Content ───────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationContentProps extends React.ComponentPropsWithoutRef<'ul'> {}\r\n\r\nconst PaginationContent = React.forwardRef<HTMLUListElement, PaginationContentProps>(\r\n ({ className, ...props }, ref) => {\r\n const { list } = paginationVariants();\r\n return <ul ref={ref} className={list({ className })} {...props} />;\r\n }\r\n);\r\nPaginationContent.displayName = 'PaginationContent';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationItemProps extends React.ComponentPropsWithoutRef<'li'> {}\r\n\r\nconst PaginationItem = React.forwardRef<HTMLLIElement, PaginationItemProps>(\r\n ({ className, ...props }, ref) => {\r\n const { item } = paginationVariants();\r\n return <li ref={ref} className={item({ className })} {...props} />;\r\n }\r\n);\r\nPaginationItem.displayName = 'PaginationItem';\r\n\r\n/* ─── Link ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the PaginationLink component */\r\nexport interface PaginationLinkProps\r\n extends React.ComponentPropsWithoutRef<'button'>,\r\n Pick<VariantProps<typeof paginationVariants>, 'size'> {\r\n /** Whether this link represents the current page */\r\n isActive?: boolean;\r\n}\r\n\r\nconst PaginationLink = React.forwardRef<HTMLButtonElement, PaginationLinkProps>(\r\n ({ className, isActive, size, ...props }, ref) => {\r\n const { link } = paginationVariants({ active: isActive, size });\r\n return (\r\n <button\r\n ref={ref}\r\n aria-current={isActive ? 'page' : undefined}\r\n className={link({ className })}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nPaginationLink.displayName = 'PaginationLink';\r\n\r\n/* ─── Previous ──────────────────────────────────────────────────────── */\r\n\r\n/** Props for the PaginationPrevious component */\r\nexport interface PaginationPreviousProps extends React.ComponentPropsWithoutRef<'button'> {\r\n /** Optional text label displayed next to the chevron icon */\r\n label?: string;\r\n}\r\n\r\nconst PaginationPrevious = React.forwardRef<HTMLButtonElement, PaginationPreviousProps>(\r\n ({ className, label, ...props }, ref) => {\r\n const { link } = paginationVariants();\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Go to previous page\"\r\n className={link({ className: `gap-1 ${label ? 'pl-2.5 w-auto px-4' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n <ChevronLeft className=\"h-4 w-4\" />\r\n {label && <span>{label}</span>}\r\n </button>\r\n );\r\n }\r\n);\r\nPaginationPrevious.displayName = 'PaginationPrevious';\r\n\r\n/* ─── Next ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the PaginationNext component */\r\nexport interface PaginationNextProps extends React.ComponentPropsWithoutRef<'button'> {\r\n /** Optional text label displayed next to the chevron icon */\r\n label?: string;\r\n}\r\n\r\nconst PaginationNext = React.forwardRef<HTMLButtonElement, PaginationNextProps>(\r\n ({ className, label, ...props }, ref) => {\r\n const { link } = paginationVariants();\r\n return (\r\n <button\r\n ref={ref}\r\n aria-label=\"Go to next page\"\r\n className={link({ className: `gap-1 ${label ? 'pr-2.5 w-auto px-4' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n {label && <span>{label}</span>}\r\n <ChevronRight className=\"h-4 w-4\" />\r\n </button>\r\n );\r\n }\r\n);\r\nPaginationNext.displayName = 'PaginationNext';\r\n\r\n/* ─── Ellipsis ──────────────────────────────────────────────────────── */\r\n\r\nexport interface PaginationEllipsisProps extends React.ComponentPropsWithoutRef<'span'> {}\r\n\r\nconst PaginationEllipsis = React.forwardRef<HTMLSpanElement, PaginationEllipsisProps>(\r\n ({ className, ...props }, ref) => {\r\n const { ellipsis } = paginationVariants();\r\n return (\r\n <span ref={ref} aria-hidden className={ellipsis({ className })} {...props}>\r\n <MoreHorizontal className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">More pages</span>\r\n </span>\r\n );\r\n }\r\n);\r\nPaginationEllipsis.displayName = 'PaginationEllipsis';\r\n\r\nexport {\r\n Pagination,\r\n PaginationContent,\r\n PaginationItem,\r\n PaginationLink,\r\n PaginationPrevious,\r\n PaginationNext,\r\n PaginationEllipsis,\r\n paginationVariants,\r\n};\r\n"
279
350
  }
280
351
  ]
281
352
  },
@@ -289,24 +360,7 @@
289
360
  "files": [
290
361
  {
291
362
  "path": "src/components/ui/popover/Popover.tsx",
292
- "content": "import * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { tv, type VariantProps } 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\nexport interface PopoverProps extends React.ComponentPropsWithoutRef<typeof BasePopover.Root> {\r\n trigger: React.ReactNode;\r\n children: React.ReactNode;\r\n className?: string;\r\n}\r\n\r\nconst Popover = React.forwardRef<HTMLDivElement, PopoverProps>(({ trigger, children, className, ...props }, ref) => {\r\n return (\r\n <BasePopover.Root {...props}>\r\n <BasePopover.Trigger nativeButton={false} render={<div ref={ref} className=\"inline-block\">{trigger}</div>} />\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner sideOffset={4}>\r\n <BasePopover.Popup className={popup({ className })}>\r\n <BasePopover.Arrow className={arrow()} />\r\n {children}\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n );\r\n}\r\n);\r\nPopover.displayName = \"Popover\";\r\n\r\nexport { Popover };\r\n"
293
- }
294
- ]
295
- },
296
- "pretty-code": {
297
- "name": "pretty-code",
298
- "dependencies": [
299
- "shiki",
300
- "unified",
301
- "rehype-parse",
302
- "rehype-pretty-code",
303
- "rehype-react"
304
- ],
305
- "internalDependencies": [],
306
- "files": [
307
- {
308
- "path": "src/components/ui/pretty-code/PrettyCode.tsx",
309
- "content": "import React, { useState, useEffect } from 'react';\r\nimport { createHighlighter, type Highlighter } from 'shiki';\r\nimport { unified } from 'unified';\r\nimport rehypeParse from 'rehype-parse';\r\nimport rehypePrettyCode from 'rehype-pretty-code';\r\nimport rehypeReact from 'rehype-react';\r\nimport * as prod from 'react/jsx-runtime';\r\n\r\n// Singleton highlighter to avoid reloading\r\nlet globalHighlighter: Highlighter | null = null;\r\n\r\nconst getHighlighter = async () => {\r\n if (globalHighlighter) return globalHighlighter;\r\n globalHighlighter = await createHighlighter({\r\n themes: ['nord'],\r\n langs: ['tsx', 'typescript', 'javascript', 'bash', 'json'],\r\n });\r\n return globalHighlighter;\r\n};\r\n\r\ninterface PrettyCodeProps {\r\n code: string;\r\n lang?: string;\r\n className?: string;\r\n}\r\n\r\nexport const PrettyCode: React.FC<PrettyCodeProps> = ({ code, lang = 'tsx', className }) => {\r\n const [nodes, setNodes] = useState<React.ReactNode>(null);\r\n const [loading, setLoading] = useState(true);\r\n\r\n useEffect(() => {\r\n let isMounted = true;\r\n\r\n const highlight = async () => {\r\n try {\r\n const highlighter = await getHighlighter();\r\n \r\n // Use shiki directly to generate HTML\r\n const html = highlighter.codeToHtml(code, { \r\n lang, \r\n theme: 'nord' \r\n });\r\n\r\n const file = await unified()\r\n .use(rehypeParse, { fragment: true })\r\n .use(rehypeReact, { \r\n ...prod,\r\n })\r\n .process(html);\r\n\r\n if (isMounted) {\r\n setNodes(file.result as React.ReactNode);\r\n setLoading(false);\r\n }\r\n } catch (error) {\r\n console.error('Failed to highlight code:', error);\r\n if (isMounted) setLoading(false);\r\n }\r\n };\r\n\r\n highlight();\r\n return () => { isMounted = false; };\r\n }, [code, lang]);\r\n\r\n if (loading) {\r\n return (\r\n <pre className={`p-6 bg-zinc-950 text-zinc-100/50 text-xs animate-pulse ${className}`}>\r\n <code>{code}</code>\r\n </pre>\r\n );\r\n }\r\n\r\n return (\r\n <div className={`pretty-code-wrapper ${className}`}>\r\n {nodes}\r\n </div>\r\n );\r\n};\r\n"
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"
310
364
  }
311
365
  ]
312
366
  },
@@ -322,7 +376,7 @@
322
376
  "files": [
323
377
  {
324
378
  "path": "src/components/ui/preview-card/PreviewCard.tsx",
325
- "content": "import * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Button } from '../button/Button';\r\n\r\nconst previewCardVariants = tv({\r\n slots: {\r\n popup: [\r\n 'z-50 w-72 rounded-xl border border-border bg-background shadow-xl outline-none',\r\n 'data-starting:animate-in data-ending:animate-out',\r\n 'data-ending:fade-out-0 data-starting:fade-in-0',\r\n 'data-ending:zoom-out-95 data-starting:zoom-in-95',\r\n 'data-side-bottom:slide-in-from-top-2',\r\n 'data-side-left:slide-in-from-right-2',\r\n 'data-side-right:slide-in-from-left-2',\r\n 'data-side-top:slide-in-from-bottom-2',\r\n ],\r\n cover: 'w-full overflow-hidden rounded-t-xl',\r\n body: 'p-4 space-y-2',\r\n title: 'font-semibold text-sm text-foreground leading-tight',\r\n description: 'text-xs text-muted-foreground leading-relaxed',\r\n footer: 'px-4 pb-4 pt-0 border-t border-border/50 mt-2 pt-3',\r\n },\r\n});\r\n\r\nexport type PreviewCardSide = 'top' | 'right' | 'bottom' | 'left';\r\nexport type PreviewCardAlign = 'start' | 'center' | 'end';\r\n\r\nexport interface PreviewCardProps {\r\n trigger: React.ReactNode;\r\n title?: string;\r\n description?: string;\r\n coverImage?: string;\r\n coverAlt?: string;\r\n coverHeight?: number;\r\n children?: React.ReactNode;\r\n footerContent?: React.ReactNode;\r\n side?: PreviewCardSide;\r\n align?: PreviewCardAlign;\r\n sideOffset?: number;\r\n openOnHover?: boolean;\r\n width?: number;\r\n className?: string;\r\n}\r\n\r\nconst PreviewCard = React.forwardRef<HTMLSpanElement, PreviewCardProps>(({\r\n trigger,\r\n title,\r\n description,\r\n coverImage,\r\n coverAlt = '',\r\n coverHeight = 120,\r\n children,\r\n footerContent,\r\n side = 'bottom',\r\n align = 'start',\r\n sideOffset = 8,\r\n openOnHover = false,\r\n width = 288,\r\n className,\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const slots = previewCardVariants();\r\n\r\n const triggerProps = openOnHover\r\n ? {\r\n onMouseEnter: () => setOpen(true),\r\n onMouseLeave: () => setOpen(false),\r\n }\r\n : {};\r\n\r\n return (\r\n <BasePopover.Root open={open} onOpenChange={setOpen}>\r\n <BasePopover.Trigger\r\n nativeButton={false}\r\n render={\r\n <span\r\n ref={ref}\r\n className=\"inline-block cursor-pointer\"\r\n {...triggerProps}\r\n >\r\n {trigger}\r\n </span>\r\n }\r\n />\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BasePopover.Popup\r\n className={slots.popup({ className })}\r\n style={{ width }}\r\n >\r\n {coverImage && (\r\n <div className={slots.cover()} style={{ height: coverHeight }}>\r\n <img\r\n src={coverImage}\r\n alt={coverAlt}\r\n className=\"w-full h-full object-cover\"\r\n />\r\n </div>\r\n )}\r\n {(title || description || children) && (\r\n <div className={slots.body()}>\r\n {title && <p className={slots.title()}>{title}</p>}\r\n {description && <p className={slots.description()}>{description}</p>}\r\n {children}\r\n </div>\r\n )}\r\n {footerContent && (\r\n <div className={slots.footer()}>{footerContent}</div>\r\n )}\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n );\r\n});\r\n\r\nPreviewCard.displayName = 'PreviewCard';\r\n\r\nexport { PreviewCard };\r\n"
379
+ "content": "import * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Button } from '../button/Button';\r\n\r\nconst previewCardVariants = tv({\r\n slots: {\r\n popup: [\r\n 'z-50 w-72 rounded-xl border border-border bg-background shadow-xl outline-none',\r\n 'data-starting:animate-in data-ending:animate-out',\r\n 'data-ending:fade-out-0 data-starting:fade-in-0',\r\n 'data-ending:zoom-out-95 data-starting:zoom-in-95',\r\n 'data-side-bottom:slide-in-from-top-2',\r\n 'data-side-left:slide-in-from-right-2',\r\n 'data-side-right:slide-in-from-left-2',\r\n 'data-side-top:slide-in-from-bottom-2',\r\n ],\r\n cover: 'w-full overflow-hidden rounded-t-xl',\r\n body: 'p-4 space-y-2',\r\n title: 'font-semibold text-sm text-foreground leading-tight',\r\n description: 'text-xs text-muted-foreground leading-relaxed',\r\n footer: 'px-4 pb-4 pt-0 border-t border-border/50 mt-2 pt-3',\r\n },\r\n});\r\n\r\nexport type PreviewCardSide = 'top' | 'right' | 'bottom' | 'left';\r\nexport type PreviewCardAlign = 'start' | 'center' | 'end';\r\n\r\n/** Props for the PreviewCard component */\r\nexport interface PreviewCardProps {\r\n /** Element that triggers the preview card */\r\n trigger: React.ReactNode;\r\n /** Title text displayed in the card body */\r\n title?: string;\r\n /** Description text displayed below the title */\r\n description?: string;\r\n /** URL for the cover image at the top of the card */\r\n coverImage?: string;\r\n /** Alt text for the cover image */\r\n coverAlt?: string;\r\n /** Height in px of the cover image area */\r\n coverHeight?: number;\r\n children?: React.ReactNode;\r\n /** Content rendered in the card footer section */\r\n footerContent?: React.ReactNode;\r\n /** Which side of the trigger to render the card */\r\n side?: PreviewCardSide;\r\n /** Alignment relative to the trigger */\r\n align?: PreviewCardAlign;\r\n /** Distance in px between the trigger and the card */\r\n sideOffset?: number;\r\n /** Open the card on hover instead of click */\r\n openOnHover?: boolean;\r\n /** Width of the card in px */\r\n width?: number;\r\n className?: string;\r\n}\r\n\r\nconst PreviewCard = React.forwardRef<HTMLSpanElement, PreviewCardProps>(({\r\n trigger,\r\n title,\r\n description,\r\n coverImage,\r\n coverAlt = '',\r\n coverHeight = 120,\r\n children,\r\n footerContent,\r\n side = 'bottom',\r\n align = 'start',\r\n sideOffset = 8,\r\n openOnHover = false,\r\n width = 288,\r\n className,\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const slots = previewCardVariants();\r\n\r\n const triggerProps = openOnHover\r\n ? {\r\n onMouseEnter: () => setOpen(true),\r\n onMouseLeave: () => setOpen(false),\r\n }\r\n : {};\r\n\r\n return (\r\n <BasePopover.Root open={open} onOpenChange={setOpen}>\r\n <BasePopover.Trigger\r\n nativeButton={false}\r\n render={\r\n <span\r\n ref={ref}\r\n className=\"inline-block cursor-pointer\"\r\n {...triggerProps}\r\n >\r\n {trigger}\r\n </span>\r\n }\r\n />\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BasePopover.Popup\r\n className={slots.popup({ className })}\r\n style={{ width }}\r\n >\r\n {coverImage && (\r\n <div className={slots.cover()} style={{ height: coverHeight }}>\r\n <img\r\n src={coverImage}\r\n alt={coverAlt}\r\n className=\"w-full h-full object-cover\"\r\n />\r\n </div>\r\n )}\r\n {(title || description || children) && (\r\n <div className={slots.body()}>\r\n {title && <p className={slots.title()}>{title}</p>}\r\n {description && <p className={slots.description()}>{description}</p>}\r\n {children}\r\n </div>\r\n )}\r\n {footerContent && (\r\n <div className={slots.footer()}>{footerContent}</div>\r\n )}\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n );\r\n});\r\n\r\nPreviewCard.displayName = 'PreviewCard';\r\n\r\nexport { PreviewCard };\r\n"
326
380
  }
327
381
  ]
328
382
  },
@@ -336,7 +390,7 @@
336
390
  "files": [
337
391
  {
338
392
  "path": "src/components/ui/progress/Progress.tsx",
339
- "content": "import * as React from 'react';\nimport { Progress as BaseProgress } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\n\nconst progressVariants = tv({\n slots: {\n base: 'flex flex-col gap-1.5 w-full',\n labelContainer: 'flex justify-between items-center text-sm font-medium',\n root: 'relative w-full overflow-hidden rounded-full bg-secondary',\n indicator: 'rounded-full h-full w-full flex-1 transition-all duration-500 ease-in-out relative overflow-hidden flex items-center justify-end',\n innerLabel: 'text-[10px] font-bold text-white drop-shadow-md pr-2',\n },\n variants: {\n size: {\n sm: { root: 'h-3', innerLabel: 'text-[10px] pr-1' },\n md: { root: 'h-4', innerLabel: 'text-[10px] pr-1' },\n lg: { root: 'h-6', innerLabel: 'text-xs pr-3' },\n },\n variant: {\n default: { indicator: 'bg-primary' },\n success: { indicator: 'bg-success' },\n warning: { indicator: 'bg-warning' },\n danger: { indicator: 'bg-danger' },\n gradient: { indicator: 'bg-gradient-to-r from-primary to-indigo-400' },\n },\n striped: {\n true: { \n indicator: 'bg-[linear-gradient(45deg,rgba(255,255,255,0.15)_25%,transparent_25%,transparent_50%,rgba(255,255,255,0.15)_50%,rgba(255,255,255,0.15)_75%,transparent_75%,transparent)] bg-[length:1rem_1rem]' \n }\n },\n animated: {\n true: {\n indicator: 'animate-progress-stripes'\n }\n }\n },\n defaultVariants: {\n size: 'md',\n variant: 'default',\n }\n});\n\nexport interface ProgressProps \n extends Omit<React.ComponentPropsWithoutRef<typeof BaseProgress.Root>, 'value'>, \n VariantProps<typeof progressVariants> {\n className?: string;\n value?: number | null;\n showLabel?: boolean;\n labelPosition?: 'inside' | 'outside' | 'none';\n label?: string;\n}\n\nconst Progress = React.forwardRef<React.ElementRef<typeof BaseProgress.Root>, ProgressProps>(\n ({ className, value, size, variant, striped, animated, showLabel = false, labelPosition = 'none', label, ...props }, ref) => {\n const { base, root, indicator, labelContainer, innerLabel } = progressVariants({ size, variant, striped, animated });\n \n // Auto-enable striped if string animated is true, unless explicitly turned off\n const isStriped = striped !== undefined ? striped : animated;\n const { indicator: finalIndicator } = progressVariants({ size, variant, striped: isStriped, animated });\n\n const displayValue = value ?? 0;\n\n return (\n <div className={base({ className })}>\n {(labelPosition === 'outside' || label) && (\n <div className={labelContainer()}>\n {label && <span>{label}</span>}\n {labelPosition === 'outside' && <span>{Math.round(displayValue)}%</span>}\n </div>\n )}\n <BaseProgress.Root\n ref={ref}\n className={root()}\n value={value ?? null}\n {...props}\n >\n <BaseProgress.Indicator \n className={finalIndicator()} \n style={{ transform: `translateX(-${100 - displayValue}%)` }} \n >\n {labelPosition === 'inside' && displayValue > 5 && (\n <span className={innerLabel()}>{Math.round(displayValue)}%</span>\n )}\n </BaseProgress.Indicator>\n </BaseProgress.Root>\n </div>\n )\n }\n)\nProgress.displayName = 'Progress';\n\nexport { Progress };\n"
393
+ "content": "import * as React from 'react';\r\nimport { Progress as BaseProgress } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst progressVariants = tv({\r\n slots: {\r\n base: 'flex flex-col gap-1.5 w-full',\r\n labelContainer: 'flex justify-between items-center text-sm font-medium',\r\n root: 'relative w-full overflow-hidden rounded-full bg-secondary',\r\n indicator: 'rounded-full h-full w-full flex-1 transition-all duration-500 ease-in-out relative overflow-hidden flex items-center justify-end',\r\n innerLabel: 'text-[10px] font-bold text-white drop-shadow-md pr-2',\r\n },\r\n variants: {\r\n size: {\r\n sm: { root: 'h-3', innerLabel: 'text-[10px] pr-1' },\r\n md: { root: 'h-4', innerLabel: 'text-[10px] pr-1' },\r\n lg: { root: 'h-6', innerLabel: 'text-xs pr-3' },\r\n },\r\n variant: {\r\n default: { indicator: 'bg-primary' },\r\n success: { indicator: 'bg-success' },\r\n warning: { indicator: 'bg-warning' },\r\n danger: { indicator: 'bg-danger' },\r\n gradient: { indicator: 'bg-gradient-to-r from-primary to-indigo-400' },\r\n },\r\n striped: {\r\n true: { \r\n indicator: 'bg-[linear-gradient(45deg,rgba(255,255,255,0.15)_25%,transparent_25%,transparent_50%,rgba(255,255,255,0.15)_50%,rgba(255,255,255,0.15)_75%,transparent_75%,transparent)] bg-[length:1rem_1rem]' \r\n }\r\n },\r\n animated: {\r\n true: {\r\n indicator: 'animate-progress-stripes'\r\n }\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n variant: 'default',\r\n }\r\n});\r\n\r\n/** Props for the Progress component */\r\nexport interface ProgressProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseProgress.Root>, 'value'>,\r\n VariantProps<typeof progressVariants> {\r\n className?: string;\r\n /** Progress value from 0 to 100 */\r\n value?: number | null;\r\n /** @deprecated Use `labelPosition` instead */\r\n showLabel?: boolean;\r\n /** Where to display the percentage label */\r\n labelPosition?: 'inside' | 'outside' | 'none';\r\n /** Descriptive text label shown above the progress bar */\r\n label?: string;\r\n}\r\n\r\nconst Progress = React.forwardRef<React.ElementRef<typeof BaseProgress.Root>, ProgressProps>(\r\n ({ className, value, size, variant, striped, animated, showLabel = false, labelPosition = 'none', label, ...props }, ref) => {\r\n const { base, root, indicator, labelContainer, innerLabel } = progressVariants({ size, variant, striped, animated });\r\n \r\n // Auto-enable striped if string animated is true, unless explicitly turned off\r\n const isStriped = striped !== undefined ? striped : animated;\r\n const { indicator: finalIndicator } = progressVariants({ size, variant, striped: isStriped, animated });\r\n\r\n const displayValue = value ?? 0;\r\n\r\n return (\r\n <div className={base({ className })}>\r\n {(labelPosition === 'outside' || label) && (\r\n <div className={labelContainer()}>\r\n {label && <span>{label}</span>}\r\n {labelPosition === 'outside' && <span>{Math.round(displayValue)}%</span>}\r\n </div>\r\n )}\r\n <BaseProgress.Root\r\n ref={ref}\r\n className={root()}\r\n value={value ?? null}\r\n aria-valuemin={0}\r\n aria-valuemax={100}\r\n aria-valuenow={displayValue}\r\n aria-label={label ?? 'Progress'}\r\n {...props}\r\n >\r\n <BaseProgress.Indicator \r\n className={finalIndicator()} \r\n style={{ transform: `translateX(-${100 - displayValue}%)` }} \r\n >\r\n {labelPosition === 'inside' && displayValue > 5 && (\r\n <span className={innerLabel()}>{Math.round(displayValue)}%</span>\r\n )}\r\n </BaseProgress.Indicator>\r\n </BaseProgress.Root>\r\n </div>\r\n )\r\n }\r\n)\r\nProgress.displayName = 'Progress';\r\n\r\nexport { Progress };\r\n"
340
394
  }
341
395
  ]
342
396
  },
@@ -357,13 +411,14 @@
357
411
  "radio-group": {
358
412
  "name": "radio-group",
359
413
  "dependencies": [
360
- "@base-ui/react"
414
+ "@base-ui/react",
415
+ "tailwind-variants"
361
416
  ],
362
417
  "internalDependencies": [],
363
418
  "files": [
364
419
  {
365
420
  "path": "src/components/ui/radio-group/RadioGroup.tsx",
366
- "content": "import * as React from 'react';\r\nimport { RadioGroup as BaseRadioGroup } from '@base-ui/react';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\nexport interface RadioGroupProps extends React.ComponentPropsWithoutRef<typeof BaseRadioGroup> {\r\n className?: string;\r\n}\r\n\r\nconst RadioGroup = React.forwardRef<React.ElementRef<typeof BaseRadioGroup>, RadioGroupProps>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <BaseRadioGroup\r\n ref={ref}\r\n className={cn('grid gap-2', className)}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\n\r\nRadioGroup.displayName = 'RadioGroup';\r\n\r\nexport { RadioGroup };\r\n"
421
+ "content": "import * as React from 'react';\r\nimport { RadioGroup as BaseRadioGroup } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst radioGroupVariants = tv({\r\n base: 'grid gap-2',\r\n variants: {\r\n orientation: {\r\n vertical: 'grid-flow-row',\r\n horizontal: 'grid-flow-col auto-cols-auto',\r\n },\r\n },\r\n defaultVariants: {\r\n orientation: 'vertical',\r\n },\r\n});\r\n\r\n/** Props for the RadioGroup component */\r\nexport interface RadioGroupProps\r\n extends React.ComponentPropsWithoutRef<typeof BaseRadioGroup>,\r\n VariantProps<typeof radioGroupVariants> {\r\n className?: string;\r\n}\r\n\r\nconst RadioGroup = React.forwardRef<React.ElementRef<typeof BaseRadioGroup>, RadioGroupProps>(\r\n ({ className, orientation, ...props }, ref) => {\r\n return (\r\n <BaseRadioGroup\r\n ref={ref}\r\n className={radioGroupVariants({ orientation, className })}\r\n aria-orientation={orientation ?? 'vertical'}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\n\r\nRadioGroup.displayName = 'RadioGroup';\r\n\r\nexport { RadioGroup, radioGroupVariants };\r\n"
367
422
  }
368
423
  ]
369
424
  },
@@ -377,7 +432,20 @@
377
432
  "files": [
378
433
  {
379
434
  "path": "src/components/ui/rate/Rate.tsx",
380
- "content": "import * 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\nexport interface RateProps extends VariantProps<typeof rateVariants> {\r\n value?: number;\r\n defaultValue?: number;\r\n onChange?: (value: number) => void;\r\n count?: number;\r\n allowHalf?: boolean;\r\n allowClear?: boolean;\r\n readonly?: boolean;\r\n disabled?: boolean;\r\n character?: React.ReactNode;\r\n activeColor?: string;\r\n inactiveColor?: string;\r\n className?: string;\r\n 'aria-label'?: 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}, 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 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 || 'Đánh giá'}\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={`${full} sao`}\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"
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"
436
+ }
437
+ ]
438
+ },
439
+ "scroll-area": {
440
+ "name": "scroll-area",
441
+ "dependencies": [
442
+ "tailwind-variants"
443
+ ],
444
+ "internalDependencies": [],
445
+ "files": [
446
+ {
447
+ "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"
381
449
  }
382
450
  ]
383
451
  },
@@ -392,7 +460,33 @@
392
460
  "files": [
393
461
  {
394
462
  "path": "src/components/ui/select/Select.tsx",
395
- "content": "import * as React from 'react';\nimport { Select as BaseSelect } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@lib/utils/cn';\nimport { ChevronDown, Check, X } from 'lucide-react';\n\nconst selectVariants = tv({\n slots: {\n trigger: 'flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer',\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-md border border-border bg-background text-foreground shadow-md data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\n viewport: 'p-1',\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\n }\n});\n\nconst { trigger, content, viewport, item, icon } = selectVariants();\n\nexport interface SelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\n label?: string;\n description?: string;\n error?: string;\n placeholder?: string;\n options: { label: string; value: string }[];\n id?: string;\n className?: string;\n value?: string;\n defaultValue?: string;\n clearable?: boolean;\n onChange?: (value: string) => void;\n emptyText?: string;\n clearLabel?: string;\n}\n\nconst Select = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, SelectProps>(\n ({ label, description, error, placeholder = 'Chọn...', options, id, className, clearable = true, onChange, onValueChange, value, defaultValue, emptyText = 'Không tìm thấy kết quả.', clearLabel = 'Xóa lựa chọn', ...props }, ref) => {\n const triggerRef = React.useRef<HTMLButtonElement>(null);\n\n const [selectedValue, setSelectedValue] = React.useState<string>(value ?? defaultValue ?? '');\n\n React.useEffect(() => {\n if (value !== undefined) setSelectedValue(value);\n }, [value]);\n\n const handleValueChange = (val: unknown) => {\n const strVal = val as string;\n setSelectedValue(strVal);\n onChange?.(strVal);\n // @ts-expect-error Base UI type mapping for onValueChange expects strict context which is internal\n onValueChange?.(strVal, {\n event: new Event('change')\n });\n };\n\n const handleClear = (e: React.MouseEvent) => {\n e.preventDefault();\n e.stopPropagation();\n setSelectedValue('');\n onChange?.('');\n // @ts-expect-error Base UI type mapping for onValueChange expects strict context which is internal\n onValueChange?.('', {\n event: new Event('change')\n });\n };\n\n const selectedLabel = options.find((o) => o.value === selectedValue)?.label;\n\n return (\n <div className=\"flex flex-col gap-1.5 w-full\">\n {label && (\n <label className=\"text-sm font-medium text-foreground leading-none\">\n {label}\n </label>\n )}\n\n {/*\n * Wrapper relative: nút X nằm NGOÀI BaseSelect.Trigger (absolute)\n * → click X không bao giờ bubble lên Trigger → popup không mở\n */}\n <div className=\"relative w-full\">\n <BaseSelect.Root\n value={value}\n defaultValue={defaultValue}\n onValueChange={handleValueChange}\n {...props}\n >\n <BaseSelect.Trigger\n ref={(node) => {\n triggerRef.current = node;\n if (typeof ref === 'function') ref(node);\n else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;\n }}\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\n id={id}\n >\n <span className={selectedLabel ? 'text-foreground' : 'text-muted-foreground'}>\n {selectedLabel ?? placeholder}\n </span>\n <BaseSelect.Icon>\n <ChevronDown className={icon()} />\n </BaseSelect.Icon>\n </BaseSelect.Trigger>\n <BaseSelect.Portal>\n <BaseSelect.Positioner anchor={triggerRef} className=\"z-50\" sideOffset={4}>\n <BaseSelect.Popup className={content()}>\n <div className={viewport()}>\n {options.length === 0 ? (\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic text-center\">\n {emptyText}\n </div>\n ) : (\n options.map((option) => (\n <BaseSelect.Item key={option.value} value={option.value} className={item()}>\n <BaseSelect.ItemIndicator className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <Check className=\"h-4 w-4\" />\n </BaseSelect.ItemIndicator>\n <BaseSelect.ItemText>{option.label}</BaseSelect.ItemText>\n </BaseSelect.Item>\n ))\n )}\n </div>\n </BaseSelect.Popup>\n </BaseSelect.Positioner>\n </BaseSelect.Portal>\n </BaseSelect.Root>\n\n {/* Nút X đặt NGOÀI Trigger, absolute position — click không bubble lên Trigger */}\n {clearable && selectedValue && (\n <button\n type=\"button\"\n aria-label={clearLabel}\n onMouseDown={handleClear}\n className=\"cursor-pointer absolute right-8 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-red-50 hover:text-red-500 transition-colors z-10\"\n >\n <X className=\"h-3 w-3\" />\n </button>\n )}\n </div>\n\n {description && !error && (\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n </div>\n );\n }\n);\n\nSelect.displayName = 'Select';\n\nexport { Select };\n"
463
+ "content": "import * as React from 'react';\r\nimport { Select as BaseSelect } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { ChevronDown, Check, X } from 'lucide-react';\r\n\r\nconst selectVariants = tv({\r\n slots: {\r\n trigger: 'flex h-10 w-full items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm ring-offset-background focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow group cursor-pointer',\r\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-md border border-border bg-background text-foreground shadow-md data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n viewport: 'p-1',\r\n item: 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-muted data-highlighted:text-foreground',\r\n icon: 'h-4 w-4 opacity-50 transition-transform duration-200 group-data-open:rotate-180',\r\n }\r\n});\r\n\r\nconst { trigger, content, viewport, item, icon } = selectVariants();\r\n\r\n/** Props for the Select component */\r\nexport interface SelectProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.Root>, 'value' | 'defaultValue'> {\r\n /** Label text displayed above the select trigger */\r\n label?: string;\r\n /** Helper text displayed below the select (hidden when error is present) */\r\n description?: string;\r\n /** Error message displayed below the select; also applies danger styling */\r\n error?: string;\r\n /** Placeholder shown when no option is selected */\r\n placeholder?: string;\r\n /** Array of selectable options */\r\n options: { label: string; value: string }[];\r\n id?: string;\r\n className?: string;\r\n /** Controlled selected value */\r\n value?: string;\r\n /** Initial selected value for uncontrolled usage */\r\n defaultValue?: string;\r\n /** Whether a clear button is shown when a value is selected */\r\n clearable?: boolean;\r\n /** Callback fired when the selected value changes */\r\n onChange?: (value: string) => void;\r\n /** Text shown when the options array is empty */\r\n emptyText?: string;\r\n /** Accessible label for the clear button */\r\n clearLabel?: string;\r\n}\r\n\r\nconst Select = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, SelectProps>(\r\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', ...props }, ref) => {\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n const [selectedValue, setSelectedValue] = React.useState<string>(value ?? defaultValue ?? '');\r\n\r\n React.useEffect(() => {\r\n if (value !== undefined) setSelectedValue(value);\r\n }, [value]);\r\n\r\n const handleValueChange = (val: string) => {\r\n setSelectedValue(val);\r\n onChange?.(val);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setSelectedValue('');\r\n onChange?.('');\r\n };\r\n\r\n const selectedLabel = options.find((o) => o.value === selectedValue)?.label;\r\n\r\n return (\r\n <div className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground leading-none\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n {/*\r\n * Wrapper relative: nút X nằm NGOÀI BaseSelect.Trigger (absolute)\r\n * → click X không bao giờ bubble lên Trigger → popup không mở\r\n */}\r\n <div className=\"relative w-full\">\r\n <BaseSelect.Root\r\n value={value}\r\n defaultValue={defaultValue}\r\n onValueChange={handleValueChange as (value: unknown) => void}\r\n {...props}\r\n >\r\n <BaseSelect.Trigger\r\n ref={(node) => {\r\n triggerRef.current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;\r\n }}\r\n className={trigger({ className: cn(className, error ? 'border-danger focus:border-danger' : '') })}\r\n id={id}\r\n >\r\n <span className={selectedLabel ? 'text-foreground' : 'text-muted-foreground'}>\r\n {selectedLabel ?? placeholder}\r\n </span>\r\n <BaseSelect.Icon>\r\n <ChevronDown className={icon()} />\r\n </BaseSelect.Icon>\r\n </BaseSelect.Trigger>\r\n <BaseSelect.Portal>\r\n <BaseSelect.Positioner anchor={triggerRef} className=\"z-50\" sideOffset={4}>\r\n <BaseSelect.Popup className={content()}>\r\n <div className={viewport()}>\r\n {options.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic text-center\">\r\n {emptyText}\r\n </div>\r\n ) : (\r\n options.map((option) => (\r\n <BaseSelect.Item key={option.value} value={option.value} className={item()}>\r\n <BaseSelect.ItemIndicator className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\r\n <Check className=\"h-4 w-4\" />\r\n </BaseSelect.ItemIndicator>\r\n <BaseSelect.ItemText>{option.label}</BaseSelect.ItemText>\r\n </BaseSelect.Item>\r\n ))\r\n )}\r\n </div>\r\n </BaseSelect.Popup>\r\n </BaseSelect.Positioner>\r\n </BaseSelect.Portal>\r\n </BaseSelect.Root>\r\n\r\n {/* Nút X đặt NGOÀI Trigger, absolute position — click không bubble lên Trigger */}\r\n {clearable && selectedValue && (\r\n <button\r\n type=\"button\"\r\n aria-label={clearLabel}\r\n onMouseDown={handleClear}\r\n className=\"cursor-pointer absolute right-8 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground hover:bg-red-50 hover:text-red-500 transition-colors z-10\"\r\n >\r\n <X className=\"h-3 w-3\" />\r\n </button>\r\n )}\r\n </div>\r\n\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSelect.displayName = 'Select';\r\n\r\nexport { Select };\r\n"
464
+ }
465
+ ]
466
+ },
467
+ "separator": {
468
+ "name": "separator",
469
+ "dependencies": [
470
+ "tailwind-variants"
471
+ ],
472
+ "internalDependencies": [],
473
+ "files": [
474
+ {
475
+ "path": "src/components/ui/separator/Separator.tsx",
476
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst separatorVariants = tv({\r\n base: 'shrink-0 bg-border',\r\n variants: {\r\n orientation: {\r\n horizontal: 'h-[1px] w-full',\r\n vertical: 'h-full w-[1px]',\r\n },\r\n variant: {\r\n default: 'bg-border',\r\n muted: 'bg-muted',\r\n primary: 'bg-primary/20',\r\n dashed: 'bg-transparent border-0 border-border border-dashed',\r\n },\r\n },\r\n compoundVariants: [\r\n { orientation: 'horizontal', variant: 'dashed', className: 'border-t h-0' },\r\n { orientation: 'vertical', variant: 'dashed', className: 'border-l w-0' },\r\n ],\r\n defaultVariants: {\r\n orientation: 'horizontal',\r\n variant: 'default',\r\n },\r\n});\r\n\r\n/** Props for the Separator component */\r\nexport interface SeparatorProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof separatorVariants> {\r\n /** When true, the separator is purely visual and hidden from assistive technology */\r\n decorative?: boolean;\r\n}\r\n\r\nconst Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(\r\n ({ className, orientation = 'horizontal', variant, decorative = true, ...props }, ref) => (\r\n <div\r\n ref={ref}\r\n role={decorative ? 'none' : 'separator'}\r\n aria-orientation={decorative ? undefined : orientation}\r\n className={separatorVariants({ orientation, variant, className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nSeparator.displayName = 'Separator';\r\n\r\nexport { Separator, separatorVariants };\r\n"
477
+ }
478
+ ]
479
+ },
480
+ "sheet": {
481
+ "name": "sheet",
482
+ "dependencies": [],
483
+ "internalDependencies": [
484
+ "drawer"
485
+ ],
486
+ "files": [
487
+ {
488
+ "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"
396
490
  }
397
491
  ]
398
492
  },
@@ -410,18 +504,20 @@
410
504
  "files": [
411
505
  {
412
506
  "path": "src/components/ui/sidebar/Sidebar.tsx",
413
- "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 } 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\nexport interface SidebarProviderProps {\r\n children: React.ReactNode;\r\n defaultOpen?: boolean;\r\n open?: boolean;\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\nexport interface SidebarProps extends React.HTMLAttributes<HTMLElement> {\r\n side?: 'left' | 'right';\r\n variant?: 'sidebar' | 'floating' | 'inset';\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\nexport interface SidebarMenuButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\r\n asChild?: boolean;\r\n isActive?: boolean;\r\n tooltip?: string;\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 content={tooltip} side=\"right\">\r\n {button}\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\nexport interface SidebarNavLinkProps {\r\n to: string;\r\n icon?: React.ReactNode;\r\n label: string;\r\n end?: boolean;\r\n badge?: React.ReactNode;\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 content={label} side=\"right\">\r\n <span className=\"block\">{link}</span>\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\nexport interface SidebarMenuCollapsibleProps {\r\n id: string;\r\n icon: React.ReactNode;\r\n label: string;\r\n children: React.ReactNode;\r\n defaultOpen?: boolean;\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 content={label} side=\"right\">\r\n <span className=\"block\">{trigger}</span>\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 content={name} side=\"right\">\r\n <span className=\"block\">{trigger}</span>\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"
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"
414
508
  }
415
509
  ]
416
510
  },
417
511
  "skeleton": {
418
512
  "name": "skeleton",
419
- "dependencies": [],
513
+ "dependencies": [
514
+ "tailwind-variants"
515
+ ],
420
516
  "internalDependencies": [],
421
517
  "files": [
422
518
  {
423
519
  "path": "src/components/ui/skeleton/Skeleton.tsx",
424
- "content": "import * as React from 'react';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\nconst Skeleton = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <div\r\n ref={ref}\r\n className={cn('animate-pulse rounded-md bg-secondary', className)}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSkeleton.displayName = 'Skeleton';\r\n\r\nexport { Skeleton };\r\n"
520
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst skeletonVariants = tv({\r\n base: 'animate-pulse rounded-md bg-secondary',\r\n variants: {\r\n variant: {\r\n default: 'bg-secondary',\r\n muted: 'bg-muted',\r\n },\r\n rounded: {\r\n none: 'rounded-none',\r\n sm: 'rounded-sm',\r\n md: 'rounded-md',\r\n lg: 'rounded-lg',\r\n full: 'rounded-full',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n rounded: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Skeleton component */\r\nexport interface SkeletonProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof skeletonVariants> {}\r\n\r\nconst Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(\r\n ({ className, variant, rounded, ...props }, ref) => {\r\n return (\r\n <div\r\n ref={ref}\r\n className={skeletonVariants({ variant, rounded, className })}\r\n aria-hidden=\"true\"\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nSkeleton.displayName = 'Skeleton';\r\n\r\nexport { Skeleton, skeletonVariants };\r\n"
425
521
  }
426
522
  ]
427
523
  },
@@ -435,7 +531,7 @@
435
531
  "files": [
436
532
  {
437
533
  "path": "src/components/ui/slider/Slider.tsx",
438
- "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\nexport interface SliderProps extends React.ComponentPropsWithoutRef<typeof BaseSlider.Root> {\r\n className?: string;\r\n}\r\n\r\nconst Slider = React.forwardRef<React.ElementRef<typeof BaseSlider.Root>, SliderProps>(\r\n ({ className, ...props }, ref) => (\r\n <BaseSlider.Root\r\n ref={ref}\r\n className={root({ className })}\r\n {...props}\r\n >\r\n {/* 1. Thêm BaseSlider.Control bọc ngoài */}\r\n <BaseSlider.Control className={control()}>\r\n \r\n <BaseSlider.Track className={track()}>\r\n <BaseSlider.Indicator className={indicator()} />\r\n </BaseSlider.Track>\r\n\r\n {/* 2. Đưa Thumb ra ngoài Track, đứng ngang hàng */}\r\n <BaseSlider.Thumb className={thumb()} />\r\n \r\n </BaseSlider.Control>\r\n </BaseSlider.Root>\r\n )\r\n)\r\nSlider.displayName = 'Slider';\r\n\r\nexport { Slider };"
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', \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}\r\n\r\nconst Slider = React.forwardRef<React.ElementRef<typeof BaseSlider.Root>, SliderProps>(\r\n ({ className, ...props }, ref) => (\r\n <BaseSlider.Root\r\n ref={ref}\r\n className={root({ className })}\r\n aria-label={props['aria-label'] ?? 'Slider'}\r\n {...props}\r\n >\r\n {/* 1. Thêm BaseSlider.Control bọc ngoài */}\r\n <BaseSlider.Control className={control()}>\r\n \r\n <BaseSlider.Track className={track()}>\r\n <BaseSlider.Indicator className={indicator()} />\r\n </BaseSlider.Track>\r\n\r\n {/* 2. Đưa Thumb ra ngoài Track, đứng ngang hàng */}\r\n <BaseSlider.Thumb className={thumb()} />\r\n \r\n </BaseSlider.Control>\r\n </BaseSlider.Root>\r\n )\r\n)\r\nSlider.displayName = 'Slider';\r\n\r\nexport { Slider };"
439
535
  }
440
536
  ]
441
537
  },
@@ -448,7 +544,7 @@
448
544
  "files": [
449
545
  {
450
546
  "path": "src/components/ui/spinner/Spinner.tsx",
451
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst spinnerVariants = tv({\r\n base: 'animate-spin rounded-full border-2 border-current border-t-transparent',\r\n variants: {\r\n size: {\r\n xs: 'h-3 w-3 border-[1.5px]',\r\n sm: 'h-4 w-4 border-2',\r\n md: 'h-6 w-6 border-2',\r\n lg: 'h-8 w-8 border-3',\r\n xl: 'h-12 w-12 border-4',\r\n },\r\n variant: {\r\n primary: 'text-primary',\r\n secondary: 'text-secondary',\r\n white: 'text-white',\r\n muted: 'text-muted-foreground',\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n variant: 'primary'\r\n }\r\n});\r\n\r\nexport interface SpinnerProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof spinnerVariants> {}\r\n\r\nconst Spinner = React.forwardRef<HTMLDivElement, SpinnerProps>(\r\n ({ className, size, variant, ...props }, ref) => {\r\n return (\r\n <div\r\n ref={ref}\r\n className={spinnerVariants({ size, variant, className })}\r\n role=\"status\"\r\n aria-label=\"Loading\"\r\n {...props}\r\n >\r\n <span className=\"sr-only\">Loading...</span>\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSpinner.displayName = 'Spinner';\r\n\r\nexport { Spinner };\r\n"
547
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst spinnerVariants = tv({\r\n base: 'animate-spin rounded-full border-2 border-current border-t-transparent',\r\n variants: {\r\n size: {\r\n xs: 'h-3 w-3 border-[1.5px]',\r\n sm: 'h-4 w-4 border-2',\r\n md: 'h-6 w-6 border-2',\r\n lg: 'h-8 w-8 border-3',\r\n xl: 'h-12 w-12 border-4',\r\n },\r\n variant: {\r\n primary: 'text-primary',\r\n secondary: 'text-secondary',\r\n white: 'text-white',\r\n muted: 'text-muted-foreground',\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md',\r\n variant: 'primary'\r\n }\r\n});\r\n\r\n/** Props for the Spinner component */\r\nexport interface SpinnerProps\r\n extends React.HTMLAttributes<HTMLDivElement>,\r\n VariantProps<typeof spinnerVariants> {}\r\n\r\nconst Spinner = React.forwardRef<HTMLDivElement, SpinnerProps>(\r\n ({ className, size, variant, ...props }, ref) => {\r\n return (\r\n <div\r\n ref={ref}\r\n className={spinnerVariants({ size, variant, className })}\r\n role=\"status\"\r\n aria-label=\"Loading\"\r\n {...props}\r\n >\r\n <span className=\"sr-only\">Loading...</span>\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSpinner.displayName = 'Spinner';\r\n\r\nexport { Spinner };\r\n"
452
548
  }
453
549
  ]
454
550
  },
@@ -462,7 +558,7 @@
462
558
  "files": [
463
559
  {
464
560
  "path": "src/components/ui/switch/Switch.tsx",
465
- "content": "import * as React from 'react';\r\nimport { Switch as BaseSwitch } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst switchVariants = tv({\r\n slots: {\r\n root: 'group inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-primary data-unchecked:bg-switch-background',\r\n thumb: 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-checked:translate-x-5 data-unchecked:translate-x-0',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n root: 'h-5 w-9',\r\n thumb: 'h-4 w-4 data-checked:translate-x-4',\r\n },\r\n md: {\r\n root: 'h-6 w-11',\r\n thumb: 'h-5 w-5 data-checked:translate-x-5',\r\n }\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md'\r\n }\r\n});\r\n\r\n\r\n\r\nexport interface SwitchProps\r\n extends Omit<BaseSwitch.Root.Props, 'className'>,\r\n VariantProps<typeof switchVariants> {\r\n label?: string;\r\n className?: string;\r\n}\r\n\r\nconst Switch = React.forwardRef<React.ElementRef<typeof BaseSwitch.Root>, SwitchProps>(\r\n ({ className, size, label, id, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const switchId = id || defaultId;\r\n\r\n const { root, thumb } = switchVariants({ size });\r\n\r\n return (\r\n <div className={cn(\" flex items-center gap-2 w-fit\", props.disabled && \"opacity-40 cursor-not-allowed\")}>\r\n <BaseSwitch.Root\r\n ref={ref}\r\n id={switchId}\r\n className={root({ className: cn(!props.disabled && \"cursor-pointer\", className) })}\r\n {...props}\r\n >\r\n <BaseSwitch.Thumb className={thumb()} />\r\n </BaseSwitch.Root>\r\n {label && (\r\n <label\r\n htmlFor={switchId}\r\n className={cn(\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\", props.disabled && \"cursor-not-allowed\")}\r\n >\r\n {label}\r\n </label>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSwitch.displayName = 'Switch';\r\n\r\nexport { Switch };\r\n"
561
+ "content": "import * as React from 'react';\r\nimport { Switch as BaseSwitch } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst switchVariants = tv({\r\n slots: {\r\n root: 'group inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-checked:bg-primary data-unchecked:bg-switch-background',\r\n thumb: 'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-checked:translate-x-5 data-unchecked:translate-x-0',\r\n },\r\n variants: {\r\n size: {\r\n sm: {\r\n root: 'h-5 w-9',\r\n thumb: 'h-4 w-4 data-checked:translate-x-4',\r\n },\r\n md: {\r\n root: 'h-6 w-11',\r\n thumb: 'h-5 w-5 data-checked:translate-x-5',\r\n }\r\n }\r\n },\r\n defaultVariants: {\r\n size: 'md'\r\n }\r\n});\r\n\r\n\r\n\r\n/** Props for the Switch component */\r\nexport interface SwitchProps\r\n extends Omit<BaseSwitch.Root.Props, 'className'>,\r\n VariantProps<typeof switchVariants> {\r\n /** Text label displayed next to the switch */\r\n label?: string;\r\n className?: string;\r\n}\r\n\r\nconst Switch = React.forwardRef<React.ElementRef<typeof BaseSwitch.Root>, SwitchProps>(\r\n ({ className, size, label, id, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const switchId = id || defaultId;\r\n\r\n const { root, thumb } = switchVariants({ size });\r\n\r\n return (\r\n <div className={cn(\" flex items-center gap-2 w-fit\", props.disabled && \"opacity-40 cursor-not-allowed\")}>\r\n <BaseSwitch.Root\r\n ref={ref}\r\n id={switchId}\r\n className={root({ className: cn(!props.disabled && \"cursor-pointer\", className) })}\r\n {...props}\r\n >\r\n <BaseSwitch.Thumb className={thumb()} />\r\n </BaseSwitch.Root>\r\n {label && (\r\n <label\r\n htmlFor={switchId}\r\n className={cn(\"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\", props.disabled && \"cursor-not-allowed\")}\r\n >\r\n {label}\r\n </label>\r\n )}\r\n </div>\r\n );\r\n }\r\n);\r\n\r\nSwitch.displayName = 'Switch';\r\n\r\nexport { Switch };\r\n"
466
562
  }
467
563
  ]
468
564
  },
@@ -470,6 +566,7 @@
470
566
  "name": "table",
471
567
  "dependencies": [
472
568
  "@tanstack/react-table",
569
+ "@tanstack/react-virtual",
473
570
  "lucide-react"
474
571
  ],
475
572
  "internalDependencies": [
@@ -480,7 +577,7 @@
480
577
  "files": [
481
578
  {
482
579
  "path": "src/components/ui/table/Table.tsx",
483
- "content": "import React, { useEffect, useState } from 'react';\r\nimport {\r\n useReactTable,\r\n getCoreRowModel,\r\n getSortedRowModel,\r\n getPaginationRowModel,\r\n getFilteredRowModel,\r\n getExpandedRowModel,\r\n flexRender,\r\n type ColumnDef,\r\n type SortingState,\r\n type PaginationState,\r\n type RowSelectionState,\r\n type RowData,\r\n type ColumnResizeMode,\r\n} from '@tanstack/react-table';\r\n\r\ndeclare module '@tanstack/react-table' {\r\n interface ColumnMeta<TData extends RowData, TValue> {\r\n align?: 'left' | 'center' | 'right';\r\n }\r\n}\r\nimport { cn } from '@lib/utils/cn';\r\nimport { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\r\nimport { Button } from '../button/Button';\r\nimport { Checkbox } from '../checkbox/Checkbox';\r\nimport { Spinner } from '../spinner/Spinner';\r\n\r\n// ─── Pagination Config ───────────────────────────────────────────────────────\r\n\r\nexport interface PaginationConfig {\r\n /** Trang hiện tại, 1-based (dùng để controlled ở server mode) */\r\n current?: number;\r\n /** Số dòng mỗi trang, default 10 */\r\n pageSize?: number;\r\n /** Tổng bản ghi từ BE — có giá trị → tự động bật server mode */\r\n total?: number;\r\n /** Danh sách page size options, default [5,10,20,50,100] */\r\n pageSizeOptions?: number[];\r\n /** Hiển thị info tổng. (total, range) => ReactNode */\r\n showTotal?: (total: number, range: [number, number]) => React.ReactNode;\r\n /** Ẩn/hiện page size selector, default true */\r\n showSizeChanger?: boolean;\r\n /**\r\n * Callback khi đổi trang / pageSize.\r\n * - Client mode: tuỳ chọn, chỉ để notify\r\n * - Server mode: bắt buộc, dùng để fetch API\r\n */\r\n onChange?: (page: number, pageSize: number) => void;\r\n}\r\n\r\nexport interface TableProps<TData, TValue = unknown> {\r\n data: TData[];\r\n columns: ColumnDef<TData, TValue>[];\r\n isLoading?: boolean;\r\n enableSorting?: boolean;\r\n enableRowSelection?: boolean;\r\n enableExpanding?: boolean;\r\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\r\n getRowCanExpand?: (row: TData) => boolean;\r\n onSelectionChange?: (selectedRows: TData[]) => void;\r\n className?: string;\r\n enableColumnResizing?: boolean;\r\n columnResizeMode?: ColumnResizeMode;\r\n /**\r\n * Cấu hình pagination.\r\n * - false / không truyền: tắt pagination\r\n * - {} object: bật pagination (client-side mặc định)\r\n * - { total }: bật server-side mode\r\n */\r\n pagination?: PaginationConfig | false;\r\n emptyText?: string;\r\n}\r\n\r\nexport function Table<TData, TValue = unknown>({\r\n data,\r\n columns,\r\n isLoading = false,\r\n enableSorting = true,\r\n enableRowSelection = false,\r\n enableExpanding = false,\r\n renderSubComponent,\r\n getRowCanExpand,\r\n onSelectionChange,\r\n className,\r\n enableColumnResizing = false,\r\n columnResizeMode = 'onChange',\r\n pagination: paginationProp = {},\r\n emptyText = 'Không có dữ liệu',\r\n}: TableProps<TData>) {\r\n // Xác định có bật pagination không\r\n const paginationEnabled = paginationProp !== false;\r\n const cfg = paginationEnabled ? (paginationProp as PaginationConfig) : {};\r\n\r\n // Server mode khi có cfg.total\r\n const isServerMode = paginationEnabled && cfg.total !== undefined;\r\n const pageSizeOptions = cfg.pageSizeOptions ?? [5, 10, 20, 50, 100];\r\n\r\n // Internal pagination state (0-based pageIndex cho tanstack)\r\n const [page, setPage] = useState(cfg.current ?? 1); // 1-based\r\n const [pageSize, setPageSize] = useState(cfg.pageSize ?? 10);\r\n\r\n // Sync controlled current từ ngoài vào (server mode)\r\n useEffect(() => {\r\n if (cfg.current !== undefined) setPage(cfg.current);\r\n }, [cfg.current]);\r\n\r\n // Derived\r\n const pageIndex = page - 1; // 0-based cho tanstack\r\n const totalRows = isServerMode ? cfg.total! : data.length;\r\n const pageCount = isServerMode ? Math.ceil(totalRows / pageSize) : undefined;\r\n\r\n const tanstackPagination: PaginationState = { pageIndex, pageSize };\r\n\r\n const handlePaginationChange = (updater: PaginationState | ((prev: PaginationState) => PaginationState)) => {\r\n const next = typeof updater === 'function' ? updater(tanstackPagination) : updater;\r\n const newPage = next.pageIndex + 1; // convert về 1-based\r\n const newPageSize = next.pageSize;\r\n\r\n if (!isServerMode) {\r\n setPage(newPage);\r\n setPageSize(newPageSize);\r\n }\r\n cfg.onChange?.(newPage, newPageSize);\r\n };\r\n\r\n const [sorting, setSorting] = useState<SortingState>([]);\r\n const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\r\n\r\n const finalColumns = React.useMemo(() => {\r\n const cols = [...columns];\r\n if (enableRowSelection) {\r\n cols.unshift({\r\n id: 'select',\r\n size: 10,\r\n minSize: 5,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n header: ({ table }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={table.getIsAllRowsSelected()}\r\n indeterminate={table.getIsSomeRowsSelected()}\r\n onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n cell: ({ row }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={row.getIsSelected()}\r\n disabled={!row.getCanSelect()}\r\n onCheckedChange={(checked) => row.toggleSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n enableSorting: false,\r\n });\r\n }\r\n if (enableExpanding) {\r\n cols.unshift({\r\n id: 'expander',\r\n header: () => null,\r\n size: 10,\r\n minSize: 10,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n cell: ({ row }) => row.getCanExpand() ? (\r\n <div className=\"flex items-center justify-center\">\r\n <span\r\n onClick={row.getToggleExpandedHandler()}\r\n className=\"hover:bg-muted text-muted-foreground transition-colors cursor-pointer outline-none focus:ring-2 focus:ring-primary/50 p-1 rounded-md border border-border\"\r\n >\r\n {row.getIsExpanded() ? <ChevronDown className=\"w-3 h-3\" /> : <ChevronRight className=\"w-3 h-3\" />}\r\n </span>\r\n </div>\r\n ) : null,\r\n enableSorting: false,\r\n });\r\n }\r\n return cols;\r\n }, [columns, enableRowSelection, enableExpanding]);\r\n\r\n const table = useReactTable({\r\n data,\r\n columns: finalColumns,\r\n state: { sorting, rowSelection, pagination: tanstackPagination },\r\n onSortingChange: setSorting,\r\n onRowSelectionChange: setRowSelection,\r\n onPaginationChange: handlePaginationChange,\r\n getCoreRowModel: getCoreRowModel(),\r\n getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,\r\n getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined,\r\n getFilteredRowModel: getFilteredRowModel(),\r\n getExpandedRowModel: getExpandedRowModel(),\r\n columnResizeMode,\r\n enableColumnResizing,\r\n enableRowSelection,\r\n manualPagination: isServerMode,\r\n pageCount: isServerMode ? pageCount : undefined,\r\n getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => !!renderSubComponent,\r\n });\r\n\r\n // Input go-to-page\r\n const [inputValue, setInputValue] = useState<string | number>(page);\r\n useEffect(() => { setInputValue(page); }, [page]);\r\n\r\n useEffect(() => {\r\n if (onSelectionChange) {\r\n const selected = table.getSelectedRowModel().rows.map(row => row.original);\r\n onSelectionChange(selected);\r\n }\r\n }, [rowSelection, onSelectionChange, table]);\r\n\r\n // Computed display values\r\n const currentPageIndex = table.getState().pagination.pageIndex;\r\n const currentPageSize = table.getState().pagination.pageSize;\r\n const from = currentPageIndex * currentPageSize + 1;\r\n const to = Math.min((currentPageIndex + 1) * currentPageSize, totalRows);\r\n const totalPageCount = isServerMode ? (pageCount ?? 1) : (table.getPageCount() || 1);\r\n\r\n return (\r\n <div className={cn(\"relative w-full rounded-md border border-border bg-background flex flex-col overflow-hidden\", className)}>\r\n\r\n {/* Loading Overlay */}\r\n {isLoading && (\r\n <div className=\"absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[0.5px]\">\r\n <Spinner size=\"lg\" variant=\"primary\" />\r\n </div>\r\n )}\r\n\r\n <div className=\"overflow-x-auto w-full\">\r\n <table\r\n className=\"w-full text-sm text-left text-foreground whitespace-nowrap\"\r\n style={{\r\n width: enableColumnResizing ? table.getCenterTotalSize() : undefined,\r\n tableLayout: enableColumnResizing ? 'fixed' : 'auto'\r\n }}\r\n >\r\n <thead className=\"text-xs text-muted-foreground bg-muted/50 border-b border-border\">\r\n {table.getHeaderGroups().map(headerGroup => (\r\n <tr key={headerGroup.id} >\r\n {headerGroup.headers.map(header => {\r\n const meta = header.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n const canSort = header.column.getCanSort() && enableSorting && header.column.id !== 'select';\r\n\r\n return (\r\n <th\r\n key={header.id}\r\n colSpan={header.colSpan}\r\n style={{\r\n width: enableColumnResizing ? header.getSize() : header.column.columnDef.size,\r\n position: 'relative'\r\n }}\r\n aria-sort={\r\n canSort\r\n ? header.column.getIsSorted() === 'desc'\r\n ? 'descending'\r\n : header.column.getIsSorted() === 'asc'\r\n ? 'ascending'\r\n : 'none'\r\n : undefined\r\n }\r\n className={cn(\r\n header.column.id === 'select' || header.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 font-semibold tracking-wide border border-border transition-colors group/header\",\r\n canSort ? \"cursor-pointer select-none hover:bg-muted\" : \"\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div\r\n className={cn(\"flex flex-col h-full\")}\r\n onClick={canSort ? header.column.getToggleSortingHandler() : undefined}\r\n >\r\n {header.isPlaceholder ? null : (\r\n <div className={cn(\r\n \"flex items-center gap-2\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-between\"\r\n )}>\r\n <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>\r\n {canSort && (\r\n <span className=\"shrink-0\">\r\n {{\r\n asc: <ChevronUp className=\"w-4 h-4 text-primary\" />,\r\n desc: <ChevronDown className=\"w-4 h-4 text-primary\" />,\r\n }[header.column.getIsSorted() as string] ?? (\r\n <div className=\"flex flex-col opacity-30 -space-y-1 hover:opacity-100 transition-opacity\">\r\n <ChevronUp className=\"w-3 h-3\" />\r\n <ChevronDown className=\"w-3 h-3\" />\r\n </div>\r\n )}\r\n </span>\r\n )}\r\n </div>\r\n )}\r\n </div>\r\n\r\n {/* Resize Handle */}\r\n {enableColumnResizing && header.column.getCanResize() && (\r\n <div\r\n {...{\r\n onMouseDown: header.getResizeHandler(),\r\n onTouchStart: header.getResizeHandler(),\r\n className: cn(\r\n \"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 transition-colors\",\r\n header.column.getIsResizing() ? \"bg-primary w-1.5 z-10\" : \"bg-transparent\"\r\n ),\r\n }}\r\n />\r\n )}\r\n </th>\r\n );\r\n })}\r\n </tr>\r\n ))}\r\n </thead>\r\n <tbody className=\"\">\r\n {!isLoading && data.length === 0 ? (\r\n <tr>\r\n <td colSpan={finalColumns.length} className=\" px-4 py-16 text-center text-muted-foreground\">\r\n <div className=\"flex flex-col items-center justify-center space-y-2\">\r\n <span className=\"text-muted-foreground/50\">\r\n <svg className=\"w-12 h-12\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1} d=\"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4\" />\r\n </svg>\r\n </span>\r\n <span>{emptyText}</span>\r\n </div>\r\n </td>\r\n </tr>\r\n ) : (\r\n table.getRowModel().rows.map(row => (\r\n <React.Fragment key={row.id}>\r\n <tr\r\n className={cn(\r\n \"hover:bg-muted/50 transition-colors group\",\r\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\",\r\n row.getIsExpanded() ? \"bg-primary/5\" : \"\"\r\n )}\r\n >\r\n {row.getVisibleCells().map(cell => {\r\n const meta = cell.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n return (\r\n <td\r\n key={cell.id}\r\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\r\n className={cn(\r\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 border border-border align-middle\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div className={cn(\r\n \"flex items-center\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\r\n )}>\r\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\r\n </div>\r\n </td>\r\n );\r\n })}\r\n </tr>\r\n {row.getIsExpanded() && renderSubComponent && (\r\n <tr>\r\n <td colSpan={row.getVisibleCells().length} className=\"p-0 border-b border-border whitespace-normal\">\r\n <div className=\"bg-muted/50 px-4 py-5 shadow-inner w-full border-l-4 border-l-primary/40 wrap-break-word\">\r\n {renderSubComponent({ row: row.original })}\r\n </div>\r\n </td>\r\n </tr>\r\n )}\r\n </React.Fragment>\r\n ))\r\n )}\r\n </tbody>\r\n </table>\r\n </div>\r\n\r\n {/* Pagination Controls */}\r\n {paginationEnabled && totalPageCount > 0 && (\r\n <div className=\"flex flex-col sm:flex-row items-center justify-between px-3 py-2.5 border-t border-border bg-muted/50 gap-2\">\r\n {/* showTotal info */}\r\n <div className=\"text-xs text-muted-foreground shrink-0 order-2 sm:order-1\">\r\n {cfg.showTotal\r\n ? cfg.showTotal(totalRows, [from, to])\r\n : <>Show <b>{from}</b>–<b>{to}</b> of <b>{totalRows}</b> results</>\r\n }\r\n </div>\r\n\r\n {/* Controls */}\r\n <div className=\"flex items-center gap-2 overflow-x-auto order-1 sm:order-2 w-full sm:w-auto pb-0.5 sm:pb-0\">\r\n {/* Page size */}\r\n {(cfg.showSizeChanger !== false) && (\r\n <select\r\n value={currentPageSize}\r\n onChange={e => table.setPageSize(Number(e.target.value))}\r\n className=\"shrink-0 px-2 py-1 text-xs border border-border rounded-md bg-background text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary cursor-pointer\"\r\n >\r\n {pageSizeOptions.map(s => (\r\n <option key={s} value={s}>{s} / trang</option>\r\n ))}\r\n </select>\r\n )}\r\n\r\n {/* Nav buttons */}\r\n <div className=\"flex items-center gap-1 shrink-0\">\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(0)}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"First page\"\r\n >\r\n <ChevronsLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.previousPage()}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"Previous page\"\r\n >\r\n <ChevronLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n\r\n <span className=\"text-xs font-medium px-2.5 py-1 bg-background border border-border rounded shrink-0 min-w-14 text-center text-foreground\">\r\n {currentPageIndex + 1} / {totalPageCount}\r\n </span>\r\n\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.nextPage()}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Next page\"\r\n >\r\n <ChevronRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(totalPageCount - 1)}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Last page\"\r\n >\r\n <ChevronsRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n </div>\r\n\r\n {/* Go to page */}\r\n <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground border-l border-border pl-2 ml-1 shrink-0\">\r\n <span className=\"hidden sm:inline\">Trang</span>\r\n <input\r\n type=\"number\"\r\n min={1}\r\n max={totalPageCount}\r\n value={inputValue}\r\n onChange={e => {\r\n const val = e.target.value;\r\n setInputValue(val);\r\n const p = val ? Number(val) - 1 : 0;\r\n if (val && p >= 0 && p < totalPageCount) {\r\n table.setPageIndex(p);\r\n }\r\n }}\r\n onBlur={() => setInputValue(currentPageIndex + 1)}\r\n className=\"w-10 px-1 py-1 text-xs border border-border rounded bg-background text-foreground text-center outline-none focus:border-primary focus:ring-1 focus:ring-primary\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n </div>\r\n );\r\n}\r\n"
580
+ "content": "'use client';\r\n\r\nimport React, { useEffect, useRef, useState } from 'react';\r\nimport {\r\n useReactTable,\r\n getCoreRowModel,\r\n getSortedRowModel,\r\n getPaginationRowModel,\r\n getFilteredRowModel,\r\n getExpandedRowModel,\r\n flexRender,\r\n type ColumnDef,\r\n type SortingState,\r\n type PaginationState,\r\n type RowSelectionState,\r\n type RowData,\r\n type ColumnResizeMode,\r\n} from '@tanstack/react-table';\r\nimport { useVirtualizer } from '@tanstack/react-virtual';\r\n\r\ndeclare module '@tanstack/react-table' {\r\n interface ColumnMeta<TData extends RowData, TValue> {\r\n align?: 'left' | 'center' | 'right';\r\n }\r\n}\r\nimport { cn } from '@lib/utils/cn';\r\nimport { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\r\nimport { Button } from '../button/Button';\r\nimport { Checkbox } from '../checkbox/Checkbox';\r\nimport { Spinner } from '../spinner/Spinner';\r\n\r\n// ─── Pagination Config ───────────────────────────────────────────────────────\r\n\r\nexport interface PaginationConfig {\r\n /** Trang hiện tại, 1-based (dùng để controlled ở server mode) */\r\n current?: number;\r\n /** Số dòng mỗi trang, default 10 */\r\n pageSize?: number;\r\n /** Tổng bản ghi từ BE — có giá trị → tự động bật server mode */\r\n total?: number;\r\n /** Danh sách page size options, default [5,10,20,50,100] */\r\n pageSizeOptions?: number[];\r\n /** Hiển thị info tổng. (total, range) => ReactNode */\r\n showTotal?: (total: number, range: [number, number]) => React.ReactNode;\r\n /** Ẩn/hiện page size selector, default true */\r\n showSizeChanger?: boolean;\r\n /**\r\n * Callback khi đổi trang / pageSize.\r\n * - Client mode: tuỳ chọn, chỉ để notify\r\n * - Server mode: bắt buộc, dùng để fetch API\r\n */\r\n onChange?: (page: number, pageSize: number) => void;\r\n}\r\n\r\n/** i18n labels for Table UI strings */\r\nexport interface TableLabels {\r\n /** Label for the page input prefix. Default: \"Page\" */\r\n page?: string;\r\n /** Suffix for page size selector. Default: \"/ page\" */\r\n perPage?: string;\r\n /** Text shown when there is no data. Default: \"No data\" */\r\n empty?: string;\r\n}\r\n\r\nexport interface TableProps<TData, TValue = unknown> {\r\n data: TData[];\r\n columns: ColumnDef<TData, TValue>[];\r\n isLoading?: boolean;\r\n enableSorting?: boolean;\r\n enableRowSelection?: boolean;\r\n enableExpanding?: boolean;\r\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\r\n getRowCanExpand?: (row: TData) => boolean;\r\n onSelectionChange?: (selectedRows: TData[]) => void;\r\n className?: string;\r\n enableColumnResizing?: boolean;\r\n columnResizeMode?: ColumnResizeMode;\r\n /**\r\n * Pagination config.\r\n * - false / omitted: disable pagination\r\n * - {} object: enable (client-side by default)\r\n * - { total }: enable server-side mode\r\n */\r\n pagination?: PaginationConfig | false;\r\n /** @deprecated Use `labels.empty` instead */\r\n emptyText?: string;\r\n /** i18n labels for UI strings */\r\n labels?: TableLabels;\r\n /**\r\n * Enable row virtualization for large datasets.\r\n * Requires @tanstack/react-virtual to be installed.\r\n * When enabled, `pagination` is ignored.\r\n */\r\n virtualize?: boolean;\r\n /**\r\n * Height (px) of the virtualized scroll container.\r\n * Only used when `virtualize={true}`. Default: 400\r\n */\r\n virtualHeight?: number;\r\n /**\r\n * Estimated row height (px) for the virtualizer.\r\n * Only used when `virtualize={true}`. Default: 45\r\n */\r\n estimatedRowHeight?: number;\r\n}\r\n\r\nexport function Table<TData, TValue = unknown>({\r\n data = [],\r\n columns,\r\n isLoading = false,\r\n enableSorting = true,\r\n enableRowSelection = false,\r\n enableExpanding = false,\r\n renderSubComponent,\r\n getRowCanExpand,\r\n onSelectionChange,\r\n className,\r\n enableColumnResizing = false,\r\n columnResizeMode = 'onChange',\r\n pagination: paginationProp = {},\r\n emptyText,\r\n labels,\r\n virtualize = false,\r\n virtualHeight = 400,\r\n estimatedRowHeight = 45,\r\n}: TableProps<TData>) {\r\n const resolvedEmptyText = emptyText ?? labels?.empty ?? 'No data';\r\n // Virtualization setup\r\n const scrollContainerRef = useRef<HTMLDivElement>(null);\r\n // Xác định có bật pagination không\r\n const paginationEnabled = paginationProp !== false;\r\n const cfg = paginationEnabled ? (paginationProp as PaginationConfig) : {};\r\n\r\n // Server mode khi có cfg.total\r\n const isServerMode = paginationEnabled && cfg.total !== undefined;\r\n const pageSizeOptions = cfg.pageSizeOptions ?? [5, 10, 20, 50, 100];\r\n\r\n // Internal pagination state (0-based pageIndex cho tanstack)\r\n const [page, setPage] = useState(cfg.current ?? 1); // 1-based\r\n const [pageSize, setPageSize] = useState(cfg.pageSize ?? 10);\r\n\r\n // Sync controlled current/pageSize từ ngoài vào (server mode)\r\n useEffect(() => {\r\n if (cfg.current !== undefined) setPage(cfg.current);\r\n }, [cfg.current]);\r\n\r\n useEffect(() => {\r\n if (cfg.pageSize !== undefined) setPageSize(cfg.pageSize);\r\n }, [cfg.pageSize]);\r\n\r\n // Derived\r\n const pageIndex = page - 1; // 0-based cho tanstack\r\n const totalRows = isServerMode ? cfg.total! : data.length;\r\n const pageCount = isServerMode ? Math.ceil(totalRows / pageSize) : undefined;\r\n\r\n const tanstackPagination: PaginationState = { pageIndex, pageSize };\r\n\r\n const handlePaginationChange = (updater: PaginationState | ((prev: PaginationState) => PaginationState)) => {\r\n const next = typeof updater === 'function' ? updater(tanstackPagination) : updater;\r\n const newPage = next.pageIndex + 1; // convert về 1-based\r\n const newPageSize = next.pageSize;\r\n\r\n if (!isServerMode) {\r\n setPage(newPage);\r\n setPageSize(newPageSize);\r\n }\r\n cfg.onChange?.(newPage, newPageSize);\r\n };\r\n\r\n const [sorting, setSorting] = useState<SortingState>([]);\r\n const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\r\n\r\n const finalColumns = React.useMemo(() => {\r\n const cols = [...columns];\r\n if (enableRowSelection) {\r\n cols.unshift({\r\n id: 'select',\r\n size: 10,\r\n minSize: 5,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n header: ({ table }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={table.getIsAllRowsSelected()}\r\n indeterminate={table.getIsSomeRowsSelected()}\r\n onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n cell: ({ row }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={row.getIsSelected()}\r\n disabled={!row.getCanSelect()}\r\n onCheckedChange={(checked) => row.toggleSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n enableSorting: false,\r\n });\r\n }\r\n if (enableExpanding) {\r\n cols.unshift({\r\n id: 'expander',\r\n header: () => null,\r\n size: 10,\r\n minSize: 10,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n cell: ({ row }) => row.getCanExpand() ? (\r\n <div className=\"flex items-center justify-center\">\r\n <span\r\n onClick={row.getToggleExpandedHandler()}\r\n className=\"hover:bg-muted text-muted-foreground transition-colors cursor-pointer outline-none focus:ring-2 focus:ring-primary/50 p-1 rounded-md border border-border\"\r\n >\r\n {row.getIsExpanded() ? <ChevronDown className=\"w-3 h-3\" /> : <ChevronRight className=\"w-3 h-3\" />}\r\n </span>\r\n </div>\r\n ) : null,\r\n enableSorting: false,\r\n });\r\n }\r\n return cols;\r\n }, [columns, enableRowSelection, enableExpanding]);\r\n\r\n const table = useReactTable({\r\n data,\r\n columns: finalColumns,\r\n state: { sorting, rowSelection, pagination: tanstackPagination },\r\n onSortingChange: setSorting,\r\n onRowSelectionChange: setRowSelection,\r\n onPaginationChange: handlePaginationChange,\r\n getCoreRowModel: getCoreRowModel(),\r\n getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,\r\n getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined,\r\n getFilteredRowModel: getFilteredRowModel(),\r\n getExpandedRowModel: getExpandedRowModel(),\r\n columnResizeMode,\r\n enableColumnResizing,\r\n enableRowSelection,\r\n manualPagination: isServerMode,\r\n pageCount: isServerMode ? pageCount : undefined,\r\n getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => !!renderSubComponent,\r\n });\r\n\r\n // Input go-to-page\r\n const [inputValue, setInputValue] = useState<string | number>(page);\r\n useEffect(() => { setInputValue(page); }, [page]);\r\n\r\n useEffect(() => {\r\n if (onSelectionChange) {\r\n const selected = table.getSelectedRowModel().rows.map(row => row.original);\r\n onSelectionChange(selected);\r\n }\r\n }, [rowSelection, onSelectionChange, table]);\r\n\r\n // Computed display values\r\n const currentPageIndex = table.getState().pagination.pageIndex;\r\n const currentPageSize = table.getState().pagination.pageSize;\r\n const from = currentPageIndex * currentPageSize + 1;\r\n const to = Math.min((currentPageIndex + 1) * currentPageSize, totalRows);\r\n const totalPageCount = isServerMode ? (pageCount ?? 1) : (table.getPageCount() || 1);\r\n\r\n // Virtualizer — only active when virtualize=true\r\n const allRows = virtualize ? table.getCoreRowModel().rows : [];\r\n const rowVirtualizer = useVirtualizer({\r\n count: virtualize ? allRows.length : 0,\r\n getScrollElement: () => scrollContainerRef.current,\r\n estimateSize: () => estimatedRowHeight,\r\n overscan: 8,\r\n enabled: virtualize,\r\n });\r\n const virtualItems = virtualize ? rowVirtualizer.getVirtualItems() : [];\r\n\r\n return (\r\n <div className={cn(\"relative w-full rounded-md border border-border bg-background flex flex-col overflow-hidden\", className)}>\r\n\r\n {/* Loading Overlay */}\r\n {isLoading && (\r\n <div className=\"absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[0.5px]\">\r\n <Spinner size=\"lg\" variant=\"primary\" />\r\n </div>\r\n )}\r\n\r\n <div\r\n ref={scrollContainerRef}\r\n className=\"overflow-x-auto w-full\"\r\n style={virtualize ? { overflowY: 'auto', maxHeight: virtualHeight } : undefined}\r\n >\r\n <table\r\n className=\"w-full text-sm text-left text-foreground whitespace-nowrap\"\r\n style={{\r\n width: enableColumnResizing ? table.getCenterTotalSize() : undefined,\r\n tableLayout: enableColumnResizing ? 'fixed' : 'auto'\r\n }}\r\n >\r\n <thead className=\"text-xs text-muted-foreground bg-muted/50 border-b border-border\">\r\n {table.getHeaderGroups().map(headerGroup => (\r\n <tr key={headerGroup.id} >\r\n {headerGroup.headers.map(header => {\r\n const meta = header.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n const canSort = header.column.getCanSort() && enableSorting && header.column.id !== 'select';\r\n\r\n return (\r\n <th\r\n key={header.id}\r\n colSpan={header.colSpan}\r\n style={{\r\n width: enableColumnResizing ? header.getSize() : header.column.columnDef.size,\r\n position: 'relative'\r\n }}\r\n aria-sort={\r\n canSort\r\n ? header.column.getIsSorted() === 'desc'\r\n ? 'descending'\r\n : header.column.getIsSorted() === 'asc'\r\n ? 'ascending'\r\n : 'none'\r\n : undefined\r\n }\r\n className={cn(\r\n header.column.id === 'select' || header.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 font-semibold tracking-wide border border-border transition-colors group/header\",\r\n canSort ? \"cursor-pointer select-none hover:bg-muted\" : \"\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div\r\n className={cn(\"flex flex-col h-full\")}\r\n onClick={canSort ? header.column.getToggleSortingHandler() : undefined}\r\n >\r\n {header.isPlaceholder ? null : (\r\n <div className={cn(\r\n \"flex items-center gap-2\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-between\"\r\n )}>\r\n <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>\r\n {canSort && (\r\n <span className=\"shrink-0\">\r\n {{\r\n asc: <ChevronUp className=\"w-4 h-4 text-primary\" />,\r\n desc: <ChevronDown className=\"w-4 h-4 text-primary\" />,\r\n }[header.column.getIsSorted() as string] ?? (\r\n <div className=\"flex flex-col opacity-30 -space-y-1 hover:opacity-100 transition-opacity\">\r\n <ChevronUp className=\"w-3 h-3\" />\r\n <ChevronDown className=\"w-3 h-3\" />\r\n </div>\r\n )}\r\n </span>\r\n )}\r\n </div>\r\n )}\r\n </div>\r\n\r\n {/* Resize Handle */}\r\n {enableColumnResizing && header.column.getCanResize() && (\r\n <div\r\n {...{\r\n onMouseDown: header.getResizeHandler(),\r\n onTouchStart: header.getResizeHandler(),\r\n className: cn(\r\n \"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 transition-colors\",\r\n header.column.getIsResizing() ? \"bg-primary w-1.5 z-10\" : \"bg-transparent\"\r\n ),\r\n }}\r\n />\r\n )}\r\n </th>\r\n );\r\n })}\r\n </tr>\r\n ))}\r\n </thead>\r\n <tbody className=\"\">\r\n {!isLoading && data.length === 0 ? (\r\n <tr>\r\n <td colSpan={finalColumns.length} className=\" px-4 py-16 text-center text-muted-foreground\">\r\n <div className=\"flex flex-col items-center justify-center space-y-2\">\r\n <span className=\"text-muted-foreground/50\">\r\n <svg className=\"w-12 h-12\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1} d=\"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4\" />\r\n </svg>\r\n </span>\r\n <span>{resolvedEmptyText}</span>\r\n </div>\r\n </td>\r\n </tr>\r\n ) : virtualize ? (\r\n // ── Virtual rows ──────────────────────────────────\r\n <>\r\n {rowVirtualizer.getTotalSize() > 0 && (\r\n <tr aria-hidden=\"true\">\r\n <td style={{ height: virtualItems[0]?.start ?? 0, padding: 0, border: 0 }} colSpan={finalColumns.length} />\r\n </tr>\r\n )}\r\n {virtualItems.map(virtualRow => {\r\n const row = allRows[virtualRow.index];\r\n if (!row) return null;\r\n return (\r\n <tr\r\n key={row.id}\r\n data-index={virtualRow.index}\r\n ref={rowVirtualizer.measureElement}\r\n className={cn(\r\n \"hover:bg-muted/50 transition-colors\",\r\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\"\r\n )}\r\n >\r\n {row.getVisibleCells().map(cell => {\r\n const meta = cell.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n return (\r\n <td\r\n key={cell.id}\r\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\r\n className={cn(\r\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 border border-border align-middle\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div className={cn(\r\n \"flex items-center\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\r\n )}>\r\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\r\n </div>\r\n </td>\r\n );\r\n })}\r\n </tr>\r\n );\r\n })}\r\n {rowVirtualizer.getTotalSize() > 0 && (\r\n <tr aria-hidden=\"true\">\r\n <td\r\n style={{\r\n height: rowVirtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0),\r\n padding: 0,\r\n border: 0,\r\n }}\r\n colSpan={finalColumns.length}\r\n />\r\n </tr>\r\n )}\r\n </>\r\n ) : (\r\n // ── Normal rows ───────────────────────────────────\r\n table.getRowModel().rows.map(row => (\r\n <React.Fragment key={row.id}>\r\n <tr\r\n className={cn(\r\n \"hover:bg-muted/50 transition-colors group\",\r\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\",\r\n row.getIsExpanded() ? \"bg-primary/5\" : \"\"\r\n )}\r\n >\r\n {row.getVisibleCells().map(cell => {\r\n const meta = cell.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n return (\r\n <td\r\n key={cell.id}\r\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\r\n className={cn(\r\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 border border-border align-middle\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div className={cn(\r\n \"flex items-center\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\r\n )}>\r\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\r\n </div>\r\n </td>\r\n );\r\n })}\r\n </tr>\r\n {row.getIsExpanded() && renderSubComponent && (\r\n <tr>\r\n <td colSpan={row.getVisibleCells().length} className=\"p-0 border-b border-border whitespace-normal\">\r\n <div className=\"bg-muted/50 px-4 py-5 shadow-inner w-full border-l-4 border-l-primary/40 wrap-break-word\">\r\n {renderSubComponent({ row: row.original })}\r\n </div>\r\n </td>\r\n </tr>\r\n )}\r\n </React.Fragment>\r\n ))\r\n )}\r\n </tbody>\r\n </table>\r\n </div>\r\n\r\n {/* Pagination Controls — hidden in virtualize mode */}\r\n {!virtualize && paginationEnabled && totalPageCount > 0 && (\r\n <div className=\"flex flex-col sm:flex-row items-center justify-between px-3 py-2.5 border-t border-border bg-muted/50 gap-2\">\r\n {/* showTotal info */}\r\n <div className=\"text-xs text-muted-foreground shrink-0 order-2 sm:order-1\">\r\n {cfg.showTotal\r\n ? cfg.showTotal(totalRows, [from, to])\r\n : <>Show <b>{from}</b>–<b>{to}</b> of <b>{totalRows}</b> results</>\r\n }\r\n </div>\r\n\r\n {/* Controls */}\r\n <div className=\"flex items-center gap-2 overflow-x-auto order-1 sm:order-2 w-full sm:w-auto pb-0.5 sm:pb-0\">\r\n {/* Page size */}\r\n {(cfg.showSizeChanger !== false) && (\r\n <select\r\n value={currentPageSize}\r\n onChange={e => table.setPageSize(Number(e.target.value))}\r\n className=\"shrink-0 px-2 py-1 text-xs border border-border rounded-md bg-background text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary cursor-pointer\"\r\n >\r\n {pageSizeOptions.map(s => (\r\n <option key={s} value={s}>{s}{labels?.perPage ?? ' / page'}</option>\r\n ))}\r\n </select>\r\n )}\r\n\r\n {/* Nav buttons */}\r\n <div className=\"flex items-center gap-1 shrink-0\">\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(0)}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"First page\"\r\n >\r\n <ChevronsLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.previousPage()}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"Previous page\"\r\n >\r\n <ChevronLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n\r\n <span className=\"text-xs font-medium px-2.5 py-1 bg-background border border-border rounded shrink-0 min-w-14 text-center text-foreground\">\r\n {currentPageIndex + 1} / {totalPageCount}\r\n </span>\r\n\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.nextPage()}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Next page\"\r\n >\r\n <ChevronRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(totalPageCount - 1)}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Last page\"\r\n >\r\n <ChevronsRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n </div>\r\n\r\n {/* Go to page */}\r\n <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground border-l border-border pl-2 ml-1 shrink-0\">\r\n <span className=\"hidden sm:inline\">{labels?.page ?? 'Page'}</span>\r\n <input\r\n type=\"number\"\r\n min={1}\r\n max={totalPageCount}\r\n value={inputValue}\r\n onChange={e => {\r\n const val = e.target.value;\r\n setInputValue(val);\r\n const p = val ? Number(val) - 1 : 0;\r\n if (val && p >= 0 && p < totalPageCount) {\r\n table.setPageIndex(p);\r\n }\r\n }}\r\n onBlur={() => setInputValue(currentPageIndex + 1)}\r\n className=\"w-10 px-1 py-1 text-xs border border-border rounded bg-background text-foreground text-center outline-none focus:border-primary focus:ring-1 focus:ring-primary\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n </div>\r\n );\r\n}\r\n"
484
581
  }
485
582
  ]
486
583
  },
@@ -494,7 +591,7 @@
494
591
  "files": [
495
592
  {
496
593
  "path": "src/components/ui/tabs/Tabs.tsx",
497
- "content": "import * as React from 'react';\r\nimport { Tabs as BaseTabs } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\nconst tabsVariants = tv({\r\n slots: {\r\n rootSlots: 'flex flex-col w-full',\r\n list: 'relative inline-flex items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground w-fit',\r\n indicator: 'absolute top-1 bottom-1 left-[var(--active-tab-left)] w-[var(--active-tab-width)] rounded-md bg-background shadow-sm transition-all duration-300 ease-out z-0',\r\n trigger: 'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-active:text-foreground data-active:font-semibold',\r\n panel: 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n }\r\n});\r\n\r\nconst { rootSlots, list, indicator, trigger, panel } = tabsVariants();\r\n\r\nexport interface TabsProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.Root> {\r\n}\r\n\r\nconst Tabs = React.forwardRef<React.ElementRef<typeof BaseTabs.Root>, TabsProps>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <BaseTabs.Root\r\n ref={ref}\r\n className={cn(rootSlots(), className)}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nTabs.displayName = 'Tabs';\r\n\r\nexport interface TabsListProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.List> {\r\n}\r\n\r\nconst TabsList = React.forwardRef<React.ElementRef<typeof BaseTabs.List>, TabsListProps>(\r\n ({ className, children, ...props }, ref) => {\r\n return (\r\n <BaseTabs.List ref={ref} className={cn(list(), className)} {...props}>\r\n <BaseTabs.Indicator className={indicator()} />\r\n {children}\r\n </BaseTabs.List>\r\n );\r\n }\r\n);\r\nTabsList.displayName = 'TabsList';\r\n\r\nexport interface TabsTriggerProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.Tab> {\r\n}\r\n\r\nconst TabsTrigger = React.forwardRef<React.ElementRef<typeof BaseTabs.Tab>, TabsTriggerProps>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <BaseTabs.Tab ref={ref} className={cn(trigger(), className)} {...props} />\r\n );\r\n }\r\n);\r\nTabsTrigger.displayName = 'TabsTrigger';\r\n\r\nexport interface TabsContentProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.Panel> {\r\n}\r\n\r\nconst TabsContent = React.forwardRef<React.ElementRef<typeof BaseTabs.Panel>, TabsContentProps>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <BaseTabs.Panel ref={ref} className={cn(panel(), className)} {...props} />\r\n );\r\n }\r\n);\r\nTabsContent.displayName = 'TabsContent';\r\n\r\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\r\n"
594
+ "content": "import * as React from 'react';\r\nimport { Tabs as BaseTabs } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst tabsVariants = tv({\r\n slots: {\r\n rootSlots: 'flex flex-col w-full',\r\n list: 'relative inline-flex items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground w-fit',\r\n indicator: 'absolute top-1 bottom-1 left-[var(--active-tab-left)] w-[var(--active-tab-width)] rounded-md bg-background shadow-sm transition-all duration-300 ease-out z-0',\r\n trigger: 'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-active:text-foreground data-active:font-semibold',\r\n panel: 'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n }\r\n});\r\n\r\nconst { rootSlots, list, indicator, trigger, panel } = tabsVariants();\r\n\r\n/** Props for the root Tabs container */\r\nexport interface TabsProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.Root> {\r\n}\r\n\r\nconst Tabs = React.forwardRef<React.ElementRef<typeof BaseTabs.Root>, TabsProps>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <BaseTabs.Root\r\n ref={ref}\r\n className={cn(rootSlots(), className)}\r\n {...props}\r\n />\r\n );\r\n }\r\n);\r\nTabs.displayName = 'Tabs';\r\n\r\n/** Props for the tab list container that holds tab triggers */\r\nexport interface TabsListProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.List> {\r\n}\r\n\r\nconst TabsList = React.forwardRef<React.ElementRef<typeof BaseTabs.List>, TabsListProps>(\r\n ({ className, children, ...props }, ref) => {\r\n return (\r\n <BaseTabs.List ref={ref} className={cn(list(), className)} {...props}>\r\n <BaseTabs.Indicator className={indicator()} />\r\n {children}\r\n </BaseTabs.List>\r\n );\r\n }\r\n);\r\nTabsList.displayName = 'TabsList';\r\n\r\n/** Props for an individual tab trigger button */\r\nexport interface TabsTriggerProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.Tab> {\r\n}\r\n\r\nconst TabsTrigger = React.forwardRef<React.ElementRef<typeof BaseTabs.Tab>, TabsTriggerProps>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <BaseTabs.Tab ref={ref} className={cn(trigger(), className)} {...props} />\r\n );\r\n }\r\n);\r\nTabsTrigger.displayName = 'TabsTrigger';\r\n\r\n/** Props for a tab content panel */\r\nexport interface TabsContentProps extends React.ComponentPropsWithoutRef<typeof BaseTabs.Panel> {\r\n}\r\n\r\nconst TabsContent = React.forwardRef<React.ElementRef<typeof BaseTabs.Panel>, TabsContentProps>(\r\n ({ className, ...props }, ref) => {\r\n return (\r\n <BaseTabs.Panel ref={ref} className={cn(panel(), className)} {...props} />\r\n );\r\n }\r\n);\r\nTabsContent.displayName = 'TabsContent';\r\n\r\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\r\n"
498
595
  }
499
596
  ]
500
597
  },
@@ -506,13 +603,9 @@
506
603
  ],
507
604
  "internalDependencies": [],
508
605
  "files": [
509
- {
510
- "path": "src/components/ui/textarea/Textarea.test.tsx",
511
- "content": "import { render, screen } from '@testing-library/react';\nimport { describe, it, expect } from 'vitest';\nimport { Textarea } from './Textarea';\nimport React from 'react';\n\ndescribe('Textarea', () => {\n it('renders correctly', () => {\n render(<Textarea placeholder=\"Type your message\" />);\n const textarea = screen.getByPlaceholderText(/type your message/i);\n expect(textarea).toBeInTheDocument();\n });\n\n it('forwards ref correctly', () => {\n const ref = React.createRef<HTMLTextAreaElement>();\n render(<Textarea ref={ref} />);\n expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);\n });\n\n it('renders label and description correctly', () => {\n render(\n <Textarea \n label=\"Message\" \n description=\"Max 500 characters\" \n id=\"textarea-1\"\n />\n );\n \n expect(screen.getByText('Message')).toBeInTheDocument();\n expect(screen.getByText('Max 500 characters')).toBeInTheDocument();\n });\n\n it('displays error message and applies error styles', () => {\n const { container } = render(<Textarea error=\"This field is required\" />);\n \n expect(screen.getByText('This field is required')).toBeInTheDocument();\n \n const textarea = container.querySelector('textarea');\n expect(textarea?.className).toContain('border-danger');\n });\n});\n"
512
- },
513
606
  {
514
607
  "path": "src/components/ui/textarea/Textarea.tsx",
515
- "content": "import * as React from 'react';\nimport { Field as BaseField } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@lib/utils/cn';\n\nconst textareaVariants = tv({\n base: 'flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:ring-0 placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\n variants: {\n variant: {\n default: '',\n filled: 'bg-accent border-transparent focus-visible:border-primary focus-visible:ring-0',\n }\n },\n defaultVariants: {\n variant: 'default'\n }\n});\n\nexport interface TextareaProps \n extends React.TextareaHTMLAttributes<HTMLTextAreaElement>, \n VariantProps<typeof textareaVariants> {\n label?: string;\n error?: string;\n description?: string;\n}\n\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n ({ className, variant, label, error, description, id, ...props }, ref) => {\n const defaultId = React.useId();\n const textareaId = id || defaultId;\n\n return (\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\n {label && (\n <BaseField.Label htmlFor={textareaId} className=\"text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\n {label}\n </BaseField.Label>\n )}\n \n <BaseField.Control render={\n <textarea\n ref={ref}\n id={textareaId}\n className={cn(\n textareaVariants({ variant }),\n error && 'border-danger focus-visible:ring-danger',\n className\n )}\n {...props}\n />\n } />\n \n {description && !error && (\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\n {description}\n </BaseField.Description>\n )}\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">\n {error}\n </p>\n )}\n </BaseField.Root>\n );\n }\n);\nTextarea.displayName = 'Textarea';\n\nexport { Textarea };\n"
608
+ "content": "import * as React from 'react';\r\nimport { Field as BaseField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst textareaVariants = tv({\r\n base: 'flex min-h-[80px] w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:border-primary focus:ring-0 placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\r\n variants: {\r\n variant: {\r\n default: '',\r\n filled: 'bg-accent border-transparent focus-visible:border-primary focus-visible:ring-0',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\n/** Props for the Textarea component */\r\nexport interface TextareaProps\r\n extends React.TextareaHTMLAttributes<HTMLTextAreaElement>,\r\n VariantProps<typeof textareaVariants> {\r\n /** Label text displayed above the textarea */\r\n label?: string;\r\n /** Error message displayed below the textarea (replaces description) */\r\n error?: string;\r\n /** Helper text displayed below the textarea */\r\n description?: string;\r\n}\r\n\r\nconst Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\r\n ({ className, variant, label, error, description, id, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const textareaId = id || defaultId;\r\n\r\n return (\r\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <BaseField.Label htmlFor={textareaId} className=\"text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\r\n {label}\r\n </BaseField.Label>\r\n )}\r\n \r\n <BaseField.Control render={\r\n <textarea\r\n ref={ref}\r\n id={textareaId}\r\n className={cn(\r\n textareaVariants({ variant }),\r\n error && 'border-danger focus-visible:ring-danger',\r\n className\r\n )}\r\n {...props}\r\n />\r\n } />\r\n \r\n {description && !error && (\r\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\r\n {description}\r\n </BaseField.Description>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">\r\n {error}\r\n </p>\r\n )}\r\n </BaseField.Root>\r\n );\r\n }\r\n);\r\nTextarea.displayName = 'Textarea';\r\n\r\nexport { Textarea };\r\n"
516
609
  }
517
610
  ]
518
611
  },
@@ -538,7 +631,7 @@
538
631
  "files": [
539
632
  {
540
633
  "path": "src/components/ui/toggle/Toggle.tsx",
541
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@lib/utils/cn';\r\n\r\nconst toggleVariants = tv({\r\n base: [\r\n 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium text-sm',\r\n 'transition-all duration-150 cursor-pointer select-none',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n 'border',\r\n ],\r\n variants: {\r\n variant: {\r\n default: [\r\n 'bg-transparent border-border text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-secondary data-[state=on]:text-foreground data-[state=on]:border-secondary',\r\n ],\r\n outline: [\r\n 'bg-transparent border-border text-muted-foreground',\r\n 'hover:border-primary/50 hover:text-primary',\r\n 'data-[state=on]:bg-primary/10 data-[state=on]:text-primary data-[state=on]:border-primary/50',\r\n ],\r\n solid: [\r\n 'bg-transparent border-transparent text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:border-primary',\r\n ],\r\n ghost: [\r\n 'bg-transparent border-transparent text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-muted data-[state=on]:text-foreground',\r\n ],\r\n },\r\n size: {\r\n sm: 'h-7 px-2 text-xs',\r\n md: 'h-9 px-3 text-sm',\r\n lg: 'h-11 px-4 text-base',\r\n icon: 'h-9 w-9',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n size: 'md',\r\n },\r\n});\r\n\r\nexport interface ToggleProps\r\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'>,\r\n VariantProps<typeof toggleVariants> {\r\n pressed?: boolean;\r\n defaultPressed?: boolean;\r\n onPressedChange?: (pressed: boolean) => void;\r\n}\r\n\r\nconst Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(\r\n (\r\n {\r\n pressed: controlledPressed,\r\n defaultPressed = false,\r\n onPressedChange,\r\n variant,\r\n size,\r\n className,\r\n children,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const isControlled = controlledPressed !== undefined;\r\n const [internalPressed, setInternalPressed] = React.useState(defaultPressed);\r\n\r\n const isPressed = isControlled ? controlledPressed! : internalPressed;\r\n\r\n const handleClick = () => {\r\n const next = !isPressed;\r\n if (!isControlled) setInternalPressed(next);\r\n onPressedChange?.(next);\r\n };\r\n\r\n return (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n aria-pressed={isPressed}\r\n data-state={isPressed ? 'on' : 'off'}\r\n onClick={handleClick}\r\n className={toggleVariants({ variant, size, className })}\r\n {...props}\r\n >\r\n {children}\r\n </button>\r\n );\r\n }\r\n);\r\n\r\nToggle.displayName = 'Toggle';\r\n\r\n// ─── ToggleGroup ─────────────────────────────────────────────────────────────\r\n\r\ninterface ToggleGroupContextValue {\r\n value: string[];\r\n onValueChange: (value: string[]) => void;\r\n type: 'single' | 'multiple';\r\n variant?: VariantProps<typeof toggleVariants>['variant'];\r\n size?: VariantProps<typeof toggleVariants>['size'];\r\n}\r\n\r\nconst ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null);\r\n\r\nexport interface ToggleGroupProps\r\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\r\n type?: 'single' | 'multiple';\r\n value?: string[];\r\n defaultValue?: string[];\r\n onValueChange?: (value: string[]) => void;\r\n variant?: VariantProps<typeof toggleVariants>['variant'];\r\n size?: VariantProps<typeof toggleVariants>['size'];\r\n children: React.ReactNode;\r\n disabled?: boolean;\r\n}\r\n\r\nconst ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>(\r\n (\r\n {\r\n type = 'single',\r\n value: controlledValue,\r\n defaultValue = [],\r\n onValueChange,\r\n variant = 'default',\r\n size = 'md',\r\n className,\r\n children,\r\n disabled,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const isControlled = controlledValue !== undefined;\r\n const [internalValue, setInternalValue] = React.useState<string[]>(defaultValue);\r\n const value = isControlled ? controlledValue! : internalValue;\r\n\r\n const handleValueChange = (newValues: string[]) => {\r\n if (!isControlled) setInternalValue(newValues);\r\n onValueChange?.(newValues);\r\n };\r\n\r\n return (\r\n <ToggleGroupContext.Provider value={{ value, onValueChange: handleValueChange, type, variant, size }}>\r\n <div\r\n ref={ref}\r\n role=\"group\"\r\n className={cn('inline-flex items-center gap-1', className)}\r\n aria-disabled={disabled}\r\n {...props}\r\n >\r\n {children}\r\n </div>\r\n </ToggleGroupContext.Provider>\r\n );\r\n }\r\n);\r\n\r\nToggleGroup.displayName = 'ToggleGroup';\r\n\r\nexport interface ToggleGroupItemProps\r\n extends Omit<ToggleProps, 'pressed' | 'onPressedChange'> {\r\n value: string;\r\n}\r\n\r\nconst ToggleGroupItem = React.forwardRef<HTMLButtonElement, ToggleGroupItemProps>(\r\n ({ value, variant: itemVariant, size: itemSize, children, ...props }, ref) => {\r\n const ctx = React.useContext(ToggleGroupContext);\r\n if (!ctx) throw new Error('ToggleGroupItem must be inside ToggleGroup');\r\n\r\n const { value: groupValue, onValueChange, type, variant: ctxVariant, size: ctxSize } = ctx;\r\n const isPressed = groupValue.includes(value);\r\n\r\n const handlePressedChange = (pressed: boolean) => {\r\n if (type === 'single') {\r\n onValueChange(pressed ? [value] : []);\r\n } else {\r\n onValueChange(\r\n pressed ? [...groupValue, value] : groupValue.filter((v) => v !== value)\r\n );\r\n }\r\n };\r\n\r\n return (\r\n <Toggle\r\n ref={ref}\r\n pressed={isPressed}\r\n onPressedChange={handlePressedChange}\r\n variant={itemVariant ?? ctxVariant}\r\n size={itemSize ?? ctxSize}\r\n {...props}\r\n >\r\n {children}\r\n </Toggle>\r\n );\r\n }\r\n);\r\n\r\nToggleGroupItem.displayName = 'ToggleGroupItem';\r\n\r\nexport { Toggle, ToggleGroup, ToggleGroupItem };\r\n"
634
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst toggleVariants = tv({\r\n base: [\r\n 'inline-flex items-center justify-center gap-1.5 rounded-md font-medium text-sm',\r\n 'transition-all duration-150 cursor-pointer select-none',\r\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',\r\n 'disabled:pointer-events-none disabled:opacity-50',\r\n 'border',\r\n ],\r\n variants: {\r\n variant: {\r\n default: [\r\n 'bg-transparent border-border text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-secondary data-[state=on]:text-foreground data-[state=on]:border-secondary',\r\n ],\r\n outline: [\r\n 'bg-transparent border-border text-muted-foreground',\r\n 'hover:border-primary/50 hover:text-primary',\r\n 'data-[state=on]:bg-primary/10 data-[state=on]:text-primary data-[state=on]:border-primary/50',\r\n ],\r\n solid: [\r\n 'bg-transparent border-transparent text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-primary data-[state=on]:text-primary-foreground data-[state=on]:border-primary',\r\n ],\r\n ghost: [\r\n 'bg-transparent border-transparent text-muted-foreground',\r\n 'hover:bg-muted hover:text-foreground',\r\n 'data-[state=on]:bg-muted data-[state=on]:text-foreground',\r\n ],\r\n },\r\n size: {\r\n sm: 'h-7 px-2 text-xs',\r\n md: 'h-9 px-3 text-sm',\r\n lg: 'h-11 px-4 text-base',\r\n icon: 'h-9 w-9',\r\n },\r\n },\r\n defaultVariants: {\r\n variant: 'default',\r\n size: 'md',\r\n },\r\n});\r\n\r\n/** Props for the Toggle component */\r\nexport interface ToggleProps\r\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'>,\r\n VariantProps<typeof toggleVariants> {\r\n /** Controlled pressed state */\r\n pressed?: boolean;\r\n /** Default pressed state (uncontrolled) */\r\n defaultPressed?: boolean;\r\n /** Callback fired when the pressed state changes */\r\n onPressedChange?: (pressed: boolean) => void;\r\n}\r\n\r\nconst Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(\r\n (\r\n {\r\n pressed: controlledPressed,\r\n defaultPressed = false,\r\n onPressedChange,\r\n variant,\r\n size,\r\n className,\r\n children,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const isControlled = controlledPressed !== undefined;\r\n const [internalPressed, setInternalPressed] = React.useState(defaultPressed);\r\n\r\n const isPressed = isControlled ? controlledPressed! : internalPressed;\r\n\r\n const handleClick = () => {\r\n const next = !isPressed;\r\n if (!isControlled) setInternalPressed(next);\r\n onPressedChange?.(next);\r\n };\r\n\r\n return (\r\n <button\r\n ref={ref}\r\n type=\"button\"\r\n aria-pressed={isPressed}\r\n data-state={isPressed ? 'on' : 'off'}\r\n onClick={handleClick}\r\n className={toggleVariants({ variant, size, className })}\r\n {...props}\r\n >\r\n {children}\r\n </button>\r\n );\r\n }\r\n);\r\n\r\nToggle.displayName = 'Toggle';\r\n\r\n// ─── ToggleGroup ─────────────────────────────────────────────────────────────\r\n\r\ninterface ToggleGroupContextValue {\r\n value: string[];\r\n onValueChange: (value: string[]) => void;\r\n type: 'single' | 'multiple';\r\n variant?: VariantProps<typeof toggleVariants>['variant'];\r\n size?: VariantProps<typeof toggleVariants>['size'];\r\n}\r\n\r\nconst ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null);\r\n\r\n/** Props for the ToggleGroup component */\r\nexport interface ToggleGroupProps\r\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\r\n /** Whether only one or multiple items can be active at a time */\r\n type?: 'single' | 'multiple';\r\n /** Controlled array of active item values */\r\n value?: string[];\r\n /** Default active values (uncontrolled) */\r\n defaultValue?: string[];\r\n /** Callback fired when the active values change */\r\n onValueChange?: (value: string[]) => void;\r\n /** Variant applied to all child ToggleGroupItems (can be overridden per item) */\r\n variant?: VariantProps<typeof toggleVariants>['variant'];\r\n /** Size applied to all child ToggleGroupItems (can be overridden per item) */\r\n size?: VariantProps<typeof toggleVariants>['size'];\r\n children: React.ReactNode;\r\n /** Disable the entire group */\r\n disabled?: boolean;\r\n}\r\n\r\nconst ToggleGroup = React.forwardRef<HTMLDivElement, ToggleGroupProps>(\r\n (\r\n {\r\n type = 'single',\r\n value: controlledValue,\r\n defaultValue = [],\r\n onValueChange,\r\n variant = 'default',\r\n size = 'md',\r\n className,\r\n children,\r\n disabled,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const isControlled = controlledValue !== undefined;\r\n const [internalValue, setInternalValue] = React.useState<string[]>(defaultValue);\r\n const value = isControlled ? controlledValue! : internalValue;\r\n\r\n const handleValueChange = (newValues: string[]) => {\r\n if (!isControlled) setInternalValue(newValues);\r\n onValueChange?.(newValues);\r\n };\r\n\r\n return (\r\n <ToggleGroupContext.Provider value={{ value, onValueChange: handleValueChange, type, variant, size }}>\r\n <div\r\n ref={ref}\r\n role=\"group\"\r\n className={cn('inline-flex items-center gap-1', className)}\r\n aria-disabled={disabled}\r\n {...props}\r\n >\r\n {children}\r\n </div>\r\n </ToggleGroupContext.Provider>\r\n );\r\n }\r\n);\r\n\r\nToggleGroup.displayName = 'ToggleGroup';\r\n\r\n/** Props for the ToggleGroupItem component */\r\nexport interface ToggleGroupItemProps\r\n extends Omit<ToggleProps, 'pressed' | 'onPressedChange'> {\r\n /** Unique value identifying this item within the group */\r\n value: string;\r\n}\r\n\r\nconst ToggleGroupItem = React.forwardRef<HTMLButtonElement, ToggleGroupItemProps>(\r\n ({ value, variant: itemVariant, size: itemSize, children, ...props }, ref) => {\r\n const ctx = React.useContext(ToggleGroupContext);\r\n if (!ctx) throw new Error('ToggleGroupItem must be inside ToggleGroup');\r\n\r\n const { value: groupValue, onValueChange, type, variant: ctxVariant, size: ctxSize } = ctx;\r\n const isPressed = groupValue.includes(value);\r\n\r\n const handlePressedChange = (pressed: boolean) => {\r\n if (type === 'single') {\r\n onValueChange(pressed ? [value] : []);\r\n } else {\r\n onValueChange(\r\n pressed ? [...groupValue, value] : groupValue.filter((v) => v !== value)\r\n );\r\n }\r\n };\r\n\r\n return (\r\n <Toggle\r\n ref={ref}\r\n pressed={isPressed}\r\n onPressedChange={handlePressedChange}\r\n variant={itemVariant ?? ctxVariant}\r\n size={itemSize ?? ctxSize}\r\n {...props}\r\n >\r\n {children}\r\n </Toggle>\r\n );\r\n }\r\n);\r\n\r\nToggleGroupItem.displayName = 'ToggleGroupItem';\r\n\r\nexport { Toggle, ToggleGroup, ToggleGroupItem };\r\n"
542
635
  }
543
636
  ]
544
637
  },
@@ -552,40 +645,7 @@
552
645
  "files": [
553
646
  {
554
647
  "path": "src/components/ui/tooltip/Tooltip.tsx",
555
- "content": "import * as React from 'react';\r\nimport { Tooltip as BaseTooltip } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\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\nexport interface TooltipProps extends React.ComponentPropsWithoutRef<typeof BaseTooltip.Root> {\r\n content: React.ReactNode;\r\n children: React.ReactNode;\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n align?: 'start' | 'center' | 'end';\r\n}\r\n\r\nconst Tooltip = React.forwardRef<React.ElementRef<typeof BaseTooltip.Root>, TooltipProps>(\r\n ({ children, content, side = 'top', align = 'center', ...props }, ref) => {\r\n return (\r\n <BaseTooltip.Root {...props}>\r\n <BaseTooltip.Trigger render={children as React.ReactElement} />\r\n <BaseTooltip.Portal>\r\n <BaseTooltip.Positioner side={side} align={align} sideOffset={4}>\r\n <BaseTooltip.Popup className={popup()}>\r\n <BaseTooltip.Arrow className={arrow()} />\r\n {content}\r\n </BaseTooltip.Popup>\r\n </BaseTooltip.Positioner>\r\n </BaseTooltip.Portal>\r\n </BaseTooltip.Root>\r\n );\r\n }\r\n);\r\n\r\nTooltip.displayName = 'Tooltip';\r\n\r\nexport { Tooltip };\r\n"
556
- }
557
- ]
558
- },
559
- "vs-code": {
560
- "name": "vs-code",
561
- "dependencies": [
562
- "lucide-react"
563
- ],
564
- "internalDependencies": [],
565
- "files": [
566
- {
567
- "path": "src/components/ui/vs-code/ide/ActivityBar.tsx",
568
- "content": "import React from 'react';\r\nimport { Files, Search, GitBranch, MonitorPlay, Settings, User } from 'lucide-react';\r\n\r\ninterface ActivityBarProps {\r\n activeTab: 'explorer' | 'search' | 'git';\r\n setActiveTab: (tab: 'explorer' | 'search' | 'git') => void;\r\n}\r\n\r\nexport function ActivityBar({ activeTab, setActiveTab }: ActivityBarProps) {\r\n return (\r\n <div className=\"w-[48px] bg-[#151515] border-r border-[#242424] flex flex-col items-center py-3 shrink-0\">\r\n <button className=\"w-full h-12 flex items-center justify-center hover:text-white transition-colors\">\r\n <svg viewBox=\"0 0 100 100\" className=\"w-7 h-7\" fill=\"currentColor\">\r\n <path d=\"M50 0L0 25v50l50 25 50-25V25L50 0zm38.1 29.8L50 48.7 11.9 29.8 50 10.7l38.1 19.1zM50 82.7L18.4 66.9v-28L50 54.8l31.6-15.9v28L50 82.7z\"/>\r\n </svg>\r\n </button>\r\n\r\n <div className=\"w-full h-[1px] bg-[#242424] my-2\" />\r\n\r\n <ActivityIcon \r\n icon={<Files className=\"w-[22px] h-[22px]\" />} \r\n active={activeTab === 'explorer'} \r\n onClick={() => setActiveTab('explorer')}\r\n />\r\n <ActivityIcon \r\n icon={<Search className=\"w-[22px] h-[22px]\" />} \r\n active={activeTab === 'search'} \r\n onClick={() => setActiveTab('search')}\r\n />\r\n <ActivityIcon \r\n icon={<GitBranch className=\"w-[22px] h-[22px]\" />} \r\n active={activeTab === 'git'} \r\n onClick={() => setActiveTab('git')}\r\n />\r\n \r\n <div className=\"flex-1\" />\r\n\r\n <ActivityIcon icon={<User className=\"w-[22px] h-[22px]\" />} />\r\n <ActivityIcon icon={<Settings className=\"w-[22px] h-[22px]\" />} />\r\n </div>\r\n );\r\n}\r\n\r\nfunction ActivityIcon({ icon, active, onClick }: { icon: React.ReactNode; active?: boolean; onClick?: () => void }) {\r\n return (\r\n <button\r\n onClick={onClick}\r\n className={`relative w-full h-[48px] flex items-center justify-center transition-colors ${active ? 'text-white' : 'text-[#666666] hover:text-[#cccccc]'}`}\r\n >\r\n {active && (\r\n <span className=\"absolute left-0 top-[10%] bottom-[10%] w-[2px] bg-blue-500 rounded-r-md\" />\r\n )}\r\n {icon}\r\n </button>\r\n );\r\n}\r\n"
569
- },
570
- {
571
- "path": "src/components/ui/vs-code/ide/FileExplorer.tsx",
572
- "content": "import React, { useState } from 'react';\r\nimport { useSandpack } from '@codesandbox/sandpack-react';\r\nimport { FilePlus, FolderPlus, Trash2, Edit2, File as FileIcon, ChevronDown, ChevronRight } from 'lucide-react';\r\n\r\nexport function FileExplorer() {\r\n const { sandpack } = useSandpack();\r\n const { files, activeFile, openFile, addFile, deleteFile } = sandpack;\r\n\r\n const [isCreating, setIsCreating] = useState<'file' | 'folder' | null>(null);\r\n const [newName, setNewName] = useState('');\r\n\r\n // Lấy danh sách files và hiển thị phẳng (flat) hoặc nhóm thư mục\r\n // Để tối ưu và tránh quá dài, hiển thị danh sách các path dạng phẳng\r\n const filePaths = Object.keys(files);\r\n\r\n const handleCreate = (e: React.FormEvent) => {\r\n e.preventDefault();\r\n if (!newName) return;\r\n \r\n // Đảm bảo có dấu / phía trước\r\n const path = newName.startsWith('/') ? newName : `${newName}`;\r\n \r\n if (isCreating === 'file') {\r\n addFile(path, '');\r\n openFile(path);\r\n } else {\r\n // Sandpack tự hiển thị folder dựa vào path của file. \r\n // Nhưng vì không có file nào thì folder không tồn tại trong sandpack\r\n // Ta tạo 1 file .gitkeep ẩn để giữ folder.\r\n addFile(`${path}/.gitkeep`, '');\r\n }\r\n \r\n setNewName('');\r\n setIsCreating(null);\r\n };\r\n\r\n return (\r\n <div className=\"flex flex-col h-full bg-[#151515] text-[#cccccc] font-sans\">\r\n {/* Header Toolbar */}\r\n <div className=\"flex items-center justify-between px-3 py-2 text-[11px] font-semibold tracking-wider text-[#999999] shrink-0 uppercase mb-1 group\">\r\n <span>Explorer</span>\r\n <div className=\"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity\">\r\n <button onClick={() => setIsCreating('file')} className=\"p-1 hover:bg-[#2a2d2e] rounded transition-colors\" title=\"New File\">\r\n <FilePlus className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button onClick={() => setIsCreating('folder')} className=\"p-1 hover:bg-[#2a2d2e] rounded transition-colors\" title=\"New Folder\">\r\n <FolderPlus className=\"w-3.5 h-3.5\" />\r\n </button>\r\n </div>\r\n </div>\r\n\r\n <div className=\"flex-1 overflow-y-auto w-full outline-none\">\r\n {/* Input box for new file/folder */}\r\n {isCreating && (\r\n <div className=\"px-3 py-1 flex items-center\">\r\n {isCreating === 'file' ? <FileIcon className=\"w-3.5 h-3.5 mr-1 text-[#5c98ce]\" /> : <ChevronRight className=\"w-3.5 h-3.5 mr-1 text-[#666666]\" />}\r\n <form onSubmit={handleCreate} className=\"flex-1\">\r\n <input\r\n autoFocus\r\n type=\"text\"\r\n placeholder={isCreating === 'file' ? 'Tên file (vd: /src/App.js)' : 'Tên thư mục (vd: /src/components)'}\r\n className=\"w-full bg-[#3c3c3c] border border-[#007acc] text-[#cccccc] text-[13px] px-1 py-0.5 outline-none font-mono\"\r\n value={newName}\r\n onChange={(e) => setNewName(e.target.value)}\r\n onBlur={() => setIsCreating(null)}\r\n // Stop propagation để click vào file không làm mất focus bị lỗi\r\n onMouseDown={(e) => e.stopPropagation()}\r\n />\r\n </form>\r\n </div>\r\n )}\r\n\r\n {/* Danh sách Files hiện tại */}\r\n {filePaths.sort().map((path) => {\r\n // Bỏ qua hiển thị file ẩn như .gitkeep\r\n if (path.endsWith('.gitkeep')) return null;\r\n\r\n const isActive = path === activeFile;\r\n const fileName = path.split('/').pop() || path;\r\n \r\n return (\r\n <div\r\n key={path}\r\n onClick={() => openFile(path)}\r\n className={`flex items-center justify-between py-1 px-3 cursor-pointer text-[13px] group ${isActive ? 'bg-[#37373d] text-white' : 'hover:bg-[#2a2d2e]'}`}\r\n >\r\n <div className=\"flex items-center gap-1.5 truncate\">\r\n <FileIcon className={`w-3.5 h-3.5 shrink-0 ${isActive ? 'text-[#5c98ce]' : 'text-[#666666]'}`} />\r\n <span className={`truncate ${isActive ? '' : 'text-[#cccccc]'}`}>{fileName}</span>\r\n <span className=\"text-[10px] text-[#666666] ml-2 hidden sm:block truncate opacity-50\">{path}</span>\r\n </div>\r\n \r\n <div className=\"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0\">\r\n <button \r\n onClick={(e) => { e.stopPropagation(); deleteFile(path); }}\r\n className=\"p-0.5 hover:bg-[#4d4d4d] rounded text-[#cccccc] hover:text-red-400 z-10\"\r\n title=\"Delete File\"\r\n >\r\n <Trash2 className=\"w-3 h-3\" />\r\n </button>\r\n </div>\r\n </div>\r\n );\r\n })}\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
573
- },
574
- {
575
- "path": "src/components/ui/vs-code/ide/IdeLayout.tsx",
576
- "content": "import React, { useState } from 'react';\r\nimport { Group, Panel, Separator } from 'react-resizable-panels';\r\nimport { SandpackCodeEditor, SandpackPreview, SandpackConsole } from '@codesandbox/sandpack-react';\r\nimport { FileExplorer } from './FileExplorer';\r\nimport { TopBar } from './TopBar';\r\nimport { ActivityBar } from './ActivityBar';\r\n\r\nexport function IdeLayout() {\r\n const [activeTab, setActiveTab] = useState<'explorer' | 'search' | 'git'>('explorer');\r\n\r\n return (\r\n <div className=\"flex flex-col h-screen w-screen overflow-hidden bg-[#151515] text-[#999999] font-sans\">\r\n <TopBar />\r\n \r\n <div className=\"flex flex-1 min-h-0\">\r\n <ActivityBar activeTab={activeTab} setActiveTab={setActiveTab} />\r\n \r\n <Group orientation=\"horizontal\" className=\"h-full\">\r\n {/* Cột trái: Explorer / Sidebar */}\r\n <Panel defaultSize={15} minSize={10} className=\"flex flex-col border-r border-[#242424] bg-[#151515]\">\r\n {activeTab === 'explorer' && <FileExplorer />}\r\n {activeTab === 'search' && <div className=\"p-4\">Search...</div>}\r\n {activeTab === 'git' && <div className=\"p-4\">Source Control...</div>}\r\n </Panel>\r\n \r\n <ResizeHandle />\r\n\r\n {/* Cột phải: Main Editor Space */}\r\n <Panel defaultSize={85}>\r\n <Group orientation=\"horizontal\">\r\n {/* Editor + Terminal */}\r\n <Panel defaultSize={60} className=\"flex flex-col min-w-0 border-r border-[#242424]\">\r\n <Group orientation=\"vertical\">\r\n {/* Editor */}\r\n <Panel defaultSize={70} className=\"min-h-0 bg-[#1e1e1e]\">\r\n <SandpackCodeEditor \r\n showTabs \r\n showLineNumbers \r\n showInlineErrors \r\n wrapContent \r\n style={{ height: '100%' }} \r\n />\r\n </Panel>\r\n <ResizeHandle horizontal />\r\n {/* Terminal/Console */}\r\n <Panel defaultSize={30} minSize={10} className=\"flex flex-col bg-[#1e1e1e]\">\r\n <div className=\"h-9 shrink-0 bg-[#151515] border-b border-t border-[#242424] flex items-center px-4 gap-4 text-[11px] font-semibold text-[#cccccc] tracking-wider\">\r\n <span className=\"hover:text-white cursor-pointer transition-colors text-blue-500 border-b border-blue-500 pb-2 translate-y-1\">TERMINAL</span>\r\n <span className=\"hover:text-white cursor-pointer transition-colors\">OUTPUT</span>\r\n <span className=\"hover:text-white cursor-pointer transition-colors\">PROBLEMS</span>\r\n </div>\r\n <div className=\"flex-1 overflow-auto bg-[#151515]\">\r\n <SandpackConsole standalone style={{ height: '100%', background: 'transparent' }} />\r\n </div>\r\n </Panel>\r\n </Group>\r\n </Panel>\r\n\r\n <ResizeHandle />\r\n\r\n {/* Preview */}\r\n <Panel defaultSize={40} className=\"min-w-0 bg-[#ffffff]\">\r\n <SandpackPreview \r\n showNavigator \r\n showRefreshButton \r\n showOpenInCodeSandbox={false} \r\n style={{ height: '100%' }} \r\n />\r\n </Panel>\r\n </Group>\r\n </Panel>\r\n </Group>\r\n </div>\r\n\r\n {/* Status Bar */}\r\n <div className=\"h-[22px] bg-[#007acc] text-white flex items-center px-3 text-[10px] shrink-0 font-medium\">\r\n <span className=\"mr-4 hover:bg-white/20 px-1 cursor-pointer transition-colors rounded\">master*</span>\r\n <span className=\"mr-4 hover:bg-white/20 px-1 cursor-pointer transition-colors rounded\">React Preview</span>\r\n <div className=\"flex-1\" />\r\n <span className=\"hover:bg-white/20 px-1 cursor-pointer transition-colors rounded\">LF</span>\r\n <span className=\"hover:bg-white/20 px-1 ml-2 cursor-pointer transition-colors rounded\">UTF-8</span>\r\n </div>\r\n </div>\r\n );\r\n}\r\n\r\nfunction ResizeHandle({ horizontal }: { horizontal?: boolean }) {\r\n return (\r\n <Separator className={`${horizontal ? 'h-1 hover:bg-[#007acc] hover:cursor-row-resize' : 'w-1 hover:bg-[#007acc] hover:cursor-col-resize'} bg-[#242424] transition-colors`} />\r\n );\r\n}\r\n"
577
- },
578
- {
579
- "path": "src/components/ui/vs-code/ide/TopBar.tsx",
580
- "content": "import React from 'react';\r\nimport { ArrowLeft, Play, Download } from 'lucide-react';\r\nimport { useNavigate } from 'react-router-dom';\r\n\r\nexport function TopBar() {\r\n const navigate = useNavigate();\r\n\r\n return (\r\n <div className=\"h-[40px] bg-[#151515] border-b border-[#242424] flex items-center px-4 shrink-0 shadow-sm relative text-[#cccccc]\">\r\n <button\r\n onClick={() => navigate(-1)}\r\n className=\"flex items-center gap-1.5 hover:text-white transition-colors mr-4\"\r\n >\r\n <ArrowLeft className=\"w-4 h-4\" />\r\n </button>\r\n\r\n <div className=\"flex items-center gap-2 bg-[#1e1e1e] border border-[#242424] px-3 py-1 rounded-md hover:border-[#3a3a3a] transition-colors\">\r\n <span className=\"text-xs font-semibold\">Sandbox</span>\r\n <span className=\"text-[#666666] text-xs\">/</span>\r\n <span className=\"text-xs text-white\">react-playground</span>\r\n </div>\r\n\r\n <div className=\"flex-1\" />\r\n \r\n {/* Cụm menu ở giữa */}\r\n <div className=\"absolute left-1/2 -translate-x-1/2 flex items-center h-full\">\r\n <div className=\"flex items-center gap-2 bg-[#2a2d2e] rounded-md px-1 py-1 shadow-sm border border-[#3c3c3c]\">\r\n <button className=\"px-3 py-1 hover:bg-[#3c3c3c] rounded text-[11px] font-medium transition-colors flex items-center gap-1\">\r\n <Play className=\"w-3 h-3 text-green-400\" /> Start\r\n </button>\r\n <div className=\"w-[1px] h-3 bg-[#4d4d4d]\" />\r\n <button className=\"px-3 py-1 hover:bg-[#3c3c3c] rounded text-[11px] font-medium transition-colors flex items-center gap-1\">\r\n <Download className=\"w-3 h-3\" /> Export\r\n </button>\r\n </div>\r\n </div>\r\n\r\n <div className=\"flex items-center gap-3 relative z-10\">\r\n <button className=\"bg-[#007acc] hover:bg-[#005f9e] text-white text-[11px] font-semibold px-3 py-1.5 rounded transition-colors hidden sm:block\">\r\n Share\r\n </button>\r\n <button className=\"bg-[#1e1e1e] border border-[#3c3c3c] hover:bg-[#2a2a2a] text-white text-[11px] font-semibold px-3 py-1.5 rounded transition-colors hidden sm:block\">\r\n Sign In\r\n </button>\r\n </div>\r\n </div>\r\n );\r\n}\r\n"
581
- },
582
- {
583
- "path": "src/components/ui/vs-code/initialFiles.ts",
584
- "content": "export const initialFiles = {\r\n '/public/index.html': `<!DOCTYPE html>\r\n<html lang=\"en\">\r\n<head>\r\n <meta charset=\"UTF-8\">\r\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\r\n <title>Document</title>\r\n</head>\r\n<body>\r\n <div id=\"root\"></div>\r\n</body>\r\n</html>`,\r\n '/App.js': `import React from 'react';\r\nimport \"./styles.css\";\r\n\r\nexport default function App() {\r\n return (\r\n <div className=\"App\">\r\n <h1>Hello Professional Web IDE</h1>\r\n <h2>Start editing to see some magic happen!</h2>\r\n </div>\r\n );\r\n}\r\n`,\r\n '/index.js': `import React, { StrictMode } from \"react\";\r\nimport { createRoot } from \"react-dom/client\";\r\nimport \"./styles.css\";\r\n\r\nimport App from \"./App\";\r\n\r\nconst root = createRoot(document.getElementById(\"root\"));\r\nroot.render(\r\n <StrictMode>\r\n <App />\r\n </StrictMode>\r\n);`,\r\n '/styles.css': `.App {\r\n font-family: sans-serif;\r\n text-align: center;\r\n}\r\n`,\r\n '/package.json': `{\r\n \"name\": \"react-playground\",\r\n \"version\": \"1.0.0\",\r\n \"dependencies\": {\r\n \"react\": \"^18.0.0\",\r\n \"react-dom\": \"^18.0.0\",\r\n \"react-scripts\": \"^5.0.0\"\r\n },\r\n \"main\": \"/index.js\"\r\n}\r\n`\r\n};\r\n"
585
- },
586
- {
587
- "path": "src/components/ui/vs-code/VsCodeIDE.tsx",
588
- "content": "import React from 'react';\r\nimport { SandpackProvider } from '@codesandbox/sandpack-react';\r\nimport { IdeLayout } from './ide/IdeLayout';\r\n\r\nconst myCodeSandboxTheme = {\r\n colors: {\r\n surface1: '#151515',\r\n surface2: '#151515',\r\n surface3: '#2a2a2a',\r\n clickable: '#999999',\r\n base: '#808080',\r\n disabled: '#4d4d4d',\r\n hover: '#ffffff',\r\n accent: '#007acc',\r\n error: '#ff453a',\r\n errorSurface: '#ffeceb',\r\n },\r\n syntax: {\r\n plain: '#d4d4d4',\r\n comment: { color: '#6a9955', fontStyle: 'italic' as 'italic' },\r\n keyword: '#c586c0',\r\n tag: '#569cd6',\r\n punctuation: '#d4d4d4',\r\n definition: '#dcdcaa',\r\n property: '#9cdcfe',\r\n static: '#b5cea8',\r\n string: '#ce9178',\r\n },\r\n font: {\r\n body: '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\"',\r\n mono: '\"Fira Code\", \"Fira Mono\", Consolas, Menlo, Monaco, \"Courier New\", monospace',\r\n size: '13px',\r\n lineHeight: '20px',\r\n },\r\n};\r\n\r\nimport { initialFiles } from './initialFiles';\r\n\r\nexport function VsCodeIDE() {\r\n return (\r\n <SandpackProvider\r\n template=\"react\"\r\n theme={myCodeSandboxTheme}\r\n files={initialFiles}\r\n customSetup={{\r\n dependencies: {\r\n \"react\": \"^18.0.0\",\r\n \"react-dom\": \"^18.0.0\",\r\n \"react-scripts\": \"^5.0.0\"\r\n }\r\n }}\r\n options={{\r\n classes: {\r\n 'sp-wrapper': 'h-full',\r\n 'sp-layout': 'h-full rounded-none border-none bg-transparent',\r\n },\r\n }}\r\n >\r\n <IdeLayout />\r\n </SandpackProvider>\r\n );\r\n}\r\n"
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"
589
649
  }
590
650
  ]
591
651
  }