@comitor/ui 0.9.1 → 1.1.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.
- package/README.md +158 -858
- package/dist/components/composite/avatar-group.d.ts +18 -2
- package/dist/components/composite/avatar-group.js +2 -5
- package/dist/components/composite/avatar-group.js.map +1 -1
- package/dist/components/composite/combobox.d.ts +31 -2
- package/dist/components/composite/combobox.js +12 -9
- package/dist/components/composite/combobox.js.map +1 -1
- package/dist/components/composite/command-palette-constants.d.ts +35 -0
- package/dist/components/composite/command-palette-constants.js +8 -0
- package/dist/components/composite/command-palette-constants.js.map +1 -0
- package/dist/components/composite/command-palette.d.ts +4 -1
- package/dist/components/composite/command-palette.js +5 -2
- package/dist/components/composite/command-palette.js.map +1 -1
- package/dist/components/composite/confirm-dialog.d.ts +11 -2
- package/dist/components/composite/confirm-dialog.js +2 -1
- package/dist/components/composite/confirm-dialog.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/shell/contrast-provider.d.ts +10 -2
- package/dist/shell/contrast-provider.js +12 -4
- package/dist/shell/contrast-provider.js.map +1 -1
- package/dist/shell/display-axis-provider.d.ts +30 -4
- package/dist/shell/display-axis-provider.js +12 -4
- package/dist/shell/display-axis-provider.js.map +1 -1
- package/dist/shell/mobile-menu.js +1 -0
- package/dist/shell/mobile-menu.js.map +1 -1
- package/dist/shell/types.d.ts +8 -1
- package/dist/shell/types.js +1 -1
- package/dist/shell/types.js.map +1 -1
- package/dist/shell/workspace-switcher.js +1 -1
- package/dist/shell/workspace-switcher.js.map +1 -1
- package/package.json +3 -7
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/confirm-dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { type ReactNode, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger\n} from \"../ui/alert-dialog.js\";\nimport { buttonVariants } from \"../ui/button.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\nexport interface ConfirmDialogProps {\n /** Controlled — bỏ trống thì dialog tự quản trạng thái mở (dùng kèm `trigger`) */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Phần tử mở dialog (đặt trong `AlertDialogTrigger asChild`) */\n trigger?: ReactNode;\n\n title: ReactNode;\n description?: ReactNode;\n /** Nội dung phụ (danh sách bản ghi sẽ bị ảnh hưởng, ô nhập lý do…) */\n children?: ReactNode;\n icon?: IconComponent;\n\n confirmLabel?: string;\n cancelLabel?: string;\n /** \"destructive\" tô nút xác nhận màu đỏ — dùng cho xoá/thu hồi */\n variant?: \"default\" | \"destructive\";\n /**\n * Hành động khi xác nhận. Trả về Promise thì dialog giữ nguyên và bật trạng thái đang chạy\n * cho tới khi Promise xong — đóng ngay rồi mới gọi API là cách chắc chắn nhất để người dùng\n * bấm xoá hai lần.\n */\n onConfirm: () => void | Promise<void>;\n /** Khoá nút xác nhận (vd chưa gõ đúng tên bản ghi cần xoá) */\n confirmDisabled?: boolean;\n /** Nhận lỗi từ `onConfirm` để app hiện toast; không truyền thì lỗi được ghi ra console */\n onError?: (error: unknown) => void;\n className?: string;\n}\n\n/**\n * Hộp thoại xác nhận cho hành động không hoàn tác được (xoá việc, gỡ thành viên, huỷ gói).\n *\n * Bọc `AlertDialog` để mọi app có cùng một cách hỏi lại; nút xác nhận tự vào trạng thái\n * \"Đang xử lý…\" khi `onConfirm` bất đồng bộ, và lỗi ném ra sẽ
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/confirm-dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { type ReactNode, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger\n} from \"../ui/alert-dialog.js\";\nimport { buttonVariants } from \"../ui/button.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\nexport interface ConfirmDialogProps {\n /** Controlled — bỏ trống thì dialog tự quản trạng thái mở (dùng kèm `trigger`) */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Phần tử mở dialog (đặt trong `AlertDialogTrigger asChild`) */\n trigger?: ReactNode;\n\n title: ReactNode;\n description?: ReactNode;\n /** Nội dung phụ (danh sách bản ghi sẽ bị ảnh hưởng, ô nhập lý do…) */\n children?: ReactNode;\n icon?: IconComponent;\n\n confirmLabel?: string;\n cancelLabel?: string;\n /**\n * Nhãn nút xác nhận trong lúc `onConfirm` bất đồng bộ đang chạy (mặc định \"Đang xử lý…\").\n *\n * Phải là prop chứ không được chôn cứng: tài liệu khuyến khích `onConfirm` trả về Promise, nên\n * đây KHÔNG phải nhánh hiếm — app tiếng Anh đặt `confirmLabel=\"Delete\"` vẫn thấy nút nhảy sang\n * tiếng Việt đúng vào giây người dùng đang nhìn nó.\n */\n pendingLabel?: string;\n /** \"destructive\" tô nút xác nhận màu đỏ — dùng cho xoá/thu hồi */\n variant?: \"default\" | \"destructive\";\n /**\n * Hành động khi xác nhận. Trả về Promise thì dialog giữ nguyên và bật trạng thái đang chạy\n * cho tới khi Promise xong — đóng ngay rồi mới gọi API là cách chắc chắn nhất để người dùng\n * bấm xoá hai lần.\n */\n onConfirm: () => void | Promise<void>;\n /** Khoá nút xác nhận (vd chưa gõ đúng tên bản ghi cần xoá) */\n confirmDisabled?: boolean;\n /** Nhận lỗi từ `onConfirm` để app hiện toast; không truyền thì lỗi được ghi ra console */\n onError?: (error: unknown) => void;\n className?: string;\n}\n\n/**\n * Hộp thoại xác nhận cho hành động không hoàn tác được (xoá việc, gỡ thành viên, huỷ gói).\n *\n * Bọc `AlertDialog` để mọi app có cùng một cách hỏi lại; nút xác nhận tự vào trạng thái\n * \"Đang xử lý…\" (đổi được qua `pendingLabel`) khi `onConfirm` bất đồng bộ, và lỗi ném ra sẽ giữ\n * dialog mở để người dùng thử lại.\n */\nexport function ConfirmDialog({\n open,\n onOpenChange,\n trigger,\n title,\n description,\n children,\n icon: Icon,\n confirmLabel = \"Xác nhận\",\n cancelLabel = \"Hủy\",\n pendingLabel = \"Đang xử lý…\",\n variant = \"default\",\n onConfirm,\n confirmDisabled = false,\n onError,\n className\n}: ConfirmDialogProps) {\n const [internalOpen, setInternalOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const isOpen = open ?? internalOpen;\n const setOpen = (next: boolean) => {\n if (pending && !next) return; // đang chạy thì không cho đóng ngang\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n };\n\n const handleConfirm = async () => {\n try {\n setPending(true);\n await onConfirm();\n setPending(false);\n if (open === undefined) setInternalOpen(false);\n onOpenChange?.(false);\n } catch (error) {\n // Giữ dialog mở để người dùng thấy toast lỗi của app và thử lại.\n // KHÔNG ném tiếp: lời gọi là `void handleConfirm()` trong handler nên ném ra chỉ tạo\n // unhandled rejection mà app không bắt được — lỗi đi ra ngoài qua `onError`.\n setPending(false);\n if (onError) onError(error);\n else console.error(\"[ConfirmDialog] onConfirm thất bại:\", error);\n }\n };\n\n return (\n <AlertDialog open={isOpen} onOpenChange={setOpen}>\n {trigger && <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>}\n <AlertDialogContent className={className}>\n <AlertDialogHeader>\n <div className=\"flex items-start gap-3\">\n {Icon && (\n <span\n aria-hidden=\"true\"\n // Cùng khuôn với `statCardIconVariants`: nền tint `--x` + icon bậc `--x-ink`.\n // `text-destructive` trên `bg-destructive/15` chỉ 4,06:1 (light) / 3,14:1 (dark) —\n // qua ngưỡng 3:1 của icon nhưng hết biên an toàn. Ink: 5,76:1 / 5,71:1 ✓.\n className={cn(\n \"flex size-9 shrink-0 items-center justify-center rounded-md\",\n variant === \"destructive\"\n ? \"bg-destructive/15 text-destructive-ink\"\n : \"bg-muted text-muted-foreground\"\n )}\n >\n <Icon className=\"size-5\" />\n </span>\n )}\n <div className=\"min-w-0 space-y-1.5\">\n <AlertDialogTitle>{title}</AlertDialogTitle>\n {description && <AlertDialogDescription>{description}</AlertDialogDescription>}\n </div>\n </div>\n </AlertDialogHeader>\n\n {children}\n\n <AlertDialogFooter>\n <AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>\n <AlertDialogAction\n disabled={pending || confirmDisabled}\n onClick={(event) => {\n // Chặn hành vi tự đóng của Radix: dialog chỉ đóng sau khi onConfirm chạy xong.\n event.preventDefault();\n void handleConfirm();\n }}\n className={cn(variant === \"destructive\" && buttonVariants({ variant: \"destructive\" }))}\n >\n {pending ? pendingLabel : confirmLabel}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n );\n}\n"],"mappings":";AA6GkB,cAoBN,YApBM;AA3GlB,SAAyB,gBAAgB;AACzC,SAAS,UAAU;AACnB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAgDxB,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,eAAe;AAAA,EACf,cAAc;AAAA,EACd,eAAe;AAAA,EACf,UAAU;AAAA,EACV;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AACF,GAAuB;AACrB,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AACtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAE5C,QAAM,SAAS,QAAQ;AACvB,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,WAAW,CAAC,KAAM;AACtB,QAAI,SAAS,OAAW,iBAAgB,IAAI;AAC5C,mBAAe,IAAI;AAAA,EACrB;AAEA,QAAM,gBAAgB,YAAY;AAChC,QAAI;AACF,iBAAW,IAAI;AACf,YAAM,UAAU;AAChB,iBAAW,KAAK;AAChB,UAAI,SAAS,OAAW,iBAAgB,KAAK;AAC7C,qBAAe,KAAK;AAAA,IACtB,SAAS,OAAO;AAId,iBAAW,KAAK;AAChB,UAAI,QAAS,SAAQ,KAAK;AAAA,UACrB,SAAQ,MAAM,iDAAuC,KAAK;AAAA,IACjE;AAAA,EACF;AAEA,SACE,qBAAC,eAAY,MAAM,QAAQ,cAAc,SACtC;AAAA,eAAW,oBAAC,sBAAmB,SAAO,MAAE,mBAAQ;AAAA,IACjD,qBAAC,sBAAmB,WAClB;AAAA,0BAAC,qBACC,+BAAC,SAAI,WAAU,0BACZ;AAAA,gBACC;AAAA,UAAC;AAAA;AAAA,YACC,eAAY;AAAA,YAIZ,WAAW;AAAA,cACT;AAAA,cACA,YAAY,gBACR,2CACA;AAAA,YACN;AAAA,YAEA,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,QAC3B;AAAA,QAEF,qBAAC,SAAI,WAAU,uBACb;AAAA,8BAAC,oBAAkB,iBAAM;AAAA,UACxB,eAAe,oBAAC,0BAAwB,uBAAY;AAAA,WACvD;AAAA,SACF,GACF;AAAA,MAEC;AAAA,MAED,qBAAC,qBACC;AAAA,4BAAC,qBAAkB,UAAU,SAAU,uBAAY;AAAA,QACnD;AAAA,UAAC;AAAA;AAAA,YACC,UAAU,WAAW;AAAA,YACrB,SAAS,CAAC,UAAU;AAElB,oBAAM,eAAe;AACrB,mBAAK,cAAc;AAAA,YACrB;AAAA,YACA,WAAW,GAAG,YAAY,iBAAiB,eAAe,EAAE,SAAS,cAAc,CAAC,CAAC;AAAA,YAEpF,oBAAU,eAAe;AAAA;AAAA,QAC5B;AAAA,SACF;AAAA,OACF;AAAA,KACF;AAEJ;","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { AvatarGroup, AvatarGroupItem, AvatarGroupProps } from './components/composite/avatar-group.js';
|
|
2
2
|
export { Combobox, ComboboxOption, ComboboxProps, MultiCombobox, MultiComboboxProps } from './components/composite/combobox.js';
|
|
3
3
|
export { CommandPalette, CommandPaletteGroup, CommandPaletteItem, CommandPaletteProps } from './components/composite/command-palette.js';
|
|
4
|
+
export { CommandPaletteLabels, DEFAULT_COMMAND_PALETTE_LABELS } from './components/composite/command-palette-constants.js';
|
|
4
5
|
export { ConfirmDialog, ConfirmDialogProps } from './components/composite/confirm-dialog.js';
|
|
5
6
|
export { CopyButton, CopyButtonProps } from './components/composite/copy-button.js';
|
|
6
7
|
export { DataTable, DataTableAlign, DataTableColumn, DataTableColumnToggle, DataTableColumnToggleProps, DataTableProps, DataTableSort, SortDirection } from './components/composite/data-table.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from "./components/composite/avatar-group.js";
|
|
2
2
|
export * from "./components/composite/combobox.js";
|
|
3
3
|
export * from "./components/composite/command-palette.js";
|
|
4
|
+
export * from "./components/composite/command-palette-constants.js";
|
|
4
5
|
export * from "./components/composite/confirm-dialog.js";
|
|
5
6
|
export * from "./components/composite/copy-button.js";
|
|
6
7
|
export * from "./components/composite/data-table.js";
|
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/*\n * ⚠
|
|
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 * ⚠ Sáu 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ủ (`command-palette.tsx`, `data-table.tsx`, `date-picker.tsx`,\n * `form-field.tsx`, `search-filter-bar.tsx`, `table-pagination.tsx`) đều mở đầu bằng `\"use client\"`,\n * mà mọi export của một module client đến tay Server Component dưới dạng client-reference proxy:\n * đọc ra `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/command-palette-constants.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/route-tabs.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\";\n/* Bảng lớp của Tabs xuất THẲNG từ module thuần — `RouteTabs` và `Tabs` dùng chung nó, và hằng\n dữ liệu đi vòng qua file \"use client\" sẽ thành proxy ở Server Component (CHANGELOG 0.2.2 §2). */\nexport * from \"./components/ui/tabs-variants.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;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;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;AAGd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AACd,cAAc;AAGd,cAAc;AACd,cAAc;","names":[]}
|
|
@@ -14,6 +14,13 @@ interface ContrastProviderProps {
|
|
|
14
14
|
defaultContrast?: ContrastMode;
|
|
15
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
16
|
storageKey?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Nonce của CSP, cho `<ContrastScript>` mà provider này TỰ render.
|
|
19
|
+
*
|
|
20
|
+
* Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ không mang thuộc tính `nonce`,
|
|
21
|
+
* đúng hành vi trước đây. Xem `ContrastScript` để biết vì sao nó chỉ phát ở phía máy chủ.
|
|
22
|
+
*/
|
|
23
|
+
nonce?: string;
|
|
17
24
|
}
|
|
18
25
|
/**
|
|
19
26
|
* Trục thứ HAI của giao diện, độc lập với sáng/tối: chọn BẢNG MÀU.
|
|
@@ -40,7 +47,7 @@ interface ContrastProviderProps {
|
|
|
40
47
|
* `<html>` phải có `suppressHydrationWarning` (cùng lý do với ThemeProvider: script sửa DOM
|
|
41
48
|
* trước khi React hydrate nên HTML client luôn khác HTML server).
|
|
42
49
|
*/
|
|
43
|
-
declare function ContrastProvider({ children, defaultContrast, storageKey }: ContrastProviderProps): React$1.JSX.Element;
|
|
50
|
+
declare function ContrastProvider({ children, defaultContrast, storageKey, nonce }: ContrastProviderProps): React$1.JSX.Element;
|
|
44
51
|
/**
|
|
45
52
|
* Script chống nháy. `<ContrastProvider>` đã render sẵn nó nên **app thường không cần gọi tay** —
|
|
46
53
|
* 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.
|
|
@@ -48,8 +55,9 @@ declare function ContrastProvider({ children, defaultContrast, storageKey }: Con
|
|
|
48
55
|
* ⚠ ĐỪ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
56
|
* `ContrastProvider`). Gọi hai lần cũng vô hại — script idempotent — chỉ tốn một thẻ.
|
|
50
57
|
*/
|
|
51
|
-
declare function ContrastScript({ storageKey }: {
|
|
58
|
+
declare function ContrastScript({ storageKey, nonce }: {
|
|
52
59
|
storageKey?: string;
|
|
60
|
+
nonce?: string;
|
|
53
61
|
}): React$1.JSX.Element;
|
|
54
62
|
/**
|
|
55
63
|
* Đọ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
|
|
@@ -6,7 +6,8 @@ const ContrastContext = createContext(null);
|
|
|
6
6
|
function ContrastProvider({
|
|
7
7
|
children,
|
|
8
8
|
defaultContrast = "normal",
|
|
9
|
-
storageKey = CONTRAST_STORAGE_KEY
|
|
9
|
+
storageKey = CONTRAST_STORAGE_KEY,
|
|
10
|
+
nonce
|
|
10
11
|
}) {
|
|
11
12
|
const [contrast, setContrastState] = useState(defaultContrast);
|
|
12
13
|
const [mounted, setMounted] = useState(false);
|
|
@@ -40,13 +41,20 @@ function ContrastProvider({
|
|
|
40
41
|
[contrast, setContrast, mounted]
|
|
41
42
|
);
|
|
42
43
|
return /* @__PURE__ */ jsxs(ContrastContext.Provider, { value, children: [
|
|
43
|
-
/* @__PURE__ */ jsx(ContrastScript, { storageKey }),
|
|
44
|
+
/* @__PURE__ */ jsx(ContrastScript, { storageKey, nonce }),
|
|
44
45
|
children
|
|
45
46
|
] });
|
|
46
47
|
}
|
|
47
|
-
function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY }) {
|
|
48
|
+
function ContrastScript({ storageKey = CONTRAST_STORAGE_KEY, nonce }) {
|
|
48
49
|
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(
|
|
50
|
+
return /* @__PURE__ */ jsx(
|
|
51
|
+
"script",
|
|
52
|
+
{
|
|
53
|
+
suppressHydrationWarning: true,
|
|
54
|
+
nonce: typeof window === "undefined" ? nonce : "",
|
|
55
|
+
dangerouslySetInnerHTML: { __html: script }
|
|
56
|
+
}
|
|
57
|
+
);
|
|
50
58
|
}
|
|
51
59
|
function useContrast() {
|
|
52
60
|
const ctx = useContext(ContrastContext);
|
|
@@ -1 +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
|
|
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 * Nonce của CSP, cho `<ContrastScript>` mà provider này TỰ render.\n *\n * Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ không mang thuộc tính `nonce`,\n * đúng hành vi trước đây. Xem `ContrastScript` để biết vì sao nó chỉ phát ở phía máy chủ.\n */\n nonce?: 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 nonce\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} nonce={nonce} />\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, nonce }: { storageKey?: string; nonce?: 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 return (\n <script\n suppressHydrationWarning\n /*\n * ⚠ `nonce` CHỈ phát ở phía MÁY CHỦ. Khuôn chép của `next-themes`, hai lý do thật:\n * 1. Trình duyệt CỐ Ý giấu giá trị `nonce` — `getAttribute(\"nonce\")` trả chuỗi RỖNG sau khi\n * trang tải xong, để một XSS đọc được DOM cũng không lấy được nonce. Render nonce thật ở\n * client thì React so với chuỗi rỗng đó và báo lệch hydration ở MỌI trang.\n * 2. Nonce chỉ có tác dụng ở lượt HTML đầu tiên, mà script này cũng chỉ chạy ở lượt đó.\n */\n nonce={typeof window === \"undefined\" ? nonce : \"\"}\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 dangerouslySetInnerHTML={{ __html: script }}\n />\n );\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":";AAsGI,SACE,KADF;AApGJ,SAAS,eAAe,aAAa,YAAY,WAAW,SAAS,gBAAgB;AACrF,SAAS,oBAAoB,sBAAyC,sBAAsB;AAU5F,MAAM,kBAAkB,cAA2C,IAAI;AA0ChE,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA,kBAAkB;AAAA,EAClB,aAAa;AAAA,EACb;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,OAAc;AAAA,IACrD;AAAA,KACH;AAEJ;AASO,SAAS,eAAe,EAAE,aAAa,sBAAsB,MAAM,GAA4C;AAEpH,QAAM,SAAS,8CAA8C,KAAK,UAAU,UAAU,CAAC,yDAAyD,KAAK,UAAU,kBAAkB,CAAC;AAClL,SACE;AAAA,IAAC;AAAA;AAAA,MACC,0BAAwB;AAAA,MAQxB,OAAO,OAAO,WAAW,cAAc,QAAQ;AAAA,MAE/C,yBAAyB,EAAE,QAAQ,OAAO;AAAA;AAAA,EAC5C;AAEJ;AAMO,SAAS,cAAoC;AAClD,QAAM,MAAM,WAAW,eAAe;AACtC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,+FAAwE;AAClG,SAAO;AACT;","names":[]}
|
|
@@ -8,13 +8,26 @@ interface DisplayAxisValue<T extends string> {
|
|
|
8
8
|
/** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */
|
|
9
9
|
mounted: boolean;
|
|
10
10
|
}
|
|
11
|
-
declare const DensityProvider: ({ children, defaultValue, storageKey }: {
|
|
11
|
+
declare const DensityProvider: ({ children, defaultValue, storageKey, nonce }: {
|
|
12
12
|
children: ReactNode;
|
|
13
13
|
defaultValue?: "compact" | "comfortable" | undefined;
|
|
14
14
|
storageKey?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Nonce của CSP, cho script chống nháy mà provider này TỰ render.
|
|
17
|
+
*
|
|
18
|
+
* Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ `<script>` không mang thuộc
|
|
19
|
+
* tính `nonce`, đúng hành vi trước đây.
|
|
20
|
+
*
|
|
21
|
+
* Đường lấy giá trị (Next 16): `proxy.ts` sinh nonce và đặt nó lên header REQUEST cùng chính
|
|
22
|
+
* sách CSP; root layout đọc bằng `(await headers()).get("x-nonce")`. Next TỰ gắn nonce cho mọi
|
|
23
|
+
* script của CHÍNH NÓ, nhưng script do gói này render thì React 19 **không** gắn hộ — nên nó
|
|
24
|
+
* bắt buộc phải đi qua prop.
|
|
25
|
+
*/
|
|
26
|
+
nonce?: string;
|
|
15
27
|
}) => React.JSX.Element;
|
|
16
|
-
declare const DensityScript: ({ storageKey }: {
|
|
28
|
+
declare const DensityScript: ({ storageKey, nonce }: {
|
|
17
29
|
storageKey?: string;
|
|
30
|
+
nonce?: string;
|
|
18
31
|
}) => React.JSX.Element;
|
|
19
32
|
/** Đọc/đổi mật độ bố cục. */
|
|
20
33
|
declare function useDensity(): {
|
|
@@ -22,13 +35,26 @@ declare function useDensity(): {
|
|
|
22
35
|
setDensity: (m: DensityMode) => void;
|
|
23
36
|
mounted: boolean;
|
|
24
37
|
};
|
|
25
|
-
declare const FontSizeProvider: ({ children, defaultValue, storageKey }: {
|
|
38
|
+
declare const FontSizeProvider: ({ children, defaultValue, storageKey, nonce }: {
|
|
26
39
|
children: ReactNode;
|
|
27
40
|
defaultValue?: "sm" | "md" | "lg" | undefined;
|
|
28
41
|
storageKey?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Nonce của CSP, cho script chống nháy mà provider này TỰ render.
|
|
44
|
+
*
|
|
45
|
+
* Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ `<script>` không mang thuộc
|
|
46
|
+
* tính `nonce`, đúng hành vi trước đây.
|
|
47
|
+
*
|
|
48
|
+
* Đường lấy giá trị (Next 16): `proxy.ts` sinh nonce và đặt nó lên header REQUEST cùng chính
|
|
49
|
+
* sách CSP; root layout đọc bằng `(await headers()).get("x-nonce")`. Next TỰ gắn nonce cho mọi
|
|
50
|
+
* script của CHÍNH NÓ, nhưng script do gói này render thì React 19 **không** gắn hộ — nên nó
|
|
51
|
+
* bắt buộc phải đi qua prop.
|
|
52
|
+
*/
|
|
53
|
+
nonce?: string;
|
|
29
54
|
}) => React.JSX.Element;
|
|
30
|
-
declare const FontSizeScript: ({ storageKey }: {
|
|
55
|
+
declare const FontSizeScript: ({ storageKey, nonce }: {
|
|
31
56
|
storageKey?: string;
|
|
57
|
+
nonce?: string;
|
|
32
58
|
}) => React.JSX.Element;
|
|
33
59
|
/** Đọc/đổi cỡ chữ. */
|
|
34
60
|
declare function useFontSize(): {
|
|
@@ -13,14 +13,22 @@ import {
|
|
|
13
13
|
} from "./display-axis-constants.js";
|
|
14
14
|
function createDisplayAxis(config) {
|
|
15
15
|
const Context = createContext(null);
|
|
16
|
-
function AxisScript({ storageKey = config.storageKey }) {
|
|
16
|
+
function AxisScript({ storageKey = config.storageKey, nonce }) {
|
|
17
17
|
const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}catch(e){}})();`;
|
|
18
|
-
return /* @__PURE__ */ jsx(
|
|
18
|
+
return /* @__PURE__ */ jsx(
|
|
19
|
+
"script",
|
|
20
|
+
{
|
|
21
|
+
suppressHydrationWarning: true,
|
|
22
|
+
nonce: typeof window === "undefined" ? nonce : "",
|
|
23
|
+
dangerouslySetInnerHTML: { __html: script }
|
|
24
|
+
}
|
|
25
|
+
);
|
|
19
26
|
}
|
|
20
27
|
function Provider({
|
|
21
28
|
children,
|
|
22
29
|
defaultValue = config.defaultValue,
|
|
23
|
-
storageKey = config.storageKey
|
|
30
|
+
storageKey = config.storageKey,
|
|
31
|
+
nonce
|
|
24
32
|
}) {
|
|
25
33
|
const [value, setValueState] = useState(defaultValue);
|
|
26
34
|
const [mounted, setMounted] = useState(false);
|
|
@@ -51,7 +59,7 @@ function createDisplayAxis(config) {
|
|
|
51
59
|
);
|
|
52
60
|
const ctx = useMemo(() => ({ value, setValue, mounted }), [value, setValue, mounted]);
|
|
53
61
|
return /* @__PURE__ */ jsxs(Context.Provider, { value: ctx, children: [
|
|
54
|
-
/* @__PURE__ */ jsx(AxisScript, { storageKey }),
|
|
62
|
+
/* @__PURE__ */ jsx(AxisScript, { storageKey, nonce }),
|
|
55
63
|
children
|
|
56
64
|
] });
|
|
57
65
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/display-axis-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport {\n DEFAULT_DENSITY,\n DEFAULT_FONT_SIZE,\n DENSITY_ATTRIBUTE,\n DENSITY_STORAGE_KEY,\n type DensityMode,\n FONT_SIZE_ATTRIBUTE,\n FONT_SIZE_STORAGE_KEY,\n type FontSizeMode,\n isDensityMode,\n isFontSizeMode\n} from \"./display-axis-constants.js\";\n\n/**\n * Xưởng dựng TRỤC HIỂN THỊ — dùng chung cho mật độ và cỡ chữ.\n *\n * `ContrastProvider` viết tay ~60 dòng cho trục thứ hai. Trục thứ ba và thứ tư sẽ là hai bản chép\n * gần như y hệt, nên phần chung tách ra đây: cùng cơ chế đọc localStorage sau khi mount, cùng cờ\n * `mounted` chống lệch hydration, cùng script chống nháy trong `<body>`, cùng quy ước GỠ thuộc\n * tính ở giá trị mặc định.\n *\n * ⚠ `ContrastProvider` CỐ Ý chưa chuyển sang xưởng này. Nó là API đã phát hành và có hành vi\n * riêng đã được ghi chép; chuyển nó là một thay đổi có rủi ro mà lượt này không cần tới. Nếu sau\n * này chuyển thì chuyển một mình nó, có bản so đầu ra trước/sau.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` ─\n * Nó giữ MỘT context singleton cho cả cây. Lồng thêm provider với `attribute`/`storageKey` khác\n * thì provider trong cùng thắng và `useTheme()` của app đọc nhầm trục — không lỗi biên dịch, chỉ\n * là nút đổi sáng/tối lặng lẽ ngừng ăn. `contrast-provider.tsx` đã ghi lại đúng cạm bẫy này.\n */\ninterface AxisConfig<T extends string> {\n attribute: string;\n storageKey: string;\n defaultValue: T;\n isValue: (v: unknown) => v is T;\n hookName: string;\n providerName: string;\n}\n\nexport interface DisplayAxisValue<T extends string> {\n value: T;\n setValue: (next: T) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */\n mounted: boolean;\n}\n\nfunction createDisplayAxis<T extends string>(config: AxisConfig<T>) {\n const Context = createContext<DisplayAxisValue<T> | null>(null);\n\n /**\n * Script chống nháy. Phải chạy TRƯỚC lần sơn đầu tiên, nếu không người đã chọn `lg` sẽ thấy một\n * khung hình cỡ mặc định rồi mới nhảy. Effect của React chạy SAU khi sơn nên không dùng được.\n *\n * ⚠ Nằm trong `<body>`, KHÔNG phải `<head>`: React 19 kêu \"Encountered a script tag while\n * rendering React component\" ở mọi lần render client nếu đặt vào head. Trong body thì tương\n * đương về chống nháy vì parser chạy script ngay khi gặp, mà nó đứng trước toàn bộ markup.\n */\n function AxisScript({ storageKey = config.storageKey }: { storageKey?: string }) {\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}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 function Provider({\n children,\n defaultValue = config.defaultValue,\n storageKey = config.storageKey\n }: {\n children: ReactNode;\n defaultValue?: T;\n storageKey?: string;\n }) {\n const [value, setValueState] = useState<T>(defaultValue);\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 AxisScript đặ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 (config.isValue(stored)) setValueState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n // Mặc định thì GỠ thuộc tính, không ghi giá trị — xem ghi chú cuối display-axis-constants.ts.\n if (value === config.defaultValue) root.removeAttribute(config.attribute);\n else root.setAttribute(config.attribute, value);\n }, [value, mounted]);\n\n const setValue = useCallback(\n (next: T) => {\n setValueState(next);\n try {\n window.localStorage.setItem(storageKey, next);\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 ctx = useMemo<DisplayAxisValue<T>>(() => ({ value, setValue, mounted }), [value, setValue, mounted]);\n\n return (\n <Context.Provider value={ctx}>\n <AxisScript storageKey={storageKey} />\n {children}\n </Context.Provider>\n );\n }\n\n function useAxis(): DisplayAxisValue<T> {\n const ctx = useContext(Context);\n // Ném lỗi thay vì trả mặc định: im lặng thì control trông như hỏng mà không chỉ ra nguyên nhân.\n if (!ctx) throw new Error(`${config.hookName}() phải nằm trong <${config.providerName}>. Bọc nó ở root layout.`);\n return ctx;\n }\n\n return { Provider, useAxis, AxisScript };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 3 — MẬT ĐỘ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst densityAxis = createDisplayAxis<DensityMode>({\n attribute: DENSITY_ATTRIBUTE,\n storageKey: DENSITY_STORAGE_KEY,\n defaultValue: DEFAULT_DENSITY,\n isValue: isDensityMode,\n hookName: \"useDensity\",\n providerName: \"DensityProvider\"\n});\n\nexport const DensityProvider = densityAxis.Provider;\nexport const DensityScript = densityAxis.AxisScript;\n\n/** Đọc/đổi mật độ bố cục. */\nexport function useDensity(): { density: DensityMode; setDensity: (m: DensityMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = densityAxis.useAxis();\n return { density: value, setDensity: setValue, mounted };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 4 — CỠ CHỮ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst fontSizeAxis = createDisplayAxis<FontSizeMode>({\n attribute: FONT_SIZE_ATTRIBUTE,\n storageKey: FONT_SIZE_STORAGE_KEY,\n defaultValue: DEFAULT_FONT_SIZE,\n isValue: isFontSizeMode,\n hookName: \"useFontSize\",\n providerName: \"FontSizeProvider\"\n});\n\nexport const FontSizeProvider = fontSizeAxis.Provider;\nexport const FontSizeScript = fontSizeAxis.AxisScript;\n\n/** Đọc/đổi cỡ chữ. */\nexport function useFontSize(): { fontSize: FontSizeMode; setFontSize: (m: FontSizeMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = fontSizeAxis.useAxis();\n return { fontSize: value, setFontSize: setValue, mounted };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/display-axis-provider.tsx"],"sourcesContent":["\"use client\";\n\nimport { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from \"react\";\nimport {\n DEFAULT_DENSITY,\n DEFAULT_FONT_SIZE,\n DENSITY_ATTRIBUTE,\n DENSITY_STORAGE_KEY,\n type DensityMode,\n FONT_SIZE_ATTRIBUTE,\n FONT_SIZE_STORAGE_KEY,\n type FontSizeMode,\n isDensityMode,\n isFontSizeMode\n} from \"./display-axis-constants.js\";\n\n/**\n * Xưởng dựng TRỤC HIỂN THỊ — dùng chung cho mật độ và cỡ chữ.\n *\n * `ContrastProvider` viết tay ~60 dòng cho trục thứ hai. Trục thứ ba và thứ tư sẽ là hai bản chép\n * gần như y hệt, nên phần chung tách ra đây: cùng cơ chế đọc localStorage sau khi mount, cùng cờ\n * `mounted` chống lệch hydration, cùng script chống nháy trong `<body>`, cùng quy ước GỠ thuộc\n * tính ở giá trị mặc định.\n *\n * ⚠ `ContrastProvider` CỐ Ý chưa chuyển sang xưởng này. Nó là API đã phát hành và có hành vi\n * riêng đã được ghi chép; chuyển nó là một thay đổi có rủi ro mà lượt này không cần tới. Nếu sau\n * này chuyển thì chuyển một mình nó, có bản so đầu ra trước/sau.\n *\n * ─ VÌ SAO KHÔNG DÙNG `next-themes` ─\n * Nó giữ MỘT context singleton cho cả cây. Lồng thêm provider với `attribute`/`storageKey` khác\n * thì provider trong cùng thắng và `useTheme()` của app đọc nhầm trục — không lỗi biên dịch, chỉ\n * là nút đổi sáng/tối lặng lẽ ngừng ăn. `contrast-provider.tsx` đã ghi lại đúng cạm bẫy này.\n */\ninterface AxisConfig<T extends string> {\n attribute: string;\n storageKey: string;\n defaultValue: T;\n isValue: (v: unknown) => v is T;\n hookName: string;\n providerName: string;\n}\n\nexport interface DisplayAxisValue<T extends string> {\n value: T;\n setValue: (next: T) => void;\n /** `false` cho tới khi effect đầu tiên chạy — dùng để tránh lệch hydration ở control. */\n mounted: boolean;\n}\n\nfunction createDisplayAxis<T extends string>(config: AxisConfig<T>) {\n const Context = createContext<DisplayAxisValue<T> | null>(null);\n\n /**\n * Script chống nháy. Phải chạy TRƯỚC lần sơn đầu tiên, nếu không người đã chọn `lg` sẽ thấy một\n * khung hình cỡ mặc định rồi mới nhảy. Effect của React chạy SAU khi sơn nên không dùng được.\n *\n * ⚠ Nằm trong `<body>`, KHÔNG phải `<head>`: React 19 kêu \"Encountered a script tag while\n * rendering React component\" ở mọi lần render client nếu đặt vào head. Trong body thì tương\n * đương về chống nháy vì parser chạy script ngay khi gặp, mà nó đứng trước toàn bộ markup.\n */\n function AxisScript({ storageKey = config.storageKey, nonce }: { storageKey?: string; nonce?: string }) {\n const script = `(function(){try{var v=localStorage.getItem(${JSON.stringify(storageKey)});if(v&&v!==${JSON.stringify(config.defaultValue)})document.documentElement.setAttribute(${JSON.stringify(config.attribute)},v)}catch(e){}})();`;\n return (\n <script\n suppressHydrationWarning\n /*\n * ⚠ `nonce` CHỈ phát ở phía MÁY CHỦ — `typeof window === \"undefined\" ? nonce : \"\"`.\n *\n * Khuôn này chép nguyên của `next-themes`, và nó có hai lý do thật chứ không phải thẩm mỹ:\n *\n * 1. **Trình duyệt CỐ Ý giấu giá trị `nonce`**: `el.getAttribute(\"nonce\")` trả về chuỗi\n * RỖNG sau khi trang tải xong. Đó là một chốt của chính CSP — để một XSS đọc được DOM\n * cũng không lấy được nonce rồi tự tiêm script hợp lệ. Render nonce thật ở client thì\n * React so nó với chuỗi rỗng của DOM và báo lệch hydration ở MỌI trang.\n * 2. Nonce chỉ có tác dụng ở lượt phản hồi HTML đầu tiên, mà script này cũng chỉ chạy ở\n * đúng lượt đó — sau khi hydrate xong nó không còn việc gì.\n */\n nonce={typeof window === \"undefined\" ? nonce : \"\"}\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 dangerouslySetInnerHTML={{ __html: script }}\n />\n );\n }\n\n function Provider({\n children,\n defaultValue = config.defaultValue,\n storageKey = config.storageKey,\n nonce\n }: {\n children: ReactNode;\n defaultValue?: T;\n storageKey?: string;\n /**\n * Nonce của CSP, cho script chống nháy mà provider này TỰ render.\n *\n * Bỏ trống nếu app không ép `script-src` — `undefined` nghĩa là thẻ `<script>` không mang thuộc\n * tính `nonce`, đúng hành vi trước đây.\n *\n * Đường lấy giá trị (Next 16): `proxy.ts` sinh nonce và đặt nó lên header REQUEST cùng chính\n * sách CSP; root layout đọc bằng `(await headers()).get(\"x-nonce\")`. Next TỰ gắn nonce cho mọi\n * script của CHÍNH NÓ, nhưng script do gói này render thì React 19 **không** gắn hộ — nên nó\n * bắt buộc phải đi qua prop.\n */\n nonce?: string;\n }) {\n const [value, setValueState] = useState<T>(defaultValue);\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 AxisScript đặ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 (config.isValue(stored)) setValueState(stored);\n setMounted(true);\n }, [storageKey]);\n\n useEffect(() => {\n if (!mounted) return;\n const root = document.documentElement;\n // Mặc định thì GỠ thuộc tính, không ghi giá trị — xem ghi chú cuối display-axis-constants.ts.\n if (value === config.defaultValue) root.removeAttribute(config.attribute);\n else root.setAttribute(config.attribute, value);\n }, [value, mounted]);\n\n const setValue = useCallback(\n (next: T) => {\n setValueState(next);\n try {\n window.localStorage.setItem(storageKey, next);\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 ctx = useMemo<DisplayAxisValue<T>>(() => ({ value, setValue, mounted }), [value, setValue, mounted]);\n\n return (\n <Context.Provider value={ctx}>\n <AxisScript storageKey={storageKey} nonce={nonce} />\n {children}\n </Context.Provider>\n );\n }\n\n function useAxis(): DisplayAxisValue<T> {\n const ctx = useContext(Context);\n // Ném lỗi thay vì trả mặc định: im lặng thì control trông như hỏng mà không chỉ ra nguyên nhân.\n if (!ctx) throw new Error(`${config.hookName}() phải nằm trong <${config.providerName}>. Bọc nó ở root layout.`);\n return ctx;\n }\n\n return { Provider, useAxis, AxisScript };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 3 — MẬT ĐỘ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst densityAxis = createDisplayAxis<DensityMode>({\n attribute: DENSITY_ATTRIBUTE,\n storageKey: DENSITY_STORAGE_KEY,\n defaultValue: DEFAULT_DENSITY,\n isValue: isDensityMode,\n hookName: \"useDensity\",\n providerName: \"DensityProvider\"\n});\n\nexport const DensityProvider = densityAxis.Provider;\nexport const DensityScript = densityAxis.AxisScript;\n\n/** Đọc/đổi mật độ bố cục. */\nexport function useDensity(): { density: DensityMode; setDensity: (m: DensityMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = densityAxis.useAxis();\n return { density: value, setDensity: setValue, mounted };\n}\n\n/* ────────────────────────────────────────────────────────────────────────────\n Trục 4 — CỠ CHỮ\n ──────────────────────────────────────────────────────────────────────────── */\n\nconst fontSizeAxis = createDisplayAxis<FontSizeMode>({\n attribute: FONT_SIZE_ATTRIBUTE,\n storageKey: FONT_SIZE_STORAGE_KEY,\n defaultValue: DEFAULT_FONT_SIZE,\n isValue: isFontSizeMode,\n hookName: \"useFontSize\",\n providerName: \"FontSizeProvider\"\n});\n\nexport const FontSizeProvider = fontSizeAxis.Provider;\nexport const FontSizeScript = fontSizeAxis.AxisScript;\n\n/** Đọc/đổi cỡ chữ. */\nexport function useFontSize(): { fontSize: FontSizeMode; setFontSize: (m: FontSizeMode) => void; mounted: boolean } {\n const { value, setValue, mounted } = fontSizeAxis.useAxis();\n return { fontSize: value, setFontSize: setValue, mounted };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/mobile-menu.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Separator } from \"../components/ui/separator.js\";\nimport { Sheet, SheetContent, SheetHeader, SheetTitle } from \"../components/ui/sheet.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { AppLauncher } from \"./app-launcher.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { SidebarGroup } from \"./sidebar-group.js\";\nimport { ThemeToggle } from \"./theme-toggle.js\";\nimport { UserMenu, type UserMenuProps } from \"./user-menu.js\";\nimport { appAccentStyle, flattenNavItems } from \"./utils.js\";\nimport { WorkspaceSwitcher } from \"./workspace-switcher.js\";\n\nexport interface MobileMenuProps {\n className?: string;\n /** Slot ngay dưới WorkspaceSwitcher. */\n header?: ReactNode;\n /** Slot đáy (phiên bản app, liên kết hỗ trợ…). */\n footer?: ReactNode;\n showAppLauncher?: boolean;\n showThemeToggle?: boolean;\n /** Chuyển tiếp props cho `UserMenu` ở đáy. */\n userMenuProps?: UserMenuProps;\n}\n\n/**\n * Menu off-canvas cho màn hình hẹp (`Sheet` trượt từ trái):\n * WorkspaceSwitcher (trục 1) → menu app HIỆN TẠI → danh sách app (trục 2) → user.\n *\n * ⚠ Thứ tự đó cố ý KHÁC thanh bên desktop. Trên desktop danh sách app nằm ở header nên nó không\n * chiếm chỗ của menu; ở đây cả hai nằm chung một vùng cuộn, và menu của app đang mở là thứ người\n * dùng mở menu để tìm. Đặt danh sách app lên trước thì một hệ sinh thái 8–10 sản phẩm đẩy toàn bộ\n * menu chính xuống dưới nếp gấp — phải cuộn mới thấy thứ mình cần.\n *\n * Tự đóng khi đổi route (`ShellProvider` lo) và khi bấm vào một link thật trong menu.\n */\nexport function MobileMenu({\n className,\n header,\n footer,\n showAppLauncher = true,\n showThemeToggle = true,\n userMenuProps\n}: MobileMenuProps) {\n const { mobileMenuOpen, setMobileMenuOpen, nav, currentApp, user, labels } = useShell();\n\n const allItems = flattenNavItems(nav);\n\n // Chỉ đóng khi bấm trúng LINK thật — `SidebarMenuItem` không gọi callback này\n // khi người dùng chỉ mở/đóng nhánh con, nên menu giữ nguyên cho họ đi tiếp.\n const closeMenu = () => setMobileMenuOpen(false);\n\n return (\n <Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>\n <SheetContent\n side=\"left\"\n aria-describedby={undefined}\n style={appAccentStyle(currentApp)}\n className={cn(\"flex w-80 max-w-[85vw] flex-col gap-0 bg-sidebar p-0 md:hidden\", className)}\n >\n {/*\n `pr-12`: `SheetContent` đặt nút đóng ở `absolute top-4 right-4`, tức nó nằm ĐÈ lên nội\n dung header. Không chừa chỗ thì nó phủ lên mũi tên của `WorkspaceSwitcher` — bấm vào\n tưởng đổi workspace mà hoá ra đóng menu.\n */}\n <SheetHeader className=\"shrink-0 border-b border-sidebar-border p-2 pr-12\">\n <SheetTitle className=\"sr-only\">{labels.navigationLabel}</SheetTitle>\n <WorkspaceSwitcher align=\"start\" />\n </SheetHeader>\n\n {header ? <div className=\"shrink-0 p-2\">{header}</div> : null}\n\n <div className=\"flex-1 overflow-y-auto scrollbar-thin\">\n {/* Menu của app HIỆN TẠI đứng trước — xem lý do thứ tự ở JSDoc đầu file. */}\n <div className=\"flex flex-col gap-4 p-2\">\n {nav.map((group) => (\n <SidebarGroup key={group.id} group={group} siblings={allItems} onNavigate={closeMenu} />\n ))}\n </div>\n\n {showAppLauncher ? (\n <>\n <Separator className=\"bg-sidebar-border\" />\n <div className=\"p-2\">\n <AppLauncher inline variant=\"list\" onSelected={closeMenu} />\n </div>\n </>\n ) : null}\n </div>\n\n <div className=\"flex shrink-0 items-center gap-2 border-t border-sidebar-border p-2\">\n {user ? <UserMenu showName {...userMenuProps} className=\"min-w-0 flex-1\" /> : null}\n {showThemeToggle ? <ThemeToggle labels={labels} /> : null}\n </div>\n\n {footer ? <div className=\"shrink-0 border-t border-sidebar-border p-2\">{footer}</div> : null}\n </SheetContent>\n </Sheet>\n );\n}\n"],"mappings":";
|
|
1
|
+
{"version":3,"sources":["../../src/shell/mobile-menu.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { Separator } from \"../components/ui/separator.js\";\nimport { Sheet, SheetContent, SheetHeader, SheetTitle } from \"../components/ui/sheet.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { AppLauncher } from \"./app-launcher.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { SidebarGroup } from \"./sidebar-group.js\";\nimport { ThemeToggle } from \"./theme-toggle.js\";\nimport { UserMenu, type UserMenuProps } from \"./user-menu.js\";\nimport { appAccentStyle, flattenNavItems } from \"./utils.js\";\nimport { WorkspaceSwitcher } from \"./workspace-switcher.js\";\n\nexport interface MobileMenuProps {\n className?: string;\n /** Slot ngay dưới WorkspaceSwitcher. */\n header?: ReactNode;\n /** Slot đáy (phiên bản app, liên kết hỗ trợ…). */\n footer?: ReactNode;\n showAppLauncher?: boolean;\n showThemeToggle?: boolean;\n /** Chuyển tiếp props cho `UserMenu` ở đáy. */\n userMenuProps?: UserMenuProps;\n}\n\n/**\n * Menu off-canvas cho màn hình hẹp (`Sheet` trượt từ trái):\n * WorkspaceSwitcher (trục 1) → menu app HIỆN TẠI → danh sách app (trục 2) → user.\n *\n * ⚠ Thứ tự đó cố ý KHÁC thanh bên desktop. Trên desktop danh sách app nằm ở header nên nó không\n * chiếm chỗ của menu; ở đây cả hai nằm chung một vùng cuộn, và menu của app đang mở là thứ người\n * dùng mở menu để tìm. Đặt danh sách app lên trước thì một hệ sinh thái 8–10 sản phẩm đẩy toàn bộ\n * menu chính xuống dưới nếp gấp — phải cuộn mới thấy thứ mình cần.\n *\n * Tự đóng khi đổi route (`ShellProvider` lo) và khi bấm vào một link thật trong menu.\n */\nexport function MobileMenu({\n className,\n header,\n footer,\n showAppLauncher = true,\n showThemeToggle = true,\n userMenuProps\n}: MobileMenuProps) {\n const { mobileMenuOpen, setMobileMenuOpen, nav, currentApp, user, labels } = useShell();\n\n const allItems = flattenNavItems(nav);\n\n // Chỉ đóng khi bấm trúng LINK thật — `SidebarMenuItem` không gọi callback này\n // khi người dùng chỉ mở/đóng nhánh con, nên menu giữ nguyên cho họ đi tiếp.\n const closeMenu = () => setMobileMenuOpen(false);\n\n return (\n <Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>\n <SheetContent\n side=\"left\"\n aria-describedby={undefined}\n // `closeLabel` PHẢI truyền tay. Mặc định của `SheetContent` là \"Đóng\" ghim cứng trong\n // primitive, mà app đổi ngôn ngữ shell qua ĐÚNG MỘT cửa: prop `labels`. Không nối hai đầu\n // ở đây thì app tiếng Anh khai đủ `ShellLabels` vẫn đẩy tiếng Việt vào cây trợ năng — và\n // họ không có chỗ nào sửa, vì `MobileMenu` không nhận props của `Sheet`.\n // Dùng `closeMenu` (\"Đóng menu\") chứ không phải một nhãn \"Đóng\" chung: trình đọc màn hình\n // đọc nút này TÁCH khỏi ngữ cảnh, nên nó phải nói rõ đóng cái gì — cùng lý do với\n // `collapseSidebar` trong types.ts.\n closeLabel={labels.closeMenu}\n style={appAccentStyle(currentApp)}\n className={cn(\"flex w-80 max-w-[85vw] flex-col gap-0 bg-sidebar p-0 md:hidden\", className)}\n >\n {/*\n `pr-12`: `SheetContent` đặt nút đóng ở `absolute top-4 right-4`, tức nó nằm ĐÈ lên nội\n dung header. Không chừa chỗ thì nó phủ lên mũi tên của `WorkspaceSwitcher` — bấm vào\n tưởng đổi workspace mà hoá ra đóng menu.\n */}\n <SheetHeader className=\"shrink-0 border-b border-sidebar-border p-2 pr-12\">\n <SheetTitle className=\"sr-only\">{labels.navigationLabel}</SheetTitle>\n <WorkspaceSwitcher align=\"start\" />\n </SheetHeader>\n\n {header ? <div className=\"shrink-0 p-2\">{header}</div> : null}\n\n <div className=\"flex-1 overflow-y-auto scrollbar-thin\">\n {/* Menu của app HIỆN TẠI đứng trước — xem lý do thứ tự ở JSDoc đầu file. */}\n <div className=\"flex flex-col gap-4 p-2\">\n {nav.map((group) => (\n <SidebarGroup key={group.id} group={group} siblings={allItems} onNavigate={closeMenu} />\n ))}\n </div>\n\n {showAppLauncher ? (\n <>\n <Separator className=\"bg-sidebar-border\" />\n <div className=\"p-2\">\n <AppLauncher inline variant=\"list\" onSelected={closeMenu} />\n </div>\n </>\n ) : null}\n </div>\n\n <div className=\"flex shrink-0 items-center gap-2 border-t border-sidebar-border p-2\">\n {user ? <UserMenu showName {...userMenuProps} className=\"min-w-0 flex-1\" /> : null}\n {showThemeToggle ? <ThemeToggle labels={labels} /> : null}\n </div>\n\n {footer ? <div className=\"shrink-0 border-t border-sidebar-border p-2\">{footer}</div> : null}\n </SheetContent>\n </Sheet>\n );\n}\n"],"mappings":";AA0EQ,SAgBI,UAfF,KADF;AAvER,SAAS,iBAAiB;AAC1B,SAAS,OAAO,cAAc,aAAa,kBAAkB;AAC7D,SAAS,UAAU;AACnB,SAAS,mBAAmB;AAC5B,SAAS,gBAAgB;AACzB,SAAS,oBAAoB;AAC7B,SAAS,mBAAmB;AAC5B,SAAS,gBAAoC;AAC7C,SAAS,gBAAgB,uBAAuB;AAChD,SAAS,yBAAyB;AAyB3B,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB;AACF,GAAoB;AAClB,QAAM,EAAE,gBAAgB,mBAAmB,KAAK,YAAY,MAAM,OAAO,IAAI,SAAS;AAEtF,QAAM,WAAW,gBAAgB,GAAG;AAIpC,QAAM,YAAY,MAAM,kBAAkB,KAAK;AAE/C,SACE,oBAAC,SAAM,MAAM,gBAAgB,cAAc,mBACzC;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,oBAAkB;AAAA,MAQlB,YAAY,OAAO;AAAA,MACnB,OAAO,eAAe,UAAU;AAAA,MAChC,WAAW,GAAG,kEAAkE,SAAS;AAAA,MAOzF;AAAA,6BAAC,eAAY,WAAU,qDACrB;AAAA,8BAAC,cAAW,WAAU,WAAW,iBAAO,iBAAgB;AAAA,UACxD,oBAAC,qBAAkB,OAAM,SAAQ;AAAA,WACnC;AAAA,QAEC,SAAS,oBAAC,SAAI,WAAU,gBAAgB,kBAAO,IAAS;AAAA,QAEzD,qBAAC,SAAI,WAAU,yCAEb;AAAA,8BAAC,SAAI,WAAU,2BACZ,cAAI,IAAI,CAAC,UACR,oBAAC,gBAA4B,OAAc,UAAU,UAAU,YAAY,aAAxD,MAAM,EAA6D,CACvF,GACH;AAAA,UAEC,kBACC,iCACE;AAAA,gCAAC,aAAU,WAAU,qBAAoB;AAAA,YACzC,oBAAC,SAAI,WAAU,OACb,8BAAC,eAAY,QAAM,MAAC,SAAQ,QAAO,YAAY,WAAW,GAC5D;AAAA,aACF,IACE;AAAA,WACN;AAAA,QAEA,qBAAC,SAAI,WAAU,uEACZ;AAAA,iBAAO,oBAAC,YAAS,UAAQ,MAAE,GAAG,eAAe,WAAU,kBAAiB,IAAK;AAAA,UAC7E,kBAAkB,oBAAC,eAAY,QAAgB,IAAK;AAAA,WACvD;AAAA,QAEC,SAAS,oBAAC,SAAI,WAAU,+CAA+C,kBAAO,IAAS;AAAA;AAAA;AAAA,EAC1F,GACF;AAEJ;","names":[]}
|
package/dist/shell/types.d.ts
CHANGED
|
@@ -173,7 +173,14 @@ interface ShellLabels {
|
|
|
173
173
|
createWorkspace: string;
|
|
174
174
|
workspaceSettings: string;
|
|
175
175
|
inviteMembers: string;
|
|
176
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Đơn vị đếm thành viên của một workspace. Là HÀM chứ không phải chuỗi vì nó CHÈN SỐ, và
|
|
178
|
+
* trật tự từ đổi theo ngôn ngữ: tiếng Việt đặt số trước ("12 thành viên"), tiếng Anh cũng vậy
|
|
179
|
+
* ("12 members"), nhưng nhiều ngôn ngữ thì không — nối `${count} ${đơn_vị}` ở phía component
|
|
180
|
+
* là khoá cứng trật tự tiếng Việt vào mọi bản dịch. Cùng khuôn với `range`/`pageStatus` của
|
|
181
|
+
* `TablePagination` (README §5, ba quy tắc của cơ chế `labels`).
|
|
182
|
+
*/
|
|
183
|
+
members: (count: number) => string;
|
|
177
184
|
apps: string;
|
|
178
185
|
appLauncherLabel: string;
|
|
179
186
|
myApps: string;
|
package/dist/shell/types.js
CHANGED
|
@@ -15,7 +15,7 @@ const DEFAULT_SHELL_LABELS = {
|
|
|
15
15
|
createWorkspace: "T\u1EA1o kh\xF4ng gian l\xE0m vi\u1EC7c",
|
|
16
16
|
workspaceSettings: "C\xE0i \u0111\u1EB7t kh\xF4ng gian l\xE0m vi\u1EC7c",
|
|
17
17
|
inviteMembers: "M\u1EDDi th\xE0nh vi\xEAn",
|
|
18
|
-
members:
|
|
18
|
+
members: (count) => `${count} th\xE0nh vi\xEAn`,
|
|
19
19
|
apps: "\u1EE8ng d\u1EE5ng",
|
|
20
20
|
appLauncherLabel: "M\u1EDF danh s\xE1ch \u1EE9ng d\u1EE5ng",
|
|
21
21
|
myApps: "\u1EE8ng d\u1EE5ng c\u1EE7a b\u1EA1n",
|
package/dist/shell/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/types.ts"],"sourcesContent":["/**\n * Hợp đồng dữ liệu của Tier 3 (shell).\n *\n * Nguyên tắc: package KHÔNG chứa dữ liệu app. Toàn bộ workspace / danh sách app /\n * menu / user đều là *type* — app truyền vào qua props. File này là **module thuần**\n * (chỉ type + hằng nhãn, không hook, không directive) nên dùng được ở cả server lẫn client.\n *\n * Hai trục điều hướng của Comitor — một user thuộc NHIỀU workspace, và mỗi workspace\n * lại bật NHIỀU sản phẩm. Hai chiều đó vuông góc nhau (đổi workspace không đổi app đang\n * dùng, và ngược lại) nên không gộp được vào một menu phẳng:\n * trục 1 — WORKSPACE: đổi ngữ cảnh dữ liệu (Acme Corp ▸ Beta Ltd)\n * trục 2 — APP: đổi sản phẩm trong cùng workspace (Tasks ▸ Chat ▸ CRM ▸ HR)\n */\n\nimport type { LucideIcon } from \"lucide-react\";\nimport type { AnchorHTMLAttributes, ComponentType } from \"react\";\n\n/**\n * Icon dùng trong shell: `LucideIcon` là trường hợp phổ biến, nhưng app có thể\n * truyền component SVG riêng miễn nhận `className` (để shell điều khiển kích thước).\n */\nexport type ShellIcon = LucideIcon | ComponentType<{ className?: string }>;\n\n/** Workspace (trục 1) — một tổ chức/không gian làm việc mà user thuộc về. */\nexport interface Workspace {\n id: string;\n name: string;\n /** Định danh trên URL/subdomain — shell chỉ hiển thị, không tự điều hướng. */\n slug: string;\n logoUrl?: string;\n /** \"Free\" | \"Pro\" | \"Business\"… — chỉ để hiển thị nhãn gói, shell không suy luận quyền từ đây. */\n plan?: string;\n /** Vai trò của user trong workspace này (chuỗi hiển thị sẵn). */\n role?: string;\n memberCount?: number;\n}\n\n/** Một sản phẩm trong hệ sinh thái (trục 2) — Tasks, Chat, CRM, HR… */\nexport interface AppDescriptor {\n /** \"tasks\" | \"chat\" | \"crm\" | \"hr\" | … */\n id: string;\n /** Tên hiển thị (mặc định tiếng Việt). */\n name: string;\n description?: string;\n icon: ShellIcon;\n href: string;\n /**\n * Màu nhấn riêng của app — bất kỳ giá trị màu CSS hợp lệ (nên là `var(--…)`).\n * Shell gán vào biến `--app-accent` bằng inline style, KHÔNG sinh class động.\n *\n * ⚠ `--app-accent` CHỈ để TÔ NỀN. Khai `accent` thì phải khai kèm `accentInk`\n * (và `accentForeground` nếu nền đặc của bạn không mang được mực) — xem\n * `docs/hop-dong-mau.md` §5.\n */\n accent?: string;\n /** Màu chữ/icon nằm TRÊN nền `--app-accent` ĐẶC; mặc định giữ `--app-accent-foreground` của theme. */\n accentForeground?: string;\n /**\n * Chính màu app khi nó đi lên NỀN TRANG (chữ, icon rời, viền, vạch active, dấu chọn)\n * → biến `--app-accent-ink`. Mặc định giữ bậc ink của theme.\n *\n * Vì sao phải khai riêng chứ không dẫn xuất từ `accent`: bậc nền và bậc mực có ràng buộc\n * NGƯỢC NHAU — nền càng đúng brand thì cùng màu đó làm chữ càng chìm. Gold mặc định là ví\n * dụ: `--app-accent` #E8B824 làm chữ trên nền trắng chỉ **1,86:1**, trên chính tint 15%\n * của nó **1,69:1**; `--app-accent-ink` #7A5800 đạt **6,51:1** trên `--background`,\n * **6,24:1** trên `--sidebar`, **5,96:1** trên `--sidebar-accent`. Bỏ trống trường này mà\n * vẫn đổi `accent` thì mọi chỗ dùng `text-app-accent-ink` giữ nguyên gold — sai màu app.\n */\n accentInk?: string;\n /** `false` = workspace chưa bật/chưa mua → hiện khoá và gọi `onUpsell` thay vì điều hướng. */\n entitled?: boolean;\n badge?: number | string;\n}\n\n/** Variant badge của mục menu — map thẳng sang variant của primitive `Badge`. */\nexport type NavBadgeVariant = \"default\" | \"destructive\" | \"warning\" | \"success\";\n\n/** Một mục điều hướng trong sidebar. */\nexport interface NavItem {\n id: string;\n label: string;\n href: string;\n icon?: ShellIcon;\n badge?: number | string;\n badgeVariant?: NavBadgeVariant;\n /** Mục con — render trong `Collapsible`; cha có children thì click cha chỉ mở/đóng. */\n children?: NavItem[];\n /**\n * Ép trạng thái active thay vì để shell tự suy từ `pathname`\n * (dùng khi route thật khác href, vd trang chi tiết `/tasks/123`).\n */\n isActive?: boolean;\n /** Mở link ở tab mới (link ngoài: tài liệu, trạng thái hệ thống…). */\n external?: boolean;\n disabled?: boolean;\n}\n\n/** Nhóm mục menu (Linear-style: nhãn nhỏ + thu gọn được). */\nexport interface NavGroup {\n id: string;\n /** Bỏ trống = nhóm không nhãn (dùng cho nhóm đầu tiên). */\n label?: string;\n items: NavItem[];\n /** Cho phép thu gọn cả nhóm; mặc định true khi có `label`. */\n collapsible?: boolean;\n /** Trạng thái ban đầu khi `collapsible` — mặc định mở. */\n defaultCollapsed?: boolean;\n}\n\n/**\n * Menu app truyền vào: mảng phẳng `NavItem[]` (app đơn giản) hoặc `NavGroup[]`.\n * Shell chuẩn hoá về `NavGroup[]` bằng `toNavGroups()` trong `utils.ts`.\n */\nexport type ShellNav = NavItem[] | NavGroup[];\n\n/** Người dùng đang đăng nhập. */\nexport interface ShellUser {\n id: string;\n name: string;\n email?: string;\n avatarUrl?: string;\n /** Vai trò trong workspace hiện tại — chuỗi hiển thị sẵn, shell không map mã. */\n role?: string;\n}\n\n/** Mục bổ sung do app chèn vào `UserMenu`. */\nexport interface UserMenuItem {\n id: string;\n label: string;\n icon?: ShellIcon;\n /** Có `href` thì render link; không thì render nút gọi `onSelect`. */\n href?: string;\n onSelect?: () => void;\n /** Nhãn phím tắt hiển thị bên phải, vd \"Ctrl+P\" (chỉ hiển thị, không tự gắn phím). */\n shortcut?: string;\n variant?: \"default\" | \"destructive\";\n}\n\n/**\n * Props của component link do app cung cấp (`LinkComponent`).\n * Mặc định shell dùng `next/link`; app không-Next (Vite, Storybook) truyền\n * component router của mình miễn khớp hình dạng props này.\n */\nexport interface ShellLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\"> {\n /** Chỉ nhận chuỗi — shell không dùng dạng `UrlObject` của Next để adapter khác còn dùng lại được. */\n href: string;\n}\n\n/** Component link thay thế `next/link`. */\nexport type ShellLinkComponent = ComponentType<ShellLinkProps>;\n\n/**\n * Toàn bộ chuỗi hiển thị của shell — app tiếng Anh (hoặc ngôn ngữ khác) override\n * từng khoá qua prop `labels`. Mặc định là tiếng Việt.\n */\nexport interface ShellLabels {\n /* Sidebar & header */\n collapseSidebar: string;\n /**\n * Nhãn NGẮN hiện trên chính nút thu gọn (khi thanh bên đang mở rộng).\n *\n * Tách khỏi `collapseSidebar` vì hai chuỗi có hai việc khác nhau: chuỗi dài là TÊN TRUY CẬP\n * (`aria-label` + tooltip) — nó phải nói rõ thu gọn cái gì, vì người dùng trình đọc màn hình\n * nghe nó tách khỏi ngữ cảnh; chuỗi ngắn là chữ NHÌN THẤY trong một nút rộng 240px nằm ngay\n * dưới thanh bên, nơi ngữ cảnh đã hiển nhiên và chữ dài chỉ làm nút chật.\n *\n * ⚠ WCAG 2.5.3 (Label in Name): chuỗi ngắn PHẢI là một phần của chuỗi dài, nếu không lệnh thoại\n * \"bấm Thu gọn\" sẽ không khớp tên truy cập. \"Thu gọn\" ⊂ \"Thu gọn thanh bên\" ✓ — giữ quan hệ đó\n * khi dịch sang ngôn ngữ khác.\n */\n collapseSidebarShort: string;\n expandSidebar: string;\n openMenu: string;\n closeMenu: string;\n search: string;\n searchPlaceholder: string;\n notifications: string;\n navigationLabel: string;\n /* Workspace switcher */\n workspace: string;\n workspaceSwitcherLabel: string;\n workspaceSearchPlaceholder: string;\n workspaceEmpty: string;\n createWorkspace: string;\n workspaceSettings: string;\n inviteMembers: string;\n members: string;\n /* App launcher */\n apps: string;\n appLauncherLabel: string;\n myApps: string;\n discoverApps: string;\n locked: string;\n lockedHint: string;\n /* User menu */\n account: string;\n profile: string;\n settings: string;\n appearance: string;\n help: string;\n logout: string;\n /* Theme */\n themeLight: string;\n themeDark: string;\n themeSystem: string;\n toggleTheme: string;\n}\n\n/** Nhãn tiếng Việt mặc định — mọi khoá đều override được qua prop `labels`. */\nexport const DEFAULT_SHELL_LABELS: ShellLabels = {\n collapseSidebar: \"Thu gọn thanh bên\",\n collapseSidebarShort: \"Thu gọn\",\n expandSidebar: \"Mở rộng thanh bên\",\n openMenu: \"Mở menu\",\n closeMenu: \"Đóng menu\",\n search: \"Tìm kiếm\",\n searchPlaceholder: \"Tìm kiếm…\",\n notifications: \"Thông báo\",\n navigationLabel: \"Điều hướng chính\",\n workspace: \"Không gian làm việc\",\n workspaceSwitcherLabel: \"Đổi không gian làm việc\",\n workspaceSearchPlaceholder: \"Tìm không gian làm việc…\",\n workspaceEmpty: \"Không tìm thấy không gian làm việc\",\n createWorkspace: \"Tạo không gian làm việc\",\n workspaceSettings: \"Cài đặt không gian làm việc\",\n inviteMembers: \"Mời thành viên\",\n members: \"thành viên\",\n apps: \"Ứng dụng\",\n appLauncherLabel: \"Mở danh sách ứng dụng\",\n myApps: \"Ứng dụng của bạn\",\n discoverApps: \"Khám phá thêm\",\n locked: \"Chưa mở khoá\",\n lockedHint: \"Ứng dụng chưa có trong gói hiện tại\",\n account: \"Tài khoản\",\n profile: \"Hồ sơ\",\n settings: \"Cài đặt\",\n appearance: \"Giao diện\",\n help: \"Trợ giúp\",\n logout: \"Đăng xuất\",\n themeLight: \"Sáng\",\n themeDark: \"Tối\",\n themeSystem: \"Hệ thống\",\n toggleTheme: \"Đổi giao diện\"\n};\n"],"mappings":"AAiNO,MAAM,uBAAoC;AAAA,EAC/C,iBAAiB;AAAA,EACjB,sBAAsB;AAAA,EACtB,eAAe;AAAA,EACf,UAAU;AAAA,EACV,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,mBAAmB;AAAA,EACnB,eAAe;AAAA,EACf,iBAAiB;AAAA,EACjB,WAAW;AAAA,EACX,wBAAwB;AAAA,EACxB,4BAA4B;AAAA,EAC5B,gBAAgB;AAAA,EAChB,iBAAiB;AAAA,EACjB,mBAAmB;AAAA,EACnB,eAAe;AAAA,EACf,SAAS;AAAA,EACT,MAAM;AAAA,EACN,kBAAkB;AAAA,EAClB,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,SAAS;AAAA,EACT,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,aAAa;AAAA,EACb,aAAa;AACf;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/types.ts"],"sourcesContent":["/**\n * Hợp đồng dữ liệu của Tier 3 (shell).\n *\n * Nguyên tắc: package KHÔNG chứa dữ liệu app. Toàn bộ workspace / danh sách app /\n * menu / user đều là *type* — app truyền vào qua props. File này là **module thuần**\n * (chỉ type + hằng nhãn, không hook, không directive) nên dùng được ở cả server lẫn client.\n *\n * Hai trục điều hướng của Comitor — một user thuộc NHIỀU workspace, và mỗi workspace\n * lại bật NHIỀU sản phẩm. Hai chiều đó vuông góc nhau (đổi workspace không đổi app đang\n * dùng, và ngược lại) nên không gộp được vào một menu phẳng:\n * trục 1 — WORKSPACE: đổi ngữ cảnh dữ liệu (Acme Corp ▸ Beta Ltd)\n * trục 2 — APP: đổi sản phẩm trong cùng workspace (Tasks ▸ Chat ▸ CRM ▸ HR)\n */\n\nimport type { LucideIcon } from \"lucide-react\";\nimport type { AnchorHTMLAttributes, ComponentType } from \"react\";\n\n/**\n * Icon dùng trong shell: `LucideIcon` là trường hợp phổ biến, nhưng app có thể\n * truyền component SVG riêng miễn nhận `className` (để shell điều khiển kích thước).\n */\nexport type ShellIcon = LucideIcon | ComponentType<{ className?: string }>;\n\n/** Workspace (trục 1) — một tổ chức/không gian làm việc mà user thuộc về. */\nexport interface Workspace {\n id: string;\n name: string;\n /** Định danh trên URL/subdomain — shell chỉ hiển thị, không tự điều hướng. */\n slug: string;\n logoUrl?: string;\n /** \"Free\" | \"Pro\" | \"Business\"… — chỉ để hiển thị nhãn gói, shell không suy luận quyền từ đây. */\n plan?: string;\n /** Vai trò của user trong workspace này (chuỗi hiển thị sẵn). */\n role?: string;\n memberCount?: number;\n}\n\n/** Một sản phẩm trong hệ sinh thái (trục 2) — Tasks, Chat, CRM, HR… */\nexport interface AppDescriptor {\n /** \"tasks\" | \"chat\" | \"crm\" | \"hr\" | … */\n id: string;\n /** Tên hiển thị (mặc định tiếng Việt). */\n name: string;\n description?: string;\n icon: ShellIcon;\n href: string;\n /**\n * Màu nhấn riêng của app — bất kỳ giá trị màu CSS hợp lệ (nên là `var(--…)`).\n * Shell gán vào biến `--app-accent` bằng inline style, KHÔNG sinh class động.\n *\n * ⚠ `--app-accent` CHỈ để TÔ NỀN. Khai `accent` thì phải khai kèm `accentInk`\n * (và `accentForeground` nếu nền đặc của bạn không mang được mực) — xem\n * `docs/hop-dong-mau.md` §5.\n */\n accent?: string;\n /** Màu chữ/icon nằm TRÊN nền `--app-accent` ĐẶC; mặc định giữ `--app-accent-foreground` của theme. */\n accentForeground?: string;\n /**\n * Chính màu app khi nó đi lên NỀN TRANG (chữ, icon rời, viền, vạch active, dấu chọn)\n * → biến `--app-accent-ink`. Mặc định giữ bậc ink của theme.\n *\n * Vì sao phải khai riêng chứ không dẫn xuất từ `accent`: bậc nền và bậc mực có ràng buộc\n * NGƯỢC NHAU — nền càng đúng brand thì cùng màu đó làm chữ càng chìm. Gold mặc định là ví\n * dụ: `--app-accent` #E8B824 làm chữ trên nền trắng chỉ **1,86:1**, trên chính tint 15%\n * của nó **1,69:1**; `--app-accent-ink` #7A5800 đạt **6,51:1** trên `--background`,\n * **6,24:1** trên `--sidebar`, **5,96:1** trên `--sidebar-accent`. Bỏ trống trường này mà\n * vẫn đổi `accent` thì mọi chỗ dùng `text-app-accent-ink` giữ nguyên gold — sai màu app.\n */\n accentInk?: string;\n /** `false` = workspace chưa bật/chưa mua → hiện khoá và gọi `onUpsell` thay vì điều hướng. */\n entitled?: boolean;\n badge?: number | string;\n}\n\n/** Variant badge của mục menu — map thẳng sang variant của primitive `Badge`. */\nexport type NavBadgeVariant = \"default\" | \"destructive\" | \"warning\" | \"success\";\n\n/** Một mục điều hướng trong sidebar. */\nexport interface NavItem {\n id: string;\n label: string;\n href: string;\n icon?: ShellIcon;\n badge?: number | string;\n badgeVariant?: NavBadgeVariant;\n /** Mục con — render trong `Collapsible`; cha có children thì click cha chỉ mở/đóng. */\n children?: NavItem[];\n /**\n * Ép trạng thái active thay vì để shell tự suy từ `pathname`\n * (dùng khi route thật khác href, vd trang chi tiết `/tasks/123`).\n */\n isActive?: boolean;\n /** Mở link ở tab mới (link ngoài: tài liệu, trạng thái hệ thống…). */\n external?: boolean;\n disabled?: boolean;\n}\n\n/** Nhóm mục menu (Linear-style: nhãn nhỏ + thu gọn được). */\nexport interface NavGroup {\n id: string;\n /** Bỏ trống = nhóm không nhãn (dùng cho nhóm đầu tiên). */\n label?: string;\n items: NavItem[];\n /** Cho phép thu gọn cả nhóm; mặc định true khi có `label`. */\n collapsible?: boolean;\n /** Trạng thái ban đầu khi `collapsible` — mặc định mở. */\n defaultCollapsed?: boolean;\n}\n\n/**\n * Menu app truyền vào: mảng phẳng `NavItem[]` (app đơn giản) hoặc `NavGroup[]`.\n * Shell chuẩn hoá về `NavGroup[]` bằng `toNavGroups()` trong `utils.ts`.\n */\nexport type ShellNav = NavItem[] | NavGroup[];\n\n/** Người dùng đang đăng nhập. */\nexport interface ShellUser {\n id: string;\n name: string;\n email?: string;\n avatarUrl?: string;\n /** Vai trò trong workspace hiện tại — chuỗi hiển thị sẵn, shell không map mã. */\n role?: string;\n}\n\n/** Mục bổ sung do app chèn vào `UserMenu`. */\nexport interface UserMenuItem {\n id: string;\n label: string;\n icon?: ShellIcon;\n /** Có `href` thì render link; không thì render nút gọi `onSelect`. */\n href?: string;\n onSelect?: () => void;\n /** Nhãn phím tắt hiển thị bên phải, vd \"Ctrl+P\" (chỉ hiển thị, không tự gắn phím). */\n shortcut?: string;\n variant?: \"default\" | \"destructive\";\n}\n\n/**\n * Props của component link do app cung cấp (`LinkComponent`).\n * Mặc định shell dùng `next/link`; app không-Next (Vite, Storybook) truyền\n * component router của mình miễn khớp hình dạng props này.\n */\nexport interface ShellLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\"> {\n /** Chỉ nhận chuỗi — shell không dùng dạng `UrlObject` của Next để adapter khác còn dùng lại được. */\n href: string;\n}\n\n/** Component link thay thế `next/link`. */\nexport type ShellLinkComponent = ComponentType<ShellLinkProps>;\n\n/**\n * Toàn bộ chuỗi hiển thị của shell — app tiếng Anh (hoặc ngôn ngữ khác) override\n * từng khoá qua prop `labels`. Mặc định là tiếng Việt.\n */\nexport interface ShellLabels {\n /* Sidebar & header */\n collapseSidebar: string;\n /**\n * Nhãn NGẮN hiện trên chính nút thu gọn (khi thanh bên đang mở rộng).\n *\n * Tách khỏi `collapseSidebar` vì hai chuỗi có hai việc khác nhau: chuỗi dài là TÊN TRUY CẬP\n * (`aria-label` + tooltip) — nó phải nói rõ thu gọn cái gì, vì người dùng trình đọc màn hình\n * nghe nó tách khỏi ngữ cảnh; chuỗi ngắn là chữ NHÌN THẤY trong một nút rộng 240px nằm ngay\n * dưới thanh bên, nơi ngữ cảnh đã hiển nhiên và chữ dài chỉ làm nút chật.\n *\n * ⚠ WCAG 2.5.3 (Label in Name): chuỗi ngắn PHẢI là một phần của chuỗi dài, nếu không lệnh thoại\n * \"bấm Thu gọn\" sẽ không khớp tên truy cập. \"Thu gọn\" ⊂ \"Thu gọn thanh bên\" ✓ — giữ quan hệ đó\n * khi dịch sang ngôn ngữ khác.\n */\n collapseSidebarShort: string;\n expandSidebar: string;\n openMenu: string;\n closeMenu: string;\n search: string;\n searchPlaceholder: string;\n notifications: string;\n navigationLabel: string;\n /* Workspace switcher */\n workspace: string;\n workspaceSwitcherLabel: string;\n workspaceSearchPlaceholder: string;\n workspaceEmpty: string;\n createWorkspace: string;\n workspaceSettings: string;\n inviteMembers: string;\n /**\n * Đơn vị đếm thành viên của một workspace. Là HÀM chứ không phải chuỗi vì nó CHÈN SỐ, và\n * trật tự từ đổi theo ngôn ngữ: tiếng Việt đặt số trước (\"12 thành viên\"), tiếng Anh cũng vậy\n * (\"12 members\"), nhưng nhiều ngôn ngữ thì không — nối `${count} ${đơn_vị}` ở phía component\n * là khoá cứng trật tự tiếng Việt vào mọi bản dịch. Cùng khuôn với `range`/`pageStatus` của\n * `TablePagination` (README §5, ba quy tắc của cơ chế `labels`).\n */\n members: (count: number) => string;\n /* App launcher */\n apps: string;\n appLauncherLabel: string;\n myApps: string;\n discoverApps: string;\n locked: string;\n lockedHint: string;\n /* User menu */\n account: string;\n profile: string;\n settings: string;\n appearance: string;\n help: string;\n logout: string;\n /* Theme */\n themeLight: string;\n themeDark: string;\n themeSystem: string;\n toggleTheme: string;\n}\n\n/** Nhãn tiếng Việt mặc định — mọi khoá đều override được qua prop `labels`. */\nexport const DEFAULT_SHELL_LABELS: ShellLabels = {\n collapseSidebar: \"Thu gọn thanh bên\",\n collapseSidebarShort: \"Thu gọn\",\n expandSidebar: \"Mở rộng thanh bên\",\n openMenu: \"Mở menu\",\n closeMenu: \"Đóng menu\",\n search: \"Tìm kiếm\",\n searchPlaceholder: \"Tìm kiếm…\",\n notifications: \"Thông báo\",\n navigationLabel: \"Điều hướng chính\",\n workspace: \"Không gian làm việc\",\n workspaceSwitcherLabel: \"Đổi không gian làm việc\",\n workspaceSearchPlaceholder: \"Tìm không gian làm việc…\",\n workspaceEmpty: \"Không tìm thấy không gian làm việc\",\n createWorkspace: \"Tạo không gian làm việc\",\n workspaceSettings: \"Cài đặt không gian làm việc\",\n inviteMembers: \"Mời thành viên\",\n members: (count) => `${count} thành viên`,\n apps: \"Ứng dụng\",\n appLauncherLabel: \"Mở danh sách ứng dụng\",\n myApps: \"Ứng dụng của bạn\",\n discoverApps: \"Khám phá thêm\",\n locked: \"Chưa mở khoá\",\n lockedHint: \"Ứng dụng chưa có trong gói hiện tại\",\n account: \"Tài khoản\",\n profile: \"Hồ sơ\",\n settings: \"Cài đặt\",\n appearance: \"Giao diện\",\n help: \"Trợ giúp\",\n logout: \"Đăng xuất\",\n themeLight: \"Sáng\",\n themeDark: \"Tối\",\n themeSystem: \"Hệ thống\",\n toggleTheme: \"Đổi giao diện\"\n};\n"],"mappings":"AAwNO,MAAM,uBAAoC;AAAA,EAC/C,iBAAiB;AAAA,EACjB,sBAAsB;AAAA,EACtB,eAAe;AAAA,EACf,UAAU;AAAA,EACV,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,mBAAmB;AAAA,EACnB,eAAe;AAAA,EACf,iBAAiB;AAAA,EACjB,WAAW;AAAA,EACX,wBAAwB;AAAA,EACxB,4BAA4B;AAAA,EAC5B,gBAAgB;AAAA,EAChB,iBAAiB;AAAA,EACjB,mBAAmB;AAAA,EACnB,eAAe;AAAA,EACf,SAAS,CAAC,UAAU,GAAG,KAAK;AAAA,EAC5B,MAAM;AAAA,EACN,kBAAkB;AAAA,EAClB,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,SAAS;AAAA,EACT,SAAS;AAAA,EACT,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,YAAY;AAAA,EACZ,WAAW;AAAA,EACX,aAAa;AAAA,EACb,aAAa;AACf;","names":[]}
|
|
@@ -161,7 +161,7 @@ function WorkspaceSwitcher({
|
|
|
161
161
|
/* @__PURE__ */ jsx("span", { className: "truncate text-sm font-medium leading-tight text-foreground", children: workspace.name }),
|
|
162
162
|
workspace.role || workspace.memberCount !== void 0 ? /* @__PURE__ */ jsx("span", { className: "truncate text-[11px] leading-tight text-muted-foreground", children: [
|
|
163
163
|
workspace.role,
|
|
164
|
-
workspace.memberCount === void 0 ? void 0 :
|
|
164
|
+
workspace.memberCount === void 0 ? void 0 : labels.members(workspace.memberCount)
|
|
165
165
|
].filter(Boolean).join(" \xB7 ") }) : null
|
|
166
166
|
] }),
|
|
167
167
|
workspace.plan ? /* @__PURE__ */ jsx(Badge, { variant: "secondary", size: "sm", className: "shrink-0", children: workspace.plan }) : null,
|