@comitor/ui 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +303 -43
- package/dist/chart/chart.d.ts +35 -3
- package/dist/chart/chart.js.map +1 -1
- package/dist/chart/comitor-chart.d.ts +16 -3
- package/dist/chart/comitor-chart.js.map +1 -1
- package/dist/chart/index.d.ts +2 -2
- package/dist/components/composite/avatar-group.js +1 -1
- package/dist/components/composite/avatar-group.js.map +1 -1
- package/dist/components/composite/combobox.d.ts +9 -2
- package/dist/components/composite/combobox.js +9 -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 +3 -3
- 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/card.d.ts +35 -2
- package/dist/components/ui/card.js +17 -3
- package/dist/components/ui/card.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/app-shell.d.ts +2 -0
- package/dist/shell/index.d.ts +3 -3
- package/dist/shell/shell-context.d.ts +29 -8
- package/dist/shell/shell-context.js +6 -20
- 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 +15 -7
- 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 +36 -24
- package/dist/shell/utils.js +55 -20
- 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 +8 -5
- package/styles.css +312 -22
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/status-config.ts"],"sourcesContent":["/**\n * Cơ chế StatusConfig generic — app khai map \"trạng thái → lớp màu TOKEN\".\n *\n * Vì sao generic thay vì liệt kê sẵn trạng thái: mỗi app Comitor có bộ trạng thái riêng\n * (Tasks: todo/doing/done; CRM: lead/qualified/won/lost; HR: nộp/duyệt/từ chối). Package\n * chỉ cấp KIỂU và bảng tone, còn nhãn tiếng Việt là chuyện của app.\n *\n * Bắt buộc dùng class token (`bg-success/15 text-success`), KHÔNG hex — hex sẽ chết ở dark mode.\n *\n * @example\n * const TASK_STATUSES: StatusConfig<TaskStatus>[] = [\n * { value: \"todo\", label: \"Cần làm\", shortLabel: \"CL\", ...STATUS_TONES.neutral },\n * { value: \"doing\", label: \"Đang làm\", shortLabel: \"ĐL\", ...STATUS_TONES.info },\n * { value: \"done\", label: \"Hoàn thành\", shortLabel: \"HT\", ...STATUS_TONES.success }\n * ];\n */\n\nexport interface StatusTone {\n /** Lớp nền tint — token, vd \"bg-success/15\" */\n bgColor: string;\n
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/status-config.ts"],"sourcesContent":["/**\n * Cơ chế StatusConfig generic — app khai map \"trạng thái → lớp màu TOKEN\".\n *\n * Vì sao generic thay vì liệt kê sẵn trạng thái: mỗi app Comitor có bộ trạng thái riêng\n * (Tasks: todo/doing/done; CRM: lead/qualified/won/lost; HR: nộp/duyệt/từ chối). Package\n * chỉ cấp KIỂU và bảng tone, còn nhãn tiếng Việt là chuyện của app.\n *\n * Bắt buộc dùng class token (`bg-success/15 text-success-ink`), KHÔNG hex — hex sẽ chết ở dark mode.\n *\n * @example\n * const TASK_STATUSES: StatusConfig<TaskStatus>[] = [\n * { value: \"todo\", label: \"Cần làm\", shortLabel: \"CL\", ...STATUS_TONES.neutral },\n * { value: \"doing\", label: \"Đang làm\", shortLabel: \"ĐL\", ...STATUS_TONES.info },\n * { value: \"done\", label: \"Hoàn thành\", shortLabel: \"HT\", ...STATUS_TONES.success }\n * ];\n */\n\nexport interface StatusTone {\n /** Lớp nền tint — token TÔ NỀN, vd \"bg-success/15\" */\n bgColor: string;\n /**\n * Lớp chữ — phải là bậc **ink** (`text-success-ink`), KHÔNG phải token tô nền (`text-success`).\n * Chữ chip đứng trên tint của chính màu đó nên đo theo AA 4,5:1; bậc tô nền không đạt ở light.\n */\n textColor: string;\n /**\n * Lớp viền (tuỳ chọn) — dùng cho biến thể `outline`. Ở biến thể đó nền trong suốt nên viền là\n * thứ DUY NHẤT khoanh vùng chip → chịu ngưỡng 3:1 của WCAG 1.4.11. Dùng cùng bậc ink với\n * `textColor` (`border-success-ink`), đừng hạ alpha xuống `/40`.\n */\n borderColor?: string;\n}\n\nexport interface StatusConfig<T extends string = string> extends StatusTone {\n value: T;\n label: string;\n /** Nhãn rút gọn cho bảng/chip hẹp (vd \"HT\" thay \"Hoàn thành\") */\n shortLabel?: string;\n /** Ưu tiên sắp xếp (nhỏ = lên trước) */\n priority?: number;\n /** Tint nền dòng bảng khi trạng thái cần gây chú ý (vd quá hạn) */\n rowClassName?: string;\n}\n\n/**\n * Bảng tone dựng sẵn theo token semantic — trải (`...STATUS_TONES.success`) vào StatusConfig.\n *\n * Mọi chuỗi ở đây là class TĨNH để Tailwind quét được từ `dist`; tuyệt đối không sinh động\n * kiểu `bg-${tone}/15` (class động bị loại khỏi bundle của app).\n *\n * ─ VÌ SAO CHỮ VÀ VIỀN ĐỀU LÀ BẬC `--x-ink`, KHÔNG PHẢI `--x` ─\n *\n * `--success/--warning/--info/--destructive` là token TÔ NỀN. Đặt chúng làm màu CHỮ thì bản light\n * hỏng ở cả ba biến thể của `StatusPill` (đo theo WCAG, nền tint đã trộn với `--card` trắng):\n *\n * text-success trên bg-success/15 (#E0F2E9) = 2,69:1 ✗ (dưới cả ngưỡng 3:1)\n * text-info trên bg-info/15 (#DDEDEE) = 3,44:1 ✗\n * text-destructive trên bg-destructive/15 = 4,06:1 ✗\n * text-success / text-info trên nền TRANG (outline, dot) = 3,13:1 / 4,14:1 ✗\n *\n * Đi qua bậc ink thì đạt ở CẢ hai theme (`--x-ink` ở `.dark` vốn trỏ về chính `--x`, nên không\n * cần biến thể `dark:` nào):\n *\n * soft (trên tint của chính nó) outline · dot (trên nền trang)\n * success 5,60:1 (L) · 7,44:1 (D) 6,52:1 (L) · 10,85:1 (D)\n * info 5,54:1 (L) · 7,12:1 (D) 6,68:1 (L) · 10,16:1 (D)\n * destructive 5,76:1 (L) · 5,71:1 (D) 7,15:1 (L) · 6,84:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) 6,51:1 (L) · 12,35:1 (D)\n * neutral 4,89:1 (L) · 4,86:1 (D) 5,23:1 (L) · 5,70:1 (D)\n * primary 5,77:1 (L) · 7,26:1 (D) 6,51:1 (L) · 12,67:1 (D)\n *\n * `warning` không còn cần cặp `text-gold-500 dark:text-warning`: `--warning-ink` ĐÚNG BẰNG\n * `--gold-500` ở light và `--yellow` ở dark — cùng một màu, ít hơn một class và một nơi để lệch.\n *\n * ─ TONE `primary` ĐI THEO `--primary`, KHÔNG PHẢI THANG `gold-*` ─\n * Trước đây tone này chôn cứng `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100`.\n * Tương phản thì ĐẠT (7,77:1 L · 11,85:1 D soft; 9,58:1 L · 15,33:1 D outline — bảng cũ ghi\n * 11,90:1 cho ô dark, đó là số của `--primary-ink` trên `--card`, chép nhầm sang), nhưng SAI VAI\n * TRÒ: thang `gold-*` không có bản dark nên phải viết hai lớp `dark:`, và app đổi `--primary` cho\n * sản phẩm của mình thì chip \"primary\" vẫn cứ vàng. `bg-primary/20` + `text-primary-ink` đi theo\n * token ở cả hai theme bằng MỘT lớp — hợp đồng §5. Alpha `/20` (không phải `/15` như các tone\n * khác) để giữ đúng độ nổi của `bg-gold-100` cũ: nền chip ⇄ nền trang 1,13:1 thay vì 1,10:1,\n * cùng mức với `warning` — vàng luôn cần thêm một nấc mới thấy được.\n *\n * ─ VIỀN: VÌ SAO BỎ HẲN ALPHA ─\n * Ở `variant=\"outline\"` nền trong suốt nên viền là ranh giới DUY NHẤT của chip → ngưỡng 3:1 của\n * WCAG 1.4.11. Không cách hạ alpha nào qua được; đo trên nền trang, cả hai cơ sở đều trượt:\n *\n * border-<tone>/40 (token NỀN) light 1,21–1,84:1 · dark 1,51–2,86:1 ✗\n * border-<tone>/70 (token NỀN) light 1,38–3,09:1 · dark 2,38–6,45:1 ✗\n * border-<tone>-ink/40 (bậc ink) light 1,88–1,99:1 · dark 2,02–2,86:1 ✗\n * border-<tone>-ink/70 (bậc ink) light 3,34–3,69:1 · dark 3,87–6,45:1 — hết biên an toàn\n *\n * (Bảng cũ trộn hai cơ sở: dải `/40` là của token NỀN, còn con số `/70 = 3,34:1` là của bậc ink.)\n * Viền full-strength bằng đúng màu chữ: 6,51–7,15:1 (light) · 6,84–12,67:1 (dark) ✓.\n */\nexport const STATUS_TONES = {\n neutral: {\n bgColor: \"bg-muted\",\n textColor: \"text-muted-foreground\",\n borderColor: \"border-muted-foreground\"\n },\n primary: { bgColor: \"bg-primary/20\", textColor: \"text-primary-ink\", borderColor: \"border-primary-ink\" },\n success: { bgColor: \"bg-success/15\", textColor: \"text-success-ink\", borderColor: \"border-success-ink\" },\n warning: { bgColor: \"bg-warning/20\", textColor: \"text-warning-ink\", borderColor: \"border-warning-ink\" },\n destructive: {\n bgColor: \"bg-destructive/15\",\n textColor: \"text-destructive-ink\",\n borderColor: \"border-destructive-ink\"\n },\n info: { bgColor: \"bg-info/15\", textColor: \"text-info-ink\", borderColor: \"border-info-ink\" }\n} as const satisfies Record<string, StatusTone>;\n\nexport type StatusToneName = keyof typeof STATUS_TONES;\n\n/** Tìm config theo value trong danh sách app cung cấp. */\nexport function getStatusConfig<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined\n): StatusConfig<T> | undefined {\n if (value == null) return undefined;\n return configs.find((config) => config.value === value);\n}\n\n/** Lớp màu (nền tint + chữ) của một trạng thái; không tìm thấy → chuỗi rỗng. */\nexport function getStatusClasses<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined\n): string {\n const config = getStatusConfig(configs, value);\n if (!config) return \"\";\n return `${config.bgColor} ${config.textColor}`;\n}\n\n/** Nhãn hiển thị của trạng thái; không tìm thấy → trả về chính `value` để không mất dữ liệu. */\nexport function getStatusLabel<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined,\n options: { short?: boolean } = {}\n): string {\n const config = getStatusConfig(configs, value);\n if (!config) return value ?? \"\";\n return options.short ? (config.shortLabel ?? config.label) : config.label;\n}\n\n/** Sắp xếp theo `priority` (thiếu priority thì xuống cuối, giữ thứ tự khai báo). */\nexport function sortByStatusPriority<T extends string>(configs: readonly StatusConfig<T>[]): StatusConfig<T>[] {\n return [...configs].sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));\n}\n"],"mappings":"AAgGO,MAAM,eAAe;AAAA,EAC1B,SAAS;AAAA,IACP,SAAS;AAAA,IACT,WAAW;AAAA,IACX,aAAa;AAAA,EACf;AAAA,EACA,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,aAAa;AAAA,IACX,SAAS;AAAA,IACT,WAAW;AAAA,IACX,aAAa;AAAA,EACf;AAAA,EACA,MAAM,EAAE,SAAS,cAAc,WAAW,iBAAiB,aAAa,kBAAkB;AAC5F;AAKO,SAAS,gBACd,SACA,OAC6B;AAC7B,MAAI,SAAS,KAAM,QAAO;AAC1B,SAAO,QAAQ,KAAK,CAAC,WAAW,OAAO,UAAU,KAAK;AACxD;AAGO,SAAS,iBACd,SACA,OACQ;AACR,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,GAAG,OAAO,OAAO,IAAI,OAAO,SAAS;AAC9C;AAGO,SAAS,eACd,SACA,OACA,UAA+B,CAAC,GACxB;AACR,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,MAAI,CAAC,OAAQ,QAAO,SAAS;AAC7B,SAAO,QAAQ,QAAS,OAAO,cAAc,OAAO,QAAS,OAAO;AACtE;AAGO,SAAS,qBAAuC,SAAwD;AAC7G,SAAO,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,GAAG,OAAO,EAAE,YAAY,OAAO,qBAAqB,EAAE,YAAY,OAAO,iBAAiB;AACtH;","names":[]}
|
|
@@ -34,7 +34,11 @@ function StatusPill({
|
|
|
34
34
|
className: cn(
|
|
35
35
|
base,
|
|
36
36
|
"border bg-transparent",
|
|
37
|
-
|
|
37
|
+
// Lui về `border-muted-foreground` chứ KHÔNG phải `border-border`: ở biến thể này nền
|
|
38
|
+
// trong suốt nên viền là thứ DUY NHẤT khoanh vùng chip → ngưỡng 3:1. `--border` là bậc
|
|
39
|
+
// TRANG TRÍ, chỉ 1,16:1 (L) · 1,17:1 (D) so với nền trang ✗. `--muted-foreground` đạt
|
|
40
|
+
// 5,23:1 (L) · 5,70:1 (D) và khớp đúng màu chữ lui bên dưới.
|
|
41
|
+
config?.borderColor ?? "border-muted-foreground",
|
|
38
42
|
config?.textColor ?? "text-muted-foreground",
|
|
39
43
|
className
|
|
40
44
|
),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/status-pill.tsx"],"sourcesContent":["import type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport type { StatusConfig } from \"./status-config.js\";\nimport { getStatusConfig } from \"./status-config.js\";\n\nexport interface StatusPillProps<T extends string = string> extends Omit<ComponentProps<\"span\">, \"children\"> {\n /** Toàn bộ bảng trạng thái của app */\n configs: readonly StatusConfig<T>[];\n /** Trạng thái cần hiển thị */\n value: T | null | undefined;\n /** Dùng shortLabel nếu có (bảng hẹp) */\n short?: boolean;\n /** \"soft\" = nền tint (mặc định), \"outline\" = chỉ viền + chữ, \"dot\" = chấm màu + chữ thường */\n variant?: StatusPillVariant;\n /** Nhãn thay thế khi `value` không có trong `configs` */\n fallbackLabel?: string;\n}\n\nexport type StatusPillVariant = \"soft\" | \"outline\" | \"dot\";\n\n/**\n * Chip trạng thái tô theo StatusConfig của app (màu lấy từ token, không hex).\n *\n * Tĩnh hoàn toàn → dùng được trong React Server Component (bảng danh sách render phía server).\n * Trạng thái lạ (dữ liệu cũ, enum mới ở backend) KHÔNG bị nuốt: vẫn hiện nhãn ở tone neutral.\n */\nexport function StatusPill<T extends string = string>({\n configs,\n value,\n short = false,\n variant = \"soft\",\n fallbackLabel,\n className,\n ...props\n}: StatusPillProps<T>) {\n const config = getStatusConfig(configs, value);\n const label = config ? (short ? (config.shortLabel ?? config.label) : config.label) : (fallbackLabel ?? value ?? \"—\");\n\n const base = \"inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-2 py-0.5 text-xs font-medium\";\n\n if (variant === \"dot\") {\n return (\n <span data-slot=\"status-pill\" className={cn(base, \"px-0 text-foreground\", className)} {...props}>\n <span\n aria-hidden=\"true\"\n className={cn(\"size-1.5 shrink-0 rounded-full\", config ? config.textColor : \"text-muted-foreground\")}\n style={{ backgroundColor: \"currentColor\" }}\n />\n {label}\n </span>\n );\n }\n\n if (variant === \"outline\") {\n return (\n <span\n data-slot=\"status-pill\"\n className={cn(\n base,\n \"border bg-transparent\",\n config?.borderColor ?? \"border-
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/status-pill.tsx"],"sourcesContent":["import type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport type { StatusConfig } from \"./status-config.js\";\nimport { getStatusConfig } from \"./status-config.js\";\n\nexport interface StatusPillProps<T extends string = string> extends Omit<ComponentProps<\"span\">, \"children\"> {\n /** Toàn bộ bảng trạng thái của app */\n configs: readonly StatusConfig<T>[];\n /** Trạng thái cần hiển thị */\n value: T | null | undefined;\n /** Dùng shortLabel nếu có (bảng hẹp) */\n short?: boolean;\n /** \"soft\" = nền tint (mặc định), \"outline\" = chỉ viền + chữ, \"dot\" = chấm màu + chữ thường */\n variant?: StatusPillVariant;\n /** Nhãn thay thế khi `value` không có trong `configs` */\n fallbackLabel?: string;\n}\n\nexport type StatusPillVariant = \"soft\" | \"outline\" | \"dot\";\n\n/**\n * Chip trạng thái tô theo StatusConfig của app (màu lấy từ token, không hex).\n *\n * Tĩnh hoàn toàn → dùng được trong React Server Component (bảng danh sách render phía server).\n * Trạng thái lạ (dữ liệu cũ, enum mới ở backend) KHÔNG bị nuốt: vẫn hiện nhãn ở tone neutral.\n */\nexport function StatusPill<T extends string = string>({\n configs,\n value,\n short = false,\n variant = \"soft\",\n fallbackLabel,\n className,\n ...props\n}: StatusPillProps<T>) {\n const config = getStatusConfig(configs, value);\n const label = config ? (short ? (config.shortLabel ?? config.label) : config.label) : (fallbackLabel ?? value ?? \"—\");\n\n const base = \"inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-2 py-0.5 text-xs font-medium\";\n\n if (variant === \"dot\") {\n return (\n <span data-slot=\"status-pill\" className={cn(base, \"px-0 text-foreground\", className)} {...props}>\n <span\n aria-hidden=\"true\"\n className={cn(\"size-1.5 shrink-0 rounded-full\", config ? config.textColor : \"text-muted-foreground\")}\n style={{ backgroundColor: \"currentColor\" }}\n />\n {label}\n </span>\n );\n }\n\n if (variant === \"outline\") {\n return (\n <span\n data-slot=\"status-pill\"\n className={cn(\n base,\n \"border bg-transparent\",\n // Lui về `border-muted-foreground` chứ KHÔNG phải `border-border`: ở biến thể này nền\n // trong suốt nên viền là thứ DUY NHẤT khoanh vùng chip → ngưỡng 3:1. `--border` là bậc\n // TRANG TRÍ, chỉ 1,16:1 (L) · 1,17:1 (D) so với nền trang ✗. `--muted-foreground` đạt\n // 5,23:1 (L) · 5,70:1 (D) và khớp đúng màu chữ lui bên dưới.\n config?.borderColor ?? \"border-muted-foreground\",\n config?.textColor ?? \"text-muted-foreground\",\n className\n )}\n {...props}\n >\n {label}\n </span>\n );\n }\n\n return (\n <span\n data-slot=\"status-pill\"\n className={cn(base, config?.bgColor ?? \"bg-muted\", config?.textColor ?? \"text-muted-foreground\", className)}\n {...props}\n >\n {label}\n </span>\n );\n}\n"],"mappings":"AA0CM,SACE,KADF;AAzCN,SAAS,UAAU;AAEnB,SAAS,uBAAuB;AAuBzB,SAAS,WAAsC;AAAA,EACpD;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,UAAU;AAAA,EACV;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,QAAM,QAAQ,SAAU,QAAS,OAAO,cAAc,OAAO,QAAS,OAAO,QAAU,iBAAiB,SAAS;AAEjH,QAAM,OAAO;AAEb,MAAI,YAAY,OAAO;AACrB,WACE,qBAAC,UAAK,aAAU,eAAc,WAAW,GAAG,MAAM,wBAAwB,SAAS,GAAI,GAAG,OACxF;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,eAAY;AAAA,UACZ,WAAW,GAAG,kCAAkC,SAAS,OAAO,YAAY,uBAAuB;AAAA,UACnG,OAAO,EAAE,iBAAiB,eAAe;AAAA;AAAA,MAC3C;AAAA,MACC;AAAA,OACH;AAAA,EAEJ;AAEA,MAAI,YAAY,WAAW;AACzB,WACE;AAAA,MAAC;AAAA;AAAA,QACC,aAAU;AAAA,QACV,WAAW;AAAA,UACT;AAAA,UACA;AAAA;AAAA;AAAA;AAAA;AAAA,UAKA,QAAQ,eAAe;AAAA,UACvB,QAAQ,aAAa;AAAA,UACrB;AAAA,QACF;AAAA,QACC,GAAG;AAAA,QAEH;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,MAAM,QAAQ,WAAW,YAAY,QAAQ,aAAa,yBAAyB,SAAS;AAAA,MACzG,GAAG;AAAA,MAEH;AAAA;AAAA,EACH;AAEJ;","names":[]}
|
|
@@ -2,6 +2,46 @@ import * as React from 'react';
|
|
|
2
2
|
|
|
3
3
|
/** Cỡ trang mặc định — chốt ở 3 mức để mọi bảng trong hệ sinh thái giống nhau. */
|
|
4
4
|
declare const DEFAULT_PAGE_SIZE_OPTIONS: readonly [20, 50, 100];
|
|
5
|
+
/**
|
|
6
|
+
* Toàn bộ chuỗi hiển thị và chuỗi a11y của `TablePagination` — cùng khuôn `DataTableLabels`.
|
|
7
|
+
*
|
|
8
|
+
* Vì sao phải có, dù component đã nhận `label`/`unitLabel` qua prop rời: 4 nhãn `sr-only` của
|
|
9
|
+
* cụm nút ("Trang đầu/trước/sau/cuối"), `aria-label` của ô chọn cỡ trang và hai dòng đếm
|
|
10
|
+
* (`range`, `pageStatus`) KHÔNG có prop nào chạm tới được, nên app tiếng Anh dùng `DataTable`
|
|
11
|
+
* (đã i18n) kèm `TablePagination` (chưa) sẽ ra một cái bảng dịch NỬA VỜI — tệ hơn 0.1.0 vốn
|
|
12
|
+
* nhất quán tiếng Việt.
|
|
13
|
+
*
|
|
14
|
+
* Khoá nào có số/đơn vị chèn vào thì là HÀM: nối chuỗi bằng dấu cộng ở phía app là cách chắc
|
|
15
|
+
* chắn sai trật tự từ với các ngôn ngữ khác.
|
|
16
|
+
*/
|
|
17
|
+
interface TablePaginationLabels {
|
|
18
|
+
/** `aria-label` của vùng phân trang (`<nav>`) — prop `label` vẫn ghi đè được riêng */
|
|
19
|
+
region: string;
|
|
20
|
+
/**
|
|
21
|
+
* Đơn vị đếm mặc định ("mục" / "items") — prop `unitLabel` vẫn ghi đè được riêng.
|
|
22
|
+
*
|
|
23
|
+
* Nằm trong `labels` vì đây là chuỗi theo NGÔN NGỮ: app tiếng Anh override cả bộ `labels`
|
|
24
|
+
* mà vẫn nhận "mục" thì đúng là cái giao diện nửa vời mà cơ chế này sinh ra để chặn. Còn
|
|
25
|
+
* prop `unitLabel` là chuỗi theo NỘI DUNG ("việc", "liên hệ") — mỗi bảng một khác, nên vẫn
|
|
26
|
+
* là prop rời và vẫn thắng `labels`, đúng cặp `label`/`labels.region` ở trên.
|
|
27
|
+
*/
|
|
28
|
+
unitLabel: string;
|
|
29
|
+
/** Chữ đứng trước ô chọn cỡ trang (ẩn ở màn hình hẹp) */
|
|
30
|
+
pageSizePrefix: string;
|
|
31
|
+
/** `aria-label` của ô chọn cỡ trang */
|
|
32
|
+
pageSizeSelect: (unitLabel: string) => string;
|
|
33
|
+
/** Dòng đếm bản ghi: "1–20 trên tổng 57 mục" */
|
|
34
|
+
range: (start: number, end: number, total: number, unitLabel: string) => string;
|
|
35
|
+
/** Dòng đếm trang: "Trang 2 / 5" */
|
|
36
|
+
pageStatus: (page: number, pageCount: number) => string;
|
|
37
|
+
/** `sr-only` của 4 nút điều hướng */
|
|
38
|
+
firstPage: string;
|
|
39
|
+
previousPage: string;
|
|
40
|
+
nextPage: string;
|
|
41
|
+
lastPage: string;
|
|
42
|
+
}
|
|
43
|
+
/** Nhãn tiếng Việt mặc định — mọi khoá đều override được qua prop `labels`. */
|
|
44
|
+
declare const DEFAULT_TABLE_PAGINATION_LABELS: TablePaginationLabels;
|
|
5
45
|
interface TablePaginationProps {
|
|
6
46
|
/** Trang hiện tại, đếm từ 1 */
|
|
7
47
|
page: number;
|
|
@@ -11,12 +51,14 @@ interface TablePaginationProps {
|
|
|
11
51
|
pageSizeOptions?: readonly number[];
|
|
12
52
|
onPageChange: (page: number) => void;
|
|
13
53
|
onPageSizeChange: (pageSize: number) => void;
|
|
14
|
-
/** Đơn vị đếm, vd "việc", "liên hệ" — mặc định "mục" */
|
|
54
|
+
/** Đơn vị đếm theo nội dung bảng, vd "việc", "liên hệ" — mặc định `labels.unitLabel` ("mục") */
|
|
15
55
|
unitLabel?: string;
|
|
16
56
|
/** Ẩn ô chọn số dòng/trang (khi cỡ trang do hệ thống quyết định) */
|
|
17
57
|
hidePageSize?: boolean;
|
|
18
|
-
/** Nhãn vùng phân trang — cần khi một trang có nhiều bảng */
|
|
58
|
+
/** Nhãn vùng phân trang — cần khi một trang có nhiều bảng; mặc định `labels.region` */
|
|
19
59
|
label?: string;
|
|
60
|
+
/** Ghi đè chuỗi hiển thị/a11y (mặc định tiếng Việt) — xem `TablePaginationLabels` */
|
|
61
|
+
labels?: Partial<TablePaginationLabels>;
|
|
20
62
|
className?: string;
|
|
21
63
|
}
|
|
22
64
|
/**
|
|
@@ -26,6 +68,6 @@ interface TablePaginationProps {
|
|
|
26
68
|
* tự tính rất dễ lệch nhau một trang ở bản ghi cuối, và người dùng sẽ thấy nút "Trang sau"
|
|
27
69
|
* còn bấm được trong khi bảng đã hết dữ liệu.
|
|
28
70
|
*/
|
|
29
|
-
declare function TablePagination({ page, pageSize, total, pageSizeOptions, onPageChange, onPageSizeChange, unitLabel, hidePageSize, label, className }: TablePaginationProps): React.JSX.Element;
|
|
71
|
+
declare function TablePagination({ page, pageSize, total, pageSizeOptions, onPageChange, onPageSizeChange, unitLabel: unitLabelProp, hidePageSize, label, labels: labelsProp, className }: TablePaginationProps): React.JSX.Element;
|
|
30
72
|
|
|
31
|
-
export { DEFAULT_PAGE_SIZE_OPTIONS, TablePagination, type TablePaginationProps };
|
|
73
|
+
export { DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_TABLE_PAGINATION_LABELS, TablePagination, type TablePaginationLabels, type TablePaginationProps };
|
|
@@ -5,6 +5,18 @@ import { cn } from "../../lib/cn.js";
|
|
|
5
5
|
import { Button } from "../ui/button.js";
|
|
6
6
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select.js";
|
|
7
7
|
const DEFAULT_PAGE_SIZE_OPTIONS = [20, 50, 100];
|
|
8
|
+
const DEFAULT_TABLE_PAGINATION_LABELS = {
|
|
9
|
+
region: "Ph\xE2n trang",
|
|
10
|
+
unitLabel: "m\u1EE5c",
|
|
11
|
+
pageSizePrefix: "Hi\u1EC3n th\u1ECB",
|
|
12
|
+
pageSizeSelect: (unitLabel) => `S\u1ED1 ${unitLabel} m\u1ED7i trang`,
|
|
13
|
+
range: (start, end, total, unitLabel) => `${start}\u2013${end} tr\xEAn t\u1ED5ng ${total} ${unitLabel}`,
|
|
14
|
+
pageStatus: (page, pageCount) => `Trang ${page} / ${pageCount}`,
|
|
15
|
+
firstPage: "Trang \u0111\u1EA7u",
|
|
16
|
+
previousPage: "Trang tr\u01B0\u1EDBc",
|
|
17
|
+
nextPage: "Trang sau",
|
|
18
|
+
lastPage: "Trang cu\u1ED1i"
|
|
19
|
+
};
|
|
8
20
|
function TablePagination({
|
|
9
21
|
page,
|
|
10
22
|
pageSize,
|
|
@@ -12,11 +24,14 @@ function TablePagination({
|
|
|
12
24
|
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
|
|
13
25
|
onPageChange,
|
|
14
26
|
onPageSizeChange,
|
|
15
|
-
unitLabel
|
|
27
|
+
unitLabel: unitLabelProp,
|
|
16
28
|
hidePageSize = false,
|
|
17
|
-
label
|
|
29
|
+
label,
|
|
30
|
+
labels: labelsProp,
|
|
18
31
|
className
|
|
19
32
|
}) {
|
|
33
|
+
const labels = { ...DEFAULT_TABLE_PAGINATION_LABELS, ...labelsProp };
|
|
34
|
+
const unitLabel = unitLabelProp ?? labels.unitLabel;
|
|
20
35
|
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
|
21
36
|
const current = Math.min(Math.max(1, page), pageCount);
|
|
22
37
|
const start = total === 0 ? 0 : (current - 1) * pageSize + 1;
|
|
@@ -26,27 +41,19 @@ function TablePagination({
|
|
|
26
41
|
return /* @__PURE__ */ jsxs(
|
|
27
42
|
"nav",
|
|
28
43
|
{
|
|
29
|
-
"aria-label": label,
|
|
44
|
+
"aria-label": label ?? labels.region,
|
|
30
45
|
"data-slot": "table-pagination",
|
|
31
46
|
className: cn("flex flex-wrap items-center justify-between gap-3 py-2 text-sm", className),
|
|
32
47
|
children: [
|
|
33
48
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-muted-foreground", children: [
|
|
34
49
|
!hidePageSize && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
35
|
-
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children:
|
|
50
|
+
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: labels.pageSizePrefix }),
|
|
36
51
|
/* @__PURE__ */ jsxs(Select, { value: String(pageSize), onValueChange: (value) => onPageSizeChange(Number(value)), children: [
|
|
37
|
-
/* @__PURE__ */ jsx(SelectTrigger, { className: "h-8 w-[4.5rem]", "aria-label":
|
|
52
|
+
/* @__PURE__ */ jsx(SelectTrigger, { className: "h-8 w-[4.5rem]", "aria-label": labels.pageSizeSelect(unitLabel), children: /* @__PURE__ */ jsx(SelectValue, {}) }),
|
|
38
53
|
/* @__PURE__ */ jsx(SelectContent, { children: pageSizeOptions.map((size) => /* @__PURE__ */ jsx(SelectItem, { value: String(size), children: size }, size)) })
|
|
39
54
|
] })
|
|
40
55
|
] }),
|
|
41
|
-
/* @__PURE__ */
|
|
42
|
-
start,
|
|
43
|
-
"\u2013",
|
|
44
|
-
end,
|
|
45
|
-
" tr\xEAn t\u1ED5ng ",
|
|
46
|
-
total,
|
|
47
|
-
" ",
|
|
48
|
-
unitLabel
|
|
49
|
-
] })
|
|
56
|
+
/* @__PURE__ */ jsx("span", { role: "status", className: "tabular-nums", children: labels.range(start, end, total, unitLabel) })
|
|
50
57
|
] }),
|
|
51
58
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
|
|
52
59
|
/* @__PURE__ */ jsxs(
|
|
@@ -59,23 +66,18 @@ function TablePagination({
|
|
|
59
66
|
className: "hidden sm:inline-flex",
|
|
60
67
|
children: [
|
|
61
68
|
/* @__PURE__ */ jsx(ChevronsLeft, { "aria-hidden": "true", className: "size-4" }),
|
|
62
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
69
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.firstPage })
|
|
63
70
|
]
|
|
64
71
|
}
|
|
65
72
|
),
|
|
66
73
|
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "icon-sm", disabled: isFirst, onClick: () => onPageChange(current - 1), children: [
|
|
67
74
|
/* @__PURE__ */ jsx(ChevronLeft, { "aria-hidden": "true", className: "size-4" }),
|
|
68
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
69
|
-
] }),
|
|
70
|
-
/* @__PURE__ */ jsxs("span", { role: "status", className: "px-2 tabular-nums text-muted-foreground", children: [
|
|
71
|
-
"Trang ",
|
|
72
|
-
current,
|
|
73
|
-
" / ",
|
|
74
|
-
pageCount
|
|
75
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.previousPage })
|
|
75
76
|
] }),
|
|
77
|
+
/* @__PURE__ */ jsx("span", { role: "status", className: "px-2 tabular-nums text-muted-foreground", children: labels.pageStatus(current, pageCount) }),
|
|
76
78
|
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "icon-sm", disabled: isLast, onClick: () => onPageChange(current + 1), children: [
|
|
77
79
|
/* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" }),
|
|
78
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
80
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.nextPage })
|
|
79
81
|
] }),
|
|
80
82
|
/* @__PURE__ */ jsxs(
|
|
81
83
|
Button,
|
|
@@ -87,7 +89,7 @@ function TablePagination({
|
|
|
87
89
|
className: "hidden sm:inline-flex",
|
|
88
90
|
children: [
|
|
89
91
|
/* @__PURE__ */ jsx(ChevronsRight, { "aria-hidden": "true", className: "size-4" }),
|
|
90
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
92
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.lastPage })
|
|
91
93
|
]
|
|
92
94
|
}
|
|
93
95
|
)
|
|
@@ -98,6 +100,7 @@ function TablePagination({
|
|
|
98
100
|
}
|
|
99
101
|
export {
|
|
100
102
|
DEFAULT_PAGE_SIZE_OPTIONS,
|
|
103
|
+
DEFAULT_TABLE_PAGINATION_LABELS,
|
|
101
104
|
TablePagination
|
|
102
105
|
};
|
|
103
106
|
//# sourceMappingURL=table-pagination.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/table-pagination.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from \"lucide-react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"../ui/select.js\";\n\n/** Cỡ trang mặc định — chốt ở 3 mức để mọi bảng trong hệ sinh thái giống nhau. */\nexport const DEFAULT_PAGE_SIZE_OPTIONS = [20, 50, 100] as const;\n\nexport interface TablePaginationProps {\n /** Trang hiện tại, đếm từ 1 */\n page: number;\n pageSize: number;\n /** Tổng số bản ghi (sau khi lọc) */\n total: number;\n pageSizeOptions?: readonly number[];\n onPageChange: (page: number) => void;\n onPageSizeChange: (pageSize: number) => void;\n /** Đơn vị đếm, vd \"việc\", \"liên hệ\" — mặc định \"mục\" */\n unitLabel?: string;\n /** Ẩn ô chọn số dòng/trang (khi cỡ trang do hệ thống quyết định) */\n hidePageSize?: boolean;\n /** Nhãn vùng phân trang — cần khi một trang có nhiều bảng */\n label?: string;\n className?: string;\n}\n\n/**\n * Thanh phân trang đi kèm `DataTable`.\n *\n * Tổng số trang tính từ `total`/`pageSize` ngay tại đây thay vì bắt app truyền vào — hai bên\n * tự tính rất dễ lệch nhau một trang ở bản ghi cuối, và người dùng sẽ thấy nút \"Trang sau\"\n * còn bấm được trong khi bảng đã hết dữ liệu.\n */\nexport function TablePagination({\n page,\n pageSize,\n total,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n onPageChange,\n onPageSizeChange,\n unitLabel
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/table-pagination.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from \"lucide-react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"../ui/select.js\";\n\n/** Cỡ trang mặc định — chốt ở 3 mức để mọi bảng trong hệ sinh thái giống nhau. */\nexport const DEFAULT_PAGE_SIZE_OPTIONS = [20, 50, 100] as const;\n\n/**\n * Toàn bộ chuỗi hiển thị và chuỗi a11y của `TablePagination` — cùng khuôn `DataTableLabels`.\n *\n * Vì sao phải có, dù component đã nhận `label`/`unitLabel` qua prop rời: 4 nhãn `sr-only` của\n * cụm nút (\"Trang đầu/trước/sau/cuối\"), `aria-label` của ô chọn cỡ trang và hai dòng đếm\n * (`range`, `pageStatus`) KHÔNG có prop nào chạm tới được, nên app tiếng Anh dùng `DataTable`\n * (đã i18n) kèm `TablePagination` (chưa) sẽ ra một cái bảng dịch NỬA VỜI — tệ hơn 0.1.0 vốn\n * nhất quán tiếng Việt.\n *\n * Khoá nào có số/đơn vị chèn vào thì là HÀM: nối chuỗi bằng dấu cộng ở phía app là cách chắc\n * chắn sai trật tự từ với các ngôn ngữ khác.\n */\nexport interface TablePaginationLabels {\n /** `aria-label` của vùng phân trang (`<nav>`) — prop `label` vẫn ghi đè được riêng */\n region: string;\n /**\n * Đơn vị đếm mặc định (\"mục\" / \"items\") — prop `unitLabel` vẫn ghi đè được riêng.\n *\n * Nằm trong `labels` vì đây là chuỗi theo NGÔN NGỮ: app tiếng Anh override cả bộ `labels`\n * mà vẫn nhận \"mục\" thì đúng là cái giao diện nửa vời mà cơ chế này sinh ra để chặn. Còn\n * prop `unitLabel` là chuỗi theo NỘI DUNG (\"việc\", \"liên hệ\") — mỗi bảng một khác, nên vẫn\n * là prop rời và vẫn thắng `labels`, đúng cặp `label`/`labels.region` ở trên.\n */\n unitLabel: string;\n /** Chữ đứng trước ô chọn cỡ trang (ẩn ở màn hình hẹp) */\n pageSizePrefix: string;\n /** `aria-label` của ô chọn cỡ trang */\n pageSizeSelect: (unitLabel: string) => string;\n /** Dòng đếm bản ghi: \"1–20 trên tổng 57 mục\" */\n range: (start: number, end: number, total: number, unitLabel: string) => string;\n /** Dòng đếm trang: \"Trang 2 / 5\" */\n pageStatus: (page: number, pageCount: number) => string;\n /** `sr-only` của 4 nút điều hướng */\n firstPage: string;\n previousPage: string;\n nextPage: string;\n lastPage: string;\n}\n\n/** Nhãn tiếng Việt mặc định — mọi khoá đều override được qua prop `labels`. */\nexport const DEFAULT_TABLE_PAGINATION_LABELS: TablePaginationLabels = {\n region: \"Phân trang\",\n unitLabel: \"mục\",\n pageSizePrefix: \"Hiển thị\",\n pageSizeSelect: (unitLabel) => `Số ${unitLabel} mỗi trang`,\n range: (start, end, total, unitLabel) => `${start}–${end} trên tổng ${total} ${unitLabel}`,\n pageStatus: (page, pageCount) => `Trang ${page} / ${pageCount}`,\n firstPage: \"Trang đầu\",\n previousPage: \"Trang trước\",\n nextPage: \"Trang sau\",\n lastPage: \"Trang cuối\"\n};\n\nexport interface TablePaginationProps {\n /** Trang hiện tại, đếm từ 1 */\n page: number;\n pageSize: number;\n /** Tổng số bản ghi (sau khi lọc) */\n total: number;\n pageSizeOptions?: readonly number[];\n onPageChange: (page: number) => void;\n onPageSizeChange: (pageSize: number) => void;\n /** Đơn vị đếm theo nội dung bảng, vd \"việc\", \"liên hệ\" — mặc định `labels.unitLabel` (\"mục\") */\n unitLabel?: string;\n /** Ẩn ô chọn số dòng/trang (khi cỡ trang do hệ thống quyết định) */\n hidePageSize?: boolean;\n /** Nhãn vùng phân trang — cần khi một trang có nhiều bảng; mặc định `labels.region` */\n label?: string;\n /** Ghi đè chuỗi hiển thị/a11y (mặc định tiếng Việt) — xem `TablePaginationLabels` */\n labels?: Partial<TablePaginationLabels>;\n className?: string;\n}\n\n/**\n * Thanh phân trang đi kèm `DataTable`.\n *\n * Tổng số trang tính từ `total`/`pageSize` ngay tại đây thay vì bắt app truyền vào — hai bên\n * tự tính rất dễ lệch nhau một trang ở bản ghi cuối, và người dùng sẽ thấy nút \"Trang sau\"\n * còn bấm được trong khi bảng đã hết dữ liệu.\n */\nexport function TablePagination({\n page,\n pageSize,\n total,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n onPageChange,\n onPageSizeChange,\n unitLabel: unitLabelProp,\n hidePageSize = false,\n label,\n labels: labelsProp,\n className\n}: TablePaginationProps) {\n const labels = { ...DEFAULT_TABLE_PAGINATION_LABELS, ...labelsProp };\n const unitLabel = unitLabelProp ?? labels.unitLabel;\n const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));\n const current = Math.min(Math.max(1, page), pageCount);\n const start = total === 0 ? 0 : (current - 1) * pageSize + 1;\n const end = Math.min(current * pageSize, total);\n const isFirst = current <= 1;\n const isLast = current >= pageCount;\n\n return (\n <nav\n aria-label={label ?? labels.region}\n data-slot=\"table-pagination\"\n className={cn(\"flex flex-wrap items-center justify-between gap-3 py-2 text-sm\", className)}\n >\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n {!hidePageSize && (\n <>\n <span className=\"hidden sm:inline\">{labels.pageSizePrefix}</span>\n <Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>\n <SelectTrigger className=\"h-8 w-[4.5rem]\" aria-label={labels.pageSizeSelect(unitLabel)}>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {size}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </>\n )}\n <span role=\"status\" className=\"tabular-nums\">\n {labels.range(start, end, total, unitLabel)}\n </span>\n </div>\n\n <div className=\"flex items-center gap-1\">\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isFirst}\n onClick={() => onPageChange(1)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.firstPage}</span>\n </Button>\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isFirst} onClick={() => onPageChange(current - 1)}>\n <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.previousPage}</span>\n </Button>\n\n <span role=\"status\" className=\"px-2 tabular-nums text-muted-foreground\">\n {labels.pageStatus(current, pageCount)}\n </span>\n\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isLast} onClick={() => onPageChange(current + 1)}>\n <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.nextPage}</span>\n </Button>\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isLast}\n onClick={() => onPageChange(pageCount)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.lastPage}</span>\n </Button>\n </div>\n </nav>\n );\n}\n"],"mappings":";AAwHU,mBACE,KACA,YAFF;AAtHV,SAAS,aAAa,cAAc,cAAc,qBAAqB;AACvE,SAAS,UAAU;AACnB,SAAS,cAAc;AACvB,SAAS,QAAQ,eAAe,YAAY,eAAe,mBAAmB;AAGvE,MAAM,4BAA4B,CAAC,IAAI,IAAI,GAAG;AA0C9C,MAAM,kCAAyD;AAAA,EACpE,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,gBAAgB;AAAA,EAChB,gBAAgB,CAAC,cAAc,WAAM,SAAS;AAAA,EAC9C,OAAO,CAAC,OAAO,KAAK,OAAO,cAAc,GAAG,KAAK,SAAI,GAAG,sBAAc,KAAK,IAAI,SAAS;AAAA,EACxF,YAAY,CAAC,MAAM,cAAc,SAAS,IAAI,MAAM,SAAS;AAAA,EAC7D,WAAW;AAAA,EACX,cAAc;AAAA,EACd,UAAU;AAAA,EACV,UAAU;AACZ;AA6BO,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,eAAe;AAAA,EACf;AAAA,EACA,QAAQ;AAAA,EACR;AACF,GAAyB;AACvB,QAAM,SAAS,EAAE,GAAG,iCAAiC,GAAG,WAAW;AACnE,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,YAAY,KAAK,IAAI,GAAG,KAAK,KAAK,QAAQ,KAAK,IAAI,GAAG,QAAQ,CAAC,CAAC;AACtE,QAAM,UAAU,KAAK,IAAI,KAAK,IAAI,GAAG,IAAI,GAAG,SAAS;AACrD,QAAM,QAAQ,UAAU,IAAI,KAAK,UAAU,KAAK,WAAW;AAC3D,QAAM,MAAM,KAAK,IAAI,UAAU,UAAU,KAAK;AAC9C,QAAM,UAAU,WAAW;AAC3B,QAAM,SAAS,WAAW;AAE1B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,SAAS,OAAO;AAAA,MAC5B,aAAU;AAAA,MACV,WAAW,GAAG,kEAAkE,SAAS;AAAA,MAEzF;AAAA,6BAAC,SAAI,WAAU,iDACZ;AAAA,WAAC,gBACA,iCACE;AAAA,gCAAC,UAAK,WAAU,oBAAoB,iBAAO,gBAAe;AAAA,YAC1D,qBAAC,UAAO,OAAO,OAAO,QAAQ,GAAG,eAAe,CAAC,UAAU,iBAAiB,OAAO,KAAK,CAAC,GACvF;AAAA,kCAAC,iBAAc,WAAU,kBAAiB,cAAY,OAAO,eAAe,SAAS,GACnF,8BAAC,eAAY,GACf;AAAA,cACA,oBAAC,iBACE,0BAAgB,IAAI,CAAC,SACpB,oBAAC,cAAsB,OAAO,OAAO,IAAI,GACtC,kBADc,IAEjB,CACD,GACH;AAAA,eACF;AAAA,aACF;AAAA,UAEF,oBAAC,UAAK,MAAK,UAAS,WAAU,gBAC3B,iBAAO,MAAM,OAAO,KAAK,OAAO,SAAS,GAC5C;AAAA,WACF;AAAA,QAEA,qBAAC,SAAI,WAAU,2BACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,UAAU;AAAA,cACV,SAAS,MAAM,aAAa,CAAC;AAAA,cAC7B,WAAU;AAAA,cAEV;AAAA,oCAAC,gBAAa,eAAY,QAAO,WAAU,UAAS;AAAA,gBACpD,oBAAC,UAAK,WAAU,WAAW,iBAAO,WAAU;AAAA;AAAA;AAAA,UAC9C;AAAA,UACA,qBAAC,UAAO,SAAQ,WAAU,MAAK,WAAU,UAAU,SAAS,SAAS,MAAM,aAAa,UAAU,CAAC,GACjG;AAAA,gCAAC,eAAY,eAAY,QAAO,WAAU,UAAS;AAAA,YACnD,oBAAC,UAAK,WAAU,WAAW,iBAAO,cAAa;AAAA,aACjD;AAAA,UAEA,oBAAC,UAAK,MAAK,UAAS,WAAU,2CAC3B,iBAAO,WAAW,SAAS,SAAS,GACvC;AAAA,UAEA,qBAAC,UAAO,SAAQ,WAAU,MAAK,WAAU,UAAU,QAAQ,SAAS,MAAM,aAAa,UAAU,CAAC,GAChG;AAAA,gCAAC,gBAAa,eAAY,QAAO,WAAU,UAAS;AAAA,YACpD,oBAAC,UAAK,WAAU,WAAW,iBAAO,UAAS;AAAA,aAC7C;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,UAAU;AAAA,cACV,SAAS,MAAM,aAAa,SAAS;AAAA,cACrC,WAAU;AAAA,cAEV;AAAA,oCAAC,iBAAc,eAAY,QAAO,WAAU,UAAS;AAAA,gBACrD,oBAAC,UAAK,WAAU,WAAW,iBAAO,UAAS;AAAA;AAAA;AAAA,UAC7C;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).
|
|
3
3
|
*
|
|
4
4
|
* Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,
|
|
5
|
-
* initials avatar
|
|
5
|
+
* initials avatar). Tách file để `cn()` giữ đúng một việc.
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
8
|
* Bỏ dấu tiếng Việt để so khớp tìm kiếm ("Nguyễn" ⇄ "nguyen").
|
|
@@ -12,8 +12,18 @@
|
|
|
12
12
|
* ra "Đức".
|
|
13
13
|
*/
|
|
14
14
|
declare function removeVietnameseTones(input: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Chuẩn hoá chuỗi để so khớp: bỏ dấu + hạ chữ thường + `trim`.
|
|
17
|
+
*
|
|
18
|
+
* Đây là phép chuẩn hoá DUY NHẤT của cả gói (tầng shell cũng gọi lại hàm này, không giữ bản
|
|
19
|
+
* riêng) — mỗi nơi tự chuẩn hoá một kiểu là cùng một từ khoá ra hai kết quả tìm kiếm khác nhau.
|
|
20
|
+
* `removeVietnameseTones` ở trên là bậc thấp hơn, chỉ bỏ dấu, dùng khi cần giữ nguyên hoa/thường
|
|
21
|
+
* (vd sinh slug).
|
|
22
|
+
*/
|
|
23
|
+
declare function normalizeVi(value: string): string;
|
|
15
24
|
/**
|
|
16
25
|
* So khớp không dấu, không phân biệt hoa thường: `needle` có nằm trong `haystack` không.
|
|
26
|
+
* Cả hai vế đi qua `normalizeVi` — cùng một luật với ô tìm kiếm của shell.
|
|
17
27
|
*/
|
|
18
28
|
declare function matchesSearch(haystack: string, needle: string): boolean;
|
|
19
29
|
/**
|
|
@@ -23,16 +33,7 @@ declare function matchesSearch(haystack: string, needle: string): boolean;
|
|
|
23
33
|
* Tên một từ → lấy 1 ký tự. Chuỗi rỗng → "?" (tránh ô avatar trống trơn).
|
|
24
34
|
*/
|
|
25
35
|
declare function getVietnameseInitials(name: string): string;
|
|
26
|
-
/**
|
|
27
|
-
* Hash chuỗi → số nguyên không âm (biến thể FNV-1a 32 bit).
|
|
28
|
-
*
|
|
29
|
-
* Mục đích duy nhất: chọn màu/tone deterministic theo tên. KHÔNG dùng cho bảo mật.
|
|
30
|
-
* Chạy trên code point (không phải byte) để tên có dấu vẫn ổn định giữa server và client.
|
|
31
|
-
*/
|
|
32
|
-
declare function hashString(input: string): number;
|
|
33
|
-
/** Chọn phần tử deterministic trong mảng theo chuỗi khoá (vd tên người → tone màu). */
|
|
34
|
-
declare function pickByString<T>(items: readonly T[], key: string, fallback: T): T;
|
|
35
36
|
/** Cắt chuỗi kèm dấu "…" — dùng cho tên workspace/task dài trong chip, tooltip. */
|
|
36
37
|
declare function truncate(input: string, maxLength: number): string;
|
|
37
38
|
|
|
38
|
-
export { getVietnameseInitials,
|
|
39
|
+
export { getVietnameseInitials, matchesSearch, normalizeVi, removeVietnameseTones, truncate };
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
function removeVietnameseTones(input) {
|
|
2
2
|
return input.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "D");
|
|
3
3
|
}
|
|
4
|
+
function normalizeVi(value) {
|
|
5
|
+
return removeVietnameseTones(value).toLowerCase().trim();
|
|
6
|
+
}
|
|
4
7
|
function matchesSearch(haystack, needle) {
|
|
5
|
-
|
|
6
|
-
|
|
8
|
+
const query = normalizeVi(needle);
|
|
9
|
+
if (!query) return true;
|
|
10
|
+
return normalizeVi(haystack).includes(query);
|
|
7
11
|
}
|
|
8
12
|
function getVietnameseInitials(name) {
|
|
9
13
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
@@ -13,27 +17,14 @@ function getVietnameseInitials(name) {
|
|
|
13
17
|
const initials = parts.length === 1 ? first.slice(0, 1) : `${first.slice(0, 1)}${last.slice(0, 1)}`;
|
|
14
18
|
return initials.toLocaleUpperCase("vi-VN");
|
|
15
19
|
}
|
|
16
|
-
function hashString(input) {
|
|
17
|
-
let hash = 2166136261;
|
|
18
|
-
for (let i = 0; i < input.length; i++) {
|
|
19
|
-
hash ^= input.charCodeAt(i);
|
|
20
|
-
hash = Math.imul(hash, 16777619);
|
|
21
|
-
}
|
|
22
|
-
return Math.abs(hash | 0);
|
|
23
|
-
}
|
|
24
|
-
function pickByString(items, key, fallback) {
|
|
25
|
-
if (items.length === 0) return fallback;
|
|
26
|
-
return items[hashString(key) % items.length] ?? fallback;
|
|
27
|
-
}
|
|
28
20
|
function truncate(input, maxLength) {
|
|
29
21
|
if (input.length <= maxLength) return input;
|
|
30
22
|
return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}\u2026`;
|
|
31
23
|
}
|
|
32
24
|
export {
|
|
33
25
|
getVietnameseInitials,
|
|
34
|
-
hashString,
|
|
35
26
|
matchesSearch,
|
|
36
|
-
|
|
27
|
+
normalizeVi,
|
|
37
28
|
removeVietnameseTones,
|
|
38
29
|
truncate
|
|
39
30
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/text-utils.ts"],"sourcesContent":["/**\n * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).\n *\n * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,\n * initials avatar
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/text-utils.ts"],"sourcesContent":["/**\n * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).\n *\n * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,\n * initials avatar). Tách file để `cn()` giữ đúng một việc.\n */\n\n/**\n * Bỏ dấu tiếng Việt để so khớp tìm kiếm (\"Nguyễn\" ⇄ \"nguyen\").\n *\n * Dùng NFD tách dấu thành ký tự tổ hợp rồi loại dải U+0300–U+036F. `đ/Đ` KHÔNG phải chữ có dấu\n * tổ hợp (là ký tự riêng trong Unicode) nên phải thay tay — thiếu bước này thì gõ \"duc\" sẽ không\n * ra \"Đức\".\n */\nexport function removeVietnameseTones(input: string): string {\n return input\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/đ/g, \"d\")\n .replace(/Đ/g, \"D\");\n}\n\n/**\n * Chuẩn hoá chuỗi để so khớp: bỏ dấu + hạ chữ thường + `trim`.\n *\n * Đây là phép chuẩn hoá DUY NHẤT của cả gói (tầng shell cũng gọi lại hàm này, không giữ bản\n * riêng) — mỗi nơi tự chuẩn hoá một kiểu là cùng một từ khoá ra hai kết quả tìm kiếm khác nhau.\n * `removeVietnameseTones` ở trên là bậc thấp hơn, chỉ bỏ dấu, dùng khi cần giữ nguyên hoa/thường\n * (vd sinh slug).\n */\nexport function normalizeVi(value: string): string {\n return removeVietnameseTones(value).toLowerCase().trim();\n}\n\n/**\n * So khớp không dấu, không phân biệt hoa thường: `needle` có nằm trong `haystack` không.\n * Cả hai vế đi qua `normalizeVi` — cùng một luật với ô tìm kiếm của shell.\n */\nexport function matchesSearch(haystack: string, needle: string): boolean {\n const query = normalizeVi(needle);\n if (!query) return true;\n return normalizeVi(haystack).includes(query);\n}\n\n/**\n * Initials kiểu Việt Nam: chữ đầu của HỌ + chữ đầu của TÊN (\"Nguyễn Đức Thành\" → \"NT\").\n *\n * Người Việt gọi nhau bằng TÊN (từ cuối), nên lấy 2 từ đầu như phương Tây (\"NĐ\") là sai thói quen.\n * Tên một từ → lấy 1 ký tự. Chuỗi rỗng → \"?\" (tránh ô avatar trống trơn).\n */\nexport function getVietnameseInitials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"?\";\n\n const first = parts[0] ?? \"\";\n const last = parts[parts.length - 1] ?? \"\";\n const initials = parts.length === 1 ? first.slice(0, 1) : `${first.slice(0, 1)}${last.slice(0, 1)}`;\n\n return initials.toLocaleUpperCase(\"vi-VN\");\n}\n\n/** Cắt chuỗi kèm dấu \"…\" — dùng cho tên workspace/task dài trong chip, tooltip. */\nexport function truncate(input: string, maxLength: number): string {\n if (input.length <= maxLength) return input;\n return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;\n}\n"],"mappings":"AAcO,SAAS,sBAAsB,OAAuB;AAC3D,SAAO,MACJ,UAAU,KAAK,EACf,QAAQ,oBAAoB,EAAE,EAC9B,QAAQ,MAAM,GAAG,EACjB,QAAQ,MAAM,GAAG;AACtB;AAUO,SAAS,YAAY,OAAuB;AACjD,SAAO,sBAAsB,KAAK,EAAE,YAAY,EAAE,KAAK;AACzD;AAMO,SAAS,cAAc,UAAkB,QAAyB;AACvE,QAAM,QAAQ,YAAY,MAAM;AAChC,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,YAAY,QAAQ,EAAE,SAAS,KAAK;AAC7C;AAQO,SAAS,sBAAsB,MAAsB;AAC1D,QAAM,QAAQ,KAAK,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAO;AACrD,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,QAAQ,MAAM,CAAC,KAAK;AAC1B,QAAM,OAAO,MAAM,MAAM,SAAS,CAAC,KAAK;AACxC,QAAM,WAAW,MAAM,WAAW,IAAI,MAAM,MAAM,GAAG,CAAC,IAAI,GAAG,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC;AAEjG,SAAO,SAAS,kBAAkB,OAAO;AAC3C;AAGO,SAAS,SAAS,OAAe,WAA2B;AACjE,MAAI,MAAM,UAAU,UAAW,QAAO;AACtC,SAAO,GAAG,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,QAAQ,CAAC;AAChE;","names":[]}
|
|
@@ -10,18 +10,28 @@ function Toaster({ theme = "system", position = "bottom-right", ...props }) {
|
|
|
10
10
|
position,
|
|
11
11
|
className: "toaster group",
|
|
12
12
|
icons: {
|
|
13
|
-
success: /* @__PURE__ */ jsx(CheckCircle2, { className: "size-4 text-success" }),
|
|
14
|
-
warning: /* @__PURE__ */ jsx(AlertTriangle, { className: "size-4 text-warning" }),
|
|
15
|
-
error: /* @__PURE__ */ jsx(XCircle, { className: "size-4 text-destructive" }),
|
|
16
|
-
info: /* @__PURE__ */ jsx(Info, { className: "size-4 text-info" })
|
|
13
|
+
success: /* @__PURE__ */ jsx(CheckCircle2, { className: "size-4 text-success-ink" }),
|
|
14
|
+
warning: /* @__PURE__ */ jsx(AlertTriangle, { className: "size-4 text-warning-ink" }),
|
|
15
|
+
error: /* @__PURE__ */ jsx(XCircle, { className: "size-4 text-destructive-ink" }),
|
|
16
|
+
info: /* @__PURE__ */ jsx(Info, { className: "size-4 text-info-ink" })
|
|
17
17
|
},
|
|
18
18
|
toastOptions: {
|
|
19
19
|
classNames: {
|
|
20
20
|
toast: "group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card",
|
|
21
21
|
title: "group-[.toast]:text-sm group-[.toast]:font-medium",
|
|
22
22
|
description: "group-[.toast]:text-sm group-[.toast]:text-muted-foreground",
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
// Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải
|
|
24
|
+
// nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):
|
|
25
|
+
// actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ
|
|
26
|
+
// lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của
|
|
27
|
+
// `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu
|
|
28
|
+
// độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.
|
|
29
|
+
// cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền
|
|
30
|
+
// popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ
|
|
31
|
+
// trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,
|
|
32
|
+
// 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).
|
|
33
|
+
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control",
|
|
34
|
+
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge"
|
|
25
35
|
}
|
|
26
36
|
},
|
|
27
37
|
...props
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/toast.tsx"],"sourcesContent":["\"use client\";\n\nimport { AlertTriangle, CheckCircle2, Info, XCircle } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { Toaster as SonnerToaster, toast as sonnerToast } from \"sonner\";\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\nexport type ToastVariant = \"default\" | \"success\" | \"warning\" | \"destructive\" | \"info\";\n\nexport interface ToastOptions {\n description?: ReactNode;\n variant?: ToastVariant;\n /** ms; mặc định theo sonner (4000). `Number.POSITIVE_INFINITY` = giữ đến khi người dùng đóng. */\n duration?: number;\n /** Nút hành động, vd { label: \"Hoàn tác\", onClick } */\n action?: { label: string; onClick: () => void };\n /** id để cập nhật/đóng đúng toast đó (dùng với luồng \"đang lưu…\" → \"đã lưu\") */\n id?: string | number;\n}\n\n/**\n * Vùng hiển thị toast — đặt MỘT LẦN ở gốc app.\n *\n * `theme` mặc định \"system\" và KHÔNG đọc `next-themes`: entry \".\" của package phải chạy được ở\n * app không có next-themes. App dùng next-themes hãy truyền xuống, nếu không toast sẽ theo\n * `prefers-color-scheme` của hệ điều hành và lệch với theme người dùng tự chọn:\n *\n * const { resolvedTheme } = useTheme();\n * <Toaster theme={resolvedTheme as \"light\" | \"dark\"} />\n */\nexport function Toaster({ theme = \"system\", position = \"bottom-right\", ...props }: ToasterProps) {\n return (\n <SonnerToaster\n theme={theme}\n position={position}\n className=\"toaster group\"\n icons={{\n success: <CheckCircle2 className=\"size-4 text-success\" />,\n warning: <AlertTriangle className=\"size-4 text-warning\" />,\n error: <XCircle className=\"size-4 text-destructive\" />,\n info: <Info className=\"size-4 text-info\" />\n }}\n toastOptions={{\n classNames: {\n toast:\n \"group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card\",\n title: \"group-[.toast]:text-sm group-[.toast]:font-medium\",\n description: \"group-[.toast]:text-sm group-[.toast]:text-muted-foreground\",\n actionButton: \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground\",\n cancelButton
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/toast.tsx"],"sourcesContent":["\"use client\";\n\nimport { AlertTriangle, CheckCircle2, Info, XCircle } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { Toaster as SonnerToaster, toast as sonnerToast } from \"sonner\";\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\nexport type ToastVariant = \"default\" | \"success\" | \"warning\" | \"destructive\" | \"info\";\n\nexport interface ToastOptions {\n description?: ReactNode;\n variant?: ToastVariant;\n /** ms; mặc định theo sonner (4000). `Number.POSITIVE_INFINITY` = giữ đến khi người dùng đóng. */\n duration?: number;\n /** Nút hành động, vd { label: \"Hoàn tác\", onClick } */\n action?: { label: string; onClick: () => void };\n /** id để cập nhật/đóng đúng toast đó (dùng với luồng \"đang lưu…\" → \"đã lưu\") */\n id?: string | number;\n}\n\n/**\n * Vùng hiển thị toast — đặt MỘT LẦN ở gốc app.\n *\n * `theme` mặc định \"system\" và KHÔNG đọc `next-themes`: entry \".\" của package phải chạy được ở\n * app không có next-themes. App dùng next-themes hãy truyền xuống, nếu không toast sẽ theo\n * `prefers-color-scheme` của hệ điều hành và lệch với theme người dùng tự chọn:\n *\n * const { resolvedTheme } = useTheme();\n * <Toaster theme={resolvedTheme as \"light\" | \"dark\"} />\n */\nexport function Toaster({ theme = \"system\", position = \"bottom-right\", ...props }: ToasterProps) {\n return (\n <SonnerToaster\n theme={theme}\n position={position}\n className=\"toaster group\"\n // Icon tô bằng bậc `--x-ink`, KHÔNG phải token tô nền `--x`. Icon là thành phần phi văn\n // bản (ngưỡng 3:1) đứng trên nền `--popover`: `text-warning` (= `--yellow` #F2C94C) trên\n // nền trắng chỉ **1,59:1** ✗ — gần như vô hình ở light (dark thì 11,60:1, lại một chỗ mỗi\n // theme hỏng một nửa); `text-success` 3,13:1 và `text-info` 4,14:1 tuy qua ngưỡng nhưng\n // sát mép. Bậc ink (light / dark): success 6,52 / 10,19 · warning 6,51 / 11,60 ·\n // destructive 7,15 / 6,42 · info 6,68 / 9,54 ✓.\n icons={{\n success: <CheckCircle2 className=\"size-4 text-success-ink\" />,\n warning: <AlertTriangle className=\"size-4 text-warning-ink\" />,\n error: <XCircle className=\"size-4 text-destructive-ink\" />,\n info: <Info className=\"size-4 text-info-ink\" />\n }}\n toastOptions={{\n classNames: {\n toast:\n \"group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card\",\n title: \"group-[.toast]:text-sm group-[.toast]:font-medium\",\n description: \"group-[.toast]:text-sm group-[.toast]:text-muted-foreground\",\n // Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải\n // nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):\n // actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ\n // lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của\n // `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu\n // độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.\n // cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền\n // popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ\n // trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,\n // 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).\n actionButton:\n \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control\",\n cancelButton:\n \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge\"\n }\n }}\n {...props}\n />\n );\n}\n\n/**\n * Bắn toast theo biến thể của Comitor.\n *\n * Vì sao bọc `sonner` thay vì để app gọi thẳng: tên biến thể ở đây khớp với ngôn ngữ của design\n * system (`destructive`, không phải `error`), và khi cần đổi thư viện toast thì chỉ sửa một file.\n */\nexport function toast(title: ReactNode, options: ToastOptions = {}): string | number {\n const { variant = \"default\", description, duration, action, id } = options;\n const config = { description, duration, action, id };\n\n switch (variant) {\n case \"success\":\n return sonnerToast.success(title, config);\n case \"warning\":\n return sonnerToast.warning(title, config);\n case \"destructive\":\n return sonnerToast.error(title, config);\n case \"info\":\n return sonnerToast.info(title, config);\n default:\n return sonnerToast(title, config);\n }\n}\n\n/** Đóng một toast theo id, hoặc đóng tất cả khi không truyền id. */\nexport function dismissToast(id?: string | number): void {\n sonnerToast.dismiss(id);\n}\n\n/**\n * Toast bám theo một Promise: \"Đang lưu…\" → \"Đã lưu\" / \"Lưu thất bại\".\n * Chuỗi mặc định là tiếng Việt, app ghi đè được từng chuỗi.\n */\nexport function toastPromise<T>(\n promise: Promise<T>,\n messages: {\n loading?: ReactNode;\n success: ReactNode | ((data: T) => ReactNode);\n error: ReactNode | ((error: unknown) => ReactNode);\n }\n) {\n return sonnerToast.promise(promise, {\n loading: messages.loading ?? \"Đang xử lý…\",\n success: messages.success,\n error: messages.error\n });\n}\n"],"mappings":";AA4CiB;AA1CjB,SAAS,eAAe,cAAc,MAAM,eAAe;AAE3D,SAAS,WAAW,eAAe,SAAS,mBAAmB;AA2BxD,SAAS,QAAQ,EAAE,QAAQ,UAAU,WAAW,gBAAgB,GAAG,MAAM,GAAiB;AAC/F,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA,WAAU;AAAA,MAOV,OAAO;AAAA,QACL,SAAS,oBAAC,gBAAa,WAAU,2BAA0B;AAAA,QAC3D,SAAS,oBAAC,iBAAc,WAAU,2BAA0B;AAAA,QAC5D,OAAO,oBAAC,WAAQ,WAAU,+BAA8B;AAAA,QACxD,MAAM,oBAAC,QAAK,WAAU,wBAAuB;AAAA,MAC/C;AAAA,MACA,cAAc;AAAA,QACZ,YAAY;AAAA,UACV,OACE;AAAA,UACF,OAAO;AAAA,UACP,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAWb,cACE;AAAA,UACF,cACE;AAAA,QACJ;AAAA,MACF;AAAA,MACC,GAAG;AAAA;AAAA,EACN;AAEJ;AAQO,SAAS,MAAM,OAAkB,UAAwB,CAAC,GAAoB;AACnF,QAAM,EAAE,UAAU,WAAW,aAAa,UAAU,QAAQ,GAAG,IAAI;AACnE,QAAM,SAAS,EAAE,aAAa,UAAU,QAAQ,GAAG;AAEnD,UAAQ,SAAS;AAAA,IACf,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,MAAM,OAAO,MAAM;AAAA,IACxC,KAAK;AACH,aAAO,YAAY,KAAK,OAAO,MAAM;AAAA,IACvC;AACE,aAAO,YAAY,OAAO,MAAM;AAAA,EACpC;AACF;AAGO,SAAS,aAAa,IAA4B;AACvD,cAAY,QAAQ,EAAE;AACxB;AAMO,SAAS,aACd,SACA,UAKA;AACA,SAAO,YAAY,QAAQ,SAAS;AAAA,IAClC,SAAS,SAAS,WAAW;AAAA,IAC7B,SAAS,SAAS;AAAA,IAClB,OAAO,SAAS;AAAA,EAClB,CAAC;AACH;","names":[]}
|
|
@@ -7,7 +7,7 @@ const alertVariants = cva(
|
|
|
7
7
|
variants: {
|
|
8
8
|
variant: {
|
|
9
9
|
default: "bg-card text-card-foreground",
|
|
10
|
-
destructive: "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90"
|
|
10
|
+
destructive: "text-destructive-ink bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-ink/90"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
defaultVariants: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/ui/alert.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\nconst alertVariants = cva(\n \"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current\",\n {\n variants: {\n variant: {\n default: \"bg-card text-card-foreground\",\n destructive
|
|
1
|
+
{"version":3,"sources":["../../../src/components/ui/alert.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * Variant `destructive` dùng bậc `--destructive-ink`, KHÔNG dùng `--destructive`.\n * `--destructive` là màu TÔ NỀN (nút xoá) và cố ý ghim một giá trị ở cả hai theme, nên làm CHỮ\n * trên `bg-card` chỉ được 3,53:1 ở dark ✗; phần mô tả còn bị hạ thêm bằng `/90` nên hỏng ở CẢ\n * hai theme (4,32:1 light · 3,08:1 dark ✗).\n * Sau khi đổi (đo trên `--card`): tiêu đề 7,15:1 (light) / 6,42:1 (dark); mô tả `/90` 5,78:1\n * (light) / 5,40:1 (dark) ✓ — bậc `/90` giữ lại vì nó là phân cấp thị giác, và ở bậc ink nó vẫn\n * còn dư biên so với AA 4,5:1.\n */\nconst alertVariants = cva(\n \"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current\",\n {\n variants: {\n variant: {\n default: \"bg-card text-card-foreground\",\n destructive:\n \"text-destructive-ink bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive-ink/90\"\n }\n },\n defaultVariants: {\n variant: \"default\"\n }\n }\n);\n\nexport interface AlertProps extends React.ComponentProps<\"div\">, VariantProps<typeof alertVariants> {}\nexport type AlertTitleProps = React.ComponentProps<\"div\">;\nexport type AlertDescriptionProps = React.ComponentProps<\"div\">;\n\nfunction Alert({ className, variant, ...props }: AlertProps) {\n return <div data-slot=\"alert\" role=\"alert\" className={cn(alertVariants({ variant }), className)} {...props} />;\n}\n\nfunction AlertTitle({ className, ...props }: AlertTitleProps) {\n return (\n <div\n data-slot=\"alert-title\"\n className={cn(\"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight\", className)}\n {...props}\n />\n );\n}\n\nfunction AlertDescription({ className, ...props }: AlertDescriptionProps) {\n return (\n <div\n data-slot=\"alert-description\"\n className={cn(\n \"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed\",\n className\n )}\n {...props}\n />\n );\n}\n\nexport { Alert, AlertDescription, AlertTitle, alertVariants };\n"],"mappings":"AAkCS;AAlCT,SAAS,WAA8B;AAEvC,SAAS,UAAU;AAWnB,MAAM,gBAAgB;AAAA,EACpB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SAAS;AAAA,QACT,aACE;AAAA,MACJ;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,IACX;AAAA,EACF;AACF;AAMA,SAAS,MAAM,EAAE,WAAW,SAAS,GAAG,MAAM,GAAe;AAC3D,SAAO,oBAAC,SAAI,aAAU,SAAQ,MAAK,SAAQ,WAAW,GAAG,cAAc,EAAE,QAAQ,CAAC,GAAG,SAAS,GAAI,GAAG,OAAO;AAC9G;AAEA,SAAS,WAAW,EAAE,WAAW,GAAG,MAAM,GAAoB;AAC5D,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,+DAA+D,SAAS;AAAA,MACrF,GAAG;AAAA;AAAA,EACN;AAEJ;AAEA,SAAS,iBAAiB,EAAE,WAAW,GAAG,MAAM,GAA0B;AACxE,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA;AAAA,EACN;AAEJ;","names":[]}
|
|
@@ -10,10 +10,10 @@ const badgeVariants = cva(
|
|
|
10
10
|
default: "bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200",
|
|
11
11
|
primary: "bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200",
|
|
12
12
|
secondary: "bg-secondary text-secondary-foreground",
|
|
13
|
-
outline: "bg-transparent border border-
|
|
14
|
-
success: "bg-success/15 text-
|
|
15
|
-
warning: "bg-warning/20 text-
|
|
16
|
-
destructive: "bg-destructive/15 text-
|
|
13
|
+
outline: "bg-transparent border border-control-edge text-foreground",
|
|
14
|
+
success: "bg-success/15 text-success-ink",
|
|
15
|
+
warning: "bg-warning/20 text-warning-ink",
|
|
16
|
+
destructive: "bg-destructive/15 text-destructive-ink"
|
|
17
17
|
},
|
|
18
18
|
size: {
|
|
19
19
|
sm: "px-1.5 py-0.5 text-[10px]",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/ui/badge.tsx"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * Style port từ comitor-ds (biến thể tonal + 2 cỡ sm/md).\n * API giữ tương thích shadcn (cva + asChild + data-slot).\n *\n * ─
|
|
1
|
+
{"version":3,"sources":["../../../src/components/ui/badge.tsx"],"sourcesContent":["import { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * Style port từ comitor-ds (biến thể tonal + 2 cỡ sm/md).\n * API giữ tương thích shadcn (cva + asChild + data-slot).\n *\n * ─ BIẾN THỂ TONAL: NỀN LÀ `--x`, CHỮ LÀ `--x-ink` ─\n *\n * Badge in chữ 10–12px nên phải đạt AA 4,5:1 trên chính cái tint mà nó đứng lên. Hợp đồng\n * ba vai trò (docs/hop-dong-mau.md §1) đã có sẵn bậc chữ cho việc này: nền là `--success` /\n * `--warning` / `--destructive`, chữ là bậc `-ink` tương ứng. Đo trên tint hợp lên `--card`:\n *\n * success-ink #216A48 trên bg-success/15 #E0F2E9 = 5,60:1 ✓ (light)\n * #4ADE80 trên #1E362C = 7,44:1 ✓ (dark)\n * warning-ink #7A5800 trên bg-warning/20 #FCF4DB = 5,93:1 ✓ (light)\n * #FACC15 trên #443C1B = 7,19:1 ✓ (dark)\n * destructive-ink #9A3537 trên bg-destructive/15 #F7E2E2 = 5,76:1 ✓ (light)\n * #F87171 trên #311D22 = 5,71:1 ✓ (dark)\n *\n * Bản trước dẫn xuất bậc tối tại chỗ bằng `color-mix(--x, --ink N%)` cho light rồi lật về\n * `dark:text-success` / `dark:text-warning` / `dark:text-red` cho dark. Số thì đạt, nhưng\n * NHÁNH DARK vi phạm hợp đồng: `--success`/`--warning` là token TÔ NỀN, không phải màu chữ —\n * app nào đổi `--success` cho hợp brand mình là badge hỏng chữ mà không có gì báo. Bậc `-ink`\n * mới là biến mang ràng buộc tương phản, và nó tự lật theo theme (styles.css khai `-ink` riêng\n * cho `.dark`) nên không cần biến thể `dark:` nào ở đây nữa.\n *\n * ⚠ Trần tint: `/20` là mức đậm nhất an toàn cho MỌI tone ở cả hai theme. `warning` chịu được\n * `/20` thoải mái (trần của vàng là `/60` light · `/36` dark) nhưng đỏ ở dark chỉ tới `/22` —\n * muốn đậm hơn `/15` phải tra bảng trần tint ở docs/hop-dong-mau.md §2.\n *\n * ─ VIỀN CỦA BIẾN THỂ `outline` ─\n * `border-control-edge` chứ không `border-border`: nền badge trong suốt nên VIỀN là thứ duy\n * nhất khoanh vùng nó → chịu ngưỡng 3:1 (WCAG 1.4.11). `--border` là bậc TRANG TRÍ, chỉ\n * 1,16:1 (light) / 1,17:1 (dark) — badge gần như tàng hình. `--control-edge`: 3,83:1 / 4,94:1 ✓.\n *\n * ─ BIẾN THỂ `default`/`primary` ─\n * Vẫn là thang gold thô (`gold-100`/`gold-600` ở light, `gold-500/20`+`gold-200` ở dark) vì\n * cặp này đã đo và dư biên: 7,77:1 (light) · 10,23:1 (dark).\n */\nconst badgeVariants = cva(\n \"inline-flex items-center font-medium rounded-md whitespace-nowrap gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none\",\n {\n variants: {\n variant: {\n default: \"bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200\",\n primary: \"bg-gold-100 text-gold-600 dark:bg-gold-500/20 dark:text-gold-200\",\n secondary: \"bg-secondary text-secondary-foreground\",\n outline: \"bg-transparent border border-control-edge text-foreground\",\n success: \"bg-success/15 text-success-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\"\n },\n size: {\n sm: \"px-1.5 py-0.5 text-[10px]\",\n md: \"px-2 py-0.5 text-xs\"\n }\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\"\n }\n }\n);\n\nexport interface BadgeProps extends React.ComponentProps<\"span\">, VariantProps<typeof badgeVariants> {\n /** Render ra chính phần tử con (Radix Slot) thay vì thẻ <span>. */\n asChild?: boolean;\n}\n\nfunction Badge({ className, variant, size, asChild = false, ...props }: BadgeProps) {\n const Comp = asChild ? Slot : \"span\";\n\n return <Comp data-slot=\"badge\" className={cn(badgeVariants({ variant, size }), className)} {...props} />;\n}\n\nexport { Badge, badgeVariants };\n"],"mappings":"AA2ES;AA3ET,SAAS,YAAY;AACrB,SAAS,WAA8B;AAEvC,SAAS,UAAU;AAuCnB,MAAM,gBAAgB;AAAA,EACpB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SAAS;AAAA,QACT,SAAS;AAAA,QACT,WAAW;AAAA,QACX,SAAS;AAAA,QACT,SAAS;AAAA,QACT,SAAS;AAAA,QACT,aAAa;AAAA,MACf;AAAA,MACA,MAAM;AAAA,QACJ,IAAI;AAAA,QACJ,IAAI;AAAA,MACN;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,MACT,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAOA,SAAS,MAAM,EAAE,WAAW,SAAS,MAAM,UAAU,OAAO,GAAG,MAAM,GAAe;AAClF,QAAM,OAAO,UAAU,OAAO;AAE9B,SAAO,oBAAC,QAAK,aAAU,SAAQ,WAAW,GAAG,cAAc,EAAE,SAAS,KAAK,CAAC,GAAG,SAAS,GAAI,GAAG,OAAO;AACxG;","names":[]}
|