@comitor/ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -41
- package/dist/components/composite/avatar-group.js +1 -1
- package/dist/components/composite/avatar-group.js.map +1 -1
- package/dist/components/composite/combobox.js +2 -2
- package/dist/components/composite/combobox.js.map +1 -1
- package/dist/components/composite/confirm-dialog.js +1 -1
- package/dist/components/composite/confirm-dialog.js.map +1 -1
- package/dist/components/composite/copy-button.js +1 -1
- package/dist/components/composite/copy-button.js.map +1 -1
- package/dist/components/composite/data-table.d.ts +17 -8
- package/dist/components/composite/data-table.js +8 -8
- package/dist/components/composite/data-table.js.map +1 -1
- package/dist/components/composite/filter-chips.js +6 -5
- package/dist/components/composite/filter-chips.js.map +1 -1
- package/dist/components/composite/form-field.js +3 -1
- package/dist/components/composite/form-field.js.map +1 -1
- package/dist/components/composite/form-section.js +5 -5
- package/dist/components/composite/form-section.js.map +1 -1
- package/dist/components/composite/inline-edit.js +1 -1
- package/dist/components/composite/inline-edit.js.map +1 -1
- package/dist/components/composite/keyboard-hint.d.ts +16 -0
- package/dist/components/composite/keyboard-hint.js +8 -6
- package/dist/components/composite/keyboard-hint.js.map +1 -1
- package/dist/components/composite/letter-avatar.d.ts +10 -18
- package/dist/components/composite/letter-avatar.js +6 -17
- package/dist/components/composite/letter-avatar.js.map +1 -1
- package/dist/components/composite/search-filter-bar.d.ts +41 -3
- package/dist/components/composite/search-filter-bar.js +27 -15
- package/dist/components/composite/search-filter-bar.js.map +1 -1
- package/dist/components/composite/stat-card.d.ts +16 -1
- package/dist/components/composite/stat-card.js +16 -9
- package/dist/components/composite/stat-card.js.map +1 -1
- package/dist/components/composite/status-config.d.ts +69 -16
- package/dist/components/composite/status-config.js +10 -10
- package/dist/components/composite/status-config.js.map +1 -1
- package/dist/components/composite/status-pill.js +5 -1
- package/dist/components/composite/status-pill.js.map +1 -1
- package/dist/components/composite/table-pagination.d.ts +46 -4
- package/dist/components/composite/table-pagination.js +27 -24
- package/dist/components/composite/table-pagination.js.map +1 -1
- package/dist/components/composite/text-utils.d.ts +12 -11
- package/dist/components/composite/text-utils.js +7 -16
- package/dist/components/composite/text-utils.js.map +1 -1
- package/dist/components/composite/toast.js +16 -6
- package/dist/components/composite/toast.js.map +1 -1
- package/dist/components/ui/alert.js +1 -1
- package/dist/components/ui/alert.js.map +1 -1
- package/dist/components/ui/badge.js +4 -4
- package/dist/components/ui/badge.js.map +1 -1
- package/dist/components/ui/button.js +2 -2
- package/dist/components/ui/button.js.map +1 -1
- package/dist/components/ui/calendar.d.ts +41 -0
- package/dist/components/ui/calendar.js +1 -1
- package/dist/components/ui/calendar.js.map +1 -1
- package/dist/components/ui/checkbox.js +3 -3
- package/dist/components/ui/checkbox.js.map +1 -1
- package/dist/components/ui/command.js +1 -1
- package/dist/components/ui/command.js.map +1 -1
- package/dist/components/ui/context-menu.js +1 -1
- package/dist/components/ui/context-menu.js.map +1 -1
- package/dist/components/ui/drawer.js +1 -1
- package/dist/components/ui/drawer.js.map +1 -1
- package/dist/components/ui/dropdown-menu.js +1 -1
- package/dist/components/ui/dropdown-menu.js.map +1 -1
- package/dist/components/ui/empty.js +1 -1
- package/dist/components/ui/empty.js.map +1 -1
- package/dist/components/ui/field.js +4 -4
- package/dist/components/ui/field.js.map +1 -1
- package/dist/components/ui/form.js +8 -2
- package/dist/components/ui/form.js.map +1 -1
- package/dist/components/ui/input-group.js +3 -3
- package/dist/components/ui/input-group.js.map +1 -1
- package/dist/components/ui/input-otp.js +1 -1
- package/dist/components/ui/input-otp.js.map +1 -1
- package/dist/components/ui/input.js +3 -3
- package/dist/components/ui/input.js.map +1 -1
- package/dist/components/ui/item.js +1 -1
- package/dist/components/ui/item.js.map +1 -1
- package/dist/components/ui/menubar.js +1 -1
- package/dist/components/ui/menubar.js.map +1 -1
- package/dist/components/ui/navigation-menu.js +1 -1
- package/dist/components/ui/navigation-menu.js.map +1 -1
- package/dist/components/ui/pagination.js +1 -1
- package/dist/components/ui/pagination.js.map +1 -1
- package/dist/components/ui/progress.d.ts +26 -0
- package/dist/components/ui/progress.js +5 -2
- package/dist/components/ui/progress.js.map +1 -1
- package/dist/components/ui/radio-group.js +2 -2
- package/dist/components/ui/radio-group.js.map +1 -1
- package/dist/components/ui/resizable.d.ts +14 -0
- package/dist/components/ui/resizable.js +2 -2
- package/dist/components/ui/resizable.js.map +1 -1
- package/dist/components/ui/scroll-area.js +1 -1
- package/dist/components/ui/scroll-area.js.map +1 -1
- package/dist/components/ui/select.d.ts +33 -0
- package/dist/components/ui/select.js +3 -3
- package/dist/components/ui/select.js.map +1 -1
- package/dist/components/ui/slider.d.ts +19 -0
- package/dist/components/ui/slider.js +3 -3
- package/dist/components/ui/slider.js.map +1 -1
- package/dist/components/ui/sonner.d.ts +21 -3
- package/dist/components/ui/sonner.js +1 -2
- package/dist/components/ui/sonner.js.map +1 -1
- package/dist/components/ui/switch.js +2 -2
- package/dist/components/ui/switch.js.map +1 -1
- package/dist/components/ui/table.d.ts +16 -0
- package/dist/components/ui/table.js +1 -1
- package/dist/components/ui/table.js.map +1 -1
- package/dist/components/ui/tabs.js +1 -1
- package/dist/components/ui/tabs.js.map +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/textarea.js.map +1 -1
- package/dist/components/ui/toggle.js +1 -1
- package/dist/components/ui/toggle.js.map +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.js +17 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/avatar-tone.d.ts +126 -0
- package/dist/lib/avatar-tone.js +68 -0
- package/dist/lib/avatar-tone.js.map +1 -0
- package/dist/shell/app-header.js +12 -2
- package/dist/shell/app-header.js.map +1 -1
- package/dist/shell/app-launcher.js +2 -2
- package/dist/shell/app-launcher.js.map +1 -1
- package/dist/shell/index.d.ts +3 -3
- package/dist/shell/shell-context.d.ts +1 -7
- package/dist/shell/shell-context.js +1 -19
- package/dist/shell/shell-context.js.map +1 -1
- package/dist/shell/sidebar-group.js +5 -3
- package/dist/shell/sidebar-group.js.map +1 -1
- package/dist/shell/sidebar-menu-item.js +12 -4
- package/dist/shell/sidebar-menu-item.js.map +1 -1
- package/dist/shell/types.d.ts +17 -1
- package/dist/shell/types.js.map +1 -1
- package/dist/shell/user-menu.js +3 -2
- package/dist/shell/user-menu.js.map +1 -1
- package/dist/shell/utils.d.ts +10 -22
- package/dist/shell/utils.js +3 -11
- package/dist/shell/utils.js.map +1 -1
- package/dist/shell/workspace-switcher.js +23 -8
- package/dist/shell/workspace-switcher.js.map +1 -1
- package/dist/tokens.d.ts +165 -17
- package/dist/tokens.js +122 -25
- package/dist/tokens.js.map +1 -1
- package/package.json +4 -2
- package/styles.css +312 -22
|
@@ -3,9 +3,25 @@ import * as React from 'react';
|
|
|
3
3
|
/**
|
|
4
4
|
* Máy đang chạy có phải macOS không — chỉ biết được ở phía client.
|
|
5
5
|
*
|
|
6
|
+
* ⚠ CÀI ĐẶT DUY NHẤT **và CỬA XUẤT DUY NHẤT** trong cả gói: entry `.` (`@comitor/ui`).
|
|
7
|
+
* 0.1.0 xuất tên này ở cả `@comitor/ui/shell` với một thân hàm KHÁC (useState+useEffect đọc
|
|
8
|
+
* `userAgent`) — cùng một tên, hai hành vi hydrate, mà `export *` thì im lặng tuyệt đối với
|
|
9
|
+
* trùng tên XUYÊN entry. 0.2.0 bỏ hẳn cửa `/shell`: tầng shell vẫn dùng hàm này (`AppHeader`)
|
|
10
|
+
* nhưng **import nội bộ**, không xuất lại.
|
|
11
|
+
*
|
|
12
|
+
* Vì sao giữ ở `.` chứ không phải `/shell`: (a) thân hàm buộc phải nằm ở Tier 2 —
|
|
13
|
+
* `KeyboardHint` không được phép kéo theo peer `next` của `/shell`; (b) app dùng `/shell`
|
|
14
|
+
* đằng nào cũng đã import `.` cho `Button`/`Badge`, còn app KHÔNG dùng shell thì `/shell` là
|
|
15
|
+
* cửa đóng — đặt ở đó là nhốt hook sau một peer optional mà nó không cần.
|
|
16
|
+
*
|
|
6
17
|
* Trả về `false` ở lần render đầu (kể cả trên máy Mac) là CHỦ Ý: server không biết hệ điều hành
|
|
7
18
|
* của người dùng, nếu đoán bừa thì HTML server và client lệch nhau → React cảnh báo hydration
|
|
8
19
|
* và đôi khi vứt cả cây con. Hiện "Ctrl" trong một khung hình rồi đổi thành "⌘" là cái giá rẻ hơn.
|
|
20
|
+
* Dùng `useSyncExternalStore` (snapshot phía server = `false`) thay cho cặp `useState`+`useEffect`:
|
|
21
|
+
* React tự khớp server/client, không cần cờ `mounted` thủ công.
|
|
22
|
+
*
|
|
23
|
+
* Đọc `navigator.platform` trước rồi mới tới `userAgent`: Safari/Chrome bản mới đã rút gọn
|
|
24
|
+
* userAgent, còn `platform` vẫn trả "MacIntel" (kể cả iPadOS ở chế độ desktop).
|
|
9
25
|
*/
|
|
10
26
|
declare function useIsMac(): boolean;
|
|
11
27
|
interface KeyboardHintProps {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
4
|
import { cn } from "../../lib/cn.js";
|
|
5
5
|
import { Kbd, KbdGroup } from "../ui/kbd.js";
|
|
6
|
+
const emptySubscribe = () => () => {
|
|
7
|
+
};
|
|
6
8
|
function useIsMac() {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
return useSyncExternalStore(
|
|
10
|
+
emptySubscribe,
|
|
11
|
+
() => /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent),
|
|
12
|
+
() => false
|
|
13
|
+
);
|
|
12
14
|
}
|
|
13
15
|
const MAC_SYMBOLS = {
|
|
14
16
|
mod: "\u2318",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/keyboard-hint.tsx"],"sourcesContent":["\"use client\";\n\nimport {
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/keyboard-hint.tsx"],"sourcesContent":["\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Kbd, KbdGroup } from \"../ui/kbd.js\";\n\n/** Không có gì để đăng ký lắng nghe: hệ điều hành không đổi giữa chừng. */\nconst emptySubscribe = () => () => {};\n\n/**\n * Máy đang chạy có phải macOS không — chỉ biết được ở phía client.\n *\n * ⚠ CÀI ĐẶT DUY NHẤT **và CỬA XUẤT DUY NHẤT** trong cả gói: entry `.` (`@comitor/ui`).\n * 0.1.0 xuất tên này ở cả `@comitor/ui/shell` với một thân hàm KHÁC (useState+useEffect đọc\n * `userAgent`) — cùng một tên, hai hành vi hydrate, mà `export *` thì im lặng tuyệt đối với\n * trùng tên XUYÊN entry. 0.2.0 bỏ hẳn cửa `/shell`: tầng shell vẫn dùng hàm này (`AppHeader`)\n * nhưng **import nội bộ**, không xuất lại.\n *\n * Vì sao giữ ở `.` chứ không phải `/shell`: (a) thân hàm buộc phải nằm ở Tier 2 —\n * `KeyboardHint` không được phép kéo theo peer `next` của `/shell`; (b) app dùng `/shell`\n * đằng nào cũng đã import `.` cho `Button`/`Badge`, còn app KHÔNG dùng shell thì `/shell` là\n * cửa đóng — đặt ở đó là nhốt hook sau một peer optional mà nó không cần.\n *\n * Trả về `false` ở lần render đầu (kể cả trên máy Mac) là CHỦ Ý: server không biết hệ điều hành\n * của người dùng, nếu đoán bừa thì HTML server và client lệch nhau → React cảnh báo hydration\n * và đôi khi vứt cả cây con. Hiện \"Ctrl\" trong một khung hình rồi đổi thành \"⌘\" là cái giá rẻ hơn.\n * Dùng `useSyncExternalStore` (snapshot phía server = `false`) thay cho cặp `useState`+`useEffect`:\n * React tự khớp server/client, không cần cờ `mounted` thủ công.\n *\n * Đọc `navigator.platform` trước rồi mới tới `userAgent`: Safari/Chrome bản mới đã rút gọn\n * userAgent, còn `platform` vẫn trả \"MacIntel\" (kể cả iPadOS ở chế độ desktop).\n */\nexport function useIsMac(): boolean {\n return useSyncExternalStore(\n emptySubscribe,\n () => /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent),\n () => false\n );\n}\n\nconst MAC_SYMBOLS: Record<string, string> = {\n mod: \"⌘\",\n cmd: \"⌘\",\n meta: \"⌘\",\n ctrl: \"⌃\",\n control: \"⌃\",\n alt: \"⌥\",\n option: \"⌥\",\n shift: \"⇧\"\n};\n\nconst OTHER_LABELS: Record<string, string> = {\n mod: \"Ctrl\",\n cmd: \"Ctrl\",\n meta: \"Ctrl\",\n ctrl: \"Ctrl\",\n control: \"Ctrl\",\n alt: \"Alt\",\n option: \"Alt\",\n shift: \"Shift\"\n};\n\nconst COMMON_LABELS: Record<string, string> = {\n enter: \"↵\",\n return: \"↵\",\n esc: \"Esc\",\n escape: \"Esc\",\n space: \"Space\",\n tab: \"Tab\",\n backspace: \"⌫\",\n delete: \"Del\",\n up: \"↑\",\n down: \"↓\",\n left: \"←\",\n right: \"→\"\n};\n\n/** Chuyển một token phím thành nhãn hiển thị theo nền tảng. */\nfunction formatKey(token: string, isMac: boolean): string {\n const key = token.trim().toLowerCase();\n if (!key) return \"\";\n const modifier = isMac ? MAC_SYMBOLS[key] : OTHER_LABELS[key];\n if (modifier) return modifier;\n const common = COMMON_LABELS[key];\n if (common) return common;\n return key.length === 1 ? key.toUpperCase() : token.trim();\n}\n\nexport interface KeyboardHintProps {\n /**\n * Tổ hợp phím dạng chuỗi, vd `\"mod+k\"`, `\"mod+shift+n\"`, `\"esc\"`.\n *\n * Dùng `mod` cho phím lệnh chính: render thành `⌘` trên macOS và `Ctrl` ở nơi khác — viết\n * cứng \"Ctrl+K\" trong giao diện là sai với một nửa người dùng.\n */\n keys: string;\n /** Gộp các phím vào MỘT ô thay vì mỗi phím một ô (kiểu \"⌘K\") */\n compact?: boolean;\n className?: string;\n}\n\n/**\n * Gợi ý phím tắt hiển thị đúng theo nền tảng.\n *\n * `aria-hidden` toàn bộ: ký hiệu ⌘⇧↵ đọc lên nghe vô nghĩa. Nút mang phím tắt nên tự mô tả\n * trong `aria-label` (vd \"Mở bảng lệnh, Ctrl K\").\n */\nexport function KeyboardHint({ keys, compact = false, className }: KeyboardHintProps) {\n const isMac = useIsMac();\n const tokens = keys\n .split(\"+\")\n .map((token) => formatKey(token, isMac))\n .filter(Boolean);\n\n if (tokens.length === 0) return null;\n\n if (compact) {\n return (\n <Kbd aria-hidden=\"true\" className={className}>\n {isMac ? tokens.join(\"\") : tokens.join(\"+\")}\n </Kbd>\n );\n }\n\n return (\n <KbdGroup aria-hidden=\"true\" className={cn(\"shrink-0\", className)}>\n {tokens.map((token, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: token phím có thể trùng nhau và không có id riêng\n <Kbd key={`${index}-${token}`}>{token}</Kbd>\n ))}\n </KbdGroup>\n );\n}\n"],"mappings":";AAsHM;AApHN,SAAS,4BAA4B;AACrC,SAAS,UAAU;AACnB,SAAS,KAAK,gBAAgB;AAG9B,MAAM,iBAAiB,MAAM,MAAM;AAAC;AAyB7B,SAAS,WAAoB;AAClC,SAAO;AAAA,IACL;AAAA,IACA,MAAM,wBAAwB,KAAK,UAAU,YAAY,UAAU,SAAS;AAAA,IAC5E,MAAM;AAAA,EACR;AACF;AAEA,MAAM,cAAsC;AAAA,EAC1C,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,MAAM;AAAA,EACN,SAAS;AAAA,EACT,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AACT;AAEA,MAAM,eAAuC;AAAA,EAC3C,KAAK;AAAA,EACL,KAAK;AAAA,EACL,MAAM;AAAA,EACN,MAAM;AAAA,EACN,SAAS;AAAA,EACT,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AACT;AAEA,MAAM,gBAAwC;AAAA,EAC5C,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,KAAK;AAAA,EACL,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,KAAK;AAAA,EACL,WAAW;AAAA,EACX,QAAQ;AAAA,EACR,IAAI;AAAA,EACJ,MAAM;AAAA,EACN,MAAM;AAAA,EACN,OAAO;AACT;AAGA,SAAS,UAAU,OAAe,OAAwB;AACxD,QAAM,MAAM,MAAM,KAAK,EAAE,YAAY;AACrC,MAAI,CAAC,IAAK,QAAO;AACjB,QAAM,WAAW,QAAQ,YAAY,GAAG,IAAI,aAAa,GAAG;AAC5D,MAAI,SAAU,QAAO;AACrB,QAAM,SAAS,cAAc,GAAG;AAChC,MAAI,OAAQ,QAAO;AACnB,SAAO,IAAI,WAAW,IAAI,IAAI,YAAY,IAAI,MAAM,KAAK;AAC3D;AAqBO,SAAS,aAAa,EAAE,MAAM,UAAU,OAAO,UAAU,GAAsB;AACpF,QAAM,QAAQ,SAAS;AACvB,QAAM,SAAS,KACZ,MAAM,GAAG,EACT,IAAI,CAAC,UAAU,UAAU,OAAO,KAAK,CAAC,EACtC,OAAO,OAAO;AAEjB,MAAI,OAAO,WAAW,EAAG,QAAO;AAEhC,MAAI,SAAS;AACX,WACE,oBAAC,OAAI,eAAY,QAAO,WACrB,kBAAQ,OAAO,KAAK,EAAE,IAAI,OAAO,KAAK,GAAG,GAC5C;AAAA,EAEJ;AAEA,SACE,oBAAC,YAAS,eAAY,QAAO,WAAW,GAAG,YAAY,SAAS,GAC7D,iBAAO,IAAI,CAAC,OAAO;AAAA;AAAA,IAElB,oBAAC,OAA+B,mBAAtB,GAAG,KAAK,IAAI,KAAK,EAAW;AAAA,GACvC,GACH;AAEJ;","names":[]}
|
|
@@ -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":";AAmDI,mBACE,KADF;AAjDJ,SAAS,QAAQ,mBAAmB,SAAS;AAC7C,SAAkE,WAAW,QAAQ,gBAAgB;AACrG,SAAS,UAAU;AACnB,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,aAAa;AAqCf,MAAM,mCAA0D;AAAA,EACrE,mBAAmB;AAAA,EACnB,WAAW;AAAA,EACX,aAAa;AAAA,EACb,aAAa,CAAC,UAAU,GAAG,KAAK;AAAA,EAChC,cAAc;AAAA,EACd,eAAe,CAAC,aAAa,OAAO,cAClC,iCACE;AAAA,wBAAC,YAAO,WAAU,4CAA4C,uBAAY;AAAA,IACzE,UAAU,SAAY,MAAM,KAAK,KAAK;AAAA,IAAG;AAAA,IAAE;AAAA,KAC9C;AAEJ;AAwCO,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,QAAQ;AAAA,EACR;AACF,GAAyB;AACvB,QAAM,SAAS,EAAE,GAAG,kCAAkC,GAAG,WAAW;AACpE,QAAM,cAAc,qBAAqB,OAAO;AAChD,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,WAAW;AACxD,QAAM,iBAAiB,OAAO,WAAW;AACzC,QAAM,WAAW,OAA6C,IAAI;AAClE,QAAM,oBAAoB,OAAO,cAAc;AAE/C,YAAU,MAAM;AACd,sBAAkB,UAAU;AAAA,EAC9B,CAAC;AAID,YAAU,MAAM;AACd,QAAI,gBAAgB,eAAe,SAAS;AAC1C,qBAAe,UAAU;AACzB,oBAAc,WAAW;AACzB,UAAI,SAAS,SAAS;AACpB,qBAAa,SAAS,OAAO;AAC7B,iBAAS,UAAU;AAAA,MACrB;AAAA,IACF;AAAA,EACF,GAAG,CAAC,WAAW,CAAC;AAEhB;AAAA,IACE,MAAM,MAAM;AACV,UAAI,SAAS,QAAS,cAAa,SAAS,OAAO;AAAA,IACrD;AAAA,IACA,CAAC;AAAA,EACH;AAEA,QAAM,OAAO,CAAC,UAAkB;AAC9B,kBAAc,KAAK;AACnB,QAAI,SAAS,QAAS,cAAa,SAAS,OAAO;AACnD,QAAI,CAAC,YAAY;AACf,qBAAe,UAAU;AACzB,wBAAkB,QAAQ,KAAK;AAC/B;AAAA,IACF;AACA,aAAS,UAAU,WAAW,MAAM;AAClC,qBAAe,UAAU;AACzB,wBAAkB,QAAQ,KAAK;AAAA,IACjC,GAAG,UAAU;AAAA,EACf;AAEA,QAAM,cAAc,MAAM;AACxB,QAAI,SAAS,SAAS;AACpB,mBAAa,SAAS,OAAO;AAC7B,eAAS,UAAU;AAAA,IACrB;AACA,kBAAc,EAAE;AAChB,mBAAe,UAAU;AACzB,sBAAkB,QAAQ,EAAE;AAAA,EAC9B;AAEA,QAAM,aAAa,oBAAoB;AACvC,QAAM,YAAY,cAAc,QAAQ,UAAU;AAElD,SACE,qBAAC,SAAI,aAAU,qBAAoB,WAAW,GAAG,uBAAuB,SAAS,GAC/E;AAAA,yBAAC,SAAI,WAAU,qCACb;AAAA,2BAAC,SAAI,WAAU,6CACb;AAAA;AAAA,UAAC;AAAA;AAAA,YACC,eAAY;AAAA,YACZ,WAAU;AAAA;AAAA,QACZ;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,MAAK;AAAA,YACL,OAAO;AAAA,YACP;AAAA,YACA,cAAY;AAAA,YACZ,UAAU,CAAC,UAAU,KAAK,MAAM,OAAO,KAAK;AAAA,YAC5C,WAAU;AAAA,YACT,GAAG;AAAA;AAAA,QACN;AAAA,QACC,cACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAQ;AAAA,YACR,MAAK;AAAA,YACL,SAAS;AAAA,YACT,WAAU;AAAA,YAEV;AAAA,kCAAC,KAAE,eAAY,QAAO,WAAU,YAAW;AAAA,cAC3C,oBAAC,UAAK,WAAU,WAAW,iBAAO,aAAY;AAAA;AAAA;AAAA,QAChD;AAAA,SAEJ;AAAA,MAEC;AAAA,MAEA,cACC,qBAAC,SAAM,SAAQ,aAAY,WAAU,SACnC;AAAA,4BAAC,qBAAkB,eAAY,QAAO;AAAA,QACrC,OAAO,YAAY,iBAAiB;AAAA,SACvC;AAAA,MAGD,WAAW,oBAAC,SAAI,WAAU,mCAAmC,mBAAQ;AAAA,OACxE;AAAA,IAEC,aACC,qBAAC,SAAI,MAAK,UAAS,WAAU,iEAC1B;AAAA,sBAAgB,UAAa,oBAAC,UAAM,iBAAO,cAAc,aAAa,OAAO,SAAS,GAAE;AAAA,MACxF,cAAc,kBACb,qBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,MAAK,MAAK,SAAS,gBAAgB,WAAU,oBACjF;AAAA,4BAAC,KAAE,eAAY,QAAO,WAAU,YAAW;AAAA,QAC1C,OAAO;AAAA,SACV;AAAA,OAEJ;AAAA,KAEJ;AAEJ;","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":[]}
|