basuicn 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/dist/assets/abap-CglpPUUh.js +1 -0
  2. package/dist/assets/actionscript-3-CGv72Q9E.js +1 -0
  3. package/dist/assets/ada-CfxXaXwr.js +1 -0
  4. package/dist/assets/andromeeda-DXsn6mTE.js +1 -0
  5. package/dist/assets/angular-html-Bc8jBW13.js +1 -0
  6. package/dist/assets/angular-ts-BKEiR2gl.js +1 -0
  7. package/dist/assets/apache-D5xAQKSB.js +1 -0
  8. package/dist/assets/apex-qgfedJV2.js +1 -0
  9. package/dist/assets/apl-akXtgiiz.js +1 -0
  10. package/dist/assets/applescript-DqumQxTt.js +1 -0
  11. package/dist/assets/ara-Ditv3ow6.js +1 -0
  12. package/dist/assets/asciidoc-DL8QwiT7.js +1 -0
  13. package/dist/assets/asm-K6TMxl3o.js +1 -0
  14. package/dist/assets/astro-CN0Tv6yD.js +1 -0
  15. package/dist/assets/aurora-x-DGv-xZaa.js +1 -0
  16. package/dist/assets/awk-CYSfQSDT.js +1 -0
  17. package/dist/assets/ayu-dark-DGBaJbpH.js +1 -0
  18. package/dist/assets/ayu-light-BQrGPVs2.js +1 -0
  19. package/dist/assets/ayu-mirage-Do-5o2VL.js +1 -0
  20. package/dist/assets/ballerina-BE-8n54s.js +1 -0
  21. package/dist/assets/base-80a1f760-BubTTHfk.js +1 -0
  22. package/dist/assets/bat-B1zTc1QZ.js +1 -0
  23. package/dist/assets/beancount-DhObrvif.js +1 -0
  24. package/dist/assets/berry-Cnbvthv1.js +1 -0
  25. package/dist/assets/bibtex-pp5SrwZy.js +1 -0
  26. package/dist/assets/bicep-BL6eYxbs.js +1 -0
  27. package/dist/assets/bird2-D57-fQHo.js +1 -0
  28. package/dist/assets/blade-e8vwYS05.js +1 -0
  29. package/dist/assets/bsl-B0twaj2j.js +1 -0
  30. package/dist/assets/c-BbKTZcBW.js +1 -0
  31. package/dist/assets/c3-BHTco5Hb.js +1 -0
  32. package/dist/assets/cadence-CoPxW0qb.js +1 -0
  33. package/dist/assets/cairo-DASKFAWI.js +1 -0
  34. package/dist/assets/catppuccin-frappe-9fYwMJCf.js +1 -0
  35. package/dist/assets/catppuccin-latte-BUiKH1yZ.js +1 -0
  36. package/dist/assets/catppuccin-macchiato-ZmO2e0M_.js +1 -0
  37. package/dist/assets/catppuccin-mocha-CFwbXQfO.js +1 -0
  38. package/dist/assets/chunk-BEqpzyXh.js +1 -0
  39. package/dist/assets/clarity-DVUGm1xP.js +1 -0
  40. package/dist/assets/clojure-BgXJG6FG.js +1 -0
  41. package/dist/assets/cmake-CZndsvm6.js +1 -0
  42. package/dist/assets/cobol-DYVFfDXp.js +1 -0
  43. package/dist/assets/codeowners-Dd7a447M.js +1 -0
  44. package/dist/assets/codeql-XJR6llG-.js +1 -0
  45. package/dist/assets/coffee--RG78j0v.js +1 -0
  46. package/dist/assets/common-lisp-BOMbLvgT.js +1 -0
  47. package/dist/assets/consoleHook-59e792cb-3dE1G69s.js +2 -0
  48. package/dist/assets/coq-DnhmQYDa.js +1 -0
  49. package/dist/assets/cpp-B8wbpQ1o.js +1 -0
  50. package/dist/assets/crystal-2t0qtInQ.js +1 -0
  51. package/dist/assets/csharp-BdEyRqBm.js +1 -0
  52. package/dist/assets/css-BGJ_Me92.js +1 -0
  53. package/dist/assets/csv-CXchYFbz.js +1 -0
  54. package/dist/assets/cue-D7YpBZvd.js +1 -0
  55. package/dist/assets/cypher-XIm1N8YJ.js +1 -0
  56. package/dist/assets/d-DvB0pbZH.js +1 -0
  57. package/dist/assets/dark-plus-BUdpMFIA.js +1 -0
  58. package/dist/assets/dart-CJ0nYDsz.js +1 -0
  59. package/dist/assets/dax-CEynWJsU.js +1 -0
  60. package/dist/assets/desktop-Dk-YiYS_.js +1 -0
  61. package/dist/assets/diff-abLZ-xM1.js +1 -0
  62. package/dist/assets/docker-BT48KsRJ.js +1 -0
  63. package/dist/assets/dotenv-NOMQQbM0.js +1 -0
  64. package/dist/assets/dracula-BUW0ZyNO.js +1 -0
  65. package/dist/assets/dracula-soft-BMjAt1_u.js +1 -0
  66. package/dist/assets/dream-maker-D26TP1Q3.js +1 -0
  67. package/dist/assets/edge-CxTqP9J2.js +1 -0
  68. package/dist/assets/elixir-rfdqmTwN.js +1 -0
  69. package/dist/assets/elm-b1gwuH2v.js +1 -0
  70. package/dist/assets/emacs-lisp-CgQmnPzZ.js +1 -0
  71. package/dist/assets/erb-BzQ154zj.js +1 -0
  72. package/dist/assets/erlang-DnbJbitJ.js +1 -0
  73. package/dist/assets/everforest-dark-hC6a272f.js +1 -0
  74. package/dist/assets/everforest-light-BTpbYY5G.js +1 -0
  75. package/dist/assets/fennel-DWOL-Ewx.js +1 -0
  76. package/dist/assets/fish-CQPQq0pQ.js +1 -0
  77. package/dist/assets/fluent-BYdsr2M8.js +1 -0
  78. package/dist/assets/fortran-fixed-form-Bse4XUyD.js +1 -0
  79. package/dist/assets/fortran-free-form-C_EtY4F9.js +1 -0
  80. package/dist/assets/fsharp-Dy8OnZ0O.js +1 -0
  81. package/dist/assets/gdresource-BD_7yRHA.js +1 -0
  82. package/dist/assets/gdscript-CH_tD-VB.js +1 -0
  83. package/dist/assets/gdshader-BemFblFP.js +1 -0
  84. package/dist/assets/genie-hCPg_eq7.js +1 -0
  85. package/dist/assets/gherkin-CO0ypD1P.js +1 -0
  86. package/dist/assets/git-commit-CIMjlyHf.js +1 -0
  87. package/dist/assets/git-rebase-BhLC-wcM.js +1 -0
  88. package/dist/assets/github-dark-cA72fqmI.js +1 -0
  89. package/dist/assets/github-dark-default-DiYTFvY5.js +1 -0
  90. package/dist/assets/github-dark-dimmed-D-uBHzyG.js +1 -0
  91. package/dist/assets/github-dark-high-contrast-BrUSkckI.js +1 -0
  92. package/dist/assets/github-light-default-BT-6Vf7C.js +1 -0
  93. package/dist/assets/github-light-high-contrast-DqmEyTTc.js +1 -0
  94. package/dist/assets/github-light-piFPqU8R.js +1 -0
  95. package/dist/assets/gleam-CWJA_H5i.js +1 -0
  96. package/dist/assets/glimmer-js-UABHyGyr.js +1 -0
  97. package/dist/assets/glimmer-ts-BZodzUJU.js +1 -0
  98. package/dist/assets/glsl-DB4OjpDf.js +1 -0
  99. package/dist/assets/gn-nlTwLSjP.js +1 -0
  100. package/dist/assets/gnuplot-wYV0MbPl.js +1 -0
  101. package/dist/assets/go-Dhd8RnZp.js +1 -0
  102. package/dist/assets/graphql-CjkeIubW.js +1 -0
  103. package/dist/assets/groovy-BusafOA8.js +1 -0
  104. package/dist/assets/gruvbox-dark-hard-BrrZYoPF.js +1 -0
  105. package/dist/assets/gruvbox-dark-medium-DsxcLuUs.js +1 -0
  106. package/dist/assets/gruvbox-dark-soft-DQXOs_GH.js +1 -0
  107. package/dist/assets/gruvbox-light-hard-CtHwj_cB.js +1 -0
  108. package/dist/assets/gruvbox-light-medium-CPJMdgXQ.js +1 -0
  109. package/dist/assets/gruvbox-light-soft-C4Xh9Hc-.js +1 -0
  110. package/dist/assets/hack-hTulK3E0.js +1 -0
  111. package/dist/assets/haml-0xmzGOI8.js +1 -0
  112. package/dist/assets/handlebars-BXRDjB0q.js +1 -0
  113. package/dist/assets/haskell-CaDqJkn4.js +1 -0
  114. package/dist/assets/haxe-Dxg-8_J7.js +1 -0
  115. package/dist/assets/hcl-Bjg-rMJ0.js +1 -0
  116. package/dist/assets/hjson-BMbeOOMg.js +1 -0
  117. package/dist/assets/hlsl-D-cuwCFV.js +1 -0
  118. package/dist/assets/horizon-BofYY9of.js +1 -0
  119. package/dist/assets/horizon-bright-BtJSJLk4.js +1 -0
  120. package/dist/assets/houston-BIBYxFpQ.js +1 -0
  121. package/dist/assets/html-DNHpMnNu.js +1 -0
  122. package/dist/assets/html-derivative-BBCc_afp.js +1 -0
  123. package/dist/assets/http-BJnSZ0fu.js +1 -0
  124. package/dist/assets/hurl-DVkxBugG.js +1 -0
  125. package/dist/assets/hxml-CGx66ebi.js +1 -0
  126. package/dist/assets/hy-Cye3Jt7O.js +1 -0
  127. package/dist/assets/imba-CjkQtgD2.js +1 -0
  128. package/dist/assets/index-599aeaf7-tfuZHJ9A.js +16 -0
  129. package/dist/assets/index-DMOps9TG.js +1893 -0
  130. package/dist/assets/index-DReVoGMp.css +1 -0
  131. package/dist/assets/ini-BvMtfDNl.js +1 -0
  132. package/dist/assets/java-D2XAnJB0.js +1 -0
  133. package/dist/assets/javascript-X-FQYNvg.js +1 -0
  134. package/dist/assets/jinja-BnJYnQiG.js +1 -0
  135. package/dist/assets/jison-DnA7sY80.js +1 -0
  136. package/dist/assets/json-SC38HrRr.js +1 -0
  137. package/dist/assets/json5-BCxDPjxU.js +1 -0
  138. package/dist/assets/jsonc-BV8VFjqO.js +1 -0
  139. package/dist/assets/jsonl-DHrr7c-M.js +1 -0
  140. package/dist/assets/jsonnet-xURar8mu.js +1 -0
  141. package/dist/assets/jssm-DtoLUQAt.js +1 -0
  142. package/dist/assets/jsx-bsCPoRSN.js +1 -0
  143. package/dist/assets/julia-Dk81kxcu.js +1 -0
  144. package/dist/assets/just-DvZye5TV.js +1 -0
  145. package/dist/assets/kanagawa-dragon-COwlYgQB.js +1 -0
  146. package/dist/assets/kanagawa-lotus-GZWqyq_E.js +1 -0
  147. package/dist/assets/kanagawa-wave-CP4uw3DX.js +1 -0
  148. package/dist/assets/kdl-DGo2N7N5.js +1 -0
  149. package/dist/assets/kotlin-Cm4W8Bbs.js +1 -0
  150. package/dist/assets/kusto-DNFwIuzY.js +1 -0
  151. package/dist/assets/laserwave-DfABGYsD.js +1 -0
  152. package/dist/assets/latex-D3SwBEWn.js +1 -0
  153. package/dist/assets/lean-D-8a6Wwv.js +1 -0
  154. package/dist/assets/less-B-qZzFKw.js +1 -0
  155. package/dist/assets/light-plus-DS_rmE1N.js +1 -0
  156. package/dist/assets/liquid-CVass2H-.js +1 -0
  157. package/dist/assets/llvm-Ch-k6TUk.js +1 -0
  158. package/dist/assets/log-BGwbCG2S.js +1 -0
  159. package/dist/assets/logo-DHGHbFWy.js +1 -0
  160. package/dist/assets/lua-D_mthnvY.js +1 -0
  161. package/dist/assets/luau-qsvm_nYh.js +1 -0
  162. package/dist/assets/make-Dlfjp_Q8.js +1 -0
  163. package/dist/assets/markdown-COh4ruD_.js +1 -0
  164. package/dist/assets/marko-LZp-7Vlz.js +1 -0
  165. package/dist/assets/material-theme-Ccrl1jie.js +1 -0
  166. package/dist/assets/material-theme-darker-CcOH0iHe.js +1 -0
  167. package/dist/assets/material-theme-lighter-DoKpC7NM.js +1 -0
  168. package/dist/assets/material-theme-ocean-BcvBZHgH.js +1 -0
  169. package/dist/assets/material-theme-palenight-DIL4EAzl.js +1 -0
  170. package/dist/assets/matlab-CBqMuw7w.js +1 -0
  171. package/dist/assets/mdc-BWMWBj66.js +1 -0
  172. package/dist/assets/mdx-BjoJxHxW.js +1 -0
  173. package/dist/assets/mermaid-DjAh2yR5.js +1 -0
  174. package/dist/assets/min-dark-CbwghdE5.js +1 -0
  175. package/dist/assets/min-light-_F39d_94.js +1 -0
  176. package/dist/assets/mipsasm-DA1H0O78.js +1 -0
  177. package/dist/assets/mojo-BVgjRjTn.js +1 -0
  178. package/dist/assets/monokai-CxgvaWkX.js +1 -0
  179. package/dist/assets/moonbit-tKY94ZOM.js +1 -0
  180. package/dist/assets/move-B5WspEjG.js +1 -0
  181. package/dist/assets/narrat-CDPRStf_.js +1 -0
  182. package/dist/assets/nextflow-D6ub5x6I.js +1 -0
  183. package/dist/assets/nextflow-groovy-DC2pAw1t.js +1 -0
  184. package/dist/assets/nginx-tinol6SS.js +1 -0
  185. package/dist/assets/night-owl-CCydx7ZR.js +1 -0
  186. package/dist/assets/night-owl-light-CLM7UUPY.js +1 -0
  187. package/dist/assets/nim-B62YiABd.js +1 -0
  188. package/dist/assets/nix-DXFN4IwW.js +1 -0
  189. package/dist/assets/node-C92mwFMz.js +4 -0
  190. package/dist/assets/nord-CiCWZ1de.js +1 -0
  191. package/dist/assets/nushell-Ck9o2qOx.js +1 -0
  192. package/dist/assets/objective-c-4koxci9E.js +1 -0
  193. package/dist/assets/objective-cpp-BPFAgSN9.js +1 -0
  194. package/dist/assets/ocaml-BdlBLhZV.js +1 -0
  195. package/dist/assets/odin-BcGOLCqZ.js +1 -0
  196. package/dist/assets/one-dark-pro-DEMdTgiO.js +1 -0
  197. package/dist/assets/one-light-Cmpe9X1u.js +1 -0
  198. package/dist/assets/openscad-DBveSHYE.js +1 -0
  199. package/dist/assets/pascal-DxOVE99u.js +1 -0
  200. package/dist/assets/perl-Di890ZVN.js +1 -0
  201. package/dist/assets/php-BE50NddE.js +1 -0
  202. package/dist/assets/pkl-pIvtf7Vu.js +1 -0
  203. package/dist/assets/plastic-Dvdf8qVN.js +1 -0
  204. package/dist/assets/plsql-d50MFTFK.js +1 -0
  205. package/dist/assets/po-CboxWwYg.js +1 -0
  206. package/dist/assets/poimandres-DHgQGH4M.js +1 -0
  207. package/dist/assets/polar-DNp7Oek8.js +1 -0
  208. package/dist/assets/postcss-LpmI6XQv.js +1 -0
  209. package/dist/assets/powerquery-BNCl8Uwz.js +1 -0
  210. package/dist/assets/powershell-CZ9J0XuS.js +1 -0
  211. package/dist/assets/prisma-AELUiJCl.js +1 -0
  212. package/dist/assets/prolog-BlqwejRD.js +1 -0
  213. package/dist/assets/proto-DRQC3swD.js +1 -0
  214. package/dist/assets/pug-DGP7PTk-.js +1 -0
  215. package/dist/assets/puppet-DBKR8MlH.js +1 -0
  216. package/dist/assets/purescript-DItLzxZS.js +1 -0
  217. package/dist/assets/python-D9t_ETu8.js +1 -0
  218. package/dist/assets/qml-BBpLsqsj.js +1 -0
  219. package/dist/assets/qmldir-DP_KD1nk.js +1 -0
  220. package/dist/assets/qss-CL02opOd.js +1 -0
  221. package/dist/assets/r-BL7rt_9D.js +1 -0
  222. package/dist/assets/racket-K0frLTWj.js +1 -0
  223. package/dist/assets/raku-DwgLlPg9.js +1 -0
  224. package/dist/assets/razor-CeWBotJS.js +1 -0
  225. package/dist/assets/red-DzDu-aXc.js +1 -0
  226. package/dist/assets/reg-B_y3c7di.js +1 -0
  227. package/dist/assets/regexp-Dd73s7V-.js +1 -0
  228. package/dist/assets/rel-CT65ywJp.js +1 -0
  229. package/dist/assets/riscv-Qv5W2vIg.js +1 -0
  230. package/dist/assets/ron-cRHu9zPp.js +1 -0
  231. package/dist/assets/rose-pine-DFmTJHIJ.js +1 -0
  232. package/dist/assets/rose-pine-dawn-YIuK9wUm.js +1 -0
  233. package/dist/assets/rose-pine-moon-BYEHfkUB.js +1 -0
  234. package/dist/assets/rosmsg-_L1z4wrx.js +1 -0
  235. package/dist/assets/rst-CKr96H10.js +1 -0
  236. package/dist/assets/ruby-B7TsannQ.js +1 -0
  237. package/dist/assets/runtime-Dva4yzBQ.js +1 -0
  238. package/dist/assets/rust-BA5wQ_EY.js +1 -0
  239. package/dist/assets/sas-C_sF_r8a.js +1 -0
  240. package/dist/assets/sass-sd__ov8V.js +1 -0
  241. package/dist/assets/scala--cVG4U7H.js +1 -0
  242. package/dist/assets/scheme-D6Q1O-kY.js +1 -0
  243. package/dist/assets/scss-B16LvjsL.js +1 -0
  244. package/dist/assets/sdbl-jB0DUrL_.js +1 -0
  245. package/dist/assets/shaderlab-uUHiM5Mm.js +1 -0
  246. package/dist/assets/shellscript-dnyLKRin.js +1 -0
  247. package/dist/assets/shellsession-DTlo_aKR.js +1 -0
  248. package/dist/assets/slack-dark-DHhAlQBa.js +1 -0
  249. package/dist/assets/slack-ochin-PZXbcZaN.js +1 -0
  250. package/dist/assets/smalltalk-C7VkdTlv.js +1 -0
  251. package/dist/assets/snazzy-light-CPc_Qts9.js +1 -0
  252. package/dist/assets/solarized-dark-DCCfKtTh.js +1 -0
  253. package/dist/assets/solarized-light-B84T-R9X.js +1 -0
  254. package/dist/assets/solidity-DVsDPpDS.js +1 -0
  255. package/dist/assets/soy-CYe5idz8.js +1 -0
  256. package/dist/assets/sparql-D5zsvcbT.js +1 -0
  257. package/dist/assets/splunk-A-BwP6Va.js +1 -0
  258. package/dist/assets/sql-D-E0nBNJ.js +1 -0
  259. package/dist/assets/ssh-config-CFPv6mTy.js +1 -0
  260. package/dist/assets/stata-uX6s6LU7.js +1 -0
  261. package/dist/assets/stylus-CUf3IESt.js +1 -0
  262. package/dist/assets/surrealql-GkyIuwxY.js +1 -0
  263. package/dist/assets/svelte-CfnJqTwW.js +1 -0
  264. package/dist/assets/swift-CSiiYB_Q.js +1 -0
  265. package/dist/assets/synthwave-84-uJp1j2Jd.js +1 -0
  266. package/dist/assets/system-verilog-rMjKy-O9.js +1 -0
  267. package/dist/assets/systemd-R-KDM4-t.js +1 -0
  268. package/dist/assets/talonscript-D666vCtm.js +1 -0
  269. package/dist/assets/tasl-68cbBocU.js +1 -0
  270. package/dist/assets/tcl-Y39dJ_pX.js +1 -0
  271. package/dist/assets/templ-D1EOUGk9.js +1 -0
  272. package/dist/assets/terraform-BF7wAo-b.js +1 -0
  273. package/dist/assets/tex-DJNezBXL.js +1 -0
  274. package/dist/assets/tokyo-night-EeABtN82.js +1 -0
  275. package/dist/assets/toml-BrMFN1JF.js +1 -0
  276. package/dist/assets/ts-tags-B6yMAMBr.js +1 -0
  277. package/dist/assets/tsv-Dp2l5v7f.js +1 -0
  278. package/dist/assets/tsx-BOLo_Ogf.js +1 -0
  279. package/dist/assets/turtle-_N1aDtpV.js +1 -0
  280. package/dist/assets/twig-BI3vVzh2.js +1 -0
  281. package/dist/assets/typescript-kRJFfHtG.js +1 -0
  282. package/dist/assets/typespec-b80CR7oU.js +1 -0
  283. package/dist/assets/typst-DP_izaNK.js +1 -0
  284. package/dist/assets/utils-52664384-CNFmSnPR.js +6 -0
  285. package/dist/assets/v-CdNC_Wok.js +1 -0
  286. package/dist/assets/vala-Bu4pYkLC.js +1 -0
  287. package/dist/assets/vb-u84IyWSS.js +1 -0
  288. package/dist/assets/verilog-BC33pi7Z.js +1 -0
  289. package/dist/assets/vesper-7-XtS0Kg.js +1 -0
  290. package/dist/assets/vhdl-BBxTOQe0.js +1 -0
  291. package/dist/assets/viml-X_r-ivXA.js +1 -0
  292. package/dist/assets/vitesse-black-DRdlY-cW.js +1 -0
  293. package/dist/assets/vitesse-dark-Bz0hOFHJ.js +1 -0
  294. package/dist/assets/vitesse-light-7kCLd0yn.js +1 -0
  295. package/dist/assets/vue-B2GeWsQX.js +1 -0
  296. package/dist/assets/vue-html-DIRaMPNP.js +1 -0
  297. package/dist/assets/vue-vine-CfdXDvVv.js +1 -0
  298. package/dist/assets/vyper-E7qIwMXy.js +1 -0
  299. package/dist/assets/wasm-C18OEcCL.js +1 -0
  300. package/dist/assets/wasm-CfqNvg3Z.js +1 -0
  301. package/dist/assets/wenyan-siXAC4el.js +1 -0
  302. package/dist/assets/wgsl-BJQt8Uld.js +1 -0
  303. package/dist/assets/wikitext-Ci0ePJmf.js +1 -0
  304. package/dist/assets/wit-BdDJ7geN.js +1 -0
  305. package/dist/assets/wolfram-Bi-O1IUM.js +1 -0
  306. package/dist/assets/xml-CFID48H0.js +1 -0
  307. package/dist/assets/xsl-BK4ngHMJ.js +1 -0
  308. package/dist/assets/yaml-D6lFkv0K.js +1 -0
  309. package/dist/assets/zenscript-CnkrxCBw.js +1 -0
  310. package/dist/assets/zig-CgMsxF9-.js +1 -0
  311. package/dist/favicon.svg +1 -0
  312. package/dist/icons.svg +24 -0
  313. package/dist/index.html +31 -0
  314. package/dist/ui-cli.cjs +1 -1
  315. package/package.json +1 -1
  316. package/registry.json +133 -14
  317. package/scripts/ui-cli.ts +1 -1
package/registry.json CHANGED
@@ -69,7 +69,7 @@
69
69
  "files": [
70
70
  {
71
71
  "path": "src/components/ui/alert-dialog/AlertDialog.tsx",
72
- "content": "import * as React from 'react';\r\nimport { AlertDialog as BaseAlertDialog } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst alertDialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\r\n content:\r\n 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0 data-close:zoom-out-95 data-open:zoom-in-95 sm:rounded-lg',\r\n header: 'flex flex-col space-y-2 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-2',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst AlertDialog = BaseAlertDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst AlertDialogTrigger = BaseAlertDialog.Trigger;\r\n\r\n/* ─── Close (wraps BaseAlertDialog.Close for cancel buttons) ─── */\r\nconst AlertDialogClose = BaseAlertDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\nconst AlertDialogContent = React.forwardRef<\r\n HTMLDivElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Popup>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, children, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Portal>\r\n <BaseAlertDialog.Backdrop className={slots.overlay()} />\r\n <BaseAlertDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n </BaseAlertDialog.Popup>\r\n </BaseAlertDialog.Portal>\r\n );\r\n});\r\nAlertDialogContent.displayName = 'AlertDialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst AlertDialogHeader = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n});\r\nAlertDialogHeader.displayName = 'AlertDialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst AlertDialogFooter = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n});\r\nAlertDialogFooter.displayName = 'AlertDialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst AlertDialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <BaseAlertDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nAlertDialogTitle.displayName = 'AlertDialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst AlertDialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Description\r\n ref={ref}\r\n className={slots.description({ className })}\r\n {...props}\r\n />\r\n );\r\n});\r\nAlertDialogDescription.displayName = 'AlertDialogDescription';\r\n\r\nexport {\r\n AlertDialog,\r\n AlertDialogTrigger,\r\n AlertDialogContent,\r\n AlertDialogHeader,\r\n AlertDialogFooter,\r\n AlertDialogTitle,\r\n AlertDialogDescription,\r\n AlertDialogClose,\r\n alertDialogVariants,\r\n};\r\n"
72
+ "content": "import * as React from 'react';\r\nimport { AlertDialog as BaseAlertDialog } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\n\r\nconst alertDialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\r\n content:\r\n 'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0 data-close:zoom-out-95 data-open:zoom-in-95 sm:rounded-lg',\r\n header: 'flex flex-col space-y-2 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-2',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst AlertDialog = BaseAlertDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\n// Hỗ trợ cả render={} (Base UI) lẫn children trực tiếp.\r\n// Nếu children là một React element (e.g. <Button>), tự động dùng làm render prop\r\n// để tránh nested button (<button><button>…</button></button>).\r\ntype BaseTriggerProps = React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Trigger>;\r\n\r\ninterface AlertDialogTriggerProps extends Omit<BaseTriggerProps, 'render'> {\r\n render?: BaseTriggerProps['render'];\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst AlertDialogTrigger = React.forwardRef<HTMLElement, AlertDialogTriggerProps>(\r\n ({ render: renderProp, children, ...props }, ref) => {\r\n const resolvedRender =\r\n renderProp ?? (React.isValidElement(children) ? children : undefined);\r\n\r\n return (\r\n <BaseAlertDialog.Trigger\r\n ref={ref as React.Ref<HTMLButtonElement>}\r\n render={resolvedRender}\r\n {...props}\r\n >\r\n {resolvedRender ? undefined : children}\r\n </BaseAlertDialog.Trigger>\r\n );\r\n },\r\n);\r\nAlertDialogTrigger.displayName = 'AlertDialogTrigger';\r\n\r\n/* ─── Close (wraps BaseAlertDialog.Close for cancel buttons) ─── */\r\nconst AlertDialogClose = BaseAlertDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\nconst AlertDialogContent = React.forwardRef<\r\n HTMLDivElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Popup>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, children, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Portal>\r\n <BaseAlertDialog.Backdrop className={slots.overlay()} />\r\n <BaseAlertDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n </BaseAlertDialog.Popup>\r\n </BaseAlertDialog.Portal>\r\n );\r\n});\r\nAlertDialogContent.displayName = 'AlertDialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst AlertDialogHeader = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n});\r\nAlertDialogHeader.displayName = 'AlertDialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst AlertDialogFooter = React.forwardRef<\r\n HTMLDivElement,\r\n React.HTMLAttributes<HTMLDivElement>\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n});\r\nAlertDialogFooter.displayName = 'AlertDialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst AlertDialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return <BaseAlertDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nAlertDialogTitle.displayName = 'AlertDialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst AlertDialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseAlertDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = alertDialogVariants();\r\n return (\r\n <BaseAlertDialog.Description\r\n ref={ref}\r\n className={slots.description({ className })}\r\n {...props}\r\n />\r\n );\r\n});\r\nAlertDialogDescription.displayName = 'AlertDialogDescription';\r\n\r\nexport {\r\n AlertDialog,\r\n AlertDialogTrigger,\r\n AlertDialogContent,\r\n AlertDialogHeader,\r\n AlertDialogFooter,\r\n AlertDialogTitle,\r\n AlertDialogDescription,\r\n AlertDialogClose,\r\n alertDialogVariants,\r\n};\r\n"
73
73
  }
74
74
  ]
75
75
  },
@@ -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-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"
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 'icon-sm': 'h-8 w-8',\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
  },
@@ -244,17 +244,41 @@
244
244
  "path": "src/components/ui/code-sandbox/CodeSandbox.tsx",
245
245
  "content": "import React, { useState, useEffect } from 'react';\nimport { SandpackProvider } from '@codesandbox/sandpack-react';\nimport { cn } from '@/lib/utils/cn';\nimport { SandboxLayout } from './SandboxLayout';\nimport { SANDBOX_TEMPLATES } from './templates';\nimport type { SandboxTemplate } from './templates';\n\n// ─── Dark mode detection ─────────────────────────────────────\n\nfunction useIsDark() {\n const [isDark, setIsDark] = useState(\n () => document.documentElement.classList.contains('dark'),\n );\n\n useEffect(() => {\n const observer = new MutationObserver(() => {\n setIsDark(document.documentElement.classList.contains('dark'));\n });\n observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: ['class'],\n });\n return () => observer.disconnect();\n }, []);\n\n return isDark;\n}\n\n// ─── Sandpack themes ─────────────────────────────────────────\n\nconst lightTheme = {\n colors: {\n surface1: '#ffffff',\n surface2: '#f8fafc',\n surface3: '#f1f5f9',\n clickable: '#64748b',\n base: '#0f172a',\n disabled: '#94a3b8',\n hover: '#0f172a',\n accent: '#2f27ce',\n error: '#ef4444',\n errorSurface: '#fef2f2',\n },\n syntax: {\n plain: '#0f172a',\n comment: { color: '#94a3b8', fontStyle: 'italic' as const },\n keyword: '#7c3aed',\n tag: '#2563eb',\n punctuation: '#64748b',\n definition: '#059669',\n property: '#0891b2',\n static: '#c2410c',\n string: '#16a34a',\n },\n font: {\n body: 'Inter, system-ui, sans-serif',\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\n size: '13px',\n lineHeight: '20px',\n },\n};\n\nconst darkTheme = {\n colors: {\n surface1: '#0f172a',\n surface2: '#1e293b',\n surface3: '#334155',\n clickable: '#94a3b8',\n base: '#e2e8f0',\n disabled: '#475569',\n hover: '#f8fafc',\n accent: '#6366f1',\n error: '#ef4444',\n errorSurface: '#450a0a',\n },\n syntax: {\n plain: '#e2e8f0',\n comment: { color: '#64748b', fontStyle: 'italic' as const },\n keyword: '#c084fc',\n tag: '#60a5fa',\n punctuation: '#94a3b8',\n definition: '#34d399',\n property: '#22d3ee',\n static: '#fb923c',\n string: '#4ade80',\n },\n font: {\n body: 'Inter, system-ui, sans-serif',\n mono: '\"Fira Code\", Consolas, \"Courier New\", monospace',\n size: '13px',\n lineHeight: '20px',\n },\n};\n\n// ─── Props ───────────────────────────────────────────────────\n\nexport interface CodeSandboxProps {\n /** Starting template id */\n defaultTemplate?: string;\n /** Custom initial files (overrides template) */\n files?: Record<string, string>;\n /** Extra dependencies */\n dependencies?: Record<string, string>;\n /** Container className */\n className?: string;\n}\n\n// ─── Component ───────────────────────────────────────────────\n\nexport function CodeSandbox({\n defaultTemplate = 'react-ts',\n files: customFiles,\n dependencies: extraDeps,\n className,\n}: CodeSandboxProps) {\n const isDark = useIsDark();\n const [templateId, setTemplateId] = useState(defaultTemplate);\n\n const template: SandboxTemplate =\n SANDBOX_TEMPLATES.find((t) => t.id === templateId) ??\n SANDBOX_TEMPLATES[0];\n\n const files = customFiles ?? template.files;\n const deps = {\n ...template.dependencies,\n ...extraDeps,\n };\n\n return (\n <div className={cn('h-full w-full', className)}>\n <SandpackProvider\n key={templateId}\n template={template.template}\n theme={isDark ? darkTheme : lightTheme}\n files={files}\n customSetup={{\n dependencies: {\n react: '^18.0.0',\n 'react-dom': '^18.0.0',\n 'react-scripts': '^5.0.0',\n ...deps,\n },\n }}\n options={{\n recompileMode: 'delayed',\n recompileDelay: 400,\n classes: {\n 'sp-wrapper': 'h-full w-full',\n },\n }}\n >\n <SandboxLayout\n templateId={templateId}\n onTemplateChange={setTemplateId}\n className=\"h-full w-full\"\n />\n </SandpackProvider>\n </div>\n );\n}\n"
