@comitor/ui 0.9.0 → 1.0.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.
@@ -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ẽ giữ 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 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 ? \"Đang xử lý…\" : confirmLabel}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n );\n}\n"],"mappings":";AAmGkB,cAoBN,YApBM;AAjGlB,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;AAuCxB,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,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,kCAAgB;AAAA;AAAA,QAC7B;AAAA,SACF;AAAA,OACF;AAAA,KACF;AAEJ;","names":[]}
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 * ⚠ 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/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;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":[]}
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":[]}
@@ -62,10 +62,47 @@ function AppLauncherPanel({ variant = "grid", className, onSelected }) {
62
62
  ]
63
63
  }
64
64
  ),
65
- /* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 flex-col", variant === "list" && "flex-1"), children: [
66
- /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1.5", children: [
67
- /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-medium text-foreground", children: app.name }),
68
- app.badge ? /* @__PURE__ */ jsx(Badge, { variant: "destructive", size: "sm", children: app.badge }) : null
65
+ /* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 flex-col", variant === "grid" ? "w-full" : "flex-1"), children: [
66
+ /* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 gap-1.5", variant === "grid" ? "items-start" : "items-center"), children: [
67
+ /* @__PURE__ */ jsx(
68
+ "span",
69
+ {
70
+ className: cn(
71
+ "text-sm font-medium text-foreground",
72
+ /*
73
+ * Lưới: tên ứng dụng dài hơn ô là chuyện thường ("Tài khoản Comitor" ở ô 109px),
74
+ * nên cho hai dòng rồi mới cắt — `grid-cols-3` khai `items-stretch` mặc định nên
75
+ * ô cao thêm một dòng không làm hàng bị so le.
76
+ *
77
+ * `break-words` đi KÈM `line-clamp-2`, không phải phòng xa: `-webkit-line-clamp`
78
+ * chỉ đặt được dấu "…" khi chữ THẬT SỰ tràn sang dòng thứ ba. Một tên không có
79
+ * khoảng trắng thì không xuống dòng được, nên nó nằm gọn một dòng và bị
80
+ * `overflow: hidden` cắt PHẲNG giữa chữ — đo được: `soDong: 1`,
81
+ * `scrollHeight === clientHeight`, không có dấu ba chấm nào. Cho phép ngắt trong
82
+ * từ thì nó thành hai dòng và dấu "…" quay lại.
83
+ *
84
+ * Danh sách: mỗi mục là một hàng ngang trong popover hẹp; hai dòng ở đó làm chiều
85
+ * cao các mục nhấp nhô. Giữ một dòng — `truncate` vốn đã chạy đúng ở đó vì `flex-1`
86
+ * ghim sẵn bề rộng. Cùng lý do tác giả chỉ cho `description` xuống hai dòng ở lưới.
87
+ */
88
+ variant === "grid" ? "line-clamp-2 break-words" : "truncate"
89
+ ),
90
+ children: app.name
91
+ }
92
+ ),
93
+ app.badge ? (
94
+ /*
95
+ * `shrink-0` KHÔNG phải để chống bóp méo — đo rồi: có hay không, badge đều 116,8px,
96
+ * vì `whitespace-nowrap` trong `Badge` đã giữ sàn `min-width: auto` bằng trọn bề rộng
97
+ * chữ, và phần co dồn hết sang ô tên (`overflow: hidden` → sàn 0).
98
+ *
99
+ * Nó ở đây vì hai lý do khác: khớp quy ước của mọi chỗ gọi `Badge` khác trong shell
100
+ * (`workspace-switcher`, `sidebar-menu-item` — chỗ này là chỗ DUY NHẤT thiếu), và để
101
+ * lá chắn thôi phụ thuộc vào một lớp ngầm trong `Badge`. Ai đó thêm `truncate` vào
102
+ * base cva của `Badge` là sàn tụt về 0 và badge méo, im lặng.
103
+ */
104
+ /* @__PURE__ */ jsx(Badge, { variant: "destructive", size: "sm", className: "shrink-0", children: app.badge })
105
+ ) : null
69
106
  ] }),
70
107
  app.description ? /* @__PURE__ */ jsx(
71
108
  "span",
@@ -128,7 +165,7 @@ function AppLauncherPanel({ variant = "grid", className, onSelected }) {
128
165
  );
129
166
  };
130
167
  if (total === 0) return null;
