@comitor/ui 3.3.4 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +19 -5
  2. package/dist/components/composite/form-section.js.map +1 -1
  3. package/dist/components/composite/relative-time.d.ts +13 -2
  4. package/dist/components/composite/relative-time.js +12 -1
  5. package/dist/components/composite/relative-time.js.map +1 -1
  6. package/dist/components/composite/stat-card.d.ts +3 -0
  7. package/dist/components/composite/stat-card.js +2 -1
  8. package/dist/components/composite/stat-card.js.map +1 -1
  9. package/dist/components/ui/badge.js.map +1 -1
  10. package/dist/components/ui/field.js.map +1 -1
  11. package/dist/components/ui/item.d.ts +1 -1
  12. package/dist/data-grid/use-data-grid.js +24 -10
  13. package/dist/data-grid/use-data-grid.js.map +1 -1
  14. package/dist/editor/bubble-toolbar.d.ts +34 -0
  15. package/dist/editor/bubble-toolbar.js +391 -0
  16. package/dist/editor/bubble-toolbar.js.map +1 -0
  17. package/dist/editor/image-upload.d.ts +57 -0
  18. package/dist/editor/image-upload.js +136 -0
  19. package/dist/editor/image-upload.js.map +1 -0
  20. package/dist/editor/index.d.ts +5 -0
  21. package/dist/editor/index.js +11 -0
  22. package/dist/editor/index.js.map +1 -0
  23. package/dist/editor/markdown-editor-classes.d.ts +16 -0
  24. package/dist/editor/markdown-editor-classes.js +117 -0
  25. package/dist/editor/markdown-editor-classes.js.map +1 -0
  26. package/dist/editor/markdown-editor-constants.d.ts +92 -0
  27. package/dist/editor/markdown-editor-constants.js +6 -0
  28. package/dist/editor/markdown-editor-constants.js.map +1 -0
  29. package/dist/editor/markdown-editor.d.ts +125 -0
  30. package/dist/editor/markdown-editor.js +325 -0
  31. package/dist/editor/markdown-editor.js.map +1 -0
  32. package/dist/editor/markdown-extensions.d.ts +71 -0
  33. package/dist/editor/markdown-extensions.js +172 -0
  34. package/dist/editor/markdown-extensions.js.map +1 -0
  35. package/dist/editor/markdown-syntax.d.ts +87 -0
  36. package/dist/editor/markdown-syntax.js +58 -0
  37. package/dist/editor/markdown-syntax.js.map +1 -0
  38. package/dist/editor/slash-commands.d.ts +62 -0
  39. package/dist/editor/slash-commands.js +186 -0
  40. package/dist/editor/slash-commands.js.map +1 -0
  41. package/dist/editor/suggestion-menu.d.ts +38 -0
  42. package/dist/editor/suggestion-menu.js +144 -0
  43. package/dist/editor/suggestion-menu.js.map +1 -0
  44. package/dist/editor/suggestion-store.d.ts +47 -0
  45. package/dist/editor/suggestion-store.js +49 -0
  46. package/dist/editor/suggestion-store.js.map +1 -0
  47. package/dist/i18n/locale-provider.d.ts +1 -0
  48. package/dist/i18n/messages.d.ts +2 -0
  49. package/dist/i18n/messages.js +3 -0
  50. package/dist/i18n/messages.js.map +1 -1
  51. package/dist/i18n/namespaces/markdown-editor.d.ts +6 -0
  52. package/dist/i18n/namespaces/markdown-editor.js +127 -0
  53. package/dist/i18n/namespaces/markdown-editor.js.map +1 -0
  54. package/dist/index.d.ts +1 -0
  55. package/package.json +80 -2
package/README.md CHANGED
@@ -10,7 +10,7 @@ component ghép sẵn và khung ứng dụng (shell) kiểu workspace × app.
10
10
 
11
11
  > Trạng thái: `3.0.0` — mặc định **tiếng Anh**, tiếng Việt dựng sẵn (§5) · 79 file primitive (Tier 1,
12
12
  > gồm 18 component port từ [Dice UI](docs/diceui.md)) · 33 file composite (Tier 2) · 25 file shell
13
- > (Tier 3) · 4 file chart · 32 file data-grid · 5 file uploader · 43 file ngôn ngữ (`src/i18n`). API
13
+ > (Tier 3) · 4 file chart · 32 file data-grid · 5 file uploader · 11 file editor · 44 file ngôn ngữ (`src/i18n`). API
14
14
  > chốt từ `1.0.0`. (Đếm bằng `ls src/<tầng>`; con số trôi theo mỗi bản — đếm lại, đừng chép.)
15
15
  >
16
16
  > Tài liệu đầy đủ: **https://design-system.comitor.ai** —
@@ -41,6 +41,11 @@ pnpm add recharts # cho @comitor/ui/chart
41
41
  pnpm add react-hook-form # cho @comitor/ui/form
42
42
  pnpm add @tanstack/react-table # cho @comitor/ui/data-grid
43
43
  pnpm add @uppy/core @uppy/xhr-upload # cho @comitor/ui/uploader