246
246
  },
247
+ {
248
+ "path": "src/components/ui/code-sandbox/DependencyPanel.tsx",
249
+ "content": "import React, { useState } from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\nimport { Package, Plus, X } from 'lucide-react';\n\nconst POPULAR_PACKAGES = [\n 'axios',\n 'framer-motion',\n 'zustand',\n 'react-router-dom',\n 'date-fns',\n 'clsx',\n 'zod',\n 'react-hook-form',\n 'swr',\n 'lodash',\n];\n\nexport function DependencyPanel() {\n const { sandpack } = useSandpack();\n const [newDep, setNewDep] = useState('');\n\n let deps: Record<string, string> = {};\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n deps = pkg.dependencies || {};\n } catch {\n /* ignore parse error */\n }\n\n const addDep = (name: string) => {\n if (!name.trim() || deps[name]) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n pkg.dependencies = { ...pkg.dependencies, [name.trim()]: 'latest' };\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n setNewDep('');\n } catch {\n /* ignore */\n }\n };\n\n const removeDep = (name: string) => {\n if (['react', 'react-dom', 'react-scripts'].includes(name)) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n const { [name]: _, ...rest } = pkg.dependencies || {};\n pkg.dependencies = rest;\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n } catch {\n /* ignore */\n }\n };\n\n const available = POPULAR_PACKAGES.filter((p) => !deps[p]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Dependencies\n </div>\n\n <form\n onSubmit={(e) => {\n e.preventDefault();\n addDep(newDep);\n }}\n className=\"flex gap-1 px-3 pb-2\"\n >\n <input\n value={newDep}\n onChange={(e) => setNewDep(e.target.value)}\n placeholder=\"Package name...\"\n className=\"flex-1 min-w-0 bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n <button\n type=\"submit\"\n className=\"px-2 py-1.5 bg-primary text-primary-foreground rounded text-xs font-medium hover:bg-primary/90 transition-colors shrink-0\"\n >\n <Plus className=\"w-3.5 h-3.5\" />\n </button>\n </form>\n\n {available.length > 0 && (\n <div className=\"px-3 pb-3\">\n <div className=\"text-[10px] text-muted-foreground mb-1.5\">\n Quick add:\n </div>\n <div className=\"flex flex-wrap gap-1\">\n {available.slice(0, 5).map((p) => (\n <button\n key={p}\n onClick={() => addDep(p)}\n className=\"text-[10px] px-1.5 py-0.5 bg-muted hover:bg-accent rounded border border-border transition-colors\"\n >\n + {p}\n </button>\n ))}\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto border-t border-border\">\n <div className=\"px-3 py-1.5 text-[10px] text-muted-foreground\">\n Installed ({Object.keys(deps).length})\n </div>\n {Object.entries(deps).map(([name, version]) => (\n <div\n key={name}\n className=\"flex items-center justify-between px-3 py-1.5 hover:bg-muted/50 text-[13px] group\"\n >\n <div className=\"flex items-center gap-1.5 truncate min-w-0\">\n <Package className=\"w-3 h-3 text-muted-foreground shrink-0\" />\n <span className=\"truncate\">{name}</span>\n <span className=\"text-[10px] text-muted-foreground shrink-0\">\n {version as string}\n </span>\n </div>\n {!['react', 'react-dom', 'react-scripts'].includes(name) && (\n <button\n onClick={() => removeDep(name)}\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger transition-opacity shrink-0\"\n >\n <X className=\"w-3 h-3\" />\n </button>\n )}\n </div>\n ))}\n </div>\n </div>\n );\n}\n"
250
+ },
247
251
  {
248
252
  "path": "src/components/ui/code-sandbox/FileTree.tsx",
249
253
  "content": "import React, { useState, useMemo } from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\nimport {\n ChevronRight,\n ChevronDown,\n FilePlus,\n FolderPlus,\n Trash2,\n Folder,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Tree data structure ─────────────────────────────────────\n\ninterface TreeNode {\n name: string;\n path: string;\n type: 'file' | 'folder';\n children?: TreeNode[];\n}\n\nfunction buildFileTree(paths: string[]): TreeNode[] {\n const root: TreeNode[] = [];\n\n for (const filePath of paths) {\n const parts = filePath.split('/').filter(Boolean);\n let level = root;\n let currentPath = '';\n\n for (let i = 0; i < parts.length; i++) {\n currentPath += '/' + parts[i];\n const isLast = i === parts.length - 1;\n\n if (isLast) {\n level.push({ name: parts[i], path: currentPath, type: 'file' });\n } else {\n let folder = level.find(\n (n) => n.type === 'folder' && n.name === parts[i],\n );\n if (!folder) {\n folder = {\n name: parts[i],\n path: currentPath,\n type: 'folder',\n children: [],\n };\n level.push(folder);\n }\n level = folder.children!;\n }\n }\n }\n\n function sortNodes(nodes: TreeNode[]): TreeNode[] {\n return nodes\n .sort((a, b) => {\n if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;\n return a.name.localeCompare(b.name);\n })\n .map((n) => {\n if (n.children) n.children = sortNodes(n.children);\n return n;\n });\n }\n\n return sortNodes(root);\n}\n\n// ─── File icon by extension ──────────────────────────────────\n\nfunction getFileIcon(name: string): { label: string; color: string } {\n const ext = name.split('.').pop()?.toLowerCase();\n switch (ext) {\n case 'js':\n case 'mjs':\n return { label: 'JS', color: '#f7df1e' };\n case 'jsx':\n return { label: 'JSX', color: '#61dafb' };\n case 'ts':\n return { label: 'TS', color: '#3178c6' };\n case 'tsx':\n return { label: 'TSX', color: '#3178c6' };\n case 'css':\n case 'scss':\n return { label: '#', color: '#264de4' };\n case 'html':\n return { label: '<>', color: '#e34f26' };\n case 'json':\n return { label: '{ }', color: '#5b5b5b' };\n case 'md':\n return { label: 'M', color: '#083fa1' };\n case 'svg':\n return { label: 'SVG', color: '#ffb13b' };\n default:\n return { label: '\\u00B7', color: '#6b7280' };\n }\n}\n\n// ─── Single tree node ────────────────────────────────────────\n\nfunction TreeItem({\n node,\n depth,\n activeFile,\n openFile,\n deleteFile,\n expanded,\n toggleFolder,\n}: {\n node: TreeNode;\n depth: number;\n activeFile: string;\n openFile: (p: string) => void;\n deleteFile: (p: string) => void;\n expanded: Set<string>;\n toggleFolder: (p: string) => void;\n}) {\n const pl = 12 + depth * 16;\n\n if (node.type === 'folder') {\n const isOpen = expanded.has(node.path);\n return (\n <>\n <div\n className=\"flex items-center py-1 px-2 cursor-pointer hover:bg-muted/50 text-[13px] group\"\n style={{ paddingLeft: pl }}\n onClick={() => toggleFolder(node.path)}\n >\n {isOpen ? (\n <ChevronDown className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\n ) : (\n <ChevronRight className=\"w-3.5 h-3.5 mr-1 shrink-0 text-muted-foreground\" />\n )}\n <Folder className=\"w-3.5 h-3.5 mr-1.5 shrink-0 text-amber-500\" />\n <span className=\"truncate\">{node.name}</span>\n </div>\n {isOpen &&\n node.children?.map((child) => (\n <TreeItem\n key={child.path}\n node={child}\n depth={depth + 1}\n activeFile={activeFile}\n openFile={openFile}\n deleteFile={deleteFile}\n expanded={expanded}\n toggleFolder={toggleFolder}\n />\n ))}\n </>\n );\n }\n\n const icon = getFileIcon(node.name);\n const isActive = node.path === activeFile;\n\n return (\n <div\n className={cn(\n 'flex items-center py-1 px-2 cursor-pointer text-[13px] group',\n isActive\n ? 'bg-primary/10 text-primary font-medium'\n : 'hover:bg-muted/50',\n )}\n style={{ paddingLeft: pl }}\n onClick={() => openFile(node.path)}\n >\n <span\n className=\"w-5 mr-1.5 shrink-0 text-[9px] font-bold text-center leading-none\"\n style={{ color: icon.color }}\n >\n {icon.label}\n </span>\n <span className=\"truncate flex-1\">{node.name}</span>\n <button\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger shrink-0 transition-opacity\"\n onClick={(e) => {\n e.stopPropagation();\n deleteFile(node.path);\n }}\n >\n <Trash2 className=\"w-3 h-3\" />\n </button>\n </div>\n );\n}\n\n// ─── File Tree ───────────────────────────────────────────────\n\nexport function FileTree() {\n const { sandpack } = useSandpack();\n const { files, activeFile, openFile, addFile, deleteFile } = sandpack;\n\n const [expanded, setExpanded] = useState<Set<string>>(\n () =>\n new Set([\n '/',\n '/src',\n '/components',\n '/examples',\n '/public',\n ]),\n );\n const [creating, setCreating] = useState<'file' | 'folder' | null>(null);\n const [newName, setNewName] = useState('');\n\n const filePaths = Object.keys(files).filter((p) => !p.endsWith('.gitkeep'));\n const tree = useMemo(() => buildFileTree(filePaths), [filePaths]);\n\n const toggleFolder = (path: string) => {\n setExpanded((prev) => {\n const next = new Set(prev);\n if (next.has(path)) next.delete(path);\n else next.add(path);\n return next;\n });\n };\n\n const handleCreate = (e: React.FormEvent) => {\n e.preventDefault();\n if (!newName.trim()) return;\n const path = newName.startsWith('/') ? newName : '/' + newName;\n if (creating === 'file') {\n addFile(path, '');\n openFile(path);\n } else {\n addFile(`${path}/.gitkeep`, '');\n setExpanded((prev) => new Set([...prev, path]));\n }\n setNewName('');\n setCreating(null);\n };\n\n return (\n <div className=\"flex flex-col h-full text-foreground select-none\">\n {/* Header */}\n <div className=\"flex items-center justify-between px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0 group\">\n <span>Explorer</span>\n <div className=\"flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity\">\n <button\n onClick={() => setCreating('file')}\n className=\"p-1 hover:bg-muted rounded\"\n title=\"New File\"\n >\n <FilePlus className=\"w-3.5 h-3.5\" />\n </button>\n <button\n onClick={() => setCreating('folder')}\n className=\"p-1 hover:bg-muted rounded\"\n title=\"New Folder\"\n >\n <FolderPlus className=\"w-3.5 h-3.5\" />\n </button>\n </div>\n </div>\n\n {/* Create input */}\n {creating && (\n <form onSubmit={handleCreate} className=\"px-3 pb-2\">\n <input\n autoFocus\n type=\"text\"\n placeholder={\n creating === 'file'\n ? '/src/NewFile.tsx'\n : '/src/newfolder'\n }\n className=\"w-full bg-muted border border-primary text-foreground text-[13px] px-2 py-1 rounded outline-none font-mono\"\n value={newName}\n onChange={(e) => setNewName(e.target.value)}\n onBlur={() => setCreating(null)}\n />\n </form>\n )}\n\n {/* Tree */}\n <div className=\"flex-1 overflow-y-auto\">\n {tree.map((node) => (\n <TreeItem\n key={node.path}\n node={node}\n depth={0}\n activeFile={activeFile}\n openFile={openFile}\n deleteFile={deleteFile}\n expanded={expanded}\n toggleFolder={toggleFolder}\n />\n ))}\n </div>\n </div>\n );\n}\n"
250
254
  },
255
+ {
256
+ "path": "src/components/ui/code-sandbox/SandboxActivityBar.tsx",
257
+ "content": "import React from 'react';\nimport { Files, Search, Package, Settings } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\nexport type SidebarTab = 'explorer' | 'search' | 'dependencies';\n\ninterface ActivityBarProps {\n activeTab: SidebarTab | null;\n onTabChange: (tab: SidebarTab | null) => void;\n}\n\nconst tabs: { id: SidebarTab; icon: React.ReactNode; label: string }[] = [\n { id: 'explorer', icon: <Files className=\"w-[18px] h-[18px]\" />, label: 'Explorer' },\n { id: 'search', icon: <Search className=\"w-[18px] h-[18px]\" />, label: 'Search' },\n { id: 'dependencies', icon: <Package className=\"w-[18px] h-[18px]\" />, label: 'Dependencies' },\n];\n\nexport function SandboxActivityBar({ activeTab, onTabChange }: ActivityBarProps) {\n return (\n <div className=\"w-11 bg-muted/30 border-r border-border flex flex-col items-center py-1.5 shrink-0\">\n {tabs.map((tab) => (\n <button\n key={tab.id}\n onClick={() => onTabChange(activeTab === tab.id ? null : tab.id)}\n className={cn(\n 'relative w-full h-9 flex items-center justify-center transition-colors',\n activeTab === tab.id\n ? 'text-foreground'\n : 'text-muted-foreground hover:text-foreground',\n )}\n title={tab.label}\n >\n {activeTab === tab.id && (\n <span className=\"absolute left-0 top-[20%] bottom-[20%] w-0.5 bg-primary rounded-r\" />\n )}\n {tab.icon}\n </button>\n ))}\n <div className=\"flex-1\" />\n <button\n className=\"w-full h-9 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors\"\n title=\"Settings\"\n >\n <Settings className=\"w-[18px] h-[18px]\" />\n </button>\n </div>\n );\n}\n"
258
+ },
251
259
  {
252
260
  "path": "src/components/ui/code-sandbox/SandboxLayout.tsx",
253
- "content": "import React, { useState, useMemo } from 'react';\nimport {\n SandpackCodeEditor,\n SandpackPreview,\n SandpackConsole,\n useSandpack,\n} from '@codesandbox/sandpack-react';\nimport { Group, Panel, Separator } from 'react-resizable-panels';\nimport {\n Files,\n Search,\n Package,\n Settings,\n ChevronDown,\n Plus,\n X,\n} from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\nimport { FileTree } from './FileTree';\nimport { SANDBOX_TEMPLATES } from './templates';\n\n// ─── Types ───────────────────────────────────────────────────\n\ntype SidebarTab = 'explorer' | 'search' | 'dependencies';\ntype TerminalTab = 'console' | 'problems';\n\nexport interface SandboxLayoutProps {\n templateId: string;\n onTemplateChange: (id: string) => void;\n className?: string;\n}\n\n// ─── Search Panel ────────────────────────────────────────────\n\nfunction SearchPanel() {\n const { sandpack } = useSandpack();\n const [query, setQuery] = useState('');\n\n const results = useMemo(() => {\n if (!query.trim()) return [];\n const q = query.toLowerCase();\n const matches: { path: string; line: number; text: string }[] = [];\n for (const [path, file] of Object.entries(sandpack.files)) {\n file.code.split('\\n').forEach((line, i) => {\n if (line.toLowerCase().includes(q)) {\n matches.push({ path, line: i + 1, text: line.trim() });\n }\n });\n }\n return matches.slice(0, 100);\n }, [query, sandpack.files]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Search\n </div>\n <div className=\"px-3 pb-2\">\n <input\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search in files...\"\n className=\"w-full bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n </div>\n <div className=\"flex-1 overflow-y-auto\">\n {results.length > 0 && (\n <div className=\"px-3 pb-1 text-[10px] text-muted-foreground\">\n {results.length} result{results.length !== 1 ? 's' : ''}\n </div>\n )}\n {results.map((r, i) => (\n <div\n key={`${r.path}:${r.line}:${i}`}\n className=\"px-3 py-1.5 cursor-pointer hover:bg-muted/50 text-xs border-b border-border/30\"\n onClick={() => sandpack.openFile(r.path)}\n >\n <div className=\"font-medium truncate\">\n {r.path.split('/').pop()}\n <span className=\"text-muted-foreground ml-1 font-normal\">\n {r.path}\n </span>\n </div>\n <div className=\"text-muted-foreground truncate font-mono\">\n <span className=\"text-primary mr-1\">{r.line}:</span>\n {r.text}\n </div>\n </div>\n ))}\n {query && results.length === 0 && (\n <div className=\"px-3 py-6 text-xs text-muted-foreground text-center\">\n No results found\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ─── Dependency Panel ────────────────────────────────────────\n\nconst POPULAR_PACKAGES = [\n 'axios',\n 'framer-motion',\n 'zustand',\n 'react-router-dom',\n 'date-fns',\n 'clsx',\n 'zod',\n 'react-hook-form',\n 'swr',\n 'lodash',\n];\n\nfunction DependencyPanel() {\n const { sandpack } = useSandpack();\n const [newDep, setNewDep] = useState('');\n\n let deps: Record<string, string> = {};\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n deps = pkg.dependencies || {};\n } catch {\n /* ignore parse error */\n }\n\n const addDep = (name: string) => {\n if (!name.trim() || deps[name]) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n pkg.dependencies = { ...pkg.dependencies, [name.trim()]: 'latest' };\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n setNewDep('');\n } catch {\n /* ignore */\n }\n };\n\n const removeDep = (name: string) => {\n if (['react', 'react-dom', 'react-scripts'].includes(name)) return;\n try {\n const pkg = JSON.parse(\n sandpack.files['/package.json']?.code || '{}',\n );\n const { [name]: _, ...rest } = pkg.dependencies || {};\n pkg.dependencies = rest;\n sandpack.updateFile(\n '/package.json',\n JSON.stringify(pkg, null, 2),\n );\n } catch {\n /* ignore */\n }\n };\n\n const available = POPULAR_PACKAGES.filter((p) => !deps[p]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Dependencies\n </div>\n\n {/* Add input */}\n <form\n onSubmit={(e) => {\n e.preventDefault();\n addDep(newDep);\n }}\n className=\"flex gap-1 px-3 pb-2\"\n >\n <input\n value={newDep}\n onChange={(e) => setNewDep(e.target.value)}\n placeholder=\"Package name...\"\n className=\"flex-1 min-w-0 bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n <button\n type=\"submit\"\n className=\"px-2 py-1.5 bg-primary text-primary-foreground rounded text-xs font-medium hover:bg-primary/90 transition-colors shrink-0\"\n >\n <Plus className=\"w-3.5 h-3.5\" />\n </button>\n </form>\n\n {/* Quick add */}\n {available.length > 0 && (\n <div className=\"px-3 pb-3\">\n <div className=\"text-[10px] text-muted-foreground mb-1.5\">\n Quick add:\n </div>\n <div className=\"flex flex-wrap gap-1\">\n {available.slice(0, 5).map((p) => (\n <button\n key={p}\n onClick={() => addDep(p)}\n className=\"text-[10px] px-1.5 py-0.5 bg-muted hover:bg-accent rounded border border-border transition-colors\"\n >\n + {p}\n </button>\n ))}\n </div>\n </div>\n )}\n\n {/* Installed list */}\n <div className=\"flex-1 overflow-y-auto border-t border-border\">\n <div className=\"px-3 py-1.5 text-[10px] text-muted-foreground\">\n Installed ({Object.keys(deps).length})\n </div>\n {Object.entries(deps).map(([name, version]) => (\n <div\n key={name}\n className=\"flex items-center justify-between px-3 py-1.5 hover:bg-muted/50 text-[13px] group\"\n >\n <div className=\"flex items-center gap-1.5 truncate min-w-0\">\n <Package className=\"w-3 h-3 text-muted-foreground shrink-0\" />\n <span className=\"truncate\">{name}</span>\n <span className=\"text-[10px] text-muted-foreground shrink-0\">\n {version as string}\n </span>\n </div>\n {!['react', 'react-dom', 'react-scripts'].includes(name) && (\n <button\n onClick={() => removeDep(name)}\n className=\"opacity-0 group-hover:opacity-100 p-0.5 hover:text-danger transition-opacity shrink-0\"\n >\n <X className=\"w-3 h-3\" />\n </button>\n )}\n </div>\n ))}\n </div>\n </div>\n );\n}\n\n// ─── Activity Bar ────────────────────────────────────────────\n\nfunction ActivityBar({\n activeTab,\n onTabChange,\n}: {\n activeTab: SidebarTab | null;\n onTabChange: (tab: SidebarTab | null) => void;\n}) {\n const tabs: { id: SidebarTab; icon: React.ReactNode; label: string }[] = [\n {\n id: 'explorer',\n icon: <Files className=\"w-[18px] h-[18px]\" />,\n label: 'Explorer',\n },\n {\n id: 'search',\n icon: <Search className=\"w-[18px] h-[18px]\" />,\n label: 'Search',\n },\n {\n id: 'dependencies',\n icon: <Package className=\"w-[18px] h-[18px]\" />,\n label: 'Dependencies',\n },\n ];\n\n return (\n <div className=\"w-11 bg-muted/30 border-r border-border flex flex-col items-center py-1.5 shrink-0\">\n {tabs.map((tab) => (\n <button\n key={tab.id}\n onClick={() =>\n onTabChange(activeTab === tab.id ? null : tab.id)\n }\n className={cn(\n 'relative w-full h-9 flex items-center justify-center transition-colors',\n activeTab === tab.id\n ? 'text-foreground'\n : 'text-muted-foreground hover:text-foreground',\n )}\n title={tab.label}\n >\n {activeTab === tab.id && (\n <span className=\"absolute left-0 top-[20%] bottom-[20%] w-0.5 bg-primary rounded-r\" />\n )}\n {tab.icon}\n </button>\n ))}\n <div className=\"flex-1\" />\n <button\n className=\"w-full h-9 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors\"\n title=\"Settings\"\n >\n <Settings className=\"w-[18px] h-[18px]\" />\n </button>\n </div>\n );\n}\n\n// ─── Terminal Pane ───────────────────────────────────────────\n\nfunction TerminalPane() {\n const { sandpack } = useSandpack();\n const [tab, setTab] = useState<TerminalTab>('console');\n\n return (\n <div className=\"flex flex-col h-full bg-background\">\n {/* Tab bar */}\n <div className=\"h-7 shrink-0 bg-muted/30 border-t border-b border-border flex items-center px-3 gap-3 text-[10px] font-semibold tracking-wider\">\n {(['console', 'problems'] as TerminalTab[]).map((t) => (\n <button\n key={t}\n onClick={() => setTab(t)}\n className={cn(\n 'uppercase transition-colors pb-0.5 -mb-px border-b',\n tab === t\n ? 'text-primary border-primary'\n : 'text-muted-foreground border-transparent hover:text-foreground',\n )}\n >\n {t}\n {t === 'problems' && sandpack.error && (\n <span className=\"ml-1 text-danger font-bold\">1</span>\n )}\n </button>\n ))}\n </div>\n\n {/* Content */}\n <div className=\"flex-1 overflow-auto min-h-0\">\n {tab === 'console' && (\n <SandpackConsole\n standalone\n style={{ height: '100%' }}\n />\n )}\n {tab === 'problems' && (\n <div className=\"p-3 text-xs\">\n {sandpack.error ? (\n <div className=\"text-danger font-mono whitespace-pre-wrap break-words\">\n {sandpack.error.message}\n </div>\n ) : (\n <div className=\"text-muted-foreground text-center py-4\">\n No problems detected\n </div>\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ─── Status Bar ──────────────────────────────────────────────\n\nfunction StatusBar() {\n const { sandpack } = useSandpack();\n const fileName = sandpack.activeFile?.split('/').pop() || '';\n const ext = fileName.split('.').pop()?.toUpperCase() || '';\n\n return (\n <div className=\"h-6 bg-primary text-primary-foreground flex items-center px-3 text-[10px] font-medium shrink-0 gap-3\">\n {/* Status */}\n <span className=\"flex items-center gap-1.5\">\n <span\n className={cn(\n 'w-1.5 h-1.5 rounded-full',\n sandpack.status === 'running'\n ? 'bg-green-400'\n : sandpack.status === 'idle'\n ? 'bg-yellow-400'\n : 'bg-white/50',\n )}\n />\n {sandpack.status === 'running'\n ? 'Running'\n : sandpack.status === 'idle'\n ? 'Ready'\n : 'Loading'}\n </span>\n\n {sandpack.error && (\n <span className=\"bg-white/20 px-1.5 py-0.5 rounded text-[9px]\">\n 1 Error\n </span>\n )}\n\n <div className=\"flex-1\" />\n\n <span className=\"opacity-75\">{fileName}</span>\n <span className=\"opacity-75\">UTF-8</span>\n {ext && <span className=\"opacity-75\">{ext}</span>}\n </div>\n );\n}\n\n// ─── Toolbar ─────────────────────────────────────────────────\n\nfunction Toolbar({\n templateId,\n onTemplateChange,\n}: {\n templateId: string;\n onTemplateChange: (id: string) => void;\n}) {\n const [open, setOpen] = useState(false);\n const current = SANDBOX_TEMPLATES.find((t) => t.id === templateId);\n\n return (\n <div className=\"h-10 bg-muted/30 border-b border-border flex items-center px-3 shrink-0 gap-3\">\n {/* Template picker */}\n <div className=\"relative\">\n <button\n onClick={() => setOpen(!open)}\n className=\"flex items-center gap-2 bg-background border border-border px-2.5 py-1.5 rounded-md hover:bg-muted transition-colors text-sm\"\n >\n <span>{current?.icon}</span>\n <span className=\"font-medium text-foreground\">\n {current?.label}\n </span>\n <ChevronDown className=\"w-3.5 h-3.5 text-muted-foreground\" />\n </button>\n\n {open && (\n <>\n <div\n className=\"fixed inset-0 z-40\"\n onClick={() => setOpen(false)}\n />\n <div className=\"absolute top-full left-0 mt-1 bg-background border border-border rounded-lg shadow-xl z-50 min-w-[260px] py-1 animate-in fade-in slide-in-from-top-2 duration-200\">\n {SANDBOX_TEMPLATES.map((t) => (\n <button\n key={t.id}\n onClick={() => {\n onTemplateChange(t.id);\n setOpen(false);\n }}\n className={cn(\n 'w-full text-left px-3 py-2.5 hover:bg-muted flex items-center gap-3 transition-colors',\n t.id === templateId && 'bg-primary/5',\n )}\n >\n <span className=\"text-lg shrink-0\">{t.icon}</span>\n <div className=\"min-w-0\">\n <div className=\"text-sm font-medium text-foreground\">\n {t.label}\n </div>\n <div className=\"text-xs text-muted-foreground\">\n {t.description}\n </div>\n </div>\n </button>\n ))}\n </div>\n </>\n )}\n </div>\n\n <div className=\"flex-1\" />\n\n {/* Project badge */}\n <div className=\"text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded hidden sm:block\">\n react-playground\n </div>\n </div>\n );\n}\n\n// ─── Resize Handle ───────────────────────────────────────────\n\nfunction ResizeHandle({ horizontal }: { horizontal?: boolean }) {\n return (\n <Separator\n className={cn(\n 'transition-colors shrink-0',\n horizontal\n ? 'h-[3px] hover:bg-primary/40 cursor-row-resize bg-border/60'\n : 'w-[3px] hover:bg-primary/40 cursor-col-resize bg-border/60',\n )}\n />\n );\n}\n\n// ─── Main Layout ─────────────────────────────────────────────\n\nexport function SandboxLayout({\n templateId,\n onTemplateChange,\n className,\n}: SandboxLayoutProps) {\n const [sidebarTab, setSidebarTab] = useState<SidebarTab | null>(\n 'explorer',\n );\n\n return (\n <div\n className={cn(\n 'flex flex-col h-full w-full bg-background text-foreground overflow-hidden',\n className,\n )}\n >\n <Toolbar\n templateId={templateId}\n onTemplateChange={onTemplateChange}\n />\n\n <div className=\"flex flex-1 min-h-0\">\n {/* Activity bar */}\n <ActivityBar activeTab={sidebarTab} onTabChange={setSidebarTab} />\n\n {/* Sidebar panel (fixed width, toggleable) */}\n {sidebarTab && (\n <div className=\"w-56 border-r border-border shrink-0 overflow-hidden bg-background\">\n {sidebarTab === 'explorer' && <FileTree />}\n {sidebarTab === 'search' && <SearchPanel />}\n {sidebarTab === 'dependencies' && <DependencyPanel />}\n </div>\n )}\n\n {/* Resizable main area: Editor + Preview */}\n <Group orientation=\"horizontal\" className=\"h-full flex-1 min-w-0\">\n {/* Editor + Terminal column */}\n <Panel defaultSize={55} minSize={25}>\n <Group orientation=\"vertical\">\n {/* Code editor */}\n <Panel defaultSize={70} minSize={20}>\n <SandpackCodeEditor\n showTabs\n showLineNumbers\n showInlineErrors\n wrapContent\n closableTabs\n style={{ height: '100%' }}\n />\n </Panel>\n\n <ResizeHandle horizontal />\n\n {/* Terminal */}\n <Panel defaultSize={30} minSize={8}>\n <TerminalPane />\n </Panel>\n </Group>\n </Panel>\n\n <ResizeHandle />\n\n {/* Preview */}\n <Panel defaultSize={45} minSize={20}>\n <SandpackPreview\n showNavigator\n showRefreshButton\n showOpenInCodeSandbox={false}\n style={{ height: '100%' }}\n />\n </Panel>\n </Group>\n </div>\n\n <StatusBar />\n </div>\n );\n}\n"
261
+ "content": "import React, { useState } from 'react';\r\nimport {\r\n SandpackCodeEditor,\r\n SandpackPreview,\r\n} from '@codesandbox/sandpack-react';\r\nimport { Group, Panel, Separator } from 'react-resizable-panels';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { FileTree } from './FileTree';\r\nimport { SearchPanel } from './SearchPanel';\r\nimport { DependencyPanel } from './DependencyPanel';\r\nimport { SandboxActivityBar, type SidebarTab } from './SandboxActivityBar';\r\nimport { TerminalPane } from './TerminalPane';\r\nimport { SandboxToolbar } from './SandboxToolbar';\r\nimport { SandboxStatusBar } from './SandboxStatusBar';\r\n\r\n// ─── Resize Handle ───────────────────────────────────────────\r\n\r\nfunction ResizeHandle({ horizontal }: { horizontal?: boolean }) {\r\n return (\r\n <Separator\r\n className={cn(\r\n 'transition-colors shrink-0',\r\n horizontal\r\n ? 'h-[3px] hover:bg-primary/40 cursor-row-resize bg-border/60'\r\n : 'w-[3px] hover:bg-primary/40 cursor-col-resize bg-border/60',\r\n )}\r\n />\r\n );\r\n}\r\n\r\n// ─── Main Layout ───────────────────────���─────────────────────\r\n\r\nexport interface SandboxLayoutProps {\r\n templateId: string;\r\n onTemplateChange: (id: string) => void;\r\n className?: string;\r\n}\r\n\r\nexport function SandboxLayout({\r\n templateId,\r\n onTemplateChange,\r\n className,\r\n}: SandboxLayoutProps) {\r\n const [sidebarTab, setSidebarTab] = useState<SidebarTab | null>('explorer');\r\n\r\n return (\r\n <div\r\n className={cn(\r\n 'flex flex-col h-full w-full bg-background text-foreground overflow-hidden',\r\n className,\r\n )}\r\n >\r\n <SandboxToolbar templateId={templateId} onTemplateChange={onTemplateChange} />\r\n\r\n <div className=\"flex flex-1 min-h-0\">\r\n <SandboxActivityBar activeTab={sidebarTab} onTabChange={setSidebarTab} />\r\n\r\n {sidebarTab && (\r\n <div className=\"w-56 border-r border-border shrink-0 overflow-hidden bg-background\">\r\n {sidebarTab === 'explorer' && <FileTree />}\r\n {sidebarTab === 'search' && <SearchPanel />}\r\n {sidebarTab === 'dependencies' && <DependencyPanel />}\r\n </div>\r\n )}\r\n\r\n <Group orientation=\"horizontal\" className=\"h-full flex-1 min-w-0\">\r\n <Panel defaultSize={55} minSize={25}>\r\n <Group orientation=\"vertical\">\r\n <Panel defaultSize={70} minSize={20}>\r\n <SandpackCodeEditor\r\n showTabs\r\n showLineNumbers\r\n showInlineErrors\r\n wrapContent\r\n closableTabs\r\n style={{ height: '100%' }}\r\n />\r\n </Panel>\r\n <ResizeHandle horizontal />\r\n <Panel defaultSize={30} minSize={8}>\r\n <TerminalPane />\r\n </Panel>\r\n </Group>\r\n </Panel>\r\n\r\n <ResizeHandle />\r\n\r\n <Panel defaultSize={45} minSize={20}>\r\n <SandpackPreview\r\n showNavigator\r\n showRefreshButton\r\n showOpenInCodeSandbox={false}\r\n style={{ height: '100%' }}\r\n />\r\n </Panel>\r\n </Group>\r\n </div>\r\n\r\n <SandboxStatusBar />\r\n </div>\r\n );\r\n}\r\n"
262
+ },
263
+ {
264
+ "path": "src/components/ui/code-sandbox/SandboxStatusBar.tsx",
265
+ "content": "import React from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\nimport { cn } from '@/lib/utils/cn';\n\nexport function SandboxStatusBar() {\n const { sandpack } = useSandpack();\n const fileName = sandpack.activeFile?.split('/').pop() || '';\n const ext = fileName.split('.').pop()?.toUpperCase() || '';\n\n return (\n <div className=\"h-6 bg-primary text-primary-foreground flex items-center px-3 text-[10px] font-medium shrink-0 gap-3\">\n <span className=\"flex items-center gap-1.5\">\n <span\n className={cn(\n 'w-1.5 h-1.5 rounded-full',\n sandpack.status === 'running'\n ? 'bg-green-400'\n : sandpack.status === 'idle'\n ? 'bg-yellow-400'\n : 'bg-white/50',\n )}\n />\n {sandpack.status === 'running'\n ? 'Running'\n : sandpack.status === 'idle'\n ? 'Ready'\n : 'Loading'}\n </span>\n\n {sandpack.error && (\n <span className=\"bg-white/20 px-1.5 py-0.5 rounded text-[9px]\">\n 1 Error\n </span>\n )}\n\n <div className=\"flex-1\" />\n\n <span className=\"opacity-75\">{fileName}</span>\n <span className=\"opacity-75\">UTF-8</span>\n {ext && <span className=\"opacity-75\">{ext}</span>}\n </div>\n );\n}\n"
266
+ },
267
+ {
268
+ "path": "src/components/ui/code-sandbox/SandboxToolbar.tsx",
269
+ "content": "import React, { useState } from 'react';\nimport { ChevronDown } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\nimport { SANDBOX_TEMPLATES } from './templates';\n\ninterface ToolbarProps {\n templateId: string;\n onTemplateChange: (id: string) => void;\n}\n\nexport function SandboxToolbar({ templateId, onTemplateChange }: ToolbarProps) {\n const [open, setOpen] = useState(false);\n const current = SANDBOX_TEMPLATES.find((t) => t.id === templateId);\n\n return (\n <div className=\"h-10 bg-muted/30 border-b border-border flex items-center px-3 shrink-0 gap-3\">\n <div className=\"relative\">\n <button\n onClick={() => setOpen(!open)}\n className=\"flex items-center gap-2 bg-background border border-border px-2.5 py-1.5 rounded-md hover:bg-muted transition-colors text-sm\"\n >\n <span>{current?.icon}</span>\n <span className=\"font-medium text-foreground\">{current?.label}</span>\n <ChevronDown className=\"w-3.5 h-3.5 text-muted-foreground\" />\n </button>\n\n {open && (\n <>\n <div className=\"fixed inset-0 z-40\" onClick={() => setOpen(false)} />\n <div className=\"absolute top-full left-0 mt-1 bg-background border border-border rounded-lg shadow-xl z-50 min-w-[260px] py-1 animate-in fade-in slide-in-from-top-2 duration-200\">\n {SANDBOX_TEMPLATES.map((t) => (\n <button\n key={t.id}\n onClick={() => {\n onTemplateChange(t.id);\n setOpen(false);\n }}\n className={cn(\n 'w-full text-left px-3 py-2.5 hover:bg-muted flex items-center gap-3 transition-colors',\n t.id === templateId && 'bg-primary/5',\n )}\n >\n <span className=\"text-lg shrink-0\">{t.icon}</span>\n <div className=\"min-w-0\">\n <div className=\"text-sm font-medium text-foreground\">{t.label}</div>\n <div className=\"text-xs text-muted-foreground\">{t.description}</div>\n </div>\n </button>\n ))}\n </div>\n </>\n )}\n </div>\n\n <div className=\"flex-1\" />\n\n <div className=\"text-xs text-muted-foreground bg-muted px-2.5 py-1 rounded hidden sm:block\">\n react-playground\n </div>\n </div>\n );\n}\n"
270
+ },
271
+ {
272
+ "path": "src/components/ui/code-sandbox/SearchPanel.tsx",
273
+ "content": "import React, { useState, useMemo } from 'react';\nimport { useSandpack } from '@codesandbox/sandpack-react';\n\nexport function SearchPanel() {\n const { sandpack } = useSandpack();\n const [query, setQuery] = useState('');\n\n const results = useMemo(() => {\n if (!query.trim()) return [];\n const q = query.toLowerCase();\n const matches: { path: string; line: number; text: string }[] = [];\n for (const [path, file] of Object.entries(sandpack.files)) {\n file.code.split('\\n').forEach((line, i) => {\n if (line.toLowerCase().includes(q)) {\n matches.push({ path, line: i + 1, text: line.trim() });\n }\n });\n }\n return matches.slice(0, 100);\n }, [query, sandpack.files]);\n\n return (\n <div className=\"flex flex-col h-full text-foreground\">\n <div className=\"px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground shrink-0\">\n Search\n </div>\n <div className=\"px-3 pb-2\">\n <input\n value={query}\n onChange={(e) => setQuery(e.target.value)}\n placeholder=\"Search in files...\"\n className=\"w-full bg-muted border border-border text-foreground text-[13px] px-2 py-1.5 rounded outline-none focus:border-primary transition-colors\"\n />\n </div>\n <div className=\"flex-1 overflow-y-auto\">\n {results.length > 0 && (\n <div className=\"px-3 pb-1 text-[10px] text-muted-foreground\">\n {results.length} result{results.length !== 1 ? 's' : ''}\n </div>\n )}\n {results.map((r, i) => (\n <div\n key={`${r.path}:${r.line}:${i}`}\n className=\"px-3 py-1.5 cursor-pointer hover:bg-muted/50 text-xs border-b border-border/30\"\n onClick={() => sandpack.openFile(r.path)}\n >\n <div className=\"font-medium truncate\">\n {r.path.split('/').pop()}\n <span className=\"text-muted-foreground ml-1 font-normal\">\n {r.path}\n </span>\n </div>\n <div className=\"text-muted-foreground truncate font-mono\">\n <span className=\"text-primary mr-1\">{r.line}:</span>\n {r.text}\n </div>\n </div>\n ))}\n {query && results.length === 0 && (\n <div className=\"px-3 py-6 text-xs text-muted-foreground text-center\">\n No results found\n </div>\n )}\n </div>\n </div>\n );\n}\n"
254
274
  },
