@comitor/ui 0.1.0 → 0.2.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 (146) hide show
  1. package/README.md +222 -41
  2. package/dist/components/composite/avatar-group.js +1 -1
  3. package/dist/components/composite/avatar-group.js.map +1 -1
  4. package/dist/components/composite/combobox.js +2 -2
  5. package/dist/components/composite/combobox.js.map +1 -1
  6. package/dist/components/composite/confirm-dialog.js +1 -1
  7. package/dist/components/composite/confirm-dialog.js.map +1 -1
  8. package/dist/components/composite/copy-button.js +1 -1
  9. package/dist/components/composite/copy-button.js.map +1 -1
  10. package/dist/components/composite/data-table.d.ts +17 -8
  11. package/dist/components/composite/data-table.js +8 -8
  12. package/dist/components/composite/data-table.js.map +1 -1
  13. package/dist/components/composite/filter-chips.js +6 -5
  14. package/dist/components/composite/filter-chips.js.map +1 -1
  15. package/dist/components/composite/form-field.js +3 -1
  16. package/dist/components/composite/form-field.js.map +1 -1
  17. package/dist/components/composite/form-section.js +5 -5
  18. package/dist/components/composite/form-section.js.map +1 -1
  19. package/dist/components/composite/inline-edit.js +1 -1
  20. package/dist/components/composite/inline-edit.js.map +1 -1
  21. package/dist/components/composite/keyboard-hint.d.ts +16 -0
  22. package/dist/components/composite/keyboard-hint.js +8 -6
  23. package/dist/components/composite/keyboard-hint.js.map +1 -1
  24. package/dist/components/composite/letter-avatar.d.ts +10 -18
  25. package/dist/components/composite/letter-avatar.js +6 -17
  26. package/dist/components/composite/letter-avatar.js.map +1 -1
  27. package/dist/components/composite/search-filter-bar.d.ts +41 -3
  28. package/dist/components/composite/search-filter-bar.js +27 -15
  29. package/dist/components/composite/search-filter-bar.js.map +1 -1
  30. package/dist/components/composite/stat-card.d.ts +16 -1
  31. package/dist/components/composite/stat-card.js +16 -9
  32. package/dist/components/composite/stat-card.js.map +1 -1
  33. package/dist/components/composite/status-config.d.ts +69 -16
  34. package/dist/components/composite/status-config.js +10 -10
  35. package/dist/components/composite/status-config.js.map +1 -1
  36. package/dist/components/composite/status-pill.js +5 -1
  37. package/dist/components/composite/status-pill.js.map +1 -1
  38. package/dist/components/composite/table-pagination.d.ts +46 -4
  39. package/dist/components/composite/table-pagination.js +27 -24
  40. package/dist/components/composite/table-pagination.js.map +1 -1
  41. package/dist/components/composite/text-utils.d.ts +12 -11
  42. package/dist/components/composite/text-utils.js +7 -16
  43. package/dist/components/composite/text-utils.js.map +1 -1
  44. package/dist/components/composite/toast.js +16 -6
  45. package/dist/components/composite/toast.js.map +1 -1
  46. package/dist/components/ui/alert.js +1 -1
  47. package/dist/components/ui/alert.js.map +1 -1
  48. package/dist/components/ui/badge.js +4 -4
  49. package/dist/components/ui/badge.js.map +1 -1
  50. package/dist/components/ui/button.js +2 -2
  51. package/dist/components/ui/button.js.map +1 -1
  52. package/dist/components/ui/calendar.d.ts +41 -0
  53. package/dist/components/ui/calendar.js +1 -1
  54. package/dist/components/ui/calendar.js.map +1 -1
  55. package/dist/components/ui/checkbox.js +3 -3
  56. package/dist/components/ui/checkbox.js.map +1 -1
  57. package/dist/components/ui/command.js +1 -1
  58. package/dist/components/ui/command.js.map +1 -1
  59. package/dist/components/ui/context-menu.js +1 -1
  60. package/dist/components/ui/context-menu.js.map +1 -1
  61. package/dist/components/ui/drawer.js +1 -1
  62. package/dist/components/ui/drawer.js.map +1 -1
  63. package/dist/components/ui/dropdown-menu.js +1 -1
  64. package/dist/components/ui/dropdown-menu.js.map +1 -1
  65. package/dist/components/ui/empty.js +1 -1
  66. package/dist/components/ui/empty.js.map +1 -1
  67. package/dist/components/ui/field.js +4 -4
  68. package/dist/components/ui/field.js.map +1 -1
  69. package/dist/components/ui/form.js +8 -2
  70. package/dist/components/ui/form.js.map +1 -1
  71. package/dist/components/ui/input-group.js +3 -3
  72. package/dist/components/ui/input-group.js.map +1 -1
  73. package/dist/components/ui/input-otp.js +1 -1
  74. package/dist/components/ui/input-otp.js.map +1 -1
  75. package/dist/components/ui/input.js +3 -3
  76. package/dist/components/ui/input.js.map +1 -1
  77. package/dist/components/ui/item.js +1 -1
  78. package/dist/components/ui/item.js.map +1 -1
  79. package/dist/components/ui/menubar.js +1 -1
  80. package/dist/components/ui/menubar.js.map +1 -1
  81. package/dist/components/ui/navigation-menu.js +1 -1
  82. package/dist/components/ui/navigation-menu.js.map +1 -1
  83. package/dist/components/ui/pagination.js +1 -1
  84. package/dist/components/ui/pagination.js.map +1 -1
  85. package/dist/components/ui/progress.d.ts +26 -0
  86. package/dist/components/ui/progress.js +5 -2
  87. package/dist/components/ui/progress.js.map +1 -1
  88. package/dist/components/ui/radio-group.js +2 -2
  89. package/dist/components/ui/radio-group.js.map +1 -1
  90. package/dist/components/ui/resizable.d.ts +14 -0
  91. package/dist/components/ui/resizable.js +2 -2
  92. package/dist/components/ui/resizable.js.map +1 -1
  93. package/dist/components/ui/scroll-area.js +1 -1
  94. package/dist/components/ui/scroll-area.js.map +1 -1
  95. package/dist/components/ui/select.d.ts +33 -0
  96. package/dist/components/ui/select.js +3 -3
  97. package/dist/components/ui/select.js.map +1 -1
  98. package/dist/components/ui/slider.d.ts +19 -0
  99. package/dist/components/ui/slider.js +3 -3
  100. package/dist/components/ui/slider.js.map +1 -1
  101. package/dist/components/ui/sonner.d.ts +21 -3
  102. package/dist/components/ui/sonner.js +1 -2
  103. package/dist/components/ui/sonner.js.map +1 -1
  104. package/dist/components/ui/switch.js +2 -2
  105. package/dist/components/ui/switch.js.map +1 -1
  106. package/dist/components/ui/table.d.ts +16 -0
  107. package/dist/components/ui/table.js +1 -1
  108. package/dist/components/ui/table.js.map +1 -1
  109. package/dist/components/ui/tabs.js +1 -1
  110. package/dist/components/ui/tabs.js.map +1 -1
  111. package/dist/components/ui/textarea.js +1 -1
  112. package/dist/components/ui/textarea.js.map +1 -1
  113. package/dist/components/ui/toggle.js +1 -1
  114. package/dist/components/ui/toggle.js.map +1 -1
  115. package/dist/index.d.ts +6 -5
  116. package/dist/index.js +17 -2
  117. package/dist/index.js.map +1 -1
  118. package/dist/lib/avatar-tone.d.ts +126 -0
  119. package/dist/lib/avatar-tone.js +68 -0
  120. package/dist/lib/avatar-tone.js.map +1 -0
  121. package/dist/shell/app-header.js +12 -2
  122. package/dist/shell/app-header.js.map +1 -1
  123. package/dist/shell/app-launcher.js +2 -2
  124. package/dist/shell/app-launcher.js.map +1 -1
  125. package/dist/shell/index.d.ts +3 -3
  126. package/dist/shell/shell-context.d.ts +1 -7
  127. package/dist/shell/shell-context.js +1 -19
  128. package/dist/shell/shell-context.js.map +1 -1
  129. package/dist/shell/sidebar-group.js +5 -3
  130. package/dist/shell/sidebar-group.js.map +1 -1
  131. package/dist/shell/sidebar-menu-item.js +12 -4
  132. package/dist/shell/sidebar-menu-item.js.map +1 -1
  133. package/dist/shell/types.d.ts +17 -1
  134. package/dist/shell/types.js.map +1 -1
  135. package/dist/shell/user-menu.js +3 -2
  136. package/dist/shell/user-menu.js.map +1 -1
  137. package/dist/shell/utils.d.ts +10 -22
  138. package/dist/shell/utils.js +3 -11
  139. package/dist/shell/utils.js.map +1 -1
  140. package/dist/shell/workspace-switcher.js +23 -8
  141. package/dist/shell/workspace-switcher.js.map +1 -1
  142. package/dist/tokens.d.ts +165 -17
  143. package/dist/tokens.js +122 -25
  144. package/dist/tokens.js.map +1 -1
  145. package/package.json +4 -2
  146. package/styles.css +312 -22
package/README.md CHANGED
@@ -8,11 +8,16 @@ component ghép sẵn và khung ứng dụng (shell) kiểu workspace × app.
8
8
  - Token brand nằm ở **`styles.css`** — đổi token, bump version, mọi app nhận cùng một thay đổi.
9
9
  - Server-component-friendly: `"use client"` chỉ nằm ở file thật sự cần.
10
10
 
11
- > Trạng thái: `0.1.0` — **đã port xong** 51 primitive + 24 composite + 18 file shell + chart, và đã qua
12
- > **một đợt sửa theo review** (tương phản màu theo WCAG, thang đổ bóng 4 bậc, a11y tầng shell, chuỗi
13
- > hiển thị của Tier 2 override được). `typecheck`, `build`, `lint`, `verify:directives`, `check:exports`
14
- > đều xanh. API còn có thể đổi cho tới `1.0.0` — thay đổi phá vỡ tương thích ghi ở
15
- > [docs/quyet-dinh-tich-hop.md](docs/quyet-dinh-tich-hop.md).
11
+ > Trạng thái: `0.2.0` — 51 primitive + 24 composite + 18 file shell + chart, đã qua **một đợt sửa theo
12
+ > review** (tương phản màu theo WCAG, thang đổ bóng 4 bậc, a11y tầng shell, chuỗi hiển thị của Tier 2
13
+ > override được) và **một đợt dọn bề mặt API** (0.1.0 chưa app nào import — lần cuối đổi/xoá tên mà
14
+ > không ai phải viết codemod).
15
+ >
16
+ > ⚠ **`0.2.0` phá vỡ tương thích với `0.1.0`**: đổi tên hàm màu avatar, bỏ `avatarPalette` /
17
+ > `LETTER_AVATAR_TONES` / `hashString` / `pickByString`, `useIsMac` chỉ còn một cửa, `DataTable` đổi
18
+ > tên prop, và **giá trị một số token màu đã đổi**. Bảng chuyển đổi đầy đủ:
19
+ > [CHANGELOG.md](CHANGELOG.md). Trong dải `0.x`, thay đổi phá vỡ tương thích đi kèm bump **minor**;
20
+ > API chốt ở `1.0.0`.
16
21
 
17
22
  ---
18
23
 
@@ -117,7 +122,7 @@ barrel chính re-export **một** file có `import` tới peer optional là mọ
117
122
 
118
123
  | Entry | Nội dung | Peer optional kéo theo |
119
124
  |---|---|---|
120
- | `@comitor/ui` | Tier 1 (primitive) + Tier 2 (composite) + `cn` + token TS | **không có** — chạy được ở Vite, Storybook, test runner |
125
+ | `@comitor/ui` | Tier 1 (primitive) + Tier 2 (composite) + `cn` + token TS + `useIsMac` | **không có** — chạy được ở Vite, Storybook, test runner |
121
126
  | `@comitor/ui/shell` | Tier 3: `AppShell`, `Sidebar`, `AppHeader`, `WorkspaceSwitcher`, `AppLauncher`, `UserMenu`, `ThemeProvider`, `SonnerToaster` | `next`, `next-themes` |