44
+ # cho @comitor/ui/editor — MỌI gói @tiptap/* CÙNG một phiên bản (docs/editor.md §3)
45
+ pnpm add @tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/markdown \
46
+ @tiptap/extensions @tiptap/extension-list @tiptap/extension-table @tiptap/extension-image \
47
+ @tiptap/extension-mention @tiptap/suggestion @tiptap/extension-paragraph \
48
+ @tiptap/extension-code-block-lowlight lowlight highlight.js
44
49
  ```
45
50
 
46
51
  | Peer | Range | Bắt buộc | Cần khi |
@@ -55,6 +60,8 @@ pnpm add @uppy/core @uppy/xhr-upload # cho @comitor/ui/uploader
55
60
  | `@tanstack/react-table` | `>=8.21.0 <9` | — | `/data-grid` (app và gói phải dùng **cùng một bản**, nếu không `ColumnDef` app tự khai sẽ lệch kiểu) |
56
61
  | `@uppy/core` | `^6.0.0` | — | `/uploader` |
57
62
  | `@uppy/xhr-upload` | `^6.0.0` | — | `/uploader` |
63
+ | `@tiptap/*` (13 gói, xem lệnh trên) | `^3.31.0` | — | `/editor` — mọi gói **cùng một phiên bản** (chúng ghim `@tiptap/core`/`@tiptap/pm` bằng số chính xác), và app tự viết extension phải dùng **chính** bản đó |
64
+ | `lowlight` | `^3.0.0` | — | `/editor` (tô màu khối mã; kéo theo `highlight.js` `^11`) |
58
65
 
59
66
  > **Vì sao `recharts` và `lucide-react` là `>=` chứ không phải `^`.** `^2.15.0` loại thẳng recharts
60
67
  > 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ừ
@@ -113,7 +120,7 @@ buộc ở thẻ `<html>`: https://design-system.comitor.ai/shell
113
120
 
114
121
  ## 3. Entry point
115
122
 
116
- Package có **7 entry**, tách theo peer optional mà mỗi tầng kéo theo:
123
+ Package có **8 entry**, tách theo peer optional mà mỗi tầng kéo theo:
117
124
 
118
125
  | Entry | Nội dung | Peer optional kéo theo |
119
126
  |---|---|---|
@@ -122,6 +129,7 @@ Package có **7 entry**, tách theo peer optional mà mỗi tầng kéo theo:
122
129
  | `@comitor/ui/chart` | Lớp shadcn bọc recharts + `Comitor*Chart` + `CHART_COLORS` | `recharts` (~100KB) |
123
130
  | `@comitor/ui/data-grid` | `DataGrid` — lưới sửa trực tiếp kiểu bảng tính: ảo hoá dòng, chọn vùng bằng bàn phím, copy/paste với Excel, ghim cột, lọc/sắp xếp nhiều tiêu chí, hoàn tác/làm lại | `@tanstack/react-table` (peer); kèm `@tanstack/react-virtual` + `@dnd-kit/*` (dependency của gói) |
124
131
  | `@comitor/ui/uploader` | `FileUpload` (tệp tài liệu) · `ImageUploadField` (ảnh đại diện, logo) · `useFileUpload` | `@uppy/core`, `@uppy/xhr-upload` (~13KB gzip) |
132
+ | `@comitor/ui/editor` | `MarkdownEditor` — trình soạn kiểu Linear, lưu bằng markdown: gõ tắt, menu `/`, `@` nhắc người, thanh định dạng nổi, ảnh, bảng, khối mã tô màu | `@tiptap/*` + `lowlight` (~234KB gzip — nạp lười nếu trang không cần ngay) |
125
133
  | `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | `react-hook-form` |
126
134
  | `@comitor/ui/tokens` | Token dạng object TS — cho nơi không có Tailwind: canvas, email HTML, PDF | không có |
127
135
 
@@ -168,7 +176,7 @@ https://design-system.comitor.ai/foundations/server-client
168
176
 
169
177
  ## 5. Bảng component
170
178
 
171
- Số file nguồn hiện tại: **79 primitive · 33 composite · 25 shell · 4 chart · 32 data-grid · 5 uploader · 43 i18n**. Mỗi component có trang
179
+ Số file nguồn hiện tại: **79 primitive · 33 composite · 25 shell · 4 chart · 32 data-grid · 5 uploader · 11 editor · 44 i18n**. Mỗi component có trang
172
180
  riêng kèm demo, bảng prop và cạm bẫy của nó:
173
181
 
174
182
  | Tầng | Là gì | Trang |
@@ -179,6 +187,7 @@ riêng kèm demo, bảng prop và cạm bẫy của nó:
179
187
  | **chart** · 4 file · `@comitor/ui/chart` | Lớp shadcn bọc recharts, cùng ba bản dựng sẵn theo brand. ⚠ Ba wrapper `Comitor*Chart` **mặc định TẮT** hiệu ứng vẽ dần (`isAnimationActive = false`) — bật lại là biểu đồ kẹt ở khung đầu: có trục, có lưới, KHÔNG có dữ liệu. Mặc định đó KHÔNG áp cho `<ChartContainer>` + recharts do app tự lắp | https://design-system.comitor.ai/components/chart |
180
188
  | **data-grid** · 32 file · `@comitor/ui/data-grid` | `DataGrid` — lưới sửa trực tiếp kiểu bảng tính, cho màn nhập liệu hàng loạt. Port từ [tablecn](https://www.tablecn.com/data-grid) (MIT) sang primitive + token của gói. ⚠ Là tầng DUY NHẤT có peer **`@tanstack/react-table`**, có thêm prop `locale` riêng (xem dưới), và mọi thứ trong nó là client component | [docs/data-grid.md](docs/data-grid.md) |
181
189
  | **uploader** · 5 file · `@comitor/ui/uploader` | `FileUpload` (tệp) · `ImageUploadField` (ảnh) · `useFileUpload` — kéo thả, xem trước, tiến độ. Peer optional: `@uppy/core` + `@uppy/xhr-upload` | https://design-system.comitor.ai/components |
190
+ | **editor** · 11 file · `@comitor/ui/editor` | `MarkdownEditor` — soạn trực tiếp trên bản đã định dạng, giá trị vào/ra là markdown (GFM). Dựng trên Tiptap 3 (ProseMirror). ⚠ Định dạng LƯU là hợp đồng: nhắc người là `[@Tên](mention:<id>)`, ảnh lưu `src` app trả về (không phải URL đã ký), gạch chân cố ý không có. Peer optional: bộ `@tiptap/*` + `lowlight` | [docs/editor.md](docs/editor.md) |
182
191
  | **form** · `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | https://design-system.comitor.ai/components/form |
183
192
 
184
193
  **Ngôn ngữ — mặc định TIẾNG ANH, tiếng Việt dựng sẵn (từ `3.0.0`).** Mọi chuỗi hiển thị của gói —
@@ -200,6 +209,8 @@ import { ComitorLocaleProvider } from "@comitor/ui";
200
209
  client, và trang lỗi 500 ("Functions cannot be passed directly to Client Components"). Cùng luật
201
210
  cho `messages` có khoá là hàm (xem gạch đầu dòng về HÀM bên dưới). Mẫu dựng sẵn:
202
211
  https://design-system.comitor.ai/get-started#language
212
+ - Múi giờ KHÔNG đi theo ngôn ngữ: `RelativeTime` nhận `timeZone` (từ `3.4.0`) — truyền múi của NGƯỜI XEM để
213
+ tooltip giờ tuyệt đối giống nhau ở server lẫn trình duyệt. Thiếu nó thì trang render ở server giữ giờ của máy chủ.
203
214
  - Thứ tự ưu tiên cho MỌI chuỗi: prop của chính component (`labels`, `placeholder`, `clearLabel`…)
204
215
  → provider gần nhất → tiếng Anh. Kiểu của `labels` là `Partial<>` nên truyền một khoá không làm mất
205
216
  phần còn lại.
@@ -278,7 +289,7 @@ https://design-system.comitor.ai/foundations/colors
278
289
 
279
290
  Mỗi bản publish đi qua cùng một chuỗi cổng (`prepublishOnly` nối chúng lại): typecheck → build →
280
291
  `verify:directives` → `smoke:entries` → `check:exports` → `test:nav-active` → `test:grid-contract`
281
- → `test:grid-render` → `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:
292
+ → `test:grid-render` → `test:editor-markdown` → `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:
282
293
 
283
294
  - **`lint:a11y` — hợp đồng màu.** Quét **toàn bộ** `src/**/*.{ts,tsx}` theo QUY TẮC (không theo danh
284
295
  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
@@ -298,7 +309,10 @@ Mỗi bản publish đi qua cùng một chuỗi cổng (`prepublishOnly` nối c
298
309
  - **`verify:directives` — ranh giới server/client.** File nguồn nào mở đầu bằng `"use client"` thì
299
310
  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ữ
300
311
  liệu thuần nào được kẹt trong file client (§4.2).
301
- - **`smoke:entries`** nạp cả 7 entry bằng Node ESM thuần, qua đúng `exports` map (§3).
312
+ - **`smoke:entries`** nạp cả 8 entry bằng Node ESM thuần, qua đúng `exports` map (§3).
313
+ - **`test:editor-markdown`** — markdown là định dạng LƯU của `MarkdownEditor`, nên phép đo là tài liệu →
314
+ markdown → tài liệu phải ra đúng tài liệu ban đầu, trên CHÍNH lược đồ trong `dist/`. Lỗi loại này không lộ
315
+ ra lúc gõ mà ở lần mở sau. Trên lược đồ gốc của Tiptap (chưa vá) cổng trượt 16/46 — [docs/editor.md](docs/editor.md) §5.
302
316
  - **`test:a11y-gate`** tự kiểm chính cổng màu (cổng còn bắt đủ không); **`test:nav-active`** khoá
303
317
  toàn bộ luật active matching của sidebar dưới dạng hàm thuần (§4.1).
304
318
  - **`test:grid-render`** render THẬT `DataGrid` bằng `react-dom/server` rồi soi HTML ra được. Nó
@@ -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. 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":[]}
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":[]}
@@ -40,6 +40,16 @@ interface RelativeTimeProps extends Omit<ComponentProps<"time">, "dateTime" | "c
40
40
  * Việt vào mọi ngôn ngữ. Cần lại đúng câu cũ thì truyền chính chuỗi đó.
41
41
  */
42
42
  titleFormat?: string;
43
+ /**
44
+ * Múi giờ IANA (vd `"Asia/Ho_Chi_Minh"`) để in mốc tuyệt đối trong tooltip `title` — thường là múi
45
+ * của NGƯỜI XEM, lấy từ hồ sơ người dùng. Chuỗi tương đối ("3 phút trước") không phụ thuộc múi giờ.
46
+ *
47
+ * Bỏ trống = múi của máy đang chạy (như trước `3.4.0`). ⚠ Ở trang render tại server, máy chạy lượt
48
+ * đầu là MÁY CHỦ (thường UTC), và React KHÔNG vá thuộc tính lệch lúc hydrate — nên tooltip giữ giờ
49
+ * của máy chủ mãi (`suppressHydrationWarning` chỉ giấu cảnh báo). Truyền `timeZone` thì server và
50
+ * trình duyệt in cùng một chuỗi. Múi không hợp lệ thì rơi về múi của máy chạy, không làm vỡ trang.
51
+ */
52
+ timeZone?: string;
43
53
  /** Nội dung thay thế khi `value` không hợp lệ */
44
54
  fallback?: string;
45
55
  }
@@ -50,13 +60,14 @@ interface RelativeTimeProps extends Omit<ComponentProps<"time">, "dateTime" | "c
50
60
  * tháng" cho mọi thứ từ 26 ngày đến 45 ngày — trong danh sách công việc thì "26 ngày trước" và
51
61
  * "45 ngày trước" là hai câu chuyện khác hẳn nhau.
52
62
  *
53
- * `title` luôn giữ mốc thời gian tuyệt đối để người dùng rà chuột là biết chính xác.
63
+ * `title` luôn giữ mốc thời gian tuyệt đối để người dùng rà chuột là biết chính xác — theo `timeZone`
64
+ * nếu có (xem prop đó: thiếu nó thì trang render ở server giữ giờ của máy chủ trong tooltip).
54
65
  * Có `suppressHydrationWarning` vì đồng hồ server và client gần như luôn lệch vài giây — trừ khi
55
66
  * truyền `now`, lúc đó kết quả tất định và thuộc tính này chỉ còn là vô hại.
56
67
  *
57
68
  * Hai lối đo dùng CHUNG một cách diễn đạt (`…Strict` của date-fns, cùng `locale`), nên đổi giữa
58
69
  * `now` và đồng hồ thật không làm chữ đổi giọng: vẫn "12 phút trước", không phải "khoảng 12 phút".
59
70
  */
60
- declare function RelativeTime({ value, now, addSuffix, updateIntervalMs, locale: localeProp, titleFormat, fallback, className, ...props }: RelativeTimeProps): React.JSX.Element;
71
+ declare function RelativeTime({ value, now, addSuffix, updateIntervalMs, locale: localeProp, titleFormat, timeZone, fallback, className, ...props }: RelativeTimeProps): React.JSX.Element;
61
72
 
62
73
  export { RelativeTime, type RelativeTimeProps };
@@ -1,11 +1,21 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
+ import { tz } from "@date-fns/tz";
3
4
  import { format as formatDate, formatDistanceStrict, formatDistanceToNowStrict, isValid } from "date-fns";
4
5
  import { useEffect, useState } from "react";
5
6
  import { useComitorLocale } from "../../i18n/locale-provider.js";
6
7
  function toDate(value) {
7
8
  return value instanceof Date ? value : new Date(value);
8
9
  }
10
+ function formatTitle(date, pattern, locale, timeZone) {
11
+ if (timeZone) {
12
+ try {
13
+ return formatDate(date, pattern, { locale, in: tz(timeZone) });
14
+ } catch {
15
+ }
16
+ }
17
+ return formatDate(date, pattern, { locale });
18
+ }
9
19
  function RelativeTime({
10
20
  value,
11
21
  now,
@@ -13,6 +23,7 @@ function RelativeTime({
13
23
  updateIntervalMs = 6e4,
14
24
  locale: localeProp,
15
25
  titleFormat = "Pp",
26
+ timeZone,
16
27
  fallback = "\u2014",
17
28
  className,
18
29
  ...props
@@ -35,7 +46,7 @@ function RelativeTime({
35
46
  "time",
36
47
  {
37
48
  dateTime: date.toISOString(),
38
- title: formatDate(date, titleFormat, { locale }),
49
+ title: formatTitle(date, titleFormat, locale, timeZone),
39
50
  suppressHydrationWarning: true,
40
51
  className,
41
52
  ...props,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/relative-time.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, formatDistanceStrict, formatDistanceToNowStrict, isValid, type Locale } from \"date-fns\";\nimport { type ComponentProps, useEffect, useState } from \"react\";\nimport { useComitorLocale } from \"../../i18n/locale-provider.js\";\n\nexport interface RelativeTimeProps extends Omit<ComponentProps<\"time\">, \"dateTime\" | \"children\"> {\n /** Thời điểm cần hiển thị: Date, ISO string hoặc epoch ms */\n value: Date | string | number;\n /**\n * Mốc để ĐO TỪ ĐÓ. Bỏ trống = đồng hồ thật (hành vi mặc định, đúng cho bản ghi từ backend).\n *\n * Truyền `now` khi dữ liệu ĐỨNG YÊN — bản mẫu, ảnh chụp báo cáo, kết quả test: đồng hồ thật đo\n * trên mốc cố định thì kết quả trôi mỗi ngày, và mốc nào lỡ nằm ở tương lai in ra \"2 giờ nữa\",\n * một câu vô nghĩa mà TypeScript lẫn linter đều thấy hợp lệ.\n *\n * Có `now` thì component KHÔNG tự cập nhật nữa (xem `updateIntervalMs`) và kết quả trở nên tất\n * định — server và client cho ra cùng một chuỗi.\n */\n now?: Date;\n /** Thêm hậu tố \"trước\"/\"nữa\" (mặc định true) */\n addSuffix?: boolean;\n /**\n * Chu kỳ tự cập nhật (ms). 0 = không tự cập nhật. Mặc định 60_000.\n *\n * ⚠ Bị BỎ QUA khi có `now`: đo từ một mốc cố định thì chuỗi hiển thị không bao giờ đổi, nên hẹn\n * giờ chỉ để render lại y nguyên mỗi phút.\n */\n updateIntervalMs?: number;\n /**\n * Ngôn ngữ của date-fns — quyết định CẢ chuỗi tương đối lẫn mốc tuyệt đối trong tooltip.\n *\n * Không truyền thì theo `ComitorLocaleProvider` (`vi` → \"3 phút trước\", còn lại `enUS` → \"3\n * minutes ago\"). Trước `3.0.0` mặc định cứng là `vi`, nên app tiếng Anh phải nhớ truyền.\n */\n locale?: Locale;\n /**\n * Định dạng của tooltip `title`. Mặc định `\"Pp\"` — ngày + giờ THEO `locale`, nên nó tự đúng ở\n * mọi ngôn ngữ (vi: `29/08/2026 09:12`; en-US: `08/29/2026, 9:12 AM`).\n *\n * ⚠ Trước `0.9.0` mặc định là `\"HH:mm 'ngày' dd/MM/yyyy\"` — chuỗi đó nhét sẵn chữ \"ngày\" tiếng\n * Việt vào mọi ngôn ngữ. Cần lại đúng câu cũ thì truyền chính chuỗi đó.\n */\n titleFormat?: string;\n /** Nội dung thay thế khi `value` không hợp lệ */\n fallback?: string;\n}\n\nfunction toDate(value: Date | string | number): Date {\n return value instanceof Date ? value : new Date(value);\n}\n\n/**\n * Thời gian tương đối kiểu \"3 phút trước\", tự cập nhật theo chu kỳ.\n *\n * Dùng `formatDistanceToNowStrict` (không phải bản thường): bản thường làm tròn kiểu \"khoảng 1\n * tháng\" cho mọi thứ từ 26 ngày đến 45 ngày — trong danh sách công việc thì \"26 ngày trước\" và\n * \"45 ngày trước\" là hai câu chuyện khác hẳn nhau.\n *\n * `title` luôn giữ mốc thời gian tuyệt đối để người dùng rà chuột là biết chính xác.\n * Có `suppressHydrationWarning` vì đồng hồ server và client gần như luôn lệch vài giây — trừ khi\n * truyền `now`, lúc đó kết quả tất định và thuộc tính này chỉ còn là vô hại.\n *\n * Hai lối đo dùng CHUNG một cách diễn đạt (`…Strict` của date-fns, cùng `locale`), nên đổi giữa\n * `now` và đồng hồ thật không làm chữ đổi giọng: vẫn \"12 phút trước\", không phải \"khoảng 12 phút\".\n */\nexport function RelativeTime({\n value,\n now,\n addSuffix = true,\n updateIntervalMs = 60_000,\n locale: localeProp,\n titleFormat = \"Pp\",\n fallback = \"—\",\n className,\n ...props\n}: RelativeTimeProps) {\n const { dateFnsLocale } = useComitorLocale();\n const locale = localeProp ?? dateFnsLocale;\n const date = toDate(value);\n const valid = isValid(date);\n\n // Mốc cố định thì chuỗi không bao giờ đổi — tắt hẳn hẹn giờ, kể cả khi người gọi vẫn truyền\n // `updateIntervalMs`. Nếu không, component render lại mỗi phút để in ra đúng chữ cũ.\n const tickIntervalMs = now ? 0 : updateIntervalMs;\n\n const [, setTick] = useState(0);\n\n useEffect(() => {\n if (!valid || !tickIntervalMs) return;\n const timer = setInterval(() => setTick((current) => current + 1), tickIntervalMs);\n return () => clearInterval(timer);\n }, [valid, tickIntervalMs]);\n\n // Ngày hỏng (chuỗi rỗng, null từ API) → KHÔNG render <time>: thẻ này bắt buộc phải có\n // `datetime` hợp lệ, đưa \"Invalid Date\" vào là tạo dữ liệu sai cho trình đọc màn hình và bộ máy tìm kiếm.\n if (!valid) {\n return <span className={className}>{fallback}</span>;\n }\n\n return (\n <time\n dateTime={date.toISOString()}\n title={formatDate(date, titleFormat, { locale })}\n suppressHydrationWarning\n className={className}\n {...props}\n >\n {now\n ? formatDistanceStrict(date, now, { addSuffix, locale })\n : formatDistanceToNowStrict(date, { addSuffix, locale })}\n </time>\n );\n}\n"],"mappings":";AAiGW;AA/FX,SAAS,UAAU,YAAY,sBAAsB,2BAA2B,eAA4B;AAC5G,SAA8B,WAAW,gBAAgB;AACzD,SAAS,wBAAwB;AA4CjC,SAAS,OAAO,OAAqC;AACnD,SAAO,iBAAiB,OAAO,QAAQ,IAAI,KAAK,KAAK;AACvD;AAgBO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,QAAQ;AAAA,EACR,cAAc;AAAA,EACd,WAAW;AAAA,EACX;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,EAAE,cAAc,IAAI,iBAAiB;AAC3C,QAAM,SAAS,cAAc;AAC7B,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,QAAQ,QAAQ,IAAI;AAI1B,QAAM,iBAAiB,MAAM,IAAI;AAEjC,QAAM,CAAC,EAAE,OAAO,IAAI,SAAS,CAAC;AAE9B,YAAU,MAAM;AACd,QAAI,CAAC,SAAS,CAAC,eAAgB;AAC/B,UAAM,QAAQ,YAAY,MAAM,QAAQ,CAAC,YAAY,UAAU,CAAC,GAAG,cAAc;AACjF,WAAO,MAAM,cAAc,KAAK;AAAA,EAClC,GAAG,CAAC,OAAO,cAAc,CAAC;AAI1B,MAAI,CAAC,OAAO;AACV,WAAO,oBAAC,UAAK,WAAuB,oBAAS;AAAA,EAC/C;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,UAAU,KAAK,YAAY;AAAA,MAC3B,OAAO,WAAW,MAAM,aAAa,EAAE,OAAO,CAAC;AAAA,MAC/C,0BAAwB;AAAA,MACxB;AAAA,MACC,GAAG;AAAA,MAEH,gBACG,qBAAqB,MAAM,KAAK,EAAE,WAAW,OAAO,CAAC,IACrD,0BAA0B,MAAM,EAAE,WAAW,OAAO,CAAC;AAAA;AAAA,EAC3D;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/relative-time.tsx"],"sourcesContent":["\"use client\";\n\nimport { tz } from \"@date-fns/tz\";\nimport { format as formatDate, formatDistanceStrict, formatDistanceToNowStrict, isValid, type Locale } from \"date-fns\";\nimport { type ComponentProps, useEffect, useState } from \"react\";\nimport { useComitorLocale } from \"../../i18n/locale-provider.js\";\n\nexport interface RelativeTimeProps extends Omit<ComponentProps<\"time\">, \"dateTime\" | \"children\"> {\n /** Thời điểm cần hiển thị: Date, ISO string hoặc epoch ms */\n value: Date | string | number;\n /**\n * Mốc để ĐO TỪ ĐÓ. Bỏ trống = đồng hồ thật (hành vi mặc định, đúng cho bản ghi từ backend).\n *\n * Truyền `now` khi dữ liệu ĐỨNG YÊN — bản mẫu, ảnh chụp báo cáo, kết quả test: đồng hồ thật đo\n * trên mốc cố định thì kết quả trôi mỗi ngày, và mốc nào lỡ nằm ở tương lai in ra \"2 giờ nữa\",\n * một câu vô nghĩa mà TypeScript lẫn linter đều thấy hợp lệ.\n *\n * Có `now` thì component KHÔNG tự cập nhật nữa (xem `updateIntervalMs`) và kết quả trở nên tất\n * định — server và client cho ra cùng một chuỗi.\n */\n now?: Date;\n /** Thêm hậu tố \"trước\"/\"nữa\" (mặc định true) */\n addSuffix?: boolean;\n /**\n * Chu kỳ tự cập nhật (ms). 0 = không tự cập nhật. Mặc định 60_000.\n *\n * ⚠ Bị BỎ QUA khi có `now`: đo từ một mốc cố định thì chuỗi hiển thị không bao giờ đổi, nên hẹn\n * giờ chỉ để render lại y nguyên mỗi phút.\n */\n updateIntervalMs?: number;\n /**\n * Ngôn ngữ của date-fns — quyết định CẢ chuỗi tương đối lẫn mốc tuyệt đối trong tooltip.\n *\n * Không truyền thì theo `ComitorLocaleProvider` (`vi` → \"3 phút trước\", còn lại `enUS` → \"3\n * minutes ago\"). Trước `3.0.0` mặc định cứng là `vi`, nên app tiếng Anh phải nhớ truyền.\n */\n locale?: Locale;\n /**\n * Định dạng của tooltip `title`. Mặc định `\"Pp\"` — ngày + giờ THEO `locale`, nên nó tự đúng ở\n * mọi ngôn ngữ (vi: `29/08/2026 09:12`; en-US: `08/29/2026, 9:12 AM`).\n *\n * ⚠ Trước `0.9.0` mặc định là `\"HH:mm 'ngày' dd/MM/yyyy\"` — chuỗi đó nhét sẵn chữ \"ngày\" tiếng\n * Việt vào mọi ngôn ngữ. Cần lại đúng câu cũ thì truyền chính chuỗi đó.\n */\n titleFormat?: string;\n /**\n * Múi giờ IANA (vd `\"Asia/Ho_Chi_Minh\"`) để in mốc tuyệt đối trong tooltip `title` — thường là múi\n * của NGƯỜI XEM, lấy từ hồ sơ người dùng. Chuỗi tương đối (\"3 phút trước\") không phụ thuộc múi giờ.\n *\n * Bỏ trống = múi của máy đang chạy (như trước `3.4.0`). ⚠ Ở trang render tại server, máy chạy lượt\n * đầu là MÁY CHỦ (thường UTC), và React KHÔNG vá thuộc tính lệch lúc hydrate — nên tooltip giữ giờ\n * của máy chủ mãi (`suppressHydrationWarning` chỉ giấu cảnh báo). Truyền `timeZone` thì server và\n * trình duyệt in cùng một chuỗi. Múi không hợp lệ thì rơi về múi của máy chạy, không làm vỡ trang.\n */\n timeZone?: string;\n /** Nội dung thay thế khi `value` không hợp lệ */\n fallback?: string;\n}\n\nfunction toDate(value: Date | string | number): Date {\n return value instanceof Date ? value : new Date(value);\n}\n\nfunction formatTitle(date: Date, pattern: string, locale: Locale | undefined, timeZone: string | undefined): string {\n if (timeZone) {\n try {\n return formatDate(date, pattern, { locale, in: tz(timeZone) });\n } catch {\n // Múi giờ lạ (chuỗi hỏng từ cookie hay hồ sơ): rơi về múi của máy chạy thay vì ném lỗi giữa trang.\n }\n }\n return formatDate(date, pattern, { locale });\n}\n\n/**\n * Thời gian tương đối kiểu \"3 phút trước\", tự cập nhật theo chu kỳ.\n *\n * Dùng `formatDistanceToNowStrict` (không phải bản thường): bản thường làm tròn kiểu \"khoảng 1\n * tháng\" cho mọi thứ từ 26 ngày đến 45 ngày — trong danh sách công việc thì \"26 ngày trước\" và\n * \"45 ngày trước\" là hai câu chuyện khác hẳn nhau.\n *\n * `title` luôn giữ mốc thời gian tuyệt đối để người dùng rà chuột là biết chính xác — theo `timeZone`\n * nếu có (xem prop đó: thiếu nó thì trang render ở server giữ giờ của máy chủ trong tooltip).\n * Có `suppressHydrationWarning` vì đồng hồ server và client gần như luôn lệch vài giây — trừ khi\n * truyền `now`, lúc đó kết quả tất định và thuộc tính này chỉ còn là vô hại.\n *\n * Hai lối đo dùng CHUNG một cách diễn đạt (`…Strict` của date-fns, cùng `locale`), nên đổi giữa\n * `now` và đồng hồ thật không làm chữ đổi giọng: vẫn \"12 phút trước\", không phải \"khoảng 12 phút\".\n */\nexport function RelativeTime({\n value,\n now,\n addSuffix = true,\n updateIntervalMs = 60_000,\n locale: localeProp,\n titleFormat = \"Pp\",\n timeZone,\n fallback = \"—\",\n className,\n ...props\n}: RelativeTimeProps) {\n const { dateFnsLocale } = useComitorLocale();\n const locale = localeProp ?? dateFnsLocale;\n const date = toDate(value);\n const valid = isValid(date);\n\n // Mốc cố định thì chuỗi không bao giờ đổi — tắt hẳn hẹn giờ, kể cả khi người gọi vẫn truyền\n // `updateIntervalMs`. Nếu không, component render lại mỗi phút để in ra đúng chữ cũ.\n const tickIntervalMs = now ? 0 : updateIntervalMs;\n\n const [, setTick] = useState(0);\n\n useEffect(() => {\n if (!valid || !tickIntervalMs) return;\n const timer = setInterval(() => setTick((current) => current + 1), tickIntervalMs);\n return () => clearInterval(timer);\n }, [valid, tickIntervalMs]);\n\n // Ngày hỏng (chuỗi rỗng, null từ API) → KHÔNG render <time>: thẻ này bắt buộc phải có\n // `datetime` hợp lệ, đưa \"Invalid Date\" vào là tạo dữ liệu sai cho trình đọc màn hình và bộ máy tìm kiếm.\n if (!valid) {\n return <span className={className}>{fallback}</span>;\n }\n\n return (\n <time\n dateTime={date.toISOString()}\n title={formatTitle(date, titleFormat, locale, timeZone)}\n suppressHydrationWarning\n className={className}\n {...props}\n >\n {now\n ? formatDistanceStrict(date, now, { addSuffix, locale })\n : formatDistanceToNowStrict(date, { addSuffix, locale })}\n </time>\n );\n}\n"],"mappings":";AAyHW;AAvHX,SAAS,UAAU;AACnB,SAAS,UAAU,YAAY,sBAAsB,2BAA2B,eAA4B;AAC5G,SAA8B,WAAW,gBAAgB;AACzD,SAAS,wBAAwB;AAsDjC,SAAS,OAAO,OAAqC;AACnD,SAAO,iBAAiB,OAAO,QAAQ,IAAI,KAAK,KAAK;AACvD;AAEA,SAAS,YAAY,MAAY,SAAiB,QAA4B,UAAsC;AAClH,MAAI,UAAU;AACZ,QAAI;AACF,aAAO,WAAW,MAAM,SAAS,EAAE,QAAQ,IAAI,GAAG,QAAQ,EAAE,CAAC;AAAA,IAC/D,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO,WAAW,MAAM,SAAS,EAAE,OAAO,CAAC;AAC7C;AAiBO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,QAAQ;AAAA,EACR,cAAc;AAAA,EACd;AAAA,EACA,WAAW;AAAA,EACX;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,EAAE,cAAc,IAAI,iBAAiB;AAC3C,QAAM,SAAS,cAAc;AAC7B,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,QAAQ,QAAQ,IAAI;AAI1B,QAAM,iBAAiB,MAAM,IAAI;AAEjC,QAAM,CAAC,EAAE,OAAO,IAAI,SAAS,CAAC;AAE9B,YAAU,MAAM;AACd,QAAI,CAAC,SAAS,CAAC,eAAgB;AAC/B,UAAM,QAAQ,YAAY,MAAM,QAAQ,CAAC,YAAY,UAAU,CAAC,GAAG,cAAc;AACjF,WAAO,MAAM,cAAc,KAAK;AAAA,EAClC,GAAG,CAAC,OAAO,cAAc,CAAC;AAI1B,MAAI,CAAC,OAAO;AACV,WAAO,oBAAC,UAAK,WAAuB,oBAAS;AAAA,EAC/C;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,UAAU,KAAK,YAAY;AAAA,MAC3B,OAAO,YAAY,MAAM,aAAa,QAAQ,QAAQ;AAAA,MACtD,0BAAwB;AAAA,MACxB;AAAA,MACC,GAAG;AAAA,MAEH,gBACG,qBAAqB,MAAM,KAAK,EAAE,WAAW,OAAO,CAAC,IACrD,0BAA0B,MAAM,EAAE,WAAW,OAAO,CAAC;AAAA;AAAA,EAC3D;AAEJ;","names":[]}
