@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
|
@@ -3,26 +3,18 @@ import { ComponentProps } from 'react';
|
|
|
3
3
|
|
|
4
4
|
type LetterAvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Chuỗi class token của tone màu ứng với một tên — cùng một người luôn ra cùng màu ở mọi app
|
|
7
|
+
* Comitor, và cùng màu với bản canvas/email/PDF (`getAvatarToneColors` ở `@comitor/ui/tokens`)
|
|
8
|
+
* vì hai bên đọc CÙNG một bảng tone trong `lib/avatar-tone.ts`.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Tên hàm nói đúng thứ nó trả về: `...Classes` = class Tailwind (DOM). Bảng tone không xuất ra
|
|
11
|
+
* ngoài: muốn tô màu khác thì truyền prop `toneClassName`, đừng dựng lại phép chọn tone bằng hàm
|
|
12
|
+
* băm riêng — đó là đường ngắn nhất để cùng một người ra hai màu.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* bảng tone riêng. Đừng dựng bảng thứ hai — cùng một người mà ra hai màu trên cùng một
|
|
15
|
-
* trang là lỗi đã từng xảy ra ở 0.1.0.
|
|
14
|
+
* SEED là TÊN (được `trim` + hạ chữ thường theo locale vi-VN bên trong), không phải email/slug:
|
|
15
|
+
* mọi nơi hiển thị avatar đều chắc chắn có `name`, còn email/slug thì không.
|
|
16
16
|
*/
|
|
17
|
-
declare
|
|
18
|
-
/**
|
|
19
|
-
* Tone màu ổn định theo tên — cùng một người luôn ra cùng màu ở mọi app Comitor.
|
|
20
|
-
*
|
|
21
|
-
* SEED là TÊN (đã trim + hạ chữ thường theo locale vi-VN), không phải email/slug: mọi nơi
|
|
22
|
-
* hiển thị avatar đều chắc chắn có `name`, còn email/slug thì không — lấy khoá khác nhau ở
|
|
23
|
-
* từng chỗ là cách nhanh nhất để cùng một người ra hai màu.
|
|
24
|
-
*/
|
|
25
|
-
declare function getLetterAvatarTone(name: string): string;
|
|
17
|
+
declare function getAvatarToneClasses(name: string): string;
|
|
26
18
|
interface LetterAvatarProps extends Omit<ComponentProps<"span">, "children"> {
|
|
27
19
|
/** Họ tên đầy đủ — dùng cho cả initials lẫn màu */
|
|
28
20
|
name: string;
|
|
@@ -44,4 +36,4 @@ interface LetterAvatarProps extends Omit<ComponentProps<"span">, "children"> {
|
|
|
44
36
|
*/
|
|
45
37
|
declare function LetterAvatar({ name, src, size, square, toneClassName, className, ...props }: LetterAvatarProps): React.JSX.Element;
|
|
46
38
|
|
|
47
|
-
export {
|
|
39
|
+
export { LetterAvatar, type LetterAvatarProps, type LetterAvatarSize, getAvatarToneClasses };
|
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { getAvatarTone } from "../../lib/avatar-tone.js";
|
|
2
3
|
import { cn } from "../../lib/cn.js";
|
|
3
|
-
import { getVietnameseInitials
|
|
4
|
-
const LETTER_AVATAR_TONES = [
|
|
5
|
-
"bg-gold-100 text-gold-600 dark:bg-gold-500/30 dark:text-gold-100",
|
|
6
|
-
"bg-teal/15 text-teal",
|
|
7
|
-
"bg-green/15 text-green",
|
|
8
|
-
"bg-red/15 text-red",
|
|
9
|
-
"bg-yellow/25 text-gold-500 dark:bg-yellow/20 dark:text-yellow",
|
|
10
|
-
"bg-chart-3/15 text-chart-3",
|
|
11
|
-
"bg-ash-100 text-ash-700 dark:bg-ash-500/25 dark:text-ash-100",
|
|
12
|
-
"bg-ink/10 text-ink-soft dark:bg-paper/15 dark:text-paper"
|
|
13
|
-
];
|
|
4
|
+
import { getVietnameseInitials } from "./text-utils.js";
|
|
14
5
|
const sizeClasses = {
|
|
15
6
|
xs: "size-6 text-[10px]",
|
|
16
7
|
sm: "size-7 text-xs",
|
|
@@ -18,9 +9,8 @@ const sizeClasses = {
|
|
|
18
9
|
lg: "size-11 text-base",
|
|
19
10
|
xl: "size-14 text-lg"
|
|
20
11
|
};
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
return tones[hashString(name.trim().toLocaleLowerCase("vi-VN")) % tones.length] ?? tones[0];
|
|
12
|
+
function getAvatarToneClasses(name) {
|
|
13
|
+
return getAvatarTone(name).classes;
|
|
24
14
|
}
|
|
25
15
|
function LetterAvatar({
|
|
26
16
|
name,
|
|
@@ -31,7 +21,7 @@ function LetterAvatar({
|
|
|
31
21
|
className,
|
|
32
22
|
...props
|
|
33
23
|
}) {
|
|
34
|
-
const tone = toneClassName ??
|
|
24
|
+
const tone = toneClassName ?? getAvatarToneClasses(name);
|
|
35
25
|
return /* @__PURE__ */ jsxs(
|
|
36
26
|
"span",
|
|
37
27
|
{
|
|
@@ -59,8 +49,7 @@ function LetterAvatar({
|
|
|
59
49
|
);
|
|
60
50
|
}
|
|
61
51
|
export {
|
|
62
|
-
LETTER_AVATAR_TONES,
|
|
63
52
|
LetterAvatar,
|
|
64
|
-
|
|
53
|
+
getAvatarToneClasses
|
|
65
54
|
};
|
|
66
55
|
//# sourceMappingURL=letter-avatar.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/letter-avatar.tsx"],"sourcesContent":["import type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { getVietnameseInitials
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/letter-avatar.tsx"],"sourcesContent":["import type { ComponentProps } from \"react\";\nimport { getAvatarTone } from \"../../lib/avatar-tone.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { getVietnameseInitials } from \"./text-utils.js\";\n\nexport type LetterAvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n\nconst sizeClasses: Record<LetterAvatarSize, string> = {\n xs: \"size-6 text-[10px]\",\n sm: \"size-7 text-xs\",\n md: \"size-9 text-sm\",\n lg: \"size-11 text-base\",\n xl: \"size-14 text-lg\"\n};\n\n/**\n * Chuỗi class token của tone màu ứng với một tên — cùng một người luôn ra cùng màu ở mọi app\n * Comitor, và cùng màu với bản canvas/email/PDF (`getAvatarToneColors` ở `@comitor/ui/tokens`)\n * vì hai bên đọc CÙNG một bảng tone trong `lib/avatar-tone.ts`.\n *\n * Tên hàm nói đúng thứ nó trả về: `...Classes` = class Tailwind (DOM). Bảng tone không xuất ra\n * ngoài: muốn tô màu khác thì truyền prop `toneClassName`, đừng dựng lại phép chọn tone bằng hàm\n * băm riêng — đó là đường ngắn nhất để cùng một người ra hai màu.\n *\n * SEED là TÊN (được `trim` + hạ chữ thường theo locale vi-VN bên trong), không phải email/slug:\n * mọi nơi hiển thị avatar đều chắc chắn có `name`, còn email/slug thì không.\n */\nexport function getAvatarToneClasses(name: string): string {\n return getAvatarTone(name).classes;\n}\n\nexport interface LetterAvatarProps extends Omit<ComponentProps<\"span\">, \"children\"> {\n /** Họ tên đầy đủ — dùng cho cả initials lẫn màu */\n name: string;\n /** Có ảnh thì hiện ảnh, không thì rơi về initials */\n src?: string | null;\n size?: LetterAvatarSize;\n /** Bo vuông (dùng cho workspace/app) thay vì tròn (dùng cho người) */\n square?: boolean;\n /** Ghi đè tone (chuỗi class token) — vd tô theo màu app thay vì theo tên */\n toneClassName?: string;\n}\n\n/**\n * Avatar chữ cái: ảnh nếu có, không thì initials kiểu Việt (chữ đầu HỌ + chữ đầu TÊN) trên nền\n * màu suy ra từ tên.\n *\n * Không dùng primitive `Avatar` (Radix) một cách cố ý: Radix Avatar là client component vì phải\n * theo dõi trạng thái tải ảnh; ở danh sách 200 dòng thì đó là 200 island vô ích. Bản này tĩnh\n * hoàn toàn → render thẳng ở server.\n */\nexport function LetterAvatar({\n name,\n src,\n size = \"md\",\n square = false,\n toneClassName,\n className,\n ...props\n}: LetterAvatarProps) {\n const tone = toneClassName ?? getAvatarToneClasses(name);\n\n return (\n <span\n data-slot=\"letter-avatar\"\n title={name}\n className={cn(\n \"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden font-medium\",\n square ? \"rounded-md\" : \"rounded-full\",\n sizeClasses[size],\n !src && tone,\n className\n )}\n {...props}\n >\n {src ? (\n // Không dùng next/image: entry \".\" phải chạy cả ngoài Next (Vite, Storybook, test).\n // App muốn tối ưu ảnh thì truyền URL đã qua CDN/loader của mình.\n // `alt=\"\"`: tên đã nằm ở `sr-only` bên dưới — để alt={name} nữa là đọc tên hai lần.\n // biome-ignore lint/performance/noImgElement: package dùng chung không phụ thuộc next/*\n <img src={src} alt=\"\" className=\"size-full object-cover\" loading=\"lazy\" decoding=\"async\" />\n ) : (\n <span aria-hidden=\"true\">{getVietnameseInitials(name)}</span>\n )}\n {/* Tên cho trình đọc màn hình ở MỌI nhánh: nhánh initials vốn `aria-hidden` (chữ \"NT\"\n đọc lên vô nghĩa), thiếu dòng này thì avatar không ảnh là một ô màu câm — và khi\n avatar nằm trong một trigger (AvatarGroup) thì chính nó là tên của trigger đó. */}\n <span className=\"sr-only\">{name}</span>\n </span>\n );\n}\n"],"mappings":"AA+DI,SAiBI,KAjBJ;AA9DJ,SAAS,qBAAqB;AAC9B,SAAS,UAAU;AACnB,SAAS,6BAA6B;AAItC,MAAM,cAAgD;AAAA,EACpD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAcO,SAAS,qBAAqB,MAAsB;AACzD,SAAO,cAAc,IAAI,EAAE;AAC7B;AAsBO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,SAAS;AAAA,EACT;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,OAAO,iBAAiB,qBAAqB,IAAI;AAEvD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,OAAO;AAAA,MACP,WAAW;AAAA,QACT;AAAA,QACA,SAAS,eAAe;AAAA,QACxB,YAAY,IAAI;AAAA,QAChB,CAAC,OAAO;AAAA,QACR;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEH;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAKC,oBAAC,SAAI,KAAU,KAAI,IAAG,WAAU,0BAAyB,SAAQ,QAAO,UAAS,SAAQ;AAAA,YAEzF,oBAAC,UAAK,eAAY,QAAQ,gCAAsB,IAAI,GAAE;AAAA,QAKxD,oBAAC,UAAK,WAAU,WAAW,gBAAK;AAAA;AAAA;AAAA,EAClC;AAEJ;","names":[]}
|
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
-
import { Ref, ComponentPropsWithoutRef
|
|
2
|
+
import { ReactNode, Ref, ComponentPropsWithoutRef } from 'react';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Chuỗi hiển thị + chuỗi a11y của `SearchFilterBar` — cùng khuôn `DataTableLabels`.
|
|
6
|
+
*
|
|
7
|
+
* `searchPlaceholder` và `unitLabel` vốn đã là prop rời và vẫn được giữ (prop thắng `labels`);
|
|
8
|
+
* các khoá còn lại trước đây chôn cứng, trong đó có `sr-only` và nội dung nút mà KHÔNG prop nào
|
|
9
|
+
* chạm tới — app tiếng Anh ghép thanh này với `DataTable` đã i18n sẽ ra giao diện nửa Việt nửa Anh.
|
|
10
|
+
*/
|
|
11
|
+
interface SearchFilterBarLabels {
|
|
12
|
+
/** Placeholder + `aria-label` của ô tìm */
|
|
13
|
+
searchPlaceholder: string;
|
|
14
|
+
/**
|
|
15
|
+
* Đơn vị đếm mặc định ("mục" / "items") — prop `unitLabel` vẫn ghi đè được riêng.
|
|
16
|
+
*
|
|
17
|
+
* Cùng lý do như `TablePaginationLabels.unitLabel`: mặc định là chuỗi theo NGÔN NGỮ nên
|
|
18
|
+
* phải override được qua `labels`, còn prop `unitLabel` là chuỗi theo NỘI DUNG của từng
|
|
19
|
+
* bảng ("việc", "liên hệ") nên vẫn là prop rời và vẫn thắng `labels`.
|
|
20
|
+
*/
|
|
21
|
+
unitLabel: string;
|
|
22
|
+
/** `sr-only` của nút xoá từ khoá trong ô tìm */
|
|
23
|
+
clearSearch: string;
|
|
24
|
+
/** Huy hiệu đếm bộ lọc đang bật: "3 bộ lọc" */
|
|
25
|
+
filterCount: (count: number) => string;
|
|
26
|
+
/** Nhãn nút xoá toàn bộ bộ lọc */
|
|
27
|
+
clearFilters: string;
|
|
28
|
+
/**
|
|
29
|
+
* Dòng đếm kết quả sau lọc: "**3** / 57 mục" (`total` bỏ trống thì chỉ "**3** mục").
|
|
30
|
+
*
|
|
31
|
+
* Trả `ReactNode` chứ không phải chuỗi vì con số cần thẻ riêng (`tabular-nums` để số không
|
|
32
|
+
* nhảy cột, `text-foreground` để nổi trên nền chữ mờ) — ép về chuỗi là mất phần định dạng đó.
|
|
33
|
+
* Là hàm vì trật tự số/đơn vị không giống nhau giữa các ngôn ngữ.
|
|
34
|
+
*/
|
|
35
|
+
resultSummary: (resultCount: number, total: number | undefined, unitLabel: string) => ReactNode;
|
|
36
|
+
}
|
|
37
|
+
/** Nhãn tiếng Việt mặc định — mọi khoá đều override được qua prop `labels`. */
|
|
38
|
+
declare const DEFAULT_SEARCH_FILTER_BAR_LABELS: SearchFilterBarLabels;
|
|
4
39
|
interface SearchFilterBarProps {
|
|
5
40
|
/** Giá trị ô tìm kiếm (controlled) */
|
|
6
41
|
searchValue: string;
|
|
@@ -22,7 +57,10 @@ interface SearchFilterBarProps {
|
|
|
22
57
|
resultCount?: number;
|
|
23
58
|
/** Tổng số bản ghi khi chưa lọc */
|
|
24
59
|
total?: number;
|
|
60
|
+
/** Đơn vị đếm theo nội dung bảng, vd "việc", "liên hệ" — mặc định `labels.unitLabel` ("mục") */
|
|
25
61
|
unitLabel?: string;
|
|
62
|
+
/** Ghi đè chuỗi hiển thị/a11y (mặc định tiếng Việt) — xem `SearchFilterBarLabels` */
|
|
63
|
+
labels?: Partial<SearchFilterBarLabels>;
|
|
26
64
|
className?: string;
|
|
27
65
|
}
|
|
28
66
|
/**
|
|
@@ -33,6 +71,6 @@ interface SearchFilterBarProps {
|
|
|
33
71
|
*
|
|
34
72
|
* Dòng đếm kết quả có `min-h-7` cố định để việc bật/tắt lọc không làm bảng nhảy lên xuống.
|
|
35
73
|
*/
|
|
36
|
-
declare function SearchFilterBar({ searchValue, onSearchChange, searchPlaceholder, debounceMs, searchInputRef, searchInputProps, children, actions, activeFilterCount, onClearFilters, resultCount, total, unitLabel, className }: SearchFilterBarProps): React.JSX.Element;
|
|
74
|
+
declare function SearchFilterBar({ searchValue, onSearchChange, searchPlaceholder, debounceMs, searchInputRef, searchInputProps, children, actions, activeFilterCount, onClearFilters, resultCount, total, unitLabel: unitLabelProp, labels: labelsProp, className }: SearchFilterBarProps): React.JSX.Element;
|
|
37
75
|
|
|
38
|
-
export { SearchFilterBar, type SearchFilterBarProps };
|
|
76
|
+
export { DEFAULT_SEARCH_FILTER_BAR_LABELS, SearchFilterBar, type SearchFilterBarLabels, type SearchFilterBarProps };
|
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Search, SlidersHorizontal, X } from "lucide-react";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
5
|
import { cn } from "../../lib/cn.js";
|
|
6
6
|
import { Badge } from "../ui/badge.js";
|
|
7
7
|
import { Button } from "../ui/button.js";
|
|
8
8
|
import { Input } from "../ui/input.js";
|
|
9
|
+
const DEFAULT_SEARCH_FILTER_BAR_LABELS = {
|
|
10
|
+
searchPlaceholder: "T\xECm ki\u1EBFm\u2026",
|
|
11
|
+
unitLabel: "m\u1EE5c",
|
|
12
|
+
clearSearch: "X\xF3a t\u1EEB kh\xF3a",
|
|
13
|
+
filterCount: (count) => `${count} b\u1ED9 l\u1ECDc`,
|
|
14
|
+
clearFilters: "X\xF3a l\u1ECDc",
|
|
15
|
+
resultSummary: (resultCount, total, unitLabel) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
16
|
+
/* @__PURE__ */ jsx("strong", { className: "font-medium text-foreground tabular-nums", children: resultCount }),
|
|
17
|
+
total !== void 0 ? ` / ${total}` : "",
|
|
18
|
+
" ",
|
|
19
|
+
unitLabel
|
|
20
|
+
] })
|
|
21
|
+
};
|
|
9
22
|
function SearchFilterBar({
|
|
10
23
|
searchValue,
|
|
11
24
|
onSearchChange,
|
|
12
|
-
searchPlaceholder
|
|
25
|
+
searchPlaceholder,
|
|
13
26
|
debounceMs = 250,
|
|
14
27
|
searchInputRef,
|
|
15
28
|
searchInputProps,
|
|
@@ -19,9 +32,13 @@ function SearchFilterBar({
|
|
|
19
32
|
onClearFilters,
|
|
20
33
|
resultCount,
|
|
21
34
|
total,
|
|
22
|
-
unitLabel
|
|
35
|
+
unitLabel: unitLabelProp,
|
|
36
|
+
labels: labelsProp,
|
|
23
37
|
className
|
|
24
38
|
}) {
|
|
39
|
+
const labels = { ...DEFAULT_SEARCH_FILTER_BAR_LABELS, ...labelsProp };
|
|
40
|
+
const placeholder = searchPlaceholder ?? labels.searchPlaceholder;
|
|
41
|
+
const unitLabel = unitLabelProp ?? labels.unitLabel;
|
|
25
42
|
const [localValue, setLocalValue] = useState(searchValue);
|
|
26
43
|
const lastEmittedRef = useRef(searchValue);
|
|
27
44
|
const timerRef = useRef(null);
|
|
@@ -85,8 +102,8 @@ function SearchFilterBar({
|
|
|
85
102
|
ref: searchInputRef,
|
|
86
103
|
type: "search",
|
|
87
104
|
value: localValue,
|
|
88
|
-
placeholder
|
|
89
|
-
"aria-label":
|
|
105
|
+
placeholder,
|
|
106
|
+
"aria-label": placeholder,
|
|
90
107
|
onChange: (event) => emit(event.target.value),
|
|
91
108
|
className: "pl-9 pr-8 [&::-webkit-search-cancel-button]:hidden",
|
|
92
109
|
...searchInputProps
|
|
@@ -102,7 +119,7 @@ function SearchFilterBar({
|
|
|
102
119
|
className: "absolute right-1 top-1/2 size-6 -translate-y-1/2",
|
|
103
120
|
children: [
|
|
104
121
|
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
105
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
122
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: labels.clearSearch })
|
|
106
123
|
]
|
|
107
124
|
}
|
|
108
125
|
)
|
|
@@ -110,26 +127,21 @@ function SearchFilterBar({
|
|
|
110
127
|
children,
|
|
111
128
|
hasFilters && /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "gap-1", children: [
|
|
112
129
|
/* @__PURE__ */ jsx(SlidersHorizontal, { "aria-hidden": "true" }),
|
|
113
|
-
activeFilterCount
|
|
114
|
-
" b\u1ED9 l\u1ECDc"
|
|
130
|
+
labels.filterCount(activeFilterCount)
|
|
115
131
|
] }),
|
|
116
132
|
actions && /* @__PURE__ */ jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })
|
|
117
133
|
] }),
|
|
118
134
|
showCount && /* @__PURE__ */ jsxs("div", { role: "status", className: "flex min-h-7 items-center gap-2 text-sm text-muted-foreground", children: [
|
|
119
|
-
resultCount !== void 0 && /* @__PURE__ */
|
|
120
|
-
/* @__PURE__ */ jsx("strong", { className: "font-medium text-foreground tabular-nums", children: resultCount }),
|
|
121
|
-
total !== void 0 ? ` / ${total}` : "",
|
|
122
|
-
" ",
|
|
123
|
-
unitLabel
|
|
124
|
-
] }),
|
|
135
|
+
resultCount !== void 0 && /* @__PURE__ */ jsx("span", { children: labels.resultSummary(resultCount, total, unitLabel) }),
|
|
125
136
|
hasFilters && onClearFilters && /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: onClearFilters, className: "h-7 gap-1.5 px-2", children: [
|
|
126
137
|
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
127
|
-
|
|
138
|
+
labels.clearFilters
|
|
128
139
|
] })
|
|
129
140
|
] })
|
|
130
141
|
] });
|
|
131
142
|
}
|
|
132
143
|
export {
|
|
144
|
+
DEFAULT_SEARCH_FILTER_BAR_LABELS,
|
|
133
145
|
SearchFilterBar
|
|
134
146
|
};
|
|
135
147
|
//# sourceMappingURL=search-filter-bar.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/search-filter-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport { Search, SlidersHorizontal, X } from \"lucide-react\";\nimport { type ComponentPropsWithoutRef, type ReactNode, type Ref, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Input } from \"../ui/input.js\";\n\nexport interface SearchFilterBarProps {\n /** Giá trị ô tìm kiếm (controlled) */\n searchValue: string;\n onSearchChange: (value: string) => void;\n searchPlaceholder?: string;\n /** Trễ trước khi gọi `onSearchChange` (ms). 0 = gọi ngay. Gõ tìm gọi API thì đặt 250–350. */\n debounceMs?: number;\n searchInputRef?: Ref<HTMLInputElement>;\n /** Props đặt thẳng lên ô nhập (id, name, aria-label, enterKeyHint…) */\n searchInputProps?: ComponentPropsWithoutRef<\"input\">;\n\n /** Slot bộ lọc cạnh ô tìm (Select, Combobox, DatePicker…) */\n children?: ReactNode;\n /** Slot hành động bên phải (nút Tạo mới, menu cột…) */\n actions?: ReactNode;\n\n /** Số bộ lọc đang bật — hiện huy hiệu và mở dòng đếm kết quả */\n activeFilterCount?: number;\n onClearFilters?: () => void;\n /** Số bản ghi sau lọc */\n resultCount?: number;\n /** Tổng số bản ghi khi chưa lọc */\n total?: number;\n unitLabel?: string;\n className?: string;\n}\n\n/**\n * Thanh tìm kiếm + lọc chuẩn cho trang danh sách.\n *\n * Ô tìm giữ giá trị cục bộ và chỉ đẩy lên sau `debounceMs` — nếu bind thẳng vào state của trang,\n * mỗi phím gõ là một lần render cả bảng (và thường là một request).\n *\n * Dòng đếm kết quả có `min-h-7` cố định để việc bật/tắt lọc không làm bảng nhảy lên xuống.\n */\nexport function SearchFilterBar({\n searchValue,\n onSearchChange,\n searchPlaceholder = \"Tìm kiếm…\",\n debounceMs = 250,\n searchInputRef,\n searchInputProps,\n children,\n actions,\n activeFilterCount = 0,\n onClearFilters,\n resultCount,\n total,\n unitLabel = \"mục\",\n className\n}: SearchFilterBarProps) {\n const [localValue, setLocalValue] = useState(searchValue);\n const lastEmittedRef = useRef(searchValue);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const onSearchChangeRef = useRef(onSearchChange);\n\n useEffect(() => {\n onSearchChangeRef.current = onSearchChange;\n });\n\n // Đồng bộ khi giá trị bị đổi từ bên ngoài (nút \"Xóa lọc\", điều hướng lại trang) — nhưng\n // không đè lên những gì người dùng đang gõ dở.\n useEffect(() => {\n if (searchValue !== lastEmittedRef.current) {\n lastEmittedRef.current = searchValue;\n setLocalValue(searchValue);\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n }\n }, [searchValue]);\n\n useEffect(\n () => () => {\n if (timerRef.current) clearTimeout(timerRef.current);\n },\n []\n );\n\n const emit = (value: string) => {\n setLocalValue(value);\n if (timerRef.current) clearTimeout(timerRef.current);\n if (!debounceMs) {\n lastEmittedRef.current = value;\n onSearchChangeRef.current(value);\n return;\n }\n timerRef.current = setTimeout(() => {\n lastEmittedRef.current = value;\n onSearchChangeRef.current(value);\n }, debounceMs);\n };\n\n const clearSearch = () => {\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n setLocalValue(\"\");\n lastEmittedRef.current = \"\";\n onSearchChangeRef.current(\"\");\n };\n\n const hasFilters = activeFilterCount > 0;\n const showCount = hasFilters || Boolean(localValue);\n\n return (\n <div data-slot=\"search-filter-bar\" className={cn(\"flex flex-col gap-2\", className)}>\n <div className=\"flex flex-wrap items-center gap-2\">\n <div className=\"relative min-w-[12rem] flex-1 sm:max-w-sm\">\n <Search\n aria-hidden=\"true\"\n className=\"absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\"\n />\n <Input\n ref={searchInputRef}\n type=\"search\"\n value={localValue}\n placeholder={searchPlaceholder}\n aria-label={searchPlaceholder}\n onChange={(event) => emit(event.target.value)}\n className=\"pl-9 pr-8 [&::-webkit-search-cancel-button]:hidden\"\n {...searchInputProps}\n />\n {localValue && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n onClick={clearSearch}\n className=\"absolute right-1 top-1/2 size-6 -translate-y-1/2\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">Xóa từ khóa</span>\n </Button>\n )}\n </div>\n\n {children}\n\n {hasFilters && (\n <Badge variant=\"secondary\" className=\"gap-1\">\n <SlidersHorizontal aria-hidden=\"true\" />\n {activeFilterCount} bộ lọc\n </Badge>\n )}\n\n {actions && <div className=\"ml-auto flex items-center gap-2\">{actions}</div>}\n </div>\n\n {showCount && (\n <div role=\"status\" className=\"flex min-h-7 items-center gap-2 text-sm text-muted-foreground\">\n {resultCount !== undefined && (\n <span>\n <strong className=\"font-medium text-foreground tabular-nums\">{resultCount}</strong>\n {total !== undefined ? ` / ${total}` : \"\"} {unitLabel}\n </span>\n )}\n {hasFilters && onClearFilters && (\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={onClearFilters} className=\"h-7 gap-1.5 px-2\">\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n Xóa lọc\n </Button>\n )}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/search-filter-bar.tsx"],"sourcesContent":["\"use client\";\n\nimport { Search, SlidersHorizontal, X } from \"lucide-react\";\nimport { type ComponentPropsWithoutRef, type ReactNode, type Ref, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Input } from \"../ui/input.js\";\n\n/**\n * Chuỗi hiển thị + chuỗi a11y của `SearchFilterBar` — cùng khuôn `DataTableLabels`.\n *\n * `searchPlaceholder` và `unitLabel` vốn đã là prop rời và vẫn được giữ (prop thắng `labels`);\n * các khoá còn lại trước đây chôn cứng, trong đó có `sr-only` và nội dung nút mà KHÔNG prop nào\n * chạm tới — app tiếng Anh ghép thanh này với `DataTable` đã i18n sẽ ra giao diện nửa Việt nửa Anh.\n */\nexport interface SearchFilterBarLabels {\n /** Placeholder + `aria-label` của ô tìm */\n searchPlaceholder: string;\n /**\n * Đơn vị đếm mặc định (\"mục\" / \"items\") — prop `unitLabel` vẫn ghi đè được riêng.\n *\n * Cùng lý do như `TablePaginationLabels.unitLabel`: mặc định là chuỗi theo NGÔN NGỮ nên\n * phải override được qua `labels`, còn prop `unitLabel` là chuỗi theo NỘI DUNG của từng\n * bảng (\"việc\", \"liên hệ\") nên vẫn là prop rời và vẫn thắng `labels`.\n */\n unitLabel: string;\n /** `sr-only` của nút xoá từ khoá trong ô tìm */\n clearSearch: string;\n /** Huy hiệu đếm bộ lọc đang bật: \"3 bộ lọc\" */\n filterCount: (count: number) => string;\n /** Nhãn nút xoá toàn bộ bộ lọc */\n clearFilters: string;\n /**\n * Dòng đếm kết quả sau lọc: \"**3** / 57 mục\" (`total` bỏ trống thì chỉ \"**3** mục\").\n *\n * Trả `ReactNode` chứ không phải chuỗi vì con số cần thẻ riêng (`tabular-nums` để số không\n * nhảy cột, `text-foreground` để nổi trên nền chữ mờ) — ép về chuỗi là mất phần định dạng đó.\n * Là hàm vì trật tự số/đơn vị không giống nhau giữa các ngôn ngữ.\n */\n resultSummary: (resultCount: number, total: number | undefined, unitLabel: string) => ReactNode;\n}\n\n/** Nhãn tiếng Việt mặc định — mọi khoá đều override được qua prop `labels`. */\nexport const DEFAULT_SEARCH_FILTER_BAR_LABELS: SearchFilterBarLabels = {\n searchPlaceholder: \"Tìm kiếm…\",\n unitLabel: \"mục\",\n clearSearch: \"Xóa từ khóa\",\n filterCount: (count) => `${count} bộ lọc`,\n clearFilters: \"Xóa lọc\",\n resultSummary: (resultCount, total, unitLabel) => (\n <>\n <strong className=\"font-medium text-foreground tabular-nums\">{resultCount}</strong>\n {total !== undefined ? ` / ${total}` : \"\"} {unitLabel}\n </>\n )\n};\n\nexport interface SearchFilterBarProps {\n /** Giá trị ô tìm kiếm (controlled) */\n searchValue: string;\n onSearchChange: (value: string) => void;\n searchPlaceholder?: string;\n /** Trễ trước khi gọi `onSearchChange` (ms). 0 = gọi ngay. Gõ tìm gọi API thì đặt 250–350. */\n debounceMs?: number;\n searchInputRef?: Ref<HTMLInputElement>;\n /** Props đặt thẳng lên ô nhập (id, name, aria-label, enterKeyHint…) */\n searchInputProps?: ComponentPropsWithoutRef<\"input\">;\n\n /** Slot bộ lọc cạnh ô tìm (Select, Combobox, DatePicker…) */\n children?: ReactNode;\n /** Slot hành động bên phải (nút Tạo mới, menu cột…) */\n actions?: ReactNode;\n\n /** Số bộ lọc đang bật — hiện huy hiệu và mở dòng đếm kết quả */\n activeFilterCount?: number;\n onClearFilters?: () => void;\n /** Số bản ghi sau lọc */\n resultCount?: number;\n /** Tổng số bản ghi khi chưa lọc */\n total?: number;\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 /** Ghi đè chuỗi hiển thị/a11y (mặc định tiếng Việt) — xem `SearchFilterBarLabels` */\n labels?: Partial<SearchFilterBarLabels>;\n className?: string;\n}\n\n/**\n * Thanh tìm kiếm + lọc chuẩn cho trang danh sách.\n *\n * Ô tìm giữ giá trị cục bộ và chỉ đẩy lên sau `debounceMs` — nếu bind thẳng vào state của trang,\n * mỗi phím gõ là một lần render cả bảng (và thường là một request).\n *\n * Dòng đếm kết quả có `min-h-7` cố định để việc bật/tắt lọc không làm bảng nhảy lên xuống.\n */\nexport function SearchFilterBar({\n searchValue,\n onSearchChange,\n searchPlaceholder,\n debounceMs = 250,\n searchInputRef,\n searchInputProps,\n children,\n actions,\n activeFilterCount = 0,\n onClearFilters,\n resultCount,\n total,\n unitLabel: unitLabelProp,\n labels: labelsProp,\n className\n}: SearchFilterBarProps) {\n const labels = { ...DEFAULT_SEARCH_FILTER_BAR_LABELS, ...labelsProp };\n const placeholder = searchPlaceholder ?? labels.searchPlaceholder;\n const unitLabel = unitLabelProp ?? labels.unitLabel;\n const [localValue, setLocalValue] = useState(searchValue);\n const lastEmittedRef = useRef(searchValue);\n const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const onSearchChangeRef = useRef(onSearchChange);\n\n useEffect(() => {\n onSearchChangeRef.current = onSearchChange;\n });\n\n // Đồng bộ khi giá trị bị đổi từ bên ngoài (nút \"Xóa lọc\", điều hướng lại trang) — nhưng\n // không đè lên những gì người dùng đang gõ dở.\n useEffect(() => {\n if (searchValue !== lastEmittedRef.current) {\n lastEmittedRef.current = searchValue;\n setLocalValue(searchValue);\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n }\n }, [searchValue]);\n\n useEffect(\n () => () => {\n if (timerRef.current) clearTimeout(timerRef.current);\n },\n []\n );\n\n const emit = (value: string) => {\n setLocalValue(value);\n if (timerRef.current) clearTimeout(timerRef.current);\n if (!debounceMs) {\n lastEmittedRef.current = value;\n onSearchChangeRef.current(value);\n return;\n }\n timerRef.current = setTimeout(() => {\n lastEmittedRef.current = value;\n onSearchChangeRef.current(value);\n }, debounceMs);\n };\n\n const clearSearch = () => {\n if (timerRef.current) {\n clearTimeout(timerRef.current);\n timerRef.current = null;\n }\n setLocalValue(\"\");\n lastEmittedRef.current = \"\";\n onSearchChangeRef.current(\"\");\n };\n\n const hasFilters = activeFilterCount > 0;\n const showCount = hasFilters || Boolean(localValue);\n\n return (\n <div data-slot=\"search-filter-bar\" className={cn(\"flex flex-col gap-2\", className)}>\n <div className=\"flex flex-wrap items-center gap-2\">\n <div className=\"relative min-w-[12rem] flex-1 sm:max-w-sm\">\n <Search\n aria-hidden=\"true\"\n className=\"absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\"\n />\n <Input\n ref={searchInputRef}\n type=\"search\"\n value={localValue}\n placeholder={placeholder}\n aria-label={placeholder}\n onChange={(event) => emit(event.target.value)}\n className=\"pl-9 pr-8 [&::-webkit-search-cancel-button]:hidden\"\n {...searchInputProps}\n />\n {localValue && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n onClick={clearSearch}\n className=\"absolute right-1 top-1/2 size-6 -translate-y-1/2\"\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{labels.clearSearch}</span>\n </Button>\n )}\n </div>\n\n {children}\n\n {hasFilters && (\n <Badge variant=\"secondary\" className=\"gap-1\">\n <SlidersHorizontal aria-hidden=\"true\" />\n {labels.filterCount(activeFilterCount)}\n </Badge>\n )}\n\n {actions && <div className=\"ml-auto flex items-center gap-2\">{actions}</div>}\n </div>\n\n {showCount && (\n <div role=\"status\" className=\"flex min-h-7 items-center gap-2 text-sm text-muted-foreground\">\n {resultCount !== undefined && <span>{labels.resultSummary(resultCount, total, unitLabel)}</span>}\n {hasFilters && onClearFilters && (\n <Button type=\"button\" variant=\"ghost\" size=\"sm\" onClick={onClearFilters} className=\"h-7 gap-1.5 px-2\">\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n {labels.clearFilters}\n </Button>\n )}\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -4,7 +4,22 @@ import * as class_variance_authority_types from 'class-variance-authority/types'
|
|
|
4
4
|
import { VariantProps } from 'class-variance-authority';
|
|
5
5
|
import { IconComponent } from './empty-state.js';
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Ô icon của StatCard, tô theo tone token (không hex).
|
|
9
|
+
*
|
|
10
|
+
* Icon là thành phần PHI VĂN BẢN → ngưỡng WCAG 1.4.11 là 3:1 so với nền tint mà nó đứng lên.
|
|
11
|
+
* Màu icon vì thế phải là bậc `--x-ink`, không phải token tô nền `--x`: `text-success` trên
|
|
12
|
+
* `bg-success/15` chỉ được **2,69:1 ✗** ở light (dark thì 7,44:1 — mỗi theme hỏng một nửa là
|
|
13
|
+
* dấu hiệu dùng sai vai trò token). Đo lại sau khi đổi, trên tint đã trộn với `--card`:
|
|
14
|
+
*
|
|
15
|
+
* success 5,60:1 (L) · 7,44:1 (D) · info 5,54:1 (L) · 7,12:1 (D)
|
|
16
|
+
* warning 5,93:1 (L) · 7,19:1 (D) · destructive 5,76:1 (L) · 5,71:1 (D)
|
|
17
|
+
* default 4,89:1 (L) · 4,86:1 (D) · primary 5,77:1 (L) · 7,26:1 (D)
|
|
18
|
+
* app 6,14:1 (L) · 9,61:1 (D)
|
|
19
|
+
*
|
|
20
|
+
* `warning` bỏ cặp `text-gold-500 dark:text-warning`: `--warning-ink` đúng bằng `--gold-500` ở
|
|
21
|
+
* light và `--yellow` ở dark, nên một class là đủ.
|
|
22
|
+
*/
|
|
8
23
|
declare const statCardIconVariants: (props?: ({
|
|
9
24
|
tone?: "default" | "primary" | "success" | "warning" | "destructive" | "info" | "app" | null | undefined;
|
|
10
25
|
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
@@ -7,14 +7,21 @@ const statCardIconVariants = cva("flex size-9 shrink-0 items-center justify-cent
|
|
|
7
7
|
variants: {
|
|
8
8
|
tone: {
|
|
9
9
|
default: "bg-muted text-muted-foreground",
|
|
10
|
-
primary
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
// Đi theo `--primary`, không phải thang `gold-*` chôn cứng. Bản cũ
|
|
11
|
+
// `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100` đạt tương phản
|
|
12
|
+
// (7,77:1 L · 11,85:1 D) nhưng đóng băng màu: app đổi brand thì ô icon vẫn vàng, và phải
|
|
13
|
+
// viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D).
|
|
14
|
+
primary: "bg-primary/20 text-primary-ink",
|
|
15
|
+
success: "bg-success/15 text-success-ink",
|
|
16
|
+
warning: "bg-warning/20 text-warning-ink",
|
|
17
|
+
destructive: "bg-destructive/15 text-destructive-ink",
|
|
18
|
+
info: "bg-info/15 text-info-ink",
|
|
19
|
+
// Icon app đứng trên tint của CHÍNH nó → phải là bậc ink, đúng như năm tone trên.
|
|
20
|
+
// `text-app-accent` (gold #E8B824) trên `bg-app-accent/10` (trộn ra #FDF8E9) chỉ 1,75:1 ✗.
|
|
21
|
+
// `text-foreground` như bản trước thì ĐỌC được (17,81:1) nhưng bỏ mất màu app — tức là bỏ
|
|
22
|
+
// mất đúng thứ tone này sinh ra để nói. `text-app-accent-ink`: 6,14:1 (L) · 9,61:1 (D) ✓
|
|
23
|
+
// — hợp đồng §5, cùng lối chữa với AppLauncher.
|
|
24
|
+
app: "bg-app-accent/10 text-app-accent-ink"
|
|
18
25
|
}
|
|
19
26
|
},
|
|
20
27
|
defaultVariants: {
|
|
@@ -29,7 +36,7 @@ const deltaIcons = {
|
|
|
29
36
|
function deltaToneClass({ direction, polarity = "positive-up" }) {
|
|
30
37
|
if (direction === "flat" || polarity === "neutral") return "text-muted-foreground";
|
|
31
38
|
const isGood = polarity === "positive-up" ? direction === "up" : direction === "down";
|
|
32
|
-
return isGood ? "text-success" : "text-destructive";
|
|
39
|
+
return isGood ? "text-success-ink" : "text-destructive-ink";
|
|
33
40
|
}
|
|
34
41
|
function StatCard({
|
|
35
42
|
label,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/stat-card.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport { ArrowDownRight, ArrowUpRight, Minus } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\n
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/stat-card.tsx"],"sourcesContent":["import { cva, type VariantProps } from \"class-variance-authority\";\nimport { ArrowDownRight, ArrowUpRight, Minus } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Skeleton } from \"../ui/skeleton.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\n/**\n * Ô icon của StatCard, tô theo tone token (không hex).\n *\n * Icon là thành phần PHI VĂN BẢN → ngưỡng WCAG 1.4.11 là 3:1 so với nền tint mà nó đứng lên.\n * Màu icon vì thế phải là bậc `--x-ink`, không phải token tô nền `--x`: `text-success` trên\n * `bg-success/15` chỉ được **2,69:1 ✗** ở light (dark thì 7,44:1 — mỗi theme hỏng một nửa là\n * dấu hiệu dùng sai vai trò token). Đo lại sau khi đổi, trên tint đã trộn với `--card`:\n *\n * success 5,60:1 (L) · 7,44:1 (D) · info 5,54:1 (L) · 7,12:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) · destructive 5,76:1 (L) · 5,71:1 (D)\n * default 4,89:1 (L) · 4,86:1 (D) · primary 5,77:1 (L) · 7,26:1 (D)\n * app 6,14:1 (L) · 9,61:1 (D)\n *\n * `warning` bỏ cặp `text-gold-500 dark:text-warning`: `--warning-ink` đúng bằng `--gold-500` ở\n * light và `--yellow` ở dark, nên một class là đủ.\n */\nexport const statCardIconVariants = cva(\"flex size-9 shrink-0 items-center justify-center rounded-md\", {\n variants: {\n tone: {\n default: \"bg-muted text-muted-foreground\",\n // Đi theo `--primary`, không phải thang `gold-*` chôn cứng. Bản cũ\n // `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100` đạt tương phản\n // (7,77:1 L · 11,85:1 D) nhưng đóng băng màu: app đổi brand thì ô icon vẫn vàng, và phải\n // viết hai lớp `dark:` vì thang gold không có bản dark. Token đạt 5,77:1 (L) · 7,26:1 (D).\n primary: \"bg-primary/20 text-primary-ink\",\n success: \"bg-success/15 text-success-ink\",\n warning: \"bg-warning/20 text-warning-ink\",\n destructive: \"bg-destructive/15 text-destructive-ink\",\n info: \"bg-info/15 text-info-ink\",\n // Icon app đứng trên tint của CHÍNH nó → phải là bậc ink, đúng như năm tone trên.\n // `text-app-accent` (gold #E8B824) trên `bg-app-accent/10` (trộn ra #FDF8E9) chỉ 1,75:1 ✗.\n // `text-foreground` như bản trước thì ĐỌC được (17,81:1) nhưng bỏ mất màu app — tức là bỏ\n // mất đúng thứ tone này sinh ra để nói. `text-app-accent-ink`: 6,14:1 (L) · 9,61:1 (D) ✓\n // — hợp đồng §5, cùng lối chữa với AppLauncher.\n app: \"bg-app-accent/10 text-app-accent-ink\"\n }\n },\n defaultVariants: {\n tone: \"default\"\n }\n});\n\nexport type StatCardTone = NonNullable<VariantProps<typeof statCardIconVariants>[\"tone\"]>;\n\nexport type StatCardDeltaDirection = \"up\" | \"down\" | \"flat\";\n\nexport interface StatCardDelta {\n /** Nội dung hiển thị, vd \"+12,5%\" hoặc \"3 việc\" */\n value: ReactNode;\n direction: StatCardDeltaDirection;\n /**\n * Tăng là TỐT hay XẤU — mặc định \"positive-up\" (tăng = tốt).\n *\n * Cần thiết vì có chỉ số ngược chiều: số ticket quá hạn tăng là xấu, tỷ lệ rời bỏ giảm là tốt.\n * Tô màu theo hướng mũi tên một cách máy móc sẽ nói dối người đọc.\n */\n polarity?: \"positive-up\" | \"positive-down\" | \"neutral\";\n /** Chú thích sau con số, vd \"so với tuần trước\" */\n label?: ReactNode;\n}\n\nexport interface StatCardProps extends Omit<ComponentProps<\"div\">, \"title\"> {\n /** Nhãn chỉ số, vd \"Việc đang mở\" */\n label: ReactNode;\n value: ReactNode;\n icon?: IconComponent;\n tone?: StatCardTone;\n delta?: StatCardDelta;\n /** Slot biểu đồ mini (sparkline) — package không ép thư viện biểu đồ nào */\n sparkline?: ReactNode;\n /** Dòng chú thích dưới cùng */\n footer?: ReactNode;\n /** Đang tải → khung xương thay cho số liệu */\n loading?: boolean;\n}\n\nconst deltaIcons: Record<StatCardDeltaDirection, IconComponent> = {\n up: ArrowUpRight,\n down: ArrowDownRight,\n flat: Minus\n};\n\n/**\n * Màu của delta = hướng × cực tính, không phải chỉ hướng.\n *\n * Dòng biến động là CHỮ (12px, `text-xs`) đứng trên `bg-card` → ngưỡng AA 4,5:1, và phải đạt ở\n * cả hai theme. Token tô nền không làm được việc đó: `text-success` chỉ 3,13:1 ở light còn\n * `text-destructive` chỉ 3,53:1 ở dark — mỗi theme hỏng một nửa. Bậc ink đạt cả hai:\n * success 6,52:1 (L) / 10,19:1 (D) · destructive 7,15:1 (L) / 6,42:1 (D) · muted-foreground\n * 5,23:1 (L) / 5,35:1 (D).\n */\nfunction deltaToneClass({ direction, polarity = \"positive-up\" }: StatCardDelta): string {\n if (direction === \"flat\" || polarity === \"neutral\") return \"text-muted-foreground\";\n const isGood = polarity === \"positive-up\" ? direction === \"up\" : direction === \"down\";\n return isGood ? \"text-success-ink\" : \"text-destructive-ink\";\n}\n\n/**\n * Thẻ KPI: nhãn + số liệu lớn + biến động + sparkline tuỳ chọn.\n *\n * Số liệu dùng `tabular-nums` để cột số không nhảy khi cập nhật realtime. Tĩnh → RSC-safe.\n */\nexport function StatCard({\n label,\n value,\n icon: Icon,\n tone = \"default\",\n delta,\n sparkline,\n footer,\n loading = false,\n className,\n ...props\n}: StatCardProps) {\n const DeltaIcon = delta ? deltaIcons[delta.direction] : undefined;\n\n return (\n <div\n data-slot=\"stat-card\"\n className={cn(\"rounded-xl border border-border bg-card p-4 shadow-card\", className)}\n {...props}\n >\n <div className=\"flex items-start justify-between gap-3\">\n <div className=\"min-w-0 flex-1\">\n <p className=\"truncate text-sm text-muted-foreground\">{label}</p>\n {loading ? (\n <Skeleton className=\"mt-2 h-7 w-24\" />\n ) : (\n <p className=\"mt-1 text-2xl font-semibold tabular-nums leading-tight text-foreground\">{value}</p>\n )}\n {delta && !loading && DeltaIcon && (\n <p className={cn(\"mt-1 flex items-center gap-1 text-xs font-medium\", deltaToneClass(delta))}>\n <DeltaIcon aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n <span className=\"tabular-nums\">{delta.value}</span>\n {delta.label && <span className=\"font-normal text-muted-foreground\">{delta.label}</span>}\n </p>\n )}\n </div>\n {Icon && (\n <span aria-hidden=\"true\" className={statCardIconVariants({ tone })}>\n <Icon className=\"size-4.5\" />\n </span>\n )}\n </div>\n\n {sparkline && <div className=\"mt-3 h-10\">{sparkline}</div>}\n {footer && <div className=\"mt-3 border-t border-border pt-2 text-xs text-muted-foreground\">{footer}</div>}\n </div>\n );\n}\n"],"mappings":"AAmIU,cAOE,YAPF;AAnIV,SAAS,WAA8B;AACvC,SAAS,gBAAgB,cAAc,aAAa;AAEpD,SAAS,UAAU;AACnB,SAAS,gBAAgB;AAmBlB,MAAM,uBAAuB,IAAI,+DAA+D;AAAA,EACrG,UAAU;AAAA,IACR,MAAM;AAAA,MACJ,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,MAKT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMN,KAAK;AAAA,IACP;AAAA,EACF;AAAA,EACA,iBAAiB;AAAA,IACf,MAAM;AAAA,EACR;AACF,CAAC;AAoCD,MAAM,aAA4D;AAAA,EAChE,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,MAAM;AACR;AAWA,SAAS,eAAe,EAAE,WAAW,WAAW,cAAc,GAA0B;AACtF,MAAI,cAAc,UAAU,aAAa,UAAW,QAAO;AAC3D,QAAM,SAAS,aAAa,gBAAgB,cAAc,OAAO,cAAc;AAC/E,SAAO,SAAS,qBAAqB;AACvC;AAOO,SAAS,SAAS;AAAA,EACvB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV;AAAA,EACA,GAAG;AACL,GAAkB;AAChB,QAAM,YAAY,QAAQ,WAAW,MAAM,SAAS,IAAI;AAExD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,2DAA2D,SAAS;AAAA,MACjF,GAAG;AAAA,MAEJ;AAAA,6BAAC,SAAI,WAAU,0CACb;AAAA,+BAAC,SAAI,WAAU,kBACb;AAAA,gCAAC,OAAE,WAAU,0CAA0C,iBAAM;AAAA,YAC5D,UACC,oBAAC,YAAS,WAAU,iBAAgB,IAEpC,oBAAC,OAAE,WAAU,0EAA0E,iBAAM;AAAA,YAE9F,SAAS,CAAC,WAAW,aACpB,qBAAC,OAAE,WAAW,GAAG,oDAAoD,eAAe,KAAK,CAAC,GACxF;AAAA,kCAAC,aAAU,eAAY,QAAO,WAAU,qBAAoB;AAAA,cAC5D,oBAAC,UAAK,WAAU,gBAAgB,gBAAM,OAAM;AAAA,cAC3C,MAAM,SAAS,oBAAC,UAAK,WAAU,qCAAqC,gBAAM,OAAM;AAAA,eACnF;AAAA,aAEJ;AAAA,UACC,QACC,oBAAC,UAAK,eAAY,QAAO,WAAW,qBAAqB,EAAE,KAAK,CAAC,GAC/D,8BAAC,QAAK,WAAU,YAAW,GAC7B;AAAA,WAEJ;AAAA,QAEC,aAAa,oBAAC,SAAI,WAAU,aAAa,qBAAU;AAAA,QACnD,UAAU,oBAAC,SAAI,WAAU,kEAAkE,kBAAO;AAAA;AAAA;AAAA,EACrG;AAEJ;","names":[]}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* (Tasks: todo/doing/done; CRM: lead/qualified/won/lost; HR: nộp/duyệt/từ chối). Package
|
|
6
6
|
* chỉ cấp KIỂU và bảng tone, còn nhãn tiếng Việt là chuyện của app.
|
|
7
7
|
*
|
|
8
|
-
* Bắt buộc dùng class token (`bg-success/15 text-success`), KHÔNG hex — hex sẽ chết ở dark mode.
|
|
8
|
+
* Bắt buộc dùng class token (`bg-success/15 text-success-ink`), KHÔNG hex — hex sẽ chết ở dark mode.
|
|
9
9
|
*
|
|
10
10
|
* @example
|
|
11
11
|
* const TASK_STATUSES: StatusConfig<TaskStatus>[] = [
|
|
@@ -15,11 +15,18 @@
|
|
|
15
15
|
* ];
|
|
16
16
|
*/
|
|
17
17
|
interface StatusTone {
|
|
18
|
-
/** Lớp nền tint — token, vd "bg-success/15" */
|
|
18
|
+
/** Lớp nền tint — token TÔ NỀN, vd "bg-success/15" */
|
|
19
19
|
bgColor: string;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Lớp chữ — phải là bậc **ink** (`text-success-ink`), KHÔNG phải token tô nền (`text-success`).
|
|
22
|
+
* 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.
|
|
23
|
+
*/
|
|
21
24
|
textColor: string;
|
|
22
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* 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à
|
|
27
|
+
* 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
|
|
28
|
+
* `textColor` (`border-success-ink`), đừng hạ alpha xuống `/40`.
|
|
29
|
+
*/
|
|
23
30
|
borderColor?: string;
|
|
24
31
|
}
|
|
25
32
|
interface StatusConfig<T extends string = string> extends StatusTone {
|
|
@@ -37,37 +44,83 @@ interface StatusConfig<T extends string = string> extends StatusTone {
|
|
|
37
44
|
*
|
|
38
45
|
* Mọi chuỗi ở đây là class TĨNH để Tailwind quét được từ `dist`; tuyệt đối không sinh động
|
|
39
46
|
* kiểu `bg-${tone}/15` (class động bị loại khỏi bundle của app).
|
|
47
|
+
*
|
|
48
|
+
* ─ VÌ SAO CHỮ VÀ VIỀN ĐỀU LÀ BẬC `--x-ink`, KHÔNG PHẢI `--x` ─
|
|
49
|
+
*
|
|
50
|
+
* `--success/--warning/--info/--destructive` là token TÔ NỀN. Đặt chúng làm màu CHỮ thì bản light
|
|
51
|
+
* hỏng ở cả ba biến thể của `StatusPill` (đo theo WCAG, nền tint đã trộn với `--card` trắng):
|
|
52
|
+
*
|
|
53
|
+
* text-success trên bg-success/15 (#E0F2E9) = 2,69:1 ✗ (dưới cả ngưỡng 3:1)
|
|
54
|
+
* text-info trên bg-info/15 (#DDEDEE) = 3,44:1 ✗
|
|
55
|
+
* text-destructive trên bg-destructive/15 = 4,06:1 ✗
|
|
56
|
+
* text-success / text-info trên nền TRANG (outline, dot) = 3,13:1 / 4,14:1 ✗
|
|
57
|
+
*
|
|
58
|
+
* Đi qua bậc ink thì đạt ở CẢ hai theme (`--x-ink` ở `.dark` vốn trỏ về chính `--x`, nên không
|
|
59
|
+
* cần biến thể `dark:` nào):
|
|
60
|
+
*
|
|
61
|
+
* soft (trên tint của chính nó) outline · dot (trên nền trang)
|
|
62
|
+
* success 5,60:1 (L) · 7,44:1 (D) 6,52:1 (L) · 10,85:1 (D)
|
|
63
|
+
* info 5,54:1 (L) · 7,12:1 (D) 6,68:1 (L) · 10,16:1 (D)
|
|
64
|
+
* destructive 5,76:1 (L) · 5,71:1 (D) 7,15:1 (L) · 6,84:1 (D)
|
|
65
|
+
* warning 5,93:1 (L) · 7,19:1 (D) 6,51:1 (L) · 12,35:1 (D)
|
|
66
|
+
* neutral 4,89:1 (L) · 4,86:1 (D) 5,23:1 (L) · 5,70:1 (D)
|
|
67
|
+
* primary 5,77:1 (L) · 7,26:1 (D) 6,51:1 (L) · 12,67:1 (D)
|
|
68
|
+
*
|
|
69
|
+
* `warning` không còn cần cặp `text-gold-500 dark:text-warning`: `--warning-ink` ĐÚNG BẰNG
|
|
70
|
+
* `--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.
|
|
71
|
+
*
|
|
72
|
+
* ─ TONE `primary` ĐI THEO `--primary`, KHÔNG PHẢI THANG `gold-*` ─
|
|
73
|
+
* 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`.
|
|
74
|
+
* 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
|
|
75
|
+
* 11,90:1 cho ô dark, đó là số của `--primary-ink` trên `--card`, chép nhầm sang), nhưng SAI VAI
|
|
76
|
+
* TRÒ: thang `gold-*` không có bản dark nên phải viết hai lớp `dark:`, và app đổi `--primary` cho
|
|
77
|
+
* sản phẩm của mình thì chip "primary" vẫn cứ vàng. `bg-primary/20` + `text-primary-ink` đi theo
|
|
78
|
+
* 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
|
|
79
|
+
* 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,
|
|
80
|
+
* cùng mức với `warning` — vàng luôn cần thêm một nấc mới thấy được.
|
|
81
|
+
*
|
|
82
|
+
* ─ VIỀN: VÌ SAO BỎ HẲN ALPHA ─
|
|
83
|
+
* Ở `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
|
|
84
|
+
* 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:
|
|
85
|
+
*
|
|
86
|
+
* border-<tone>/40 (token NỀN) light 1,21–1,84:1 · dark 1,51–2,86:1 ✗
|
|
87
|
+
* border-<tone>/70 (token NỀN) light 1,38–3,09:1 · dark 2,38–6,45:1 ✗
|
|
88
|
+
* border-<tone>-ink/40 (bậc ink) light 1,88–1,99:1 · dark 2,02–2,86:1 ✗
|
|
89
|
+
* 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
|
|
90
|
+
*
|
|
91
|
+
* (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.)
|
|
92
|
+
* Viền full-strength bằng đúng màu chữ: 6,51–7,15:1 (light) · 6,84–12,67:1 (dark) ✓.
|
|
40
93
|
*/
|
|
41
94
|
declare const STATUS_TONES: {
|
|
42
95
|
readonly neutral: {
|
|
43
96
|
readonly bgColor: "bg-muted";
|
|
44
97
|
readonly textColor: "text-muted-foreground";
|
|
45
|
-
readonly borderColor: "border-
|
|
98
|
+
readonly borderColor: "border-muted-foreground";
|
|
46
99
|
};
|
|
47
100
|
readonly primary: {
|
|
48
|
-
readonly bgColor: "bg-
|
|
49
|
-
readonly textColor: "text-
|
|
50
|
-
readonly borderColor: "border-
|
|
101
|
+
readonly bgColor: "bg-primary/20";
|
|
102
|
+
readonly textColor: "text-primary-ink";
|
|
103
|
+
readonly borderColor: "border-primary-ink";
|
|
51
104
|
};
|
|
52
105
|
readonly success: {
|
|
53
106
|
readonly bgColor: "bg-success/15";
|
|
54
|
-
readonly textColor: "text-success";
|
|
55
|
-
readonly borderColor: "border-success
|
|
107
|
+
readonly textColor: "text-success-ink";
|
|
108
|
+
readonly borderColor: "border-success-ink";
|
|
56
109
|
};
|
|
57
110
|
readonly warning: {
|
|
58
111
|
readonly bgColor: "bg-warning/20";
|
|
59
|
-
readonly textColor: "text-
|
|
60
|
-
readonly borderColor: "border-warning
|
|
112
|
+
readonly textColor: "text-warning-ink";
|
|
113
|
+
readonly borderColor: "border-warning-ink";
|
|
61
114
|
};
|
|
62
115
|
readonly destructive: {
|
|
63
116
|
readonly bgColor: "bg-destructive/15";
|
|
64
|
-
readonly textColor: "text-destructive";
|
|
65
|
-
readonly borderColor: "border-destructive
|
|
117
|
+
readonly textColor: "text-destructive-ink";
|
|
118
|
+
readonly borderColor: "border-destructive-ink";
|
|
66
119
|
};
|
|
67
120
|
readonly info: {
|
|
68
121
|
readonly bgColor: "bg-info/15";
|
|
69
|
-
readonly textColor: "text-info";
|
|
70
|
-
readonly borderColor: "border-info
|
|
122
|
+
readonly textColor: "text-info-ink";
|
|
123
|
+
readonly borderColor: "border-info-ink";
|
|
71
124
|
};
|
|
72
125
|
};
|
|
73
126
|
type StatusToneName = keyof typeof STATUS_TONES;
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
const STATUS_TONES = {
|
|
2
|
-
neutral: {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
borderColor: "border-gold-300"
|
|
2
|
+
neutral: {
|
|
3
|
+
bgColor: "bg-muted",
|
|
4
|
+
textColor: "text-muted-foreground",
|
|
5
|
+
borderColor: "border-muted-foreground"
|
|
7
6
|
},
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
primary: { bgColor: "bg-primary/20", textColor: "text-primary-ink", borderColor: "border-primary-ink" },
|
|
8
|
+
success: { bgColor: "bg-success/15", textColor: "text-success-ink", borderColor: "border-success-ink" },
|
|
9
|
+
warning: { bgColor: "bg-warning/20", textColor: "text-warning-ink", borderColor: "border-warning-ink" },
|
|
10
10
|
destructive: {
|
|
11
11
|
bgColor: "bg-destructive/15",
|
|
12
|
-
textColor: "text-destructive",
|
|
13
|
-
borderColor: "border-destructive
|
|
12
|
+
textColor: "text-destructive-ink",
|
|
13
|
+
borderColor: "border-destructive-ink"
|
|
14
14
|
},
|
|
15
|
-
info: { bgColor: "bg-info/15", textColor: "text-info", borderColor: "border-info
|
|
15
|
+
info: { bgColor: "bg-info/15", textColor: "text-info-ink", borderColor: "border-info-ink" }
|
|
16
16
|
};
|
|
17
17
|
function getStatusConfig(configs, value) {
|
|
18
18
|
if (value == null) return void 0;
|