basuicn 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui-cli.cjs CHANGED
@@ -27,7 +27,7 @@ var import_fs = __toESM(require("fs"), 1);
27
27
  var import_path = __toESM(require("path"), 1);
28
28
  var import_child_process = require("child_process");
29
29
  var import_readline = __toESM(require("readline"), 1);
30
- var VERSION = "0.2.3";
30
+ var VERSION = "0.2.5";
31
31
  var REGISTRY_LOCAL = "./registry.json";
32
32
  var REGISTRY_REMOTE = "https://raw.githubusercontent.com/Basuicn/basuicn-core/main/registry.json";
33
33
  var c = {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "basuicn",
3
3
  "private": false,
4
- "version": "0.2.3",
4
+ "version": "0.2.5",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "basuicn": "./dist/ui-cli.cjs"
@@ -30,12 +30,18 @@
30
30
  "devDependencies": {
31
31
  "@babel/core": "^7.29.0",
32
32
  "@base-ui/react": "^1.3.0",
33
+ "@chromatic-com/storybook": "^5.1.1",
33
34
  "@codesandbox/sandpack-react": "^2.20.0",
34
35
  "@eslint/js": "^9.39.4",
35
36
  "@fontsource-variable/geist": "^5.2.8",
36
37
  "@hookform/resolvers": "^5.2.2",
37
38
  "@monaco-editor/react": "^4.7.0",
38
39
  "@rolldown/plugin-babel": "^0.2.1",
40
+ "@storybook/addon-a11y": "^10.3.4",
41
+ "@storybook/addon-docs": "^10.3.4",
42
+ "@storybook/addon-onboarding": "^10.3.4",
43
+ "@storybook/addon-vitest": "^10.3.4",
44
+ "@storybook/react-vite": "^10.3.4",
39
45
  "@tailwindcss/vite": "^4.2.2",
40
46
  "@tanstack/react-table": "^8.21.3",
41
47
  "@tanstack/react-virtual": "^3.13.23",
@@ -48,6 +54,8 @@
48
54
  "@types/react": "^19.2.14",
49
55
  "@types/react-dom": "^19.2.3",
50
56
  "@vitejs/plugin-react": "^6.0.1",
57
+ "@vitest/browser-playwright": "^4.1.2",
58
+ "@vitest/coverage-v8": "^4.1.2",
51
59
  "@vitest/ui": "^4.1.2",
52
60
  "autoprefixer": "^10.4.27",
53
61
  "babel-plugin-react-compiler": "^1.0.0",
@@ -57,9 +65,11 @@
57
65
  "eslint": "^9.39.4",
58
66
  "eslint-plugin-react-hooks": "^7.0.1",
59
67
  "eslint-plugin-react-refresh": "^0.5.2",
68
+ "eslint-plugin-storybook": "^10.3.4",
60
69
  "globals": "^17.4.0",
61
70
  "jsdom": "^29.0.1",
62
71
  "lucide-react": "^0.577.0",
72
+ "playwright": "^1.59.1",
63
73
  "postcss": "^8.5.8",
64
74
  "react": "^19.2.4",
65
75
  "react-day-picker": "^9.14.0",
@@ -73,6 +83,7 @@
73
83
  "rehype-react": "^8.0.0",
74
84
  "shiki": "^4.0.2",
75
85
  "sonner": "^2.0.7",
86
+ "storybook": "^10.3.4",
76
87
  "tailwind-merge": "^3.5.0",
77
88
  "tailwind-variants": "^3.2.2",
78
89
  "tailwindcss": "^4.2.2",
@@ -83,20 +94,11 @@
83
94
  "unified": "^11.0.5",
84
95
  "vite": "^8.0.1",
85
96
  "vitest": "^4.1.2",
86
- "zod": "^4.3.6",
87
- "storybook": "^10.3.4",
88
- "@storybook/react-vite": "^10.3.4",
89
- "@chromatic-com/storybook": "^5.1.1",
90
- "@storybook/addon-vitest": "^10.3.4",
91
- "@storybook/addon-a11y": "^10.3.4",
92
- "@storybook/addon-docs": "^10.3.4",
93
- "@storybook/addon-onboarding": "^10.3.4",
94
- "eslint-plugin-storybook": "^10.3.4",
95
- "playwright": "^1.59.1",
96
- "@vitest/browser-playwright": "^4.1.2",
97
- "@vitest/coverage-v8": "^4.1.2"
97
+ "zod": "^4.3.6"
98
98
  },
99
99
  "dependencies": {
100
- "keen-slider": "^6.8.6"
100
+ "@recharts/devtools": "^0.0.11",
101
+ "keen-slider": "^6.8.6",
102
+ "recharts": "^3.8.1"
101
103
  }
102
104
  }
package/registry.json CHANGED
@@ -18,11 +18,11 @@
18
18
  },