131
- const groupClass = variant === "grid" ? "grid grid-cols-3 gap-1" : "flex flex-col gap-0.5";
168
+ const groupClass = variant === "grid" ? "grid auto-rows-fr grid-cols-3 gap-1" : "flex flex-col gap-0.5";
132
169
  return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("flex flex-col gap-3", className), children: [
133
170
  entitled.length > 0 ? /* @__PURE__ */ jsxs("section", { children: [
134
171
  /* @__PURE__ */ jsx("p", { className: "px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: labels.myApps }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/app-launcher.tsx"],"sourcesContent":["\"use client\";\n\nimport { LayoutGrid, Lock } from \"lucide-react\";\nimport { type KeyboardEvent, useRef } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../components/ui/popover.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { AppDescriptor } from \"./types.js\";\nimport { appAccentStyle, getEntitledApps, getLockedApps } from \"./utils.js\";\n\n/** Đánh dấu ô app để điều hướng bàn phím tìm được, kể cả ô render bằng LinkComponent của app. */\nconst TILE_ATTR = \"data-app-tile\";\n\nexport interface AppLauncherPanelProps {\n /** \"grid\" = lưới 3 cột kiểu Lark/Google Workspace; \"list\" = danh sách dọc (menu mobile). */\n variant?: \"grid\" | \"list\";\n className?: string;\n /** Gọi sau khi người dùng chọn một app — dùng để đóng popover/menu mobile. */\n onSelected?: () => void;\n}\n\n/**\n * Nội dung của AppLauncher, tách riêng để `MobileMenu` nhúng thẳng (không popover).\n *\n * Điều hướng bàn phím: ←/→/↑/↓ chạy giữa các ô (xuyên cả hai nhóm như một lưới liền),\n * Home/End nhảy đầu-cuối; Enter/Space để chính thẻ button/link xử lý.\n * Vị trí ô đọc từ DOM chứ không giữ mảng ref: ô \"đã mở khoá\" có thể là `LinkComponent`\n * do app cung cấp — không chắc component đó nhận `ref`.\n */\nexport function AppLauncherPanel({ variant = \"grid\", className, onSelected }: AppLauncherPanelProps) {\n const { apps, currentApp, onAppSelect, onUpsell, labels } = useShell();\n\n const entitled = getEntitledApps(apps);\n const locked = getLockedApps(apps);\n const total = entitled.length + locked.length;\n\n const rootRef = useRef<HTMLDivElement>(null);\n const columns = variant === \"grid\" ? 3 : 1;\n\n const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n const root = rootRef.current;\n if (!root) return;\n const tiles = Array.from(root.querySelectorAll<HTMLElement>(`[${TILE_ATTR}]`));\n const index = tiles.indexOf(event.currentTarget);\n if (index < 0) return;\n\n const moves: Record<string, number | undefined> = {\n ArrowRight: index + 1,\n ArrowLeft: index - 1,\n ArrowDown: index + columns,\n ArrowUp: index - columns,\n Home: 0,\n End: tiles.length - 1\n };\n const next = moves[event.key];\n if (next === undefined) return;\n\n event.preventDefault();\n tiles[Math.max(0, Math.min(tiles.length - 1, next))]?.focus();\n };\n\n const renderTile = (app: AppDescriptor) => {\n const isLocked = app.entitled === false;\n const isCurrent = app.id === currentApp?.id;\n const Icon = app.icon;\n\n // Ô KHOÁ không tô `opacity-60` nữa. Nó KHÔNG phải control vô hiệu — bấm được và gọi\n // `onUpsell(appId)` — nên không hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3/1.4.11.\n // Với 60% độ mờ thì mô tả app tụt còn 2,41:1 (light) / 2,74:1 (dark) ✗ và icon trong ô\n // `bg-muted` còn 2,31:1 / 2,60:1 ✗, chỉ phục hồi khi hover — tức người không rê chuột thì\n // không đọc được. Bỏ độ mờ: mô tả `text-muted-foreground` 5,23:1 (light) / 5,35:1 (dark) ✓,\n // icon trên `bg-muted` 4,89:1 / 4,86:1 ✓. Tín hiệu \"chưa mở khoá\" không mất gì: ô icon vẫn\n // xám (thay vì tô màu app), vẫn có huy hiệu ổ khoá, vẫn có `title`/`aria-label` nói rõ.\n const tileClass = cn(\n \"group relative flex rounded-md text-left transition-colors duration-150 ease-out\",\n variant === \"grid\" ? \"flex-col items-start gap-1.5 p-2.5\" : \"items-center gap-2.5 p-2\",\n isLocked ? \"hover:bg-muted\" : \"hover:bg-accent\",\n isCurrent && \"bg-accent\"\n );\n\n const body = (\n <>\n {/* Ô icon tô ĐẶC bằng `--app-accent` + chữ `--app-accent-foreground`, KHÔNG phải\n `bg-app-accent/15 text-app-accent`: accent mặc định là gold #E8B824, icon vàng trên\n nền vàng 15% (đã trộn ra #FCF4DE) chỉ được 1,69:1 — dưới xa mức 3:1 mà WCAG 1.4.11\n đòi cho icon. Nền đặc thì icon là mực trên vàng = 10,19:1 ✓ và ô app vẫn giữ đúng\n màu riêng của từng app. Cùng cách xử lý với `FilterChips` (chip đang bật). */}\n <span\n className={cn(\n \"relative flex shrink-0 items-center justify-center rounded-md\",\n variant === \"grid\" ? \"size-9\" : \"size-8\",\n isLocked ? \"bg-muted text-muted-foreground\" : \"bg-app-accent text-app-accent-foreground\"\n )}\n style={appAccentStyle(app)}\n >\n <Icon className=\"size-4\" />\n {isLocked ? (\n <span className=\"absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full bg-muted text-muted-foreground ring-2 ring-popover\">\n <Lock className=\"size-2.5\" />\n </span>\n ) : null}\n </span>\n\n <span className={cn(\"flex min-w-0 flex-col\", variant === \"list\" && \"flex-1\")}>\n <span className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-sm font-medium text-foreground\">{app.name}</span>\n {app.badge ? (\n <Badge variant=\"destructive\" size=\"sm\">\n {app.badge}\n </Badge>\n ) : null}\n </span>\n {app.description ? (\n <span\n className={cn(\"text-[11px] leading-snug text-muted-foreground\", variant === \"grid\" && \"line-clamp-2\")}\n >\n {app.description}\n </span>\n ) : null}\n </span>\n </>\n );\n\n // Chưa mở khoá → không điều hướng, chỉ báo cho app biết để mời nâng gói.\n if (isLocked) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n title={labels.lockedHint}\n aria-label={`${app.name} — ${labels.locked}`}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onUpsell?.(app.id);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n // Có `onAppSelect` → app tự lo điều hướng (đổi subdomain, nạp trước dữ liệu…);\n // không có → dùng link thật để giữ chuột-giữa / mở tab mới / trình đọc màn hình.\n if (onAppSelect) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onAppSelect(app);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n return (\n <ShellNavLink\n key={app.id}\n href={app.href}\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => onSelected?.()}\n >\n {body}\n </ShellNavLink>\n );\n };\n\n if (total === 0) return null;\n\n const groupClass = variant === \"grid\" ? \"grid grid-cols-3 gap-1\" : \"flex flex-col gap-0.5\";\n\n return (\n <div ref={rootRef} className={cn(\"flex flex-col gap-3\", className)}>\n {entitled.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.myApps}\n </p>\n <div className={groupClass}>{entitled.map(renderTile)}</div>\n </section>\n ) : null}\n\n {locked.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.discoverApps}\n </p>\n <div className={groupClass}>{locked.map(renderTile)}</div>\n </section>\n ) : null}\n </div>\n );\n}\n\nexport interface AppLauncherProps extends AppLauncherPanelProps {\n /** Bỏ nút bấm + Popover, render thẳng panel (MobileMenu dùng cách này). */\n inline?: boolean;\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * **Trục 2 — chuyển sản phẩm trong cùng workspace.**\n *\n * Nút lưới 3×3 ở header (kiểu Lark / Google Workspace) mở popover lưới ô app.\n * Trạng thái mở nằm ở `ShellProvider` để phím tắt `Cmd/Ctrl + Shift + K` bật được.\n * App chưa có trong gói hiện ô khoá và gọi `onUpsell(appId)` thay vì điều hướng.\n */\nexport function AppLauncher({\n className,\n variant = \"grid\",\n inline = false,\n align = \"end\",\n onSelected\n}: AppLauncherProps) {\n const { apps, appLauncherOpen, setAppLauncherOpen, labels } = useShell();\n\n if (apps.length === 0) return null;\n\n if (inline) {\n return <AppLauncherPanel variant={variant} className={className} onSelected={onSelected} />;\n }\n\n return (\n <Popover open={appLauncherOpen} onOpenChange={setAppLauncherOpen}>\n <Tooltip>\n <TooltipTrigger asChild>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n aria-label={labels.appLauncherLabel}\n className={cn(\n \"flex size-9 items-center justify-center rounded-md text-muted-foreground\",\n \"transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground\",\n appLauncherOpen && \"bg-muted text-foreground\",\n className\n )}\n >\n <LayoutGrid className=\"size-4\" />\n </button>\n </PopoverTrigger>\n </TooltipTrigger>\n <TooltipContent side=\"bottom\">{labels.apps}</TooltipContent>\n </Tooltip>\n\n <PopoverContent align={align} sideOffset={8} className=\"w-[22rem] p-2\">\n <AppLauncherPanel\n variant={variant}\n onSelected={() => {\n setAppLauncherOpen(false);\n onSelected?.();\n }}\n />\n </PopoverContent>\n </Popover>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/shell/app-launcher.tsx"],"sourcesContent":["\"use client\";\n\nimport { LayoutGrid, Lock } from \"lucide-react\";\nimport { type KeyboardEvent, useRef } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../components/ui/popover.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { AppDescriptor } from \"./types.js\";\nimport { appAccentStyle, getEntitledApps, getLockedApps } from \"./utils.js\";\n\n/** Đánh dấu ô app để điều hướng bàn phím tìm được, kể cả ô render bằng LinkComponent của app. */\nconst TILE_ATTR = \"data-app-tile\";\n\nexport interface AppLauncherPanelProps {\n /** \"grid\" = lưới 3 cột kiểu Lark/Google Workspace; \"list\" = danh sách dọc (menu mobile). */\n variant?: \"grid\" | \"list\";\n className?: string;\n /** Gọi sau khi người dùng chọn một app — dùng để đóng popover/menu mobile. */\n onSelected?: () => void;\n}\n\n/**\n * Nội dung của AppLauncher, tách riêng để `MobileMenu` nhúng thẳng (không popover).\n *\n * Điều hướng bàn phím: ←/→/↑/↓ chạy giữa các ô (xuyên cả hai nhóm như một lưới liền),\n * Home/End nhảy đầu-cuối; Enter/Space để chính thẻ button/link xử lý.\n * Vị trí ô đọc từ DOM chứ không giữ mảng ref: ô \"đã mở khoá\" có thể là `LinkComponent`\n * do app cung cấp — không chắc component đó nhận `ref`.\n */\nexport function AppLauncherPanel({ variant = \"grid\", className, onSelected }: AppLauncherPanelProps) {\n const { apps, currentApp, onAppSelect, onUpsell, labels } = useShell();\n\n const entitled = getEntitledApps(apps);\n const locked = getLockedApps(apps);\n const total = entitled.length + locked.length;\n\n const rootRef = useRef<HTMLDivElement>(null);\n const columns = variant === \"grid\" ? 3 : 1;\n\n const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n const root = rootRef.current;\n if (!root) return;\n const tiles = Array.from(root.querySelectorAll<HTMLElement>(`[${TILE_ATTR}]`));\n const index = tiles.indexOf(event.currentTarget);\n if (index < 0) return;\n\n const moves: Record<string, number | undefined> = {\n ArrowRight: index + 1,\n ArrowLeft: index - 1,\n ArrowDown: index + columns,\n ArrowUp: index - columns,\n Home: 0,\n End: tiles.length - 1\n };\n const next = moves[event.key];\n if (next === undefined) return;\n\n event.preventDefault();\n tiles[Math.max(0, Math.min(tiles.length - 1, next))]?.focus();\n };\n\n const renderTile = (app: AppDescriptor) => {\n const isLocked = app.entitled === false;\n const isCurrent = app.id === currentApp?.id;\n const Icon = app.icon;\n\n // Ô KHOÁ không tô `opacity-60` nữa. Nó KHÔNG phải control vô hiệu — bấm được và gọi\n // `onUpsell(appId)` — nên không hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3/1.4.11.\n // Với 60% độ mờ thì mô tả app tụt còn 2,41:1 (light) / 2,74:1 (dark) ✗ và icon trong ô\n // `bg-muted` còn 2,31:1 / 2,60:1 ✗, chỉ phục hồi khi hover — tức người không rê chuột thì\n // không đọc được. Bỏ độ mờ: mô tả `text-muted-foreground` 5,23:1 (light) / 5,35:1 (dark) ✓,\n // icon trên `bg-muted` 4,89:1 / 4,86:1 ✓. Tín hiệu \"chưa mở khoá\" không mất gì: ô icon vẫn\n // xám (thay vì tô màu app), vẫn có huy hiệu ổ khoá, vẫn có `title`/`aria-label` nói rõ.\n const tileClass = cn(\n \"group relative flex rounded-md text-left transition-colors duration-150 ease-out\",\n variant === \"grid\" ? \"flex-col items-start gap-1.5 p-2.5\" : \"items-center gap-2.5 p-2\",\n isLocked ? \"hover:bg-muted\" : \"hover:bg-accent\",\n isCurrent && \"bg-accent\"\n );\n\n const body = (\n <>\n {/* Ô icon tô ĐẶC bằng `--app-accent` + chữ `--app-accent-foreground`, KHÔNG phải\n `bg-app-accent/15 text-app-accent`: accent mặc định là gold #E8B824, icon vàng trên\n nền vàng 15% (đã trộn ra #FCF4DE) chỉ được 1,69:1 — dưới xa mức 3:1 mà WCAG 1.4.11\n đòi cho icon. Nền đặc thì icon là mực trên vàng = 10,19:1 ✓ và ô app vẫn giữ đúng\n màu riêng của từng app. Cùng cách xử lý với `FilterChips` (chip đang bật). */}\n <span\n className={cn(\n \"relative flex shrink-0 items-center justify-center rounded-md\",\n variant === \"grid\" ? \"size-9\" : \"size-8\",\n isLocked ? \"bg-muted text-muted-foreground\" : \"bg-app-accent text-app-accent-foreground\"\n )}\n style={appAccentStyle(app)}\n >\n <Icon className=\"size-4\" />\n {isLocked ? (\n <span className=\"absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full bg-muted text-muted-foreground ring-2 ring-popover\">\n <Lock className=\"size-2.5\" />\n </span>\n ) : null}\n </span>\n\n {/*\n * `w-full` ở ô lưới là thứ THẬT SỰ bó được chữ — không phải `min-w-0`.\n *\n * Ô lưới là `flex-col items-start` (xem `tileClass`), nên theo trục ngang cột này lấy\n * `fit-content`: nó RỘNG RA THEO TÊN thay vì bằng ô. Mọi phép cắt bên trong vì thế không\n * bao giờ có gì để bám vào. Đo ở app thật, ô 109px: tên một từ dài cho cột 272px và tràn\n * 174px, `scrollWidth === clientWidth` — `line-clamp` được đặt nhưng chưa từng chạy.\n * Thêm `min-w-0` vào hàng bên dưới KHÔNG đổi được số nào (đo: y hệt bản hỏng); `w-full`\n * đưa cột về đúng 89px và từ đó phép cắt mới có hiệu lực.\n *\n * Biến thể `list` không dính: ô đó là hàng ngang nên `flex-1` đã ghim bề rộng sẵn.\n */}\n <span className={cn(\"flex min-w-0 flex-col\", variant === \"grid\" ? \"w-full\" : \"flex-1\")}>\n {/*\n * `items-start` chứ không `items-center` khi tên có thể xuống hai dòng: căn giữa đẩy\n * badge xuống giữa khe hai dòng (đo được 10,6px), xa hẳn dòng chữ nó đang chú thích.\n * Tên một dòng thì hai cách lệch nhau 0,8px — không nhìn thấy.\n */}\n <span className={cn(\"flex min-w-0 gap-1.5\", variant === \"grid\" ? \"items-start\" : \"items-center\")}>\n <span\n className={cn(\n \"text-sm font-medium text-foreground\",\n /*\n * Lưới: tên ứng dụng dài hơn ô là chuyện thường (\"Tài khoản Comitor\" ở ô 109px),\n * nên cho hai dòng rồi mới cắt — `grid-cols-3` khai `items-stretch` mặc định nên\n * ô cao thêm một dòng không làm hàng bị so le.\n *\n * `break-words` đi KÈM `line-clamp-2`, không phải phòng xa: `-webkit-line-clamp`\n * chỉ đặt được dấu \"…\" khi chữ THẬT SỰ tràn sang dòng thứ ba. Một tên không có\n * khoảng trắng thì không xuống dòng được, nên nó nằm gọn một dòng và bị\n * `overflow: hidden` cắt PHẲNG giữa chữ — đo được: `soDong: 1`,\n * `scrollHeight === clientHeight`, không có dấu ba chấm nào. Cho phép ngắt trong\n * từ thì nó thành hai dòng và dấu \"…\" quay lại.\n *\n * Danh sách: mỗi mục là một hàng ngang trong popover hẹp; hai dòng ở đó làm chiều\n * cao các mục nhấp nhô. Giữ một dòng — `truncate` vốn đã chạy đúng ở đó vì `flex-1`\n * ghim sẵn bề rộng. Cùng lý do tác giả chỉ cho `description` xuống hai dòng ở lưới.\n */\n variant === \"grid\" ? \"line-clamp-2 break-words\" : \"truncate\"\n )}\n >\n {app.name}\n </span>\n {app.badge ? (\n /*\n * `shrink-0` KHÔNG phải để chống bóp méo — đo rồi: có hay không, badge đều 116,8px,\n * vì `whitespace-nowrap` trong `Badge` đã giữ sàn `min-width: auto` bằng trọn bề rộng\n * chữ, và phần co dồn hết sang ô tên (`overflow: hidden` → sàn 0).\n *\n * Nó ở đây vì hai lý do khác: khớp quy ước của mọi chỗ gọi `Badge` khác trong shell\n * (`workspace-switcher`, `sidebar-menu-item` — chỗ này là chỗ DUY NHẤT thiếu), và để\n * lá chắn thôi phụ thuộc vào một lớp ngầm trong `Badge`. Ai đó thêm `truncate` vào\n * base cva của `Badge` là sàn tụt về 0 và badge méo, im lặng.\n */\n <Badge variant=\"destructive\" size=\"sm\" className=\"shrink-0\">\n {app.badge}\n </Badge>\n ) : null}\n </span>\n {app.description ? (\n <span\n className={cn(\"text-[11px] leading-snug text-muted-foreground\", variant === \"grid\" && \"line-clamp-2\")}\n >\n {app.description}\n </span>\n ) : null}\n </span>\n </>\n );\n\n // Chưa mở khoá → không điều hướng, chỉ báo cho app biết để mời nâng gói.\n if (isLocked) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n title={labels.lockedHint}\n aria-label={`${app.name} — ${labels.locked}`}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onUpsell?.(app.id);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n // Có `onAppSelect` → app tự lo điều hướng (đổi subdomain, nạp trước dữ liệu…);\n // không có → dùng link thật để giữ chuột-giữa / mở tab mới / trình đọc màn hình.\n if (onAppSelect) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onAppSelect(app);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n return (\n <ShellNavLink\n key={app.id}\n href={app.href}\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => onSelected?.()}\n >\n {body}\n </ShellNavLink>\n );\n };\n\n if (total === 0) return null;\n\n /*\n * `auto-rows-fr` vá thứ duy nhất mà việc cho tên xuống hai dòng làm xấu đi: TRONG một hàng thì\n * các ô đã bằng nhau sẵn (`align-items: stretch` là mặc định của Grid), nhưng GIỮA các hàng thì\n * không — hàng có một tên hai dòng cao 116px trong khi hàng kia 97px. `fr` cho mọi hàng chung\n * một chiều cao.\n */\n const groupClass = variant === \"grid\" ? \"grid auto-rows-fr grid-cols-3 gap-1\" : \"flex flex-col gap-0.5\";\n\n return (\n <div ref={rootRef} className={cn(\"flex flex-col gap-3\", className)}>\n {entitled.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.myApps}\n </p>\n <div className={groupClass}>{entitled.map(renderTile)}</div>\n </section>\n ) : null}\n\n {locked.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.discoverApps}\n </p>\n <div className={groupClass}>{locked.map(renderTile)}</div>\n </section>\n ) : null}\n </div>\n );\n}\n\nexport interface AppLauncherProps extends AppLauncherPanelProps {\n /** Bỏ nút bấm + Popover, render thẳng panel (MobileMenu dùng cách này). */\n inline?: boolean;\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * **Trục 2 — chuyển sản phẩm trong cùng workspace.**\n *\n * Nút lưới 3×3 ở header (kiểu Lark / Google Workspace) mở popover lưới ô app.\n * Trạng thái mở nằm ở `ShellProvider` để phím tắt `Cmd/Ctrl + Shift + K` bật được.\n * App chưa có trong gói hiện ô khoá và gọi `onUpsell(appId)` thay vì điều hướng.\n */\nexport function AppLauncher({\n className,\n variant = \"grid\",\n inline = false,\n align = \"end\",\n onSelected\n}: AppLauncherProps) {\n const { apps, appLauncherOpen, setAppLauncherOpen, labels } = useShell();\n\n if (apps.length === 0) return null;\n\n if (inline) {\n return <AppLauncherPanel variant={variant} className={className} onSelected={onSelected} />;\n }\n\n return (\n <Popover open={appLauncherOpen} onOpenChange={setAppLauncherOpen}>\n <Tooltip>\n <TooltipTrigger asChild>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n aria-label={labels.appLauncherLabel}\n className={cn(\n \"flex size-9 items-center justify-center rounded-md text-muted-foreground\",\n \"transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground\",\n appLauncherOpen && \"bg-muted text-foreground\",\n className\n )}\n >\n <LayoutGrid className=\"size-4\" />\n </button>\n </PopoverTrigger>\n </TooltipTrigger>\n <TooltipContent side=\"bottom\">{labels.apps}</TooltipContent>\n </Tooltip>\n\n <PopoverContent align={align} sideOffset={8} className=\"w-[22rem] p-2\">\n <AppLauncherPanel\n variant={variant}\n onSelected={() => {\n setAppLauncherOpen(false);\n onSelected?.();\n }}\n />\n </PopoverContent>\n </Popover>\n );\n}\n"],"mappings":"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","names":[]}
@@ -26,6 +26,7 @@ function MobileMenu({
26
26
  {
27
27
  side: "left",
28
28
  "aria-describedby": void 0,
29
+ closeLabel: labels.closeMenu,
29
30
  style: appAccentStyle(currentApp),
30
31
  className: cn("flex w-80 max-w-[85vw] flex-col gap-0 bg-sidebar p-0 md:hidden", className),
31
32
  children: [
@@ -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":";AAkEQ,SAgBI,UAfF,KADF;AA/DR,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,MAClB,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":[]}
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":[]}
@@ -173,7 +173,14 @@ interface ShellLabels {
173
173
  createWorkspace: string;
174
174
  workspaceSettings: string;
175
175
  inviteMembers: string;
176
- members: string;
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;
@@ -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: "th\xE0nh vi\xEAn",
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",
@@ -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 : `${workspace.memberCount} ${labels.members}`
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,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/workspace-switcher.tsx"],"sourcesContent":["\"use client\";\n\nimport { Building2, Check, ChevronsUpDown, LogOut, Plus, Search, Settings, UserPlus } from \"lucide-react\";\nimport { useMemo, useRef, useState } from \"react\";\nimport { getAvatarToneClasses } from \"../components/composite/letter-avatar.js\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"../components/ui/avatar.js\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../components/ui/popover.js\";\nimport { Separator } from \"../components/ui/separator.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport type { Workspace } from \"./types.js\";\nimport { getVietnameseInitials, normalizeVi } from \"./utils.js\";\n\n/** Từ số workspace này trở lên thì hiện ô tìm kiếm trong danh sách. */\nconst SEARCH_THRESHOLD = 8;\n\n/**\n * Ảnh đại diện workspace: logo nếu có, không thì ô chữ cái tô màu deterministic.\n *\n * Seed là `workspace.name` chứ KHÔNG phải slug: app hiển thị workspace bằng\n * `<LetterAvatar name={workspace.name} square />` ở chỗ khác, seed lệch là màu lệch.\n */\nfunction WorkspaceAvatar({ workspace, className }: { workspace: Workspace; className?: string }) {\n return (\n <Avatar className={cn(\"size-6 rounded-md\", className)}>\n {workspace.logoUrl ? <AvatarImage src={workspace.logoUrl} alt=\"\" className=\"rounded-md\" /> : null}\n <AvatarFallback className={cn(\"rounded-md text-[10px] font-semibold\", getAvatarToneClasses(workspace.name))}>\n {getVietnameseInitials(workspace.name)}\n </AvatarFallback>\n </Avatar>\n );\n}\n\nexport interface WorkspaceSwitcherProps {\n className?: string;\n /** Sidebar thu gọn → chỉ hiện avatar + tooltip. */\n collapsed?: boolean;\n /** Ẩn khu \"Hành động\" (tạo workspace, cài đặt, mời thành viên, đăng xuất). */\n showActions?: boolean;\n /** Căn panel — mặc định \"start\" (mở xuôi theo mép trái sidebar). */\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * **Trục 1 — đổi không gian làm việc.**\n *\n * Chỉ gọi `onWorkspaceChange(id)`; điều hướng/đổi subdomain là việc của app\n * (đổi workspace kéo theo đổi toàn bộ dữ liệu + quyền nên shell không tự quyết).\n * Có ≤1 workspace thì phần danh sách tự ẩn, chỉ còn nhãn workspace hiện tại.\n *\n * Dùng `Popover` chứ không `DropdownMenu`: menu của Radix có typeahead, gõ vào\n * ô tìm kiếm bên trong sẽ bị nó cướp phím.\n */\nexport function WorkspaceSwitcher({\n className,\n collapsed = false,\n showActions = true,\n align = \"start\"\n}: WorkspaceSwitcherProps) {\n const {\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n onLogout,\n labels\n } = useShell();\n\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n const searchRef = useRef<HTMLInputElement>(null);\n\n const showList = workspaces.length > 1;\n const showSearch = workspaces.length >= SEARCH_THRESHOLD;\n\n const filtered = useMemo(() => {\n if (!query.trim()) return workspaces;\n const needle = normalizeVi(query);\n return workspaces.filter(\n (workspace) => normalizeVi(workspace.name).includes(needle) || normalizeVi(workspace.slug).includes(needle)\n );\n }, [workspaces, query]);\n\n const hasActions = showActions && Boolean(onCreateWorkspace || onWorkspaceSettings || onInviteMembers || onLogout);\n\n if (!currentWorkspace) return null;\n\n const handleSelect = (workspace: Workspace) => {\n setOpen(false);\n setQuery(\"\");\n if (workspace.id !== currentWorkspace.id) onWorkspaceChange?.(workspace.id);\n };\n\n /** Đóng panel rồi mới chạy hành động — tránh panel còn mở đè lên dialog app mở ra sau đó. */\n const closeThen = (action: () => void) => () => {\n setOpen(false);\n action();\n };\n\n const trigger = collapsed ? (\n <button\n type=\"button\"\n aria-label={labels.workspaceSwitcherLabel}\n className={cn(\n \"flex size-9 items-center justify-center rounded-md\",\n \"transition-colors duration-150 ease-out hover:bg-sidebar-accent\",\n className\n )}\n >\n <WorkspaceAvatar workspace={currentWorkspace} />\n </button>\n ) : (\n <button\n type=\"button\"\n aria-label={labels.workspaceSwitcherLabel}\n className={cn(\n \"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left\",\n \"transition-colors duration-150 ease-out hover:bg-sidebar-accent\",\n className\n )}\n >\n {/*\n `size-8` (32px) thay vì `size-6` (24px): avatar cao BẰNG khối chữ hai dòng bên cạnh\n (17,5px + 13,75px ≈ 31px sau khi cả hai dòng dùng `leading-tight`), nên hàng này đọc ra\n một khối chứ không phải một ô vuông nhỏ lửng lơ cạnh hai dòng chữ.\n ⚠ Chỉ đổi ở trigger dạng ĐẦY ĐỦ. Trigger lúc thanh bên thu gọn vẫn `size-6` trong một nút\n 36px — ở đó avatar là toàn bộ nội dung nên nó cần đệm quanh, không cần lấp đầy.\n */}\n <WorkspaceAvatar workspace={currentWorkspace} className=\"size-8\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n {/* `leading-tight` ở CẢ hai dòng: mặc định `text-sm` có line-height 20px và dòng gói\n 11px thừa hưởng line-height của body (~1,5) — cộng lại là một khoảng hở rộng hơn cả\n thân chữ, khiến tên gói trông như không thuộc về tên workspace. */}\n <span className=\"truncate text-sm font-semibold leading-tight text-sidebar-foreground\">\n {currentWorkspace.name}\n </span>\n {currentWorkspace.plan ? (\n // `text-muted-foreground` chứ không `text-sidebar-foreground/55`: đây là CHỮ (11px) nên\n // ngưỡng là AA 4,5:1, mà /55 trên `--sidebar` #FAFAF9 chỉ được 4,13:1 ✗ (dark 5,78:1 —\n // mỗi theme hỏng một nửa). `--muted-foreground` là bậc chữ phụ đã đo cho đúng vai trò:\n // 5,01:1 (light) · 5,35:1 (dark) trên `--sidebar` ✓.\n <span className=\"truncate text-[11px] leading-tight text-muted-foreground\">{currentWorkspace.plan}</span>\n ) : null}\n </span>\n {/* Mũi tên MANG THÔNG TIN (nút này mở ra một panel chọn) nên chịu ngưỡng 3:1 của WCAG\n 1.4.11, mà `text-sidebar-foreground/45` chỉ được 3,02:1 trên `--sidebar` ở light và\n tụt xuống 3,01:1 khi hover đổi nền sang `--sidebar-accent` — sát/dưới vạch, và ở\n layout \"header-first\" nút này còn nằm trên nền `--background` khác nữa.\n `--muted-foreground` là bậc đã đo và dư biên ở mọi nền nó có thể đứng: 5,01:1 trên\n `--sidebar` · 4,78:1 trên `--sidebar-accent` · 5,23:1 trên `--background` (light);\n 5,35 / 4,86 / 5,70:1 (dark) ✓. (Dark của `/45` là 4,26 / 4,14:1 — chỉ light hỏng,\n nhưng bậc mờ theo alpha luôn lệch nhau như vậy giữa hai theme.) */}\n <ChevronsUpDown className=\"size-3.5 shrink-0 text-muted-foreground\" />\n </button>\n );\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n {collapsed ? (\n <Tooltip>\n <TooltipTrigger asChild>\n <PopoverTrigger asChild>{trigger}</PopoverTrigger>\n </TooltipTrigger>\n <TooltipContent side=\"right\">{currentWorkspace.name}</TooltipContent>\n </Tooltip>\n ) : (\n <PopoverTrigger asChild>{trigger}</PopoverTrigger>\n )}\n\n <PopoverContent\n align={align}\n sideOffset={6}\n /*\n * `w-80` (320px), không phải `w-72` (288px). Ở 288px dòng phụ của hàng ĐANG CHỌN bị cắt —\n * đo được thiếu đúng 4px cho \"Quản trị viên · 6 thành viên\". Hàng đang chọn là hàng chật\n * nhất vì nó mang THÊM huy hiệu gói và dấu check, hai thứ mà các hàng khác không có.\n * Không nới vừa đủ 4px: tên vai trò và tên workspace là dữ liệu của app, dài ngắn tuỳ nơi\n * dùng — chọn mức có dư thay vì mức vừa khít với đúng bộ dữ liệu mẫu này.\n */\n className=\"w-80 p-0\"\n onOpenAutoFocus={(event) => {\n // Mặc định Radix focus vào khung panel; có ô tìm thì đưa con trỏ vào đó luôn.\n if (!searchRef.current) return;\n event.preventDefault();\n searchRef.current.focus();\n }}\n >\n {showList ? (\n <>\n {showSearch ? (\n <div className=\"relative border-b border-border p-2\">\n <Search className=\"pointer-events-none absolute left-4 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\" />\n <input\n ref={searchRef}\n type=\"search\"\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={labels.workspaceSearchPlaceholder}\n aria-label={labels.workspaceSearchPlaceholder}\n className={cn(\n \"h-8 w-full rounded-md border border-input bg-background pl-7 pr-2 text-sm\",\n // Focus dùng `--primary-ink`, KHÔNG `--ring` cũng không `--gold-300`. Cả hai\n // bậc kia ở light đều là gold #E8B824 — chỉ 1,73:1 trên nền trang, tức lúc\n // focus ô tìm MẤT ranh giới (`--ring` chỉ hợp cho vòng focus `ring-*`, thứ có\n // luật riêng, không phải cho viền control). `--primary-ink` giữ đúng sắc gold\n // mà đạt 6,09–6,51:1 (light) · 10,80–12,67:1 (dark) — hợp đồng màu §3.\n \"placeholder:text-muted-foreground focus:border-primary-ink focus:outline-none focus:ring-2 focus:ring-ring/50\"\n )}\n />\n </div>\n ) : null}\n\n {/*\n CỐ Ý không dùng role=\"listbox\"/\"option\": bộ ARIA đó hứa với trình đọc màn hình\n một widget điều khiển bằng ↑/↓/Home/End + roving tabindex + aria-activedescendant,\n mà hành vi thật ở đây chỉ là Tab qua từng nút trong Popover (Popover được chọn\n thay DropdownMenu vì menu của Radix cướp phím gõ của ô tìm kiếm — xem chú thích\n component). Khai đúng cái mình làm còn hơn khai sang trọng rồi lừa người dùng:\n danh sách nút thường + `aria-current` cho mục đang chọn.\n */}\n {/*\n `flex flex-col gap-1`: các hàng là khối `rounded-md` có nền khi hover/đang chọn —\n dính sát nhau thì hai nền bo góc chạm nhau và đọc thành một mảng liền, không ra ba\n mục riêng. 4px khớp đúng `p-1` của khung, nên đệm trong và khoảng cách giữa bằng nhau.\n */}\n <div\n className=\"flex max-h-64 flex-col gap-1 overflow-y-auto scrollbar-thin p-1\"\n role=\"group\"\n aria-label={labels.workspace}\n >\n {filtered.length === 0 ? (\n <p className=\"px-2 py-6 text-center text-sm text-muted-foreground\">{labels.workspaceEmpty}</p>\n ) : (\n filtered.map((workspace) => {\n const isCurrent = workspace.id === currentWorkspace.id;\n return (\n <button\n key={workspace.id}\n type=\"button\"\n aria-current={isCurrent ? \"true\" : undefined}\n onClick={() => handleSelect(workspace)}\n className={cn(\n \"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left\",\n \"transition-colors duration-150 ease-out hover:bg-accent\",\n isCurrent && \"bg-accent/60\"\n )}\n >\n {/* Cùng cách xử lý với trigger ở trên: avatar cao bằng khối chữ hai dòng,\n và cả hai dòng `leading-tight` để dòng phụ dính vào tên chứ không trôi\n lửng lơ. Trigger và danh sách là cùng một thứ ở hai chỗ — để hai kiểu\n khác nhau là người dùng thấy giao diện \"nhảy\" khi mở popover. */}\n <WorkspaceAvatar workspace={workspace} className=\"size-8\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"truncate text-sm font-medium leading-tight text-foreground\">\n {workspace.name}\n </span>\n {workspace.role || workspace.memberCount !== undefined ? (\n <span className=\"truncate text-[11px] leading-tight text-muted-foreground\">\n {[\n workspace.role,\n workspace.memberCount === undefined\n ? undefined\n : `${workspace.memberCount} ${labels.members}`\n ]\n .filter(Boolean)\n .join(\" · \")}\n </span>\n ) : null}\n </span>\n {workspace.plan ? (\n <Badge variant=\"secondary\" size=\"sm\" className=\"shrink-0\">\n {workspace.plan}\n </Badge>\n ) : null}\n {/* Ô check LUÔN chiếm chỗ, chỉ ẩn nội dung khi không phải mục đang chọn —\n `invisible` chứ không phải render có điều kiện. Render có điều kiện làm\n badge gói thuê bao của DÒNG ĐANG CHỌN bị đẩy lệch 24px so với các dòng\n khác, nên cột badge không thẳng hàng.\n Cùng thủ pháp với `OptionRow` của `Combobox` trong gói.\n\n Và vì sao KHÔNG bỏ hẳn dấu check: mục đang chọn khi đó chỉ còn `bg-accent/60`\n để nhận biết — ~1,03:1 với nền popover, tức không dấu hiệu nào đạt ngưỡng, và\n trạng thái lại đi bằng MỖI màu (WCAG 1.4.1). `aria-current` chỉ cứu trình đọc\n màn hình, không cứu người nhìn.\n\n KHÔNG dùng `text-app-accent` cho dấu check: accent mặc định (gold #E8B824)\n chỉ đạt 1,86:1 trên nền popover trắng — dưới ngưỡng 3:1 của WCAG 1.4.11 cho\n thành phần phi-văn-bản. `text-popover-foreground` đạt 18,9:1 (light)\n và 16,6:1 (dark). */}\n <Check\n aria-hidden=\"true\"\n className={cn(\"size-4 shrink-0 text-popover-foreground\", !isCurrent && \"invisible\")}\n />\n </button>\n );\n })\n )}\n </div>\n </>\n ) : (\n <div className=\"flex items-center gap-2 p-3\">\n <WorkspaceAvatar workspace={currentWorkspace} className=\"size-8\" />\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate text-sm font-semibold text-foreground\">{currentWorkspace.name}</span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {currentWorkspace.plan ?? currentWorkspace.slug}\n </span>\n </span>\n </div>\n )}\n\n {hasActions ? (\n <>\n <Separator />\n {/* `gap-1` — cùng 4px với danh sách workspace ở trên và với khoảng cách mục của họ\n menu (`DropdownMenuItem`…). Thiếu nó thì bốn hàng hành động dính liền thành một\n khối, và vùng `hover:bg-accent` của chúng cũng liền một mảng. */}\n <div className=\"flex flex-col gap-1 p-1\">\n {onCreateWorkspace ? (\n <ActionRow icon={Plus} label={labels.createWorkspace} onClick={closeThen(onCreateWorkspace)} />\n ) : null}\n {onWorkspaceSettings ? (\n <ActionRow\n icon={Settings}\n label={labels.workspaceSettings}\n onClick={closeThen(() => onWorkspaceSettings(currentWorkspace.id))}\n />\n ) : null}\n {onInviteMembers ? (\n <ActionRow\n icon={UserPlus}\n label={labels.inviteMembers}\n onClick={closeThen(() => onInviteMembers(currentWorkspace.id))}\n />\n ) : null}\n {onLogout ? (\n <ActionRow icon={LogOut} label={labels.logout} destructive onClick={closeThen(onLogout)} />\n ) : null}\n </div>\n </>\n ) : null}\n </PopoverContent>\n </Popover>\n );\n}\n\n/** Một dòng hành động trong panel (không phải mục chọn workspace). */\nfunction ActionRow({\n icon: Icon = Building2,\n label,\n onClick,\n destructive = false\n}: {\n icon?: typeof Building2;\n label: string;\n onClick: () => void;\n destructive?: boolean;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n className={cn(\n \"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm\",\n \"transition-colors duration-150 ease-out\",\n // Bậc ink chứ không `text-destructive`: dòng \"Đăng xuất\" là CHỮ trên nền `--popover`,\n // mà `--destructive` (token NỀN) chỉ được 3,53:1 trên `--popover` ở dark ✗. Ink đạt cả\n // hai: 7,15:1 (light) · 6,42:1 (dark). Icon ở `opacity-70` là thành phần phi-văn-bản\n // (ngưỡng 3:1): 3,69:1 / 3,73:1 lúc nghỉ, và 3,37:1 / 3,62:1 trên nền hover\n // `bg-destructive/10` — đó mới là tổ hợp chặt nhất, vẫn ✓. Chữ lúc hover: 6,18:1 / 5,94:1.\n destructive ? \"text-destructive-ink hover:bg-destructive/10\" : \"text-foreground hover:bg-accent\"\n )}\n >\n <Icon className=\"size-4 shrink-0 opacity-70\" />\n <span className=\"truncate\">{label}</span>\n </button>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/shell/workspace-switcher.tsx"],"sourcesContent":["\"use client\";\n\nimport { Building2, Check, ChevronsUpDown, LogOut, Plus, Search, Settings, UserPlus } from \"lucide-react\";\nimport { useMemo, useRef, useState } from \"react\";\nimport { getAvatarToneClasses } from \"../components/composite/letter-avatar.js\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"../components/ui/avatar.js\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../components/ui/popover.js\";\nimport { Separator } from \"../components/ui/separator.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport type { Workspace } from \"./types.js\";\nimport { getVietnameseInitials, normalizeVi } from \"./utils.js\";\n\n/** Từ số workspace này trở lên thì hiện ô tìm kiếm trong danh sách. */\nconst SEARCH_THRESHOLD = 8;\n\n/**\n * Ảnh đại diện workspace: logo nếu có, không thì ô chữ cái tô màu deterministic.\n *\n * Seed là `workspace.name` chứ KHÔNG phải slug: app hiển thị workspace bằng\n * `<LetterAvatar name={workspace.name} square />` ở chỗ khác, seed lệch là màu lệch.\n */\nfunction WorkspaceAvatar({ workspace, className }: { workspace: Workspace; className?: string }) {\n return (\n <Avatar className={cn(\"size-6 rounded-md\", className)}>\n {workspace.logoUrl ? <AvatarImage src={workspace.logoUrl} alt=\"\" className=\"rounded-md\" /> : null}\n <AvatarFallback className={cn(\"rounded-md text-[10px] font-semibold\", getAvatarToneClasses(workspace.name))}>\n {getVietnameseInitials(workspace.name)}\n </AvatarFallback>\n </Avatar>\n );\n}\n\nexport interface WorkspaceSwitcherProps {\n className?: string;\n /** Sidebar thu gọn → chỉ hiện avatar + tooltip. */\n collapsed?: boolean;\n /** Ẩn khu \"Hành động\" (tạo workspace, cài đặt, mời thành viên, đăng xuất). */\n showActions?: boolean;\n /** Căn panel — mặc định \"start\" (mở xuôi theo mép trái sidebar). */\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * **Trục 1 — đổi không gian làm việc.**\n *\n * Chỉ gọi `onWorkspaceChange(id)`; điều hướng/đổi subdomain là việc của app\n * (đổi workspace kéo theo đổi toàn bộ dữ liệu + quyền nên shell không tự quyết).\n * Có ≤1 workspace thì phần danh sách tự ẩn, chỉ còn nhãn workspace hiện tại.\n *\n * Dùng `Popover` chứ không `DropdownMenu`: menu của Radix có typeahead, gõ vào\n * ô tìm kiếm bên trong sẽ bị nó cướp phím.\n */\nexport function WorkspaceSwitcher({\n className,\n collapsed = false,\n showActions = true,\n align = \"start\"\n}: WorkspaceSwitcherProps) {\n const {\n workspaces,\n currentWorkspace,\n onWorkspaceChange,\n onCreateWorkspace,\n onWorkspaceSettings,\n onInviteMembers,\n onLogout,\n labels\n } = useShell();\n\n const [open, setOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n const searchRef = useRef<HTMLInputElement>(null);\n\n const showList = workspaces.length > 1;\n const showSearch = workspaces.length >= SEARCH_THRESHOLD;\n\n const filtered = useMemo(() => {\n if (!query.trim()) return workspaces;\n const needle = normalizeVi(query);\n return workspaces.filter(\n (workspace) => normalizeVi(workspace.name).includes(needle) || normalizeVi(workspace.slug).includes(needle)\n );\n }, [workspaces, query]);\n\n const hasActions = showActions && Boolean(onCreateWorkspace || onWorkspaceSettings || onInviteMembers || onLogout);\n\n if (!currentWorkspace) return null;\n\n const handleSelect = (workspace: Workspace) => {\n setOpen(false);\n setQuery(\"\");\n if (workspace.id !== currentWorkspace.id) onWorkspaceChange?.(workspace.id);\n };\n\n /** Đóng panel rồi mới chạy hành động — tránh panel còn mở đè lên dialog app mở ra sau đó. */\n const closeThen = (action: () => void) => () => {\n setOpen(false);\n action();\n };\n\n const trigger = collapsed ? (\n <button\n type=\"button\"\n aria-label={labels.workspaceSwitcherLabel}\n className={cn(\n \"flex size-9 items-center justify-center rounded-md\",\n \"transition-colors duration-150 ease-out hover:bg-sidebar-accent\",\n className\n )}\n >\n <WorkspaceAvatar workspace={currentWorkspace} />\n </button>\n ) : (\n <button\n type=\"button\"\n aria-label={labels.workspaceSwitcherLabel}\n className={cn(\n \"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left\",\n \"transition-colors duration-150 ease-out hover:bg-sidebar-accent\",\n className\n )}\n >\n {/*\n `size-8` (32px) thay vì `size-6` (24px): avatar cao BẰNG khối chữ hai dòng bên cạnh\n (17,5px + 13,75px ≈ 31px sau khi cả hai dòng dùng `leading-tight`), nên hàng này đọc ra\n một khối chứ không phải một ô vuông nhỏ lửng lơ cạnh hai dòng chữ.\n ⚠ Chỉ đổi ở trigger dạng ĐẦY ĐỦ. Trigger lúc thanh bên thu gọn vẫn `size-6` trong một nút\n 36px — ở đó avatar là toàn bộ nội dung nên nó cần đệm quanh, không cần lấp đầy.\n */}\n <WorkspaceAvatar workspace={currentWorkspace} className=\"size-8\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n {/* `leading-tight` ở CẢ hai dòng: mặc định `text-sm` có line-height 20px và dòng gói\n 11px thừa hưởng line-height của body (~1,5) — cộng lại là một khoảng hở rộng hơn cả\n thân chữ, khiến tên gói trông như không thuộc về tên workspace. */}\n <span className=\"truncate text-sm font-semibold leading-tight text-sidebar-foreground\">\n {currentWorkspace.name}\n </span>\n {currentWorkspace.plan ? (\n // `text-muted-foreground` chứ không `text-sidebar-foreground/55`: đây là CHỮ (11px) nên\n // ngưỡng là AA 4,5:1, mà /55 trên `--sidebar` #FAFAF9 chỉ được 4,13:1 ✗ (dark 5,78:1 —\n // mỗi theme hỏng một nửa). `--muted-foreground` là bậc chữ phụ đã đo cho đúng vai trò:\n // 5,01:1 (light) · 5,35:1 (dark) trên `--sidebar` ✓.\n <span className=\"truncate text-[11px] leading-tight text-muted-foreground\">{currentWorkspace.plan}</span>\n ) : null}\n </span>\n {/* Mũi tên MANG THÔNG TIN (nút này mở ra một panel chọn) nên chịu ngưỡng 3:1 của WCAG\n 1.4.11, mà `text-sidebar-foreground/45` chỉ được 3,02:1 trên `--sidebar` ở light và\n tụt xuống 3,01:1 khi hover đổi nền sang `--sidebar-accent` — sát/dưới vạch, và ở\n layout \"header-first\" nút này còn nằm trên nền `--background` khác nữa.\n `--muted-foreground` là bậc đã đo và dư biên ở mọi nền nó có thể đứng: 5,01:1 trên\n `--sidebar` · 4,78:1 trên `--sidebar-accent` · 5,23:1 trên `--background` (light);\n 5,35 / 4,86 / 5,70:1 (dark) ✓. (Dark của `/45` là 4,26 / 4,14:1 — chỉ light hỏng,\n nhưng bậc mờ theo alpha luôn lệch nhau như vậy giữa hai theme.) */}\n <ChevronsUpDown className=\"size-3.5 shrink-0 text-muted-foreground\" />\n </button>\n );\n\n return (\n <Popover open={open} onOpenChange={setOpen}>\n {collapsed ? (\n <Tooltip>\n <TooltipTrigger asChild>\n <PopoverTrigger asChild>{trigger}</PopoverTrigger>\n </TooltipTrigger>\n <TooltipContent side=\"right\">{currentWorkspace.name}</TooltipContent>\n </Tooltip>\n ) : (\n <PopoverTrigger asChild>{trigger}</PopoverTrigger>\n )}\n\n <PopoverContent\n align={align}\n sideOffset={6}\n /*\n * `w-80` (320px), không phải `w-72` (288px). Ở 288px dòng phụ của hàng ĐANG CHỌN bị cắt —\n * đo được thiếu đúng 4px cho \"Quản trị viên · 6 thành viên\". Hàng đang chọn là hàng chật\n * nhất vì nó mang THÊM huy hiệu gói và dấu check, hai thứ mà các hàng khác không có.\n * Không nới vừa đủ 4px: tên vai trò và tên workspace là dữ liệu của app, dài ngắn tuỳ nơi\n * dùng — chọn mức có dư thay vì mức vừa khít với đúng bộ dữ liệu mẫu này.\n */\n className=\"w-80 p-0\"\n onOpenAutoFocus={(event) => {\n // Mặc định Radix focus vào khung panel; có ô tìm thì đưa con trỏ vào đó luôn.\n if (!searchRef.current) return;\n event.preventDefault();\n searchRef.current.focus();\n }}\n >\n {showList ? (\n <>\n {showSearch ? (\n <div className=\"relative border-b border-border p-2\">\n <Search className=\"pointer-events-none absolute left-4 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground\" />\n <input\n ref={searchRef}\n type=\"search\"\n value={query}\n onChange={(event) => setQuery(event.target.value)}\n placeholder={labels.workspaceSearchPlaceholder}\n aria-label={labels.workspaceSearchPlaceholder}\n className={cn(\n \"h-8 w-full rounded-md border border-input bg-background pl-7 pr-2 text-sm\",\n // Focus dùng `--primary-ink`, KHÔNG `--ring` cũng không `--gold-300`. Cả hai\n // bậc kia ở light đều là gold #E8B824 — chỉ 1,73:1 trên nền trang, tức lúc\n // focus ô tìm MẤT ranh giới (`--ring` chỉ hợp cho vòng focus `ring-*`, thứ có\n // luật riêng, không phải cho viền control). `--primary-ink` giữ đúng sắc gold\n // mà đạt 6,09–6,51:1 (light) · 10,80–12,67:1 (dark) — hợp đồng màu §3.\n \"placeholder:text-muted-foreground focus:border-primary-ink focus:outline-none focus:ring-2 focus:ring-ring/50\"\n )}\n />\n </div>\n ) : null}\n\n {/*\n CỐ Ý không dùng role=\"listbox\"/\"option\": bộ ARIA đó hứa với trình đọc màn hình\n một widget điều khiển bằng ↑/↓/Home/End + roving tabindex + aria-activedescendant,\n mà hành vi thật ở đây chỉ là Tab qua từng nút trong Popover (Popover được chọn\n thay DropdownMenu vì menu của Radix cướp phím gõ của ô tìm kiếm — xem chú thích\n component). Khai đúng cái mình làm còn hơn khai sang trọng rồi lừa người dùng:\n danh sách nút thường + `aria-current` cho mục đang chọn.\n */}\n {/*\n `flex flex-col gap-1`: các hàng là khối `rounded-md` có nền khi hover/đang chọn —\n dính sát nhau thì hai nền bo góc chạm nhau và đọc thành một mảng liền, không ra ba\n mục riêng. 4px khớp đúng `p-1` của khung, nên đệm trong và khoảng cách giữa bằng nhau.\n */}\n <div\n className=\"flex max-h-64 flex-col gap-1 overflow-y-auto scrollbar-thin p-1\"\n role=\"group\"\n aria-label={labels.workspace}\n >\n {filtered.length === 0 ? (\n <p className=\"px-2 py-6 text-center text-sm text-muted-foreground\">{labels.workspaceEmpty}</p>\n ) : (\n filtered.map((workspace) => {\n const isCurrent = workspace.id === currentWorkspace.id;\n return (\n <button\n key={workspace.id}\n type=\"button\"\n aria-current={isCurrent ? \"true\" : undefined}\n onClick={() => handleSelect(workspace)}\n className={cn(\n \"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left\",\n \"transition-colors duration-150 ease-out hover:bg-accent\",\n isCurrent && \"bg-accent/60\"\n )}\n >\n {/* Cùng cách xử lý với trigger ở trên: avatar cao bằng khối chữ hai dòng,\n và cả hai dòng `leading-tight` để dòng phụ dính vào tên chứ không trôi\n lửng lơ. Trigger và danh sách là cùng một thứ ở hai chỗ — để hai kiểu\n khác nhau là người dùng thấy giao diện \"nhảy\" khi mở popover. */}\n <WorkspaceAvatar workspace={workspace} className=\"size-8\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"truncate text-sm font-medium leading-tight text-foreground\">\n {workspace.name}\n </span>\n {workspace.role || workspace.memberCount !== undefined ? (\n <span className=\"truncate text-[11px] leading-tight text-muted-foreground\">\n {[\n workspace.role,\n workspace.memberCount === undefined ? undefined : labels.members(workspace.memberCount)\n ]\n .filter(Boolean)\n .join(\" · \")}\n </span>\n ) : null}\n </span>\n {workspace.plan ? (\n <Badge variant=\"secondary\" size=\"sm\" className=\"shrink-0\">\n {workspace.plan}\n </Badge>\n ) : null}\n {/* Ô check LUÔN chiếm chỗ, chỉ ẩn nội dung khi không phải mục đang chọn —\n `invisible` chứ không phải render có điều kiện. Render có điều kiện làm\n badge gói thuê bao của DÒNG ĐANG CHỌN bị đẩy lệch 24px so với các dòng\n khác, nên cột badge không thẳng hàng.\n Cùng thủ pháp với `OptionRow` của `Combobox` trong gói.\n\n Và vì sao KHÔNG bỏ hẳn dấu check: mục đang chọn khi đó chỉ còn `bg-accent/60`\n để nhận biết — ~1,03:1 với nền popover, tức không dấu hiệu nào đạt ngưỡng, và\n trạng thái lại đi bằng MỖI màu (WCAG 1.4.1). `aria-current` chỉ cứu trình đọc\n màn hình, không cứu người nhìn.\n\n KHÔNG dùng `text-app-accent` cho dấu check: accent mặc định (gold #E8B824)\n chỉ đạt 1,86:1 trên nền popover trắng — dưới ngưỡng 3:1 của WCAG 1.4.11 cho\n thành phần phi-văn-bản. `text-popover-foreground` đạt 18,9:1 (light)\n và 16,6:1 (dark). */}\n <Check\n aria-hidden=\"true\"\n className={cn(\"size-4 shrink-0 text-popover-foreground\", !isCurrent && \"invisible\")}\n />\n </button>\n );\n })\n )}\n </div>\n </>\n ) : (\n <div className=\"flex items-center gap-2 p-3\">\n <WorkspaceAvatar workspace={currentWorkspace} className=\"size-8\" />\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate text-sm font-semibold text-foreground\">{currentWorkspace.name}</span>\n <span className=\"truncate text-[11px] text-muted-foreground\">\n {currentWorkspace.plan ?? currentWorkspace.slug}\n </span>\n </span>\n </div>\n )}\n\n {hasActions ? (\n <>\n <Separator />\n {/* `gap-1` — cùng 4px với danh sách workspace ở trên và với khoảng cách mục của họ\n menu (`DropdownMenuItem`…). Thiếu nó thì bốn hàng hành động dính liền thành một\n khối, và vùng `hover:bg-accent` của chúng cũng liền một mảng. */}\n <div className=\"flex flex-col gap-1 p-1\">\n {onCreateWorkspace ? (\n <ActionRow icon={Plus} label={labels.createWorkspace} onClick={closeThen(onCreateWorkspace)} />\n ) : null}\n {onWorkspaceSettings ? (\n <ActionRow\n icon={Settings}\n label={labels.workspaceSettings}\n onClick={closeThen(() => onWorkspaceSettings(currentWorkspace.id))}\n />\n ) : null}\n {onInviteMembers ? (\n <ActionRow\n icon={UserPlus}\n label={labels.inviteMembers}\n onClick={closeThen(() => onInviteMembers(currentWorkspace.id))}\n />\n ) : null}\n {onLogout ? (\n <ActionRow icon={LogOut} label={labels.logout} destructive onClick={closeThen(onLogout)} />\n ) : null}\n </div>\n </>\n ) : null}\n </PopoverContent>\n </Popover>\n );\n}\n\n/** Một dòng hành động trong panel (không phải mục chọn workspace). */\nfunction ActionRow({\n icon: Icon = Building2,\n label,\n onClick,\n destructive = false\n}: {\n icon?: typeof Building2;\n label: string;\n onClick: () => void;\n destructive?: boolean;\n}) {\n return (\n <button\n type=\"button\"\n onClick={onClick}\n className={cn(\n \"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm\",\n \"transition-colors duration-150 ease-out\",\n // Bậc ink chứ không `text-destructive`: dòng \"Đăng xuất\" là CHỮ trên nền `--popover`,\n // mà `--destructive` (token NỀN) chỉ được 3,53:1 trên `--popover` ở dark ✗. Ink đạt cả\n // hai: 7,15:1 (light) · 6,42:1 (dark). Icon ở `opacity-70` là thành phần phi-văn-bản\n // (ngưỡng 3:1): 3,69:1 / 3,73:1 lúc nghỉ, và 3,37:1 / 3,62:1 trên nền hover\n // `bg-destructive/10` — đó mới là tổ hợp chặt nhất, vẫn ✓. Chữ lúc hover: 6,18:1 / 5,94:1.\n destructive ? \"text-destructive-ink hover:bg-destructive/10\" : \"text-foreground hover:bg-accent\"\n )}\n >\n <Icon className=\"size-4 shrink-0 opacity-70\" />\n <span className=\"truncate\">{label}</span>\n </button>\n );\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,19 +1,15 @@
1
1
  {
2
2
  "name": "@comitor/ui",
3
- "version": "0.9.0",
3
+ "version": "1.0.0",
4
4
  "description": "Comitor design system — Shadcn/UI tùy biến theo brand Comitor + component dùng chung cho hệ sinh thái SaaS (Tasks, Chat, CRM, HR…)",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "publishConfig": {
8
8
  "access": "public"
9
9
  },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/comitor-ai/comitor-ui.git"
13
- },
14
- "homepage": "https://github.com/comitor-ai/comitor-ui#readme",
10
+ "homepage": "https://design-system.comitor.ai",
15
11
  "bugs": {
16
- "url": "https://github.com/comitor-ai/comitor-ui/issues"
12
+ "email": "contact@comitor.ai"
17
13
  },
18
14
  "keywords": [
19
15
  "comitor",