255
275
  {
256
276
  "path": "src/components/ui/code-sandbox/templates.ts",
257
277
  "content": "export interface SandboxTemplate {\n id: string;\n label: string;\n description: string;\n icon: string;\n template: 'react' | 'react-ts';\n files: Record<string, string>;\n dependencies?: Record<string, string>;\n}\n\nexport const SANDBOX_TEMPLATES: SandboxTemplate[] = [\n {\n id: 'react',\n label: 'React',\n description: 'React with JavaScript',\n icon: '\\u269B\\uFE0F',\n template: 'react',\n files: {\n '/App.js': `import React, { useState } from 'react';\nimport './styles.css';\n\nexport default function App() {\n const [count, setCount] = useState(0);\n\n return (\n <div className=\"app\">\n <h1>React Playground</h1>\n <p className=\"count\">{count}</p>\n <div className=\"actions\">\n <button onClick={() => setCount(c => c - 1)}>-</button>\n <button onClick={() => setCount(0)}>Reset</button>\n <button onClick={() => setCount(c => c + 1)}>+</button>\n </div>\n </div>\n );\n}\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\nh1 { font-size: 24px; margin-bottom: 16px; }\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\n.actions { display: flex; gap: 8px; justify-content: center; }\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'react-ts',\n label: 'React + TypeScript',\n description: 'React with TypeScript strict mode',\n icon: '\\uD83D\\uDC8E',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React, { useState } from 'react';\nimport './styles.css';\n\ninterface AppProps {\n title?: string;\n}\n\nconst App: React.FC<AppProps> = ({ title = 'React + TypeScript' }) => {\n const [count, setCount] = useState<number>(0);\n\n return (\n <div className=\"app\">\n <h1>{title}</h1>\n <p className=\"count\">{count}</p>\n <div className=\"actions\">\n <button onClick={() => setCount(c => c - 1)}>-</button>\n <button onClick={() => setCount(0)}>Reset</button>\n <button onClick={() => setCount(c => c + 1)}>+</button>\n </div>\n </div>\n );\n};\n\nexport default App;\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 600px; margin: 40px auto; padding: 24px; text-align: center; }\nh1 { font-size: 24px; margin-bottom: 16px; }\n.count { font-size: 48px; font-weight: 700; color: #3b82f6; margin: 16px 0; }\n.actions { display: flex; gap: 8px; justify-content: center; }\n.actions button { width: 64px; padding: 8px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 16px; transition: all 0.15s; }\n.actions button:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'component',\n label: 'Component Workshop',\n description: 'Build & test React components',\n icon: '\\uD83E\\uDDE9',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React from 'react';\nimport { Button } from './components/Button';\nimport { Card } from './components/Card';\nimport './styles.css';\n\nexport default function App() {\n return (\n <div className=\"app\">\n <h1>Component Workshop</h1>\n <div className=\"grid\">\n <Card title=\"Button Variants\">\n <div className=\"row\">\n <Button variant=\"primary\">Primary</Button>\n <Button variant=\"secondary\">Secondary</Button>\n <Button variant=\"outline\">Outline</Button>\n </div>\n </Card>\n <Card title=\"Button Sizes\">\n <div className=\"row\">\n <Button size=\"sm\">Small</Button>\n <Button size=\"md\">Medium</Button>\n <Button size=\"lg\">Large</Button>\n </div>\n </Card>\n <Card title=\"Your Component\">\n <p style={{ color: '#64748b' }}>Create your own component in /components!</p>\n </Card>\n </div>\n </div>\n );\n}\n`,\n '/components/Button.tsx': `import React from 'react';\n\ninterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n variant?: 'primary' | 'secondary' | 'outline';\n size?: 'sm' | 'md' | 'lg';\n}\n\nconst styles: Record<string, React.CSSProperties> = {\n primary: { background: '#3b82f6', color: 'white', border: 'none' },\n secondary: { background: '#e2e8f0', color: '#334155', border: 'none' },\n outline: { background: 'transparent', color: '#3b82f6', border: '1.5px solid #3b82f6' },\n sm: { padding: '6px 12px', fontSize: 12 },\n md: { padding: '8px 16px', fontSize: 14 },\n lg: { padding: '12px 24px', fontSize: 16 },\n};\n\nexport const Button: React.FC<ButtonProps> = ({\n children,\n variant = 'primary',\n size = 'md',\n style,\n ...props\n}) => (\n <button\n style={{\n borderRadius: 8,\n fontWeight: 500,\n cursor: 'pointer',\n transition: 'opacity 0.15s',\n ...styles[variant],\n ...styles[size],\n ...style,\n }}\n {...props}\n >\n {children}\n </button>\n);\n`,\n '/components/Card.tsx': `import React from 'react';\n\ninterface CardProps {\n title: string;\n children: React.ReactNode;\n}\n\nexport const Card: React.FC<CardProps> = ({ title, children }) => (\n <div style={{\n background: 'white',\n border: '1px solid #e2e8f0',\n borderRadius: 12,\n padding: 20,\n }}>\n <h3 style={{ fontSize: 16, marginBottom: 12, color: '#334155' }}>{title}</h3>\n {children}\n </div>\n);\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 800px; margin: 24px auto; padding: 20px; }\nh1 { font-size: 22px; margin-bottom: 20px; }\n.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }\n@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }\n.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }\n`,\n },\n },\n {\n id: 'hooks',\n label: 'Hooks Playground',\n description: 'Practice React Hooks patterns',\n icon: '\\uD83E\\uDE9D',\n template: 'react-ts',\n files: {\n '/App.tsx': `import React from 'react';\nimport { Counter } from './examples/Counter';\nimport { TodoList } from './examples/TodoList';\nimport { FetchData } from './examples/FetchData';\nimport './styles.css';\n\nexport default function App() {\n return (\n <div className=\"app\">\n <h1>React Hooks Playground</h1>\n <section>\n <h2>useState + useCallback</h2>\n <Counter />\n </section>\n <section>\n <h2>useState + useRef</h2>\n <TodoList />\n </section>\n <section>\n <h2>useEffect + fetch</h2>\n <FetchData />\n </section>\n </div>\n );\n}\n`,\n '/examples/Counter.tsx': `import React, { useState, useCallback } from 'react';\n\nexport const Counter = () => {\n const [count, setCount] = useState(0);\n\n const increment = useCallback(() => setCount(c => c + 1), []);\n const decrement = useCallback(() => setCount(c => c - 1), []);\n const reset = useCallback(() => setCount(0), []);\n\n return (\n <div className=\"card\">\n <p className=\"count\">{count}</p>\n <div className=\"row center\">\n <button onClick={decrement}>-</button>\n <button onClick={reset}>Reset</button>\n <button onClick={increment}>+</button>\n </div>\n </div>\n );\n};\n`,\n '/examples/TodoList.tsx': `import React, { useState, useRef } from 'react';\n\ninterface Todo {\n id: number;\n text: string;\n done: boolean;\n}\n\nexport const TodoList = () => {\n const [todos, setTodos] = useState<Todo[]>([]);\n const inputRef = useRef<HTMLInputElement>(null);\n\n const addTodo = () => {\n const text = inputRef.current?.value.trim();\n if (!text) return;\n setTodos(prev => [...prev, { id: Date.now(), text, done: false }]);\n inputRef.current!.value = '';\n inputRef.current!.focus();\n };\n\n const toggle = (id: number) =>\n setTodos(prev =>\n prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))\n );\n\n return (\n <div className=\"card\">\n <div className=\"row\" style={{ marginBottom: 12 }}>\n <input\n ref={inputRef}\n placeholder=\"Add todo...\"\n onKeyDown={e => e.key === 'Enter' && addTodo()}\n style={{\n flex: 1, padding: '8px 12px', border: '1px solid #e2e8f0',\n borderRadius: 6, outline: 'none', fontSize: 14,\n }}\n />\n <button onClick={addTodo}>Add</button>\n </div>\n {todos.map(t => (\n <div\n key={t.id}\n onClick={() => toggle(t.id)}\n style={{\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\n cursor: 'pointer', textDecoration: t.done ? 'line-through' : 'none',\n color: t.done ? '#94a3b8' : 'inherit',\n }}\n >\n {t.text}\n </div>\n ))}\n {todos.length === 0 && (\n <p style={{ color: '#94a3b8', fontSize: 13, textAlign: 'center', padding: 16 }}>\n No todos yet. Add one above!\n </p>\n )}\n </div>\n );\n};\n`,\n '/examples/FetchData.tsx': `import React, { useState, useEffect } from 'react';\n\ninterface User {\n id: number;\n name: string;\n email: string;\n}\n\nexport const FetchData = () => {\n const [users, setUsers] = useState<User[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n\n useEffect(() => {\n fetch('https://jsonplaceholder.typicode.com/users')\n .then(res => {\n if (!res.ok) throw new Error('Failed to fetch');\n return res.json();\n })\n .then(data => {\n setUsers(data.slice(0, 5));\n setLoading(false);\n })\n .catch(err => {\n setError(err.message);\n setLoading(false);\n });\n }, []);\n\n if (loading) return <div className=\"card\">Loading...</div>;\n if (error) return <div className=\"card\" style={{ color: '#ef4444' }}>Error: {error}</div>;\n\n return (\n <div className=\"card\">\n {users.map(u => (\n <div key={u.id} style={{\n display: 'flex', justifyContent: 'space-between', alignItems: 'center',\n padding: '8px 0', borderBottom: '1px solid #f1f5f9',\n }}>\n <strong style={{ fontSize: 14 }}>{u.name}</strong>\n <span style={{ fontSize: 13, color: '#64748b' }}>{u.email}</span>\n </div>\n ))}\n </div>\n );\n};\n`,\n '/styles.css': `* { margin: 0; padding: 0; box-sizing: border-box; }\nbody { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f8fafc; color: #0f172a; }\n.app { max-width: 680px; margin: 20px auto; padding: 20px; }\nh1 { font-size: 22px; margin-bottom: 20px; }\nh2 { font-size: 12px; color: #64748b; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }\nsection { margin-bottom: 24px; }\n.card { background: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }\n.count { font-size: 48px; font-weight: 700; text-align: center; color: #3b82f6; }\n.row { display: flex; gap: 8px; align-items: center; }\n.center { justify-content: center; margin-top: 12px; }\nbutton { padding: 8px 16px; border-radius: 6px; border: 1px solid #e2e8f0; background: white; cursor: pointer; font-size: 14px; transition: all 0.15s; }\nbutton:hover { background: #f1f5f9; border-color: #3b82f6; }\n`,\n },\n },\n {\n id: 'tailwind',\n label: 'React + Tailwind',\n description: 'React with Tailwind CSS via CDN',\n icon: '\\uD83C\\uDFA8',\n template: 'react',\n files: {\n '/public/index.html': `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <title>React + Tailwind</title>\n <script src=\"https://cdn.tailwindcss.com\"></script>\n</head>\n<body>\n <div id=\"root\"></div>\n</body>\n</html>`,\n '/App.js': `import React, { useState } from 'react';\n\nexport default function App() {\n const [count, setCount] = useState(0);\n\n return (\n <div className=\"min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 flex items-center justify-center p-4\">\n <div className=\"bg-white rounded-2xl shadow-xl p-8 w-full max-w-md text-center\">\n <h1 className=\"text-2xl font-bold text-slate-800 mb-2\">\n React + Tailwind\n </h1>\n <p className=\"text-slate-500 mb-6\">Edit App.js to get started</p>\n\n <div className=\"text-6xl font-bold text-blue-500 mb-6\">{count}</div>\n\n <div className=\"flex gap-3 justify-center\">\n <button\n onClick={() => setCount(c => c - 1)}\n className=\"w-12 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-lg font-medium transition-colors\"\n >\n -\n </button>\n <button\n onClick={() => setCount(0)}\n className=\"px-6 h-12 rounded-xl bg-slate-100 hover:bg-slate-200 text-sm font-medium transition-colors\"\n >\n Reset\n </button>\n <button\n onClick={() => setCount(c => c + 1)}\n className=\"w-12 h-12 rounded-xl bg-blue-500 hover:bg-blue-600 text-white text-lg font-medium transition-colors\"\n >\n +\n </button>\n </div>\n </div>\n </div>\n );\n}\n`,\n '/index.js': `import React, { StrictMode } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport App from \"./App\";\n\nconst root = createRoot(document.getElementById(\"root\"));\nroot.render(\n <StrictMode>\n <App />\n </StrictMode>\n);\n`,\n },\n },\n];\n"
278
+ },
279
+ {
280
+ "path": "src/components/ui/code-sandbox/TerminalPane.tsx",
281
+ "content": "import React, { useState } from 'react';\nimport { SandpackConsole, useSandpack } from '@codesandbox/sandpack-react';\nimport { cn } from '@/lib/utils/cn';\n\ntype TerminalTab = 'console' | 'problems';\n\nexport function TerminalPane() {\n const { sandpack } = useSandpack();\n const [tab, setTab] = useState<TerminalTab>('console');\n\n return (\n <div className=\"flex flex-col h-full bg-background\">\n <div className=\"h-7 shrink-0 bg-muted/30 border-t border-b border-border flex items-center px-3 gap-3 text-[10px] font-semibold tracking-wider\">\n {(['console', 'problems'] as TerminalTab[]).map((t) => (\n <button\n key={t}\n onClick={() => setTab(t)}\n className={cn(\n 'uppercase transition-colors pb-0.5 -mb-px border-b',\n tab === t\n ? 'text-primary border-primary'\n : 'text-muted-foreground border-transparent hover:text-foreground',\n )}\n >\n {t}\n {t === 'problems' && sandpack.error && (\n <span className=\"ml-1 text-danger font-bold\">1</span>\n )}\n </button>\n ))}\n </div>\n\n <div className=\"flex-1 overflow-auto min-h-0\">\n {tab === 'console' && (\n <SandpackConsole standalone style={{ height: '100%' }} />\n )}\n {tab === 'problems' && (\n <div className=\"p-3 text-xs\">\n {sandpack.error ? (\n <div className=\"text-danger font-mono whitespace-pre-wrap break-words\">\n {sandpack.error.message}\n </div>\n ) : (\n <div className=\"text-muted-foreground text-center py-4\">\n No problems detected\n </div>\n )}\n </div>\n )}\n </div>\n </div>\n );\n}\n"
258
282
  }
259
283
  ]
260
284
  },
@@ -284,7 +308,22 @@
284
308
  "files": [
285
309
  {
286
310
  "path": "src/components/ui/combobox/ComboBox.tsx",
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"
311
+ "content": "\"use client\" \r\nimport * 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"
312
+ }
313
+ ]
314
+ },
315
+ "command": {
316
+ "name": "command",
317
+ "dependencies": [
318
+ "@base-ui/react",
319
+ "tailwind-variants",
320
+ "lucide-react"
321
+ ],
322
+ "internalDependencies": [],
323
+ "files": [
324
+ {
325
+ "path": "src/components/ui/command/Command.tsx",
326
+ "content": "import * as React from 'react';\nimport { Dialog as BaseDialog } from '@base-ui/react';\nimport { tv } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Search } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst commandVariants = tv({\n slots: {\n overlay:\n 'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',\n content: [\n 'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',\n 'w-full max-w-lg rounded-xl border border-border bg-background shadow-2xl',\n 'data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-open:slide-in-from-left-1/2 data-open:slide-in-from-top-[48%]',\n 'data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n 'overflow-hidden flex flex-col max-h-[min(80vh,460px)]',\n ].join(' '),\n input: [\n 'flex h-12 w-full bg-transparent px-4 text-sm text-foreground outline-none',\n 'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',\n ].join(' '),\n list: 'overflow-y-auto overflow-x-hidden flex-1',\n group: 'p-1',\n groupLabel: 'px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\n item: [\n 'relative flex cursor-pointer select-none items-center gap-3 rounded-md px-3 py-2.5 text-sm outline-none',\n 'transition-colors',\n 'data-[highlighted=true]:bg-accent data-[highlighted=true]:text-accent-foreground',\n '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground',\n ].join(' '),\n separator: '-mx-1 my-1 h-px bg-border',\n empty: 'py-8 text-center text-sm text-muted-foreground',\n shortcut: 'ml-auto text-xs tracking-widest text-muted-foreground/70',\n },\n});\n\nconst styles = commandVariants();\n\n// ─── Context ─────────────────────────────────────────────────────────────────\n\ninterface CommandContextValue {\n search: string;\n setSearch: React.Dispatch<React.SetStateAction<string>>;\n highlightedIndex: number;\n setHighlightedIndex: React.Dispatch<React.SetStateAction<number>>;\n}\n\nconst CommandContext = React.createContext<CommandContextValue | null>(null);\n\nfunction useCommand() {\n const ctx = React.useContext(CommandContext);\n if (!ctx) throw new Error('useCommand must be used within <Command>');\n return ctx;\n}\n\n// ─── Command (Root) ──────────────────────────────────────────────────────────\n\nexport interface CommandProps {\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n children: React.ReactNode;\n className?: string;\n}\n\nconst Command: React.FC<CommandProps> = ({ open, onOpenChange, children, className }) => {\n const [search, setSearch] = React.useState('');\n const [highlightedIndex, setHighlightedIndex] = React.useState(0);\n\n React.useEffect(() => {\n if (open) {\n setSearch('');\n setHighlightedIndex(0);\n }\n }, [open]);\n\n return (\n <CommandContext.Provider value={{ search, setSearch, highlightedIndex, setHighlightedIndex }}>\n <BaseDialog.Root open={open} onOpenChange={onOpenChange}>\n <BaseDialog.Portal>\n <BaseDialog.Backdrop className={styles.overlay()} />\n <BaseDialog.Popup className={cn(styles.content(), className)}>\n {children}\n </BaseDialog.Popup>\n </BaseDialog.Portal>\n </BaseDialog.Root>\n </CommandContext.Provider>\n );\n};\nCommand.displayName = 'Command';\n\n// ─── CommandInput ────────────────────────────────────────────────────────────\n\nexport interface CommandInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {\n onValueChange?: (value: string) => void;\n}\n\nconst CommandInput = React.forwardRef<HTMLInputElement, CommandInputProps>(\n ({ className, placeholder = 'Type a command or search...', onValueChange, ...props }, ref) => {\n const { search, setSearch, setHighlightedIndex } = useCommand();\n\n return (\n <div className=\"flex items-center border-b border-border px-3 shrink-0\">\n <Search className=\"mr-2 h-4 w-4 shrink-0 text-muted-foreground\" />\n <input\n ref={ref}\n value={search}\n onChange={(e) => {\n setSearch(e.target.value);\n setHighlightedIndex(0);\n onValueChange?.(e.target.value);\n }}\n placeholder={placeholder}\n className={cn(styles.input(), className)}\n {...props}\n />\n </div>\n );\n },\n);\nCommandInput.displayName = 'CommandInput';\n\n// ─── CommandList ─────────────────────────────────────────────────────────────\n\nconst CommandList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, children, ...props }, ref) => (\n <div ref={ref} className={cn(styles.list(), className)} role=\"listbox\" {...props}>\n {children}\n </div>\n ),\n);\nCommandList.displayName = 'CommandList';\n\n// ─── CommandGroup ────────────────────────────────────────────────────────────\n\nexport interface CommandGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n heading?: string;\n}\n\nconst CommandGroup = React.forwardRef<HTMLDivElement, CommandGroupProps>(\n ({ className, heading, children, ...props }, ref) => (\n <div ref={ref} className={cn(styles.group(), className)} role=\"group\" {...props}>\n {heading && <div className={styles.groupLabel()}>{heading}</div>}\n {children}\n </div>\n ),\n);\nCommandGroup.displayName = 'CommandGroup';\n\n// ─── CommandItem ─────────────────────────────────────────────────────────────\n\nexport interface CommandItemProps extends React.HTMLAttributes<HTMLDivElement> {\n disabled?: boolean;\n keywords?: string[];\n onSelect?: () => void;\n value?: string;\n}\n\nconst CommandItem = React.forwardRef<HTMLDivElement, CommandItemProps>(\n ({ className, disabled, keywords = [], onSelect, value, children, ...props }, ref) => {\n const { search, highlightedIndex, setHighlightedIndex } = useCommand();\n const [itemIndex] = React.useState(() => Math.random());\n\n // Filter: check value, text content, and keywords\n const searchable = [value ?? '', ...(typeof children === 'string' ? [children] : []), ...keywords]\n .join(' ')\n .toLowerCase();\n const isVisible = !search || searchable.includes(search.toLowerCase());\n\n if (!isVisible) return null;\n\n return (\n <div\n ref={ref}\n role=\"option\"\n aria-disabled={disabled || undefined}\n className={cn(\n styles.item(),\n disabled && 'opacity-50 pointer-events-none',\n className,\n )}\n onClick={() => {\n if (!disabled) onSelect?.();\n }}\n onKeyDown={(e) => {\n if ((e.key === 'Enter' || e.key === ' ') && !disabled) {\n e.preventDefault();\n onSelect?.();\n }\n }}\n {...props}\n >\n {children}\n </div>\n );\n },\n);\nCommandItem.displayName = 'CommandItem';\n\n// ─── CommandEmpty ────────────────────────────────────────────────────────────\n\nconst CommandEmpty = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, children = 'No results found.', ...props }, ref) => (\n <div ref={ref} className={cn(styles.empty(), className)} {...props}>\n {children}\n </div>\n ),\n);\nCommandEmpty.displayName = 'CommandEmpty';\n\n// ─── CommandSeparator ────────────────────────────────────────────────────────\n\nconst CommandSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={cn(styles.separator(), className)} {...props} />\n ),\n);\nCommandSeparator.displayName = 'CommandSeparator';\n\n// ─── CommandShortcut ─────────────────────────────────────────────────────────\n\nconst CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={cn(styles.shortcut(), className)} {...props} />\n);\nCommandShortcut.displayName = 'CommandShortcut';\n\n// ─── Exports ─────────────────────────────────────────────────────────────────\n\nexport {\n Command,\n CommandInput,\n CommandList,\n CommandGroup,\n CommandItem,\n CommandEmpty,\n CommandSeparator,\n CommandShortcut,\n commandVariants,\n};\n"
288
327
  }
289
328
  ]
290
329
  },
@@ -317,7 +356,15 @@
317
356
  "files": [
318
357
  {
319
358
  "path": "src/components/ui/datepicker/DatePicker.tsx",
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"
359
+ "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\nimport { TimePicker, type TimePickerStyle } from './TimePicker';\r\nimport {\r\n type TimeFormat,\r\n type TimeParts,\r\n DEFAULT_TIME,\r\n parseTimeParts,\r\n buildTimeString,\r\n applyTimeToDate,\r\n dateToTimeParts,\r\n formatDateDisplay,\r\n} from './time-helpers';\r\n\r\n// ---------- types ----------\r\n\r\nexport type { TimeFormat, TimePickerStyle };\r\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\r\n\r\nexport interface DatePickerProps {\r\n mode?: DatePickerMode;\r\n date?: Date | DateRange;\r\n onDateChange?: (date: Date | DateRange | undefined) => void;\r\n onChange?: (date: Date | DateRange | undefined) => void;\r\n timeValue?: string;\r\n onTimeChange?: (time: string) => void;\r\n label?: string;\r\n placeholder?: string;\r\n disablePastDates?: boolean;\r\n showTime?: boolean;\r\n timeFormat?: TimeFormat;\r\n timePickerStyle?: TimePickerStyle;\r\n disabled?: boolean;\r\n className?: string;\r\n description?: string;\r\n error?: string;\r\n locale?: keyof typeof locales;\r\n}\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// ---------- 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 locale: localeKey = 'vi',\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n const resolvedLocale = locales[localeKey];\r\n\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 // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, []);\r\n\r\n const [timeParts, setTimeParts] = React.useState<TimeParts>(initParts);\r\n\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 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 onDateChange?.(selectedDate as DateRange);\r\n onChange?.(selectedDate as DateRange);\r\n }\r\n };\r\n\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={resolvedLocale}\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={resolvedLocale}\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 {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 <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"
360
+ },
361
+ {
362
+ "path": "src/components/ui/datepicker/time-helpers.ts",
363
+ "content": "import { format } from 'date-fns';\n\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\n\nexport interface TimeParts {\n h: string;\n m: string;\n s: string;\n}\n\nexport const DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\n\nexport function parseTimeParts(timeStr: string): TimeParts {\n const [h = '00', m = '00', s = '00'] = timeStr.split(':');\n return {\n h: h.padStart(2, '0'),\n m: m.padStart(2, '0'),\n s: s.padStart(2, '0'),\n };\n}\n\nexport function buildTimeString(parts: TimeParts, fmt: TimeFormat): string {\n if (fmt === 'HH') return parts.h;\n if (fmt === 'HH:mm') return `${parts.h}:${parts.m}`;\n return `${parts.h}:${parts.m}:${parts.s}`;\n}\n\nexport function applyTimeToDate(base: Date, parts: TimeParts): Date {\n const d = new Date(base);\n d.setHours(Number(parts.h), Number(parts.m), Number(parts.s), 0);\n return d;\n}\n\nexport function dateToTimeParts(d: Date): TimeParts {\n return {\n h: d.getHours().toString().padStart(2, '0'),\n m: d.getMinutes().toString().padStart(2, '0'),\n s: d.getSeconds().toString().padStart(2, '0'),\n };\n}\n\nexport function formatDateDisplay(d: Date, showTime: boolean, fmt: TimeFormat): string {\n const datePart = format(d, 'dd/MM/yyyy');\n if (!showTime) return datePart;\n if (fmt === 'HH') return `${datePart} ${format(d, 'HH')}h`;\n if (fmt === 'HH:mm') return `${datePart} ${format(d, 'HH:mm')}`;\n return `${datePart} ${format(d, 'HH:mm:ss')}`;\n}\n\nfunction padOptions(count: number) {\n return Array.from({ length: count }, (_, i) => ({\n label: i.toString().padStart(2, '0'),\n value: i.toString().padStart(2, '0'),\n }));\n}\n\nexport const hoursOptions = padOptions(24);\nexport const minutesOptions = padOptions(60);\nexport const secondsOptions = padOptions(60);\n"
364
+ },
365
+ {
366
+ "path": "src/components/ui/datepicker/TimePicker.tsx",
367
+ "content": "import React from 'react';\nimport type { TimeParts, TimeFormat } from './time-helpers';\nimport { hoursOptions, minutesOptions, secondsOptions } from './time-helpers';\n\nexport type TimePickerStyle = 'input' | 'select';\n\n// ─── NativeScrollSelect ───────��─────────────────────────────────────────────\n\ninterface NativeSelectProps {\n value: string;\n options: { label: string; value: string }[];\n onChange: (val: string) => void;\n 'aria-label'?: string;\n}\n\nconst NativeScrollSelect: React.FC<NativeSelectProps> = ({ value, options, onChange, 'aria-label': ariaLabel }) => (\n <select\n aria-label={ariaLabel}\n value={value}\n onChange={(e) => onChange(e.target.value)}\n className=\"h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none\"\n >\n {options.map((o) => (\n <option key={o.value} value={o.value}>{o.label}</option>\n ))}\n </select>\n);\n\n// ─── TimePicker ──────���──────────────────────────────────────────────────────\n\nexport interface TimePickerProps {\n parts: TimeParts;\n onChange: (parts: TimeParts) => void;\n timeFormat: TimeFormat;\n timePickerStyle: TimePickerStyle;\n}\n\nexport const TimePicker: React.FC<TimePickerProps> = ({ parts, onChange, timeFormat, timePickerStyle }) => {\n const showMinutes = timeFormat === 'HH:mm' || timeFormat === 'HH:mm:ss';\n const showSeconds = timeFormat === 'HH:mm:ss';\n\n if (timePickerStyle === 'input') {\n const step = showSeconds ? 1 : 60;\n const rawValue = showSeconds\n ? `${parts.h}:${parts.m}:${parts.s}`\n : `${parts.h}:${parts.m}`;\n\n return (\n <input\n type=\"time\"\n value={rawValue}\n step={step}\n onChange={(e) => {\n const [h = '00', m = '00', s = '00'] = e.target.value.split(':');\n onChange({ h: h.padStart(2, '0'), m: m.padStart(2, '0'), s: s.padStart(2, '0') });\n }}\n className=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus:border-primary focus:outline-none\"\n />\n );\n }\n\n return (\n <div className=\"flex items-center gap-1.5\">\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Hours\"\n value={parts.h}\n options={hoursOptions}\n onChange={(val) => onChange({ ...parts, h: val })}\n />\n </div>\n {showMinutes && (\n <>\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Minutes\"\n value={parts.m}\n options={minutesOptions}\n onChange={(val) => onChange({ ...parts, m: val })}\n />\n </div>\n </>\n )}\n {showSeconds && (\n <>\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\n <div className=\"flex-1\">\n <NativeScrollSelect\n aria-label=\"Seconds\"\n value={parts.s}\n options={secondsOptions}\n onChange={(val) => onChange({ ...parts, s: val })}\n />\n </div>\n </>\n )}\n </div>\n );\n};\n"
321
368
  }
322
369
  ]