19
19
  {
20
20
  "path": "src/styles/index.css",
21
- "content": "@import \"tailwindcss\";\r\n@plugin \"tailwindcss-animate\";\r\n@custom-variant dark (&:where(.dark, .dark *));\r\n\r\n/*\r\n View Transitions API: Tắt animation mặc định (fade cross-dissolve).\r\n ThemeToggle sẽ tự định nghĩa clip-path ripple animation thay thế.\r\n*/\r\n::view-transition-old(root),\r\n::view-transition-new(root) {\r\n animation: none;\r\n mix-blend-mode: normal;\r\n}\r\n\r\n::view-transition-old(root) {\r\n z-index: 1;\r\n}\r\n\r\n::view-transition-new(root) {\r\n z-index: 9999;\r\n}\r\n\r\n\r\n@theme {\r\n --animate-ping: ping 1.5s linear infinite; /* Chỉnh cho nó chạy chậm lại */\r\n\r\n\r\n\r\n --color-background: var(--background);\r\n --color-foreground: var(--foreground);\r\n\r\n --color-primary: var(--primary);\r\n --color-primary-foreground: var(--primary-foreground);\r\n\r\n --color-secondary: var(--secondary);\r\n --color-secondary-foreground: var(--secondary-foreground);\r\n\r\n --color-muted: var(--muted);\r\n --color-muted-foreground: var(--muted-foreground);\r\n\r\n --color-accent: var(--accent);\r\n --color-accent-foreground: var(--accent-foreground);\r\n\r\n --color-switch-background: var(--switch-background);\r\n\r\n --color-border: var(--border);\r\n\r\n --color-success: var(--success);\r\n --color-success-foreground: var(--success-foreground);\r\n\r\n --color-warning: var(--warning);\r\n --color-warning-foreground: var(--warning-foreground);\r\n\r\n --color-destructive: var(--danger);\r\n --color-destructive-foreground: var(--danger-foreground);\r\n\r\n --color-danger: var(--danger);\r\n --color-danger-foreground: var(--danger-foreground);\r\n\r\n --color-ring: var(--ring);\r\n --color-input: var(--input);\r\n\r\n --color-chart-1: var(--chart-1);\r\n --color-chart-2: var(--chart-2);\r\n --color-chart-3: var(--chart-3);\r\n --color-chart-4: var(--chart-4);\r\n --color-chart-5: var(--chart-5);\r\n\r\n --color-popover: var(--popover);\r\n --color-popover-foreground: var(--popover-foreground);\r\n\r\n --color-sidebar: var(--sidebar);\r\n --color-sidebar-foreground: var(--sidebar-foreground);\r\n --color-sidebar-border: var(--sidebar-border);\r\n --color-sidebar-accent: var(--sidebar-accent);\r\n --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);\r\n --color-sidebar-ring: var(--sidebar-ring);\r\n\r\n --radius-sm: 0.125rem;\r\n --radius-md: 0.375rem;\r\n --radius-lg: 0.5rem;\r\n --radius-xl: 1rem;\r\n\r\n /* Z-index scale */\r\n --z-dropdown: 50;\r\n --z-sticky: 100;\r\n --z-overlay: 200;\r\n --z-modal: 300;\r\n --z-popover: 400;\r\n --z-toast: 500;\r\n\r\n --animate-spin-slow: spin 3s linear infinite;\r\n --animate-progress-stripes: progress-stripes 1s linear infinite;\r\n\r\n @keyframes progress-stripes {\r\n from { background-position: 1rem 0; }\r\n to { background-position: 0 0; }\r\n }\r\n\r\n --animate-blink: blink 1s step-end infinite;\r\n @keyframes blink {\r\n 0%, 100% { opacity: 1; }\r\n 50% { opacity: 0; }\r\n }\r\n}\r\n\r\n@layer base {\r\n :root {\r\n /* GENERATED:theme-start */\r\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\r\n --background: #ffffff;\r\n --foreground: #0f172a;\r\n --primary: #2f27ce;\r\n --primary-foreground: #ffffff;\r\n --secondary: #dedcff;\r\n --secondary-foreground: #2f27ce;\r\n --muted: #f8fafc;\r\n --muted-foreground: #64748b;\r\n --accent: #f1f5f9;\r\n --accent-foreground: #0f172a;\r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n --destructive: #ef4444;\r\n --destructive-foreground: #ffffff;\r\n --border: #e2e8f0;\r\n --input: #e2e8f0;\r\n --ring: #2f27ce;\r\n --popover: #ffffff;\r\n --popover-foreground: #0f172a;\r\n /* GENERATED:theme-end */\r\n\r\n /* Non-theme tokens (not managed by applyTheme) */\r\n --switch-background: #cbd5e1;\r\n\r\n --chart-1: #e11d48;\r\n --chart-2: #2f27ce;\r\n --chart-3: #10b981;\r\n --chart-4: #f59e0b;\r\n --chart-5: #8b5cf6;\r\n\r\n --sidebar: #f8fafc;\r\n --sidebar-foreground: #0f172a;\r\n --sidebar-border: #e2e8f0;\r\n --sidebar-accent: #f1f5f9;\r\n --sidebar-accent-foreground: #2f27ce;\r\n --sidebar-ring: #2f27ce;\r\n }\r\n\r\n .dark {\r\n /* Dark Theme - Deep Space Slate (Chuyên nghiệp & Hiện đại) */\r\n --background: #09090b; /* Very deep zinc/slate */\r\n --foreground: #f8fafc;\r\n \r\n --primary: #6366f1; /* Bright Indigo for pop */\r\n --primary-foreground: #ffffff;\r\n \r\n --secondary: #1e293b; /* Slate 800 */\r\n --secondary-foreground: #f8fafc;\r\n \r\n --muted: #0f172a; /* Slate 900 for subdued bg */\r\n --muted-foreground: #94a3b8; /* Slate 400 for text */\r\n \r\n --accent: #1e293b;\r\n --accent-foreground: #f8fafc;\r\n \r\n --switch-background: #334155;\r\n --border: #334155; /* Slate 700 */\r\n \r\n --success: #10b981;\r\n --success-foreground: #ffffff;\r\n \r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n \r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n\r\n --ring: #6366f1;\r\n --input: #334155;\r\n\r\n --chart-1: #fb7185;\r\n --chart-2: #6366f1;\r\n --chart-3: #34d399;\r\n --chart-4: #fbbf24;\r\n --chart-5: #818cf8;\r\n\r\n --popover: #0f172a;\r\n --popover-foreground: #f8fafc;\r\n\r\n --sidebar: #0f172a;\r\n --sidebar-foreground: #f8fafc;\r\n --sidebar-border: #334155;\r\n --sidebar-accent: #1e293b;\r\n --sidebar-accent-foreground: #818cf8;\r\n --sidebar-ring: #6366f1;\r\n }\r\n}\r\n\r\n\r\n@layer base {\r\n * {\r\n border-color: var(--border);\r\n }\r\n\r\n html, body {\r\n margin: 0;\r\n padding: 0;\r\n background-color: var(--background);\r\n color: var(--foreground);\r\n font-family: 'Inter', system-ui, sans-serif;\r\n overflow: hidden; /* Khóa scroll tổng để dùng nội bộ */\r\n height: 100%;\r\n color-scheme: light;\r\n transition: background-color 0.3s ease, color 0.3s ease;\r\n }\r\n\r\n html.dark {\r\n color-scheme: dark;\r\n }\r\n\r\n /* Fix dải trắng khi mở modal/dialog trong các thư viện (Base UI, Radix) */\r\n body[style*=\"overflow: hidden\"],\r\n body[data-scroll-locked] {\r\n padding-right: 0 !important;\r\n margin-right: 0 !important;\r\n }\r\n\r\n /* Đảm bảo Backdrop luôn phủ kín màn hình bất chấp các tính toán của thư viện */\r\n [data-base-ui-dialog-backdrop],\r\n .base-ui-backdrop,\r\n [role=\"presentation\"] > div[style*=\"fixed\"] {\r\n width: 100vw !important;\r\n height: 100vh !important;\r\n left: 0 !important;\r\n top: 0 !important;\r\n right: 0 !important;\r\n bottom: 0 !important;\r\n }\r\n\r\n /* Custom Scrollbar - Sleek & Modern */\r\n ::-webkit-scrollbar {\r\n width: 8px;\r\n height: 8px;\r\n }\r\n\r\n ::-webkit-scrollbar-track {\r\n background: transparent;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb {\r\n background: #cbd5e1; /* slate-300 */\r\n border-radius: 10px;\r\n border: 2px solid transparent;\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb {\r\n background: #334155; /* slate-700 */\r\n }\r\n\r\n ::-webkit-scrollbar-thumb:hover {\r\n background: #94a3b8; /* slate-400 */\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb:hover {\r\n background: #475569; /* slate-600 */\r\n }\r\n\r\n /* Firefox */\r\n * {\r\n scrollbar-width: thin;\r\n scrollbar-color: #cbd5e1 transparent;\r\n }\r\n\r\n .dark * {\r\n scrollbar-color: #334155 transparent;\r\n }\r\n}\r\n\r\n/* ─── Reduced Motion ─────────────────────────────────────────────────────────\r\n Respect prefers-reduced-motion for a11y.\r\n Disables all animations and transitions globally when the user's OS\r\n requests reduced motion. Individual components can opt-out via\r\n motion-reduce:* utilities if an animation is essential.\r\n*/\r\n@media (prefers-reduced-motion: reduce) {\r\n *, *::before, *::after {\r\n animation-duration: 0.01ms !important;\r\n animation-iteration-count: 1 !important;\r\n transition-duration: 0.01ms !important;\r\n scroll-behavior: auto !important;\r\n }\r\n}\r\n\r\n/* Ensure data-state=\"checked\" always works for background */\r\n[data-state=\"checked\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}\r\n\r\n[data-state=\"indeterminate\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}"
21
+ "content": "@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600;700&display=swap');\r\n@import \"tailwindcss\";\r\n@plugin \"tailwindcss-animate\";\r\n@custom-variant dark (&:where(.dark, .dark *));\r\n/* Thin scrollbar — auto-hide */\r\n* {\r\n scrollbar-width: thin;\r\n scrollbar-color: transparent transparent;\r\n}\r\n\r\n*:hover {\r\n scrollbar-color: var(--border) transparent;\r\n}\r\n\r\n*::-webkit-scrollbar {\r\n width: 4px;\r\n height: 4px;\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: transparent;\r\n border-radius: 9999px;\r\n}\r\n\r\n*:hover::-webkit-scrollbar-thumb {\r\n background: var(--border);\r\n}\r\n\r\n*::-webkit-scrollbar-thumb:hover {\r\n background: var(--muted-foreground);\r\n}\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 /* ─── Kraken-inspired radius scale ───────────────────────────────────────── */\r\n --radius-sm: 0.375rem; /* 6px — badges, tags */\r\n --radius-md: 0.5rem; /* 8px — tooltips, popovers */\r\n --radius-lg: 0.75rem; /* 12px — buttons, inputs, cards */\r\n --radius-xl: 1rem; /* 16px — modals, large containers */\r\n\r\n /* ─── Shadow tokens ───────────────────────────────────────────────────────── */\r\n --shadow-subtle: rgba(0,0,0,0.03) 0px 4px 24px;\r\n --shadow-micro: rgba(16,24,40,0.04) 0px 1px 4px;\r\n\r\n /* ─── Font families ───────────────────────────────────────────────────────── */\r\n --font-display: 'IBM Plex Sans', Helvetica, Arial, sans-serif;\r\n --font-ui: 'Inter', 'Helvetica Neue', Helvetica, Arial, sans-serif;\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 --animate-blink: blink 1s step-end infinite;\r\n @keyframes blink {\r\n 0%, 100% { opacity: 1; }\r\n 50% { opacity: 0; }\r\n }\r\n}\r\n\r\n@layer base {\r\n :root {\r\n /* GENERATED:theme-start */\r\n /* Auto-generated from themes.ts — run `npm run theme:sync` to update */\r\n --background: #ffffff;\r\n --foreground: #101114; /* Kraken Near Black */\r\n --primary: #7132f5; /* Kraken Purple */\r\n --primary-foreground: #ffffff;\r\n --secondary: #ebe5fe; /* Purple Subtle — rgba(133,91,251,0.16) on white */\r\n --secondary-foreground: #5741d8; /* Purple Dark */\r\n --muted: #f8f8fb; /* Cool off-white */\r\n --muted-foreground: #9497a9; /* Kraken Silver Blue */\r\n --accent: #f5f4ff; /* Light purple tint */\r\n --accent-foreground: #101114;\r\n --success: #149e61; /* Kraken Green */\r\n --success-foreground: #ffffff;\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n --danger: #ef4444;\r\n --danger-foreground: #ffffff;\r\n --destructive: #ef4444;\r\n --destructive-foreground: #ffffff;\r\n --border: #dedee5; /* Kraken Border Gray */\r\n --input: #dedee5;\r\n --ring: #7132f5;\r\n --popover: #ffffff;\r\n --popover-foreground: #101114;\r\n /* GENERATED:theme-end */\r\n\r\n /* Non-theme tokens (not managed by applyTheme) */\r\n --switch-background: #c0c2d1; /* Cool gray toggle */\r\n\r\n --chart-1: #e11d48;\r\n --chart-2: #7132f5; /* Kraken Purple */\r\n --chart-3: #149e61; /* Kraken Green */\r\n --chart-4: #f59e0b;\r\n --chart-5: #5741d8; /* Purple Dark */\r\n\r\n --sidebar: #f8f8fb;\r\n --sidebar-foreground: #101114;\r\n --sidebar-border: #dedee5;\r\n --sidebar-accent: #f5f4ff;\r\n --sidebar-accent-foreground: #7132f5;\r\n --sidebar-ring: #7132f5;\r\n }\r\n\r\n .dark {\r\n /* Dark Theme — Kraken-inspired Deep Purple Night */\r\n --background: #0e0c14; /* Near-black with purple tint */\r\n --foreground: #ededf0; /* Off-white */\r\n\r\n --primary: #9b72ff; /* Lighter Kraken Purple — readable on dark */\r\n --primary-foreground: #ffffff;\r\n\r\n --secondary: #1c1929; /* Dark purple-tinted surface */\r\n --secondary-foreground: #c4b0ff; /* Soft lavender */\r\n\r\n --muted: #17151f; /* Deeper surface */\r\n --muted-foreground: #7b7d99; /* Muted silver-blue */\r\n\r\n --accent: #1c1929;\r\n --accent-foreground: #ededf0;\r\n\r\n --switch-background: #3d3860;\r\n\r\n --border: #2a2740; /* Dark purple-tinted border */\r\n --input: #2a2740;\r\n\r\n --success: #1acc72; /* Brighter Kraken green for dark bg */\r\n --success-foreground: #ffffff;\r\n\r\n --warning: #f59e0b;\r\n --warning-foreground: #ffffff;\r\n\r\n --danger: #f05555;\r\n --danger-foreground: #ffffff;\r\n\r\n --ring: #9b72ff;\r\n\r\n --chart-1: #fb7185;\r\n --chart-2: #9b72ff;\r\n --chart-3: #1acc72;\r\n --chart-4: #fbbf24;\r\n --chart-5: #c4b0ff;\r\n\r\n --popover: #17151f;\r\n --popover-foreground: #ededf0;\r\n\r\n --sidebar: #0b0a11;\r\n --sidebar-foreground: #ededf0;\r\n --sidebar-border: #2a2740;\r\n --sidebar-accent: #1c1929;\r\n --sidebar-accent-foreground: #c4b0ff;\r\n --sidebar-ring: #9b72ff;\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: var(--font-ui);\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: #c0c2d1; /* Kraken cool gray */\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: #2a2740;\r\n }\r\n\r\n ::-webkit-scrollbar-thumb:hover {\r\n background: #9497a9; /* Kraken Silver Blue */\r\n background-clip: content-box;\r\n }\r\n\r\n .dark ::-webkit-scrollbar-thumb:hover {\r\n background: #3d3860;\r\n }\r\n\r\n /* Firefox */\r\n * {\r\n scrollbar-width: thin;\r\n scrollbar-color: #c0c2d1 transparent;\r\n }\r\n\r\n .dark * {\r\n scrollbar-color: #2a2740 transparent;\r\n }\r\n}\r\n\r\n/* ─── Reduced Motion ─────────────────────────────────────────────────────────\r\n Respect prefers-reduced-motion for a11y.\r\n Disables all animations and transitions globally when the user's OS\r\n requests reduced motion. Individual components can opt-out via\r\n motion-reduce:* utilities if an animation is essential.\r\n*/\r\n@media (prefers-reduced-motion: reduce) {\r\n *, *::before, *::after {\r\n animation-duration: 0.01ms !important;\r\n animation-iteration-count: 1 !important;\r\n transition-duration: 0.01ms !important;\r\n scroll-behavior: auto !important;\r\n }\r\n}\r\n\r\n/* Ensure data-state=\"checked\" always works for background */\r\n[data-state=\"checked\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}\r\n\r\n[data-state=\"indeterminate\"] {\r\n &.bg-primary {\r\n background-color: var(--primary);\r\n }\r\n}"
22
22
  },
