@comitor/ui 3.12.1 → 3.13.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/dist/components/composite/data-table.d.ts +2 -5
- package/dist/components/composite/data-table.js +9 -33
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/form-section.js +5 -5
- package/dist/components/composite/form-section.js.map +1 -1
- package/dist/components/composite/gantt.js +47 -28
- package/dist/components/composite/gantt.js.map +1 -1
- package/dist/components/composite/inline-edit.js +33 -38
- package/dist/components/composite/inline-edit.js.map +1 -1
- package/dist/components/composite/search-filter-bar.js +2 -1
- package/dist/components/composite/search-filter-bar.js.map +1 -1
- package/dist/components/composite/status-config.d.ts +17 -15
- package/dist/components/composite/status-config.js +6 -2
- package/dist/components/composite/status-config.js.map +1 -1
- package/dist/components/ui/breadcrumb.js +1 -4
- package/dist/components/ui/breadcrumb.js.map +1 -1
- package/dist/components/ui/button.js +7 -7
- package/dist/components/ui/button.js.map +1 -1
- package/dist/components/ui/circular-progress.d.ts +9 -4
- package/dist/components/ui/circular-progress.js +58 -24
- package/dist/components/ui/circular-progress.js.map +1 -1
- package/dist/components/ui/progress.d.ts +6 -0
- package/dist/components/ui/progress.js +2 -2
- package/dist/components/ui/progress.js.map +1 -1
- package/dist/components/ui/table.d.ts +7 -13
- package/dist/components/ui/table.js +1 -1
- package/dist/components/ui/table.js.map +1 -1
- package/dist/components/ui/tabs-variants.js +2 -2
- package/dist/components/ui/tabs-variants.js.map +1 -1
- package/dist/components/ui/timeline.js +19 -14
- package/dist/components/ui/timeline.js.map +1 -1
- package/dist/i18n/namespaces/file-upload.js +5 -1
- package/dist/i18n/namespaces/file-upload.js.map +1 -1
- package/dist/i18n/namespaces/image-upload-field.js +10 -0
- package/dist/i18n/namespaces/image-upload-field.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/uploader/file-upload-constants.d.ts +26 -2
- package/dist/uploader/file-upload-constants.js.map +1 -1
- package/dist/uploader/file-upload.d.ts +18 -1
- package/dist/uploader/file-upload.js +50 -5
- package/dist/uploader/file-upload.js.map +1 -1
- package/dist/uploader/image-upload-field.d.ts +31 -2
- package/dist/uploader/image-upload-field.js +139 -8
- package/dist/uploader/image-upload-field.js.map +1 -1
- package/dist/uploader/index.d.ts +2 -2
- package/dist/uploader/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -76,11 +76,8 @@ interface DataTableProps<TRow> {
|
|
|
76
76
|
/**
|
|
77
77
|
* Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bên).
|
|
78
78
|
*
|
|
79
|
-
* Đánh dấu bằng
|
|
80
|
-
* trên
|
|
81
|
-
* Tóm tắt: nền có alpha làm ô cột GHIM trong mờ; nền class trần thua sọc của `striped` về độ đặc
|
|
82
|
-
* tả nên mất hẳn ở hàng chẵn; `outline` của `<tr>` bị ô cột ghim (`z-index: 2`) đè cụt. Bóng
|
|
83
|
-
* inset không dính cả ba, và không cộng vào chiều cao dòng.
|
|
79
|
+
* Đánh dấu bằng nền `--accent` trên TỪNG Ô — cùng tint với dòng đã chọn và với DataGrid. Lý do tô
|
|
80
|
+
* trên ô chứ không trên `<tr>` ở chỗ áp class.
|
|
84
81
|
*
|
|
85
82
|
* ⚠ Vì thế app KHÔNG cần tự vá bằng `rowClassName` nữa — dùng thẳng prop này.
|
|
86
83
|
*/
|
|
@@ -208,41 +208,17 @@ function DataTable({
|
|
|
208
208
|
// card — dưới 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản).
|
|
209
209
|
onRowClick && "cursor-pointer focus-visible:outline-2 focus-visible:outline-foreground focus-visible:[outline-offset:-2px]",
|
|
210
210
|
/*
|
|
211
|
-
* Dòng ĐANG MỞ
|
|
212
|
-
*
|
|
211
|
+
* Dòng ĐANG MỞ tô nền `--accent` trên TỪNG Ô (`[&>td]`), cùng tint với dòng đã
|
|
212
|
+
* chọn (`TableRow`) và DataGrid. Tô trên ô vì hai lỗi thật của bản nền trên
|
|
213
|
+
* `<tr>` (`bg-app-accent/5`): nền có alpha làm ô cột ghim (`bg-inherit`) trong
|
|
214
|
+
* mờ khi cuộn ngang; và class trần (0,1,0) thua sọc của `striped` (0,2,2) nên
|
|
215
|
+
* mất ở hàng chẵn. `--accent` là màu ĐỤC, và `[&>td]` (0,1,1) thắng
|
|
216
|
+
* `bg-inherit` của ô ghim.
|
|
213
217
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* các cột bên dưới lộ xuyên qua cột ghim.
|
|
217
|
-
* 2. Nó là một class trần, độ đặc tả (0,1,0). Sọc của `striped` khai ở cấp
|
|
218
|
-
* `<table>` nên chọn tử ra (0,2,2) và THẮNG — tức ở bảng có sọc, dòng
|
|
219
|
-
* đang mở không hiện gì ở hàng CHẴN.
|
|
220
|
-
*
|
|
221
|
-
* Và vì sao vẽ trên TỪNG Ô (`[&>td]`) chứ không phải `outline` trên `<tr>`:
|
|
222
|
-
* ô cột ghim mang `z-index: 2` nên nó nằm ở lớp cao hơn và ĐÈ LÊN viền của
|
|
223
|
-
* hàng — dấu bị cắt cụt đúng ở vùng cột ghim, kể cả khi chưa cuộn ngang. Đã
|
|
224
|
-
* thử và chụp lại. Bóng INSET vẽ trong lòng từng ô, tức vẽ CÙNG lớp với ô đó,
|
|
225
|
-
* nên cột ghim cũng có dấu; và vì là bóng chứ không phải viền nên nó không
|
|
226
|
-
* cộng vào chiều cao — mở một dòng không làm bảng nhảy 4px.
|
|
227
|
-
*
|
|
228
|
-
* Không đụng nhóm `bg-*` nên miễn nhiễm cả hai lỗi trên: cột ghim giữ nền
|
|
229
|
-
* đục, và sọc không có gì để tranh.
|
|
230
|
-
*
|
|
231
|
-
* Màu `--app-accent-ink` (bậc đi lên nền trang): 6,14:1 trên `--card` ở light
|
|
232
|
-
* · 9,61:1 ở dark — trên ngưỡng 3:1 của WCAG 1.4.11. KHÔNG dùng
|
|
233
|
-
* `--app-accent` trần: gold #E8B824 chỉ 1,86:1, đúng cái bẫy "token tô NỀN
|
|
234
|
-
* đem vẽ DẤU".
|
|
235
|
-
*
|
|
236
|
-
* ⚠ Trong giá trị tuỳ ý phải viết `var(--app-accent-ink)` — TOKEN THÔ — chứ
|
|
237
|
-
* KHÔNG phải `var(--color-app-accent-ink)`. `@theme inline` khai
|
|
238
|
-
* `--color-app-accent-ink: var(--app-accent-ink)`, nghĩa là utility sinh ra
|
|
239
|
-
* nhúng thẳng `var(--app-accent-ink)` nên nó KẾ THỪA được accent mà app đặt
|
|
240
|
-
* trên một thẻ bọc; còn biến `--color-*` thì vẫn nằm ở `:root` với giá trị đã
|
|
241
|
-
* hoá giải theo gốc. Đo tại chính ô: `--app-accent-ink` = #1A6668 (teal của
|
|
242
|
-
* app) nhưng `--color-app-accent-ink` = #7A5800 (gold mặc định) — viết nhầm
|
|
243
|
-
* là dấu ra sai màu app mà không có gì báo.
|
|
218
|
+
* Bản trước dùng hai vạch `--app-accent-ink` thay cho nền; bỏ vạch để khớp
|
|
219
|
+
* DataGrid, và tint tone primary đã được duyệt bằng mắt là đủ rõ.
|
|
244
220
|
*/
|
|
245
|
-
active && "[&>td]:
|
|
221
|
+
active && "[&>td]:bg-accent",
|
|
246
222
|
rowClassName?.(row, rowIndex)
|
|
247
223
|
),
|
|
248
224
|
children: [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/data-table.tsx"],"sourcesContent":["\"use client\";\n\nimport { ArrowDown, ArrowUp, ChevronsUpDown, Inbox, SlidersHorizontal } from \"lucide-react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Checkbox } from \"../ui/checkbox.js\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger\n} from \"../ui/dropdown-menu.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport { TableBody, TableCell, TableHead, TableHeader, TableRow } from \"../ui/table.js\";\nimport type { DataTableLabels } from \"./data-table-constants.js\";\nimport { EmptyState } from \"./empty-state.js\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface DataTableSort {\n columnId: string;\n direction: SortDirection;\n}\n\nexport type DataTableAlign = \"left\" | \"center\" | \"right\";\n\nexport interface DataTableColumn<TRow> {\n id: string;\n /** Nội dung ô tiêu đề */\n header: ReactNode;\n /** Nhãn chữ dùng trong menu ẩn/hiện cột — bắt buộc khi `header` không phải chuỗi */\n label?: string;\n /** Render ô dữ liệu — nhận cả chỉ số dòng để đánh số thứ tự */\n cell: (row: TRow, rowIndex: number) => ReactNode;\n sortable?: boolean;\n align?: DataTableAlign;\n /**\n * Bề rộng cột tính bằng px. BẮT BUỘC với cột ghim (`pinned`) vì offset `left/right` của\n * sticky được tính cộng dồn từ giá trị này — thiếu width thì các cột ghim chồng lên nhau.\n */\n width?: number;\n minWidth?: number;\n /** Ghim cột khi cuộn ngang (bảng nhiều cột) */\n pinned?: \"left\" | \"right\";\n /** Cho phép người dùng ẩn cột này (mặc định true; cột định danh nên đặt false) */\n hideable?: boolean;\n headerClassName?: string;\n cellClassName?: string;\n}\n\nexport interface DataTableProps<TRow> {\n columns: DataTableColumn<TRow>[];\n rows: TRow[];\n /** Khoá ổn định của dòng — dùng cho `key` React và cho tập chọn */\n getRowId: (row: TRow, index: number) => string;\n\n /** Đang tải → hiện `loadingRowCount` dòng khung xương thay vì bảng trống */\n loading?: boolean;\n loadingRowCount?: number;\n /** Nội dung khi không có dòng nào (mặc định EmptyState theo `labels.emptyTitle`) */\n empty?: ReactNode;\n\n /** Sắp xếp — controlled; `null` = không sắp xếp. App tự sắp/tự gọi API. */\n sort?: DataTableSort | null;\n onSortChange?: (sort: DataTableSort | null) => void;\n\n /** Chọn dòng bằng checkbox — controlled */\n selectable?: boolean;\n selectedIds?: string[];\n onSelectedIdsChange?: (ids: string[]) => void;\n /** Khoá chọn với một số dòng (vd bản ghi đã khoá) */\n isRowSelectable?: (row: TRow, index: number) => boolean;\n\n /**\n * Mã các cột đang hiện — controlled. Bỏ trống = hiện tất cả. Đổi bố cục đi qua\n * `DataTableColumnToggle`, app tự lưu theo người dùng (server, localStorage…).\n *\n * ⚠ Đây là một **TẬP HỢP**, không phải thứ tự: thứ tự cột luôn lấy từ mảng `columns`, thứ tự\n * bên trong mảng này bị bỏ qua. Tên có hậu tố `Ids` chính là để nói đúng thứ nó chứa —\n * 0.1.0 đặt `visibleColumns` khiến người đọc tưởng là mảng cột (`DataTableColumn[]`) hoặc\n * tưởng sắp lại mảng thì cột đổi chỗ.\n *\n * Cố ý KHÔNG dùng `Record<string, boolean>`: kiểu đó có trạng thái thứ ba — **khoá vắng\n * mặt**. App thêm cột mới ở bản sau thì bố cục người dùng đã lưu không có khoá đó, mỗi nơi\n * tự đoán hiện/ẩn → lỗi im lặng. Một mảng mã cột thì `JSON.stringify` là lưu được.\n *\n * Package cố ý KHÔNG tự ghi localStorage: ở SaaS nhiều workspace, bố cục bảng phải theo\n * cặp (người dùng × workspace) và thường cần đồng bộ giữa các máy — chuyện của app.\n */\n visibleColumnIds?: string[];\n\n /**\n * Bấm vào dòng (vd mở chi tiết bản ghi). Có prop này thì dòng vào chuỗi Tab và nhận\n * Enter/Space — mở chi tiết KHÔNG được là thao tác chỉ-chuột.\n */\n onRowClick?: (row: TRow, index: number) => void;\n rowClassName?: (row: TRow, index: number) => string | undefined;\n /**\n * Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bên).\n *\n * Đánh dấu bằng hai vạch 2px màu `--app-accent-ink` ở mép trên và dưới dòng, vẽ bằng bóng INSET\n * trên TỪNG Ô — không bằng nền, và không bằng `outline` của `<tr>`. Lý do đầy đủ ở chỗ áp class.\n * Tóm tắt: nền có alpha làm ô cột GHIM trong mờ; nền class trần thua sọc của `striped` về độ đặc\n * tả nên mất hẳn ở hàng chẵn; `outline` của `<tr>` bị ô cột ghim (`z-index: 2`) đè cụt. Bóng\n * inset không dính cả ba, và không cộng vào chiều cao dòng.\n *\n * ⚠ Vì thế app KHÔNG cần tự vá bằng `rowClassName` nữa — dùng thẳng prop này.\n */\n isRowActive?: (row: TRow, index: number) => boolean;\n\n /** Tiêu đề dính đỉnh vùng cuộn (mặc định true) */\n stickyHeader?: boolean;\n /**\n * Tô nền xen kẽ cho hàng chẵn — mẫu DataTable ở mục **Patterns** của comitor-ds dùng\n * `<Table striped>`, còn `DataTable` trước đây không có đường bật.\n *\n * Mặc định `false`: bảng dài và dày cột thì sọc giúp dò hàng, nhưng bảng ngắn thì nó chỉ thêm\n * nhiễu. Đây là quyết định của từng trang, không phải mặc định của gói.\n *\n * ⚠ Sọc PHẢI là màu ĐỤC (`--table-row-stripe`), không phải `bg-muted/30` như bản trước.\n *\n * Vì sao: sọc khai bằng biến thể tuỳ ý ở cấp `<table>` nên chọn tử sinh ra là\n * `.[…] tbody tr:nth-child(even)` — độ đặc tả (0,2,2), trong khi `bg-card` mà `<TableRow>` tự\n * mang chỉ là một class (0,1,0). Sọc THẮNG và THAY THẾ `bg-card` chứ không chồng lên nó, nên\n * hàng chẵn còn alpha 0,3 mà không có lớp đục nào bên dưới. Ô cột ghim lấy `bg-inherit` từ hàng\n * → cũng trong mờ → cuộn ngang là chữ của các cột bên dưới lộ xuyên qua cột ghim. Đây đúng là\n * lỗi người dùng nhìn thấy, và nó chỉ xuất hiện ở hàng CHẴN nên rất dễ tưởng là \"hàng đầu ổn\".\n *\n * (Chú thích cũ ở đây giải thích rằng \"nền trạng thái luôn thắng vì ghi sau trong chuỗi lớp\" —\n * SAI, và đó chính là lý do lỗi trên lọt qua: thứ tự trong chuỗi class không quyết định gì, độ\n * đặc tả mới quyết định. Nền của hàng ĐANG MỞ `bg-app-accent/5` là (0,1,0) nên nó cũng thua\n * sọc — nhưng hàng đang mở vốn được đánh dấu bằng `outline`, không bằng nền, nên chỗ đó không\n * lộ ra. Đừng lấy nền làm dấu hiệu hàng ở bảng có sọc.)\n */\n striped?: boolean;\n /** Bề rộng tối thiểu của bảng — bật cuộn ngang khi màn hình hẹp */\n minWidth?: number | string;\n /**\n * Ghi đè mật độ cho RIÊNG bảng này, bất kể trục mật độ toàn app đang ở mức nào.\n *\n * Bỏ trống = theo `<html data-density>`. Đặt `\"compact\"` thì bảng tự đặt lại `--row-py` và\n * `--table-head-h` ngay trên thẻ `<table>`, nên tiêu đề và thân đi cùng nhau — bản trước chỉ\n * gắn class vào ô THÂN nên `compact` cho hàng tiêu đề 40px đứng trên hàng thân ~26px.\n */\n density?: \"comfortable\" | \"compact\";\n /**\n * Ghi đè từng khoá chuỗi hiển thị/a11y — xem `DataTableLabels`. Bỏ trống thì theo\n * `ComitorLocaleProvider` (tiếng Anh khi không có provider — từ `3.0.0`; trước đó là tiếng Việt).\n */\n labels?: Partial<DataTableLabels>;\n /** Nhãn của bảng cho trình đọc màn hình (bắt buộc khi trang có nhiều bảng) */\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n className?: string;\n /** className của khung cuộn bọc ngoài */\n containerClassName?: string;\n}\n\nconst alignClasses: Record<DataTableAlign, string> = {\n left: \"text-left\",\n center: \"text-center\",\n right: \"text-right\"\n};\n\n/** Offset sticky cộng dồn cho cột ghim trái/phải. */\nfunction getPinnedOffsets<TRow>(columns: DataTableColumn<TRow>[], selectable: boolean): Map<string, CSSProperties> {\n const styles = new Map<string, CSSProperties>();\n // Cột checkbox (nếu có) luôn ghim trái và chiếm 44px — cột ghim đầu tiên phải bắt đầu sau nó.\n let left = selectable ? 44 : 0;\n for (const column of columns) {\n if (column.pinned !== \"left\") continue;\n styles.set(column.id, { position: \"sticky\", left, zIndex: 2 });\n left += column.width ?? 0;\n }\n\n let right = 0;\n for (const column of [...columns].reverse()) {\n if (column.pinned !== \"right\") continue;\n styles.set(column.id, { position: \"sticky\", right, zIndex: 2 });\n right += column.width ?? 0;\n }\n\n return styles;\n}\n\nfunction nextSort(current: DataTableSort | null | undefined, columnId: string): DataTableSort | null {\n // asc → desc → bỏ sắp xếp. Vòng 3 trạng thái để người dùng quay lại thứ tự gốc của server\n // mà không phải tải lại trang.\n if (!current || current.columnId !== columnId) return { columnId, direction: \"asc\" };\n if (current.direction === \"asc\") return { columnId, direction: \"desc\" };\n return null;\n}\n\n/**\n * Bảng dữ liệu chuẩn của Comitor: tiêu đề dính, sắp xếp, chọn dòng, ghim cột, ẩn/hiện cột,\n * trạng thái tải và trạng thái rỗng.\n *\n * Mọi trạng thái đều **controlled** — package không giữ dữ liệu, không gọi API, không tự phân\n * trang. Nhờ vậy dùng được cả với dữ liệu client (mảng trong bộ nhớ) lẫn server-side\n * pagination mà không phải viết hai component.\n */\nexport function DataTable<TRow>({\n columns,\n rows,\n getRowId,\n loading = false,\n loadingRowCount = 5,\n empty,\n sort,\n onSortChange,\n selectable = false,\n selectedIds,\n onSelectedIdsChange,\n isRowSelectable,\n visibleColumnIds,\n onRowClick,\n rowClassName,\n isRowActive,\n stickyHeader = true,\n striped = false,\n minWidth,\n density = \"comfortable\",\n labels: labelsProp,\n className,\n containerClassName,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledby\n}: DataTableProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n // Thứ tự luôn theo `columns` — `visibleColumnIds` chỉ trả lời \"cột nào được hiện\".\n const shownColumns = visibleColumnIds ? columns.filter((column) => visibleColumnIds.includes(column.id)) : columns;\n const pinnedStyles = getPinnedOffsets(shownColumns, selectable);\n const colCount = shownColumns.length + (selectable ? 1 : 0);\n\n const selected = selectedIds ?? [];\n // Một lượt duyệt: vừa lọc dòng chọn được vừa lấy id — tránh indexOf() lồng trong map (O(n²))\n // và tránh sai khi hai dòng cùng tham chiếu object.\n const selectableIds: string[] = [];\n rows.forEach((row, index) => {\n if (isRowSelectable?.(row, index) ?? true) selectableIds.push(getRowId(row, index));\n });\n const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selected.includes(id));\n const someSelected = !allSelected && selectableIds.some((id) => selected.includes(id));\n\n const toggleAll = () => {\n if (!onSelectedIdsChange) return;\n if (allSelected) {\n onSelectedIdsChange(selected.filter((id) => !selectableIds.includes(id)));\n return;\n }\n onSelectedIdsChange([...new Set([...selected, ...selectableIds])]);\n };\n\n const toggleRow = (id: string) => {\n if (!onSelectedIdsChange) return;\n onSelectedIdsChange(selected.includes(id) ? selected.filter((item) => item !== id) : [...selected, id]);\n };\n\n /*\n * `density` nay là GHI ĐÈ CỤC BỘ của trục mật độ toàn cục, không phải một bảng lớp riêng.\n *\n * Bản trước là `density === \"compact\" ? \"px-3 py-1.5\" : \"px-4 py-3\"` gắn tay vào bốn ô THÂN —\n * ba khuyết tật cùng lúc: (1) nó không bao giờ tới `<TableHead>`, nên `compact` cho hàng tiêu\n * đề 40px đứng trên hàng thân ~26px; (2) nó trộn hai trục, co cả chiều NGANG (px-4 → px-3)\n * trong khi \"gọn\" chỉ nên là chuyện chiều dọc; (3) bảng nào dùng `<Table>` trần thì không có\n * mật độ nào cả.\n *\n * Đặt biến ở chính thẻ `<table>` thì cả ba biến mất: `TableHead`/`TableCell` đã đọc\n * `--table-head-h`/`--row-py` nên tiêu đề và thân đi cùng nhau, và một bảng vẫn gọn được ngay\n * cả khi toàn app đang ở mức thoải mái (hoặc ngược lại).\n */\n const densityVars =\n density === \"compact\" ? ({ \"--row-py\": \"0.375rem\", \"--table-head-h\": \"2rem\" } as CSSProperties) : undefined;\n\n return (\n <div\n data-slot=\"data-table-container\"\n className={cn(\"relative w-full overflow-auto rounded-xl border border-border bg-card\", containerClassName)}\n >\n <table\n data-slot=\"data-table\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n aria-busy={loading || undefined}\n className={cn(\n \"w-full caption-bottom text-sm\",\n // Cùng một chuỗi lớp với `<Table striped>` của Tier 1 — đổi một chỗ thì đổi cả hai.\n striped &&\n \"[&_tbody_tr:nth-child(even)]:bg-table-row-stripe [&_tbody_tr:nth-child(even):hover]:bg-table-row-hover\",\n className\n )}\n style={\n minWidth != null || densityVars ? { ...densityVars, ...(minWidth != null ? { minWidth } : null) } : undefined\n }\n >\n <TableHeader className={cn(stickyHeader && \"sticky top-0 z-10\")}>\n {/* Nền ĐẶC đặt trên <tr>, không phải <thead>: ô ghim dùng `bg-inherit` mà\n background-color kế thừa từ CHA TRỰC TIẾP (<tr>). Để nền ở <thead> thì <tr> vẫn\n trong suốt → cột ghim nhìn xuyên thấy dữ liệu cuộn phía dưới. */}\n <TableRow className=\"bg-muted hover:bg-muted\">\n {selectable && (\n <TableHead\n className=\"w-11 bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 3, width: 44, minWidth: 44 }}\n >\n <Checkbox\n checked={allSelected ? true : someSelected ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={selectableIds.length === 0}\n aria-label={allSelected ? labels.deselectAllRows : labels.selectAllRows}\n />\n </TableHead>\n )}\n\n {shownColumns.map((column) => {\n const isSorted = sort?.columnId === column.id;\n const SortIcon = isSorted ? (sort?.direction === \"asc\" ? ArrowUp : ArrowDown) : ChevronsUpDown;\n const pinnedStyle = pinnedStyles.get(column.id);\n\n return (\n <TableHead\n key={column.id}\n scope=\"col\"\n aria-sort={isSorted ? (sort?.direction === \"asc\" ? \"ascending\" : \"descending\") : undefined}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.headerClassName\n )}\n style={{\n ...pinnedStyle,\n width: column.width,\n minWidth: column.minWidth ?? column.width\n }}\n >\n {column.sortable && onSortChange ? (\n <button\n type=\"button\"\n onClick={() => onSortChange(nextSort(sort, column.id))}\n className={cn(\n \"-mx-1 inline-flex items-center gap-1 rounded-sm px-1 py-0.5 uppercase transition-colors hover:text-foreground\",\n column.align === \"right\" && \"flex-row-reverse\",\n isSorted && \"text-foreground\"\n )}\n >\n {column.header}\n {/* KHÔNG làm mờ icon chưa sắp xếp: nút này còn bấm được nên `opacity-*`\n không được miễn trừ ngưỡng 3:1 của WCAG 1.4.11 cho icon mang nghĩa.\n `opacity-50` cho ra 1,98:1 (light) / 2,23:1 (dark) trên nền `bg-muted`\n của hàng tiêu đề ✗; để nguyên `text-muted-foreground` là 4,89 / 4,86:1 ✓.\n Trạng thái ĐANG sắp xếp vẫn phân biệt được: nút đổi sang\n `text-foreground` và icon đổi hình (ArrowUp/ArrowDown ⇄ ChevronsUpDown). */}\n <SortIcon aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n ) : (\n column.header\n )}\n </TableHead>\n );\n })}\n </TableRow>\n </TableHeader>\n\n <TableBody>\n {loading &&\n Array.from({ length: loadingRowCount }).map((_, rowIndex) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: dòng khung xương không có danh tính dữ liệu\n <TableRow key={`skeleton-${rowIndex}`} className=\"hover:bg-transparent\">\n {selectable && (\n <TableCell>\n <Skeleton className=\"size-4 rounded-sm\" />\n </TableCell>\n )}\n {shownColumns.map((column) => (\n <TableCell key={column.id}>\n <Skeleton className=\"h-4 w-full max-w-[12rem]\" />\n </TableCell>\n ))}\n </TableRow>\n ))}\n\n {!loading && rows.length === 0 && (\n <TableRow className=\"hover:bg-transparent\">\n <TableCell colSpan={colCount} className=\"p-0\">\n {empty ?? <EmptyState icon={Inbox} title={labels.emptyTitle} description={labels.emptyDescription} />}\n </TableCell>\n </TableRow>\n )}\n\n {!loading &&\n rows.map((row, rowIndex) => {\n const id = getRowId(row, rowIndex);\n const isSelected = selected.includes(id);\n const canSelect = isRowSelectable?.(row, rowIndex) ?? true;\n const active = isRowActive?.(row, rowIndex) ?? false;\n\n return (\n <TableRow\n key={id}\n data-state={isSelected ? \"selected\" : undefined}\n aria-selected={selectable ? isSelected : undefined}\n onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}\n // Dòng bấm được phải dùng được bằng BÀN PHÍM: đưa <tr> vào chuỗi Tab và tự\n // xử lý Enter/Space. Cố ý KHÔNG đặt role=\"button\" — role đó đè role=\"row\"\n // ngầm, trình đọc màn hình sẽ mất cấu trúc bảng (số dòng/cột, tiêu đề cột);\n // dòng vẫn là dòng, chỉ thêm khả năng kích hoạt.\n tabIndex={onRowClick ? 0 : undefined}\n onKeyDown={\n onRowClick\n ? (event) => {\n // Chỉ nhận phím khi CHÍNH <tr> đang focus: Enter/Space bấm trên\n // control bên trong ô (checkbox, nút, link) đã có nghĩa riêng —\n // nổi bọt lên đây sẽ mở chi tiết ngoài ý muốn.\n if (event.target !== event.currentTarget) return;\n if (event.key !== \"Enter\" && event.key !== \" \") return;\n // Space mặc định cuộn trang, Enter có thể submit form bao ngoài.\n event.preventDefault();\n onRowClick(row, rowIndex);\n }\n : undefined\n }\n className={cn(\n // bg-card trên <tr> để ô ghim (`bg-inherit`) có màu đặc mà vẫn đổi theo\n // trạng thái chọn/hover của chính dòng đó.\n \"bg-card\",\n // Viền focus vẽ bằng `outline` chứ không `ring`: <tr> nằm trong bảng\n // border-collapse, box-shadow không được vẽ. Offset ÂM để viền nằm trong\n // lòng dòng, không bị khung cuộn cắt mất ở mép trái/phải.\n // Màu dùng `foreground` chứ không `ring` (gold #E8B824 chỉ ~1,9:1 trên nền\n // card — dưới 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản).\n onRowClick &&\n \"cursor-pointer focus-visible:outline-2 focus-visible:outline-foreground focus-visible:[outline-offset:-2px]\",\n /*\n * Dòng ĐANG MỞ đánh dấu bằng bóng INSET trên từng ô, KHÔNG bằng nền — hai lý\n * do, cả hai đều là lỗi thật của bản trước (`bg-app-accent/5`):\n *\n * 1. Lớp nền đó CÓ ALPHA. Ô cột ghim lấy nền bằng `bg-inherit` từ hàng, nên\n * nó thừa hưởng luôn alpha và trở nên trong mờ — cuộn ngang là chữ của\n * các cột bên dưới lộ xuyên qua cột ghim.\n * 2. Nó là một class trần, độ đặc tả (0,1,0). Sọc của `striped` khai ở cấp\n * `<table>` nên chọn tử ra (0,2,2) và THẮNG — tức ở bảng có sọc, dòng\n * đang mở không hiện gì ở hàng CHẴN.\n *\n * Và vì sao vẽ trên TỪNG Ô (`[&>td]`) chứ không phải `outline` trên `<tr>`:\n * ô cột ghim mang `z-index: 2` nên nó nằm ở lớp cao hơn và ĐÈ LÊN viền của\n * hàng — dấu bị cắt cụt đúng ở vùng cột ghim, kể cả khi chưa cuộn ngang. Đã\n * thử và chụp lại. Bóng INSET vẽ trong lòng từng ô, tức vẽ CÙNG lớp với ô đó,\n * nên cột ghim cũng có dấu; và vì là bóng chứ không phải viền nên nó không\n * cộng vào chiều cao — mở một dòng không làm bảng nhảy 4px.\n *\n * Không đụng nhóm `bg-*` nên miễn nhiễm cả hai lỗi trên: cột ghim giữ nền\n * đục, và sọc không có gì để tranh.\n *\n * Màu `--app-accent-ink` (bậc đi lên nền trang): 6,14:1 trên `--card` ở light\n * · 9,61:1 ở dark — trên ngưỡng 3:1 của WCAG 1.4.11. KHÔNG dùng\n * `--app-accent` trần: gold #E8B824 chỉ 1,86:1, đúng cái bẫy \"token tô NỀN\n * đem vẽ DẤU\".\n *\n * ⚠ Trong giá trị tuỳ ý phải viết `var(--app-accent-ink)` — TOKEN THÔ — chứ\n * KHÔNG phải `var(--color-app-accent-ink)`. `@theme inline` khai\n * `--color-app-accent-ink: var(--app-accent-ink)`, nghĩa là utility sinh ra\n * nhúng thẳng `var(--app-accent-ink)` nên nó KẾ THỪA được accent mà app đặt\n * trên một thẻ bọc; còn biến `--color-*` thì vẫn nằm ở `:root` với giá trị đã\n * hoá giải theo gốc. Đo tại chính ô: `--app-accent-ink` = #1A6668 (teal của\n * app) nhưng `--color-app-accent-ink` = #7A5800 (gold mặc định) — viết nhầm\n * là dấu ra sai màu app mà không có gì báo.\n */\n active &&\n \"[&>td]:shadow-[inset_0_2px_0_var(--app-accent-ink),inset_0_-2px_0_var(--app-accent-ink)]\",\n rowClassName?.(row, rowIndex)\n )}\n >\n {selectable && (\n <TableCell\n className=\"bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 1, width: 44, minWidth: 44 }}\n >\n {/* Chặn nổi bọt: bấm checkbox không được kích hoạt onRowClick (mở chi tiết) */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: lớp bọc chỉ chặn sự kiện, tương tác nằm ở Checkbox bên trong */}\n {/* biome-ignore lint/a11y/useKeyWithClickEvents: phím bấm đã do Checkbox xử lý */}\n <span onClick={(event) => event.stopPropagation()}>\n <Checkbox\n checked={isSelected}\n disabled={!canSelect}\n onCheckedChange={() => toggleRow(id)}\n aria-label={isSelected ? labels.deselectRow : labels.selectRow}\n />\n </span>\n </TableCell>\n )}\n\n {shownColumns.map((column) => {\n const pinnedStyle = pinnedStyles.get(column.id);\n return (\n <TableCell\n key={column.id}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.cellClassName\n )}\n style={{ ...pinnedStyle, width: column.width, minWidth: column.minWidth ?? column.width }}\n >\n {column.cell(row, rowIndex)}\n </TableCell>\n );\n })}\n </TableRow>\n );\n })}\n </TableBody>\n </table>\n </div>\n );\n}\n\nexport interface DataTableColumnToggleProps<TRow> {\n columns: DataTableColumn<TRow>[];\n /** Mã các cột đang hiện — cùng nguồn với `DataTable.visibleColumnIds` (tập hợp, không phải thứ tự) */\n visibleColumnIds: string[];\n onVisibleColumnIdsChange: (visibleColumnIds: string[]) => void;\n /** Nhãn nút + tiêu đề menu; không truyền thì lấy `labels.columnToggle` */\n label?: string;\n /** Ghi đè từng khoá chuỗi hiển thị — xem `DataTableLabels`. Bỏ trống thì theo `ComitorLocaleProvider`. */\n labels?: Partial<DataTableLabels>;\n className?: string;\n}\n\n/**\n * Menu ẩn/hiện cột đi kèm `DataTable` (đặt trên thanh công cụ của trang danh sách).\n *\n * Không cho ẩn hết: luôn giữ lại ít nhất một cột, nếu không bảng thành khối trắng vô nghĩa\n * và người dùng không có đường quay lại.\n */\nexport function DataTableColumnToggle<TRow>({\n columns,\n visibleColumnIds,\n onVisibleColumnIdsChange,\n label,\n labels: labelsProp,\n className\n}: DataTableColumnToggleProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n const text = label ?? labels.columnToggle;\n const hideable = columns.filter((column) => column.hideable !== false);\n\n const toggle = (columnId: string) => {\n // Bật lại một cột thì dựng lại danh sách theo thứ tự `columns` — giữ cho giá trị app lưu\n // luôn cùng một hình dạng, dù `DataTable` đằng nào cũng bỏ qua thứ tự bên trong.\n const next = visibleColumnIds.includes(columnId)\n ? visibleColumnIds.filter((id) => id !== columnId)\n : columns.filter((column) => visibleColumnIds.includes(column.id) || column.id === columnId).map((c) => c.id);\n if (next.length === 0) return;\n onVisibleColumnIdsChange(next);\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant=\"outline\" size=\"sm\" className={className} leftIcon={<SlidersHorizontal className=\"size-4\" />}>\n {text}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-52\">\n <DropdownMenuLabel>{text}</DropdownMenuLabel>\n <DropdownMenuSeparator />\n {hideable.map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n checked={visibleColumnIds.includes(column.id)}\n onCheckedChange={() => toggle(column.id)}\n onSelect={(event) => event.preventDefault()}\n >\n {column.label ?? (typeof column.header === \"string\" ? column.header : column.id)}\n </DropdownMenuCheckboxItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/data-table.tsx"],"sourcesContent":["\"use client\";\n\nimport { ArrowDown, ArrowUp, ChevronsUpDown, Inbox, SlidersHorizontal } from \"lucide-react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Checkbox } from \"../ui/checkbox.js\";\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuSeparator,\n DropdownMenuTrigger\n} from \"../ui/dropdown-menu.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport { TableBody, TableCell, TableHead, TableHeader, TableRow } from \"../ui/table.js\";\nimport type { DataTableLabels } from \"./data-table-constants.js\";\nimport { EmptyState } from \"./empty-state.js\";\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport interface DataTableSort {\n columnId: string;\n direction: SortDirection;\n}\n\nexport type DataTableAlign = \"left\" | \"center\" | \"right\";\n\nexport interface DataTableColumn<TRow> {\n id: string;\n /** Nội dung ô tiêu đề */\n header: ReactNode;\n /** Nhãn chữ dùng trong menu ẩn/hiện cột — bắt buộc khi `header` không phải chuỗi */\n label?: string;\n /** Render ô dữ liệu — nhận cả chỉ số dòng để đánh số thứ tự */\n cell: (row: TRow, rowIndex: number) => ReactNode;\n sortable?: boolean;\n align?: DataTableAlign;\n /**\n * Bề rộng cột tính bằng px. BẮT BUỘC với cột ghim (`pinned`) vì offset `left/right` của\n * sticky được tính cộng dồn từ giá trị này — thiếu width thì các cột ghim chồng lên nhau.\n */\n width?: number;\n minWidth?: number;\n /** Ghim cột khi cuộn ngang (bảng nhiều cột) */\n pinned?: \"left\" | \"right\";\n /** Cho phép người dùng ẩn cột này (mặc định true; cột định danh nên đặt false) */\n hideable?: boolean;\n headerClassName?: string;\n cellClassName?: string;\n}\n\nexport interface DataTableProps<TRow> {\n columns: DataTableColumn<TRow>[];\n rows: TRow[];\n /** Khoá ổn định của dòng — dùng cho `key` React và cho tập chọn */\n getRowId: (row: TRow, index: number) => string;\n\n /** Đang tải → hiện `loadingRowCount` dòng khung xương thay vì bảng trống */\n loading?: boolean;\n loadingRowCount?: number;\n /** Nội dung khi không có dòng nào (mặc định EmptyState theo `labels.emptyTitle`) */\n empty?: ReactNode;\n\n /** Sắp xếp — controlled; `null` = không sắp xếp. App tự sắp/tự gọi API. */\n sort?: DataTableSort | null;\n onSortChange?: (sort: DataTableSort | null) => void;\n\n /** Chọn dòng bằng checkbox — controlled */\n selectable?: boolean;\n selectedIds?: string[];\n onSelectedIdsChange?: (ids: string[]) => void;\n /** Khoá chọn với một số dòng (vd bản ghi đã khoá) */\n isRowSelectable?: (row: TRow, index: number) => boolean;\n\n /**\n * Mã các cột đang hiện — controlled. Bỏ trống = hiện tất cả. Đổi bố cục đi qua\n * `DataTableColumnToggle`, app tự lưu theo người dùng (server, localStorage…).\n *\n * ⚠ Đây là một **TẬP HỢP**, không phải thứ tự: thứ tự cột luôn lấy từ mảng `columns`, thứ tự\n * bên trong mảng này bị bỏ qua. Tên có hậu tố `Ids` chính là để nói đúng thứ nó chứa —\n * 0.1.0 đặt `visibleColumns` khiến người đọc tưởng là mảng cột (`DataTableColumn[]`) hoặc\n * tưởng sắp lại mảng thì cột đổi chỗ.\n *\n * Cố ý KHÔNG dùng `Record<string, boolean>`: kiểu đó có trạng thái thứ ba — **khoá vắng\n * mặt**. App thêm cột mới ở bản sau thì bố cục người dùng đã lưu không có khoá đó, mỗi nơi\n * tự đoán hiện/ẩn → lỗi im lặng. Một mảng mã cột thì `JSON.stringify` là lưu được.\n *\n * Package cố ý KHÔNG tự ghi localStorage: ở SaaS nhiều workspace, bố cục bảng phải theo\n * cặp (người dùng × workspace) và thường cần đồng bộ giữa các máy — chuyện của app.\n */\n visibleColumnIds?: string[];\n\n /**\n * Bấm vào dòng (vd mở chi tiết bản ghi). Có prop này thì dòng vào chuỗi Tab và nhận\n * Enter/Space — mở chi tiết KHÔNG được là thao tác chỉ-chuột.\n */\n onRowClick?: (row: TRow, index: number) => void;\n rowClassName?: (row: TRow, index: number) => string | undefined;\n /**\n * Dòng đang được làm nổi (vd bản ghi vừa mở ở panel bên).\n *\n * Đánh dấu bằng nền `--accent` trên TỪNG Ô — cùng tint với dòng đã chọn và với DataGrid. Lý do tô\n * trên ô chứ không trên `<tr>` ở chỗ áp class.\n *\n * ⚠ Vì thế app KHÔNG cần tự vá bằng `rowClassName` nữa — dùng thẳng prop này.\n */\n isRowActive?: (row: TRow, index: number) => boolean;\n\n /** Tiêu đề dính đỉnh vùng cuộn (mặc định true) */\n stickyHeader?: boolean;\n /**\n * Tô nền xen kẽ cho hàng chẵn — mẫu DataTable ở mục **Patterns** của comitor-ds dùng\n * `<Table striped>`, còn `DataTable` trước đây không có đường bật.\n *\n * Mặc định `false`: bảng dài và dày cột thì sọc giúp dò hàng, nhưng bảng ngắn thì nó chỉ thêm\n * nhiễu. Đây là quyết định của từng trang, không phải mặc định của gói.\n *\n * ⚠ Sọc PHẢI là màu ĐỤC (`--table-row-stripe`), không phải `bg-muted/30` như bản trước.\n *\n * Vì sao: sọc khai bằng biến thể tuỳ ý ở cấp `<table>` nên chọn tử sinh ra là\n * `.[…] tbody tr:nth-child(even)` — độ đặc tả (0,2,2), trong khi `bg-card` mà `<TableRow>` tự\n * mang chỉ là một class (0,1,0). Sọc THẮNG và THAY THẾ `bg-card` chứ không chồng lên nó, nên\n * hàng chẵn còn alpha 0,3 mà không có lớp đục nào bên dưới. Ô cột ghim lấy `bg-inherit` từ hàng\n * → cũng trong mờ → cuộn ngang là chữ của các cột bên dưới lộ xuyên qua cột ghim. Đây đúng là\n * lỗi người dùng nhìn thấy, và nó chỉ xuất hiện ở hàng CHẴN nên rất dễ tưởng là \"hàng đầu ổn\".\n *\n * (Chú thích cũ ở đây giải thích rằng \"nền trạng thái luôn thắng vì ghi sau trong chuỗi lớp\" —\n * SAI, và đó chính là lý do lỗi trên lọt qua: thứ tự trong chuỗi class không quyết định gì, độ\n * đặc tả mới quyết định. Nền của hàng ĐANG MỞ `bg-app-accent/5` là (0,1,0) nên nó cũng thua\n * sọc — nhưng hàng đang mở vốn được đánh dấu bằng `outline`, không bằng nền, nên chỗ đó không\n * lộ ra. Đừng lấy nền làm dấu hiệu hàng ở bảng có sọc.)\n */\n striped?: boolean;\n /** Bề rộng tối thiểu của bảng — bật cuộn ngang khi màn hình hẹp */\n minWidth?: number | string;\n /**\n * Ghi đè mật độ cho RIÊNG bảng này, bất kể trục mật độ toàn app đang ở mức nào.\n *\n * Bỏ trống = theo `<html data-density>`. Đặt `\"compact\"` thì bảng tự đặt lại `--row-py` và\n * `--table-head-h` ngay trên thẻ `<table>`, nên tiêu đề và thân đi cùng nhau — bản trước chỉ\n * gắn class vào ô THÂN nên `compact` cho hàng tiêu đề 40px đứng trên hàng thân ~26px.\n */\n density?: \"comfortable\" | \"compact\";\n /**\n * Ghi đè từng khoá chuỗi hiển thị/a11y — xem `DataTableLabels`. Bỏ trống thì theo\n * `ComitorLocaleProvider` (tiếng Anh khi không có provider — từ `3.0.0`; trước đó là tiếng Việt).\n */\n labels?: Partial<DataTableLabels>;\n /** Nhãn của bảng cho trình đọc màn hình (bắt buộc khi trang có nhiều bảng) */\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n className?: string;\n /** className của khung cuộn bọc ngoài */\n containerClassName?: string;\n}\n\nconst alignClasses: Record<DataTableAlign, string> = {\n left: \"text-left\",\n center: \"text-center\",\n right: \"text-right\"\n};\n\n/** Offset sticky cộng dồn cho cột ghim trái/phải. */\nfunction getPinnedOffsets<TRow>(columns: DataTableColumn<TRow>[], selectable: boolean): Map<string, CSSProperties> {\n const styles = new Map<string, CSSProperties>();\n // Cột checkbox (nếu có) luôn ghim trái và chiếm 44px — cột ghim đầu tiên phải bắt đầu sau nó.\n let left = selectable ? 44 : 0;\n for (const column of columns) {\n if (column.pinned !== \"left\") continue;\n styles.set(column.id, { position: \"sticky\", left, zIndex: 2 });\n left += column.width ?? 0;\n }\n\n let right = 0;\n for (const column of [...columns].reverse()) {\n if (column.pinned !== \"right\") continue;\n styles.set(column.id, { position: \"sticky\", right, zIndex: 2 });\n right += column.width ?? 0;\n }\n\n return styles;\n}\n\nfunction nextSort(current: DataTableSort | null | undefined, columnId: string): DataTableSort | null {\n // asc → desc → bỏ sắp xếp. Vòng 3 trạng thái để người dùng quay lại thứ tự gốc của server\n // mà không phải tải lại trang.\n if (!current || current.columnId !== columnId) return { columnId, direction: \"asc\" };\n if (current.direction === \"asc\") return { columnId, direction: \"desc\" };\n return null;\n}\n\n/**\n * Bảng dữ liệu chuẩn của Comitor: tiêu đề dính, sắp xếp, chọn dòng, ghim cột, ẩn/hiện cột,\n * trạng thái tải và trạng thái rỗng.\n *\n * Mọi trạng thái đều **controlled** — package không giữ dữ liệu, không gọi API, không tự phân\n * trang. Nhờ vậy dùng được cả với dữ liệu client (mảng trong bộ nhớ) lẫn server-side\n * pagination mà không phải viết hai component.\n */\nexport function DataTable<TRow>({\n columns,\n rows,\n getRowId,\n loading = false,\n loadingRowCount = 5,\n empty,\n sort,\n onSortChange,\n selectable = false,\n selectedIds,\n onSelectedIdsChange,\n isRowSelectable,\n visibleColumnIds,\n onRowClick,\n rowClassName,\n isRowActive,\n stickyHeader = true,\n striped = false,\n minWidth,\n density = \"comfortable\",\n labels: labelsProp,\n className,\n containerClassName,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledby\n}: DataTableProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n // Thứ tự luôn theo `columns` — `visibleColumnIds` chỉ trả lời \"cột nào được hiện\".\n const shownColumns = visibleColumnIds ? columns.filter((column) => visibleColumnIds.includes(column.id)) : columns;\n const pinnedStyles = getPinnedOffsets(shownColumns, selectable);\n const colCount = shownColumns.length + (selectable ? 1 : 0);\n\n const selected = selectedIds ?? [];\n // Một lượt duyệt: vừa lọc dòng chọn được vừa lấy id — tránh indexOf() lồng trong map (O(n²))\n // và tránh sai khi hai dòng cùng tham chiếu object.\n const selectableIds: string[] = [];\n rows.forEach((row, index) => {\n if (isRowSelectable?.(row, index) ?? true) selectableIds.push(getRowId(row, index));\n });\n const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selected.includes(id));\n const someSelected = !allSelected && selectableIds.some((id) => selected.includes(id));\n\n const toggleAll = () => {\n if (!onSelectedIdsChange) return;\n if (allSelected) {\n onSelectedIdsChange(selected.filter((id) => !selectableIds.includes(id)));\n return;\n }\n onSelectedIdsChange([...new Set([...selected, ...selectableIds])]);\n };\n\n const toggleRow = (id: string) => {\n if (!onSelectedIdsChange) return;\n onSelectedIdsChange(selected.includes(id) ? selected.filter((item) => item !== id) : [...selected, id]);\n };\n\n /*\n * `density` nay là GHI ĐÈ CỤC BỘ của trục mật độ toàn cục, không phải một bảng lớp riêng.\n *\n * Bản trước là `density === \"compact\" ? \"px-3 py-1.5\" : \"px-4 py-3\"` gắn tay vào bốn ô THÂN —\n * ba khuyết tật cùng lúc: (1) nó không bao giờ tới `<TableHead>`, nên `compact` cho hàng tiêu\n * đề 40px đứng trên hàng thân ~26px; (2) nó trộn hai trục, co cả chiều NGANG (px-4 → px-3)\n * trong khi \"gọn\" chỉ nên là chuyện chiều dọc; (3) bảng nào dùng `<Table>` trần thì không có\n * mật độ nào cả.\n *\n * Đặt biến ở chính thẻ `<table>` thì cả ba biến mất: `TableHead`/`TableCell` đã đọc\n * `--table-head-h`/`--row-py` nên tiêu đề và thân đi cùng nhau, và một bảng vẫn gọn được ngay\n * cả khi toàn app đang ở mức thoải mái (hoặc ngược lại).\n */\n const densityVars =\n density === \"compact\" ? ({ \"--row-py\": \"0.375rem\", \"--table-head-h\": \"2rem\" } as CSSProperties) : undefined;\n\n return (\n <div\n data-slot=\"data-table-container\"\n className={cn(\"relative w-full overflow-auto rounded-xl border border-border bg-card\", containerClassName)}\n >\n <table\n data-slot=\"data-table\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n aria-busy={loading || undefined}\n className={cn(\n \"w-full caption-bottom text-sm\",\n // Cùng một chuỗi lớp với `<Table striped>` của Tier 1 — đổi một chỗ thì đổi cả hai.\n striped &&\n \"[&_tbody_tr:nth-child(even)]:bg-table-row-stripe [&_tbody_tr:nth-child(even):hover]:bg-table-row-hover\",\n className\n )}\n style={\n minWidth != null || densityVars ? { ...densityVars, ...(minWidth != null ? { minWidth } : null) } : undefined\n }\n >\n <TableHeader className={cn(stickyHeader && \"sticky top-0 z-10\")}>\n {/* Nền ĐẶC đặt trên <tr>, không phải <thead>: ô ghim dùng `bg-inherit` mà\n background-color kế thừa từ CHA TRỰC TIẾP (<tr>). Để nền ở <thead> thì <tr> vẫn\n trong suốt → cột ghim nhìn xuyên thấy dữ liệu cuộn phía dưới. */}\n <TableRow className=\"bg-muted hover:bg-muted\">\n {selectable && (\n <TableHead\n className=\"w-11 bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 3, width: 44, minWidth: 44 }}\n >\n <Checkbox\n checked={allSelected ? true : someSelected ? \"indeterminate\" : false}\n onCheckedChange={toggleAll}\n disabled={selectableIds.length === 0}\n aria-label={allSelected ? labels.deselectAllRows : labels.selectAllRows}\n />\n </TableHead>\n )}\n\n {shownColumns.map((column) => {\n const isSorted = sort?.columnId === column.id;\n const SortIcon = isSorted ? (sort?.direction === \"asc\" ? ArrowUp : ArrowDown) : ChevronsUpDown;\n const pinnedStyle = pinnedStyles.get(column.id);\n\n return (\n <TableHead\n key={column.id}\n scope=\"col\"\n aria-sort={isSorted ? (sort?.direction === \"asc\" ? \"ascending\" : \"descending\") : undefined}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.headerClassName\n )}\n style={{\n ...pinnedStyle,\n width: column.width,\n minWidth: column.minWidth ?? column.width\n }}\n >\n {column.sortable && onSortChange ? (\n <button\n type=\"button\"\n onClick={() => onSortChange(nextSort(sort, column.id))}\n className={cn(\n \"-mx-1 inline-flex items-center gap-1 rounded-sm px-1 py-0.5 uppercase transition-colors hover:text-foreground\",\n column.align === \"right\" && \"flex-row-reverse\",\n isSorted && \"text-foreground\"\n )}\n >\n {column.header}\n {/* KHÔNG làm mờ icon chưa sắp xếp: nút này còn bấm được nên `opacity-*`\n không được miễn trừ ngưỡng 3:1 của WCAG 1.4.11 cho icon mang nghĩa.\n `opacity-50` cho ra 1,98:1 (light) / 2,23:1 (dark) trên nền `bg-muted`\n của hàng tiêu đề ✗; để nguyên `text-muted-foreground` là 4,89 / 4,86:1 ✓.\n Trạng thái ĐANG sắp xếp vẫn phân biệt được: nút đổi sang\n `text-foreground` và icon đổi hình (ArrowUp/ArrowDown ⇄ ChevronsUpDown). */}\n <SortIcon aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n ) : (\n column.header\n )}\n </TableHead>\n );\n })}\n </TableRow>\n </TableHeader>\n\n <TableBody>\n {loading &&\n Array.from({ length: loadingRowCount }).map((_, rowIndex) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: dòng khung xương không có danh tính dữ liệu\n <TableRow key={`skeleton-${rowIndex}`} className=\"hover:bg-transparent\">\n {selectable && (\n <TableCell>\n <Skeleton className=\"size-4 rounded-sm\" />\n </TableCell>\n )}\n {shownColumns.map((column) => (\n <TableCell key={column.id}>\n <Skeleton className=\"h-4 w-full max-w-[12rem]\" />\n </TableCell>\n ))}\n </TableRow>\n ))}\n\n {!loading && rows.length === 0 && (\n <TableRow className=\"hover:bg-transparent\">\n <TableCell colSpan={colCount} className=\"p-0\">\n {empty ?? <EmptyState icon={Inbox} title={labels.emptyTitle} description={labels.emptyDescription} />}\n </TableCell>\n </TableRow>\n )}\n\n {!loading &&\n rows.map((row, rowIndex) => {\n const id = getRowId(row, rowIndex);\n const isSelected = selected.includes(id);\n const canSelect = isRowSelectable?.(row, rowIndex) ?? true;\n const active = isRowActive?.(row, rowIndex) ?? false;\n\n return (\n <TableRow\n key={id}\n data-state={isSelected ? \"selected\" : undefined}\n aria-selected={selectable ? isSelected : undefined}\n onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}\n // Dòng bấm được phải dùng được bằng BÀN PHÍM: đưa <tr> vào chuỗi Tab và tự\n // xử lý Enter/Space. Cố ý KHÔNG đặt role=\"button\" — role đó đè role=\"row\"\n // ngầm, trình đọc màn hình sẽ mất cấu trúc bảng (số dòng/cột, tiêu đề cột);\n // dòng vẫn là dòng, chỉ thêm khả năng kích hoạt.\n tabIndex={onRowClick ? 0 : undefined}\n onKeyDown={\n onRowClick\n ? (event) => {\n // Chỉ nhận phím khi CHÍNH <tr> đang focus: Enter/Space bấm trên\n // control bên trong ô (checkbox, nút, link) đã có nghĩa riêng —\n // nổi bọt lên đây sẽ mở chi tiết ngoài ý muốn.\n if (event.target !== event.currentTarget) return;\n if (event.key !== \"Enter\" && event.key !== \" \") return;\n // Space mặc định cuộn trang, Enter có thể submit form bao ngoài.\n event.preventDefault();\n onRowClick(row, rowIndex);\n }\n : undefined\n }\n className={cn(\n // bg-card trên <tr> để ô ghim (`bg-inherit`) có màu đặc mà vẫn đổi theo\n // trạng thái chọn/hover của chính dòng đó.\n \"bg-card\",\n // Viền focus vẽ bằng `outline` chứ không `ring`: <tr> nằm trong bảng\n // border-collapse, box-shadow không được vẽ. Offset ÂM để viền nằm trong\n // lòng dòng, không bị khung cuộn cắt mất ở mép trái/phải.\n // Màu dùng `foreground` chứ không `ring` (gold #E8B824 chỉ ~1,9:1 trên nền\n // card — dưới 3:1 của WCAG 1.4.11 cho thành phần phi-văn-bản).\n onRowClick &&\n \"cursor-pointer focus-visible:outline-2 focus-visible:outline-foreground focus-visible:[outline-offset:-2px]\",\n /*\n * Dòng ĐANG MỞ tô nền `--accent` trên TỪNG Ô (`[&>td]`), cùng tint với dòng đã\n * chọn (`TableRow`) và DataGrid. Tô trên ô vì hai lỗi thật của bản nền trên\n * `<tr>` (`bg-app-accent/5`): nền có alpha làm ô cột ghim (`bg-inherit`) trong\n * mờ khi cuộn ngang; và class trần (0,1,0) thua sọc của `striped` (0,2,2) nên\n * mất ở hàng chẵn. `--accent` là màu ĐỤC, và `[&>td]` (0,1,1) thắng\n * `bg-inherit` của ô ghim.\n *\n * Bản trước dùng hai vạch `--app-accent-ink` thay cho nền; bỏ vạch để khớp\n * DataGrid, và tint tone primary đã được duyệt bằng mắt là đủ rõ.\n */\n active && \"[&>td]:bg-accent\",\n rowClassName?.(row, rowIndex)\n )}\n >\n {selectable && (\n <TableCell\n className=\"bg-inherit\"\n style={{ position: \"sticky\", left: 0, zIndex: 1, width: 44, minWidth: 44 }}\n >\n {/* Chặn nổi bọt: bấm checkbox không được kích hoạt onRowClick (mở chi tiết) */}\n {/* biome-ignore lint/a11y/noStaticElementInteractions: lớp bọc chỉ chặn sự kiện, tương tác nằm ở Checkbox bên trong */}\n {/* biome-ignore lint/a11y/useKeyWithClickEvents: phím bấm đã do Checkbox xử lý */}\n <span onClick={(event) => event.stopPropagation()}>\n <Checkbox\n checked={isSelected}\n disabled={!canSelect}\n onCheckedChange={() => toggleRow(id)}\n aria-label={isSelected ? labels.deselectRow : labels.selectRow}\n />\n </span>\n </TableCell>\n )}\n\n {shownColumns.map((column) => {\n const pinnedStyle = pinnedStyles.get(column.id);\n return (\n <TableCell\n key={column.id}\n className={cn(\n alignClasses[column.align ?? \"left\"],\n pinnedStyle && \"bg-inherit\",\n column.cellClassName\n )}\n style={{ ...pinnedStyle, width: column.width, minWidth: column.minWidth ?? column.width }}\n >\n {column.cell(row, rowIndex)}\n </TableCell>\n );\n })}\n </TableRow>\n );\n })}\n </TableBody>\n </table>\n </div>\n );\n}\n\nexport interface DataTableColumnToggleProps<TRow> {\n columns: DataTableColumn<TRow>[];\n /** Mã các cột đang hiện — cùng nguồn với `DataTable.visibleColumnIds` (tập hợp, không phải thứ tự) */\n visibleColumnIds: string[];\n onVisibleColumnIdsChange: (visibleColumnIds: string[]) => void;\n /** Nhãn nút + tiêu đề menu; không truyền thì lấy `labels.columnToggle` */\n label?: string;\n /** Ghi đè từng khoá chuỗi hiển thị — xem `DataTableLabels`. Bỏ trống thì theo `ComitorLocaleProvider`. */\n labels?: Partial<DataTableLabels>;\n className?: string;\n}\n\n/**\n * Menu ẩn/hiện cột đi kèm `DataTable` (đặt trên thanh công cụ của trang danh sách).\n *\n * Không cho ẩn hết: luôn giữ lại ít nhất một cột, nếu không bảng thành khối trắng vô nghĩa\n * và người dùng không có đường quay lại.\n */\nexport function DataTableColumnToggle<TRow>({\n columns,\n visibleColumnIds,\n onVisibleColumnIdsChange,\n label,\n labels: labelsProp,\n className\n}: DataTableColumnToggleProps<TRow>) {\n const labels = useComitorLabels(\"dataTable\", labelsProp);\n const text = label ?? labels.columnToggle;\n const hideable = columns.filter((column) => column.hideable !== false);\n\n const toggle = (columnId: string) => {\n // Bật lại một cột thì dựng lại danh sách theo thứ tự `columns` — giữ cho giá trị app lưu\n // luôn cùng một hình dạng, dù `DataTable` đằng nào cũng bỏ qua thứ tự bên trong.\n const next = visibleColumnIds.includes(columnId)\n ? visibleColumnIds.filter((id) => id !== columnId)\n : columns.filter((column) => visibleColumnIds.includes(column.id) || column.id === columnId).map((c) => c.id);\n if (next.length === 0) return;\n onVisibleColumnIdsChange(next);\n };\n\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button variant=\"outline\" size=\"sm\" className={className} leftIcon={<SlidersHorizontal className=\"size-4\" />}>\n {text}\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" className=\"w-52\">\n <DropdownMenuLabel>{text}</DropdownMenuLabel>\n <DropdownMenuSeparator />\n {hideable.map((column) => (\n <DropdownMenuCheckboxItem\n key={column.id}\n checked={visibleColumnIds.includes(column.id)}\n onCheckedChange={() => toggle(column.id)}\n onSelect={(event) => event.preventDefault()}\n >\n {column.label ?? (typeof column.header === \"string\" ? column.header : column.id)}\n </DropdownMenuCheckboxItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -6,11 +6,11 @@ import { cn } from "../../lib/cn.js";
|
|
|
6
6
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible.js";
|
|
7
7
|
const accentClasses = {
|
|
8
8
|
none: "",
|
|
9
|
-
primary: "border-l-4 border-l-primary
|
|
10
|
-
info: "border-l-4 border-l-info
|
|
11
|
-
warning: "border-l-4 border-l-warning
|
|
12
|
-
success: "border-l-4 border-l-success
|
|
13
|
-
destructive: "border-l-4 border-l-destructive
|
|
9
|
+
primary: "border-l-4 border-l-primary",
|
|
10
|
+
info: "border-l-4 border-l-info",
|
|
11
|
+
warning: "border-l-4 border-l-warning",
|
|
12
|
+
success: "border-l-4 border-l-success",
|
|
13
|
+
destructive: "border-l-4 border-l-destructive"
|
|
14
14
|
};
|
|
15
15
|
const accentIconClasses = {
|
|
16
16
|
none: "bg-muted text-muted-foreground",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/form-section.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronDown } from \"lucide-react\";\nimport { type ReactNode, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"../ui/collapsible.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\nexport type FormSectionAccent = \"none\" | \"primary\" | \"info\" | \"warning\" | \"success\" | \"destructive\";\n\nexport interface FormSectionProps {\n title: ReactNode;\n description?: ReactNode;\n icon?: IconComponent;\n /** Slot bên phải tiêu đề (nút phụ, công tắc…) — nằm NGOÀI nút gập để không lồng button */\n actions?: ReactNode;\n /** Cho phép gập/mở (mặc định false) */\n collapsible?: boolean;\n /** Trạng thái mở ban đầu khi không controlled */\n defaultOpen?: boolean;\n /** Controlled: app tự giữ trạng thái mở */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Vạch màu bên trái, phân biệt nhóm trường (mặc định \"none\") */\n accent?: FormSectionAccent;\n children: ReactNode;\n className?: string;\n contentClassName?: string;\n}\n\n/*\n * Vạch 4px bên trái MANG THÔNG TIN (phân biệt nhóm trường), nên chịu ngưỡng 3:1 của WCAG\n * 1.4.11 — không phải viền trang trí. Nó đứng trên nền TRANG (`bg-card`) chứ không phải trên\n * nền cùng tone, tức đúng vai trò bậc `-ink` của hợp đồng màu (docs/hop-dong-mau.md §1).\n *\n * Bản trước lấy thẳng bậc NỀN và hỏng ở light — vạch gần như biến mất trên thẻ trắng:\n * gold-300 #E8B824 1,86:1 ✗ · warning #F2C94C 1,59:1 ✗\n * success #2EA56A 3,13:1 · info #1F8A8B 4,14:1 · destructive #C93B3B 5,03:1\n * (dark thì ngược lại — destructive #C93B3B chỉ 3,53:1 trên `--card`.)\n *\n * Bậc ink đạt thoải mái ở CẢ hai theme, đo trên `--card` (light #FFFFFF · dark #16181D):\n * primary-ink 6,51 / 11,90:1 success-ink 6,52 / 10,19:1\n * info-ink 6,68 / 9,54:1 warning-ink 6,51 / 11,60:1\n * destructive-ink 7,15 / 6,42:1\n * ⚠ Các số trên là của bảng TƯƠNG PHẢN CAO. Bảng MẶC ĐỊNH (đo trên 3.3.4, trên `--card`, light / dark):\n * info 6,68 / 4,28 · success 6,52 / 5,67 · warning 6,51 / 11,19 · destructive 4,38 / 4,06 — vẫn qua 3:1;\n * riêng `primary-ink` trỏ thẳng về `--primary` (từ 0.3.0) nên vạch `primary` ở light chỉ 1,86:1 — khoản\n * gold-của-control dưới ngưỡng mà bảng mặc định đã chấp nhận; ai cần tương phản thì bật bảng tương phản cao.\n */\nconst accentClasses: Record<FormSectionAccent, string> = {\n none: \"\",\n primary: \"border-l-4 border-l-primary-ink\",\n info: \"border-l-4 border-l-info-ink\",\n warning: \"border-l-4 border-l-warning-ink\",\n success: \"border-l-4 border-l-success-ink\",\n destructive: \"border-l-4 border-l-destructive-ink\"\n};\n\n/*\n * Ô icon đi theo CÙNG accent với vạch trái.\n *\n * Vì sao không để nó trung tính như bản trước: nhóm `accent=\"destructive\"` vẽ vạch đỏ nhưng ô\n * icon vẫn `bg-muted` xám, nên hai tín hiệu trong cùng một header nói hai điều khác nhau — và\n * cái NẶNG KÝ hơn về thị giác (ô 32px có nền, so với vạch 4px) là cái nói sai. Đo trên\n * `/settings/advanced` của comitor-starter: nhóm \"Vùng nguy hiểm\" — nhóm chứa nút xoá sạch dữ\n * liệu — có icon cảnh báo cùng màu với icon của nhóm \"Dữ liệu ứng dụng\" ngay phía trên.\n *\n * ⚠ `primary` CỐ Ý giữ ô trung tính, không tô tint. Ở bảng MẶC ĐỊNH `--primary-ink` trỏ thẳng về\n * `--primary` (styles.css §\"TỪ 0.3.0\"), nên `text-primary-ink` trên `bg-primary/20` là gold\n * #E8B824 trên tint gold #FAF1D3 = **1,64:1** — thủng ngưỡng 3:1 của WCAG 1.4.11, và tệ hơn hẳn\n * ô trung tính đang có (3,83:1). Đây là món nợ brand mà §22 của docs/quyet-dinh-tich-hop.md đã\n * ghi là \"CÒN TỒN, có chủ ý\"; nối accent vào ô icon mà không loại `primary` ra là MỞ RỘNG món nợ\n * đó sang một bề mặt mới. Tô ĐẶC (`bg-primary text-primary-foreground`, 10,19:1 — lối chữa mà\n * §22 dùng cho `AppLauncher`) thì đọc được, nhưng một ô gold đặc 32px kêu to hơn cả nhóm\n * `destructive`, tức lật ngược thứ bậc nghiêm trọng. Nên: giữ trung tính cho tới khi `--primary`\n * có một bậc ink thật ở bảng mặc định.\n *\n * ⚠⚠ SỐ ĐO DƯỚI ĐÂY LÀ CỦA BẢNG MẶC ĐỊNH, và phải luôn ghi rõ bảng nào. Từ 1.6.0 cổng ĐO cả hai\n * bảng, nhưng nó chỉ **CHẶN** ở bảng tương phản cao; bảng mặc định vào sổ `banDuyetChuaChan` — in\n * ra mỗi lần chạy, chưa chặn. Nên với những cặp mà hai bảng cho hai con số khác nhau, cổng xanh\n * KHÔNG chứng minh bảng mặc định đạt. (Trước 1.6.0 còn tệ hơn: cổng không đo bảng mặc định lần\n * nào. Và bản đầu của chính khối comment này chép số của bảng tương phản cao mà không ghi rõ, tức\n * mắc đúng cái bẫy nó đang cảnh báo.)\n *\n * tone bảng mặc định (light, ink trên tint của chính nó)\n * none 3,83:1 ✓ (bg-muted, giữ nguyên)\n * primary 1,64:1 ✗ → KHÔNG tint, dùng lại ô trung tính\n * warning 5,93:1 ✓\n * success 5,60:1 ✓\n * destructive 3,57:1 ✓\n * info 5,54:1 ✓\n *\n * Bốn tone có tint lấy Y HỆT `statCardIconVariants` (stat-card.tsx) để gói chỉ có MỘT thang tone\n * cho ô icon. Khác duy nhất là hình học — ở đây `size-8 rounded-md`, ở StatCard `size-7\n * rounded-lg` — nên hai chỗ không dùng chung được một `cva`, và đó là lý do bảng này chép giá trị\n * chứ không import. Đổi thang thì phải đổi CẢ HAI.\n *\n * ⚠ Viết THẲNG từng chuỗi, không dựng bằng `bg-${accent}/15`: cổng quét class TĨNH trong `src/**`\n * và xếp lớp dựng lúc chạy vào nhóm \"CẦN KIỂM TAY\" → FAIL. Hành vi cố ý, không phải hạn chế.\n */\nconst accentIconClasses: Record<FormSectionAccent, string> = {\n none: \"bg-muted text-muted-foreground\",\n primary: \"bg-muted text-muted-foreground\",\n info: \"bg-info/15 text-info-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n success: \"bg-success/15 text-success-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\"\n};\n\nfunction SectionHeading({\n title,\n description,\n icon: Icon,\n accent\n}: Pick<FormSectionProps, \"title\" | \"description\" | \"icon\"> & { accent: FormSectionAccent }) {\n return (\n <div className=\"flex min-w-0 items-start gap-3 text-left\">\n {Icon && (\n <span\n aria-hidden=\"true\"\n className={cn(\n \"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md\",\n accentIconClasses[accent]\n )}\n >\n <Icon className=\"size-4\" />\n </span>\n )}\n <div className=\"min-w-0\">\n {/* KHÔNG `truncate`: ở 320px tiêu đề nhóm dài (\"Thông tin không gian làm việc\") bị cắt\n mất chữ — đo được 26px nội dung biến mất. Tiêu đề xuống dòng thì xấu hơn một chút\n nhưng vẫn đọc được, còn cắt thì mất thông tin. `min-w-0` ở thẻ cha vẫn giữ cho hàng\n header của nhóm không đẩy nút actions ra ngoài. */}\n <h3 className=\"text-sm font-semibold tracking-tight text-foreground\">{title}</h3>\n {description && <p className=\"mt-0.5 text-xs text-muted-foreground\">{description}</p>}\n </div>\n </div>\n );\n}\n\n/**\n * Nhóm trường trong form: tiêu đề + mô tả + lưới 12 cột cho các `FormField`.\n *\n * Nội dung là `grid-cols-12` để `FormField` chỉ cần khai `colSpan` — cách này giữ mọi form\n * trong hệ sinh thái cùng một nhịp cột, thay vì mỗi trang tự chế `flex` với bề rộng riêng.\n */\nexport function FormSection({\n title,\n description,\n icon,\n actions,\n collapsible = false,\n defaultOpen = true,\n open,\n onOpenChange,\n accent = \"none\",\n children,\n className,\n contentClassName\n}: FormSectionProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const isOpen = open ?? internalOpen;\n\n const setOpen = (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n };\n\n const shell = cn(\"rounded-xl border border-border bg-card\", accentClasses[accent], className);\n const grid = cn(\"grid grid-cols-12 gap-4 p-4 md:gap-5 md:p-5\", contentClassName);\n\n if (!collapsible) {\n return (\n <section data-slot=\"form-section\" className={shell}>\n <div className=\"flex items-start justify-between gap-3 border-b border-border p-4 md:px-5\">\n <SectionHeading title={title} description={description} icon={icon} accent={accent} />\n {actions && <div className=\"flex shrink-0 items-center gap-2\">{actions}</div>}\n </div>\n <div className={grid}>{children}</div>\n </section>\n );\n }\n\n return (\n <Collapsible open={isOpen} onOpenChange={setOpen} asChild>\n <section data-slot=\"form-section\" className={shell}>\n <div className=\"flex items-start justify-between gap-3 border-b border-border p-4 md:px-5\">\n <CollapsibleTrigger className=\"flex min-w-0 flex-1 items-start gap-3 rounded-md text-left\">\n <SectionHeading title={title} description={description} icon={icon} accent={accent} />\n <ChevronDown\n aria-hidden=\"true\"\n className={cn(\n \"ml-auto mt-1 size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out\",\n isOpen && \"rotate-180\"\n )}\n />\n </CollapsibleTrigger>\n {actions && <div className=\"flex shrink-0 items-center gap-2\">{actions}</div>}\n </div>\n <CollapsibleContent>\n <div className={grid}>{children}</div>\n </CollapsibleContent>\n </section>\n </Collapsible>\n );\n}\n"],"mappings":";AA6HU,cAGJ,YAHI;AA3HV,SAAS,mBAAmB;AAC5B,SAAyB,gBAAgB;AACzC,SAAS,UAAU;AACnB,SAAS,aAAa,oBAAoB,0BAA0B;AA4CpE,MAAM,gBAAmD;AAAA,EACvD,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,aAAa;AACf;AA4CA,MAAM,oBAAuD;AAAA,EAC3D,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,aAAa;AACf;AAEA,SAAS,eAAe;AAAA,EACtB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AACF,GAA6F;AAC3F,SACE,qBAAC,SAAI,WAAU,4CACZ;AAAA,YACC;AAAA,MAAC;AAAA;AAAA,QACC,eAAY;AAAA,QACZ,WAAW;AAAA,UACT;AAAA,UACA,kBAAkB,MAAM;AAAA,QAC1B;AAAA,QAEA,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,IAC3B;AAAA,IAEF,qBAAC,SAAI,WAAU,WAKb;AAAA,0BAAC,QAAG,WAAU,wDAAwD,iBAAM;AAAA,MAC3E,eAAe,oBAAC,OAAE,WAAU,wCAAwC,uBAAY;AAAA,OACnF;AAAA,KACF;AAEJ;AAQO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AACF,GAAqB;AACnB,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,WAAW;AAC5D,QAAM,SAAS,QAAQ;AAEvB,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,SAAS,OAAW,iBAAgB,IAAI;AAC5C,mBAAe,IAAI;AAAA,EACrB;AAEA,QAAM,QAAQ,GAAG,2CAA2C,cAAc,MAAM,GAAG,SAAS;AAC5F,QAAM,OAAO,GAAG,+CAA+C,gBAAgB;AAE/E,MAAI,CAAC,aAAa;AAChB,WACE,qBAAC,aAAQ,aAAU,gBAAe,WAAW,OAC3C;AAAA,2BAAC,SAAI,WAAU,6EACb;AAAA,4BAAC,kBAAe,OAAc,aAA0B,MAAY,QAAgB;AAAA,QACnF,WAAW,oBAAC,SAAI,WAAU,oCAAoC,mBAAQ;AAAA,SACzE;AAAA,MACA,oBAAC,SAAI,WAAW,MAAO,UAAS;AAAA,OAClC;AAAA,EAEJ;AAEA,SACE,oBAAC,eAAY,MAAM,QAAQ,cAAc,SAAS,SAAO,MACvD,+BAAC,aAAQ,aAAU,gBAAe,WAAW,OAC3C;AAAA,yBAAC,SAAI,WAAU,6EACb;AAAA,2BAAC,sBAAmB,WAAU,8DAC5B;AAAA,4BAAC,kBAAe,OAAc,aAA0B,MAAY,QAAgB;AAAA,QACpF;AAAA,UAAC;AAAA;AAAA,YACC,eAAY;AAAA,YACZ,WAAW;AAAA,cACT;AAAA,cACA,UAAU;AAAA,YACZ;AAAA;AAAA,QACF;AAAA,SACF;AAAA,MACC,WAAW,oBAAC,SAAI,WAAU,oCAAoC,mBAAQ;AAAA,OACzE;AAAA,IACA,oBAAC,sBACC,8BAAC,SAAI,WAAW,MAAO,UAAS,GAClC;AAAA,KACF,GACF;AAEJ;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/form-section.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronDown } from \"lucide-react\";\nimport { type ReactNode, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from \"../ui/collapsible.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\nexport type FormSectionAccent = \"none\" | \"primary\" | \"info\" | \"warning\" | \"success\" | \"destructive\";\n\nexport interface FormSectionProps {\n title: ReactNode;\n description?: ReactNode;\n icon?: IconComponent;\n /** Slot bên phải tiêu đề (nút phụ, công tắc…) — nằm NGOÀI nút gập để không lồng button */\n actions?: ReactNode;\n /** Cho phép gập/mở (mặc định false) */\n collapsible?: boolean;\n /** Trạng thái mở ban đầu khi không controlled */\n defaultOpen?: boolean;\n /** Controlled: app tự giữ trạng thái mở */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Vạch màu bên trái, phân biệt nhóm trường (mặc định \"none\") */\n accent?: FormSectionAccent;\n children: ReactNode;\n className?: string;\n contentClassName?: string;\n}\n\n/*\n * Vạch 4px bên trái dùng bậc TÔ NỀN của tone (`--success`, `--warning`…, `primary` = `--primary`), không phải\n * bậc `-ink` — duyệt ở comitor-ds (chưa phát hành): bậc ink đậm quá, vạch nặng hơn cả tiêu đề. Cùng bậc với cung\n * `CircularProgress`.\n *\n * Cái giá: ở light vài vạch dưới 3:1 với `--card` — `warning` #F2C94C 1,59:1, `primary` #E8B824 1,86:1, `success`\n * 3,13:1 (`info` 4,14:1 · `destructive` 4,38:1 vẫn qua). Chấp nhận vì vạch KHÔNG đứng một mình: ô icon cùng accent\n * (bậc ink trên tint, ≥4,5:1) và tiêu đề nhóm mang cùng thông tin. Số đo đầy đủ cả hai theme ở trang FormSection\n * của comitor-ds.\n *\n * Cổng tương phản thấy bảng này: từng lớp có miễn trừ kèm số đo (vai trò + tương phản) ở\n * `scripts/contrast-exemptions.json`.\n */\nconst accentClasses: Record<FormSectionAccent, string> = {\n none: \"\",\n primary: \"border-l-4 border-l-primary\",\n info: \"border-l-4 border-l-info\",\n warning: \"border-l-4 border-l-warning\",\n success: \"border-l-4 border-l-success\",\n destructive: \"border-l-4 border-l-destructive\"\n};\n\n/*\n * Ô icon đi theo CÙNG accent với vạch trái.\n *\n * Vì sao không để nó trung tính như bản trước: nhóm `accent=\"destructive\"` vẽ vạch đỏ nhưng ô\n * icon vẫn `bg-muted` xám, nên hai tín hiệu trong cùng một header nói hai điều khác nhau — và\n * cái NẶNG KÝ hơn về thị giác (ô 32px có nền, so với vạch 4px) là cái nói sai. Đo trên\n * `/settings/advanced` của comitor-starter: nhóm \"Vùng nguy hiểm\" — nhóm chứa nút xoá sạch dữ\n * liệu — có icon cảnh báo cùng màu với icon của nhóm \"Dữ liệu ứng dụng\" ngay phía trên.\n *\n * ⚠ `primary` CỐ Ý giữ ô trung tính, không tô tint. Ở bảng MẶC ĐỊNH `--primary-ink` trỏ thẳng về\n * `--primary` (styles.css §\"TỪ 0.3.0\"), nên `text-primary-ink` trên `bg-primary/20` là gold\n * #E8B824 trên tint gold #FAF1D3 = **1,64:1** — thủng ngưỡng 3:1 của WCAG 1.4.11, và tệ hơn hẳn\n * ô trung tính đang có (3,83:1). Đây là món nợ brand mà §22 của docs/quyet-dinh-tich-hop.md đã\n * ghi là \"CÒN TỒN, có chủ ý\"; nối accent vào ô icon mà không loại `primary` ra là MỞ RỘNG món nợ\n * đó sang một bề mặt mới. Tô ĐẶC (`bg-primary text-primary-foreground`, 10,19:1 — lối chữa mà\n * §22 dùng cho `AppLauncher`) thì đọc được, nhưng một ô gold đặc 32px kêu to hơn cả nhóm\n * `destructive`, tức lật ngược thứ bậc nghiêm trọng. Nên: giữ trung tính cho tới khi `--primary`\n * có một bậc ink thật ở bảng mặc định.\n *\n * ⚠⚠ SỐ ĐO DƯỚI ĐÂY LÀ CỦA BẢNG MẶC ĐỊNH, và phải luôn ghi rõ bảng nào. Từ 1.6.0 cổng ĐO cả hai\n * bảng, nhưng nó chỉ **CHẶN** ở bảng tương phản cao; bảng mặc định vào sổ `banDuyetChuaChan` — in\n * ra mỗi lần chạy, chưa chặn. Nên với những cặp mà hai bảng cho hai con số khác nhau, cổng xanh\n * KHÔNG chứng minh bảng mặc định đạt. (Trước 1.6.0 còn tệ hơn: cổng không đo bảng mặc định lần\n * nào. Và bản đầu của chính khối comment này chép số của bảng tương phản cao mà không ghi rõ, tức\n * mắc đúng cái bẫy nó đang cảnh báo.)\n *\n * tone bảng mặc định (light, ink trên tint của chính nó)\n * none 3,83:1 ✓ (bg-muted, giữ nguyên)\n * primary 1,64:1 ✗ → KHÔNG tint, dùng lại ô trung tính\n * warning 5,93:1 ✓\n * success 5,60:1 ✓\n * destructive 3,57:1 ✓\n * info 5,54:1 ✓\n *\n * Bốn tone có tint lấy Y HỆT `statCardIconVariants` (stat-card.tsx) để gói chỉ có MỘT thang tone\n * cho ô icon. Khác duy nhất là hình học — ở đây `size-8 rounded-md`, ở StatCard `size-7\n * rounded-lg` — nên hai chỗ không dùng chung được một `cva`, và đó là lý do bảng này chép giá trị\n * chứ không import. Đổi thang thì phải đổi CẢ HAI.\n *\n * ⚠ Viết THẲNG từng chuỗi, không dựng bằng `bg-${accent}/15`: cổng quét class TĨNH trong `src/**`\n * và xếp lớp dựng lúc chạy vào nhóm \"CẦN KIỂM TAY\" → FAIL. Hành vi cố ý, không phải hạn chế.\n */\nconst accentIconClasses: Record<FormSectionAccent, string> = {\n none: \"bg-muted text-muted-foreground\",\n primary: \"bg-muted text-muted-foreground\",\n info: \"bg-info/15 text-info-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n success: \"bg-success/15 text-success-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\"\n};\n\nfunction SectionHeading({\n title,\n description,\n icon: Icon,\n accent\n}: Pick<FormSectionProps, \"title\" | \"description\" | \"icon\"> & { accent: FormSectionAccent }) {\n return (\n <div className=\"flex min-w-0 items-start gap-3 text-left\">\n {Icon && (\n <span\n aria-hidden=\"true\"\n className={cn(\n \"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md\",\n accentIconClasses[accent]\n )}\n >\n <Icon className=\"size-4\" />\n </span>\n )}\n <div className=\"min-w-0\">\n {/* KHÔNG `truncate`: ở 320px tiêu đề nhóm dài (\"Thông tin không gian làm việc\") bị cắt\n mất chữ — đo được 26px nội dung biến mất. Tiêu đề xuống dòng thì xấu hơn một chút\n nhưng vẫn đọc được, còn cắt thì mất thông tin. `min-w-0` ở thẻ cha vẫn giữ cho hàng\n header của nhóm không đẩy nút actions ra ngoài. */}\n <h3 className=\"text-sm font-semibold tracking-tight text-foreground\">{title}</h3>\n {description && <p className=\"mt-0.5 text-xs text-muted-foreground\">{description}</p>}\n </div>\n </div>\n );\n}\n\n/**\n * Nhóm trường trong form: tiêu đề + mô tả + lưới 12 cột cho các `FormField`.\n *\n * Nội dung là `grid-cols-12` để `FormField` chỉ cần khai `colSpan` — cách này giữ mọi form\n * trong hệ sinh thái cùng một nhịp cột, thay vì mỗi trang tự chế `flex` với bề rộng riêng.\n */\nexport function FormSection({\n title,\n description,\n icon,\n actions,\n collapsible = false,\n defaultOpen = true,\n open,\n onOpenChange,\n accent = \"none\",\n children,\n className,\n contentClassName\n}: FormSectionProps) {\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const isOpen = open ?? internalOpen;\n\n const setOpen = (next: boolean) => {\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n };\n\n const shell = cn(\"rounded-xl border border-border bg-card\", accentClasses[accent], className);\n const grid = cn(\"grid grid-cols-12 gap-4 p-4 md:gap-5 md:p-5\", contentClassName);\n\n if (!collapsible) {\n return (\n <section data-slot=\"form-section\" className={shell}>\n <div className=\"flex items-start justify-between gap-3 border-b border-border p-4 md:px-5\">\n <SectionHeading title={title} description={description} icon={icon} accent={accent} />\n {actions && <div className=\"flex shrink-0 items-center gap-2\">{actions}</div>}\n </div>\n <div className={grid}>{children}</div>\n </section>\n );\n }\n\n return (\n <Collapsible open={isOpen} onOpenChange={setOpen} asChild>\n <section data-slot=\"form-section\" className={shell}>\n <div className=\"flex items-start justify-between gap-3 border-b border-border p-4 md:px-5\">\n <CollapsibleTrigger className=\"flex min-w-0 flex-1 items-start gap-3 rounded-md text-left\">\n <SectionHeading title={title} description={description} icon={icon} accent={accent} />\n <ChevronDown\n aria-hidden=\"true\"\n className={cn(\n \"ml-auto mt-1 size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out\",\n isOpen && \"rotate-180\"\n )}\n />\n </CollapsibleTrigger>\n {actions && <div className=\"flex shrink-0 items-center gap-2\">{actions}</div>}\n </div>\n <CollapsibleContent>\n <div className={grid}>{children}</div>\n </CollapsibleContent>\n </section>\n </Collapsible>\n );\n}\n"],"mappings":";AAuHU,cAGJ,YAHI;AArHV,SAAS,mBAAmB;AAC5B,SAAyB,gBAAgB;AACzC,SAAS,UAAU;AACnB,SAAS,aAAa,oBAAoB,0BAA0B;AAsCpE,MAAM,gBAAmD;AAAA,EACvD,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,aAAa;AACf;AA4CA,MAAM,oBAAuD;AAAA,EAC3D,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,aAAa;AACf;AAEA,SAAS,eAAe;AAAA,EACtB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AACF,GAA6F;AAC3F,SACE,qBAAC,SAAI,WAAU,4CACZ;AAAA,YACC;AAAA,MAAC;AAAA;AAAA,QACC,eAAY;AAAA,QACZ,WAAW;AAAA,UACT;AAAA,UACA,kBAAkB,MAAM;AAAA,QAC1B;AAAA,QAEA,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,IAC3B;AAAA,IAEF,qBAAC,SAAI,WAAU,WAKb;AAAA,0BAAC,QAAG,WAAU,wDAAwD,iBAAM;AAAA,MAC3E,eAAe,oBAAC,OAAE,WAAU,wCAAwC,uBAAY;AAAA,OACnF;AAAA,KACF;AAEJ;AAQO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AACF,GAAqB;AACnB,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,WAAW;AAC5D,QAAM,SAAS,QAAQ;AAEvB,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,SAAS,OAAW,iBAAgB,IAAI;AAC5C,mBAAe,IAAI;AAAA,EACrB;AAEA,QAAM,QAAQ,GAAG,2CAA2C,cAAc,MAAM,GAAG,SAAS;AAC5F,QAAM,OAAO,GAAG,+CAA+C,gBAAgB;AAE/E,MAAI,CAAC,aAAa;AAChB,WACE,qBAAC,aAAQ,aAAU,gBAAe,WAAW,OAC3C;AAAA,2BAAC,SAAI,WAAU,6EACb;AAAA,4BAAC,kBAAe,OAAc,aAA0B,MAAY,QAAgB;AAAA,QACnF,WAAW,oBAAC,SAAI,WAAU,oCAAoC,mBAAQ;AAAA,SACzE;AAAA,MACA,oBAAC,SAAI,WAAW,MAAO,UAAS;AAAA,OAClC;AAAA,EAEJ;AAEA,SACE,oBAAC,eAAY,MAAM,QAAQ,cAAc,SAAS,SAAO,MACvD,+BAAC,aAAQ,aAAU,gBAAe,WAAW,OAC3C;AAAA,yBAAC,SAAI,WAAU,6EACb;AAAA,2BAAC,sBAAmB,WAAU,8DAC5B;AAAA,4BAAC,kBAAe,OAAc,aAA0B,MAAY,QAAgB;AAAA,QACpF;AAAA,UAAC;AAAA;AAAA,YACC,eAAY;AAAA,YACZ,WAAW;AAAA,cACT;AAAA,cACA,UAAU;AAAA,YACZ;AAAA;AAAA,QACF;AAAA,SACF;AAAA,MACC,WAAW,oBAAC,SAAI,WAAU,oCAAoC,mBAAQ;AAAA,OACzE;AAAA,IACA,oBAAC,sBACC,8BAAC,SAAI,WAAW,MAAO,UAAS,GAClC;AAAA,KACF,GACF;AAEJ;","names":[]}
|
|
@@ -26,21 +26,21 @@ import {
|
|
|
26
26
|
weekday
|
|
27
27
|
} from "./gantt-utils.js";
|
|
28
28
|
const ganttBarVariants = cva(
|
|
29
|
-
"absolute flex items-center overflow-hidden rounded-md border text-xs font-medium touch-none select-none",
|
|
29
|
+
"absolute flex items-center overflow-hidden rounded-md border bg-card text-xs font-medium touch-none select-none",
|
|
30
30
|
{
|
|
31
31
|
variants: {
|
|
32
32
|
tone: {
|
|
33
|
-
neutral: "border-
|
|
34
|
-
primary: "border-
|
|
35
|
-
blue: "border-blue-ink
|
|
36
|
-
teal: "border-teal-ink
|
|
37
|
-
green: "border-green-ink
|
|
38
|
-
yellow: "border-yellow-ink
|
|
39
|
-
orange: "border-orange-ink
|
|
40
|
-
red: "border-destructive-ink
|
|
41
|
-
purple: "border-purple-ink
|
|
42
|
-
pink: "border-pink-ink
|
|
43
|
-
navy: "border-navy-ink
|
|
33
|
+
neutral: "bg-linear-to-r border-muted-foreground from-muted to-muted text-foreground",
|
|
34
|
+
primary: "bg-linear-to-r border-gold-600 from-gold-100 to-gold-100 text-gold-600 dark:border-gold-100 dark:from-gold-500/30 dark:to-gold-500/30 dark:text-gold-100",
|
|
35
|
+
blue: "bg-linear-to-r border-blue-ink from-blue/15 to-blue/15 text-blue-ink",
|
|
36
|
+
teal: "bg-linear-to-r border-teal-ink from-teal/15 to-teal/15 text-teal-ink",
|
|
37
|
+
green: "bg-linear-to-r border-green-ink from-green/15 to-green/15 text-green-ink",
|
|
38
|
+
yellow: "bg-linear-to-r border-yellow-ink from-yellow/25 to-yellow/25 text-yellow-ink dark:from-yellow/20 dark:to-yellow/20",
|
|
39
|
+
orange: "bg-linear-to-r border-orange-ink from-orange/15 to-orange/15 text-orange-ink",
|
|
40
|
+
red: "bg-linear-to-r border-destructive-ink from-destructive/15 to-destructive/15 text-destructive-ink",
|
|
41
|
+
purple: "bg-linear-to-r border-purple-ink from-purple/15 to-purple/15 text-purple-ink",
|
|
42
|
+
pink: "bg-linear-to-r border-pink-ink from-pink/15 to-pink/15 text-pink-ink",
|
|
43
|
+
navy: "bg-linear-to-r border-navy-ink from-navy/15 to-navy/15 text-navy-ink"
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
46
|
defaultVariants: { tone: "neutral" }
|
|
@@ -49,7 +49,9 @@ const ganttBarVariants = cva(
|
|
|
49
49
|
const ganttMarkVariants = cva("", {
|
|
50
50
|
variants: {
|
|
51
51
|
tone: {
|
|
52
|
-
|
|
52
|
+
// `--muted-foreground`, không `--foreground`: thanh tổng và mốc đen kịt nặng hơn mọi thứ quanh nó. Vẫn qua
|
|
53
|
+
// 3:1 của dấu phi văn bản ở mọi bảng (3,83:1 light mặc định · 4,94:1 dark · 5,23:1 tương phản cao).
|
|
54
|
+
neutral: "bg-muted-foreground",
|
|
53
55
|
primary: "bg-primary-ink",
|
|
54
56
|
blue: "bg-blue-ink",
|
|
55
57
|
teal: "bg-teal-ink",
|
|
@@ -271,7 +273,7 @@ function Gantt({
|
|
|
271
273
|
const y = row.index * rowHeight + rowHeight / 2;
|
|
272
274
|
if (row.item.type === "milestone") {
|
|
273
275
|
const cx = xOf(dates.start) + ppd / 2;
|
|
274
|
-
const half = barHeight /
|
|
276
|
+
const half = Math.max(3, Math.round(barHeight / 5));
|
|
275
277
|
return { x1: cx - half, x2: cx + half, y, ...dates };
|
|
276
278
|
}
|
|
277
279
|
const x1 = xOf(dates.start);
|
|
@@ -520,6 +522,11 @@ function Gantt({
|
|
|
520
522
|
}
|
|
521
523
|
return set;
|
|
522
524
|
}, [dependencies, rows, indexById]);
|
|
525
|
+
const arrowEnds = (row, g) => {
|
|
526
|
+
if (row.item.type !== "milestone") return g;
|
|
527
|
+
const grow = (g.x2 - g.x1) / 2 * (Math.SQRT2 - 1);
|
|
528
|
+
return { ...g, x1: g.x1 - grow, x2: g.x2 + grow };
|
|
529
|
+
};
|
|
523
530
|
const dependencyPaths = [];
|
|
524
531
|
for (const dep of dependencies) {
|
|
525
532
|
const a = rows[indexById.get(dep.from) ?? -1];
|
|
@@ -532,7 +539,7 @@ function Gantt({
|
|
|
532
539
|
const type = dep.type ?? "finish-to-start";
|
|
533
540
|
dependencyPaths.push({
|
|
534
541
|
dep,
|
|
535
|
-
d: routeDependency(ga, gb, type, rowHeight),
|
|
542
|
+
d: routeDependency(arrowEnds(a, ga), arrowEnds(b, gb), type, rowHeight),
|
|
536
543
|
violated: isDependencyViolated(ga, gb, type),
|
|
537
544
|
label: t.dependency(a.item.title, b.item.title)
|
|
538
545
|
});
|
|
@@ -572,6 +579,7 @@ function Gantt({
|
|
|
572
579
|
const totalWidth = sidebarWidth + timelineWidth;
|
|
573
580
|
const todayX = xOf(today) + ppd / 2;
|
|
574
581
|
const showToday = today >= range.start && today < range.end;
|
|
582
|
+
const todayChipHalf = showToday && config.minor !== "day" ? (formatShort(today).length * 7 + 8) / 2 + 4 : 0;
|
|
575
583
|
const instructionsId = React.useId();
|
|
576
584
|
const toolbarExtra = typeof toolbar === "boolean" ? null : toolbar;
|
|
577
585
|
const scaleNames = {
|
|
@@ -673,6 +681,8 @@ function Gantt({
|
|
|
673
681
|
minorTicks.map((tick) => {
|
|
674
682
|
const width = (tick.next - tick.day) * ppd;
|
|
675
683
|
const isToday = config.minor === "day" && tick.day === today;
|
|
684
|
+
const labelStart = xOf(tick.day) + 8;
|
|
685
|
+
const hiddenByToday = todayChipHalf > 0 && todayX - todayChipHalf < labelStart + minorLabel(tick.day).length * 7 && todayX + todayChipHalf > labelStart;
|
|
676
686
|
return /* @__PURE__ */ jsx(
|
|
677
687
|
"div",
|
|
678
688
|
{
|
|
@@ -682,7 +692,7 @@ function Gantt({
|
|
|
682
692
|
config.minor === "day" ? "justify-center" : "px-2"
|
|
683
693
|
),
|
|
684
694
|
style: { left: xOf(tick.day), width, top: headerHeight - HEADER_ROW, height: HEADER_ROW },
|
|
685
|
-
children: isToday ? /* @__PURE__ */ jsx("span", { className: "rounded-sm bg-primary px-1 font-semibold text-primary-foreground", children: minorLabel(tick.day) }) : /* @__PURE__ */ jsxs("span", { className: "truncate", children: [
|
|
695
|
+
children: isToday ? /* @__PURE__ */ jsx("span", { className: "rounded-sm bg-primary px-1 font-semibold text-primary-foreground", children: minorLabel(tick.day) }) : /* @__PURE__ */ jsxs("span", { className: cn("truncate", hiddenByToday && "invisible"), children: [
|
|
686
696
|
config.minor === "day" && /* @__PURE__ */ jsx("span", { className: "me-0.5 text-[10px]", children: fmt.weekdayNarrow.format(dayToLocalDate(tick.day)) }),
|
|
687
697
|
minorLabel(tick.day)
|
|
688
698
|
] })
|
|
@@ -729,6 +739,14 @@ function Gantt({
|
|
|
729
739
|
},
|
|
730
740
|
`line-${tick.day}`
|
|
731
741
|
)),
|
|
742
|
+
virtualRows.map((virtualRow) => /* @__PURE__ */ jsx(
|
|
743
|
+
"div",
|
|
744
|
+
{
|
|
745
|
+
className: "absolute inset-x-0 border-b border-border",
|
|
746
|
+
style: { top: virtualRow.start - headerHeight + rowHeight - 1, height: 1 }
|
|
747
|
+
},
|
|
748
|
+
`row-line-${virtualRow.index}`
|
|
749
|
+
)),
|
|
732
750
|
showToday && /* @__PURE__ */ jsx(
|
|
733
751
|
"div",
|
|
734
752
|
{
|
|
@@ -757,8 +775,9 @@ function Gantt({
|
|
|
757
775
|
viewBox: "0 0 8 8",
|
|
758
776
|
refX: "7",
|
|
759
777
|
refY: "4",
|
|
760
|
-
|
|
761
|
-
|
|
778
|
+
markerUnits: "userSpaceOnUse",
|
|
779
|
+
markerWidth: "8",
|
|
780
|
+
markerHeight: "8",
|
|
762
781
|
orient: "auto",
|
|
763
782
|
children: /* @__PURE__ */ jsx("path", { d: "M0,0 L8,4 L0,8 z", className: "fill-muted-foreground" })
|
|
764
783
|
}
|
|
@@ -770,8 +789,9 @@ function Gantt({
|
|
|
770
789
|
viewBox: "0 0 8 8",
|
|
771
790
|
refX: "7",
|
|
772
791
|
refY: "4",
|
|
773
|
-
|
|
774
|
-
|
|
792
|
+
markerUnits: "userSpaceOnUse",
|
|
793
|
+
markerWidth: "8",
|
|
794
|
+
markerHeight: "8",
|
|
775
795
|
orient: "auto",
|
|
776
796
|
children: /* @__PURE__ */ jsx("path", { d: "M0,0 L8,4 L0,8 z", className: "fill-destructive-ink" })
|
|
777
797
|
}
|
|
@@ -920,7 +940,7 @@ function Gantt({
|
|
|
920
940
|
{
|
|
921
941
|
role: "gridcell",
|
|
922
942
|
tabIndex: -1,
|
|
923
|
-
className: cn("relative
|
|
943
|
+
className: cn("relative", isHovered && "bg-muted/40"),
|
|
924
944
|
style: { width: timelineWidth },
|
|
925
945
|
children: [
|
|
926
946
|
/* @__PURE__ */ jsx("span", { className: "sr-only", children: description }),
|
|
@@ -958,7 +978,7 @@ function Gantt({
|
|
|
958
978
|
className: cn(
|
|
959
979
|
"pointer-events-auto absolute rotate-45 rounded-[2px]",
|
|
960
980
|
ganttMarkVariants({ tone }),
|
|
961
|
-
item.critical && "outline-
|
|
981
|
+
item.critical && "outline-[1.5px] outline-destructive-ink",
|
|
962
982
|
editable ? "cursor-grab" : "cursor-default",
|
|
963
983
|
isDragging && "cursor-grabbing shadow-overlay"
|
|
964
984
|
),
|
|
@@ -1008,14 +1028,13 @@ function Gantt({
|
|
|
1008
1028
|
onDoubleClick: () => onItemOpen?.(item),
|
|
1009
1029
|
children: [
|
|
1010
1030
|
geometry.x2 - geometry.x1 >= MIN_INNER_LABEL && /* @__PURE__ */ jsx("span", { className: "min-w-0 truncate px-2", children: item.title }),
|
|
1011
|
-
item.progress != null && item.progress > 0 &&
|
|
1031
|
+
item.progress != null && item.progress > 0 && // Vạch tiến độ 2px, lùi 1px khỏi viền (cùng màu `-ink`): dán sát đáy thì nó nhập vào
|
|
1032
|
+
// viền thành một dải 4px, nặng hơn cả thân thanh.
|
|
1033
|
+
/* @__PURE__ */ jsx(
|
|
1012
1034
|
"span",
|
|
1013
1035
|
{
|
|
1014
|
-
className: cn(
|
|
1015
|
-
|
|
1016
|
-
ganttMarkVariants({ tone })
|
|
1017
|
-
),
|
|
1018
|
-
style: { width: `${Math.min(100, item.progress)}%` }
|
|
1036
|
+
className: cn("absolute bottom-px left-px h-0.5 rounded-full", ganttMarkVariants({ tone })),
|
|
1037
|
+
style: { width: `max(0px, calc(${Math.min(100, item.progress)}% - 2px))` }
|
|
1019
1038
|
}
|
|
1020
1039
|
),
|
|
1021
1040
|
editable && /* @__PURE__ */ jsxs(Fragment, { children: [
|