323
370
  },
@@ -332,7 +379,7 @@
332
379
  "files": [
333
380
  {
334
381
  "path": "src/components/ui/dialog/Dialog.tsx",
335
- "content": "import * as React from 'react';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst dialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0! z-50 bg-black/30 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\r\n content:\r\n 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0 data-close:zoom-out-95 data-open:zoom-in-95',\r\n header: 'flex flex-col space-y-1.5 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-auto',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n close:\r\n 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:pointer-events-none data-open:bg-accent data-open:text-muted-foreground',\r\n },\r\n variants: {\r\n size: {\r\n default: {\r\n content: 'max-w-lg sm:rounded-lg',\r\n },\r\n fullScreen: {\r\n content:\r\n 'inset-0 left-0 top-0 translate-x-0 translate-y-0 max-w-none h-full rounded-none border-none',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'default',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Dialog = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DialogTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DialogClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup + default X button) ─── */\r\ninterface DialogContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof dialogVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(\r\n ({ className, children, size, ...props }, ref) => {\r\n const slots = dialogVariants({ size });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n <BaseDialog.Close className={slots.close()}>\r\n <X className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Close</span>\r\n </BaseDialog.Close>\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDialogContent.displayName = 'DialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n },\r\n);\r\nDialogHeader.displayName = 'DialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDialogFooter.displayName = 'DialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst DialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDialogTitle.displayName = 'DialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDialogDescription.displayName = 'DialogDescription';\r\n\r\nexport {\r\n Dialog,\r\n DialogTrigger,\r\n DialogContent,\r\n DialogHeader,\r\n DialogFooter,\r\n DialogTitle,\r\n DialogDescription,\r\n DialogClose,\r\n dialogVariants,\r\n};\r\n"
382
+ "content": "import * as React from 'react';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\n\r\nconst dialogVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0! z-50 bg-black/30 backdrop-blur-sm data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0',\r\n content:\r\n 'fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-open:animate-in data-close:animate-out data-close:fade-out-0 data-open:fade-in-0 data-close:zoom-out-95 data-open:zoom-in-95',\r\n header: 'flex flex-col space-y-1.5 text-center sm:text-left',\r\n footer: 'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 mt-auto',\r\n title: 'text-lg font-semibold leading-none tracking-tight',\r\n description: 'text-sm text-muted-foreground',\r\n close:\r\n 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 disabled:pointer-events-none data-open:bg-accent data-open:text-muted-foreground',\r\n },\r\n variants: {\r\n size: {\r\n default: {\r\n content: 'max-w-lg sm:rounded-lg',\r\n },\r\n fullScreen: {\r\n content:\r\n 'inset-0 left-0 top-0 translate-x-0 translate-y-0 max-w-none h-full rounded-none border-none',\r\n },\r\n },\r\n },\r\n defaultVariants: {\r\n size: 'default',\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Dialog = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\n// Hỗ trợ cả render={} (Base UI) lẫn children trực tiếp.\r\n// Nếu children là một React element (e.g. <Button>), tự động dùng làm render prop\r\n// để tránh nested button (<button><button>…</button></button>).\r\ntype BaseTriggerProps = React.ComponentPropsWithoutRef<typeof BaseDialog.Trigger>;\r\n\r\ninterface DialogTriggerProps extends Omit<BaseTriggerProps, 'render'> {\r\n render?: BaseTriggerProps['render'];\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst DialogTrigger = React.forwardRef<HTMLElement, DialogTriggerProps>(\r\n ({ render: renderProp, children, ...props }, ref) => {\r\n const resolvedRender =\r\n renderProp ?? (React.isValidElement(children) ? children : undefined);\r\n\r\n return (\r\n <BaseDialog.Trigger\r\n ref={ref as React.Ref<HTMLButtonElement>}\r\n render={resolvedRender}\r\n {...props}\r\n >\r\n {resolvedRender ? undefined : children}\r\n </BaseDialog.Trigger>\r\n );\r\n },\r\n);\r\nDialogTrigger.displayName = 'DialogTrigger';\r\n\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DialogClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup + default X button) ─── */\r\ninterface DialogContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof dialogVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(\r\n ({ className, children, size, ...props }, ref) => {\r\n const slots = dialogVariants({ size });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.content({ className })} {...props}>\r\n {children}\r\n <BaseDialog.Close className={slots.close()}>\r\n <X className=\"h-4 w-4\" />\r\n <span className=\"sr-only\">Close</span>\r\n </BaseDialog.Close>\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDialogContent.displayName = 'DialogContent';\r\n\r\n/* ─── Header ─── */\r\nconst DialogHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.header({ className })} {...props} />;\r\n },\r\n);\r\nDialogHeader.displayName = 'DialogHeader';\r\n\r\n/* ─── Footer ─── */\r\nconst DialogFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDialogFooter.displayName = 'DialogFooter';\r\n\r\n/* ─── Title ─── */\r\nconst DialogTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDialogTitle.displayName = 'DialogTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DialogDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & { className?: string }\r\n>(({ className, ...props }, ref) => {\r\n const slots = dialogVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDialogDescription.displayName = 'DialogDescription';\r\n\r\nexport {\r\n Dialog,\r\n DialogTrigger,\r\n DialogContent,\r\n DialogHeader,\r\n DialogFooter,\r\n DialogTitle,\r\n DialogDescription,\r\n DialogClose,\r\n};\r\n"
336
383
  }
337
384
  ]
338
385
  },
@@ -347,7 +394,7 @@
347
394
  "files": [
348
395
  {
349
396
  "path": "src/components/ui/drawer/Drawer.tsx",
350
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/40 data-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0',\r\n panel: [\r\n 'fixed z-50 bg-background shadow-2xl flex flex-col',\r\n 'data-starting:animate-in data-ending:animate-out duration-300',\r\n 'outline-none overflow-hidden m-0 p-0 max-w-full max-h-full border-none',\r\n ],\r\n header:\r\n 'flex items-center justify-between px-6 py-4 border-b border-border/50 shrink-0',\r\n title: 'text-base font-semibold text-foreground',\r\n description: 'text-sm text-muted-foreground mt-0.5',\r\n body: 'flex-1 overflow-y-auto px-6 py-4',\r\n footer: 'px-6 py-4 border-t border-border/50 shrink-0',\r\n close:\r\n 'rounded-sm opacity-70 hover:opacity-100 transition-opacity ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',\r\n },\r\n variants: {\r\n direction: {\r\n left: {\r\n panel:\r\n 'inset-y-0 left-0 h-full data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full transition-transform',\r\n },\r\n right: {\r\n panel:\r\n 'inset-y-0 right-0 h-full data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full transition-transform',\r\n },\r\n top: {\r\n panel:\r\n 'inset-x-0 top-0 w-full data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full transition-transform',\r\n },\r\n bottom: {\r\n panel:\r\n 'inset-x-0 bottom-0 w-full data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full transition-transform',\r\n },\r\n },\r\n size: {\r\n sm: {},\r\n md: {},\r\n lg: {},\r\n full: {},\r\n },\r\n backdropBlur: {\r\n true: { overlay: 'backdrop-blur-sm' },\r\n false: { overlay: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { direction: 'left', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'left', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'left', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'left', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'right', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'right', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'right', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'right', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'top', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'top', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'top', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'top', size: 'full', class: { panel: 'h-full' } },\r\n { direction: 'bottom', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'bottom', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'bottom', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'bottom', size: 'full', class: { panel: 'h-full' } },\r\n ],\r\n defaultVariants: {\r\n direction: 'right',\r\n size: 'md',\r\n backdropBlur: true,\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Drawer = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DrawerTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DrawerClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\ninterface DrawerContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof drawerVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ className, children, direction, size, backdropBlur, ...props }, ref) => {\r\n const slots = drawerVariants({ direction, size, backdropBlur });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.panel({ className })} {...props}>\r\n {children}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDrawerContent.displayName = 'DrawerContent';\r\n\r\n/* ─── Header (includes close button by default) ─── */\r\ninterface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\r\n hideClose?: boolean;\r\n}\r\n\r\nconst DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(\r\n ({ className, children, hideClose, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <div ref={ref} className={slots.header({ className })} {...props}>\r\n <div>{children}</div>\r\n {!hideClose && (\r\n <BaseDialog.Close className={slots.close()} aria-label=\"Close\">\r\n <X className=\"h-4 w-4\" />\r\n </BaseDialog.Close>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\nDrawerHeader.displayName = 'DrawerHeader';\r\n\r\n/* ─── Title ─── */\r\nconst DrawerTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDrawerTitle.displayName = 'DrawerTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DrawerDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDrawerDescription.displayName = 'DrawerDescription';\r\n\r\n/* ─── Body (scrollable content area) ─── */\r\nconst DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.body({ className })} {...props} />;\r\n },\r\n);\r\nDrawerBody.displayName = 'DrawerBody';\r\n\r\n/* ─── Footer ─── */\r\nconst DrawerFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDrawerFooter.displayName = 'DrawerFooter';\r\n\r\nexport {\r\n Drawer,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n drawerVariants,\r\n};\r\nexport type { DrawerContentProps, DrawerHeaderProps };\r\n"
397
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay:\r\n 'fixed inset-0 z-50 bg-black/40 data-starting:animate-in data-ending:animate-out data-ending:fade-out-0 data-starting:fade-in-0',\r\n panel: [\r\n 'fixed z-50 bg-background shadow-2xl flex flex-col',\r\n 'data-starting:animate-in data-ending:animate-out duration-300',\r\n 'outline-none overflow-hidden m-0 p-0 max-w-full max-h-full border-none',\r\n ],\r\n header:\r\n 'flex items-center justify-between px-6 py-4 border-b border-border/50 shrink-0',\r\n title: 'text-base font-semibold text-foreground',\r\n description: 'text-sm text-muted-foreground mt-0.5',\r\n body: 'flex-1 overflow-y-auto px-6 py-4',\r\n footer: 'px-6 py-4 border-t border-border/50 shrink-0',\r\n close:\r\n 'rounded-sm opacity-70 hover:opacity-100 transition-opacity ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',\r\n },\r\n variants: {\r\n direction: {\r\n left: {\r\n panel:\r\n 'inset-y-0 left-0 h-full data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full transition-transform',\r\n },\r\n right: {\r\n panel:\r\n 'inset-y-0 right-0 h-full data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full transition-transform',\r\n },\r\n top: {\r\n panel:\r\n 'inset-x-0 top-0 w-full data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full transition-transform',\r\n },\r\n bottom: {\r\n panel:\r\n 'inset-x-0 bottom-0 w-full data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full transition-transform',\r\n },\r\n },\r\n size: {\r\n sm: {},\r\n md: {},\r\n lg: {},\r\n full: {},\r\n },\r\n backdropBlur: {\r\n true: { overlay: 'backdrop-blur-sm' },\r\n false: { overlay: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { direction: 'left', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'left', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'left', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'left', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'right', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'right', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'right', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'right', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'top', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'top', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'top', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'top', size: 'full', class: { panel: 'h-full' } },\r\n { direction: 'bottom', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'bottom', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'bottom', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'bottom', size: 'full', class: { panel: 'h-full' } },\r\n ],\r\n defaultVariants: {\r\n direction: 'right',\r\n size: 'md',\r\n backdropBlur: true,\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Drawer = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DrawerTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DrawerClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\ninterface DrawerContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof drawerVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ className, children, direction, size, backdropBlur, ...props }, ref) => {\r\n const slots = drawerVariants({ direction, size, backdropBlur });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.panel({ className })} {...props}>\r\n {children}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDrawerContent.displayName = 'DrawerContent';\r\n\r\n/* ─── Header (includes close button by default) ─── */\r\ninterface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\r\n hideClose?: boolean;\r\n}\r\n\r\nconst DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(\r\n ({ className, children, hideClose, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <div ref={ref} className={slots.header({ className })} {...props}>\r\n <div>{children}</div>\r\n {!hideClose && (\r\n <BaseDialog.Close className={slots.close()} aria-label=\"Close\">\r\n <X className=\"h-4 w-4\" />\r\n </BaseDialog.Close>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\nDrawerHeader.displayName = 'DrawerHeader';\r\n\r\n/* ─── Title ─── */\r\nconst DrawerTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDrawerTitle.displayName = 'DrawerTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DrawerDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDrawerDescription.displayName = 'DrawerDescription';\r\n\r\n/* ─── Body (scrollable content area) ─── */\r\nconst DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.body({ className })} {...props} />;\r\n },\r\n);\r\nDrawerBody.displayName = 'DrawerBody';\r\n\r\n/* ─── Footer ─── */\r\nconst DrawerFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDrawerFooter.displayName = 'DrawerFooter';\r\n\r\nexport {\r\n Drawer,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n};\r\nexport type { DrawerContentProps, DrawerHeaderProps };\r\n"
351
398
  }
352
399
  ]
353
400
  },
@@ -362,7 +409,7 @@
362
409
  "files": [
363
410
  {
364
411
  "path": "src/components/ui/dropdown-menu/DropdownMenu.tsx",
365
- "content": "import * as React from 'react';\r\nimport { Menu as BaseMenu } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Check, ChevronRight, Circle } from 'lucide-react';\r\n\r\nconst dropdownMenuVariants = tv({\r\n slots: {\r\n content:\r\n 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n checkboxItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n radioItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n label: 'px-2 py-1.5 text-sm font-semibold',\r\n separator: '-mx-1 my-1 h-px bg-border',\r\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\r\n subTrigger:\r\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-open:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n subContent:\r\n 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nconst styles = dropdownMenuVariants();\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenu = BaseMenu.Root;\r\n\r\n/* ─── Trigger ───────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuTriggerProps\r\n extends React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger> { }\r\n\r\nconst DropdownMenuTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n DropdownMenuTriggerProps\r\n>(({ ...props }, ref) => <BaseMenu.Trigger ref={ref as React.Ref<HTMLButtonElement>} {...props} />);\r\nDropdownMenuTrigger.displayName = 'DropdownMenuTrigger';\r\n\r\n/* ─── Content ───────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuContent component */\r\nexport interface DropdownMenuContentProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n /** Which side of the trigger to render the menu */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger */\r\n align?: 'start' | 'center' | 'end';\r\n /** Distance in px between the trigger and the menu */\r\n sideOffset?: number;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuContentProps\r\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuContent.displayName = 'DropdownMenuContent';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuItem component */\r\nexport interface DropdownMenuItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Item>,\r\n DropdownMenuItemProps\r\n>(({ className, inset, ...props }, ref) => (\r\n <BaseMenu.Item\r\n ref={ref}\r\n className={styles.item({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n />\r\n));\r\nDropdownMenuItem.displayName = 'DropdownMenuItem';\r\n\r\n/* ─── CheckboxItem ──────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuCheckboxItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.CheckboxItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuCheckboxItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.CheckboxItem>,\r\n DropdownMenuCheckboxItemProps\r\n>(({ className, children, checked, ...props }, ref) => (\r\n <BaseMenu.CheckboxItem\r\n ref={ref}\r\n className={styles.checkboxItem({ className })}\r\n checked={checked}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.CheckboxItemIndicator>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseMenu.CheckboxItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.CheckboxItem>\r\n));\r\nDropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';\r\n\r\n/* ─── RadioGroup ────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuRadioGroup = BaseMenu.RadioGroup;\r\n\r\n/* ─── RadioItem ─────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuRadioItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.RadioItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuRadioItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.RadioItem>,\r\n DropdownMenuRadioItemProps\r\n>(({ className, children, ...props }, ref) => (\r\n <BaseMenu.RadioItem ref={ref} className={styles.radioItem({ className })} {...props}>\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.RadioItemIndicator>\r\n <Circle className=\"h-2 w-2 fill-current\" />\r\n </BaseMenu.RadioItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.RadioItem>\r\n));\r\nDropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';\r\n\r\n/* ─── Label ─────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuLabel component */\r\nexport interface DropdownMenuLabelProps extends React.ComponentPropsWithoutRef<'div'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n}\r\n\r\nconst DropdownMenuLabel = React.forwardRef<HTMLDivElement, DropdownMenuLabelProps>(\r\n ({ className, inset, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })} {...props} />\r\n )\r\n);\r\nDropdownMenuLabel.displayName = 'DropdownMenuLabel';\r\n\r\n/* ─── Separator ─────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuSeparatorProps extends React.ComponentPropsWithoutRef<'div'> { }\r\n\r\nconst DropdownMenuSeparator = React.forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nDropdownMenuSeparator.displayName = 'DropdownMenuSeparator';\r\n\r\n/* ─── Shortcut ──────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span className={styles.shortcut({ className })} {...props} />\r\n);\r\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\r\n\r\n/* ─── Sub ───────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuSub = BaseMenu.SubmenuRoot;\r\n\r\n/** Props for the DropdownMenuSubTrigger component */\r\nexport interface DropdownMenuSubTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubTrigger = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\r\n DropdownMenuSubTriggerProps\r\n>(({ className, inset, children, ...props }, ref) => (\r\n <BaseMenu.SubmenuTrigger\r\n ref={ref}\r\n className={styles.subTrigger({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n {children}\r\n <ChevronRight className=\"ml-auto\" />\r\n </BaseMenu.SubmenuTrigger>\r\n));\r\nDropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';\r\n\r\nexport interface DropdownMenuSubContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuSubContentProps\r\n>(({ className, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner sideOffset={-4}>\r\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuSubContent.displayName = 'DropdownMenuSubContent';\r\n\r\n/* ─── Group ─────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuGroup = BaseMenu.Group;\r\n\r\nexport {\r\n DropdownMenu,\r\n DropdownMenuTrigger,\r\n DropdownMenuContent,\r\n DropdownMenuItem,\r\n DropdownMenuCheckboxItem,\r\n DropdownMenuRadioGroup,\r\n DropdownMenuRadioItem,\r\n DropdownMenuLabel,\r\n DropdownMenuSeparator,\r\n DropdownMenuShortcut,\r\n DropdownMenuSub,\r\n DropdownMenuSubTrigger,\r\n DropdownMenuSubContent,\r\n DropdownMenuGroup,\r\n dropdownMenuVariants,\r\n};\r\n"
412
+ "content": "import * as React from 'react';\r\nimport { Menu as BaseMenu } from '@base-ui/react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { Check, ChevronRight, Circle } from 'lucide-react';\r\n\r\nconst dropdownMenuVariants = tv({\r\n slots: {\r\n content:\r\n 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n checkboxItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n radioItem:\r\n 'relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n label: 'px-2 py-1.5 text-sm font-semibold',\r\n separator: '-mx-1 my-1 h-px bg-border',\r\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\r\n subTrigger:\r\n 'flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-open:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n subContent:\r\n 'z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n indicatorWrapper: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n },\r\n});\r\n\r\nconst styles = dropdownMenuVariants();\r\n\r\n/* ─── Root ──────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenu = BaseMenu.Root;\r\n\r\n/* ─── Trigger ───────────────────────────────────────────────────────── */\r\n\r\nexport type DropdownMenuTriggerProps = React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>;\r\n\r\nconst DropdownMenuTrigger = React.forwardRef<\r\n HTMLButtonElement,\r\n DropdownMenuTriggerProps\r\n>(({ ...props }, ref) => <BaseMenu.Trigger ref={ref as React.Ref<HTMLButtonElement>} {...props} />);\r\nDropdownMenuTrigger.displayName = 'DropdownMenuTrigger';\r\n\r\n/* ─── Content ───────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuContent component */\r\nexport interface DropdownMenuContentProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n /** Which side of the trigger to render the menu */\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n /** Alignment relative to the trigger */\r\n align?: 'start' | 'center' | 'end';\r\n /** Distance in px between the trigger and the menu */\r\n sideOffset?: number;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuContentProps\r\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuContent.displayName = 'DropdownMenuContent';\r\n\r\n/* ─── Item ──────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuItem component */\r\nexport interface DropdownMenuItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Item>,\r\n DropdownMenuItemProps\r\n>(({ className, inset, ...props }, ref) => (\r\n <BaseMenu.Item\r\n ref={ref}\r\n className={styles.item({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n />\r\n));\r\nDropdownMenuItem.displayName = 'DropdownMenuItem';\r\n\r\n/* ─── CheckboxItem ──────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuCheckboxItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.CheckboxItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuCheckboxItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.CheckboxItem>,\r\n DropdownMenuCheckboxItemProps\r\n>(({ className, children, checked, ...props }, ref) => (\r\n <BaseMenu.CheckboxItem\r\n ref={ref}\r\n className={styles.checkboxItem({ className })}\r\n checked={checked}\r\n {...props}\r\n >\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.CheckboxItemIndicator>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseMenu.CheckboxItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.CheckboxItem>\r\n));\r\nDropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem';\r\n\r\n/* ─── RadioGroup ────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuRadioGroup = BaseMenu.RadioGroup;\r\n\r\n/* ─── RadioItem ─────────────────────────────────────────────────────── */\r\n\r\nexport interface DropdownMenuRadioItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.RadioItem>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuRadioItem = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.RadioItem>,\r\n DropdownMenuRadioItemProps\r\n>(({ className, children, ...props }, ref) => (\r\n <BaseMenu.RadioItem ref={ref} className={styles.radioItem({ className })} {...props}>\r\n <span className={styles.indicatorWrapper()}>\r\n <BaseMenu.RadioItemIndicator>\r\n <Circle className=\"h-2 w-2 fill-current\" />\r\n </BaseMenu.RadioItemIndicator>\r\n </span>\r\n {children}\r\n </BaseMenu.RadioItem>\r\n));\r\nDropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem';\r\n\r\n/* ─── Label ─────────────────────────────────────────────────────────── */\r\n\r\n/** Props for the DropdownMenuLabel component */\r\nexport interface DropdownMenuLabelProps extends React.ComponentPropsWithoutRef<'div'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n}\r\n\r\nconst DropdownMenuLabel = React.forwardRef<HTMLDivElement, DropdownMenuLabelProps>(\r\n ({ className, inset, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })} {...props} />\r\n )\r\n);\r\nDropdownMenuLabel.displayName = 'DropdownMenuLabel';\r\n\r\n/* ─── Separator ─────────────────────────────────────────────────────── */\r\n\r\nexport type DropdownMenuSeparatorProps = React.ComponentPropsWithoutRef<'div'>;\r\n\r\nconst DropdownMenuSeparator = React.forwardRef<HTMLDivElement, DropdownMenuSeparatorProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nDropdownMenuSeparator.displayName = 'DropdownMenuSeparator';\r\n\r\n/* ─── Shortcut ──────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span className={styles.shortcut({ className })} {...props} />\r\n);\r\nDropdownMenuShortcut.displayName = 'DropdownMenuShortcut';\r\n\r\n/* ─── Sub ───────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuSub = BaseMenu.SubmenuRoot;\r\n\r\n/** Props for the DropdownMenuSubTrigger component */\r\nexport interface DropdownMenuSubTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\r\n /** Add left padding to align with items that have icons */\r\n inset?: boolean;\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubTrigger = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\r\n DropdownMenuSubTriggerProps\r\n>(({ className, inset, children, ...props }, ref) => (\r\n <BaseMenu.SubmenuTrigger\r\n ref={ref}\r\n className={styles.subTrigger({ className: `${inset ? 'pl-8' : ''} ${className ?? ''}` })}\r\n {...props}\r\n >\r\n {children}\r\n <ChevronRight className=\"ml-auto\" />\r\n </BaseMenu.SubmenuTrigger>\r\n));\r\nDropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger';\r\n\r\nexport interface DropdownMenuSubContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst DropdownMenuSubContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n DropdownMenuSubContentProps\r\n>(({ className, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner sideOffset={-4}>\r\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nDropdownMenuSubContent.displayName = 'DropdownMenuSubContent';\r\n\r\n/* ─── Group ─────────────────────────────────────────────────────────── */\r\n\r\nconst DropdownMenuGroup = BaseMenu.Group;\r\n\r\nexport {\r\n DropdownMenu,\r\n DropdownMenuTrigger,\r\n DropdownMenuContent,\r\n DropdownMenuItem,\r\n DropdownMenuCheckboxItem,\r\n DropdownMenuRadioGroup,\r\n DropdownMenuRadioItem,\r\n DropdownMenuLabel,\r\n DropdownMenuSeparator,\r\n DropdownMenuShortcut,\r\n DropdownMenuSub,\r\n DropdownMenuSubTrigger,\r\n DropdownMenuSubContent,\r\n DropdownMenuGroup,\r\n};\r\n"
366
413
  }
367
414
  ]
368
415
  },
@@ -379,6 +426,20 @@
379
426
  }
380
427
  ]
381
428
  },