23
23
  {
24
24
  "path": "src/lib/theme/themes.ts",
25
- "content": "// ─── Token Interfaces ─────────────────────────────────────────────────────────\r\n\r\nexport interface ThemeColors {\r\n // Surface\r\n background: string;\r\n foreground: string;\r\n // Brand\r\n primary: string;\r\n primaryForeground: string;\r\n // Secondary surface\r\n secondary: string;\r\n secondaryForeground: string;\r\n // Muted surface\r\n muted: string;\r\n mutedForeground: string;\r\n // Accent / hover surface\r\n accent: string;\r\n accentForeground: string;\r\n // Semantic states\r\n success: string;\r\n successForeground: string;\r\n warning: string;\r\n warningForeground: string;\r\n danger: string;\r\n dangerForeground: string;\r\n // Form / input\r\n border: string;\r\n input: string;\r\n ring: string;\r\n // Popover / overlay\r\n popover: string;\r\n popoverForeground: string;\r\n}\r\n\r\nexport interface Theme {\r\n name: string;\r\n label: string;\r\n colors: ThemeColors;\r\n}\r\n\r\nexport type BuiltInThemeName = 'indigo' | 'blue' | 'violet' | 'rose' | 'emerald' | 'orange' | 'slate';\r\n\r\n// ─── Built-in Themes ──────────────────────────────────────────────────────────\r\n\r\nexport const themes: Theme[] = [\r\n {\r\n name: 'indigo',\r\n label: 'Indigo (Default)',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#0f172a',\r\n primary: '#2f27ce',\r\n primaryForeground: '#ffffff',\r\n secondary: '#dedcff',\r\n secondaryForeground: '#2f27ce',\r\n muted: '#f8fafc',\r\n mutedForeground: '#64748b',\r\n accent: '#f1f5f9',\r\n accentForeground: '#0f172a',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#e2e8f0',\r\n input: '#e2e8f0',\r\n ring: '#2f27ce',\r\n popover: '#ffffff',\r\n popoverForeground: '#0f172a',\r\n },\r\n },\r\n {\r\n name: 'blue',\r\n label: 'Blue',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#0f172a',\r\n primary: '#3b82f6',\r\n primaryForeground: '#ffffff',\r\n secondary: '#dbeafe',\r\n secondaryForeground: '#1d4ed8',\r\n muted: '#f8fafc',\r\n mutedForeground: '#64748b',\r\n accent: '#eff6ff',\r\n accentForeground: '#1e40af',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#e2e8f0',\r\n input: '#e2e8f0',\r\n ring: '#3b82f6',\r\n popover: '#ffffff',\r\n popoverForeground: '#0f172a',\r\n },\r\n },\r\n {\r\n name: 'violet',\r\n label: 'Violet',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#1e1b4b',\r\n primary: '#7c3aed',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ede9fe',\r\n secondaryForeground: '#5b21b6',\r\n muted: '#f5f3ff',\r\n mutedForeground: '#6d28d9',\r\n accent: '#f5f3ff',\r\n accentForeground: '#4c1d95',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#ddd6fe',\r\n input: '#ddd6fe',\r\n ring: '#7c3aed',\r\n popover: '#ffffff',\r\n popoverForeground: '#1e1b4b',\r\n },\r\n },\r\n {\r\n name: 'rose',\r\n label: 'Rose',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#18181b',\r\n primary: '#e11d48',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffe4e6',\r\n secondaryForeground: '#9f1239',\r\n muted: '#fff1f2',\r\n mutedForeground: '#64748b',\r\n accent: '#fff1f2',\r\n accentForeground: '#881337',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#fecdd3',\r\n input: '#fecdd3',\r\n ring: '#e11d48',\r\n popover: '#ffffff',\r\n popoverForeground: '#18181b',\r\n },\r\n },\r\n {\r\n name: 'emerald',\r\n label: 'Emerald',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#064e3b',\r\n primary: '#059669',\r\n primaryForeground: '#ffffff',\r\n secondary: '#d1fae5',\r\n secondaryForeground: '#065f46',\r\n muted: '#ecfdf5',\r\n mutedForeground: '#064748b',\r\n accent: '#ecfdf5',\r\n accentForeground: '#065f46',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#a7f3d0',\r\n input: '#a7f3d0',\r\n ring: '#059669',\r\n popover: '#ffffff',\r\n popoverForeground: '#064e3b',\r\n },\r\n },\r\n {\r\n name: 'orange',\r\n label: 'Orange',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#1c1917',\r\n primary: '#ea580c',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffedd5',\r\n secondaryForeground: '#9a3412',\r\n muted: '#fff7ed',\r\n mutedForeground: '#64748b',\r\n accent: '#fff7ed',\r\n accentForeground: '#7c2d12',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#fed7aa',\r\n input: '#fed7aa',\r\n ring: '#ea580c',\r\n popover: '#ffffff',\r\n popoverForeground: '#1c1917',\r\n },\r\n },\r\n {\r\n name: 'slate',\r\n label: 'Slate',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#0f172a',\r\n primary: '#475569',\r\n primaryForeground: '#ffffff',\r\n secondary: '#f1f5f9',\r\n secondaryForeground: '#0f172a',\r\n muted: '#f8fafc',\r\n mutedForeground: '#64748b',\r\n accent: '#f1f5f9',\r\n accentForeground: '#0f172a',\r\n success: '#10b981',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#e2e8f0',\r\n input: '#e2e8f0',\r\n ring: '#475569',\r\n popover: '#ffffff',\r\n popoverForeground: '#0f172a',\r\n },\r\n },\r\n];\r\n\r\n// ─── Apply Theme ──────────────────────────────────────────────────────────────\r\n\r\nconst THEME_STYLE_ID = 'basuicn-theme';\r\n\r\n/**\r\n * Applies a theme by injecting a <style> tag at the START of <head>.\r\n *\r\n * Why <style> tag instead of element.style.setProperty():\r\n * Inline styles have the highest CSS specificity and would override\r\n * .dark { } class rules, breaking dark mode. A <style> tag injected\r\n * before the app's CSS bundle has lower specificity than .dark { }\r\n * rules defined later in the bundle — so dark mode always wins.\r\n */\r\nexport function applyTheme(theme: Theme) {\r\n if (typeof window === 'undefined') return;\r\n if (!theme?.colors) return;\r\n const { colors: c } = theme;\r\n\r\n const css = `\r\n:root:not(.dark) {\r\n --background: ${c.background};\r\n --foreground: ${c.foreground};\r\n --primary: ${c.primary};\r\n --primary-foreground: ${c.primaryForeground};\r\n --secondary: ${c.secondary};\r\n --secondary-foreground: ${c.secondaryForeground};\r\n --muted: ${c.muted};\r\n --muted-foreground: ${c.mutedForeground};\r\n --accent: ${c.accent};\r\n --accent-foreground: ${c.accentForeground};\r\n --success: ${c.success};\r\n --success-foreground: ${c.successForeground};\r\n --warning: ${c.warning};\r\n --warning-foreground: ${c.warningForeground};\r\n --danger: ${c.danger};\r\n --danger-foreground: ${c.dangerForeground};\r\n --destructive: ${c.danger};\r\n --destructive-foreground: ${c.dangerForeground};\r\n --border: ${c.border};\r\n --input: ${c.input};\r\n --ring: ${c.ring};\r\n --popover: ${c.popover};\r\n --popover-foreground: ${c.popoverForeground};\r\n}`.trim();\r\n\r\n let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;\r\n if (!styleEl) {\r\n styleEl = document.createElement('style');\r\n styleEl.id = THEME_STYLE_ID;\r\n document.head.appendChild(styleEl);\r\n }\r\n styleEl.textContent = css;\r\n}\r\n\r\n// ─── CSS Variable Generator ───────────────────────────────────────────────────\r\n\r\n/**\r\n * Converts a Theme's colors into a CSS `:root { }` block string.\r\n * Used by scripts to generate or sync CSS.\r\n */\r\nexport function toCssVars(theme: Theme): string {\r\n const { colors: c } = theme;\r\n const vars: [string, string][] = [\r\n ['--background', c.background],\r\n ['--foreground', c.foreground],\r\n ['--primary', c.primary],\r\n ['--primary-foreground', c.primaryForeground],\r\n ['--secondary', c.secondary],\r\n ['--secondary-foreground', c.secondaryForeground],\r\n ['--muted', c.muted],\r\n ['--muted-foreground', c.mutedForeground],\r\n ['--accent', c.accent],\r\n ['--accent-foreground', c.accentForeground],\r\n ['--success', c.success],\r\n ['--success-foreground', c.successForeground],\r\n ['--warning', c.warning],\r\n ['--warning-foreground', c.warningForeground],\r\n ['--danger', c.danger],\r\n ['--danger-foreground', c.dangerForeground],\r\n ['--destructive', c.danger],\r\n ['--destructive-foreground', c.dangerForeground],\r\n ['--border', c.border],\r\n ['--input', c.input],\r\n ['--ring', c.ring],\r\n ['--popover', c.popover],\r\n ['--popover-foreground', c.popoverForeground],\r\n ];\r\n const body = vars.map(([k, v]) => ` ${k}: ${v};`).join('\\n');\r\n return `:root {\\n${body}\\n }`;\r\n}\r\n\r\n// ─── Custom Theme Factory ─────────────────────────────────────────────────────\r\n\r\n/**\r\n * Creates a custom theme by merging overrides with the default (indigo) theme.\r\n *\r\n * @example\r\n * const myTheme = createTheme('brand', 'My Brand', { primary: '#ff6b35' });\r\n */\r\nexport function createTheme(\r\n name: string,\r\n label: string,\r\n colors: Partial<ThemeColors>\r\n): Theme {\r\n const base = themes[0]; // indigo as default base\r\n return {\r\n name,\r\n label,\r\n colors: { ...base.colors, ...colors },\r\n };\r\n}\r\n"
25
+ "content": "// ─── Token Interfaces ─────────────────────────────────────────────────────────\r\n\r\nexport interface ThemeColors {\r\n // Surface\r\n background: string;\r\n foreground: string;\r\n // Brand\r\n primary: string;\r\n primaryForeground: string;\r\n // Secondary surface\r\n secondary: string;\r\n secondaryForeground: string;\r\n // Muted surface\r\n muted: string;\r\n mutedForeground: string;\r\n // Accent / hover surface\r\n accent: string;\r\n accentForeground: string;\r\n // Semantic states\r\n success: string;\r\n successForeground: string;\r\n warning: string;\r\n warningForeground: string;\r\n danger: string;\r\n dangerForeground: string;\r\n // Form / input\r\n border: string;\r\n input: string;\r\n ring: string;\r\n // Popover / overlay\r\n popover: string;\r\n popoverForeground: string;\r\n}\r\n\r\nexport interface Theme {\r\n name: string;\r\n label: string;\r\n colors: ThemeColors;\r\n}\r\n\r\nexport type BuiltInThemeName = 'indigo' | 'blue' | 'violet' | 'rose' | 'emerald' | 'orange' | 'slate';\r\n\r\n// ─── Built-in Themes ──────────────────────────────────────────────────────────\r\n\r\nexport const themes: Theme[] = [\r\n // ─── Default: Kraken Design System ────────────────────────────────────────────\r\n {\r\n name: 'indigo',\r\n label: 'Indigo (Default)',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114', // Kraken Near Black\r\n primary: '#7132f5', // Kraken Purple\r\n primaryForeground: '#ffffff',\r\n secondary: '#ebe5fe', // Purple Subtle (~rgba(133,91,251,0.16) on white)\r\n secondaryForeground: '#5741d8', // Purple Dark\r\n muted: '#f8f8fb', // Cool off-white\r\n mutedForeground: '#9497a9', // Kraken Silver Blue\r\n accent: '#f5f4ff', // Light purple tint\r\n accentForeground: '#101114',\r\n success: '#149e61', // Kraken Green\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#dedee5', // Kraken Border Gray\r\n input: '#dedee5',\r\n ring: '#7132f5',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Blue — same contrast standard as Kraken ──────────────────────────────────\r\n {\r\n name: 'blue',\r\n label: 'Blue',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#2563eb',\r\n primaryForeground: '#ffffff',\r\n secondary: '#dbeafe',\r\n secondaryForeground: '#1d4ed8',\r\n muted: '#f8f9fc',\r\n mutedForeground: '#9497a9', // same contrast standard\r\n accent: '#eff6ff',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#dde5f0',\r\n input: '#dde5f0',\r\n ring: '#2563eb',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Violet ────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'violet',\r\n label: 'Violet',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#7c3aed',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ede9fe',\r\n secondaryForeground: '#5b21b6',\r\n muted: '#f5f3ff',\r\n mutedForeground: '#9491c4', // violet-tinted, same relative lightness\r\n accent: '#f5f3ff',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#ddd6fe',\r\n input: '#ddd6fe',\r\n ring: '#7c3aed',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Rose ──────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'rose',\r\n label: 'Rose',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#e11d48',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffe4e6',\r\n secondaryForeground: '#9f1239',\r\n muted: '#fff1f2',\r\n mutedForeground: '#9e9099', // rose-tinted silver\r\n accent: '#fff1f2',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#f0d5d9',\r\n input: '#f0d5d9',\r\n ring: '#e11d48',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Emerald ───────────────────────────────────────────────────────────────────\r\n {\r\n name: 'emerald',\r\n label: 'Emerald',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#059669',\r\n primaryForeground: '#ffffff',\r\n secondary: '#d1fae5',\r\n secondaryForeground: '#065f46',\r\n muted: '#ecfdf5',\r\n mutedForeground: '#7a9e8d', // emerald-tinted silver\r\n accent: '#ecfdf5',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#c5e8d9',\r\n input: '#c5e8d9',\r\n ring: '#059669',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Orange ────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'orange',\r\n label: 'Orange',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#ea580c',\r\n primaryForeground: '#ffffff',\r\n secondary: '#ffedd5',\r\n secondaryForeground: '#9a3412',\r\n muted: '#fff7ed',\r\n mutedForeground: '#9e9087', // warm-tinted silver\r\n accent: '#fff7ed',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#f5d9b8',\r\n input: '#f5d9b8',\r\n ring: '#ea580c',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n\r\n // ─── Slate ─────────────────────────────────────────────────────────────────────\r\n {\r\n name: 'slate',\r\n label: 'Slate',\r\n colors: {\r\n background: '#ffffff',\r\n foreground: '#101114',\r\n primary: '#475569',\r\n primaryForeground: '#ffffff',\r\n secondary: '#f1f5f9',\r\n secondaryForeground: '#334155',\r\n muted: '#f8f9fc',\r\n mutedForeground: '#9497a9',\r\n accent: '#f1f5f9',\r\n accentForeground: '#101114',\r\n success: '#149e61',\r\n successForeground: '#ffffff',\r\n warning: '#f59e0b',\r\n warningForeground: '#ffffff',\r\n danger: '#ef4444',\r\n dangerForeground: '#ffffff',\r\n border: '#dedee5',\r\n input: '#dedee5',\r\n ring: '#475569',\r\n popover: '#ffffff',\r\n popoverForeground: '#101114',\r\n },\r\n },\r\n];\r\n\r\n// ─── Apply Theme ──────────────────────────────────────────────────────────────\r\n\r\nconst THEME_STYLE_ID = 'basuicn-theme';\r\n\r\n/**\r\n * Applies a theme by injecting a <style> tag at the START of <head>.\r\n *\r\n * Why <style> tag instead of element.style.setProperty():\r\n * Inline styles have the highest CSS specificity and would override\r\n * .dark { } class rules, breaking dark mode. A <style> tag injected\r\n * before the app's CSS bundle has lower specificity than .dark { }\r\n * rules defined later in the bundle — so dark mode always wins.\r\n */\r\nexport function applyTheme(theme: Theme) {\r\n if (typeof window === 'undefined') return;\r\n if (!theme?.colors) return;\r\n const { colors: c } = theme;\r\n\r\n const css = `\r\n:root:not(.dark) {\r\n --background: ${c.background};\r\n --foreground: ${c.foreground};\r\n --primary: ${c.primary};\r\n --primary-foreground: ${c.primaryForeground};\r\n --secondary: ${c.secondary};\r\n --secondary-foreground: ${c.secondaryForeground};\r\n --muted: ${c.muted};\r\n --muted-foreground: ${c.mutedForeground};\r\n --accent: ${c.accent};\r\n --accent-foreground: ${c.accentForeground};\r\n --success: ${c.success};\r\n --success-foreground: ${c.successForeground};\r\n --warning: ${c.warning};\r\n --warning-foreground: ${c.warningForeground};\r\n --danger: ${c.danger};\r\n --danger-foreground: ${c.dangerForeground};\r\n --destructive: ${c.danger};\r\n --destructive-foreground: ${c.dangerForeground};\r\n --border: ${c.border};\r\n --input: ${c.input};\r\n --ring: ${c.ring};\r\n --popover: ${c.popover};\r\n --popover-foreground: ${c.popoverForeground};\r\n}`.trim();\r\n\r\n let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;\r\n if (!styleEl) {\r\n styleEl = document.createElement('style');\r\n styleEl.id = THEME_STYLE_ID;\r\n document.head.appendChild(styleEl);\r\n }\r\n styleEl.textContent = css;\r\n}\r\n\r\n// ─── CSS Variable Generator ───────────────────────────────────────────────────\r\n\r\n/**\r\n * Converts a Theme's colors into a CSS `:root { }` block string.\r\n * Used by scripts to generate or sync CSS.\r\n */\r\nexport function toCssVars(theme: Theme): string {\r\n const { colors: c } = theme;\r\n const vars: [string, string][] = [\r\n ['--background', c.background],\r\n ['--foreground', c.foreground],\r\n ['--primary', c.primary],\r\n ['--primary-foreground', c.primaryForeground],\r\n ['--secondary', c.secondary],\r\n ['--secondary-foreground', c.secondaryForeground],\r\n ['--muted', c.muted],\r\n ['--muted-foreground', c.mutedForeground],\r\n ['--accent', c.accent],\r\n ['--accent-foreground', c.accentForeground],\r\n ['--success', c.success],\r\n ['--success-foreground', c.successForeground],\r\n ['--warning', c.warning],\r\n ['--warning-foreground', c.warningForeground],\r\n ['--danger', c.danger],\r\n ['--danger-foreground', c.dangerForeground],\r\n ['--destructive', c.danger],\r\n ['--destructive-foreground', c.dangerForeground],\r\n ['--border', c.border],\r\n ['--input', c.input],\r\n ['--ring', c.ring],\r\n ['--popover', c.popover],\r\n ['--popover-foreground', c.popoverForeground],\r\n ];\r\n const body = vars.map(([k, v]) => ` ${k}: ${v};`).join('\\n');\r\n return `:root {\\n${body}\\n }`;\r\n}\r\n\r\n// ─── Custom Theme Factory ─────────────────────────────────────────────────────\r\n\r\n/**\r\n * Creates a custom theme by merging overrides with the default (indigo) theme.\r\n *\r\n * @example\r\n * const myTheme = createTheme('brand', 'My Brand', { primary: '#ff6b35' });\r\n */\r\nexport function createTheme(\r\n name: string,\r\n label: string,\r\n colors: Partial<ThemeColors>\r\n): Theme {\r\n const base = themes[0]; // indigo as default base\r\n return {\r\n name,\r\n label,\r\n colors: { ...base.colors, ...colors },\r\n };\r\n}\r\n"
26
26
  },
