@comitor/ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/README.md +222 -41
  2. package/dist/components/composite/avatar-group.js +1 -1
  3. package/dist/components/composite/avatar-group.js.map +1 -1
  4. package/dist/components/composite/combobox.js +2 -2
  5. package/dist/components/composite/combobox.js.map +1 -1
  6. package/dist/components/composite/confirm-dialog.js +1 -1
  7. package/dist/components/composite/confirm-dialog.js.map +1 -1
  8. package/dist/components/composite/copy-button.js +1 -1
  9. package/dist/components/composite/copy-button.js.map +1 -1
  10. package/dist/components/composite/data-table.d.ts +17 -8
  11. package/dist/components/composite/data-table.js +8 -8
  12. package/dist/components/composite/data-table.js.map +1 -1
  13. package/dist/components/composite/filter-chips.js +6 -5
  14. package/dist/components/composite/filter-chips.js.map +1 -1
  15. package/dist/components/composite/form-field.js +3 -1
  16. package/dist/components/composite/form-field.js.map +1 -1
  17. package/dist/components/composite/form-section.js +5 -5
  18. package/dist/components/composite/form-section.js.map +1 -1
  19. package/dist/components/composite/inline-edit.js +1 -1
  20. package/dist/components/composite/inline-edit.js.map +1 -1
  21. package/dist/components/composite/keyboard-hint.d.ts +16 -0
  22. package/dist/components/composite/keyboard-hint.js +8 -6
  23. package/dist/components/composite/keyboard-hint.js.map +1 -1
  24. package/dist/components/composite/letter-avatar.d.ts +10 -18
  25. package/dist/components/composite/letter-avatar.js +6 -17
  26. package/dist/components/composite/letter-avatar.js.map +1 -1
  27. package/dist/components/composite/search-filter-bar.d.ts +41 -3
  28. package/dist/components/composite/search-filter-bar.js +27 -15
  29. package/dist/components/composite/search-filter-bar.js.map +1 -1
  30. package/dist/components/composite/stat-card.d.ts +16 -1
  31. package/dist/components/composite/stat-card.js +16 -9
  32. package/dist/components/composite/stat-card.js.map +1 -1
  33. package/dist/components/composite/status-config.d.ts +69 -16
  34. package/dist/components/composite/status-config.js +10 -10
  35. package/dist/components/composite/status-config.js.map +1 -1
  36. package/dist/components/composite/status-pill.js +5 -1
  37. package/dist/components/composite/status-pill.js.map +1 -1
  38. package/dist/components/composite/table-pagination.d.ts +46 -4
  39. package/dist/components/composite/table-pagination.js +27 -24
  40. package/dist/components/composite/table-pagination.js.map +1 -1
  41. package/dist/components/composite/text-utils.d.ts +12 -11
  42. package/dist/components/composite/text-utils.js +7 -16
  43. package/dist/components/composite/text-utils.js.map +1 -1
  44. package/dist/components/composite/toast.js +16 -6
  45. package/dist/components/composite/toast.js.map +1 -1
  46. package/dist/components/ui/alert.js +1 -1
  47. package/dist/components/ui/alert.js.map +1 -1
  48. package/dist/components/ui/badge.js +4 -4
  49. package/dist/components/ui/badge.js.map +1 -1
  50. package/dist/components/ui/button.js +2 -2
  51. package/dist/components/ui/button.js.map +1 -1
  52. package/dist/components/ui/calendar.d.ts +41 -0
  53. package/dist/components/ui/calendar.js +1 -1
  54. package/dist/components/ui/calendar.js.map +1 -1
  55. package/dist/components/ui/checkbox.js +3 -3
  56. package/dist/components/ui/checkbox.js.map +1 -1
  57. package/dist/components/ui/command.js +1 -1
  58. package/dist/components/ui/command.js.map +1 -1
  59. package/dist/components/ui/context-menu.js +1 -1
  60. package/dist/components/ui/context-menu.js.map +1 -1
  61. package/dist/components/ui/drawer.js +1 -1
  62. package/dist/components/ui/drawer.js.map +1 -1
  63. package/dist/components/ui/dropdown-menu.js +1 -1
  64. package/dist/components/ui/dropdown-menu.js.map +1 -1
  65. package/dist/components/ui/empty.js +1 -1
  66. package/dist/components/ui/empty.js.map +1 -1
  67. package/dist/components/ui/field.js +4 -4
  68. package/dist/components/ui/field.js.map +1 -1
  69. package/dist/components/ui/form.js +8 -2
  70. package/dist/components/ui/form.js.map +1 -1
  71. package/dist/components/ui/input-group.js +3 -3
  72. package/dist/components/ui/input-group.js.map +1 -1
  73. package/dist/components/ui/input-otp.js +1 -1
  74. package/dist/components/ui/input-otp.js.map +1 -1
  75. package/dist/components/ui/input.js +3 -3
  76. package/dist/components/ui/input.js.map +1 -1
  77. package/dist/components/ui/item.js +1 -1
  78. package/dist/components/ui/item.js.map +1 -1
  79. package/dist/components/ui/menubar.js +1 -1
  80. package/dist/components/ui/menubar.js.map +1 -1
  81. package/dist/components/ui/navigation-menu.js +1 -1
  82. package/dist/components/ui/navigation-menu.js.map +1 -1
  83. package/dist/components/ui/pagination.js +1 -1
  84. package/dist/components/ui/pagination.js.map +1 -1
  85. package/dist/components/ui/progress.d.ts +26 -0
  86. package/dist/components/ui/progress.js +5 -2
  87. package/dist/components/ui/progress.js.map +1 -1
  88. package/dist/components/ui/radio-group.js +2 -2
  89. package/dist/components/ui/radio-group.js.map +1 -1
  90. package/dist/components/ui/resizable.d.ts +14 -0
  91. package/dist/components/ui/resizable.js +2 -2
  92. package/dist/components/ui/resizable.js.map +1 -1
  93. package/dist/components/ui/scroll-area.js +1 -1
  94. package/dist/components/ui/scroll-area.js.map +1 -1
  95. package/dist/components/ui/select.d.ts +33 -0
  96. package/dist/components/ui/select.js +3 -3
  97. package/dist/components/ui/select.js.map +1 -1
  98. package/dist/components/ui/slider.d.ts +19 -0
  99. package/dist/components/ui/slider.js +3 -3
  100. package/dist/components/ui/slider.js.map +1 -1
  101. package/dist/components/ui/sonner.d.ts +21 -3
  102. package/dist/components/ui/sonner.js +1 -2
  103. package/dist/components/ui/sonner.js.map +1 -1
  104. package/dist/components/ui/switch.js +2 -2
  105. package/dist/components/ui/switch.js.map +1 -1
  106. package/dist/components/ui/table.d.ts +16 -0
  107. package/dist/components/ui/table.js +1 -1
  108. package/dist/components/ui/table.js.map +1 -1
  109. package/dist/components/ui/tabs.js +1 -1
  110. package/dist/components/ui/tabs.js.map +1 -1
  111. package/dist/components/ui/textarea.js +1 -1
  112. package/dist/components/ui/textarea.js.map +1 -1
  113. package/dist/components/ui/toggle.js +1 -1
  114. package/dist/components/ui/toggle.js.map +1 -1
  115. package/dist/index.d.ts +6 -5
  116. package/dist/index.js +17 -2
  117. package/dist/index.js.map +1 -1
  118. package/dist/lib/avatar-tone.d.ts +126 -0
  119. package/dist/lib/avatar-tone.js +68 -0
  120. package/dist/lib/avatar-tone.js.map +1 -0
  121. package/dist/shell/app-header.js +12 -2
  122. package/dist/shell/app-header.js.map +1 -1
  123. package/dist/shell/app-launcher.js +2 -2
  124. package/dist/shell/app-launcher.js.map +1 -1
  125. package/dist/shell/index.d.ts +3 -3
  126. package/dist/shell/shell-context.d.ts +1 -7
  127. package/dist/shell/shell-context.js +1 -19
  128. package/dist/shell/shell-context.js.map +1 -1
  129. package/dist/shell/sidebar-group.js +5 -3
  130. package/dist/shell/sidebar-group.js.map +1 -1
  131. package/dist/shell/sidebar-menu-item.js +12 -4
  132. package/dist/shell/sidebar-menu-item.js.map +1 -1
  133. package/dist/shell/types.d.ts +17 -1
  134. package/dist/shell/types.js.map +1 -1
  135. package/dist/shell/user-menu.js +3 -2
  136. package/dist/shell/user-menu.js.map +1 -1
  137. package/dist/shell/utils.d.ts +10 -22
  138. package/dist/shell/utils.js +3 -11
  139. package/dist/shell/utils.js.map +1 -1
  140. package/dist/shell/workspace-switcher.js +23 -8
  141. package/dist/shell/workspace-switcher.js.map +1 -1
  142. package/dist/tokens.d.ts +165 -17
  143. package/dist/tokens.js +122 -25
  144. package/dist/tokens.js.map +1 -1
  145. package/package.json +4 -2
  146. package/styles.css +312 -22