@@ -16,6 +16,9 @@ import { IconComponent } from './empty-state.js';
16
16
  * warning 5,93:1 (L) · 7,19:1 (D) · destructive 5,76:1 (L) · 5,71:1 (D)
17
17
  * default 4,89:1 (L) · 4,86:1 (D) · primary 5,77:1 (L) · 7,26:1 (D)
18
18
  * app 6,14:1 (L) · 9,61:1 (D)
19
+ * ⚠ `primary` đo trên bảng TƯƠNG PHẢN CAO. Ở bảng MẶC ĐỊNH `--primary-ink` = `--primary` (từ 0.3.0) nên
20
+ * icon gold trên tint gold đo 1,64:1 — dưới ngưỡng CÓ CHỦ Ý: màu thương hiệu, bản duyệt giữ gold sáng. Đừng
21
+ * "sửa" bằng một bậc vàng tối; ai cần tương phản bật bảng tương phản cao.
19
22
  *
20
23
  * `warning` bỏ cặp `text-gold-500 dark:text-warning`: `--warning-ink` đúng bằng `--gold-500` ở
21
24
  * light và `--yellow` ở dark, nên một class là đủ.
@@ -10,7 +10,8 @@ const statCardIconVariants = cva("flex size-7 shrink-0 items-center justify-cent
10
10
  // Đi theo `--primary`, không phải thang `gold-*` chôn cứng. Bản cũ
11
11
  // `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100` đạt tương phản
12
12
  // (7,77:1 L · 11,85:1 D) nhưng đóng băng màu: app đổi brand thì ô icon vẫn vàng, và phải
13
- // viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D).
13
+ // viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D) ở bảng
14
+ // tương phản cao; bảng mặc định chỉ 1,64:1 (xem chú thích đầu file).
14
15
  primary: "bg-primary/20 text-primary-ink",
15
16
  success: "bg-success/15 text-success-ink",
16
17
  warning: "bg-warning/20 text-warning-ink",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/stat-card.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport { ArrowDownRight, ArrowUpRight, Minus } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\n/**\n * Ô icon của StatCard, tô theo tone token (không hex).\n *\n * Icon là thành phần PHI VĂN BẢN → ngưỡng WCAG 1.4.11 là 3:1 so với nền tint mà nó đứng lên.\n * Màu icon vì thế phải là bậc `--x-ink`, không phải token tô nền `--x`: `text-success` trên\n * `bg-success/15` chỉ được **2,69:1 ✗** ở light (dark thì 7,44:1 — mỗi theme hỏng một nửa là\n * dấu hiệu dùng sai vai trò token). Đo lại sau khi đổi, trên tint đã trộn với `--card`:\n *\n * success 5,60:1 (L) · 7,44:1 (D) · info 5,54:1 (L) · 7,12:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) · destructive 5,76:1 (L) · 5,71:1 (D)\n * default 4,89:1 (L) · 4,86:1 (D) · primary 5,77:1 (L) · 7,26:1 (D)\n * app 6,14:1 (L) · 9,61:1 (D)\n *\n * `warning` bỏ cặp `text-gold-500 dark:text-warning`: `--warning-ink` đúng bằng `--gold-500` ở\n * light và `--yellow` ở dark, nên một class là đủ.\n */\n/*\n * Hình học của ô icon lấy theo mẫu StatCards ở mục **Patterns** của comitor-ds:\n * `p-1.5` quanh icon 14px + `rounded-lg` — tức ô ~28px, KHÔNG phải 36px với `rounded-md`.\n */\nexport const statCardIconVariants = cva(\"flex size-7 shrink-0 items-center justify-center rounded-lg\", {\n variants: {\n tone: {\n default: \"bg-muted text-muted-foreground\",\n // Đi theo `--primary`, không phải thang `gold-*` chôn cứng. Bản cũ\n // `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100` đạt tương phản\n // (7,77:1 L · 11,85:1 D) nhưng đóng băng màu: app đổi brand thì ô icon vẫn vàng, và phải\n // viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D).\n primary: \"bg-primary/20 text-primary-ink\",\n success: \"bg-success/15 text-success-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\",\n info: \"bg-info/15 text-info-ink\",\n // Icon app đứng trên tint của CHÍNH nó → phải là bậc ink, đúng như năm tone trên.\n // `text-app-accent` (gold #E8B824) trên `bg-app-accent/10` (trộn ra #FDF8E9) chỉ 1,75:1 ✗.\n // `text-foreground` như bản trước thì ĐỌC được (17,81:1) nhưng bỏ mất màu app — tức là bỏ\n // mất đúng thứ tone này sinh ra để nói. `text-app-accent-ink`: 6,14:1 (L) · 9,61:1 (D) ✓\n // — hợp đồng §5, cùng lối chữa với AppLauncher.\n app: \"bg-app-accent/10 text-app-accent-ink\"\n }\n },\n defaultVariants: {\n tone: \"default\"\n }\n});\n\nexport type StatCardTone = NonNullable<VariantProps<typeof statCardIconVariants>[\"tone\"]>;\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\";\n\nexport interface StatCardDelta {\n /** Nội dung hiển thị, vd \"+12,5%\" hoặc \"3 việc\" */\n value: ReactNode;\n direction: StatCardDeltaDirection;\n /**\n * Tăng là TỐT hay XẤU — mặc định \"positive-up\" (tăng = tốt).\n *\n * Cần thiết vì có chỉ số ngược chiều: số ticket quá hạn tăng là xấu, tỷ lệ rời bỏ giảm là tốt.\n * Tô màu theo hướng mũi tên một cách máy móc sẽ nói dối người đọc.\n */\n polarity?: \"positive-up\" | \"positive-down\" | \"neutral\";\n /** Chú thích sau con số, vd \"so với tuần trước\" */\n label?: ReactNode;\n}\n\nexport interface StatCardProps extends Omit<ComponentProps<\"div\">, \"title\"> {\n /** Nhãn chỉ số, vd \"Việc đang mở\" */\n label: ReactNode;\n value: ReactNode;\n icon?: IconComponent;\n tone?: StatCardTone;\n delta?: StatCardDelta;\n /** Slot biểu đồ mini (sparkline) — package không ép thư viện biểu đồ nào */\n sparkline?: ReactNode;\n /** Dòng chú thích dưới cùng */\n footer?: ReactNode;\n /** Đang tải → khung xương thay cho số liệu */\n loading?: boolean;\n}\n\nconst deltaIcons: Record<StatCardDeltaDirection, IconComponent> = {\n up: ArrowUpRight,\n down: ArrowDownRight,\n flat: Minus\n};\n\n/**\n * Màu của delta = hướng × cực tính, không phải chỉ hướng.\n *\n * Dòng biến động là CHỮ (12px, `text-xs`) đứng trên `bg-card` → ngưỡng AA 4,5:1, và phải đạt ở\n * cả hai theme. Token tô nền không làm được việc đó: `text-success` chỉ 3,13:1 ở light còn\n * `text-destructive` chỉ 3,53:1 ở dark — mỗi theme hỏng một nửa. Bậc ink đạt cả hai:\n * success 6,52:1 (L) / 10,19:1 (D) · destructive 7,15:1 (L) / 6,42:1 (D) · muted-foreground\n * 5,23:1 (L) / 5,35:1 (D).\n */\nfunction deltaToneClass({ direction, polarity = \"positive-up\" }: StatCardDelta): string {\n if (direction === \"flat\" || polarity === \"neutral\") return \"text-muted-foreground\";\n const isGood = polarity === \"positive-up\" ? direction === \"up\" : direction === \"down\";\n return isGood ? \"text-success-ink\" : \"text-destructive-ink\";\n}\n\n/**\n * Thẻ KPI: nhãn + số liệu lớn + biến động + sparkline tuỳ chọn.\n *\n * Số liệu dùng `tabular-nums` để cột số không nhảy khi cập nhật realtime. Tĩnh → RSC-safe.\n */\nexport function StatCard({\n label,\n value,\n icon: Icon,\n tone = \"default\",\n delta,\n sparkline,\n footer,\n loading = false,\n className,\n ...props\n}: StatCardProps) {\n const DeltaIcon = delta ? deltaIcons[delta.direction] : undefined;\n\n return (\n <div\n data-slot=\"stat-card\"\n /*\n * `p-5`, và KHÔNG có đổ bóng — đúng mẫu StatCards ở mục Patterns của comitor-ds\n * (`rounded-xl border border-border bg-card p-5`). Bản trước dùng `p-4 shadow-card`; trên\n * một hàng bốn thẻ thì bóng đó nổi rõ và làm lưới thẻ nặng hơn bản duyệt.\n */\n className={cn(\"rounded-xl border border-border bg-card p-5\", className)}\n {...props}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"min-w-0 flex-1\">\n {/* `text-xs font-medium` theo bản duyệt, không phải `text-sm` thường. */}\n <p className=\"truncate text-xs font-medium text-muted-foreground\">{label}</p>\n {loading ? (\n <Skeleton className=\"mt-2 h-7 w-24\" />\n ) : (\n /* `font-bold` theo bản duyệt. `tabular-nums` là khoản gói thêm và giữ lại: chữ số\n đều bề ngang thì một hàng bốn thẻ không nhảy khi số đổi. */\n <p className=\"mt-1 text-2xl font-bold tabular-nums leading-tight text-foreground\">{value}</p>\n )}\n </div>\n {Icon && (\n <span aria-hidden=\"true\" className={statCardIconVariants({ tone })}>\n <Icon className=\"size-3.5\" />\n </span>\n )}\n </div>\n\n {/*\n * Dòng biến động nằm NGOÀI hàng hai cột ở trên, và đó là chủ ý.\n *\n * Ô icon chỉ cao 28px trong khi nhãn + số liệu đã cao ~46px, nên dòng này luôn rơi xuống\n * DƯỚI icon — nhưng hồi nó còn nằm trong cột trái thì nó vẫn bị bó theo bề rộng của cột đó\n * (147px trên thẻ 229px) và bỏ không phần dưới ô icon. Hệ quả nhìn thấy được: \"↗ +3 việc so\n * với tuần trước\" cần 171px nên nó vỡ đôi, mà vỡ theo kiểu tệ nhất — hai `<span>` là flex\n * item nên mỗi cái tự co về min-content rồi xuống dòng BÊN TRONG, ra thành hai cột lệch\n * \"+3 / việc\" và \"so với tuần / trước\". Đưa ra ngoài là dòng này được cả 189px, vừa một dòng.\n *\n * `flex-wrap` + `whitespace-nowrap` ở phần số là lưới an toàn cho thẻ hẹp hơn nữa: hết chỗ\n * thì chú thích xuống hàng NGUYÊN CỤM, không còn chuyện tách \"+3\" khỏi \"việc\".\n */}\n {delta && !loading && DeltaIcon && (\n <p className={cn(\"mt-1 flex flex-wrap items-center gap-x-1 text-xs font-medium\", deltaToneClass(delta))}>\n <DeltaIcon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n <span className=\"whitespace-nowrap tabular-nums\">{delta.value}</span>\n {delta.label && <span className=\"font-normal text-muted-foreground\">{delta.label}</span>}\n </p>\n )}\n\n {sparkline && <div className=\"mt-3 h-10\">{sparkline}</div>}\n {footer && <div className=\"mt-3 border-t border-border pt-2 text-xs text-muted-foreground\">{footer}</div>}\n </div>\n );\n}\n"],"mappings":"AA2IQ,SAEE,KAFF;AA3IR,SAAS,WAA8B;AACvC,SAAS,gBAAgB,cAAc,aAAa;AAEpD,SAAS,UAAU;AACnB,SAAS,gBAAgB;AAuBlB,MAAM,uBAAuB,IAAI,+DAA+D;AAAA,EACrG,UAAU;AAAA,IACR,MAAM;AAAA,MACJ,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,MAKT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMN,KAAK;AAAA,IACP;AAAA,EACF;AAAA,EACA,iBAAiB;AAAA,IACf,MAAM;AAAA,EACR;AACF,CAAC;AAoCD,MAAM,aAA4D;AAAA,EAChE,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,MAAM;AACR;AAWA,SAAS,eAAe,EAAE,WAAW,WAAW,cAAc,GAA0B;AACtF,MAAI,cAAc,UAAU,aAAa,UAAW,QAAO;AAC3D,QAAM,SAAS,aAAa,gBAAgB,cAAc,OAAO,cAAc;AAC/E,SAAO,SAAS,qBAAqB;AACvC;AAOO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,YAAY,QAAQ,WAAW,MAAM,SAAS,IAAI;AAExD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MAMV,WAAW,GAAG,+CAA+C,SAAS;AAAA,MACrE,GAAG;AAAA,MAEJ;AAAA,6BAAC,SAAI,WAAU,0CACb;AAAA,+BAAC,SAAI,WAAU,kBAEb;AAAA,gCAAC,OAAE,WAAU,sDAAsD,iBAAM;AAAA,YACxE,UACC,oBAAC,YAAS,WAAU,iBAAgB;AAAA;AAAA;AAAA,cAIpC,oBAAC,OAAE,WAAU,sEAAsE,iBAAM;AAAA;AAAA,aAE7F;AAAA,UACC,QACC,oBAAC,UAAK,eAAY,QAAO,WAAW,qBAAqB,EAAE,KAAK,CAAC,GAC/D,8BAAC,QAAK,WAAU,YAAW,GAC7B;AAAA,WAEJ;AAAA,QAeC,SAAS,CAAC,WAAW,aACpB,qBAAC,OAAE,WAAW,GAAG,gEAAgE,eAAe,KAAK,CAAC,GACpG;AAAA,8BAAC,aAAU,eAAY,QAAO,WAAU,qBAAoB;AAAA,UAC5D,oBAAC,UAAK,WAAU,kCAAkC,gBAAM,OAAM;AAAA,UAC7D,MAAM,SAAS,oBAAC,UAAK,WAAU,qCAAqC,gBAAM,OAAM;AAAA,WACnF;AAAA,QAGD,aAAa,oBAAC,SAAI,WAAU,aAAa,qBAAU;AAAA,QACnD,UAAU,oBAAC,SAAI,WAAU,kEAAkE,kBAAO;AAAA;AAAA;AAAA,EACrG;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/stat-card.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport { ArrowDownRight, ArrowUpRight, Minus } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\n/**\n * Ô icon của StatCard, tô theo tone token (không hex).\n *\n * Icon là thành phần PHI VĂN BẢN → ngưỡng WCAG 1.4.11 là 3:1 so với nền tint mà nó đứng lên.\n * Màu icon vì thế phải là bậc `--x-ink`, không phải token tô nền `--x`: `text-success` trên\n * `bg-success/15` chỉ được **2,69:1 ✗** ở light (dark thì 7,44:1 — mỗi theme hỏng một nửa là\n * dấu hiệu dùng sai vai trò token). Đo lại sau khi đổi, trên tint đã trộn với `--card`:\n *\n * success 5,60:1 (L) · 7,44:1 (D) · info 5,54:1 (L) · 7,12:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) · destructive 5,76:1 (L) · 5,71:1 (D)\n * default 4,89:1 (L) · 4,86:1 (D) · primary 5,77:1 (L) · 7,26:1 (D)\n * app 6,14:1 (L) · 9,61:1 (D)\n * ⚠ `primary` đo trên bảng TƯƠNG PHẢN CAO. Ở bảng MẶC ĐỊNH `--primary-ink` = `--primary` (từ 0.3.0) nên\n * icon gold trên tint gold đo 1,64:1 — dưới ngưỡng CÓ CHỦ Ý: màu thương hiệu, bản duyệt giữ gold sáng. Đừng\n * \"sửa\" bằng một bậc vàng tối; ai cần tương phản bật bảng tương phản cao.\n *\n * `warning` bỏ cặp `text-gold-500 dark:text-warning`: `--warning-ink` đúng bằng `--gold-500` ở\n * light và `--yellow` ở dark, nên một class là đủ.\n */\n/*\n * Hình học của ô icon lấy theo mẫu StatCards ở mục **Patterns** của comitor-ds:\n * `p-1.5` quanh icon 14px + `rounded-lg` — tức ô ~28px, KHÔNG phải 36px với `rounded-md`.\n */\nexport const statCardIconVariants = cva(\"flex size-7 shrink-0 items-center justify-center rounded-lg\", {\n variants: {\n tone: {\n default: \"bg-muted text-muted-foreground\",\n // Đi theo `--primary`, không phải thang `gold-*` chôn cứng. Bản cũ\n // `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100` đạt tương phản\n // (7,77:1 L · 11,85:1 D) nhưng đóng băng màu: app đổi brand thì ô icon vẫn vàng, và phải\n // viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D) ở bảng\n // tương phản cao; bảng mặc định chỉ 1,64:1 (xem chú thích đầu file).\n primary: \"bg-primary/20 text-primary-ink\",\n success: \"bg-success/15 text-success-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\",\n info: \"bg-info/15 text-info-ink\",\n // Icon app đứng trên tint của CHÍNH nó → phải là bậc ink, đúng như năm tone trên.\n // `text-app-accent` (gold #E8B824) trên `bg-app-accent/10` (trộn ra #FDF8E9) chỉ 1,75:1 ✗.\n // `text-foreground` như bản trước thì ĐỌC được (17,81:1) nhưng bỏ mất màu app — tức là bỏ\n // mất đúng thứ tone này sinh ra để nói. `text-app-accent-ink`: 6,14:1 (L) · 9,61:1 (D) ✓\n // — hợp đồng §5, cùng lối chữa với AppLauncher.\n app: \"bg-app-accent/10 text-app-accent-ink\"\n }\n },\n defaultVariants: {\n tone: \"default\"\n }\n});\n\nexport type StatCardTone = NonNullable<VariantProps<typeof statCardIconVariants>[\"tone\"]>;\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\";\n\nexport interface StatCardDelta {\n /** Nội dung hiển thị, vd \"+12,5%\" hoặc \"3 việc\" */\n value: ReactNode;\n direction: StatCardDeltaDirection;\n /**\n * Tăng là TỐT hay XẤU — mặc định \"positive-up\" (tăng = tốt).\n *\n * Cần thiết vì có chỉ số ngược chiều: số ticket quá hạn tăng là xấu, tỷ lệ rời bỏ giảm là tốt.\n * Tô màu theo hướng mũi tên một cách máy móc sẽ nói dối người đọc.\n */\n polarity?: \"positive-up\" | \"positive-down\" | \"neutral\";\n /** Chú thích sau con số, vd \"so với tuần trước\" */\n label?: ReactNode;\n}\n\nexport interface StatCardProps extends Omit<ComponentProps<\"div\">, \"title\"> {\n /** Nhãn chỉ số, vd \"Việc đang mở\" */\n label: ReactNode;\n value: ReactNode;\n icon?: IconComponent;\n tone?: StatCardTone;\n delta?: StatCardDelta;\n /** Slot biểu đồ mini (sparkline) — package không ép thư viện biểu đồ nào */\n sparkline?: ReactNode;\n /** Dòng chú thích dưới cùng */\n footer?: ReactNode;\n /** Đang tải → khung xương thay cho số liệu */\n loading?: boolean;\n}\n\nconst deltaIcons: Record<StatCardDeltaDirection, IconComponent> = {\n up: ArrowUpRight,\n down: ArrowDownRight,\n flat: Minus\n};\n\n/**\n * Màu của delta = hướng × cực tính, không phải chỉ hướng.\n *\n * Dòng biến động là CHỮ (12px, `text-xs`) đứng trên `bg-card` → ngưỡng AA 4,5:1, và phải đạt ở\n * cả hai theme. Token tô nền không làm được việc đó: `text-success` chỉ 3,13:1 ở light còn\n * `text-destructive` chỉ 3,53:1 ở dark — mỗi theme hỏng một nửa. Bậc ink đạt cả hai:\n * success 6,52:1 (L) / 10,19:1 (D) · destructive 7,15:1 (L) / 6,42:1 (D) · muted-foreground\n * 5,23:1 (L) / 5,35:1 (D).\n */\nfunction deltaToneClass({ direction, polarity = \"positive-up\" }: StatCardDelta): string {\n if (direction === \"flat\" || polarity === \"neutral\") return \"text-muted-foreground\";\n const isGood = polarity === \"positive-up\" ? direction === \"up\" : direction === \"down\";\n return isGood ? \"text-success-ink\" : \"text-destructive-ink\";\n}\n\n/**\n * Thẻ KPI: nhãn + số liệu lớn + biến động + sparkline tuỳ chọn.\n *\n * Số liệu dùng `tabular-nums` để cột số không nhảy khi cập nhật realtime. Tĩnh → RSC-safe.\n */\nexport function StatCard({\n label,\n value,\n icon: Icon,\n tone = \"default\",\n delta,\n sparkline,\n footer,\n loading = false,\n className,\n ...props\n}: StatCardProps) {\n const DeltaIcon = delta ? deltaIcons[delta.direction] : undefined;\n\n return (\n <div\n data-slot=\"stat-card\"\n /*\n * `p-5`, và KHÔNG có đổ bóng — đúng mẫu StatCards ở mục Patterns của comitor-ds\n * (`rounded-xl border border-border bg-card p-5`). Bản trước dùng `p-4 shadow-card`; trên\n * một hàng bốn thẻ thì bóng đó nổi rõ và làm lưới thẻ nặng hơn bản duyệt.\n */\n className={cn(\"rounded-xl border border-border bg-card p-5\", className)}\n {...props}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"min-w-0 flex-1\">\n {/* `text-xs font-medium` theo bản duyệt, không phải `text-sm` thường. */}\n <p className=\"truncate text-xs font-medium text-muted-foreground\">{label}</p>\n {loading ? (\n <Skeleton className=\"mt-2 h-7 w-24\" />\n ) : (\n /* `font-bold` theo bản duyệt. `tabular-nums` là khoản gói thêm và giữ lại: chữ số\n đều bề ngang thì một hàng bốn thẻ không nhảy khi số đổi. */\n <p className=\"mt-1 text-2xl font-bold tabular-nums leading-tight text-foreground\">{value}</p>\n )}\n </div>\n {Icon && (\n <span aria-hidden=\"true\" className={statCardIconVariants({ tone })}>\n <Icon className=\"size-3.5\" />\n </span>\n )}\n </div>\n\n {/*\n * Dòng biến động nằm NGOÀI hàng hai cột ở trên, và đó là chủ ý.\n *\n * Ô icon chỉ cao 28px trong khi nhãn + số liệu đã cao ~46px, nên dòng này luôn rơi xuống\n * DƯỚI icon — nhưng hồi nó còn nằm trong cột trái thì nó vẫn bị bó theo bề rộng của cột đó\n * (147px trên thẻ 229px) và bỏ không phần dưới ô icon. Hệ quả nhìn thấy được: \"↗ +3 việc so\n * với tuần trước\" cần 171px nên nó vỡ đôi, mà vỡ theo kiểu tệ nhất — hai `<span>` là flex\n * item nên mỗi cái tự co về min-content rồi xuống dòng BÊN TRONG, ra thành hai cột lệch\n * \"+3 / việc\" và \"so với tuần / trước\". Đưa ra ngoài là dòng này được cả 189px, vừa một dòng.\n *\n * `flex-wrap` + `whitespace-nowrap` ở phần số là lưới an toàn cho thẻ hẹp hơn nữa: hết chỗ\n * thì chú thích xuống hàng NGUYÊN CỤM, không còn chuyện tách \"+3\" khỏi \"việc\".\n */}\n {delta && !loading && DeltaIcon && (\n <p className={cn(\"mt-1 flex flex-wrap items-center gap-x-1 text-xs font-medium\", deltaToneClass(delta))}>\n <DeltaIcon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n <span className=\"whitespace-nowrap tabular-nums\">{delta.value}</span>\n {delta.label && <span className=\"font-normal text-muted-foreground\">{delta.label}</span>}\n </p>\n )}\n\n {sparkline && <div className=\"mt-3 h-10\">{sparkline}</div>}\n {footer && <div className=\"mt-3 border-t border-border pt-2 text-xs text-muted-foreground\">{footer}</div>}\n </div>\n );\n}\n"],"mappings":"AA+IQ,SAEE,KAFF;AA/IR,SAAS,WAA8B;AACvC,SAAS,gBAAgB,cAAc,aAAa;AAEpD,SAAS,UAAU;AACnB,SAAS,gBAAgB;AA0BlB,MAAM,uBAAuB,IAAI,+DAA+D;AAAA,EACrG,UAAU;AAAA,IACR,MAAM;AAAA,MACJ,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMN,KAAK;AAAA,IACP;AAAA,EACF;AAAA,EACA,iBAAiB;AAAA,IACf,MAAM;AAAA,EACR;AACF,CAAC;AAoCD,MAAM,aAA4D;AAAA,EAChE,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,MAAM;AACR;AAWA,SAAS,eAAe,EAAE,WAAW,WAAW,cAAc,GAA0B;AACtF,MAAI,cAAc,UAAU,aAAa,UAAW,QAAO;AAC3D,QAAM,SAAS,aAAa,gBAAgB,cAAc,OAAO,cAAc;AAC/E,SAAO,SAAS,qBAAqB;AACvC;AAOO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,YAAY,QAAQ,WAAW,MAAM,SAAS,IAAI;AAExD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MAMV,WAAW,GAAG,+CAA+C,SAAS;AAAA,MACrE,GAAG;AAAA,MAEJ;AAAA,6BAAC,SAAI,WAAU,0CACb;AAAA,+BAAC,SAAI,WAAU,kBAEb;AAAA,gCAAC,OAAE,WAAU,sDAAsD,iBAAM;AAAA,YACxE,UACC,oBAAC,YAAS,WAAU,iBAAgB;AAAA;AAAA;AAAA,cAIpC,oBAAC,OAAE,WAAU,sEAAsE,iBAAM;AAAA;AAAA,aAE7F;AAAA,UACC,QACC,oBAAC,UAAK,eAAY,QAAO,WAAW,qBAAqB,EAAE,KAAK,CAAC,GAC/D,8BAAC,QAAK,WAAU,YAAW,GAC7B;AAAA,WAEJ;AAAA,QAeC,SAAS,CAAC,WAAW,aACpB,qBAAC,OAAE,WAAW,GAAG,gEAAgE,eAAe,KAAK,CAAC,GACpG;AAAA,8BAAC,aAAU,eAAY,QAAO,WAAU,qBAAoB;AAAA,UAC5D,oBAAC,UAAK,WAAU,kCAAkC,gBAAM,OAAM;AAAA,UAC7D,MAAM,SAAS,oBAAC,UAAK,WAAU,qCAAqC,gBAAM,OAAM;AAAA,WACnF;AAAA,QAGD,aAAa,oBAAC,SAAI,WAAU,aAAa,qBAAU;AAAA,QACnD,UAAU,oBAAC,SAAI,WAAU,kEAAkE,kBAAO;AAAA;AAAA;AAAA,EACrG;AAEJ;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/ui/badge.tsx"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * Style port từ comitor-ds (biến thể tonal + 2 cỡ sm/md).\n * API giữ tương thích shadcn (cva + asChild + data-slot).\n *\n * ─ BIẾN THỂ TONAL: NỀN LÀ `--x`, CHỮ LÀ `--x-ink` ─\n *\n * Badge in chữ 10–12px nên phải đạt AA 4,5:1 trên chính cái tint mà nó đứng lên. Hợp đồng\n * ba vai trò (docs/hop-dong-mau.md §1) đã có sẵn bậc chữ cho việc này: nền là `--success` /\n * `--warning` / `--destructive`, chữ là bậc `-ink` tương ứng. Đo trên tint hợp lên `--card`:\n *\n * success-ink #216A48 trên bg-success/15 #E0F2E9 = 5,60:1 ✓ (light)\n * #4ADE80 trên #1E362C = 7,44:1 ✓ (dark)\n * warning-ink #7A5800 trên bg-warning/20 #FCF4DB = 5,93:1 ✓ (light)\n * #FACC15 trên #443C1B = 7,19:1 ✓ (dark)\n * destructive-ink #9A3537 trên bg-destructive/15 #F7E2E2 = 5,76:1 ✓ (light)\n * #F87171 trên #311D22 = 5,71:1 ✓ (dark)\n *\n * Bản trước dẫn xuất bậc tối tại chỗ bằng `color-mix(--x, --ink N%)` cho light rồi lật về\n * `dark:text-success` / `dark:text-warning` / `dark:text-red` cho dark. Số thì đạt, nhưng\n * NHÁNH DARK vi phạm hợp đồng: `--success`/`--warning` là token TÔ NỀN, không phải màu chữ —\n * app nào đổi `--success` cho hợp brand mình là badge hỏng chữ mà không có gì báo. Bậc `-ink`\n * mới là biến mang ràng buộc tương phản, và nó tự lật theo theme (styles.css khai `-ink` riêng\n * cho `.dark`) nên không cần biến thể `dark:` nào ở đây nữa.\n *\n * ⚠ Trần tint: `/20` là mức đậm nhất an toàn cho MỌI tone ở cả hai theme. `warning` chịu được\n * `/20` thoải mái (trần của vàng là `/60` light · `/36` dark) nhưng đỏ ở dark chỉ tới `/22` —\n * muốn đậm hơn `/15` phải tra bảng trần tint ở docs/hop-dong-mau.md §2.\n *\n * ─ VIỀN CỦA BIẾN THỂ `outline` ─\n * `border-control-edge` chứ không `border-border`: nền badge trong suốt nên VIỀN là thứ duy\n * nhất khoanh vùng nó → chịu ngưỡng 3:1 (WCAG 1.4.11). `--border` là bậc TRANG TRÍ, chỉ\n * 1,16:1 (light) / 1,17:1 (dark) — badge gần như tàng hình. `--control-edge`: 3,83:1 / 4,94:1 ✓.\n *\n * ─ BIẾN THỂ `default`/`primary` ─\n * Vẫn là thang gold thô (`gold-100`/`gold-600` ở light, `gold-500/20`+`gold-200` ở dark) vì\n * cặp này đã đo và dư biên: 7,77:1 (light) · 10,23:1 (dark).\n */\nconst badgeVariants = cva(\n \"inline-flex items-center font-medium rounded-md whitespace-nowrap gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none\",\n {\n variants: {\n variant: {\n default: \"bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200\",\n primary: \"bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200\",\n secondary: \"bg-secondary text-secondary-foreground\",\n outline: \"bg-transparent border border-control-edge text-foreground\",\n success: \"bg-green/10 dark:bg-green/20 text-green-ink\",\n warning: \"bg-yellow/10 dark:bg-yellow/20 text-yellow-ink\",\n destructive: \"bg-red/10 dark:bg-red/15 text-destructive-ink\"\n },\n size: {\n sm: \"px-1.5 py-0.5 text-[10px]\",\n md: \"px-2 py-0.5 text-xs\"\n }\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\"\n }\n }\n);\n\nexport interface BadgeProps extends React.ComponentProps<\"span\">, VariantProps<typeof badgeVariants> {\n /** Render ra chính phần tử con (Radix Slot) thay vì thẻ <span>. */\n asChild?: boolean;\n}\n\nfunction Badge({ className, variant, size, asChild = false, ...props }: BadgeProps) {\n const Comp = asChild ? Slot : \"span\";\n\n return <Comp data-slot=\"badge\" className={cn(badgeVariants({ variant, size }), className)} {...props} />;\n}\n\nexport { Badge, badgeVariants };\n"],"mappings":"AA2ES;AA3ET,SAAS,YAAY;AACrB,SAAS,WAA8B;AAEvC,SAAS,UAAU;AAuCnB,MAAM,gBAAgB;AAAA,EACpB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SAAS;AAAA,QACT,SAAS;AAAA,QACT,WAAW;AAAA,QACX,SAAS;AAAA,QACT,SAAS;AAAA,QACT,SAAS;AAAA,QACT,aAAa;AAAA,MACf;AAAA,MACA,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,MACN;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,MACT,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAOA,SAAS,MAAM,EAAE,WAAW,SAAS,MAAM,UAAU,OAAO,GAAG,MAAM,GAAe;AAClF,QAAM,OAAO,UAAU,OAAO;AAE9B,SAAO,oBAAC,QAAK,aAAU,SAAQ,WAAW,GAAG,cAAc,EAAE,SAAS,KAAK,CAAC,GAAG,SAAS,GAAI,GAAG,OAAO;AACxG;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/ui/badge.tsx"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * Style port từ comitor-ds (biến thể tonal + 2 cỡ sm/md).\n * API giữ tương thích shadcn (cva + asChild + data-slot).\n *\n * ─ BIẾN THỂ TONAL: NỀN LÀ `--x`, CHỮ LÀ `--x-ink` ─\n *\n * Badge in chữ 10–12px nên phải đạt AA 4,5:1 trên chính cái tint mà nó đứng lên. Hợp đồng\n * ba vai trò (docs/hop-dong-mau.md §1) đã có sẵn bậc chữ cho việc này: nền là `--success` /\n * `--warning` / `--destructive`, chữ là bậc `-ink` tương ứng. Đo trên tint hợp lên `--card`:\n *\n * success-ink #216A48 trên bg-success/15 #E0F2E9 = 5,60:1 ✓ (light)\n * #4ADE80 trên #1E362C = 7,44:1 ✓ (dark)\n * warning-ink #7A5800 trên bg-warning/20 #FCF4DB = 5,93:1 ✓ (light)\n * #FACC15 trên #443C1B = 7,19:1 ✓ (dark)\n * destructive-ink #9A3537 trên bg-destructive/15 #F7E2E2 = 5,76:1 ✓ (light)\n * #F87171 trên #311D22 = 5,71:1 ✓ (dark)\n *\n * Bản trước dẫn xuất bậc tối tại chỗ bằng `color-mix(--x, --ink N%)` cho light rồi lật về\n * `dark:text-success` / `dark:text-warning` / `dark:text-red` cho dark. Số thì đạt, nhưng\n * NHÁNH DARK vi phạm hợp đồng: `--success`/`--warning` là token TÔ NỀN, không phải màu chữ —\n * app nào đổi `--success` cho hợp brand mình là badge hỏng chữ mà không có gì báo. Bậc `-ink`\n * mới là biến mang ràng buộc tương phản, và nó tự lật theo theme (styles.css khai `-ink` riêng\n * cho `.dark`) nên không cần biến thể `dark:` nào ở đây nữa.\n *\n * ⚠ Trần tint: `/20` là mức đậm nhất an toàn cho MỌI tone ở cả hai theme. `warning` chịu được\n * `/20` thoải mái (trần của vàng là `/60` light · `/36` dark) nhưng đỏ ở dark chỉ tới `/22` —\n * muốn đậm hơn `/15` phải tra bảng trần tint ở docs/hop-dong-mau.md §2.\n *\n * ─ VIỀN CỦA BIẾN THỂ `outline` ─\n * `border-control-edge` chứ không `border-border`: nền badge trong suốt nên VIỀN là thứ duy\n * nhất khoanh vùng nó → chịu ngưỡng 3:1 (WCAG 1.4.11). `--border` là bậc TRANG TRÍ, chỉ\n * 1,16:1 (light) / 1,17:1 (dark) — badge gần như tàng hình. `--control-edge`: 3,83:1 / 4,94:1 ✓ ở bảng\n * TƯƠNG PHẢN CAO; ở bảng MẶC ĐỊNH nó cũng chỉ 1,16:1 (light) / 2,00:1 (dark) trên `--card` (đo trên 3.3.4) —\n * khoản dưới ngưỡng bảng mặc định đã chấp nhận cho viền control, người cần tương phản bật bảng tương phản cao.\n *\n * ─ BIẾN THỂ `default`/`primary` ─\n * Vẫn là thang gold thô (`gold-100`/`gold-600` ở light, `gold-500/20`+`gold-200` ở dark) vì\n * cặp này đã đo và dư biên: 7,77:1 (light) · 10,23:1 (dark).\n */\nconst badgeVariants = cva(\n \"inline-flex items-center font-medium rounded-md whitespace-nowrap gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none\",\n {\n variants: {\n variant: {\n default: \"bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200\",\n primary: \"bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200\",\n secondary: \"bg-secondary text-secondary-foreground\",\n outline: \"bg-transparent border border-control-edge text-foreground\",\n success: \"bg-green/10 dark:bg-green/20 text-green-ink\",\n warning: \"bg-yellow/10 dark:bg-yellow/20 text-yellow-ink\",\n destructive: \"bg-red/10 dark:bg-red/15 text-destructive-ink\"\n },\n size: {\n sm: \"px-1.5 py-0.5 text-[10px]\",\n md: \"px-2 py-0.5 text-xs\"\n }\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\"\n }\n }\n);\n\nexport interface BadgeProps extends React.ComponentProps<\"span\">, VariantProps<typeof badgeVariants> {\n /** Render ra chính phần tử con (Radix Slot) thay vì thẻ <span>. */\n asChild?: boolean;\n}\n\nfunction Badge({ className, variant, size, asChild = false, ...props }: BadgeProps) {\n const Comp = asChild ? Slot : \"span\";\n\n return <Comp data-slot=\"badge\" className={cn(badgeVariants({ variant, size }), className)} {...props} />;\n}\n\nexport { Badge, badgeVariants };\n"],"mappings":"AA6ES;AA7ET,SAAS,YAAY;AACrB,SAAS,WAA8B;AAEvC,SAAS,UAAU;AAyCnB,MAAM,gBAAgB;AAAA,EACpB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SAAS;AAAA,QACT,SAAS;AAAA,QACT,WAAW;AAAA,QACX,SAAS;AAAA,QACT,SAAS;AAAA,QACT,SAAS;AAAA,QACT,aAAa;AAAA,MACf;AAAA,MACA,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,MACN;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,MACT,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAOA,SAAS,MAAM,EAAE,WAAW,SAAS,MAAM,UAAU,OAAO,GAAG,MAAM,GAAe;AAClF,QAAM,OAAO,UAAU,OAAO;AAE9B,SAAO,oBAAC,QAAK,aAAU,SAAQ,WAAW,GAAG,cAAc,EAAE,SAAS,KAAK,CAAC,GAAG,SAAS,GAAI,GAAG,OAAO;AACxG;","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/ui/field.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { useMemo } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Label, type LabelProps } from \"./label.js\";\nimport { Separator } from \"./separator.js\";\n\nexport type FieldSetProps = React.ComponentProps<\"fieldset\">;\n\nfunction FieldSet({ className, ...props }: FieldSetProps) {\n return (\n <fieldset\n data-slot=\"field-set\"\n className={cn(\n \"flex flex-col gap-6\",\n \"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport interface FieldLegendProps extends React.ComponentProps<\"legend\"> {\n /** `legend` = tiêu đề nhóm cỡ base; `label` = cỡ sm như một nhãn thường. */\n variant?: \"legend\" | \"label\";\n}\n\nfunction FieldLegend({ className, variant = \"legend\", ...props }: FieldLegendProps) {\n return (\n <legend\n data-slot=\"field-legend\"\n data-variant={variant}\n className={cn(\"mb-3 font-medium\", \"data-[variant=legend]:text-base\", \"data-[variant=label]:text-sm\", className)}\n {...props}\n />\n );\n}\n\nexport type FieldGroupProps = React.ComponentProps<\"div\">;\n\nfunction FieldGroup({ className, ...props }: FieldGroupProps) {\n return (\n <div\n data-slot=\"field-group\"\n className={cn(\n \"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4\",\n className\n )}\n {...props}\n />\n );\n}\n\n// `text-destructive-ink` (bậc CHỮ đỏ) chứ không `text-destructive` (bậc NỀN đỏ của nút xoá):\n// bậc nền chỉ 3,76:1 trên `--background` và 3,53:1 trên `--card` ở dark ✗. Ink: 7,15:1 (light) ·\n// 6,84:1 / 6,42:1 (dark) ✓. Cùng một lối với `form.tsx` và `composite/form-field.tsx`.\nconst fieldVariants = cva(\"group/field flex w-full data-[invalid=true]:text-destructive-ink\", {\n variants: {\n orientation: {\n vertical: [\"flex-col gap-2 [&>*]:w-full [&>.sr-only]:w-auto\"],\n horizontal: [\n \"flex-row items-center gap-3\",\n \"[&>[data-slot=field-label]]:flex-auto\",\n \"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\"\n ],\n responsive: [\n \"flex-col gap-2 [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 @md/field-group:[&>*]:w-auto\",\n \"@md/field-group:[&>[data-slot=field-label]]:flex-auto\",\n \"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\"\n ]\n }\n },\n defaultVariants: {\n orientation: \"vertical\"\n }\n});\n\nexport interface FieldProps extends React.ComponentProps<\"div\">, VariantProps<typeof fieldVariants> {}\n\nfunction Field({ className, orientation = \"vertical\", ...props }: FieldProps) {\n return (\n <div\n role=\"group\"\n data-slot=\"field\"\n data-orientation={orientation}\n className={cn(fieldVariants({ orientation }), className)}\n {...props}\n />\n );\n}\n\nexport type FieldContentProps = React.ComponentProps<\"div\">;\n\nfunction FieldContent({ className, ...props }: FieldContentProps) {\n return (\n <div\n data-slot=\"field-content\"\n className={cn(\"group/field-content flex flex-1 flex-col gap-1.5 leading-snug\", className)}\n {...props}\n />\n );\n}\n\nexport type FieldLabelProps = LabelProps;\n\n/*\n * Thẻ chọn (label bọc cả một Field, kiểu radio-card): trạng thái ĐÃ CHỌN được kể bằng nền tint\n * `bg-primary/5` — chỉ khác nền trang cỡ 1,03:1 — nên VIỀN mới là tín hiệu thật, và nó chịu\n * ngưỡng 3:1 của WCAG 1.4.11. `border-primary` gold #E8B824 chỉ **1,86:1** với nền trang và\n * **1,80:1** với chính cái tint (light) ✗. `--primary-ink`: 6,51:1 với nền trang · 6,30:1 với\n * tint (light); 12,67:1 · 10,43:1 (dark) ✓. Tint giữ nguyên `--primary` vì đó là vai trò TÔ NỀN.\n */\nfunction FieldLabel({ className, ...props }: FieldLabelProps) {\n return (\n <Label\n data-slot=\"field-label\"\n className={cn(\n \"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50\",\n \"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4\",\n \"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary-ink dark:has-data-[state=checked]:bg-primary/10\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport type FieldTitleProps = React.ComponentProps<\"div\">;\n\nfunction FieldTitle({ className, ...props }: FieldTitleProps) {\n return (\n <div\n data-slot=\"field-label\"\n className={cn(\n \"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport type FieldDescriptionProps = React.ComponentProps<\"p\">;\n\nfunction FieldDescription({ className, ...props }: FieldDescriptionProps) {\n return (\n <p\n data-slot=\"field-description\"\n className={cn(\n \"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance\",\n \"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5\",\n \"[&>a:hover]:text-link-ink-hover [&>a]:underline [&>a]:underline-offset-4\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport interface FieldSeparatorProps extends React.ComponentProps<\"div\"> {\n /** Có nội dung thì chữ nằm giữa đường kẻ (kiểu \"hoặc\"). */\n children?: React.ReactNode;\n}\n\n/*\n * ─ CÓ CHỮ THÌ CẮT ĐƯỜNG KẺ, KHÔNG PHỦ MỘT MẢNG NỀN LÊN NÓ ─\n *\n * Kiểu quen thuộc (shadcn gốc, và comitor-ds) là một đường kẻ chạy suốt cộng một `<span>` tô\n * `bg-background` đè lên giữa để \"khoét\" chỗ cho chữ. Cách đó chỉ đúng khi thành phần đứng THẲNG\n * trên nền trang — mà chỗ dùng thật gần như luôn là bên trong một `Card`:\n *\n * --background #0E1116 ≠ --card #16181D (theme tối)\n *\n * nên mảng tô hiện nguyên hình một hình chữ nhật TỐI HƠN nằm giữa thẻ. Ở theme sáng hai token\n * cùng là #FFFFFF nên lỗi vô hình — đây đúng loại lỗi chỉ lộ ra sau khi đổi theme, và không có\n * cách nào chữa từ phía app vì `<span>` nằm bên trong gói.\n *\n * Bản này dựng chữ ở GIỮA hai đoạn kẻ (`flex` + `flex-1`), nên không cần biết phía sau là nền gì:\n * đúng trên `--background`, trên `--card`, trên `--popover`, trên nền tô của một `Alert`. Nhánh\n * KHÔNG có chữ giữ nguyên đường kẻ tuyệt đối như cũ.\n */\nfunction FieldSeparator({ children, className, ...props }: FieldSeparatorProps) {\n return (\n <div\n data-slot=\"field-separator\"\n data-content={!!children}\n className={cn(\"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2\", className)}\n {...props}\n >\n {children ? (\n <div className=\"flex h-full items-center gap-2\">\n <Separator className=\"flex-1\" />\n <span className=\"text-muted-foreground shrink-0\" data-slot=\"field-separator-content\">\n {children}\n </span>\n <Separator className=\"flex-1\" />\n </div>\n ) : (\n <Separator className=\"absolute inset-0 top-1/2\" />\n )}\n </div>\n );\n}\n\nexport interface FieldErrorProps extends React.ComponentProps<\"div\"> {\n /**\n * Danh sách lỗi (khớp shape của react-hook-form). Bỏ qua khi đã truyền `children`.\n * Một lỗi → in thẳng; nhiều lỗi → danh sách gạch đầu dòng.\n */\n errors?: Array<{ message?: string } | undefined>;\n}\n\nfunction FieldError({ className, children, errors, ...props }: FieldErrorProps) {\n const content = useMemo(() => {\n if (children) {\n return children;\n }\n\n if (!errors) {\n return null;\n }\n\n // Lọc sạch phần tử rỗng trước để dùng chính message làm key (tránh key theo index).\n const messages = errors.flatMap((error) => (error?.message ? [error.message] : []));\n\n if (messages.length === 0) {\n return null;\n }\n\n if (messages.length === 1) {\n return messages[0];\n }\n\n return (\n <ul className=\"ml-4 flex list-disc flex-col gap-1\">\n {messages.map((message) => (\n <li key={message}>{message}</li>\n ))}\n </ul>\n );\n }, [children, errors]);\n\n if (!content) {\n return null;\n }\n\n return (\n <div\n role=\"alert\"\n data-slot=\"field-error\"\n className={cn(\"text-destructive-ink text-sm font-normal\", className)}\n {...props}\n >\n {content}\n </div>\n );\n}\n\nexport {\n Field,\n FieldContent,\n FieldDescription,\n FieldError,\n FieldGroup,\n FieldLabel,\n FieldLegend,\n FieldSeparator,\n FieldSet,\n FieldTitle,\n fieldVariants\n};\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/components/ui/field.tsx"],"sourcesContent":["\"use client\";\n\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { useMemo } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Label, type LabelProps } from \"./label.js\";\nimport { Separator } from \"./separator.js\";\n\nexport type FieldSetProps = React.ComponentProps<\"fieldset\">;\n\nfunction FieldSet({ className, ...props }: FieldSetProps) {\n return (\n <fieldset\n data-slot=\"field-set\"\n className={cn(\n \"flex flex-col gap-6\",\n \"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport interface FieldLegendProps extends React.ComponentProps<\"legend\"> {\n /** `legend` = tiêu đề nhóm cỡ base; `label` = cỡ sm như một nhãn thường. */\n variant?: \"legend\" | \"label\";\n}\n\nfunction FieldLegend({ className, variant = \"legend\", ...props }: FieldLegendProps) {\n return (\n <legend\n data-slot=\"field-legend\"\n data-variant={variant}\n className={cn(\"mb-3 font-medium\", \"data-[variant=legend]:text-base\", \"data-[variant=label]:text-sm\", className)}\n {...props}\n />\n );\n}\n\nexport type FieldGroupProps = React.ComponentProps<\"div\">;\n\nfunction FieldGroup({ className, ...props }: FieldGroupProps) {\n return (\n <div\n data-slot=\"field-group\"\n className={cn(\n \"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4\",\n className\n )}\n {...props}\n />\n );\n}\n\n// `text-destructive-ink` (bậc CHỮ đỏ) chứ không `text-destructive` (bậc NỀN đỏ của nút xoá):\n// bậc nền chỉ 3,76:1 trên `--background` và 3,53:1 trên `--card` ở dark ✗. Ink: 7,15:1 (light) ·\n// 6,84:1 / 6,42:1 (dark) ✓. Cùng một lối với `form.tsx` và `composite/form-field.tsx`.\nconst fieldVariants = cva(\"group/field flex w-full data-[invalid=true]:text-destructive-ink\", {\n variants: {\n orientation: {\n vertical: [\"flex-col gap-2 [&>*]:w-full [&>.sr-only]:w-auto\"],\n horizontal: [\n \"flex-row items-center gap-3\",\n \"[&>[data-slot=field-label]]:flex-auto\",\n \"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\"\n ],\n responsive: [\n \"flex-col gap-2 [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:gap-3 @md/field-group:[&>*]:w-auto\",\n \"@md/field-group:[&>[data-slot=field-label]]:flex-auto\",\n \"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px\"\n ]\n }\n },\n defaultVariants: {\n orientation: \"vertical\"\n }\n});\n\nexport interface FieldProps extends React.ComponentProps<\"div\">, VariantProps<typeof fieldVariants> {}\n\nfunction Field({ className, orientation = \"vertical\", ...props }: FieldProps) {\n return (\n <div\n role=\"group\"\n data-slot=\"field\"\n data-orientation={orientation}\n className={cn(fieldVariants({ orientation }), className)}\n {...props}\n />\n );\n}\n\nexport type FieldContentProps = React.ComponentProps<\"div\">;\n\nfunction FieldContent({ className, ...props }: FieldContentProps) {\n return (\n <div\n data-slot=\"field-content\"\n className={cn(\"group/field-content flex flex-1 flex-col gap-1.5 leading-snug\", className)}\n {...props}\n />\n );\n}\n\nexport type FieldLabelProps = LabelProps;\n\n/*\n * Thẻ chọn (label bọc cả một Field, kiểu radio-card): trạng thái ĐÃ CHỌN được kể bằng nền tint\n * `bg-primary/5` — chỉ khác nền trang cỡ 1,03:1 — nên VIỀN mới là tín hiệu thật, và nó chịu\n * ngưỡng 3:1 của WCAG 1.4.11. `border-primary` gold #E8B824 chỉ **1,86:1** với nền trang và\n * **1,80:1** với chính cái tint (light) ✗. `--primary-ink`: 6,51:1 với nền trang · 6,30:1 với\n * tint (light); 12,67:1 · 10,43:1 (dark) ✓ — số của bảng TƯƠNG PHẢN CAO. Từ 0.3.0, ở bảng MẶC ĐỊNH\n * `--primary-ink` = `--primary`, nên ở light viền này cũng chỉ 1,86:1: khoản gold-của-control dưới ngưỡng mà\n * bảng mặc định đã chấp nhận. Tint giữ nguyên `--primary` vì đó là vai trò TÔ NỀN.\n */\nfunction FieldLabel({ className, ...props }: FieldLabelProps) {\n return (\n <Label\n data-slot=\"field-label\"\n className={cn(\n \"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50\",\n \"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4\",\n \"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary-ink dark:has-data-[state=checked]:bg-primary/10\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport type FieldTitleProps = React.ComponentProps<\"div\">;\n\nfunction FieldTitle({ className, ...props }: FieldTitleProps) {\n return (\n <div\n data-slot=\"field-label\"\n className={cn(\n \"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport type FieldDescriptionProps = React.ComponentProps<\"p\">;\n\nfunction FieldDescription({ className, ...props }: FieldDescriptionProps) {\n return (\n <p\n data-slot=\"field-description\"\n className={cn(\n \"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance\",\n \"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5\",\n \"[&>a:hover]:text-link-ink-hover [&>a]:underline [&>a]:underline-offset-4\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport interface FieldSeparatorProps extends React.ComponentProps<\"div\"> {\n /** Có nội dung thì chữ nằm giữa đường kẻ (kiểu \"hoặc\"). */\n children?: React.ReactNode;\n}\n\n/*\n * ─ CÓ CHỮ THÌ CẮT ĐƯỜNG KẺ, KHÔNG PHỦ MỘT MẢNG NỀN LÊN NÓ ─\n *\n * Kiểu quen thuộc (shadcn gốc, và comitor-ds) là một đường kẻ chạy suốt cộng một `<span>` tô\n * `bg-background` đè lên giữa để \"khoét\" chỗ cho chữ. Cách đó chỉ đúng khi thành phần đứng THẲNG\n * trên nền trang — mà chỗ dùng thật gần như luôn là bên trong một `Card`:\n *\n * --background #0E1116 ≠ --card #16181D (theme tối)\n *\n * nên mảng tô hiện nguyên hình một hình chữ nhật TỐI HƠN nằm giữa thẻ. Ở theme sáng hai token\n * cùng là #FFFFFF nên lỗi vô hình — đây đúng loại lỗi chỉ lộ ra sau khi đổi theme, và không có\n * cách nào chữa từ phía app vì `<span>` nằm bên trong gói.\n *\n * Bản này dựng chữ ở GIỮA hai đoạn kẻ (`flex` + `flex-1`), nên không cần biết phía sau là nền gì:\n * đúng trên `--background`, trên `--card`, trên `--popover`, trên nền tô của một `Alert`. Nhánh\n * KHÔNG có chữ giữ nguyên đường kẻ tuyệt đối như cũ.\n */\nfunction FieldSeparator({ children, className, ...props }: FieldSeparatorProps) {\n return (\n <div\n data-slot=\"field-separator\"\n data-content={!!children}\n className={cn(\"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2\", className)}\n {...props}\n >\n {children ? (\n <div className=\"flex h-full items-center gap-2\">\n <Separator className=\"flex-1\" />\n <span className=\"text-muted-foreground shrink-0\" data-slot=\"field-separator-content\">\n {children}\n </span>\n <Separator className=\"flex-1\" />\n </div>\n ) : (\n <Separator className=\"absolute inset-0 top-1/2\" />\n )}\n </div>\n );\n}\n\nexport interface FieldErrorProps extends React.ComponentProps<\"div\"> {\n /**\n * Danh sách lỗi (khớp shape của react-hook-form). Bỏ qua khi đã truyền `children`.\n * Một lỗi → in thẳng; nhiều lỗi → danh sách gạch đầu dòng.\n */\n errors?: Array<{ message?: string } | undefined>;\n}\n\nfunction FieldError({ className, children, errors, ...props }: FieldErrorProps) {\n const content = useMemo(() => {\n if (children) {\n return children;\n }\n\n if (!errors) {\n return null;\n }\n\n // Lọc sạch phần tử rỗng trước để dùng chính message làm key (tránh key theo index).\n const messages = errors.flatMap((error) => (error?.message ? [error.message] : []));\n\n if (messages.length === 0) {\n return null;\n }\n\n if (messages.length === 1) {\n return messages[0];\n }\n\n return (\n <ul className=\"ml-4 flex list-disc flex-col gap-1\">\n {messages.map((message) => (\n <li key={message}>{message}</li>\n ))}\n </ul>\n );\n }, [children, errors]);\n\n if (!content) {\n return null;\n }\n\n return (\n <div\n role=\"alert\"\n data-slot=\"field-error\"\n className={cn(\"text-destructive-ink text-sm font-normal\", className)}\n {...props}\n >\n {content}\n </div>\n );\n}\n\nexport {\n Field,\n FieldContent,\n FieldDescription,\n FieldError,\n FieldGroup,\n FieldLabel,\n FieldLegend,\n FieldSeparator,\n FieldSet,\n FieldTitle,\n fieldVariants\n};\n"],"mappings":"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","names":[]}
