@comitor/ui 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/components/composite/command-palette.d.ts +1 -1
  2. package/dist/components/composite/command-palette.js.map +1 -1
  3. package/dist/components/composite/data-table.d.ts +10 -5
  4. package/dist/components/composite/data-table.js +2 -2
  5. package/dist/components/composite/data-table.js.map +1 -1
  6. package/dist/components/composite/filter-chips.d.ts +1 -1
  7. package/dist/components/composite/filter-chips.js.map +1 -1
  8. package/dist/components/composite/inline-edit.d.ts +2 -2
  9. package/dist/components/composite/inline-edit.js.map +1 -1
  10. package/dist/components/composite/route-tabs.d.ts +8 -6
  11. package/dist/components/composite/route-tabs.js.map +1 -1
  12. package/dist/components/composite/status-config.d.ts +1 -1
  13. package/dist/components/composite/status-config.js.map +1 -1
  14. package/dist/components/ui/progress.d.ts +14 -2
  15. package/dist/components/ui/progress.js +12 -3
  16. package/dist/components/ui/progress.js.map +1 -1
  17. package/dist/components/ui/resizable.d.ts +1 -1
  18. package/dist/components/ui/resizable.js.map +1 -1
  19. package/dist/data-grid/data-grid-context-menu.js +3 -2
  20. package/dist/data-grid/data-grid-context-menu.js.map +1 -1
  21. package/dist/data-grid/data-grid-row-height-menu.js +2 -2
  22. package/dist/data-grid/data-grid-row-height-menu.js.map +1 -1
  23. package/dist/data-grid/types.d.ts +7 -2
  24. package/dist/data-grid/use-data-grid.d.ts +14 -4
  25. package/dist/data-grid/use-data-grid.js +5 -3
  26. package/dist/data-grid/use-data-grid.js.map +1 -1
  27. package/dist/data-grid/utils.js +0 -1
  28. package/dist/data-grid/utils.js.map +1 -1
  29. package/dist/i18n/namespaces/combobox.js +2 -2
  30. package/dist/i18n/namespaces/combobox.js.map +1 -1
  31. package/dist/i18n/namespaces/confirm-dialog.js +1 -1
  32. package/dist/i18n/namespaces/confirm-dialog.js.map +1 -1
  33. package/dist/i18n/namespaces/date-picker.js +2 -2
  34. package/dist/i18n/namespaces/date-picker.js.map +1 -1
  35. package/dist/i18n/namespaces/inline-edit.js +1 -1
  36. package/dist/i18n/namespaces/inline-edit.js.map +1 -1
  37. package/dist/i18n/namespaces/search-filter-bar.js +2 -2
  38. package/dist/i18n/namespaces/search-filter-bar.js.map +1 -1
  39. package/dist/i18n/namespaces/time-picker.js +1 -1
  40. package/dist/i18n/namespaces/time-picker.js.map +1 -1
  41. package/dist/shell/app-launcher.d.ts +2 -2
  42. package/dist/shell/app-launcher.js.map +1 -1
  43. package/dist/shell/app-shell.d.ts +1 -1
  44. package/dist/shell/app-shell.js.map +1 -1
  45. package/dist/shell/comitor-logo.d.ts +1 -1
  46. package/dist/shell/comitor-logo.js.map +1 -1
  47. package/dist/shell/shell-context.d.ts +3 -2
  48. package/dist/shell/shell-context.js.map +1 -1
  49. package/dist/shell/sidebar-group.d.ts +1 -1
  50. package/dist/shell/sidebar-group.js.map +1 -1
  51. package/dist/shell/types.d.ts +1 -1
  52. package/dist/shell/types.js.map +1 -1
  53. package/dist/shell/utils.d.ts +2 -2
  54. package/dist/shell/utils.js.map +1 -1
  55. package/dist/tokens.d.ts +23 -9
  56. package/dist/tokens.js +17 -9
  57. package/dist/tokens.js.map +1 -1
  58. package/dist/uploader/image-upload-field.d.ts +2 -2
  59. package/dist/uploader/image-upload-field.js.map +1 -1
  60. package/package.json +1 -1
@@ -47,7 +47,7 @@ interface CommandPaletteProps {
47
47
  className?: string;
48
48
  }
49
49
  /**
50
- * Bảng lệnh (Cmd/Ctrl+K) — điều hướng nhanh và chạy lệnh, kiểu Linear/Raycast.
50
+ * Bảng lệnh (Cmd/Ctrl+K) — điều hướng nhanh và chạy lệnh.
51
51
  *
52
52
  * Đặt ở tầng composite (không phải trong shell) vì app không dùng `AppShell` — trang cài đặt
53
53
  * độc lập, công cụ nội bộ — vẫn cần bảng lệnh. Shell chỉ giữ state mở/đóng và truyền xuống.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/command-palette.tsx"],"sourcesContent":["\"use client\";\n\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { Loader2, Search } from \"lucide-react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Dialog, DialogContent, DialogDescription, DialogTitle } from \"../ui/dialog.js\";\nimport type { CommandPaletteLabels } from \"./command-palette-constants.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { KeyboardHint } from \"./keyboard-hint.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface CommandPaletteItem {\n id: string;\n label: string;\n /** Dòng phụ mờ (đường dẫn, mô tả ngắn, tên workspace) */\n description?: string;\n icon?: IconComponent;\n /** Gợi ý phím tắt hiện bên phải, vd \"mod+shift+n\" */\n shortcut?: string;\n /** Từ khoá phụ để tìm ra mục (tên tiếng Anh, viết tắt, mã) */\n keywords?: string[];\n disabled?: boolean;\n onSelect: () => void;\n}\n\nexport interface CommandPaletteGroup {\n id: string;\n label?: string;\n items: CommandPaletteItem[];\n}\n\nexport interface CommandPaletteProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n groups: CommandPaletteGroup[];\n /** Nhóm \"Gần đây\" luôn nằm trên cùng khi chưa gõ gì */\n recent?: CommandPaletteItem[];\n /*\n * Ba chuỗi dưới đây: không truyền thì lấy namespace `commandPalette` của `ComitorLocaleProvider`\n * (mặc định tiếng Anh). Truyền prop là đè riêng chỗ này.\n */\n recentLabel?: string;\n placeholder?: string;\n emptyText?: string;\n /** Có mặt = app tự tìm (gọi API) → tắt lọc nội bộ của cmdk */\n onSearchChange?: (search: string) => void;\n loading?: boolean;\n /** Nội dung chân bảng (gợi ý phím, trạng thái) */\n footer?: ReactNode;\n /**\n * Tự gắn phím tắt Cmd/Ctrl+K để mở (mặc định true).\n *\n * Shell của Comitor cũng gắn phím tắt này; khi dùng trong shell hãy đặt `false` để tránh\n * hai listener cùng lật một state (mở rồi đóng ngay trong một lần bấm).\n */\n registerShortcut?: boolean;\n /** Ghi đè hai chuỗi sr-only của hộp thoại (mặc định theo provider) — xem `CommandPaletteLabels` */\n labels?: Partial<CommandPaletteLabels>;\n className?: string;\n}\n\n/**\n * Ô nhập của palette — dựng thẳng trên `cmdk` thay vì dùng `CommandInput` của tầng primitive.\n *\n * Lý do: cần thay icon kính lúp bằng vòng xoay khi đang tìm phía server, và cần chỗ đặt gợi ý\n * phím Esc bên phải — `CommandInput` khoá cứng cả hai chi tiết đó.\n */\nfunction PaletteInput({\n value,\n onValueChange,\n placeholder,\n loading\n}: {\n value: string;\n onValueChange: (value: string) => void;\n placeholder: string;\n loading: boolean;\n}) {\n return (\n <div className=\"flex h-12 items-center gap-2 border-b border-border px-3\">\n {loading ? (\n <Loader2 aria-hidden=\"true\" className=\"size-4 shrink-0 animate-spin text-muted-foreground\" />\n ) : (\n <Search aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n )}\n <CommandPrimitive.Input\n value={value}\n onValueChange={onValueChange}\n placeholder={placeholder}\n aria-label={placeholder}\n className=\"flex h-11 w-full bg-transparent text-sm outline-hidden placeholder:text-muted-foreground\"\n />\n <KeyboardHint keys=\"esc\" className=\"hidden sm:inline-flex\" />\n </div>\n );\n}\n\nfunction PaletteRow({ item }: { item: CommandPaletteItem }) {\n const Icon = item.icon;\n return (\n <>\n {Icon && <Icon aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{item.label}</span>\n {item.description && <span className=\"block truncate text-xs text-muted-foreground\">{item.description}</span>}\n </span>\n {item.shortcut && <KeyboardHint keys={item.shortcut} className=\"ml-auto\" />}\n </>\n );\n}\n\n/**\n * Bảng lệnh (Cmd/Ctrl+K) — điều hướng nhanh và chạy lệnh, kiểu Linear/Raycast.\n *\n * Đặt ở tầng composite (không phải trong shell) vì app không dùng `AppShell` — trang cài đặt\n * độc lập, công cụ nội bộ — vẫn cần bảng lệnh. Shell chỉ giữ state mở/đóng và truyền xuống.\n *\n * Tìm kiếm mặc định KHÔNG DẤU tiếng Việt: gõ \"tao viec\" ra \"Tạo việc mới\".\n */\nexport function CommandPalette({\n open,\n onOpenChange,\n groups,\n recent = [],\n recentLabel: recentLabelProp,\n placeholder: placeholderProp,\n emptyText: emptyTextProp,\n onSearchChange,\n loading = false,\n footer,\n registerShortcut = true,\n labels: labelsProp,\n className\n}: CommandPaletteProps) {\n // Cả hai đường chữ đọc CHUNG một namespace: `labels` (hai chuỗi sr-only) trộn lên bảng, còn ba\n // prop rời thì lui về đúng khoá cùng tên của bảng đã trộn đó.\n const labels = useComitorLabels(\"commandPalette\", labelsProp);\n const recentLabel = recentLabelProp ?? labels.recentLabel;\n const placeholder = placeholderProp ?? labels.placeholder;\n const emptyText = emptyTextProp ?? labels.emptyText;\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n\n // Reset ô tìm mỗi lần mở: mở lại palette mà còn nguyên từ khoá cũ là hành vi gây bất ngờ.\n useEffect(() => {\n if (!open) setSearch(\"\");\n }, [open]);\n\n useEffect(() => {\n if (!registerShortcut) return;\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"k\" || !(event.metaKey || event.ctrlKey)) return;\n event.preventDefault();\n onOpenChange(!open);\n };\n\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [registerShortcut, open, onOpenChange]);\n\n const handleSearch = (value: string) => {\n setSearch(value);\n onSearchChange?.(value);\n };\n\n const runItem = (item: CommandPaletteItem) => {\n if (item.disabled) return;\n onOpenChange(false);\n item.onSelect();\n };\n\n const showRecent = recent.length > 0 && search.trim() === \"\";\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent size=\"lg\" showCloseButton={false} className={cn(\"overflow-hidden p-0\", className)}>\n <DialogTitle className=\"sr-only\">{labels.title}</DialogTitle>\n <DialogDescription className=\"sr-only\">{labels.description}</DialogDescription>\n\n <Command\n shouldFilter={!isAsync}\n filter={(value, query, keywords) => {\n if (!query) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(query).toLowerCase()) ? 1 : 0;\n }}\n className=\"bg-transparent\"\n >\n <PaletteInput value={search} onValueChange={handleSearch} placeholder={placeholder} loading={loading} />\n\n <CommandList className=\"max-h-[22rem]\">\n {!loading && <CommandEmpty>{emptyText}</CommandEmpty>}\n\n {showRecent && (\n <CommandGroup heading={recentLabel}>\n {recent.map((item) => (\n <CommandItem\n key={`recent-${item.id}`}\n value={`recent-${item.id}`}\n keywords={[item.label, item.description ?? \"\", ...(item.keywords ?? [])]}\n disabled={item.disabled}\n onSelect={() => runItem(item)}\n >\n <PaletteRow item={item} />\n </CommandItem>\n ))}\n </CommandGroup>\n )}\n\n {groups.map((group) => (\n <CommandGroup key={group.id} heading={group.label}>\n {group.items.map((item) => (\n <CommandItem\n key={item.id}\n value={item.id}\n keywords={[item.label, item.description ?? \"\", ...(item.keywords ?? [])]}\n disabled={item.disabled}\n onSelect={() => runItem(item)}\n >\n <PaletteRow item={item} />\n </CommandItem>\n ))}\n </CommandGroup>\n ))}\n </CommandList>\n\n {footer && (\n <div className=\"flex items-center gap-2 border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n {footer}\n </div>\n )}\n </Command>\n </DialogContent>\n </Dialog>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/command-palette.tsx"],"sourcesContent":["\"use client\";\n\nimport { Command as CommandPrimitive } from \"cmdk\";\nimport { Loader2, Search } from \"lucide-react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Dialog, DialogContent, DialogDescription, DialogTitle } from \"../ui/dialog.js\";\nimport type { CommandPaletteLabels } from \"./command-palette-constants.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { KeyboardHint } from \"./keyboard-hint.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface CommandPaletteItem {\n id: string;\n label: string;\n /** Dòng phụ mờ (đường dẫn, mô tả ngắn, tên workspace) */\n description?: string;\n icon?: IconComponent;\n /** Gợi ý phím tắt hiện bên phải, vd \"mod+shift+n\" */\n shortcut?: string;\n /** Từ khoá phụ để tìm ra mục (tên tiếng Anh, viết tắt, mã) */\n keywords?: string[];\n disabled?: boolean;\n onSelect: () => void;\n}\n\nexport interface CommandPaletteGroup {\n id: string;\n label?: string;\n items: CommandPaletteItem[];\n}\n\nexport interface CommandPaletteProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n groups: CommandPaletteGroup[];\n /** Nhóm \"Gần đây\" luôn nằm trên cùng khi chưa gõ gì */\n recent?: CommandPaletteItem[];\n /*\n * Ba chuỗi dưới đây: không truyền thì lấy namespace `commandPalette` của `ComitorLocaleProvider`\n * (mặc định tiếng Anh). Truyền prop là đè riêng chỗ này.\n */\n recentLabel?: string;\n placeholder?: string;\n emptyText?: string;\n /** Có mặt = app tự tìm (gọi API) → tắt lọc nội bộ của cmdk */\n onSearchChange?: (search: string) => void;\n loading?: boolean;\n /** Nội dung chân bảng (gợi ý phím, trạng thái) */\n footer?: ReactNode;\n /**\n * Tự gắn phím tắt Cmd/Ctrl+K để mở (mặc định true).\n *\n * Shell của Comitor cũng gắn phím tắt này; khi dùng trong shell hãy đặt `false` để tránh\n * hai listener cùng lật một state (mở rồi đóng ngay trong một lần bấm).\n */\n registerShortcut?: boolean;\n /** Ghi đè hai chuỗi sr-only của hộp thoại (mặc định theo provider) — xem `CommandPaletteLabels` */\n labels?: Partial<CommandPaletteLabels>;\n className?: string;\n}\n\n/**\n * Ô nhập của palette — dựng thẳng trên `cmdk` thay vì dùng `CommandInput` của tầng primitive.\n *\n * Lý do: cần thay icon kính lúp bằng vòng xoay khi đang tìm phía server, và cần chỗ đặt gợi ý\n * phím Esc bên phải — `CommandInput` khoá cứng cả hai chi tiết đó.\n */\nfunction PaletteInput({\n value,\n onValueChange,\n placeholder,\n loading\n}: {\n value: string;\n onValueChange: (value: string) => void;\n placeholder: string;\n loading: boolean;\n}) {\n return (\n <div className=\"flex h-12 items-center gap-2 border-b border-border px-3\">\n {loading ? (\n <Loader2 aria-hidden=\"true\" className=\"size-4 shrink-0 animate-spin text-muted-foreground\" />\n ) : (\n <Search aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n )}\n <CommandPrimitive.Input\n value={value}\n onValueChange={onValueChange}\n placeholder={placeholder}\n aria-label={placeholder}\n className=\"flex h-11 w-full bg-transparent text-sm outline-hidden placeholder:text-muted-foreground\"\n />\n <KeyboardHint keys=\"esc\" className=\"hidden sm:inline-flex\" />\n </div>\n );\n}\n\nfunction PaletteRow({ item }: { item: CommandPaletteItem }) {\n const Icon = item.icon;\n return (\n <>\n {Icon && <Icon aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{item.label}</span>\n {item.description && <span className=\"block truncate text-xs text-muted-foreground\">{item.description}</span>}\n </span>\n {item.shortcut && <KeyboardHint keys={item.shortcut} className=\"ml-auto\" />}\n </>\n );\n}\n\n/**\n * Bảng lệnh (Cmd/Ctrl+K) — điều hướng nhanh và chạy lệnh.\n *\n * Đặt ở tầng composite (không phải trong shell) vì app không dùng `AppShell` — trang cài đặt\n * độc lập, công cụ nội bộ — vẫn cần bảng lệnh. Shell chỉ giữ state mở/đóng và truyền xuống.\n *\n * Tìm kiếm mặc định KHÔNG DẤU tiếng Việt: gõ \"tao viec\" ra \"Tạo việc mới\".\n */\nexport function CommandPalette({\n open,\n onOpenChange,\n groups,\n recent = [],\n recentLabel: recentLabelProp,\n placeholder: placeholderProp,\n emptyText: emptyTextProp,\n onSearchChange,\n loading = false,\n footer,\n registerShortcut = true,\n labels: labelsProp,\n className\n}: CommandPaletteProps) {\n // Cả hai đường chữ đọc CHUNG một namespace: `labels` (hai chuỗi sr-only) trộn lên bảng, còn ba\n // prop rời thì lui về đúng khoá cùng tên của bảng đã trộn đó.\n const labels = useComitorLabels(\"commandPalette\", labelsProp);\n const recentLabel = recentLabelProp ?? labels.recentLabel;\n const placeholder = placeholderProp ?? labels.placeholder;\n const emptyText = emptyTextProp ?? labels.emptyText;\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n\n // Reset ô tìm mỗi lần mở: mở lại palette mà còn nguyên từ khoá cũ là hành vi gây bất ngờ.\n useEffect(() => {\n if (!open) setSearch(\"\");\n }, [open]);\n\n useEffect(() => {\n if (!registerShortcut) return;\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.key !== \"k\" || !(event.metaKey || event.ctrlKey)) return;\n event.preventDefault();\n onOpenChange(!open);\n };\n\n document.addEventListener(\"keydown\", onKeyDown);\n return () => document.removeEventListener(\"keydown\", onKeyDown);\n }, [registerShortcut, open, onOpenChange]);\n\n const handleSearch = (value: string) => {\n setSearch(value);\n onSearchChange?.(value);\n };\n\n const runItem = (item: CommandPaletteItem) => {\n if (item.disabled) return;\n onOpenChange(false);\n item.onSelect();\n };\n\n const showRecent = recent.length > 0 && search.trim() === \"\";\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent size=\"lg\" showCloseButton={false} className={cn(\"overflow-hidden p-0\", className)}>\n <DialogTitle className=\"sr-only\">{labels.title}</DialogTitle>\n <DialogDescription className=\"sr-only\">{labels.description}</DialogDescription>\n\n <Command\n shouldFilter={!isAsync}\n filter={(value, query, keywords) => {\n if (!query) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(query).toLowerCase()) ? 1 : 0;\n }}\n className=\"bg-transparent\"\n >\n <PaletteInput value={search} onValueChange={handleSearch} placeholder={placeholder} loading={loading} />\n\n <CommandList className=\"max-h-[22rem]\">\n {!loading && <CommandEmpty>{emptyText}</CommandEmpty>}\n\n {showRecent && (\n <CommandGroup heading={recentLabel}>\n {recent.map((item) => (\n <CommandItem\n key={`recent-${item.id}`}\n value={`recent-${item.id}`}\n keywords={[item.label, item.description ?? \"\", ...(item.keywords ?? [])]}\n disabled={item.disabled}\n onSelect={() => runItem(item)}\n >\n <PaletteRow item={item} />\n </CommandItem>\n ))}\n </CommandGroup>\n )}\n\n {groups.map((group) => (\n <CommandGroup key={group.id} heading={group.label}>\n {group.items.map((item) => (\n <CommandItem\n key={item.id}\n value={item.id}\n keywords={[item.label, item.description ?? \"\", ...(item.keywords ?? [])]}\n disabled={item.disabled}\n onSelect={() => runItem(item)}\n >\n <PaletteRow item={item} />\n </CommandItem>\n ))}\n </CommandGroup>\n ))}\n </CommandList>\n\n {footer && (\n <div className=\"flex items-center gap-2 border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n {footer}\n </div>\n )}\n </Command>\n </DialogContent>\n </Dialog>\n );\n}\n"],"mappings":"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","names":[]}