122
127
  | `@comitor/ui/chart` | `Chart*` (shadcn) + `ComitorAreaChart/BarChart/LineChart` + `CHART_COLORS` | `recharts` (~100KB) |
123
128
  | `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form: `Form`, `FormField`, `FormItem`, `FormControl`, `FormMessage`, `useFormField` | `react-hook-form` |
@@ -126,20 +131,25 @@ barrel chính re-export **một** file có `import` tới peer optional là mọ
126
131
 
127
132
  ### Tên xuất hiện ở nhiều entry
128
133
 
129
- Số tên mỗi entry xuất ra (đo bằng trình biên dịch TypeScript, không đếm tay): `.` **631** · `/shell` **70**
130
- · `/chart` **27** · `/form` **15** · `/tokens` **11**. Giao nhau đúng **4 nhóm** dưới đây — `export *`
131
- chỉ báo đỏ khi trùng tên trong **cùng một** entry, nên trùng xuyên entry phải tra bằng bảng này:
134
+ Số tên mỗi entry xuất ra (đo bằng trình biên dịch TypeScript trên đúng 5 file entry, giải alias về khai
135
+ báo gốc — không đếm tay): `.` **637** · `/shell` **68** · `/chart` **27** · `/form` **15** · `/tokens`
136
+ **15**. Đúng **19 tên** xuất hiện ở nhiều hơn một entry, gom thành 4 nhóm dưới đây — `export *` chỉ báo
137
+ đỏ khi trùng tên trong **cùng một** entry, nên trùng xuyên entry phải tra bằng bảng này:
132
138
 
133
139
  | Tên | Ở `@comitor/ui` | Ở entry khác | Cùng một cài đặt? |
134
140
  |---|---|---|---|
135
141
  | `FormField`, `FormFieldProps` | Tier 2 — bố cục 12 cột (label + control + mô tả + lỗi) | `/form` — wrapper của `Controller` (API shadcn) | **KHÔNG** — hai component khác hẳn nhau |
136
- | `useIsMac` | `composite/keyboard-hint.tsx` — `useState` + `useEffect`, đọc `navigator.userAgent` | `/shell` (`shell-context.tsx`) — `useSyncExternalStore`, đọc `navigator.platform` rồi mới tới `userAgent` | **KHÔNG** — hai thân hàm. Hợp đồng thì giống nhau: **render đầu luôn trả `false`** ở cả hai bản, nên không bản nào gây hydration mismatch |
142
+ | `normalizeVi` | `composite/text-utils.ts` | `/shell` — **re-export đúng bản đó** | **CÓ** — một thân hàm duy nhất (`0.2.0` gộp; trước đó là hai khai báo riêng giống nhau chữ-cho-chữ) |
137
143
  | `getVietnameseInitials` | `composite/text-utils.ts` (`toLocaleUpperCase("vi-VN")`) | `/shell` — **re-export đúng bản đó** | **CÓ** — một thân hàm duy nhất |
138
- | 11 token: `gold`, `ash`, `accent`, `brandNeutral`, `semantic`, `status`, `chart`, `avatarPalette`, `layout`, `typography`, `ThemeMode` | `export * from "./tokens.js"` | `/tokens` — **cùng một module** | **CÓ** — `/tokens` là tập con của `.`, tách ra cho nơi chỉ cần hằng số (email HTML, PDF, canvas) |
144
+ | 15 token: `gold`, `ash`, `accent`, `accentInk`, `accentForeground`, `brandNeutral`, `semantic`, `status`, `chart`, `layout`, `typography`, `ThemeMode`, `getAvatarToneColors`, `getAvatarToneIndex`, `AvatarToneColors` | `export * from "./tokens.js"` | `/tokens` — **cùng một module** | **CÓ** — `/tokens` là tập con của `.`, tách ra cho nơi chỉ cần hằng số (email HTML, PDF, canvas) |
139
145
 
140
146
  Quy tắc dùng: hằng ngày lấy bản ở `@comitor/ui`; chỉ với tới `/form` khi app đã dùng react-hook-form.
141
- Trong một app, hãy import `useIsMac` từ **một** entry duy nhất — trộn cả hai là hai đăng ký hook khác nhau
142
- cho cùng một thông tin.
147
+ Chỉ `FormField` là thật sự hai component khác nhau, phải nhìn đúng entry.
148
+
149
+ `0.2.0` đã dọn ba chỗ của `0.1.0`: **`useIsMac`** (từng có hai thân hàm khác nhau ở `.` và `/shell`)
150
+ nay chỉ còn cửa `@comitor/ui`; **`avatarPalette`** (bảng màu avatar thứ hai) bị xoá; **`normalizeVi`**
151
+ gộp về một binding. Còn đúng **`FormField`/`FormFieldProps`** là hai cài đặt khác nhau trong bảng trên
152
+ — 16/18 tên còn lại đều trỏ vào cùng một binding. Xem [CHANGELOG.md](CHANGELOG.md).
143
153
 
144
154
  > **`Toaster` KHÔNG phải tên trùng.** Có hai toaster nhưng chúng mang hai tên: `Toaster` ở
145
155
  > `@comitor/ui` (toast có icon theo trạng thái, nhận prop `theme`) và **`SonnerToaster`** ở
@@ -290,17 +300,27 @@ App **không chạy trên Next**: truyền `pathname` và `LinkComponent` cho `S
290
300
  ### 4.3 Token ngoài Tailwind
291
301
 
292
302
  ```ts
293
- import { chart, gold, layout } from "@comitor/ui/tokens";
303
+ import { chart, getAvatarToneColors, layout, semantic } from "@comitor/ui/tokens";
304
+
305
+ const seriesColor = chart.light[0]; // #E8B824
306
+ const headerPx = layout.headerHeight; // 56
307
+ const errorText = semantic.dark.destructiveInk; // mực đỏ trên nền tối
294
308
 
295
- const seriesColor = chart.light[0]; // #E8B824
296
- const headerPx = layout.headerHeight; // 56
309
+ // Avatar trong email HTML / canvas / PDF — cùng tone với avatar trên web
310
+ const { background, foreground } = getAvatarToneColors("Nguyễn Đức Thành", "light");
311
+ // { background: "#E0F2E9", foreground: "#216A48" }
297
312
  ```
298
313
 
314
+ > Hai cửa cho **một** bảng màu avatar: `getAvatarToneClasses(name)` ở `@comitor/ui` (có Tailwind → trả
315
+ > chuỗi class, đổi theo theme) và `getAvatarToneColors(name, mode)` ở `@comitor/ui/tokens` (không có
316
+ > Tailwind → trả hex đục đã trộn sẵn tint). Cùng bảng tone, cùng hàm băm, nên một người ra một màu ở cả
317
+ > hai nơi. **Đừng** tự băm tên rồi bốc màu từ `accent` — đó là đường ngắn nhất để web và email lệch màu.
318
+
299
319
  ## 5. Bảng component
300
320
 
301
- Số liệu thực tế của `0.1.0`: **51 file primitive · 24 file composite · 18 file shell · 2 file chart**
302
- (chưa kể barrel) — số tên xuất ra theo entry: `.` **631** · `/shell` **70** · `/chart` **27** · `/form`
303
- **15** · `/tokens` **11** (xem §3 cho các tên xuất hiện ở nhiều entry). Bảng dưới liệt kê component gốc;
321
+ Số liệu thực tế của `0.2.0`: **51 file primitive · 24 file composite · 18 file shell · 2 file chart**
322
+ (chưa kể barrel) — số tên xuất ra theo entry: `.` **637** · `/shell` **68** · `/chart` **27** · `/form`
323
+ **15** · `/tokens` **15** (xem §3 cho các tên xuất hiện ở nhiều entry). Bảng dưới liệt kê component gốc;
304
324
  các thành phần con (`CardHeader`, `DialogFooter`, `SelectItem`…) export kèm theo file tương ứng.
305
325
 
306
326
  ### Tier 1 — primitive (`@comitor/ui`)
@@ -331,31 +351,37 @@ Giá trị thật của package: pattern lặp đi lặp lại ở mọi app, gh
331
351
  | Component | Công dụng |
332
352
  |---|---|
333
353
  | `PageHeader`, `PageContainer` | Khung đầu trang (breadcrumb + hành động) và khung nội dung chuẩn |
334
- | `DataTable`, `DataTableColumnToggle`, `TablePagination` | Bảng dữ liệu: sort, chọn dòng, ẩn/hiện cột, phân trang. Mọi chuỗi (kể cả `aria-label`) override được qua prop `labels` — xem `DataTableLabels` / `DEFAULT_DATA_TABLE_LABELS` |
335
- | `SearchFilterBar`, `FilterChips` | Thanh tìm + lọc + đếm kết quả |
354
+ | `DataTable`, `DataTableColumnToggle` | Bảng dữ liệu: sort, chọn dòng, ẩn/hiện cột. Mọi chuỗi (kể cả `aria-label`) override được qua prop `labels` — xem `DataTableLabels` / `DEFAULT_DATA_TABLE_LABELS` |
355
+ | `TablePagination` | Phân trang đi kèm bảng. Chuỗi override qua prop `labels` — `TablePaginationLabels` / `DEFAULT_TABLE_PAGINATION_LABELS` (khoá có số/đơn vị chèn vào là **hàm**, vd `range(start, end, total, unitLabel)`) |
356
+ | `SearchFilterBar`, `FilterChips` | Thanh tìm + lọc + đếm kết quả. `SearchFilterBar` override chuỗi qua `labels` — `SearchFilterBarLabels` / `DEFAULT_SEARCH_FILTER_BAR_LABELS`; `FilterChips` nhận nhãn thẳng trong dữ liệu chip nên không có `labels` |
336
357
  | `StatCard`, `StatusPill`, `EmptyState` | Số liệu có delta, nhãn trạng thái, trạng thái rỗng |
337
358
  | `FormSection`, `FormField` | Bố cục form 12 cột; uỷ quyền label/mô tả/lỗi xuống `Field` |
338
359
  | `Combobox`, `MultiCombobox` | Chọn có tìm kiếm, **khớp không dấu tiếng Việt** |
339
360
  | `DatePicker`, `DateRangePicker` | Chọn ngày/khoảng ngày, locale `vi`, preset dựng sẵn |
340
- | `LetterAvatar`, `AvatarGroup` | Avatar chữ cái theo tên tiếng Việt (tông màu deterministic), nhóm người tham gia |
361
+ | `LetterAvatar`, `AvatarGroup` | Avatar chữ cái theo tên tiếng Việt (tông màu deterministic), nhóm người tham gia. Lấy tone bằng `getAvatarToneClasses(name)`; bản hex cho canvas/email/PDF là `getAvatarToneColors(name, mode)` ở `/tokens` — **cùng một bảng tone**, cùng một hàm băm |
341
362
  | `ConfirmDialog`, `CommandPalette` | Hộp xác nhận, bảng lệnh `⌘K` |
342
363
  | `Toaster`, `toast()`, `toastPromise()`, `dismissToast()` | Thông báo có icon theo trạng thái |
343
364
  | `CopyButton`, `RelativeTime`, `InlineEdit`, `KeyboardHint` | Tiện ích nhỏ dùng khắp nơi |
344
- | `useIsMac` | Hook đi kèm `KeyboardHint` — `true` khi chạy trên macOS (render đầu luôn `false`, xem §3) |
365
+ | `useIsMac` | Hook đi kèm `KeyboardHint` — `true` khi chạy trên macOS (render đầu luôn `false`, xem §3). **Cửa xuất duy nhất của cả gói**; `/shell` không re-export |
345
366
 
346
367
  Kèm 2 module thuần (không phải component, dùng được cả hai phía):
347
368
 
348
369
  | Module | Export |
349
370
  |---|---|
350
371
  | `status-config` | `STATUS_TONES`, `getStatusConfig`, `getStatusClasses`, `getStatusLabel`, `sortByStatusPriority` |