@@ -18,7 +18,7 @@ interface ItemProps extends React.ComponentProps<"div">, VariantProps<typeof ite
18
18
  }
19
19
  declare function Item({ className, variant, size, asChild, ...props }: ItemProps): React.JSX.Element;
20
20
  declare const itemMediaVariants: (props?: ({
21
- variant?: "default" | "image" | "icon" | null | undefined;
21
+ variant?: "image" | "default" | "icon" | null | undefined;
22
22
  } & class_variance_authority_types.ClassProp) | undefined) => string;
23
23
  interface ItemMediaProps extends React.ComponentProps<"div">, VariantProps<typeof itemMediaVariants> {
24
24
  }
@@ -47,6 +47,16 @@ const AUTO_SCROLL_SELECTION_THROTTLE_MS = 32;
47
47
  function getIsColumnReadOnly(table, columnId) {
48
48
  return table?.getColumn(columnId)?.columnDef.meta?.readOnly === true;
49
49
  }
50
+ function getRowSelectionCells(rows, rowSelection, columnIds) {
51
+ const selectedCells = /* @__PURE__ */ new Set();
52
+ for (const [rowIndex, row] of rows.entries()) {
53
+ if (!rowSelection[row.id]) continue;
54
+ for (const columnId of columnIds) {
55
+ selectedCells.add(getCellKey(rowIndex, columnId));
56
+ }
57
+ }
58
+ return selectedCells;
59
+ }
50
60
  function getCellDisplayText(value, cellOpts) {
51
61
  if (cellOpts?.variant === "select") {
52
62
  return cellOpts.options.find((option) => option.value === value)?.label ?? String(value ?? "");
@@ -1471,16 +1481,11 @@ function useDataGrid({
1471
1481
  (updater) => {
1472
1482
  const currentState = store.getState();
1473
1483
  const newRowSelection = typeof updater === "function" ? updater(currentState.rowSelection) : updater;
1474
- const selectedRows = Object.keys(newRowSelection).filter((key) => newRowSelection[key]);
1475
- const selectedCells = /* @__PURE__ */ new Set();
1476
- const rows = tableRef.current?.getRowModel().rows ?? [];
1477
- for (const rowId of selectedRows) {
1478
- const rowIndex = rows.findIndex((r) => r.id === rowId);
1479
- if (rowIndex === -1) continue;
1480
- for (const columnId of columnIds) {
1481
- selectedCells.add(getCellKey(rowIndex, columnId));
1482
- }
1483
- }
1484
+ const selectedCells = getRowSelectionCells(
1485
+ tableRef.current?.getRowModel().rows ?? [],
1486
+ newRowSelection,
1487
+ columnIds
1488
+ );
1484
1489
  store.batch(() => {
1485
1490
  store.setState("rowSelection", newRowSelection);
1486
1491
  store.setState("selectionState", {
@@ -1759,6 +1764,15 @@ function useDataGrid({
1759
1764
  if (!tableRef.current) {
1760
1765
  tableRef.current = table;
1761
1766
  }
1767
+ const rowModelRows = table.getRowModel().rows;
1768
+ useIsomorphicLayoutEffect(() => {
1769
+ const currentState = store.getState();
1770
+ if (!Object.values(currentState.rowSelection).some(Boolean)) return;
1771
+ const selectedCells = getRowSelectionCells(rowModelRows, currentState.rowSelection, columnIds);
1772
+ const currentCells = currentState.selectionState.selectedCells;
1773
+ if (selectedCells.size === currentCells.size && [...selectedCells].every((key) => currentCells.has(key))) return;
1774
+ store.setState("selectionState", { selectedCells, selectionRange: null, isSelecting: false });
1775
+ }, [rowModelRows, columnIds, store]);
1762
1776
  const columnSizeVars = React.useMemo(() => {
1763
1777
  const headers = table.getFlatHeaders();
1764
1778
  const colSizes = {};