@suphark/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/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/components.json +21 -0
- package/package.json +108 -0
- package/scripts/audit-base-ui-props.mjs +179 -0
- package/scripts/audit-tailwind-typography.mjs +100 -0
- package/src/components/design-system/design-system-showcase.tsx +138 -0
- package/src/components/design-system/sections/showcase-data-display.tsx +454 -0
- package/src/components/design-system/sections/showcase-feedback.tsx +273 -0
- package/src/components/design-system/sections/showcase-forms.tsx +373 -0
- package/src/components/design-system/sections/showcase-foundations.tsx +384 -0
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +397 -0
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +479 -0
- package/src/components/design-system/sections/showcase-overview.tsx +368 -0
- package/src/components/design-system/sections/showcase-primitives.tsx +463 -0
- package/src/components/shared/__tests__/multi-select-combobox.test.tsx +180 -0
- package/src/components/shared/__tests__/name-id-fields.test.ts +24 -0
- package/src/components/shared/button/add-new-button.tsx +19 -0
- package/src/components/shared/button/base/action-button.tsx +134 -0
- package/src/components/shared/button/base/link-button.tsx +130 -0
- package/src/components/shared/button/cancel-button.tsx +27 -0
- package/src/components/shared/button/confirm-button.tsx +26 -0
- package/src/components/shared/button/delete-button.tsx +36 -0
- package/src/components/shared/button/floating-save-button.tsx +89 -0
- package/src/components/shared/button/index.ts +13 -0
- package/src/components/shared/button/print-button.tsx +67 -0
- package/src/components/shared/button/refresh-button.tsx +63 -0
- package/src/components/shared/button/save-button.tsx +29 -0
- package/src/components/shared/button/submit-button.tsx +58 -0
- package/src/components/shared/client-redirect.tsx +11 -0
- package/src/components/shared/combobox.tsx +116 -0
- package/src/components/shared/data-list.tsx +65 -0
- package/src/components/shared/date/date-range-picker.tsx +106 -0
- package/src/components/shared/description-list.tsx +27 -0
- package/src/components/shared/empty-row.tsx +13 -0
- package/src/components/shared/empty-state.tsx +31 -0
- package/src/components/shared/env-snippet.tsx +62 -0
- package/src/components/shared/form/__tests__/simple-select.test.tsx +72 -0
- package/src/components/shared/form/form-field-boolean.tsx +93 -0
- package/src/components/shared/form/form-field-color-select.tsx +86 -0
- package/src/components/shared/form/form-field-combobox.tsx +167 -0
- package/src/components/shared/form/form-field-date.tsx +55 -0
- package/src/components/shared/form/form-field-formatted-number.tsx +103 -0
- package/src/components/shared/form/form-field-input.tsx +372 -0
- package/src/components/shared/form/form-field-radio-cards.tsx +127 -0
- package/src/components/shared/form/form-field-radio-group.tsx +56 -0
- package/src/components/shared/form/form-field-select.tsx +312 -0
- package/src/components/shared/form/simple-select.tsx +152 -0
- package/src/components/shared/form-dialog.tsx +109 -0
- package/src/components/shared/form-message.tsx +30 -0
- package/src/components/shared/initials.tsx +33 -0
- package/src/components/shared/layout/error-state.tsx +160 -0
- package/src/components/shared/layout/page-shell.tsx +57 -0
- package/src/components/shared/link/transition-link.tsx +72 -0
- package/src/components/shared/multi-select-combobox.tsx +392 -0
- package/src/components/shared/name-id-fields.tsx +133 -0
- package/src/components/shared/navigation/page-breadcrumb.tsx +100 -0
- package/src/components/shared/notice.tsx +35 -0
- package/src/components/shared/number/formatted-number.tsx +133 -0
- package/src/components/shared/page-header.tsx +25 -0
- package/src/components/shared/page-skeleton.tsx +18 -0
- package/src/components/shared/row-actions.tsx +93 -0
- package/src/components/shared/row-menu.tsx +55 -0
- package/src/components/shared/secret-reveal.tsx +53 -0
- package/src/components/shared/section-card.tsx +44 -0
- package/src/components/shared/stat.tsx +26 -0
- package/src/components/shared/status-badge.tsx +47 -0
- package/src/components/shared/text/primary-secondary-text.tsx +186 -0
- package/src/components/shared/theme/index.ts +4 -0
- package/src/components/shared/theme/theme-menu-items.tsx +43 -0
- package/src/components/shared/theme/theme-provider.tsx +39 -0
- package/src/components/shared/theme/theme-switch.tsx +36 -0
- package/src/components/shared/theme/theme-toggle.tsx +76 -0
- package/src/components/shared/time/client-date-formatter.tsx +188 -0
- package/src/components/shared/ui-text.tsx +47 -0
- package/src/components/ui/__tests__/combobox.test.tsx +40 -0
- package/src/components/ui/__tests__/date-picker.test.tsx +53 -0
- package/src/components/ui/__tests__/slider.test.tsx +18 -0
- package/src/components/ui/__tests__/tabs.test.tsx +33 -0
- package/src/components/ui/accordion.tsx +69 -0
- package/src/components/ui/alert-dialog.tsx +161 -0
- package/src/components/ui/alert.tsx +68 -0
- package/src/components/ui/aspect-ratio.tsx +22 -0
- package/src/components/ui/attachment.tsx +194 -0
- package/src/components/ui/avatar.tsx +92 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/breadcrumb.tsx +102 -0
- package/src/components/ui/button-group.tsx +77 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +184 -0
- package/src/components/ui/card.tsx +87 -0
- package/src/components/ui/carousel.tsx +230 -0
- package/src/components/ui/chart.tsx +338 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/collapsible.tsx +17 -0
- package/src/components/ui/combobox.tsx +272 -0
- package/src/components/ui/command.tsx +180 -0
- package/src/components/ui/context-menu.tsx +244 -0
- package/src/components/ui/date-picker.tsx +308 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/drawer.tsx +208 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/empty.tsx +93 -0
- package/src/components/ui/field.tsx +223 -0
- package/src/components/ui/form.tsx +161 -0
- package/src/components/ui/hover-card.tsx +46 -0
- package/src/components/ui/input-group.tsx +145 -0
- package/src/components/ui/input-otp.tsx +85 -0
- package/src/components/ui/input.tsx +19 -0
- package/src/components/ui/item.tsx +187 -0
- package/src/components/ui/kbd.tsx +26 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/menubar.tsx +266 -0
- package/src/components/ui/navigation-menu.tsx +160 -0
- package/src/components/ui/pagination.tsx +117 -0
- package/src/components/ui/popover.tsx +76 -0
- package/src/components/ui/progress.tsx +66 -0
- package/src/components/ui/radio-group.tsx +38 -0
- package/src/components/ui/relative-date.tsx +40 -0
- package/src/components/ui/resizable.tsx +41 -0
- package/src/components/ui/scroll-area.tsx +67 -0
- package/src/components/ui/searchable-select.tsx +124 -0
- package/src/components/ui/select.tsx +189 -0
- package/src/components/ui/separator.tsx +21 -0
- package/src/components/ui/sheet.tsx +124 -0
- package/src/components/ui/sidebar.tsx +691 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +55 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/table.tsx +95 -0
- package/src/components/ui/tabs.tsx +79 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/toggle-group.tsx +86 -0
- package/src/components/ui/toggle.tsx +44 -0
- package/src/components/ui/tooltip.tsx +54 -0
- package/src/hooks/use-copy-to-clipboard.ts +27 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/index.ts +156 -0
- package/src/lib/formatters.ts +44 -0
- package/src/lib/permission.ts +75 -0
- package/src/lib/status-colors.ts +52 -0
- package/src/lib/utils.ts +6 -0
- package/src/next.ts +15 -0
- package/src/styles.css +214 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button, buttonVariants } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@suphark/ui/components/ui/card";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { AlertTriangle, Home, LogIn, RotateCcw } from "lucide-react";
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Error object ที่ Next.js ส่งให้ error boundary
|
|
11
|
+
* `digest` คือรหัสอ้างอิงของ server error ที่ Next สร้างให้ ใช้จับคู่กับ log ฝั่งเซิร์ฟเวอร์ได้
|
|
12
|
+
*/
|
|
13
|
+
export type RouteError = Error & { digest?: string };
|
|
14
|
+
|
|
15
|
+
interface ErrorStateProps {
|
|
16
|
+
error: RouteError;
|
|
17
|
+
/** ฟังก์ชัน reset ของ error boundary — เรนเดอร์ segment ที่พังใหม่โดยไม่ reload ทั้งหน้า */
|
|
18
|
+
reset: () => void;
|
|
19
|
+
title?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
/** ซ่อนปุ่มกลับหน้าหลัก เช่นในหน้า auth ที่ผู้ใช้ยังไม่ได้ล็อกอิน */
|
|
22
|
+
hideHomeButton?: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* UI กลางสำหรับทุก error boundary ของแอป
|
|
28
|
+
*
|
|
29
|
+
* แยกออกมาไว้ที่เดียวเพื่อให้ error.tsx ของแต่ละ route group และ global-error.tsx
|
|
30
|
+
* แสดงผลเหมือนกัน และแก้ข้อความที่จุดเดียวจบ
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* server action ที่ถูก proxy ตอบ 401 (เซสชันหมดอายุ) ทำให้ client ของ Next โยน error ข้อความนี้
|
|
34
|
+
* ซึ่งบอกอะไรผู้ใช้ไม่ได้เลย — ต้องเช็คเซสชันซ้ำเพื่อแยกจากกรณีเซิร์ฟเวอร์พังจริง
|
|
35
|
+
*/
|
|
36
|
+
function looksLikeBrokenActionResponse(error: RouteError) {
|
|
37
|
+
return /unexpected response was received from the server/i.test(error.message ?? "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ErrorState({
|
|
41
|
+
error,
|
|
42
|
+
reset,
|
|
43
|
+
title = "เกิดข้อผิดพลาด",
|
|
44
|
+
description = "ระบบไม่สามารถแสดงหน้านี้ได้ในขณะนี้ กรุณาลองใหม่อีกครั้ง หากยังพบปัญหาโปรดแจ้งผู้ดูแลระบบ",
|
|
45
|
+
hideHomeButton = false,
|
|
46
|
+
className,
|
|
47
|
+
}: ErrorStateProps) {
|
|
48
|
+
const [sessionExpired, setSessionExpired] = useState(false);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
// Server error จะถูก log ฝั่งเซิร์ฟเวอร์อยู่แล้ว ตัวนี้ไว้จับ error ที่เกิดฝั่ง client
|
|
52
|
+
console.error("[error-boundary]", error);
|
|
53
|
+
}, [error]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!looksLikeBrokenActionResponse(error)) return;
|
|
57
|
+
let cancelled = false;
|
|
58
|
+
fetch("/api/auth/get-session", { credentials: "include", cache: "no-store" })
|
|
59
|
+
.then(async (response) => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
if (response.status === 401) return setSessionExpired(true);
|
|
62
|
+
const body = await response.json().catch(() => null);
|
|
63
|
+
if (!body || !(body as { user?: unknown }).user) setSessionExpired(true);
|
|
64
|
+
})
|
|
65
|
+
.catch(() => {
|
|
66
|
+
// เช็คไม่ได้ก็แสดงข้อความทั่วไปตามเดิม
|
|
67
|
+
});
|
|
68
|
+
return () => {
|
|
69
|
+
cancelled = true;
|
|
70
|
+
};
|
|
71
|
+
}, [error]);
|
|
72
|
+
|
|
73
|
+
if (sessionExpired) {
|
|
74
|
+
const signInHref = `/sign-in?redirect_url=${encodeURIComponent(
|
|
75
|
+
typeof window === "undefined" ? "/" : window.location.href,
|
|
76
|
+
)}`;
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"flex min-h-[60vh] flex-col items-center justify-center gap-4 p-4",
|
|
81
|
+
className,
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<Card className="w-full max-w-md shadow-lg">
|
|
85
|
+
<CardHeader className="pb-2 text-center">
|
|
86
|
+
<div className="mb-4 flex justify-center">
|
|
87
|
+
<div className="rounded-full bg-background p-3 shadow-sm">
|
|
88
|
+
<LogIn className="h-10 w-10 text-primary" />
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<CardTitle className="text-2xl">เซสชันหมดอายุ</CardTitle>
|
|
92
|
+
</CardHeader>
|
|
93
|
+
<CardContent className="space-y-4 text-center">
|
|
94
|
+
<p className="text-muted-foreground">
|
|
95
|
+
คุณถูกออกจากระบบระหว่างใช้งาน ข้อมูลที่ยังไม่ได้บันทึกจะหายไป กรุณาเข้าสู่ระบบอีกครั้ง
|
|
96
|
+
แล้วระบบจะพากลับมาหน้านี้
|
|
97
|
+
</p>
|
|
98
|
+
<div className="flex justify-center pt-2">
|
|
99
|
+
{/* full page load เพื่อให้ proxy/เซสชันเริ่มใหม่สะอาด ๆ */}
|
|
100
|
+
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
|
|
101
|
+
<a href={signInHref} className={cn(buttonVariants())}>
|
|
102
|
+
<LogIn className="mr-2 h-4 w-4" />
|
|
103
|
+
เข้าสู่ระบบ
|
|
104
|
+
</a>
|
|
105
|
+
</div>
|
|
106
|
+
</CardContent>
|
|
107
|
+
</Card>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
className={cn("flex min-h-[60vh] flex-col items-center justify-center gap-4 p-4", className)}
|
|
115
|
+
>
|
|
116
|
+
<Card className="w-full max-w-md border-destructive/20 bg-destructive/5 shadow-lg">
|
|
117
|
+
<CardHeader className="pb-2 text-center">
|
|
118
|
+
<div className="mb-4 flex justify-center">
|
|
119
|
+
<div className="rounded-full bg-background p-3 shadow-sm">
|
|
120
|
+
<AlertTriangle className="h-10 w-10 text-destructive" />
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
<CardTitle className="text-2xl text-destructive">{title}</CardTitle>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent className="space-y-4 text-center">
|
|
126
|
+
<p className="text-muted-foreground">{description}</p>
|
|
127
|
+
|
|
128
|
+
{error.digest && (
|
|
129
|
+
<p className="text-muted-foreground text-xs">
|
|
130
|
+
รหัสอ้างอิง: <code className="font-mono">{error.digest}</code>
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{process.env.NODE_ENV === "development" && error.message && (
|
|
135
|
+
<pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded border bg-muted p-2 text-left font-mono text-muted-foreground text-xs">
|
|
136
|
+
{error.message}
|
|
137
|
+
</pre>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<div className="flex justify-center gap-2 pt-2">
|
|
141
|
+
<Button onClick={reset}>
|
|
142
|
+
<RotateCcw className="mr-2 h-4 w-4" />
|
|
143
|
+
ลองใหม่
|
|
144
|
+
</Button>
|
|
145
|
+
{!hideHomeButton && (
|
|
146
|
+
// ตั้งใจใช้ <a> แทน next/link เพื่อให้เป็น full page load ไม่ใช่ client navigation:
|
|
147
|
+
// router state ตอนที่ boundary ทำงานอาจพังไปแล้ว และ global-error.tsx
|
|
148
|
+
// เรนเดอร์อยู่นอก root layout การรีโหลดจริงจึงเป็นทางกู้คืนที่แน่นอนกว่า
|
|
149
|
+
// eslint-disable-next-line @next/next/no-html-link-for-pages
|
|
150
|
+
<a href="/" className={cn(buttonVariants({ variant: "outline" }))}>
|
|
151
|
+
<Home className="mr-2 h-4 w-4" />
|
|
152
|
+
กลับหน้าหลัก
|
|
153
|
+
</a>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</CardContent>
|
|
157
|
+
</Card>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import { type ReactNode, Suspense } from "react";
|
|
3
|
+
import { PageBreadcrumb, type PageBreadcrumbItem } from "../navigation/page-breadcrumb";
|
|
4
|
+
|
|
5
|
+
interface PageShellProps {
|
|
6
|
+
title?: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
breadcrumb?: PageBreadcrumbItem[];
|
|
9
|
+
actions?: ReactNode;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
fallback: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* PageShell - A standard template for implementing the Parallel Shell & Static Header Pattern.
|
|
17
|
+
* It renders the static header (title, description, breadcrumbs) immediately to the browser
|
|
18
|
+
* while wrapping the dynamic children in a Suspense boundary for non-blocking streaming.
|
|
19
|
+
*/
|
|
20
|
+
export function PageShell({
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
breadcrumb,
|
|
24
|
+
actions,
|
|
25
|
+
children,
|
|
26
|
+
fallback,
|
|
27
|
+
className,
|
|
28
|
+
}: PageShellProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn("flex flex-col gap-6", className)}>
|
|
31
|
+
{/*
|
|
32
|
+
STATIC HEADER (Rendered Immediately)
|
|
33
|
+
This part ensures the user sees the page title and context right away.
|
|
34
|
+
*/}
|
|
35
|
+
<div className="space-y-4">
|
|
36
|
+
{breadcrumb && <PageBreadcrumb items={breadcrumb} />}
|
|
37
|
+
|
|
38
|
+
{(title || actions) && (
|
|
39
|
+
<div className="flex items-center justify-between">
|
|
40
|
+
<div className="space-y-1">
|
|
41
|
+
{title && <h1 className="font-bold text-2xl tracking-tight">{title}</h1>}
|
|
42
|
+
{description && <p className="text-muted-foreground">{description}</p>}
|
|
43
|
+
</div>
|
|
44
|
+
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
|
45
|
+
</div>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/*
|
|
50
|
+
DYNAMIC CONTENT (Streamed)
|
|
51
|
+
The children are wrapped in Suspense to prevent data waterfalls
|
|
52
|
+
from blocking the entire page render.
|
|
53
|
+
*/}
|
|
54
|
+
<Suspense fallback={fallback}>{children}</Suspense>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useEffect, useTransition } from "react";
|
|
6
|
+
import { toast } from "sonner";
|
|
7
|
+
|
|
8
|
+
interface TransitionLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
9
|
+
href: string;
|
|
10
|
+
children?: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A client-side link that uses useTransition to show pending UI (Toast, opacity)
|
|
15
|
+
* without blocking the UI, providing a smoother navigation experience
|
|
16
|
+
* than a standard Next.js <Link> or router.push().
|
|
17
|
+
*/
|
|
18
|
+
export function TransitionLink({
|
|
19
|
+
href,
|
|
20
|
+
children,
|
|
21
|
+
className,
|
|
22
|
+
onClick,
|
|
23
|
+
...props
|
|
24
|
+
}: TransitionLinkProps) {
|
|
25
|
+
const router = useRouter();
|
|
26
|
+
const [isPending, startTransition] = useTransition();
|
|
27
|
+
|
|
28
|
+
// Handle Toast notification for loading state
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (isPending) {
|
|
31
|
+
// Use a fixed ID to prevent toast stacking on rapid clicks
|
|
32
|
+
const toastId = "page-transition-loading";
|
|
33
|
+
toast.loading("กำลังโหลด...", { id: toastId });
|
|
34
|
+
|
|
35
|
+
// Clean up: Dismiss toast when transition finishes (isPending -> false)
|
|
36
|
+
// or when the component unmounts (navigation completes)
|
|
37
|
+
return () => {
|
|
38
|
+
toast.dismiss(toastId);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}, [isPending]);
|
|
42
|
+
|
|
43
|
+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
44
|
+
if (onClick) {
|
|
45
|
+
onClick(e);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (e.defaultPrevented) return;
|
|
49
|
+
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
startTransition(() => {
|
|
52
|
+
router.push(href);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<a
|
|
58
|
+
href={href}
|
|
59
|
+
onClick={handleClick}
|
|
60
|
+
className={cn(
|
|
61
|
+
"font-medium text-primary hover:underline",
|
|
62
|
+
// Add visual feedback (opacity)
|
|
63
|
+
isPending && "cursor-wait opacity-70",
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
aria-disabled={isPending}
|
|
67
|
+
{...props}
|
|
68
|
+
>
|
|
69
|
+
{children}
|
|
70
|
+
</a>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
4
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
|
+
import {
|
|
6
|
+
Command,
|
|
7
|
+
CommandEmpty,
|
|
8
|
+
CommandGroup,
|
|
9
|
+
CommandInput,
|
|
10
|
+
CommandItem,
|
|
11
|
+
CommandList,
|
|
12
|
+
CommandSeparator,
|
|
13
|
+
} from "@suphark/ui/components/ui/command";
|
|
14
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
15
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
16
|
+
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
|
17
|
+
import * as React from "react";
|
|
18
|
+
|
|
19
|
+
export interface MultiSelectOption {
|
|
20
|
+
label: string;
|
|
21
|
+
value: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
searchableValue?: string;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MultiSelectComboboxProps {
|
|
28
|
+
/** All available options */
|
|
29
|
+
options: MultiSelectOption[];
|
|
30
|
+
/** Selected values */
|
|
31
|
+
value?: string[];
|
|
32
|
+
/** Change callback */
|
|
33
|
+
onChange: (value: string[]) => void;
|
|
34
|
+
/** Trigger placeholder */
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
/** Search input placeholder */
|
|
37
|
+
searchPlaceholder?: string;
|
|
38
|
+
/** Empty search result message */
|
|
39
|
+
emptyText?: string;
|
|
40
|
+
/** Additional CSS class for the trigger button */
|
|
41
|
+
className?: string;
|
|
42
|
+
/** Disabled state */
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
/** Maximum number of badges to display before showing +N more */
|
|
45
|
+
maxDisplay?: number;
|
|
46
|
+
/** Allow creating custom options on the fly */
|
|
47
|
+
creatable?: boolean;
|
|
48
|
+
/** Show select all / clear all actions in the dropdown */
|
|
49
|
+
showSelectAll?: boolean;
|
|
50
|
+
/** Select all button label */
|
|
51
|
+
selectAllText?: string;
|
|
52
|
+
/** Clear all button label */
|
|
53
|
+
clearAllText?: string;
|
|
54
|
+
/** Show group headers separating selected vs unselected items (default: true) */
|
|
55
|
+
showGroupHeaders?: boolean;
|
|
56
|
+
/** Selected group header label */
|
|
57
|
+
selectedGroupHeading?: string;
|
|
58
|
+
/** Unselected group header label */
|
|
59
|
+
unselectedGroupHeading?: string;
|
|
60
|
+
/** Popover modal mode (useful inside Dialogs) */
|
|
61
|
+
modal?: boolean;
|
|
62
|
+
/** Custom render element for PopoverTrigger (e.g. <FormControl />) */
|
|
63
|
+
triggerRender?: React.ReactElement;
|
|
64
|
+
/** Accessibility label */
|
|
65
|
+
ariaLabel?: string;
|
|
66
|
+
/** ให้ชื่อรายการที่เลือกขึ้นบรรทัดใหม่ได้ แทนการตัดที่ 200px เป็น "..." */
|
|
67
|
+
wrapText?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function MultiSelectCombobox({
|
|
71
|
+
options = [],
|
|
72
|
+
value = [],
|
|
73
|
+
onChange,
|
|
74
|
+
placeholder = "เลือก...",
|
|
75
|
+
searchPlaceholder = "ค้นหา...",
|
|
76
|
+
emptyText = "ไม่พบผลลัพธ์",
|
|
77
|
+
className,
|
|
78
|
+
disabled = false,
|
|
79
|
+
maxDisplay,
|
|
80
|
+
creatable = false,
|
|
81
|
+
showSelectAll = false,
|
|
82
|
+
selectAllText = "เลือกทั้งหมด",
|
|
83
|
+
clearAllText = "ล้างการเลือก",
|
|
84
|
+
showGroupHeaders = true,
|
|
85
|
+
selectedGroupHeading = "รายการที่เลือก",
|
|
86
|
+
unselectedGroupHeading = "ตัวเลือกทั้งหมด",
|
|
87
|
+
modal,
|
|
88
|
+
triggerRender,
|
|
89
|
+
ariaLabel,
|
|
90
|
+
wrapText = false,
|
|
91
|
+
}: MultiSelectComboboxProps) {
|
|
92
|
+
const [open, setOpen] = React.useState(false);
|
|
93
|
+
const [inputValue, setInputValue] = React.useState("");
|
|
94
|
+
|
|
95
|
+
const handleToggle = (optionValue: string) => {
|
|
96
|
+
if (value.includes(optionValue)) {
|
|
97
|
+
onChange(value.filter((v) => v !== optionValue));
|
|
98
|
+
} else {
|
|
99
|
+
onChange([...value, optionValue]);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleRemove = (valueToRemove: string, e?: React.MouseEvent | React.KeyboardEvent) => {
|
|
104
|
+
e?.stopPropagation();
|
|
105
|
+
onChange(value.filter((v) => v !== valueToRemove));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleClearAll = (e: React.MouseEvent) => {
|
|
109
|
+
e.stopPropagation();
|
|
110
|
+
onChange([]);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleSelectAll = () => {
|
|
114
|
+
const allSelectableValues = options.filter((o) => !o.disabled).map((o) => o.value);
|
|
115
|
+
const combined = Array.from(new Set([...value, ...allSelectableValues]));
|
|
116
|
+
onChange(combined);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleCreate = () => {
|
|
120
|
+
const trimmed = inputValue.trim();
|
|
121
|
+
if (!trimmed) return;
|
|
122
|
+
if (!value.includes(trimmed)) {
|
|
123
|
+
onChange([...value, trimmed]);
|
|
124
|
+
}
|
|
125
|
+
setInputValue("");
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const getLabel = (val: string) => {
|
|
129
|
+
return options.find((o) => o.value === val)?.label || val;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Split and order options: Selected items ALWAYS appear first at the top
|
|
133
|
+
const { selectedOptions, unselectedOptions } = React.useMemo(() => {
|
|
134
|
+
const knownSelected: MultiSelectOption[] = [];
|
|
135
|
+
const unselected: MultiSelectOption[] = [];
|
|
136
|
+
const valueSet = new Set(value);
|
|
137
|
+
|
|
138
|
+
for (const option of options) {
|
|
139
|
+
if (valueSet.has(option.value)) {
|
|
140
|
+
knownSelected.push(option);
|
|
141
|
+
} else {
|
|
142
|
+
unselected.push(option);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Include values that might not be in options (e.g. custom or external IDs)
|
|
147
|
+
const optionsValueSet = new Set(options.map((o) => o.value));
|
|
148
|
+
const unknownSelected: MultiSelectOption[] = value
|
|
149
|
+
.filter((val) => !optionsValueSet.has(val))
|
|
150
|
+
.map((val) => ({ label: val, value: val }));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
selectedOptions: [...knownSelected, ...unknownSelected],
|
|
154
|
+
unselectedOptions: unselected,
|
|
155
|
+
};
|
|
156
|
+
}, [options, value]);
|
|
157
|
+
|
|
158
|
+
const displayedValues =
|
|
159
|
+
typeof maxDisplay === "number" && maxDisplay > 0 ? value.slice(0, maxDisplay) : value;
|
|
160
|
+
const remainingCount = value.length - displayedValues.length;
|
|
161
|
+
|
|
162
|
+
const canCreate =
|
|
163
|
+
creatable &&
|
|
164
|
+
inputValue.trim().length > 0 &&
|
|
165
|
+
!options.some(
|
|
166
|
+
(o) =>
|
|
167
|
+
o.label.toLowerCase() === inputValue.trim().toLowerCase() ||
|
|
168
|
+
o.value.toLowerCase() === inputValue.trim().toLowerCase(),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const defaultTrigger = (
|
|
172
|
+
<Button
|
|
173
|
+
type="button"
|
|
174
|
+
variant="outline"
|
|
175
|
+
role="combobox"
|
|
176
|
+
aria-label={ariaLabel || placeholder}
|
|
177
|
+
aria-expanded={open}
|
|
178
|
+
className={cn(
|
|
179
|
+
"h-auto min-h-10 w-full justify-between font-normal",
|
|
180
|
+
value.length === 0 && "text-muted-foreground",
|
|
181
|
+
className,
|
|
182
|
+
)}
|
|
183
|
+
disabled={disabled}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<Popover open={open} onOpenChange={setOpen} modal={modal}>
|
|
189
|
+
<PopoverTrigger render={triggerRender ?? defaultTrigger}>
|
|
190
|
+
<div className="flex flex-wrap items-center gap-1.5 py-0.5">
|
|
191
|
+
{value.length > 0 ? (
|
|
192
|
+
<>
|
|
193
|
+
{displayedValues.map((val) => (
|
|
194
|
+
<Badge
|
|
195
|
+
key={val}
|
|
196
|
+
variant="secondary"
|
|
197
|
+
className={cn(
|
|
198
|
+
"rounded-md px-1.5 py-0.5 font-normal text-xs transition-colors",
|
|
199
|
+
wrapText && "h-auto max-w-full whitespace-normal text-left",
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
<span
|
|
203
|
+
className={cn(
|
|
204
|
+
wrapText
|
|
205
|
+
? "min-w-0 wrap-break-word whitespace-normal"
|
|
206
|
+
: "max-w-[200px] truncate",
|
|
207
|
+
)}
|
|
208
|
+
>
|
|
209
|
+
{getLabel(val)}
|
|
210
|
+
</span>
|
|
211
|
+
{!disabled && (
|
|
212
|
+
<span
|
|
213
|
+
role="button"
|
|
214
|
+
tabIndex={0}
|
|
215
|
+
aria-label={`ลบ ${getLabel(val)}`}
|
|
216
|
+
onKeyDown={(e) => {
|
|
217
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
handleRemove(val, e);
|
|
220
|
+
}
|
|
221
|
+
}}
|
|
222
|
+
onClick={(e) => handleRemove(val, e)}
|
|
223
|
+
className="ml-1 rounded-full p-0.5 outline-none hover:bg-muted-foreground/20 focus:ring-1 focus:ring-ring print:hidden"
|
|
224
|
+
>
|
|
225
|
+
<X className="size-3 text-muted-foreground hover:text-foreground" />
|
|
226
|
+
</span>
|
|
227
|
+
)}
|
|
228
|
+
</Badge>
|
|
229
|
+
))}
|
|
230
|
+
{remainingCount > 0 && (
|
|
231
|
+
<Badge
|
|
232
|
+
variant="outline"
|
|
233
|
+
className="rounded-md px-1.5 py-0.5 font-normal text-muted-foreground text-xs"
|
|
234
|
+
>
|
|
235
|
+
+{remainingCount} รายการ
|
|
236
|
+
</Badge>
|
|
237
|
+
)}
|
|
238
|
+
</>
|
|
239
|
+
) : (
|
|
240
|
+
<span className="text-muted-foreground text-sm">{placeholder}</span>
|
|
241
|
+
)}
|
|
242
|
+
</div>
|
|
243
|
+
<div className="flex items-center gap-1">
|
|
244
|
+
{value.length > 0 && !disabled && (
|
|
245
|
+
<span
|
|
246
|
+
role="button"
|
|
247
|
+
tabIndex={0}
|
|
248
|
+
title="ล้างทั้งหมด"
|
|
249
|
+
aria-label="ล้างทั้งหมด"
|
|
250
|
+
onClick={handleClearAll}
|
|
251
|
+
onKeyDown={(e) => {
|
|
252
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
handleClearAll(e as unknown as React.MouseEvent);
|
|
255
|
+
}
|
|
256
|
+
}}
|
|
257
|
+
className="rounded-full p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:ring-1 focus:ring-ring print:hidden"
|
|
258
|
+
>
|
|
259
|
+
<X className="size-3.5" />
|
|
260
|
+
</span>
|
|
261
|
+
)}
|
|
262
|
+
<ChevronsUpDown
|
|
263
|
+
className={cn("size-4 shrink-0 opacity-50 print:hidden", disabled && "hidden")}
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
</PopoverTrigger>
|
|
267
|
+
<PopoverContent className="w-[--anchor-width] min-w-[220px] p-0" align="start">
|
|
268
|
+
<Command shouldFilter={true}>
|
|
269
|
+
<CommandInput
|
|
270
|
+
placeholder={searchPlaceholder}
|
|
271
|
+
value={inputValue}
|
|
272
|
+
onValueChange={(val) => {
|
|
273
|
+
if (val !== null) setInputValue(val);
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
{showSelectAll && options.length > 0 && (
|
|
277
|
+
<div className="flex items-center justify-between border-b px-3 py-1.5 text-xs">
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
className="font-medium text-primary hover:underline disabled:opacity-50"
|
|
281
|
+
onClick={handleSelectAll}
|
|
282
|
+
disabled={options.every((o) => value.includes(o.value))}
|
|
283
|
+
>
|
|
284
|
+
{selectAllText}
|
|
285
|
+
</button>
|
|
286
|
+
{value.length > 0 && (
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
className="text-muted-foreground hover:text-destructive hover:underline"
|
|
290
|
+
onClick={() => onChange([])}
|
|
291
|
+
>
|
|
292
|
+
{clearAllText}
|
|
293
|
+
</button>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
)}
|
|
297
|
+
<CommandList className="max-h-64 overflow-y-auto">
|
|
298
|
+
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
299
|
+
|
|
300
|
+
{/* 1. รายการที่ถูกเลือกจะแสดงอยู่บนสุดเสมอ (Selected items always at the top) */}
|
|
301
|
+
{selectedOptions.length > 0 && (
|
|
302
|
+
<CommandGroup
|
|
303
|
+
heading={showGroupHeaders ? selectedGroupHeading : undefined}
|
|
304
|
+
className="pb-1"
|
|
305
|
+
>
|
|
306
|
+
{selectedOptions.map((option) => (
|
|
307
|
+
<CommandItem
|
|
308
|
+
key={`selected-${option.value}`}
|
|
309
|
+
value={
|
|
310
|
+
option.searchableValue ||
|
|
311
|
+
`${option.label} ${option.description || ""} ${option.value}`
|
|
312
|
+
}
|
|
313
|
+
onSelect={() => handleToggle(option.value)}
|
|
314
|
+
disabled={option.disabled}
|
|
315
|
+
className="flex cursor-pointer items-center justify-between gap-2 aria-selected:bg-accent"
|
|
316
|
+
>
|
|
317
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
318
|
+
<div className="flex size-4 shrink-0 items-center justify-center rounded-sm bg-primary text-primary-foreground">
|
|
319
|
+
<Check className="size-3" />
|
|
320
|
+
</div>
|
|
321
|
+
<span className="truncate font-medium text-foreground">{option.label}</span>
|
|
322
|
+
</div>
|
|
323
|
+
{option.description && (
|
|
324
|
+
<span className="truncate text-muted-foreground text-xs">
|
|
325
|
+
{option.description}
|
|
326
|
+
</span>
|
|
327
|
+
)}
|
|
328
|
+
</CommandItem>
|
|
329
|
+
))}
|
|
330
|
+
</CommandGroup>
|
|
331
|
+
)}
|
|
332
|
+
|
|
333
|
+
{selectedOptions.length > 0 && unselectedOptions.length > 0 && (
|
|
334
|
+
<CommandSeparator className="my-1" />
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
{/* 2. รายการตัวเลือกที่ยังไม่ได้เลือก (Unselected items) */}
|
|
338
|
+
{unselectedOptions.length > 0 && (
|
|
339
|
+
<CommandGroup
|
|
340
|
+
heading={
|
|
341
|
+
showGroupHeaders && selectedOptions.length > 0
|
|
342
|
+
? unselectedGroupHeading
|
|
343
|
+
: undefined
|
|
344
|
+
}
|
|
345
|
+
>
|
|
346
|
+
{unselectedOptions.map((option) => (
|
|
347
|
+
<CommandItem
|
|
348
|
+
key={`unselected-${option.value}`}
|
|
349
|
+
value={
|
|
350
|
+
option.searchableValue ||
|
|
351
|
+
`${option.label} ${option.description || ""} ${option.value}`
|
|
352
|
+
}
|
|
353
|
+
onSelect={() => handleToggle(option.value)}
|
|
354
|
+
disabled={option.disabled}
|
|
355
|
+
className="flex cursor-pointer items-center justify-between gap-2"
|
|
356
|
+
>
|
|
357
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
358
|
+
<div className="size-4 shrink-0 rounded-sm border border-muted-foreground/30" />
|
|
359
|
+
<span className="truncate">{option.label}</span>
|
|
360
|
+
</div>
|
|
361
|
+
{option.description && (
|
|
362
|
+
<span className="truncate text-muted-foreground text-xs">
|
|
363
|
+
{option.description}
|
|
364
|
+
</span>
|
|
365
|
+
)}
|
|
366
|
+
</CommandItem>
|
|
367
|
+
))}
|
|
368
|
+
</CommandGroup>
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
{/* 3. Creatable Option */}
|
|
372
|
+
{canCreate && (
|
|
373
|
+
<>
|
|
374
|
+
<CommandSeparator className="my-1" />
|
|
375
|
+
<CommandGroup>
|
|
376
|
+
<CommandItem
|
|
377
|
+
value={inputValue}
|
|
378
|
+
onSelect={handleCreate}
|
|
379
|
+
className="flex cursor-pointer items-center gap-2 text-primary"
|
|
380
|
+
>
|
|
381
|
+
<Plus className="size-4" />
|
|
382
|
+
<span>เพิ่ม "{inputValue}"</span>
|
|
383
|
+
</CommandItem>
|
|
384
|
+
</CommandGroup>
|
|
385
|
+
</>
|
|
386
|
+
)}
|
|
387
|
+
</CommandList>
|
|
388
|
+
</Command>
|
|
389
|
+
</PopoverContent>
|
|
390
|
+
</Popover>
|
|
391
|
+
);
|
|
392
|
+
}
|