27
27
  {
28
28
  "path": "src/lib/theme/ThemeProvider.tsx",
@@ -55,7 +55,7 @@
55
55
  "files": [
56
56
  {
57
57
  "path": "src/components/ui/alert/Alert.tsx",
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"
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 // Kraken: rounded-lg (12px), subtle soft backgrounds\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 border-border',\r\n destructive: 'border-danger/30 bg-danger/[0.06] text-danger [&>svg]:text-danger',\r\n success: 'border-success/30 bg-success/[0.06] text-success [&>svg]:text-success',\r\n warning: 'border-warning/30 bg-warning/[0.06] text-warning [&>svg]:text-warning',\r\n info: 'border-primary/30 bg-primary/[0.06] text-primary [&>svg]:text-primary',\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"
59
59
  }
60
60
  ]
61
61
  },
@@ -97,7 +97,7 @@
97
97
  "files": [
98
98
  {
99
99
  "path": "src/components/ui/autocomplete/Autocomplete.tsx",
100
- "content": "\"use client\"\r\nimport * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst autocompleteVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex items-center min-h-10 w-full rounded-md border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-md border border-border bg-background text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nexport interface AutocompleteOption {\r\n label: string;\r\n value: string;\r\n}\r\n\r\nexport interface AutocompleteProps {\r\n options: AutocompleteOption[];\r\n label?: string;\r\n placeholder?: string;\r\n value?: string;\r\n defaultValue?: string;\r\n onValueChange?: (value: string) => void;\r\n isLoading?: boolean;\r\n className?: string;\r\n emptyText?: string;\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\r\n ({\r\n options,\r\n label,\r\n placeholder,\r\n value,\r\n defaultValue,\r\n onValueChange,\r\n isLoading,\r\n className,\r\n emptyText = 'No results found.',\r\n leftIcon,\r\n }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) setInternalValue(newVal);\r\n if (newVal !== null) onValueChange?.(newVal);\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n // Khi base-ui cập nhật input sau khi chọn item, bỏ qua để tránh nháy popup\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n setInputValue(val);\r\n // Chỉ mở popup khi người dùng đang gõ\r\n setOpen(val.length > 0);\r\n };\r\n\r\n // Block mọi lần mở từ focus/click — chỉ cho phép đóng từ bên ngoài (click-outside, select)\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (!newOpen) setOpen(false);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(null);\r\n setInputValue('');\r\n setOpen(false);\r\n };\r\n\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue) return options;\r\n if (activeValue) {\r\n const selected = options.find(o => o.value === activeValue);\r\n if (selected && inputValue === selected.label) return options;\r\n }\r\n return options.filter(o =>\r\n o.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n onInputValueChange={handleInputValueChange}\r\n open={open}\r\n onOpenChange={handleOpenChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find(o => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\r\n\r\n <div className=\"relative w-full group\">\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n\r\n {isLoading ? (\r\n <Loader2 className=\"absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground\" />\r\n ) : activeValue && (\r\n <button\r\n type=\"button\"\r\n aria-label=\"Clear selection\"\r\n onClick={handleClear}\r\n className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </button>\r\n )}\r\n\r\n <BaseCombobox.Input\r\n ref={ref}\r\n placeholder={placeholder}\r\n className={cn(input(), (isLoading || activeValue) && 'pr-8')}\r\n />\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)' }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : (\r\n filteredOptions.map((option) => (\r\n <BaseCombobox.Item\r\n key={option.value}\r\n value={option.value}\r\n className={item()}\r\n >\r\n <BaseCombobox.ItemIndicator className={indicator()}>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseCombobox.ItemIndicator>\r\n {option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nAutocomplete.displayName = 'Autocomplete';\r\n\r\nexport { Autocomplete };\r\n"
100
+ "content": "\"use client\"\r\nimport * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst autocompleteVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex items-center min-h-10 w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nexport interface AutocompleteOption {\r\n label: string;\r\n value: string;\r\n description?: string;\r\n}\r\n\r\nexport interface AutocompleteProps {\r\n options: AutocompleteOption[];\r\n label?: string;\r\n placeholder?: string;\r\n value?: string;\r\n defaultValue?: string;\r\n onValueChange?: (value: string) => void;\r\n isLoading?: boolean;\r\n className?: string;\r\n emptyText?: string;\r\n leftIcon?: React.ReactNode;\r\n clearOnSelect?: boolean;\r\n}\r\n\r\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\r\n ({\r\n options,\r\n label,\r\n placeholder,\r\n value,\r\n defaultValue,\r\n onValueChange,\r\n isLoading,\r\n className,\r\n emptyText = 'No results found.',\r\n leftIcon,\r\n clearOnSelect = false,\r\n }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) setInternalValue(clearOnSelect ? null : newVal);\r\n if (newVal !== null) onValueChange?.(newVal);\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n // clearOnSelect: xóa input ngay sau khi chọn, không để label xuất hiện\r\n if (clearOnSelect) {\r\n setInputValue('');\r\n setOpen(false);\r\n }\r\n return;\r\n }\r\n setInputValue(val);\r\n setOpen(val.length > 0);\r\n };\r\n\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (!newOpen) setOpen(false);\r\n };\r\n\r\n const handleClear = (e: React.SyntheticEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(null);\r\n setInputValue('');\r\n setOpen(false);\r\n };\r\n\r\n // Đóng popup trước khi browser paint nếu input rỗng — loại bỏ hoàn toàn nháy 1 frame\r\n React.useLayoutEffect(() => {\r\n if (!inputValue && open) setOpen(false);\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, [inputValue]);\r\n\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue) return [];\r\n return options.filter(o =>\r\n o.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n onInputValueChange={handleInputValueChange}\r\n open={open}\r\n onOpenChange={handleOpenChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find(o => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\r\n\r\n <div className=\"relative w-full group\">\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n\r\n <BaseCombobox.Input\r\n ref={ref}\r\n placeholder={placeholder}\r\n className={input()}\r\n />\r\n\r\n <div className=\"flex items-center gap-1 shrink-0 text-muted-foreground\">\r\n {isLoading ? (\r\n <Loader2 className=\"h-4 w-4 animate-spin\" />\r\n ) : activeValue && !clearOnSelect ? (\r\n <span\r\n role=\"button\"\r\n aria-label=\"Clear selection\"\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : null}\r\n </div>\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)' }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n inputValue ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : null\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.description ? (\r\n <div className=\"flex flex-col\">\r\n <span>{option.label}</span>\r\n <span className=\"text-xs text-muted-foreground\">{option.description}</span>\r\n </div>\r\n ) : option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nAutocomplete.displayName = 'Autocomplete';\r\n\r\nexport { Autocomplete };\r\n"
101
101
  }
102
102
  ]
103
103
  },
@@ -123,7 +123,7 @@
123
123
  "files": [
124
124
  {
125
125
  "path": "src/components/ui/badge/Badge.tsx",
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"
126
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst badgeVariants = tv({\r\n // Kraken: 6–8px radius for badges, not pill-shaped\r\n base: 'inline-flex items-center justify-center rounded-md 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-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n secondary: 'border-transparent bg-secondary text-secondary-foreground',\r\n outline: 'border-border text-foreground hover:bg-muted',\r\n success: 'border-transparent bg-success text-success-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n warning: 'border-transparent bg-warning text-warning-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n danger: 'border-transparent bg-danger text-danger-foreground shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n\r\n // Soft variants — Kraken-style: 16% opacity background, dark text\r\n 'soft-primary': 'border-transparent bg-primary/[0.12] text-primary',\r\n 'soft-success': 'border-transparent bg-success/[0.16] text-success',\r\n 'soft-warning': 'border-transparent bg-warning/[0.16] text-warning',\r\n 'soft-danger': 'border-transparent bg-danger/[0.12] 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-violet-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 };\r\n"
127
127
  }
128
128
  ]
129
129
  },
@@ -153,7 +153,7 @@
153
153
  "files": [
154
154
  {
155
155
  "path": "src/components/ui/button/Button.tsx",
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"
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-lg 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 // Kraken Primary Purple\r\n solid: 'bg-primary text-primary-foreground hover:bg-primary/80 shadow-[rgba(0,0,0,0.08)_0px_1px_4px]',\r\n // Kraken Purple Outlined — border + text use primary colour\r\n outline: 'border border-primary/40 bg-transparent text-primary hover:bg-primary/5 hover:border-primary/70',\r\n // Kraken Secondary Gray — subtle bg, neutral text\r\n ghost: 'hover:bg-accent hover:text-accent-foreground',\r\n // Kraken Purple Subtle — secondary surface\r\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/70 shadow-[rgba(0,0,0,0.04)_0px_1px_4px]',\r\n danger: 'bg-danger text-danger-foreground hover:bg-danger/80 shadow-[rgba(0,0,0,0.08)_0px_1px_4px]',\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-accent 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 };\r\n"
157
157
  }
158
158
  ]
159
159
  },
