@comitor/ui 0.2.1 → 0.3.0

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 (90) hide show
  1. package/README.md +236 -30
  2. package/dist/chart/chart-colors.d.ts +56 -0
  3. package/dist/chart/chart-colors.js +21 -0
  4. package/dist/chart/chart-colors.js.map +1 -0
  5. package/dist/chart/chart.d.ts +40 -0
  6. package/dist/chart/chart.js.map +1 -1
  7. package/dist/chart/comitor-chart.d.ts +21 -43
  8. package/dist/chart/comitor-chart.js +13 -24
  9. package/dist/chart/comitor-chart.js.map +1 -1
  10. package/dist/chart/index.d.ts +2 -1
  11. package/dist/chart/index.js +1 -0
  12. package/dist/chart/index.js.map +1 -1
  13. package/dist/components/composite/data-table-constants.d.ts +40 -0
  14. package/dist/components/composite/data-table-constants.js +13 -0
  15. package/dist/components/composite/data-table-constants.js.map +1 -0
  16. package/dist/components/composite/data-table.d.ts +2 -27
  17. package/dist/components/composite/data-table.js +1 -10
  18. package/dist/components/composite/data-table.js.map +1 -1
  19. package/dist/components/composite/date-picker-constants.d.ts +21 -0
  20. package/dist/components/composite/date-picker-constants.js +39 -0
  21. package/dist/components/composite/date-picker-constants.js.map +1 -0
  22. package/dist/components/composite/date-picker.d.ts +2 -18
  23. package/dist/components/composite/date-picker.js +6 -48
  24. package/dist/components/composite/date-picker.js.map +1 -1
  25. package/dist/components/composite/form-field-constants.d.ts +26 -0
  26. package/dist/components/composite/form-field-constants.js +13 -0
  27. package/dist/components/composite/form-field-constants.js.map +1 -0
  28. package/dist/components/composite/form-field.d.ts +1 -13
  29. package/dist/components/composite/form-field.js +1 -11
  30. package/dist/components/composite/form-field.js.map +1 -1
  31. package/dist/components/composite/search-filter-bar-constants.d.ts +52 -0
  32. package/dist/components/composite/search-filter-bar-constants.js +18 -0
  33. package/dist/components/composite/search-filter-bar-constants.js.map +1 -0
  34. package/dist/components/composite/search-filter-bar.d.ts +3 -37
  35. package/dist/components/composite/search-filter-bar.js +2 -15
  36. package/dist/components/composite/search-filter-bar.js.map +1 -1
  37. package/dist/components/composite/table-pagination-constants.d.ts +56 -0
  38. package/dist/components/composite/table-pagination-constants.js +18 -0
  39. package/dist/components/composite/table-pagination-constants.js.map +1 -0
  40. package/dist/components/composite/table-pagination.d.ts +2 -43
  41. package/dist/components/composite/table-pagination.js +4 -15
  42. package/dist/components/composite/table-pagination.js.map +1 -1
  43. package/dist/components/ui/badge.js +3 -3
  44. package/dist/components/ui/badge.js.map +1 -1
  45. package/dist/components/ui/button.js +4 -4
  46. package/dist/components/ui/button.js.map +1 -1
  47. package/dist/components/ui/calendar.js +1 -1
  48. package/dist/components/ui/calendar.js.map +1 -1
  49. package/dist/components/ui/checkbox.js +2 -2
  50. package/dist/components/ui/checkbox.js.map +1 -1
  51. package/dist/components/ui/command.js +1 -1
  52. package/dist/components/ui/command.js.map +1 -1
  53. package/dist/components/ui/input-group.js +1 -1
  54. package/dist/components/ui/input-group.js.map +1 -1
  55. package/dist/components/ui/input-otp.js +1 -1
  56. package/dist/components/ui/input-otp.js.map +1 -1
  57. package/dist/components/ui/navigation-menu.js +1 -1
  58. package/dist/components/ui/navigation-menu.js.map +1 -1
  59. package/dist/components/ui/radio-group.js +1 -1
  60. package/dist/components/ui/radio-group.js.map +1 -1
  61. package/dist/components/ui/resizable.js +1 -1
  62. package/dist/components/ui/resizable.js.map +1 -1
  63. package/dist/components/ui/slider.js +1 -1
  64. package/dist/components/ui/slider.js.map +1 -1
  65. package/dist/components/ui/switch.js +1 -1
  66. package/dist/components/ui/switch.js.map +1 -1
  67. package/dist/components/ui/textarea.js +1 -1
  68. package/dist/components/ui/textarea.js.map +1 -1
  69. package/dist/index.d.ts +11 -6
  70. package/dist/index.js +5 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/shell/contrast-constants.d.ts +30 -0
  73. package/dist/shell/contrast-constants.js +16 -0
  74. package/dist/shell/contrast-constants.js.map +1 -0
  75. package/dist/shell/contrast-provider.d.ts +60 -0
  76. package/dist/shell/contrast-provider.js +61 -0
  77. package/dist/shell/contrast-provider.js.map +1 -0
  78. package/dist/shell/contrast-toggle.d.ts +24 -0
  79. package/dist/shell/contrast-toggle.js +29 -0
  80. package/dist/shell/contrast-toggle.js.map +1 -0
  81. package/dist/shell/index.d.ts +3 -0
  82. package/dist/shell/index.js +3 -0
  83. package/dist/shell/index.js.map +1 -1
  84. package/dist/shell/shell-context.js +3 -2
  85. package/dist/shell/shell-context.js.map +1 -1
  86. package/dist/tokens.d.ts +170 -70
  87. package/dist/tokens.js +124 -47
  88. package/dist/tokens.js.map +1 -1
  89. package/package.json +3 -2
  90. package/styles.css +255 -120
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Entry chính \"@comitor/ui\" — Tier 1 (primitive) + Tier 2 (composite) + lib + token.\n *\n * Quy tắc của entry này: **chỉ được phụ thuộc peer BẮT BUỘC** (react, react-dom,\n * lucide-react, tailwindcss). Không `next/*`, không `next-themes`, không `recharts`,\n * không `react-hook-form` — đó đều là peer OPTIONAL. Vì `tsup` chạy `bundle: false`\n * (ESM per-file), chỉ cần barrel này re-export một file có import optional peer là\n * mọi app import \"@comitor/ui\" đều buộc phải cài gói đó, nếu không bundler gãy ở\n * bước resolve. Vì vậy 3 nhánh dưới đây nằm ở entry riêng:\n *\n * - Tier 3 shell (next/*, next-themes) → \"@comitor/ui/shell\"\n * - chart (recharts) → \"@comitor/ui/chart\"\n * - binding react-hook-form → \"@comitor/ui/form\"\n *\n * ⚠ Hai file trong `components/ui/` CỐ Ý không có mặt ở đây:\n * - `form.tsx` → xuất qua \"@comitor/ui/form\" (react-hook-form).\n * - `sonner.tsx` → xuất qua \"@comitor/ui/shell\" dưới tên `SonnerToaster`\n * (nó gọi `useTheme()` của next-themes). Bản dùng hằng ngày là composite\n * `Toaster`/`toast` ở Tier 2 — không phụ thuộc next-themes, có sẵn ở entry này.\n *\n * ⚠ Mọi import nội bộ phải giữ đuôi `.js`: output là ESM per-file, Node ESM thuần\n * không tự suy ra phần mở rộng (bundler thì tha, Node thì không).\n */\n\n// ─── Tier 2 — composite (pattern lặp lại ở mọi app Comitor) ───\nexport * from \"./components/composite/avatar-group.js\";\nexport * from \"./components/composite/combobox.js\";\nexport * from \"./components/composite/command-palette.js\";\nexport * from \"./components/composite/confirm-dialog.js\";\nexport * from \"./components/composite/copy-button.js\";\nexport * from \"./components/composite/data-table.js\";\nexport * from \"./components/composite/date-picker.js\";\nexport * from \"./components/composite/empty-state.js\";\nexport * from \"./components/composite/filter-chips.js\";\nexport * from \"./components/composite/form-field.js\";\nexport * from \"./components/composite/form-section.js\";\nexport * from \"./components/composite/inline-edit.js\";\nexport * from \"./components/composite/keyboard-hint.js\";\nexport type { LetterAvatarProps, LetterAvatarSize } from \"./components/composite/letter-avatar.js\";\n// Xuất CHỌN LỌC (không `export *`): file này còn `AVATAR_TONES` nhập từ `lib/avatar-tone.js`\n// và các helper nội bộ khác. Liệt kê tường minh thì thêm helper riêng tư vào file KHÔNG tự động\n// biến nó thành API công khai — hàng rào mà `export *` không dựng được.\nexport { getAvatarToneClasses, LetterAvatar } from \"./components/composite/letter-avatar.js\";\nexport * from \"./components/composite/page-container.js\";\nexport * from \"./components/composite/page-header.js\";\nexport * from \"./components/composite/relative-time.js\";\nexport * from \"./components/composite/search-filter-bar.js\";\nexport * from \"./components/composite/stat-card.js\";\nexport * from \"./components/composite/status-config.js\";\nexport * from \"./components/composite/status-pill.js\";\nexport * from \"./components/composite/table-pagination.js\";\n// Xuất CHỌN LỌC vì cùng lý do với `letter-avatar` ở trên: `text-utils.ts` là chỗ dễ bị thêm\n// helper nội bộ nhất của Tier 2 (0.1.0 từng rò `hashString`/`pickByString` ra API công khai\n// đúng theo đường này). Năm hàm dưới đây là hợp đồng, phần còn lại của file thì không.\nexport {\n getVietnameseInitials,\n matchesSearch,\n normalizeVi,\n removeVietnameseTones,\n truncate\n} from \"./components/composite/text-utils.js\";\nexport * from \"./components/composite/toast.js\";\n// ─── Tier 1 — primitive (shadcn/ui \"new-york\", đã custom theo brand Comitor) ───\nexport * from \"./components/ui/accordion.js\";\nexport * from \"./components/ui/alert.js\";\nexport * from \"./components/ui/alert-dialog.js\";\nexport * from \"./components/ui/aspect-ratio.js\";\nexport * from \"./components/ui/avatar.js\";\nexport * from \"./components/ui/badge.js\";\nexport * from \"./components/ui/breadcrumb.js\";\nexport * from \"./components/ui/button.js\";\nexport * from \"./components/ui/button-group.js\";\nexport * from \"./components/ui/calendar.js\";\nexport * from \"./components/ui/card.js\";\nexport * from \"./components/ui/carousel.js\";\nexport * from \"./components/ui/checkbox.js\";\nexport * from \"./components/ui/collapsible.js\";\nexport * from \"./components/ui/command.js\";\nexport * from \"./components/ui/context-menu.js\";\nexport * from \"./components/ui/dialog.js\";\nexport * from \"./components/ui/drawer.js\";\nexport * from \"./components/ui/dropdown-menu.js\";\nexport * from \"./components/ui/empty.js\";\nexport * from \"./components/ui/field.js\";\nexport * from \"./components/ui/hover-card.js\";\nexport * from \"./components/ui/input.js\";\nexport * from \"./components/ui/input-group.js\";\nexport * from \"./components/ui/input-otp.js\";\nexport * from \"./components/ui/item.js\";\nexport * from \"./components/ui/kbd.js\";\nexport * from \"./components/ui/label.js\";\nexport * from \"./components/ui/menubar.js\";\nexport * from \"./components/ui/navigation-menu.js\";\nexport * from \"./components/ui/pagination.js\";\nexport * from \"./components/ui/popover.js\";\nexport * from \"./components/ui/progress.js\";\nexport * from \"./components/ui/radio-group.js\";\nexport * from \"./components/ui/resizable.js\";\nexport * from \"./components/ui/scroll-area.js\";\nexport * from \"./components/ui/select.js\";\nexport * from \"./components/ui/separator.js\";\nexport * from \"./components/ui/sheet.js\";\nexport * from \"./components/ui/skeleton.js\";\nexport * from \"./components/ui/slider.js\";\nexport * from \"./components/ui/spinner.js\";\nexport * from \"./components/ui/switch.js\";\nexport * from \"./components/ui/table.js\";\nexport * from \"./components/ui/tabs.js\";\nexport * from \"./components/ui/textarea.js\";\nexport * from \"./components/ui/toggle.js\";\nexport * from \"./components/ui/toggle-group.js\";\nexport * from \"./components/ui/tooltip.js\";\n\n// ─── lib + token ───\nexport * from \"./lib/cn.js\";\nexport * from \"./tokens.js\";\n"],"mappings":"AAyBA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAKd,SAAS,sBAAsB,oBAAoB;AACnD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAId;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,cAAc;AAEd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAGd,cAAc;AACd,cAAc;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * Entry chính \"@comitor/ui\" — Tier 1 (primitive) + Tier 2 (composite) + lib + token.\n *\n * Quy tắc của entry này: **chỉ được phụ thuộc peer BẮT BUỘC** (react, react-dom,\n * lucide-react, tailwindcss). Không `next/*`, không `next-themes`, không `recharts`,\n * không `react-hook-form` — đó đều là peer OPTIONAL. Vì `tsup` chạy `bundle: false`\n * (ESM per-file), chỉ cần barrel này re-export một file có import optional peer là\n * mọi app import \"@comitor/ui\" đều buộc phải cài gói đó, nếu không bundler gãy ở\n * bước resolve. Vì vậy 3 nhánh dưới đây nằm ở entry riêng:\n *\n * - Tier 3 shell (next/*, next-themes) → \"@comitor/ui/shell\"\n * - chart (recharts) → \"@comitor/ui/chart\"\n * - binding react-hook-form → \"@comitor/ui/form\"\n *\n * ⚠ Hai file trong `components/ui/` CỐ Ý không có mặt ở đây:\n * - `form.tsx` → xuất qua \"@comitor/ui/form\" (react-hook-form).\n * - `sonner.tsx` → xuất qua \"@comitor/ui/shell\" dưới tên `SonnerToaster`\n * (nó gọi `useTheme()` của next-themes). Bản dùng hằng ngày là composite\n * `Toaster`/`toast` ở Tier 2 — không phụ thuộc next-themes, có sẵn ở entry này.\n *\n * ⚠ Mọi import nội bộ phải giữ đuôi `.js`: output là ESM per-file, Node ESM thuần\n * không tự suy ra phần mở rộng (bundler thì tha, Node thì không).\n */\n\n/*\n * ⚠ Năm file `*-constants` bên dưới KHÔNG phải chỗ để gom code cho gọn — chúng là RANH GIỚI\n * SERVER/CLIENT. Component chủ (`data-table.tsx`, `date-picker.tsx`, `form-field.tsx`,\n * `search-filter-bar.tsx`, `table-pagination.tsx`) đều mở đầu bằng `\"use client\"`, mà mọi export\n * của một module client đến tay Server Component dưới dạng client-reference proxy: đọc ra\n * `undefined`, KHÔNG throw, KHÔNG warning, trong khi TypeScript vẫn khai kiểu thật. Nhãn mặc\n * định / preset / sentinel là hằng dữ liệu thuần nên phải nằm ngoài ranh giới đó, và barrel này\n * phải xuất chúng THẲNG — re-export vòng qua file client là vô hiệu.\n * Cùng lý do với `chart/chart-colors.ts`; chi tiết ghi ở đầu file đó.\n * `scripts/verify-directives.mjs` canh không cho hằng thuần lọt ngược vào file client.\n */\n\n// ─── Tier 2 — composite (pattern lặp lại ở mọi app Comitor) ───\nexport * from \"./components/composite/avatar-group.js\";\nexport * from \"./components/composite/combobox.js\";\nexport * from \"./components/composite/command-palette.js\";\nexport * from \"./components/composite/confirm-dialog.js\";\nexport * from \"./components/composite/copy-button.js\";\nexport * from \"./components/composite/data-table.js\";\nexport * from \"./components/composite/data-table-constants.js\";\nexport * from \"./components/composite/date-picker.js\";\nexport * from \"./components/composite/date-picker-constants.js\";\nexport * from \"./components/composite/empty-state.js\";\nexport * from \"./components/composite/filter-chips.js\";\nexport * from \"./components/composite/form-field.js\";\nexport * from \"./components/composite/form-field-constants.js\";\nexport * from \"./components/composite/form-section.js\";\nexport * from \"./components/composite/inline-edit.js\";\nexport * from \"./components/composite/keyboard-hint.js\";\nexport type { LetterAvatarProps, LetterAvatarSize } from \"./components/composite/letter-avatar.js\";\n// Xuất CHỌN LỌC (không `export *`): file này còn `AVATAR_TONES` nhập từ `lib/avatar-tone.js`\n// và các helper nội bộ khác. Liệt kê tường minh thì thêm helper riêng tư vào file KHÔNG tự động\n// biến nó thành API công khai — hàng rào mà `export *` không dựng được.\nexport { getAvatarToneClasses, LetterAvatar } from \"./components/composite/letter-avatar.js\";\nexport * from \"./components/composite/page-container.js\";\nexport * from \"./components/composite/page-header.js\";\nexport * from \"./components/composite/relative-time.js\";\nexport * from \"./components/composite/search-filter-bar.js\";\nexport * from \"./components/composite/search-filter-bar-constants.js\";\nexport * from \"./components/composite/stat-card.js\";\nexport * from \"./components/composite/status-config.js\";\nexport * from \"./components/composite/status-pill.js\";\nexport * from \"./components/composite/table-pagination.js\";\nexport * from \"./components/composite/table-pagination-constants.js\";\n// Xuất CHỌN LỌC vì cùng lý do với `letter-avatar` ở trên: `text-utils.ts` là chỗ dễ bị thêm\n// helper nội bộ nhất của Tier 2 (0.1.0 từng rò `hashString`/`pickByString` ra API công khai\n// đúng theo đường này). Năm hàm dưới đây là hợp đồng, phần còn lại của file thì không.\nexport {\n getVietnameseInitials,\n matchesSearch,\n normalizeVi,\n removeVietnameseTones,\n truncate\n} from \"./components/composite/text-utils.js\";\nexport * from \"./components/composite/toast.js\";\n// ─── Tier 1 — primitive (shadcn/ui \"new-york\", đã custom theo brand Comitor) ───\nexport * from \"./components/ui/accordion.js\";\nexport * from \"./components/ui/alert.js\";\nexport * from \"./components/ui/alert-dialog.js\";\nexport * from \"./components/ui/aspect-ratio.js\";\nexport * from \"./components/ui/avatar.js\";\nexport * from \"./components/ui/badge.js\";\nexport * from \"./components/ui/breadcrumb.js\";\nexport * from \"./components/ui/button.js\";\nexport * from \"./components/ui/button-group.js\";\nexport * from \"./components/ui/calendar.js\";\nexport * from \"./components/ui/card.js\";\nexport * from \"./components/ui/carousel.js\";\nexport * from \"./components/ui/checkbox.js\";\nexport * from \"./components/ui/collapsible.js\";\nexport * from \"./components/ui/command.js\";\nexport * from \"./components/ui/context-menu.js\";\nexport * from \"./components/ui/dialog.js\";\nexport * from \"./components/ui/drawer.js\";\nexport * from \"./components/ui/dropdown-menu.js\";\nexport * from \"./components/ui/empty.js\";\nexport * from \"./components/ui/field.js\";\nexport * from \"./components/ui/hover-card.js\";\nexport * from \"./components/ui/input.js\";\nexport * from \"./components/ui/input-group.js\";\nexport * from \"./components/ui/input-otp.js\";\nexport * from \"./components/ui/item.js\";\nexport * from \"./components/ui/kbd.js\";\nexport * from \"./components/ui/label.js\";\nexport * from \"./components/ui/menubar.js\";\nexport * from \"./components/ui/navigation-menu.js\";\nexport * from \"./components/ui/pagination.js\";\nexport * from \"./components/ui/popover.js\";\nexport * from \"./components/ui/progress.js\";\nexport * from \"./components/ui/radio-group.js\";\nexport * from \"./components/ui/resizable.js\";\nexport * from \"./components/ui/scroll-area.js\";\nexport * from \"./components/ui/select.js\";\nexport * from \"./components/ui/separator.js\";\nexport * from \"./components/ui/sheet.js\";\nexport * from \"./components/ui/skeleton.js\";\nexport * from \"./components/ui/slider.js\";\nexport * from \"./components/ui/spinner.js\";\nexport * from \"./components/ui/switch.js\";\nexport * from \"./components/ui/table.js\";\nexport * from \"./components/ui/tabs.js\";\nexport * from \"./components/ui/textarea.js\";\nexport * from \"./components/ui/toggle.js\";\nexport * from \"./components/ui/toggle-group.js\";\nexport * from \"./components/ui/tooltip.js\";\n\n// ─── lib + token ───\nexport * from \"./lib/cn.js\";\nexport * from \"./tokens.js\";\n"],"mappings":"AAqCA,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAKd,SAAS,sBAAsB,oBAAoB;AACnD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAId;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,cAAc;AAEd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAGd,cAAc;AACd,cAAc;","names":[]}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Hằng và kiểu của trục TƯƠNG PHẢN — module THUẦN, cố ý KHÔNG có `"use client"`.
3
+ *
4
+ * Ranh giới RSC cắt theo FILE: mọi export của một module `"use client"` khi đi tới Server
5
+ * Component đều thành client-reference proxy, nên `CONTRAST_STORAGE_KEY` sẽ ra `undefined` mà
6
+ * KHÔNG throw và KHÔNG warning (xem CHANGELOG 0.2.2 §2). Root layout là Server Component và nó
7
+ * cần đúng khoá này để render `<ContrastScript />`, nên hằng phải sống ở đây.
8
+ *
9
+ * ⚠ Barrel `shell/index.ts` phải xuất thẳng từ file này, KHÔNG vòng qua contrast-provider.tsx —
10
+ * đi qua một file client là ranh giới biến hằng lại thành proxy (cổng `verify:directives` phép
11
+ * kiểm 3 canh đúng chuyện này).
12
+ */
13
+ /** Hai bảng màu của hệ sinh thái. `normal` = comitor-ds (bản duyệt), `high` = bảng đã đo WCAG. */
14
+ type ContrastMode = "normal" | "high";
15
+ declare const CONTRAST_MODES: readonly ContrastMode[];
16
+ /** Thuộc tính trên `<html>` mà styles.css nhìn vào: `[data-contrast="high"]`. */
17
+ declare const CONTRAST_ATTRIBUTE = "data-contrast";
18
+ /** Khoá localStorage mặc định — cùng lối đặt tên với `comitor-theme` của ThemeProvider. */
19
+ declare const CONTRAST_STORAGE_KEY = "comitor-contrast";
20
+ /** Nhãn của riêng ContrastToggle — không nằm trong `ShellLabels` vì nút này dùng được ngoài shell. */
21
+ interface ContrastToggleLabels {
22
+ /** Nhãn chính, đứng cạnh nút gạt. */
23
+ label: string;
24
+ /** Dòng mô tả bên dưới nhãn. */
25
+ description: string;
26
+ }
27
+ declare const DEFAULT_CONTRAST_TOGGLE_LABELS: ContrastToggleLabels;
28
+ declare const isContrastMode: (value: unknown) => value is ContrastMode;
29
+
30
+ export { CONTRAST_ATTRIBUTE, CONTRAST_MODES, CONTRAST_STORAGE_KEY, type ContrastMode, type ContrastToggleLabels, DEFAULT_CONTRAST_TOGGLE_LABELS, isContrastMode };
@@ -0,0 +1,16 @@
1
+ const CONTRAST_MODES = ["normal", "high"];
2
+ const CONTRAST_ATTRIBUTE = "data-contrast";
3
+ const CONTRAST_STORAGE_KEY = "comitor-contrast";
4
+ const DEFAULT_CONTRAST_TOGGLE_LABELS = {
5
+ label: "T\u01B0\u01A1ng ph\u1EA3n cao",
6
+ description: "\u0110\u1EADm m\xE0u vi\u1EC1n, c\xF4ng t\u1EAFc v\xE0 ch\u1EEF ph\u1EE5. H\u1EEFu \xEDch tr\xEAn m\xE0n h\xECnh \u0111\u1ED9 s\xE1ng th\u1EA5p ho\u1EB7c khi d\xF9ng ngo\xE0i tr\u1EDDi."
7
+ };
8
+ const isContrastMode = (value) => typeof value === "string" && CONTRAST_MODES.includes(value);
9
+ export {
10
+ CONTRAST_ATTRIBUTE,
11
+ CONTRAST_MODES,
12
+ CONTRAST_STORAGE_KEY,
13
+ DEFAULT_CONTRAST_TOGGLE_LABELS,
14
+ isContrastMode
15
+ };
16
+ //# sourceMappingURL=contrast-constants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/contrast-constants.ts"],"sourcesContent":["/**\n * Hằng và kiểu của trục TƯƠNG PHẢN — module THUẦN, cố ý KHÔNG có `\"use client\"`.\n *\n * Ranh giới RSC cắt theo FILE: mọi export của một module `\"use client\"` khi đi tới Server\n * Component đều thành client-reference proxy, nên `CONTRAST_STORAGE_KEY` sẽ ra `undefined` mà\n * KHÔNG throw và KHÔNG warning (xem CHANGELOG 0.2.2 §2). Root layout là Server Component và nó\n * cần đúng khoá này để render `<ContrastScript />`, nên hằng phải sống ở đây.\n *\n * ⚠ Barrel `shell/index.ts` phải xuất thẳng từ file này, KHÔNG vòng qua contrast-provider.tsx —\n * đi qua một file client là ranh giới biến hằng lại thành proxy (cổng `verify:directives` phép\n * kiểm 3 canh đúng chuyện này).\n */\n\n/** Hai bảng màu của hệ sinh thái. `normal` = comitor-ds (bản duyệt), `high` = bảng đã đo WCAG. */\nexport type ContrastMode = \"normal\" | \"high\";\n\nexport const CONTRAST_MODES: readonly ContrastMode[] = [\"normal\", \"high\"];\n\n/** Thuộc tính trên `<html>` mà styles.css nhìn vào: `[data-contrast=\"high\"]`. */\nexport const CONTRAST_ATTRIBUTE = \"data-contrast\";\n\n/** Khoá localStorage mặc định — cùng lối đặt tên với `comitor-theme` của ThemeProvider. */\nexport const CONTRAST_STORAGE_KEY = \"comitor-contrast\";\n\n/** Nhãn của riêng ContrastToggle — không nằm trong `ShellLabels` vì nút này dùng được ngoài shell. */\nexport interface ContrastToggleLabels {\n /** Nhãn chính, đứng cạnh nút gạt. */\n label: string;\n /** Dòng mô tả bên dưới nhãn. */\n description: string;\n}\n\nexport const DEFAULT_CONTRAST_TOGGLE_LABELS: ContrastToggleLabels = {\n label: \"Tương phản cao\",\n description: \"Đậm màu viền, công tắc và chữ phụ. Hữu ích trên màn hình độ sáng thấp hoặc khi dùng ngoài trời.\"\n};\n\nexport const isContrastMode = (value: unknown): value is ContrastMode =>\n typeof value === \"string\" && (CONTRAST_MODES as readonly string[]).includes(value);\n"],"mappings":"AAgBO,MAAM,iBAA0C,CAAC,UAAU,MAAM;AAGjE,MAAM,qBAAqB;AAG3B,MAAM,uBAAuB;AAU7B,MAAM,iCAAuD;AAAA,EAClE,OAAO;AAAA,EACP,aAAa;AACf;AAEO,MAAM,iBAAiB,CAAC,UAC7B,OAAO,UAAU,YAAa,eAAqC,SAAS,KAAK;","names":[]}
@@ -0,0 +1,60 @@
1
+ import * as React$1 from 'react';
2
+ import { ContrastMode } from './contrast-constants.js';
3
+
4
+ interface ContrastContextValue {
5
+ /** Chế độ đang áp dụng. Trước khi mount luôn là giá trị mặc định, không phải giá trị đã lưu. */
6
+ contrast: ContrastMode;
7
+ setContrast: (mode: ContrastMode) => void;
8
+ /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration, xem ContrastToggle. */
9
+ mounted: boolean;
10
+ }
11
+ interface ContrastProviderProps {
12
+ children: React.ReactNode;
13
+ /** Bảng màu khi người dùng chưa chọn gì. Mặc định `normal` (comitor-ds). */
14
+ defaultContrast?: ContrastMode;
15
+ /** Khoá localStorage. Đổi khi một app muốn tách lựa chọn khỏi các app khác cùng tên miền. */
16
+ storageKey?: string;
17
+ }
18
+ /**
19
+ * Trục thứ HAI của giao diện, độc lập với sáng/tối: chọn BẢNG MÀU.
20
+ *
21
+ * ─ VÌ SAO KHÔNG DÙNG `next-themes` CHO TRỤC NÀY ─
22
+ * `next-themes` giữ **một context singleton** cho toàn cây. Lồng hai `<ThemeProvider>` với
23
+ * `attribute`/`storageKey` khác nhau thì provider trong cùng thắng và `useTheme()` của app đọc
24
+ * nhầm trục — không có lỗi biên dịch nào, chỉ có nút đổi sáng/tối lặng lẽ ngừng ăn. Trục tương
25
+ * phản đơn giản hơn theme (không có "system", không đọc `prefers-color-scheme`) nên tự viết
26
+ * ~60 dòng là đủ và không phải trả cái giá đó.
27
+ *
28
+ * ─ CHỐNG NHÁY ─
29
+ * Bảng màu phải có mặt **trước** lần sơn đầu tiên, nếu không người bật tương phản cao sẽ thấy một
30
+ * khung hình bảng mặc định rồi mới đổi. Effect của React chạy SAU khi sơn, nên phải là một script
31
+ * đồng bộ — provider TỰ render nó (`<ContrastScript />`) làm con đầu tiên, app không phải làm gì.
32
+ *
33
+ * ⚠ Script nằm trong `<body>`, KHÔNG phải `<head>` — và đó là chỗ duy nhất đúng. Đặt vào `<head>`
34
+ * thì React 19 kêu "Encountered a script tag while rendering React component" ở mỗi lần render
35
+ * client (script inline không thuộc nhóm phần tử React hoisted được), tức console đỏ ở mọi trang.
36
+ * Trong `<body>` thì không, và về chống nháy vẫn tương đương: trình duyệt chạy script ngay khi
37
+ * parser đi tới nó, mà nó đứng TRƯỚC toàn bộ markup của app. Đây cũng đúng chỗ next-themes đặt
38
+ * script của nó.
39
+ *
40
+ * `<html>` phải có `suppressHydrationWarning` (cùng lý do với ThemeProvider: script sửa DOM
41
+ * trước khi React hydrate nên HTML client luôn khác HTML server).
42
+ */
43
+ declare function ContrastProvider({ children, defaultContrast, storageKey }: ContrastProviderProps): React$1.JSX.Element;
44
+ /**
45
+ * Script chống nháy. `<ContrastProvider>` đã render sẵn nó nên **app thường không cần gọi tay** —
46
+ * export ra chỉ cho trường hợp app tự dựng provider hoặc cần đặt nó ở một vị trí khác trong body.
47
+ *
48
+ * ⚠ ĐỪNG đặt vào `<head>`: React 19 báo lỗi console ở mọi lần render client (xem chú thích của
49
+ * `ContrastProvider`). Gọi hai lần cũng vô hại — script idempotent — chỉ tốn một thẻ.
50
+ */
51
+ declare function ContrastScript({ storageKey }: {
52
+ storageKey?: string;
53
+ }): React$1.JSX.Element;
54
+ /**
55
+ * Đọc/đổi bảng màu. Ném lỗi nếu gọi ngoài `<ContrastProvider>` — im lặng trả mặc định sẽ khiến
56
+ * nút gạt trông như hỏng mà không có gì chỉ ra nguyên nhân.
57
+ */
58
+ declare function useContrast(): ContrastContextValue;
59
+
60
+ export { type ContrastContextValue, ContrastProvider, type ContrastProviderProps, ContrastScript, useContrast };
@@ -0,0 +1,61 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4
+ import { CONTRAST_ATTRIBUTE, CONTRAST_STORAGE_KEY, isContrastMode } from "./contrast-constants.js";
5
+ const ContrastContext = createContext(null);
6
+ function ContrastProvider({
7
+ children,
8
+ defaultContrast = "normal",
9
+ storageKey = CONTRAST_STORAGE_KEY
10
+ }) {
11
+ const [contrast, setContrastState] = useState(defaultContrast);
12
+ const [mounted, setMounted] = useState(false);
13
+ useEffect(() => {
14
+ let stored = null;
15
+ try {
16
+ stored = window.localStorage.getItem(storageKey);
17
+ } catch {
18
+ }
19
+ if (isContrastMode(stored)) setContrastState(stored);
20
+ setMounted(true);
21
+ }, [storageKey]);
22
+ useEffect(() => {
23
+ if (!mounted) return;
24
+ const root = document.documentElement;
25
+ if (contrast === "normal") root.removeAttribute(CONTRAST_ATTRIBUTE);
26
+ else root.setAttribute(CONTRAST_ATTRIBUTE, contrast);
27
+ }, [contrast, mounted]);
28
+ const setContrast = useCallback(
29
+ (mode) => {
30
+ setContrastState(mode);
31
+ try {
32
+ window.localStorage.setItem(storageKey, mode);
33
+ } catch {
34
+ }
35
+ },
36
+ [storageKey]
37
+ );
38
+ const value = useMemo(
39
+ () => ({ contrast, setContrast, mounted }),
40
+ [contrast, setContrast, mounted]
41
+ );
42
+ return /* @__PURE__ */ jsxs(ContrastContext.Provider, { value, children: [
43
+ /* @__PURE__ */ jsx(ContrastScript, { storageKey }),
44
+ children
45
+ ] });
46
+ }
47
+ function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY }) {
48
+ const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v==="high")document.documentElement.setAttribute(${JSON.stringify(CONTRAST_ATTRIBUTE)},"high")}catch(e){}})();`;
49
+ return /* @__PURE__ */ jsx("script", { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: script } });
50
+ }
51
+ function useContrast() {
52
+ const ctx = useContext(ContrastContext);
53
+ if (!ctx) throw new Error("useContrast() ph\u1EA3i n\u1EB1m trong <ContrastProvider>. B\u1ECDc n\xF3 \u1EDF root layout.");
54
+ return ctx;
55
+ }
56
+ export {
57
+ ContrastProvider,
58
+ ContrastScript,
59
+ useContrast
60
+ };
61
+ //# sourceMappingURL=contrast-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/contrast-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { CONTRAST_ATTRIBUTE, CONTRAST_STORAGE_KEY, type ContrastMode, isContrastMode } from \"./contrast-constants.js\";\n\nexport interface ContrastContextValue {\n /** Chế độ đang áp dụng. Trước khi mount luôn là giá trị mặc định, không phải giá trị đã lưu. */\n contrast: ContrastMode;\n setContrast: (mode: ContrastMode) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration, xem ContrastToggle. */\n mounted: boolean;\n}\n\nconst ContrastContext = createContext<ContrastContextValue | null>(null);\n\nexport interface ContrastProviderProps {\n children: React.ReactNode;\n /** Bảng màu khi người dùng chưa chọn gì. Mặc định `normal` (comitor-ds). */\n defaultContrast?: ContrastMode;\n /** Khoá localStorage. Đổi khi một app muốn tách lựa chọn khỏi các app khác cùng tên miền. */\n storageKey?: string;\n}\n\n/**\n * Trục thứ HAI của giao diện, độc lập với sáng/tối: chọn BẢNG MÀU.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` CHO TRỤC NÀY ─\n * `next-themes` giữ **một context singleton** cho toàn cây. Lồng hai `<ThemeProvider>` với\n * `attribute`/`storageKey` khác nhau thì provider trong cùng thắng và `useTheme()` của app đọc\n * nhầm trục — không có lỗi biên dịch nào, chỉ có nút đổi sáng/tối lặng lẽ ngừng ăn. Trục tương\n * phản đơn giản hơn theme (không có \"system\", không đọc `prefers-color-scheme`) nên tự viết\n * ~60 dòng là đủ và không phải trả cái giá đó.\n *\n * ─ CHỐNG NHÁY ─\n * Bảng màu phải có mặt **trước** lần sơn đầu tiên, nếu không người bật tương phản cao sẽ thấy một\n * khung hình bảng mặc định rồi mới đổi. Effect của React chạy SAU khi sơn, nên phải là một script\n * đồng bộ — provider TỰ render nó (`<ContrastScript />`) làm con đầu tiên, app không phải làm gì.\n *\n * ⚠ Script nằm trong `<body>`, KHÔNG phải `<head>` — và đó là chỗ duy nhất đúng. Đặt vào `<head>`\n * thì React 19 kêu \"Encountered a script tag while rendering React component\" ở mỗi lần render\n * client (script inline không thuộc nhóm phần tử React hoisted được), tức console đỏ ở mọi trang.\n * Trong `<body>` thì không, và về chống nháy vẫn tương đương: trình duyệt chạy script ngay khi\n * parser đi tới nó, mà nó đứng TRƯỚC toàn bộ markup của app. Đây cũng đúng chỗ next-themes đặt\n * script của nó.\n *\n * `<html>` phải có `suppressHydrationWarning` (cùng lý do với ThemeProvider: script sửa DOM\n * trước khi React hydrate nên HTML client luôn khác HTML server).\n */\nexport function ContrastProvider({\n children,\n defaultContrast = \"normal\",\n storageKey = CONTRAST_STORAGE_KEY\n}: ContrastProviderProps) {\n const [contrast, setContrastState] = useState<ContrastMode>(defaultContrast);\n const [mounted, setMounted] = useState(false);\n\n // Đọc lựa chọn đã lưu SAU khi mount. Trước đó state phải bằng đúng giá trị server đã render,\n // nếu không React báo lệch hydration — DOM thật đã được ContrastScript đặt đúng từ trước rồi.\n useEffect(() => {\n let stored: string | null = null;\n try {\n stored = window.localStorage.getItem(storageKey);\n } catch {\n // Safari chế độ riêng tư / localStorage bị chặn: rơi về mặc định, không phải lỗi.\n }\n if (isContrastMode(stored)) setContrastState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n if (contrast === \"normal\") root.removeAttribute(CONTRAST_ATTRIBUTE);\n else root.setAttribute(CONTRAST_ATTRIBUTE, contrast);\n }, [contrast, mounted]);\n\n const setContrast = useCallback(\n (mode: ContrastMode) => {\n setContrastState(mode);\n try {\n window.localStorage.setItem(storageKey, mode);\n } catch {\n // Không lưu được thì lựa chọn chỉ sống trong phiên này — vẫn tốt hơn là ném lỗi.\n }\n },\n [storageKey]\n );\n\n const value = useMemo<ContrastContextValue>(\n () => ({ contrast, setContrast, mounted }),\n [contrast, setContrast, mounted]\n );\n\n return (\n <ContrastContext.Provider value={value}>\n <ContrastScript storageKey={storageKey} />\n {children}\n </ContrastContext.Provider>\n );\n}\n\n/**\n * Script chống nháy. `<ContrastProvider>` đã render sẵn nó nên **app thường không cần gọi tay** —\n * export ra chỉ cho trường hợp app tự dựng provider hoặc cần đặt nó ở một vị trí khác trong body.\n *\n * ⚠ ĐỪNG đặt vào `<head>`: React 19 báo lỗi console ở mọi lần render client (xem chú thích của\n * `ContrastProvider`). Gọi hai lần cũng vô hại — script idempotent — chỉ tốn một thẻ.\n */\nexport function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY }: { storageKey?: string }) {\n // Chuỗi tự gọi, không phụ thuộc React: chạy ngay lúc trình duyệt gặp thẻ <script>.\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v===\"high\")document.documentElement.setAttribute(${JSON.stringify(CONTRAST_ATTRIBUTE)},\"high\")}catch(e){}})();`;\n // biome-ignore lint/security/noDangerouslySetInnerHtml: chuỗi hằng do chính package sinh, không có dữ liệu người dùng.\n return <script suppressHydrationWarning dangerouslySetInnerHTML={{ __html: script }} />;\n}\n\n/**\n * Đọc/đổi bảng màu. Ném lỗi nếu gọi ngoài `<ContrastProvider>` — im lặng trả mặc định sẽ khiến\n * nút gạt trông như hỏng mà không có gì chỉ ra nguyên nhân.\n */\nexport function useContrast(): ContrastContextValue {\n const ctx = useContext(ContrastContext);\n if (!ctx) throw new Error(\"useContrast() phải nằm trong <ContrastProvider>. Bọc nó ở root layout.\");\n return ctx;\n}\n"],"mappings":";AA8FI,SACE,KADF;AA5FJ,SAAS,eAAe,aAAa,YAAY,WAAW,SAAS,gBAAgB;AACrF,SAAS,oBAAoB,sBAAyC,sBAAsB;AAU5F,MAAM,kBAAkB,cAA2C,IAAI;AAmChE,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA,kBAAkB;AAAA,EAClB,aAAa;AACf,GAA0B;AACxB,QAAM,CAAC,UAAU,gBAAgB,IAAI,SAAuB,eAAe;AAC3E,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAI5C,YAAU,MAAM;AACd,QAAI,SAAwB;AAC5B,QAAI;AACF,eAAS,OAAO,aAAa,QAAQ,UAAU;AAAA,IACjD,QAAQ;AAAA,IAER;AACA,QAAI,eAAe,MAAM,EAAG,kBAAiB,MAAM;AACnD,eAAW,IAAI;AAAA,EACjB,GAAG,CAAC,UAAU,CAAC;AAEf,YAAU,MAAM;AACd,QAAI,CAAC,QAAS;AACd,UAAM,OAAO,SAAS;AACtB,QAAI,aAAa,SAAU,MAAK,gBAAgB,kBAAkB;AAAA,QAC7D,MAAK,aAAa,oBAAoB,QAAQ;AAAA,EACrD,GAAG,CAAC,UAAU,OAAO,CAAC;AAEtB,QAAM,cAAc;AAAA,IAClB,CAAC,SAAuB;AACtB,uBAAiB,IAAI;AACrB,UAAI;AACF,eAAO,aAAa,QAAQ,YAAY,IAAI;AAAA,MAC9C,QAAQ;AAAA,MAER;AAAA,IACF;AAAA,IACA,CAAC,UAAU;AAAA,EACb;AAEA,QAAM,QAAQ;AAAA,IACZ,OAAO,EAAE,UAAU,aAAa,QAAQ;AAAA,IACxC,CAAC,UAAU,aAAa,OAAO;AAAA,EACjC;AAEA,SACE,qBAAC,gBAAgB,UAAhB,EAAyB,OACxB;AAAA,wBAAC,kBAAe,YAAwB;AAAA,IACvC;AAAA,KACH;AAEJ;AASO,SAAS,eAAe,EAAE,aAAa,qBAAqB,GAA4B;AAE7F,QAAM,SAAS,8CAA8C,KAAK,UAAU,UAAU,CAAC,yDAAyD,KAAK,UAAU,kBAAkB,CAAC;AAElL,SAAO,oBAAC,YAAO,0BAAwB,MAAC,yBAAyB,EAAE,QAAQ,OAAO,GAAG;AACvF;AAMO,SAAS,cAAoC;AAClD,QAAM,MAAM,WAAW,eAAe;AACtC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,+FAAwE;AAClG,SAAO;AACT;","names":[]}
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import { ContrastToggleLabels, ContrastMode } from './contrast-constants.js';
3
+
4
+ interface ContrastToggleProps {
5
+ className?: string;
6
+ /** Override chuỗi hiển thị (mặc định tiếng Việt). */
7
+ labels?: Partial<ContrastToggleLabels>;
8
+ /** Gọi sau khi người dùng đổi — để app hiện toast hoặc ghi log. */
9
+ onContrastChange?: (mode: ContrastMode) => void;
10
+ }
11
+ /**
12
+ * Nút gạt chọn BẢNG MÀU (bản duyệt comitor-ds ⇄ bản tương phản cao). Trục này độc lập với
13
+ * sáng/tối: hai bảng màu đều có bản light và bản dark riêng.
14
+ *
15
+ * Đặt ở trang cài đặt của app, cạnh `<ThemeToggle>`. Phải nằm trong `<ContrastProvider>`.
16
+ *
17
+ * Chống lệch hydration bằng cờ `mounted` của provider — cùng lối với `ThemeToggle`: lựa chọn thật
18
+ * chỉ đọc được ở client (localStorage), render thẳng trạng thái nút ngay lần đầu sẽ lệch với HTML
19
+ * do server sinh ra. Trước khi mount thì nút hiện ở trạng thái mặc định và bị khoá, để layout
20
+ * không nhảy và người dùng không bấm trúng một trạng thái sắp bị thay.
21
+ */
22
+ declare function ContrastToggle({ className, labels: labelsProp, onContrastChange }: ContrastToggleProps): React.JSX.Element;
23
+
24
+ export { ContrastToggle, type ContrastToggleProps };
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { Switch } from "../components/ui/switch.js";
4
+ import { cn } from "../lib/cn.js";
5
+ import { DEFAULT_CONTRAST_TOGGLE_LABELS } from "./contrast-constants.js";
6
+ import { useContrast } from "./contrast-provider.js";
7
+ function ContrastToggle({ className, labels: labelsProp, onContrastChange }) {
8
+ const { contrast, setContrast, mounted } = useContrast();
9
+ const labels = { ...DEFAULT_CONTRAST_TOGGLE_LABELS, ...labelsProp };
10
+ return /* @__PURE__ */ jsx(
11
+ Switch,
12
+ {
13
+ className: cn(className),
14
+ checked: contrast === "high",
15
+ disabled: !mounted,
16
+ label: labels.label,
17
+ description: labels.description,
18
+ onCheckedChange: (checked) => {
19
+ const next = checked ? "high" : "normal";
20
+ setContrast(next);
21
+ onContrastChange?.(next);
22
+ }
23
+ }
24
+ );
25
+ }
26
+ export {
27
+ ContrastToggle
28
+ };
29
+ //# sourceMappingURL=contrast-toggle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/contrast-toggle.tsx"],"sourcesContent":["\"use client\";\n\nimport { Switch } from \"../components/ui/switch.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { type ContrastMode, type ContrastToggleLabels, DEFAULT_CONTRAST_TOGGLE_LABELS } from \"./contrast-constants.js\";\nimport { useContrast } from \"./contrast-provider.js\";\n\nexport interface ContrastToggleProps {\n className?: string;\n /** Override chuỗi hiển thị (mặc định tiếng Việt). */\n labels?: Partial<ContrastToggleLabels>;\n /** Gọi sau khi người dùng đổi — để app hiện toast hoặc ghi log. */\n onContrastChange?: (mode: ContrastMode) => void;\n}\n\n/**\n * Nút gạt chọn BẢNG MÀU (bản duyệt comitor-ds ⇄ bản tương phản cao). Trục này độc lập với\n * sáng/tối: hai bảng màu đều có bản light và bản dark riêng.\n *\n * Đặt ở trang cài đặt của app, cạnh `<ThemeToggle>`. Phải nằm trong `<ContrastProvider>`.\n *\n * Chống lệch hydration bằng cờ `mounted` của provider — cùng lối với `ThemeToggle`: lựa chọn thật\n * chỉ đọc được ở client (localStorage), render thẳng trạng thái nút ngay lần đầu sẽ lệch với HTML\n * do server sinh ra. Trước khi mount thì nút hiện ở trạng thái mặc định và bị khoá, để layout\n * không nhảy và người dùng không bấm trúng một trạng thái sắp bị thay.\n */\nexport function ContrastToggle({ className, labels: labelsProp, onContrastChange }: ContrastToggleProps) {\n const { contrast, setContrast, mounted } = useContrast();\n const labels: ContrastToggleLabels = { ...DEFAULT_CONTRAST_TOGGLE_LABELS, ...labelsProp };\n\n return (\n <Switch\n className={cn(className)}\n checked={contrast === \"high\"}\n disabled={!mounted}\n label={labels.label}\n description={labels.description}\n onCheckedChange={(checked) => {\n const next: ContrastMode = checked ? \"high\" : \"normal\";\n setContrast(next);\n onContrastChange?.(next);\n }}\n />\n );\n}\n"],"mappings":";AA+BI;AA7BJ,SAAS,cAAc;AACvB,SAAS,UAAU;AACnB,SAAuD,sCAAsC;AAC7F,SAAS,mBAAmB;AAqBrB,SAAS,eAAe,EAAE,WAAW,QAAQ,YAAY,iBAAiB,GAAwB;AACvG,QAAM,EAAE,UAAU,aAAa,QAAQ,IAAI,YAAY;AACvD,QAAM,SAA+B,EAAE,GAAG,gCAAgC,GAAG,WAAW;AAExF,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAW,GAAG,SAAS;AAAA,MACvB,SAAS,aAAa;AAAA,MACtB,UAAU,CAAC;AAAA,MACX,OAAO,OAAO;AAAA,MACd,aAAa,OAAO;AAAA,MACpB,iBAAiB,CAAC,YAAY;AAC5B,cAAM,OAAqB,UAAU,SAAS;AAC9C,oBAAY,IAAI;AAChB,2BAAmB,IAAI;AAAA,MACzB;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -4,6 +4,9 @@ export { AppLauncher, AppLauncherPanel, AppLauncherPanelProps, AppLauncherProps
4
4
  export { AppShell, AppShellLayout, AppShellProps } from './app-shell.js';
