@comitor/ui 0.1.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/LICENSE +21 -0
- package/README.md +458 -0
- package/dist/chart/chart.d.ts +88 -0
- package/dist/chart/chart.js +199 -0
- package/dist/chart/chart.js.map +1 -0
- package/dist/chart/comitor-chart.d.ts +109 -0
- package/dist/chart/comitor-chart.js +189 -0
- package/dist/chart/comitor-chart.js.map +1 -0
- package/dist/chart/index.d.ts +4 -0
- package/dist/chart/index.js +3 -0
- package/dist/chart/index.js.map +1 -0
- package/dist/components/composite/avatar-group.d.ts +33 -0
- package/dist/components/composite/avatar-group.js +86 -0
- package/dist/components/composite/avatar-group.js.map +1 -0
- package/dist/components/composite/combobox.d.ts +70 -0
- package/dist/components/composite/combobox.js +291 -0
- package/dist/components/composite/combobox.js.map +1 -0
- package/dist/components/composite/command-palette.d.ts +56 -0
- package/dist/components/composite/command-palette.js +131 -0
- package/dist/components/composite/command-palette.js.map +1 -0
- package/dist/components/composite/confirm-dialog.d.ts +40 -0
- package/dist/components/composite/confirm-dialog.js +96 -0
- package/dist/components/composite/confirm-dialog.js.map +1 -0
- package/dist/components/composite/copy-button.d.ts +27 -0
- package/dist/components/composite/copy-button.js +80 -0
- package/dist/components/composite/copy-button.js.map +1 -0
- package/dist/components/composite/data-table.d.ts +136 -0
- package/dist/components/composite/data-table.js +300 -0
- package/dist/components/composite/data-table.js.map +1 -0
- package/dist/components/composite/date-picker.d.ts +77 -0
- package/dist/components/composite/date-picker.js +268 -0
- package/dist/components/composite/date-picker.js.map +1 -0
- package/dist/components/composite/empty-state.d.ts +38 -0
- package/dist/components/composite/empty-state.js +35 -0
- package/dist/components/composite/empty-state.js.map +1 -0
- package/dist/components/composite/filter-chips.d.ts +39 -0
- package/dist/components/composite/filter-chips.js +79 -0
- package/dist/components/composite/filter-chips.js.map +1 -0
- package/dist/components/composite/form-field.d.ts +58 -0
- package/dist/components/composite/form-field.js +81 -0
- package/dist/components/composite/form-field.js.map +1 -0
- package/dist/components/composite/form-section.d.ts +33 -0
- package/dist/components/composite/form-section.js +85 -0
- package/dist/components/composite/form-section.js.map +1 -0
- package/dist/components/composite/inline-edit.d.ts +38 -0
- package/dist/components/composite/inline-edit.js +165 -0
- package/dist/components/composite/inline-edit.js.map +1 -0
- package/dist/components/composite/keyboard-hint.d.ts +31 -0
- package/dist/components/composite/keyboard-hint.js +72 -0
- package/dist/components/composite/keyboard-hint.js.map +1 -0
- package/dist/components/composite/letter-avatar.d.ts +47 -0
- package/dist/components/composite/letter-avatar.js +66 -0
- package/dist/components/composite/letter-avatar.js.map +1 -0
- package/dist/components/composite/page-container.d.ts +25 -0
- package/dist/components/composite/page-container.js +23 -0
- package/dist/components/composite/page-container.js.map +1 -0
- package/dist/components/composite/page-header.d.ts +49 -0
- package/dist/components/composite/page-header.js +75 -0
- package/dist/components/composite/page-header.js.map +1 -0
- package/dist/components/composite/relative-time.d.ts +28 -0
- package/dist/components/composite/relative-time.js +44 -0
- package/dist/components/composite/relative-time.js.map +1 -0
- package/dist/components/composite/search-filter-bar.d.ts +38 -0
- package/dist/components/composite/search-filter-bar.js +135 -0
- package/dist/components/composite/search-filter-bar.js.map +1 -0
- package/dist/components/composite/stat-card.d.ts +48 -0
- package/dist/components/composite/stat-card.js +76 -0
- package/dist/components/composite/stat-card.js.map +1 -0
- package/dist/components/composite/status-config.d.ts +85 -0
- package/dist/components/composite/status-config.js +41 -0
- package/dist/components/composite/status-config.js.map +1 -0
- package/dist/components/composite/status-pill.d.ts +26 -0
- package/dist/components/composite/status-pill.js +59 -0
- package/dist/components/composite/status-pill.js.map +1 -0
- package/dist/components/composite/table-pagination.d.ts +31 -0
- package/dist/components/composite/table-pagination.js +103 -0
- package/dist/components/composite/table-pagination.js.map +1 -0
- package/dist/components/composite/text-utils.d.ts +38 -0
- package/dist/components/composite/text-utils.js +40 -0
- package/dist/components/composite/text-utils.js.map +1 -0
- package/dist/components/composite/toast.d.ts +56 -0
- package/dist/components/composite/toast.js +63 -0
- package/dist/components/composite/toast.js.map +1 -0
- package/dist/components/ui/accordion.d.ts +17 -0
- package/dist/components/ui/accordion.js +53 -0
- package/dist/components/ui/accordion.js.map +1 -0
- package/dist/components/ui/alert-dialog.d.ts +34 -0
- package/dist/components/ui/alert-dialog.js +103 -0
- package/dist/components/ui/alert-dialog.js.map +1 -0
- package/dist/components/ui/alert.d.ts +16 -0
- package/dist/components/ui/alert.js +50 -0
- package/dist/components/ui/alert.js.map +1 -0
- package/dist/components/ui/aspect-ratio.d.ts +7 -0
- package/dist/components/ui/aspect-ratio.js +10 -0
- package/dist/components/ui/aspect-ratio.js.map +1 -0
- package/dist/components/ui/avatar.d.ts +11 -0
- package/dist/components/ui/avatar.js +33 -0
- package/dist/components/ui/avatar.js.map +1 -0
- package/dist/components/ui/badge.d.ts +15 -0
- package/dist/components/ui/badge.js +37 -0
- package/dist/components/ui/badge.js.map +1 -0
- package/dist/components/ui/breadcrumb.d.ts +30 -0
- package/dist/components/ui/breadcrumb.js +89 -0
- package/dist/components/ui/breadcrumb.js.map +1 -0
- package/dist/components/ui/button-group.d.ts +21 -0
- package/dist/components/ui/button-group.js +62 -0
- package/dist/components/ui/button-group.js.map +1 -0
- package/dist/components/ui/button.d.ts +21 -0
- package/dist/components/ui/button.js +70 -0
- package/dist/components/ui/button.js.map +1 -0
- package/dist/components/ui/calendar.d.ts +26 -0
- package/dist/components/ui/calendar.js +148 -0
- package/dist/components/ui/calendar.js.map +1 -0
- package/dist/components/ui/card.d.ts +33 -0
- package/dist/components/ui/card.js +70 -0
- package/dist/components/ui/card.js.map +1 -0
- package/dist/components/ui/carousel.d.ts +46 -0
- package/dist/components/ui/carousel.js +173 -0
- package/dist/components/ui/carousel.js.map +1 -0
- package/dist/components/ui/checkbox.d.ts +17 -0
- package/dist/components/ui/checkbox.js +67 -0
- package/dist/components/ui/checkbox.js.map +1 -0
- package/dist/components/ui/collapsible.d.ts +17 -0
- package/dist/components/ui/collapsible.js +18 -0
- package/dist/components/ui/collapsible.js.map +1 -0
- package/dist/components/ui/command.d.ts +41 -0
- package/dist/components/ui/command.js +122 -0
- package/dist/components/ui/command.js.map +1 -0
- package/dist/components/ui/context-menu.d.ts +51 -0
- package/dist/components/ui/context-menu.js +166 -0
- package/dist/components/ui/context-menu.js.map +1 -0
- package/dist/components/ui/dialog.d.ts +48 -0
- package/dist/components/ui/dialog.js +131 -0
- package/dist/components/ui/dialog.js.map +1 -0
- package/dist/components/ui/drawer.d.ts +32 -0
- package/dist/components/ui/drawer.js +102 -0
- package/dist/components/ui/drawer.js.map +1 -0
- package/dist/components/ui/dropdown-menu.d.ts +52 -0
- package/dist/components/ui/dropdown-menu.js +167 -0
- package/dist/components/ui/dropdown-menu.js.map +1 -0
- package/dist/components/ui/empty.d.ts +22 -0
- package/dist/components/ui/empty.js +87 -0
- package/dist/components/ui/empty.js.map +1 -0
- package/dist/components/ui/field.d.ts +46 -0
- package/dist/components/ui/field.js +197 -0
- package/dist/components/ui/field.js.map +1 -0
- package/dist/components/ui/form.d.ts +50 -0
- package/dist/components/ui/form.js +100 -0
- package/dist/components/ui/form.js.map +1 -0
- package/dist/components/ui/hover-card.d.ts +17 -0
- package/dist/components/ui/hover-card.js +31 -0
- package/dist/components/ui/hover-card.js.map +1 -0
- package/dist/components/ui/input-group.d.ts +26 -0
- package/dist/components/ui/input-group.js +148 -0
- package/dist/components/ui/input-group.js.map +1 -0
- package/dist/components/ui/input-otp.d.ts +23 -0
- package/dist/components/ui/input-otp.js +54 -0
- package/dist/components/ui/input-otp.js.map +1 -0
- package/dist/components/ui/input.d.ts +21 -0
- package/dist/components/ui/input.js +62 -0
- package/dist/components/ui/input.js.map +1 -0
- package/dist/components/ui/item.d.ts +39 -0
- package/dist/components/ui/item.js +142 -0
- package/dist/components/ui/item.js.map +1 -0
- package/dist/components/ui/kbd.d.ts +8 -0
- package/dist/components/ui/kbd.js +25 -0
- package/dist/components/ui/kbd.js.map +1 -0
- package/dist/components/ui/label.d.ts +7 -0
- package/dist/components/ui/label.js +21 -0
- package/dist/components/ui/label.js.map +1 -0
- package/dist/components/ui/menubar.d.ts +51 -0
- package/dist/components/ui/menubar.js +196 -0
- package/dist/components/ui/menubar.js.map +1 -0
- package/dist/components/ui/navigation-menu.d.ts +39 -0
- package/dist/components/ui/navigation-menu.js +123 -0
- package/dist/components/ui/navigation-menu.js.map +1 -0
- package/dist/components/ui/pagination.d.ts +40 -0
- package/dist/components/ui/pagination.js +95 -0
- package/dist/components/ui/pagination.js.map +1 -0
- package/dist/components/ui/popover.d.ts +19 -0
- package/dist/components/ui/popover.js +35 -0
- package/dist/components/ui/popover.js.map +1 -0
- package/dist/components/ui/progress.d.ts +12 -0
- package/dist/components/ui/progress.js +27 -0
- package/dist/components/ui/progress.js.map +1 -0
- package/dist/components/ui/radio-group.d.ts +9 -0
- package/dist/components/ui/radio-group.js +34 -0
- package/dist/components/ui/radio-group.js.map +1 -0
- package/dist/components/ui/resizable.d.ts +19 -0
- package/dist/components/ui/resizable.js +38 -0
- package/dist/components/ui/resizable.js.map +1 -0
- package/dist/components/ui/scroll-area.d.ts +13 -0
- package/dist/components/ui/scroll-area.js +46 -0
- package/dist/components/ui/scroll-area.js.map +1 -0
- package/dist/components/ui/select.d.ts +33 -0
- package/dist/components/ui/select.js +133 -0
- package/dist/components/ui/select.js.map +1 -0
- package/dist/components/ui/separator.d.ts +7 -0
- package/dist/components/ui/separator.js +23 -0
- package/dist/components/ui/separator.js.map +1 -0
- package/dist/components/ui/sheet.d.ts +38 -0
- package/dist/components/ui/sheet.js +96 -0
- package/dist/components/ui/sheet.js.map +1 -0
- package/dist/components/ui/skeleton.d.ts +6 -0
- package/dist/components/ui/skeleton.js +9 -0
- package/dist/components/ui/skeleton.js.map +1 -0
- package/dist/components/ui/slider.d.ts +12 -0
- package/dist/components/ui/slider.js +54 -0
- package/dist/components/ui/slider.js.map +1 -0
- package/dist/components/ui/sonner.d.ts +9 -0
- package/dist/components/ui/sonner.js +25 -0
- package/dist/components/ui/sonner.js.map +1 -0
- package/dist/components/ui/spinner.d.ts +6 -0
- package/dist/components/ui/spinner.js +10 -0
- package/dist/components/ui/spinner.js.map +1 -0
- package/dist/components/ui/switch.d.ts +14 -0
- package/dist/components/ui/switch.js +65 -0
- package/dist/components/ui/switch.js.map +1 -0
- package/dist/components/ui/table.d.ts +30 -0
- package/dist/components/ui/table.js +87 -0
- package/dist/components/ui/table.js.map +1 -0
- package/dist/components/ui/tabs.d.ts +35 -0
- package/dist/components/ui/tabs.js +54 -0
- package/dist/components/ui/tabs.js.map +1 -0
- package/dist/components/ui/textarea.d.ts +6 -0
- package/dist/components/ui/textarea.js +19 -0
- package/dist/components/ui/textarea.js.map +1 -0
- package/dist/components/ui/toggle-group.d.ts +18 -0
- package/dist/components/ui/toggle-group.js +52 -0
- package/dist/components/ui/toggle-group.js.map +1 -0
- package/dist/components/ui/toggle.d.ts +15 -0
- package/dist/components/ui/toggle.js +33 -0
- package/dist/components/ui/toggle.js.map +1 -0
- package/dist/components/ui/tooltip.d.ts +21 -0
- package/dist/components/ui/tooltip.js +38 -0
- package/dist/components/ui/tooltip.js.map +1 -0
- package/dist/form.d.ts +5 -0
- package/dist/form.js +2 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cn.d.ts +16 -0
- package/dist/lib/cn.js +9 -0
- package/dist/lib/cn.js.map +1 -0
- package/dist/shell/app-header.d.ts +41 -0
- package/dist/shell/app-header.js +136 -0
- package/dist/shell/app-header.js.map +1 -0
- package/dist/shell/app-launcher.d.ts +33 -0
- package/dist/shell/app-launcher.js +189 -0
- package/dist/shell/app-launcher.js.map +1 -0
- package/dist/shell/app-shell.d.ts +44 -0
- package/dist/shell/app-shell.js +99 -0
- package/dist/shell/app-shell.js.map +1 -0
- package/dist/shell/comitor-lockup.d.ts +33 -0
- package/dist/shell/comitor-lockup.js +112 -0
- package/dist/shell/comitor-lockup.js.map +1 -0
- package/dist/shell/comitor-logo.d.ts +28 -0
- package/dist/shell/comitor-logo.js +92 -0
- package/dist/shell/comitor-logo.js.map +1 -0
- package/dist/shell/index.d.ts +24 -0
- package/dist/shell/index.js +23 -0
- package/dist/shell/index.js.map +1 -0
- package/dist/shell/mobile-menu.d.ts +26 -0
- package/dist/shell/mobile-menu.js +56 -0
- package/dist/shell/mobile-menu.js.map +1 -0
- package/dist/shell/notifications-button.d.ts +22 -0
- package/dist/shell/notifications-button.js +39 -0
- package/dist/shell/notifications-button.js.map +1 -0
- package/dist/shell/shell-context.d.ts +91 -0
- package/dist/shell/shell-context.js +233 -0
- package/dist/shell/shell-context.js.map +1 -0
- package/dist/shell/shell-link.d.ts +19 -0
- package/dist/shell/shell-link.js +18 -0
- package/dist/shell/shell-link.js.map +1 -0
- package/dist/shell/sidebar-group.d.ts +21 -0
- package/dist/shell/sidebar-group.js +43 -0
- package/dist/shell/sidebar-group.js.map +1 -0
- package/dist/shell/sidebar-menu-item.d.ts +27 -0
- package/dist/shell/sidebar-menu-item.js +152 -0
- package/dist/shell/sidebar-menu-item.js.map +1 -0
- package/dist/shell/sidebar.d.ts +26 -0
- package/dist/shell/sidebar.js +92 -0
- package/dist/shell/sidebar.js.map +1 -0
- package/dist/shell/theme-provider.d.ts +18 -0
- package/dist/shell/theme-provider.js +29 -0
- package/dist/shell/theme-provider.js.map +1 -0
- package/dist/shell/theme-toggle.d.ts +28 -0
- package/dist/shell/theme-toggle.js +67 -0
- package/dist/shell/theme-toggle.js.map +1 -0
- package/dist/shell/types.d.ts +168 -0
- package/dist/shell/types.js +38 -0
- package/dist/shell/types.js.map +1 -0
- package/dist/shell/user-menu.d.ts +42 -0
- package/dist/shell/user-menu.js +132 -0
- package/dist/shell/user-menu.js.map +1 -0
- package/dist/shell/utils.d.ts +62 -0
- package/dist/shell/utils.js +73 -0
- package/dist/shell/utils.js.map +1 -0
- package/dist/shell/workspace-switcher.d.ts +24 -0
- package/dist/shell/workspace-switcher.js +219 -0
- package/dist/shell/workspace-switcher.js.map +1 -0
- package/dist/tokens.d.ts +173 -0
- package/dist/tokens.js +161 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +151 -0
- package/styles.css +357 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
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
|
+
];
|
|
14
|
+
const sizeClasses = {
|
|
15
|
+
xs: "size-6 text-[10px]",
|
|
16
|
+
sm: "size-7 text-xs",
|
|
17
|
+
md: "size-9 text-sm",
|
|
18
|
+
lg: "size-11 text-base",
|
|
19
|
+
xl: "size-14 text-lg"
|
|
20
|
+
};
|
|
21
|
+
function getLetterAvatarTone(name) {
|
|
22
|
+
const tones = LETTER_AVATAR_TONES;
|
|
23
|
+
return tones[hashString(name.trim().toLocaleLowerCase("vi-VN")) % tones.length] ?? tones[0];
|
|
24
|
+
}
|
|
25
|
+
function LetterAvatar({
|
|
26
|
+
name,
|
|
27
|
+
src,
|
|
28
|
+
size = "md",
|
|
29
|
+
square = false,
|
|
30
|
+
toneClassName,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}) {
|
|
34
|
+
const tone = toneClassName ?? getLetterAvatarTone(name);
|
|
35
|
+
return /* @__PURE__ */ jsxs(
|
|
36
|
+
"span",
|
|
37
|
+
{
|
|
38
|
+
"data-slot": "letter-avatar",
|
|
39
|
+
title: name,
|
|
40
|
+
className: cn(
|
|
41
|
+
"relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden font-medium",
|
|
42
|
+
square ? "rounded-md" : "rounded-full",
|
|
43
|
+
sizeClasses[size],
|
|
44
|
+
!src && tone,
|
|
45
|
+
className
|
|
46
|
+
),
|
|
47
|
+
...props,
|
|
48
|
+
children: [
|
|
49
|
+
src ? (
|
|
50
|
+
// Không dùng next/image: entry "." phải chạy cả ngoài Next (Vite, Storybook, test).
|
|
51
|
+
// App muốn tối ưu ảnh thì truyền URL đã qua CDN/loader của mình.
|
|
52
|
+
// `alt=""`: tên đã nằm ở `sr-only` bên dưới — để alt={name} nữa là đọc tên hai lần.
|
|
53
|
+
// biome-ignore lint/performance/noImgElement: package dùng chung không phụ thuộc next/*
|
|
54
|
+
/* @__PURE__ */ jsx("img", { src, alt: "", className: "size-full object-cover", loading: "lazy", decoding: "async" })
|
|
55
|
+
) : /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: getVietnameseInitials(name) }),
|
|
56
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: name })
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
LETTER_AVATAR_TONES,
|
|
63
|
+
LetterAvatar,
|
|
64
|
+
getLetterAvatarTone
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=letter-avatar.js.map
|
|
@@ -0,0 +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":[]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
type PageContainerWidth = "sm" | "md" | "lg" | "full";
|
|
5
|
+
interface PageContainerProps extends ComponentProps<"div"> {
|
|
6
|
+
/**
|
|
7
|
+
* Bề rộng nội dung tối đa:
|
|
8
|
+
* `sm` 42rem (trang cài đặt, form một cột) · `md` 64rem (mặc định, trang chi tiết)
|
|
9
|
+
* `lg` 90rem (bảng nhiều cột) · `full` không giới hạn (bảng full-bleed, kanban).
|
|
10
|
+
*/
|
|
11
|
+
width?: PageContainerWidth;
|
|
12
|
+
/** Bỏ padding dọc — dùng khi trang tự quản khoảng cách (vd kanban cuộn ngang) */
|
|
13
|
+
flush?: boolean;
|
|
14
|
+
/** Đổi thẻ bọc (mặc định `div`; đặt "main" khi container là vùng nội dung chính) */
|
|
15
|
+
as?: "div" | "main" | "section";
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Khung nội dung chuẩn của trang: căn giữa, giới hạn bề rộng, padding theo breakpoint.
|
|
19
|
+
*
|
|
20
|
+
* Vì sao cần: nếu mỗi trang tự viết `px-4 md:px-6 max-w-…` thì lề trái của Tasks và CRM
|
|
21
|
+
* sẽ lệch nhau vài px — lỗi rất khó thấy nhưng làm hệ sinh thái trông rời rạc. Tĩnh → RSC-safe.
|
|
22
|
+
*/
|
|
23
|
+
declare function PageContainer({ width, flush, as, className, ...props }: PageContainerProps): React.JSX.Element;
|
|
24
|
+
|
|
25
|
+
export { PageContainer, type PageContainerProps, type PageContainerWidth };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../../lib/cn.js";
|
|
3
|
+
const widthClasses = {
|
|
4
|
+
sm: "max-w-2xl",
|
|
5
|
+
md: "max-w-5xl",
|
|
6
|
+
lg: "max-w-[90rem]",
|
|
7
|
+
full: "max-w-none"
|
|
8
|
+
};
|
|
9
|
+
function PageContainer({ width = "md", flush = false, as = "div", className, ...props }) {
|
|
10
|
+
const Comp = as;
|
|
11
|
+
return /* @__PURE__ */ jsx(
|
|
12
|
+
Comp,
|
|
13
|
+
{
|
|
14
|
+
"data-slot": "page-container",
|
|
15
|
+
className: cn("mx-auto w-full px-4 md:px-6", !flush && "py-4 md:py-6", widthClasses[width], className),
|
|
16
|
+
...props
|
|
17
|
+
}
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
export {
|
|
21
|
+
PageContainer
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=page-container.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/page-container.tsx"],"sourcesContent":["import type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\nexport type PageContainerWidth = \"sm\" | \"md\" | \"lg\" | \"full\";\n\nexport interface PageContainerProps extends ComponentProps<\"div\"> {\n /**\n * Bề rộng nội dung tối đa:\n * `sm` 42rem (trang cài đặt, form một cột) · `md` 64rem (mặc định, trang chi tiết)\n * `lg` 90rem (bảng nhiều cột) · `full` không giới hạn (bảng full-bleed, kanban).\n */\n width?: PageContainerWidth;\n /** Bỏ padding dọc — dùng khi trang tự quản khoảng cách (vd kanban cuộn ngang) */\n flush?: boolean;\n /** Đổi thẻ bọc (mặc định `div`; đặt \"main\" khi container là vùng nội dung chính) */\n as?: \"div\" | \"main\" | \"section\";\n}\n\nconst widthClasses: Record<PageContainerWidth, string> = {\n sm: \"max-w-2xl\",\n md: \"max-w-5xl\",\n lg: \"max-w-[90rem]\",\n full: \"max-w-none\"\n};\n\n/**\n * Khung nội dung chuẩn của trang: căn giữa, giới hạn bề rộng, padding theo breakpoint.\n *\n * Vì sao cần: nếu mỗi trang tự viết `px-4 md:px-6 max-w-…` thì lề trái của Tasks và CRM\n * sẽ lệch nhau vài px — lỗi rất khó thấy nhưng làm hệ sinh thái trông rời rạc. Tĩnh → RSC-safe.\n */\nexport function PageContainer({ width = \"md\", flush = false, as = \"div\", className, ...props }: PageContainerProps) {\n const Comp = as;\n\n return (\n <Comp\n data-slot=\"page-container\"\n className={cn(\"mx-auto w-full px-4 md:px-6\", !flush && \"py-4 md:py-6\", widthClasses[width], className)}\n {...props}\n />\n );\n}\n"],"mappings":"AAmCI;AAlCJ,SAAS,UAAU;AAiBnB,MAAM,eAAmD;AAAA,EACvD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,MAAM;AACR;AAQO,SAAS,cAAc,EAAE,QAAQ,MAAM,QAAQ,OAAO,KAAK,OAAO,WAAW,GAAG,MAAM,GAAuB;AAClH,QAAM,OAAO;AAEb,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,+BAA+B,CAAC,SAAS,gBAAgB,aAAa,KAAK,GAAG,SAAS;AAAA,MACpG,GAAG;AAAA;AAAA,EACN;AAEJ;","names":[]}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ComponentProps, ReactNode, ComponentType } from 'react';
|
|
3
|
+
import { IconComponent } from './empty-state.js';
|
|
4
|
+
|
|
5
|
+
/** Props tối thiểu của component link app tiêm vào (vd `next/link`). */
|
|
6
|
+
interface PageHeaderLinkProps {
|
|
7
|
+
href: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
interface PageHeaderBreadcrumbItem {
|
|
12
|
+
label: string;
|
|
13
|
+
/** Không có href → mục tĩnh (thường là mục cuối = trang hiện tại) */
|
|
14
|
+
href?: string;
|
|
15
|
+
}
|
|
16
|
+
interface PageHeaderProps extends Omit<ComponentProps<"header">, "title"> {
|
|
17
|
+
title: ReactNode;
|
|
18
|
+
description?: ReactNode;
|
|
19
|
+
/** Icon nhỏ đứng trước tiêu đề (vd icon của app/khu vực) */
|
|
20
|
+
icon?: IconComponent;
|
|
21
|
+
/** Đường dẫn phân cấp; mục cuối luôn render dạng "trang hiện tại" (không bấm được) */
|
|
22
|
+
breadcrumb?: PageHeaderBreadcrumbItem[];
|
|
23
|
+
/**
|
|
24
|
+
* Component link cho breadcrumb — mặc định thẻ `<a>` thường.
|
|
25
|
+
*
|
|
26
|
+
* Vì sao nhận qua prop thay vì import `next/link`: entry "." của package phải chạy được cả
|
|
27
|
+
* ngoài Next (Vite, Storybook, test). Đụng `next/*` ở đây là vỡ resolve ngay khi app chỉ
|
|
28
|
+
* `import { Button }`.
|
|
29
|
+
*/
|
|
30
|
+
LinkComponent?: ComponentType<PageHeaderLinkProps>;
|
|
31
|
+
/** Slot hành động bên phải (nút Tạo mới, menu …) */
|
|
32
|
+
actions?: ReactNode;
|
|
33
|
+
/** Hàng phụ dưới tiêu đề: tabs, thanh lọc, chip trạng thái… */
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
/** Dính đỉnh vùng cuộn (danh sách dài) */
|
|
36
|
+
sticky?: boolean;
|
|
37
|
+
/** Bỏ đường kẻ dưới header */
|
|
38
|
+
borderless?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Header trang chuẩn: breadcrumb + tiêu đề + mô tả + slot hành động (+ hàng phụ).
|
|
42
|
+
*
|
|
43
|
+
* Nhận breadcrumb qua props, KHÔNG đọc từ context của shell: khi PageHeader phụ thuộc
|
|
44
|
+
* ShellContext thì mọi trang không nằm trong shell (trang in ấn, trang lỗi, dialog toàn
|
|
45
|
+
* màn hình) đều nổ. Tĩnh hoàn toàn → RSC-safe.
|
|
46
|
+
*/
|
|
47
|
+
declare function PageHeader({ title, description, icon: Icon, breadcrumb, LinkComponent, actions, children, sticky, borderless, className, ...props }: PageHeaderProps): React.JSX.Element;
|
|
48
|
+
|
|
49
|
+
export { PageHeader, type PageHeaderBreadcrumbItem, type PageHeaderLinkProps, type PageHeaderProps };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Fragment } from "react";
|
|
3
|
+
import { cn } from "../../lib/cn.js";
|
|
4
|
+
import {
|
|
5
|
+
Breadcrumb,
|
|
6
|
+
BreadcrumbItem,
|
|
7
|
+
BreadcrumbLink,
|
|
8
|
+
BreadcrumbList,
|
|
9
|
+
BreadcrumbPage,
|
|
10
|
+
BreadcrumbSeparator
|
|
11
|
+
} from "../ui/breadcrumb.js";
|
|
12
|
+
function DefaultLink({ href, className, children }) {
|
|
13
|
+
return /* @__PURE__ */ jsx("a", { href, className, children });
|
|
14
|
+
}
|
|
15
|
+
function PageHeader({
|
|
16
|
+
title,
|
|
17
|
+
description,
|
|
18
|
+
icon: Icon,
|
|
19
|
+
breadcrumb = [],
|
|
20
|
+
LinkComponent = DefaultLink,
|
|
21
|
+
actions,
|
|
22
|
+
children,
|
|
23
|
+
sticky = false,
|
|
24
|
+
borderless = false,
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}) {
|
|
28
|
+
return /* @__PURE__ */ jsxs(
|
|
29
|
+
"header",
|
|
30
|
+
{
|
|
31
|
+
"data-slot": "page-header",
|
|
32
|
+
className: cn(
|
|
33
|
+
"w-full bg-background px-4 pt-4 md:px-6",
|
|
34
|
+
!borderless && "border-b border-border pb-4",
|
|
35
|
+
sticky && "sticky top-0 z-20 backdrop-blur supports-[backdrop-filter]:bg-background/80",
|
|
36
|
+
className
|
|
37
|
+
),
|
|
38
|
+
...props,
|
|
39
|
+
children: [
|
|
40
|
+
breadcrumb.length > 0 && /* @__PURE__ */ jsx(Breadcrumb, { className: "mb-2", children: /* @__PURE__ */ jsx(BreadcrumbList, { children: breadcrumb.map((item, index) => {
|
|
41
|
+
const isLast = index === breadcrumb.length - 1;
|
|
42
|
+
return (
|
|
43
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: breadcrumb là danh sách vị trí, nhãn có thể trùng giữa các cấp
|
|
44
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
45
|
+
/* @__PURE__ */ jsx(BreadcrumbItem, { children: isLast || !item.href ? /* @__PURE__ */ jsx(BreadcrumbPage, { children: item.label }) : /* @__PURE__ */ jsx(BreadcrumbLink, { asChild: true, children: /* @__PURE__ */ jsx(LinkComponent, { href: item.href, children: item.label }) }) }),
|
|
46
|
+
!isLast && /* @__PURE__ */ jsx(BreadcrumbSeparator, {})
|
|
47
|
+
] }, `${index}-${item.label}`)
|
|
48
|
+
);
|
|
49
|
+
}) }) }),
|
|
50
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [
|
|
51
|
+
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-start gap-3", children: [
|
|
52
|
+
Icon && /* @__PURE__ */ jsx(
|
|
53
|
+
"span",
|
|
54
|
+
{
|
|
55
|
+
"aria-hidden": "true",
|
|
56
|
+
className: "mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-app-accent/10 text-foreground",
|
|
57
|
+
children: /* @__PURE__ */ jsx(Icon, { className: "size-5" })
|
|
58
|
+
}
|
|
59
|
+
),
|
|
60
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
61
|
+
/* @__PURE__ */ jsx("h1", { className: "truncate text-xl font-semibold tracking-tight text-foreground md:text-2xl", children: title }),
|
|
62
|
+
description && /* @__PURE__ */ jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: description })
|
|
63
|
+
] })
|
|
64
|
+
] }),
|
|
65
|
+
actions && /* @__PURE__ */ jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })
|
|
66
|
+
] }),
|
|
67
|
+
children && /* @__PURE__ */ jsx("div", { className: "mt-3", children })
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
export {
|
|
73
|
+
PageHeader
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=page-header.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/page-header.tsx"],"sourcesContent":["import type { ComponentProps, ComponentType, ReactNode } from \"react\";\nimport { Fragment } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport {\n Breadcrumb,\n BreadcrumbItem,\n BreadcrumbLink,\n BreadcrumbList,\n BreadcrumbPage,\n BreadcrumbSeparator\n} from \"../ui/breadcrumb.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\n/** Props tối thiểu của component link app tiêm vào (vd `next/link`). */\nexport interface PageHeaderLinkProps {\n href: string;\n className?: string;\n children: ReactNode;\n}\n\nexport interface PageHeaderBreadcrumbItem {\n label: string;\n /** Không có href → mục tĩnh (thường là mục cuối = trang hiện tại) */\n href?: string;\n}\n\nexport interface PageHeaderProps extends Omit<ComponentProps<\"header\">, \"title\"> {\n title: ReactNode;\n description?: ReactNode;\n /** Icon nhỏ đứng trước tiêu đề (vd icon của app/khu vực) */\n icon?: IconComponent;\n /** Đường dẫn phân cấp; mục cuối luôn render dạng \"trang hiện tại\" (không bấm được) */\n breadcrumb?: PageHeaderBreadcrumbItem[];\n /**\n * Component link cho breadcrumb — mặc định thẻ `<a>` thường.\n *\n * Vì sao nhận qua prop thay vì import `next/link`: entry \".\" của package phải chạy được cả\n * ngoài Next (Vite, Storybook, test). Đụng `next/*` ở đây là vỡ resolve ngay khi app chỉ\n * `import { Button }`.\n */\n LinkComponent?: ComponentType<PageHeaderLinkProps>;\n /** Slot hành động bên phải (nút Tạo mới, menu …) */\n actions?: ReactNode;\n /** Hàng phụ dưới tiêu đề: tabs, thanh lọc, chip trạng thái… */\n children?: ReactNode;\n /** Dính đỉnh vùng cuộn (danh sách dài) */\n sticky?: boolean;\n /** Bỏ đường kẻ dưới header */\n borderless?: boolean;\n}\n\nfunction DefaultLink({ href, className, children }: PageHeaderLinkProps) {\n return (\n <a href={href} className={className}>\n {children}\n </a>\n );\n}\n\n/**\n * Header trang chuẩn: breadcrumb + tiêu đề + mô tả + slot hành động (+ hàng phụ).\n *\n * Nhận breadcrumb qua props, KHÔNG đọc từ context của shell: khi PageHeader phụ thuộc\n * ShellContext thì mọi trang không nằm trong shell (trang in ấn, trang lỗi, dialog toàn\n * màn hình) đều nổ. Tĩnh hoàn toàn → RSC-safe.\n */\nexport function PageHeader({\n title,\n description,\n icon: Icon,\n breadcrumb = [],\n LinkComponent = DefaultLink,\n actions,\n children,\n sticky = false,\n borderless = false,\n className,\n ...props\n}: PageHeaderProps) {\n return (\n <header\n data-slot=\"page-header\"\n className={cn(\n \"w-full bg-background px-4 pt-4 md:px-6\",\n !borderless && \"border-b border-border pb-4\",\n sticky && \"sticky top-0 z-20 backdrop-blur supports-[backdrop-filter]:bg-background/80\",\n className\n )}\n {...props}\n >\n {breadcrumb.length > 0 && (\n <Breadcrumb className=\"mb-2\">\n <BreadcrumbList>\n {breadcrumb.map((item, index) => {\n const isLast = index === breadcrumb.length - 1;\n return (\n // biome-ignore lint/suspicious/noArrayIndexKey: breadcrumb là danh sách vị trí, nhãn có thể trùng giữa các cấp\n <Fragment key={`${index}-${item.label}`}>\n <BreadcrumbItem>\n {isLast || !item.href ? (\n <BreadcrumbPage>{item.label}</BreadcrumbPage>\n ) : (\n <BreadcrumbLink asChild>\n <LinkComponent href={item.href}>{item.label}</LinkComponent>\n </BreadcrumbLink>\n )}\n </BreadcrumbItem>\n {!isLast && <BreadcrumbSeparator />}\n </Fragment>\n );\n })}\n </BreadcrumbList>\n </Breadcrumb>\n )}\n\n <div className=\"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between\">\n <div className=\"flex min-w-0 items-start gap-3\">\n {Icon && (\n <span\n aria-hidden=\"true\"\n className=\"mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-app-accent/10 text-foreground\"\n >\n <Icon className=\"size-5\" />\n </span>\n )}\n <div className=\"min-w-0\">\n <h1 className=\"truncate text-xl font-semibold tracking-tight text-foreground md:text-2xl\">{title}</h1>\n {description && <p className=\"mt-1 text-sm text-muted-foreground\">{description}</p>}\n </div>\n </div>\n {actions && <div className=\"flex shrink-0 flex-wrap items-center gap-2\">{actions}</div>}\n </div>\n\n {children && <div className=\"mt-3\">{children}</div>}\n </header>\n );\n}\n"],"mappings":"AAqDI,cA4CY,YA5CZ;AApDJ,SAAS,gBAAgB;AACzB,SAAS,UAAU;AACnB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAyCP,SAAS,YAAY,EAAE,MAAM,WAAW,SAAS,GAAwB;AACvE,SACE,oBAAC,OAAE,MAAY,WACZ,UACH;AAEJ;AASO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,aAAa,CAAC;AAAA,EACd,gBAAgB;AAAA,EAChB;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT,aAAa;AAAA,EACb;AAAA,EACA,GAAG;AACL,GAAoB;AAClB,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA,CAAC,cAAc;AAAA,QACf,UAAU;AAAA,QACV;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEH;AAAA,mBAAW,SAAS,KACnB,oBAAC,cAAW,WAAU,QACpB,8BAAC,kBACE,qBAAW,IAAI,CAAC,MAAM,UAAU;AAC/B,gBAAM,SAAS,UAAU,WAAW,SAAS;AAC7C;AAAA;AAAA,YAEE,qBAAC,YACC;AAAA,kCAAC,kBACE,oBAAU,CAAC,KAAK,OACf,oBAAC,kBAAgB,eAAK,OAAM,IAE5B,oBAAC,kBAAe,SAAO,MACrB,8BAAC,iBAAc,MAAM,KAAK,MAAO,eAAK,OAAM,GAC9C,GAEJ;AAAA,cACC,CAAC,UAAU,oBAAC,uBAAoB;AAAA,iBAVpB,GAAG,KAAK,IAAI,KAAK,KAAK,EAWrC;AAAA;AAAA,QAEJ,CAAC,GACH,GACF;AAAA,QAGF,qBAAC,SAAI,WAAU,qEACb;AAAA,+BAAC,SAAI,WAAU,kCACZ;AAAA,oBACC;AAAA,cAAC;AAAA;AAAA,gBACC,eAAY;AAAA,gBACZ,WAAU;AAAA,gBAEV,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,YAC3B;AAAA,YAEF,qBAAC,SAAI,WAAU,WACb;AAAA,kCAAC,QAAG,WAAU,6EAA6E,iBAAM;AAAA,cAChG,eAAe,oBAAC,OAAE,WAAU,sCAAsC,uBAAY;AAAA,eACjF;AAAA,aACF;AAAA,UACC,WAAW,oBAAC,SAAI,WAAU,8CAA8C,mBAAQ;AAAA,WACnF;AAAA,QAEC,YAAY,oBAAC,SAAI,WAAU,QAAQ,UAAS;AAAA;AAAA;AAAA,EAC/C;AAEJ;","names":[]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
interface RelativeTimeProps extends Omit<ComponentProps<"time">, "dateTime" | "children"> {
|
|
5
|
+
/** Thời điểm cần hiển thị: Date, ISO string hoặc epoch ms */
|
|
6
|
+
value: Date | string | number;
|
|
7
|
+
/** Thêm hậu tố "trước"/"nữa" (mặc định true) */
|
|
8
|
+
addSuffix?: boolean;
|
|
9
|
+
/** Chu kỳ tự cập nhật (ms). 0 = không tự cập nhật. Mặc định 60_000. */
|
|
10
|
+
updateIntervalMs?: number;
|
|
11
|
+
/** Định dạng của tooltip `title` (mặc định "HH:mm 'ngày' dd/MM/yyyy") */
|
|
12
|
+
titleFormat?: string;
|
|
13
|
+
/** Nội dung thay thế khi `value` không hợp lệ */
|
|
14
|
+
fallback?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Thời gian tương đối kiểu "3 phút trước", tự cập nhật theo chu kỳ.
|
|
18
|
+
*
|
|
19
|
+
* Dùng `formatDistanceToNowStrict` (không phải bản thường): bản thường làm tròn kiểu "khoảng 1
|
|
20
|
+
* tháng" cho mọi thứ từ 26 ngày đến 45 ngày — trong danh sách công việc thì "26 ngày trước" và
|
|
21
|
+
* "45 ngày trước" là hai câu chuyện khác hẳn nhau.
|
|
22
|
+
*
|
|
23
|
+
* `title` luôn giữ mốc thời gian tuyệt đối để người dùng rà chuột là biết chính xác.
|
|
24
|
+
* Có `suppressHydrationWarning` vì đồng hồ server và client gần như luôn lệch vài giây.
|
|
25
|
+
*/
|
|
26
|
+
declare function RelativeTime({ value, addSuffix, updateIntervalMs, titleFormat, fallback, className, ...props }: RelativeTimeProps): React.JSX.Element;
|
|
27
|
+
|
|
28
|
+
export { RelativeTime, type RelativeTimeProps };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { format as formatDate, formatDistanceToNowStrict, isValid } from "date-fns";
|
|
4
|
+
import { vi } from "date-fns/locale";
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
function toDate(value) {
|
|
7
|
+
return value instanceof Date ? value : new Date(value);
|
|
8
|
+
}
|
|
9
|
+
function RelativeTime({
|
|
10
|
+
value,
|
|
11
|
+
addSuffix = true,
|
|
12
|
+
updateIntervalMs = 6e4,
|
|
13
|
+
titleFormat = "HH:mm 'ng\xE0y' dd/MM/yyyy",
|
|
14
|
+
fallback = "\u2014",
|
|
15
|
+
className,
|
|
16
|
+
...props
|
|
17
|
+
}) {
|
|
18
|
+
const date = toDate(value);
|
|
19
|
+
const valid = isValid(date);
|
|
20
|
+
const [, setTick] = useState(0);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!valid || !updateIntervalMs) return;
|
|
23
|
+
const timer = setInterval(() => setTick((current) => current + 1), updateIntervalMs);
|
|
24
|
+
return () => clearInterval(timer);
|
|
25
|
+
}, [valid, updateIntervalMs]);
|
|
26
|
+
if (!valid) {
|
|
27
|
+
return /* @__PURE__ */ jsx("span", { className, children: fallback });
|
|
28
|
+
}
|
|
29
|
+
return /* @__PURE__ */ jsx(
|
|
30
|
+
"time",
|
|
31
|
+
{
|
|
32
|
+
dateTime: date.toISOString(),
|
|
33
|
+
title: formatDate(date, titleFormat, { locale: vi }),
|
|
34
|
+
suppressHydrationWarning: true,
|
|
35
|
+
className,
|
|
36
|
+
...props,
|
|
37
|
+
children: formatDistanceToNowStrict(date, { addSuffix, locale: vi })
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
export {
|
|
42
|
+
RelativeTime
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=relative-time.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/relative-time.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, formatDistanceToNowStrict, isValid } from \"date-fns\";\nimport { vi } from \"date-fns/locale\";\nimport { type ComponentProps, useEffect, useState } from \"react\";\n\nexport interface RelativeTimeProps extends Omit<ComponentProps<\"time\">, \"dateTime\" | \"children\"> {\n /** Thời điểm cần hiển thị: Date, ISO string hoặc epoch ms */\n value: Date | string | number;\n /** Thêm hậu tố \"trước\"/\"nữa\" (mặc định true) */\n addSuffix?: boolean;\n /** Chu kỳ tự cập nhật (ms). 0 = không tự cập nhật. Mặc định 60_000. */\n updateIntervalMs?: number;\n /** Định dạng của tooltip `title` (mặc định \"HH:mm 'ngày' dd/MM/yyyy\") */\n titleFormat?: string;\n /** Nội dung thay thế khi `value` không hợp lệ */\n fallback?: string;\n}\n\nfunction toDate(value: Date | string | number): Date {\n return value instanceof Date ? value : new Date(value);\n}\n\n/**\n * Thời gian tương đối kiểu \"3 phút trước\", tự cập nhật theo chu kỳ.\n *\n * Dùng `formatDistanceToNowStrict` (không phải bản thường): bản thường làm tròn kiểu \"khoảng 1\n * tháng\" cho mọi thứ từ 26 ngày đến 45 ngày — trong danh sách công việc thì \"26 ngày trước\" và\n * \"45 ngày trước\" là hai câu chuyện khác hẳn nhau.\n *\n * `title` luôn giữ mốc thời gian tuyệt đối để người dùng rà chuột là biết chính xác.\n * Có `suppressHydrationWarning` vì đồng hồ server và client gần như luôn lệch vài giây.\n */\nexport function RelativeTime({\n value,\n addSuffix = true,\n updateIntervalMs = 60_000,\n titleFormat = \"HH:mm 'ngày' dd/MM/yyyy\",\n fallback = \"—\",\n className,\n ...props\n}: RelativeTimeProps) {\n const date = toDate(value);\n const valid = isValid(date);\n\n const [, setTick] = useState(0);\n\n useEffect(() => {\n if (!valid || !updateIntervalMs) return;\n const timer = setInterval(() => setTick((current) => current + 1), updateIntervalMs);\n return () => clearInterval(timer);\n }, [valid, updateIntervalMs]);\n\n // Ngày hỏng (chuỗi rỗng, null từ API) → KHÔNG render <time>: thẻ này bắt buộc phải có\n // `datetime` hợp lệ, đưa \"Invalid Date\" vào là tạo dữ liệu sai cho trình đọc màn hình và bộ máy tìm kiếm.\n if (!valid) {\n return <span className={className}>{fallback}</span>;\n }\n\n return (\n <time\n dateTime={date.toISOString()}\n title={formatDate(date, titleFormat, { locale: vi })}\n suppressHydrationWarning\n className={className}\n {...props}\n >\n {formatDistanceToNowStrict(date, { addSuffix, locale: vi })}\n </time>\n );\n}\n"],"mappings":";AAwDW;AAtDX,SAAS,UAAU,YAAY,2BAA2B,eAAe;AACzE,SAAS,UAAU;AACnB,SAA8B,WAAW,gBAAgB;AAezD,SAAS,OAAO,OAAqC;AACnD,SAAO,iBAAiB,OAAO,QAAQ,IAAI,KAAK,KAAK;AACvD;AAYO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,cAAc;AAAA,EACd,WAAW;AAAA,EACX;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,QAAQ,QAAQ,IAAI;AAE1B,QAAM,CAAC,EAAE,OAAO,IAAI,SAAS,CAAC;AAE9B,YAAU,MAAM;AACd,QAAI,CAAC,SAAS,CAAC,iBAAkB;AACjC,UAAM,QAAQ,YAAY,MAAM,QAAQ,CAAC,YAAY,UAAU,CAAC,GAAG,gBAAgB;AACnF,WAAO,MAAM,cAAc,KAAK;AAAA,EAClC,GAAG,CAAC,OAAO,gBAAgB,CAAC;AAI5B,MAAI,CAAC,OAAO;AACV,WAAO,oBAAC,UAAK,WAAuB,oBAAS;AAAA,EAC/C;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,UAAU,KAAK,YAAY;AAAA,MAC3B,OAAO,WAAW,MAAM,aAAa,EAAE,QAAQ,GAAG,CAAC;AAAA,MACnD,0BAAwB;AAAA,MACxB;AAAA,MACC,GAAG;AAAA,MAEH,oCAA0B,MAAM,EAAE,WAAW,QAAQ,GAAG,CAAC;AAAA;AAAA,EAC5D;AAEJ;","names":[]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Ref, ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
interface SearchFilterBarProps {
|
|
5
|
+
/** Giá trị ô tìm kiếm (controlled) */
|
|
6
|
+
searchValue: string;
|
|
7
|
+
onSearchChange: (value: string) => void;
|
|
8
|
+
searchPlaceholder?: string;
|
|
9
|
+
/** Trễ trước khi gọi `onSearchChange` (ms). 0 = gọi ngay. Gõ tìm gọi API thì đặt 250–350. */
|
|
10
|
+
debounceMs?: number;
|
|
11
|
+
searchInputRef?: Ref<HTMLInputElement>;
|
|
12
|
+
/** Props đặt thẳng lên ô nhập (id, name, aria-label, enterKeyHint…) */
|
|
13
|
+
searchInputProps?: ComponentPropsWithoutRef<"input">;
|
|
14
|
+
/** Slot bộ lọc cạnh ô tìm (Select, Combobox, DatePicker…) */
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
/** Slot hành động bên phải (nút Tạo mới, menu cột…) */
|
|
17
|
+
actions?: ReactNode;
|
|
18
|
+
/** Số bộ lọc đang bật — hiện huy hiệu và mở dòng đếm kết quả */
|
|
19
|
+
activeFilterCount?: number;
|
|
20
|
+
onClearFilters?: () => void;
|
|
21
|
+
/** Số bản ghi sau lọc */
|
|
22
|
+
resultCount?: number;
|
|
23
|
+
/** Tổng số bản ghi khi chưa lọc */
|
|
24
|
+
total?: number;
|
|
25
|
+
unitLabel?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Thanh tìm kiếm + lọc chuẩn cho trang danh sách.
|
|
30
|
+
*
|
|
31
|
+
* Ô 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,
|
|
32
|
+
* mỗi phím gõ là một lần render cả bảng (và thường là một request).
|
|
33
|
+
*
|
|
34
|
+
* 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
|
+
*/
|
|
36
|
+
declare function SearchFilterBar({ searchValue, onSearchChange, searchPlaceholder, debounceMs, searchInputRef, searchInputProps, children, actions, activeFilterCount, onClearFilters, resultCount, total, unitLabel, className }: SearchFilterBarProps): React.JSX.Element;
|
|
37
|
+
|
|
38
|
+
export { SearchFilterBar, type SearchFilterBarProps };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Search, SlidersHorizontal, X } from "lucide-react";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { cn } from "../../lib/cn.js";
|
|
6
|
+
import { Badge } from "../ui/badge.js";
|
|
7
|
+
import { Button } from "../ui/button.js";
|
|
8
|
+
import { Input } from "../ui/input.js";
|
|
9
|
+
function SearchFilterBar({
|
|
10
|
+
searchValue,
|
|
11
|
+
onSearchChange,
|
|
12
|
+
searchPlaceholder = "T\xECm ki\u1EBFm\u2026",
|
|
13
|
+
debounceMs = 250,
|
|
14
|
+
searchInputRef,
|
|
15
|
+
searchInputProps,
|
|
16
|
+
children,
|
|
17
|
+
actions,
|
|
18
|
+
activeFilterCount = 0,
|
|
19
|
+
onClearFilters,
|
|
20
|
+
resultCount,
|
|
21
|
+
total,
|
|
22
|
+
unitLabel = "m\u1EE5c",
|
|
23
|
+
className
|
|
24
|
+
}) {
|
|
25
|
+
const [localValue, setLocalValue] = useState(searchValue);
|
|
26
|
+
const lastEmittedRef = useRef(searchValue);
|
|
27
|
+
const timerRef = useRef(null);
|
|
28
|
+
const onSearchChangeRef = useRef(onSearchChange);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
onSearchChangeRef.current = onSearchChange;
|
|
31
|
+
});
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (searchValue !== lastEmittedRef.current) {
|
|
34
|
+
lastEmittedRef.current = searchValue;
|
|
35
|
+
setLocalValue(searchValue);
|
|
36
|
+
if (timerRef.current) {
|
|
37
|
+
clearTimeout(timerRef.current);
|
|
38
|
+
timerRef.current = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}, [searchValue]);
|
|
42
|
+
useEffect(
|
|
43
|
+
() => () => {
|
|
44
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
45
|
+
},
|
|
46
|
+
[]
|
|
47
|
+
);
|
|
48
|
+
const emit = (value) => {
|
|
49
|
+
setLocalValue(value);
|
|
50
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
51
|
+
if (!debounceMs) {
|
|
52
|
+
lastEmittedRef.current = value;
|
|
53
|
+
onSearchChangeRef.current(value);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
timerRef.current = setTimeout(() => {
|
|
57
|
+
lastEmittedRef.current = value;
|
|
58
|
+
onSearchChangeRef.current(value);
|
|
59
|
+
}, debounceMs);
|
|
60
|
+
};
|
|
61
|
+
const clearSearch = () => {
|
|
62
|
+
if (timerRef.current) {
|
|
63
|
+
clearTimeout(timerRef.current);
|
|
64
|
+
timerRef.current = null;
|
|
65
|
+
}
|
|
66
|
+
setLocalValue("");
|
|
67
|
+
lastEmittedRef.current = "";
|
|
68
|
+
onSearchChangeRef.current("");
|
|
69
|
+
};
|
|
70
|
+
const hasFilters = activeFilterCount > 0;
|
|
71
|
+
const showCount = hasFilters || Boolean(localValue);
|
|
72
|
+
return /* @__PURE__ */ jsxs("div", { "data-slot": "search-filter-bar", className: cn("flex flex-col gap-2", className), children: [
|
|
73
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
74
|
+
/* @__PURE__ */ jsxs("div", { className: "relative min-w-[12rem] flex-1 sm:max-w-sm", children: [
|
|
75
|
+
/* @__PURE__ */ jsx(
|
|
76
|
+
Search,
|
|
77
|
+
{
|
|
78
|
+
"aria-hidden": "true",
|
|
79
|
+
className: "absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
|
80
|
+
}
|
|
81
|
+
),
|
|
82
|
+
/* @__PURE__ */ jsx(
|
|
83
|
+
Input,
|
|
84
|
+
{
|
|
85
|
+
ref: searchInputRef,
|
|
86
|
+
type: "search",
|
|
87
|
+
value: localValue,
|
|
88
|
+
placeholder: searchPlaceholder,
|
|
89
|
+
"aria-label": searchPlaceholder,
|
|
90
|
+
onChange: (event) => emit(event.target.value),
|
|
91
|
+
className: "pl-9 pr-8 [&::-webkit-search-cancel-button]:hidden",
|
|
92
|
+
...searchInputProps
|
|
93
|
+
}
|
|
94
|
+
),
|
|
95
|
+
localValue && /* @__PURE__ */ jsxs(
|
|
96
|
+
Button,
|
|
97
|
+
{
|
|
98
|
+
type: "button",
|
|
99
|
+
variant: "ghost",
|
|
100
|
+
size: "icon-sm",
|
|
101
|
+
onClick: clearSearch,
|
|
102
|
+
className: "absolute right-1 top-1/2 size-6 -translate-y-1/2",
|
|
103
|
+
children: [
|
|
104
|
+
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
105
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: "X\xF3a t\u1EEB kh\xF3a" })
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
] }),
|
|
110
|
+
children,
|
|
111
|
+
hasFilters && /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "gap-1", children: [
|
|
112
|
+
/* @__PURE__ */ jsx(SlidersHorizontal, { "aria-hidden": "true" }),
|
|
113
|
+
activeFilterCount,
|
|
114
|
+
" b\u1ED9 l\u1ECDc"
|
|
115
|
+
] }),
|
|
116
|
+
actions && /* @__PURE__ */ jsx("div", { className: "ml-auto flex items-center gap-2", children: actions })
|
|
117
|
+
] }),
|
|
118
|
+
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
|
+
] }),
|
|
125
|
+
hasFilters && onClearFilters && /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "sm", onClick: onClearFilters, className: "h-7 gap-1.5 px-2", children: [
|
|
126
|
+
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
127
|
+
"X\xF3a l\u1ECDc"
|
|
128
|
+
] })
|
|
129
|
+
] })
|
|
130
|
+
] });
|
|
131
|
+
}
|
|
132
|
+
export {
|
|
133
|
+
SearchFilterBar
|
|
134
|
+
};
|
|
135
|
+
//# sourceMappingURL=search-filter-bar.js.map
|
|
@@ -0,0 +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":[]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
import * as class_variance_authority_types from 'class-variance-authority/types';
|
|
4
|
+
import { VariantProps } from 'class-variance-authority';
|
|
5
|
+
import { IconComponent } from './empty-state.js';
|
|
6
|
+
|
|
7
|
+
/** Ô icon của StatCard, tô theo tone token (không hex). */
|
|
8
|
+
declare const statCardIconVariants: (props?: ({
|
|
9
|
+
tone?: "default" | "primary" | "success" | "warning" | "destructive" | "info" | "app" | null | undefined;
|
|
10
|
+
} & class_variance_authority_types.ClassProp) | undefined) => string;
|
|
11
|
+
type StatCardTone = NonNullable<VariantProps<typeof statCardIconVariants>["tone"]>;
|
|
12
|
+
type StatCardDeltaDirection = "up" | "down" | "flat";
|
|
13
|
+
interface StatCardDelta {
|
|
14
|
+
/** Nội dung hiển thị, vd "+12,5%" hoặc "3 việc" */
|
|
15
|
+
value: ReactNode;
|
|
16
|
+
direction: StatCardDeltaDirection;
|
|
17
|
+
/**
|
|
18
|
+
* Tăng là TỐT hay XẤU — mặc định "positive-up" (tăng = tốt).
|
|
19
|
+
*
|
|
20
|
+
* 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.
|
|
21
|
+
* 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.
|
|
22
|
+
*/
|
|
23
|
+
polarity?: "positive-up" | "positive-down" | "neutral";
|
|
24
|
+
/** Chú thích sau con số, vd "so với tuần trước" */
|
|
25
|
+
label?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
interface StatCardProps extends Omit<ComponentProps<"div">, "title"> {
|
|
28
|
+
/** Nhãn chỉ số, vd "Việc đang mở" */
|
|
29
|
+
label: ReactNode;
|
|
30
|
+
value: ReactNode;
|
|
31
|
+
icon?: IconComponent;
|
|
32
|
+
tone?: StatCardTone;
|
|
33
|
+
delta?: StatCardDelta;
|
|
34
|
+
/** Slot biểu đồ mini (sparkline) — package không ép thư viện biểu đồ nào */
|
|
35
|
+
sparkline?: ReactNode;
|
|
36
|
+
/** Dòng chú thích dưới cùng */
|
|
37
|
+
footer?: ReactNode;
|
|
38
|
+
/** Đang tải → khung xương thay cho số liệu */
|
|
39
|
+
loading?: boolean;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Thẻ KPI: nhãn + số liệu lớn + biến động + sparkline tuỳ chọn.
|
|
43
|
+
*
|
|
44
|
+
* Số liệu dùng `tabular-nums` để cột số không nhảy khi cập nhật realtime. Tĩnh → RSC-safe.
|
|
45
|
+
*/
|
|
46
|
+
declare function StatCard({ label, value, icon: Icon, tone, delta, sparkline, footer, loading, className, ...props }: StatCardProps): React.JSX.Element;
|
|
47
|
+
|
|
48
|
+
export { StatCard, type StatCardDelta, type StatCardDeltaDirection, type StatCardProps, type StatCardTone, statCardIconVariants };
|