@@ -180,7 +180,7 @@
180
180
  "files": [
181
181
  {
182
182
  "path": "src/components/ui/card/Card.tsx",
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"
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 // Kraken: 16px radius, whisper shadow rgba(0,0,0,0.03)\r\n base: 'rounded-xl border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.03)_0px_4px_24px]',\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-foreground', 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"
184
184
  }
185
185
  ]
186
186
  },
@@ -199,6 +199,23 @@
199
199
  }
200
200
  ]
201
201
  },
202
+ "chart": {
203
+ "name": "chart",
204
+ "dependencies": [
205
+ "recharts"
206
+ ],
207
+ "internalDependencies": [],
208
+ "files": [
209
+ {
210
+ "path": "src/components/ui/chart/chart-tokens.ts",
211
+ "content": "// ─── Chart design tokens (Kraken palette) ────────────────────────────────────\n\nexport const CHART_COLORS = [\n '#7132f5', '#149e61', '#2563eb', '#f59e0b',\n '#ef4444', '#c4b0ff', '#5741d8', '#1acc72',\n] as const;\n\n/** Axis tick style */\nexport const CHART_AX = { fontSize: 12, fill: '#9497a9' } as const;\n\n/** Cartesian grid style */\nexport const CHART_GRD = { stroke: '#dedee5', strokeDasharray: '4 4' } as const;\n\n// ─── Shared prop types ────────────────────────────────────────────────────────\n\nexport type SeriesItem = {\n key: string;\n name?: string;\n color?: string;\n stackId?: string;\n};\n\nexport type PieDataItem = {\n name: string;\n value: number;\n color?: string;\n};\n\n/** Dữ liệu trả về khi onClick trên các chart\n * - activeLabel : giá trị trục X (category) tại vị trí click\n * - activeIndex : vị trí trong mảng data\n * - dataKey : key của series được click (line/area/bar cụ thể)\n * - payload : toàn bộ row data tại vị trí đó (data[activeIndex])\n */\nexport type ChartClickEvent = {\n activeLabel?: string | number;\n activeIndex?: number;\n dataKey?: string;\n payload?: Record<string, unknown>;\n};\n"
212
+ },
213
+ {
214
+ "path": "src/components/ui/chart/Chart.tsx",
215
+ "content": "import * as React from 'react';\nimport {\n LineChart, Line,\n BarChart, Bar,\n AreaChart, Area,\n PieChart, Pie, Cell,\n RadarChart, Radar, PolarGrid, PolarAngleAxis,\n XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,\n} from 'recharts';\nimport type { TooltipContentProps, TooltipIndex } from 'recharts';\nimport {\n CHART_COLORS, CHART_AX, CHART_GRD,\n} from './chart-tokens';\nimport type { SeriesItem, PieDataItem, ChartClickEvent } from './chart-tokens';\n\n// ─── Helper: tìm row bằng activeLabel (recharts v3 không có activePayload trong onClick) ─\nconst resolveByLabel = (\n data: Record<string, unknown>[],\n labelKey: string,\n activeLabel: string | number | undefined,\n e: React.SyntheticEvent,\n cb: (payload: ChartClickEvent, event: React.MouseEvent) => void,\n) => {\n if (activeLabel == null) return;\n const idx = data.findIndex(d => d[labelKey] === activeLabel);\n if (idx < 0) return;\n cb({ activeLabel, activeIndex: idx, payload: data[idx] }, e as React.MouseEvent);\n};\n\n// ─── Custom Tooltip ───────────────────────────────────────────────────────────\nexport const ChartTooltip = ({ active, payload, label }: TooltipContentProps) => {\n if (!active || !payload?.length) return null;\n return (\n <div className=\"rounded-lg border border-border bg-background p-3 shadow-[rgba(0,0,0,0.1)_0px_4px_20px] text-sm min-w-[150px]\">\n {label != null && (\n <p className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n {label}\n </p>\n )}\n {payload.map((e) => (\n <div key={String(e.dataKey)} className=\"flex items-center gap-2 py-0.5\">\n <span className=\"h-2 w-2 shrink-0 rounded-full\" style={{ background: e.color }} />\n <span className=\"text-muted-foreground\">{e.name}:</span>\n <span className=\"ml-auto pl-3 font-semibold text-foreground\">\n {typeof e.value === 'number' ? e.value.toLocaleString() : e.value}\n </span>\n </div>\n ))}\n </div>\n );\n};\n\n// ─── ChartLine ────────────────────────────────────────────────────────────────\nexport interface ChartLineProps {\n data: Record<string, unknown>[];\n xKey: string;\n series: SeriesItem[];\n height?: number;\n curved?: boolean;\n defaultIndex?: TooltipIndex;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartLine: React.FC<ChartLineProps> = ({\n data, xKey, series, height = 280, curved = true, defaultIndex, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <LineChart\n data={data}\n margin={{ top: 4, right: 16, left: 0, bottom: 0 }}\n style={{ cursor: onClick ? 'pointer' : undefined }}\n onClick={onClick\n ? ({ activeLabel }, e) => resolveByLabel(data, xKey, activeLabel, e, onClick)\n : undefined}\n >\n <CartesianGrid {...CHART_GRD} vertical={false} />\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\n <Tooltip content={ChartTooltip} defaultIndex={defaultIndex} />\n <Legend wrapperStyle={{ fontSize: 12 }} />\n {series.map((s, i) => (\n <Line key={s.key} type={curved ? 'monotone' : 'linear'}\n dataKey={s.key} name={s.name ?? s.key}\n stroke={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}\n strokeWidth={2} dot={false}\n activeDot={onClick ? makeActiveDot(xKey, onClick) : { r: 5, strokeWidth: 0 }} />\n ))}\n </LineChart>\n </ResponsiveContainer>\n);\n\n// ─── ChartBar ─────────────────────────────────────────────────────────────────\nexport interface ChartBarProps {\n data: Record<string, unknown>[];\n xKey: string;\n series: SeriesItem[];\n height?: number;\n stacked?: boolean;\n layout?: 'horizontal' | 'vertical';\n defaultIndex?: TooltipIndex;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartBar: React.FC<ChartBarProps> = ({\n data, xKey, series, height = 280, stacked, layout = 'horizontal', defaultIndex, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <BarChart data={data} layout={layout} margin={{ top: 4, right: 16, left: 0, bottom: 0 }} barCategoryGap=\"35%\">\n {layout === 'horizontal' ? (\n <>\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\n </>\n ) : (\n <>\n <XAxis type=\"number\" tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis dataKey={xKey} type=\"category\" tick={CHART_AX} axisLine={false} tickLine={false} width={80} />\n </>\n )}\n <CartesianGrid {...CHART_GRD} vertical={layout === 'vertical'} horizontal={layout === 'horizontal'} />\n <Tooltip content={ChartTooltip} cursor={{ fill: 'rgba(113,50,245,0.05)' }} defaultIndex={defaultIndex} />\n <Legend wrapperStyle={{ fontSize: 12 }} />\n {series.map((s, i) => (\n // Bar.onClick giống Pie.onClick — recharts truyền toàn bộ row data trực tiếp\n <Bar key={s.key} dataKey={s.key} name={s.name ?? s.key}\n fill={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}\n radius={layout === 'horizontal' ? [4, 4, 0, 0] : [0, 4, 4, 0]}\n stackId={stacked ? 'stack' : undefined}\n cursor={onClick ? 'pointer' : undefined}\n onClick={onClick\n ? (barData, index, e) => {\n const row = barData as unknown as Record<string, unknown>;\n onClick({ activeLabel: row[xKey] as string | number, activeIndex: index, dataKey: s.key, payload: row }, e as React.MouseEvent);\n }\n : undefined} />\n ))}\n </BarChart>\n </ResponsiveContainer>\n);\n\n// ─── ChartArea ────────────────────────────────────────────────────────────────\nexport interface ChartAreaProps {\n data: Record<string, unknown>[];\n xKey: string;\n series: SeriesItem[];\n height?: number;\n stacked?: boolean;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartArea: React.FC<ChartAreaProps> = ({\n data, xKey, series, height = 280, stacked, onClick,\n}) => {\n const uid = React.useId().replace(/:/g, '');\n return (\n <ResponsiveContainer width=\"100%\" height={height}>\n <AreaChart\n data={data}\n margin={{ top: 4, right: 16, left: 0, bottom: 0 }}\n style={{ cursor: onClick ? 'pointer' : undefined }}\n onClick={onClick\n ? ({ activeLabel }, e) => resolveByLabel(data, xKey, activeLabel, e, onClick)\n : undefined}\n >\n <defs>\n {series.map((s, i) => {\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\n return (\n <linearGradient key={s.key} id={`${uid}${s.key}`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n <stop offset=\"5%\" stopColor={c} stopOpacity={0.2} />\n <stop offset=\"95%\" stopColor={c} stopOpacity={0} />\n </linearGradient>\n );\n })}\n </defs>\n <CartesianGrid {...CHART_GRD} vertical={false} />\n <XAxis dataKey={xKey} tick={CHART_AX} axisLine={false} tickLine={false} />\n <YAxis tick={CHART_AX} axisLine={false} tickLine={false} width={64} />\n <Tooltip content={ChartTooltip} />\n <Legend wrapperStyle={{ fontSize: 12 }} />\n {series.map((s, i) => {\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\n return (\n <Area key={s.key} type=\"monotone\" dataKey={s.key} name={s.name ?? s.key}\n stroke={c} fill={`url(#${uid}${s.key})`} strokeWidth={2}\n stackId={stacked ? 'stack' : undefined}\n dot={false}\n activeDot={onClick ? makeActiveDot(xKey, onClick) : { r: 5, strokeWidth: 0 }} />\n );\n })}\n </AreaChart>\n </ResponsiveContainer>\n );\n};\n\n// ─── Helper: custom activeDot với onClick cho Line/Area ──────────────────────\ntype ActiveDotRenderProps = {\n cx?: number; cy?: number; r?: number;\n fill?: string; stroke?: string;\n payload?: Record<string, unknown>;\n index?: number;\n dataKey?: string; // key của series được click (vd: 'revenue', 'cost')\n};\n\nconst makeActiveDot = (\n xKey: string,\n onClick: (payload: ChartClickEvent, event: React.MouseEvent) => void,\n) => (dotProps: unknown) => {\n const { cx = 0, cy = 0, fill = '', payload = {}, index = 0, dataKey } = dotProps as ActiveDotRenderProps;\n return (\n <circle\n cx={cx} cy={cy} r={6}\n fill={fill} stroke=\"white\" strokeWidth={2}\n style={{ cursor: 'pointer' }}\n onClick={(e) => {\n e.stopPropagation();\n onClick({ activeLabel: payload[xKey] as string | number, activeIndex: index, dataKey, payload }, e);\n }}\n />\n );\n};\n\n// ─── ChartPie ─────────────────────────────────────────────────────────────────\nexport interface ChartPieProps {\n data: PieDataItem[];\n donut?: boolean;\n height?: number;\n showLabel?: boolean;\n defaultIndex?: TooltipIndex;\n isAnimationActive?: boolean;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartPie: React.FC<ChartPieProps> = ({\n data, donut = false, height = 300, showLabel = true, defaultIndex, isAnimationActive = true, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <PieChart margin={{ top: 20, right: 30, left: 30, bottom: 20 }}>\n <Pie data={data}\n cx=\"50%\" cy=\"50%\"\n dataKey=\"value\"\n innerRadius={donut ? 50 : 0}\n outerRadius={80}\n isAnimationActive={isAnimationActive}\n onClick={onClick\n ? (d, index, e) => {\n const row = d as unknown as Record<string, unknown>;\n onClick({ activeLabel: row['name'] as string, activeIndex: index, dataKey: 'value', payload: row }, e as React.MouseEvent);\n }\n : undefined}\n label={showLabel ? (props) => {\n const { cx = 0, cy = 0, midAngle = 0, outerRadius = 0, percent = 0, name = '' } = props as {\n cx?: number; cy?: number; midAngle?: number;\n outerRadius?: number; percent?: number; name?: string;\n };\n const RADIAN = Math.PI / 180;\n const radius = outerRadius + 15;\n const x = cx + radius * Math.cos(-midAngle * RADIAN);\n const y = cy + radius * Math.sin(-midAngle * RADIAN);\n const shortName = name.length > 12 ? `${name.substring(0, 12)}...` : name;\n return (\n <text x={x} y={y} fill=\"currentColor\" className=\"text-[11px] text-muted-foreground\" textAnchor={x > cx ? 'start' : 'end'} dominantBaseline=\"central\">\n {shortName} {(percent * 100).toFixed(0)}%\n </text>\n );\n } : false}\n labelLine={showLabel ? { stroke: 'currentColor', className: 'text-border opacity-50' } : false}\n >\n {data.map((entry, i) => (\n <Cell\n key={entry.name}\n fill={entry.color ?? CHART_COLORS[i % CHART_COLORS.length]}\n style={{ cursor: onClick ? 'pointer' : 'default', outline: 'none' }}\n />\n ))}\n </Pie>\n <Tooltip content={ChartTooltip} defaultIndex={defaultIndex} />\n <Legend wrapperStyle={{ fontSize: 11, paddingTop: 10 }} iconType=\"circle\" />\n </PieChart>\n </ResponsiveContainer>\n);\n\n// ─── ChartRadar ───────────────────────────────────────────────────────────────\nexport interface ChartRadarProps {\n data: Record<string, unknown>[];\n angleKey: string;\n series: Array<{ key: string; name?: string; color?: string }>;\n height?: number;\n onClick?: (payload: ChartClickEvent, event: React.MouseEvent) => void;\n}\n\nexport const ChartRadar: React.FC<ChartRadarProps> = ({\n data, angleKey, series, height = 280, onClick,\n}) => (\n <ResponsiveContainer width=\"100%\" height={height}>\n <RadarChart\n data={data}\n cx=\"50%\" cy=\"50%\" outerRadius=\"72%\"\n style={{ cursor: onClick ? 'pointer' : undefined }}\n onClick={onClick\n ? ({ activeLabel }, e) => resolveByLabel(data, angleKey, activeLabel, e, onClick)\n : undefined}\n >\n <PolarGrid stroke=\"#dedee5\" />\n <PolarAngleAxis dataKey={angleKey} tick={CHART_AX} />\n <Tooltip content={ChartTooltip} />\n <Legend wrapperStyle={{ fontSize: 12 }} iconType=\"circle\" />\n {series.map((s, i) => {\n const c = s.color ?? CHART_COLORS[i % CHART_COLORS.length];\n return (\n <Radar key={s.key} dataKey={s.key} name={s.name ?? s.key}\n stroke={c} fill={c} fillOpacity={0.15} strokeWidth={2} />\n );\n })}\n </RadarChart>\n </ResponsiveContainer>\n);\n\n// ─── Re-export Recharts primitives for composed / custom charts ───────────────\nexport {\n ResponsiveContainer, LineChart, BarChart, AreaChart, PieChart, RadarChart,\n Line, Bar, Area, Pie, Cell, Radar,\n XAxis, YAxis, CartesianGrid, Tooltip, Legend, PolarGrid, PolarAngleAxis,\n} from 'recharts';\nexport type { TooltipProps, TooltipContentProps } from 'recharts';\n\n// ─── Re-export tokens so consumers only need one import path ──────────────────\nexport type { SeriesItem, PieDataItem, ChartClickEvent } from './chart-tokens';\nexport { CHART_COLORS } from './chart-tokens';\n"
216
+ }
217
+ ]
218
+ },
202
219
  "checkbox": {
203
220
  "name": "checkbox",
204
221
  "dependencies": [
@@ -267,7 +284,7 @@
267
284
  "files": [
268
285
  {
269
286
  "path": "src/components/ui/combobox/ComboBox.tsx",
270
- "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 const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | string[] | null) => {\r\n isSelectingRef.current = true;\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 handleInputValueChange = (val: string) => {\r\n // Bỏ qua cập nhật inputValue khi đang chọn item để tránh nháy popup\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n setInputValue(val);\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={handleInputValueChange}\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"
287
+ "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-lg 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-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] 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 const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | string[] | null) => {\r\n isSelectingRef.current = true;\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 handleInputValueChange = (val: string) => {\r\n // Bỏ qua cập nhật inputValue khi đang chọn item để tránh nháy popup\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n setInputValue(val);\r\n };\r\n\r\n const handleClear = (e: React.SyntheticEvent) => {\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={handleInputValueChange}\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 <div className=\"flex items-center gap-1 shrink-0 ml-auto text-muted-foreground\">\r\n {hasValue ? (\r\n <span\r\n role=\"button\"\r\n aria-label=\"Clear selection\"\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : (\r\n <BaseCombobox.Trigger className=\"transition-transform group-data-open:rotate-180\">\r\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\r\n </BaseCombobox.Trigger>\r\n )}\r\n </div>\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"
271
288
  }
272
289
  ]
273
290
  },
@@ -300,7 +317,7 @@
300
317
  "files": [
301
318
  {
302
319
  "path": "src/components/ui/datepicker/DatePicker.tsx",
303
- "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"
320
+ "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 ${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-lg 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"
304
321
  }
305
322
  ]
306
323
  },
