@comitor/ui 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -8
- package/dist/components/composite/avatar-group.d.ts +13 -1
- package/dist/components/composite/avatar-group.js +19 -4
- package/dist/components/composite/avatar-group.js.map +1 -1
- package/dist/components/composite/combobox.js +169 -146
- package/dist/components/composite/combobox.js.map +1 -1
- package/dist/components/composite/data-table.d.ts +14 -3
- package/dist/components/composite/data-table.js +1 -1
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/date-picker.js +46 -17
- package/dist/components/composite/date-picker.js.map +1 -1
- package/dist/components/composite/stat-card.js +6 -6
- package/dist/components/composite/stat-card.js.map +1 -1
- package/dist/components/ui/calendar.d.ts +29 -24
- package/dist/components/ui/calendar.js +1 -1
- package/dist/components/ui/calendar.js.map +1 -1
- package/dist/components/ui/checkbox.js +2 -2
- package/dist/components/ui/checkbox.js.map +1 -1
- package/dist/components/ui/command.js +1 -1
- package/dist/components/ui/command.js.map +1 -1
- package/dist/components/ui/context-menu.js +4 -4
- package/dist/components/ui/context-menu.js.map +1 -1
- package/dist/components/ui/dropdown-menu.js +4 -4
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/empty.js +1 -1
- package/dist/components/ui/empty.js.map +1 -1
- package/dist/components/ui/field.js +1 -1
- package/dist/components/ui/field.js.map +1 -1
- package/dist/components/ui/item.js +1 -1
- package/dist/components/ui/item.js.map +1 -1
- package/dist/components/ui/menubar.js +4 -4
- package/dist/components/ui/menubar.js.map +1 -1
- package/dist/components/ui/radio-group.js +1 -1
- package/dist/components/ui/radio-group.js.map +1 -1
- package/dist/components/ui/select.d.ts +35 -12
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/select.js.map +1 -1
- package/dist/components/ui/switch.js +2 -2
- package/dist/components/ui/switch.js.map +1 -1
- package/dist/components/ui/table.js +2 -2
- package/dist/components/ui/table.js.map +1 -1
- package/dist/shell/app-header.js +46 -48
- package/dist/shell/app-header.js.map +1 -1
- package/dist/shell/mobile-menu.d.ts +6 -1
- package/dist/shell/mobile-menu.js +5 -5
- package/dist/shell/mobile-menu.js.map +1 -1
- package/dist/shell/notifications-button.js +31 -9
- package/dist/shell/notifications-button.js.map +1 -1
- package/dist/shell/sidebar.js +1 -1
- package/dist/shell/sidebar.js.map +1 -1
- package/dist/shell/types.d.ts +13 -0
- package/dist/shell/types.js +1 -0
- package/dist/shell/types.js.map +1 -1
- package/dist/shell/user-menu.d.ts +14 -2
- package/dist/shell/user-menu.js +3 -3
- package/dist/shell/user-menu.js.map +1 -1
- package/dist/shell/workspace-switcher.js +48 -34
- package/dist/shell/workspace-switcher.js.map +1 -1
- package/package.json +1 -1
- package/styles.css +95 -16
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/combobox.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, ChevronsUpDown, Loader2, X } from \"lucide-react\";\nimport { type ReactNode, useMemo, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface ComboboxOption {\n value: string;\n label: string;\n /** Dòng phụ mờ dưới nhãn (vd email của người dùng, mã của khách hàng) */\n description?: string;\n icon?: IconComponent;\n disabled?: boolean;\n /** Nhãn nhóm — các mục cùng nhóm được gom lại và có tiêu đề nhóm */\n group?: string;\n /** Từ khoá phụ để tìm ra mục này (vd mã số, tên viết tắt) */\n keywords?: string[];\n}\n\ninterface ComboboxSharedProps {\n options: ComboboxOption[];\n placeholder?: string;\n searchPlaceholder?: string;\n emptyText?: string;\n disabled?: boolean;\n /**\n * Có mặt = chế độ BẤT ĐỒNG BỘ: tắt lọc nội bộ của cmdk, app tự lọc `options` (thường là gọi API).\n * Không truyền = lọc tại chỗ, không dấu tiếng Việt.\n */\n onSearchChange?: (search: string) => void;\n /** Đang tải kết quả (chỉ có nghĩa ở chế độ bất đồng bộ) */\n loading?: boolean;\n /** Cho phép xoá lựa chọn bằng nút X trên trigger */\n clearable?: boolean;\n className?: string;\n /** className của panel popover (vd đặt bề rộng cố định) */\n contentClassName?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n /**\n * Ô đang lỗi. Ngoài `aria-invalid` cho trình đọc màn hình, trigger còn chuyển sang viền\n * `--destructive-ink` — cùng tín hiệu thị giác mà `Input` đã có (nhánh nằm ở `button.tsx`).\n *\n * Nhận cả `boolean` lẫn `\"true\"`/`\"false\"` để dùng thẳng được `aria-invalid={!!errors.x}`\n * của react-hook-form. Giá trị falsy KHÔNG phát thuộc tính ra DOM.\n */\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** `aria-invalid` nhận nhiều hình dạng — quy về một boolean trước khi dùng làm điều kiện. */\nfunction isAriaInvalid(value: ComboboxSharedProps[\"aria-invalid\"]): boolean {\n return value === true || value === \"true\";\n}\n\nexport interface ComboboxProps extends ComboboxSharedProps {\n /** Giá trị đang chọn; `null`/`undefined` = chưa chọn */\n value?: string | null;\n onValueChange: (value: string | null) => void;\n}\n\nexport interface MultiComboboxProps extends ComboboxSharedProps {\n value: string[];\n onValueChange: (value: string[]) => void;\n /** Số nhãn hiện trên trigger trước khi gộp thành \"+k\" (mặc định 2) */\n maxDisplay?: number;\n /** Giới hạn số lựa chọn; đạt ngưỡng thì các mục còn lại bị khoá */\n maxSelected?: number;\n}\n\n/**\n * Lọc không dấu cho cmdk: so khớp cả value, label lẫn keywords.\n *\n * cmdk mặc định chấm điểm theo thuật toán riêng có phân biệt dấu — người Việt gõ \"nguyen\"\n * sẽ không ra \"Nguyễn\". Trả 1/0 là đủ vì thứ tự hiển thị đã theo thứ tự app truyền vào.\n */\nfunction vietnameseFilter(value: string, search: string, keywords?: string[]): number {\n if (!search) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(search).toLowerCase()) ? 1 : 0;\n}\n\n/** Gom mục theo `group`, giữ nguyên thứ tự xuất hiện đầu tiên của mỗi nhóm. */\nfunction groupOptions(options: ComboboxOption[]): Array<{ label: string | undefined; items: ComboboxOption[] }> {\n const groups = new Map<string, ComboboxOption[]>();\n for (const option of options) {\n const key = option.group ?? \"\";\n const bucket = groups.get(key);\n if (bucket) bucket.push(option);\n else groups.set(key, [option]);\n }\n return [...groups.entries()].map(([label, items]) => ({ label: label || undefined, items }));\n}\n\nfunction OptionRow({ option, selected }: { option: ComboboxOption; selected: boolean }) {\n const Icon = option.icon;\n return (\n <>\n <span\n aria-hidden=\"true\"\n className={cn(\"flex size-4 shrink-0 items-center justify-center\", !selected && \"invisible\")}\n >\n <Check className=\"size-4\" />\n </span>\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\">{option.label}</span>\n {option.description && (\n <span className=\"block truncate text-xs text-muted-foreground\">{option.description}</span>\n )}\n </span>\n </>\n );\n}\n\nfunction useComboboxList(options: ComboboxOption[], isAsync: boolean) {\n const groups = useMemo(() => groupOptions(options), [options]);\n return { groups, shouldFilter: !isAsync };\n}\n\nfunction ComboboxPanel({\n options,\n isAsync,\n loading,\n search,\n onSearch,\n searchPlaceholder,\n emptyText,\n renderItem\n}: {\n options: ComboboxOption[];\n isAsync: boolean;\n loading: boolean;\n search: string;\n onSearch: (value: string) => void;\n searchPlaceholder: string;\n emptyText: string;\n renderItem: (option: ComboboxOption) => ReactNode;\n}) {\n const { groups, shouldFilter } = useComboboxList(options, isAsync);\n\n return (\n <Command shouldFilter={shouldFilter} filter={vietnameseFilter}>\n <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={onSearch} />\n <CommandList>\n {loading ? (\n <div className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\">\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n Đang tải…\n </div>\n ) : (\n <>\n <CommandEmpty>{emptyText}</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.label ?? \"__default__\"} heading={group.label}>\n {group.items.map(renderItem)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n </Command>\n );\n}\n\n/**\n * Ô chọn một giá trị có tìm kiếm — cmdk trong Popover, rộng bằng trigger.\n *\n * Mặc định tìm KHÔNG DẤU tiếng Việt. Truyền `onSearchChange` để chuyển sang chế độ bất đồng bộ\n * (app tự gọi API và cập nhật `options`).\n */\nexport function Combobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = false,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: ComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const selected = options.find((option) => option.value === value);\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && Boolean(selected) && !disabled;\n\n return (\n /* Nút xóa là ANH EM của trigger, không nằm trong nó: <button> lồng <button> là HTML không\n hợp lệ, và nếu hạ xuống <span role=\"button\" tabIndex={-1}> thì người dùng bàn phím vĩnh\n viễn không tới được nút xóa. Đặt tuyệt đối chồng lên phần đệm phải của trigger. */\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"w-full justify-between gap-2 font-normal\",\n !selected && \"text-muted-foreground\",\n showClear && \"pr-12\",\n className\n )}\n {...aria}\n // Sau `{...aria}`: chỉ phát thuộc tính khi THẬT SỰ lỗi, để `aria-invalid={false}`\n // không rơi ra DOM thành `aria-invalid=\"false\"` (hợp lệ nhưng thừa).\n aria-invalid={invalid || undefined}\n >\n <span className=\"truncate\">{selected ? selected.label : placeholder}</span>\n {/* Chevron BÁO TRẠNG THÁI (aria-expanded) trên control còn bấm được → ngưỡng 3:1\n của WCAG 1.4.11, `opacity-*` không miễn trừ. `opacity-50` chỉ được 2,03:1 (light)\n / 2,30:1 (dark) khi trigger đang ở trạng thái placeholder (kế thừa\n `text-muted-foreground`) ✗. Nêu thẳng `text-muted-foreground` cho icon: 5,23:1\n (light) / 5,70:1 (dark) ✓, và không đổi theo việc đã chọn hay chưa. */}\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled}\n onSelect={() => {\n onValueChange(option.value === value ? null : option.value);\n setOpen(false);\n setSearch(\"\");\n if (isAsync) onSearchChange?.(\"\");\n }}\n >\n <OptionRow option={option} selected={option.value === value} />\n </CommandItem>\n )}\n />\n </PopoverContent>\n </Popover>\n\n {showClear && (\n <button\n type=\"button\"\n aria-label=\"Xóa lựa chọn\"\n onClick={() => onValueChange(null)}\n className=\"absolute right-8 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n )}\n </div>\n );\n}\n\n/**\n * Ô chọn NHIỀU giá trị — cùng hạ tầng với `Combobox`, popover không tự đóng sau mỗi lần chọn.\n *\n * Trigger hiện tối đa `maxDisplay` nhãn rồi gộp phần dư thành \"+k\": danh sách 12 người thực hiện\n * mà đổ hết ra trigger thì hàng form vỡ bố cục.\n */\nexport function MultiCombobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = true,\n maxDisplay = 2,\n maxSelected,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: MultiComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const selectedOptions = options.filter((option) => value.includes(option.value));\n const shown = selectedOptions.slice(0, Math.max(1, maxDisplay));\n const overflow = selectedOptions.length - shown.length;\n const limitReached = maxSelected !== undefined && value.length >= maxSelected;\n\n const toggle = (optionValue: string) => {\n if (value.includes(optionValue)) {\n onValueChange(value.filter((item) => item !== optionValue));\n return;\n }\n if (limitReached) return;\n onValueChange([...value, optionValue]);\n };\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && value.length > 0 && !disabled;\n\n return (\n // Cùng lý do như Combobox đơn: nút xóa là anh em của trigger để bấm được bằng bàn phím.\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"h-auto min-h-9 w-full justify-between gap-2 py-1.5 font-normal\",\n value.length === 0 && \"text-muted-foreground\",\n showClear && \"pr-12\",\n className\n )}\n {...aria}\n // Cùng lý do như Combobox đơn.\n aria-invalid={invalid || undefined}\n >\n <span className=\"flex min-w-0 flex-wrap items-center gap-1\">\n {value.length === 0 ? (\n placeholder\n ) : (\n <>\n {shown.map((option) => (\n <Badge key={option.value} variant=\"secondary\" className=\"max-w-[10rem]\">\n <span className=\"truncate\">{option.label}</span>\n </Badge>\n ))}\n {overflow > 0 && <Badge variant=\"outline\">+{overflow}</Badge>}\n </>\n )}\n </span>\n {/* Chevron BÁO TRẠNG THÁI (aria-expanded) trên control còn bấm được → ngưỡng 3:1\n của WCAG 1.4.11, `opacity-*` không miễn trừ. `opacity-50` chỉ được 2,03:1 (light)\n / 2,30:1 (dark) khi trigger đang ở trạng thái placeholder (kế thừa\n `text-muted-foreground`) ✗. Nêu thẳng `text-muted-foreground` cho icon: 5,23:1\n (light) / 5,70:1 (dark) ✓, và không đổi theo việc đã chọn hay chưa. */}\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => {\n const isSelected = value.includes(option.value);\n return (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled || (limitReached && !isSelected)}\n onSelect={() => toggle(option.value)}\n >\n <OptionRow option={option} selected={isSelected} />\n </CommandItem>\n );\n }}\n />\n {maxSelected !== undefined && (\n <p className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n Đã chọn {value.length}/{maxSelected}\n </p>\n )}\n </PopoverContent>\n </Popover>\n\n {showClear && (\n <button\n type=\"button\"\n aria-label=\"Xóa tất cả lựa chọn\"\n onClick={() => onValueChange([])}\n className=\"absolute right-8 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/combobox.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, ChevronsUpDown, Loader2, X } from \"lucide-react\";\nimport { type ReactNode, useMemo, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface ComboboxOption {\n value: string;\n label: string;\n /** Dòng phụ mờ dưới nhãn (vd email của người dùng, mã của khách hàng) */\n description?: string;\n icon?: IconComponent;\n disabled?: boolean;\n /** Nhãn nhóm — các mục cùng nhóm được gom lại và có tiêu đề nhóm */\n group?: string;\n /** Từ khoá phụ để tìm ra mục này (vd mã số, tên viết tắt) */\n keywords?: string[];\n}\n\ninterface ComboboxSharedProps {\n options: ComboboxOption[];\n placeholder?: string;\n searchPlaceholder?: string;\n emptyText?: string;\n disabled?: boolean;\n /**\n * Có mặt = chế độ BẤT ĐỒNG BỘ: tắt lọc nội bộ của cmdk, app tự lọc `options` (thường là gọi API).\n * Không truyền = lọc tại chỗ, không dấu tiếng Việt.\n */\n onSearchChange?: (search: string) => void;\n /** Đang tải kết quả (chỉ có nghĩa ở chế độ bất đồng bộ) */\n loading?: boolean;\n /** Cho phép xoá lựa chọn bằng nút X trên trigger */\n clearable?: boolean;\n className?: string;\n /** className của panel popover (vd đặt bề rộng cố định) */\n contentClassName?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n /**\n * Ô đang lỗi. Ngoài `aria-invalid` cho trình đọc màn hình, trigger còn chuyển sang viền\n * `--destructive-ink` — cùng tín hiệu thị giác mà `Input` đã có (nhánh nằm ở `button.tsx`).\n *\n * Nhận cả `boolean` lẫn `\"true\"`/`\"false\"` để dùng thẳng được `aria-invalid={!!errors.x}`\n * của react-hook-form. Giá trị falsy KHÔNG phát thuộc tính ra DOM.\n */\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** `aria-invalid` nhận nhiều hình dạng — quy về một boolean trước khi dùng làm điều kiện. */\nfunction isAriaInvalid(value: ComboboxSharedProps[\"aria-invalid\"]): boolean {\n return value === true || value === \"true\";\n}\n\nexport interface ComboboxProps extends ComboboxSharedProps {\n /** Giá trị đang chọn; `null`/`undefined` = chưa chọn */\n value?: string | null;\n onValueChange: (value: string | null) => void;\n}\n\nexport interface MultiComboboxProps extends ComboboxSharedProps {\n value: string[];\n onValueChange: (value: string[]) => void;\n /** Số nhãn hiện trên trigger trước khi gộp thành \"+k\" (mặc định 2) */\n maxDisplay?: number;\n /** Giới hạn số lựa chọn; đạt ngưỡng thì các mục còn lại bị khoá */\n maxSelected?: number;\n}\n\n/**\n * Lọc không dấu cho cmdk: so khớp cả value, label lẫn keywords.\n *\n * cmdk mặc định chấm điểm theo thuật toán riêng có phân biệt dấu — người Việt gõ \"nguyen\"\n * sẽ không ra \"Nguyễn\". Trả 1/0 là đủ vì thứ tự hiển thị đã theo thứ tự app truyền vào.\n */\nfunction vietnameseFilter(value: string, search: string, keywords?: string[]): number {\n if (!search) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(search).toLowerCase()) ? 1 : 0;\n}\n\n/** Gom mục theo `group`, giữ nguyên thứ tự xuất hiện đầu tiên của mỗi nhóm. */\nfunction groupOptions(options: ComboboxOption[]): Array<{ label: string | undefined; items: ComboboxOption[] }> {\n const groups = new Map<string, ComboboxOption[]>();\n for (const option of options) {\n const key = option.group ?? \"\";\n const bucket = groups.get(key);\n if (bucket) bucket.push(option);\n else groups.set(key, [option]);\n }\n return [...groups.entries()].map(([label, items]) => ({ label: label || undefined, items }));\n}\n\nfunction OptionRow({ option, selected }: { option: ComboboxOption; selected: boolean }) {\n const Icon = option.icon;\n return (\n <>\n <span\n aria-hidden=\"true\"\n className={cn(\"flex size-4 shrink-0 items-center justify-center\", !selected && \"invisible\")}\n >\n <Check className=\"size-4\" />\n </span>\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\">{option.label}</span>\n {option.description && (\n <span className=\"block truncate text-xs text-muted-foreground\">{option.description}</span>\n )}\n </span>\n </>\n );\n}\n\n/**\n * Hàng icon ĐÈ lên trigger: nút xóa bên trái, chevron neo cứng bên phải.\n *\n * Vì sao nút xóa phải là ANH EM của trigger chứ không nằm trong: `<button>` lồng `<button>` là\n * HTML không hợp lệ, và nếu hạ nó xuống `<span role=\"button\" tabIndex={-1}>` thì người dùng bàn\n * phím vĩnh viễn không tới được nút xóa.\n *\n * Vì sao CHEVRON cũng phải ra ngoài theo: nó là con cuối của trigger, nên chỗ trống dành cho nút\n * xóa chỉ có thể lấy từ `padding-right` của trigger — mà padding đó ĐẨY luôn chevron sang trái.\n * Kết quả là chọn xong một giá trị thì chevron nhảy chỗ, và hai ô cạnh nhau trong cùng một hàng\n * biểu mẫu (một ô đã chọn, một ô chưa) có chevron lệch nhau. Ra ngoài rồi thì chevron đứng yên ở\n * `right-4` như mọi control khác, nút xóa chèn vào BÊN TRÁI nó — đúng thứ tự của `DatePicker`.\n *\n * `pointer-events-none` ở hàng, `pointer-events-auto` ở nút xóa: chevron chỉ là trang trí, bấm\n * vào nó phải xuyên xuống trigger để mở popover (hồi nó còn là con của trigger thì miễn phí).\n *\n * Và hàng này phải TỰ mờ khi `disabled`: `disabled:opacity-50` của `Button` chỉ áp cho chính\n * trigger, không với tới anh em của nó.\n */\nfunction ComboboxAdornments({\n showClear,\n disabled,\n clearLabel,\n onClear\n}: {\n showClear: boolean;\n disabled: boolean;\n clearLabel: string;\n onClear: () => void;\n}) {\n return (\n <div\n className={cn(\n /* `border border-transparent pr-3` chứ không phải `right-3`: hàng này là ANH EM của\n trigger nên nó đo từ mép BORDER, trong khi chevron của `Select` nằm trong trigger nên\n đo từ mép CONTENT — lệch đúng 1px bề dày viền. Mượn lại mô hình hộp của trigger (viền\n 1px trong suốt + lề 12px) là hai bên rơi vào cùng một mốc, không phải bịa số lẻ. */\n \"pointer-events-none absolute inset-y-0 right-0 flex items-center border border-transparent pr-3\",\n disabled && \"opacity-50\"\n )}\n >\n {showClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n onClick={onClear}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{clearLabel}</span>\n </Button>\n )}\n {/* Chevron BÁO TRẠNG THÁI (aria-expanded) trên control còn bấm được → ngưỡng 3:1 của\n WCAG 1.4.11, `opacity-*` không miễn trừ. `opacity-50` chỉ được 2,03:1 (light) / 2,30:1\n (dark) khi trigger đang ở trạng thái placeholder (kế thừa `text-muted-foreground`) ✗.\n Nêu thẳng `text-muted-foreground` cho icon: 5,23:1 (light) / 5,70:1 (dark) ✓, và không\n đổi theo việc đã chọn hay chưa. */}\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </div>\n );\n}\n\n/**\n * Lề của trigger. Trigger là `Button` nên nó mang sẵn `px-4` — mà Combobox nhìn là một Ô NHẬP chứ\n * không phải cái nút, và `Input`/`Select` (cả ở đây lẫn ở comitor-ds) đều `px-3`. Để nguyên `px-4`\n * là trong cùng một cột biểu mẫu, chữ của Combobox thụt vào 4px so với chữ của Input bên trên nó.\n * Nên `px-3`, và mọi icon phải neo ở 12px như các control còn lại.\n *\n * Chỗ trống bên phải phải chừa cho hàng icon tuyệt đối, vì trigger không còn tự đẩy chỗ nữa:\n * `pr-9` = 12 (lề) + 16 (chevron) + 8 (khoảng tới chữ); `pr-15` cộng thêm 24 cho nút xóa.\n */\nconst TRIGGER_PADDING = \"px-3 pr-9\";\nconst TRIGGER_PADDING_WITH_CLEAR = \"px-3 pr-15\";\n\nfunction useComboboxList(options: ComboboxOption[], isAsync: boolean) {\n const groups = useMemo(() => groupOptions(options), [options]);\n return { groups, shouldFilter: !isAsync };\n}\n\nfunction ComboboxPanel({\n options,\n isAsync,\n loading,\n search,\n onSearch,\n searchPlaceholder,\n emptyText,\n renderItem\n}: {\n options: ComboboxOption[];\n isAsync: boolean;\n loading: boolean;\n search: string;\n onSearch: (value: string) => void;\n searchPlaceholder: string;\n emptyText: string;\n renderItem: (option: ComboboxOption) => ReactNode;\n}) {\n const { groups, shouldFilter } = useComboboxList(options, isAsync);\n\n return (\n <Command shouldFilter={shouldFilter} filter={vietnameseFilter}>\n <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={onSearch} />\n <CommandList>\n {loading ? (\n <div className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\">\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n Đang tải…\n </div>\n ) : (\n <>\n <CommandEmpty>{emptyText}</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.label ?? \"__default__\"} heading={group.label}>\n {group.items.map(renderItem)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n </Command>\n );\n}\n\n/**\n * Ô chọn một giá trị có tìm kiếm — cmdk trong Popover, rộng bằng trigger.\n *\n * Mặc định tìm KHÔNG DẤU tiếng Việt. Truyền `onSearchChange` để chuyển sang chế độ bất đồng bộ\n * (app tự gọi API và cập nhật `options`).\n */\nexport function Combobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = false,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: ComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const selected = options.find((option) => option.value === value);\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && Boolean(selected) && !disabled;\n\n return (\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"w-full justify-start font-normal\",\n showClear ? TRIGGER_PADDING_WITH_CLEAR : TRIGGER_PADDING,\n !selected && \"text-muted-foreground\",\n className\n )}\n {...aria}\n // Sau `{...aria}`: chỉ phát thuộc tính khi THẬT SỰ lỗi, để `aria-invalid={false}`\n // không rơi ra DOM thành `aria-invalid=\"false\"` (hợp lệ nhưng thừa).\n aria-invalid={invalid || undefined}\n >\n <span className=\"truncate\">{selected ? selected.label : placeholder}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled}\n onSelect={() => {\n onValueChange(option.value === value ? null : option.value);\n setOpen(false);\n setSearch(\"\");\n if (isAsync) onSearchChange?.(\"\");\n }}\n >\n <OptionRow option={option} selected={option.value === value} />\n </CommandItem>\n )}\n />\n </PopoverContent>\n </Popover>\n\n <ComboboxAdornments\n showClear={showClear}\n disabled={disabled}\n clearLabel=\"Xóa lựa chọn\"\n onClear={() => onValueChange(null)}\n />\n </div>\n );\n}\n\n/**\n * Ô chọn NHIỀU giá trị — cùng hạ tầng với `Combobox`, popover không tự đóng sau mỗi lần chọn.\n *\n * Trigger hiện tối đa `maxDisplay` nhãn rồi gộp phần dư thành \"+k\": danh sách 12 người thực hiện\n * mà đổ hết ra trigger thì hàng form vỡ bố cục.\n */\nexport function MultiCombobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = true,\n maxDisplay = 2,\n maxSelected,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: MultiComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const selectedOptions = options.filter((option) => value.includes(option.value));\n const shown = selectedOptions.slice(0, Math.max(1, maxDisplay));\n const overflow = selectedOptions.length - shown.length;\n const limitReached = maxSelected !== undefined && value.length >= maxSelected;\n\n const toggle = (optionValue: string) => {\n if (value.includes(optionValue)) {\n onValueChange(value.filter((item) => item !== optionValue));\n return;\n }\n if (limitReached) return;\n onValueChange([...value, optionValue]);\n };\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && value.length > 0 && !disabled;\n\n return (\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"h-auto min-h-9 w-full justify-start py-1.5 font-normal\",\n showClear ? TRIGGER_PADDING_WITH_CLEAR : TRIGGER_PADDING,\n value.length === 0 && \"text-muted-foreground\",\n className\n )}\n {...aria}\n // Cùng lý do như Combobox đơn.\n aria-invalid={invalid || undefined}\n >\n <span className=\"flex min-w-0 flex-wrap items-center gap-1\">\n {value.length === 0 ? (\n placeholder\n ) : (\n <>\n {shown.map((option) => (\n <Badge key={option.value} variant=\"secondary\" className=\"max-w-[10rem]\">\n <span className=\"truncate\">{option.label}</span>\n </Badge>\n ))}\n {overflow > 0 && <Badge variant=\"outline\">+{overflow}</Badge>}\n </>\n )}\n </span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => {\n const isSelected = value.includes(option.value);\n return (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled || (limitReached && !isSelected)}\n onSelect={() => toggle(option.value)}\n >\n <OptionRow option={option} selected={isSelected} />\n </CommandItem>\n );\n }}\n />\n {maxSelected !== undefined && (\n <p className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n Đã chọn {value.length}/{maxSelected}\n </p>\n )}\n </PopoverContent>\n </Popover>\n\n <ComboboxAdornments\n showClear={showClear}\n disabled={disabled}\n clearLabel=\"Xóa tất cả lựa chọn\"\n onClear={() => onValueChange([])}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -84,9 +84,20 @@ interface DataTableProps<TRow> {
|
|
|
84
84
|
* 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
|
|
85
85
|
* nhiễu. Đây là quyết định của từng trang, không phải mặc định của gói.
|
|
86
86
|
*
|
|
87
|
-
* ⚠ Sọc
|
|
88
|
-
*
|
|
89
|
-
*
|
|
87
|
+
* ⚠ Sọc PHẢI là màu ĐỤC (`--table-row-stripe`), không phải `bg-muted/30` như bản trước.
|
|
88
|
+
*
|
|
89
|
+
* Vì sao: sọc khai bằng biến thể tuỳ ý ở cấp `<table>` nên chọn tử sinh ra là
|
|
90
|
+
* `.[…] tbody tr:nth-child(even)` — độ đặc tả (0,2,2), trong khi `bg-card` mà `<TableRow>` tự
|
|
91
|
+
* 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
|
|
92
|
+
* 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
|
|
93
|
+
* → 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à
|
|
94
|
+
* 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".
|
|
95
|
+
*
|
|
96
|
+
* (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" —
|
|
97
|
+
* 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ì, độ
|
|
98
|
+
* đặ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
|
|
99
|
+
* 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
|
|
100
|
+
* lộ ra. Đừng lấy nền làm dấu hiệu hàng ở bảng có sọc.)
|
|
90
101
|
*/
|
|
91
102
|
striped?: boolean;
|
|
92
103
|
/** Bề rộng tối thiểu của bảng — bật cuộn ngang khi màn hình hẹp */
|
|
@@ -108,7 +108,7 @@ function DataTable({
|
|
|
108
108
|
className: cn(
|
|
109
109
|
"w-full caption-bottom text-sm",
|
|
110
110
|
// Cùng một chuỗi lớp với `<Table striped>` của Tier 1 — đổi một chỗ thì đổi cả hai.
|
|
111
|
-
striped && "[&_tbody_tr:nth-child(even)]:bg-
|
|
111
|
+
striped && "[&_tbody_tr:nth-child(even)]:bg-table-row-stripe [&_tbody_tr:nth-child(even):hover]:bg-table-row-hover",
|
|
112
112
|
className
|
|
113
113
|
),
|
|
114
114
|
style: minWidth != null ? { minWidth } : void 0,
|
|
@@ -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 { 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, DEFAULT_DATA_TABLE_LABELS } 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 /** Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bê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 dùng `bg-muted/30` và đặt trên `<tr>` — cùng chỗ mà nền hàng ĐANG CHỌN / ĐANG MỞ đặt.\n * Nền trạng thái luôn thắng vì nó ghi sau trong chuỗi lớp của `<TableRow>`; ô cột ghim\n * (`bg-inherit`) vì thế vẫn kế thừa đúng nền của hàng.\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 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 = { ...DEFAULT_DATA_TABLE_LABELS, ...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 const cellPadding = density === \"compact\" ? \"px-3 py-1.5\" : \"px-4 py-3\";\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 && \"[&_tbody_tr:nth-child(even)]:bg-muted/30\",\n className\n )}\n style={minWidth != null ? { minWidth } : undefined}\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 className={cellPadding}>\n <Skeleton className=\"size-4 rounded-sm\" />\n </TableCell>\n )}\n {shownColumns.map((column) => (\n <TableCell key={column.id} className={cellPadding}>\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 active && \"bg-app-accent/5\",\n rowClassName?.(row, rowIndex)\n )}\n >\n {selectable && (\n <TableCell\n className={cn(cellPadding, \"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 cellPadding,\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 = { ...DEFAULT_DATA_TABLE_LABELS, ...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":";AAoQgB,cA+BI,YA/BJ;AAlQhB,SAAS,WAAW,SAAS,gBAAgB,OAAO,yBAAyB;AAE7E,SAAS,UAAU;AACnB,SAAS,cAAc;AACvB,SAAS,gBAAgB;AACzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;AACzB,SAAS,WAAW,WAAW,WAAW,aAAa,gBAAgB;AACvE,SAA+B,iCAAiC;AAChE,SAAS,kBAAkB;AAgH3B,MAAM,eAA+C;AAAA,EACnD,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,OAAO;AACT;AAGA,SAAS,iBAAuB,SAAkC,YAAiD;AACjH,QAAM,SAAS,oBAAI,IAA2B;AAE9C,MAAI,OAAO,aAAa,KAAK;AAC7B,aAAW,UAAU,SAAS;AAC5B,QAAI,OAAO,WAAW,OAAQ;AAC9B,WAAO,IAAI,OAAO,IAAI,EAAE,UAAU,UAAU,MAAM,QAAQ,EAAE,CAAC;AAC7D,YAAQ,OAAO,SAAS;AAAA,EAC1B;AAEA,MAAI,QAAQ;AACZ,aAAW,UAAU,CAAC,GAAG,OAAO,EAAE,QAAQ,GAAG;AAC3C,QAAI,OAAO,WAAW,QAAS;AAC/B,WAAO,IAAI,OAAO,IAAI,EAAE,UAAU,UAAU,OAAO,QAAQ,EAAE,CAAC;AAC9D,aAAS,OAAO,SAAS;AAAA,EAC3B;AAEA,SAAO;AACT;AAEA,SAAS,SAAS,SAA2C,UAAwC;AAGnG,MAAI,CAAC,WAAW,QAAQ,aAAa,SAAU,QAAO,EAAE,UAAU,WAAW,MAAM;AACnF,MAAI,QAAQ,cAAc,MAAO,QAAO,EAAE,UAAU,WAAW,OAAO;AACtE,SAAO;AACT;AAUO,SAAS,UAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,kBAAkB;AAAA,EAClB;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,eAAe;AAAA,EACf,UAAU;AAAA,EACV;AAAA,EACA,UAAU;AAAA,EACV,QAAQ;AAAA,EACR;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,mBAAmB;AACrB,GAAyB;AACvB,QAAM,SAAS,EAAE,GAAG,2BAA2B,GAAG,WAAW;AAE7D,QAAM,eAAe,mBAAmB,QAAQ,OAAO,CAAC,WAAW,iBAAiB,SAAS,OAAO,EAAE,CAAC,IAAI;AAC3G,QAAM,eAAe,iBAAiB,cAAc,UAAU;AAC9D,QAAM,WAAW,aAAa,UAAU,aAAa,IAAI;AAEzD,QAAM,WAAW,eAAe,CAAC;AAGjC,QAAM,gBAA0B,CAAC;AACjC,OAAK,QAAQ,CAAC,KAAK,UAAU;AAC3B,QAAI,kBAAkB,KAAK,KAAK,KAAK,KAAM,eAAc,KAAK,SAAS,KAAK,KAAK,CAAC;AAAA,EACpF,CAAC;AACD,QAAM,cAAc,cAAc,SAAS,KAAK,cAAc,MAAM,CAAC,OAAO,SAAS,SAAS,EAAE,CAAC;AACjG,QAAM,eAAe,CAAC,eAAe,cAAc,KAAK,CAAC,OAAO,SAAS,SAAS,EAAE,CAAC;AAErF,QAAM,YAAY,MAAM;AACtB,QAAI,CAAC,oBAAqB;AAC1B,QAAI,aAAa;AACf,0BAAoB,SAAS,OAAO,CAAC,OAAO,CAAC,cAAc,SAAS,EAAE,CAAC,CAAC;AACxE;AAAA,IACF;AACA,wBAAoB,CAAC,GAAG,oBAAI,IAAI,CAAC,GAAG,UAAU,GAAG,aAAa,CAAC,CAAC,CAAC;AAAA,EACnE;AAEA,QAAM,YAAY,CAAC,OAAe;AAChC,QAAI,CAAC,oBAAqB;AAC1B,wBAAoB,SAAS,SAAS,EAAE,IAAI,SAAS,OAAO,CAAC,SAAS,SAAS,EAAE,IAAI,CAAC,GAAG,UAAU,EAAE,CAAC;AAAA,EACxG;AAEA,QAAM,cAAc,YAAY,YAAY,gBAAgB;AAE5D,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,yEAAyE,kBAAkB;AAAA,MAEzG;AAAA,QAAC;AAAA;AAAA,UACC,aAAU;AAAA,UACV,cAAY;AAAA,UACZ,mBAAiB;AAAA,UACjB,aAAW,WAAW;AAAA,UACtB,WAAW;AAAA,YACT;AAAA;AAAA,YAEA,WAAW;AAAA,YACX;AAAA,UACF;AAAA,UACA,OAAO,YAAY,OAAO,EAAE,SAAS,IAAI;AAAA,UAEzC;AAAA,gCAAC,eAAY,WAAW,GAAG,gBAAgB,mBAAmB,GAI5D,+BAAC,YAAS,WAAU,2BACjB;AAAA,4BACC;AAAA,gBAAC;AAAA;AAAA,kBACC,WAAU;AAAA,kBACV,OAAO,EAAE,UAAU,UAAU,MAAM,GAAG,QAAQ,GAAG,OAAO,IAAI,UAAU,GAAG;AAAA,kBAEzE;AAAA,oBAAC;AAAA;AAAA,sBACC,SAAS,cAAc,OAAO,eAAe,kBAAkB;AAAA,sBAC/D,iBAAiB;AAAA,sBACjB,UAAU,cAAc,WAAW;AAAA,sBACnC,cAAY,cAAc,OAAO,kBAAkB,OAAO;AAAA;AAAA,kBAC5D;AAAA;AAAA,cACF;AAAA,cAGD,aAAa,IAAI,CAAC,WAAW;AAC5B,sBAAM,WAAW,MAAM,aAAa,OAAO;AAC3C,sBAAM,WAAW,WAAY,MAAM,cAAc,QAAQ,UAAU,YAAa;AAChF,sBAAM,cAAc,aAAa,IAAI,OAAO,EAAE;AAE9C,uBACE;AAAA,kBAAC;AAAA;AAAA,oBAEC,OAAM;AAAA,oBACN,aAAW,WAAY,MAAM,cAAc,QAAQ,cAAc,eAAgB;AAAA,oBACjF,WAAW;AAAA,sBACT,aAAa,OAAO,SAAS,MAAM;AAAA,sBACnC,eAAe;AAAA,sBACf,OAAO;AAAA,oBACT;AAAA,oBACA,OAAO;AAAA,sBACL,GAAG;AAAA,sBACH,OAAO,OAAO;AAAA,sBACd,UAAU,OAAO,YAAY,OAAO;AAAA,oBACtC;AAAA,oBAEC,iBAAO,YAAY,eAClB;AAAA,sBAAC;AAAA;AAAA,wBACC,MAAK;AAAA,wBACL,SAAS,MAAM,aAAa,SAAS,MAAM,OAAO,EAAE,CAAC;AAAA,wBACrD,WAAW;AAAA,0BACT;AAAA,0BACA,OAAO,UAAU,WAAW;AAAA,0BAC5B,YAAY;AAAA,wBACd;AAAA,wBAEC;AAAA,iCAAO;AAAA,0BAOR,oBAAC,YAAS,eAAY,QAAO,WAAU,YAAW;AAAA;AAAA;AAAA,oBACpD,IAEA,OAAO;AAAA;AAAA,kBAlCJ,OAAO;AAAA,gBAoCd;AAAA,cAEJ,CAAC;AAAA,eACH,GACF;AAAA,YAEA,qBAAC,aACE;AAAA,yBACC,MAAM,KAAK,EAAE,QAAQ,gBAAgB,CAAC,EAAE,IAAI,CAAC,GAAG;AAAA;AAAA,gBAE9C,qBAAC,YAAsC,WAAU,wBAC9C;AAAA,gCACC,oBAAC,aAAU,WAAW,aACpB,8BAAC,YAAS,WAAU,qBAAoB,GAC1C;AAAA,kBAED,aAAa,IAAI,CAAC,WACjB,oBAAC,aAA0B,WAAW,aACpC,8BAAC,YAAS,WAAU,4BAA2B,KADjC,OAAO,EAEvB,CACD;AAAA,qBAVY,YAAY,QAAQ,EAWnC;AAAA,eACD;AAAA,cAEF,CAAC,WAAW,KAAK,WAAW,KAC3B,oBAAC,YAAS,WAAU,wBAClB,8BAAC,aAAU,SAAS,UAAU,WAAU,OACrC,mBAAS,oBAAC,cAAW,MAAM,OAAO,OAAO,OAAO,YAAY,aAAa,OAAO,kBAAkB,GACrG,GACF;AAAA,cAGD,CAAC,WACA,KAAK,IAAI,CAAC,KAAK,aAAa;AAC1B,sBAAM,KAAK,SAAS,KAAK,QAAQ;AACjC,sBAAM,aAAa,SAAS,SAAS,EAAE;AACvC,sBAAM,YAAY,kBAAkB,KAAK,QAAQ,KAAK;AACtD,sBAAM,SAAS,cAAc,KAAK,QAAQ,KAAK;AAE/C,uBACE;AAAA,kBAAC;AAAA;AAAA,oBAEC,cAAY,aAAa,aAAa;AAAA,oBACtC,iBAAe,aAAa,aAAa;AAAA,oBACzC,SAAS,aAAa,MAAM,WAAW,KAAK,QAAQ,IAAI;AAAA,oBAKxD,UAAU,aAAa,IAAI;AAAA,oBAC3B,WACE,aACI,CAAC,UAAU;AAIT,0BAAI,MAAM,WAAW,MAAM,cAAe;AAC1C,0BAAI,MAAM,QAAQ,WAAW,MAAM,QAAQ,IAAK;AAEhD,4BAAM,eAAe;AACrB,iCAAW,KAAK,QAAQ;AAAA,oBAC1B,IACA;AAAA,oBAEN,WAAW;AAAA;AAAA;AAAA,sBAGT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAMA,cACE;AAAA,sBACF,UAAU;AAAA,sBACV,eAAe,KAAK,QAAQ;AAAA,oBAC9B;AAAA,oBAEC;AAAA,oCACC;AAAA,wBAAC;AAAA;AAAA,0BACC,WAAW,GAAG,aAAa,YAAY;AAAA,0BACvC,OAAO,EAAE,UAAU,UAAU,MAAM,GAAG,QAAQ,GAAG,OAAO,IAAI,UAAU,GAAG;AAAA,0BAKzE,8BAAC,UAAK,SAAS,CAAC,UAAU,MAAM,gBAAgB,GAC9C;AAAA,4BAAC;AAAA;AAAA,8BACC,SAAS;AAAA,8BACT,UAAU,CAAC;AAAA,8BACX,iBAAiB,MAAM,UAAU,EAAE;AAAA,8BACnC,cAAY,aAAa,OAAO,cAAc,OAAO;AAAA;AAAA,0BACvD,GACF;AAAA;AAAA,sBACF;AAAA,sBAGD,aAAa,IAAI,CAAC,WAAW;AAC5B,8BAAM,cAAc,aAAa,IAAI,OAAO,EAAE;AAC9C,+BACE;AAAA,0BAAC;AAAA;AAAA,4BAEC,WAAW;AAAA,8BACT;AAAA,8BACA,aAAa,OAAO,SAAS,MAAM;AAAA,8BACnC,eAAe;AAAA,8BACf,OAAO;AAAA,4BACT;AAAA,4BACA,OAAO,EAAE,GAAG,aAAa,OAAO,OAAO,OAAO,UAAU,OAAO,YAAY,OAAO,MAAM;AAAA,4BAEvF,iBAAO,KAAK,KAAK,QAAQ;AAAA;AAAA,0BATrB,OAAO;AAAA,wBAUd;AAAA,sBAEJ,CAAC;AAAA;AAAA;AAAA,kBAzEI;AAAA,gBA0EP;AAAA,cAEJ,CAAC;AAAA,eACL;AAAA;AAAA;AAAA,MACF;AAAA;AAAA,EACF;AAEJ;AAoBO,SAAS,sBAA4B;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR;AACF,GAAqC;AACnC,QAAM,SAAS,EAAE,GAAG,2BAA2B,GAAG,WAAW;AAC7D,QAAM,OAAO,SAAS,OAAO;AAC7B,QAAM,WAAW,QAAQ,OAAO,CAAC,WAAW,OAAO,aAAa,KAAK;AAErE,QAAM,SAAS,CAAC,aAAqB;AAGnC,UAAM,OAAO,iBAAiB,SAAS,QAAQ,IAC3C,iBAAiB,OAAO,CAAC,OAAO,OAAO,QAAQ,IAC/C,QAAQ,OAAO,CAAC,WAAW,iBAAiB,SAAS,OAAO,EAAE,KAAK,OAAO,OAAO,QAAQ,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE;AAC9G,QAAI,KAAK,WAAW,EAAG;AACvB,6BAAyB,IAAI;AAAA,EAC/B;AAEA,SACE,qBAAC,gBACC;AAAA,wBAAC,uBAAoB,SAAO,MAC1B,8BAAC,UAAO,SAAQ,WAAU,MAAK,MAAK,WAAsB,UAAU,oBAAC,qBAAkB,WAAU,UAAS,GACvG,gBACH,GACF;AAAA,IACA,qBAAC,uBAAoB,OAAM,OAAM,WAAU,QACzC;AAAA,0BAAC,qBAAmB,gBAAK;AAAA,MACzB,oBAAC,yBAAsB;AAAA,MACtB,SAAS,IAAI,CAAC,WACb;AAAA,QAAC;AAAA;AAAA,UAEC,SAAS,iBAAiB,SAAS,OAAO,EAAE;AAAA,UAC5C,iBAAiB,MAAM,OAAO,OAAO,EAAE;AAAA,UACvC,UAAU,CAAC,UAAU,MAAM,eAAe;AAAA,UAEzC,iBAAO,UAAU,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS,OAAO;AAAA;AAAA,QALxE,OAAO;AAAA,MAMd,CACD;AAAA,OACH;AAAA,KACF;AAEJ;","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 { 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, DEFAULT_DATA_TABLE_LABELS } 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 /** Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bê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 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 = { ...DEFAULT_DATA_TABLE_LABELS, ...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 const cellPadding = density === \"compact\" ? \"px-3 py-1.5\" : \"px-4 py-3\";\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={minWidth != null ? { minWidth } : undefined}\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 className={cellPadding}>\n <Skeleton className=\"size-4 rounded-sm\" />\n </TableCell>\n )}\n {shownColumns.map((column) => (\n <TableCell key={column.id} className={cellPadding}>\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 active && \"bg-app-accent/5\",\n rowClassName?.(row, rowIndex)\n )}\n >\n {selectable && (\n <TableCell\n className={cn(cellPadding, \"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 cellPadding,\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 = { ...DEFAULT_DATA_TABLE_LABELS, ...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":[]}
|
|
@@ -80,16 +80,36 @@ function DatePicker({
|
|
|
80
80
|
...aria
|
|
81
81
|
}
|
|
82
82
|
),
|
|
83
|
-
/* @__PURE__ */ jsxs("div", { className: "absolute
|
|
84
|
-
clearable && value && !disabled && /* @__PURE__ */ jsxs(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
/* @__PURE__ */ jsxs("div", { className: "pointer-events-none absolute inset-y-0 right-0 flex items-center border border-transparent pr-2", children: [
|
|
84
|
+
clearable && value && !disabled && /* @__PURE__ */ jsxs(
|
|
85
|
+
Button,
|
|
86
|
+
{
|
|
87
|
+
type: "button",
|
|
88
|
+
variant: "ghost",
|
|
89
|
+
size: "icon-sm",
|
|
90
|
+
className: "pointer-events-auto size-6 text-muted-foreground hover:text-foreground",
|
|
91
|
+
onClick: () => onValueChange(null),
|
|
92
|
+
children: [
|
|
93
|
+
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
94
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: "X\xF3a ng\xE0y" })
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
),
|
|
88
98
|
/* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
|
|
89
|
-
/* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
/* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
|
|
100
|
+
Button,
|
|
101
|
+
{
|
|
102
|
+
type: "button",
|
|
103
|
+
variant: "ghost",
|
|
104
|
+
size: "icon-sm",
|
|
105
|
+
className: "pointer-events-auto size-6 text-muted-foreground hover:text-foreground",
|
|
106
|
+
disabled,
|
|
107
|
+
children: [
|
|
108
|
+
/* @__PURE__ */ jsx(CalendarDays, { "aria-hidden": "true", className: "size-4" }),
|
|
109
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: "M\u1EDF l\u1ECBch" })
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
) }),
|
|
93
113
|
/* @__PURE__ */ jsxs(PopoverContent, { align: "end", className: "flex w-auto gap-2 p-2", children: [
|
|
94
114
|
presetList.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex w-32 flex-col gap-1 border-r border-border pr-2", children: presetList.map((preset) => /* @__PURE__ */ jsx(
|
|
95
115
|
Button,
|
|
@@ -163,14 +183,17 @@ function DateRangePicker({
|
|
|
163
183
|
variant: "outline",
|
|
164
184
|
disabled,
|
|
165
185
|
className: cn(
|
|
166
|
-
|
|
186
|
+
// `px-3` chứ không phải `px-4` của `Button`: đây là một Ô NHẬP nằm cùng cột với
|
|
187
|
+
// `Input`/`Select`/`Combobox`, và cả ba đều thụt 12px. `pr-10` = 12 + 24 (nút xóa)
|
|
188
|
+
// + 4, chỉ cần khi nút xóa có mặt.
|
|
189
|
+
"w-full justify-start gap-2 px-3 font-normal",
|
|
167
190
|
!label && "text-muted-foreground",
|
|
168
|
-
showClear && "pr-
|
|
191
|
+
showClear && "pr-10",
|
|
169
192
|
className
|
|
170
193
|
),
|
|
171
194
|
...aria,
|
|
172
195
|
children: [
|
|
173
|
-
/* @__PURE__ */ jsx(CalendarDays, { "aria-hidden": "true", className: "size-4 shrink-0" }),
|
|
196
|
+
/* @__PURE__ */ jsx(CalendarDays, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }),
|
|
174
197
|
/* @__PURE__ */ jsx("span", { className: "truncate", children: label ?? placeholder })
|
|
175
198
|
]
|
|
176
199
|
}
|
|
@@ -206,14 +229,20 @@ function DateRangePicker({
|
|
|
206
229
|
)
|
|
207
230
|
] })
|
|
208
231
|
] }),
|
|
209
|
-
showClear &&
|
|
210
|
-
|
|
232
|
+
showClear && // Cùng hình dạng với nút xóa của `DatePicker`: ô 24px ở `right-2`. Là `<Button>` thật nên
|
|
233
|
+
// có viền focus và vùng bấm đạt 24px — bản `<button>` thô trước đây chỉ 18px.
|
|
234
|
+
/* @__PURE__ */ jsxs(
|
|
235
|
+
Button,
|
|
211
236
|
{
|
|
212
237
|
type: "button",
|
|
213
|
-
|
|
238
|
+
variant: "ghost",
|
|
239
|
+
size: "icon-sm",
|
|
240
|
+
className: "absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground",
|
|
214
241
|
onClick: () => onValueChange(null),
|
|
215
|
-
|
|
216
|
-
|
|
242
|
+
children: [
|
|
243
|
+
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
244
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: "X\xF3a kho\u1EA3ng ng\xE0y" })
|
|
245
|
+
]
|
|
217
246
|
}
|
|
218
247
|
)
|
|
219
248
|
] })
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/date-picker.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, isAfter, isBefore, isValid, parse, startOfDay } from \"date-fns\";\nimport { vi } from \"date-fns/locale\";\nimport { CalendarDays, X } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport type { DateRange, Matcher } from \"react-day-picker\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Calendar } from \"../ui/calendar.js\";\nimport { Input } from \"../ui/input.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport {\n type DatePreset,\n type DateRangePreset,\n DEFAULT_DATE_RANGE_PRESETS,\n VI_DATE_FORMAT\n} from \"./date-picker-constants.js\";\n\nexport type { DateRange };\n\nfunction isOutOfRange(date: Date, minDate?: Date, maxDate?: Date): boolean {\n if (minDate && isBefore(date, startOfDay(minDate))) return true;\n if (maxDate && isAfter(date, startOfDay(maxDate))) return true;\n return false;\n}\n\n/**\n * Chặn ngày ngoài khoảng cho phép — trả về MẢNG matcher, không phải một object.\n *\n * Cạm bẫy react-day-picker: `{ before, after }` trong CÙNG một object là `DateInterval`, nghĩa là\n * \"các ngày NẰM GIỮA\" — ngược hẳn ý định. Tách thành hai matcher riêng mới ra \"trước min HOẶC sau max\".\n */\nfunction buildDisabledMatchers(minDate?: Date, maxDate?: Date): Matcher[] | undefined {\n const matchers: Matcher[] = [];\n if (minDate) matchers.push({ before: startOfDay(minDate) });\n if (maxDate) matchers.push({ after: startOfDay(maxDate) });\n return matchers.length > 0 ? matchers : undefined;\n}\n\nexport interface DatePickerProps {\n value?: Date | null;\n onValueChange: (value: Date | null) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Ngày nhỏ nhất / lớn nhất được chọn */\n minDate?: Date;\n maxDate?: Date;\n /** Hiện nút xoá khi đã có giá trị (mặc định true) */\n clearable?: boolean;\n /** Cột preset bên trái lịch (mặc định ẩn) */\n presets?: DatePreset[] | false;\n className?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n \"aria-required\"?: true;\n}\n\n/**\n * Ô chọn ngày: gõ tay `dd/MM/yyyy` HOẶC bấm lịch.\n *\n * Vì sao vẫn giữ ô gõ tay thay vì chỉ có nút mở lịch: người nhập liệu chuyên nghiệp (kế toán,\n * nhân sự) gõ nhanh hơn bấm nhiều lần, và ngày sinh năm 1975 mà bắt lật lịch 50 năm thì không\n * ai chịu nổi. Chuỗi gõ dở KHÔNG bị ghi đè trong lúc gõ; sai định dạng thì trả về giá trị cũ\n * khi rời ô, không âm thầm nuốt dữ liệu.\n */\nexport function DatePicker({\n value,\n onValueChange,\n placeholder = VI_DATE_FORMAT,\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = false,\n className,\n id,\n name,\n ...aria\n}: DatePickerProps) {\n const [open, setOpen] = useState(false);\n const [text, setText] = useState(() => (value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\"));\n\n // Đồng bộ khi giá trị đổi từ bên ngoài (reset form, chọn preset, load dữ liệu về).\n useEffect(() => {\n setText(value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\");\n }, [value]);\n\n const commitText = (raw: string) => {\n const trimmed = raw.trim();\n if (trimmed === \"\") {\n onValueChange(null);\n return;\n }\n const parsed = parse(trimmed, VI_DATE_FORMAT, new Date(), { locale: vi });\n if (!isValid(parsed) || isOutOfRange(parsed, minDate, maxDate)) {\n // Không hợp lệ → khôi phục hiển thị theo giá trị đang có (không đổi dữ liệu).\n setText(value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\");\n return;\n }\n onValueChange(startOfDay(parsed));\n };\n\n const presetList = presets === false ? [] : presets;\n\n return (\n <div className={cn(\"relative\", className)}>\n <Input\n id={id}\n name={name}\n value={text}\n disabled={disabled}\n placeholder={placeholder}\n inputMode=\"numeric\"\n autoComplete=\"off\"\n onChange={(event) => setText(event.target.value)}\n onBlur={(event) => commitText(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commitText(text);\n }\n }}\n className=\"pr-16\"\n {...aria}\n />\n\n <div className=\"absolute right-1 top-1/2 flex -translate-y-1/2 items-center\">\n {clearable && value && !disabled && (\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className=\"size-7\" onClick={() => onValueChange(null)}>\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">Xóa ngày</span>\n </Button>\n )}\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className=\"size-7\" disabled={disabled}>\n <CalendarDays aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">Mở lịch</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"end\" className=\"flex w-auto gap-2 p-2\">\n {presetList.length > 0 && (\n <div className=\"flex w-32 flex-col gap-1 border-r border-border pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"single\"\n locale={vi}\n autoFocus\n selected={value ?? undefined}\n defaultMonth={value ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(date) => {\n onValueChange(date ? startOfDay(date) : null);\n setOpen(false);\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n </div>\n );\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange | null;\n onValueChange: (value: DateRange | null) => void;\n placeholder?: string;\n disabled?: boolean;\n minDate?: Date;\n maxDate?: Date;\n clearable?: boolean;\n /** Cột preset bên trái (mặc định DEFAULT_DATE_RANGE_PRESETS; `false` để tắt) */\n presets?: DateRangePreset[] | false;\n /** Số tháng hiện cùng lúc (mặc định 2 trên màn rộng) */\n numberOfMonths?: number;\n className?: string;\n id?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n}\n\nfunction formatRange(range: DateRange | null | undefined): string | null {\n if (!range?.from) return null;\n const from = formatDate(range.from, VI_DATE_FORMAT, { locale: vi });\n if (!range.to) return from;\n return `${from} – ${formatDate(range.to, VI_DATE_FORMAT, { locale: vi })}`;\n}\n\n/**\n * Ô chọn khoảng ngày cho báo cáo và bộ lọc danh sách: cột preset + lịch 2 tháng.\n *\n * Preset đứng trước lịch vì 9/10 lần người dùng muốn \"7 ngày qua\" chứ không phải một khoảng\n * tuỳ ý — bắt họ bấm hai lần trên lịch mỗi lần xem báo cáo là thuế lặp lại vô ích.\n */\nexport function DateRangePicker({\n value,\n onValueChange,\n placeholder = \"Chọn khoảng ngày\",\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = DEFAULT_DATE_RANGE_PRESETS,\n numberOfMonths = 2,\n className,\n id,\n ...aria\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false);\n const label = formatRange(value);\n const presetList = presets === false ? [] : presets;\n\n const showClear = clearable && Boolean(label) && !disabled;\n\n return (\n /* Nút xóa nằm NGOÀI trigger (anh em), không lồng trong <button> — vừa hợp lệ về HTML vừa\n đến được bằng phím Tab. */\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n id={id}\n variant=\"outline\"\n disabled={disabled}\n className={cn(\n \"w-full justify-start gap-2 font-normal\",\n !label && \"text-muted-foreground\",\n showClear && \"pr-9\",\n className\n )}\n {...aria}\n >\n <CalendarDays aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n <span className=\"truncate\">{label ?? placeholder}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"flex w-auto flex-col gap-2 p-2 sm:flex-row\">\n {presetList.length > 0 && (\n <div className=\"flex flex-row flex-wrap gap-1 sm:w-36 sm:flex-col sm:border-r sm:border-border sm:pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"range\"\n locale={vi}\n autoFocus\n numberOfMonths={numberOfMonths}\n selected={value ?? undefined}\n defaultMonth={value?.from ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(range) => onValueChange(range ?? null)}\n />\n </PopoverContent>\n </Popover>\n\n {showClear && (\n <button\n type=\"button\"\n aria-label=\"Xóa khoảng ngày\"\n onClick={() => onValueChange(null)}\n className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/date-picker.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, isAfter, isBefore, isValid, parse, startOfDay } from \"date-fns\";\nimport { vi } from \"date-fns/locale\";\nimport { CalendarDays, X } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport type { DateRange, Matcher } from \"react-day-picker\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Calendar } from \"../ui/calendar.js\";\nimport { Input } from \"../ui/input.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport {\n type DatePreset,\n type DateRangePreset,\n DEFAULT_DATE_RANGE_PRESETS,\n VI_DATE_FORMAT\n} from \"./date-picker-constants.js\";\n\nexport type { DateRange };\n\nfunction isOutOfRange(date: Date, minDate?: Date, maxDate?: Date): boolean {\n if (minDate && isBefore(date, startOfDay(minDate))) return true;\n if (maxDate && isAfter(date, startOfDay(maxDate))) return true;\n return false;\n}\n\n/**\n * Chặn ngày ngoài khoảng cho phép — trả về MẢNG matcher, không phải một object.\n *\n * Cạm bẫy react-day-picker: `{ before, after }` trong CÙNG một object là `DateInterval`, nghĩa là\n * \"các ngày NẰM GIỮA\" — ngược hẳn ý định. Tách thành hai matcher riêng mới ra \"trước min HOẶC sau max\".\n */\nfunction buildDisabledMatchers(minDate?: Date, maxDate?: Date): Matcher[] | undefined {\n const matchers: Matcher[] = [];\n if (minDate) matchers.push({ before: startOfDay(minDate) });\n if (maxDate) matchers.push({ after: startOfDay(maxDate) });\n return matchers.length > 0 ? matchers : undefined;\n}\n\nexport interface DatePickerProps {\n value?: Date | null;\n onValueChange: (value: Date | null) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Ngày nhỏ nhất / lớn nhất được chọn */\n minDate?: Date;\n maxDate?: Date;\n /** Hiện nút xoá khi đã có giá trị (mặc định true) */\n clearable?: boolean;\n /** Cột preset bên trái lịch (mặc định ẩn) */\n presets?: DatePreset[] | false;\n className?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n \"aria-required\"?: true;\n}\n\n/**\n * Ô chọn ngày: gõ tay `dd/MM/yyyy` HOẶC bấm lịch.\n *\n * Vì sao vẫn giữ ô gõ tay thay vì chỉ có nút mở lịch: người nhập liệu chuyên nghiệp (kế toán,\n * nhân sự) gõ nhanh hơn bấm nhiều lần, và ngày sinh năm 1975 mà bắt lật lịch 50 năm thì không\n * ai chịu nổi. Chuỗi gõ dở KHÔNG bị ghi đè trong lúc gõ; sai định dạng thì trả về giá trị cũ\n * khi rời ô, không âm thầm nuốt dữ liệu.\n */\nexport function DatePicker({\n value,\n onValueChange,\n placeholder = VI_DATE_FORMAT,\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = false,\n className,\n id,\n name,\n ...aria\n}: DatePickerProps) {\n const [open, setOpen] = useState(false);\n const [text, setText] = useState(() => (value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\"));\n\n // Đồng bộ khi giá trị đổi từ bên ngoài (reset form, chọn preset, load dữ liệu về).\n useEffect(() => {\n setText(value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\");\n }, [value]);\n\n const commitText = (raw: string) => {\n const trimmed = raw.trim();\n if (trimmed === \"\") {\n onValueChange(null);\n return;\n }\n const parsed = parse(trimmed, VI_DATE_FORMAT, new Date(), { locale: vi });\n if (!isValid(parsed) || isOutOfRange(parsed, minDate, maxDate)) {\n // Không hợp lệ → khôi phục hiển thị theo giá trị đang có (không đổi dữ liệu).\n setText(value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\");\n return;\n }\n onValueChange(startOfDay(parsed));\n };\n\n const presetList = presets === false ? [] : presets;\n\n return (\n <div className={cn(\"relative\", className)}>\n <Input\n id={id}\n name={name}\n value={text}\n disabled={disabled}\n placeholder={placeholder}\n inputMode=\"numeric\"\n autoComplete=\"off\"\n onChange={(event) => setText(event.target.value)}\n onBlur={(event) => commitText(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commitText(text);\n }\n }}\n className=\"pr-16\"\n {...aria}\n />\n\n {/* Nút `size-6`: glyph 16px nằm giữa ô 24px nên nó thụt thêm 4px so với mép ô — cộng với\n `pr-2` của hàng là glyph rơi đúng mốc 12px của `Select`/`Combobox`. Ba loại control này\n hay đứng chung một cột biểu mẫu, lệch vài px là mép phải nhìn răng cưa.\n\n `border border-transparent`: hàng nằm ngoài `Input` nên đo từ mép BORDER, còn icon nằm\n trong ô thì đo từ mép CONTENT — mượn lại viền 1px là hai mốc trùng nhau.\n\n Icon lấy `text-muted-foreground`: `Button variant=\"ghost\"` mặc định là `text-foreground`,\n đậm hơn hẳn chevron của `Select` (vốn `text-muted-foreground`). Chúng là cùng một hạng\n trang trí của ô nhập thì phải cùng một sắc. */}\n {/* `pointer-events-none` ở hàng, `pointer-events-auto` ở từng nút: phần đệm của hàng phủ lên\n dải sát mép phải của ô nhập, để nguyên là bấm vào đó không đặt được con trỏ vào ô. */}\n <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center border border-transparent pr-2\">\n {clearable && value && !disabled && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n onClick={() => onValueChange(null)}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">Xóa ngày</span>\n </Button>\n )}\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n disabled={disabled}\n >\n <CalendarDays aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">Mở lịch</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"end\" className=\"flex w-auto gap-2 p-2\">\n {presetList.length > 0 && (\n <div className=\"flex w-32 flex-col gap-1 border-r border-border pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"single\"\n locale={vi}\n autoFocus\n selected={value ?? undefined}\n defaultMonth={value ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(date) => {\n onValueChange(date ? startOfDay(date) : null);\n setOpen(false);\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n </div>\n );\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange | null;\n onValueChange: (value: DateRange | null) => void;\n placeholder?: string;\n disabled?: boolean;\n minDate?: Date;\n maxDate?: Date;\n clearable?: boolean;\n /** Cột preset bên trái (mặc định DEFAULT_DATE_RANGE_PRESETS; `false` để tắt) */\n presets?: DateRangePreset[] | false;\n /** Số tháng hiện cùng lúc (mặc định 2 trên màn rộng) */\n numberOfMonths?: number;\n className?: string;\n id?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n}\n\nfunction formatRange(range: DateRange | null | undefined): string | null {\n if (!range?.from) return null;\n const from = formatDate(range.from, VI_DATE_FORMAT, { locale: vi });\n if (!range.to) return from;\n return `${from} – ${formatDate(range.to, VI_DATE_FORMAT, { locale: vi })}`;\n}\n\n/**\n * Ô chọn khoảng ngày cho báo cáo và bộ lọc danh sách: cột preset + lịch 2 tháng.\n *\n * Preset đứng trước lịch vì 9/10 lần người dùng muốn \"7 ngày qua\" chứ không phải một khoảng\n * tuỳ ý — bắt họ bấm hai lần trên lịch mỗi lần xem báo cáo là thuế lặp lại vô ích.\n */\nexport function DateRangePicker({\n value,\n onValueChange,\n placeholder = \"Chọn khoảng ngày\",\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = DEFAULT_DATE_RANGE_PRESETS,\n numberOfMonths = 2,\n className,\n id,\n ...aria\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false);\n const label = formatRange(value);\n const presetList = presets === false ? [] : presets;\n\n const showClear = clearable && Boolean(label) && !disabled;\n\n return (\n /* Nút xóa nằm NGOÀI trigger (anh em), không lồng trong <button> — vừa hợp lệ về HTML vừa\n đến được bằng phím Tab. */\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n id={id}\n variant=\"outline\"\n disabled={disabled}\n className={cn(\n // `px-3` chứ không phải `px-4` của `Button`: đây là một Ô NHẬP nằm cùng cột với\n // `Input`/`Select`/`Combobox`, và cả ba đều thụt 12px. `pr-10` = 12 + 24 (nút xóa)\n // + 4, chỉ cần khi nút xóa có mặt.\n \"w-full justify-start gap-2 px-3 font-normal\",\n !label && \"text-muted-foreground\",\n showClear && \"pr-10\",\n className\n )}\n {...aria}\n >\n <CalendarDays aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n <span className=\"truncate\">{label ?? placeholder}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"flex w-auto flex-col gap-2 p-2 sm:flex-row\">\n {presetList.length > 0 && (\n <div className=\"flex flex-row flex-wrap gap-1 sm:w-36 sm:flex-col sm:border-r sm:border-border sm:pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"range\"\n locale={vi}\n autoFocus\n numberOfMonths={numberOfMonths}\n selected={value ?? undefined}\n defaultMonth={value?.from ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(range) => onValueChange(range ?? null)}\n />\n </PopoverContent>\n </Popover>\n\n {showClear && (\n // Cùng hình dạng với nút xóa của `DatePicker`: ô 24px ở `right-2`. Là `<Button>` thật nên\n // có viền focus và vùng bấm đạt 24px — bản `<button>` thô trước đây chỉ 18px.\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n onClick={() => onValueChange(null)}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">Xóa khoảng ngày</span>\n </Button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -65,15 +65,15 @@ function StatCard({
|
|
|
65
65
|
/* `font-bold` theo bản duyệt. `tabular-nums` là khoản gói thêm và giữ lại: chữ số
|
|
66
66
|
đều bề ngang thì một hàng bốn thẻ không nhảy khi số đổi. */
|
|
67
67
|
/* @__PURE__ */ jsx("p", { className: "mt-1 text-2xl font-bold tabular-nums leading-tight text-foreground", children: value })
|
|
68
|
-
)
|
|
69
|
-
delta && !loading && DeltaIcon && /* @__PURE__ */ jsxs("p", { className: cn("mt-1 flex items-center gap-1 text-xs font-medium", deltaToneClass(delta)), children: [
|
|
70
|
-
/* @__PURE__ */ jsx(DeltaIcon, { "aria-hidden": "true", className: "size-3.5 shrink-0" }),
|
|
71
|
-
/* @__PURE__ */ jsx("span", { className: "tabular-nums", children: delta.value }),
|
|
72
|
-
delta.label && /* @__PURE__ */ jsx("span", { className: "font-normal text-muted-foreground", children: delta.label })
|
|
73
|
-
] })
|
|
68
|
+
)
|
|
74
69
|
] }),
|
|
75
70
|
Icon && /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: statCardIconVariants({ tone }), children: /* @__PURE__ */ jsx(Icon, { className: "size-3.5" }) })
|
|
76
71
|
] }),
|
|
72
|
+
delta && !loading && DeltaIcon && /* @__PURE__ */ jsxs("p", { className: cn("mt-1 flex flex-wrap items-center gap-x-1 text-xs font-medium", deltaToneClass(delta)), children: [
|
|
73
|
+
/* @__PURE__ */ jsx(DeltaIcon, { "aria-hidden": "true", className: "size-3.5 shrink-0" }),
|
|
74
|
+
/* @__PURE__ */ jsx("span", { className: "whitespace-nowrap tabular-nums", children: delta.value }),
|
|
75
|
+
delta.label && /* @__PURE__ */ jsx("span", { className: "font-normal text-muted-foreground", children: delta.label })
|
|
76
|
+
] }),
|
|
77
77
|
sparkline && /* @__PURE__ */ jsx("div", { className: "mt-3 h-10", children: sparkline }),
|
|
78
78
|
footer && /* @__PURE__ */ jsx("div", { className: "mt-3 border-t border-border pt-2 text-xs text-muted-foreground", children: footer })
|
|
79
79
|
]
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/stat-card.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport { ArrowDownRight, ArrowUpRight, Minus } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\n/**\n * Ô icon của StatCard, tô theo tone token (không hex).\n *\n * Icon là thành phần PHI VĂN BẢN → ngưỡng WCAG 1.4.11 là 3:1 so với nền tint mà nó đứng lên.\n * Màu icon vì thế phải là bậc `--x-ink`, không phải token tô nền `--x`: `text-success` trên\n * `bg-success/15` chỉ được **2,69:1 ✗** ở light (dark thì 7,44:1 — mỗi theme hỏng một nửa là\n * dấu hiệu dùng sai vai trò token). Đo lại sau khi đổi, trên tint đã trộn với `--card`:\n *\n * success 5,60:1 (L) · 7,44:1 (D) · info 5,54:1 (L) · 7,12:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) · destructive 5,76:1 (L) · 5,71:1 (D)\n * default 4,89:1 (L) · 4,86:1 (D) · primary 5,77:1 (L) · 7,26:1 (D)\n * app 6,14:1 (L) · 9,61:1 (D)\n *\n * `warning` bỏ cặp `text-gold-500 dark:text-warning`: `--warning-ink` đúng bằng `--gold-500` ở\n * light và `--yellow` ở dark, nên một class là đủ.\n */\n/*\n * Hình học của ô icon lấy theo mẫu StatCards ở mục **Patterns** của comitor-ds:\n * `p-1.5` quanh icon 14px + `rounded-lg` — tức ô ~28px, KHÔNG phải 36px với `rounded-md`.\n */\nexport const statCardIconVariants = cva(\"flex size-7 shrink-0 items-center justify-center rounded-lg\", {\n variants: {\n tone: {\n default: \"bg-muted text-muted-foreground\",\n // Đi theo `--primary`, không phải thang `gold-*` chôn cứng. Bản cũ\n // `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100` đạt tương phản\n // (7,77:1 L · 11,85:1 D) nhưng đóng băng màu: app đổi brand thì ô icon vẫn vàng, và phải\n // viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D).\n primary: \"bg-primary/20 text-primary-ink\",\n success: \"bg-success/15 text-success-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\",\n info: \"bg-info/15 text-info-ink\",\n // Icon app đứng trên tint của CHÍNH nó → phải là bậc ink, đúng như năm tone trên.\n // `text-app-accent` (gold #E8B824) trên `bg-app-accent/10` (trộn ra #FDF8E9) chỉ 1,75:1 ✗.\n // `text-foreground` như bản trước thì ĐỌC được (17,81:1) nhưng bỏ mất màu app — tức là bỏ\n // mất đúng thứ tone này sinh ra để nói. `text-app-accent-ink`: 6,14:1 (L) · 9,61:1 (D) ✓\n // — hợp đồng §5, cùng lối chữa với AppLauncher.\n app: \"bg-app-accent/10 text-app-accent-ink\"\n }\n },\n defaultVariants: {\n tone: \"default\"\n }\n});\n\nexport type StatCardTone = NonNullable<VariantProps<typeof statCardIconVariants>[\"tone\"]>;\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\";\n\nexport interface StatCardDelta {\n /** Nội dung hiển thị, vd \"+12,5%\" hoặc \"3 việc\" */\n value: ReactNode;\n direction: StatCardDeltaDirection;\n /**\n * Tăng là TỐT hay XẤU — mặc định \"positive-up\" (tăng = tốt).\n *\n * Cần thiết vì có chỉ số ngược chiều: số ticket quá hạn tăng là xấu, tỷ lệ rời bỏ giảm là tốt.\n * Tô màu theo hướng mũi tên một cách máy móc sẽ nói dối người đọc.\n */\n polarity?: \"positive-up\" | \"positive-down\" | \"neutral\";\n /** Chú thích sau con số, vd \"so với tuần trước\" */\n label?: ReactNode;\n}\n\nexport interface StatCardProps extends Omit<ComponentProps<\"div\">, \"title\"> {\n /** Nhãn chỉ số, vd \"Việc đang mở\" */\n label: ReactNode;\n value: ReactNode;\n icon?: IconComponent;\n tone?: StatCardTone;\n delta?: StatCardDelta;\n /** Slot biểu đồ mini (sparkline) — package không ép thư viện biểu đồ nào */\n sparkline?: ReactNode;\n /** Dòng chú thích dưới cùng */\n footer?: ReactNode;\n /** Đang tải → khung xương thay cho số liệu */\n loading?: boolean;\n}\n\nconst deltaIcons: Record<StatCardDeltaDirection, IconComponent> = {\n up: ArrowUpRight,\n down: ArrowDownRight,\n flat: Minus\n};\n\n/**\n * Màu của delta = hướng × cực tính, không phải chỉ hướng.\n *\n * Dòng biến động là CHỮ (12px, `text-xs`) đứng trên `bg-card` → ngưỡng AA 4,5:1, và phải đạt ở\n * cả hai theme. Token tô nền không làm được việc đó: `text-success` chỉ 3,13:1 ở light còn\n * `text-destructive` chỉ 3,53:1 ở dark — mỗi theme hỏng một nửa. Bậc ink đạt cả hai:\n * success 6,52:1 (L) / 10,19:1 (D) · destructive 7,15:1 (L) / 6,42:1 (D) · muted-foreground\n * 5,23:1 (L) / 5,35:1 (D).\n */\nfunction deltaToneClass({ direction, polarity = \"positive-up\" }: StatCardDelta): string {\n if (direction === \"flat\" || polarity === \"neutral\") return \"text-muted-foreground\";\n const isGood = polarity === \"positive-up\" ? direction === \"up\" : direction === \"down\";\n return isGood ? \"text-success-ink\" : \"text-destructive-ink\";\n}\n\n/**\n * Thẻ KPI: nhãn + số liệu lớn + biến động + sparkline tuỳ chọn.\n *\n * Số liệu dùng `tabular-nums` để cột số không nhảy khi cập nhật realtime. Tĩnh → RSC-safe.\n */\nexport function StatCard({\n label,\n value,\n icon: Icon,\n tone = \"default\",\n delta,\n sparkline,\n footer,\n loading = false,\n className,\n ...props\n}: StatCardProps) {\n const DeltaIcon = delta ? deltaIcons[delta.direction] : undefined;\n\n return (\n <div\n data-slot=\"stat-card\"\n /*\n * `p-5`, và KHÔNG có đổ bóng — đúng mẫu StatCards ở mục Patterns của comitor-ds\n * (`rounded-xl border border-border bg-card p-5`). Bản trước dùng `p-4 shadow-card`; trên\n * một hàng bốn thẻ thì bóng đó nổi rõ và làm lưới thẻ nặng hơn bản duyệt.\n */\n className={cn(\"rounded-xl border border-border bg-card p-5\", className)}\n {...props}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"min-w-0 flex-1\">\n {/* `text-xs font-medium` theo bản duyệt, không phải `text-sm` thường. */}\n <p className=\"truncate text-xs font-medium text-muted-foreground\">{label}</p>\n {loading ? (\n <Skeleton className=\"mt-2 h-7 w-24\" />\n ) : (\n /* `font-bold` theo bản duyệt. `tabular-nums` là khoản gói thêm và giữ lại: chữ số\n đều bề ngang thì một hàng bốn thẻ không nhảy khi số đổi. */\n <p className=\"mt-1 text-2xl font-bold tabular-nums leading-tight text-foreground\">{value}</p>\n )}\n {delta && !loading && DeltaIcon && (\n
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/stat-card.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport { ArrowDownRight, ArrowUpRight, Minus } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\n/**\n * Ô icon của StatCard, tô theo tone token (không hex).\n *\n * Icon là thành phần PHI VĂN BẢN → ngưỡng WCAG 1.4.11 là 3:1 so với nền tint mà nó đứng lên.\n * Màu icon vì thế phải là bậc `--x-ink`, không phải token tô nền `--x`: `text-success` trên\n * `bg-success/15` chỉ được **2,69:1 ✗** ở light (dark thì 7,44:1 — mỗi theme hỏng một nửa là\n * dấu hiệu dùng sai vai trò token). Đo lại sau khi đổi, trên tint đã trộn với `--card`:\n *\n * success 5,60:1 (L) · 7,44:1 (D) · info 5,54:1 (L) · 7,12:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) · destructive 5,76:1 (L) · 5,71:1 (D)\n * default 4,89:1 (L) · 4,86:1 (D) · primary 5,77:1 (L) · 7,26:1 (D)\n * app 6,14:1 (L) · 9,61:1 (D)\n *\n * `warning` bỏ cặp `text-gold-500 dark:text-warning`: `--warning-ink` đúng bằng `--gold-500` ở\n * light và `--yellow` ở dark, nên một class là đủ.\n */\n/*\n * Hình học của ô icon lấy theo mẫu StatCards ở mục **Patterns** của comitor-ds:\n * `p-1.5` quanh icon 14px + `rounded-lg` — tức ô ~28px, KHÔNG phải 36px với `rounded-md`.\n */\nexport const statCardIconVariants = cva(\"flex size-7 shrink-0 items-center justify-center rounded-lg\", {\n variants: {\n tone: {\n default: \"bg-muted text-muted-foreground\",\n // Đi theo `--primary`, không phải thang `gold-*` chôn cứng. Bản cũ\n // `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100` đạt tương phản\n // (7,77:1 L · 11,85:1 D) nhưng đóng băng màu: app đổi brand thì ô icon vẫn vàng, và phải\n // viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D).\n primary: \"bg-primary/20 text-primary-ink\",\n success: \"bg-success/15 text-success-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\",\n info: \"bg-info/15 text-info-ink\",\n // Icon app đứng trên tint của CHÍNH nó → phải là bậc ink, đúng như năm tone trên.\n // `text-app-accent` (gold #E8B824) trên `bg-app-accent/10` (trộn ra #FDF8E9) chỉ 1,75:1 ✗.\n // `text-foreground` như bản trước thì ĐỌC được (17,81:1) nhưng bỏ mất màu app — tức là bỏ\n // mất đúng thứ tone này sinh ra để nói. `text-app-accent-ink`: 6,14:1 (L) · 9,61:1 (D) ✓\n // — hợp đồng §5, cùng lối chữa với AppLauncher.\n app: \"bg-app-accent/10 text-app-accent-ink\"\n }\n },\n defaultVariants: {\n tone: \"default\"\n }\n});\n\nexport type StatCardTone = NonNullable<VariantProps<typeof statCardIconVariants>[\"tone\"]>;\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\";\n\nexport interface StatCardDelta {\n /** Nội dung hiển thị, vd \"+12,5%\" hoặc \"3 việc\" */\n value: ReactNode;\n direction: StatCardDeltaDirection;\n /**\n * Tăng là TỐT hay XẤU — mặc định \"positive-up\" (tăng = tốt).\n *\n * Cần thiết vì có chỉ số ngược chiều: số ticket quá hạn tăng là xấu, tỷ lệ rời bỏ giảm là tốt.\n * Tô màu theo hướng mũi tên một cách máy móc sẽ nói dối người đọc.\n */\n polarity?: \"positive-up\" | \"positive-down\" | \"neutral\";\n /** Chú thích sau con số, vd \"so với tuần trước\" */\n label?: ReactNode;\n}\n\nexport interface StatCardProps extends Omit<ComponentProps<\"div\">, \"title\"> {\n /** Nhãn chỉ số, vd \"Việc đang mở\" */\n label: ReactNode;\n value: ReactNode;\n icon?: IconComponent;\n tone?: StatCardTone;\n delta?: StatCardDelta;\n /** Slot biểu đồ mini (sparkline) — package không ép thư viện biểu đồ nào */\n sparkline?: ReactNode;\n /** Dòng chú thích dưới cùng */\n footer?: ReactNode;\n /** Đang tải → khung xương thay cho số liệu */\n loading?: boolean;\n}\n\nconst deltaIcons: Record<StatCardDeltaDirection, IconComponent> = {\n up: ArrowUpRight,\n down: ArrowDownRight,\n flat: Minus\n};\n\n/**\n * Màu của delta = hướng × cực tính, không phải chỉ hướng.\n *\n * Dòng biến động là CHỮ (12px, `text-xs`) đứng trên `bg-card` → ngưỡng AA 4,5:1, và phải đạt ở\n * cả hai theme. Token tô nền không làm được việc đó: `text-success` chỉ 3,13:1 ở light còn\n * `text-destructive` chỉ 3,53:1 ở dark — mỗi theme hỏng một nửa. Bậc ink đạt cả hai:\n * success 6,52:1 (L) / 10,19:1 (D) · destructive 7,15:1 (L) / 6,42:1 (D) · muted-foreground\n * 5,23:1 (L) / 5,35:1 (D).\n */\nfunction deltaToneClass({ direction, polarity = \"positive-up\" }: StatCardDelta): string {\n if (direction === \"flat\" || polarity === \"neutral\") return \"text-muted-foreground\";\n const isGood = polarity === \"positive-up\" ? direction === \"up\" : direction === \"down\";\n return isGood ? \"text-success-ink\" : \"text-destructive-ink\";\n}\n\n/**\n * Thẻ KPI: nhãn + số liệu lớn + biến động + sparkline tuỳ chọn.\n *\n * Số liệu dùng `tabular-nums` để cột số không nhảy khi cập nhật realtime. Tĩnh → RSC-safe.\n */\nexport function StatCard({\n label,\n value,\n icon: Icon,\n tone = \"default\",\n delta,\n sparkline,\n footer,\n loading = false,\n className,\n ...props\n}: StatCardProps) {\n const DeltaIcon = delta ? deltaIcons[delta.direction] : undefined;\n\n return (\n <div\n data-slot=\"stat-card\"\n /*\n * `p-5`, và KHÔNG có đổ bóng — đúng mẫu StatCards ở mục Patterns của comitor-ds\n * (`rounded-xl border border-border bg-card p-5`). Bản trước dùng `p-4 shadow-card`; trên\n * một hàng bốn thẻ thì bóng đó nổi rõ và làm lưới thẻ nặng hơn bản duyệt.\n */\n className={cn(\"rounded-xl border border-border bg-card p-5\", className)}\n {...props}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"min-w-0 flex-1\">\n {/* `text-xs font-medium` theo bản duyệt, không phải `text-sm` thường. */}\n <p className=\"truncate text-xs font-medium text-muted-foreground\">{label}</p>\n {loading ? (\n <Skeleton className=\"mt-2 h-7 w-24\" />\n ) : (\n /* `font-bold` theo bản duyệt. `tabular-nums` là khoản gói thêm và giữ lại: chữ số\n đều bề ngang thì một hàng bốn thẻ không nhảy khi số đổi. */\n <p className=\"mt-1 text-2xl font-bold tabular-nums leading-tight text-foreground\">{value}</p>\n )}\n </div>\n {Icon && (\n <span aria-hidden=\"true\" className={statCardIconVariants({ tone })}>\n <Icon className=\"size-3.5\" />\n </span>\n )}\n </div>\n\n {/*\n * Dòng biến động nằm NGOÀI hàng hai cột ở trên, và đó là chủ ý.\n *\n * Ô icon chỉ cao 28px trong khi nhãn + số liệu đã cao ~46px, nên dòng này luôn rơi xuống\n * DƯỚI icon — nhưng hồi nó còn nằm trong cột trái thì nó vẫn bị bó theo bề rộng của cột đó\n * (147px trên thẻ 229px) và bỏ không phần dưới ô icon. Hệ quả nhìn thấy được: \"↗ +3 việc so\n * với tuần trước\" cần 171px nên nó vỡ đôi, mà vỡ theo kiểu tệ nhất — hai `<span>` là flex\n * item nên mỗi cái tự co về min-content rồi xuống dòng BÊN TRONG, ra thành hai cột lệch\n * \"+3 / việc\" và \"so với tuần / trước\". Đưa ra ngoài là dòng này được cả 189px, vừa một dòng.\n *\n * `flex-wrap` + `whitespace-nowrap` ở phần số là lưới an toàn cho thẻ hẹp hơn nữa: hết chỗ\n * thì chú thích xuống hàng NGUYÊN CỤM, không còn chuyện tách \"+3\" khỏi \"việc\".\n */}\n {delta && !loading && DeltaIcon && (\n <p className={cn(\"mt-1 flex flex-wrap items-center gap-x-1 text-xs font-medium\", deltaToneClass(delta))}>\n <DeltaIcon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n <span className=\"whitespace-nowrap tabular-nums\">{delta.value}</span>\n {delta.label && <span className=\"font-normal text-muted-foreground\">{delta.label}</span>}\n </p>\n )}\n\n {sparkline && <div className=\"mt-3 h-10\">{sparkline}</div>}\n {footer && <div className=\"mt-3 border-t border-border pt-2 text-xs text-muted-foreground\">{footer}</div>}\n </div>\n );\n}\n"],"mappings":"AA2IQ,SAEE,KAFF;AA3IR,SAAS,WAA8B;AACvC,SAAS,gBAAgB,cAAc,aAAa;AAEpD,SAAS,UAAU;AACnB,SAAS,gBAAgB;AAuBlB,MAAM,uBAAuB,IAAI,+DAA+D;AAAA,EACrG,UAAU;AAAA,IACR,MAAM;AAAA,MACJ,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,MAKT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMN,KAAK;AAAA,IACP;AAAA,EACF;AAAA,EACA,iBAAiB;AAAA,IACf,MAAM;AAAA,EACR;AACF,CAAC;AAoCD,MAAM,aAA4D;AAAA,EAChE,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,MAAM;AACR;AAWA,SAAS,eAAe,EAAE,WAAW,WAAW,cAAc,GAA0B;AACtF,MAAI,cAAc,UAAU,aAAa,UAAW,QAAO;AAC3D,QAAM,SAAS,aAAa,gBAAgB,cAAc,OAAO,cAAc;AAC/E,SAAO,SAAS,qBAAqB;AACvC;AAOO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,YAAY,QAAQ,WAAW,MAAM,SAAS,IAAI;AAExD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MAMV,WAAW,GAAG,+CAA+C,SAAS;AAAA,MACrE,GAAG;AAAA,MAEJ;AAAA,6BAAC,SAAI,WAAU,0CACb;AAAA,+BAAC,SAAI,WAAU,kBAEb;AAAA,gCAAC,OAAE,WAAU,sDAAsD,iBAAM;AAAA,YACxE,UACC,oBAAC,YAAS,WAAU,iBAAgB;AAAA;AAAA;AAAA,cAIpC,oBAAC,OAAE,WAAU,sEAAsE,iBAAM;AAAA;AAAA,aAE7F;AAAA,UACC,QACC,oBAAC,UAAK,eAAY,QAAO,WAAW,qBAAqB,EAAE,KAAK,CAAC,GAC/D,8BAAC,QAAK,WAAU,YAAW,GAC7B;AAAA,WAEJ;AAAA,QAeC,SAAS,CAAC,WAAW,aACpB,qBAAC,OAAE,WAAW,GAAG,gEAAgE,eAAe,KAAK,CAAC,GACpG;AAAA,8BAAC,aAAU,eAAY,QAAO,WAAU,qBAAoB;AAAA,UAC5D,oBAAC,UAAK,WAAU,kCAAkC,gBAAM,OAAM;AAAA,UAC7D,MAAM,SAAS,oBAAC,UAAK,WAAU,qCAAqC,gBAAM,OAAM;AAAA,WACnF;AAAA,QAGD,aAAa,oBAAC,SAAI,WAAU,aAAa,qBAAU;AAAA,QACnD,UAAU,oBAAC,SAAI,WAAU,kEAAkE,kBAAO;AAAA;AAAA;AAAA,EACrG;AAEJ;","names":[]}
|