5
5
  export { ComitorLockup, ComitorLockupProps, ComitorLockupSize, ComitorLockupVariant } from './comitor-lockup.js';
6
6
  export { ComitorLogo, ComitorLogoProps, ComitorLogoVariant } from './comitor-logo.js';
7
+ export { CONTRAST_ATTRIBUTE, CONTRAST_MODES, CONTRAST_STORAGE_KEY, ContrastMode, ContrastToggleLabels, DEFAULT_CONTRAST_TOGGLE_LABELS, isContrastMode } from './contrast-constants.js';
8
+ export { ContrastContextValue, ContrastProvider, ContrastProviderProps, ContrastScript, useContrast } from './contrast-provider.js';
9
+ export { ContrastToggle, ContrastToggleProps } from './contrast-toggle.js';
7
10
  export { MobileMenu, MobileMenuProps } from './mobile-menu.js';
8
11
  export { NotificationsButton, NotificationsButtonProps } from './notifications-button.js';
9
12
  export { ShellContextValue, ShellProvider, ShellProviderProps, useShell } from './shell-context.js';
@@ -4,6 +4,9 @@ export * from "./app-launcher.js";
4
4
  export * from "./app-shell.js";
5
5
  export * from "./comitor-lockup.js";
6
6
  export * from "./comitor-logo.js";
7
+ export * from "./contrast-constants.js";
8
+ export * from "./contrast-provider.js";
9
+ export * from "./contrast-toggle.js";
7
10
  export * from "./mobile-menu.js";
8
11
  export * from "./notifications-button.js";
9
12
  export * from "./shell-context.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/index.ts"],"sourcesContent":["/**\n * Entry \"@comitor/ui/shell\" — Tier 3: khung ứng dụng (AppShell, Sidebar, AppHeader,\n * WorkspaceSwitcher, AppLauncher, UserMenu, ThemeProvider…).\n *\n * Tách khỏi entry chính vì tầng này kéo theo `next/navigation` và `next-themes` — hai\n * peer OPTIONAL. App không chạy trên Next thì đừng import entry này (hoặc tự cấp\n * `ShellLinkComponent` riêng qua `ShellProvider`).\n *\n * Toàn bộ file ở đây là client component: có state, có routing, có phím tắt.\n */\n\n/**\n * Toaster bám theo next-themes — về tier thì nó là primitive (`components/ui/sonner.tsx`),\n * nhưng đặt cửa ra ở entry shell vì nó gọi `useTheme()`, mà `next-themes` chỉ là peer\n * OPTIONAL: để ở entry chính là ép mọi app phải cài next-themes.\n * Dùng chung với `ThemeProvider` ở trên. Nếu app KHÔNG dùng next-themes thì lấy composite\n * `Toaster` ở \"@comitor/ui\" và tự truyền prop `theme`.\n */\nexport { SonnerToaster, type SonnerToasterProps } from \"../components/ui/sonner.js\";\nexport * from \"./app-header.js\";\nexport * from \"./app-launcher.js\";\nexport * from \"./app-shell.js\";\nexport * from \"./comitor-lockup.js\";\nexport * from \"./comitor-logo.js\";\nexport * from \"./mobile-menu.js\";\nexport * from \"./notifications-button.js\";\nexport * from \"./shell-context.js\";\nexport * from \"./shell-link.js\";\nexport * from \"./sidebar.js\";\nexport * from \"./sidebar-group.js\";\nexport * from \"./sidebar-menu-item.js\";\nexport * from \"./theme-provider.js\";\nexport * from \"./theme-toggle.js\";\nexport * from \"./types.js\";\nexport * from \"./user-menu.js\";\nexport * from \"./utils.js\";\nexport * from \"./workspace-switcher.js\";\n"],"mappings":"AAkBA,SAAS,qBAA8C;AACvD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}