351
- | `text-utils` | `removeVietnameseTones`, `matchesSearch`, `getVietnameseInitials`, `hashString`, `pickByString`, `truncate` |
372
+ | `text-utils` | `removeVietnameseTones`, `normalizeVi`, `matchesSearch`, `getVietnameseInitials`, `truncate` |
373
+
374
+ > `text-utils` **không còn** `hashString` / `pickByString` (0.1.0 có). Hàm băm là chuyện nội bộ của phép
375
+ > chọn tone avatar; xuất nó ra ngoài chỉ mời app dựng lại phép chọn của riêng mình — đúng thứ làm cùng
376
+ > một người hiện hai màu ở web và email. Cần màu avatar thì dùng `getAvatarToneClasses` /
377
+ > `getAvatarToneColors`; cần một màu tuỳ ý thì truyền `toneClassName` cho `LetterAvatar`.
352
378
 
353
379
  ### Tier 3 — shell (`@comitor/ui/shell`)
354
380
 
355
381
  | Component | Công dụng |
356
382
  |---|---|
357
383
  | `AppShell` | Lắp sẵn `ShellProvider` + `Sidebar` + `AppHeader` + `<main>` + `MobileMenu`; hai layout `sidebar-first` / `header-first` |
358
- | `ShellProvider`, `useShell`, `useIsMac` | Context + phím tắt toàn cục; dùng khi tự lắp khung |
384
+ | `ShellProvider`, `useShell` | Context + phím tắt toàn cục; dùng khi tự lắp khung. (`useIsMac` **không** ở entry này — nhập từ `@comitor/ui`) |
359
385
  | `WorkspaceSwitcher` | **Trục 1** — đổi ngữ cảnh dữ liệu; dùng `Popover` (không `DropdownMenu`, vì typeahead của Radix cướp phím ô tìm kiếm) |
360
386
  | `AppLauncher`, `AppLauncherPanel` | **Trục 2** — lưới 3×3 đổi sản phẩm; app chưa mua hiện khoá và gọi `onUpsell` |
361
387
  | `AppHeader`, `Sidebar`, `SidebarGroup`, `SidebarMenuItem`, `MobileMenu` | Thành phần khung điều hướng |
@@ -363,7 +389,7 @@ Kèm 2 module thuần (không phải component, dùng được cả hai phía):
363
389
  | `ThemeProvider`, `ThemeToggle`, `SonnerToaster` | Theme (next-themes) + toast bám theme |
364
390
  | `ShellNavLink` | Link nội bộ của shell; thay `next/link` bằng `LinkComponent` khi không chạy Next |
365
391
  | `ComitorLogo`, `ComitorLockup` | Vector brand — **không** có `"use client"`, RSC render thẳng (hex trong SVG là brand book, không tokenize) |
366
- | `DEFAULT_SHELL_LABELS` + `utils` | Chuỗi hiển thị mặc định; helper `flattenNavItems`, `isNavItemActive`, `resolveCurrentApp`, `getEntitledApps`, `normalizeVi`… |
392
+ | `DEFAULT_SHELL_LABELS` + `utils` | Chuỗi hiển thị mặc định (`ShellLabels`, 32 khoá); helper `flattenNavItems`, `isNavItemActive`, `isNavBranchActive`, `toNavGroups`, `resolveCurrentApp`, `getEntitledApps`, `getLockedApps`, `appAccentStyle`, `normalizeVi`, `getVietnameseInitials` |
367
393
 
368
394
  Hợp đồng dữ liệu (type): `Workspace`, `AppDescriptor`, `NavItem`, `NavGroup`, `ShellNav`, `ShellUser`,
369
395
  `UserMenuItem`, `ShellLabels`, `ShellIcon`, `ShellLinkComponent`.
@@ -382,29 +408,163 @@ Hợp đồng dữ liệu (type): `Workspace`, `AppDescriptor`, `NavItem`, `NavG
382
408
  `Form`, `FormField`, `FormItem`, `FormLabel`, `FormControl`, `FormDescription`, `FormMessage`, `useFormField`
383
409
  — binding shadcn ⇄ react-hook-form. ⚠ `FormField` ở đây **khác** `FormField` của Tier 2 (§3).
384
410
 
385
- ## 6. Token
411
+ ### Cơ chế `labels` — đổi ngôn ngữ mà không fork component
412
+
413
+ Chuỗi hiển thị mặc định của gói là **tiếng Việt**, kể cả chuỗi chỉ trình đọc màn hình nghe thấy
414
+ (`aria-label`, `sr-only`). App dùng ngôn ngữ khác override qua prop `labels` — không phải fork
415
+ component, không phải bọc lại.
416
+
417
+ | Component | Type | Hằng mặc định |
418
+ |---|---|---|
419
+ | `DataTable`, `DataTableColumnToggle` | `DataTableLabels` | `DEFAULT_DATA_TABLE_LABELS` |
420
+ | `TablePagination` | `TablePaginationLabels` | `DEFAULT_TABLE_PAGINATION_LABELS` |
421
+ | `SearchFilterBar` | `SearchFilterBarLabels` | `DEFAULT_SEARCH_FILTER_BAR_LABELS` |
422
+ | Toàn bộ Tier 3 (`AppShell`/`ShellProvider` truyền xuống) | `ShellLabels` (32 khoá) | `DEFAULT_SHELL_LABELS` |
423
+ | `ThemeToggle` (dùng độc lập, ngoài shell) | `ThemeToggleLabels` = 4 khoá theme của `ShellLabels` | `DEFAULT_SHELL_LABELS` |
424
+
425
+ Ba quy tắc chung cho mọi bộ nhãn:
426
+
427
+ 1. **`Partial<>` — override từng khoá.** Component luôn trải `{ ...DEFAULT_*, ...labels }`, nên truyền
428
+ một khoá không làm mất phần còn lại.
429
+ 2. **Khoá có số/đơn vị chèn vào là HÀM, không phải chuỗi có placeholder.** Nối chuỗi bằng dấu cộng ở
430
+ phía app là cách chắc chắn sai trật tự từ với ngôn ngữ khác:
431
+ `range(start, end, total, unitLabel)`, `pageStatus(page, pageCount)`, `filterCount(count)`,
432
+ `pageSizeSelect(unitLabel)`. `resultSummary` còn trả `ReactNode` vì con số cần thẻ riêng
433
+ (`tabular-nums` để số không nhảy cột).
434
+ 3. **Prop rời thắng `labels`.** `unitLabel`, `label`, `searchPlaceholder` vẫn là prop riêng và vẫn ghi
435
+ đè giá trị trong `labels`: chúng là chuỗi theo **nội dung** của từng bảng ("việc", "liên hệ"), còn
436
+ `labels` là chuỗi theo **ngôn ngữ** ("mục" / "items").
437
+
438
+ ```tsx
439
+ const EN_PAGINATION: Partial<TablePaginationLabels> = {
440
+ region: "Pagination",
441
+ unitLabel: "items",
442
+ pageSizePrefix: "Show",
443
+ range: (start, end, total, unit) => `${start}–${end} of ${total} ${unit}`,
444
+ pageStatus: (page, count) => `Page ${page} of ${count}`,
445
+ firstPage: "First page",
446
+ previousPage: "Previous page",
447
+ nextPage: "Next page",
448
+ lastPage: "Last page"
449
+ };
450
+
451
+ <TablePagination {...paging} labels={EN_PAGINATION} unitLabel="contacts" />
452
+ ```
453
+
454
+ `FilterChips` **không** có `labels` — nhãn của nó nằm thẳng trong dữ liệu chip, không có chuỗi nào chôn
455
+ trong component.
456
+
457
+ ## 6. Màu và token
386
458
 
387
459
  Toàn bộ token nằm ở [`styles.css`](./styles.css); bản mirror TS ở [`src/tokens.ts`](./src/tokens.ts).
388
460
 
461
+ ### 6.1 Hợp đồng BA VAI TRÒ của màu — đọc trước khi dùng bất kỳ màu nào
462
+
463
+ Đây là quy tắc quan trọng nhất của gói. Mỗi màu có **ba** biến thể, đọc tên là biết dùng ở đâu:
464
+
465
+ | Tên | Vai trò | Đo tương phản với | Ví dụ dùng |
466
+ |---|---|---|---|
467
+ | `--x` | tô **NỀN** — fill đặc hoặc tint `/10`–`/25` | — | `bg-teal`, `bg-teal/15` |
468
+ | `--x-foreground` | chữ/icon **NẰM TRÊN** nền `--x` đặc | `--x` | `bg-destructive text-destructive-foreground` |
469
+ | `--x-ink` | **chính màu đó** khi đi lên nền TRANG: chữ, icon rời, viền, vạch active | `--background` / `--card` / `--muted`, hoặc tint của chính nó | `text-teal-ink`, `border-red-ink` |
470
+
471
+ **Vì sao phải có bậc `-ink` riêng thay vì dùng thẳng `--teal` / `--green` / `--red`**: bản light của màu
472
+ phụ được chọn để tô **nền** nên quá nhạt cho vai trò chữ. Đo trên tint 15% của chính nó — nền thật mà
473
+ chữ initials avatar và badge tonal đứng lên:
474
+
475
+ ```
476
+ teal #1F8A8B trên #DDEDEE = 3,44:1 ✗ → --teal-ink #1A6668 = 5,54:1 ✓
477
+ green #2EA56A trên #E0F2E9 = 2,69:1 ✗ → --green-ink #216A48 = 5,60:1 ✓
478
+ red #D64545 trên #F9E3E3 = 3,57:1 ✗ → --red-ink #9A3537 = 5,83:1 ✓
479
+ ```
480
+
481
+ Ở **dark** không có bậc riêng: khối `.dark` vốn đã nâng `--teal/--green/--red/--yellow` lên mức làm chữ
482
+ được, nên bên đó `--x-ink` chỉ trỏ về `var(--x)`. Nhờ vậy `text-teal-ink` viết **một lần** là đúng cả
483
+ hai theme, không cần biến thể `dark:`.
484
+
485
+ Ba vai trò áp cho mọi họ token:
486
+
487
+ | Họ | Nền | Chữ trên nền đó | Trên nền trang |
488
+ |---|---|---|---|
489
+ | Màu phụ | `--teal` `--green` `--red` `--yellow` `--navy` | — (dùng `--ink`/`--paper` tuỳ nền) | `--teal-ink` `--green-ink` `--red-ink` `--yellow-ink` `--navy-ink` |
490
+ | Trạng thái | `--success` `--warning` `--info` `--destructive` | `--success-foreground` … `--destructive-foreground` | `--success-ink` `--warning-ink` `--info-ink` `--destructive-ink` |
491
+ | Brand | `--primary` | `--primary-foreground` | `--primary-ink` |
492
+ | Accent theo app | `--app-accent` | `--app-accent-foreground` | `--app-accent-ink` (mặc định `= --primary-ink`) |
493
+
494
+ Brand cũng cần bậc `-ink` vì đúng lý do trên: gold `#E8B824` là màu **sáng** nên tô nền thì tuyệt, còn
495
+ làm ranh giới trạng thái trên nền trang chỉ được **1,86:1** ✗. `--primary-ink` mượn `--gold-500`
496
+ `#7A5800` ở light (**6,51:1** ✓) và trỏ về chính `--primary` ở dark (**12,67:1** ✓). Đây là màu của
497
+ rãnh `Switch` khi BẬT và ô `Checkbox` đã chọn. `--ring` **cố ý vẫn là** `--primary`: vòng focus thuộc
498
+ tiêu chí AAA của WCAG 2.2, ngoài mục tiêu AA của gói.
499
+
500
+ Ba lỗi hay gặp nhất, và cách sửa:
501
+
502
+ | Viết sai | Hỏng ở đâu | Viết đúng |
503
+ |---|---|---|
504
+ | `text-red` cho dòng báo lỗi | 4,38:1 ở light ✗; ở dark thì đây là thang brand thô | `text-destructive-ink` |
505
+ | `text-success` / `text-info` / `text-warning` cho chữ hoặc icon trạng thái | Token TÔ NỀN: trên tint `/15` của chính nó chỉ 2,69:1 / 3,44:1 ✗ ở light; trên nền trang 3,13:1 / 4,14:1 ✗ | `text-success-ink` / `text-info-ink` / `text-warning-ink` (5,5–6,7:1 light · 7,1–12,4:1 dark) |
506
+ | `bg-primary` làm rãnh `Switch` khi BẬT / ô `Checkbox` đã chọn | 1,86:1 với nền trắng ✗ — trạng thái BẬT chỉ còn phân biệt bằng vị trí núm | `bg-primary-ink` (6,51:1 light / 12,67:1 dark) |
507
+ | `border-border` làm viền của control | `--border` là ranh giới **TRANG TRÍ**, 1,16:1 ✗ | `border-input` (= `--control-edge`, 3,83:1 / 4,94:1) |
508
+ | `text-app-accent` cho icon/vạch active | accent mặc định là gold `#E8B824` → 1,7–1,9:1 trên nền sidebar ✗ | `text-app-accent-ink`, hoặc nền ĐẶC + `text-app-accent-foreground` |
509
+ | `text-destructive` cho chữ trên nền trang | `--destructive` là màu **NỀN** (ghim một giá trị đậm cho chữ trắng đứng lên); dùng làm chữ ở dark chỉ 3,76:1 ✗ | `text-destructive-ink` (7,15:1 light / 6,42:1 dark) |
510
+
511
+ > ⚠ **App ghi đè `--app-accent` thì PHẢI set kèm `--app-accent-ink`** (và `--app-accent-foreground` nếu
512
+ > accent sáng). Gói cố ý **không** dẫn xuất `-ink` bằng `color-mix` từ `--app-accent`: dẫn xuất tự động
513
+ > cho ra con số tương phản không ai kiểm, và mỗi màu nền cần một hệ số khác nhau.
514
+ >
515
+ > ⚠ **Ghi đè `--primary` cũng PHẢI set kèm `--primary-ink`** — cùng một luật, cùng một lý do. Quên thì
516
+ > công tắc/checkbox vẫn đọc được nhưng còn màu gold mặc định: sai màu, không sai chuẩn.
517
+ >
518
+ > ⚠ **Đổi `--teal` / `--green` / `--red` thì phải tính lại bậc `-ink` tương ứng** — chúng là hex đã đóng
519
+ > băng, không tự đi theo. Đóng băng là chủ ý: một chỗ duy nhất để đo lại, component không phải nhắc lại
520
+ > chuỗi `color-mix(…)` (dễ chép lệch % giữa `Badge`/`LetterAvatar`/`Input`), và `tokens.ts` mirror được
521
+ > ra hex thật cho canvas/email/PDF.
522
+
523
+ Bản mirror TS đi theo đúng hợp đồng này: `accent` (nền) ⇄ `accentInk` (mực), `status.light.success` ⇄
524
+ `status.light.successInk`, `semantic.light.destructive` ⇄ `semantic.light.destructiveInk`.
525
+
526
+ ### 6.2 Bảng token
527
+
389
528
  | Nhóm | Token | Ghi chú |