429
+ "file-upload": {
430
+ "name": "file-upload",
431
+ "dependencies": [
432
+ "tailwind-variants",
433
+ "lucide-react"
434
+ ],
435
+ "internalDependencies": [],
436
+ "files": [
437
+ {
438
+ "path": "src/components/ui/file-upload/FileUpload.tsx",
439
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Upload, X, FileIcon, ImageIcon, FileText, FileArchive } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst fileUploadVariants = tv({\n slots: {\n root: 'flex flex-col gap-3',\n dropzone: [\n 'relative flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed',\n 'cursor-pointer transition-all duration-200',\n 'hover:border-primary/50 hover:bg-primary/5',\n ].join(' '),\n fileList: 'flex flex-col gap-2',\n fileItem: [\n 'flex items-center gap-3 rounded-lg border border-border bg-background p-3',\n 'transition-colors hover:bg-muted/50 group',\n ].join(' '),\n removeBtn: [\n 'shrink-0 p-1 rounded-md text-muted-foreground',\n 'hover:text-danger hover:bg-danger/10 transition-colors',\n 'opacity-0 group-hover:opacity-100',\n ].join(' '),\n },\n variants: {\n size: {\n sm: { dropzone: 'px-4 py-6 text-xs' },\n md: { dropzone: 'px-6 py-10 text-sm' },\n lg: { dropzone: 'px-8 py-14 text-base' },\n },\n isDragActive: {\n true: { dropzone: 'border-primary bg-primary/10 scale-[1.01]' },\n false: { dropzone: 'border-border' },\n },\n isError: {\n true: { dropzone: 'border-danger bg-danger/5' },\n },\n disabled: {\n true: { dropzone: 'opacity-50 cursor-not-allowed hover:border-border hover:bg-transparent' },\n },\n },\n defaultVariants: {\n size: 'md',\n isDragActive: false,\n },\n});\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nfunction formatFileSize(bytes: number): string {\n if (bytes === 0) return '0 B';\n const k = 1024;\n const sizes = ['B', 'KB', 'MB', 'GB'];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;\n}\n\nfunction getFileIcon(type: string) {\n if (type.startsWith('image/')) return ImageIcon;\n if (type.includes('pdf') || type.includes('document')) return FileText;\n if (type.includes('zip') || type.includes('archive') || type.includes('rar')) return FileArchive;\n return FileIcon;\n}\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface FileUploadProps extends VariantProps<typeof fileUploadVariants> {\n /** Accepted file types (e.g. \"image/*,.pdf\") */\n accept?: string;\n /** Allow multiple files */\n multiple?: boolean;\n /** Max file size in bytes */\n maxSize?: number;\n /** Max number of files */\n maxFiles?: number;\n /** Current files (controlled) */\n value?: File[];\n /** Called when files change */\n onChange?: (files: File[]) => void;\n /** Called on validation error */\n onError?: (message: string) => void;\n /** Disable the dropzone */\n disabled?: boolean;\n /** Error message */\n error?: string;\n /** Label */\n label?: string;\n /** Description */\n description?: string;\n className?: string;\n children?: React.ReactNode;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(\n (\n {\n accept,\n multiple = false,\n maxSize,\n maxFiles,\n value = [],\n onChange,\n onError,\n disabled = false,\n error,\n label,\n description,\n size = 'md',\n className,\n children,\n },\n ref,\n ) => {\n const [isDragActive, setIsDragActive] = React.useState(false);\n const inputRef = React.useRef<HTMLInputElement>(null);\n const rootId = React.useId();\n\n const styles = fileUploadVariants({\n size,\n isDragActive,\n isError: !!error,\n disabled,\n });\n\n const validateFiles = React.useCallback(\n (fileList: File[]): File[] => {\n const valid: File[] = [];\n for (const file of fileList) {\n if (maxSize && file.size > maxSize) {\n onError?.(`\"${file.name}\" exceeds ${formatFileSize(maxSize)} limit`);\n continue;\n }\n valid.push(file);\n }\n if (maxFiles && value.length + valid.length > maxFiles) {\n onError?.(`Maximum ${maxFiles} file${maxFiles > 1 ? 's' : ''} allowed`);\n return valid.slice(0, maxFiles - value.length);\n }\n return valid;\n },\n [maxSize, maxFiles, onError, value.length],\n );\n\n const addFiles = React.useCallback(\n (newFiles: File[]) => {\n const validated = validateFiles(newFiles);\n if (validated.length === 0) return;\n onChange?.(multiple ? [...value, ...validated] : [validated[0]]);\n },\n [validateFiles, onChange, multiple, value],\n );\n\n const removeFile = React.useCallback(\n (index: number) => {\n const next = [...value];\n next.splice(index, 1);\n onChange?.(next);\n },\n [value, onChange],\n );\n\n const handleDrop = React.useCallback(\n (e: React.DragEvent) => {\n e.preventDefault();\n setIsDragActive(false);\n if (disabled) return;\n const files = Array.from(e.dataTransfer.files);\n addFiles(files);\n },\n [disabled, addFiles],\n );\n\n const handleDragOver = React.useCallback(\n (e: React.DragEvent) => {\n e.preventDefault();\n if (!disabled) setIsDragActive(true);\n },\n [disabled],\n );\n\n const handleDragLeave = React.useCallback(() => {\n setIsDragActive(false);\n }, []);\n\n const handleInputChange = React.useCallback(\n (e: React.ChangeEvent<HTMLInputElement>) => {\n const files = Array.from(e.target.files || []);\n addFiles(files);\n e.target.value = '';\n },\n [addFiles],\n );\n\n return (\n <div ref={ref} className={cn(styles.root(), className)}>\n {label && (\n <label htmlFor={rootId} className=\"text-sm font-medium text-foreground leading-none\">\n {label}\n </label>\n )}\n\n <div\n className={styles.dropzone()}\n onClick={() => !disabled && inputRef.current?.click()}\n onDrop={handleDrop}\n onDragOver={handleDragOver}\n onDragLeave={handleDragLeave}\n >\n <input\n ref={inputRef}\n id={rootId}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n onChange={handleInputChange}\n disabled={disabled}\n className=\"sr-only\"\n />\n\n {children ?? (\n <>\n <div className=\"flex h-12 w-12 items-center justify-center rounded-full bg-muted\">\n <Upload className=\"h-5 w-5 text-muted-foreground\" />\n </div>\n <div className=\"text-center\">\n <p className=\"font-medium text-foreground\">\n Drop files here or <span className=\"text-primary\">browse</span>\n </p>\n {description && (\n <p className=\"mt-1 text-muted-foreground text-xs\">{description}</p>\n )}\n </div>\n </>\n )}\n </div>\n\n {error && (\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\n )}\n\n {value.length > 0 && (\n <div className={styles.fileList()}>\n {value.map((file, i) => {\n const Icon = getFileIcon(file.type);\n return (\n <div key={`${file.name}-${i}`} className={styles.fileItem()}>\n <div className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted\">\n <Icon className=\"h-4 w-4 text-muted-foreground\" />\n </div>\n <div className=\"min-w-0 flex-1\">\n <p className=\"text-sm font-medium text-foreground truncate\">{file.name}</p>\n <p className=\"text-xs text-muted-foreground\">{formatFileSize(file.size)}</p>\n </div>\n <button\n type=\"button\"\n onClick={(e) => {\n e.stopPropagation();\n removeFile(i);\n }}\n className={styles.removeBtn()}\n aria-label={`Remove ${file.name}`}\n >\n <X className=\"h-4 w-4\" />\n </button>\n </div>\n );\n })}\n </div>\n )}\n </div>\n );\n },\n);\n\nFileUpload.displayName = 'FileUpload';\n\nexport { FileUpload, fileUploadVariants };\n"
440
+ }
441
+ ]
442
+ },
382
443
  "form": {
383
444
  "name": "form",
384
445
  "dependencies": [
@@ -417,7 +478,11 @@
417
478
  "files": [
418
479
  {
419
480
  "path": "src/components/ui/input-otp/InputOtp.tsx",
420
- "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst inputOTPVariants = tv({\n slots: {\n root: 'flex items-center gap-2',\n slot: [\n 'relative flex items-center justify-center',\n 'border border-border bg-background text-foreground font-semibold',\n 'transition-all duration-200 outline-none',\n 'select-none',\n ].join(' '),\n separator: 'flex items-center justify-center text-muted-foreground shrink-0',\n caret: 'absolute inset-0 flex items-center justify-center pointer-events-none',\n caretBlink: 'w-px bg-foreground animate-blink',\n },\n variants: {\n variant: {\n outline: {\n slot: 'rounded-md focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n },\n filled: {\n slot: 'rounded-md bg-muted border-transparent focus-within:bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n },\n underline: {\n slot: 'border-0 border-b-2 border-b-border rounded-none bg-transparent focus-within:border-b-primary',\n },\n glass: {\n slot: 'rounded-xl bg-white/10 dark:bg-white/5 backdrop-blur-md border-white/20 dark:border-white/10 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 shadow-sm',\n },\n },\n size: {\n sm: {\n slot: 'h-9 w-9 text-sm',\n separator: 'text-lg px-0.5',\n caretBlink: 'h-4',\n },\n md: {\n slot: 'h-12 w-12 text-lg',\n separator: 'text-xl px-1',\n caretBlink: 'h-5',\n },\n lg: {\n slot: 'h-14 w-14 text-2xl',\n separator: 'text-2xl px-1.5',\n caretBlink: 'h-6',\n },\n },\n shape: {\n square: { slot: '' },\n rounded: { slot: '' },\n circle: { slot: '' },\n },\n },\n compoundVariants: [\n { shape: 'rounded', variant: 'outline', className: { slot: 'rounded-xl' } },\n { shape: 'rounded', variant: 'filled', className: { slot: 'rounded-xl' } },\n { shape: 'rounded', variant: 'glass', className: { slot: 'rounded-2xl' } },\n { shape: 'circle', variant: 'outline', className: { slot: 'rounded-full' } },\n { shape: 'circle', variant: 'filled', className: { slot: 'rounded-full' } },\n { shape: 'circle', variant: 'glass', className: { slot: 'rounded-full' } },\n ],\n defaultVariants: {\n variant: 'outline',\n size: 'md',\n shape: 'square',\n },\n});\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\ntype SeparatorType = 'dash' | 'dot' | 'space' | React.ReactNode;\n\nexport interface InputOTPProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'inputMode'>,\n VariantProps<typeof inputOTPVariants> {\n /** Total number of OTP slots */\n length?: number;\n /** Current value (controlled) */\n value?: string;\n /** Default value (uncontrolled) */\n defaultValue?: string;\n /** Fired on every value change */\n onChange?: (value: string) => void;\n /** Fired when all slots are filled */\n onComplete?: (value: string) => void;\n /** What characters are allowed */\n inputMode?: InputMode;\n /** Custom regex pattern when inputMode='custom' */\n pattern?: RegExp;\n /** Mask character for entered values (e.g. '*' for password-style) */\n mask?: string | boolean;\n /** Disable the entire input */\n disabled?: boolean;\n /** Show error state */\n error?: boolean;\n /** Error message */\n errorMessage?: string;\n /** Label */\n label?: string;\n /** Description */\n description?: string;\n /** Auto focus on mount */\n autoFocus?: boolean;\n /** Separator config: insert separator after these indices, or every N slots */\n separatorAfter?: number[] | number;\n /** Separator visual */\n separator?: SeparatorType;\n /** Auto-submit on complete */\n autoSubmit?: boolean;\n /** Slot className override */\n slotClassName?: string;\n /** Render filled slots with success style */\n successOnComplete?: boolean;\n /** Placeholder per slot */\n placeholder?: string;\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nconst INPUT_PATTERNS: Record<Exclude<InputMode, 'custom'>, RegExp> = {\n numeric: /^[0-9]$/,\n alphanumeric: /^[a-zA-Z0-9]$/,\n alpha: /^[a-zA-Z]$/,\n};\n\nfunction getPattern(mode: InputMode, custom?: RegExp): RegExp {\n if (mode === 'custom' && custom) return custom;\n return INPUT_PATTERNS[mode as Exclude<InputMode, 'custom'>] ?? INPUT_PATTERNS.numeric;\n}\n\nfunction getSeparatorPositions(config: number[] | number | undefined, length: number): Set<number> {\n if (!config) return new Set();\n if (Array.isArray(config)) return new Set(config);\n // every N slots\n const positions = new Set<number>();\n for (let i = config - 1; i < length - 1; i += config) {\n positions.add(i);\n }\n return positions;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(\n (\n {\n className,\n variant,\n size,\n shape,\n length = 6,\n value: controlledValue,\n defaultValue = '',\n onChange,\n onComplete,\n inputMode = 'numeric',\n pattern: customPattern,\n mask,\n disabled = false,\n error = false,\n errorMessage,\n label,\n description,\n autoFocus = false,\n separatorAfter,\n separator = 'dash',\n autoSubmit = false,\n slotClassName,\n successOnComplete = false,\n placeholder,\n ...props\n },\n ref,\n ) => {\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = React.useState(defaultValue);\n const currentValue = isControlled ? controlledValue : internalValue;\n\n const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);\n const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);\n const rootId = React.useId();\n\n const pat = getPattern(inputMode, customPattern);\n const separatorPositions = getSeparatorPositions(separatorAfter, length);\n const slots = inputOTPVariants({ variant, size, shape });\n\n const chars = React.useMemo(() => {\n const arr = currentValue.split('');\n while (arr.length < length) arr.push('');\n return arr.slice(0, length);\n }, [currentValue, length]);\n\n const isComplete = chars.every((c) => c !== '');\n\n // ─── Value helpers ─────────────────────────────────────────────────\n\n const updateValue = React.useCallback(\n (newChars: string[]) => {\n const val = newChars.join('');\n if (!isControlled) setInternalValue(val);\n onChange?.(val);\n if (val.length === length && newChars.every((c) => c !== '')) {\n onComplete?.(val);\n }\n },\n [isControlled, onChange, onComplete, length],\n );\n\n const focusSlot = React.useCallback((index: number) => {\n const clamped = Math.max(0, Math.min(index, length - 1));\n inputRefs.current[clamped]?.focus();\n }, [length]);\n\n // ─── Handlers ──────────────────────────────────────────────────────\n\n const handleInput = React.useCallback(\n (index: number, char: string) => {\n if (!pat.test(char)) return;\n const next = [...chars];\n next[index] = char;\n updateValue(next);\n if (index < length - 1) {\n focusSlot(index + 1);\n }\n },\n [chars, pat, updateValue, length, focusSlot],\n );\n\n const handleKeyDown = React.useCallback(\n (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {\n switch (e.key) {\n case 'Backspace': {\n e.preventDefault();\n const next = [...chars];\n if (chars[index] !== '') {\n next[index] = '';\n updateValue(next);\n } else if (index > 0) {\n next[index - 1] = '';\n updateValue(next);\n focusSlot(index - 1);\n }\n break;\n }\n case 'Delete': {\n e.preventDefault();\n const next = [...chars];\n next[index] = '';\n updateValue(next);\n break;\n }\n case 'ArrowLeft':\n e.preventDefault();\n if (index > 0) focusSlot(index - 1);\n break;\n case 'ArrowRight':\n e.preventDefault();\n if (index < length - 1) focusSlot(index + 1);\n break;\n case 'Home':\n e.preventDefault();\n focusSlot(0);\n break;\n case 'End':\n e.preventDefault();\n focusSlot(length - 1);\n break;\n }\n },\n [chars, updateValue, focusSlot, length],\n );\n\n const handlePaste = React.useCallback(\n (e: React.ClipboardEvent<HTMLInputElement>) => {\n e.preventDefault();\n const pasted = e.clipboardData.getData('text/plain').trim();\n const next = [...chars];\n let cursor = focusedIndex ?? 0;\n for (const ch of pasted) {\n if (cursor >= length) break;\n if (pat.test(ch)) {\n next[cursor] = ch;\n cursor++;\n }\n }\n updateValue(next);\n focusSlot(Math.min(cursor, length - 1));\n },\n [chars, focusedIndex, length, pat, updateValue, focusSlot],\n );\n\n // ─── Auto focus ────────────────────────────────────────────────────\n\n React.useEffect(() => {\n if (autoFocus) {\n const firstEmpty = chars.findIndex((c) => c === '');\n focusSlot(firstEmpty === -1 ? 0 : firstEmpty);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // ─── Auto submit ───────────────────────────────────────────────────\n\n React.useEffect(() => {\n if (autoSubmit && isComplete) {\n const form = inputRefs.current[0]?.closest('form');\n if (form) {\n form.requestSubmit();\n }\n }\n }, [autoSubmit, isComplete]);\n\n // ─── Render separator ──────────────────────────────────────────────\n\n const renderSeparator = () => {\n if (separator === 'dash') return <span>&ndash;</span>;\n if (separator === 'dot') return <span>&bull;</span>;\n if (separator === 'space') return <span>&nbsp;&nbsp;</span>;\n return separator;\n };\n\n // ─── Render display char ───────────────────────────────────────────\n\n const getDisplayChar = (char: string, index: number) => {\n if (char === '') {\n if (placeholder && placeholder[index]) {\n return <span className=\"text-muted-foreground/50 font-normal\">{placeholder[index]}</span>;\n }\n return null;\n }\n if (mask) {\n const maskChar = typeof mask === 'string' ? mask : '\\u2022';\n return <span>{maskChar}</span>;\n }\n return <span>{char}</span>;\n };\n\n // ─── Render ────────────────────────────────────────────────────────\n\n return (\n <div ref={ref} className=\"flex flex-col gap-1.5\" {...props}>\n {label && (\n <label htmlFor={`${rootId}-0`} className=\"text-sm font-medium text-foreground leading-none\">\n {label}\n </label>\n )}\n\n <div\n className={cn(slots.root(), className)}\n role=\"group\"\n aria-label={label || 'OTP Input'}\n aria-describedby={description ? `${rootId}-desc` : errorMessage ? `${rootId}-err` : undefined}\n >\n {Array.from({ length }).map((_, i) => {\n const isFocused = focusedIndex === i;\n const isFilled = chars[i] !== '';\n const showSuccess = successOnComplete && isComplete;\n const showError = error && !showSuccess;\n\n return (\n <React.Fragment key={i}>\n <div\n className={cn(\n slots.slot(),\n isFocused && !showError && 'border-primary ring-2 ring-primary/20',\n showError && 'border-danger',\n showSuccess && 'border-success text-success',\n disabled && 'opacity-50 cursor-not-allowed',\n slotClassName,\n )}\n >\n <input\n ref={(el) => { inputRefs.current[i] = el; }}\n id={i === 0 ? `${rootId}-0` : undefined}\n type=\"text\"\n inputMode={inputMode === 'numeric' ? 'numeric' : 'text'}\n autoComplete={i === 0 ? 'one-time-code' : 'off'}\n aria-label={`Digit ${i + 1} of ${length}`}\n aria-invalid={error || undefined}\n maxLength={1}\n value=\"\"\n disabled={disabled}\n className=\"sr-only\"\n onFocus={() => setFocusedIndex(i)}\n onBlur={() => setFocusedIndex(null)}\n onChange={(e) => {\n const char = e.target.value;\n if (char) handleInput(i, char);\n }}\n onKeyDown={(e) => handleKeyDown(i, e)}\n onPaste={handlePaste}\n />\n {/* Display */}\n <div\n className=\"flex items-center justify-center w-full h-full cursor-text\"\n onClick={() => !disabled && focusSlot(i)}\n >\n {getDisplayChar(chars[i], i)}\n </div>\n {/* Caret */}\n {isFocused && !isFilled && !disabled && (\n <div className={slots.caret()}>\n <div className={slots.caretBlink()} />\n </div>\n )}\n </div>\n\n {/* Separator */}\n {separatorPositions.has(i) && (\n <div className={slots.separator()} aria-hidden=\"true\">\n {renderSeparator()}\n </div>\n )}\n </React.Fragment>\n );\n })}\n </div>\n\n {description && !error && !errorMessage && (\n <p id={`${rootId}-desc`} className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\n )}\n {(error || errorMessage) && (\n <p id={`${rootId}-err`} className=\"text-[0.8rem] font-medium text-danger\">{errorMessage || 'Invalid code'}</p>\n )}\n </div>\n );\n },\n);\n\nInputOTP.displayName = 'InputOTP';\n\nexport { InputOTP, inputOTPVariants };\n"
481
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { useInputOTP, getSeparatorPositions } from './useInputOTP';\r\n\r\n// ─── Variants ────────────────────────────────────────────────────────────────\r\n\r\nconst inputOTPVariants = tv({\r\n slots: {\r\n root: 'flex items-center gap-2',\r\n slot: [\r\n 'relative flex items-center justify-center',\r\n 'border border-border bg-background text-foreground font-semibold',\r\n 'transition-all duration-200 outline-none',\r\n 'select-none',\r\n ].join(' '),\r\n separator: 'flex items-center justify-center text-muted-foreground shrink-0',\r\n caret: 'absolute inset-0 flex items-center justify-center pointer-events-none',\r\n caretBlink: 'w-px bg-foreground animate-blink',\r\n },\r\n variants: {\r\n variant: {\r\n outline: {\r\n slot: 'rounded-md focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\r\n },\r\n filled: {\r\n slot: 'rounded-md bg-muted border-transparent focus-within:bg-background focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\r\n },\r\n underline: {\r\n slot: 'border-0 border-b-2 border-b-border rounded-none bg-transparent focus-within:border-b-primary',\r\n },\r\n glass: {\r\n slot: 'rounded-xl bg-white/10 dark:bg-white/5 backdrop-blur-md border-white/20 dark:border-white/10 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20 shadow-sm',\r\n },\r\n },\r\n size: {\r\n sm: {\r\n slot: 'h-9 w-9 text-sm',\r\n separator: 'text-lg px-0.5',\r\n caretBlink: 'h-4',\r\n },\r\n md: {\r\n slot: 'h-12 w-12 text-lg',\r\n separator: 'text-xl px-1',\r\n caretBlink: 'h-5',\r\n },\r\n lg: {\r\n slot: 'h-14 w-14 text-2xl',\r\n separator: 'text-2xl px-1.5',\r\n caretBlink: 'h-6',\r\n },\r\n },\r\n shape: {\r\n square: { slot: '' },\r\n rounded: { slot: '' },\r\n circle: { slot: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { shape: 'rounded', variant: 'outline', className: { slot: 'rounded-xl' } },\r\n { shape: 'rounded', variant: 'filled', className: { slot: 'rounded-xl' } },\r\n { shape: 'rounded', variant: 'glass', className: { slot: 'rounded-2xl' } },\r\n { shape: 'circle', variant: 'outline', className: { slot: 'rounded-full' } },\r\n { shape: 'circle', variant: 'filled', className: { slot: 'rounded-full' } },\r\n { shape: 'circle', variant: 'glass', className: { slot: 'rounded-full' } },\r\n ],\r\n defaultVariants: {\r\n variant: 'outline',\r\n size: 'md',\r\n shape: 'square',\r\n },\r\n});\r\n\r\n// ─── Types ───────────────────────────────────────────────────────────────────\r\n\r\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\r\ntype SeparatorType = 'dash' | 'dot' | 'space' | React.ReactNode;\r\n\r\nexport interface InputOTPProps\r\n extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange' | 'inputMode'>,\r\n VariantProps<typeof inputOTPVariants> {\r\n length?: number;\r\n value?: string;\r\n defaultValue?: string;\r\n onChange?: (value: string) => void;\r\n onComplete?: (value: string) => void;\r\n inputMode?: InputMode;\r\n pattern?: RegExp;\r\n mask?: string | boolean;\r\n disabled?: boolean;\r\n error?: boolean;\r\n errorMessage?: string;\r\n label?: string;\r\n description?: string;\r\n autoFocus?: boolean;\r\n separatorAfter?: number[] | number;\r\n separator?: SeparatorType;\r\n autoSubmit?: boolean;\r\n slotClassName?: string;\r\n successOnComplete?: boolean;\r\n placeholder?: string;\r\n}\r\n\r\n// ─── Separator Renderer ─────────────────────────────────────────────────────\r\n\r\nfunction renderSeparatorContent(separator: SeparatorType) {\r\n if (separator === 'dash') return <span>&ndash;</span>;\r\n if (separator === 'dot') return <span>&bull;</span>;\r\n if (separator === 'space') return <span>&nbsp;&nbsp;</span>;\r\n return separator;\r\n}\r\n\r\n// ─── Display Char ───────────────────────────────────────────────────────────\r\n\r\nfunction getDisplayChar(\r\n char: string,\r\n index: number,\r\n mask: string | boolean | undefined,\r\n placeholder: string | undefined,\r\n) {\r\n if (char === '') {\r\n if (placeholder && placeholder[index]) {\r\n return <span className=\"text-muted-foreground/50 font-normal\">{placeholder[index]}</span>;\r\n }\r\n return null;\r\n }\r\n if (mask) {\r\n const maskChar = typeof mask === 'string' ? mask : '\\u2022';\r\n return <span>{maskChar}</span>;\r\n }\r\n return <span>{char}</span>;\r\n}\r\n\r\n// ─── Component ───────────────────────────────────────────────────────────────\r\n\r\nconst InputOTP = React.forwardRef<HTMLDivElement, InputOTPProps>(\r\n (\r\n {\r\n className,\r\n variant,\r\n size,\r\n shape,\r\n length = 6,\r\n value: controlledValue,\r\n defaultValue = '',\r\n onChange,\r\n onComplete,\r\n inputMode = 'numeric',\r\n pattern: customPattern,\r\n mask,\r\n disabled = false,\r\n error = false,\r\n errorMessage,\r\n label,\r\n description,\r\n autoFocus = false,\r\n separatorAfter,\r\n separator = 'dash',\r\n autoSubmit = false,\r\n slotClassName,\r\n successOnComplete = false,\r\n placeholder,\r\n ...props\r\n },\r\n ref,\r\n ) => {\r\n const rootId = React.useId();\r\n const slots = inputOTPVariants({ variant, size, shape });\r\n const separatorPositions = getSeparatorPositions(separatorAfter, length);\r\n\r\n const {\r\n chars,\r\n isComplete,\r\n focusedIndex,\r\n setFocusedIndex,\r\n inputRefs,\r\n focusSlot,\r\n handleInput,\r\n handleKeyDown,\r\n handlePaste,\r\n } = useInputOTP({\r\n length,\r\n controlledValue,\r\n defaultValue,\r\n onChange,\r\n onComplete,\r\n inputMode,\r\n pattern: customPattern,\r\n autoFocus,\r\n autoSubmit,\r\n });\r\n\r\n return (\r\n <div ref={ref} className=\"flex flex-col gap-1.5\" {...props}>\r\n {label && (\r\n <label htmlFor={`${rootId}-0`} className=\"text-sm font-medium text-foreground leading-none\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n <div\r\n className={cn(slots.root(), className)}\r\n role=\"group\"\r\n aria-label={label || 'OTP Input'}\r\n aria-describedby={description ? `${rootId}-desc` : errorMessage ? `${rootId}-err` : undefined}\r\n >\r\n {Array.from({ length }).map((_, i) => {\r\n const isFocused = focusedIndex === i;\r\n const isFilled = chars[i] !== '';\r\n const showSuccess = successOnComplete && isComplete;\r\n const showError = error && !showSuccess;\r\n\r\n return (\r\n <React.Fragment key={i}>\r\n <div\r\n className={cn(\r\n slots.slot(),\r\n isFocused && !showError && 'border-primary ring-2 ring-primary/20',\r\n showError && 'border-danger',\r\n showSuccess && 'border-success text-success',\r\n disabled && 'opacity-50 cursor-not-allowed',\r\n slotClassName,\r\n )}\r\n >\r\n <input\r\n ref={(el) => { inputRefs.current[i] = el; }}\r\n id={i === 0 ? `${rootId}-0` : undefined}\r\n type=\"text\"\r\n inputMode={inputMode === 'numeric' ? 'numeric' : 'text'}\r\n autoComplete={i === 0 ? 'one-time-code' : 'off'}\r\n aria-label={`Digit ${i + 1} of ${length}`}\r\n aria-invalid={error || undefined}\r\n maxLength={1}\r\n value=\"\"\r\n disabled={disabled}\r\n className=\"sr-only\"\r\n onFocus={() => setFocusedIndex(i)}\r\n onBlur={() => setFocusedIndex(null)}\r\n onChange={(e) => {\r\n const char = e.target.value;\r\n if (char) handleInput(i, char);\r\n }}\r\n onKeyDown={(e) => handleKeyDown(i, e)}\r\n onPaste={handlePaste}\r\n />\r\n <div\r\n className=\"flex items-center justify-center w-full h-full cursor-text\"\r\n onClick={() => !disabled && focusSlot(i)}\r\n >\r\n {getDisplayChar(chars[i], i, mask, placeholder)}\r\n </div>\r\n {isFocused && !isFilled && !disabled && (\r\n <div className={slots.caret()}>\r\n <div className={slots.caretBlink()} />\r\n </div>\r\n )}\r\n </div>\r\n\r\n {separatorPositions.has(i) && (\r\n <div className={slots.separator()} aria-hidden=\"true\">\r\n {renderSeparatorContent(separator)}\r\n </div>\r\n )}\r\n </React.Fragment>\r\n );\r\n })}\r\n </div>\r\n\r\n {description && !error && !errorMessage && (\r\n <p id={`${rootId}-desc`} className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {(error || errorMessage) && (\r\n <p id={`${rootId}-err`} className=\"text-[0.8rem] font-medium text-danger\">{errorMessage || 'Invalid code'}</p>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\n\r\nInputOTP.displayName = 'InputOTP';\r\n\r\nexport { InputOTP, inputOTPVariants };\r\n"
482
+ },
483
+ {
484
+ "path": "src/components/ui/input-otp/useInputOTP.ts",
485
+ "content": "import * as React from 'react';\n\ntype InputMode = 'numeric' | 'alphanumeric' | 'alpha' | 'custom';\n\nconst INPUT_PATTERNS: Record<Exclude<InputMode, 'custom'>, RegExp> = {\n numeric: /^[0-9]$/,\n alphanumeric: /^[a-zA-Z0-9]$/,\n alpha: /^[a-zA-Z]$/,\n};\n\nfunction getPattern(mode: InputMode, custom?: RegExp): RegExp {\n if (mode === 'custom' && custom) return custom;\n return INPUT_PATTERNS[mode as Exclude<InputMode, 'custom'>] ?? INPUT_PATTERNS.numeric;\n}\n\nexport function getSeparatorPositions(config: number[] | number | undefined, length: number): Set<number> {\n if (!config) return new Set();\n if (Array.isArray(config)) return new Set(config);\n const positions = new Set<number>();\n for (let i = config - 1; i < length - 1; i += config) {\n positions.add(i);\n }\n return positions;\n}\n\nexport interface UseInputOTPOptions {\n length: number;\n controlledValue?: string;\n defaultValue: string;\n onChange?: (value: string) => void;\n onComplete?: (value: string) => void;\n inputMode: InputMode;\n pattern?: RegExp;\n autoFocus: boolean;\n autoSubmit: boolean;\n}\n\nexport function useInputOTP({\n length,\n controlledValue,\n defaultValue,\n onChange,\n onComplete,\n inputMode,\n pattern: customPattern,\n autoFocus,\n autoSubmit,\n}: UseInputOTPOptions) {\n const isControlled = controlledValue !== undefined;\n const [internalValue, setInternalValue] = React.useState(defaultValue);\n const currentValue = isControlled ? controlledValue : internalValue;\n\n const [focusedIndex, setFocusedIndex] = React.useState<number | null>(null);\n const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);\n\n const pat = getPattern(inputMode, customPattern);\n\n const chars = React.useMemo(() => {\n const arr = currentValue.split('');\n while (arr.length < length) arr.push('');\n return arr.slice(0, length);\n }, [currentValue, length]);\n\n const isComplete = chars.every((c) => c !== '');\n\n const updateValue = React.useCallback(\n (newChars: string[]) => {\n const val = newChars.join('');\n if (!isControlled) setInternalValue(val);\n onChange?.(val);\n if (val.length === length && newChars.every((c) => c !== '')) {\n onComplete?.(val);\n }\n },\n [isControlled, onChange, onComplete, length],\n );\n\n const focusSlot = React.useCallback((index: number) => {\n const clamped = Math.max(0, Math.min(index, length - 1));\n inputRefs.current[clamped]?.focus();\n }, [length]);\n\n const handleInput = React.useCallback(\n (index: number, char: string) => {\n if (!pat.test(char)) return;\n const next = [...chars];\n next[index] = char;\n updateValue(next);\n if (index < length - 1) {\n focusSlot(index + 1);\n }\n },\n [chars, pat, updateValue, length, focusSlot],\n );\n\n const handleKeyDown = React.useCallback(\n (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {\n switch (e.key) {\n case 'Backspace': {\n e.preventDefault();\n const next = [...chars];\n if (chars[index] !== '') {\n next[index] = '';\n updateValue(next);\n } else if (index > 0) {\n next[index - 1] = '';\n updateValue(next);\n focusSlot(index - 1);\n }\n break;\n }\n case 'Delete': {\n e.preventDefault();\n const next = [...chars];\n next[index] = '';\n updateValue(next);\n break;\n }\n case 'ArrowLeft':\n e.preventDefault();\n if (index > 0) focusSlot(index - 1);\n break;\n case 'ArrowRight':\n e.preventDefault();\n if (index < length - 1) focusSlot(index + 1);\n break;\n case 'Home':\n e.preventDefault();\n focusSlot(0);\n break;\n case 'End':\n e.preventDefault();\n focusSlot(length - 1);\n break;\n }\n },\n [chars, updateValue, focusSlot, length],\n );\n\n const handlePaste = React.useCallback(\n (e: React.ClipboardEvent<HTMLInputElement>) => {\n e.preventDefault();\n const pasted = e.clipboardData.getData('text/plain').trim();\n const next = [...chars];\n let cursor = focusedIndex ?? 0;\n for (const ch of pasted) {\n if (cursor >= length) break;\n if (pat.test(ch)) {\n next[cursor] = ch;\n cursor++;\n }\n }\n updateValue(next);\n focusSlot(Math.min(cursor, length - 1));\n },\n [chars, focusedIndex, length, pat, updateValue, focusSlot],\n );\n\n // Auto focus\n React.useEffect(() => {\n if (autoFocus) {\n const firstEmpty = chars.findIndex((c) => c === '');\n focusSlot(firstEmpty === -1 ? 0 : firstEmpty);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // Auto submit\n React.useEffect(() => {\n if (autoSubmit && isComplete) {\n const form = inputRefs.current[0]?.closest('form');\n if (form) {\n form.requestSubmit();\n }\n }\n }, [autoSubmit, isComplete]);\n\n return {\n chars,\n isComplete,\n focusedIndex,\n setFocusedIndex,\n inputRefs,\n focusSlot,\n handleInput,\n handleKeyDown,\n handlePaste,\n };\n}\n"
421
486
  }
422
487
  ]
423
488
  },
@@ -433,7 +498,22 @@
433
498
  "files": [
434
499
  {
435
500
  "path": "src/components/ui/menu-bar/MenuBar.tsx",
436
- "content": "\"use client\"\nimport * as React from 'react';\nimport { Menu as BaseMenu } from '@base-ui/react';\nimport { useNavigate, useMatch } from 'react-router-dom';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRight, ExternalLink } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n/* ─── Types ─────────────────────────────────────────────────────────────── */\n\n/** How the menu item behaves when clicked */\nexport type MenuBarItemType = 'link' | 'button' | 'modal' | 'external';\n\n/** Config for a single item inside a menu */\nexport interface MenuBarItemConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** @default 'button' */\n type?: MenuBarItemType;\n /** Route path for type='link', full URL for type='external' */\n href?: string;\n /** Called on click for type='button' | 'modal', and as fallback for 'link' | 'external' */\n onClick?: () => void;\n shortcut?: string;\n disabled?: boolean;\n /** Renders a separator line before this item */\n separator?: boolean;\n /** Nested items — renders as a flyout submenu (unlimited depth) */\n children?: MenuBarItemConfig[];\n}\n\n/** Config for one top-level menu entry.\n *\n * - Có `items` → dropdown menu bình thường\n * - Không có `items` → click thẳng vào entry (dùng `type` + `href` / `onClick`)\n */\nexport interface MenuBarMenuConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** Nếu có items → render dropdown. Nếu bỏ qua → render trực tiếp như button/link */\n items?: MenuBarItemConfig[];\n disabled?: boolean;\n /** Chỉ dùng khi không có items. @default 'button' */\n type?: MenuBarItemType;\n /** Route path (type='link') hoặc URL (type='external') */\n href?: string;\n /** Callback khi click (type='button' | 'modal') */\n onClick?: () => void;\n}\n\n/* ─── Variants ──────────────────────────────────────────────────────────── */\n\nconst menuBarVariants = tv({\n slots: {\n root: 'flex items-center gap-0.5 rounded-md border border-border bg-background p-1',\n trigger:\n 'inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',\n content:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n item:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n itemActive: 'bg-accent/50 font-medium',\n subTrigger:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n subContent:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n separator: '-mx-1 my-1 h-px bg-border',\n label: 'px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\n },\n});\n\nconst styles = menuBarVariants();\n\n/* ─── MenuBar ───────────────────────────────────────────────────────────── */\n\nexport interface MenuBarProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBar = React.forwardRef<HTMLDivElement, MenuBarProps>(({ className, ...props }, ref) => (\n <div ref={ref} role=\"menubar\" className={styles.root({ className })} {...props} />\n));\nMenuBar.displayName = 'MenuBar';\n\n/* ─── MenuBarMenu ───────────────────────────────────────────────────────── */\n\nconst MenuBarMenu = BaseMenu.Root;\n\n/* ─── MenuBarTrigger ────────────────────────────────────────────────────── */\n\nexport interface MenuBarTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarTrigger = React.forwardRef<HTMLButtonElement, MenuBarTriggerProps>(\n ({ className, ...props }, ref) => (\n <BaseMenu.Trigger\n ref={ref as React.Ref<HTMLButtonElement>}\n className={styles.trigger({ className })}\n {...props}\n />\n )\n);\nMenuBarTrigger.displayName = 'MenuBarTrigger';\n\n/* ─── MenuBarButton (top-level direct item, no dropdown) ───────────────── */\n\nexport interface MenuBarButtonProps extends React.ComponentPropsWithoutRef<'button'> {\n /** Highlights the button (e.g. active route) */\n active?: boolean;\n}\n\nconst MenuBarButton = React.forwardRef<HTMLButtonElement, MenuBarButtonProps>(\n ({ className, active, ...props }, ref) => (\n <button\n ref={ref}\n className={styles.trigger({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n />\n )\n);\nMenuBarButton.displayName = 'MenuBarButton';\n\n/* ─── MenuBarContent ────────────────────────────────────────────────────── */\n\nexport interface MenuBarContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n side?: 'top' | 'right' | 'bottom' | 'left';\n align?: 'start' | 'center' | 'end';\n sideOffset?: number;\n}\n\nconst MenuBarContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarContentProps\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarContent.displayName = 'MenuBarContent';\n\n/* ─── MenuBarItem ───────────────────────────────────────────────────────── */\n\nexport interface MenuBarItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\n className?: string;\n /** Applies active/highlighted styling (e.g. current route) */\n active?: boolean;\n}\n\nconst MenuBarItem = React.forwardRef<React.ComponentRef<typeof BaseMenu.Item>, MenuBarItemProps>(\n ({ className, active, children, ...props }, ref) => (\n <BaseMenu.Item\n ref={ref}\n className={styles.item({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n >\n {children}\n </BaseMenu.Item>\n )\n);\nMenuBarItem.displayName = 'MenuBarItem';\n\n/* ─── MenuBarSeparator ──────────────────────────────────────────────────── */\n\nexport interface MenuBarSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarSeparator = React.forwardRef<HTMLDivElement, MenuBarSeparatorProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.separator({ className })} {...props} />\n )\n);\nMenuBarSeparator.displayName = 'MenuBarSeparator';\n\n/* ─── MenuBarLabel ──────────────────────────────────────────────────────── */\n\nexport interface MenuBarLabelProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarLabel = React.forwardRef<HTMLDivElement, MenuBarLabelProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className })} {...props} />\n )\n);\nMenuBarLabel.displayName = 'MenuBarLabel';\n\n/* ─── MenuBarShortcut ───────────────────────────────────────────────────── */\n\nconst MenuBarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={styles.shortcut({ className })} {...props} />\n);\nMenuBarShortcut.displayName = 'MenuBarShortcut';\n\n/* ─── MenuBarSub ────────────────────────────────────────────────────────── */\n\nconst MenuBarSub = BaseMenu.SubmenuRoot;\n\n/* ─── MenuBarSubTrigger ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubTrigger = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\n MenuBarSubTriggerProps\n>(({ className, children, ...props }, ref) => (\n <BaseMenu.SubmenuTrigger ref={ref} className={styles.subTrigger({ className })} {...props}>\n {children}\n <ChevronRight className=\"ml-auto\" />\n </BaseMenu.SubmenuTrigger>\n));\nMenuBarSubTrigger.displayName = 'MenuBarSubTrigger';\n\n/* ─── MenuBarSubContent ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarSubContentProps\n>(({ className, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner sideOffset={-4}>\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarSubContent.displayName = 'MenuBarSubContent';\n\n/* ─── MenuBarGroup ──────────────────────────────────────────────────────── */\n\nconst MenuBarGroup = BaseMenu.Group;\n\n/* ─── Config-driven layer ───────────────────────────────────────────────── */\n\n/**\n * Internal recursive renderer for MenuBarItemConfig.\n * Handles all 4 item types, separators, and unlimited submenu depth.\n */\nconst MenuBarItemRenderer = ({ item }: { item: MenuBarItemConfig }) => {\n const navigate = useNavigate();\n const isLinkType = item.type === 'link' && !!item.href;\n const match = useMatch(isLinkType ? item.href! : '__NO_MATCH__');\n const isActive = isLinkType && !!match;\n\n const handleClick = React.useCallback(() => {\n if (item.type === 'link' && item.href) {\n navigate(item.href);\n } else if (item.type === 'external' && item.href) {\n window.open(item.href, '_blank', 'noopener,noreferrer');\n } else {\n item.onClick?.();\n }\n }, [item, navigate]);\n\n if (item.children && item.children.length > 0) {\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarSub>\n <MenuBarSubTrigger disabled={item.disabled}>\n {item.icon}\n {item.label}\n </MenuBarSubTrigger>\n <MenuBarSubContent>\n {item.children.map((child) => (\n <MenuBarItemRenderer key={child.id} item={child} />\n ))}\n </MenuBarSubContent>\n </MenuBarSub>\n </>\n );\n }\n\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarItem active={isActive} onClick={handleClick} disabled={item.disabled}>\n {item.icon}\n {item.label}\n {item.shortcut && <MenuBarShortcut>{item.shortcut}</MenuBarShortcut>}\n {item.type === 'external' && <ExternalLink className=\"ml-auto !size-3 opacity-50\" />}\n </MenuBarItem>\n </>\n );\n};\n\n/** Props for the config-driven MenuBarNav component */\nexport interface MenuBarNavProps extends Omit<MenuBarProps, 'children'> {\n /** Array of top-level menus, each with nested items supporting unlimited depth */\n menus: MenuBarMenuConfig[];\n}\n\n/**\n * Config-driven menu bar. Pass a `menus` array and it renders everything —\n * triggers, dropdowns, submenus, separators, active link states.\n *\n * @example\n * ```tsx\n * <MenuBarNav menus={[\n * {\n * id: 'file', label: 'File',\n * items: [\n * { id: 'new', label: 'New', type: 'button', onClick: handleNew, shortcut: '⌘N' },\n * { id: 'open', label: 'Open', type: 'link', href: '/open' },\n * { id: 'sep', label: '---', separator: true, ... },\n * ],\n * },\n * ]} />\n * ```\n */\n/** Internal: renders a direct (no-dropdown) top-level entry */\nconst MenuBarDirectRenderer = ({ menu }: { menu: MenuBarMenuConfig }) => {\n const navigate = useNavigate();\n const isLink = menu.type === 'link' && !!menu.href;\n const match = useMatch(isLink ? menu.href! : '__NO_MATCH__');\n\n const handleClick = React.useCallback(() => {\n if (menu.type === 'link' && menu.href) navigate(menu.href);\n else if (menu.type === 'external' && menu.href) window.open(menu.href, '_blank', 'noopener,noreferrer');\n else menu.onClick?.();\n }, [menu, navigate]);\n\n return (\n <MenuBarButton active={isLink && !!match} disabled={menu.disabled} onClick={handleClick}>\n {menu.icon}\n {menu.label}\n {menu.type === 'external' && <ExternalLink className=\"!size-3 opacity-50\" />}\n </MenuBarButton>\n );\n};\n\nconst MenuBarNav = React.forwardRef<HTMLDivElement, MenuBarNavProps>(\n ({ menus, className, ...props }, ref) => (\n <MenuBar ref={ref} className={className} {...props}>\n {menus.map((menu) =>\n !menu.items || menu.items.length === 0 ? (\n // Direct item — không có dropdown\n <MenuBarDirectRenderer key={menu.id} menu={menu} />\n ) : (\n // Dropdown menu bình thường\n <MenuBarMenu key={menu.id}>\n <MenuBarTrigger disabled={menu.disabled}>\n {menu.icon}\n {menu.label}\n </MenuBarTrigger>\n <MenuBarContent>\n {menu.items.map((item) => (\n <MenuBarItemRenderer key={item.id} item={item} />\n ))}\n </MenuBarContent>\n </MenuBarMenu>\n )\n )}\n </MenuBar>\n )\n);\nMenuBarNav.displayName = 'MenuBarNav';\n\n/* ─── Exports ───────────────────────────────────────────────────────────── */\n\nexport {\n menuBarVariants,\n // Primitive API\n MenuBar,\n MenuBarMenu,\n MenuBarTrigger,\n MenuBarButton,\n MenuBarContent,\n MenuBarItem,\n MenuBarSeparator,\n MenuBarLabel,\n MenuBarShortcut,\n MenuBarSub,\n MenuBarSubTrigger,\n MenuBarSubContent,\n MenuBarGroup,\n // Config-driven API\n MenuBarNav,\n};\n"
501
+ "content": "import * as React from 'react';\r\nimport { Menu as BaseMenu } from '@base-ui/react';\r\nimport { useNavigate, useMatch } from 'react-router-dom';\r\nimport { tv } from 'tailwind-variants';\r\nimport { ChevronRight, ExternalLink } from 'lucide-react';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\n/* ─── Types ─────────────────────────────────────────────────────────────── */\r\n\r\n/** How the menu item behaves when clicked */\r\nexport type MenuBarItemType = 'link' | 'button' | 'modal' | 'external';\r\n\r\n/** Config for a single item inside a menu */\r\nexport interface MenuBarItemConfig {\r\n id: string;\r\n label: React.ReactNode;\r\n icon?: React.ReactNode;\r\n /** @default 'button' */\r\n type?: MenuBarItemType;\r\n /** Route path for type='link', full URL for type='external' */\r\n href?: string;\r\n /** Called on click for type='button' | 'modal', and as fallback for 'link' | 'external' */\r\n onClick?: () => void;\r\n shortcut?: string;\r\n disabled?: boolean;\r\n /** Renders a separator line before this item */\r\n separator?: boolean;\r\n /** Nested items — renders as a flyout submenu (unlimited depth) */\r\n children?: MenuBarItemConfig[];\r\n}\r\n\r\n/** Config for one top-level menu entry.\r\n *\r\n * - Có `items` → dropdown menu bình thường\r\n * - Không có `items` → click thẳng vào entry (dùng `type` + `href` / `onClick`)\r\n */\r\nexport interface MenuBarMenuConfig {\r\n id: string;\r\n label: React.ReactNode;\r\n icon?: React.ReactNode;\r\n /** Nếu có items → render dropdown. Nếu bỏ qua → render trực tiếp như button/link */\r\n items?: MenuBarItemConfig[];\r\n disabled?: boolean;\r\n /** Chỉ dùng khi không có items. @default 'button' */\r\n type?: MenuBarItemType;\r\n /** Route path (type='link') hoặc URL (type='external') */\r\n href?: string;\r\n /** Callback khi click (type='button' | 'modal') */\r\n onClick?: () => void;\r\n}\r\n\r\n/* ─── Variants ──────────────────────────────────────────────────────────── */\r\n\r\nconst menuBarVariants = tv({\r\n slots: {\r\n root: 'flex items-center gap-0.5 rounded-md border border-border bg-background p-1',\r\n trigger:\r\n 'inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',\r\n content:\r\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\r\n item:\r\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n itemActive: 'bg-accent/50 font-medium',\r\n subTrigger:\r\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\r\n subContent:\r\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\r\n separator: '-mx-1 my-1 h-px bg-border',\r\n label: 'px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\r\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\r\n },\r\n});\r\n\r\nconst styles = menuBarVariants();\r\n\r\n/* ─── MenuBar ───────────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarProps extends React.ComponentPropsWithoutRef<'div'> {}\r\n\r\nconst MenuBar = React.forwardRef<HTMLDivElement, MenuBarProps>(({ className, ...props }, ref) => (\r\n <div ref={ref} role=\"menubar\" className={styles.root({ className })} {...props} />\r\n));\r\nMenuBar.displayName = 'MenuBar';\r\n\r\n/* ─── MenuBarMenu ───────────────────────────────────────────────────────── */\r\n\r\nconst MenuBarMenu = BaseMenu.Root;\r\n\r\n/* ─── MenuBarTrigger ────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst MenuBarTrigger = React.forwardRef<HTMLButtonElement, MenuBarTriggerProps>(\r\n ({ className, ...props }, ref) => (\r\n <BaseMenu.Trigger\r\n ref={ref as React.Ref<HTMLButtonElement>}\r\n className={styles.trigger({ className })}\r\n {...props}\r\n />\r\n )\r\n);\r\nMenuBarTrigger.displayName = 'MenuBarTrigger';\r\n\r\n/* ─── MenuBarButton (top-level direct item, no dropdown) ───────────────── */\r\n\r\nexport interface MenuBarButtonProps extends React.ComponentPropsWithoutRef<'button'> {\r\n /** Highlights the button (e.g. active route) */\r\n active?: boolean;\r\n}\r\n\r\nconst MenuBarButton = React.forwardRef<HTMLButtonElement, MenuBarButtonProps>(\r\n ({ className, active, ...props }, ref) => (\r\n <button\r\n ref={ref}\r\n className={styles.trigger({ className: cn(active && styles.itemActive(), className) })}\r\n {...props}\r\n />\r\n )\r\n);\r\nMenuBarButton.displayName = 'MenuBarButton';\r\n\r\n/* ─── MenuBarContent ────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n side?: 'top' | 'right' | 'bottom' | 'left';\r\n align?: 'start' | 'center' | 'end';\r\n sideOffset?: number;\r\n}\r\n\r\nconst MenuBarContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n MenuBarContentProps\r\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\r\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nMenuBarContent.displayName = 'MenuBarContent';\r\n\r\n/* ─── MenuBarItem ───────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarItemProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\r\n className?: string;\r\n /** Applies active/highlighted styling (e.g. current route) */\r\n active?: boolean;\r\n}\r\n\r\nconst MenuBarItem = React.forwardRef<React.ComponentRef<typeof BaseMenu.Item>, MenuBarItemProps>(\r\n ({ className, active, children, ...props }, ref) => (\r\n <BaseMenu.Item\r\n ref={ref}\r\n className={styles.item({ className: cn(active && styles.itemActive(), className) })}\r\n {...props}\r\n >\r\n {children}\r\n </BaseMenu.Item>\r\n )\r\n);\r\nMenuBarItem.displayName = 'MenuBarItem';\r\n\r\n/* ─── MenuBarSeparator ──────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}\r\n\r\nconst MenuBarSeparator = React.forwardRef<HTMLDivElement, MenuBarSeparatorProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.separator({ className })} {...props} />\r\n )\r\n);\r\nMenuBarSeparator.displayName = 'MenuBarSeparator';\r\n\r\n/* ─── MenuBarLabel ──────────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarLabelProps extends React.ComponentPropsWithoutRef<'div'> {}\r\n\r\nconst MenuBarLabel = React.forwardRef<HTMLDivElement, MenuBarLabelProps>(\r\n ({ className, ...props }, ref) => (\r\n <div ref={ref} className={styles.label({ className })} {...props} />\r\n )\r\n);\r\nMenuBarLabel.displayName = 'MenuBarLabel';\r\n\r\n/* ─── MenuBarShortcut ───────────────────────────────────────────────────── */\r\n\r\nconst MenuBarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\r\n <span className={styles.shortcut({ className })} {...props} />\r\n);\r\nMenuBarShortcut.displayName = 'MenuBarShortcut';\r\n\r\n/* ─── MenuBarSub ────────────────────────────────────────────────────────── */\r\n\r\nconst MenuBarSub = BaseMenu.SubmenuRoot;\r\n\r\n/* ─── MenuBarSubTrigger ─────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarSubTriggerProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst MenuBarSubTrigger = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\r\n MenuBarSubTriggerProps\r\n>(({ className, children, ...props }, ref) => (\r\n <BaseMenu.SubmenuTrigger ref={ref} className={styles.subTrigger({ className })} {...props}>\r\n {children}\r\n <ChevronRight className=\"ml-auto\" />\r\n </BaseMenu.SubmenuTrigger>\r\n));\r\nMenuBarSubTrigger.displayName = 'MenuBarSubTrigger';\r\n\r\n/* ─── MenuBarSubContent ─────────────────────────────────────────────────── */\r\n\r\nexport interface MenuBarSubContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\r\n className?: string;\r\n}\r\n\r\nconst MenuBarSubContent = React.forwardRef<\r\n React.ComponentRef<typeof BaseMenu.Popup>,\r\n MenuBarSubContentProps\r\n>(({ className, ...props }, ref) => (\r\n <BaseMenu.Portal>\r\n <BaseMenu.Positioner sideOffset={-4}>\r\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\r\n </BaseMenu.Positioner>\r\n </BaseMenu.Portal>\r\n));\r\nMenuBarSubContent.displayName = 'MenuBarSubContent';\r\n\r\n/* ─── MenuBarGroup ──────────────────────────────────────────────────────── */\r\n\r\nconst MenuBarGroup = BaseMenu.Group;\r\n\r\n/* ─── Config-driven layer ───────────────────────────────────────────────── */\r\n\r\n/**\r\n * Internal recursive renderer for MenuBarItemConfig.\r\n * Handles all 4 item types, separators, and unlimited submenu depth.\r\n */\r\nconst MenuBarItemRenderer = ({ item }: { item: MenuBarItemConfig }) => {\r\n const navigate = useNavigate();\r\n const isLinkType = item.type === 'link' && !!item.href;\r\n const match = useMatch(isLinkType ? item.href! : '__NO_MATCH__');\r\n const isActive = isLinkType && !!match;\r\n\r\n const handleClick = React.useCallback(() => {\r\n if (item.type === 'link' && item.href) {\r\n navigate(item.href);\r\n } else if (item.type === 'external' && item.href) {\r\n window.open(item.href, '_blank', 'noopener,noreferrer');\r\n } else {\r\n item.onClick?.();\r\n }\r\n }, [item, navigate]);\r\n\r\n if (item.children && item.children.length > 0) {\r\n return (\r\n <>\r\n {item.separator && <MenuBarSeparator />}\r\n <MenuBarSub>\r\n <MenuBarSubTrigger disabled={item.disabled}>\r\n {item.icon}\r\n {item.label}\r\n </MenuBarSubTrigger>\r\n <MenuBarSubContent>\r\n {item.children.map((child) => (\r\n <MenuBarItemRenderer key={child.id} item={child} />\r\n ))}\r\n </MenuBarSubContent>\r\n </MenuBarSub>\r\n </>\r\n );\r\n }\r\n\r\n return (\r\n <>\r\n {item.separator && <MenuBarSeparator />}\r\n <MenuBarItem active={isActive} onClick={handleClick} disabled={item.disabled}>\r\n {item.icon}\r\n {item.label}\r\n {item.shortcut && <MenuBarShortcut>{item.shortcut}</MenuBarShortcut>}\r\n {item.type === 'external' && <ExternalLink className=\"ml-auto !size-3 opacity-50\" />}\r\n </MenuBarItem>\r\n </>\r\n );\r\n};\r\n\r\n/** Props for the config-driven MenuBarNav component */\r\nexport interface MenuBarNavProps extends Omit<MenuBarProps, 'children'> {\r\n /** Array of top-level menus, each with nested items supporting unlimited depth */\r\n menus: MenuBarMenuConfig[];\r\n}\r\n\r\n/**\r\n * Config-driven menu bar. Pass a `menus` array and it renders everything —\r\n * triggers, dropdowns, submenus, separators, active link states.\r\n *\r\n * @example\r\n * ```tsx\r\n * <MenuBarNav menus={[\r\n * {\r\n * id: 'file', label: 'File',\r\n * items: [\r\n * { id: 'new', label: 'New', type: 'button', onClick: handleNew, shortcut: '⌘N' },\r\n * { id: 'open', label: 'Open', type: 'link', href: '/open' },\r\n * { id: 'sep', label: '---', separator: true, ... },\r\n * ],\r\n * },\r\n * ]} />\r\n * ```\r\n */\r\n/** Internal: renders a direct (no-dropdown) top-level entry */\r\nconst MenuBarDirectRenderer = ({ menu }: { menu: MenuBarMenuConfig }) => {\r\n const navigate = useNavigate();\r\n const isLink = menu.type === 'link' && !!menu.href;\r\n const match = useMatch(isLink ? menu.href! : '__NO_MATCH__');\r\n\r\n const handleClick = React.useCallback(() => {\r\n if (menu.type === 'link' && menu.href) navigate(menu.href);\r\n else if (menu.type === 'external' && menu.href) window.open(menu.href, '_blank', 'noopener,noreferrer');\r\n else menu.onClick?.();\r\n }, [menu, navigate]);\r\n\r\n return (\r\n <MenuBarButton active={isLink && !!match} disabled={menu.disabled} onClick={handleClick}>\r\n {menu.icon}\r\n {menu.label}\r\n {menu.type === 'external' && <ExternalLink className=\"!size-3 opacity-50\" />}\r\n </MenuBarButton>\r\n );\r\n};\r\n\r\nconst MenuBarNav = React.forwardRef<HTMLDivElement, MenuBarNavProps>(\r\n ({ menus, className, ...props }, ref) => (\r\n <MenuBar ref={ref} className={className} {...props}>\r\n {menus.map((menu) =>\r\n !menu.items || menu.items.length === 0 ? (\r\n // Direct item — không có dropdown\r\n <MenuBarDirectRenderer key={menu.id} menu={menu} />\r\n ) : (\r\n // Dropdown menu bình thường\r\n <MenuBarMenu key={menu.id}>\r\n <MenuBarTrigger disabled={menu.disabled}>\r\n {menu.icon}\r\n {menu.label}\r\n </MenuBarTrigger>\r\n <MenuBarContent>\r\n {menu.items.map((item) => (\r\n <MenuBarItemRenderer key={item.id} item={item} />\r\n ))}\r\n </MenuBarContent>\r\n </MenuBarMenu>\r\n )\r\n )}\r\n </MenuBar>\r\n )\r\n);\r\nMenuBarNav.displayName = 'MenuBarNav';\r\n\r\n/* ─── Exports ───────────────────────────────────────────────────────────── */\r\n\r\nexport {\r\n menuBarVariants,\r\n // Primitive API\r\n MenuBar,\r\n MenuBarMenu,\r\n MenuBarTrigger,\r\n MenuBarButton,\r\n MenuBarContent,\r\n MenuBarItem,\r\n MenuBarSeparator,\r\n MenuBarLabel,\r\n MenuBarShortcut,\r\n MenuBarSub,\r\n MenuBarSubTrigger,\r\n MenuBarSubContent,\r\n MenuBarGroup,\r\n // Config-driven API\r\n MenuBarNav,\r\n};\r\n"
502
+ }
503
+ ]
504
+ },
505
+ "number-input": {
506
+ "name": "number-input",
507
+ "dependencies": [
508
+ "@base-ui/react",
509
+ "tailwind-variants",
510
+ "lucide-react"
511
+ ],
512
+ "internalDependencies": [],
513
+ "files": [
514
+ {
515
+ "path": "src/components/ui/number-input/NumberInput.tsx",
516
+ "content": "import * as React from 'react';\nimport { NumberField } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { Minus, Plus } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst numberInputVariants = tv({\n slots: {\n root: 'flex flex-col gap-1.5',\n group: 'inline-flex items-center border border-border rounded-lg bg-background overflow-hidden transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20',\n input: [\n 'h-full bg-transparent text-center text-sm font-medium text-foreground outline-none',\n 'placeholder:text-muted-foreground',\n '[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',\n ].join(' '),\n button: [\n 'inline-flex items-center justify-center shrink-0 border-0 bg-transparent text-muted-foreground',\n 'transition-colors hover:bg-muted hover:text-foreground',\n 'disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',\n ].join(' '),\n label: 'text-sm font-medium text-foreground leading-none',\n description: 'text-[0.8rem] text-muted-foreground',\n error: 'text-[0.8rem] font-medium text-danger',\n },\n variants: {\n size: {\n sm: {\n group: 'h-8',\n input: 'w-12 text-xs',\n button: 'w-8 h-8',\n },\n md: {\n group: 'h-10',\n input: 'w-14 text-sm',\n button: 'w-10 h-10',\n },\n lg: {\n group: 'h-12',\n input: 'w-16 text-base',\n button: 'w-12 h-12',\n },\n },\n isError: {\n true: { group: 'border-danger focus-within:border-danger focus-within:ring-danger/20' },\n },\n disabled: {\n true: { group: 'opacity-50 cursor-not-allowed' },\n },\n },\n defaultVariants: {\n size: 'md',\n },\n});\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface NumberInputProps extends VariantProps<typeof numberInputVariants> {\n value?: number | null;\n defaultValue?: number;\n onChange?: (value: number | null) => void;\n min?: number;\n max?: number;\n step?: number;\n disabled?: boolean;\n label?: string;\n description?: string;\n error?: string;\n placeholder?: string;\n className?: string;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst NumberInput = React.forwardRef<HTMLDivElement, NumberInputProps>(\n (\n {\n value,\n defaultValue = 0,\n onChange,\n min,\n max,\n step = 1,\n disabled = false,\n label,\n description,\n error,\n placeholder = '0',\n size = 'md',\n className,\n },\n ref,\n ) => {\n const styles = numberInputVariants({ size, isError: !!error, disabled });\n const rootId = React.useId();\n\n return (\n <NumberField.Root\n ref={ref}\n value={value ?? undefined}\n defaultValue={defaultValue}\n onValueChange={(val) => onChange?.(val)}\n min={min}\n max={max}\n step={step}\n disabled={disabled}\n className={cn(styles.root(), className)}\n >\n {label && (\n <label htmlFor={rootId} className={styles.label()}>\n {label}\n </label>\n )}\n\n <NumberField.Group className={styles.group()}>\n <NumberField.Decrement\n className={cn(styles.button(), 'border-r border-border')}\n aria-label=\"Decrease\"\n >\n <Minus className=\"h-3.5 w-3.5\" />\n </NumberField.Decrement>\n\n <NumberField.Input\n id={rootId}\n placeholder={placeholder}\n className={styles.input()}\n />\n\n <NumberField.Increment\n className={cn(styles.button(), 'border-l border-border')}\n aria-label=\"Increase\"\n >\n <Plus className=\"h-3.5 w-3.5\" />\n </NumberField.Increment>\n </NumberField.Group>\n\n {description && !error && (\n <p className={styles.description()}>{description}</p>\n )}\n {error && (\n <p className={styles.error()}>{error}</p>\n )}\n </NumberField.Root>\n );\n },\n);\n\nNumberInput.displayName = 'NumberInput';\n\nexport { NumberInput, numberInputVariants };\n"
437
517
  }
438
518
  ]
439
519
  },
@@ -722,14 +802,25 @@
722
802
  "lucide-react"
723
803
  ],
724
804
  "internalDependencies": [
725
- "button",
726
805
  "checkbox",
727
806
  "spinner"
728
807
  ],
729
808
  "files": [
730
809
  {
731
810
  "path": "src/components/ui/table/Table.tsx",
732
- "content": "'use client';\r\n\r\nimport React, { useEffect, useRef, useState } from 'react';\r\nimport {\r\n useReactTable,\r\n getCoreRowModel,\r\n getSortedRowModel,\r\n getPaginationRowModel,\r\n getFilteredRowModel,\r\n getExpandedRowModel,\r\n flexRender,\r\n type ColumnDef,\r\n type SortingState,\r\n type PaginationState,\r\n type RowSelectionState,\r\n type RowData,\r\n type ColumnResizeMode,\r\n} from '@tanstack/react-table';\r\nimport { useVirtualizer } from '@tanstack/react-virtual';\r\n\r\ndeclare module '@tanstack/react-table' {\r\n interface ColumnMeta<TData extends RowData, TValue> {\r\n align?: 'left' | 'center' | 'right';\r\n }\r\n}\r\nimport { cn } from '@lib/utils/cn';\r\nimport { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\r\nimport { Button } from '../button/Button';\r\nimport { Checkbox } from '../checkbox/Checkbox';\r\nimport { Spinner } from '../spinner/Spinner';\r\n\r\n// ─── Pagination Config ───────────────────────────────────────────────────────\r\n\r\nexport interface PaginationConfig {\r\n /** Trang hiện tại, 1-based (dùng để controlled ở server mode) */\r\n current?: number;\r\n /** Số dòng mỗi trang, default 10 */\r\n pageSize?: number;\r\n /** Tổng bản ghi từ BE — có giá trị → tự động bật server mode */\r\n total?: number;\r\n /** Danh sách page size options, default [5,10,20,50,100] */\r\n pageSizeOptions?: number[];\r\n /** Hiển thị info tổng. (total, range) => ReactNode */\r\n showTotal?: (total: number, range: [number, number]) => React.ReactNode;\r\n /** Ẩn/hiện page size selector, default true */\r\n showSizeChanger?: boolean;\r\n /**\r\n * Callback khi đổi trang / pageSize.\r\n * - Client mode: tuỳ chọn, chỉ để notify\r\n * - Server mode: bắt buộc, dùng để fetch API\r\n */\r\n onChange?: (page: number, pageSize: number) => void;\r\n}\r\n\r\n/** i18n labels for Table UI strings */\r\nexport interface TableLabels {\r\n /** Label for the page input prefix. Default: \"Page\" */\r\n page?: string;\r\n /** Suffix for page size selector. Default: \"/ page\" */\r\n perPage?: string;\r\n /** Text shown when there is no data. Default: \"No data\" */\r\n empty?: string;\r\n}\r\n\r\nexport interface TableProps<TData, TValue = unknown> {\r\n data: TData[];\r\n columns: ColumnDef<TData, TValue>[];\r\n isLoading?: boolean;\r\n enableSorting?: boolean;\r\n enableRowSelection?: boolean;\r\n enableExpanding?: boolean;\r\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\r\n getRowCanExpand?: (row: TData) => boolean;\r\n onSelectionChange?: (selectedRows: TData[]) => void;\r\n className?: string;\r\n enableColumnResizing?: boolean;\r\n columnResizeMode?: ColumnResizeMode;\r\n /**\r\n * Pagination config.\r\n * - false / omitted: disable pagination\r\n * - {} object: enable (client-side by default)\r\n * - { total }: enable server-side mode\r\n */\r\n pagination?: PaginationConfig | false;\r\n /** @deprecated Use `labels.empty` instead */\r\n emptyText?: string;\r\n /** i18n labels for UI strings */\r\n labels?: TableLabels;\r\n /**\r\n * Enable row virtualization for large datasets.\r\n * Requires @tanstack/react-virtual to be installed.\r\n * When enabled, `pagination` is ignored.\r\n */\r\n virtualize?: boolean;\r\n /**\r\n * Height (px) of the virtualized scroll container.\r\n * Only used when `virtualize={true}`. Default: 400\r\n */\r\n virtualHeight?: number;\r\n /**\r\n * Estimated row height (px) for the virtualizer.\r\n * Only used when `virtualize={true}`. Default: 45\r\n */\r\n estimatedRowHeight?: number;\r\n}\r\n\r\nexport function Table<TData, TValue = unknown>({\r\n data = [],\r\n columns,\r\n isLoading = false,\r\n enableSorting = true,\r\n enableRowSelection = false,\r\n enableExpanding = false,\r\n renderSubComponent,\r\n getRowCanExpand,\r\n onSelectionChange,\r\n className,\r\n enableColumnResizing = false,\r\n columnResizeMode = 'onChange',\r\n pagination: paginationProp = {},\r\n emptyText,\r\n labels,\r\n virtualize = false,\r\n virtualHeight = 400,\r\n estimatedRowHeight = 45,\r\n}: TableProps<TData>) {\r\n const resolvedEmptyText = emptyText ?? labels?.empty ?? 'No data';\r\n // Virtualization setup\r\n const scrollContainerRef = useRef<HTMLDivElement>(null);\r\n // Xác định có bật pagination không\r\n const paginationEnabled = paginationProp !== false;\r\n const cfg = paginationEnabled ? (paginationProp as PaginationConfig) : {};\r\n\r\n // Server mode khi có cfg.total\r\n const isServerMode = paginationEnabled && cfg.total !== undefined;\r\n const pageSizeOptions = cfg.pageSizeOptions ?? [5, 10, 20, 50, 100];\r\n\r\n // Internal pagination state (0-based pageIndex cho tanstack)\r\n const [page, setPage] = useState(cfg.current ?? 1); // 1-based\r\n const [pageSize, setPageSize] = useState(cfg.pageSize ?? 10);\r\n\r\n // Sync controlled current/pageSize từ ngoài vào (server mode)\r\n useEffect(() => {\r\n if (cfg.current !== undefined) setPage(cfg.current);\r\n }, [cfg.current]);\r\n\r\n useEffect(() => {\r\n if (cfg.pageSize !== undefined) setPageSize(cfg.pageSize);\r\n }, [cfg.pageSize]);\r\n\r\n // Derived\r\n const pageIndex = page - 1; // 0-based cho tanstack\r\n const totalRows = isServerMode ? cfg.total! : data.length;\r\n const pageCount = isServerMode ? Math.ceil(totalRows / pageSize) : undefined;\r\n\r\n const tanstackPagination: PaginationState = { pageIndex, pageSize };\r\n\r\n const handlePaginationChange = (updater: PaginationState | ((prev: PaginationState) => PaginationState)) => {\r\n const next = typeof updater === 'function' ? updater(tanstackPagination) : updater;\r\n const newPage = next.pageIndex + 1; // convert về 1-based\r\n const newPageSize = next.pageSize;\r\n\r\n if (!isServerMode) {\r\n setPage(newPage);\r\n setPageSize(newPageSize);\r\n }\r\n cfg.onChange?.(newPage, newPageSize);\r\n };\r\n\r\n const [sorting, setSorting] = useState<SortingState>([]);\r\n const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\r\n\r\n const finalColumns = React.useMemo(() => {\r\n const cols = [...columns];\r\n if (enableRowSelection) {\r\n cols.unshift({\r\n id: 'select',\r\n size: 10,\r\n minSize: 5,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n header: ({ table }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={table.getIsAllRowsSelected()}\r\n indeterminate={table.getIsSomeRowsSelected()}\r\n onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n cell: ({ row }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={row.getIsSelected()}\r\n disabled={!row.getCanSelect()}\r\n onCheckedChange={(checked) => row.toggleSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n enableSorting: false,\r\n });\r\n }\r\n if (enableExpanding) {\r\n cols.unshift({\r\n id: 'expander',\r\n header: () => null,\r\n size: 10,\r\n minSize: 10,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n cell: ({ row }) => row.getCanExpand() ? (\r\n <div className=\"flex items-center justify-center\">\r\n <span\r\n onClick={row.getToggleExpandedHandler()}\r\n className=\"hover:bg-muted text-muted-foreground transition-colors cursor-pointer outline-none focus:ring-2 focus:ring-primary/50 p-1 rounded-md border border-border\"\r\n >\r\n {row.getIsExpanded() ? <ChevronDown className=\"w-3 h-3\" /> : <ChevronRight className=\"w-3 h-3\" />}\r\n </span>\r\n </div>\r\n ) : null,\r\n enableSorting: false,\r\n });\r\n }\r\n return cols;\r\n }, [columns, enableRowSelection, enableExpanding]);\r\n\r\n const table = useReactTable({\r\n data,\r\n columns: finalColumns,\r\n state: { sorting, rowSelection, pagination: tanstackPagination },\r\n onSortingChange: setSorting,\r\n onRowSelectionChange: setRowSelection,\r\n onPaginationChange: handlePaginationChange,\r\n getCoreRowModel: getCoreRowModel(),\r\n getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,\r\n getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined,\r\n getFilteredRowModel: getFilteredRowModel(),\r\n getExpandedRowModel: getExpandedRowModel(),\r\n columnResizeMode,\r\n enableColumnResizing,\r\n enableRowSelection,\r\n manualPagination: isServerMode,\r\n pageCount: isServerMode ? pageCount : undefined,\r\n getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => !!renderSubComponent,\r\n });\r\n\r\n // Input go-to-page\r\n const [inputValue, setInputValue] = useState<string | number>(page);\r\n useEffect(() => { setInputValue(page); }, [page]);\r\n\r\n useEffect(() => {\r\n if (onSelectionChange) {\r\n const selected = table.getSelectedRowModel().rows.map(row => row.original);\r\n onSelectionChange(selected);\r\n }\r\n }, [rowSelection, onSelectionChange, table]);\r\n\r\n // Computed display values\r\n const currentPageIndex = table.getState().pagination.pageIndex;\r\n const currentPageSize = table.getState().pagination.pageSize;\r\n const from = currentPageIndex * currentPageSize + 1;\r\n const to = Math.min((currentPageIndex + 1) * currentPageSize, totalRows);\r\n const totalPageCount = isServerMode ? (pageCount ?? 1) : (table.getPageCount() || 1);\r\n\r\n // Virtualizer — only active when virtualize=true\r\n const allRows = virtualize ? table.getCoreRowModel().rows : [];\r\n const rowVirtualizer = useVirtualizer({\r\n count: virtualize ? allRows.length : 0,\r\n getScrollElement: () => scrollContainerRef.current,\r\n estimateSize: () => estimatedRowHeight,\r\n overscan: 8,\r\n enabled: virtualize,\r\n });\r\n const virtualItems = virtualize ? rowVirtualizer.getVirtualItems() : [];\r\n\r\n return (\r\n <div className={cn(\"relative w-full rounded-md border border-border bg-background flex flex-col overflow-hidden\", className)}>\r\n\r\n {/* Loading Overlay */}\r\n {isLoading && (\r\n <div className=\"absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[0.5px]\">\r\n <Spinner size=\"lg\" variant=\"primary\" />\r\n </div>\r\n )}\r\n\r\n <div\r\n ref={scrollContainerRef}\r\n className=\"overflow-x-auto w-full\"\r\n style={virtualize ? { overflowY: 'auto', maxHeight: virtualHeight } : undefined}\r\n >\r\n <table\r\n className=\"w-full text-sm text-left text-foreground whitespace-nowrap\"\r\n style={{\r\n width: enableColumnResizing ? table.getCenterTotalSize() : undefined,\r\n tableLayout: enableColumnResizing ? 'fixed' : 'auto'\r\n }}\r\n >\r\n <thead className=\"text-xs text-muted-foreground bg-muted/50 border-b border-border\">\r\n {table.getHeaderGroups().map(headerGroup => (\r\n <tr key={headerGroup.id} >\r\n {headerGroup.headers.map(header => {\r\n const meta = header.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n const canSort = header.column.getCanSort() && enableSorting && header.column.id !== 'select';\r\n\r\n return (\r\n <th\r\n key={header.id}\r\n colSpan={header.colSpan}\r\n style={{\r\n width: enableColumnResizing ? header.getSize() : header.column.columnDef.size,\r\n position: 'relative'\r\n }}\r\n aria-sort={\r\n canSort\r\n ? header.column.getIsSorted() === 'desc'\r\n ? 'descending'\r\n : header.column.getIsSorted() === 'asc'\r\n ? 'ascending'\r\n : 'none'\r\n : undefined\r\n }\r\n className={cn(\r\n header.column.id === 'select' || header.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 font-semibold tracking-wide border border-border transition-colors group/header\",\r\n canSort ? \"cursor-pointer select-none hover:bg-muted\" : \"\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div\r\n className={cn(\"flex flex-col h-full\")}\r\n onClick={canSort ? header.column.getToggleSortingHandler() : undefined}\r\n >\r\n {header.isPlaceholder ? null : (\r\n <div className={cn(\r\n \"flex items-center gap-2\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-between\"\r\n )}>\r\n <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>\r\n {canSort && (\r\n <span className=\"shrink-0\">\r\n {{\r\n asc: <ChevronUp className=\"w-4 h-4 text-primary\" />,\r\n desc: <ChevronDown className=\"w-4 h-4 text-primary\" />,\r\n }[header.column.getIsSorted() as string] ?? (\r\n <div className=\"flex flex-col opacity-30 -space-y-1 hover:opacity-100 transition-opacity\">\r\n <ChevronUp className=\"w-3 h-3\" />\r\n <ChevronDown className=\"w-3 h-3\" />\r\n </div>\r\n )}\r\n </span>\r\n )}\r\n </div>\r\n )}\r\n </div>\r\n\r\n {/* Resize Handle */}\r\n {enableColumnResizing && header.column.getCanResize() && (\r\n <div\r\n {...{\r\n onMouseDown: header.getResizeHandler(),\r\n onTouchStart: header.getResizeHandler(),\r\n className: cn(\r\n \"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 transition-colors\",\r\n header.column.getIsResizing() ? \"bg-primary w-1.5 z-10\" : \"bg-transparent\"\r\n ),\r\n }}\r\n />\r\n )}\r\n </th>\r\n );\r\n })}\r\n </tr>\r\n ))}\r\n </thead>\r\n <tbody className=\"\">\r\n {!isLoading && data.length === 0 ? (\r\n <tr>\r\n <td colSpan={finalColumns.length} className=\" px-4 py-16 text-center text-muted-foreground\">\r\n <div className=\"flex flex-col items-center justify-center space-y-2\">\r\n <span className=\"text-muted-foreground/50\">\r\n <svg className=\"w-12 h-12\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1} d=\"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4\" />\r\n </svg>\r\n </span>\r\n <span>{resolvedEmptyText}</span>\r\n </div>\r\n </td>\r\n </tr>\r\n ) : virtualize ? (\r\n // ── Virtual rows ──────────────────────────────────\r\n <>\r\n {rowVirtualizer.getTotalSize() > 0 && (\r\n <tr aria-hidden=\"true\">\r\n <td style={{ height: virtualItems[0]?.start ?? 0, padding: 0, border: 0 }} colSpan={finalColumns.length} />\r\n </tr>\r\n )}\r\n {virtualItems.map(virtualRow => {\r\n const row = allRows[virtualRow.index];\r\n if (!row) return null;\r\n return (\r\n <tr\r\n key={row.id}\r\n data-index={virtualRow.index}\r\n ref={rowVirtualizer.measureElement}\r\n className={cn(\r\n \"hover:bg-muted/50 transition-colors\",\r\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\"\r\n )}\r\n >\r\n {row.getVisibleCells().map(cell => {\r\n const meta = cell.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n return (\r\n <td\r\n key={cell.id}\r\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\r\n className={cn(\r\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 border border-border align-middle\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div className={cn(\r\n \"flex items-center\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\r\n )}>\r\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\r\n </div>\r\n </td>\r\n );\r\n })}\r\n </tr>\r\n );\r\n })}\r\n {rowVirtualizer.getTotalSize() > 0 && (\r\n <tr aria-hidden=\"true\">\r\n <td\r\n style={{\r\n height: rowVirtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0),\r\n padding: 0,\r\n border: 0,\r\n }}\r\n colSpan={finalColumns.length}\r\n />\r\n </tr>\r\n )}\r\n </>\r\n ) : (\r\n // ── Normal rows ───────────────────────────────────\r\n table.getRowModel().rows.map(row => (\r\n <React.Fragment key={row.id}>\r\n <tr\r\n className={cn(\r\n \"hover:bg-muted/50 transition-colors group\",\r\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\",\r\n row.getIsExpanded() ? \"bg-primary/5\" : \"\"\r\n )}\r\n >\r\n {row.getVisibleCells().map(cell => {\r\n const meta = cell.column.columnDef.meta;\r\n const align = meta?.align || 'left';\r\n return (\r\n <td\r\n key={cell.id}\r\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\r\n className={cn(\r\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\r\n \"py-3 border border-border align-middle\",\r\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\r\n )}\r\n >\r\n <div className={cn(\r\n \"flex items-center\",\r\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\r\n )}>\r\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\r\n </div>\r\n </td>\r\n );\r\n })}\r\n </tr>\r\n {row.getIsExpanded() && renderSubComponent && (\r\n <tr>\r\n <td colSpan={row.getVisibleCells().length} className=\"p-0 border-b border-border whitespace-normal\">\r\n <div className=\"bg-muted/50 px-4 py-5 shadow-inner w-full border-l-4 border-l-primary/40 wrap-break-word\">\r\n {renderSubComponent({ row: row.original })}\r\n </div>\r\n </td>\r\n </tr>\r\n )}\r\n </React.Fragment>\r\n ))\r\n )}\r\n </tbody>\r\n </table>\r\n </div>\r\n\r\n {/* Pagination Controls — hidden in virtualize mode */}\r\n {!virtualize && paginationEnabled && totalPageCount > 0 && (\r\n <div className=\"flex flex-col sm:flex-row items-center justify-between px-3 py-2.5 border-t border-border bg-muted/50 gap-2\">\r\n {/* showTotal info */}\r\n <div className=\"text-xs text-muted-foreground shrink-0 order-2 sm:order-1\">\r\n {cfg.showTotal\r\n ? cfg.showTotal(totalRows, [from, to])\r\n : <>Show <b>{from}</b>–<b>{to}</b> of <b>{totalRows}</b> results</>\r\n }\r\n </div>\r\n\r\n {/* Controls */}\r\n <div className=\"flex items-center gap-2 overflow-x-auto order-1 sm:order-2 w-full sm:w-auto pb-0.5 sm:pb-0\">\r\n {/* Page size */}\r\n {(cfg.showSizeChanger !== false) && (\r\n <select\r\n value={currentPageSize}\r\n onChange={e => table.setPageSize(Number(e.target.value))}\r\n className=\"shrink-0 px-2 py-1 text-xs border border-border rounded-md bg-background text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary cursor-pointer\"\r\n >\r\n {pageSizeOptions.map(s => (\r\n <option key={s} value={s}>{s}{labels?.perPage ?? ' / page'}</option>\r\n ))}\r\n </select>\r\n )}\r\n\r\n {/* Nav buttons */}\r\n <div className=\"flex items-center gap-1 shrink-0\">\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(0)}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"First page\"\r\n >\r\n <ChevronsLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.previousPage()}\r\n disabled={!table.getCanPreviousPage()}\r\n aria-label=\"Previous page\"\r\n >\r\n <ChevronLeft className=\"w-3.5 h-3.5\" />\r\n </button>\r\n\r\n <span className=\"text-xs font-medium px-2.5 py-1 bg-background border border-border rounded shrink-0 min-w-14 text-center text-foreground\">\r\n {currentPageIndex + 1} / {totalPageCount}\r\n </span>\r\n\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.nextPage()}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Next page\"\r\n >\r\n <ChevronRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n <button\r\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\r\n onClick={() => table.setPageIndex(totalPageCount - 1)}\r\n disabled={!table.getCanNextPage()}\r\n aria-label=\"Last page\"\r\n >\r\n <ChevronsRight className=\"w-3.5 h-3.5\" />\r\n </button>\r\n </div>\r\n\r\n {/* Go to page */}\r\n <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground border-l border-border pl-2 ml-1 shrink-0\">\r\n <span className=\"hidden sm:inline\">{labels?.page ?? 'Page'}</span>\r\n <input\r\n type=\"number\"\r\n min={1}\r\n max={totalPageCount}\r\n value={inputValue}\r\n onChange={e => {\r\n const val = e.target.value;\r\n setInputValue(val);\r\n const p = val ? Number(val) - 1 : 0;\r\n if (val && p >= 0 && p < totalPageCount) {\r\n table.setPageIndex(p);\r\n }\r\n }}\r\n onBlur={() => setInputValue(currentPageIndex + 1)}\r\n className=\"w-10 px-1 py-1 text-xs border border-border rounded bg-background text-foreground text-center outline-none focus:border-primary focus:ring-1 focus:ring-primary\"\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n )}\r\n </div>\r\n );\r\n}\r\n"
811
+ "content": "import React, { useEffect, useRef, useState } from 'react';\r\nimport {\r\n useReactTable,\r\n getCoreRowModel,\r\n getSortedRowModel,\r\n getPaginationRowModel,\r\n getFilteredRowModel,\r\n getExpandedRowModel,\r\n type ColumnDef,\r\n type SortingState,\r\n type PaginationState,\r\n type RowSelectionState,\r\n type RowData,\r\n type ColumnResizeMode,\r\n} from '@tanstack/react-table';\r\nimport { useVirtualizer } from '@tanstack/react-virtual';\r\n\r\ndeclare module '@tanstack/react-table' {\r\n interface ColumnMeta<TData extends RowData, TValue> {\r\n align?: 'left' | 'center' | 'right';\r\n }\r\n}\r\nimport { cn } from '@lib/utils/cn';\r\nimport { ChevronDown, ChevronRight } from 'lucide-react';\r\nimport { Checkbox } from '../checkbox/Checkbox';\r\nimport { Spinner } from '../spinner/Spinner';\r\nimport { TableHeader } from './TableHeader';\r\nimport { TableEmpty, TableNormalRows, TableVirtualRows } from './TableBody';\r\nimport { TablePagination } from './TablePagination';\r\n\r\n// ─── Pagination Config ───────────────────────────────────────────────────────\r\n\r\nexport interface PaginationConfig {\r\n current?: number;\r\n pageSize?: number;\r\n total?: number;\r\n pageSizeOptions?: number[];\r\n showTotal?: (total: number, range: [number, number]) => React.ReactNode;\r\n showSizeChanger?: boolean;\r\n onChange?: (page: number, pageSize: number) => void;\r\n}\r\n\r\nexport interface TableLabels {\r\n page?: string;\r\n perPage?: string;\r\n empty?: string;\r\n}\r\n\r\nexport interface TableProps<TData, TValue = unknown> {\r\n data: TData[];\r\n columns: ColumnDef<TData, TValue>[];\r\n isLoading?: boolean;\r\n enableSorting?: boolean;\r\n enableRowSelection?: boolean;\r\n enableExpanding?: boolean;\r\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\r\n getRowCanExpand?: (row: TData) => boolean;\r\n onSelectionChange?: (selectedRows: TData[]) => void;\r\n className?: string;\r\n enableColumnResizing?: boolean;\r\n columnResizeMode?: ColumnResizeMode;\r\n pagination?: PaginationConfig | false;\r\n /** @deprecated Use `labels.empty` instead */\r\n emptyText?: string;\r\n labels?: TableLabels;\r\n virtualize?: boolean;\r\n virtualHeight?: number;\r\n estimatedRowHeight?: number;\r\n}\r\n\r\nexport function Table<TData, TValue = unknown>({\r\n data = [],\r\n columns,\r\n isLoading = false,\r\n enableSorting = true,\r\n enableRowSelection = false,\r\n enableExpanding = false,\r\n renderSubComponent,\r\n getRowCanExpand,\r\n onSelectionChange,\r\n className,\r\n enableColumnResizing = false,\r\n columnResizeMode = 'onChange',\r\n pagination: paginationProp = {},\r\n emptyText,\r\n labels,\r\n virtualize = false,\r\n virtualHeight = 400,\r\n estimatedRowHeight = 45,\r\n}: TableProps<TData>) {\r\n const resolvedEmptyText = emptyText ?? labels?.empty ?? 'No data';\r\n const scrollContainerRef = useRef<HTMLDivElement>(null);\r\n const paginationEnabled = paginationProp !== false;\r\n const cfg = paginationEnabled ? (paginationProp as PaginationConfig) : {};\r\n\r\n const isServerMode = paginationEnabled && cfg.total !== undefined;\r\n const [page, setPage] = useState(cfg.current ?? 1);\r\n const [pageSize, setPageSize] = useState(cfg.pageSize ?? 10);\r\n\r\n useEffect(() => {\r\n if (cfg.current !== undefined) setPage(cfg.current);\r\n }, [cfg.current]);\r\n\r\n useEffect(() => {\r\n if (cfg.pageSize !== undefined) setPageSize(cfg.pageSize);\r\n }, [cfg.pageSize]);\r\n\r\n const pageIndex = page - 1;\r\n const totalRows = isServerMode ? cfg.total! : data.length;\r\n const pageCount = isServerMode ? Math.ceil(totalRows / pageSize) : undefined;\r\n const tanstackPagination: PaginationState = { pageIndex, pageSize };\r\n\r\n const handlePaginationChange = (updater: PaginationState | ((prev: PaginationState) => PaginationState)) => {\r\n const next = typeof updater === 'function' ? updater(tanstackPagination) : updater;\r\n const newPage = next.pageIndex + 1;\r\n const newPageSize = next.pageSize;\r\n\r\n if (!isServerMode) {\r\n setPage(newPage);\r\n setPageSize(newPageSize);\r\n }\r\n cfg.onChange?.(newPage, newPageSize);\r\n };\r\n\r\n const [sorting, setSorting] = useState<SortingState>([]);\r\n const [rowSelection, setRowSelection] = useState<RowSelectionState>({});\r\n\r\n const finalColumns = React.useMemo(() => {\r\n const cols = [...columns];\r\n if (enableRowSelection) {\r\n cols.unshift({\r\n id: 'select',\r\n size: 10,\r\n minSize: 5,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n header: ({ table }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={table.getIsAllRowsSelected()}\r\n indeterminate={table.getIsSomeRowsSelected()}\r\n onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n cell: ({ row }) => (\r\n <div className=\"flex items-center justify-center\">\r\n <Checkbox\r\n size=\"sm\"\r\n checked={row.getIsSelected()}\r\n disabled={!row.getCanSelect()}\r\n onCheckedChange={(checked) => row.toggleSelected(!!checked)}\r\n />\r\n </div>\r\n ),\r\n enableSorting: false,\r\n });\r\n }\r\n if (enableExpanding) {\r\n cols.unshift({\r\n id: 'expander',\r\n header: () => null,\r\n size: 10,\r\n minSize: 10,\r\n maxSize: 10,\r\n meta: { align: 'center' },\r\n cell: ({ row }) => row.getCanExpand() ? (\r\n <div className=\"flex items-center justify-center\">\r\n <span\r\n onClick={row.getToggleExpandedHandler()}\r\n className=\"hover:bg-muted text-muted-foreground transition-colors cursor-pointer outline-none focus:ring-2 focus:ring-primary/50 p-1 rounded-md border border-border\"\r\n >\r\n {row.getIsExpanded() ? <ChevronDown className=\"w-3 h-3\" /> : <ChevronRight className=\"w-3 h-3\" />}\r\n </span>\r\n </div>\r\n ) : null,\r\n enableSorting: false,\r\n });\r\n }\r\n return cols;\r\n }, [columns, enableRowSelection, enableExpanding]);\r\n\r\n const table = useReactTable({\r\n data,\r\n columns: finalColumns,\r\n state: { sorting, rowSelection, pagination: tanstackPagination },\r\n onSortingChange: setSorting,\r\n onRowSelectionChange: setRowSelection,\r\n onPaginationChange: handlePaginationChange,\r\n getCoreRowModel: getCoreRowModel(),\r\n getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,\r\n getPaginationRowModel: paginationEnabled ? getPaginationRowModel() : undefined,\r\n getFilteredRowModel: getFilteredRowModel(),\r\n getExpandedRowModel: getExpandedRowModel(),\r\n columnResizeMode,\r\n enableColumnResizing,\r\n enableRowSelection,\r\n manualPagination: isServerMode,\r\n pageCount: isServerMode ? pageCount : undefined,\r\n getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => !!renderSubComponent,\r\n });\r\n\r\n useEffect(() => {\r\n if (onSelectionChange) {\r\n const selected = table.getSelectedRowModel().rows.map(row => row.original);\r\n onSelectionChange(selected);\r\n }\r\n }, [rowSelection, onSelectionChange, table]);\r\n\r\n const totalPageCount = isServerMode ? (pageCount ?? 1) : (table.getPageCount() || 1);\r\n\r\n // Virtualizer\r\n const allRows = virtualize ? table.getCoreRowModel().rows : [];\r\n const rowVirtualizer = useVirtualizer({\r\n count: virtualize ? allRows.length : 0,\r\n getScrollElement: () => scrollContainerRef.current,\r\n estimateSize: () => estimatedRowHeight,\r\n overscan: 8,\r\n enabled: virtualize,\r\n });\r\n\r\n return (\r\n <div className={cn(\"relative w-full rounded-md border border-border bg-background flex flex-col overflow-hidden\", className)}>\r\n {isLoading && (\r\n <div className=\"absolute inset-0 bg-white/60 z-10 flex items-center justify-center backdrop-blur-[0.5px]\">\r\n <Spinner size=\"lg\" variant=\"primary\" />\r\n </div>\r\n )}\r\n\r\n <div\r\n ref={scrollContainerRef}\r\n className=\"overflow-x-auto w-full\"\r\n style={virtualize ? { overflowY: 'auto', maxHeight: virtualHeight } : undefined}\r\n >\r\n <table\r\n className=\"w-full text-sm text-left text-foreground whitespace-nowrap\"\r\n style={{\r\n width: enableColumnResizing ? table.getCenterTotalSize() : undefined,\r\n tableLayout: enableColumnResizing ? 'fixed' : 'auto'\r\n }}\r\n >\r\n <TableHeader\r\n headerGroups={table.getHeaderGroups()}\r\n enableSorting={enableSorting}\r\n enableColumnResizing={enableColumnResizing}\r\n />\r\n <tbody>\r\n {!isLoading && data.length === 0 ? (\r\n <TableEmpty colSpan={finalColumns.length} text={resolvedEmptyText} />\r\n ) : virtualize ? (\r\n <TableVirtualRows\r\n allRows={allRows}\r\n rowVirtualizer={rowVirtualizer}\r\n enableColumnResizing={enableColumnResizing}\r\n colSpan={finalColumns.length}\r\n />\r\n ) : (\r\n <TableNormalRows\r\n rows={table.getRowModel().rows}\r\n enableColumnResizing={enableColumnResizing}\r\n renderSubComponent={renderSubComponent}\r\n />\r\n )}\r\n </tbody>\r\n </table>\r\n </div>\r\n\r\n {!virtualize && paginationEnabled && (\r\n <TablePagination\r\n table={table}\r\n totalRows={totalRows}\r\n totalPageCount={totalPageCount}\r\n isServerMode={isServerMode}\r\n cfg={cfg}\r\n labels={labels}\r\n />\r\n )}\r\n </div>\r\n );\r\n}\r\n"
812
+ },
813
+ {
814
+ "path": "src/components/ui/table/TableBody.tsx",
815
+ "content": "import React from 'react';\nimport {\n flexRender,\n type Row,\n type ColumnDef,\n type RowData,\n} from '@tanstack/react-table';\nimport { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual';\nimport { cn } from '@lib/utils/cn';\n\n// ─── Empty State ────────────────────────────────────────────────────────────\n\ninterface TableEmptyProps {\n colSpan: number;\n text: string;\n}\n\nexport function TableEmpty({ colSpan, text }: TableEmptyProps) {\n return (\n <tr>\n <td colSpan={colSpan} className=\"px-4 py-16 text-center text-muted-foreground\">\n <div className=\"flex flex-col items-center justify-center space-y-2\">\n <span className=\"text-muted-foreground/50\">\n <svg className=\"w-12 h-12\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1} d=\"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4\" />\n </svg>\n </span>\n <span>{text}</span>\n </div>\n </td>\n </tr>\n );\n}\n\n// ─── Row Cell Renderer ──────────────────────────────────────────────────────\n\ninterface TableRowCellsProps<TData extends RowData> {\n row: Row<TData>;\n enableColumnResizing: boolean;\n}\n\nfunction TableRowCells<TData extends RowData>({ row, enableColumnResizing }: TableRowCellsProps<TData>) {\n return (\n <>\n {row.getVisibleCells().map(cell => {\n const meta = cell.column.columnDef.meta;\n const align = meta?.align || 'left';\n return (\n <td\n key={cell.id}\n style={{ width: enableColumnResizing ? cell.column.getSize() : cell.column.columnDef.size }}\n className={cn(\n cell.column.id === 'select' || cell.column.id === 'expander' ? \"px-1\" : \"px-2\",\n \"py-3 border border-border align-middle\",\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\n )}\n >\n <div className={cn(\n \"flex items-center\",\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-start\"\n )}>\n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n </div>\n </td>\n );\n })}\n </>\n );\n}\n\n// ─── Normal Rows ────────────────────────────────────────────────────────────\n\ninterface TableNormalRowsProps<TData extends RowData> {\n rows: Row<TData>[];\n enableColumnResizing: boolean;\n renderSubComponent?: (props: { row: TData }) => React.ReactNode;\n}\n\nexport function TableNormalRows<TData extends RowData>({\n rows,\n enableColumnResizing,\n renderSubComponent,\n}: TableNormalRowsProps<TData>) {\n return (\n <>\n {rows.map(row => (\n <React.Fragment key={row.id}>\n <tr\n className={cn(\n \"hover:bg-muted/50 transition-colors group\",\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\",\n row.getIsExpanded() ? \"bg-primary/5\" : \"\"\n )}\n >\n <TableRowCells row={row} enableColumnResizing={enableColumnResizing} />\n </tr>\n {row.getIsExpanded() && renderSubComponent && (\n <tr>\n <td colSpan={row.getVisibleCells().length} className=\"p-0 border-b border-border whitespace-normal\">\n <div className=\"bg-muted/50 px-4 py-5 shadow-inner w-full border-l-4 border-l-primary/40 wrap-break-word\">\n {renderSubComponent({ row: row.original })}\n </div>\n </td>\n </tr>\n )}\n </React.Fragment>\n ))}\n </>\n );\n}\n\n// ─── Virtual Rows ───────────────────────────────────────────────────────────\n\ninterface TableVirtualRowsProps<TData extends RowData> {\n allRows: Row<TData>[];\n rowVirtualizer: Virtualizer<HTMLDivElement, Element>;\n enableColumnResizing: boolean;\n colSpan: number;\n}\n\nexport function TableVirtualRows<TData extends RowData>({\n allRows,\n rowVirtualizer,\n enableColumnResizing,\n colSpan,\n}: TableVirtualRowsProps<TData>) {\n const virtualItems = rowVirtualizer.getVirtualItems();\n\n return (\n <>\n {rowVirtualizer.getTotalSize() > 0 && (\n <tr aria-hidden=\"true\">\n <td style={{ height: virtualItems[0]?.start ?? 0, padding: 0, border: 0 }} colSpan={colSpan} />\n </tr>\n )}\n {virtualItems.map(virtualRow => {\n const row = allRows[virtualRow.index];\n if (!row) return null;\n return (\n <tr\n key={row.id}\n data-index={virtualRow.index}\n ref={rowVirtualizer.measureElement}\n className={cn(\n \"hover:bg-muted/50 transition-colors\",\n row.getIsSelected() ? \"bg-primary/5 hover:bg-primary/10\" : \"\"\n )}\n >\n <TableRowCells row={row} enableColumnResizing={enableColumnResizing} />\n </tr>\n );\n })}\n {rowVirtualizer.getTotalSize() > 0 && (\n <tr aria-hidden=\"true\">\n <td\n style={{\n height: rowVirtualizer.getTotalSize() - (virtualItems[virtualItems.length - 1]?.end ?? 0),\n padding: 0,\n border: 0,\n }}\n colSpan={colSpan}\n />\n </tr>\n )}\n </>\n );\n}\n"
816
+ },
817
+ {
818
+ "path": "src/components/ui/table/TableHeader.tsx",
819
+ "content": "import React from 'react';\nimport {\n flexRender,\n type HeaderGroup,\n type RowData,\n} from '@tanstack/react-table';\nimport { cn } from '@lib/utils/cn';\nimport { ChevronDown, ChevronUp } from 'lucide-react';\n\nexport interface TableHeaderProps<TData extends RowData> {\n headerGroups: HeaderGroup<TData>[];\n enableSorting: boolean;\n enableColumnResizing: boolean;\n}\n\nexport function TableHeader<TData extends RowData>({\n headerGroups,\n enableSorting,\n enableColumnResizing,\n}: TableHeaderProps<TData>) {\n return (\n <thead className=\"text-xs text-muted-foreground bg-muted/50 border-b border-border\">\n {headerGroups.map(headerGroup => (\n <tr key={headerGroup.id}>\n {headerGroup.headers.map(header => {\n const meta = header.column.columnDef.meta;\n const align = meta?.align || 'left';\n const canSort = header.column.getCanSort() && enableSorting && header.column.id !== 'select';\n\n return (\n <th\n key={header.id}\n colSpan={header.colSpan}\n style={{\n width: enableColumnResizing ? header.getSize() : header.column.columnDef.size,\n position: 'relative'\n }}\n aria-sort={\n canSort\n ? header.column.getIsSorted() === 'desc'\n ? 'descending'\n : header.column.getIsSorted() === 'asc'\n ? 'ascending'\n : 'none'\n : undefined\n }\n className={cn(\n header.column.id === 'select' || header.column.id === 'expander' ? \"px-1\" : \"px-2\",\n \"py-3 font-semibold tracking-wide border border-border transition-colors group/header\",\n canSort ? \"cursor-pointer select-none hover:bg-muted\" : \"\",\n align === 'center' ? \"text-center\" : align === 'right' ? \"text-right\" : \"text-left\"\n )}\n >\n <div\n className={cn(\"flex flex-col h-full\")}\n onClick={canSort ? header.column.getToggleSortingHandler() : undefined}\n >\n {header.isPlaceholder ? null : (\n <div className={cn(\n \"flex items-center gap-2\",\n align === 'center' ? \"justify-center\" : align === 'right' ? \"justify-end\" : \"justify-between\"\n )}>\n <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>\n {canSort && (\n <span className=\"shrink-0\">\n {{\n asc: <ChevronUp className=\"w-4 h-4 text-primary\" />,\n desc: <ChevronDown className=\"w-4 h-4 text-primary\" />,\n }[header.column.getIsSorted() as string] ?? (\n <div className=\"flex flex-col opacity-30 -space-y-1 hover:opacity-100 transition-opacity\">\n <ChevronUp className=\"w-3 h-3\" />\n <ChevronDown className=\"w-3 h-3\" />\n </div>\n )}\n </span>\n )}\n </div>\n )}\n </div>\n\n {/* Resize Handle */}\n {enableColumnResizing && header.column.getCanResize() && (\n <div\n {...{\n onMouseDown: header.getResizeHandler(),\n onTouchStart: header.getResizeHandler(),\n className: cn(\n \"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none hover:bg-primary/50 transition-colors\",\n header.column.getIsResizing() ? \"bg-primary w-1.5 z-10\" : \"bg-transparent\"\n ),\n }}\n />\n )}\n </th>\n );\n })}\n </tr>\n ))}\n </thead>\n );\n}\n"
820
+ },
821
+ {
822
+ "path": "src/components/ui/table/TablePagination.tsx",
823
+ "content": "import React, { useState, useEffect } from 'react';\nimport { type Table as TanstackTable, type RowData } from '@tanstack/react-table';\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';\nimport type { PaginationConfig, TableLabels } from './Table';\n\ninterface TablePaginationProps<TData extends RowData> {\n table: TanstackTable<TData>;\n totalRows: number;\n totalPageCount: number;\n isServerMode: boolean;\n cfg: PaginationConfig;\n labels?: TableLabels;\n}\n\nexport function TablePagination<TData extends RowData>({\n table,\n totalRows,\n totalPageCount,\n isServerMode,\n cfg,\n labels,\n}: TablePaginationProps<TData>) {\n const currentPageIndex = table.getState().pagination.pageIndex;\n const currentPageSize = table.getState().pagination.pageSize;\n const from = currentPageIndex * currentPageSize + 1;\n const to = Math.min((currentPageIndex + 1) * currentPageSize, totalRows);\n const pageSizeOptions = cfg.pageSizeOptions ?? [5, 10, 20, 50, 100];\n\n const [inputValue, setInputValue] = useState<string | number>(currentPageIndex + 1);\n\n useEffect(() => {\n setInputValue(currentPageIndex + 1);\n }, [currentPageIndex]);\n\n if (totalPageCount <= 0) return null;\n\n return (\n <div className=\"flex flex-col sm:flex-row items-center justify-between px-3 py-2.5 border-t border-border bg-muted/50 gap-2\">\n {/* showTotal info */}\n <div className=\"text-xs text-muted-foreground shrink-0 order-2 sm:order-1\">\n {cfg.showTotal\n ? cfg.showTotal(totalRows, [from, to])\n : <>Show <b>{from}</b>–<b>{to}</b> of <b>{totalRows}</b> results</>\n }\n </div>\n\n {/* Controls */}\n <div className=\"flex items-center gap-2 overflow-x-auto order-1 sm:order-2 w-full sm:w-auto pb-0.5 sm:pb-0\">\n {/* Page size */}\n {(cfg.showSizeChanger !== false) && (\n <select\n value={currentPageSize}\n onChange={e => table.setPageSize(Number(e.target.value))}\n className=\"shrink-0 px-2 py-1 text-xs border border-border rounded-md bg-background text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary cursor-pointer\"\n >\n {pageSizeOptions.map(s => (\n <option key={s} value={s}>{s}{labels?.perPage ?? ' / page'}</option>\n ))}\n </select>\n )}\n\n {/* Nav buttons */}\n <div className=\"flex items-center gap-1 shrink-0\">\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.setPageIndex(0)}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"First page\"\n >\n <ChevronsLeft className=\"w-3.5 h-3.5\" />\n </button>\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"Previous page\"\n >\n <ChevronLeft className=\"w-3.5 h-3.5\" />\n </button>\n\n <span className=\"text-xs font-medium px-2.5 py-1 bg-background border border-border rounded shrink-0 min-w-14 text-center text-foreground\">\n {currentPageIndex + 1} / {totalPageCount}\n </span>\n\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n aria-label=\"Next page\"\n >\n <ChevronRight className=\"w-3.5 h-3.5\" />\n </button>\n <button\n className=\"p-1 rounded border border-border text-muted-foreground bg-background hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors\"\n onClick={() => table.setPageIndex(totalPageCount - 1)}\n disabled={!table.getCanNextPage()}\n aria-label=\"Last page\"\n >\n <ChevronsRight className=\"w-3.5 h-3.5\" />\n </button>\n </div>\n\n {/* Go to page */}\n <div className=\"flex items-center gap-1.5 text-xs text-muted-foreground border-l border-border pl-2 ml-1 shrink-0\">\n <span className=\"hidden sm:inline\">{labels?.page ?? 'Page'}</span>\n <input\n type=\"number\"\n min={1}\n max={totalPageCount}\n value={inputValue}\n onChange={e => {\n const val = e.target.value;\n setInputValue(val);\n const p = val ? Number(val) - 1 : 0;\n if (val && p >= 0 && p < totalPageCount) {\n table.setPageIndex(p);\n }\n }}\n onBlur={() => setInputValue(currentPageIndex + 1)}\n className=\"w-10 px-1 py-1 text-xs border border-border rounded bg-background text-foreground text-center outline-none focus:border-primary focus:ring-1 focus:ring-primary\"\n />\n </div>\n </div>\n </div>\n );\n}\n"
733
824
  }
734
825
  ]
735
826
  },
@@ -768,7 +859,20 @@
768
859
  "files": [
769
860
  {
770
861
  "path": "src/components/ui/textarea/Textarea.tsx",
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"
862
+ "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, ...props }, ref) => {\r\n return (\r\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <BaseField.Label 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 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"
863
+ }
864
+ ]
865
+ },
866
+ "timeline": {
867
+ "name": "timeline",
868
+ "dependencies": [
869
+ "tailwind-variants"
870
+ ],
871
+ "internalDependencies": [],
872
+ "files": [
873
+ {
874
+ "path": "src/components/ui/timeline/Timeline.tsx",
875
+ "content": "import * as React from 'react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst timelineVariants = tv({\n slots: {\n root: 'relative flex flex-col',\n item: 'relative flex gap-4 pb-8 last:pb-0',\n indicator: [\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full',\n 'border-2 border-background bg-muted ring-2 ring-background',\n ].join(' '),\n connector: 'absolute left-0 top-0 bottom-0 flex justify-center',\n connectorLine: 'w-px bg-border',\n content: 'flex-1 pt-0.5',\n title: 'text-sm font-semibold text-foreground leading-none',\n description: 'mt-1 text-sm text-muted-foreground leading-relaxed',\n time: 'mt-1.5 text-xs text-muted-foreground/70',\n },\n variants: {\n size: {\n sm: {\n indicator: 'h-6 w-6 [&_svg]:h-3 [&_svg]:w-3',\n connector: 'w-6',\n },\n md: {\n indicator: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4',\n connector: 'w-8',\n },\n lg: {\n indicator: 'h-10 w-10 [&_svg]:h-5 [&_svg]:w-5',\n connector: 'w-10',\n },\n },\n variant: {\n default: { indicator: 'bg-muted text-muted-foreground' },\n primary: { indicator: 'bg-primary/15 text-primary border-primary/30' },\n success: { indicator: 'bg-success/15 text-success border-success/30' },\n warning: { indicator: 'bg-warning/15 text-warning border-warning/30' },\n danger: { indicator: 'bg-danger/15 text-danger border-danger/30' },\n },\n },\n defaultVariants: {\n size: 'md',\n variant: 'default',\n },\n});\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface TimelineItemData {\n title: string;\n description?: string;\n time?: string;\n icon?: React.ReactNode;\n variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger';\n}\n\nexport interface TimelineProps extends VariantProps<typeof timelineVariants> {\n items: TimelineItemData[];\n className?: string;\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nconst Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(\n ({ items, size = 'md', variant: defaultVariant = 'default', className }, ref) => {\n const styles = timelineVariants({ size });\n\n return (\n <div ref={ref} className={cn(styles.root(), className)} role=\"list\">\n {items.map((item, index) => {\n const itemVariant = item.variant ?? defaultVariant;\n const itemStyles = timelineVariants({ size, variant: itemVariant });\n const isLast = index === items.length - 1;\n\n return (\n <div key={index} className={styles.item()} role=\"listitem\">\n {/* Connector line */}\n {!isLast && (\n <div className={styles.connector()}>\n <div className={cn(styles.connectorLine(), 'mt-8')} />\n </div>\n )}\n\n {/* Indicator dot */}\n <div className={itemStyles.indicator()}>\n {item.icon}\n </div>\n\n {/* Content */}\n <div className={styles.content()}>\n <p className={styles.title()}>{item.title}</p>\n {item.description && (\n <p className={styles.description()}>{item.description}</p>\n )}\n {item.time && (\n <p className={styles.time()}>{item.time}</p>\n )}\n </div>\n </div>\n );\n })}\n </div>\n );\n },\n);\n\nTimeline.displayName = 'Timeline';\n\nexport { Timeline, timelineVariants };\n"
772
876
  }
773
877
  ]
774
878
  },
@@ -812,6 +916,21 @@
812
916
  }
813
917
  ]