@@ -387,7 +404,7 @@
387
404
  "files": [
388
405
  {
389
406
  "path": "src/components/ui/input/Input.tsx",
390
- "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"
407
+ "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-lg 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"
391
408
  }
392
409
  ]
393
410
  },
@@ -573,9 +590,13 @@
573
590
  ],
574
591
  "internalDependencies": [],
575
592
  "files": [
593
+ {
594
+ "path": "src/components/ui/select/MultiSelect.tsx",
595
+ "content": "\"use client\"\r\nimport * 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 multiSelectVariants = tv({\r\n slots: {\r\n trigger: 'flex min-h-10 w-full items-start justify-between rounded-lg 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 gap-2',\r\n content: 'relative z-50 max-h-[300px] min-w-[var(--anchor-width)] overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] 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 } = multiSelectVariants();\r\n\r\n/** Props for the MultiSelect component */\r\nexport interface MultiSelectProps 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 values */\r\n value?: string[];\r\n /** Initial selected values for uncontrolled usage */\r\n defaultValue?: string[];\r\n /** Whether a clear button is shown when value is selected */\r\n clearable?: boolean;\r\n /** Callback fired when the selected values change */\r\n onChange?: (values: 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 /** Max tags displayed before showing \"+N more\" */\r\n maxTags?: number;\r\n}\r\n\r\nconst MultiSelect = React.forwardRef<React.ElementRef<typeof BaseSelect.Trigger>, MultiSelectProps>(\r\n ({ label, description, error, placeholder = 'Select...', options, id, className, clearable = true, onChange, value, defaultValue, emptyText = 'No results found.', clearLabel = 'Clear selection', maxTags = 2, ...props }, ref) => {\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n const [selectedValues, setSelectedValues] = React.useState<string[]>(value ?? defaultValue ?? []);\r\n\r\n React.useEffect(() => {\r\n if (value !== undefined) setSelectedValues(value);\r\n }, [value]);\r\n\r\n const handleValueChange = (val: string) => {\r\n const newValues = selectedValues.includes(val)\r\n ? selectedValues.filter((v) => v !== val)\r\n : [...selectedValues, val];\r\n setSelectedValues(newValues);\r\n onChange?.(newValues);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n setSelectedValues([]);\r\n onChange?.([]);\r\n };\r\n\r\n const selectedLabels = selectedValues\r\n .map((v) => options.find((o) => o.value === v)?.label)\r\n .filter(Boolean) as string[];\r\n\r\n const displayLabels = selectedLabels.slice(0, maxTags);\r\n const moreCount = selectedLabels.length - maxTags;\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 <div className=\"relative w-full\">\r\n <BaseSelect.Root\r\n value={undefined}\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.RefObject <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 <div className=\"flex flex-wrap gap-1.5 items-center\">\r\n {selectedLabels.length === 0 ? (\r\n <span className=\"text-muted-foreground\">{placeholder}</span>\r\n ) : (\r\n <>\r\n {displayLabels.map((label) => (\r\n <span\r\n key={label}\r\n className=\"inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs font-medium text-foreground\"\r\n >\r\n {label}\r\n </span>\r\n ))}\r\n {moreCount > 0 && (\r\n <span className=\"text-xs text-muted-foreground\">\r\n +{moreCount} more\r\n </span>\r\n )}\r\n </>\r\n )}\r\n </div>\r\n <BaseSelect.Icon>\r\n { selectedValues.length <= 0 &&<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 <button\r\n key={option.value}\r\n type=\"button\"\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange(option.value);\r\n }}\r\n className={cn(\r\n item(),\r\n 'text-left',\r\n selectedValues.includes(option.value) && 'bg-muted text-foreground'\r\n )}\r\n >\r\n <span className=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\r\n {selectedValues.includes(option.value) && <Check className=\"h-4 w-4\" />}\r\n </span>\r\n <span>{option.label}</span>\r\n </button>\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 {clearable && selectedValues.length > 0 && (\r\n <button\r\n type=\"button\"\r\n aria-label={clearLabel}\r\n onMouseDown={handleClear}\r\n className=\"cursor-pointer absolute right-1 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\nMultiSelect.displayName = 'MultiSelect';\r\n\r\nexport { MultiSelect };\r\n"
596
+ },
576
597
  {
577
598
  "path": "src/components/ui/select/Select.tsx",
578
- "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 {!selectedValue && <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-3 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"
599
+ "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-lg 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-lg border border-border bg-background text-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] 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.SyntheticEvent) => {\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\">\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 <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={cn('truncate', selectedLabel ? 'text-foreground' : 'text-muted-foreground')}>\r\n {selectedLabel ?? placeholder}\r\n </span>\r\n \r\n <div className=\"flex items-center gap-1 shrink-0 text-muted-foreground\">\r\n {clearable && selectedValue ? (\r\n <span\r\n role=\"button\"\r\n aria-label={clearLabel}\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors z-10 pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : (\r\n <BaseSelect.Icon>\r\n <ChevronDown className={icon()} />\r\n </BaseSelect.Icon>\r\n )}\r\n </div>\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 {option.value === selectedValue && <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 {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"
579
600
  }
580
601
  ]