390
529
  |---|---|---|
391
- | Brand | `gold-50 … gold-600` | `gold-300 #E8B824` = `--primary` (light); dark nâng lên `gold-200 #F5D060` |
530
+ | Brand | `gold-50 … gold-600` | `gold-300 #E8B824` = `--primary` (light); dark nâng lên `gold-200 #F5D060`. `--primary-ink` = `gold-500 #7A5800` (light) / `= --primary` (dark) — bậc gold cho vai trò **trên nền trang**, xem §6.1 |
392
531
  | Trung tính brand | `ink`, `ink-soft`, `paper`, `warm` | `warm #EDE8D8` = `--secondary` (light) |
393
532
  | Thang xám | `ash-50/100/300/500/700/900` | **Không phải `gray-*`** — xem cạm bẫy bên dưới |
394
- | Màu phụ | `navy`, `teal`, `red`, `green`, `yellow` | `red #D64545` = `--destructive` |
395
- | Trạng thái | `success`, `warning`, `info` (+ `-foreground`) | Alias sang `green` / `yellow` / `teal`, tự sáng lên ở dark |
396
- | Biểu đồ | `chart-1 … chart-5` | Đổi bảng màu biểu đồ độc lập với brand |
397
- | Theo app | `app-accent`, `app-accent-foreground` | Mỗi app Comitor tô một accent riêng — set ở `<body>`, không cần build lại package |
533
+ | Màu phụ (NỀN) | `navy`, `teal`, `red`, `green`, `yellow` | Vai trò **tô nền** (fill đặc hoặc tint). Bản dark đã được nâng sáng. ⚠ `red #D64545` **khác** `--destructive` `#C93B3B` — cái sau là nền nút xoá, ghim một giá trị cho cả hai theme để chữ trắng đạt 5,03:1 |
534
+ | Mực của màu phụ | `teal-ink`, `green-ink`, `red-ink`, `yellow-ink`, `navy-ink` | Cùng màu đó ở vai trò **chữ/icon/viền trên nền trang** — xem §6.1. Ở light là hex đã đóng băng (`#1A6668` / `#216A48` / `#9A3537` / `= gold-500` / `= navy`); ở dark trỏ về chính màu phụ |
535
+ | Trạng thái | `success`, `warning`, `info`, `destructive` (+ `-foreground`, + `-ink`) | Ba cái đầu alias sang `green` / `yellow` / `teal` và tự sáng lên ở dark; `destructive` ghim một giá trị cho cả hai theme. `-ink` là bậc để làm **chữ** trạng thái (`text-destructive-ink` cho dòng lỗi) |
536
+ | Ranh giới control | `control-edge` `#7E8290` | Token dành riêng cho **ranh giới của control** (rãnh `Switch` khi tắt, viền ô nhập/checkbox chưa chọn) — WCAG 1.4.11 đòi ≥3:1 cho thành phần phi văn bản. Ghim một giá trị cho cả hai theme: 3,83:1 với nền trắng, 4,94:1 với nền dark. **`--input` trỏ vào đúng token này**; `--border` thì KHÔNG — nó là ranh giới trang trí (1,16:1, cố ý) |
537
+ | Biểu đồ | `chart-1 … chart-5` | Đổi bảng màu biểu đồ độc lập với brand. `chart-3` trỏ vào `--navy-ink` (nét biểu đồ phải NỔI trên nền trang) |
538
+ | Avatar | `getAvatarToneColors(name, mode)`, `getAvatarToneIndex(name)`, type `AvatarToneColors` | Trả `{ background, foreground }` dạng **hex đục** cho canvas/email/PDF — đã trộn sẵn tint với nền `--card` vì ngoài CSS không có gì để hợp thành. Cùng bảng tone, cùng hàm băm với `getAvatarToneClasses(name)` ở `@comitor/ui`, nên một người luôn ra một màu ở mọi nơi. Khoá băm là **tên hiển thị**, không phải email/slug |
539
+ | Theo app | `app-accent`, `app-accent-foreground`, `app-accent-ink` | Mỗi app Comitor tô một accent riêng — khai ở CSS của app (có nhánh `.dark`), không cần build lại package. Ghi đè `--app-accent` thì **phải** set kèm `--app-accent-ink` (§6.1) |
398
540
  | Khung | `--sidebar-width`, `--sidebar-width-icon`, `--header-height` | Có utility `w-sidebar`, `w-sidebar-icon`, `h-header` |
399
541
  | Đổ bóng | `--elevation-surface`, `--elevation-control`, `--elevation-card`, `--elevation-overlay` | 4 bậc độ cao, sinh utility `shadow-surface` / `shadow-control` / `shadow-card` / `shadow-overlay`. Đổi theo theme (bản dark dùng đen thuần, alpha cao hơn) |
400
542
  | Khác | `--radius 0.375rem` | Bo góc 6px là chữ ký thị giác |
401
543
 
402
- Đổi accent theo app:
544
+ Đổi accent theo app — khai bằng **CSS, có nhánh dark**, không phải inline style trên `<body>`:
403
545
 
404
- ```tsx
405
- <body style={{ "--app-accent": "#1F8A8B", "--app-accent-foreground": "#FFFFFF" } as React.CSSProperties}>
546
+ ```css
547
+ /* app/globals.css — sau @import "@comitor/ui/styles.css" */
548
+ :root {
549
+ --app-accent: #1f8a8b; /* NỀN đặc */
550
+ --app-accent-foreground: #0e1116; /* chữ trên nền đó → 4,56:1 ✓ */
551
+ --app-accent-ink: #1a6668; /* màu app trên nền trang → 6,68:1 trên trắng ✓ */
552
+ }
553
+ .dark {
554
+ --app-accent: #2dd4bf;
555
+ --app-accent-foreground: #0e1116; /* 10,16:1 ✓ */
556
+ --app-accent-ink: #2dd4bf; /* 10,16:1 trên --background ✓ */
557
+ }
406
558
  ```
407
559
 
560
+ Ba biến, không phải hai: bỏ `--app-accent-ink` thì icon/vạch active của app rơi về gold mặc định.
561
+ Và phải khai ở CSS chứ không phải inline style vì **một giá trị inline không có nhánh `dark`** — accent
562
+ sáng vừa đủ cho light mode thường quá tối trên nền `#0E1116`.
563
+
564
+ > Trong `AppLauncher`, mỗi app còn tự tô ô icon của mình qua `AppDescriptor.accent` /
565
+ > `accentForeground` (inline style, đúng vai trò nền + chữ-trên-nền). Ô icon dùng **nền đặc**, nên hai
566
+ > biến đó là đủ; `--app-accent-ink` là chuyện của cả trang.
567
+
408
568
  Đổi độ nổi (bóng) toàn hệ — ghi đè đúng 4 biến `--elevation-*`, **không** ghi đè `--shadow-*`:
409
569
 
410
570
  ```css
@@ -415,16 +575,19 @@ Toàn bộ token nằm ở [`styles.css`](./styles.css); bản mirror TS ở [`s
415
575
  > Vì `--shadow-*` giữ giá trị dạng `var()`, Tailwind không tách được phần màu: `shadow-card/50` và
416
576
  > `shadow-<màu>` **không** có tác dụng. Đổi tông/độ đậm thì ghi đè `--elevation-*` như trên.
417
577
 
418
- ### Cạm bẫy phải nhớ
578
+ ### 6.3 Cạm bẫy phải nhớ
419
579
 
420
- 1. **`ash-*` chứ không phải `gray-*`.** Thang xám Comitor chỉ có 6 bậc; nếu đặt tên `gray-*` thì
580
+ 1. **Nền hay mực? Chọn sai vai trò là hỏng tương phản, không phải hỏng thẩm mỹ.** `--x` tô nền,
581
+ `--x-ink` làm chữ/icon/viền trên nền trang (§6.1). Utility `text-teal` / `text-app-accent` **vẫn được
582
+ Tailwind sinh ra** — nó sinh cả bộ cho mọi `--color-*` — nên trình biên dịch sẽ không nhắc gì cả.
583
+ 2. **`ash-*` chứ không phải `gray-*`.** Thang xám Comitor chỉ có 6 bậc; nếu đặt tên `gray-*` thì
421
584
  `gray-200/400/600/800` rơi về gray mặc định của Tailwind (ám xanh) và trộn với gray Comitor
422
585
  (ám tím) → lệch tông. Đổi namespace để hai thang không đụng nhau.
423
- 2. **Không sinh class động** (`` `bg-${color}-300` ``) ở bất kỳ đâu — Tailwind quét tĩnh, class động
586
+ 3. **Không sinh class động** (`` `bg-${color}-300` ``) ở bất kỳ đâu — Tailwind quét tĩnh, class động
424
587
  bị loại khỏi bundle của app.
425
- 3. **Không hardcode hex trong component** — trừ vector logo brand. Đọc hex từ `/tokens` rồi nhét vào
588
+ 4. **Không hardcode hex trong component** — trừ vector logo brand. Đọc hex từ `/tokens` rồi nhét vào
426
589
  style inline là mất dark mode.
427
- 4. `@theme **inline**` là chủ ý: `inline` nhúng thẳng `var(--x)` vào utility nên `.dark { --primary }`
590
+ 5. `@theme **inline**` là chủ ý: `inline` nhúng thẳng `var(--x)` vào utility nên `.dark { --primary }`
428
591
  đổi được màu tại runtime. Dùng `@theme` thường sẽ đóng băng giá trị lúc build → dark mode chết.
429
592
 
430
593
  ## 7. Phát triển
@@ -435,6 +598,7 @@ pnpm build # tsup: ESM, bundle:false (transpile từng file để giữ "
435
598
  pnpm typecheck # tsc --noEmit
436
599
  pnpm lint # biome check .
437
600
  pnpm lint:fix