1
+ {"version":3,"sources":["../../src/shell/index.ts"],"sourcesContent":["/**\n * Entry \"@comitor/ui/shell\" — Tier 3: khung ứng dụng (AppShell, Sidebar, AppHeader,\n * WorkspaceSwitcher, AppLauncher, UserMenu, ThemeProvider…).\n *\n * Tách khỏi entry chính vì tầng này kéo theo `next/navigation` và `next-themes` — hai\n * peer OPTIONAL. App không chạy trên Next thì đừng import entry này (hoặc tự cấp\n * `ShellLinkComponent` riêng qua `ShellProvider`).\n *\n * Toàn bộ file ở đây là client component: có state, có routing, có phím tắt.\n */\n\n/**\n * Toaster bám theo next-themes — về tier thì nó là primitive (`components/ui/sonner.tsx`),\n * nhưng đặt cửa ra ở entry shell vì nó gọi `useTheme()`, mà `next-themes` chỉ là peer\n * OPTIONAL: để ở entry chính là ép mọi app phải cài next-themes.\n * Dùng chung với `ThemeProvider` ở trên. Nếu app KHÔNG dùng next-themes thì lấy composite\n * `Toaster` ở \"@comitor/ui\" và tự truyền prop `theme`.\n */\nexport { SonnerToaster, type SonnerToasterProps } from \"../components/ui/sonner.js\";\nexport * from \"./app-header.js\";\nexport * from \"./app-launcher.js\";\nexport * from \"./app-shell.js\";\nexport * from \"./comitor-lockup.js\";\nexport * from \"./comitor-logo.js\";\n/**\n * Hằng của trục tương phản xuất THẲNG từ module thuần — root layout là Server Component và nó\n * đọc CONTRAST_STORAGE_KEY. Đi vòng qua contrast-provider.tsx (\"use client\") là hằng thành proxy\n * và ra `undefined` mà không có lỗi nào.\n */\nexport * from \"./contrast-constants.js\";\nexport * from \"./contrast-provider.js\";\nexport * from \"./contrast-toggle.js\";\nexport * from \"./mobile-menu.js\";\nexport * from \"./notifications-button.js\";\nexport * from \"./shell-context.js\";\nexport * from \"./shell-link.js\";\nexport * from \"./sidebar.js\";\nexport * from \"./sidebar-group.js\";\nexport * from \"./sidebar-menu-item.js\";\nexport * from \"./theme-provider.js\";\nexport * from \"./theme-toggle.js\";\nexport * from \"./types.js\";\nexport * from \"./user-menu.js\";\nexport * from \"./utils.js\";\nexport * from \"./workspace-switcher.js\";\n"],"mappings":"AAkBA,SAAS,qBAA8C;AACvD,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAMd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;","names":[]}
@@ -1,13 +1,14 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
- import NextLink from "next/link";
4
- import { usePathname } from "next/navigation";
3
+ import NextLinkModule from "next/link.js";
4
+ import { usePathname } from "next/navigation.js";
5
5
  import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
