@comitor/ui 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -10
- package/dist/components/composite/avatar-group.d.ts +13 -1
- package/dist/components/composite/avatar-group.js +19 -4
- package/dist/components/composite/avatar-group.js.map +1 -1
- package/dist/components/composite/combobox.js +169 -146
- package/dist/components/composite/combobox.js.map +1 -1
- package/dist/components/composite/data-table.d.ts +23 -4
- package/dist/components/composite/data-table.js +37 -2
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/date-picker.js +46 -17
- package/dist/components/composite/date-picker.js.map +1 -1
- package/dist/components/composite/stat-card.js +6 -6
- package/dist/components/composite/stat-card.js.map +1 -1
- package/dist/components/composite/status-config.d.ts +9 -1
- package/dist/components/composite/status-config.js.map +1 -1
- package/dist/components/ui/calendar.d.ts +29 -24
- package/dist/components/ui/calendar.js +1 -1
- package/dist/components/ui/calendar.js.map +1 -1
- package/dist/components/ui/checkbox.js +2 -2
- package/dist/components/ui/checkbox.js.map +1 -1
- package/dist/components/ui/command.js +1 -1
- package/dist/components/ui/command.js.map +1 -1
- package/dist/components/ui/context-menu.js +4 -4
- package/dist/components/ui/context-menu.js.map +1 -1
- package/dist/components/ui/dropdown-menu.js +4 -4
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/empty.js +1 -1
- package/dist/components/ui/empty.js.map +1 -1
- package/dist/components/ui/field.js +1 -1
- package/dist/components/ui/field.js.map +1 -1
- package/dist/components/ui/item.js +1 -1
- package/dist/components/ui/item.js.map +1 -1
- package/dist/components/ui/menubar.js +4 -4
- package/dist/components/ui/menubar.js.map +1 -1
- package/dist/components/ui/radio-group.js +1 -1
- package/dist/components/ui/radio-group.js.map +1 -1
- package/dist/components/ui/select.d.ts +35 -12
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/select.js.map +1 -1
- package/dist/components/ui/switch.js +2 -2
- package/dist/components/ui/switch.js.map +1 -1
- package/dist/components/ui/table.js +2 -2
- package/dist/components/ui/table.js.map +1 -1
- package/dist/shell/app-header.js +46 -48
- package/dist/shell/app-header.js.map +1 -1
- package/dist/shell/mobile-menu.d.ts +6 -1
- package/dist/shell/mobile-menu.js +5 -5
- package/dist/shell/mobile-menu.js.map +1 -1
- package/dist/shell/notifications-button.js +31 -9
- package/dist/shell/notifications-button.js.map +1 -1
- package/dist/shell/sidebar.js +1 -1
- package/dist/shell/sidebar.js.map +1 -1
- package/dist/shell/types.d.ts +13 -0
- package/dist/shell/types.js +1 -0
- package/dist/shell/types.js.map +1 -1
- package/dist/shell/user-menu.d.ts +14 -2
- package/dist/shell/user-menu.js +3 -3
- package/dist/shell/user-menu.js.map +1 -1
- package/dist/shell/workspace-switcher.js +48 -34
- package/dist/shell/workspace-switcher.js.map +1 -1
- package/package.json +4 -4
- package/styles.css +95 -16
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/user-menu.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronDown, CircleUser, HelpCircle, LogOut, Monitor, Moon, Palette, Settings, Sun } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { getAvatarToneClasses } from \"../components/composite/letter-avatar.js\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"../components/ui/avatar.js\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger\n} from \"../components/ui/dropdown-menu.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { ShellUser, UserMenuItem } from \"./types.js\";\nimport { getVietnameseInitials } from \"./utils.js\";\n\nexport interface UserAvatarProps {\n user: ShellUser;\n className?: string;\n}\n\n/**\n * Avatar người dùng: ảnh nếu có, không thì chữ cái kiểu VN (HỌ + TÊN) tô màu theo tên.\n *\n * Tô màu theo `user.name` chứ KHÔNG theo email: `LetterAvatar`/`AvatarGroup` ở Tier 2 seed\n * bằng tên, nên lấy khoá khác ở đây là cùng một người ra hai màu trên cùng một trang.\n */\nexport function UserAvatar({ user, className }: UserAvatarProps) {\n return (\n <Avatar className={cn(\"size-8\", className)}>\n {user.avatarUrl ? <AvatarImage src={user.avatarUrl} alt=\"\" /> : null}\n <AvatarFallback className={cn(\"text-xs font-semibold\", getAvatarToneClasses(user.name))}>\n {getVietnameseInitials(user.name)}\n </AvatarFallback>\n </Avatar>\n );\n}\n\nexport interface UserMenuProps {\n className?: string;\n /** Hiện tên + vai trò cạnh avatar; màn hình hẹp nên để `false`. */\n showName?: boolean;\n /** Link \"Hồ sơ\" — bỏ trống thì không render mục này (tránh mục dẫn đi đâu không rõ). */\n profileHref?: string;\n /** Link \"Cài đặt\". */\n settingsHref?: string;\n /** Link \"Trợ giúp\". */\n helpHref?: string;\n /** Mục bổ sung của app — chèn ngay trên \"Đăng xuất\". */\n items?: UserMenuItem[];\n /** Ẩn submenu đổi giao diện (khi header đã có `ThemeToggle` riêng và không muốn lặp). */\n showThemeSubmenu?: boolean;\n /** Ghi đè handler đăng xuất của context. */\n onLogout?: () => void;\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * Menu người dùng ở góc phải header: avatar + tên/email, các mục do app khai báo,\n * submenu đổi giao diện, và \"Đăng xuất\" (tô màu destructive).\n *\n * Mục dựng sẵn chỉ hiện khi app cấp `href` tương ứng — package không tự bịa route.\n */\nexport function UserMenu({\n className,\n showName = false,\n profileHref,\n settingsHref,\n helpHref,\n items = [],\n showThemeSubmenu = true,\n onLogout,\n align = \"end\"\n}: UserMenuProps) {\n const shell = useShell();\n const { theme, setTheme } = useTheme();\n const { user, labels } = shell;\n\n if (!user) return null;\n\n const handleLogout = onLogout ?? shell.onLogout;\n\n const renderItem = (item: UserMenuItem) => {\n const Icon = item.icon;\n const content = (\n <>\n {Icon ? <Icon className=\"size-4\" /> : null}\n <span className=\"truncate\">{item.label}</span>\n {item.shortcut ? <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut> : null}\n </>\n );\n\n if (item.href) {\n return (\n <DropdownMenuItem key={item.id} variant={item.variant} asChild>\n <ShellNavLink href={item.href} onClick={() => item.onSelect?.()}>\n {content}\n </ShellNavLink>\n </DropdownMenuItem>\n );\n }\n\n return (\n <DropdownMenuItem key={item.id} variant={item.variant} onClick={() => item.onSelect?.()}>\n {content}\n </DropdownMenuItem>\n );\n };\n\n const builtInItems: (UserMenuItem | null)[] = [\n profileHref ? { id: \"profile\", label: labels.profile, icon: CircleUser, href: profileHref } : null,\n settingsHref ? { id: \"settings\", label: labels.settings, icon: Settings, href: settingsHref } : null,\n helpHref ? { id: \"help\", label: labels.help, icon: HelpCircle, href: helpHref } : null\n ];\n const visibleBuiltIns = builtInItems.filter((item): item is UserMenuItem => item !== null);\n\n const hasEntries = visibleBuiltIns.length > 0 || items.length > 0 || showThemeSubmenu;\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n aria-label={labels.account}\n className={cn(\n \"flex items-center gap-2 rounded-md p-1 text-left\",\n \"transition-colors duration-150 ease-out hover:bg-muted\",\n className\n )}\n >\n <UserAvatar user={user} />\n {showName ? (\n <>\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate text-sm font-medium leading-tight text-foreground\">{user.name}</span>\n {user.role ? (\n <span className=\"truncate text-[11px] leading-tight text-muted-foreground\">{user.role}</span>\n ) : null}\n </span>\n <ChevronDown className=\"size-3.5 shrink-0 text-muted-foreground\" />\n </>\n ) : null}\n </button>\n </DropdownMenuTrigger>\n\n <DropdownMenuContent align={align} className=\"w-60\">\n <DropdownMenuLabel className=\"flex items-center gap-2 font-normal\">\n <UserAvatar user={user} />\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate text-sm font-medium\">{user.name}</span>\n {user.email ? <span className=\"truncate text-xs text-muted-foreground\">{user.email}</span> : null}\n {user.role ? <span className=\"truncate text-[11px] text-muted-foreground\">{user.role}</span> : null}\n </span>\n </DropdownMenuLabel>\n\n {hasEntries ? <DropdownMenuSeparator /> : null}\n\n {visibleBuiltIns.map(renderItem)}\n {items.map(renderItem)}\n\n {showThemeSubmenu ? (\n <DropdownMenuSub>\n <DropdownMenuSubTrigger>\n <Palette className=\"size-4\" />\n <span>{labels.appearance}</span>\n </DropdownMenuSubTrigger>\n <DropdownMenuSubContent>\n <DropdownMenuItem onClick={() => setTheme(\"light\")} className={cn(theme === \"light\" && \"bg-accent\")}>\n <Sun className=\"size-4\" />\n {labels.themeLight}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"dark\")} className={cn(theme === \"dark\" && \"bg-accent\")}>\n <Moon className=\"size-4\" />\n {labels.themeDark}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"system\")} className={cn(theme === \"system\" && \"bg-accent\")}>\n <Monitor className=\"size-4\" />\n {labels.themeSystem}\n </DropdownMenuItem>\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n ) : null}\n\n {handleLogout ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant=\"destructive\" onClick={handleLogout}>\n <LogOut className=\"size-4\" />\n <span>{labels.logout}</span>\n </DropdownMenuItem>\n </>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n"],"mappings":";AAqCI,SAwDE,UAvDkB,KADpB;AAnCJ,SAAS,aAAa,YAAY,YAAY,QAAQ,SAAS,MAAM,SAAS,UAAU,WAAW;AACnG,SAAS,gBAAgB;AACzB,SAAS,4BAA4B;AACrC,SAAS,QAAQ,gBAAgB,mBAAmB;AACpD;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,UAAU;AACnB,SAAS,gBAAgB;AACzB,SAAS,oBAAoB;AAE7B,SAAS,6BAA6B;AAa/B,SAAS,WAAW,EAAE,MAAM,UAAU,GAAoB;AAC/D,SACE,qBAAC,UAAO,WAAW,GAAG,UAAU,SAAS,GACtC;AAAA,SAAK,YAAY,oBAAC,eAAY,KAAK,KAAK,WAAW,KAAI,IAAG,IAAK;AAAA,IAChE,oBAAC,kBAAe,WAAW,GAAG,yBAAyB,qBAAqB,KAAK,IAAI,CAAC,GACnF,gCAAsB,KAAK,IAAI,GAClC;AAAA,KACF;AAEJ;AA2BO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ,CAAC;AAAA,EACT,mBAAmB;AAAA,EACnB;AAAA,EACA,QAAQ;AACV,GAAkB;AAChB,QAAM,QAAQ,SAAS;AACvB,QAAM,EAAE,OAAO,SAAS,IAAI,SAAS;AACrC,QAAM,EAAE,MAAM,OAAO,IAAI;AAEzB,MAAI,CAAC,KAAM,QAAO;AAElB,QAAM,eAAe,YAAY,MAAM;AAEvC,QAAM,aAAa,CAAC,SAAuB;AACzC,UAAM,OAAO,KAAK;AAClB,UAAM,UACJ,iCACG;AAAA,aAAO,oBAAC,QAAK,WAAU,UAAS,IAAK;AAAA,MACtC,oBAAC,UAAK,WAAU,YAAY,eAAK,OAAM;AAAA,MACtC,KAAK,WAAW,oBAAC,wBAAsB,eAAK,UAAS,IAA0B;AAAA,OAClF;AAGF,QAAI,KAAK,MAAM;AACb,aACE,oBAAC,oBAA+B,SAAS,KAAK,SAAS,SAAO,MAC5D,8BAAC,gBAAa,MAAM,KAAK,MAAM,SAAS,MAAM,KAAK,WAAW,GAC3D,mBACH,KAHqB,KAAK,EAI5B;AAAA,IAEJ;AAEA,WACE,oBAAC,oBAA+B,SAAS,KAAK,SAAS,SAAS,MAAM,KAAK,WAAW,GACnF,qBADoB,KAAK,EAE5B;AAAA,EAEJ;AAEA,QAAM,eAAwC;AAAA,IAC5C,cAAc,EAAE,IAAI,WAAW,OAAO,OAAO,SAAS,MAAM,YAAY,MAAM,YAAY,IAAI;AAAA,IAC9F,eAAe,EAAE,IAAI,YAAY,OAAO,OAAO,UAAU,MAAM,UAAU,MAAM,aAAa,IAAI;AAAA,IAChG,WAAW,EAAE,IAAI,QAAQ,OAAO,OAAO,MAAM,MAAM,YAAY,MAAM,SAAS,IAAI;AAAA,EACpF;AACA,QAAM,kBAAkB,aAAa,OAAO,CAAC,SAA+B,SAAS,IAAI;AAEzF,QAAM,aAAa,gBAAgB,SAAS,KAAK,MAAM,SAAS,KAAK;AAErE,SACE,qBAAC,gBACC;AAAA,wBAAC,uBAAoB,SAAO,MAC1B;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAY,OAAO;AAAA,QACnB,WAAW;AAAA,UACT;AAAA,UACA;AAAA,UACA;AAAA,QACF;AAAA,QAEA;AAAA,8BAAC,cAAW,MAAY;AAAA,UACvB,WACC,iCACE;AAAA,iCAAC,UAAK,WAAU,yBACd;AAAA,kCAAC,UAAK,WAAU,8DAA8D,eAAK,MAAK;AAAA,cACvF,KAAK,OACJ,oBAAC,UAAK,WAAU,4DAA4D,eAAK,MAAK,IACpF;AAAA,eACN;AAAA,YACA,oBAAC,eAAY,WAAU,2CAA0C;AAAA,aACnE,IACE;AAAA;AAAA;AAAA,IACN,GACF;AAAA,IAEA,qBAAC,uBAAoB,OAAc,WAAU,QAC3C;AAAA,2BAAC,qBAAkB,WAAU,uCAC3B;AAAA,4BAAC,cAAW,MAAY;AAAA,QACxB,qBAAC,UAAK,WAAU,yBACd;AAAA,8BAAC,UAAK,WAAU,gCAAgC,eAAK,MAAK;AAAA,UACzD,KAAK,QAAQ,oBAAC,UAAK,WAAU,0CAA0C,eAAK,OAAM,IAAU;AAAA,UAC5F,KAAK,OAAO,oBAAC,UAAK,WAAU,8CAA8C,eAAK,MAAK,IAAU;AAAA,WACjG;AAAA,SACF;AAAA,MAEC,aAAa,oBAAC,yBAAsB,IAAK;AAAA,MAEzC,gBAAgB,IAAI,UAAU;AAAA,MAC9B,MAAM,IAAI,UAAU;AAAA,MAEpB,mBACC,qBAAC,mBACC;AAAA,6BAAC,0BACC;AAAA,8BAAC,WAAQ,WAAU,UAAS;AAAA,UAC5B,oBAAC,UAAM,iBAAO,YAAW;AAAA,WAC3B;AAAA,QACA,qBAAC,0BACC;AAAA,+BAAC,oBAAiB,SAAS,MAAM,SAAS,OAAO,GAAG,WAAW,GAAG,UAAU,WAAW,WAAW,GAChG;AAAA,gCAAC,OAAI,WAAU,UAAS;AAAA,YACvB,OAAO;AAAA,aACV;AAAA,UACA,qBAAC,oBAAiB,SAAS,MAAM,SAAS,MAAM,GAAG,WAAW,GAAG,UAAU,UAAU,WAAW,GAC9F;AAAA,gCAAC,QAAK,WAAU,UAAS;AAAA,YACxB,OAAO;AAAA,aACV;AAAA,UACA,qBAAC,oBAAiB,SAAS,MAAM,SAAS,QAAQ,GAAG,WAAW,GAAG,UAAU,YAAY,WAAW,GAClG;AAAA,gCAAC,WAAQ,WAAU,UAAS;AAAA,YAC3B,OAAO;AAAA,aACV;AAAA,WACF;AAAA,SACF,IACE;AAAA,MAEH,eACC,iCACE;AAAA,4BAAC,yBAAsB;AAAA,QACvB,qBAAC,oBAAiB,SAAQ,eAAc,SAAS,cAC/C;AAAA,8BAAC,UAAO,WAAU,UAAS;AAAA,UAC3B,oBAAC,UAAM,iBAAO,QAAO;AAAA,WACvB;AAAA,SACF,IACE;AAAA,OACN;AAAA,KACF;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/user-menu.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronDown, CircleUser, HelpCircle, LogOut, Monitor, Moon, Palette, Settings, Sun } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { getAvatarToneClasses } from \"../components/composite/letter-avatar.js\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"../components/ui/avatar.js\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuShortcut,\n DropdownMenuSub,\n DropdownMenuSubContent,\n DropdownMenuSubTrigger,\n DropdownMenuTrigger\n} from \"../components/ui/dropdown-menu.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { ShellUser, UserMenuItem } from \"./types.js\";\nimport { getVietnameseInitials } from \"./utils.js\";\n\nexport interface UserAvatarProps {\n user: ShellUser;\n className?: string;\n}\n\n/**\n * Avatar người dùng: ảnh nếu có, không thì chữ cái kiểu VN (HỌ + TÊN) tô màu theo tên.\n *\n * Tô màu theo `user.name` chứ KHÔNG theo email: `LetterAvatar`/`AvatarGroup` ở Tier 2 seed\n * bằng tên, nên lấy khoá khác ở đây là cùng một người ra hai màu trên cùng một trang.\n */\nexport function UserAvatar({ user, className }: UserAvatarProps) {\n return (\n <Avatar className={cn(\"size-8\", className)}>\n {user.avatarUrl ? <AvatarImage src={user.avatarUrl} alt=\"\" /> : null}\n <AvatarFallback className={cn(\"text-xs font-semibold\", getAvatarToneClasses(user.name))}>\n {getVietnameseInitials(user.name)}\n </AvatarFallback>\n </Avatar>\n );\n}\n\nexport interface UserMenuProps {\n className?: string;\n /** Hiện tên + vai trò cạnh avatar; màn hình hẹp nên để `false`. */\n showName?: boolean;\n /** Link \"Hồ sơ\" — bỏ trống thì không render mục này (tránh mục dẫn đi đâu không rõ). */\n profileHref?: string;\n /** Link \"Cài đặt\". */\n settingsHref?: string;\n /** Link \"Trợ giúp\". */\n helpHref?: string;\n /** Mục bổ sung của app — chèn ngay trên \"Đăng xuất\". */\n items?: UserMenuItem[];\n /**\n * Submenu \"Giao diện\" trong menu người dùng.\n *\n * `\"mobile\"` (mặc định) — CHỈ hiện dưới `md`. Từ `md` trở lên `AppHeader` đã có\n * `ThemeToggle` riêng, để cả hai là hai lối vào cho cùng một thứ.\n * `true` — luôn hiện. Dùng khi app tắt `showThemeToggle` của header, nếu không màn\n * hình rộng sẽ KHÔNG còn chỗ nào đổi giao diện.\n * `false` — không bao giờ hiện.\n *\n * ⚠ Nhánh `\"mobile\"` ẩn bằng CSS (`md:hidden`), không phải bằng điều kiện render: nút gạt theo\n * bề rộng cần `window`, mà đọc `window` lúc render đầu là lệch hydration. `display: none` loại\n * phần tử khỏi CẢ cây trợ năng nên trình đọc màn hình ở desktop cũng không thấy mục thừa.\n */\n showThemeSubmenu?: boolean | \"mobile\";\n /** Ghi đè handler đăng xuất của context. */\n onLogout?: () => void;\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * Menu người dùng ở góc phải header: avatar + tên/email, các mục do app khai báo,\n * submenu đổi giao diện, và \"Đăng xuất\" (tô màu destructive).\n *\n * Mục dựng sẵn chỉ hiện khi app cấp `href` tương ứng — package không tự bịa route.\n */\nexport function UserMenu({\n className,\n showName = false,\n profileHref,\n settingsHref,\n helpHref,\n items = [],\n showThemeSubmenu = \"mobile\",\n onLogout,\n align = \"end\"\n}: UserMenuProps) {\n const shell = useShell();\n const { theme, setTheme } = useTheme();\n const { user, labels } = shell;\n\n if (!user) return null;\n\n const handleLogout = onLogout ?? shell.onLogout;\n\n const renderItem = (item: UserMenuItem) => {\n const Icon = item.icon;\n const content = (\n <>\n {Icon ? <Icon className=\"size-4\" /> : null}\n <span className=\"truncate\">{item.label}</span>\n {item.shortcut ? <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut> : null}\n </>\n );\n\n if (item.href) {\n return (\n <DropdownMenuItem key={item.id} variant={item.variant} asChild>\n <ShellNavLink href={item.href} onClick={() => item.onSelect?.()}>\n {content}\n </ShellNavLink>\n </DropdownMenuItem>\n );\n }\n\n return (\n <DropdownMenuItem key={item.id} variant={item.variant} onClick={() => item.onSelect?.()}>\n {content}\n </DropdownMenuItem>\n );\n };\n\n const builtInItems: (UserMenuItem | null)[] = [\n profileHref ? { id: \"profile\", label: labels.profile, icon: CircleUser, href: profileHref } : null,\n settingsHref ? { id: \"settings\", label: labels.settings, icon: Settings, href: settingsHref } : null,\n helpHref ? { id: \"help\", label: labels.help, icon: HelpCircle, href: helpHref } : null\n ];\n const visibleBuiltIns = builtInItems.filter((item): item is UserMenuItem => item !== null);\n\n const hasEntries = visibleBuiltIns.length > 0 || items.length > 0 || showThemeSubmenu;\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button\n type=\"button\"\n aria-label={labels.account}\n className={cn(\n \"flex items-center gap-2 rounded-md p-1 text-left\",\n \"transition-colors duration-150 ease-out hover:bg-muted\",\n className\n )}\n >\n <UserAvatar user={user} />\n {showName ? (\n <>\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate text-sm font-medium leading-tight text-foreground\">{user.name}</span>\n {user.role ? (\n <span className=\"truncate text-[11px] leading-tight text-muted-foreground\">{user.role}</span>\n ) : null}\n </span>\n <ChevronDown className=\"size-3.5 shrink-0 text-muted-foreground\" />\n </>\n ) : null}\n </button>\n </DropdownMenuTrigger>\n\n <DropdownMenuContent align={align} className=\"w-60\">\n <DropdownMenuLabel className=\"flex items-center gap-2 font-normal\">\n <UserAvatar user={user} />\n <span className=\"flex min-w-0 flex-col\">\n <span className=\"truncate text-sm font-medium\">{user.name}</span>\n {user.email ? <span className=\"truncate text-xs text-muted-foreground\">{user.email}</span> : null}\n {user.role ? <span className=\"truncate text-[11px] text-muted-foreground\">{user.role}</span> : null}\n </span>\n </DropdownMenuLabel>\n\n {hasEntries ? <DropdownMenuSeparator /> : null}\n\n {visibleBuiltIns.map(renderItem)}\n {items.map(renderItem)}\n\n {showThemeSubmenu !== false ? (\n <DropdownMenuSub>\n <DropdownMenuSubTrigger className={cn(showThemeSubmenu === \"mobile\" && \"md:hidden\")}>\n <Palette className=\"size-4\" />\n <span>{labels.appearance}</span>\n </DropdownMenuSubTrigger>\n <DropdownMenuSubContent>\n <DropdownMenuItem onClick={() => setTheme(\"light\")} className={cn(theme === \"light\" && \"bg-accent\")}>\n <Sun className=\"size-4\" />\n {labels.themeLight}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"dark\")} className={cn(theme === \"dark\" && \"bg-accent\")}>\n <Moon className=\"size-4\" />\n {labels.themeDark}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"system\")} className={cn(theme === \"system\" && \"bg-accent\")}>\n <Monitor className=\"size-4\" />\n {labels.themeSystem}\n </DropdownMenuItem>\n </DropdownMenuSubContent>\n </DropdownMenuSub>\n ) : null}\n\n {handleLogout ? (\n <>\n <DropdownMenuSeparator />\n <DropdownMenuItem variant=\"destructive\" onClick={handleLogout}>\n <LogOut className=\"size-4\" />\n <span>{labels.logout}</span>\n </DropdownMenuItem>\n </>\n ) : null}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -80,15 +80,15 @@ function WorkspaceSwitcher({
|
|
|
80
80
|
className
|
|
81
81
|
),
|
|
82
82
|
children: [
|
|
83
|
-
/* @__PURE__ */ jsx(WorkspaceAvatar, { workspace: currentWorkspace }),
|
|
83
|
+
/* @__PURE__ */ jsx(WorkspaceAvatar, { workspace: currentWorkspace, className: "size-8" }),
|
|
84
84
|
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
|
|
85
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-sm font-semibold text-sidebar-foreground", children: currentWorkspace.name }),
|
|
85
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-sm font-semibold leading-tight text-sidebar-foreground", children: currentWorkspace.name }),
|
|
86
86
|
currentWorkspace.plan ? (
|
|
87
87
|
// `text-muted-foreground` chứ không `text-sidebar-foreground/55`: đây là CHỮ (11px) nên
|
|
88
88
|
// ngưỡng là AA 4,5:1, mà /55 trên `--sidebar` #FAFAF9 chỉ được 4,13:1 ✗ (dark 5,78:1 —
|
|
89
89
|
// mỗi theme hỏng một nửa). `--muted-foreground` là bậc chữ phụ đã đo cho đúng vai trò:
|
|
90
90
|
// 5,01:1 (light) · 5,35:1 (dark) trên `--sidebar` ✓.
|
|
91
|
-
/* @__PURE__ */ jsx("span", { className: "truncate text-[11px] text-muted-foreground", children: currentWorkspace.plan })
|
|
91
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-[11px] leading-tight text-muted-foreground", children: currentWorkspace.plan })
|
|
92
92
|
) : null
|
|
93
93
|
] }),
|
|
94
94
|
/* @__PURE__ */ jsx(ChevronsUpDown, { className: "size-3.5 shrink-0 text-muted-foreground" })
|
|
@@ -105,7 +105,7 @@ function WorkspaceSwitcher({
|
|
|
105
105
|
{
|
|
106
106
|
align,
|
|
107
107
|
sideOffset: 6,
|
|
108
|
-
className: "w-
|
|
108
|
+
className: "w-80 p-0",
|
|
109
109
|
onOpenAutoFocus: (event) => {
|
|
110
110
|
if (!searchRef.current) return;
|
|
111
111
|
event.preventDefault();
|
|
@@ -136,35 +136,49 @@ function WorkspaceSwitcher({
|
|
|
136
136
|
}
|
|
137
137
|
)
|
|
138
138
|
] }) : null,
|
|
139
|
-
/* @__PURE__ */ jsx(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
"
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
"
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
139
|
+
/* @__PURE__ */ jsx(
|
|
140
|
+
"div",
|
|
141
|
+
{
|
|
142
|
+
className: "flex max-h-64 flex-col gap-1 overflow-y-auto scrollbar-thin p-1",
|
|
143
|
+
role: "group",
|
|
144
|
+
"aria-label": labels.workspace,
|
|
145
|
+
children: filtered.length === 0 ? /* @__PURE__ */ jsx("p", { className: "px-2 py-6 text-center text-sm text-muted-foreground", children: labels.workspaceEmpty }) : filtered.map((workspace) => {
|
|
146
|
+
const isCurrent = workspace.id === currentWorkspace.id;
|
|
147
|
+
return /* @__PURE__ */ jsxs(
|
|
148
|
+
"button",
|
|
149
|
+
{
|
|
150
|
+
type: "button",
|
|
151
|
+
"aria-current": isCurrent ? "true" : void 0,
|
|
152
|
+
onClick: () => handleSelect(workspace),
|
|
153
|
+
className: cn(
|
|
154
|
+
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left",
|
|
155
|
+
"transition-colors duration-150 ease-out hover:bg-accent",
|
|
156
|
+
isCurrent && "bg-accent/60"
|
|
157
|
+
),
|
|
158
|
+
children: [
|
|
159
|
+
/* @__PURE__ */ jsx(WorkspaceAvatar, { workspace, className: "size-8" }),
|
|
160
|
+
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
|
|
161
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-sm font-medium leading-tight text-foreground", children: workspace.name }),
|
|
162
|
+
workspace.role || workspace.memberCount !== void 0 ? /* @__PURE__ */ jsx("span", { className: "truncate text-[11px] leading-tight text-muted-foreground", children: [
|
|
163
|
+
workspace.role,
|
|
164
|
+
workspace.memberCount === void 0 ? void 0 : `${workspace.memberCount} ${labels.members}`
|
|
165
|
+
].filter(Boolean).join(" \xB7 ") }) : null
|
|
166
|
+
] }),
|
|
167
|
+
workspace.plan ? /* @__PURE__ */ jsx(Badge, { variant: "secondary", size: "sm", className: "shrink-0", children: workspace.plan }) : null,
|
|
168
|
+
/* @__PURE__ */ jsx(
|
|
169
|
+
Check,
|
|
170
|
+
{
|
|
171
|
+
"aria-hidden": "true",
|
|
172
|
+
className: cn("size-4 shrink-0 text-popover-foreground", !isCurrent && "invisible")
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
]
|
|
176
|
+
},
|
|
177
|
+
workspace.id
|
|
178
|
+
);
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
)
|
|
168
182
|
] }) : /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 p-3", children: [
|
|
169
183
|
/* @__PURE__ */ jsx(WorkspaceAvatar, { workspace: currentWorkspace, className: "size-8" }),
|
|
170
184
|
/* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-col", children: [
|
|
@@ -174,7 +188,7 @@ function WorkspaceSwitcher({
|
|
|
174
188
|
] }),
|
|
175
189
|
hasActions ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
176
190
|
/* @__PURE__ */ jsx(Separator, {}),
|
|
177
|
-
/* @__PURE__ */ jsxs("div", { className: "flex flex-col p-1", children: [
|
|
191
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1 p-1", children: [
|
|
178
192
|
onCreateWorkspace ? /* @__PURE__ */ jsx(ActionRow, { icon: Plus, label: labels.createWorkspace, onClick: closeThen(onCreateWorkspace) }) : null,
|
|
179
193
|
onWorkspaceSettings ? /* @__PURE__ */ jsx(
|
|
180
194
|
ActionRow,
|
|
@@ -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 <WorkspaceAvatar workspace={currentWorkspace} />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"truncate text-sm font-semibold text-sidebar-foreground\">{currentWorkspace.name}</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] 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 className=\"w-72 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 <div className=\"max-h-64 overflow-y-auto scrollbar-thin p-1\" role=\"group\" aria-label={labels.workspace}>\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 <WorkspaceAvatar workspace={workspace} />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"truncate text-sm font-medium text-foreground\">{workspace.name}</span>\n {workspace.role || workspace.memberCount !== undefined ? (\n <span className=\"truncate text-[11px] 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 {/* KHÔNG dùng `text-app-accent`: accent mặc định (gold #E8B824) chỉ đạt\n 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 {isCurrent ? <Check className=\"size-4 shrink-0 text-popover-foreground\" /> : null}\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 <div className=\"flex flex-col 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\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":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comitor/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
"lint": "biome check .",
|
|
64
64
|
"lint:fix": "biome check --write .",
|
|
65
65
|
"lint:a11y": "node scripts/check-contrast.mjs",
|
|
66
|
-
"test:
|
|
67
|
-
"test:nav-active": "node scripts/
|
|
66
|
+
"test:a11y-gate": "node scripts/a11y/self-test.mjs",
|
|
67
|
+
"test:nav-active": "node scripts/check-nav-active.mjs",
|
|
68
68
|
"verify:directives": "node scripts/verify-directives.mjs",
|
|
69
69
|
"smoke:entries": "node scripts/smoke-entries.mjs",
|
|
70
70
|
"check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
|
|
71
|
-
"prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run smoke:entries && pnpm run check:exports && pnpm run test:nav-active && pnpm run test:
|
|
71
|
+
"prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run smoke:entries && pnpm run check:exports && pnpm run test:nav-active && pnpm run test:a11y-gate && pnpm run lint:a11y"
|
|
72
72
|
},
|
|
73
73
|
"peerDependencies": {
|
|
74
74
|
"lucide-react": ">=0.540.0",
|
package/styles.css
CHANGED
|
@@ -54,7 +54,8 @@
|
|
|
54
54
|
Ghi ra để không ai tưởng là lỗi bỏ sót và "sửa" lại lần nữa — chỗ để sửa là bảng tương phản
|
|
55
55
|
cao, không phải bảng mặc định. Cột cuối là ngưỡng WCAG mà khoản đó không đạt:
|
|
56
56
|
|
|
57
|
-
viền
|
|
57
|
+
viền ô nhập (--input / --control-edge) trên nền trang 1,16:1 (không chịu ngưỡng)
|
|
58
|
+
viền checkbox/radio (--choice-edge) trên nền trang 2,99:1 (1.4.11 · 3:1)
|
|
58
59
|
rãnh Switch BẬT (--control-on) ⇄ nền trang và ⇄ núm 1,86:1 (1.4.11 · 3:1)
|
|
59
60
|
gạch active của Tabs, viền focus ô nhập (gold) 1,86:1 (1.4.11 · 3:1)
|
|
60
61
|
--muted-foreground trên --muted 3,58:1 (1.4.3 · 4,5:1)
|
|
@@ -218,6 +219,16 @@
|
|
|
218
219
|
--secondary: #ede8d8;
|
|
219
220
|
--secondary-foreground: #0e1116;
|
|
220
221
|
--muted: #f7f7f8;
|
|
222
|
+
/* Sọc xen kẽ của bảng (`<Table striped>`, `<DataTable striped>`) — ĐỤC, cố ý.
|
|
223
|
+
Đúng bằng --muted phủ 30% lên --card, tức y hệt `bg-muted/30` mà bản trước dùng, chỉ khác là
|
|
224
|
+
đã hợp thành sẵn. PHẢI đục vì ô cột GHIM lấy `bg-inherit` từ hàng: nền hàng còn alpha thì ô
|
|
225
|
+
ghim trong mờ và chữ của các cột đang cuộn bên dưới lộ xuyên qua.
|
|
226
|
+
⚠ Đổi --muted hoặc --card thì tính lại: color-mix không dùng được ở đây vì cổng tương phản
|
|
227
|
+
đọc token bằng hex (cùng lý do các bậc --*-ink đã đóng băng). */
|
|
228
|
+
--table-row-stripe: #fdfdfd;
|
|
229
|
+
/* Hàng đang rê chuột — cũng ĐỤC, cùng một lý do: = --muted phủ 50% lên --card (bản trước là
|
|
230
|
+
`hover:bg-muted/50`). Một màu chung cho cả hàng lẻ lẫn hàng sọc, đúng lối mọi bảng vẫn làm. */
|
|
231
|
+
--table-row-hover: #fbfbfc;
|
|
221
232
|
/* Bậc chữ phụ (placeholder, mô tả, nhãn trục biểu đồ). Giá trị của comitor-ds, bằng đúng
|
|
222
233
|
--ash-500. Số đo còn tồn (dưới AA 4,5:1 — ĐÃ BIẾT VÀ CHẤP NHẬN, xem khối NGUỒN SỰ THẬT ở
|
|
223
234
|
đầu file): 3,83:1 trên --background · 3,58:1 trên --muted · 3,67:1 trên --sidebar. */
|
|
@@ -250,10 +261,20 @@
|
|
|
250
261
|
light #EDEEF1 trên trắng = 1,16:1 · dark #1E2128 trên #0E1116 = 1,17:1
|
|
251
262
|
dark #1E2128 trên --card #16181D = 1,10:1 · --sidebar-border/--sidebar = 1,11:1
|
|
252
263
|
|
|
253
|
-
--control-edge (= --input) VIỀN
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
264
|
+
--control-edge (= --input) VIỀN Ô NHẬP. Input/Textarea/Select/Combobox/OTP, ô tìm của
|
|
265
|
+
WorkspaceSwitcher, Button variant outline/secondary, Badge variant outline, rãnh Slider.
|
|
266
|
+
Ở light giữ đúng bậc của comitor-ds (#EDEEF1 = 1,16:1) — ô nhập còn được nhận ra bằng
|
|
267
|
+
kích thước, nhãn và placeholder nên viền nhạt vẫn đọc được, và bản duyệt là nguồn sự thật.
|
|
268
|
+
Ở dark thì KHÔNG giữ được: #1E2128 trên --card chỉ 1,10:1, ô nhập gần như tan vào nền.
|
|
269
|
+
Bảng mặc định dùng #434A5A (2,00:1) — rõ hẳn mà chưa nặng như #7E8290 (4,64:1) của bảng
|
|
270
|
+
tương phản cao.
|
|
271
|
+
|
|
272
|
+
--choice-edge VIỀN CHECKBOX / RADIO. Tách khỏi --control-edge vì ở đây viền
|
|
273
|
+
là TOÀN BỘ control khi chưa chọn: không có nhãn nằm trong, không có placeholder, không
|
|
274
|
+
có nền — mất viền là mất luôn ô. Vì thế nó chịu ngưỡng 1.4.11 còn viền ô nhập thì không.
|
|
275
|
+
#959597 (2,99:1) ở light · #5B6479 (3,00:1 trên --card) ở dark. Vẫn dưới 3:1 một chút,
|
|
276
|
+
cố ý: mức "nhìn rõ mà chưa nặng như bảng tương phản cao". Ở bảng tương phản cao nó trỏ
|
|
277
|
+
thẳng về --input nên cả hai loại viền cùng lên một bậc.
|
|
257
278
|
Dùng cho: Input/Textarea/Select/Combobox, ô Checkbox chưa chọn, viền Radio, rãnh
|
|
258
279
|
Slider, Button variant outline/secondary, Badge variant outline, ô tìm của
|
|
259
280
|
WorkspaceSwitcher. (Rãnh Switch khi TẮT KHÔNG đi qua đây — comitor-ds dùng
|
|
@@ -267,7 +288,8 @@
|
|
|
267
288
|
Control nào chỉ viết `border` mà không nêu màu sẽ lặng lẽ nhận bậc 1,16:1 — luôn phải nêu
|
|
268
289
|
`border-input` / `border-control-edge` tường minh. Bảng đầy đủ: docs/hop-dong-mau.md §3. */
|
|
269
290
|
--border: #edeef1;
|
|
270
|
-
/* Viền ô nhập — giá trị của comitor-ds, cùng bậc với --border. Khối .dark
|
|
291
|
+
/* Viền ô nhập — giá trị của comitor-ds, cùng bậc với --border. Khối .dark KHÔNG giữ được bậc
|
|
292
|
+
này (xem khối "HAI VAI TRÒ" ngay trên) nên khai lại thành #434A5A. */
|
|
271
293
|
--input: #edeef1;
|
|
272
294
|
/* LỚP TÔ của ô nhập ở dark — comitor-ds viết `dark:bg-input/30`, và nó không phải trang trí:
|
|
273
295
|
ở dark viền --input (#1E2128) trên --card (#16181D) chỉ 1,10:1, nên lớp tô mờ này là thứ
|
|
@@ -280,13 +302,19 @@
|
|
|
280
302
|
|
|
281
303
|
/* Trỏ về --input, không còn là một bậc xám riêng. Token giữ lại vì component trong gói đang
|
|
282
304
|
viết `border-control-edge` và vì app có thể ghi đè MỘT chỗ này để làm đậm viền control mà
|
|
283
|
-
không đụng đường kẻ trang trí (--border). Số đo
|
|
305
|
+
không đụng đường kẻ trang trí (--border). Số đo: 1,16:1 light · 2,00:1 dark (trên --card). */
|
|
284
306
|
--control-edge: var(--input);
|
|
285
|
-
/* Hover/active của viền
|
|
307
|
+
/* Hover/active của viền Ô NHẬP — đúng cặp của comitor-ds: `hover:border-gray-300` (ash-300,
|
|
286
308
|
#C8CBD3) ở light và `dark:hover:border-gray-500` (ash-500, #7E8290) ở dark. Token hoá để
|
|
287
|
-
Input/Select/Combobox
|
|
309
|
+
Input/Select/Combobox khỏi chép lệch. Cả hai bậc đều ĐẬM HƠN lúc nghỉ, đúng chiều. */
|
|
288
310
|
--control-edge-strong: var(--ash-300);
|
|
289
311
|
|
|
312
|
+
/* Viền checkbox/radio — xem khối "HAI VAI TRÒ" ở trên để biết vì sao nó KHÔNG dùng chung
|
|
313
|
+
token với ô nhập. Hover phải đậm hơn 2,99:1 nên ash-300 (1,62:1) không dùng được ở đây;
|
|
314
|
+
ash-500 đạt 3,83:1 (light) và 4,64:1 (dark). */
|
|
315
|
+
--choice-edge: #959597;
|
|
316
|
+
--choice-edge-strong: var(--ash-500);
|
|
317
|
+
|
|
290
318
|
/* ─ CONTROL Ở TRẠNG THÁI BẬT / ĐÃ CHỌN ─
|
|
291
319
|
Rãnh `Switch` khi BẬT, ô `Checkbox` đã chọn, chấm `Radio`, đoạn đã chọn của `Slider`.
|
|
292
320
|
|
|
@@ -462,6 +490,9 @@
|
|
|
462
490
|
--secondary: #1e2128;
|
|
463
491
|
--secondary-foreground: #f7f7f8;
|
|
464
492
|
--muted: #1e2128;
|
|
493
|
+
/* = --muted 30% / 50% trên --card (#1E2128 trên #16181D). Xem chú thích ở bản light. */
|
|
494
|
+
--table-row-stripe: #181b20;
|
|
495
|
+
--table-row-hover: #1a1d23;
|
|
465
496
|
/* Cùng một giá trị với bản light — comitor-ds không khai lại token này trong .dark. Số đo còn
|
|
466
497
|
tồn (dưới AA, ĐÃ BIẾT VÀ CHẤP NHẬN): 4,21:1 trên --muted · 4,73:1 trên --card. Xem khối
|
|
467
498
|
NGUỒN SỰ THẬT ở đầu file. */
|
|
@@ -475,8 +506,16 @@
|
|
|
475
506
|
/* Viền ô nhập bản dark của comitor-ds. --control-edge trỏ vào --input nên tự theo; --control-on
|
|
476
507
|
và --control-knob cũng không khai lại (chúng trỏ vào --primary — đã có bản dark ngay trên —
|
|
477
508
|
và vào trắng cố định, đúng như núm Switch của comitor-ds). */
|
|
478
|
-
|
|
509
|
+
/* KHÔNG phải #1E2128 của comitor-ds: bậc đó chỉ 1,10:1 trên --card, ô nhập gần như tan vào nền
|
|
510
|
+
ở dark (ở light bậc tương đương vẫn đọc được — mắt bù được trên nền sáng, trên nền tối thì
|
|
511
|
+
không). #434A5A = 2,00:1, còn cách xa #7E8290 (4,64:1) của bảng tương phản cao. */
|
|
512
|
+
--input: #434a5a;
|
|
513
|
+
/* Giữ nguyên cách của comitor-ds (`dark:bg-input/30`). Placeholder trên lớp tô này đo được
|
|
514
|
+
3,90:1 — khá hơn 3,58:1 mà --muted-foreground trên --muted vốn đã chấp nhận. */
|
|
479
515
|
--input-fill: color-mix(in srgb, var(--input) 30%, transparent);
|
|
516
|
+
/* Viền checkbox/radio bản dark — 3,00:1 trên --card. --choice-edge-strong không khai lại vì nó
|
|
517
|
+
trỏ vào ash-500, đạt 4,64:1 ở đây. */
|
|
518
|
+
--choice-edge: #5b6479;
|
|
480
519
|
/* Hover viền control bản dark của comitor-ds là dark:hover:border-gray-500, tức ash-500. */
|
|
481
520
|
--control-edge-strong: var(--ash-500);
|
|
482
521
|
/* Rãnh Switch khi TẮT bản dark của comitor-ds (dark:bg-gray-700). --slider-track không khai lại
|
|
@@ -554,19 +593,50 @@
|
|
|
554
593
|
/* Ngược chiều bảng duyệt: color-mix(--destructive, --ink 15%) — chữ trắng 6,19:1 */
|
|
555
594
|
--destructive-hover: #ad3535;
|
|
556
595
|
--destructive-ink: var(--red-ink);
|
|
557
|
-
/* Viền control: 3,83:1 trên nền trắng
|
|
558
|
-
--control-edge trỏ vào --input nên tự theo. */
|
|
596
|
+
/* Viền control: 3,83:1 trên nền trắng — đủ chuẩn 1.4.11, trong khi bảng mặc định để viền Ô NHẬP
|
|
597
|
+
ở bậc nhạt của bản duyệt (1,16:1). --control-edge trỏ vào --input nên tự theo. */
|
|
559
598
|
--input: #7e8290;
|
|
599
|
+
/* ĐƯỜNG KẺ TRANG TRÍ cũng lên theo — ở bảng mặc định nó cố ý nhạt (1,16:1) vì không mang thông
|
|
600
|
+
tin, nhưng bật "tương phản cao" mà chỉ viền ô nhập đậm lên còn khung Card, đường kẻ bảng và
|
|
601
|
+
vạch ngăn menu vẫn tàng hình thì chế độ này nhìn nửa vời.
|
|
602
|
+
2,49:1 — hơn gấp đôi bậc cũ, nhưng CỐ Ý thấp hơn viền control (3,83:1) một bậc:
|
|
603
|
+
· --border viền MỌI thẻ, mọi dòng, mọi dấu phân cách. Kéo nó lên ngang viền control là mất
|
|
604
|
+
phân cấp "cái này thao tác được" ⇄ "cái này chỉ là đường kẻ", và cả trang thành lưới ô.
|
|
605
|
+
· Giữ nó dưới 3:1 cũng giữ đúng tiền đề của hợp đồng: --border là token KHÔNG chịu ngưỡng.
|
|
606
|
+
Cổng tương phản dựa vào đúng tiền đề đó để bắt lỗi "vẽ hình control bằng token trang trí"
|
|
607
|
+
(4 ca trong `test:a11y-gate`) — nâng --border lên 3,83:1 là bốn ca đó hết bắt được. */
|
|
608
|
+
--border: #a4a4a6;
|
|
609
|
+
--sidebar-border: #a4a4a6;
|
|
610
|
+
/* Ở bảng này viền ô nhập và viền checkbox/radio CÙNG một bậc — trỏ bằng var() để đổi --input
|
|
611
|
+
một chỗ là cả hai theo, không phải nhớ sửa hai số. */
|
|
612
|
+
--choice-edge: var(--input);
|
|
613
|
+
--choice-edge-strong: var(--control-edge-strong);
|
|
560
614
|
/* Viền đã đủ đậm ở cả hai theme nên bỏ hẳn lớp tô — nó chỉ làm placeholder khó đọc. */
|
|
561
615
|
--input-fill: transparent;
|
|
562
616
|
--control-edge-strong: var(--ash-700);
|
|
563
|
-
/* Control BẬT/ĐÃ CHỌN:
|
|
564
|
-
|
|
617
|
+
/* Control BẬT/ĐÃ CHỌN: gold ĐẬM, KHÔNG phải gold-500.
|
|
618
|
+
gold-500 (#7A5800) đạt 6,51:1 nhưng nó đã rời khỏi sắc gold — mắt đọc ra nâu-ô-liu xỉn, mà
|
|
619
|
+
"tương phản cao" không có nghĩa là đổi màu nhận diện.
|
|
620
|
+
#B48700 = gold-400 tối đi 2%. Vì sao không dùng thẳng gold-400 (#B88A00): nó đạt 3,15:1 trên
|
|
621
|
+
nền trang nhưng chỉ 2,94:1 trên --muted, mà control có thể nằm trên nền --muted — cổng tương
|
|
622
|
+
phản bắt đúng chỗ đó. Bậc này đạt ở MỌI nền sáng: 3,28:1 (--background/--card) · 3,06:1
|
|
623
|
+
(--muted) · 3,14:1 (--sidebar); ⇄ núm trắng 3,28:1. Bảng duyệt chỉ 1,86:1 ở cả hai phép đo.
|
|
624
|
+
Dấu check quay về MỰC (5,77:1 trên nền gold mới) — cùng cách vẽ với bảng mặc định, nên hai
|
|
625
|
+
bảng giờ chỉ khác nhau ở ĐỘ ĐẬM của gold chứ không khác ở cách vẽ control.
|
|
626
|
+
⚠ --control-knob (núm Switch) vẫn phải khai lại là TRẮNG: nó là hình tròn đặc trên rãnh, đo
|
|
627
|
+
3,28:1 — đạt ngưỡng phi-văn-bản; để nó thành mực thì núm chìm vào rãnh. */
|
|
628
|
+
--control-on: #b48700;
|
|
565
629
|
--control-knob: var(--background);
|
|
566
|
-
--control-check: var(--
|
|
630
|
+
--control-check: var(--ink);
|
|
567
631
|
--switch-track: var(--control-edge);
|
|
568
632
|
--slider-track: var(--control-edge);
|
|
569
|
-
/* Brand khi phải ĐI LÊN nền trang: viền focus, gạch active của Tabs, ngày chọn của Calendar
|
|
633
|
+
/* Brand khi phải ĐI LÊN nền trang: viền focus, gạch active của Tabs, ngày chọn của Calendar,
|
|
634
|
+
thanh Progress, nhãn trạng thái, link.
|
|
635
|
+
⚠ ĐỪNG kéo token này sáng theo --control-on. Đã thử #B48700 và cổng bắt đúng: nó là CHỮ ở
|
|
636
|
+
`status-config.ts` (nhãn trạng thái) và `[&>a:hover]` của Field/Item/Empty (ngưỡng 4,5:1 —
|
|
637
|
+
ra 3,06:1 ✗), còn ở `StatCard` nó là icon đứng trên tint `bg-primary/20` của chính nó nên
|
|
638
|
+
tụt xuống 2,73:1, thủng cả ngưỡng 3:1. --control-on thì chỉ đứng trên NỀN TRANG nên mới nới
|
|
639
|
+
sáng được. Hai token, hai vai trò — đúng hợp đồng màu §1. */
|
|
570
640
|
--primary-ink: var(--gold-500);
|
|
571
641
|
--link-ink: var(--gold-500);
|
|
572
642
|
--link-ink-hover: var(--gold-600);
|
|
@@ -576,6 +646,11 @@
|
|
|
576
646
|
/* Sáng hơn bản light: chữ phụ ở dark hay nằm trên --card/--muted chứ ít khi trên nền trang.
|
|
577
647
|
5,70:1 trên --background · 5,35:1 trên --card · 4,86:1 trên --muted */
|
|
578
648
|
--muted-foreground: #888d9b;
|
|
649
|
+
/* Đường kẻ trang trí bản dark của bảng này — #A4A4A6 của khối light là XÁM SÁNG, đặt lên nền
|
|
650
|
+
#16181D thì thành đường kẻ chói. #50586B cho ra đúng 2,49:1 như bản light, tức cùng một mức
|
|
651
|
+
"thấy rõ mà chưa ngang viền control". */
|
|
652
|
+
--border: #50586b;
|
|
653
|
+
--sidebar-border: #50586b;
|
|
579
654
|
/* Viền control ghim MỘT giá trị cho cả hai theme: 4,94:1 trên nền #0E1116 */
|
|
580
655
|
--input: #7e8290;
|
|
581
656
|
--control-edge-strong: var(--ash-300);
|
|
@@ -624,6 +699,8 @@
|
|
|
624
699
|
--color-secondary: var(--secondary);
|
|
625
700
|
--color-secondary-foreground: var(--secondary-foreground);
|
|
626
701
|
--color-muted: var(--muted);
|
|
702
|
+
--color-table-row-stripe: var(--table-row-stripe);
|
|
703
|
+
--color-table-row-hover: var(--table-row-hover);
|
|
627
704
|
--color-muted-foreground: var(--muted-foreground);
|
|
628
705
|
--color-accent: var(--accent);
|
|
629
706
|
--color-accent-foreground: var(--accent-foreground);
|
|
@@ -640,6 +717,8 @@
|
|
|
640
717
|
--color-input-fill: var(--input-fill);
|
|
641
718
|
--color-control-edge: var(--control-edge);
|
|
642
719
|
--color-control-edge-strong: var(--control-edge-strong);
|
|
720
|
+
--color-choice-edge: var(--choice-edge);
|
|
721
|
+
--color-choice-edge-strong: var(--choice-edge-strong);
|
|
643
722
|
--color-control-on: var(--control-on);
|
|
644
723
|
--color-control-knob: var(--control-knob);
|
|
645
724
|
--color-control-check: var(--control-check);
|