581
602
  },
@@ -712,6 +733,17 @@
712
733
  }
713
734
  ]
714
735
  },
736
+ "table-contents": {
737
+ "name": "table-contents",
738
+ "dependencies": [],
739
+ "internalDependencies": [],
740
+ "files": [
741
+ {
742
+ "path": "src/components/ui/table-contents/TableContents.tsx",
743
+ "content": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\nexport type TocItem = {\n id: string;\n label: string;\n level?: 1 | 2 | 3;\n};\n\nexport interface TableContentsProps {\n items: TocItem[];\n /** Offset (px) từ top khi scroll — dành cho sticky header */\n offset?: number;\n /** Title hiển thị trên danh sách */\n title?: string;\n className?: string;\n}\n\n// ─── Helper: tìm scrollable ancestor gần nhất ────────────────────────────────\nfunction getScrollParent(el: HTMLElement | null): HTMLElement | null {\n let node = el?.parentElement ?? null;\n while (node && node !== document.body) {\n const { overflow, overflowY } = getComputedStyle(node);\n if (/auto|scroll/.test(overflow + overflowY)) return node;\n node = node.parentElement;\n }\n return null;\n}\n\n// ─── Hook: theo dõi section đang active qua IntersectionObserver ──────────────\nfunction useActiveSection(ids: string[], offset: number): string {\n const [activeId, setActiveId] = React.useState('');\n\n React.useEffect(() => {\n if (!ids.length) return;\n\n // Dùng scrollable container làm root để rootMargin hoạt động đúng\n const firstEl = document.getElementById(ids[0]);\n const root = firstEl ? getScrollParent(firstEl) : null;\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n setActiveId(entry.target.id);\n break;\n }\n }\n },\n { root, rootMargin: `-${offset + 8}px 0px -70% 0px`, threshold: 0 },\n );\n\n ids.forEach((id) => {\n const el = document.getElementById(id);\n if (el) observer.observe(el);\n });\n\n return () => observer.disconnect();\n }, [ids, offset]);\n\n return activeId;\n}\n\n// ─── Component ────────────────────────────────────────────────────────────────\nexport const TableContents: React.FC<TableContentsProps> = ({\n items,\n offset = 80,\n title = 'Trên trang này',\n className,\n}) => {\n const ids = React.useMemo(() => items.map((i) => i.id), [items]);\n const activeId = useActiveSection(ids, offset);\n\n const scrollTo = (id: string) => {\n const el = document.getElementById(id);\n if (!el) return;\n\n const container = getScrollParent(el);\n if (!container) {\n // Fallback: window scroll\n window.scrollTo({\n top: el.getBoundingClientRect().top + window.scrollY - offset,\n behavior: 'smooth',\n });\n return;\n }\n\n const top =\n el.getBoundingClientRect().top -\n container.getBoundingClientRect().top +\n container.scrollTop -\n offset;\n container.scrollTo({ top, behavior: 'smooth' });\n };\n\n return (\n <nav className={cn('select-none', className)} aria-label=\"Table of contents\">\n {title && (\n <p className=\"mb-3 text-xs font-semibold uppercase tracking-widest text-muted-foreground\">\n {title}\n </p>\n )}\n\n <ul className=\"space-y-0.5 border-l border-border\">\n {items.map((item) => {\n const level = item.level ?? 1;\n const isActive = activeId === item.id;\n\n return (\n <li key={item.id}>\n <button\n onClick={() => scrollTo(item.id)}\n className={cn(\n 'block w-full text-left text-sm leading-5 transition-all duration-150',\n 'py-1 pr-2 hover:text-foreground',\n // Indentation theo level\n level === 1 && 'pl-3 font-medium',\n level === 2 && 'pl-5 font-normal',\n level === 3 && 'pl-8 font-normal text-xs',\n // Border left indicator\n isActive\n ? '-ml-px border-l-2 border-primary pl-[calc(theme(spacing.3)-1px)] text-primary'\n : 'text-muted-foreground',\n level === 2 && isActive && 'pl-[calc(theme(spacing.5)-1px)]',\n level === 3 && isActive && 'pl-[calc(theme(spacing.8)-1px)]',\n )}\n aria-current={isActive ? 'location' : undefined}\n >\n {item.label}\n </button>\n </li>\n );\n })}\n </ul>\n </nav>\n );\n};\n\nexport default TableContents;\n"
744
+ }
745
+ ]
746
+ },
715
747
  "tabs": {
716
748
  "name": "tabs",
717
749
  "dependencies": [
@@ -736,7 +768,7 @@
736
768
  "files": [
737
769
  {
738
770
  "path": "src/components/ui/textarea/Textarea.tsx",
739
- "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"
771
+ "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-lg 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"
740
772
  }
741
773
  ]
742
774
  },
package/scripts/ui-cli.ts CHANGED
@@ -6,7 +6,7 @@ import readline from 'readline';
6
6
 
7
7
  // ─── Constants ────────────────────────────────────────────────────────────────
8
8
 
9
- const VERSION = '0.2.3';
9
+ const VERSION = '0.2.5';
10
10
  const REGISTRY_LOCAL = './registry.json';
11
11
  const REGISTRY_REMOTE = 'https://raw.githubusercontent.com/Basuicn/basuicn-core/main/registry.json';
12
12