814
918
  },
919
+ "tree-view": {
920
+ "name": "tree-view",
921
+ "dependencies": [
922
+ "@base-ui/react",
923
+ "tailwind-variants",
924
+ "lucide-react"
925
+ ],
926
+ "internalDependencies": [],
927
+ "files": [
928
+ {
929
+ "path": "src/components/ui/tree-view/TreeView.tsx",
930
+ "content": "import * as React from 'react';\nimport { Collapsible } from '@base-ui/react';\nimport { tv, type VariantProps } from 'tailwind-variants';\nimport { cn } from '@/lib/utils/cn';\nimport { ChevronRight, FileIcon, FolderIcon, FolderOpen } from 'lucide-react';\n\n// ─── Variants ────────────────────────────────────────────────────────────────\n\nconst treeViewVariants = tv({\n slots: {\n root: 'flex flex-col text-sm',\n item: [\n 'flex items-center gap-1.5 rounded-md px-2 py-1.5 cursor-pointer',\n 'transition-colors hover:bg-muted/50',\n 'outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',\n ].join(' '),\n itemActive: 'bg-primary/10 text-primary hover:bg-primary/15',\n chevron: 'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',\n chevronOpen: 'rotate-90',\n icon: 'h-4 w-4 shrink-0 text-muted-foreground',\n label: 'truncate select-none',\n children: 'pl-4',\n },\n});\n\nconst styles = treeViewVariants();\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface TreeNode {\n id: string;\n label: string;\n icon?: React.ReactNode;\n children?: TreeNode[];\n disabled?: boolean;\n}\n\nexport interface TreeViewProps {\n data: TreeNode[];\n /** Currently selected node id */\n selectedId?: string;\n /** Called when a node is selected */\n onSelect?: (id: string) => void;\n /** Default expanded node ids */\n defaultExpanded?: string[];\n className?: string;\n}\n\n// ─── TreeItem (recursive) ──────────────────────────────────────���─────────────\n\ninterface TreeItemProps {\n node: TreeNode;\n level: number;\n selectedId?: string;\n onSelect?: (id: string) => void;\n expandedSet: Set<string>;\n toggleExpanded: (id: string) => void;\n}\n\nfunction TreeItem({ node, level, selectedId, onSelect, expandedSet, toggleExpanded }: TreeItemProps) {\n const hasChildren = node.children && node.children.length > 0;\n const isExpanded = expandedSet.has(node.id);\n const isSelected = selectedId === node.id;\n\n const handleClick = () => {\n if (node.disabled) return;\n if (hasChildren) {\n toggleExpanded(node.id);\n }\n onSelect?.(node.id);\n };\n\n const handleKeyDown = (e: React.KeyboardEvent) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n handleClick();\n }\n if (e.key === 'ArrowRight' && hasChildren && !isExpanded) {\n e.preventDefault();\n toggleExpanded(node.id);\n }\n if (e.key === 'ArrowLeft' && hasChildren && isExpanded) {\n e.preventDefault();\n toggleExpanded(node.id);\n }\n };\n\n const defaultIcon = hasChildren\n ? (isExpanded ? <FolderOpen className={styles.icon()} /> : <FolderIcon className={styles.icon()} />)\n : <FileIcon className={styles.icon()} />;\n\n return (\n <div role=\"treeitem\" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={isSelected}>\n <div\n className={cn(\n styles.item(),\n isSelected && styles.itemActive(),\n node.disabled && 'opacity-50 pointer-events-none',\n )}\n style={{ paddingLeft: `${level * 16 + 8}px` }}\n tabIndex={node.disabled ? undefined : 0}\n onClick={handleClick}\n onKeyDown={handleKeyDown}\n >\n {hasChildren ? (\n <ChevronRight className={cn(styles.chevron(), isExpanded && styles.chevronOpen())} />\n ) : (\n <span className=\"w-4 shrink-0\" />\n )}\n\n {node.icon ?? defaultIcon}\n <span className={styles.label()}>{node.label}</span>\n </div>\n\n {hasChildren && isExpanded && (\n <div role=\"group\">\n {node.children!.map((child) => (\n <TreeItem\n key={child.id}\n node={child}\n level={level + 1}\n selectedId={selectedId}\n onSelect={onSelect}\n expandedSet={expandedSet}\n toggleExpanded={toggleExpanded}\n />\n ))}\n </div>\n )}\n </div>\n );\n}\n\n// ─── TreeView ────────────────────────────────────────────────────────────────\n\nconst TreeView = React.forwardRef<HTMLDivElement, TreeViewProps>(\n ({ data, selectedId, onSelect, defaultExpanded = [], className }, ref) => {\n const [expandedSet, setExpandedSet] = React.useState<Set<string>>(\n () => new Set(defaultExpanded),\n );\n\n const toggleExpanded = React.useCallback((id: string) => {\n setExpandedSet((prev) => {\n const next = new Set(prev);\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n return next;\n });\n }, []);\n\n return (\n <div\n ref={ref}\n role=\"tree\"\n aria-label=\"Tree view\"\n className={cn(styles.root(), className)}\n >\n {data.map((node) => (\n <TreeItem\n key={node.id}\n node={node}\n level={0}\n selectedId={selectedId}\n onSelect={onSelect}\n expandedSet={expandedSet}\n toggleExpanded={toggleExpanded}\n />\n ))}\n </div>\n );\n },\n);\n\nTreeView.displayName = 'TreeView';\n\nexport { TreeView, treeViewVariants };\n"
931
+ }
932
+ ]
933
+ },
815
934
  "typography": {
816
935
  "name": "typography",
817
936
  "dependencies": [
@@ -822,7 +941,7 @@
822
941
  "files": [
823
942
  {
824
943
  "path": "src/components/ui/typography/Typography.tsx",
825
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Copy, Check, ExternalLink } from 'lucide-react';\r\n\r\n// ─── Shared text style variants ───────────────────────────────────────────────\r\n\r\nconst textVariants = tv({\r\n base: '',\r\n variants: {\r\n size: {\r\n xs: 'text-xs',\r\n sm: 'text-sm',\r\n md: 'text-base',\r\n lg: 'text-lg',\r\n xl: 'text-xl',\r\n '2xl': 'text-2xl',\r\n '3xl': 'text-3xl',\r\n },\r\n weight: {\r\n thin: 'font-thin',\r\n light: 'font-light',\r\n normal: 'font-normal',\r\n medium: 'font-medium',\r\n semibold: 'font-semibold',\r\n bold: 'font-bold',\r\n extrabold: 'font-extrabold',\r\n },\r\n color: {\r\n default: 'text-foreground',\r\n muted: 'text-muted-foreground',\r\n primary: 'text-primary',\r\n success: 'text-success',\r\n warning: 'text-warning',\r\n danger: 'text-danger',\r\n inherit: 'text-inherit',\r\n },\r\n align: {\r\n left: 'text-left',\r\n center: 'text-center',\r\n right: 'text-right',\r\n justify: 'text-justify',\r\n },\r\n leading: {\r\n none: 'leading-none',\r\n tight: 'leading-tight',\r\n normal: 'leading-normal',\r\n relaxed: 'leading-relaxed',\r\n loose: 'leading-loose',\r\n },\r\n tracking: {\r\n tighter: 'tracking-tighter',\r\n tight: 'tracking-tight',\r\n normal: 'tracking-normal',\r\n wide: 'tracking-wide',\r\n widest: 'tracking-widest',\r\n },\r\n },\r\n defaultVariants: {\r\n color: 'default',\r\n },\r\n});\r\n\r\n// ─── useCopy hook ─────────────────────────────────────────────────────────────\r\n\r\nfunction useCopy() {\r\n const [copied, setCopied] = React.useState(false);\r\n const copy = React.useCallback(async (text: string) => {\r\n try {\r\n await navigator.clipboard.writeText(text);\r\n setCopied(true);\r\n setTimeout(() => setCopied(false), 2000);\r\n } catch { /* noop */ }\r\n }, []);\r\n return { copied, copy };\r\n}\r\n\r\n// ─── Text ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface TextProps\r\n extends Omit<React.HTMLAttributes<HTMLElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** HTML tag to render — default `span` */\r\n as?: React.ElementType;\r\n /** Bold */\r\n strong?: boolean;\r\n /** Italic */\r\n italic?: boolean;\r\n /** Underline */\r\n underline?: boolean;\r\n /** Strikethrough */\r\n strikethrough?: boolean;\r\n /** Gradient text (primary → indigo) */\r\n gradient?: boolean;\r\n /** Highlighted mark background */\r\n mark?: boolean;\r\n /** Single-line truncate with ellipsis */\r\n truncate?: boolean;\r\n /** Multi-line clamp (number of lines) */\r\n lines?: 1 | 2 | 3 | 4 | 5;\r\n /** Tabular numbers — fixed-width digits */\r\n numeric?: boolean;\r\n /** Inline code styling */\r\n code?: boolean;\r\n /** Show copy icon on hover, copies text content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst LINES_MAP: Record<number, string> = {\r\n 1: 'line-clamp-1',\r\n 2: 'line-clamp-2',\r\n 3: 'line-clamp-3',\r\n 4: 'line-clamp-4',\r\n 5: 'line-clamp-5',\r\n};\r\n\r\nconst Text = React.forwardRef<HTMLElement, TextProps>(\r\n (\r\n {\r\n as: Tag = 'span',\r\n size,\r\n weight,\r\n color,\r\n align,\r\n leading,\r\n tracking,\r\n strong,\r\n italic,\r\n underline,\r\n strikethrough,\r\n gradient,\r\n mark,\r\n truncate,\r\n lines,\r\n numeric,\r\n code,\r\n copyable,\r\n className,\r\n children,\r\n onClick,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n const handleClick = (e: React.MouseEvent<HTMLElement>) => {\r\n if (copyable && elRef.current) {\r\n copy(elRef.current.innerText);\r\n }\r\n onClick?.(e);\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading, tracking }),\r\n strong && 'font-bold',\r\n italic && 'italic',\r\n underline && 'underline underline-offset-2',\r\n strikethrough && 'line-through',\r\n gradient && 'bg-gradient-to-r from-primary to-indigo-500 bg-clip-text text-transparent',\r\n mark && 'bg-warning/20 text-warning-foreground rounded px-0.5',\r\n truncate && 'block max-w-full truncate',\r\n lines && cn('block', LINES_MAP[lines]),\r\n numeric && 'tabular-nums',\r\n code && 'font-mono text-[0.9em] bg-muted rounded px-1 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-1.5',\r\n className,\r\n )}\r\n onClick={handleClick}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-60 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-3.5 h-3.5 text-success\" />\r\n : <Copy className=\"w-3.5 h-3.5\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nText.displayName = 'Text';\r\n\r\n// ─── Heading ──────────────────────────────────────────────────────────────────\r\n\r\nconst HEADING_SIZE: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {\r\n 1: 'text-4xl font-extrabold tracking-tight',\r\n 2: 'text-3xl font-bold tracking-tight',\r\n 3: 'text-2xl font-semibold tracking-tight',\r\n 4: 'text-xl font-semibold',\r\n 5: 'text-lg font-medium',\r\n 6: 'text-base font-medium',\r\n};\r\n\r\nexport interface HeadingProps\r\n extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Heading level 1–6, also sets default size (default: 2) */\r\n level?: 1 | 2 | 3 | 4 | 5 | 6;\r\n /** Show copy icon on hover */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(\r\n ({ level = 2, size, weight, color = 'default', align, className, copyable, children, ...props }, ref) => {\r\n const Tag = `h${level}` as React.ElementType;\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLHeadingElement>(null);\r\n const mergedRef = (node: HTMLHeadingElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n HEADING_SIZE[level],\r\n textVariants({ size, weight, color, align }),\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-2',\r\n className,\r\n )}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-50 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-4 h-4 text-success\" />\r\n : <Copy className=\"w-4 h-4\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nHeading.displayName = 'Heading';\r\n\r\n// ─── Paragraph ────────────────────────────────────────────────────────────────\r\n\r\nexport interface ParagraphProps\r\n extends Omit<React.HTMLAttributes<HTMLParagraphElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Larger intro-text styling */\r\n lead?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Paragraph = React.forwardRef<HTMLParagraphElement, ParagraphProps>(\r\n ({ lead, size, weight, color = 'default', align, leading = 'relaxed', className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading }),\r\n lead && 'text-xl text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nParagraph.displayName = 'Paragraph';\r\n\r\n// ─── Lead ────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LeadProps extends React.HTMLAttributes<HTMLParagraphElement> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Lead = React.forwardRef<HTMLParagraphElement, LeadProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-xl text-muted-foreground leading-relaxed', className)}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nLead.displayName = 'Lead';\r\n\r\n// ─── Blockquote ───────────────────────────────────────────────────────────────\r\n\r\nexport interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement> {\r\n /** Citation source text shown below the quote */\r\n cite?: string;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Blockquote = React.forwardRef<HTMLQuoteElement, BlockquoteProps>(\r\n ({ cite, className, children, ...props }, ref) => (\r\n <figure className=\"my-1\">\r\n <blockquote\r\n ref={ref}\r\n className={cn(\r\n 'border-l-4 border-primary pl-4 py-1 italic text-muted-foreground leading-relaxed',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </blockquote>\r\n {cite && (\r\n <figcaption className=\"mt-2 pl-4 text-sm text-muted-foreground/70 not-italic\">\r\n — {cite}\r\n </figcaption>\r\n )}\r\n </figure>\r\n )\r\n);\r\nBlockquote.displayName = 'Blockquote';\r\n\r\n// ─── Code (inline) ────────────────────────────────────────────────────────────\r\n\r\nexport interface CodeProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Copy content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Code = React.forwardRef<HTMLElement, CodeProps>(\r\n ({ copyable, className, children, ...props }, ref) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <code\r\n ref={mergedRef}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n className={cn(\r\n 'font-mono text-[0.875em] bg-muted text-foreground rounded px-1.5 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer hover:bg-muted/70 transition-colors group/code inline-flex items-center gap-1',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/code:opacity-60 transition-opacity\">\r\n {copied\r\n ? <Check className=\"w-3 h-3 text-success inline\" />\r\n : <Copy className=\"w-3 h-3 inline\" />\r\n }\r\n </span>\r\n )}\r\n </code>\r\n );\r\n }\r\n);\r\nCode.displayName = 'Code';\r\n\r\n// ─── Kbd ──────────────────────────────────────────────────────────────────────\r\n\r\nexport interface KbdProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Array of keys to display; joined with `+` separator */\r\n keys?: string[];\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst KbdKey = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <kbd\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={cn(\r\n 'inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5',\r\n 'font-mono text-xs font-medium',\r\n 'bg-background border border-border rounded shadow-[0_2px_0_0_hsl(var(--border))]',\r\n 'text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </kbd>\r\n )\r\n);\r\nKbdKey.displayName = 'KbdKey';\r\n\r\nconst Kbd = React.forwardRef<HTMLSpanElement, KbdProps>(\r\n ({ keys, className, children, ...props }, ref) => {\r\n const items = keys ?? (children ? [children] : []);\r\n\r\n return (\r\n <span ref={ref} className={cn('inline-flex items-center gap-0.5', className)} {...props}>\r\n {items.map((key, i) => (\r\n <React.Fragment key={i}>\r\n {i > 0 && <span className=\"text-[10px] text-muted-foreground/60 px-0.5\">+</span>}\r\n <KbdKey>{key}</KbdKey>\r\n </React.Fragment>\r\n ))}\r\n </span>\r\n );\r\n }\r\n);\r\nKbd.displayName = 'Kbd';\r\n\r\n// ─── Link ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\r\n /** Open in new tab with rel=\"noopener noreferrer\" */\r\n external?: boolean;\r\n /** Underline behaviour — default `hover` */\r\n underline?: 'always' | 'hover' | 'none';\r\n /** Color variant */\r\n color?: 'primary' | 'muted' | 'danger' | 'foreground';\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Link = React.forwardRef<HTMLAnchorElement, LinkProps>(\r\n ({ external, underline = 'hover', color = 'primary', className, children, ...props }, ref) => (\r\n <a\r\n ref={ref}\r\n target={external ? '_blank' : props.target}\r\n rel={external ? 'noopener noreferrer' : props.rel}\r\n className={cn(\r\n 'inline-flex items-center gap-0.5 transition-colors',\r\n color === 'primary' && 'text-primary',\r\n color === 'muted' && 'text-muted-foreground',\r\n color === 'danger' && 'text-danger',\r\n color === 'foreground' && 'text-foreground',\r\n underline === 'always' && 'underline underline-offset-2',\r\n underline === 'hover' && 'hover:underline underline-offset-2',\r\n underline === 'none' && 'no-underline',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {external && <ExternalLink className=\"w-3 h-3 shrink-0 opacity-70\" />}\r\n </a>\r\n )\r\n);\r\nLink.displayName = 'Link';\r\n\r\n// ─── Mark ─────────────────────────────────────────────────────────────────────\r\n\r\nconst markVariants = tv({\r\n base: 'rounded px-0.5 py-px font-medium',\r\n variants: {\r\n variant: {\r\n default: 'bg-warning/25 text-warning-foreground',\r\n primary: 'bg-primary/15 text-primary',\r\n success: 'bg-success/15 text-success',\r\n warning: 'bg-warning/25 text-warning-foreground',\r\n danger: 'bg-danger/15 text-danger',\r\n },\r\n },\r\n defaultVariants: { variant: 'default' },\r\n});\r\n\r\nexport interface MarkProps\r\n extends React.HTMLAttributes<HTMLElement>,\r\n VariantProps<typeof markVariants> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Mark = React.forwardRef<HTMLElement, MarkProps>(\r\n ({ variant, className, children, ...props }, ref) => (\r\n <mark\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={markVariants({ variant, className })}\r\n {...props}\r\n >\r\n {children}\r\n </mark>\r\n )\r\n);\r\nMark.displayName = 'Mark';\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Text,\r\n Heading,\r\n Paragraph,\r\n Lead,\r\n Blockquote,\r\n Code,\r\n Kbd,\r\n KbdKey,\r\n Link,\r\n Mark,\r\n textVariants,\r\n markVariants,\r\n};\r\n"
944
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Copy, Check, ExternalLink } from 'lucide-react';\r\nimport { useCopy } from '@/hooks/useCopy';\r\n\r\n// ─── Shared text style variants ───────────────────────────────────────────────\r\n\r\nconst textVariants = tv({\r\n base: '',\r\n variants: {\r\n size: {\r\n xs: 'text-xs',\r\n sm: 'text-sm',\r\n md: 'text-base',\r\n lg: 'text-lg',\r\n xl: 'text-xl',\r\n '2xl': 'text-2xl',\r\n '3xl': 'text-3xl',\r\n },\r\n weight: {\r\n thin: 'font-thin',\r\n light: 'font-light',\r\n normal: 'font-normal',\r\n medium: 'font-medium',\r\n semibold: 'font-semibold',\r\n bold: 'font-bold',\r\n extrabold: 'font-extrabold',\r\n },\r\n color: {\r\n default: 'text-foreground',\r\n muted: 'text-muted-foreground',\r\n primary: 'text-primary',\r\n success: 'text-success',\r\n warning: 'text-warning',\r\n danger: 'text-danger',\r\n inherit: 'text-inherit',\r\n },\r\n align: {\r\n left: 'text-left',\r\n center: 'text-center',\r\n right: 'text-right',\r\n justify: 'text-justify',\r\n },\r\n leading: {\r\n none: 'leading-none',\r\n tight: 'leading-tight',\r\n normal: 'leading-normal',\r\n relaxed: 'leading-relaxed',\r\n loose: 'leading-loose',\r\n },\r\n tracking: {\r\n tighter: 'tracking-tighter',\r\n tight: 'tracking-tight',\r\n normal: 'tracking-normal',\r\n wide: 'tracking-wide',\r\n widest: 'tracking-widest',\r\n },\r\n },\r\n defaultVariants: {\r\n color: 'default',\r\n },\r\n});\r\n\r\n// ─── Text ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface TextProps\r\n extends Omit<React.HTMLAttributes<HTMLElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** HTML tag to render — default `span` */\r\n as?: React.ElementType;\r\n /** Bold */\r\n strong?: boolean;\r\n /** Italic */\r\n italic?: boolean;\r\n /** Underline */\r\n underline?: boolean;\r\n /** Strikethrough */\r\n strikethrough?: boolean;\r\n /** Gradient text (primary → indigo) */\r\n gradient?: boolean;\r\n /** Highlighted mark background */\r\n mark?: boolean;\r\n /** Single-line truncate with ellipsis */\r\n truncate?: boolean;\r\n /** Multi-line clamp (number of lines) */\r\n lines?: 1 | 2 | 3 | 4 | 5;\r\n /** Tabular numbers — fixed-width digits */\r\n numeric?: boolean;\r\n /** Inline code styling */\r\n code?: boolean;\r\n /** Show copy icon on hover, copies text content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst LINES_MAP: Record<number, string> = {\r\n 1: 'line-clamp-1',\r\n 2: 'line-clamp-2',\r\n 3: 'line-clamp-3',\r\n 4: 'line-clamp-4',\r\n 5: 'line-clamp-5',\r\n};\r\n\r\nconst Text = React.forwardRef<HTMLElement, TextProps>(\r\n (\r\n {\r\n as: Tag = 'span',\r\n size,\r\n weight,\r\n color,\r\n align,\r\n leading,\r\n tracking,\r\n strong,\r\n italic,\r\n underline,\r\n strikethrough,\r\n gradient,\r\n mark,\r\n truncate,\r\n lines,\r\n numeric,\r\n code,\r\n copyable,\r\n className,\r\n children,\r\n onClick,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n const handleClick = (e: React.MouseEvent<HTMLElement>) => {\r\n if (copyable && elRef.current) {\r\n copy(elRef.current.innerText);\r\n }\r\n onClick?.(e);\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading, tracking }),\r\n strong && 'font-bold',\r\n italic && 'italic',\r\n underline && 'underline underline-offset-2',\r\n strikethrough && 'line-through',\r\n gradient && 'bg-gradient-to-r from-primary to-indigo-500 bg-clip-text text-transparent',\r\n mark && 'bg-warning/20 text-warning-foreground rounded px-0.5',\r\n truncate && 'block max-w-full truncate',\r\n lines && cn('block', LINES_MAP[lines]),\r\n numeric && 'tabular-nums',\r\n code && 'font-mono text-[0.9em] bg-muted rounded px-1 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-1.5',\r\n className,\r\n )}\r\n onClick={handleClick}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-60 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-3.5 h-3.5 text-success\" />\r\n : <Copy className=\"w-3.5 h-3.5\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nText.displayName = 'Text';\r\n\r\n// ─── Heading ──────────────────────────────────────────────────────────────────\r\n\r\nconst HEADING_SIZE: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {\r\n 1: 'text-4xl font-extrabold tracking-tight',\r\n 2: 'text-3xl font-bold tracking-tight',\r\n 3: 'text-2xl font-semibold tracking-tight',\r\n 4: 'text-xl font-semibold',\r\n 5: 'text-lg font-medium',\r\n 6: 'text-base font-medium',\r\n};\r\n\r\nexport interface HeadingProps\r\n extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Heading level 1–6, also sets default size (default: 2) */\r\n level?: 1 | 2 | 3 | 4 | 5 | 6;\r\n /** Show copy icon on hover */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(\r\n ({ level = 2, size, weight, color = 'default', align, className, copyable, children, ...props }, ref) => {\r\n const Tag = `h${level}` as React.ElementType;\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLHeadingElement>(null);\r\n const mergedRef = (node: HTMLHeadingElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n HEADING_SIZE[level],\r\n textVariants({ size, weight, color, align }),\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-2',\r\n className,\r\n )}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-50 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-4 h-4 text-success\" />\r\n : <Copy className=\"w-4 h-4\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nHeading.displayName = 'Heading';\r\n\r\n// ─── Paragraph ────────────────────────────────────────────────────────────────\r\n\r\nexport interface ParagraphProps\r\n extends Omit<React.HTMLAttributes<HTMLParagraphElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Larger intro-text styling */\r\n lead?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Paragraph = React.forwardRef<HTMLParagraphElement, ParagraphProps>(\r\n ({ lead, size, weight, color = 'default', align, leading = 'relaxed', className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading }),\r\n lead && 'text-xl text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nParagraph.displayName = 'Paragraph';\r\n\r\n// ─── Lead ────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LeadProps extends React.HTMLAttributes<HTMLParagraphElement> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Lead = React.forwardRef<HTMLParagraphElement, LeadProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-xl text-muted-foreground leading-relaxed', className)}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nLead.displayName = 'Lead';\r\n\r\n// ─── Blockquote ───────────────────────────────────────────────────────────────\r\n\r\nexport interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement> {\r\n /** Citation source text shown below the quote */\r\n cite?: string;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Blockquote = React.forwardRef<HTMLQuoteElement, BlockquoteProps>(\r\n ({ cite, className, children, ...props }, ref) => (\r\n <figure className=\"my-1\">\r\n <blockquote\r\n ref={ref}\r\n className={cn(\r\n 'border-l-4 border-primary pl-4 py-1 italic text-muted-foreground leading-relaxed',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </blockquote>\r\n {cite && (\r\n <figcaption className=\"mt-2 pl-4 text-sm text-muted-foreground/70 not-italic\">\r\n — {cite}\r\n </figcaption>\r\n )}\r\n </figure>\r\n )\r\n);\r\nBlockquote.displayName = 'Blockquote';\r\n\r\n// ─── Code (inline) ────────────────────────────────────────────────────────────\r\n\r\nexport interface CodeProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Copy content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Code = React.forwardRef<HTMLElement, CodeProps>(\r\n ({ copyable, className, children, ...props }, ref) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <code\r\n ref={mergedRef}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n className={cn(\r\n 'font-mono text-[0.875em] bg-muted text-foreground rounded px-1.5 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer hover:bg-muted/70 transition-colors group/code inline-flex items-center gap-1',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/code:opacity-60 transition-opacity\">\r\n {copied\r\n ? <Check className=\"w-3 h-3 text-success inline\" />\r\n : <Copy className=\"w-3 h-3 inline\" />\r\n }\r\n </span>\r\n )}\r\n </code>\r\n );\r\n }\r\n);\r\nCode.displayName = 'Code';\r\n\r\n// ─── Kbd ──────────────────────────────────────────────────────────────────────\r\n\r\nexport interface KbdProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Array of keys to display; joined with `+` separator */\r\n keys?: string[];\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst KbdKey = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <kbd\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={cn(\r\n 'inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5',\r\n 'font-mono text-xs font-medium',\r\n 'bg-background border border-border rounded shadow-[0_2px_0_0_hsl(var(--border))]',\r\n 'text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </kbd>\r\n )\r\n);\r\nKbdKey.displayName = 'KbdKey';\r\n\r\nconst Kbd = React.forwardRef<HTMLSpanElement, KbdProps>(\r\n ({ keys, className, children, ...props }, ref) => {\r\n const items = keys ?? (children ? [children] : []);\r\n\r\n return (\r\n <span ref={ref} className={cn('inline-flex items-center gap-0.5', className)} {...props}>\r\n {items.map((key, i) => (\r\n <React.Fragment key={i}>\r\n {i > 0 && <span className=\"text-[10px] text-muted-foreground/60 px-0.5\">+</span>}\r\n <KbdKey>{key}</KbdKey>\r\n </React.Fragment>\r\n ))}\r\n </span>\r\n );\r\n }\r\n);\r\nKbd.displayName = 'Kbd';\r\n\r\n// ─── Link ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\r\n /** Open in new tab with rel=\"noopener noreferrer\" */\r\n external?: boolean;\r\n /** Underline behaviour — default `hover` */\r\n underline?: 'always' | 'hover' | 'none';\r\n /** Color variant */\r\n color?: 'primary' | 'muted' | 'danger' | 'foreground';\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Link = React.forwardRef<HTMLAnchorElement, LinkProps>(\r\n ({ external, underline = 'hover', color = 'primary', className, children, ...props }, ref) => (\r\n <a\r\n ref={ref}\r\n target={external ? '_blank' : props.target}\r\n rel={external ? 'noopener noreferrer' : props.rel}\r\n className={cn(\r\n 'inline-flex items-center gap-0.5 transition-colors',\r\n color === 'primary' && 'text-primary',\r\n color === 'muted' && 'text-muted-foreground',\r\n color === 'danger' && 'text-danger',\r\n color === 'foreground' && 'text-foreground',\r\n underline === 'always' && 'underline underline-offset-2',\r\n underline === 'hover' && 'hover:underline underline-offset-2',\r\n underline === 'none' && 'no-underline',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {external && <ExternalLink className=\"w-3 h-3 shrink-0 opacity-70\" />}\r\n </a>\r\n )\r\n);\r\nLink.displayName = 'Link';\r\n\r\n// ─── Mark ─────────────────────────────────────────────────────────────────────\r\n\r\nconst markVariants = tv({\r\n base: 'rounded px-0.5 py-px font-medium',\r\n variants: {\r\n variant: {\r\n default: 'bg-warning/25 text-warning-foreground',\r\n primary: 'bg-primary/15 text-primary',\r\n success: 'bg-success/15 text-success',\r\n warning: 'bg-warning/25 text-warning-foreground',\r\n danger: 'bg-danger/15 text-danger',\r\n },\r\n },\r\n defaultVariants: { variant: 'default' },\r\n});\r\n\r\nexport interface MarkProps\r\n extends React.HTMLAttributes<HTMLElement>,\r\n VariantProps<typeof markVariants> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Mark = React.forwardRef<HTMLElement, MarkProps>(\r\n ({ variant, className, children, ...props }, ref) => (\r\n <mark\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={markVariants({ variant, className })}\r\n {...props}\r\n >\r\n {children}\r\n </mark>\r\n )\r\n);\r\nMark.displayName = 'Mark';\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Text,\r\n Heading,\r\n Paragraph,\r\n Lead,\r\n Blockquote,\r\n Code,\r\n Kbd,\r\n KbdKey,\r\n Link,\r\n Mark,\r\n textVariants,\r\n markVariants,\r\n};\r\n"
826
945
  }
827
946
  ]
828
947
  }