@@ -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 { useEffect, useState } from "react";
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
- const [isMac, setIsMac] = useState(false);
8
- useEffect(() => {
9
- setIsMac(/mac|iphone|ipad|ipod/i.test(navigator.userAgent));
10
- }, []);
11
- return isMac;
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 { useEffect, useState } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Kbd, KbdGroup } from \"../ui/kbd.js\";\n\n/**\n * Máy đang chạy có phải macOS không — chỉ biết được ở phía client.\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 */\nexport function useIsMac(): boolean {\n const [isMac, setIsMac] = useState(false);\n\n useEffect(() => {\n setIsMac(/mac|iphone|ipad|ipod/i.test(navigator.userAgent));\n }, []);\n\n return isMac;\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":";AAqGM;AAnGN,SAAS,WAAW,gBAAgB;AACpC,SAAS,UAAU;AACnB,SAAS,KAAK,gBAAgB;AASvB,SAAS,WAAoB;AAClC,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAS,KAAK;AAExC,YAAU,MAAM;AACd,aAAS,wBAAwB,KAAK,UAAU,SAAS,CAAC;AAAA,EAC5D,GAAG,CAAC,CAAC;AAEL,SAAO;AACT;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":[]}
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
- * 8 tone màu deterministic — TẤT CẢ là class token tĩnh.
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
- * Vì sao không sinh `bg-${color}-100`: Tailwind quét tĩnh, class ghép chuỗi lúc chạy sẽ không
9
- * có mặt trong CSS của app → avatar mất nền. Vì sao không dùng hex inline: mất dark mode.
10
- * Mỗi tone đã chọn cặp nền/chữ đạt tương phản ở cả hai theme.
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
- * ⚠ Đây là NGUỒN SỰ THẬT DUY NHẤT cho màu avatar chữ cái của cả package: tầng shell
13
- * (`shell/utils.ts` → `getAvatarToneClass`) gọi lại `getLetterAvatarTone` chứ không giữ
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 const LETTER_AVATAR_TONES: readonly ["bg-gold-100 text-gold-600 dark:bg-gold-500/30 dark:text-gold-100", "bg-teal/15 text-teal", "bg-green/15 text-green", "bg-red/15 text-red", "bg-yellow/25 text-gold-500 dark:bg-yellow/20 dark:text-yellow", "bg-chart-3/15 text-chart-3", "bg-ash-100 text-ash-700 dark:bg-ash-500/25 dark:text-ash-100", "bg-ink/10 text-ink-soft dark:bg-paper/15 dark:text-paper"];
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 { LETTER_AVATAR_TONES, LetterAvatar, type LetterAvatarProps, type LetterAvatarSize, getLetterAvatarTone };
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, hashString } from "./text-utils.js";
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 getLetterAvatarTone(name) {
22
- const tones = LETTER_AVATAR_TONES;
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 ?? getLetterAvatarTone(name);
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
- getLetterAvatarTone
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, hashString } from \"./text-utils.js\";\n\nexport type LetterAvatarSize = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n\n/**\n * 8 tone màu deterministic — TẤT CẢ là class token tĩnh.\n *\n * Vì sao không sinh `bg-${color}-100`: Tailwind quét tĩnh, class ghép chuỗi lúc chạy sẽ không\n * có mặt trong CSS của app → avatar mất nền. Vì sao không dùng hex inline: mất dark mode.\n * Mỗi tone đã chọn cặp nền/chữ đạt tương phản ở cả hai theme.\n *\n * ⚠ Đây là NGUỒN SỰ THẬT DUY NHẤT cho màu avatar chữ cái của cả package: tầng shell\n * (`shell/utils.ts` → `getAvatarToneClass`) gọi lại `getLetterAvatarTone` chứ không giữ\n * 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\n * trang là lỗi đã từng xảy ra ở 0.1.0.\n */\nexport const LETTER_AVATAR_TONES = [\n \"bg-gold-100 text-gold-600 dark:bg-gold-500/30 dark:text-gold-100\",\n \"bg-teal/15 text-teal\",\n \"bg-green/15 text-green\",\n \"bg-red/15 text-red\",\n \"bg-yellow/25 text-gold-500 dark:bg-yellow/20 dark:text-yellow\",\n \"bg-chart-3/15 text-chart-3\",\n \"bg-ash-100 text-ash-700 dark:bg-ash-500/25 dark:text-ash-100\",\n \"bg-ink/10 text-ink-soft dark:bg-paper/15 dark:text-paper\"\n] as const;\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 * 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.\n *\n * SEED là TÊN (đã trim + hạ chữ thường theo locale vi-VN), không phải email/slug: mọi nơi\n * hiển thị avatar đều chắc chắn có `name`, còn email/slug thì không — lấy khoá khác nhau ở\n * từng chỗ là cách nhanh nhất để cùng một người ra hai màu.\n */\nexport function getLetterAvatarTone(name: string): string {\n const tones = LETTER_AVATAR_TONES;\n return tones[hashString(name.trim().toLocaleLowerCase(\"vi-VN\")) % tones.length] ?? tones[0];\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 ?? getLetterAvatarTone(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":"AAiFI,SAiBI,KAjBJ;AAhFJ,SAAS,UAAU;AACnB,SAAS,uBAAuB,kBAAkB;AAgB3C,MAAM,sBAAsB;AAAA,EACjC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,MAAM,cAAgD;AAAA,EACpD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AASO,SAAS,oBAAoB,MAAsB;AACxD,QAAM,QAAQ;AACd,SAAO,MAAM,WAAW,KAAK,KAAK,EAAE,kBAAkB,OAAO,CAAC,IAAI,MAAM,MAAM,KAAK,MAAM,CAAC;AAC5F;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,oBAAoB,IAAI;AAEtD,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
+ {"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, ReactNode } from 'react';
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 = "T\xECm ki\u1EBFm\u2026",
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 = "m\u1EE5c",
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: searchPlaceholder,
89
- "aria-label": searchPlaceholder,
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: "X\xF3a t\u1EEB kh\xF3a" })
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__ */ jsxs("span", { children: [
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
- "X\xF3a l\u1ECDc"
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":";AAwHU,cAeE,YAfF;AAtHV,SAAS,QAAQ,mBAAmB,SAAS;AAC7C,SAAkE,WAAW,QAAQ,gBAAgB;AACrG,SAAS,UAAU;AACnB,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,aAAa;AAqCf,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,oBAAoB;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ;AACF,GAAyB;AACvB,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;AAAA,YACb,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,WAAU,oCAAW;AAAA;AAAA;AAAA,QACvC;AAAA,SAEJ;AAAA,MAEC;AAAA,MAEA,cACC,qBAAC,SAAM,SAAQ,aAAY,WAAU,SACnC;AAAA,4BAAC,qBAAkB,eAAY,QAAO;AAAA,QACrC;AAAA,QAAkB;AAAA,SACrB;AAAA,MAGD,WAAW,oBAAC,SAAI,WAAU,mCAAmC,mBAAQ;AAAA,OACxE;AAAA,IAEC,aACC,qBAAC,SAAI,MAAK,UAAS,WAAU,iEAC1B;AAAA,sBAAgB,UACf,qBAAC,UACC;AAAA,4BAAC,YAAO,WAAU,4CAA4C,uBAAY;AAAA,QACzE,UAAU,SAAY,MAAM,KAAK,KAAK;AAAA,QAAG;AAAA,QAAE;AAAA,SAC9C;AAAA,MAED,cAAc,kBACb,qBAAC,UAAO,MAAK,UAAS,SAAQ,SAAQ,MAAK,MAAK,SAAS,gBAAgB,WAAU,oBACjF;AAAA,4BAAC,KAAE,eAAY,QAAO,WAAU,YAAW;AAAA,QAAE;AAAA,SAE/C;AAAA,OAEJ;AAAA,KAEJ;AAEJ;","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
- /** Ô icon của StatCard, tô theo tone token (không hex). */
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: "bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100",
11
- success: "bg-success/15 text-success",
12
- warning: "bg-warning/20 text-gold-500 dark:text-warning",
13
- destructive: "bg-destructive/15 text-destructive",
14
- info: "bg-info/15 text-info",
15
- // text-foreground (không phải text-app-accent): accent mặc định là gold #E8B824, đặt làm
16
- // màu icon trên nền sáng chỉ được ~1.9:1 — nhìn như bị mờ.
17
- app: "bg-app-accent/10 text-foreground"
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/** Ô icon của StatCard, tô theo tone token (không hex). */\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 primary: \"bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100\",\n success: \"bg-success/15 text-success\",\n warning: \"bg-warning/20 text-gold-500 dark:text-warning\",\n destructive: \"bg-destructive/15 text-destructive\",\n info: \"bg-info/15 text-info\",\n // text-foreground (không phải text-app-accent): accent mặc định là gold #E8B824, đặt làm\n // màu icon trên nền sáng chỉ được ~1.9:1 — nhìn như bị mờ.\n app: \"bg-app-accent/10 text-foreground\"\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/** Màu của delta = hướng × cực tính, không phải chỉ hướng. */\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\" : \"text-destructive\";\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":"AAqGU,cAOE,YAPF;AArGV,SAAS,WAA8B;AACvC,SAAS,gBAAgB,cAAc,aAAa;AAEpD,SAAS,UAAU;AACnB,SAAS,gBAAgB;AAIlB,MAAM,uBAAuB,IAAI,+DAA+D;AAAA,EACrG,UAAU;AAAA,IACR,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,SAAS;AAAA,MACT,aAAa;AAAA,MACb,MAAM;AAAA;AAAA;AAAA,MAGN,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;AAGA,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,iBAAiB;AACnC;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":[]}
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":[]}