@@ -76,9 +76,11 @@ interface DataTableProps<TRow> {
76
76
  /**
77
77
  * Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bên).
78
78
  *
79
- * Đánh dấu bằng `outline` chứ không bằng nền — xem lý do đầy đủ ở chỗ áp class. Tóm tắt: nền
80
- * có alpha sẽ làm ô cột GHIM trong mờ, và nền class trần thì thua sọc của `striped` về độ đặc
81
- * tả nên mất hẳn ở hàng chẵn. `outline` không dính cả hai.
79
+ * Đánh dấu bằng hai vạch 2px màu `--app-accent-ink` ở mép trên và dưới dòng, vẽ bằng bóng INSET
80
+ * trên TỪNG Ô — không bằng nền, và không bằng `outline` của `<tr>`. Lý do đầy đủ ở chỗ áp class.
81
+ * Tóm tắt: nền có alpha làm ô cột GHIM trong mờ; nền class trần thua sọc của `striped` về độ đặc
82
+ * tả nên mất hẳn ở hàng chẵn; `outline` của `<tr>` bị ô cột ghim (`z-index: 2`) đè cụt. Bóng
83
+ * inset không dính cả ba, và không cộng vào chiều cao dòng.
82
84
  *
83
85
  * ⚠ Vì thế app KHÔNG cần tự vá bằng `rowClassName` nữa — dùng thẳng prop này.
84
86
  */
@@ -118,7 +120,10 @@ interface DataTableProps<TRow> {
118
120
  * gắn class vào ô THÂN nên `compact` cho hàng tiêu đề 40px đứng trên hàng thân ~26px.
119
121
  */
120
122
  density?: "comfortable" | "compact";
121
- /** Ghi đè chuỗi hiển thị/a11y (mặc định tiếng Việt) — xem `DataTableLabels` */
123
+ /**
124
+ * Ghi đè từng khoá chuỗi hiển thị/a11y — xem `DataTableLabels`. Bỏ trống thì theo
125
+ * `ComitorLocaleProvider` (tiếng Anh khi không có provider — từ `3.0.0`; trước đó là tiếng Việt).
126
+ */
122
127
  labels?: Partial<DataTableLabels>;
123
128
  /** Nhãn của bảng cho trình đọc màn hình (bắt buộc khi trang có nhiều bảng) */
124
129
  "aria-label"?: string;
@@ -143,7 +148,7 @@ interface DataTableColumnToggleProps<TRow> {
143
148
  onVisibleColumnIdsChange: (visibleColumnIds: string[]) => void;
144
149
  /** Nhãn nút + tiêu đề menu; không truyền thì lấy `labels.columnToggle` */
145
150
  label?: string;
146
- /** Ghi đè chuỗi hiển thị (mặc định tiếng Việt) — xem `DataTableLabels` */
151
+ /** Ghi đè từng khoá chuỗi hiển thị — xem `DataTableLabels`. Bỏ trống thì theo `ComitorLocaleProvider`. */
147
152
  labels?: Partial<DataTableLabels>;
148
153
  className?: string;
149
154
  }
@@ -208,8 +208,8 @@ function DataTable({
208
208
  // card — dưới 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản).
209
209
  onRowClick && "cursor-pointer focus-visible:outline-2 focus-visible:outline-foreground focus-visible:[outline-offset:-2px]",
210
210
  /*
211
- * Dòng ĐANG MỞ đánh dấu bằng `outline`, KHÔNG bằng nền — hai lý do, cả hai
212
- * đều là lỗi thật của bản trước (`bg-app-accent/5`):
211
+ * Dòng ĐANG MỞ đánh dấu bằng bóng INSET trên từng ô, KHÔNG bằng nền — hai lý
212
+ * do, cả hai đều là lỗi thật của bản trước (`bg-app-accent/5`):
213
213
  *
214
214
  * 1. Lớp nền đó CÓ ALPHA. Ô cột ghim lấy nền bằng `bg-inherit` từ hàng, nên
215
215
  * nó thừa hưởng luôn alpha và trở nên trong mờ — cuộn ngang là chữ của
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/data-table.tsx"],"sourcesContent":["\"use client\";\n\nimport { ArrowDown, ArrowUp, ChevronsUpDown, Inbox, SlidersHorizontal } from \"lucide-react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Checkbox } from \"../ui/checkbox.js\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger\n} from \"../ui/dropdown-menu.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport { TableBody, TableCell, TableHead, TableHeader, TableRow } from \"../ui/table.js\";\nimport type { DataTableLabels } from \"./data-table-constants.js\";\nimport { EmptyState } from \"./empty-state.js\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface DataTableSort {\n columnId: string;\n direction: SortDirection;\n}\n\nexport type DataTableAlign = \"left\" | \"center\" | \"right\";\n\nexport interface DataTableColumn<TRow> {\n id: string;\n /** Nội dung ô tiêu đề */\n header: ReactNode;\n /** Nhãn chữ dùng trong menu ẩn/hiện cột — bắt buộc khi `header` không phải chuỗi */\n label?: string;\n /** Render ô dữ liệu — nhận cả chỉ số dòng để đánh số thứ tự */\n cell: (row: TRow, rowIndex: number) => ReactNode;\n sortable?: boolean;\n align?: DataTableAlign;\n /**\n * Bề rộng cột tính bằng px. BẮT BUỘC với cột ghim (`pinned`) vì offset `left/right` của\n * sticky được tính cộng dồn từ giá trị này — thiếu width thì các cột ghim chồng lên nhau.\n */\n width?: number;\n minWidth?: number;\n /** Ghim cột khi cuộn ngang (bảng nhiều cột) */\n pinned?: \"left\" | \"right\";\n /** Cho phép người dùng ẩn cột này (mặc định true; cột định danh nên đặt false) */\n hideable?: boolean;\n headerClassName?: string;\n cellClassName?: string;\n}\n\nexport interface DataTableProps<TRow> {\n columns: DataTableColumn<TRow>[];\n rows: TRow[];\n /** Khoá ổn định của dòng — dùng cho `key` React và cho tập chọn */\n getRowId: (row: TRow, index: number) => string;\n\n /** Đang tải → hiện `loadingRowCount` dòng khung xương thay vì bảng trống */\n loading?: boolean;\n loadingRowCount?: number;\n /** Nội dung khi không có dòng nào (mặc định EmptyState theo `labels.emptyTitle`) */\n empty?: ReactNode;\n\n /** Sắp xếp — controlled; `null` = không sắp xếp. App tự sắp/tự gọi API. */\n sort?: DataTableSort | null;\n onSortChange?: (sort: DataTableSort | null) => void;\n\n /** Chọn dòng bằng checkbox — controlled */\n selectable?: boolean;\n selectedIds?: string[];\n onSelectedIdsChange?: (ids: string[]) => void;\n /** Khoá chọn với một số dòng (vd bản ghi đã khoá) */\n isRowSelectable?: (row: TRow, index: number) => boolean;\n\n /**\n * Mã các cột đang hiện — controlled. Bỏ trống = hiện tất cả. Đổi bố cục đi qua\n * `DataTableColumnToggle`, app tự lưu theo người dùng (server, localStorage…).\n *\n * ⚠ Đây là một **TẬP HỢP**, không phải thứ tự: thứ tự cột luôn lấy từ mảng `columns`, thứ tự\n * bên trong mảng này bị bỏ qua. Tên có hậu tố `Ids` chính là để nói đúng thứ nó chứa —\n * 0.1.0 đặt `visibleColumns` khiến người đọc tưởng là mảng cột (`DataTableColumn[]`) hoặc\n * tưởng sắp lại mảng thì cột đổi chỗ.\n *\n * Cố ý KHÔNG dùng `Record<string, boolean>`: kiểu đó có trạng thái thứ ba — **khoá vắng\n * mặt**. App thêm cột mới ở bản sau thì bố cục người dùng đã lưu không có khoá đó, mỗi nơi\n * tự đoán hiện/ẩn → lỗi im lặng. Một mảng mã cột thì `JSON.stringify` là lưu được.\n *\n * Package cố ý KHÔNG tự ghi localStorage: ở SaaS nhiều workspace, bố cục bảng phải theo\n * cặp (người dùng × workspace) và thường cần đồng bộ giữa các máy — chuyện của app.\n */\n visibleColumnIds?: string[];\n\n /**\n * Bấm vào dòng (vd mở chi tiết bản ghi). Có prop này thì dòng vào chuỗi Tab và nhận\n * Enter/Space — mở chi tiết KHÔNG được là thao tác chỉ-chuột.\n */\n onRowClick?: (row: TRow, index: number) => void;\n rowClassName?: (row: TRow, index: number) => string | undefined;\n /**\n * Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bên).\n *\n * Đánh dấu bằng `outline` chứ không bằng nền — xem lý do đầy đủ ở chỗ áp class. Tóm tắt: nền\n * có alpha sẽ làm ô cột GHIM trong mờ, và nền class trần thì thua sọc của `striped` về độ đặc\n * tả nên mất hẳn ở hàng chẵn. `outline` không dính cả hai.\n *\n * ⚠ Vì thế app KHÔNG cần tự vá bằng `rowClassName` nữa — dùng thẳng prop này.\n */\n isRowActive?: (row: TRow, index: number) => boolean;\n\n /** Tiêu đề dính đỉnh vùng cuộn (mặc định true) */\n stickyHeader?: boolean;\n /**\n * Tô nền xen kẽ cho hàng chẵn — mẫu DataTable ở mục **Patterns** của comitor-ds dùng\n * `<Table striped>`, còn `DataTable` trước đây không có đường bật.\n *\n * Mặc định `false`: bảng dài và dày cột thì sọc giúp dò hàng, nhưng bảng ngắn thì nó chỉ thêm\n * nhiễu. Đây là quyết định của từng trang, không phải mặc định của gói.\n *\n * ⚠ Sọc PHẢI là màu ĐỤC (`--table-row-stripe`), không phải `bg-muted/30` như bản trước.\n *\n * Vì sao: sọc khai bằng biến thể tuỳ ý ở cấp `<table>` nên chọn tử sinh ra là\n * `.[…] tbody tr:nth-child(even)` — độ đặc tả (0,2,2), trong khi `bg-card` mà `<TableRow>` tự\n * mang chỉ là một class (0,1,0). Sọc THẮNG và THAY THẾ `bg-card` chứ không chồng lên nó, nên\n * hàng chẵn còn alpha 0,3 mà không có lớp đục nào bên dưới. Ô cột ghim lấy `bg-inherit` từ hàng\n * → cũng trong mờ → cuộn ngang là chữ của các cột bên dưới lộ xuyên qua cột ghim. Đây đúng là\n * lỗi người dùng nhìn thấy, và nó chỉ xuất hiện ở hàng CHẴN nên rất dễ tưởng là \"hàng đầu ổn\".\n *\n * (Chú thích cũ ở đây giải thích rằng \"nền trạng thái luôn thắng vì ghi sau trong chuỗi lớp\" —\n * SAI, và đó chính là lý do lỗi trên lọt qua: thứ tự trong chuỗi class không quyết định gì, độ\n * đặc tả mới quyết định. Nền của hàng ĐANG MỞ `bg-app-accent/5` là (0,1,0) nên nó cũng thua\n * sọc — nhưng hàng đang mở vốn được đánh dấu bằng `outline`, không bằng nền, nên chỗ đó không\n * lộ ra. Đừng lấy nền làm dấu hiệu hàng ở bảng có sọc.)\n */\n striped?: boolean;\n /** Bề rộng tối thiểu của bảng — bật cuộn ngang khi màn hình hẹp */\n minWidth?: number | string;\n /**\n * Ghi đè mật độ cho RIÊNG bảng này, bất kể trục mật độ toàn app đang ở mức nào.\n *\n * Bỏ trống = theo `<html data-density>`. Đặt `\"compact\"` thì bảng tự đặt lại `--row-py` và\n * `--table-head-h` ngay trên thẻ `<table>`, nên tiêu đề và thân đi cùng nhau — bản trước chỉ\n * gắn class vào ô THÂN nên `compact` cho hàng tiêu đề 40px đứng trên hàng thân ~26px.\n */\n density?: \"comfortable\" | \"compact\";\n /** Ghi đè chuỗi hiển thị/a11y (mặc định tiếng Việt) — xem `DataTableLabels` */\n labels?: Partial<DataTableLabels>;\n /** Nhãn của bảng cho trình đọc màn hình (bắt buộc khi trang có nhiều bảng) */\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n className?: string;\n /** className của khung cuộn bọc ngoài */\n containerClassName?: string;\n}\n\nconst alignClasses: Record<DataTableAlign, string> = {\n left: \"text-left\",\n center: \"text-center\",\n right: \"text-right\"\n};\n\n/** Offset sticky cộng dồn cho cột ghim trái/phải. */\nfunction getPinnedOffsets<TRow>(columns: DataTableColumn<TRow>[], selectable: boolean): Map<string, CSSProperties> {\n const styles = new Map<string, CSSProperties>();\n // Cột checkbox (nếu có) luôn ghim trái và chiếm 44px — cột ghim đầu tiên phải bắt đầu sau nó.\n let left = selectable ? 44 : 0;\n for (const column of columns) {\n if (column.pinned !== \"left\") continue;\n styles.set(column.id, { position: \"sticky\", left, zIndex: 2 });\n left += column.width ?? 0;\n }\n\n let right = 0;\n for (const column of [...columns].reverse()) {\n if (column.pinned !== \"right\") continue;\n styles.set(column.id, { position: \"sticky\", right, zIndex: 2 });\n right += column.width ?? 0;\n }\n\n return styles;\n}\n\nfunction nextSort(current: DataTableSort | null | undefined, columnId: string): DataTableSort | null {\n // asc → desc → bỏ sắp xếp. Vòng 3 trạng thái để người dùng quay lại thứ tự gốc của server\n // mà không phải tải lại trang.\n if (!current || current.columnId !== columnId) return { columnId, direction: \"asc\" };\n if (current.direction === \"asc\") return { columnId, direction: \"desc\" };\n return null;\n}\n\n/**\n * Bảng dữ liệu chuẩn của Comitor: tiêu đề dính, sắp xếp, chọn dòng, ghim cột, ẩn/hiện cột,\n * trạng thái tải và trạng thái rỗng.\n *\n * Mọi trạng thái đều **controlled** — package không giữ dữ liệu, không gọi API, không tự phân\n * trang. Nhờ vậy dùng được cả với dữ liệu client (mảng trong bộ nhớ) lẫn server-side\n * pagination mà không phải viết hai component.\n */\nexport function DataTable<TRow>({\n columns,\n rows,\n getRowId,\n loading = false,\n loadingRowCount = 5,\n empty,\n sort,\n onSortChange,\n selectable = false,\n selectedIds,\n onSelectedIdsChange,\n isRowSelectable,\n visibleColumnIds,\n onRowClick,\n rowClassName,\n isRowActive,\n stickyHeader = true,\n striped = false,\n minWidth,\n density = \"comfortable\",\n labels: labelsProp,\n className,\n containerClassName,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledby\n}: DataTableProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n // Thứ tự luôn theo `columns` — `visibleColumnIds` chỉ trả lời \"cột nào được hiện\".\n const shownColumns = visibleColumnIds ? columns.filter((column) => visibleColumnIds.includes(column.id)) : columns;\n const pinnedStyles = getPinnedOffsets(shownColumns, selectable);\n const colCount = shownColumns.length + (selectable ? 1 : 0);\n\n const selected = selectedIds ?? [];\n // Một lượt duyệt: vừa lọc dòng chọn được vừa lấy id — tránh indexOf() lồng trong map (O(n²))\n // và tránh sai khi hai dòng cùng tham chiếu object.\n const selectableIds: string[] = [];\n rows.forEach((row, index) => {\n if (isRowSelectable?.(row, index) ?? true) selectableIds.push(getRowId(row, index));\n });\n const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selected.includes(id));\n const someSelected = !allSelected && selectableIds.some((id) => selected.includes(id));\n\n const toggleAll = () => {\n if (!onSelectedIdsChange) return;\n if (allSelected) {\n onSelectedIdsChange(selected.filter((id) => !selectableIds.includes(id)));\n return;\n }\n onSelectedIdsChange([...new Set([...selected, ...selectableIds])]);\n };\n\n const toggleRow = (id: string) => {\n if (!onSelectedIdsChange) return;\n onSelectedIdsChange(selected.includes(id) ? selected.filter((item) => item !== id) : [...selected, id]);\n };\n\n /*\n * `density` nay là GHI ĐÈ CỤC BỘ của trục mật độ toàn cục, không phải một bảng lớp riêng.\n *\n * Bản trước là `density === \"compact\" ? \"px-3 py-1.5\" : \"px-4 py-3\"` gắn tay vào bốn ô THÂN —\n * ba khuyết tật cùng lúc: (1) nó không bao giờ tới `<TableHead>`, nên `compact` cho hàng tiêu\n * đề 40px đứng trên hàng thân ~26px; (2) nó trộn hai trục, co cả chiều NGANG (px-4 → px-3)\n * trong khi \"gọn\" chỉ nên là chuyện chiều dọc; (3) bảng nào dùng `<Table>` trần thì không có\n * mật độ nào cả.\n *\n * Đặt biến ở chính thẻ `<table>` thì cả ba biến mất: `TableHead`/`TableCell` đã đọc\n * `--table-head-h`/`--row-py` nên tiêu đề và thân đi cùng nhau, và một bảng vẫn gọn được ngay\n * cả khi toàn app đang ở mức thoải mái (hoặc ngược lại).\n */\n const densityVars =\n density === \"compact\" ? ({ \"--row-py\": \"0.375rem\", \"--table-head-h\": \"2rem\" } as CSSProperties) : undefined;\n\n return (\n <div\n data-slot=\"data-table-container\"\n className={cn(\"relative w-full overflow-auto rounded-xl border border-border bg-card\", containerClassName)}\n >\n <table\n data-slot=\"data-table\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n aria-busy={loading || undefined}\n className={cn(\n \"w-full caption-bottom text-sm\",\n // Cùng một chuỗi lớp với `<Table striped>` của Tier 1 — đổi một chỗ thì đổi cả hai.\n striped &&\n \"[&_tbody_tr:nth-child(even)]:bg-table-row-stripe [&_tbody_tr:nth-child(even):hover]:bg-table-row-hover\",\n className\n )}\n style={\n minWidth != null || densityVars ? { ...densityVars, ...(minWidth != null ? { minWidth } : null) } : undefined\n }\n >\n <TableHeader className={cn(stickyHeader && \"sticky top-0 z-10\")}>\n {/* Nền ĐẶC đặt trên <tr>, không phải <thead>: ô ghim dùng `bg-inherit` mà\n background-color kế thừa từ CHA TRỰC TIẾP (<tr>). Để nền ở <thead> thì <tr> vẫn\n trong suốt → cột ghim nhìn xuyên thấy dữ liệu cuộn phía dưới. */}\n <TableRow className=\"bg-muted hover:bg-muted\">\n {selectable && (\n <TableHead\n className=\"w-11 bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 3, width: 44, minWidth: 44 }}\n >\n <Checkbox\n checked={allSelected ? true : someSelected ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={selectableIds.length === 0}\n aria-label={allSelected ? labels.deselectAllRows : labels.selectAllRows}\n />\n </TableHead>\n )}\n\n {shownColumns.map((column) => {\n const isSorted = sort?.columnId === column.id;\n const SortIcon = isSorted ? (sort?.direction === \"asc\" ? ArrowUp : ArrowDown) : ChevronsUpDown;\n const pinnedStyle = pinnedStyles.get(column.id);\n\n return (\n <TableHead\n key={column.id}\n scope=\"col\"\n aria-sort={isSorted ? (sort?.direction === \"asc\" ? \"ascending\" : \"descending\") : undefined}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.headerClassName\n )}\n style={{\n ...pinnedStyle,\n width: column.width,\n minWidth: column.minWidth ?? column.width\n }}\n >\n {column.sortable && onSortChange ? (\n <button\n type=\"button\"\n onClick={() => onSortChange(nextSort(sort, column.id))}\n className={cn(\n \"-mx-1 inline-flex items-center gap-1 rounded-sm px-1 py-0.5 uppercase transition-colors hover:text-foreground\",\n column.align === \"right\" && \"flex-row-reverse\",\n isSorted && \"text-foreground\"\n )}\n >\n {column.header}\n {/* KHÔNG làm mờ icon chưa sắp xếp: nút này còn bấm được nên `opacity-*`\n không được miễn trừ ngưỡng 3:1 của WCAG 1.4.11 cho icon mang nghĩa.\n `opacity-50` cho ra 1,98:1 (light) / 2,23:1 (dark) trên nền `bg-muted`\n của hàng tiêu đề ✗; để nguyên `text-muted-foreground` là 4,89 / 4,86:1 ✓.\n Trạng thái ĐANG sắp xếp vẫn phân biệt được: nút đổi sang\n `text-foreground` và icon đổi hình (ArrowUp/ArrowDown ⇄ ChevronsUpDown). */}\n <SortIcon aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n ) : (\n column.header\n )}\n </TableHead>\n );\n })}\n </TableRow>\n </TableHeader>\n\n <TableBody>\n {loading &&\n Array.from({ length: loadingRowCount }).map((_, rowIndex) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: dòng khung xương không có danh tính dữ liệu\n <TableRow key={`skeleton-${rowIndex}`} className=\"hover:bg-transparent\">\n {selectable && (\n <TableCell>\n <Skeleton className=\"size-4 rounded-sm\" />\n </TableCell>\n )}\n {shownColumns.map((column) => (\n <TableCell key={column.id}>\n <Skeleton className=\"h-4 w-full max-w-[12rem]\" />\n </TableCell>\n ))}\n </TableRow>\n ))}\n\n {!loading && rows.length === 0 && (\n <TableRow className=\"hover:bg-transparent\">\n <TableCell colSpan={colCount} className=\"p-0\">\n {empty ?? <EmptyState icon={Inbox} title={labels.emptyTitle} description={labels.emptyDescription} />}\n </TableCell>\n </TableRow>\n )}\n\n {!loading &&\n rows.map((row, rowIndex) => {\n const id = getRowId(row, rowIndex);\n const isSelected = selected.includes(id);\n const canSelect = isRowSelectable?.(row, rowIndex) ?? true;\n const active = isRowActive?.(row, rowIndex) ?? false;\n\n return (\n <TableRow\n key={id}\n data-state={isSelected ? \"selected\" : undefined}\n aria-selected={selectable ? isSelected : undefined}\n onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}\n // Dòng bấm được phải dùng được bằng BÀN PHÍM: đưa <tr> vào chuỗi Tab và tự\n // xử lý Enter/Space. Cố ý KHÔNG đặt role=\"button\" — role đó đè role=\"row\"\n // ngầm, trình đọc màn hình sẽ mất cấu trúc bảng (số dòng/cột, tiêu đề cột);\n // dòng vẫn là dòng, chỉ thêm khả năng kích hoạt.\n tabIndex={onRowClick ? 0 : undefined}\n onKeyDown={\n onRowClick\n ? (event) => {\n // Chỉ nhận phím khi CHÍNH <tr> đang focus: Enter/Space bấm trên\n // control bên trong ô (checkbox, nút, link) đã có nghĩa riêng —\n // nổi bọt lên đây sẽ mở chi tiết ngoài ý muốn.\n if (event.target !== event.currentTarget) return;\n if (event.key !== \"Enter\" && event.key !== \" \") return;\n // Space mặc định cuộn trang, Enter có thể submit form bao ngoài.\n event.preventDefault();\n onRowClick(row, rowIndex);\n }\n : undefined\n }\n className={cn(\n // bg-card trên <tr> để ô ghim (`bg-inherit`) có màu đặc mà vẫn đổi theo\n // trạng thái chọn/hover của chính dòng đó.\n \"bg-card\",\n // Viền focus vẽ bằng `outline` chứ không `ring`: <tr> nằm trong bảng\n // border-collapse, box-shadow không được vẽ. Offset ÂM để viền nằm trong\n // lòng dòng, không bị khung cuộn cắt mất ở mép trái/phải.\n // Màu dùng `foreground` chứ không `ring` (gold #E8B824 chỉ ~1,9:1 trên nền\n // card — dưới 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản).\n onRowClick &&\n \"cursor-pointer focus-visible:outline-2 focus-visible:outline-foreground focus-visible:[outline-offset:-2px]\",\n /*\n * Dòng ĐANG MỞ đánh dấu bằng `outline`, KHÔNG bằng nền — hai lý do, cả hai\n * đều là lỗi thật của bản trước (`bg-app-accent/5`):\n *\n * 1. Lớp nền đó CÓ ALPHA. Ô cột ghim lấy nền bằng `bg-inherit` từ hàng, nên\n * nó thừa hưởng luôn alpha và trở nên trong mờ — cuộn ngang là chữ của\n * các cột bên dưới lộ xuyên qua cột ghim.\n * 2. Nó là một class trần, độ đặc tả (0,1,0). Sọc của `striped` khai ở cấp\n * `<table>` nên chọn tử ra (0,2,2) và THẮNG — tức ở bảng có sọc, dòng\n * đang mở không hiện gì ở hàng CHẴN.\n *\n * Và vì sao vẽ trên TỪNG Ô (`[&>td]`) chứ không phải `outline` trên `<tr>`:\n * ô cột ghim mang `z-index: 2` nên nó nằm ở lớp cao hơn và ĐÈ LÊN viền của\n * hàng — dấu bị cắt cụt đúng ở vùng cột ghim, kể cả khi chưa cuộn ngang. Đã\n * thử và chụp lại. Bóng INSET vẽ trong lòng từng ô, tức vẽ CÙNG lớp với ô đó,\n * nên cột ghim cũng có dấu; và vì là bóng chứ không phải viền nên nó không\n * cộng vào chiều cao — mở một dòng không làm bảng nhảy 4px.\n *\n * Không đụng nhóm `bg-*` nên miễn nhiễm cả hai lỗi trên: cột ghim giữ nền\n * đục, và sọc không có gì để tranh.\n *\n * Màu `--app-accent-ink` (bậc đi lên nền trang): 6,14:1 trên `--card` ở light\n * · 9,61:1 ở dark — trên ngưỡng 3:1 của WCAG 1.4.11. KHÔNG dùng\n * `--app-accent` trần: gold #E8B824 chỉ 1,86:1, đúng cái bẫy \"token tô NỀN\n * đem vẽ DẤU\".\n *\n * ⚠ Trong giá trị tuỳ ý phải viết `var(--app-accent-ink)` — TOKEN THÔ — chứ\n * KHÔNG phải `var(--color-app-accent-ink)`. `@theme inline` khai\n * `--color-app-accent-ink: var(--app-accent-ink)`, nghĩa là utility sinh ra\n * nhúng thẳng `var(--app-accent-ink)` nên nó KẾ THỪA được accent mà app đặt\n * trên một thẻ bọc; còn biến `--color-*` thì vẫn nằm ở `:root` với giá trị đã\n * hoá giải theo gốc. Đo tại chính ô: `--app-accent-ink` = #1A6668 (teal của\n * app) nhưng `--color-app-accent-ink` = #7A5800 (gold mặc định) — viết nhầm\n * là dấu ra sai màu app mà không có gì báo.\n */\n active &&\n \"[&>td]:shadow-[inset_0_2px_0_var(--app-accent-ink),inset_0_-2px_0_var(--app-accent-ink)]\",\n rowClassName?.(row, rowIndex)\n )}\n >\n {selectable && (\n <TableCell\n className=\"bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 1, width: 44, minWidth: 44 }}\n >\n {/* Chặn nổi bọt: bấm checkbox không được kích hoạt onRowClick (mở chi tiết) */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: lớp bọc chỉ chặn sự kiện, tương tác nằm ở Checkbox bên trong */}\n {/* biome-ignore lint/a11y/useKeyWithClickEvents: phím bấm đã do Checkbox xử lý */}\n <span onClick={(event) => event.stopPropagation()}>\n <Checkbox\n checked={isSelected}\n disabled={!canSelect}\n onCheckedChange={() => toggleRow(id)}\n aria-label={isSelected ? labels.deselectRow : labels.selectRow}\n />\n </span>\n </TableCell>\n )}\n\n {shownColumns.map((column) => {\n const pinnedStyle = pinnedStyles.get(column.id);\n return (\n <TableCell\n key={column.id}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.cellClassName\n )}\n style={{ ...pinnedStyle, width: column.width, minWidth: column.minWidth ?? column.width }}\n >\n {column.cell(row, rowIndex)}\n </TableCell>\n );\n })}\n </TableRow>\n );\n })}\n </TableBody>\n </table>\n </div>\n );\n}\n\nexport interface DataTableColumnToggleProps<TRow> {\n columns: DataTableColumn<TRow>[];\n /** Mã các cột đang hiện — cùng nguồn với `DataTable.visibleColumnIds` (tập hợp, không phải thứ tự) */\n visibleColumnIds: string[];\n onVisibleColumnIdsChange: (visibleColumnIds: string[]) => void;\n /** Nhãn nút + tiêu đề menu; không truyền thì lấy `labels.columnToggle` */\n label?: string;\n /** Ghi đè chuỗi hiển thị (mặc định tiếng Việt) — xem `DataTableLabels` */\n labels?: Partial<DataTableLabels>;\n className?: string;\n}\n\n/**\n * Menu ẩn/hiện cột đi kèm `DataTable` (đặt trên thanh công cụ của trang danh sách).\n *\n * Không cho ẩn hết: luôn giữ lại ít nhất một cột, nếu không bảng thành khối trắng vô nghĩa\n * và người dùng không có đường quay lại.\n */\nexport function DataTableColumnToggle<TRow>({\n columns,\n visibleColumnIds,\n onVisibleColumnIdsChange,\n label,\n labels: labelsProp,\n className\n}: DataTableColumnToggleProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n const text = label ?? labels.columnToggle;\n const hideable = columns.filter((column) => column.hideable !== false);\n\n const toggle = (columnId: string) => {\n // Bật lại một cột thì dựng lại danh sách theo thứ tự `columns` — giữ cho giá trị app lưu\n // luôn cùng một hình dạng, dù `DataTable` đằng nào cũng bỏ qua thứ tự bên trong.\n const next = visibleColumnIds.includes(columnId)\n ? visibleColumnIds.filter((id) => id !== columnId)\n : columns.filter((column) => visibleColumnIds.includes(column.id) || column.id === columnId).map((c) => c.id);\n if (next.length === 0) return;\n onVisibleColumnIdsChange(next);\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant=\"outline\" size=\"sm\" className={className} leftIcon={<SlidersHorizontal className=\"size-4\" />}>\n {text}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-52\">\n <DropdownMenuLabel>{text}</DropdownMenuLabel>\n <DropdownMenuSeparator />\n {hideable.map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n checked={visibleColumnIds.includes(column.id)}\n onCheckedChange={() => toggle(column.id)}\n onSelect={(event) => event.preventDefault()}\n >\n {column.label ?? (typeof column.header === \"string\" ? column.header : column.id)}\n </DropdownMenuCheckboxItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/data-table.tsx"],"sourcesContent":["\"use client\";\n\nimport { ArrowDown, ArrowUp, ChevronsUpDown, Inbox, SlidersHorizontal } from \"lucide-react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Checkbox } from \"../ui/checkbox.js\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger\n} from \"../ui/dropdown-menu.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport { TableBody, TableCell, TableHead, TableHeader, TableRow } from \"../ui/table.js\";\nimport type { DataTableLabels } from \"./data-table-constants.js\";\nimport { EmptyState } from \"./empty-state.js\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface DataTableSort {\n columnId: string;\n direction: SortDirection;\n}\n\nexport type DataTableAlign = \"left\" | \"center\" | \"right\";\n\nexport interface DataTableColumn<TRow> {\n id: string;\n /** Nội dung ô tiêu đề */\n header: ReactNode;\n /** Nhãn chữ dùng trong menu ẩn/hiện cột — bắt buộc khi `header` không phải chuỗi */\n label?: string;\n /** Render ô dữ liệu — nhận cả chỉ số dòng để đánh số thứ tự */\n cell: (row: TRow, rowIndex: number) => ReactNode;\n sortable?: boolean;\n align?: DataTableAlign;\n /**\n * Bề rộng cột tính bằng px. BẮT BUỘC với cột ghim (`pinned`) vì offset `left/right` của\n * sticky được tính cộng dồn từ giá trị này — thiếu width thì các cột ghim chồng lên nhau.\n */\n width?: number;\n minWidth?: number;\n /** Ghim cột khi cuộn ngang (bảng nhiều cột) */\n pinned?: \"left\" | \"right\";\n /** Cho phép người dùng ẩn cột này (mặc định true; cột định danh nên đặt false) */\n hideable?: boolean;\n headerClassName?: string;\n cellClassName?: string;\n}\n\nexport interface DataTableProps<TRow> {\n columns: DataTableColumn<TRow>[];\n rows: TRow[];\n /** Khoá ổn định của dòng — dùng cho `key` React và cho tập chọn */\n getRowId: (row: TRow, index: number) => string;\n\n /** Đang tải → hiện `loadingRowCount` dòng khung xương thay vì bảng trống */\n loading?: boolean;\n loadingRowCount?: number;\n /** Nội dung khi không có dòng nào (mặc định EmptyState theo `labels.emptyTitle`) */\n empty?: ReactNode;\n\n /** Sắp xếp — controlled; `null` = không sắp xếp. App tự sắp/tự gọi API. */\n sort?: DataTableSort | null;\n onSortChange?: (sort: DataTableSort | null) => void;\n\n /** Chọn dòng bằng checkbox — controlled */\n selectable?: boolean;\n selectedIds?: string[];\n onSelectedIdsChange?: (ids: string[]) => void;\n /** Khoá chọn với một số dòng (vd bản ghi đã khoá) */\n isRowSelectable?: (row: TRow, index: number) => boolean;\n\n /**\n * Mã các cột đang hiện — controlled. Bỏ trống = hiện tất cả. Đổi bố cục đi qua\n * `DataTableColumnToggle`, app tự lưu theo người dùng (server, localStorage…).\n *\n * ⚠ Đây là một **TẬP HỢP**, không phải thứ tự: thứ tự cột luôn lấy từ mảng `columns`, thứ tự\n * bên trong mảng này bị bỏ qua. Tên có hậu tố `Ids` chính là để nói đúng thứ nó chứa —\n * 0.1.0 đặt `visibleColumns` khiến người đọc tưởng là mảng cột (`DataTableColumn[]`) hoặc\n * tưởng sắp lại mảng thì cột đổi chỗ.\n *\n * Cố ý KHÔNG dùng `Record<string, boolean>`: kiểu đó có trạng thái thứ ba — **khoá vắng\n * mặt**. App thêm cột mới ở bản sau thì bố cục người dùng đã lưu không có khoá đó, mỗi nơi\n * tự đoán hiện/ẩn → lỗi im lặng. Một mảng mã cột thì `JSON.stringify` là lưu được.\n *\n * Package cố ý KHÔNG tự ghi localStorage: ở SaaS nhiều workspace, bố cục bảng phải theo\n * cặp (người dùng × workspace) và thường cần đồng bộ giữa các máy — chuyện của app.\n */\n visibleColumnIds?: string[];\n\n /**\n * Bấm vào dòng (vd mở chi tiết bản ghi). Có prop này thì dòng vào chuỗi Tab và nhận\n * Enter/Space — mở chi tiết KHÔNG được là thao tác chỉ-chuột.\n */\n onRowClick?: (row: TRow, index: number) => void;\n rowClassName?: (row: TRow, index: number) => string | undefined;\n /**\n * Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bên).\n *\n * Đánh dấu bằng hai vạch 2px màu `--app-accent-ink` ở mép trên và dưới dòng, vẽ bằng bóng INSET\n * trên TỪNG Ô — không bằng nền, và không bằng `outline` của `<tr>`. Lý do đầy đủ ở chỗ áp class.\n * Tóm tắt: nền có alpha làm ô cột GHIM trong mờ; nền class trần thua sọc của `striped` về độ đặc\n * tả nên mất hẳn ở hàng chẵn; `outline` của `<tr>` bị ô cột ghim (`z-index: 2`) đè cụt. Bóng\n * inset không dính cả ba, và không cộng vào chiều cao dòng.\n *\n * ⚠ Vì thế app KHÔNG cần tự vá bằng `rowClassName` nữa — dùng thẳng prop này.\n */\n isRowActive?: (row: TRow, index: number) => boolean;\n\n /** Tiêu đề dính đỉnh vùng cuộn (mặc định true) */\n stickyHeader?: boolean;\n /**\n * Tô nền xen kẽ cho hàng chẵn — mẫu DataTable ở mục **Patterns** của comitor-ds dùng\n * `<Table striped>`, còn `DataTable` trước đây không có đường bật.\n *\n * Mặc định `false`: bảng dài và dày cột thì sọc giúp dò hàng, nhưng bảng ngắn thì nó chỉ thêm\n * nhiễu. Đây là quyết định của từng trang, không phải mặc định của gói.\n *\n * ⚠ Sọc PHẢI là màu ĐỤC (`--table-row-stripe`), không phải `bg-muted/30` như bản trước.\n *\n * Vì sao: sọc khai bằng biến thể tuỳ ý ở cấp `<table>` nên chọn tử sinh ra là\n * `.[…] tbody tr:nth-child(even)` — độ đặc tả (0,2,2), trong khi `bg-card` mà `<TableRow>` tự\n * mang chỉ là một class (0,1,0). Sọc THẮNG và THAY THẾ `bg-card` chứ không chồng lên nó, nên\n * hàng chẵn còn alpha 0,3 mà không có lớp đục nào bên dưới. Ô cột ghim lấy `bg-inherit` từ hàng\n * → cũng trong mờ → cuộn ngang là chữ của các cột bên dưới lộ xuyên qua cột ghim. Đây đúng là\n * lỗi người dùng nhìn thấy, và nó chỉ xuất hiện ở hàng CHẴN nên rất dễ tưởng là \"hàng đầu ổn\".\n *\n * (Chú thích cũ ở đây giải thích rằng \"nền trạng thái luôn thắng vì ghi sau trong chuỗi lớp\" —\n * SAI, và đó chính là lý do lỗi trên lọt qua: thứ tự trong chuỗi class không quyết định gì, độ\n * đặc tả mới quyết định. Nền của hàng ĐANG MỞ `bg-app-accent/5` là (0,1,0) nên nó cũng thua\n * sọc — nhưng hàng đang mở vốn được đánh dấu bằng `outline`, không bằng nền, nên chỗ đó không\n * lộ ra. Đừng lấy nền làm dấu hiệu hàng ở bảng có sọc.)\n */\n striped?: boolean;\n /** Bề rộng tối thiểu của bảng — bật cuộn ngang khi màn hình hẹp */\n minWidth?: number | string;\n /**\n * Ghi đè mật độ cho RIÊNG bảng này, bất kể trục mật độ toàn app đang ở mức nào.\n *\n * Bỏ trống = theo `<html data-density>`. Đặt `\"compact\"` thì bảng tự đặt lại `--row-py` và\n * `--table-head-h` ngay trên thẻ `<table>`, nên tiêu đề và thân đi cùng nhau — bản trước chỉ\n * gắn class vào ô THÂN nên `compact` cho hàng tiêu đề 40px đứng trên hàng thân ~26px.\n */\n density?: \"comfortable\" | \"compact\";\n /**\n * Ghi đè từng khoá chuỗi hiển thị/a11y — xem `DataTableLabels`. Bỏ trống thì theo\n * `ComitorLocaleProvider` (tiếng Anh khi không có provider — từ `3.0.0`; trước đó là tiếng Việt).\n */\n labels?: Partial<DataTableLabels>;\n /** Nhãn của bảng cho trình đọc màn hình (bắt buộc khi trang có nhiều bảng) */\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n className?: string;\n /** className của khung cuộn bọc ngoài */\n containerClassName?: string;\n}\n\nconst alignClasses: Record<DataTableAlign, string> = {\n left: \"text-left\",\n center: \"text-center\",\n right: \"text-right\"\n};\n\n/** Offset sticky cộng dồn cho cột ghim trái/phải. */\nfunction getPinnedOffsets<TRow>(columns: DataTableColumn<TRow>[], selectable: boolean): Map<string, CSSProperties> {\n const styles = new Map<string, CSSProperties>();\n // Cột checkbox (nếu có) luôn ghim trái và chiếm 44px — cột ghim đầu tiên phải bắt đầu sau nó.\n let left = selectable ? 44 : 0;\n for (const column of columns) {\n if (column.pinned !== \"left\") continue;\n styles.set(column.id, { position: \"sticky\", left, zIndex: 2 });\n left += column.width ?? 0;\n }\n\n let right = 0;\n for (const column of [...columns].reverse()) {\n if (column.pinned !== \"right\") continue;\n styles.set(column.id, { position: \"sticky\", right, zIndex: 2 });\n right += column.width ?? 0;\n }\n\n return styles;\n}\n\nfunction nextSort(current: DataTableSort | null | undefined, columnId: string): DataTableSort | null {\n // asc → desc → bỏ sắp xếp. Vòng 3 trạng thái để người dùng quay lại thứ tự gốc của server\n // mà không phải tải lại trang.\n if (!current || current.columnId !== columnId) return { columnId, direction: \"asc\" };\n if (current.direction === \"asc\") return { columnId, direction: \"desc\" };\n return null;\n}\n\n/**\n * Bảng dữ liệu chuẩn của Comitor: tiêu đề dính, sắp xếp, chọn dòng, ghim cột, ẩn/hiện cột,\n * trạng thái tải và trạng thái rỗng.\n *\n * Mọi trạng thái đều **controlled** — package không giữ dữ liệu, không gọi API, không tự phân\n * trang. Nhờ vậy dùng được cả với dữ liệu client (mảng trong bộ nhớ) lẫn server-side\n * pagination mà không phải viết hai component.\n */\nexport function DataTable<TRow>({\n columns,\n rows,\n getRowId,\n loading = false,\n loadingRowCount = 5,\n empty,\n sort,\n onSortChange,\n selectable = false,\n selectedIds,\n onSelectedIdsChange,\n isRowSelectable,\n visibleColumnIds,\n onRowClick,\n rowClassName,\n isRowActive,\n stickyHeader = true,\n striped = false,\n minWidth,\n density = \"comfortable\",\n labels: labelsProp,\n className,\n containerClassName,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledby\n}: DataTableProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n // Thứ tự luôn theo `columns` — `visibleColumnIds` chỉ trả lời \"cột nào được hiện\".\n const shownColumns = visibleColumnIds ? columns.filter((column) => visibleColumnIds.includes(column.id)) : columns;\n const pinnedStyles = getPinnedOffsets(shownColumns, selectable);\n const colCount = shownColumns.length + (selectable ? 1 : 0);\n\n const selected = selectedIds ?? [];\n // Một lượt duyệt: vừa lọc dòng chọn được vừa lấy id — tránh indexOf() lồng trong map (O(n²))\n // và tránh sai khi hai dòng cùng tham chiếu object.\n const selectableIds: string[] = [];\n rows.forEach((row, index) => {\n if (isRowSelectable?.(row, index) ?? true) selectableIds.push(getRowId(row, index));\n });\n const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selected.includes(id));\n const someSelected = !allSelected && selectableIds.some((id) => selected.includes(id));\n\n const toggleAll = () => {\n if (!onSelectedIdsChange) return;\n if (allSelected) {\n onSelectedIdsChange(selected.filter((id) => !selectableIds.includes(id)));\n return;\n }\n onSelectedIdsChange([...new Set([...selected, ...selectableIds])]);\n };\n\n const toggleRow = (id: string) => {\n if (!onSelectedIdsChange) return;\n onSelectedIdsChange(selected.includes(id) ? selected.filter((item) => item !== id) : [...selected, id]);\n };\n\n /*\n * `density` nay là GHI ĐÈ CỤC BỘ của trục mật độ toàn cục, không phải một bảng lớp riêng.\n *\n * Bản trước là `density === \"compact\" ? \"px-3 py-1.5\" : \"px-4 py-3\"` gắn tay vào bốn ô THÂN —\n * ba khuyết tật cùng lúc: (1) nó không bao giờ tới `<TableHead>`, nên `compact` cho hàng tiêu\n * đề 40px đứng trên hàng thân ~26px; (2) nó trộn hai trục, co cả chiều NGANG (px-4 → px-3)\n * trong khi \"gọn\" chỉ nên là chuyện chiều dọc; (3) bảng nào dùng `<Table>` trần thì không có\n * mật độ nào cả.\n *\n * Đặt biến ở chính thẻ `<table>` thì cả ba biến mất: `TableHead`/`TableCell` đã đọc\n * `--table-head-h`/`--row-py` nên tiêu đề và thân đi cùng nhau, và một bảng vẫn gọn được ngay\n * cả khi toàn app đang ở mức thoải mái (hoặc ngược lại).\n */\n const densityVars =\n density === \"compact\" ? ({ \"--row-py\": \"0.375rem\", \"--table-head-h\": \"2rem\" } as CSSProperties) : undefined;\n\n return (\n <div\n data-slot=\"data-table-container\"\n className={cn(\"relative w-full overflow-auto rounded-xl border border-border bg-card\", containerClassName)}\n >\n <table\n data-slot=\"data-table\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n aria-busy={loading || undefined}\n className={cn(\n \"w-full caption-bottom text-sm\",\n // Cùng một chuỗi lớp với `<Table striped>` của Tier 1 — đổi một chỗ thì đổi cả hai.\n striped &&\n \"[&_tbody_tr:nth-child(even)]:bg-table-row-stripe [&_tbody_tr:nth-child(even):hover]:bg-table-row-hover\",\n className\n )}\n style={\n minWidth != null || densityVars ? { ...densityVars, ...(minWidth != null ? { minWidth } : null) } : undefined\n }\n >\n <TableHeader className={cn(stickyHeader && \"sticky top-0 z-10\")}>\n {/* Nền ĐẶC đặt trên <tr>, không phải <thead>: ô ghim dùng `bg-inherit` mà\n background-color kế thừa từ CHA TRỰC TIẾP (<tr>). Để nền ở <thead> thì <tr> vẫn\n trong suốt → cột ghim nhìn xuyên thấy dữ liệu cuộn phía dưới. */}\n <TableRow className=\"bg-muted hover:bg-muted\">\n {selectable && (\n <TableHead\n className=\"w-11 bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 3, width: 44, minWidth: 44 }}\n >\n <Checkbox\n checked={allSelected ? true : someSelected ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={selectableIds.length === 0}\n aria-label={allSelected ? labels.deselectAllRows : labels.selectAllRows}\n />\n </TableHead>\n )}\n\n {shownColumns.map((column) => {\n const isSorted = sort?.columnId === column.id;\n const SortIcon = isSorted ? (sort?.direction === \"asc\" ? ArrowUp : ArrowDown) : ChevronsUpDown;\n const pinnedStyle = pinnedStyles.get(column.id);\n\n return (\n <TableHead\n key={column.id}\n scope=\"col\"\n aria-sort={isSorted ? (sort?.direction === \"asc\" ? \"ascending\" : \"descending\") : undefined}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.headerClassName\n )}\n style={{\n ...pinnedStyle,\n width: column.width,\n minWidth: column.minWidth ?? column.width\n }}\n >\n {column.sortable && onSortChange ? (\n <button\n type=\"button\"\n onClick={() => onSortChange(nextSort(sort, column.id))}\n className={cn(\n \"-mx-1 inline-flex items-center gap-1 rounded-sm px-1 py-0.5 uppercase transition-colors hover:text-foreground\",\n column.align === \"right\" && \"flex-row-reverse\",\n isSorted && \"text-foreground\"\n )}\n >\n {column.header}\n {/* KHÔNG làm mờ icon chưa sắp xếp: nút này còn bấm được nên `opacity-*`\n không được miễn trừ ngưỡng 3:1 của WCAG 1.4.11 cho icon mang nghĩa.\n `opacity-50` cho ra 1,98:1 (light) / 2,23:1 (dark) trên nền `bg-muted`\n của hàng tiêu đề ✗; để nguyên `text-muted-foreground` là 4,89 / 4,86:1 ✓.\n Trạng thái ĐANG sắp xếp vẫn phân biệt được: nút đổi sang\n `text-foreground` và icon đổi hình (ArrowUp/ArrowDown ⇄ ChevronsUpDown). */}\n <SortIcon aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n ) : (\n column.header\n )}\n </TableHead>\n );\n })}\n </TableRow>\n </TableHeader>\n\n <TableBody>\n {loading &&\n Array.from({ length: loadingRowCount }).map((_, rowIndex) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: dòng khung xương không có danh tính dữ liệu\n <TableRow key={`skeleton-${rowIndex}`} className=\"hover:bg-transparent\">\n {selectable && (\n <TableCell>\n <Skeleton className=\"size-4 rounded-sm\" />\n </TableCell>\n )}\n {shownColumns.map((column) => (\n <TableCell key={column.id}>\n <Skeleton className=\"h-4 w-full max-w-[12rem]\" />\n </TableCell>\n ))}\n </TableRow>\n ))}\n\n {!loading && rows.length === 0 && (\n <TableRow className=\"hover:bg-transparent\">\n <TableCell colSpan={colCount} className=\"p-0\">\n {empty ?? <EmptyState icon={Inbox} title={labels.emptyTitle} description={labels.emptyDescription} />}\n </TableCell>\n </TableRow>\n )}\n\n {!loading &&\n rows.map((row, rowIndex) => {\n const id = getRowId(row, rowIndex);\n const isSelected = selected.includes(id);\n const canSelect = isRowSelectable?.(row, rowIndex) ?? true;\n const active = isRowActive?.(row, rowIndex) ?? false;\n\n return (\n <TableRow\n key={id}\n data-state={isSelected ? \"selected\" : undefined}\n aria-selected={selectable ? isSelected : undefined}\n onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}\n // Dòng bấm được phải dùng được bằng BÀN PHÍM: đưa <tr> vào chuỗi Tab và tự\n // xử lý Enter/Space. Cố ý KHÔNG đặt role=\"button\" — role đó đè role=\"row\"\n // ngầm, trình đọc màn hình sẽ mất cấu trúc bảng (số dòng/cột, tiêu đề cột);\n // dòng vẫn là dòng, chỉ thêm khả năng kích hoạt.\n tabIndex={onRowClick ? 0 : undefined}\n onKeyDown={\n onRowClick\n ? (event) => {\n // Chỉ nhận phím khi CHÍNH <tr> đang focus: Enter/Space bấm trên\n // control bên trong ô (checkbox, nút, link) đã có nghĩa riêng —\n // nổi bọt lên đây sẽ mở chi tiết ngoài ý muốn.\n if (event.target !== event.currentTarget) return;\n if (event.key !== \"Enter\" && event.key !== \" \") return;\n // Space mặc định cuộn trang, Enter có thể submit form bao ngoài.\n event.preventDefault();\n onRowClick(row, rowIndex);\n }\n : undefined\n }\n className={cn(\n // bg-card trên <tr> để ô ghim (`bg-inherit`) có màu đặc mà vẫn đổi theo\n // trạng thái chọn/hover của chính dòng đó.\n \"bg-card\",\n // Viền focus vẽ bằng `outline` chứ không `ring`: <tr> nằm trong bảng\n // border-collapse, box-shadow không được vẽ. Offset ÂM để viền nằm trong\n // lòng dòng, không bị khung cuộn cắt mất ở mép trái/phải.\n // Màu dùng `foreground` chứ không `ring` (gold #E8B824 chỉ ~1,9:1 trên nền\n // card — dưới 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản).\n onRowClick &&\n \"cursor-pointer focus-visible:outline-2 focus-visible:outline-foreground focus-visible:[outline-offset:-2px]\",\n /*\n * Dòng ĐANG MỞ đánh dấu bằng bóng INSET trên từng ô, KHÔNG bằng nền — hai lý\n * do, cả hai đều là lỗi thật của bản trước (`bg-app-accent/5`):\n *\n * 1. Lớp nền đó CÓ ALPHA. Ô cột ghim lấy nền bằng `bg-inherit` từ hàng, nên\n * nó thừa hưởng luôn alpha và trở nên trong mờ — cuộn ngang là chữ của\n * các cột bên dưới lộ xuyên qua cột ghim.\n * 2. Nó là một class trần, độ đặc tả (0,1,0). Sọc của `striped` khai ở cấp\n * `<table>` nên chọn tử ra (0,2,2) và THẮNG — tức ở bảng có sọc, dòng\n * đang mở không hiện gì ở hàng CHẴN.\n *\n * Và vì sao vẽ trên TỪNG Ô (`[&>td]`) chứ không phải `outline` trên `<tr>`:\n * ô cột ghim mang `z-index: 2` nên nó nằm ở lớp cao hơn và ĐÈ LÊN viền của\n * hàng — dấu bị cắt cụt đúng ở vùng cột ghim, kể cả khi chưa cuộn ngang. Đã\n * thử và chụp lại. Bóng INSET vẽ trong lòng từng ô, tức vẽ CÙNG lớp với ô đó,\n * nên cột ghim cũng có dấu; và vì là bóng chứ không phải viền nên nó không\n * cộng vào chiều cao — mở một dòng không làm bảng nhảy 4px.\n *\n * Không đụng nhóm `bg-*` nên miễn nhiễm cả hai lỗi trên: cột ghim giữ nền\n * đục, và sọc không có gì để tranh.\n *\n * Màu `--app-accent-ink` (bậc đi lên nền trang): 6,14:1 trên `--card` ở light\n * · 9,61:1 ở dark — trên ngưỡng 3:1 của WCAG 1.4.11. KHÔNG dùng\n * `--app-accent` trần: gold #E8B824 chỉ 1,86:1, đúng cái bẫy \"token tô NỀN\n * đem vẽ DẤU\".\n *\n * ⚠ Trong giá trị tuỳ ý phải viết `var(--app-accent-ink)` — TOKEN THÔ — chứ\n * KHÔNG phải `var(--color-app-accent-ink)`. `@theme inline` khai\n * `--color-app-accent-ink: var(--app-accent-ink)`, nghĩa là utility sinh ra\n * nhúng thẳng `var(--app-accent-ink)` nên nó KẾ THỪA được accent mà app đặt\n * trên một thẻ bọc; còn biến `--color-*` thì vẫn nằm ở `:root` với giá trị đã\n * hoá giải theo gốc. Đo tại chính ô: `--app-accent-ink` = #1A6668 (teal của\n * app) nhưng `--color-app-accent-ink` = #7A5800 (gold mặc định) — viết nhầm\n * là dấu ra sai màu app mà không có gì báo.\n */\n active &&\n \"[&>td]:shadow-[inset_0_2px_0_var(--app-accent-ink),inset_0_-2px_0_var(--app-accent-ink)]\",\n rowClassName?.(row, rowIndex)\n )}\n >\n {selectable && (\n <TableCell\n className=\"bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 1, width: 44, minWidth: 44 }}\n >\n {/* Chặn nổi bọt: bấm checkbox không được kích hoạt onRowClick (mở chi tiết) */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: lớp bọc chỉ chặn sự kiện, tương tác nằm ở Checkbox bên trong */}\n {/* biome-ignore lint/a11y/useKeyWithClickEvents: phím bấm đã do Checkbox xử lý */}\n <span onClick={(event) => event.stopPropagation()}>\n <Checkbox\n checked={isSelected}\n disabled={!canSelect}\n onCheckedChange={() => toggleRow(id)}\n aria-label={isSelected ? labels.deselectRow : labels.selectRow}\n />\n </span>\n </TableCell>\n )}\n\n {shownColumns.map((column) => {\n const pinnedStyle = pinnedStyles.get(column.id);\n return (\n <TableCell\n key={column.id}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.cellClassName\n )}\n style={{ ...pinnedStyle, width: column.width, minWidth: column.minWidth ?? column.width }}\n >\n {column.cell(row, rowIndex)}\n </TableCell>\n );\n })}\n </TableRow>\n );\n })}\n </TableBody>\n </table>\n </div>\n );\n}\n\nexport interface DataTableColumnToggleProps<TRow> {\n columns: DataTableColumn<TRow>[];\n /** Mã các cột đang hiện — cùng nguồn với `DataTable.visibleColumnIds` (tập hợp, không phải thứ tự) */\n visibleColumnIds: string[];\n onVisibleColumnIdsChange: (visibleColumnIds: string[]) => void;\n /** Nhãn nút + tiêu đề menu; không truyền thì lấy `labels.columnToggle` */\n label?: string;\n /** Ghi đè từng khoá chuỗi hiển thị — xem `DataTableLabels`. Bỏ trống thì theo `ComitorLocaleProvider`. */\n labels?: Partial<DataTableLabels>;\n className?: string;\n}\n\n/**\n * Menu ẩn/hiện cột đi kèm `DataTable` (đặt trên thanh công cụ của trang danh sách).\n *\n * Không cho ẩn hết: luôn giữ lại ít nhất một cột, nếu không bảng thành khối trắng vô nghĩa\n * và người dùng không có đường quay lại.\n */\nexport function DataTableColumnToggle<TRow>({\n columns,\n visibleColumnIds,\n onVisibleColumnIdsChange,\n label,\n labels: labelsProp,\n className\n}: DataTableColumnToggleProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n const text = label ?? labels.columnToggle;\n const hideable = columns.filter((column) => column.hideable !== false);\n\n const toggle = (columnId: string) => {\n // Bật lại một cột thì dựng lại danh sách theo thứ tự `columns` — giữ cho giá trị app lưu\n // luôn cùng một hình dạng, dù `DataTable` đằng nào cũng bỏ qua thứ tự bên trong.\n const next = visibleColumnIds.includes(columnId)\n ? visibleColumnIds.filter((id) => id !== columnId)\n : columns.filter((column) => visibleColumnIds.includes(column.id) || column.id === columnId).map((c) => c.id);\n if (next.length === 0) return;\n onVisibleColumnIdsChange(next);\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant=\"outline\" size=\"sm\" className={className} leftIcon={<SlidersHorizontal className=\"size-4\" />}>\n {text}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-52\">\n <DropdownMenuLabel>{text}</DropdownMenuLabel>\n <DropdownMenuSeparator />\n {hideable.map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n checked={visibleColumnIds.includes(column.id)}\n onCheckedChange={() => toggle(column.id)}\n onSelect={(event) => event.preventDefault()}\n >\n {column.label ?? (typeof column.header === \"string\" ? column.header : column.id)}\n </DropdownMenuCheckboxItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n"],"mappings":"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","names":[]}
@@ -29,7 +29,7 @@ interface FilterChipsProps<T extends string = string> {
29
29
  className?: string;
30
30
  }
31
31
  /**
32
- * Dãy chip lọc bật/tắt kèm số đếm — kiểu Linear/Lark, dùng cho trạng thái, nhãn, mức ưu tiên.
32
+ * Dãy chip lọc bật/tắt kèm số đếm — dùng cho trạng thái, nhãn, mức ưu tiên.
33
33
  *
34
34
  * Dùng `aria-pressed` trên `<button>` thay vì `role="tab"`: chip lọc không đổi vùng nội dung
35
35
  * theo kiểu tab, nó bật/tắt điều kiện lọc. Đặt nhầm role sẽ khiến trình đọc màn hình hứa hẹn
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/filter-chips.tsx"],"sourcesContent":["\"use client\";\n\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport type { StatusTone } from \"./status-config.js\";\n\nexport interface FilterChipItem<T extends string = string> {\n value: T;\n label: string;\n /** Số bản ghi thuộc nhóm này — hiện đậm trước nhãn */\n count?: number;\n icon?: IconComponent;\n /** Tone khi chip đang bật (mặc định tone app-accent) */\n tone?: StatusTone;\n disabled?: boolean;\n}\n\nexport interface FilterChipsProps<T extends string = string> {\n items: FilterChipItem<T>[];\n /** Giá trị đang chọn — mảng (đa chọn) hoặc một giá trị/null (đơn chọn) */\n value: T[] | T | null;\n onValueChange: (value: T[] | T | null) => void;\n /** Cho chọn nhiều chip cùng lúc (mặc định false) */\n multiple?: boolean;\n /** Thêm chip \"tất cả\" ở đầu để bỏ mọi lựa chọn */\n showAll?: boolean;\n /** Chữ của chip \"tất cả\" (khoá `allLabel` của namespace `filterChips`, mặc định tiếng Anh) */\n allLabel?: string;\n allCount?: number;\n /** Nhãn nhóm cho trình đọc màn hình (khoá `label` của namespace `filterChips`) */\n label?: string;\n className?: string;\n}\n\nfunction toArray<T extends string>(value: T[] | T | null): T[] {\n if (value == null) return [];\n return Array.isArray(value) ? value : [value];\n}\n\n/**\n * Dãy chip lọc bật/tắt kèm số đếm — kiểu Linear/Lark, dùng cho trạng thái, nhãn, mức ưu tiên.\n *\n * Dùng `aria-pressed` trên `<button>` thay vì `role=\"tab\"`: chip lọc không đổi vùng nội dung\n * theo kiểu tab, nó bật/tắt điều kiện lọc. Đặt nhầm role sẽ khiến trình đọc màn hình hứa hẹn\n * một panel không tồn tại.\n */\nexport function FilterChips<T extends string = string>({\n items,\n value,\n onValueChange,\n multiple = false,\n showAll = false,\n allLabel: allLabelProp,\n allCount,\n label: labelProp,\n className\n}: FilterChipsProps<T>) {\n const t = useComitorMessages(\"filterChips\");\n const allLabel = allLabelProp ?? t.allLabel;\n const label = labelProp ?? t.label;\n const selected = toArray(value);\n const noneSelected = selected.length === 0;\n\n const toggle = (item: FilterChipItem<T>) => {\n if (item.disabled) return;\n if (!multiple) {\n onValueChange(selected.includes(item.value) ? null : item.value);\n return;\n }\n onValueChange(\n selected.includes(item.value) ? selected.filter((current) => current !== item.value) : [...selected, item.value]\n );\n };\n\n /*\n * Chip lọc là CONTROL (nút bật/tắt có `aria-pressed`), nên ranh giới của nó chịu ngưỡng 3:1 của\n * WCAG 1.4.11 — và ở đây VIỀN là thứ duy nhất khoanh vùng được: ruột `bg-card` trùng đúng nền\n * trang ở light (#FFFFFF ⇄ #FFFFFF) và chỉ lệch 1,10:1 ở dark.\n *\n * chip TẮT `border-border` (bậc TRANG TRÍ) 1,16:1 (L) · 1,10:1 (D) ✗\n * → `border-control-edge` 3,83:1 (L) · 4,64:1 (D) ✓\n * (vẫn 3,50:1 · 4,21:1 khi hover đổi ruột sang `bg-accent`)\n * chip BẬT + tone `border-transparent` để biên rơi về chính tint: 1,07–1,24:1 (L) ·\n * 1,11–1,56:1 (D) ✗ — tệ hơn: chip ĐANG BẬT trông mờ hơn chip đang tắt.\n * → `tone.borderColor` (bậc ink) 5,23–7,15:1 (L) · 5,70–12,67:1 (D) ✓\n * chip BẬT mặc định ruột `bg-app-accent` ĐẶC vẫn chỉ 1,86:1 với nền trắng (gold trên trắng —\n * đúng cái bẫy của hợp đồng §5), nên nền đặc KHÔNG tự khoanh được vùng ở light.\n * → thêm `border-app-accent-ink` 6,51:1 (L) · 12,67:1 (D) so với nền trang.\n * Ở dark `--app-accent-ink` = chính `--app-accent` nên viền trùng ruột, hình\n * không đổi — đúng ý, vì ở dark ruột đã tự nổi 12,67:1.\n *\n * `StatusTone.borderColor` là trường TUỲ CHỌN nên tone do app truyền vào có thể thiếu → lui về\n * `border-control-edge`, tuyệt đối không lui về `border-transparent`.\n *\n * `opacity-50` ở đây KHÔNG cần đạt ngưỡng: `disabled` là thuộc tính thật của <button>, chip tắt\n * rời khỏi tab order và không gọi handler (`pointer-events-none`) — WCAG loại trừ control vô\n * hiệu. Đây là chỗ khác hẳn ô app \"khoá\" của AppLauncher hay mục sidebar còn `tabIndex=0`.\n */\n const chipClass = (active: boolean, tone?: StatusTone) =>\n cn(\n \"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm font-medium transition-colors duration-150 ease-out\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n active\n ? tone\n ? cn(tone.borderColor ?? \"border-control-edge\", tone.bgColor, tone.textColor)\n : // Tô ĐẶC chứ không phải nền vàng 15%: chữ gold trên nền gold nhạt chỉ đạt 1,75:1,\n // dưới chuẩn AA. Nền đặc + `app-accent-foreground` (mực) đọc được ở cả hai theme\n // (10,19:1 L · 12,67:1 D), còn viền lo phần ranh giới.\n \"border-app-accent-ink bg-app-accent text-app-accent-foreground\"\n : \"border-control-edge bg-card text-muted-foreground hover:bg-accent hover:text-accent-foreground\"\n );\n\n return (\n <div data-slot=\"filter-chips\" role=\"group\" aria-label={label} className={cn(\"flex flex-wrap gap-2\", className)}>\n {showAll && (\n <button\n type=\"button\"\n aria-pressed={noneSelected}\n onClick={() => onValueChange(multiple ? [] : null)}\n className={chipClass(noneSelected)}\n >\n {allCount !== undefined && <span className=\"font-semibold tabular-nums\">{allCount}</span>}\n {allLabel}\n </button>\n )}\n\n {items.map((item) => {\n const active = selected.includes(item.value);\n const Icon = item.icon;\n return (\n <button\n key={item.value}\n type=\"button\"\n aria-pressed={active}\n disabled={item.disabled}\n onClick={() => toggle(item)}\n className={chipClass(active, item.tone)}\n >\n {Icon && <Icon aria-hidden=\"true\" className=\"size-3.5\" />}\n {item.count !== undefined && <span className=\"font-semibold tabular-nums\">{item.count}</span>}\n {item.label}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AAoHQ,SAM6B,KAN7B;AAlHR,SAAS,0BAA0B;AACnC,SAAS,UAAU;AAgCnB,SAAS,QAA0B,OAA4B;AAC7D,MAAI,SAAS,KAAM,QAAO,CAAC;AAC3B,SAAO,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK;AAC9C;AASO,SAAS,YAAuC;AAAA,EACrD;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,UAAU;AAAA,EACV,UAAU;AAAA,EACV;AAAA,EACA,OAAO;AAAA,EACP;AACF,GAAwB;AACtB,QAAM,IAAI,mBAAmB,aAAa;AAC1C,QAAM,WAAW,gBAAgB,EAAE;AACnC,QAAM,QAAQ,aAAa,EAAE;AAC7B,QAAM,WAAW,QAAQ,KAAK;AAC9B,QAAM,eAAe,SAAS,WAAW;AAEzC,QAAM,SAAS,CAAC,SAA4B;AAC1C,QAAI,KAAK,SAAU;AACnB,QAAI,CAAC,UAAU;AACb,oBAAc,SAAS,SAAS,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK;AAC/D;AAAA,IACF;AACA;AAAA,MACE,SAAS,SAAS,KAAK,KAAK,IAAI,SAAS,OAAO,CAAC,YAAY,YAAY,KAAK,KAAK,IAAI,CAAC,GAAG,UAAU,KAAK,KAAK;AAAA,IACjH;AAAA,EACF;AA0BA,QAAM,YAAY,CAAC,QAAiB,SAClC;AAAA,IACE;AAAA,IACA;AAAA,IACA,SACI,OACE,GAAG,KAAK,eAAe,uBAAuB,KAAK,SAAS,KAAK,SAAS;AAAA;AAAA;AAAA;AAAA,MAI1E;AAAA,QACF;AAAA,EACN;AAEF,SACE,qBAAC,SAAI,aAAU,gBAAe,MAAK,SAAQ,cAAY,OAAO,WAAW,GAAG,wBAAwB,SAAS,GAC1G;AAAA,eACC;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,gBAAc;AAAA,QACd,SAAS,MAAM,cAAc,WAAW,CAAC,IAAI,IAAI;AAAA,QACjD,WAAW,UAAU,YAAY;AAAA,QAEhC;AAAA,uBAAa,UAAa,oBAAC,UAAK,WAAU,8BAA8B,oBAAS;AAAA,UACjF;AAAA;AAAA;AAAA,IACH;AAAA,IAGD,MAAM,IAAI,CAAC,SAAS;AACnB,YAAM,SAAS,SAAS,SAAS,KAAK,KAAK;AAC3C,YAAM,OAAO,KAAK;AAClB,aACE;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,gBAAc;AAAA,UACd,UAAU,KAAK;AAAA,UACf,SAAS,MAAM,OAAO,IAAI;AAAA,UAC1B,WAAW,UAAU,QAAQ,KAAK,IAAI;AAAA,UAErC;AAAA,oBAAQ,oBAAC,QAAK,eAAY,QAAO,WAAU,YAAW;AAAA,YACtD,KAAK,UAAU,UAAa,oBAAC,UAAK,WAAU,8BAA8B,eAAK,OAAM;AAAA,YACrF,KAAK;AAAA;AAAA;AAAA,QATD,KAAK;AAAA,MAUZ;AAAA,IAEJ,CAAC;AAAA,KACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/filter-chips.tsx"],"sourcesContent":["\"use client\";\n\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport type { StatusTone } from \"./status-config.js\";\n\nexport interface FilterChipItem<T extends string = string> {\n value: T;\n label: string;\n /** Số bản ghi thuộc nhóm này — hiện đậm trước nhãn */\n count?: number;\n icon?: IconComponent;\n /** Tone khi chip đang bật (mặc định tone app-accent) */\n tone?: StatusTone;\n disabled?: boolean;\n}\n\nexport interface FilterChipsProps<T extends string = string> {\n items: FilterChipItem<T>[];\n /** Giá trị đang chọn — mảng (đa chọn) hoặc một giá trị/null (đơn chọn) */\n value: T[] | T | null;\n onValueChange: (value: T[] | T | null) => void;\n /** Cho chọn nhiều chip cùng lúc (mặc định false) */\n multiple?: boolean;\n /** Thêm chip \"tất cả\" ở đầu để bỏ mọi lựa chọn */\n showAll?: boolean;\n /** Chữ của chip \"tất cả\" (khoá `allLabel` của namespace `filterChips`, mặc định tiếng Anh) */\n allLabel?: string;\n allCount?: number;\n /** Nhãn nhóm cho trình đọc màn hình (khoá `label` của namespace `filterChips`) */\n label?: string;\n className?: string;\n}\n\nfunction toArray<T extends string>(value: T[] | T | null): T[] {\n if (value == null) return [];\n return Array.isArray(value) ? value : [value];\n}\n\n/**\n * Dãy chip lọc bật/tắt kèm số đếm — dùng cho trạng thái, nhãn, mức ưu tiên.\n *\n * Dùng `aria-pressed` trên `<button>` thay vì `role=\"tab\"`: chip lọc không đổi vùng nội dung\n * theo kiểu tab, nó bật/tắt điều kiện lọc. Đặt nhầm role sẽ khiến trình đọc màn hình hứa hẹn\n * một panel không tồn tại.\n */\nexport function FilterChips<T extends string = string>({\n items,\n value,\n onValueChange,\n multiple = false,\n showAll = false,\n allLabel: allLabelProp,\n allCount,\n label: labelProp,\n className\n}: FilterChipsProps<T>) {\n const t = useComitorMessages(\"filterChips\");\n const allLabel = allLabelProp ?? t.allLabel;\n const label = labelProp ?? t.label;\n const selected = toArray(value);\n const noneSelected = selected.length === 0;\n\n const toggle = (item: FilterChipItem<T>) => {\n if (item.disabled) return;\n if (!multiple) {\n onValueChange(selected.includes(item.value) ? null : item.value);\n return;\n }\n onValueChange(\n selected.includes(item.value) ? selected.filter((current) => current !== item.value) : [...selected, item.value]\n );\n };\n\n /*\n * Chip lọc là CONTROL (nút bật/tắt có `aria-pressed`), nên ranh giới của nó chịu ngưỡng 3:1 của\n * WCAG 1.4.11 — và ở đây VIỀN là thứ duy nhất khoanh vùng được: ruột `bg-card` trùng đúng nền\n * trang ở light (#FFFFFF ⇄ #FFFFFF) và chỉ lệch 1,10:1 ở dark.\n *\n * chip TẮT `border-border` (bậc TRANG TRÍ) 1,16:1 (L) · 1,10:1 (D) ✗\n * → `border-control-edge` 3,83:1 (L) · 4,64:1 (D) ✓\n * (vẫn 3,50:1 · 4,21:1 khi hover đổi ruột sang `bg-accent`)\n * chip BẬT + tone `border-transparent` để biên rơi về chính tint: 1,07–1,24:1 (L) ·\n * 1,11–1,56:1 (D) ✗ — tệ hơn: chip ĐANG BẬT trông mờ hơn chip đang tắt.\n * → `tone.borderColor` (bậc ink) 5,23–7,15:1 (L) · 5,70–12,67:1 (D) ✓\n * chip BẬT mặc định ruột `bg-app-accent` ĐẶC vẫn chỉ 1,86:1 với nền trắng (gold trên trắng —\n * đúng cái bẫy của hợp đồng §5), nên nền đặc KHÔNG tự khoanh được vùng ở light.\n * → thêm `border-app-accent-ink` 6,51:1 (L) · 12,67:1 (D) so với nền trang.\n * Ở dark `--app-accent-ink` = chính `--app-accent` nên viền trùng ruột, hình\n * không đổi — đúng ý, vì ở dark ruột đã tự nổi 12,67:1.\n *\n * `StatusTone.borderColor` là trường TUỲ CHỌN nên tone do app truyền vào có thể thiếu → lui về\n * `border-control-edge`, tuyệt đối không lui về `border-transparent`.\n *\n * `opacity-50` ở đây KHÔNG cần đạt ngưỡng: `disabled` là thuộc tính thật của <button>, chip tắt\n * rời khỏi tab order và không gọi handler (`pointer-events-none`) — WCAG loại trừ control vô\n * hiệu. Đây là chỗ khác hẳn ô app \"khoá\" của AppLauncher hay mục sidebar còn `tabIndex=0`.\n */\n const chipClass = (active: boolean, tone?: StatusTone) =>\n cn(\n \"inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-sm font-medium transition-colors duration-150 ease-out\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n active\n ? tone\n ? cn(tone.borderColor ?? \"border-control-edge\", tone.bgColor, tone.textColor)\n : // Tô ĐẶC chứ không phải nền vàng 15%: chữ gold trên nền gold nhạt chỉ đạt 1,75:1,\n // dưới chuẩn AA. Nền đặc + `app-accent-foreground` (mực) đọc được ở cả hai theme\n // (10,19:1 L · 12,67:1 D), còn viền lo phần ranh giới.\n \"border-app-accent-ink bg-app-accent text-app-accent-foreground\"\n : \"border-control-edge bg-card text-muted-foreground hover:bg-accent hover:text-accent-foreground\"\n );\n\n return (\n <div data-slot=\"filter-chips\" role=\"group\" aria-label={label} className={cn(\"flex flex-wrap gap-2\", className)}>\n {showAll && (\n <button\n type=\"button\"\n aria-pressed={noneSelected}\n onClick={() => onValueChange(multiple ? [] : null)}\n className={chipClass(noneSelected)}\n >\n {allCount !== undefined && <span className=\"font-semibold tabular-nums\">{allCount}</span>}\n {allLabel}\n </button>\n )}\n\n {items.map((item) => {\n const active = selected.includes(item.value);\n const Icon = item.icon;\n return (\n <button\n key={item.value}\n type=\"button\"\n aria-pressed={active}\n disabled={item.disabled}\n onClick={() => toggle(item)}\n className={chipClass(active, item.tone)}\n >\n {Icon && <Icon aria-hidden=\"true\" className=\"size-3.5\" />}\n {item.count !== undefined && <span className=\"font-semibold tabular-nums\">{item.count}</span>}\n {item.label}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AAoHQ,SAM6B,KAN7B;AAlHR,SAAS,0BAA0B;AACnC,SAAS,UAAU;AAgCnB,SAAS,QAA0B,OAA4B;AAC7D,MAAI,SAAS,KAAM,QAAO,CAAC;AAC3B,SAAO,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAC,KAAK;AAC9C;AASO,SAAS,YAAuC;AAAA,EACrD;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,UAAU;AAAA,EACV,UAAU;AAAA,EACV;AAAA,EACA,OAAO;AAAA,EACP;AACF,GAAwB;AACtB,QAAM,IAAI,mBAAmB,aAAa;AAC1C,QAAM,WAAW,gBAAgB,EAAE;AACnC,QAAM,QAAQ,aAAa,EAAE;AAC7B,QAAM,WAAW,QAAQ,KAAK;AAC9B,QAAM,eAAe,SAAS,WAAW;AAEzC,QAAM,SAAS,CAAC,SAA4B;AAC1C,QAAI,KAAK,SAAU;AACnB,QAAI,CAAC,UAAU;AACb,oBAAc,SAAS,SAAS,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK;AAC/D;AAAA,IACF;AACA;AAAA,MACE,SAAS,SAAS,KAAK,KAAK,IAAI,SAAS,OAAO,CAAC,YAAY,YAAY,KAAK,KAAK,IAAI,CAAC,GAAG,UAAU,KAAK,KAAK;AAAA,IACjH;AAAA,EACF;AA0BA,QAAM,YAAY,CAAC,QAAiB,SAClC;AAAA,IACE;AAAA,IACA;AAAA,IACA,SACI,OACE,GAAG,KAAK,eAAe,uBAAuB,KAAK,SAAS,KAAK,SAAS;AAAA;AAAA;AAAA;AAAA,MAI1E;AAAA,QACF;AAAA,EACN;AAEF,SACE,qBAAC,SAAI,aAAU,gBAAe,MAAK,SAAQ,cAAY,OAAO,WAAW,GAAG,wBAAwB,SAAS,GAC1G;AAAA,eACC;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,gBAAc;AAAA,QACd,SAAS,MAAM,cAAc,WAAW,CAAC,IAAI,IAAI;AAAA,QACjD,WAAW,UAAU,YAAY;AAAA,QAEhC;AAAA,uBAAa,UAAa,oBAAC,UAAK,WAAU,8BAA8B,oBAAS;AAAA,UACjF;AAAA;AAAA;AAAA,IACH;AAAA,IAGD,MAAM,IAAI,CAAC,SAAS;AACnB,YAAM,SAAS,SAAS,SAAS,KAAK,KAAK;AAC3C,YAAM,OAAO,KAAK;AAClB,aACE;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,gBAAc;AAAA,UACd,UAAU,KAAK;AAAA,UACf,SAAS,MAAM,OAAO,IAAI;AAAA,UAC1B,WAAW,UAAU,QAAQ,KAAK,IAAI;AAAA,UAErC;AAAA,oBAAQ,oBAAC,QAAK,eAAY,QAAO,WAAU,YAAW;AAAA,YACtD,KAAK,UAAU,UAAa,oBAAC,UAAK,WAAU,8BAA8B,eAAK,OAAM;AAAA,YACrF,KAAK;AAAA;AAAA;AAAA,QATD,KAAK;AAAA,MAUZ;AAAA,IAEJ,CAAC;AAAA,KACH;AAEJ;","names":[]}
@@ -19,7 +19,7 @@ interface InlineEditProps {
19
19
  disabled?: boolean;
20
20
  /** Bắt buộc có nội dung: chuỗi rỗng bị coi là huỷ, không lưu (mặc định true) */
21
21
  required?: boolean;
22
- /** Rời ô là lưu (mặc định true, kiểu Linear) */
22
+ /** Rời ô là lưu (mặc định true) */
23
23
  saveOnBlur?: boolean;
24
24
  /**
25
25
  * Hiện cặp nút ✓/✕ khi đang sửa (mặc định false — thao tác bằng phím). Tên sr-only của hai nút
@@ -38,7 +38,7 @@ interface InlineEditProps {
38
38
  displayClassName?: string;
39
39
  }
40
40
  /**
41
- * Sửa tại chỗ kiểu Linear: bấm vào chữ → thành ô nhập → Enter lưu, Esc huỷ.
41
+ * Sửa tại chỗ: bấm vào chữ → thành ô nhập → Enter lưu, Esc huỷ.
42
42
  *
43
43
  * Vì sao không dùng `<input>` luôn cho gọn: ô nhập luôn hiện làm danh sách trông như một cái
44
44
  * form khổng lồ và người dùng dễ sửa nhầm khi chỉ định bấm chọn. Hiển thị dạng chữ cho tới khi
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/inline-edit.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, X } from \"lucide-react\";\nimport { type KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\n\nexport interface InlineEditProps {\n /** Giá trị hiện tại (controlled từ phía dữ liệu) */\n value: string;\n /**\n * Lưu giá trị mới. Trả Promise thì component hiện trạng thái đang lưu và chỉ thoát chế độ\n * sửa khi lưu xong — thoát sớm sẽ nháy về giá trị cũ rồi mới nhảy sang giá trị mới.\n */\n onSave: (value: string) => void | Promise<void>;\n /**\n * Chữ hiện khi giá trị rỗng, kiêm placeholder của ô nhập (khoá `placeholder` của namespace\n * `inlineEdit` — `ComitorLocaleProvider`, mặc định tiếng Anh).\n */\n placeholder?: string;\n /** Ô nhập nhiều dòng (mô tả task) — Enter xuống dòng, Cmd/Ctrl+Enter mới lưu */\n multiline?: boolean;\n maxLength?: number;\n disabled?: boolean;\n /** Bắt buộc có nội dung: chuỗi rỗng bị coi là huỷ, không lưu (mặc định true) */\n required?: boolean;\n /** Rời ô là lưu (mặc định true, kiểu Linear) */\n saveOnBlur?: boolean;\n /**\n * Hiện cặp nút ✓/✕ khi đang sửa (mặc định false — thao tác bằng phím). Tên sr-only của hai nút\n * lấy khoá `save` / `cancel` của namespace `inlineEdit`; không có prop rời — đè qua `messages`\n * của `ComitorLocaleProvider`.\n */\n showActions?: boolean;\n /**\n * Nhãn cho trình đọc màn hình, vd \"Sửa tên công việc\" (không truyền thì lấy khoá `ariaLabel` của\n * namespace `inlineEdit`)\n */\n ariaLabel?: string;\n onError?: (error: unknown) => void;\n className?: string;\n /** className riêng cho phần hiển thị (khi chưa sửa) — vd chỉnh cỡ chữ tiêu đề */\n displayClassName?: string;\n}\n\n/**\n * Sửa tại chỗ kiểu Linear: bấm vào chữ → thành ô nhập → Enter lưu, Esc huỷ.\n *\n * Vì sao không dùng `<input>` luôn cho gọn: ô nhập luôn hiện làm danh sách trông như một cái\n * form khổng lồ và người dùng dễ sửa nhầm khi chỉ định bấm chọn. Hiển thị dạng chữ cho tới khi\n * người dùng thật sự muốn sửa là ranh giới an toàn.\n */\nexport function InlineEdit({\n value,\n onSave,\n placeholder: placeholderProp,\n multiline = false,\n maxLength,\n disabled = false,\n required = true,\n saveOnBlur = true,\n showActions = false,\n ariaLabel: ariaLabelProp,\n onError,\n className,\n displayClassName\n}: InlineEditProps) {\n const t = useComitorMessages(\"inlineEdit\");\n const placeholder = placeholderProp ?? t.placeholder;\n const ariaLabel = ariaLabelProp ?? t.ariaLabel;\n const [editing, setEditing] = useState(false);\n const [draft, setDraft] = useState(value);\n const [saving, setSaving] = useState(false);\n const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);\n\n // Giá trị đổi từ ngoài (người khác sửa qua realtime) — chỉ đồng bộ khi không sửa dở.\n useEffect(() => {\n if (!editing) setDraft(value);\n }, [value, editing]);\n\n useEffect(() => {\n if (!editing) return;\n const element = inputRef.current;\n if (!element) return;\n element.focus();\n element.setSelectionRange(element.value.length, element.value.length);\n }, [editing]);\n\n const cancel = () => {\n setDraft(value);\n setEditing(false);\n };\n\n const commit = async () => {\n const next = draft.trim();\n if (next === value.trim() || (required && next === \"\")) {\n cancel();\n return;\n }\n\n try {\n setSaving(true);\n await onSave(next);\n setSaving(false);\n setEditing(false);\n } catch (error) {\n setSaving(false);\n // Giữ nguyên chế độ sửa: người dùng không mất chữ vừa gõ khi mạng lỗi.\n if (onError) onError(error);\n else console.error(\"[InlineEdit] onSave thất bại:\", error);\n }\n };\n\n const handleKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n cancel();\n return;\n }\n if (event.key === \"Enter\" && (!multiline || event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void commit();\n }\n };\n\n if (!editing) {\n return (\n <button\n type=\"button\"\n disabled={disabled}\n aria-label={ariaLabel}\n onClick={() => setEditing(true)}\n className={cn(\n \"-mx-1 w-full rounded-md px-1 py-0.5 text-left transition-colors duration-150 ease-out\",\n \"hover:bg-muted disabled:pointer-events-none disabled:opacity-60\",\n !value && \"text-muted-foreground\",\n className,\n displayClassName\n )}\n >\n <span className={cn(multiline ? \"whitespace-pre-wrap\" : \"block truncate\")}>{value || placeholder}</span>\n </button>\n );\n }\n\n const sharedProps = {\n value: draft,\n maxLength,\n placeholder,\n disabled: saving,\n \"aria-label\": ariaLabel,\n onKeyDown: handleKeyDown,\n onBlur: saveOnBlur ? () => void commit() : undefined,\n className: cn(\n \"w-full rounded-md border border-input bg-background px-1.5 py-0.5 text-sm text-foreground\",\n \"focus:border-primary-ink focus:outline-none focus:ring-2 focus:ring-ring/50\",\n \"disabled:opacity-60\",\n className\n )\n };\n\n return (\n <div className=\"flex items-start gap-1\">\n {multiline ? (\n <textarea\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n rows={3}\n onChange={(event) => setDraft(event.target.value)}\n />\n ) : (\n <input\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n type=\"text\"\n onChange={(event) => setDraft(event.target.value)}\n />\n )}\n\n {showActions && (\n <div className=\"flex shrink-0 items-center gap-0.5\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n // onMouseDown: chạy TRƯỚC blur của ô nhập, nếu để onClick thì blur đã huỷ/lưu mất rồi.\n onMouseDown={(event) => {\n event.preventDefault();\n void commit();\n }}\n >\n <Check aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.save}</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n onMouseDown={(event) => {\n event.preventDefault();\n cancel();\n }}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.cancel}</span>\n </Button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/inline-edit.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, X } from \"lucide-react\";\nimport { type KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\n\nexport interface InlineEditProps {\n /** Giá trị hiện tại (controlled từ phía dữ liệu) */\n value: string;\n /**\n * Lưu giá trị mới. Trả Promise thì component hiện trạng thái đang lưu và chỉ thoát chế độ\n * sửa khi lưu xong — thoát sớm sẽ nháy về giá trị cũ rồi mới nhảy sang giá trị mới.\n */\n onSave: (value: string) => void | Promise<void>;\n /**\n * Chữ hiện khi giá trị rỗng, kiêm placeholder của ô nhập (khoá `placeholder` của namespace\n * `inlineEdit` — `ComitorLocaleProvider`, mặc định tiếng Anh).\n */\n placeholder?: string;\n /** Ô nhập nhiều dòng (mô tả task) — Enter xuống dòng, Cmd/Ctrl+Enter mới lưu */\n multiline?: boolean;\n maxLength?: number;\n disabled?: boolean;\n /** Bắt buộc có nội dung: chuỗi rỗng bị coi là huỷ, không lưu (mặc định true) */\n required?: boolean;\n /** Rời ô là lưu (mặc định true) */\n saveOnBlur?: boolean;\n /**\n * Hiện cặp nút ✓/✕ khi đang sửa (mặc định false — thao tác bằng phím). Tên sr-only của hai nút\n * lấy khoá `save` / `cancel` của namespace `inlineEdit`; không có prop rời — đè qua `messages`\n * của `ComitorLocaleProvider`.\n */\n showActions?: boolean;\n /**\n * Nhãn cho trình đọc màn hình, vd \"Sửa tên công việc\" (không truyền thì lấy khoá `ariaLabel` của\n * namespace `inlineEdit`)\n */\n ariaLabel?: string;\n onError?: (error: unknown) => void;\n className?: string;\n /** className riêng cho phần hiển thị (khi chưa sửa) — vd chỉnh cỡ chữ tiêu đề */\n displayClassName?: string;\n}\n\n/**\n * Sửa tại chỗ: bấm vào chữ → thành ô nhập → Enter lưu, Esc huỷ.\n *\n * Vì sao không dùng `<input>` luôn cho gọn: ô nhập luôn hiện làm danh sách trông như một cái\n * form khổng lồ và người dùng dễ sửa nhầm khi chỉ định bấm chọn. Hiển thị dạng chữ cho tới khi\n * người dùng thật sự muốn sửa là ranh giới an toàn.\n */\nexport function InlineEdit({\n value,\n onSave,\n placeholder: placeholderProp,\n multiline = false,\n maxLength,\n disabled = false,\n required = true,\n saveOnBlur = true,\n showActions = false,\n ariaLabel: ariaLabelProp,\n onError,\n className,\n displayClassName\n}: InlineEditProps) {\n const t = useComitorMessages(\"inlineEdit\");\n const placeholder = placeholderProp ?? t.placeholder;\n const ariaLabel = ariaLabelProp ?? t.ariaLabel;\n const [editing, setEditing] = useState(false);\n const [draft, setDraft] = useState(value);\n const [saving, setSaving] = useState(false);\n const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);\n\n // Giá trị đổi từ ngoài (người khác sửa qua realtime) — chỉ đồng bộ khi không sửa dở.\n useEffect(() => {\n if (!editing) setDraft(value);\n }, [value, editing]);\n\n useEffect(() => {\n if (!editing) return;\n const element = inputRef.current;\n if (!element) return;\n element.focus();\n element.setSelectionRange(element.value.length, element.value.length);\n }, [editing]);\n\n const cancel = () => {\n setDraft(value);\n setEditing(false);\n };\n\n const commit = async () => {\n const next = draft.trim();\n if (next === value.trim() || (required && next === \"\")) {\n cancel();\n return;\n }\n\n try {\n setSaving(true);\n await onSave(next);\n setSaving(false);\n setEditing(false);\n } catch (error) {\n setSaving(false);\n // Giữ nguyên chế độ sửa: người dùng không mất chữ vừa gõ khi mạng lỗi.\n if (onError) onError(error);\n else console.error(\"[InlineEdit] onSave thất bại:\", error);\n }\n };\n\n const handleKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n cancel();\n return;\n }\n if (event.key === \"Enter\" && (!multiline || event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void commit();\n }\n };\n\n if (!editing) {\n return (\n <button\n type=\"button\"\n disabled={disabled}\n aria-label={ariaLabel}\n onClick={() => setEditing(true)}\n className={cn(\n \"-mx-1 w-full rounded-md px-1 py-0.5 text-left transition-colors duration-150 ease-out\",\n \"hover:bg-muted disabled:pointer-events-none disabled:opacity-60\",\n !value && \"text-muted-foreground\",\n className,\n displayClassName\n )}\n >\n <span className={cn(multiline ? \"whitespace-pre-wrap\" : \"block truncate\")}>{value || placeholder}</span>\n </button>\n );\n }\n\n const sharedProps = {\n value: draft,\n maxLength,\n placeholder,\n disabled: saving,\n \"aria-label\": ariaLabel,\n onKeyDown: handleKeyDown,\n onBlur: saveOnBlur ? () => void commit() : undefined,\n className: cn(\n \"w-full rounded-md border border-input bg-background px-1.5 py-0.5 text-sm text-foreground\",\n \"focus:border-primary-ink focus:outline-none focus:ring-2 focus:ring-ring/50\",\n \"disabled:opacity-60\",\n className\n )\n };\n\n return (\n <div className=\"flex items-start gap-1\">\n {multiline ? (\n <textarea\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n rows={3}\n onChange={(event) => setDraft(event.target.value)}\n />\n ) : (\n <input\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n type=\"text\"\n onChange={(event) => setDraft(event.target.value)}\n />\n )}\n\n {showActions && (\n <div className=\"flex shrink-0 items-center gap-0.5\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n // onMouseDown: chạy TRƯỚC blur của ô nhập, nếu để onClick thì blur đã huỷ/lưu mất rồi.\n onMouseDown={(event) => {\n event.preventDefault();\n void commit();\n }}\n >\n <Check aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.save}</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n onMouseDown={(event) => {\n event.preventDefault();\n cancel();\n }}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.cancel}</span>\n </Button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -19,8 +19,10 @@ import { TabsVariant } from '../ui/tabs-variants.js';
19
19
  *
20
20
  * ─ LINK ─
21
21
  * Mặc định render \`<a>\` (tải lại cả trang). Trên Next phải truyền \`LinkComponent={Link}\` —
22
- * cùng quy ước với \`Breadcrumb\` và \`AppShell\`; gói cố ý không \`import "next/link"\` để còn chạy
23
- * được ngoài Next.
22
+ * cùng quy ước với breadcrumb của \`PageHeader\`: entry chính cố ý không \`import "next/link"\` để
23
+ * còn chạy được ngoài Next. (Primitive \`Breadcrumb\` không có prop này — bọc bằng
24
+ * \`<BreadcrumbLink asChild>\`. \`AppShell\` thì NGƯỢC lại: entry \`/shell\` đã kéo \`next/*\` nên
25
+ * \`LinkComponent\` của nó mặc định CHÍNH LÀ \`next/link\`.)
24
26
  *
25
27
  * ```tsx
26
28
  * "use client";
@@ -30,8 +32,8 @@ import { TabsVariant } from '../ui/tabs-variants.js';
30
32
  * pathname={pathname}
31
33
  * LinkComponent={Link}
32
34
  * items={[
33
- * { href: "/cai-dat", label: "Chung" },
34
- * { href: "/cai-dat/thong-bao", label: "Thông báo" }
35
+ * { href: "/settings", label: "Chung" },
36
+ * { href: "/settings/notifications", label: "Thông báo" }
35
37
  * ]}
36
38
  * />
37
39
  * ```
@@ -51,8 +53,8 @@ interface RouteTabItem {
51
53
  /**
52
54
  * Mặc định khớp CHÍNH XÁC \`pathname === href\`.
53
55
  *
54
- * \`prefix\`: khớp cả route con (\`/cai-dat/thanh-vien/abc\` vẫn sáng ở \`/cai-dat/thanh-vien\`).
55
- * ⚠ Mục có href là đường dẫn GỐC của nhóm (\`/cai-dat\`) mà để \`prefix\` thì nó sáng ở MỌI tab —
56
+ * \`prefix\`: khớp cả route con (\`/settings/notifications/email\` vẫn sáng ở \`/settings/notifications\`).
57
+ * ⚠ Mục có href là đường dẫn GỐC của nhóm (\`/settings\`) mà để \`prefix\` thì nó sáng ở MỌI tab —
56
58
  * đúng lỗi mà tab dạng \`?query\` từng mắc. Để mặc định cho mục gốc.
57
59
  */
58
60
  match?: "exact" | "prefix";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/route-tabs.tsx"],"sourcesContent":["import type { ComponentType, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { TABS_TRIGGER_BASE, type TabsVariant, tabsListVariants, tabsTriggerVariants } from \"../ui/tabs-variants.js\";\nimport { RouteTabsNav } from \"./route-tabs-nav.js\";\n\n/**\n * Tab điều hướng — mỗi mục là một ROUTE THẬT, không phải một panel trong cùng trang.\n *\n * ─ VÌ SAO KHÔNG DÙNG \\`Tabs\\` CHO VIỆC NÀY ─\n * \\`Tabs\\` (Radix) dựng \\`role=\"tablist\"\\` + \\`role=\"tab\"\\` + \\`aria-controls\\` trỏ tới một\n * \\`role=\"tabpanel\"\\` CÙNG TRANG. Khi mỗi mục là một URL riêng thì:\n * • không có tabpanel nào để trỏ tới — \\`aria-controls\\` thành id treo, một lỗi a11y thật;\n * • \\`role=\"tab\"\\` nói với trình đọc màn hình rằng bấm vào sẽ đổi panel tại chỗ, trong khi thực\n * tế là điều hướng sang trang khác;\n * • roving focus của tablist nuốt phím mũi tên, thứ mà một danh sách link không nên làm.\n * Đây là \\`<nav>\\` chứa link thật, trạng thái đi bằng \\`aria-current=\"page\"\\`.\n *\n * Nhìn thì KHÔNG khác \\`Tabs\\`: cả hai đọc chung một bảng lớp (\\`ui/tabs-variants.ts\\`), nên đổi\n * kiểu tab ở một chỗ là cả hai đi theo.\n *\n * ─ LINK ─\n * Mặc định render \\`<a>\\` (tải lại cả trang). Trên Next phải truyền \\`LinkComponent={Link}\\` —\n * cùng quy ước với \\`Breadcrumb\\` và \\`AppShell\\`; gói cố ý không \\`import \"next/link\"\\` để còn chạy\n * được ngoài Next.\n *\n * ```tsx\n * \"use client\";\n * const pathname = usePathname();\n * <RouteTabs\n * variant=\"pills\"\n * pathname={pathname}\n * LinkComponent={Link}\n * items={[\n * { href: \"/cai-dat\", label: \"Chung\" },\n * { href: \"/cai-dat/thong-bao\", label: \"Thông báo\" }\n * ]}\n * />\n * ```\n */\n\n/** Props tối thiểu mà component link phải nhận — \\`next/link\\` thoả sẵn. */\nexport interface RouteTabsLinkProps {\n href: string;\n className?: string;\n children: ReactNode;\n \"aria-current\"?: \"page\" | undefined;\n \"data-state\"?: \"active\" | \"inactive\";\n}\n\nexport type RouteTabsLinkComponent = ComponentType<RouteTabsLinkProps>;\n\nexport interface RouteTabItem {\n href: string;\n label: ReactNode;\n /**\n * Mặc định khớp CHÍNH XÁC \\`pathname === href\\`.\n *\n * \\`prefix\\`: khớp cả route con (\\`/cai-dat/thanh-vien/abc\\` vẫn sáng ở \\`/cai-dat/thanh-vien\\`).\n * ⚠ Mục có href là đường dẫn GỐC của nhóm (\\`/cai-dat\\`) mà để \\`prefix\\` thì nó sáng ở MỌI tab —\n * đúng lỗi mà tab dạng \\`?query\\` từng mắc. Để mặc định cho mục gốc.\n */\n match?: \"exact\" | \"prefix\";\n}\n\nexport interface RouteTabsProps {\n items: RouteTabItem[];\n /** Đường dẫn hiện tại — \\`usePathname()\\` trên Next. */\n pathname: string;\n variant?: TabsVariant;\n /** Component link của app. Bỏ trống → \\`<a>\\` thô, tải lại cả trang. */\n LinkComponent?: RouteTabsLinkComponent;\n /**\n * Nhãn của vùng điều hướng, cho trình đọc màn hình. Không truyền thì lấy khoá `label` của\n * namespace `routeTabs` (`ComitorLocaleProvider`, mặc định tiếng Anh).\n */\n label?: string;\n className?: string;\n /** Lớp thêm cho từng link. */\n itemClassName?: string;\n}\n\nfunction DefaultLink({ href, className, children, ...rest }: RouteTabsLinkProps) {\n return (\n <a href={href} className={className} {...rest}>\n {children}\n </a>\n );\n}\n\nfunction isItemActive(item: RouteTabItem, pathname: string): boolean {\n if (item.match === \"prefix\") return pathname === item.href || pathname.startsWith(`${item.href}/`);\n return pathname === item.href;\n}\n\nexport function RouteTabs({\n items,\n pathname,\n variant = \"default\",\n LinkComponent = DefaultLink,\n label,\n className,\n itemClassName\n}: RouteTabsProps) {\n /*\n * File này CỐ Ý không có `\"use client\"`: `LinkComponent` là component do Server Component truyền\n * vào, không qua được ranh giới RSC. Chỉ tên của `<nav>` cần đọc provider, nên nó nằm ở lá client\n * `RouteTabsNav` (`route-tabs-nav.tsx`); các link vẫn dựng ở đây rồi đi vào đó làm `children`.\n */\n return (\n <RouteTabsNav label={label}>\n {/*\n `overflow-x-auto` để danh sách CUỘN NGANG khi hẹp, thay vì xuống dòng — nhãn tiếng Việt\n thường hai từ nên rất dễ vỡ dòng và làm pill cao gấp đôi. `whitespace-nowrap` ở từng mục\n là nửa còn lại của cùng một quyết định.\n */}\n <div\n data-slot=\"route-tabs-list\"\n className={cn(\"flex gap-0 overflow-x-auto\", tabsListVariants[variant], className)}\n >\n {items.map((item) => {\n const active = isItemActive(item, pathname);\n return (\n <LinkComponent\n key={item.href}\n href={item.href}\n // `data-state` để dùng lại nguyên bảng lớp của Tabs (chúng nhắm `data-[state=active]`).\n data-state={active ? \"active\" : \"inactive\"}\n aria-current={active ? \"page\" : undefined}\n className={cn(\n tabsTriggerVariants[variant],\n TABS_TRIGGER_BASE,\n \"shrink-0 whitespace-nowrap text-center\",\n itemClassName\n )}\n >\n {item.label}\n </LinkComponent>\n );\n })}\n </div>\n </RouteTabsNav>\n );\n}\n"],"mappings":"AAmFI;AAlFJ,SAAS,UAAU;AACnB,SAAS,mBAAqC,kBAAkB,2BAA2B;AAC3F,SAAS,oBAAoB;AA8E7B,SAAS,YAAY,EAAE,MAAM,WAAW,UAAU,GAAG,KAAK,GAAuB;AAC/E,SACE,oBAAC,OAAE,MAAY,WAAuB,GAAG,MACtC,UACH;AAEJ;AAEA,SAAS,aAAa,MAAoB,UAA2B;AACnE,MAAI,KAAK,UAAU,SAAU,QAAO,aAAa,KAAK,QAAQ,SAAS,WAAW,GAAG,KAAK,IAAI,GAAG;AACjG,SAAO,aAAa,KAAK;AAC3B;AAEO,SAAS,UAAU;AAAA,EACxB;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AACF,GAAmB;AAMjB,SACE,oBAAC,gBAAa,OAMZ;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,8BAA8B,iBAAiB,OAAO,GAAG,SAAS;AAAA,MAE/E,gBAAM,IAAI,CAAC,SAAS;AACnB,cAAM,SAAS,aAAa,MAAM,QAAQ;AAC1C,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAM,KAAK;AAAA,YAEX,cAAY,SAAS,WAAW;AAAA,YAChC,gBAAc,SAAS,SAAS;AAAA,YAChC,WAAW;AAAA,cACT,oBAAoB,OAAO;AAAA,cAC3B;AAAA,cACA;AAAA,cACA;AAAA,YACF;AAAA,YAEC,eAAK;AAAA;AAAA,UAZD,KAAK;AAAA,QAaZ;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH,GACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/route-tabs.tsx"],"sourcesContent":["import type { ComponentType, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { TABS_TRIGGER_BASE, type TabsVariant, tabsListVariants, tabsTriggerVariants } from \"../ui/tabs-variants.js\";\nimport { RouteTabsNav } from \"./route-tabs-nav.js\";\n\n/**\n * Tab điều hướng — mỗi mục là một ROUTE THẬT, không phải một panel trong cùng trang.\n *\n * ─ VÌ SAO KHÔNG DÙNG \\`Tabs\\` CHO VIỆC NÀY ─\n * \\`Tabs\\` (Radix) dựng \\`role=\"tablist\"\\` + \\`role=\"tab\"\\` + \\`aria-controls\\` trỏ tới một\n * \\`role=\"tabpanel\"\\` CÙNG TRANG. Khi mỗi mục là một URL riêng thì:\n * • không có tabpanel nào để trỏ tới — \\`aria-controls\\` thành id treo, một lỗi a11y thật;\n * • \\`role=\"tab\"\\` nói với trình đọc màn hình rằng bấm vào sẽ đổi panel tại chỗ, trong khi thực\n * tế là điều hướng sang trang khác;\n * • roving focus của tablist nuốt phím mũi tên, thứ mà một danh sách link không nên làm.\n * Đây là \\`<nav>\\` chứa link thật, trạng thái đi bằng \\`aria-current=\"page\"\\`.\n *\n * Nhìn thì KHÔNG khác \\`Tabs\\`: cả hai đọc chung một bảng lớp (\\`ui/tabs-variants.ts\\`), nên đổi\n * kiểu tab ở một chỗ là cả hai đi theo.\n *\n * ─ LINK ─\n * Mặc định render \\`<a>\\` (tải lại cả trang). Trên Next phải truyền \\`LinkComponent={Link}\\` —\n * cùng quy ước với breadcrumb của \\`PageHeader\\`: entry chính cố ý không \\`import \"next/link\"\\` để\n * còn chạy được ngoài Next. (Primitive \\`Breadcrumb\\` không có prop này — bọc bằng\n * \\`<BreadcrumbLink asChild>\\`. \\`AppShell\\` thì NGƯỢC lại: entry \\`/shell\\` đã kéo \\`next/*\\` nên\n * \\`LinkComponent\\` của nó mặc định CHÍNH LÀ \\`next/link\\`.)\n *\n * ```tsx\n * \"use client\";\n * const pathname = usePathname();\n * <RouteTabs\n * variant=\"pills\"\n * pathname={pathname}\n * LinkComponent={Link}\n * items={[\n * { href: \"/settings\", label: \"Chung\" },\n * { href: \"/settings/notifications\", label: \"Thông báo\" }\n * ]}\n * />\n * ```\n */\n\n/** Props tối thiểu mà component link phải nhận — \\`next/link\\` thoả sẵn. */\nexport interface RouteTabsLinkProps {\n href: string;\n className?: string;\n children: ReactNode;\n \"aria-current\"?: \"page\" | undefined;\n \"data-state\"?: \"active\" | \"inactive\";\n}\n\nexport type RouteTabsLinkComponent = ComponentType<RouteTabsLinkProps>;\n\nexport interface RouteTabItem {\n href: string;\n label: ReactNode;\n /**\n * Mặc định khớp CHÍNH XÁC \\`pathname === href\\`.\n *\n * \\`prefix\\`: khớp cả route con (\\`/settings/notifications/email\\` vẫn sáng ở \\`/settings/notifications\\`).\n * ⚠ Mục có href là đường dẫn GỐC của nhóm (\\`/settings\\`) mà để \\`prefix\\` thì nó sáng ở MỌI tab —\n * đúng lỗi mà tab dạng \\`?query\\` từng mắc. Để mặc định cho mục gốc.\n */\n match?: \"exact\" | \"prefix\";\n}\n\nexport interface RouteTabsProps {\n items: RouteTabItem[];\n /** Đường dẫn hiện tại — \\`usePathname()\\` trên Next. */\n pathname: string;\n variant?: TabsVariant;\n /** Component link của app. Bỏ trống → \\`<a>\\` thô, tải lại cả trang. */\n LinkComponent?: RouteTabsLinkComponent;\n /**\n * Nhãn của vùng điều hướng, cho trình đọc màn hình. Không truyền thì lấy khoá `label` của\n * namespace `routeTabs` (`ComitorLocaleProvider`, mặc định tiếng Anh).\n */\n label?: string;\n className?: string;\n /** Lớp thêm cho từng link. */\n itemClassName?: string;\n}\n\nfunction DefaultLink({ href, className, children, ...rest }: RouteTabsLinkProps) {\n return (\n <a href={href} className={className} {...rest}>\n {children}\n </a>\n );\n}\n\nfunction isItemActive(item: RouteTabItem, pathname: string): boolean {\n if (item.match === \"prefix\") return pathname === item.href || pathname.startsWith(`${item.href}/`);\n return pathname === item.href;\n}\n\nexport function RouteTabs({\n items,\n pathname,\n variant = \"default\",\n LinkComponent = DefaultLink,\n label,\n className,\n itemClassName\n}: RouteTabsProps) {\n /*\n * File này CỐ Ý không có `\"use client\"`: `LinkComponent` là component do Server Component truyền\n * vào, không qua được ranh giới RSC. Chỉ tên của `<nav>` cần đọc provider, nên nó nằm ở lá client\n * `RouteTabsNav` (`route-tabs-nav.tsx`); các link vẫn dựng ở đây rồi đi vào đó làm `children`.\n */\n return (\n <RouteTabsNav label={label}>\n {/*\n `overflow-x-auto` để danh sách CUỘN NGANG khi hẹp, thay vì xuống dòng — nhãn tiếng Việt\n thường hai từ nên rất dễ vỡ dòng và làm pill cao gấp đôi. `whitespace-nowrap` ở từng mục\n là nửa còn lại của cùng một quyết định.\n */}\n <div\n data-slot=\"route-tabs-list\"\n className={cn(\"flex gap-0 overflow-x-auto\", tabsListVariants[variant], className)}\n >\n {items.map((item) => {\n const active = isItemActive(item, pathname);\n return (\n <LinkComponent\n key={item.href}\n href={item.href}\n // `data-state` để dùng lại nguyên bảng lớp của Tabs (chúng nhắm `data-[state=active]`).\n data-state={active ? \"active\" : \"inactive\"}\n aria-current={active ? \"page\" : undefined}\n className={cn(\n tabsTriggerVariants[variant],\n TABS_TRIGGER_BASE,\n \"shrink-0 whitespace-nowrap text-center\",\n itemClassName\n )}\n >\n {item.label}\n </LinkComponent>\n );\n })}\n </div>\n </RouteTabsNav>\n );\n}\n"],"mappings":"AAqFI;AApFJ,SAAS,UAAU;AACnB,SAAS,mBAAqC,kBAAkB,2BAA2B;AAC3F,SAAS,oBAAoB;AAgF7B,SAAS,YAAY,EAAE,MAAM,WAAW,UAAU,GAAG,KAAK,GAAuB;AAC/E,SACE,oBAAC,OAAE,MAAY,WAAuB,GAAG,MACtC,UACH;AAEJ;AAEA,SAAS,aAAa,MAAoB,UAA2B;AACnE,MAAI,KAAK,UAAU,SAAU,QAAO,aAAa,KAAK,QAAQ,SAAS,WAAW,GAAG,KAAK,IAAI,GAAG;AACjG,SAAO,aAAa,KAAK;AAC3B;AAEO,SAAS,UAAU;AAAA,EACxB;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA;AACF,GAAmB;AAMjB,SACE,oBAAC,gBAAa,OAMZ;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,8BAA8B,iBAAiB,OAAO,GAAG,SAAS;AAAA,MAE/E,gBAAM,IAAI,CAAC,SAAS;AACnB,cAAM,SAAS,aAAa,MAAM,QAAQ;AAC1C,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAM,KAAK;AAAA,YAEX,cAAY,SAAS,WAAW;AAAA,YAChC,gBAAc,SAAS,SAAS;AAAA,YAChC,WAAW;AAAA,cACT,oBAAoB,OAAO;AAAA,cAC3B;AAAA,cACA;AAAA,cACA;AAAA,YACF;AAAA,YAEC,eAAK;AAAA;AAAA,UAZD,KAAK;AAAA,QAaZ;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH,GACF;AAEJ;","names":[]}
@@ -94,7 +94,7 @@ interface StatusConfig<T extends string = string> extends StatusTone {
94
94
  * primary 5,77:1 (L) · 7,26:1 (D) 6,51:1 (L) · 12,67:1 (D)
95
95
  *
96
96
  * ─ TONE `severe` (cam) — nấc giữa `warning` và `destructive` ─
97
- * Ưu tiên cao, SLA sắp trễ — nấc mà GitHub Primer gọi là "severe". Có vì `warning` tô chấm bằng
97
+ * Ưu tiên cao, SLA sắp trễ — tên "severe" là tên quen dùng cho nấc này. Có vì `warning` tô chấm bằng
98
98
  * `--warning-ink` #7A5800: đạt chuẩn (6,51:1) nhưng ở cỡ chấm đọc ra ô-liu xỉn, không ra "gấp".
99
99
  * Nó là tone DUY NHẤT có `dotColor`: chấm tô bằng chính bậc tô nền `--severe` (ngưỡng 3:1, vì chấm
100
100
  * là hình) thay cho bậc ink tối. Đo ở CẢ HAI bảng màu — ở tone này bảng mặc định cũng đạt hết: