@comitor/ui 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ component ghép sẵn và khung ứng dụng (shell) kiểu workspace × app.
|
|
|
8
8
|
- Token brand nằm ở **`styles.css`** — đổi token, bump version, mọi app nhận cùng một thay đổi.
|
|
9
9
|
- Server-component-friendly: `"use client"` chỉ nằm ở file thật sự cần.
|
|
10
10
|
|
|
11
|
-
> Trạng thái: `1.
|
|
12
|
-
> (Tier 3) · 4 file chart.
|
|
11
|
+
> Trạng thái: `1.7.1` — 52 file primitive (Tier 1) · 31 file composite (Tier 2) · 25 file shell
|
|
12
|
+
> (Tier 3) · 4 file chart · 5 file uploader. API chốt từ `1.0.0`.
|
|
13
13
|
>
|
|
14
14
|
> Tài liệu đầy đủ: **https://design-system.comitor.ai** —
|
|
15
15
|
> [Get started](https://design-system.comitor.ai/get-started) ·
|
|
@@ -49,6 +49,8 @@ pnpm add @uppy/core @uppy/xhr-upload # cho @comitor/ui/uploader
|
|
|
49
49
|
| `next-themes` | `>=0.4` | — | `/shell` (context singleton — app và package phải dùng **cùng một bản**) |
|
|
50
50
|
| `recharts` | `>=2.15.0` | — | `/chart` (kiểu đã dựng cho **cả 2.x lẫn 3.x**, xem ghi chú dưới) |
|
|
51
51
|
| `react-hook-form` | `^7.54.0` | — | `/form` (context singleton) |
|
|
52
|
+
| `@uppy/core` | `^6.0.0` | — | `/uploader` |
|
|
53
|
+
| `@uppy/xhr-upload` | `^6.0.0` | — | `/uploader` |
|
|
52
54
|
|
|
53
55
|
> **Vì sao `recharts` và `lucide-react` là `>=` chứ không phải `^`.** `^2.15.0` loại thẳng recharts
|
|
54
56
|
> 3 — mà 3.x chạy tốt với gói này: kiểu của tầng `/chart` không dẫn xuất `payload`/`label` từ
|
|
@@ -89,6 +91,12 @@ Dùng pnpm strict mà thấy mất sạch style thì khai thêm ở app:
|
|
|
89
91
|
trong tooltip biểu đồ, `Kbd`…). Không gán thì mỗi biến rơi về fallback khai sẵn trong `styles.css`.
|
|
90
92
|
Cách gán bằng `next/font`: https://design-system.comitor.ai/get-started
|
|
91
93
|
|
|
94
|
+
**Con trỏ**: `styles.css` khai lại `cursor: pointer` cho `button`, `[role="button"]` và
|
|
95
|
+
`label[for]` ở `@layer base`. Preflight của Tailwind **v3** có luật này, **v4 bỏ nó** để bám mặc
|
|
96
|
+
định trình duyệt — nên thiếu nó thì `<button>` và `<a>` trông giống hệt nhau lại có con trỏ khác
|
|
97
|
+
nhau, và một nút ghost đọc ra như một cái nhãn. App **không cần** rải `cursor-pointer`; control bị
|
|
98
|
+
vô hiệu (`:disabled` hoặc `aria-disabled="true"`) vẫn giữ con trỏ mặc định.
|
|
99
|
+
|
|
92
100
|
**Dark mode** theo **class** (`@custom-variant dark (&:is(.dark *))`), khớp `next-themes`. Lưu ý:
|
|
93
101
|
biến thể `dark` chỉ khớp **con cháu** của `.dark`, không khớp chính phần tử mang class đó. Muốn dựng
|
|
94
102
|
preview 2 theme cạnh nhau phải bọc thêm một lớp: `<div class="dark"><div>…</div></div>`.
|
|
@@ -97,7 +105,7 @@ buộc ở thẻ `<html>`: https://design-system.comitor.ai/shell
|
|
|
97
105
|
|
|
98
106
|
## 3. Entry point
|
|
99
107
|
|
|
100
|
-
Package có **
|
|
108
|
+
Package có **6 entry**, tách theo peer optional mà mỗi tầng kéo theo:
|
|
101
109
|
|
|
102
110
|
| Entry | Nội dung | Peer optional kéo theo |
|
|
103
111
|
|---|---|---|
|
|
@@ -108,8 +116,10 @@ Package có **5 entry**, tách theo peer optional mà mỗi tầng kéo theo:
|
|
|
108
116
|
| `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | `react-hook-form` |
|
|
109
117
|
| `@comitor/ui/tokens` | Token dạng object TS — cho nơi không có Tailwind: canvas, email HTML, PDF | không có |
|
|
110
118
|
|
|
111
|
-
⚠ **
|
|
112
|
-
entry — trùng xuyên entry thì im lặng hoàn toàn.
|
|
119
|
+
⚠ **Một số tên xuất hiện ở nhiều hơn một entry**, và `export *` chỉ báo đỏ khi trùng trong **cùng
|
|
120
|
+
một** entry — trùng xuyên entry thì im lặng hoàn toàn. (Đếm chính xác bao nhiêu tên thì tuỳ cách
|
|
121
|
+
đếm — "cùng ký tự" hay "cùng binding sau khi giải alias" cho hai kết quả khác nhau — nên đừng ghim
|
|
122
|
+
một con số ở đây. Bất biến cần giữ: cùng tên ⇒ **cùng một binding**.) Đúng **2** trong số đó là hai cài đặt khác nhau
|
|
113
123
|
(`FormField` / `FormFieldProps`: ở Tier 2 là bố cục form 12 cột, ở `/form` là wrapper của
|
|
114
124
|
`Controller`); 18 tên còn lại trỏ về cùng một binding nên lấy ở đâu cũng như nhau. Bảng đầy đủ, kèm
|
|
115
125
|
hai ca **không** trùng tên hay bị tưởng nhầm (`Toaster` ⇄ `SonnerToaster`, và `useIsMac` chỉ xuất ở
|
|
@@ -149,7 +159,7 @@ https://design-system.comitor.ai/foundations/server-client
|
|
|
149
159
|
|
|
150
160
|
## 5. Bảng component
|
|
151
161
|
|
|
152
|
-
Số file nguồn hiện tại: **52 primitive · 31 composite · 25 shell · 4 chart**. Mỗi component có trang
|
|
162
|
+
Số file nguồn hiện tại: **52 primitive · 31 composite · 25 shell · 4 chart · 5 uploader**. Mỗi component có trang
|
|
153
163
|
riêng kèm demo, bảng prop và cạm bẫy của nó:
|
|
154
164
|
|
|
155
165
|
| Tầng | Là gì | Trang |
|
|
@@ -213,21 +223,26 @@ https://design-system.comitor.ai/foundations/colors
|
|
|
213
223
|
## 7. Kiểm định trước mỗi lần phát hành
|
|
214
224
|
|
|
215
225
|
Mỗi bản publish đi qua cùng một chuỗi cổng (`prepublishOnly` nối chúng lại): typecheck → build →
|
|
216
|
-
`verify:directives` → `smoke:entries` → `check:exports` → `test:nav-active` → `test:
|
|
217
|
-
`lint:a11y`. Những cổng đáng để người dùng gói biết là chúng tồn tại:
|
|
226
|
+
`verify:directives` → `smoke:entries` → `check:exports` → `test:nav-active` → `test:grid-contract`
|
|
227
|
+
→ `test:a11y-gate` → `lint:a11y`. Những cổng đáng để người dùng gói biết là chúng tồn tại:
|
|
218
228
|
|
|
219
229
|
- **`lint:a11y` — hợp đồng màu.** Quét **toàn bộ** `src/**/*.{ts,tsx}` theo QUY TẮC (không theo danh
|
|
220
230
|
sách phát hiện) và đối chiếu với `styles.css`: token TÔ NỀN đem làm màu chữ/icon/viền là sai
|
|
221
231
|
*vai trò*, và mọi cặp chữ/nền phải đạt 4,5:1 (chữ) hoặc 3:1 (thành phần phi văn bản) ở **cả**
|
|
222
|
-
light lẫn dark.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
232
|
+
light lẫn dark. **Bảng màu là một TRỤC** (từ `1.6.0`): cổng đo cả bốn tổ hợp bảng × theme, nhưng
|
|
233
|
+
chỉ **CHẶN** ở bảng tương phản cao. Bảng mặc định vào một sổ riêng, IN RA mỗi lần chạy nhưng
|
|
234
|
+
không làm đỏ — nó là bản duyệt và đã được chấp nhận với đúng những con số dưới ngưỡng ở §6; bắt
|
|
235
|
+
nó là bắt một quyết định, và một cổng lúc nào cũng đỏ thì lần sau không ai chạy nữa.
|
|
236
|
+
⚠ **Cổng xanh vì thế KHÔNG có nghĩa bảng mặc định đạt** — đọc khối "BẢNG MÀU MẶC ĐỊNH: N cặp dưới
|
|
237
|
+
ngưỡng" mà nó in ra (hôm nay N = 384, trong đó 78% nằm ở ba token: `text-muted-foreground`,
|
|
238
|
+
`text-destructive-ink`, `border-input`). Trước `1.6.0` cổng còn không ĐO bảng mặc định lần nào,
|
|
239
|
+
tức nó in ✓ cho một bảng nó chưa từng nhìn.
|
|
240
|
+
Ở bảng tương phản cao thì mọi loại phát hiện đều `exit 1`, kể cả lớp dùng bảng màu mặc định của
|
|
241
|
+
Tailwind và miễn trừ đã thừa; miễn trừ mỗi mục bắt buộc kèm lý do bằng chữ.
|
|
227
242
|
- **`verify:directives` — ranh giới server/client.** File nguồn nào mở đầu bằng `"use client"` thì
|
|
228
243
|
file `dist/` tương ứng phải mở đầu y hệt (thiếu file build cũng tính là hỏng), và không hằng dữ
|
|
229
244
|
liệu thuần nào được kẹt trong file client (§4.2).
|
|
230
|
-
- **`smoke:entries`** nạp cả
|
|
245
|
+
- **`smoke:entries`** nạp cả 6 entry bằng Node ESM thuần, qua đúng `exports` map (§3).
|
|
231
246
|
- **`test:a11y-gate`** tự kiểm chính cổng màu (cổng còn bắt đủ không); **`test:nav-active`** khoá
|
|
232
247
|
toàn bộ luật active matching của sidebar dưới dạng hàm thuần (§4.1).
|
|
233
248
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/chart/comitor-chart.tsx"],"sourcesContent":["\"use client\";\n\nimport type * as React from \"react\";\nimport {\n Area,\n AreaChart,\n Bar,\n BarChart,\n CartesianGrid,\n Legend,\n Line,\n LineChart,\n ResponsiveContainer,\n Tooltip,\n XAxis,\n YAxis\n} from \"recharts\";\nimport { cn } from \"../lib/cn.js\";\n/*\n * `CHART_COLORS` / `CHART_SERIES_COLORS` sống ở `chart-colors.ts` — module KHÔNG có\n * `\"use client\"`. Chỉ IMPORT vào đây để dùng nội bộ, KHÔNG re-export: mọi export đi qua một\n * file client đều biến thành client-reference proxy ở phía Server Component (đọc ra\n * `undefined`, không throw). Barrel `chart/index.ts` xuất chúng thẳng từ `chart-colors.js`.\n * Lý do đầy đủ nằm ở đầu `chart-colors.ts`.\n */\nimport { CHART_SERIES_COLORS } from \"./chart-colors.js\";\n\n/**\n * Biểu đồ \"lắp sẵn\" của Comitor — dựng nhanh line/bar/area chỉ với `data` + `series`.\n * Khác `chart.tsx` (primitive shadcn thô, tự lắp recharts), file này là tầng tiện dụng:\n * đã chọn sẵn trục, lưới, tooltip và bảng màu theo token.\n *\n * ⚠ Tên export ĐÃ ĐỔI so với comitor-ds vì hai file đụng nhau trong cùng entry `/chart`:\n * `ChartContainer` → `ComitorChartContainer`, `ChartTooltip` → `ComitorChartTooltip`.\n * Tên trần `ChartContainer`/`ChartTooltip` để dành cho primitive shadcn (giữ tương thích\n * `shadcn add`). Code mẫu cũ trong trang docs comitor-ds phải sửa theo.\n */\n\n/** Lấy màu cho series thứ `index`: ưu tiên màu tự khai, không có thì xoay vòng bảng mặc định. */\nfunction seriesColor(series: DataSeries, index: number): string {\n return series.color ?? CHART_SERIES_COLORS[index % CHART_SERIES_COLORS.length] ?? CHART_SERIES_COLORS[0];\n}\n\n// ─── Tooltip ────────────────────────────────────────────────────────────────\n\n/**\n * Một phần tử payload recharts bơm vào tooltip.\n *\n * Khai tại đây thay vì `TooltipProps<number, string>[\"payload\"]`: recharts **3** đã bỏ\n * `payload` khỏi props của `<Tooltip>` (chuyển sang context nội bộ), nên phép dẫn xuất đó\n * hỏng kiểu ngay trong file `.d.ts` phát hành. Xem chú thích cùng nội dung ở `chart.tsx`\n * (`ChartPayloadItem`) — cùng một lý do, cùng một cách chữa.\n */\nexport interface ComitorChartPayloadItem {\n value?: number;\n name?: string;\n dataKey?: string | number;\n color?: string;\n /**\n * Bản ghi GỐC của điểm dữ liệu — hàng mà app đã truyền vào `data`.\n *\n * ⚠ Thiếu trường này thì một tooltip tự viết không lần được từ một điểm về hàng sinh ra nó, nên\n * app không hiển thị nổi những cột KHÔNG nằm trong `series` (mã dự án, id, đơn vị…). Đó chính là\n * lý do đẩy một app xuống tầng primitive để dựng lại cả biểu đồ — tức dựng bộ biểu đồ THỨ HAI mà\n * gói sinh ra để tránh. Bản song sinh ở tầng primitive (`ChartPayloadItem`) đã có trường này từ\n * đầu; đây là chỗ lệch, không phải chỗ thiếu có chủ ý.\n */\n payload?: Record<string, unknown> & { fill?: string };\n}\n\nexport interface ComitorChartTooltipProps {\n /** Do recharts bơm vào — đừng truyền tay. */\n active?: boolean;\n /** Do recharts bơm vào — đừng truyền tay. */\n payload?: ComitorChartPayloadItem[];\n /** Do recharts bơm vào — đừng truyền tay. */\n label?: string;\n /** Định dạng nhãn trục X (ví dụ đổi \"2026-01\" → \"Th01/2026\"). */\n labelFormatter?: (label: string) => string;\n /** Định dạng giá trị (ví dụ thêm \"₫\", \"%\"). Mặc định: `toLocaleString(\"vi-VN\")`. */\n valueFormatter?: (value: number) => string;\n}\n\n/**\n * Tooltip theo token Comitor. Truyền vào recharts dưới dạng element:\n * `<Tooltip content={<ComitorChartTooltip valueFormatter={f} />} />`.\n */\nexport function ComitorChartTooltip({\n active,\n payload,\n label,\n labelFormatter,\n valueFormatter\n}: ComitorChartTooltipProps) {\n if (!active || !payload?.length) return null;\n\n return (\n <div data-slot=\"comitor-chart-tooltip\" className=\"rounded-lg border border-border bg-card px-3 py-2 shadow-card\">\n {label && (\n <p className=\"text-xs font-medium text-foreground mb-1.5\">{labelFormatter ? labelFormatter(label) : label}</p>\n )}\n <div className=\"space-y-1\">\n {payload.map((entry, i) => {\n const value = entry.value;\n const display = value === undefined ? \"—\" : (valueFormatter?.(value) ?? value.toLocaleString(\"vi-VN\"));\n\n return (\n <div key={`${entry.dataKey ?? entry.name ?? i}`} className=\"flex items-center gap-2 text-xs\">\n <span className=\"h-2 w-2 rounded-full shrink-0\" style={{ backgroundColor: entry.color }} />\n <span className=\"text-muted-foreground\">{entry.name}:</span>\n <span className=\"font-medium text-foreground\">{display}</span>\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n\n/**\n * Chú giải theo TOKEN của hệ thiết kế.\n *\n * ── VÌ SAO KHÔNG DÙNG `<Legend />` TRẦN ─────────────────────────────────────\n * Vì recharts tô CHỮ NHÃN bằng **màu của series**:\n * `DefaultLegendContent.js` → `finalLabelStyle.color = … || entry.color`.\n * Tức bậc `--x` (màu nền của series) bị dùng làm màu CHỮ — đúng vai trò mà hợp đồng ba vai trò của\n * gói cấm (§6 README: `--x` là nền, `--x-foreground` là chữ trên nền đó, `--x-ink` là chính màu đó\n * khi đi lên nền trang). Hậu quả không phải \"hơi nhạt\" mà là chú giải KHÔNG ĐỌC ĐƯỢC với những\n * series dùng tông sáng, và nó tự đổi theo bảng màu nên không bao giờ đo được một lần cho xong.\n *\n * Ở đây chấm màu giữ màu series (đúng vai trò: nó là NỀN của một hình vuông nhỏ), còn chữ dùng\n * `text-muted-foreground`. Cùng khuôn với `ComitorChartTooltip` ngay phía trên.\n */\n/**\n * Một mục trong `payload` mà recharts bơm vào `content` của `<Legend>`.\n *\n * ⚠ KHÁC hình dạng của Tooltip, và đây là chỗ dễ nhầm nhất: ở Legend, **nhãn nằm ở `value`** (một\n * chuỗi), còn ở Tooltip `value` là SỐ ĐO và nhãn nằm ở `name`. Dùng nhầm hình dạng thì component\n * vẫn biên dịch, vẫn render, chỉ là mọi nhãn RỖNG — chú giải hiện ra là mấy ô màu không chữ. Đã\n * xảy ra ở bản đầu của chính component này, và chỉ bị bắt khi có người NHÌN ảnh chụp.\n */\nexport interface ComitorChartLegendItem {\n /** Nhãn hiển thị — `name` của series, hoặc `dataKey` nếu series không đặt tên. */\n value?: string;\n color?: string;\n dataKey?: string | number;\n}\n\nexport interface ComitorChartLegendProps {\n /** Do recharts bơm vào — đừng truyền tay. */\n payload?: ComitorChartLegendItem[];\n}\n\nexport function ComitorChartLegend({ payload }: ComitorChartLegendProps) {\n if (!payload?.length) return null;\n\n return (\n <div className=\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-2\">\n {payload.map((entry, i) => (\n <div key={`${entry.dataKey ?? entry.value ?? i}`} className=\"flex items-center gap-1.5 text-xs\">\n {/* Chấm màu giữ màu series — ở đây `--x` đúng vai trò của nó: NỀN của một hình nhỏ. */}\n <span className=\"size-2 shrink-0 rounded-[2px]\" style={{ backgroundColor: entry.color }} />\n {/*\n `text-foreground`, KHÔNG phải `text-muted-foreground`. Nhãn chú giải là CHÌA KHOÁ để đọc\n biểu đồ — không đọc được nó thì mọi màu trong biểu đồ mất nghĩa — nên nó là chữ chính,\n không phải chữ phụ. Đo trên `--card` ở bảng màu MẶC ĐỊNH: `--muted-foreground` cho\n 3,83:1, dưới ngưỡng 4,5:1 của WCAG cho chữ thường; `--foreground` cho 17,4:1.\n ⚠ `pnpm lint:a11y` KHÔNG bắt được chỗ này — nó không đo bảng mặc định (xem\n `scripts/a11y/tailwind.mjs`). Con số trên đo bằng tay.\n */}\n <span className=\"text-foreground\">{entry.value}</span>\n </div>\n ))}\n </div>\n );\n}\n\n// ─── Khung ──────────────────────────────────────────────────────────────────\n\nexport interface ComitorChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {\n /** Chiều cao khung. Số → px. Mặc định 300. */\n height?: number | string;\n ref?: React.Ref<HTMLDivElement>;\n}\n\n/**\n * Khung `ResponsiveContainer` cao cố định. Khác `ChartContainer` của shadcn ở chỗ:\n * không cấp context `config`, không sinh biến `--color-<key>` — chỉ lo kích thước.\n */\nexport function ComitorChartContainer({\n height = 300,\n className,\n children,\n ref,\n ...props\n}: ComitorChartContainerProps) {\n return (\n <div\n ref={ref}\n data-slot=\"comitor-chart\"\n className={cn(\"w-full\", className)}\n style={{ height: typeof height === \"number\" ? `${height}px` : height }}\n {...props}\n >\n <ResponsiveContainer width=\"100%\" height=\"100%\">\n {children as React.ReactElement}\n </ResponsiveContainer>\n </div>\n );\n}\n\n// ─── Kiểu dùng chung ────────────────────────────────────────────────────────\n\n/** Một series (một đường/cột/vùng) trong biểu đồ. */\nexport interface DataSeries {\n /** Tên trường trong `data`. */\n key: string;\n /** Nhãn hiển thị ở tooltip/chú giải. Mặc định lấy `key`. */\n name?: string;\n /** Màu — nên lấy từ `CHART_COLORS` hoặc `var(--chart-N)`, đừng ghi hex. */\n color?: string;\n}\n\nexport interface BaseChartProps {\n data: Record<string, unknown>[];\n /** Tên trường dùng cho trục X. */\n xAxisKey: string;\n height?: number | string;\n className?: string;\n showGrid?: boolean;\n showLegend?: boolean;\n valueFormatter?: (value: number) => string;\n /**\n * Bật hiệu ứng vẽ dần khi mark xuất hiện. **Mặc định `false`** — khác mặc định `true` của\n * recharts, và đây là chủ đích.\n *\n * VÌ SAO TẮT MẶC ĐỊNH: với recharts 2.15.x + React 19.2 hiệu ứng KHÔNG bao giờ chạy tới\n * khung hình cuối, nên mark đứng nguyên ở khung đầu — tức là vô hình. Ba dạng cùng một lỗi,\n * đo được trên DOM thật:\n *\n * `<Bar>` `<g class=\"recharts-bar-rectangle\">` RỖNG — `Rectangle` trả `null` khi cao 0\n * `<Line>` path có `d` đúng nhưng `stroke-dasharray=\"0px 634px\"` — vẽ 0px\n * `<Area>` path có `d` đúng nhưng `<clipPath><rect width=\"0\">` — bị cắt sạch\n *\n * Kết quả giống hệt nhau ở cả ba: đủ trục, đủ lưới, đủ chú giải, KHÔNG có dữ liệu — và\n * không tự hết sau khi chờ. Một biểu đồ hiện ra ngay đáng giá hơn hiệu ứng, nên mặc định\n * đảo lại. Ai muốn hiệu ứng thì bật tường minh (`isAnimationActive` — recharts 3 chạy đúng).\n */\n isAnimationActive?: boolean;\n}\n\n/** Style dùng chung cho tick của cả hai trục — gom lại để 3 biểu đồ không lệch nhau. */\nconst AXIS_TICK = { fontSize: 11, fill: \"var(--muted-foreground)\" } as const;\nconst AXIS_LINE = { stroke: \"var(--border)\" } as const;\nconst CHART_MARGIN = { top: 8, right: 8, left: 0, bottom: 0 } as const;\n\n// ─── Line ───────────────────────────────────────────────────────────────────\n\nexport interface ComitorLineChartProps extends BaseChartProps {\n series: DataSeries[];\n}\n\n/** Biểu đồ đường — dùng cho xu hướng theo thời gian. */\nexport function ComitorLineChart({\n data,\n xAxisKey,\n series,\n height = 300,\n className,\n showGrid = true,\n showLegend = false,\n valueFormatter,\n isAnimationActive = false\n}: ComitorLineChartProps) {\n return (\n <ComitorChartContainer height={height} className={className}>\n <LineChart data={data} margin={CHART_MARGIN}>\n {showGrid && <CartesianGrid strokeDasharray=\"3 3\" stroke=\"var(--border)\" />}\n <XAxis dataKey={xAxisKey} tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <YAxis tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <Tooltip content={<ComitorChartTooltip valueFormatter={valueFormatter} />} />\n {showLegend && <Legend content={<ComitorChartLegend />} />}\n {series.map((s, i) => (\n <Line\n key={s.key}\n type=\"monotone\"\n dataKey={s.key}\n name={s.name || s.key}\n stroke={seriesColor(s, i)}\n strokeWidth={2}\n dot={false}\n activeDot={{ r: 4, strokeWidth: 0 }}\n isAnimationActive={isAnimationActive}\n />\n ))}\n </LineChart>\n </ComitorChartContainer>\n );\n}\n\n// ─── Bar ────────────────────────────────────────────────────────────────────\n\nexport interface ComitorBarChartProps extends BaseChartProps {\n series: DataSeries[];\n /** Chồng các series lên nhau thay vì xếp cạnh. */\n stacked?: boolean;\n}\n\n/** Biểu đồ cột — dùng cho so sánh giữa các hạng mục. Lưới chỉ kẻ ngang. */\nexport function ComitorBarChart({\n data,\n xAxisKey,\n series,\n height = 300,\n className,\n showGrid = true,\n showLegend = false,\n stacked = false,\n valueFormatter,\n isAnimationActive = false\n}: ComitorBarChartProps) {\n return (\n <ComitorChartContainer height={height} className={className}>\n <BarChart data={data} margin={CHART_MARGIN}>\n {showGrid && <CartesianGrid strokeDasharray=\"3 3\" stroke=\"var(--border)\" vertical={false} />}\n <XAxis dataKey={xAxisKey} tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <YAxis tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <Tooltip content={<ComitorChartTooltip valueFormatter={valueFormatter} />} />\n {showLegend && <Legend content={<ComitorChartLegend />} />}\n {series.map((s, i) => (\n <Bar\n key={s.key}\n dataKey={s.key}\n name={s.name || s.key}\n fill={seriesColor(s, i)}\n /*\n * ⚠ CHỒNG thì CHỈ đoạn trên cùng được bo.\n *\n * `radius={[4, 4, 0, 0]}` cho mọi đoạn nghĩa là mỗi đoạn giữa chồng cũng có hai góc\n * trên bo tròn, nằm ngay dưới đoạn kế tiếp — mắt thấy một vệt khuyết ở mỗi mối nối, và\n * cột chồng trông như bị nứt thành nhiều mảnh rời. Không có gì báo: recharts vẽ đúng\n * thứ được yêu cầu.\n *\n * Đoạn cuối trong `series` là đoạn NẰM TRÊN CÙNG của chồng (recharts xếp theo thứ tự\n * khai), nên chỉ nó nhận bo góc.\n *\n * ⚠ Ca biên đã biết, không chữa: nếu series cuối có giá trị 0 ở một cột thì đoạn trên\n * cùng NHÌN THẤY của cột đó là series áp chót, và nó phẳng. Chữa được thì phải tính\n * theo TỪNG CỘT chứ không theo series, tức bỏ `<Bar>` mà tự vẽ — cái giá lớn hơn hẳn\n * cái sai.\n */\n radius={stacked ? (i === series.length - 1 ? [4, 4, 0, 0] : 0) : [4, 4, 0, 0]}\n stackId={stacked ? \"stack\" : undefined}\n isAnimationActive={isAnimationActive}\n />\n ))}\n </BarChart>\n </ComitorChartContainer>\n );\n}\n\n// ─── Area ───────────────────────────────────────────────────────────────────\n\nexport interface ComitorAreaChartProps extends BaseChartProps {\n series: DataSeries[];\n /** Chồng các series lên nhau (tổng tích luỹ). */\n stacked?: boolean;\n}\n\n/** Biểu đồ vùng — xu hướng kèm cảm giác khối lượng; `fillOpacity` 0.15 để chồng lớp còn đọc được. */\nexport function ComitorAreaChart({\n data,\n xAxisKey,\n series,\n height = 300,\n className,\n showGrid = true,\n showLegend = false,\n stacked = false,\n valueFormatter,\n isAnimationActive = false\n}: ComitorAreaChartProps) {\n return (\n <ComitorChartContainer height={height} className={className}>\n <AreaChart data={data} margin={CHART_MARGIN}>\n {showGrid && <CartesianGrid strokeDasharray=\"3 3\" stroke=\"var(--border)\" />}\n <XAxis dataKey={xAxisKey} tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <YAxis tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <Tooltip content={<ComitorChartTooltip valueFormatter={valueFormatter} />} />\n {showLegend && <Legend content={<ComitorChartLegend />} />}\n {series.map((s, i) => {\n const color = seriesColor(s, i);\n return (\n <Area\n key={s.key}\n type=\"monotone\"\n dataKey={s.key}\n name={s.name || s.key}\n stroke={color}\n fill={color}\n fillOpacity={0.15}\n strokeWidth={2}\n stackId={stacked ? \"stack\" : undefined}\n isAnimationActive={isAnimationActive}\n />\n );\n })}\n </AreaChart>\n </ComitorChartContainer>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/chart/comitor-chart.tsx"],"sourcesContent":["\"use client\";\n\nimport type * as React from \"react\";\nimport {\n Area,\n AreaChart,\n Bar,\n BarChart,\n CartesianGrid,\n Legend,\n Line,\n LineChart,\n ResponsiveContainer,\n Tooltip,\n XAxis,\n YAxis\n} from \"recharts\";\nimport { cn } from \"../lib/cn.js\";\n/*\n * `CHART_COLORS` / `CHART_SERIES_COLORS` sống ở `chart-colors.ts` — module KHÔNG có\n * `\"use client\"`. Chỉ IMPORT vào đây để dùng nội bộ, KHÔNG re-export: mọi export đi qua một\n * file client đều biến thành client-reference proxy ở phía Server Component (đọc ra\n * `undefined`, không throw). Barrel `chart/index.ts` xuất chúng thẳng từ `chart-colors.js`.\n * Lý do đầy đủ nằm ở đầu `chart-colors.ts`.\n */\nimport { CHART_SERIES_COLORS } from \"./chart-colors.js\";\n\n/**\n * Biểu đồ \"lắp sẵn\" của Comitor — dựng nhanh line/bar/area chỉ với `data` + `series`.\n * Khác `chart.tsx` (primitive shadcn thô, tự lắp recharts), file này là tầng tiện dụng:\n * đã chọn sẵn trục, lưới, tooltip và bảng màu theo token.\n *\n * ⚠ Tên export ĐÃ ĐỔI so với comitor-ds vì hai file đụng nhau trong cùng entry `/chart`:\n * `ChartContainer` → `ComitorChartContainer`, `ChartTooltip` → `ComitorChartTooltip`.\n * Tên trần `ChartContainer`/`ChartTooltip` để dành cho primitive shadcn (giữ tương thích\n * `shadcn add`). Code mẫu cũ trong trang docs comitor-ds phải sửa theo.\n */\n\n/** Lấy màu cho series thứ `index`: ưu tiên màu tự khai, không có thì xoay vòng bảng mặc định. */\nfunction seriesColor(series: DataSeries, index: number): string {\n return series.color ?? CHART_SERIES_COLORS[index % CHART_SERIES_COLORS.length] ?? CHART_SERIES_COLORS[0];\n}\n\n// ─── Tooltip ────────────────────────────────────────────────────────────────\n\n/**\n * Một phần tử payload recharts bơm vào tooltip.\n *\n * Khai tại đây thay vì `TooltipProps<number, string>[\"payload\"]`: recharts **3** đã bỏ\n * `payload` khỏi props của `<Tooltip>` (chuyển sang context nội bộ), nên phép dẫn xuất đó\n * hỏng kiểu ngay trong file `.d.ts` phát hành. Xem chú thích cùng nội dung ở `chart.tsx`\n * (`ChartPayloadItem`) — cùng một lý do, cùng một cách chữa.\n */\nexport interface ComitorChartPayloadItem {\n value?: number;\n name?: string;\n dataKey?: string | number;\n color?: string;\n /**\n * Bản ghi GỐC của điểm dữ liệu — hàng mà app đã truyền vào `data`.\n *\n * ⚠ Thiếu trường này thì một tooltip tự viết không lần được từ một điểm về hàng sinh ra nó, nên\n * app không hiển thị nổi những cột KHÔNG nằm trong `series` (mã dự án, id, đơn vị…). Đó chính là\n * lý do đẩy một app xuống tầng primitive để dựng lại cả biểu đồ — tức dựng bộ biểu đồ THỨ HAI mà\n * gói sinh ra để tránh. Bản song sinh ở tầng primitive (`ChartPayloadItem`) đã có trường này từ\n * đầu; đây là chỗ lệch, không phải chỗ thiếu có chủ ý.\n */\n payload?: Record<string, unknown> & { fill?: string };\n}\n\nexport interface ComitorChartTooltipProps {\n /** Do recharts bơm vào — đừng truyền tay. */\n active?: boolean;\n /** Do recharts bơm vào — đừng truyền tay. */\n payload?: ComitorChartPayloadItem[];\n /** Do recharts bơm vào — đừng truyền tay. */\n label?: string;\n /** Định dạng nhãn trục X (ví dụ đổi \"2026-01\" → \"Th01/2026\"). */\n labelFormatter?: (label: string) => string;\n /** Định dạng giá trị (ví dụ thêm \"₫\", \"%\"). Mặc định: `toLocaleString(\"vi-VN\")`. */\n valueFormatter?: (value: number) => string;\n}\n\n/**\n * Tooltip theo token Comitor. Truyền vào recharts dưới dạng element:\n * `<Tooltip content={<ComitorChartTooltip valueFormatter={f} />} />`.\n */\nexport function ComitorChartTooltip({\n active,\n payload,\n label,\n labelFormatter,\n valueFormatter\n}: ComitorChartTooltipProps) {\n if (!active || !payload?.length) return null;\n\n return (\n <div data-slot=\"comitor-chart-tooltip\" className=\"rounded-lg border border-border bg-card px-3 py-2 shadow-card\">\n {label && (\n <p className=\"text-xs font-medium text-foreground mb-1.5\">{labelFormatter ? labelFormatter(label) : label}</p>\n )}\n <div className=\"space-y-1\">\n {payload.map((entry, i) => {\n const value = entry.value;\n const display = value === undefined ? \"—\" : (valueFormatter?.(value) ?? value.toLocaleString(\"vi-VN\"));\n\n return (\n <div key={`${entry.dataKey ?? entry.name ?? i}`} className=\"flex items-center gap-2 text-xs\">\n <span className=\"h-2 w-2 rounded-full shrink-0\" style={{ backgroundColor: entry.color }} />\n <span className=\"text-muted-foreground\">{entry.name}:</span>\n <span className=\"font-medium text-foreground\">{display}</span>\n </div>\n );\n })}\n </div>\n </div>\n );\n}\n\n/**\n * Chú giải theo TOKEN của hệ thiết kế.\n *\n * ── VÌ SAO KHÔNG DÙNG `<Legend />` TRẦN ─────────────────────────────────────\n * Vì recharts tô CHỮ NHÃN bằng **màu của series**:\n * `DefaultLegendContent.js` → `finalLabelStyle.color = … || entry.color`.\n * Tức bậc `--x` (màu nền của series) bị dùng làm màu CHỮ — đúng vai trò mà hợp đồng ba vai trò của\n * gói cấm (§6 README: `--x` là nền, `--x-foreground` là chữ trên nền đó, `--x-ink` là chính màu đó\n * khi đi lên nền trang). Hậu quả không phải \"hơi nhạt\" mà là chú giải KHÔNG ĐỌC ĐƯỢC với những\n * series dùng tông sáng, và nó tự đổi theo bảng màu nên không bao giờ đo được một lần cho xong.\n *\n * Ở đây chấm màu giữ màu series (đúng vai trò: nó là NỀN của một hình vuông nhỏ), còn chữ dùng\n * `text-muted-foreground`. Cùng khuôn với `ComitorChartTooltip` ngay phía trên.\n */\n/**\n * Một mục trong `payload` mà recharts bơm vào `content` của `<Legend>`.\n *\n * ⚠ KHÁC hình dạng của Tooltip, và đây là chỗ dễ nhầm nhất: ở Legend, **nhãn nằm ở `value`** (một\n * chuỗi), còn ở Tooltip `value` là SỐ ĐO và nhãn nằm ở `name`. Dùng nhầm hình dạng thì component\n * vẫn biên dịch, vẫn render, chỉ là mọi nhãn RỖNG — chú giải hiện ra là mấy ô màu không chữ. Đã\n * xảy ra ở bản đầu của chính component này, và chỉ bị bắt khi có người NHÌN ảnh chụp.\n */\nexport interface ComitorChartLegendItem {\n /** Nhãn hiển thị — `name` của series, hoặc `dataKey` nếu series không đặt tên. */\n value?: string;\n color?: string;\n dataKey?: string | number;\n}\n\nexport interface ComitorChartLegendProps {\n /** Do recharts bơm vào — đừng truyền tay. */\n payload?: ComitorChartLegendItem[];\n}\n\nexport function ComitorChartLegend({ payload }: ComitorChartLegendProps) {\n if (!payload?.length) return null;\n\n return (\n <div className=\"flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-2\">\n {payload.map((entry, i) => (\n <div key={`${entry.dataKey ?? entry.value ?? i}`} className=\"flex items-center gap-1.5 text-xs\">\n {/* Chấm màu giữ màu series — ở đây `--x` đúng vai trò của nó: NỀN của một hình nhỏ. */}\n <span className=\"size-2 shrink-0 rounded-[2px]\" style={{ backgroundColor: entry.color }} />\n {/*\n `text-foreground`, KHÔNG phải `text-muted-foreground`. Nhãn chú giải là CHÌA KHOÁ để đọc\n biểu đồ — không đọc được nó thì mọi màu trong biểu đồ mất nghĩa — nên nó là chữ chính,\n không phải chữ phụ. Đo trên `--card` ở bảng màu MẶC ĐỊNH: `--muted-foreground` cho\n 3,83:1, dưới ngưỡng 4,5:1 của WCAG cho chữ thường; `--foreground` cho 17,4:1.\n ⚠ `pnpm lint:a11y` ĐO bảng mặc định (từ 1.6.0) nhưng **không CHẶN** ở đó — nó vào sổ\n `banDuyetChuaChan`. Nên cổng xanh không có nghĩa cặp này đạt; con số trên đo bằng tay.\n */}\n <span className=\"text-foreground\">{entry.value}</span>\n </div>\n ))}\n </div>\n );\n}\n\n// ─── Khung ──────────────────────────────────────────────────────────────────\n\nexport interface ComitorChartContainerProps extends React.HTMLAttributes<HTMLDivElement> {\n /** Chiều cao khung. Số → px. Mặc định 300. */\n height?: number | string;\n ref?: React.Ref<HTMLDivElement>;\n}\n\n/**\n * Khung `ResponsiveContainer` cao cố định. Khác `ChartContainer` của shadcn ở chỗ:\n * không cấp context `config`, không sinh biến `--color-<key>` — chỉ lo kích thước.\n */\nexport function ComitorChartContainer({\n height = 300,\n className,\n children,\n ref,\n ...props\n}: ComitorChartContainerProps) {\n return (\n <div\n ref={ref}\n data-slot=\"comitor-chart\"\n className={cn(\"w-full\", className)}\n style={{ height: typeof height === \"number\" ? `${height}px` : height }}\n {...props}\n >\n <ResponsiveContainer width=\"100%\" height=\"100%\">\n {children as React.ReactElement}\n </ResponsiveContainer>\n </div>\n );\n}\n\n// ─── Kiểu dùng chung ────────────────────────────────────────────────────────\n\n/** Một series (một đường/cột/vùng) trong biểu đồ. */\nexport interface DataSeries {\n /** Tên trường trong `data`. */\n key: string;\n /** Nhãn hiển thị ở tooltip/chú giải. Mặc định lấy `key`. */\n name?: string;\n /** Màu — nên lấy từ `CHART_COLORS` hoặc `var(--chart-N)`, đừng ghi hex. */\n color?: string;\n}\n\nexport interface BaseChartProps {\n data: Record<string, unknown>[];\n /** Tên trường dùng cho trục X. */\n xAxisKey: string;\n height?: number | string;\n className?: string;\n showGrid?: boolean;\n showLegend?: boolean;\n valueFormatter?: (value: number) => string;\n /**\n * Bật hiệu ứng vẽ dần khi mark xuất hiện. **Mặc định `false`** — khác mặc định `true` của\n * recharts, và đây là chủ đích.\n *\n * VÌ SAO TẮT MẶC ĐỊNH: với recharts 2.15.x + React 19.2 hiệu ứng KHÔNG bao giờ chạy tới\n * khung hình cuối, nên mark đứng nguyên ở khung đầu — tức là vô hình. Ba dạng cùng một lỗi,\n * đo được trên DOM thật:\n *\n * `<Bar>` `<g class=\"recharts-bar-rectangle\">` RỖNG — `Rectangle` trả `null` khi cao 0\n * `<Line>` path có `d` đúng nhưng `stroke-dasharray=\"0px 634px\"` — vẽ 0px\n * `<Area>` path có `d` đúng nhưng `<clipPath><rect width=\"0\">` — bị cắt sạch\n *\n * Kết quả giống hệt nhau ở cả ba: đủ trục, đủ lưới, đủ chú giải, KHÔNG có dữ liệu — và\n * không tự hết sau khi chờ. Một biểu đồ hiện ra ngay đáng giá hơn hiệu ứng, nên mặc định\n * đảo lại. Ai muốn hiệu ứng thì bật tường minh (`isAnimationActive` — recharts 3 chạy đúng).\n */\n isAnimationActive?: boolean;\n}\n\n/** Style dùng chung cho tick của cả hai trục — gom lại để 3 biểu đồ không lệch nhau. */\nconst AXIS_TICK = { fontSize: 11, fill: \"var(--muted-foreground)\" } as const;\nconst AXIS_LINE = { stroke: \"var(--border)\" } as const;\nconst CHART_MARGIN = { top: 8, right: 8, left: 0, bottom: 0 } as const;\n\n// ─── Line ───────────────────────────────────────────────────────────────────\n\nexport interface ComitorLineChartProps extends BaseChartProps {\n series: DataSeries[];\n}\n\n/** Biểu đồ đường — dùng cho xu hướng theo thời gian. */\nexport function ComitorLineChart({\n data,\n xAxisKey,\n series,\n height = 300,\n className,\n showGrid = true,\n showLegend = false,\n valueFormatter,\n isAnimationActive = false\n}: ComitorLineChartProps) {\n return (\n <ComitorChartContainer height={height} className={className}>\n <LineChart data={data} margin={CHART_MARGIN}>\n {showGrid && <CartesianGrid strokeDasharray=\"3 3\" stroke=\"var(--border)\" />}\n <XAxis dataKey={xAxisKey} tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <YAxis tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <Tooltip content={<ComitorChartTooltip valueFormatter={valueFormatter} />} />\n {showLegend && <Legend content={<ComitorChartLegend />} />}\n {series.map((s, i) => (\n <Line\n key={s.key}\n type=\"monotone\"\n dataKey={s.key}\n name={s.name || s.key}\n stroke={seriesColor(s, i)}\n strokeWidth={2}\n dot={false}\n activeDot={{ r: 4, strokeWidth: 0 }}\n isAnimationActive={isAnimationActive}\n />\n ))}\n </LineChart>\n </ComitorChartContainer>\n );\n}\n\n// ─── Bar ────────────────────────────────────────────────────────────────────\n\nexport interface ComitorBarChartProps extends BaseChartProps {\n series: DataSeries[];\n /** Chồng các series lên nhau thay vì xếp cạnh. */\n stacked?: boolean;\n}\n\n/** Biểu đồ cột — dùng cho so sánh giữa các hạng mục. Lưới chỉ kẻ ngang. */\nexport function ComitorBarChart({\n data,\n xAxisKey,\n series,\n height = 300,\n className,\n showGrid = true,\n showLegend = false,\n stacked = false,\n valueFormatter,\n isAnimationActive = false\n}: ComitorBarChartProps) {\n return (\n <ComitorChartContainer height={height} className={className}>\n <BarChart data={data} margin={CHART_MARGIN}>\n {showGrid && <CartesianGrid strokeDasharray=\"3 3\" stroke=\"var(--border)\" vertical={false} />}\n <XAxis dataKey={xAxisKey} tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <YAxis tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <Tooltip content={<ComitorChartTooltip valueFormatter={valueFormatter} />} />\n {showLegend && <Legend content={<ComitorChartLegend />} />}\n {series.map((s, i) => (\n <Bar\n key={s.key}\n dataKey={s.key}\n name={s.name || s.key}\n fill={seriesColor(s, i)}\n /*\n * ⚠ CHỒNG thì CHỈ đoạn trên cùng được bo.\n *\n * `radius={[4, 4, 0, 0]}` cho mọi đoạn nghĩa là mỗi đoạn giữa chồng cũng có hai góc\n * trên bo tròn, nằm ngay dưới đoạn kế tiếp — mắt thấy một vệt khuyết ở mỗi mối nối, và\n * cột chồng trông như bị nứt thành nhiều mảnh rời. Không có gì báo: recharts vẽ đúng\n * thứ được yêu cầu.\n *\n * Đoạn cuối trong `series` là đoạn NẰM TRÊN CÙNG của chồng (recharts xếp theo thứ tự\n * khai), nên chỉ nó nhận bo góc.\n *\n * ⚠ Ca biên đã biết, không chữa: nếu series cuối có giá trị 0 ở một cột thì đoạn trên\n * cùng NHÌN THẤY của cột đó là series áp chót, và nó phẳng. Chữa được thì phải tính\n * theo TỪNG CỘT chứ không theo series, tức bỏ `<Bar>` mà tự vẽ — cái giá lớn hơn hẳn\n * cái sai.\n */\n radius={stacked ? (i === series.length - 1 ? [4, 4, 0, 0] : 0) : [4, 4, 0, 0]}\n stackId={stacked ? \"stack\" : undefined}\n isAnimationActive={isAnimationActive}\n />\n ))}\n </BarChart>\n </ComitorChartContainer>\n );\n}\n\n// ─── Area ───────────────────────────────────────────────────────────────────\n\nexport interface ComitorAreaChartProps extends BaseChartProps {\n series: DataSeries[];\n /** Chồng các series lên nhau (tổng tích luỹ). */\n stacked?: boolean;\n}\n\n/** Biểu đồ vùng — xu hướng kèm cảm giác khối lượng; `fillOpacity` 0.15 để chồng lớp còn đọc được. */\nexport function ComitorAreaChart({\n data,\n xAxisKey,\n series,\n height = 300,\n className,\n showGrid = true,\n showLegend = false,\n stacked = false,\n valueFormatter,\n isAnimationActive = false\n}: ComitorAreaChartProps) {\n return (\n <ComitorChartContainer height={height} className={className}>\n <AreaChart data={data} margin={CHART_MARGIN}>\n {showGrid && <CartesianGrid strokeDasharray=\"3 3\" stroke=\"var(--border)\" />}\n <XAxis dataKey={xAxisKey} tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <YAxis tick={AXIS_TICK} axisLine={AXIS_LINE} tickLine={AXIS_LINE} />\n <Tooltip content={<ComitorChartTooltip valueFormatter={valueFormatter} />} />\n {showLegend && <Legend content={<ComitorChartLegend />} />}\n {series.map((s, i) => {\n const color = seriesColor(s, i);\n return (\n <Area\n key={s.key}\n type=\"monotone\"\n dataKey={s.key}\n name={s.name || s.key}\n stroke={color}\n fill={color}\n fillOpacity={0.15}\n strokeWidth={2}\n stackId={stacked ? \"stack\" : undefined}\n isAnimationActive={isAnimationActive}\n />\n );\n })}\n </AreaChart>\n </ComitorChartContainer>\n );\n}\n"],"mappings":";AAmGQ,cAUM,YAVN;AAhGR;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,UAAU;AAQnB,SAAS,2BAA2B;AAcpC,SAAS,YAAY,QAAoB,OAAuB;AAC9D,SAAO,OAAO,SAAS,oBAAoB,QAAQ,oBAAoB,MAAM,KAAK,oBAAoB,CAAC;AACzG;AA8CO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA6B;AAC3B,MAAI,CAAC,UAAU,CAAC,SAAS,OAAQ,QAAO;AAExC,SACE,qBAAC,SAAI,aAAU,yBAAwB,WAAU,iEAC9C;AAAA,aACC,oBAAC,OAAE,WAAU,8CAA8C,2BAAiB,eAAe,KAAK,IAAI,OAAM;AAAA,IAE5G,oBAAC,SAAI,WAAU,aACZ,kBAAQ,IAAI,CAAC,OAAO,MAAM;AACzB,YAAM,QAAQ,MAAM;AACpB,YAAM,UAAU,UAAU,SAAY,WAAO,iBAAiB,KAAK,KAAK,MAAM,eAAe,OAAO;AAEpG,aACE,qBAAC,SAAgD,WAAU,mCACzD;AAAA,4BAAC,UAAK,WAAU,iCAAgC,OAAO,EAAE,iBAAiB,MAAM,MAAM,GAAG;AAAA,QACzF,qBAAC,UAAK,WAAU,yBAAyB;AAAA,gBAAM;AAAA,UAAK;AAAA,WAAC;AAAA,QACrD,oBAAC,UAAK,WAAU,+BAA+B,mBAAQ;AAAA,WAH/C,GAAG,MAAM,WAAW,MAAM,QAAQ,CAAC,EAI7C;AAAA,IAEJ,CAAC,GACH;AAAA,KACF;AAEJ;AAoCO,SAAS,mBAAmB,EAAE,QAAQ,GAA4B;AACvE,MAAI,CAAC,SAAS,OAAQ,QAAO;AAE7B,SACE,oBAAC,SAAI,WAAU,mEACZ,kBAAQ,IAAI,CAAC,OAAO,MACnB,qBAAC,SAAiD,WAAU,qCAE1D;AAAA,wBAAC,UAAK,WAAU,iCAAgC,OAAO,EAAE,iBAAiB,MAAM,MAAM,GAAG;AAAA,IASzF,oBAAC,UAAK,WAAU,mBAAmB,gBAAM,OAAM;AAAA,OAXvC,GAAG,MAAM,WAAW,MAAM,SAAS,CAAC,EAY9C,CACD,GACH;AAEJ;AAcO,SAAS,sBAAsB;AAAA,EACpC,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAA+B;AAC7B,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,aAAU;AAAA,MACV,WAAW,GAAG,UAAU,SAAS;AAAA,MACjC,OAAO,EAAE,QAAQ,OAAO,WAAW,WAAW,GAAG,MAAM,OAAO,OAAO;AAAA,MACpE,GAAG;AAAA,MAEJ,8BAAC,uBAAoB,OAAM,QAAO,QAAO,QACtC,UACH;AAAA;AAAA,EACF;AAEJ;AA2CA,MAAM,YAAY,EAAE,UAAU,IAAI,MAAM,0BAA0B;AAClE,MAAM,YAAY,EAAE,QAAQ,gBAAgB;AAC5C,MAAM,eAAe,EAAE,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,EAAE;AASrD,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,WAAW;AAAA,EACX,aAAa;AAAA,EACb;AAAA,EACA,oBAAoB;AACtB,GAA0B;AACxB,SACE,oBAAC,yBAAsB,QAAgB,WACrC,+BAAC,aAAU,MAAY,QAAQ,cAC5B;AAAA,gBAAY,oBAAC,iBAAc,iBAAgB,OAAM,QAAO,iBAAgB;AAAA,IACzE,oBAAC,SAAM,SAAS,UAAU,MAAM,WAAW,UAAU,WAAW,UAAU,WAAW;AAAA,IACrF,oBAAC,SAAM,MAAM,WAAW,UAAU,WAAW,UAAU,WAAW;AAAA,IAClE,oBAAC,WAAQ,SAAS,oBAAC,uBAAoB,gBAAgC,GAAI;AAAA,IAC1E,cAAc,oBAAC,UAAO,SAAS,oBAAC,sBAAmB,GAAI;AAAA,IACvD,OAAO,IAAI,CAAC,GAAG,MACd;AAAA,MAAC;AAAA;AAAA,QAEC,MAAK;AAAA,QACL,SAAS,EAAE;AAAA,QACX,MAAM,EAAE,QAAQ,EAAE;AAAA,QAClB,QAAQ,YAAY,GAAG,CAAC;AAAA,QACxB,aAAa;AAAA,QACb,KAAK;AAAA,QACL,WAAW,EAAE,GAAG,GAAG,aAAa,EAAE;AAAA,QAClC;AAAA;AAAA,MARK,EAAE;AAAA,IAST,CACD;AAAA,KACH,GACF;AAEJ;AAWO,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,WAAW;AAAA,EACX,aAAa;AAAA,EACb,UAAU;AAAA,EACV;AAAA,EACA,oBAAoB;AACtB,GAAyB;AACvB,SACE,oBAAC,yBAAsB,QAAgB,WACrC,+BAAC,YAAS,MAAY,QAAQ,cAC3B;AAAA,gBAAY,oBAAC,iBAAc,iBAAgB,OAAM,QAAO,iBAAgB,UAAU,OAAO;AAAA,IAC1F,oBAAC,SAAM,SAAS,UAAU,MAAM,WAAW,UAAU,WAAW,UAAU,WAAW;AAAA,IACrF,oBAAC,SAAM,MAAM,WAAW,UAAU,WAAW,UAAU,WAAW;AAAA,IAClE,oBAAC,WAAQ,SAAS,oBAAC,uBAAoB,gBAAgC,GAAI;AAAA,IAC1E,cAAc,oBAAC,UAAO,SAAS,oBAAC,sBAAmB,GAAI;AAAA,IACvD,OAAO,IAAI,CAAC,GAAG,MACd;AAAA,MAAC;AAAA;AAAA,QAEC,SAAS,EAAE;AAAA,QACX,MAAM,EAAE,QAAQ,EAAE;AAAA,QAClB,MAAM,YAAY,GAAG,CAAC;AAAA,QAiBtB,QAAQ,UAAW,MAAM,OAAO,SAAS,IAAI,CAAC,GAAG,GAAG,GAAG,CAAC,IAAI,IAAK,CAAC,GAAG,GAAG,GAAG,CAAC;AAAA,QAC5E,SAAS,UAAU,UAAU;AAAA,QAC7B;AAAA;AAAA,MAtBK,EAAE;AAAA,IAuBT,CACD;AAAA,KACH,GACF;AAEJ;AAWO,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AAAA,EACA,WAAW;AAAA,EACX,aAAa;AAAA,EACb,UAAU;AAAA,EACV;AAAA,EACA,oBAAoB;AACtB,GAA0B;AACxB,SACE,oBAAC,yBAAsB,QAAgB,WACrC,+BAAC,aAAU,MAAY,QAAQ,cAC5B;AAAA,gBAAY,oBAAC,iBAAc,iBAAgB,OAAM,QAAO,iBAAgB;AAAA,IACzE,oBAAC,SAAM,SAAS,UAAU,MAAM,WAAW,UAAU,WAAW,UAAU,WAAW;AAAA,IACrF,oBAAC,SAAM,MAAM,WAAW,UAAU,WAAW,UAAU,WAAW;AAAA,IAClE,oBAAC,WAAQ,SAAS,oBAAC,uBAAoB,gBAAgC,GAAI;AAAA,IAC1E,cAAc,oBAAC,UAAO,SAAS,oBAAC,sBAAmB,GAAI;AAAA,IACvD,OAAO,IAAI,CAAC,GAAG,MAAM;AACpB,YAAM,QAAQ,YAAY,GAAG,CAAC;AAC9B,aACE;AAAA,QAAC;AAAA;AAAA,UAEC,MAAK;AAAA,UACL,SAAS,EAAE;AAAA,UACX,MAAM,EAAE,QAAQ,EAAE;AAAA,UAClB,QAAQ;AAAA,UACR,MAAM;AAAA,UACN,aAAa;AAAA,UACb,aAAa;AAAA,UACb,SAAS,UAAU,UAAU;AAAA,UAC7B;AAAA;AAAA,QATK,EAAE;AAAA,MAUT;AAAA,IAEJ,CAAC;AAAA,KACH,GACF;AAEJ;","names":[]}
|
|
@@ -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 */\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, vì\n * `pnpm lint:a11y` KHÔNG ĐO BẢNG MẶC ĐỊNH: `scripts/a11y/tailwind.mjs` hợp\n * `[data-contrast=\"high\"]` ĐÈ LÊN `:root`, nên mọi token mà bảng tương phản cao ghi đè đều được\n * đo theo bảng ĐÓ rồi dán nhãn \"light\". Cổng báo `primary` 5,77:1 ✓ trong khi bảng mặc định là\n * 1,64:1 ✗. Với những cặp ấy, comment này là lớp phòng vệ DUY NHẤT — cổng xanh không chứng minh\n * gì. (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":";AA0HU,cAGJ,YAHI;AAxHV,SAAS,mBAAmB;AAC5B,SAAyB,gBAAgB;AACzC,SAAS,UAAU;AACnB,SAAS,aAAa,oBAAoB,0BAA0B;AAwCpE,MAAM,gBAAmD;AAAA,EACvD,MAAM;AAAA,EACN,SAAS;AAAA,EACT,MAAM;AAAA,EACN,SAAS;AAAA,EACT,SAAS;AAAA,EACT,aAAa;AACf;AA6CA,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 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 */\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":";AAyHU,cAGJ,YAHI;AAvHV,SAAS,mBAAmB;AAC5B,SAAyB,gBAAgB;AACzC,SAAS,UAAU;AACnB,SAAS,aAAa,oBAAoB,0BAA0B;AAwCpE,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":[]}
|
|
@@ -21,7 +21,7 @@ function RadioGroupItem({ className, ...props }) {
|
|
|
21
21
|
{
|
|
22
22
|
"data-slot": "radio-group-indicator",
|
|
23
23
|
className: "relative flex items-center justify-center",
|
|
24
|
-
children: /* @__PURE__ */ jsx(CircleIcon, { className: "fill-control-on absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" })
|
|
24
|
+
children: /* @__PURE__ */ jsx(CircleIcon, { className: "fill-control-on stroke-control-on absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" })
|
|
25
25
|
}
|
|
26
26
|
)
|
|
27
27
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/ui/radio-group.tsx"],"sourcesContent":["\"use client\";\n\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\";\nimport { CircleIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * ─ CHẤM ĐÃ CHỌN: `--control-on`, KHÔNG PHẢI `--primary` ─\n *\n * Ô radio để nền trong suốt, nên \"đã chọn hay chưa\" chỉ còn được kể bằng cái CHẤM ở giữa —\n * thông tin thị giác đúng nghĩa của WCAG 1.4.11, ngưỡng 3:1. `fill-primary` (gold #E8B824)\n * trên nền trắng chỉ **1,86:1** ✗: ở light người dùng nhìn ô đã chọn gần như y hệt ô chưa chọn.\n * (Ở dark thì lọt lưới nên lỗi này chỉ lộ ở một nửa số theme.)\n * `--control-on` là token của vai trò \"control ở trạng thái BẬT/ĐÃ CHỌN\"; giá trị của nó đổi theo\n * bảng màu (styles.css khối 5b), nên ĐỪNG viết thẳng `fill-primary` / `bg-gold-300` ở đây.\n * Đo với nền TRANG, lấy ca tệ nhất trong 5 nền (`--muted`) và ca thường gặp (`--background`):\n *\n * bảng MẶC ĐỊNH chấm ⇄ nền trang : 1,73–1,86:1 ✗ (bản duyệt, đã chấp nhận)\n * bảng TƯƠNG PHẢN CAO chấm ⇄ nền trang : 6,09–6,51:1 (light) · 10,80–12,67:1 (dark) ✓\n *\n * ─ LỚP TÔ Ở DARK: `dark:bg-input-fill` ─\n * comitor-ds viết `dark:bg-input/30`, và ở bảng mặc định lớp tô đó là thứ DUY NHẤT còn khoanh\n * vùng ô radio: viền `--input` (#1E2128) trên `--card` (#16181D) chỉ 1,10:1. Nhưng viết thẳng\n * `bg-input/30` thì ở bảng tương phản cao (`--input` = #7E8290) lớp tô sáng hơn mọi nền trang và\n * kéo `--muted-foreground` xuống 3,81 · 3,54 · 3,22:1 ✗ — nên nó đi qua `--input-fill`, biến này\n * trong suốt ở bảng tương phản cao (bên đó viền tự đủ đậm: 4,94:1).\n *\n *
|
|
1
|
+
{"version":3,"sources":["../../../src/components/ui/radio-group.tsx"],"sourcesContent":["\"use client\";\n\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\";\nimport { CircleIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * ─ CHẤM ĐÃ CHỌN: `--control-on`, KHÔNG PHẢI `--primary` ─\n *\n * Ô radio để nền trong suốt, nên \"đã chọn hay chưa\" chỉ còn được kể bằng cái CHẤM ở giữa —\n * thông tin thị giác đúng nghĩa của WCAG 1.4.11, ngưỡng 3:1. `fill-primary` (gold #E8B824)\n * trên nền trắng chỉ **1,86:1** ✗: ở light người dùng nhìn ô đã chọn gần như y hệt ô chưa chọn.\n * (Ở dark thì lọt lưới nên lỗi này chỉ lộ ở một nửa số theme.)\n * `--control-on` là token của vai trò \"control ở trạng thái BẬT/ĐÃ CHỌN\"; giá trị của nó đổi theo\n * bảng màu (styles.css khối 5b), nên ĐỪNG viết thẳng `fill-primary` / `bg-gold-300` ở đây.\n * Đo với nền TRANG, lấy ca tệ nhất trong 5 nền (`--muted`) và ca thường gặp (`--background`):\n *\n * bảng MẶC ĐỊNH chấm ⇄ nền trang : 1,73–1,86:1 ✗ (bản duyệt, đã chấp nhận)\n * bảng TƯƠNG PHẢN CAO chấm ⇄ nền trang : 6,09–6,51:1 (light) · 10,80–12,67:1 (dark) ✓\n *\n * ─ LỚP TÔ Ở DARK: `dark:bg-input-fill` ─\n * comitor-ds viết `dark:bg-input/30`, và ở bảng mặc định lớp tô đó là thứ DUY NHẤT còn khoanh\n * vùng ô radio: viền `--input` (#1E2128) trên `--card` (#16181D) chỉ 1,10:1. Nhưng viết thẳng\n * `bg-input/30` thì ở bảng tương phản cao (`--input` = #7E8290) lớp tô sáng hơn mọi nền trang và\n * kéo `--muted-foreground` xuống 3,81 · 3,54 · 3,22:1 ✗ — nên nó đi qua `--input-fill`, biến này\n * trong suốt ở bảng tương phản cao (bên đó viền tự đủ đậm: 4,94:1).\n *\n * ─ CHẤM PHẢI KHAI CẢ `fill-` LẪN `stroke-`, KHÔNG BAO GIỜ CHỈ MỘT ─\n *\n * `<CircleIcon>` là icon lucide, và lucide mặc định `stroke=\"currentColor\"` + `strokeWidth={2}`.\n * Khai mỗi `fill-control-on` là chấm được TÔ gold rồi VIỀN bằng màu chữ kế thừa — hai màu trên\n * một hình 8px.\n *\n * ⚠ Đây là một hồi quy đã xảy ra thật, và nó chui lọt vì lập luận gỡ class chỉ xét MỘT tác dụng.\n * `0.5.0` (765f6e8) gỡ `text-control-on` khỏi Item với lý do \"một class không ai dùng\": Item\n * render Indicator cứng nên không có icon nào của app đi vào, và `text-*` làm cổng tương phản đọc\n * chấm là CHỮ (4,5:1) trong khi nó là HÌNH (3:1). Cả hai vế đều ĐÚNG. Nhưng `text-control-on`\n * còn một tác dụng thứ hai không ai nêu: nó đặt `currentColor` = gold, tức nó chính là thứ đang\n * tô `stroke` mặc định của lucide. Gỡ nó đi thì `currentColor` rơi về `--foreground`.\n *\n * Đo trên app thật (`/tasks/new`, radio \"Loại yêu cầu\", trước khi sửa):\n * fill rgb(232,184,36) #E8B824 ✓ đúng token\n * stroke rgb(14,17,22) #0E1116 ✗ = --foreground, dày 0,67px quanh chấm 8px\n *\n * Nay `stroke-control-on` khai THẲNG, không mượn `currentColor` — vừa trả lại hình đã duyệt, vừa\n * bỏ được sợi dây vô hình giữa một class `text-*` ở Item và màu viền của một icon ở Indicator.\n * ⚠ ĐỪNG \"gọn hơn\" bằng `stroke-none`: nét vẽ nằm giữa đường tròn `r=10` trong viewBox 24, nên\n * bỏ nó là chấm co từ bán kính 11 xuống 10 đơn vị — nhỏ đi 10% đường kính (7,33px → 6,67px) so\n * với bản đã duyệt. Hai class cùng token là đúng ý đồ, không phải thừa.\n * Viền lỗi dùng `--destructive-ink`: ở bảng mặc định nó bằng `--destructive` (đúng `border-red`\n * của comitor-ds), ở bảng tương phản cao nó là bậc tối riêng (7,15:1 / 6,84:1) — xem hợp đồng\n * màu §1: bậc đi lên nền TRANG luôn là `-ink`.\n */\nexport type RadioGroupProps = React.ComponentProps<typeof RadioGroupPrimitive.Root>;\nexport type RadioGroupItemProps = React.ComponentProps<typeof RadioGroupPrimitive.Item>;\n\nfunction RadioGroup({ className, ...props }: RadioGroupProps) {\n return <RadioGroupPrimitive.Root data-slot=\"radio-group\" className={cn(\"grid gap-3\", className)} {...props} />;\n}\n\nfunction RadioGroupItem({ className, ...props }: RadioGroupItemProps) {\n return (\n <RadioGroupPrimitive.Item\n data-slot=\"radio-group-item\"\n className={cn(\n \"border-choice-edge focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive-ink dark:bg-input-fill aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-colors duration-150 ease-out outline-none disabled:cursor-not-allowed disabled:opacity-50\",\n className\n )}\n {...props}\n >\n <RadioGroupPrimitive.Indicator\n data-slot=\"radio-group-indicator\"\n className=\"relative flex items-center justify-center\"\n >\n <CircleIcon className=\"fill-control-on stroke-control-on absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\n </RadioGroupPrimitive.Indicator>\n </RadioGroupPrimitive.Item>\n );\n}\n\nexport { RadioGroup, RadioGroupItem };\n"],"mappings":";AA0DS;AAxDT,YAAY,yBAAyB;AACrC,SAAS,kBAAkB;AAE3B,SAAS,UAAU;AAoDnB,SAAS,WAAW,EAAE,WAAW,GAAG,MAAM,GAAoB;AAC5D,SAAO,oBAAC,oBAAoB,MAApB,EAAyB,aAAU,eAAc,WAAW,GAAG,cAAc,SAAS,GAAI,GAAG,OAAO;AAC9G;AAEA,SAAS,eAAe,EAAE,WAAW,GAAG,MAAM,GAAwB;AACpE,SACE;AAAA,IAAC,oBAAoB;AAAA,IAApB;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,QAAC,oBAAoB;AAAA,QAApB;AAAA,UACC,aAAU;AAAA,UACV,WAAU;AAAA,UAEV,8BAAC,cAAW,WAAU,wGAAuG;AAAA;AAAA,MAC/H;AAAA;AAAA,EACF;AAEJ;","names":[]}
|
package/package.json
CHANGED
package/styles.css
CHANGED
|
@@ -921,6 +921,64 @@
|
|
|
921
921
|
@apply bg-background text-foreground font-sans antialiased;
|
|
922
922
|
}
|
|
923
923
|
|
|
924
|
+
/*
|
|
925
|
+
* CON TRỎ BÀN TAY CHO MỌI CONTROL BẤM ĐƯỢC.
|
|
926
|
+
*
|
|
927
|
+
* ── VÌ SAO PHẢI KHAI LẠI ─────────────────────────────────────────────────────────────────
|
|
928
|
+
* Preflight của Tailwind **v3** có `button, [role="button"] { cursor: pointer }`. **v4 bỏ nó**
|
|
929
|
+
* để bám theo mặc định của trình duyệt, nên mọi `<button>` rơi về `cursor: default`.
|
|
930
|
+
*
|
|
931
|
+
* Hậu quả đo được trong app thật (2026-09-02): trên cùng một thanh công cụ, "Tạo việc mới" là
|
|
932
|
+
* `<a>` nên có bàn tay, còn "Xuất danh sách" và "Đánh dấu hoàn thành" là `<button>` nên không —
|
|
933
|
+
* hai control TRÔNG GIỐNG HỆT NHAU mà con trỏ nói hai chuyện khác nhau. Một nút ghost trên nền
|
|
934
|
+
* phẳng khi ấy đọc ra như một cái NHÃN.
|
|
935
|
+
*
|
|
936
|
+
* ── VÌ SAO Ở ĐÂY, KHÔNG PHẢI RẢI VÀO TỪNG COMPONENT ─────────────────────────────────────
|
|
937
|
+
* Vì đây là hành vi của TẦNG NỀN, không phải của một component. Đã đếm: 18 file trong `src/`
|
|
938
|
+
* render 36 `<button>` thô ngoài `<Button>`, và chỉ 4 file khai `cursor-pointer` — ba trong số
|
|
939
|
+
* đó nằm trên `label[for]` nên nay là thừa, cái thứ tư (`data-table.tsx`, dòng bấm được) thì
|
|
940
|
+
* luật này KHÔNG phủ vì `<tr>` không phải button. Rải tay là bỏ sót ngay ở component tiếp theo
|
|
941
|
+
* có người viết, và không cổng nào bắt được cái bỏ sót đó.
|
|
942
|
+
*
|
|
943
|
+
* ── HAI ĐIỀU KIỆN, VÀ CẢ HAI ÁP CHO CẢ HAI SELECTOR ─────────────────────────────────────
|
|
944
|
+
* Control bị vô hiệu KHÔNG được mời bấm. Cần `:not(:disabled)` VÀ `:not([aria-disabled])`:
|
|
945
|
+
* Radix (và react-day-picker) vô hiệu bằng `aria-disabled` ở những chỗ vẫn cần giữ focus, và
|
|
946
|
+
* ở đó thuộc tính `disabled` không có mặt.
|
|
947
|
+
*
|
|
948
|
+
* ⚠ `1.7.0` ghi đúng câu trên trong comment nhưng viết SAI xuống selector: mỗi selector chỉ
|
|
949
|
+
* mang MỘT guard (`button:not(:disabled)` + `[role="button"]:not([aria-disabled="true"])`).
|
|
950
|
+
* Nên `<button aria-disabled="true">` — đúng khuôn Radix mà hai guard sinh ra để xử lý — vẫn
|
|
951
|
+
* nhận bàn tay; chiều ngược lại `<button role="button" disabled>` cũng lọt qua nhánh hai.
|
|
952
|
+
* Ca thật đã đọc trong `react-day-picker@9` (`dist/esm/DayPicker.js`):
|
|
953
|
+
* · dòng 255 và 282 — mũi tên "tháng trước / tháng sau" khi KHÔNG còn tháng để đi:
|
|
954
|
+
* `aria-disabled: true` kèm `tabIndex: -1`, KHÔNG có thuộc tính `disabled`. Đây là ca xảy
|
|
955
|
+
* ra thường xuyên — mọi lịch có `startMonth`/`endMonth` hoặc khoảng giới hạn đều chạm nó.
|
|
956
|
+
* · dòng 320 — ô ngày: ngày bị khoá mà KHÔNG được focus dùng thuộc tính `disabled` thật,
|
|
957
|
+
* chỉ ngày bị khoá ĐANG focus mới dùng `aria-disabled`. Tức ô ngày dính hẹp hơn mũi tên.
|
|
958
|
+
* Bài học: một comment nói "hai điều kiện" không kiểm chứng được cái selector bên dưới nó.
|
|
959
|
+
*
|
|
960
|
+
* ── TÁM CA, ĐO TRÊN TRÌNH DUYỆT ─────────────────────────────────────────────────────────
|
|
961
|
+
* Đo lại bằng `getComputedStyle(el).cursor` sau khi sửa luật này. Hai ca đánh dấu ✗→✓ là hai
|
|
962
|
+
* ca `1.7.0` trả sai; ai đổi luật này phải chạy lại CẢ TÁM, đừng chọn vài ca dễ.
|
|
963
|
+
*
|
|
964
|
+
* <button> pointer
|
|
965
|
+
* <button disabled> default
|
|
966
|
+
* <button aria-disabled="true"> default ✗→✓
|
|
967
|
+
* <button role="button" disabled> default ✗→✓
|
|
968
|
+
* <div role="button"> pointer
|
|
969
|
+
* <div role="button" aria-disabled="true"> auto
|
|
970
|
+
* <label for="…"> pointer
|
|
971
|
+
* <label> (không for) default
|
|
972
|
+
*
|
|
973
|
+
* `<fieldset disabled>` cũng đúng theo đặc tả: control hậu duệ khớp `:disabled`, nên màn hình
|
|
974
|
+
* chỉ-đọc dựng bằng khuôn đó không mời bấm gì. (comitor-starter dùng đúng khuôn này.)
|
|
975
|
+
*/
|
|
976
|
+
button:not(:disabled):not([aria-disabled="true"]),
|
|
977
|
+
[role="button"]:not(:disabled):not([aria-disabled="true"]),
|
|
978
|
+
label[for] {
|
|
979
|
+
cursor: pointer;
|
|
980
|
+
}
|
|
981
|
+
|
|
924
982
|
/* Chữ ký thị giác: focus dày 2px, cách viền 2px, màu ring. */
|
|
925
983
|
:focus-visible {
|
|
926
984
|
@apply outline-2 outline-offset-2 outline-ring;
|