@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.
- package/README.md +222 -41
- package/dist/components/composite/avatar-group.js +1 -1
- package/dist/components/composite/avatar-group.js.map +1 -1
- package/dist/components/composite/combobox.js +2 -2
- package/dist/components/composite/combobox.js.map +1 -1
- package/dist/components/composite/confirm-dialog.js +1 -1
- package/dist/components/composite/confirm-dialog.js.map +1 -1
- package/dist/components/composite/copy-button.js +1 -1
- package/dist/components/composite/copy-button.js.map +1 -1
- package/dist/components/composite/data-table.d.ts +17 -8
- package/dist/components/composite/data-table.js +8 -8
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/filter-chips.js +6 -5
- package/dist/components/composite/filter-chips.js.map +1 -1
- package/dist/components/composite/form-field.js +3 -1
- package/dist/components/composite/form-field.js.map +1 -1
- package/dist/components/composite/form-section.js +5 -5
- package/dist/components/composite/form-section.js.map +1 -1
- package/dist/components/composite/inline-edit.js +1 -1
- package/dist/components/composite/inline-edit.js.map +1 -1
- package/dist/components/composite/keyboard-hint.d.ts +16 -0
- package/dist/components/composite/keyboard-hint.js +8 -6
- package/dist/components/composite/keyboard-hint.js.map +1 -1
- package/dist/components/composite/letter-avatar.d.ts +10 -18
- package/dist/components/composite/letter-avatar.js +6 -17
- package/dist/components/composite/letter-avatar.js.map +1 -1
- package/dist/components/composite/search-filter-bar.d.ts +41 -3
- package/dist/components/composite/search-filter-bar.js +27 -15
- package/dist/components/composite/search-filter-bar.js.map +1 -1
- package/dist/components/composite/stat-card.d.ts +16 -1
- package/dist/components/composite/stat-card.js +16 -9
- package/dist/components/composite/stat-card.js.map +1 -1
- package/dist/components/composite/status-config.d.ts +69 -16
- package/dist/components/composite/status-config.js +10 -10
- package/dist/components/composite/status-config.js.map +1 -1
- package/dist/components/composite/status-pill.js +5 -1
- package/dist/components/composite/status-pill.js.map +1 -1
- package/dist/components/composite/table-pagination.d.ts +46 -4
- package/dist/components/composite/table-pagination.js +27 -24
- package/dist/components/composite/table-pagination.js.map +1 -1
- package/dist/components/composite/text-utils.d.ts +12 -11
- package/dist/components/composite/text-utils.js +7 -16
- package/dist/components/composite/text-utils.js.map +1 -1
- package/dist/components/composite/toast.js +16 -6
- package/dist/components/composite/toast.js.map +1 -1
- package/dist/components/ui/alert.js +1 -1
- package/dist/components/ui/alert.js.map +1 -1
- package/dist/components/ui/badge.js +4 -4
- package/dist/components/ui/badge.js.map +1 -1
- package/dist/components/ui/button.js +2 -2
- package/dist/components/ui/button.js.map +1 -1
- package/dist/components/ui/calendar.d.ts +41 -0
- package/dist/components/ui/calendar.js +1 -1
- package/dist/components/ui/calendar.js.map +1 -1
- package/dist/components/ui/checkbox.js +3 -3
- package/dist/components/ui/checkbox.js.map +1 -1
- package/dist/components/ui/command.js +1 -1
- package/dist/components/ui/command.js.map +1 -1
- package/dist/components/ui/context-menu.js +1 -1
- package/dist/components/ui/context-menu.js.map +1 -1
- package/dist/components/ui/drawer.js +1 -1
- package/dist/components/ui/drawer.js.map +1 -1
- package/dist/components/ui/dropdown-menu.js +1 -1
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/empty.js +1 -1
- package/dist/components/ui/empty.js.map +1 -1
- package/dist/components/ui/field.js +4 -4
- package/dist/components/ui/field.js.map +1 -1
- package/dist/components/ui/form.js +8 -2
- package/dist/components/ui/form.js.map +1 -1
- package/dist/components/ui/input-group.js +3 -3
- package/dist/components/ui/input-group.js.map +1 -1
- package/dist/components/ui/input-otp.js +1 -1
- package/dist/components/ui/input-otp.js.map +1 -1
- package/dist/components/ui/input.js +3 -3
- package/dist/components/ui/input.js.map +1 -1
- package/dist/components/ui/item.js +1 -1
- package/dist/components/ui/item.js.map +1 -1
- package/dist/components/ui/menubar.js +1 -1
- package/dist/components/ui/menubar.js.map +1 -1
- package/dist/components/ui/navigation-menu.js +1 -1
- package/dist/components/ui/navigation-menu.js.map +1 -1
- package/dist/components/ui/pagination.js +1 -1
- package/dist/components/ui/pagination.js.map +1 -1
- package/dist/components/ui/progress.d.ts +26 -0
- package/dist/components/ui/progress.js +5 -2
- package/dist/components/ui/progress.js.map +1 -1
- package/dist/components/ui/radio-group.js +2 -2
- package/dist/components/ui/radio-group.js.map +1 -1
- package/dist/components/ui/resizable.d.ts +14 -0
- package/dist/components/ui/resizable.js +2 -2
- package/dist/components/ui/resizable.js.map +1 -1
- package/dist/components/ui/scroll-area.js +1 -1
- package/dist/components/ui/scroll-area.js.map +1 -1
- package/dist/components/ui/select.d.ts +33 -0
- package/dist/components/ui/select.js +3 -3
- package/dist/components/ui/select.js.map +1 -1
- package/dist/components/ui/slider.d.ts +19 -0
- package/dist/components/ui/slider.js +3 -3
- package/dist/components/ui/slider.js.map +1 -1
- package/dist/components/ui/sonner.d.ts +21 -3
- package/dist/components/ui/sonner.js +1 -2
- package/dist/components/ui/sonner.js.map +1 -1
- package/dist/components/ui/switch.js +2 -2
- package/dist/components/ui/switch.js.map +1 -1
- package/dist/components/ui/table.d.ts +16 -0
- package/dist/components/ui/table.js +1 -1
- package/dist/components/ui/table.js.map +1 -1
- package/dist/components/ui/tabs.js +1 -1
- package/dist/components/ui/tabs.js.map +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/textarea.js.map +1 -1
- package/dist/components/ui/toggle.js +1 -1
- package/dist/components/ui/toggle.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +17 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/avatar-tone.d.ts +126 -0
- package/dist/lib/avatar-tone.js +68 -0
- package/dist/lib/avatar-tone.js.map +1 -0
- package/dist/shell/app-header.js +12 -2
- package/dist/shell/app-header.js.map +1 -1
- package/dist/shell/app-launcher.js +2 -2
- package/dist/shell/app-launcher.js.map +1 -1
- package/dist/shell/index.d.ts +3 -3
- package/dist/shell/shell-context.d.ts +1 -7
- package/dist/shell/shell-context.js +1 -19
- package/dist/shell/shell-context.js.map +1 -1
- package/dist/shell/sidebar-group.js +5 -3
- package/dist/shell/sidebar-group.js.map +1 -1
- package/dist/shell/sidebar-menu-item.js +12 -4
- package/dist/shell/sidebar-menu-item.js.map +1 -1
- package/dist/shell/types.d.ts +17 -1
- package/dist/shell/types.js.map +1 -1
- package/dist/shell/user-menu.js +3 -2
- package/dist/shell/user-menu.js.map +1 -1
- package/dist/shell/utils.d.ts +10 -22
- package/dist/shell/utils.js +3 -11
- package/dist/shell/utils.js.map +1 -1
- package/dist/shell/workspace-switcher.js +23 -8
- package/dist/shell/workspace-switcher.js.map +1 -1
- package/dist/tokens.d.ts +165 -17
- package/dist/tokens.js +122 -25
- package/dist/tokens.js.map +1 -1
- package/package.json +4 -2
- 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.
|
|
12
|
-
>
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
|
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
|
-
|
|
142
|
-
|
|
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,
|
|
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
|
-
|
|
296
|
-
const
|
|
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.
|
|
302
|
-
(chưa kể barrel) — số tên xuất ra theo entry: `.` **
|
|
303
|
-
**15** · `/tokens` **
|
|
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
|
|
335
|
-
| `
|
|
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`, `
|
|
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
|
|
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
|
-
|
|
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`
|
|
395
|
-
|
|
|
396
|
-
|
|
|
397
|
-
|
|
|
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
|
-
```
|
|
405
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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":";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,cACpE,oBAAC,kBAAe,eAAY,QAAO,WAAU,8BAA6B;AAAA;AAAA;AAAA,QAC5E,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,cACA,oBAAC,kBAAe,eAAY,QAAO,WAAU,8BAA6B;AAAA;AAAA;AAAA,QAC5E,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":[]}
|
|
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":"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","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
|
}
|