601
+ pnpm lint:a11y # hợp đồng màu: vai trò token + tương phản WCAG ở CẢ hai theme
438
602
  pnpm verify:directives # mọi file nguồn có "use client" ⇄ file dist tương ứng
439
603
  pnpm check:exports # publint --strict + attw
440
604
  ```
@@ -450,9 +614,26 @@ File thuần dữ liệu (`lib/cn.ts`, `tokens.ts`, `shell/types.ts`) **không**
450
614
 
451
615
  `scripts/verify-directives.mjs` đi bộ **toàn bộ `src/`** (không riêng `shell/`): file nguồn nào mở đầu
452
616
  bằng `"use client"` thì file `dist/` tương ứng phải mở đầu y hệt — thiếu file build cũng tính là hỏng.
453
- Hiện **70/101** file nguồn cần directive, trải khắp cả composite lẫn primitive. Đừng thay script bằng
617
+ Hiện **70/102** file nguồn cần directive, trải khắp cả composite lẫn primitive (`pnpm verify:directives`
618
+ in ra đúng con số này — đừng chép số từ đây mà không chạy lại). Đừng thay script bằng
454
619
  `grep -L "use client" dist/shell/*.js`: lệnh đó soi chưa tới một phần ba số file phải soi.
455
620
 
621
+ ### Cổng hợp đồng màu — `pnpm lint:a11y`
622
+
623
+ `scripts/check-contrast.mjs` quét **toàn bộ** `src/**/*.{ts,tsx}` theo QUY TẮC (không theo danh sách
624
+ 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 *vai trò*, và
625
+ mọi cặp chữ/nền phải đạt 4,5:1 (chữ) hoặc 3:1 (thành phần phi văn bản) ở **cả** light lẫn dark. Mọi loại
626
+ phát hiện đều `exit 1` — kể cả lớp dùng bảng màu mặc định của Tailwind và miễn trừ đã thừa. Miễn trừ ghi
627
+ ở `scripts/contrast-exemptions.json`, mỗi mục bắt buộc kèm lý do bằng chữ. Hợp đồng đầy đủ + giới hạn đã
628
+ biết: [docs/hop-dong-mau.md](docs/hop-dong-mau.md).
629
+
630
+ App tiêu thụ chạy được cùng cổng này trên bảng token đã ghi đè của mình: lấy script từ repo rồi trỏ
631
+ `COMITOR_UI_STYLES` / `COMITOR_UI_SRC` sang file của app (script **không** nằm trong tarball npm).
632
+
633
+ Trước khi phát hành: bump `version`, ghi mục mới trong [CHANGELOG.md](CHANGELOG.md), rồi chạy lại **cả
634
+ khối lệnh ở đầu mục này** (`lint` và `lint:fix` là hai dạng của cùng một bước) — quy trình đầy đủ ở
635
+ [CLAUDE.md](CLAUDE.md) §Publish.
636
+
456
637
  ## 8. Giấy phép
457
638
 
458
639
  [MIT](./LICENSE) © Comitor
@@ -35,7 +35,7 @@ function AvatarGroup({
35
35
  "button",
36
36
  {
37
37
  type: "button",
38
- className: "rounded-full ring-2 ring-background transition-transform hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
38
+ className: "rounded-full ring-2 ring-background transition-transform hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
39
39
  children: /* @__PURE__ */ jsx(LetterAvatar, { name: item.name, src: item.src, size, title: void 0 })
40
40
  }
41
41
  ) }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/avatar-group.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"../ui/tooltip.js\";\nimport { LetterAvatar, type LetterAvatarSize } from \"./letter-avatar.js\";\n\nexport interface AvatarGroupItem {\n id: string;\n name: string;\n src?: string | null;\n /** Dòng phụ trong tooltip (vd email, vai trò) */\n caption?: string;\n}\n\nexport interface AvatarGroupProps extends Omit<ComponentProps<\"div\">, \"children\"> {\n items: AvatarGroupItem[];\n /** Số avatar hiện tối đa; phần dư gộp vào ô \"+k\" (mặc định 4) */\n max?: number;\n size?: LetterAvatarSize;\n /** Bấm vào ô \"+k\" — vd mở danh sách đầy đủ */\n onOverflowClick?: () => void;\n /** Nhãn của cả nhóm cho trình đọc màn hình (mặc định \"Thành viên\") */\n label?: string;\n}\n\nconst overflowSizeClasses: Record<LetterAvatarSize, string> = {\n xs: \"size-6 text-[10px]\",\n sm: \"size-7 text-xs\",\n md: \"size-9 text-xs\",\n lg: \"size-11 text-sm\",\n xl: \"size-14 text-base\"\n};\n\n/**\n * Chồng avatar + ô \"+k\" — dùng cho người thực hiện (Tasks), người tham gia (Chat), người theo dõi (CRM).\n *\n * Tooltip hiện tên đầy đủ vì avatar chồng nhau thì initials bị che một phần; không có tooltip\n * thì nhóm avatar chỉ là trang trí, người dùng không biết ai đang ở trong việc.\n *\n * Mỗi avatar là một nút focusable (Tab tới được) và mang tên thành viên cho trình đọc màn hình\n * — xem chú thích ở trigger bên dưới.\n */\nexport function AvatarGroup({\n items,\n max = 4,\n size = \"sm\",\n onOverflowClick,\n label = \"Thành viên\",\n className,\n ...props\n}: AvatarGroupProps) {\n const visible = items.slice(0, Math.max(0, max));\n const overflow = items.length - visible.length;\n\n return (\n <TooltipProvider delayDuration={200}>\n <div\n data-slot=\"avatar-group\"\n role=\"group\"\n aria-label={label}\n className={cn(\"flex items-center -space-x-2\", className)}\n {...props}\n >\n {visible.map((item) => (\n <Tooltip key={item.id}>\n <TooltipTrigger asChild>\n {/* Trigger phải FOCUSABLE: `<span>` thường chỉ nhận sự kiện chuột nên tooltip\n (và cả tên thành viên) biến mất với người dùng bàn phím. Nút không có\n onClick là chủ ý — nó chỉ mở tooltip; tên accessible lấy từ `sr-only`\n bên trong LetterAvatar nên không cần aria-label lặp lại. */}\n <button\n type=\"button\"\n className=\"rounded-full ring-2 ring-background transition-transform hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n >\n <LetterAvatar name={item.name} src={item.src} size={size} title={undefined} />\n </button>\n </TooltipTrigger>\n <TooltipContent>\n <span className=\"font-medium\">{item.name}</span>\n {item.caption && <span className=\"block text-xs opacity-80\">{item.caption}</span>}\n </TooltipContent>\n </Tooltip>\n ))}\n\n {overflow > 0 &&\n (onOverflowClick ? (\n <button\n type=\"button\"\n onClick={onOverflowClick}\n className={cn(\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2 ring-background transition-colors hover:bg-accent hover:text-accent-foreground\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n <span className=\"sr-only\">Xem tất cả {items.length} thành viên</span>\n </button>\n ) : (\n <span\n className={cn(\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2 ring-background\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n </span>\n ))}\n </div>\n </TooltipProvider>\n );\n}\n"],"mappings":";AA2EgB,cAGJ,YAHI;AAxEhB,SAAS,UAAU;AACnB,SAAS,SAAS,gBAAgB,iBAAiB,sBAAsB;AACzE,SAAS,oBAA2C;AAqBpD,MAAM,sBAAwD;AAAA,EAC5D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAWO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,UAAU,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,GAAG,CAAC;AAC/C,QAAM,WAAW,MAAM,SAAS,QAAQ;AAExC,SACE,oBAAC,mBAAgB,eAAe,KAC9B;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,MAAK;AAAA,MACL,cAAY;AAAA,MACZ,WAAW,GAAG,gCAAgC,SAAS;AAAA,MACtD,GAAG;AAAA,MAEH;AAAA,gBAAQ,IAAI,CAAC,SACZ,qBAAC,WACC;AAAA,8BAAC,kBAAe,SAAO,MAKrB;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,WAAU;AAAA,cAEV,8BAAC,gBAAa,MAAM,KAAK,MAAM,KAAK,KAAK,KAAK,MAAY,OAAO,QAAW;AAAA;AAAA,UAC9E,GACF;AAAA,UACA,qBAAC,kBACC;AAAA,gCAAC,UAAK,WAAU,eAAe,eAAK,MAAK;AAAA,YACxC,KAAK,WAAW,oBAAC,UAAK,WAAU,4BAA4B,eAAK,SAAQ;AAAA,aAC5E;AAAA,aAhBY,KAAK,EAiBnB,CACD;AAAA,QAEA,WAAW,MACT,kBACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAS;AAAA,YACT,WAAW;AAAA,cACT;AAAA,cACA,oBAAoB,IAAI;AAAA,YAC1B;AAAA,YACD;AAAA;AAAA,cACG;AAAA,cACF,qBAAC,UAAK,WAAU,WAAU;AAAA;AAAA,gBAAY,MAAM;AAAA,gBAAO;AAAA,iBAAW;AAAA;AAAA;AAAA,QAChE,IAEA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,oBAAoB,IAAI;AAAA,YAC1B;AAAA,YACD;AAAA;AAAA,cACG;AAAA;AAAA;AAAA,QACJ;AAAA;AAAA;AAAA,EAEN,GACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/avatar-group.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"../ui/tooltip.js\";\nimport { LetterAvatar, type LetterAvatarSize } from \"./letter-avatar.js\";\n\nexport interface AvatarGroupItem {\n id: string;\n name: string;\n src?: string | null;\n /** Dòng phụ trong tooltip (vd email, vai trò) */\n caption?: string;\n}\n\nexport interface AvatarGroupProps extends Omit<ComponentProps<\"div\">, \"children\"> {\n items: AvatarGroupItem[];\n /** Số avatar hiện tối đa; phần dư gộp vào ô \"+k\" (mặc định 4) */\n max?: number;\n size?: LetterAvatarSize;\n /** Bấm vào ô \"+k\" — vd mở danh sách đầy đủ */\n onOverflowClick?: () => void;\n /** Nhãn của cả nhóm cho trình đọc màn hình (mặc định \"Thành viên\") */\n label?: string;\n}\n\nconst overflowSizeClasses: Record<LetterAvatarSize, string> = {\n xs: \"size-6 text-[10px]\",\n sm: \"size-7 text-xs\",\n md: \"size-9 text-xs\",\n lg: \"size-11 text-sm\",\n xl: \"size-14 text-base\"\n};\n\n/**\n * Chồng avatar + ô \"+k\" — dùng cho người thực hiện (Tasks), người tham gia (Chat), người theo dõi (CRM).\n *\n * Tooltip hiện tên đầy đủ vì avatar chồng nhau thì initials bị che một phần; không có tooltip\n * thì nhóm avatar chỉ là trang trí, người dùng không biết ai đang ở trong việc.\n *\n * Mỗi avatar là một nút focusable (Tab tới được) và mang tên thành viên cho trình đọc màn hình\n * — xem chú thích ở trigger bên dưới.\n */\nexport function AvatarGroup({\n items,\n max = 4,\n size = \"sm\",\n onOverflowClick,\n label = \"Thành viên\",\n className,\n ...props\n}: AvatarGroupProps) {\n const visible = items.slice(0, Math.max(0, max));\n const overflow = items.length - visible.length;\n\n return (\n <TooltipProvider delayDuration={200}>\n <div\n data-slot=\"avatar-group\"\n role=\"group\"\n aria-label={label}\n className={cn(\"flex items-center -space-x-2\", className)}\n {...props}\n >\n {visible.map((item) => (\n <Tooltip key={item.id}>\n <TooltipTrigger asChild>\n {/* Trigger phải FOCUSABLE: `<span>` thường chỉ nhận sự kiện chuột nên tooltip\n (và cả tên thành viên) biến mất với người dùng bàn phím. Nút không có\n onClick là chủ ý — nó chỉ mở tooltip; tên accessible lấy từ `sr-only`\n bên trong LetterAvatar nên không cần aria-label lặp lại. */}\n {/* Viền focus dùng `foreground` chứ không `ring`: `--ring` là gold — #E8B824 chỉ\n 1,86:1 trên nền trang ở light, dưới 3:1 mà WCAG 1.4.11/2.4.11 đòi cho chỉ báo\n focus (ở dark #F5D060 mới đạt 12,67:1, tức hỏng đúng một nửa số người dùng).\n `outline-foreground` đạt 18,91:1 (light) / 17,66:1 (dark) — cùng lối với viền\n focus của dòng trong `DataTable`. */}\n <button\n type=\"button\"\n className=\"rounded-full ring-2 ring-background transition-transform hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\"\n >\n <LetterAvatar name={item.name} src={item.src} size={size} title={undefined} />\n </button>\n </TooltipTrigger>\n <TooltipContent>\n <span className=\"font-medium\">{item.name}</span>\n {/* `opacity-80` ở đây ĐẠT chuẩn và đã đo, không phải chỗ sót: Tooltip là\n `bg-foreground text-background`, nên chữ mờ 80% ra 12,15:1 (light) / 9,85:1\n (dark) — trên xa ngưỡng 4,5:1. Tooltip cũng không tương tác được. */}\n {item.caption && <span className=\"block text-xs opacity-80\">{item.caption}</span>}\n </TooltipContent>\n </Tooltip>\n ))}\n\n {overflow > 0 &&\n (onOverflowClick ? (\n <button\n type=\"button\"\n onClick={onOverflowClick}\n className={cn(\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2 ring-background transition-colors hover:bg-accent hover:text-accent-foreground\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n <span className=\"sr-only\">Xem tất cả {items.length} thành viên</span>\n </button>\n ) : (\n <span\n className={cn(\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2 ring-background\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n </span>\n ))}\n </div>\n </TooltipProvider>\n );\n}\n"],"mappings":";AAgFgB,cAGJ,YAHI;AA7EhB,SAAS,UAAU;AACnB,SAAS,SAAS,gBAAgB,iBAAiB,sBAAsB;AACzE,SAAS,oBAA2C;AAqBpD,MAAM,sBAAwD;AAAA,EAC5D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAWO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,UAAU,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,GAAG,CAAC;AAC/C,QAAM,WAAW,MAAM,SAAS,QAAQ;AAExC,SACE,oBAAC,mBAAgB,eAAe,KAC9B;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,MAAK;AAAA,MACL,cAAY;AAAA,MACZ,WAAW,GAAG,gCAAgC,SAAS;AAAA,MACtD,GAAG;AAAA,MAEH;AAAA,gBAAQ,IAAI,CAAC,SACZ,qBAAC,WACC;AAAA,8BAAC,kBAAe,SAAO,MAUrB;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,WAAU;AAAA,cAEV,8BAAC,gBAAa,MAAM,KAAK,MAAM,KAAK,KAAK,KAAK,MAAY,OAAO,QAAW;AAAA;AAAA,UAC9E,GACF;AAAA,UACA,qBAAC,kBACC;AAAA,gCAAC,UAAK,WAAU,eAAe,eAAK,MAAK;AAAA,YAIxC,KAAK,WAAW,oBAAC,UAAK,WAAU,4BAA4B,eAAK,SAAQ;AAAA,aAC5E;AAAA,aAxBY,KAAK,EAyBnB,CACD;AAAA,QAEA,WAAW,MACT,kBACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAS;AAAA,YACT,WAAW;AAAA,cACT;AAAA,cACA,oBAAoB,IAAI;AAAA,YAC1B;AAAA,YACD;AAAA;AAAA,cACG;AAAA,cACF,qBAAC,UAAK,WAAU,WAAU;AAAA;AAAA,gBAAY,MAAM;AAAA,gBAAO;AAAA,iBAAW;AAAA;AAAA;AAAA,QAChE,IAEA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA,oBAAoB,IAAI;AAAA,YAC1B;AAAA,YACD;AAAA;AAAA,cACG;AAAA;AAAA;AAAA,QACJ;AAAA;AAAA;AAAA,EAEN,GACF;AAEJ;","names":[]}
@@ -118,7 +118,7 @@ function Combobox({
118
118
  ...aria,
119
119
  children: [
120
120
  /* @__PURE__ */ jsx("span", { className: "truncate", children: selected ? selected.label : placeholder }),
121
- /* @__PURE__ */ jsx(ChevronsUpDown, { "aria-hidden": "true", className: "size-4 shrink-0 opacity-50" })
121
+ /* @__PURE__ */ jsx(ChevronsUpDown, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" })
122
122
  ]
123
123
  }
124
124
  ) }),
@@ -232,7 +232,7 @@ function MultiCombobox({
232
232
  overflow
233
233
  ] })
234
234
  ] }) }),
235
- /* @__PURE__ */ jsx(ChevronsUpDown, { "aria-hidden": "true", className: "size-4 shrink-0 opacity-50" })
235
+ /* @__PURE__ */ jsx(ChevronsUpDown, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" })
236
236
  ]
237
237
  }
238
238
  ) }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/combobox.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, ChevronsUpDown, Loader2, X } from \"lucide-react\";\nimport { type ReactNode, useMemo, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface ComboboxOption {\n value: string;\n label: string;\n /** Dòng phụ mờ dưới nhãn (vd email của người dùng, mã của khách hàng) */\n description?: string;\n icon?: IconComponent;\n disabled?: boolean;\n /** Nhãn nhóm — các mục cùng nhóm được gom lại và có tiêu đề nhóm */\n group?: string;\n /** Từ khoá phụ để tìm ra mục này (vd mã số, tên viết tắt) */\n keywords?: string[];\n}\n\ninterface ComboboxSharedProps {\n options: ComboboxOption[];\n placeholder?: string;\n searchPlaceholder?: string;\n emptyText?: string;\n disabled?: boolean;\n /**\n * Có mặt = chế độ BẤT ĐỒNG BỘ: tắt lọc nội bộ của cmdk, app tự lọc `options` (thường là gọi API).\n * Không truyền = lọc tại chỗ, không dấu tiếng Việt.\n */\n onSearchChange?: (search: string) => void;\n /** Đang tải kết quả (chỉ có nghĩa ở chế độ bất đồng bộ) */\n loading?: boolean;\n /** Cho phép xoá lựa chọn bằng nút X trên trigger */\n clearable?: boolean;\n className?: string;\n /** className của panel popover (vd đặt bề rộng cố định) */\n contentClassName?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n \"aria-required\"?: true;\n}\n\nexport interface ComboboxProps extends ComboboxSharedProps {\n /** Giá trị đang chọn; `null`/`undefined` = chưa chọn */\n value?: string | null;\n onValueChange: (value: string | null) => void;\n}\n\nexport interface MultiComboboxProps extends ComboboxSharedProps {\n value: string[];\n onValueChange: (value: string[]) => void;\n /** Số nhãn hiện trên trigger trước khi gộp thành \"+k\" (mặc định 2) */\n maxDisplay?: number;\n /** Giới hạn số lựa chọn; đạt ngưỡng thì các mục còn lại bị khoá */\n maxSelected?: number;\n}\n\n/**\n * Lọc không dấu cho cmdk: so khớp cả value, label lẫn keywords.\n *\n * cmdk mặc định chấm điểm theo thuật toán riêng có phân biệt dấu — người Việt gõ \"nguyen\"\n * sẽ không ra \"Nguyễn\". Trả 1/0 là đủ vì thứ tự hiển thị đã theo thứ tự app truyền vào.\n */\nfunction vietnameseFilter(value: string, search: string, keywords?: string[]): number {\n if (!search) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(search).toLowerCase()) ? 1 : 0;\n}\n\n/** Gom mục theo `group`, giữ nguyên thứ tự xuất hiện đầu tiên của mỗi nhóm. */\nfunction groupOptions(options: ComboboxOption[]): Array<{ label: string | undefined; items: ComboboxOption[] }> {\n const groups = new Map<string, ComboboxOption[]>();\n for (const option of options) {\n const key = option.group ?? \"\";\n const bucket = groups.get(key);\n if (bucket) bucket.push(option);\n else groups.set(key, [option]);\n }\n return [...groups.entries()].map(([label, items]) => ({ label: label || undefined, items }));\n}\n\nfunction OptionRow({ option, selected }: { option: ComboboxOption; selected: boolean }) {\n const Icon = option.icon;\n return (\n <>\n <span\n aria-hidden=\"true\"\n className={cn(\"flex size-4 shrink-0 items-center justify-center\", !selected && \"invisible\")}\n >\n <Check className=\"size-4\" />\n </span>\n {Icon && <Icon aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{option.label}</span>\n {option.description && (\n <span className=\"block truncate text-xs text-muted-foreground\">{option.description}</span>\n )}\n </span>\n </>\n );\n}\n\nfunction useComboboxList(options: ComboboxOption[], isAsync: boolean) {\n const groups = useMemo(() => groupOptions(options), [options]);\n return { groups, shouldFilter: !isAsync };\n}\n\nfunction ComboboxPanel({\n options,\n isAsync,\n loading,\n search,\n onSearch,\n searchPlaceholder,\n emptyText,\n renderItem\n}: {\n options: ComboboxOption[];\n isAsync: boolean;\n loading: boolean;\n search: string;\n onSearch: (value: string) => void;\n searchPlaceholder: string;\n emptyText: string;\n renderItem: (option: ComboboxOption) => ReactNode;\n}) {\n const { groups, shouldFilter } = useComboboxList(options, isAsync);\n\n return (\n <Command shouldFilter={shouldFilter} filter={vietnameseFilter}>\n <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={onSearch} />\n <CommandList>\n {loading ? (\n <div className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\">\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n Đang tải…\n </div>\n ) : (\n <>\n <CommandEmpty>{emptyText}</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.label ?? \"__default__\"} heading={group.label}>\n {group.items.map(renderItem)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n </Command>\n );\n}\n\n/**\n * Ô chọn một giá trị có tìm kiếm — cmdk trong Popover, rộng bằng trigger.\n *\n * Mặc định tìm KHÔNG DẤU tiếng Việt. Truyền `onSearchChange` để chuyển sang chế độ bất đồng bộ\n * (app tự gọi API và cập nhật `options`).\n */\nexport function Combobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = false,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: ComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const selected = options.find((option) => option.value === value);\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && Boolean(selected) && !disabled;\n\n return (\n /* Nút xóa là ANH EM của trigger, không nằm trong nó: <button> lồng <button> là HTML không\n hợp lệ, và nếu hạ xuống <span role=\"button\" tabIndex={-1}> thì người dùng bàn phím vĩnh\n viễn không tới được nút xóa. Đặt tuyệt đối chồng lên phần đệm phải của trigger. */\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"w-full justify-between gap-2 font-normal\",\n !selected && \"text-muted-foreground\",\n showClear && \"pr-12\",\n className\n )}\n {...aria}\n >\n <span className=\"truncate\">{selected ? selected.label : placeholder}</span>\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled}\n onSelect={() => {\n onValueChange(option.value === value ? null : option.value);\n setOpen(false);\n setSearch(\"\");\n if (isAsync) onSearchChange?.(\"\");\n }}\n >\n <OptionRow option={option} selected={option.value === value} />\n </CommandItem>\n )}\n />\n </PopoverContent>\n </Popover>\n\n {showClear && (\n <button\n type=\"button\"\n aria-label=\"Xóa lựa chọn\"\n onClick={() => onValueChange(null)}\n className=\"absolute right-8 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n )}\n </div>\n );\n}\n\n/**\n * Ô chọn NHIỀU giá trị — cùng hạ tầng với `Combobox`, popover không tự đóng sau mỗi lần chọn.\n *\n * Trigger hiện tối đa `maxDisplay` nhãn rồi gộp phần dư thành \"+k\": danh sách 12 người thực hiện\n * mà đổ hết ra trigger thì hàng form vỡ bố cục.\n */\nexport function MultiCombobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = true,\n maxDisplay = 2,\n maxSelected,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: MultiComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n\n const selectedOptions = options.filter((option) => value.includes(option.value));\n const shown = selectedOptions.slice(0, Math.max(1, maxDisplay));\n const overflow = selectedOptions.length - shown.length;\n const limitReached = maxSelected !== undefined && value.length >= maxSelected;\n\n const toggle = (optionValue: string) => {\n if (value.includes(optionValue)) {\n onValueChange(value.filter((item) => item !== optionValue));\n return;\n }\n if (limitReached) return;\n onValueChange([...value, optionValue]);\n };\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && value.length > 0 && !disabled;\n\n return (\n // Cùng lý do như Combobox đơn: nút xóa là anh em của trigger để bấm được bằng bàn phím.\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"h-auto min-h-9 w-full justify-between gap-2 py-1.5 font-normal\",\n value.length === 0 && \"text-muted-foreground\",\n showClear && \"pr-12\",\n className\n )}\n {...aria}\n >\n <span className=\"flex min-w-0 flex-wrap items-center gap-1\">\n {value.length === 0 ? (\n placeholder\n ) : (\n <>\n {shown.map((option) => (\n <Badge key={option.value} variant=\"secondary\" className=\"max-w-[10rem]\">\n <span className=\"truncate\">{option.label}</span>\n </Badge>\n ))}\n {overflow > 0 && <Badge variant=\"outline\">+{overflow}</Badge>}\n </>\n )}\n </span>\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => {\n const isSelected = value.includes(option.value);\n return (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled || (limitReached && !isSelected)}\n onSelect={() => toggle(option.value)}\n >\n <OptionRow option={option} selected={isSelected} />\n </CommandItem>\n );\n }}\n />\n {maxSelected !== undefined && (\n <p className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n Đã chọn {value.length}/{maxSelected}\n </p>\n )}\n </PopoverContent>\n </Popover>\n\n {showClear && (\n <button\n type=\"button\"\n aria-label=\"Xóa tất cả lựa chọn\"\n onClick={() => onValueChange([])}\n className=\"absolute right-8 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/combobox.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, ChevronsUpDown, Loader2, X } from \"lucide-react\";\nimport { type ReactNode, useMemo, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface ComboboxOption {\n value: string;\n label: string;\n /** Dòng phụ mờ dưới nhãn (vd email của người dùng, mã của khách hàng) */\n description?: string;\n icon?: IconComponent;\n disabled?: boolean;\n /** Nhãn nhóm — các mục cùng nhóm được gom lại và có tiêu đề nhóm */\n group?: string;\n /** Từ khoá phụ để tìm ra mục này (vd mã số, tên viết tắt) */\n keywords?: string[];\n}\n\ninterface ComboboxSharedProps {\n options: ComboboxOption[];\n placeholder?: string;\n searchPlaceholder?: string;\n emptyText?: string;\n disabled?: boolean;\n /**\n * Có mặt = chế độ BẤT ĐỒNG BỘ: tắt lọc nội bộ của cmdk, app tự lọc `options` (thường là gọi API).\n * Không truyền = lọc tại chỗ, không dấu tiếng Việt.\n */\n onSearchChange?: (search: string) => void;\n /** Đang tải kết quả (chỉ có nghĩa ở chế độ bất đồng bộ) */\n loading?: boolean;\n /** Cho phép xoá lựa chọn bằng nút X trên trigger */\n clearable?: boolean;\n className?: string;\n /** className của panel popover (vd đặt bề rộng cố định) */\n contentClassName?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n \"aria-required\"?: true;\n}\n\nexport interface ComboboxProps extends ComboboxSharedProps {\n /** Giá trị đang chọn; `null`/`undefined` = chưa chọn */\n value?: string | null;\n onValueChange: (value: string | null) => void;\n}\n\nexport interface MultiComboboxProps extends ComboboxSharedProps {\n value: string[];\n onValueChange: (value: string[]) => void;\n /** Số nhãn hiện trên trigger trước khi gộp thành \"+k\" (mặc định 2) */\n maxDisplay?: number;\n /** Giới hạn số lựa chọn; đạt ngưỡng thì các mục còn lại bị khoá */\n maxSelected?: number;\n}\n\n/**\n * Lọc không dấu cho cmdk: so khớp cả value, label lẫn keywords.\n *\n * cmdk mặc định chấm điểm theo thuật toán riêng có phân biệt dấu — người Việt gõ \"nguyen\"\n * sẽ không ra \"Nguyễn\". Trả 1/0 là đủ vì thứ tự hiển thị đã theo thứ tự app truyền vào.\n */\nfunction vietnameseFilter(value: string, search: string, keywords?: string[]): number {\n if (!search) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(search).toLowerCase()) ? 1 : 0;\n}\n\n/** Gom mục theo `group`, giữ nguyên thứ tự xuất hiện đầu tiên của mỗi nhóm. */\nfunction groupOptions(options: ComboboxOption[]): Array<{ label: string | undefined; items: ComboboxOption[] }> {\n const groups = new Map<string, ComboboxOption[]>();\n for (const option of options) {\n const key = option.group ?? \"\";\n const bucket = groups.get(key);\n if (bucket) bucket.push(option);\n else groups.set(key, [option]);\n }\n return [...groups.entries()].map(([label, items]) => ({ label: label || undefined, items }));\n}\n\nfunction OptionRow({ option, selected }: { option: ComboboxOption; selected: boolean }) {\n const Icon = option.icon;\n return (\n <>\n <span\n aria-hidden=\"true\"\n className={cn(\"flex size-4 shrink-0 items-center justify-center\", !selected && \"invisible\")}\n >\n <Check className=\"size-4\" />\n </span>\n {Icon && <Icon aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{option.label}</span>\n {option.description && (\n <span className=\"block truncate text-xs text-muted-foreground\">{option.description}</span>\n )}\n </span>\n </>\n );\n}\n\nfunction useComboboxList(options: ComboboxOption[], isAsync: boolean) {\n const groups = useMemo(() => groupOptions(options), [options]);\n return { groups, shouldFilter: !isAsync };\n}\n\nfunction ComboboxPanel({\n options,\n isAsync,\n loading,\n search,\n onSearch,\n searchPlaceholder,\n emptyText,\n renderItem\n}: {\n options: ComboboxOption[];\n isAsync: boolean;\n loading: boolean;\n search: string;\n onSearch: (value: string) => void;\n searchPlaceholder: string;\n emptyText: string;\n renderItem: (option: ComboboxOption) => ReactNode;\n}) {\n const { groups, shouldFilter } = useComboboxList(options, isAsync);\n\n return (\n <Command shouldFilter={shouldFilter} filter={vietnameseFilter}>\n <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={onSearch} />\n <CommandList>\n {loading ? (\n <div className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\">\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n Đang tải…\n </div>\n ) : (\n <>\n <CommandEmpty>{emptyText}</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.label ?? \"__default__\"} heading={group.label}>\n {group.items.map(renderItem)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n </Command>\n );\n}\n\n/**\n * Ô chọn một giá trị có tìm kiếm — cmdk trong Popover, rộng bằng trigger.\n *\n * Mặc định tìm KHÔNG DẤU tiếng Việt. Truyền `onSearchChange` để chuyển sang chế độ bất đồng bộ\n * (app tự gọi API và cập nhật `options`).\n */\nexport function Combobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = false,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: ComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const selected = options.find((option) => option.value === value);\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && Boolean(selected) && !disabled;\n\n return (\n /* Nút xóa là ANH EM của trigger, không nằm trong nó: <button> lồng <button> là HTML không\n hợp lệ, và nếu hạ xuống <span role=\"button\" tabIndex={-1}> thì người dùng bàn phím vĩnh\n viễn không tới được nút xóa. Đặt tuyệt đối chồng lên phần đệm phải của trigger. */\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"w-full justify-between gap-2 font-normal\",\n !selected && \"text-muted-foreground\",\n showClear && \"pr-12\",\n className\n )}\n {...aria}\n >\n <span className=\"truncate\">{selected ? selected.label : placeholder}</span>\n {/* Chevron BÁO TRẠNG THÁI (aria-expanded) trên control còn bấm được → ngưỡng 3:1\n của WCAG 1.4.11, `opacity-*` không miễn trừ. `opacity-50` chỉ được 2,03:1 (light)\n / 2,30:1 (dark) khi trigger đang ở trạng thái placeholder (kế thừa\n `text-muted-foreground`) ✗. Nêu thẳng `text-muted-foreground` cho icon: 5,23:1\n (light) / 5,70:1 (dark) ✓, và không đổi theo việc đã chọn hay chưa. */}\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled}\n onSelect={() => {\n onValueChange(option.value === value ? null : option.value);\n setOpen(false);\n setSearch(\"\");\n if (isAsync) onSearchChange?.(\"\");\n }}\n >\n <OptionRow option={option} selected={option.value === value} />\n </CommandItem>\n )}\n />\n </PopoverContent>\n </Popover>\n\n {showClear && (\n <button\n type=\"button\"\n aria-label=\"Xóa lựa chọn\"\n onClick={() => onValueChange(null)}\n className=\"absolute right-8 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n )}\n </div>\n );\n}\n\n/**\n * Ô chọn NHIỀU giá trị — cùng hạ tầng với `Combobox`, popover không tự đóng sau mỗi lần chọn.\n *\n * Trigger hiện tối đa `maxDisplay` nhãn rồi gộp phần dư thành \"+k\": danh sách 12 người thực hiện\n * mà đổ hết ra trigger thì hàng form vỡ bố cục.\n */\nexport function MultiCombobox({\n options,\n value,\n onValueChange,\n placeholder = \"Chọn…\",\n searchPlaceholder = \"Tìm kiếm…\",\n emptyText = \"Không tìm thấy kết quả.\",\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = true,\n maxDisplay = 2,\n maxSelected,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: MultiComboboxProps) {\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n\n const selectedOptions = options.filter((option) => value.includes(option.value));\n const shown = selectedOptions.slice(0, Math.max(1, maxDisplay));\n const overflow = selectedOptions.length - shown.length;\n const limitReached = maxSelected !== undefined && value.length >= maxSelected;\n\n const toggle = (optionValue: string) => {\n if (value.includes(optionValue)) {\n onValueChange(value.filter((item) => item !== optionValue));\n return;\n }\n if (limitReached) return;\n onValueChange([...value, optionValue]);\n };\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && value.length > 0 && !disabled;\n\n return (\n // Cùng lý do như Combobox đơn: nút xóa là anh em của trigger để bấm được bằng bàn phím.\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"h-auto min-h-9 w-full justify-between gap-2 py-1.5 font-normal\",\n value.length === 0 && \"text-muted-foreground\",\n showClear && \"pr-12\",\n className\n )}\n {...aria}\n >\n <span className=\"flex min-w-0 flex-wrap items-center gap-1\">\n {value.length === 0 ? (\n placeholder\n ) : (\n <>\n {shown.map((option) => (\n <Badge key={option.value} variant=\"secondary\" className=\"max-w-[10rem]\">\n <span className=\"truncate\">{option.label}</span>\n </Badge>\n ))}\n {overflow > 0 && <Badge variant=\"outline\">+{overflow}</Badge>}\n </>\n )}\n </span>\n {/* Chevron BÁO TRẠNG THÁI (aria-expanded) trên control còn bấm được → ngưỡng 3:1\n của WCAG 1.4.11, `opacity-*` không miễn trừ. `opacity-50` chỉ được 2,03:1 (light)\n / 2,30:1 (dark) khi trigger đang ở trạng thái placeholder (kế thừa\n `text-muted-foreground`) ✗. Nêu thẳng `text-muted-foreground` cho icon: 5,23:1\n (light) / 5,70:1 (dark) ✓, và không đổi theo việc đã chọn hay chưa. */}\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n renderItem={(option) => {\n const isSelected = value.includes(option.value);\n return (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled || (limitReached && !isSelected)}\n onSelect={() => toggle(option.value)}\n >\n <OptionRow option={option} selected={isSelected} />\n </CommandItem>\n );\n }}\n />\n {maxSelected !== undefined && (\n <p className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n Đã chọn {value.length}/{maxSelected}\n </p>\n )}\n </PopoverContent>\n </Popover>\n\n {showClear && (\n <button\n type=\"button\"\n aria-label=\"Xóa tất cả lựa chọn\"\n onClick={() => onValueChange([])}\n className=\"absolute right-8 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n </button>\n )}\n </div>\n );\n}\n"],"mappings":";AA8FI,mBAKI,KAGF,YARF;AA5FJ,SAAS,OAAO,gBAAgB,SAAS,SAAS;AAClD,SAAyB,SAAS,gBAAgB;AAClD,SAAS,UAAU;AACnB,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,SAAS,cAAc,cAAc,cAAc,aAAa,mBAAmB;AAC5F,SAAS,SAAS,gBAAgB,sBAAsB;AAExD,SAAS,6BAA6B;AA+DtC,SAAS,iBAAiB,OAAe,QAAgB,UAA6B;AACpF,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,WAAW,sBAAsB,GAAG,KAAK,IAAI,UAAU,KAAK,GAAG,KAAK,EAAE,EAAE,EAAE,YAAY;AAC5F,SAAO,SAAS,SAAS,sBAAsB,MAAM,EAAE,YAAY,CAAC,IAAI,IAAI;AAC9E;AAGA,SAAS,aAAa,SAA0F;AAC9G,QAAM,SAAS,oBAAI,IAA8B;AACjD,aAAW,UAAU,SAAS;AAC5B,UAAM,MAAM,OAAO,SAAS;AAC5B,UAAM,SAAS,OAAO,IAAI,GAAG;AAC7B,QAAI,OAAQ,QAAO,KAAK,MAAM;AAAA,QACzB,QAAO,IAAI,KAAK,CAAC,MAAM,CAAC;AAAA,EAC/B;AACA,SAAO,CAAC,GAAG,OAAO,QAAQ,CAAC,EAAE,IAAI,CAAC,CAAC,OAAO,KAAK,OAAO,EAAE,OAAO,SAAS,QAAW,MAAM,EAAE;AAC7F;AAEA,SAAS,UAAU,EAAE,QAAQ,SAAS,GAAkD;AACtF,QAAM,OAAO,OAAO;AACpB,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,eAAY;AAAA,QACZ,WAAW,GAAG,oDAAoD,CAAC,YAAY,WAAW;AAAA,QAE1F,8BAAC,SAAM,WAAU,UAAS;AAAA;AAAA,IAC5B;AAAA,IACC,QAAQ,oBAAC,QAAK,eAAY,QAAO,WAAU,yCAAwC;AAAA,IACpF,qBAAC,UAAK,WAAU,kBACd;AAAA,0BAAC,UAAK,WAAU,kBAAkB,iBAAO,OAAM;AAAA,MAC9C,OAAO,eACN,oBAAC,UAAK,WAAU,gDAAgD,iBAAO,aAAY;AAAA,OAEvF;AAAA,KACF;AAEJ;AAEA,SAAS,gBAAgB,SAA2B,SAAkB;AACpE,QAAM,SAAS,QAAQ,MAAM,aAAa,OAAO,GAAG,CAAC,OAAO,CAAC;AAC7D,SAAO,EAAE,QAAQ,cAAc,CAAC,QAAQ;AAC1C;AAEA,SAAS,cAAc;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GASG;AACD,QAAM,EAAE,QAAQ,aAAa,IAAI,gBAAgB,SAAS,OAAO;AAEjE,SACE,qBAAC,WAAQ,cAA4B,QAAQ,kBAC3C;AAAA,wBAAC,gBAAa,aAAa,mBAAmB,OAAO,QAAQ,eAAe,UAAU;AAAA,IACtF,oBAAC,eACE,oBACC,qBAAC,SAAI,WAAU,6EACb;AAAA,0BAAC,WAAQ,eAAY,QAAO,WAAU,uBAAsB;AAAA,MAAE;AAAA,OAEhE,IAEA,iCACE;AAAA,0BAAC,gBAAc,qBAAU;AAAA,MACxB,OAAO,IAAI,CAAC,UACX,oBAAC,gBAAgD,SAAS,MAAM,OAC7D,gBAAM,MAAM,IAAI,UAAU,KADV,MAAM,SAAS,aAElC,CACD;AAAA,OACH,GAEJ;AAAA,KACF;AAEJ;AAQO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,oBAAoB;AAAA,EACpB,YAAY;AAAA,EACZ,WAAW;AAAA,EACX;AAAA,EACA,UAAU;AAAA,EACV,YAAY;AAAA,EACZ;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,EAAE;AACvC,QAAM,UAAU,mBAAmB;AACnC,QAAM,WAAW,QAAQ,KAAK,CAAC,WAAW,OAAO,UAAU,KAAK;AAEhE,QAAM,eAAe,CAAC,SAAiB;AACrC,cAAU,IAAI;AACd,qBAAiB,IAAI;AAAA,EACvB;AAEA,QAAM,YAAY,aAAa,QAAQ,QAAQ,KAAK,CAAC;AAErD;AAAA;AAAA;AAAA;AAAA,IAIE,qBAAC,SAAI,WAAU,mBACb;AAAA,2BAAC,WAAQ,MAAY,cAAc,SACjC;AAAA,4BAAC,kBAAe,SAAO,MACrB;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,iBAAe;AAAA,YACf;AAAA,YACA;AAAA,YACA;AAAA,YACA,WAAW;AAAA,cACT;AAAA,cACA,CAAC,YAAY;AAAA,cACb,aAAa;AAAA,cACb;AAAA,YACF;AAAA,YACC,GAAG;AAAA,YAEJ;AAAA,kCAAC,UAAK,WAAU,YAAY,qBAAW,SAAS,QAAQ,aAAY;AAAA,cAMpE,oBAAC,kBAAe,eAAY,QAAO,WAAU,yCAAwC;AAAA;AAAA;AAAA,QACvF,GACF;AAAA,QACA,oBAAC,kBAAe,OAAM,SAAQ,WAAW,GAAG,yCAAyC,gBAAgB,GACnG;AAAA,UAAC;AAAA;AAAA,YACC;AAAA,YACA;AAAA,YACA;AAAA,YACA;AAAA,YACA,UAAU;AAAA,YACV;AAAA,YACA;AAAA,YACA,YAAY,CAAC,WACX;AAAA,cAAC;AAAA;AAAA,gBAEC,OAAO,OAAO;AAAA,gBACd,UAAU,CAAC,OAAO,OAAO,OAAO,eAAe,IAAI,GAAI,OAAO,YAAY,CAAC,CAAE;AAAA,gBAC7E,UAAU,OAAO;AAAA,gBACjB,UAAU,MAAM;AACd,gCAAc,OAAO,UAAU,QAAQ,OAAO,OAAO,KAAK;AAC1D,0BAAQ,KAAK;AACb,4BAAU,EAAE;AACZ,sBAAI,QAAS,kBAAiB,EAAE;AAAA,gBAClC;AAAA,gBAEA,8BAAC,aAAU,QAAgB,UAAU,OAAO,UAAU,OAAO;AAAA;AAAA,cAXxD,OAAO;AAAA,YAYd;AAAA;AAAA,QAEJ,GACF;AAAA,SACF;AAAA,MAEC,aACC;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,cAAW;AAAA,UACX,SAAS,MAAM,cAAc,IAAI;AAAA,UACjC,WAAU;AAAA,UAEV,8BAAC,KAAE,eAAY,QAAO,WAAU,YAAW;AAAA;AAAA,MAC7C;AAAA,OAEJ;AAAA;AAEJ;AAQO,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA,cAAc;AAAA,EACd,oBAAoB;AAAA,EACpB,YAAY;AAAA,EACZ,WAAW;AAAA,EACX;AAAA,EACA,UAAU;AAAA,EACV,YAAY;AAAA,EACZ,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,QAAM,CAAC,MAAM,OAAO,IAAI,SAAS,KAAK;AACtC,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,EAAE;AACvC,QAAM,UAAU,mBAAmB;AAEnC,QAAM,kBAAkB,QAAQ,OAAO,CAAC,WAAW,MAAM,SAAS,OAAO,KAAK,CAAC;AAC/E,QAAM,QAAQ,gBAAgB,MAAM,GAAG,KAAK,IAAI,GAAG,UAAU,CAAC;AAC9D,QAAM,WAAW,gBAAgB,SAAS,MAAM;AAChD,QAAM,eAAe,gBAAgB,UAAa,MAAM,UAAU;AAElE,QAAM,SAAS,CAAC,gBAAwB;AACtC,QAAI,MAAM,SAAS,WAAW,GAAG;AAC/B,oBAAc,MAAM,OAAO,CAAC,SAAS,SAAS,WAAW,CAAC;AAC1D;AAAA,IACF;AACA,QAAI,aAAc;AAClB,kBAAc,CAAC,GAAG,OAAO,WAAW,CAAC;AAAA,EACvC;AAEA,QAAM,eAAe,CAAC,SAAiB;AACrC,cAAU,IAAI;AACd,qBAAiB,IAAI;AAAA,EACvB;AAEA,QAAM,YAAY,aAAa,MAAM,SAAS,KAAK,CAAC;AAEpD;AAAA;AAAA,IAEE,qBAAC,SAAI,WAAU,mBACb;AAAA,2BAAC,WAAQ,MAAY,cAAc,SACjC;AAAA,4BAAC,kBAAe,SAAO,MACrB;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,iBAAe;AAAA,YACf;AAAA,YACA;AAAA,YACA;AAAA,YACA,WAAW;AAAA,cACT;AAAA,cACA,MAAM,WAAW,KAAK;AAAA,cACtB,aAAa;AAAA,cACb;AAAA,YACF;AAAA,YACC,GAAG;AAAA,YAEJ;AAAA,kCAAC,UAAK,WAAU,6CACb,gBAAM,WAAW,IAChB,cAEA,iCACG;AAAA,sBAAM,IAAI,CAAC,WACV,oBAAC,SAAyB,SAAQ,aAAY,WAAU,iBACtD,8BAAC,UAAK,WAAU,YAAY,iBAAO,OAAM,KAD/B,OAAO,KAEnB,CACD;AAAA,gBACA,WAAW,KAAK,qBAAC,SAAM,SAAQ,WAAU;AAAA;AAAA,kBAAE;AAAA,mBAAS;AAAA,iBACvD,GAEJ;AAAA,cAMA,oBAAC,kBAAe,eAAY,QAAO,WAAU,yCAAwC;AAAA;AAAA;AAAA,QACvF,GACF;AAAA,QACA,qBAAC,kBAAe,OAAM,SAAQ,WAAW,GAAG,yCAAyC,gBAAgB,GACnG;AAAA;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA;AAAA,cACA;AAAA,cACA,UAAU;AAAA,cACV;AAAA,cACA;AAAA,cACA,YAAY,CAAC,WAAW;AACtB,sBAAM,aAAa,MAAM,SAAS,OAAO,KAAK;AAC9C,uBACE;AAAA,kBAAC;AAAA;AAAA,oBAEC,OAAO,OAAO;AAAA,oBACd,UAAU,CAAC,OAAO,OAAO,OAAO,eAAe,IAAI,GAAI,OAAO,YAAY,CAAC,CAAE;AAAA,oBAC7E,UAAU,OAAO,YAAa,gBAAgB,CAAC;AAAA,oBAC/C,UAAU,MAAM,OAAO,OAAO,KAAK;AAAA,oBAEnC,8BAAC,aAAU,QAAgB,UAAU,YAAY;AAAA;AAAA,kBAN5C,OAAO;AAAA,gBAOd;AAAA,cAEJ;AAAA;AAAA,UACF;AAAA,UACC,gBAAgB,UACf,qBAAC,OAAE,WAAU,kEAAiE;AAAA;AAAA,YACnE,MAAM;AAAA,YAAO;AAAA,YAAE;AAAA,aAC1B;AAAA,WAEJ;AAAA,SACF;AAAA,MAEC,aACC;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,cAAW;AAAA,UACX,SAAS,MAAM,cAAc,CAAC,CAAC;AAAA,UAC/B,WAAU;AAAA,UAEV,8BAAC,KAAE,eAAY,QAAO,WAAU,YAAW;AAAA;AAAA,MAC7C;AAAA,OAEJ;AAAA;AAEJ;","names":[]}
@@ -61,7 +61,7 @@ function ConfirmDialog({
61
61
  "aria-hidden": "true",
62
62
  className: cn(
63
63
  "flex size-9 shrink-0 items-center justify-center rounded-md",
64
- variant === "destructive" ? "bg-destructive/15 text-destructive" : "bg-muted text-muted-foreground"
64
+ variant === "destructive" ? "bg-destructive/15 text-destructive-ink" : "bg-muted text-muted-foreground"
65
65
  ),
66
66
  children: /* @__PURE__ */ jsx(Icon, { className: "size-5" })
67
67
  }