6
6
  import { TooltipProvider } from "../components/ui/tooltip.js";
7
7
  import {
8
8
  DEFAULT_SHELL_LABELS
9
9
  } from "./types.js";
10
10
  import { getEntitledApps, resolveCurrentApp, toNavGroups, toSearchParams } from "./utils.js";
11
+ const NextLink = NextLinkModule.default ?? NextLinkModule;
11
12
  function DefaultShellLink({ href, children, ...props }) {
12
13
  return /* @__PURE__ */ jsx(NextLink, { href, ...props, children });
13
14
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/shell-context.tsx"],"sourcesContent":["\"use client\";\n\nimport NextLink from \"next/link\";\nimport { usePathname } from \"next/navigation\";\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { TooltipProvider } from \"../components/ui/tooltip.js\";\nimport {\n type AppDescriptor,\n DEFAULT_SHELL_LABELS,\n type NavGroup,\n type ShellLabels,\n type ShellLinkComponent,\n type ShellLinkProps,\n type ShellNav,\n type ShellUser,\n type Workspace\n} from \"./types.js\";\nimport { getEntitledApps, type NavSearchParams, resolveCurrentApp, toNavGroups, toSearchParams } from \"./utils.js\";\n\n/* ────────────────────────────────────────────────────────────────────────────\n Link mặc định\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Bọc `next/link` thay vì truyền thẳng: hình dạng props của NextLink rộng hơn\n * `ShellLinkProps` (href nhận cả UrlObject), bọc lại cho khớp type và để chỗ\n * thay adapter router khác về sau.\n */\nfunction DefaultShellLink({ href, children, ...props }: ShellLinkProps) {\n return (\n <NextLink href={href} {...props}>\n {children}\n </NextLink>\n );\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Context\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellContextValue {\n /* Trục 1 — workspace */\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /* Trục 2 — app */\n apps: AppDescriptor[];\n currentApp: AppDescriptor | null;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /* Điều hướng trong app hiện tại */\n nav: NavGroup[];\n pathname: string;\n /**\n * Query hiện tại đã chuẩn hoá, hoặc `null` khi app không truyền.\n * `null` KHÁC rỗng: rỗng nghĩa là \"URL không có query\", `null` nghĩa là \"shell không biết\"\n * — và active matching xử lý hai ca đó khác nhau (xem `isNavItemActive`).\n */\n searchParams: URLSearchParams | null;\n LinkComponent: ShellLinkComponent;\n onNavigate?: (href: string) => void;\n\n /* Người dùng */\n user: ShellUser | null;\n onLogout?: () => void;\n\n /* Trạng thái khung */\n sidebarCollapsed: boolean;\n setSidebarCollapsed: (collapsed: boolean) => void;\n toggleSidebar: () => void;\n mobileMenuOpen: boolean;\n setMobileMenuOpen: (open: boolean) => void;\n commandPaletteOpen: boolean;\n setCommandPaletteOpen: (open: boolean) => void;\n appLauncherOpen: boolean;\n setAppLauncherOpen: (open: boolean) => void;\n\n /* Chuỗi hiển thị (đã trộn mặc định tiếng Việt với override của app) */\n labels: ShellLabels;\n}\n\nconst ShellContext = createContext<ShellContextValue | undefined>(undefined);\n\n/** Truy cập state của shell — phải gọi bên trong `<ShellProvider>`. */\nexport function useShell(): ShellContextValue {\n const context = useContext(ShellContext);\n if (!context) {\n throw new Error(\"useShell phải được dùng bên trong <ShellProvider> (hoặc <AppShell>).\");\n }\n return context;\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Ghi nhớ trạng thái thu gọn sidebar\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Khoá localStorage theo workspace — mỗi workspace nhớ riêng trạng thái sidebar\n * (spec §7.6: KHÔNG dùng cookie `sidebar_state` của shadcn để tránh hai nguồn sự thật).\n */\nfunction sidebarStorageKey(workspaceId: string | undefined): string {\n return `comitor-sidebar:${workspaceId ?? \"default\"}`;\n}\n\nfunction readStoredCollapsed(workspaceId: string | undefined): boolean | null {\n if (typeof window === \"undefined\") return null;\n try {\n const raw = window.localStorage.getItem(sidebarStorageKey(workspaceId));\n if (raw === \"1\") return true;\n if (raw === \"0\") return false;\n return null;\n } catch {\n // Safari private mode / storage bị chặn — coi như chưa có giá trị lưu.\n return null;\n }\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Provider\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellProviderProps {\n /** Trục 1 — danh sách workspace của user. Rỗng/1 phần tử thì WorkspaceSwitcher tự ẩn danh sách. */\n workspaces?: Workspace[];\n /** Workspace đang mở; bỏ trống → lấy phần tử đầu. */\n currentWorkspaceId?: string;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /** Trục 2 — danh sách app của hệ sinh thái (kể cả app chưa mở khoá). */\n apps?: AppDescriptor[];\n /** App đang mở; bỏ trống → suy từ `pathname` (khớp prefix dài nhất). */\n currentAppId?: string;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /** Menu của app hiện tại — `NavItem[]` hoặc `NavGroup[]`. */\n nav?: ShellNav;\n /**\n * Ghi đè pathname (Storybook, app không-Next). Bỏ trống → dùng `usePathname()`\n * của next/navigation (trả `null` khi không có App Router → coi như \"\").\n */\n pathname?: string;\n /**\n * Query hiện tại — CHỈ cần khi menu có mục phân biệt nhau bằng query\n * (`/cai-dat?tab=chung` ⇄ `/cai-dat?tab=thanh-vien`). Bỏ trống thì mọi mục cùng đường dẫn\n * cùng sáng.\n *\n * Nhận `useSearchParams()` của Next, chuỗi `\"?tab=x\"`, hoặc object `searchParams` của\n * Server Component.\n *\n * ⚠ Shell KHÔNG tự gọi `useSearchParams()`: hook đó ép trang bọc `<Suspense>` và đẩy trang\n * sang render phía client khi build tĩnh — cái giá đó phải do app tự chọn, không phải do\n * việc lỡ import shell. Truyền vào từ một client component đã có sẵn boundary:\n *\n * ```tsx\n * \"use client\";\n * const searchParams = useSearchParams();\n * return <AppShell searchParams={searchParams} …>{children}</AppShell>;\n * ```\n */\n searchParams?: NavSearchParams | null;\n /** Component link thay `next/link` (app không-Next). */\n LinkComponent?: ShellLinkComponent;\n /** Gọi sau mỗi lần người dùng bấm một mục điều hướng (analytics, đóng panel…). */\n onNavigate?: (href: string) => void;\n\n user?: ShellUser | null;\n onLogout?: () => void;\n\n /** Trạng thái thu gọn ban đầu (SSR). Sau khi mount, localStorage của workspace thắng. */\n defaultSidebarCollapsed?: boolean;\n /** Tắt ghi nhớ localStorage (Storybook, chế độ kiosk). */\n persistSidebarState?: boolean;\n /** Tắt bộ phím tắt toàn cục. */\n enableShortcuts?: boolean;\n\n /** Override chuỗi hiển thị (mặc định tiếng Việt). */\n labels?: Partial<ShellLabels>;\n\n children: ReactNode;\n}\n\n/**\n * Provider của app shell Comitor: giữ hai trục điều hướng (workspace × app),\n * trạng thái khung (sidebar/mobile/command palette/app launcher), phím tắt toàn cục,\n * và bọc sẵn `TooltipProvider`.\n *\n * KHÔNG dùng `useRouter()` của next/navigation: hook đó *ném lỗi* khi thiếu App Router\n * (Storybook, test). Mọi điều hướng chủ động là việc của app qua callback\n * `onWorkspaceChange` / `onAppSelect` / `onNavigate`; link tĩnh đi qua `LinkComponent`.\n */\nexport function ShellProvider({\n workspaces = [],\n currentWorkspaceId,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps = [],\n currentAppId,\n onAppSelect,\n onUpsell,\n nav,\n pathname: pathnameProp,\n searchParams: searchParamsProp,\n LinkComponent = DefaultShellLink,\n onNavigate,\n user = null,\n onLogout,\n defaultSidebarCollapsed = false,\n persistSidebarState = true,\n enableShortcuts = true,\n labels: labelsProp,\n children\n}: ShellProviderProps) {\n // `usePathname()` trả null khi không có App Router → fallback chuỗi rỗng thay vì crash.\n const nextPathname = usePathname();\n const pathname = pathnameProp ?? nextPathname ?? \"\";\n\n // Chuẩn hoá MỘT lần ở đây thay vì mỗi lần `isNavItemActive` chạy: prop có thể là chuỗi\n // hoặc object, mà mỗi lần dựng `URLSearchParams` mới là một identity mới → mọi memo bên\n // dưới hỏng. Khoá memo là chính prop app truyền vào.\n const searchParams = useMemo(() => toSearchParams(searchParamsProp), [searchParamsProp]);\n\n const currentWorkspace = useMemo(\n () => workspaces.find((workspace) => workspace.id === currentWorkspaceId) ?? workspaces[0] ?? null,\n [workspaces, currentWorkspaceId]\n );\n\n const currentApp = useMemo(() => {\n if (currentAppId) return apps.find((app) => app.id === currentAppId) ?? null;\n return resolveCurrentApp(apps, pathname);\n }, [apps, currentAppId, pathname]);\n\n const navGroups = useMemo(() => toNavGroups(nav), [nav]);\n const labels = useMemo<ShellLabels>(() => ({ ...DEFAULT_SHELL_LABELS, ...labelsProp }), [labelsProp]);\n\n const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultSidebarCollapsed);\n const [mobileMenuOpen, setMobileMenuOpen] = useState(false);\n const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);\n const [appLauncherOpen, setAppLauncherOpen] = useState(false);\n\n // Nạp trạng thái đã lưu SAU khi mount (đọc localStorage lúc render đầu sẽ lệch hydration).\n useEffect(() => {\n if (!persistSidebarState) return;\n const stored = readStoredCollapsed(currentWorkspace?.id);\n if (stored !== null) setSidebarCollapsed(stored);\n }, [persistSidebarState, currentWorkspace?.id]);\n\n const persistCollapsed = useCallback(\n (collapsed: boolean) => {\n if (!persistSidebarState || typeof window === \"undefined\") return;\n try {\n window.localStorage.setItem(sidebarStorageKey(currentWorkspace?.id), collapsed ? \"1\" : \"0\");\n } catch {\n // Bỏ qua: không ghi được localStorage thì trạng thái chỉ sống trong phiên.\n }\n },\n [persistSidebarState, currentWorkspace?.id]\n );\n\n const applySidebarCollapsed = useCallback(\n (collapsed: boolean) => {\n setSidebarCollapsed(collapsed);\n persistCollapsed(collapsed);\n },\n [persistCollapsed]\n );\n\n const toggleSidebar = useCallback(() => {\n setSidebarCollapsed((prev) => {\n const next = !prev;\n persistCollapsed(next);\n return next;\n });\n }, [persistCollapsed]);\n\n // Phím tắt toàn cục (§7.4)\n useEffect(() => {\n if (!enableShortcuts) return;\n\n const entitledApps = getEntitledApps(apps);\n\n const handleKeyDown = (event: KeyboardEvent) => {\n const target = event.target;\n // Đang gõ trong ô nhập → nhường phím cho ô nhập.\n const isTyping =\n target instanceof HTMLInputElement ||\n target instanceof HTMLTextAreaElement ||\n target instanceof HTMLSelectElement ||\n (target instanceof HTMLElement && target.isContentEditable);\n\n if (event.key === \"Escape\") {\n setCommandPaletteOpen(false);\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n return;\n }\n\n if (isTyping) return;\n\n const mod = event.metaKey || event.ctrlKey;\n const key = event.key.toLowerCase();\n\n // Cmd/Ctrl + Shift + K — AppLauncher (phải xét TRƯỚC Cmd+K vì cùng phím \"k\")\n if (mod && event.shiftKey && key === \"k\") {\n event.preventDefault();\n setAppLauncherOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + K — Command Palette\n if (mod && !event.shiftKey && key === \"k\") {\n event.preventDefault();\n setCommandPaletteOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + B — thu gọn/mở sidebar\n if (mod && !event.shiftKey && key === \"b\") {\n event.preventDefault();\n toggleSidebar();\n return;\n }\n\n // Alt + 1…9 — nhảy tới app thứ n đã được cấp quyền.\n // Dùng `event.code` chứ không `event.key`: trên macOS Alt+1 cho ra \"¡\".\n if (event.altKey && !event.ctrlKey && !event.metaKey) {\n const match = /^Digit([1-9])$/.exec(event.code);\n if (match?.[1]) {\n const index = Number.parseInt(match[1], 10) - 1;\n const app = entitledApps[index];\n if (app) {\n event.preventDefault();\n onAppSelect?.(app);\n }\n }\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [enableShortcuts, apps, onAppSelect, toggleSidebar]);\n\n // Đổi route → đóng mọi overlay đang mở.\n // biome-ignore lint/correctness/useExhaustiveDependencies: chủ đích chạy theo MỌI lần đổi pathname\n useEffect(() => {\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n setCommandPaletteOpen(false);\n }, [pathname]);\n\n const value = useMemo<ShellContextValue>(\n () => ({\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n nav: navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n setSidebarCollapsed: applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n setMobileMenuOpen,\n commandPaletteOpen,\n setCommandPaletteOpen,\n appLauncherOpen,\n setAppLauncherOpen,\n labels\n }),\n [\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n commandPaletteOpen,\n appLauncherOpen,\n labels\n ]\n );\n\n return (\n <ShellContext.Provider value={value}>\n <TooltipProvider delayDuration={200}>{children}</TooltipProvider>\n </ShellContext.Provider>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/shell/shell-context.tsx"],"sourcesContent":["\"use client\";\n\n/*\n * ĐUÔI `.js` LÀ CHỦ ĐÍCH, ĐỪNG BỎ.\n *\n * Gói `next` KHÔNG khai `exports` map, nên `next/link` chỉ là đường dẫn file. Bundler\n * (webpack/Turbopack) tự thêm đuôi rồi trỏ đúng `node_modules/next/link.js`; Node ESM thuần thì\n * KHÔNG — nó ném `ERR_MODULE_NOT_FOUND: Cannot find module .../next/link` kèm gợi ý\n * \"Did you mean next/link.js?\". Hậu quả: `node -e \"await import('@comitor/ui/shell')\"` chết ngay\n * lúc nạp, tức là mọi smoke-test entry của CI đều mù với riêng entry shell.\n *\n * Ghi thẳng `next/link.js` / `next/navigation.js` là CÙNG MỘT file mà bundler vẫn resolve tới\n * (đã đối chiếu: `next` không có `resolve.alias` cho hai specifier này trong\n * `dist/build/create-compiler-aliases.js`), nhưng Node cũng nạp được.\n */\nimport NextLinkModule from \"next/link.js\";\nimport { usePathname } from \"next/navigation.js\";\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport { TooltipProvider } from \"../components/ui/tooltip.js\";\nimport {\n type AppDescriptor,\n DEFAULT_SHELL_LABELS,\n type NavGroup,\n type ShellLabels,\n type ShellLinkComponent,\n type ShellLinkProps,\n type ShellNav,\n type ShellUser,\n type Workspace\n} from \"./types.js\";\nimport { getEntitledApps, type NavSearchParams, resolveCurrentApp, toNavGroups, toSearchParams } from \"./utils.js\";\n\n/* ────────────────────────────────────────────────────────────────────────────\n Link mặc định\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Chuẩn hoá interop của `next/link.js`.\n *\n * Hai môi trường trả về hai hình dạng khác nhau cho cùng một file CJS:\n * - bundler → đã interop sẵn, biến này CHÍNH LÀ component `Link`\n * - Node ESM → biến này là namespace `{ default: Link, useLinkStatus }`\n * `.default ?? chính nó` đúng ở cả hai (component `Link` không có thuộc tính `default`).\n */\nconst NextLink = ((NextLinkModule as unknown as { default?: ShellLinkComponent }).default ??\n NextLinkModule) as ShellLinkComponent;\n\n/**\n * Bọc `next/link` thay vì truyền thẳng: hình dạng props của NextLink rộng hơn\n * `ShellLinkProps` (href nhận cả UrlObject), bọc lại cho khớp type và để chỗ\n * thay adapter router khác về sau.\n */\nfunction DefaultShellLink({ href, children, ...props }: ShellLinkProps) {\n return (\n <NextLink href={href} {...props}>\n {children}\n </NextLink>\n );\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Context\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellContextValue {\n /* Trục 1 — workspace */\n workspaces: Workspace[];\n currentWorkspace: Workspace | null;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /* Trục 2 — app */\n apps: AppDescriptor[];\n currentApp: AppDescriptor | null;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /* Điều hướng trong app hiện tại */\n nav: NavGroup[];\n pathname: string;\n /**\n * Query hiện tại đã chuẩn hoá, hoặc `null` khi app không truyền.\n * `null` KHÁC rỗng: rỗng nghĩa là \"URL không có query\", `null` nghĩa là \"shell không biết\"\n * — và active matching xử lý hai ca đó khác nhau (xem `isNavItemActive`).\n */\n searchParams: URLSearchParams | null;\n LinkComponent: ShellLinkComponent;\n onNavigate?: (href: string) => void;\n\n /* Người dùng */\n user: ShellUser | null;\n onLogout?: () => void;\n\n /* Trạng thái khung */\n sidebarCollapsed: boolean;\n setSidebarCollapsed: (collapsed: boolean) => void;\n toggleSidebar: () => void;\n mobileMenuOpen: boolean;\n setMobileMenuOpen: (open: boolean) => void;\n commandPaletteOpen: boolean;\n setCommandPaletteOpen: (open: boolean) => void;\n appLauncherOpen: boolean;\n setAppLauncherOpen: (open: boolean) => void;\n\n /* Chuỗi hiển thị (đã trộn mặc định tiếng Việt với override của app) */\n labels: ShellLabels;\n}\n\nconst ShellContext = createContext<ShellContextValue | undefined>(undefined);\n\n/** Truy cập state của shell — phải gọi bên trong `<ShellProvider>`. */\nexport function useShell(): ShellContextValue {\n const context = useContext(ShellContext);\n if (!context) {\n throw new Error(\"useShell phải được dùng bên trong <ShellProvider> (hoặc <AppShell>).\");\n }\n return context;\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Ghi nhớ trạng thái thu gọn sidebar\n ──────────────────────────────────────────────────────────────────────────── */\n\n/**\n * Khoá localStorage theo workspace — mỗi workspace nhớ riêng trạng thái sidebar\n * (spec §7.6: KHÔNG dùng cookie `sidebar_state` của shadcn để tránh hai nguồn sự thật).\n */\nfunction sidebarStorageKey(workspaceId: string | undefined): string {\n return `comitor-sidebar:${workspaceId ?? \"default\"}`;\n}\n\nfunction readStoredCollapsed(workspaceId: string | undefined): boolean | null {\n if (typeof window === \"undefined\") return null;\n try {\n const raw = window.localStorage.getItem(sidebarStorageKey(workspaceId));\n if (raw === \"1\") return true;\n if (raw === \"0\") return false;\n return null;\n } catch {\n // Safari private mode / storage bị chặn — coi như chưa có giá trị lưu.\n return null;\n }\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Provider\n ──────────────────────────────────────────────────────────────────────────── */\n\nexport interface ShellProviderProps {\n /** Trục 1 — danh sách workspace của user. Rỗng/1 phần tử thì WorkspaceSwitcher tự ẩn danh sách. */\n workspaces?: Workspace[];\n /** Workspace đang mở; bỏ trống → lấy phần tử đầu. */\n currentWorkspaceId?: string;\n onWorkspaceChange?: (workspaceId: string) => void;\n onCreateWorkspace?: () => void;\n onWorkspaceSettings?: (workspaceId: string) => void;\n onInviteMembers?: (workspaceId: string) => void;\n\n /** Trục 2 — danh sách app của hệ sinh thái (kể cả app chưa mở khoá). */\n apps?: AppDescriptor[];\n /** App đang mở; bỏ trống → suy từ `pathname` (khớp prefix dài nhất). */\n currentAppId?: string;\n onAppSelect?: (app: AppDescriptor) => void;\n onUpsell?: (appId: string) => void;\n\n /** Menu của app hiện tại — `NavItem[]` hoặc `NavGroup[]`. */\n nav?: ShellNav;\n /**\n * Ghi đè pathname (Storybook, app không-Next). Bỏ trống → dùng `usePathname()`\n * của next/navigation (trả `null` khi không có App Router → coi như \"\").\n */\n pathname?: string;\n /**\n * Query hiện tại — CHỈ cần khi menu có mục phân biệt nhau bằng query\n * (`/cai-dat?tab=chung` ⇄ `/cai-dat?tab=thanh-vien`). Bỏ trống thì mọi mục cùng đường dẫn\n * cùng sáng.\n *\n * Nhận `useSearchParams()` của Next, chuỗi `\"?tab=x\"`, hoặc object `searchParams` của\n * Server Component.\n *\n * ⚠ Shell KHÔNG tự gọi `useSearchParams()`: hook đó ép trang bọc `<Suspense>` và đẩy trang\n * sang render phía client khi build tĩnh — cái giá đó phải do app tự chọn, không phải do\n * việc lỡ import shell. Truyền vào từ một client component đã có sẵn boundary:\n *\n * ```tsx\n * \"use client\";\n * const searchParams = useSearchParams();\n * return <AppShell searchParams={searchParams} …>{children}</AppShell>;\n * ```\n */\n searchParams?: NavSearchParams | null;\n /** Component link thay `next/link` (app không-Next). */\n LinkComponent?: ShellLinkComponent;\n /** Gọi sau mỗi lần người dùng bấm một mục điều hướng (analytics, đóng panel…). */\n onNavigate?: (href: string) => void;\n\n user?: ShellUser | null;\n onLogout?: () => void;\n\n /** Trạng thái thu gọn ban đầu (SSR). Sau khi mount, localStorage của workspace thắng. */\n defaultSidebarCollapsed?: boolean;\n /** Tắt ghi nhớ localStorage (Storybook, chế độ kiosk). */\n persistSidebarState?: boolean;\n /** Tắt bộ phím tắt toàn cục. */\n enableShortcuts?: boolean;\n\n /** Override chuỗi hiển thị (mặc định tiếng Việt). */\n labels?: Partial<ShellLabels>;\n\n children: ReactNode;\n}\n\n/**\n * Provider của app shell Comitor: giữ hai trục điều hướng (workspace × app),\n * trạng thái khung (sidebar/mobile/command palette/app launcher), phím tắt toàn cục,\n * và bọc sẵn `TooltipProvider`.\n *\n * KHÔNG dùng `useRouter()` của next/navigation: hook đó *ném lỗi* khi thiếu App Router\n * (Storybook, test). Mọi điều hướng chủ động là việc của app qua callback\n * `onWorkspaceChange` / `onAppSelect` / `onNavigate`; link tĩnh đi qua `LinkComponent`.\n */\nexport function ShellProvider({\n workspaces = [],\n currentWorkspaceId,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps = [],\n currentAppId,\n onAppSelect,\n onUpsell,\n nav,\n pathname: pathnameProp,\n searchParams: searchParamsProp,\n LinkComponent = DefaultShellLink,\n onNavigate,\n user = null,\n onLogout,\n defaultSidebarCollapsed = false,\n persistSidebarState = true,\n enableShortcuts = true,\n labels: labelsProp,\n children\n}: ShellProviderProps) {\n // `usePathname()` trả null khi không có App Router → fallback chuỗi rỗng thay vì crash.\n const nextPathname = usePathname();\n const pathname = pathnameProp ?? nextPathname ?? \"\";\n\n // Chuẩn hoá MỘT lần ở đây thay vì mỗi lần `isNavItemActive` chạy: prop có thể là chuỗi\n // hoặc object, mà mỗi lần dựng `URLSearchParams` mới là một identity mới → mọi memo bên\n // dưới hỏng. Khoá memo là chính prop app truyền vào.\n const searchParams = useMemo(() => toSearchParams(searchParamsProp), [searchParamsProp]);\n\n const currentWorkspace = useMemo(\n () => workspaces.find((workspace) => workspace.id === currentWorkspaceId) ?? workspaces[0] ?? null,\n [workspaces, currentWorkspaceId]\n );\n\n const currentApp = useMemo(() => {\n if (currentAppId) return apps.find((app) => app.id === currentAppId) ?? null;\n return resolveCurrentApp(apps, pathname);\n }, [apps, currentAppId, pathname]);\n\n const navGroups = useMemo(() => toNavGroups(nav), [nav]);\n const labels = useMemo<ShellLabels>(() => ({ ...DEFAULT_SHELL_LABELS, ...labelsProp }), [labelsProp]);\n\n const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultSidebarCollapsed);\n const [mobileMenuOpen, setMobileMenuOpen] = useState(false);\n const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);\n const [appLauncherOpen, setAppLauncherOpen] = useState(false);\n\n // Nạp trạng thái đã lưu SAU khi mount (đọc localStorage lúc render đầu sẽ lệch hydration).\n useEffect(() => {\n if (!persistSidebarState) return;\n const stored = readStoredCollapsed(currentWorkspace?.id);\n if (stored !== null) setSidebarCollapsed(stored);\n }, [persistSidebarState, currentWorkspace?.id]);\n\n const persistCollapsed = useCallback(\n (collapsed: boolean) => {\n if (!persistSidebarState || typeof window === \"undefined\") return;\n try {\n window.localStorage.setItem(sidebarStorageKey(currentWorkspace?.id), collapsed ? \"1\" : \"0\");\n } catch {\n // Bỏ qua: không ghi được localStorage thì trạng thái chỉ sống trong phiên.\n }\n },\n [persistSidebarState, currentWorkspace?.id]\n );\n\n const applySidebarCollapsed = useCallback(\n (collapsed: boolean) => {\n setSidebarCollapsed(collapsed);\n persistCollapsed(collapsed);\n },\n [persistCollapsed]\n );\n\n const toggleSidebar = useCallback(() => {\n setSidebarCollapsed((prev) => {\n const next = !prev;\n persistCollapsed(next);\n return next;\n });\n }, [persistCollapsed]);\n\n // Phím tắt toàn cục (§7.4)\n useEffect(() => {\n if (!enableShortcuts) return;\n\n const entitledApps = getEntitledApps(apps);\n\n const handleKeyDown = (event: KeyboardEvent) => {\n const target = event.target;\n // Đang gõ trong ô nhập → nhường phím cho ô nhập.\n const isTyping =\n target instanceof HTMLInputElement ||\n target instanceof HTMLTextAreaElement ||\n target instanceof HTMLSelectElement ||\n (target instanceof HTMLElement && target.isContentEditable);\n\n if (event.key === \"Escape\") {\n setCommandPaletteOpen(false);\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n return;\n }\n\n if (isTyping) return;\n\n const mod = event.metaKey || event.ctrlKey;\n const key = event.key.toLowerCase();\n\n // Cmd/Ctrl + Shift + K — AppLauncher (phải xét TRƯỚC Cmd+K vì cùng phím \"k\")\n if (mod && event.shiftKey && key === \"k\") {\n event.preventDefault();\n setAppLauncherOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + K — Command Palette\n if (mod && !event.shiftKey && key === \"k\") {\n event.preventDefault();\n setCommandPaletteOpen((prev) => !prev);\n return;\n }\n\n // Cmd/Ctrl + B — thu gọn/mở sidebar\n if (mod && !event.shiftKey && key === \"b\") {\n event.preventDefault();\n toggleSidebar();\n return;\n }\n\n // Alt + 1…9 — nhảy tới app thứ n đã được cấp quyền.\n // Dùng `event.code` chứ không `event.key`: trên macOS Alt+1 cho ra \"¡\".\n if (event.altKey && !event.ctrlKey && !event.metaKey) {\n const match = /^Digit([1-9])$/.exec(event.code);\n if (match?.[1]) {\n const index = Number.parseInt(match[1], 10) - 1;\n const app = entitledApps[index];\n if (app) {\n event.preventDefault();\n onAppSelect?.(app);\n }\n }\n }\n };\n\n window.addEventListener(\"keydown\", handleKeyDown);\n return () => window.removeEventListener(\"keydown\", handleKeyDown);\n }, [enableShortcuts, apps, onAppSelect, toggleSidebar]);\n\n // Đổi route → đóng mọi overlay đang mở.\n // biome-ignore lint/correctness/useExhaustiveDependencies: chủ đích chạy theo MỌI lần đổi pathname\n useEffect(() => {\n setMobileMenuOpen(false);\n setAppLauncherOpen(false);\n setCommandPaletteOpen(false);\n }, [pathname]);\n\n const value = useMemo<ShellContextValue>(\n () => ({\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n nav: navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n setSidebarCollapsed: applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n setMobileMenuOpen,\n commandPaletteOpen,\n setCommandPaletteOpen,\n appLauncherOpen,\n setAppLauncherOpen,\n labels\n }),\n [\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n apps,\n currentApp,\n onAppSelect,\n onUpsell,\n navGroups,\n pathname,\n searchParams,\n LinkComponent,\n onNavigate,\n user,\n onLogout,\n sidebarCollapsed,\n applySidebarCollapsed,\n toggleSidebar,\n mobileMenuOpen,\n commandPaletteOpen,\n appLauncherOpen,\n labels\n ]\n );\n\n return (\n <ShellContext.Provider value={value}>\n <TooltipProvider delayDuration={200}>{children}</TooltipProvider>\n </ShellContext.Provider>\n );\n}\n"],"mappings":"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","names":[]}