@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.
Files changed (147) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/components.json +21 -0
  5. package/package.json +108 -0
  6. package/scripts/audit-base-ui-props.mjs +179 -0
  7. package/scripts/audit-tailwind-typography.mjs +100 -0
  8. package/src/components/design-system/design-system-showcase.tsx +138 -0
  9. package/src/components/design-system/sections/showcase-data-display.tsx +454 -0
  10. package/src/components/design-system/sections/showcase-feedback.tsx +273 -0
  11. package/src/components/design-system/sections/showcase-forms.tsx +373 -0
  12. package/src/components/design-system/sections/showcase-foundations.tsx +384 -0
  13. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +397 -0
  14. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +479 -0
  15. package/src/components/design-system/sections/showcase-overview.tsx +368 -0
  16. package/src/components/design-system/sections/showcase-primitives.tsx +463 -0
  17. package/src/components/shared/__tests__/multi-select-combobox.test.tsx +180 -0
  18. package/src/components/shared/__tests__/name-id-fields.test.ts +24 -0
  19. package/src/components/shared/button/add-new-button.tsx +19 -0
  20. package/src/components/shared/button/base/action-button.tsx +134 -0
  21. package/src/components/shared/button/base/link-button.tsx +130 -0
  22. package/src/components/shared/button/cancel-button.tsx +27 -0
  23. package/src/components/shared/button/confirm-button.tsx +26 -0
  24. package/src/components/shared/button/delete-button.tsx +36 -0
  25. package/src/components/shared/button/floating-save-button.tsx +89 -0
  26. package/src/components/shared/button/index.ts +13 -0
  27. package/src/components/shared/button/print-button.tsx +67 -0
  28. package/src/components/shared/button/refresh-button.tsx +63 -0
  29. package/src/components/shared/button/save-button.tsx +29 -0
  30. package/src/components/shared/button/submit-button.tsx +58 -0
  31. package/src/components/shared/client-redirect.tsx +11 -0
  32. package/src/components/shared/combobox.tsx +116 -0
  33. package/src/components/shared/data-list.tsx +65 -0
  34. package/src/components/shared/date/date-range-picker.tsx +106 -0
  35. package/src/components/shared/description-list.tsx +27 -0
  36. package/src/components/shared/empty-row.tsx +13 -0
  37. package/src/components/shared/empty-state.tsx +31 -0
  38. package/src/components/shared/env-snippet.tsx +62 -0
  39. package/src/components/shared/form/__tests__/simple-select.test.tsx +72 -0
  40. package/src/components/shared/form/form-field-boolean.tsx +93 -0
  41. package/src/components/shared/form/form-field-color-select.tsx +86 -0
  42. package/src/components/shared/form/form-field-combobox.tsx +167 -0
  43. package/src/components/shared/form/form-field-date.tsx +55 -0
  44. package/src/components/shared/form/form-field-formatted-number.tsx +103 -0
  45. package/src/components/shared/form/form-field-input.tsx +372 -0
  46. package/src/components/shared/form/form-field-radio-cards.tsx +127 -0
  47. package/src/components/shared/form/form-field-radio-group.tsx +56 -0
  48. package/src/components/shared/form/form-field-select.tsx +312 -0
  49. package/src/components/shared/form/simple-select.tsx +152 -0
  50. package/src/components/shared/form-dialog.tsx +109 -0
  51. package/src/components/shared/form-message.tsx +30 -0
  52. package/src/components/shared/initials.tsx +33 -0
  53. package/src/components/shared/layout/error-state.tsx +160 -0
  54. package/src/components/shared/layout/page-shell.tsx +57 -0
  55. package/src/components/shared/link/transition-link.tsx +72 -0
  56. package/src/components/shared/multi-select-combobox.tsx +392 -0
  57. package/src/components/shared/name-id-fields.tsx +133 -0
  58. package/src/components/shared/navigation/page-breadcrumb.tsx +100 -0
  59. package/src/components/shared/notice.tsx +35 -0
  60. package/src/components/shared/number/formatted-number.tsx +133 -0
  61. package/src/components/shared/page-header.tsx +25 -0
  62. package/src/components/shared/page-skeleton.tsx +18 -0
  63. package/src/components/shared/row-actions.tsx +93 -0
  64. package/src/components/shared/row-menu.tsx +55 -0
  65. package/src/components/shared/secret-reveal.tsx +53 -0
  66. package/src/components/shared/section-card.tsx +44 -0
  67. package/src/components/shared/stat.tsx +26 -0
  68. package/src/components/shared/status-badge.tsx +47 -0
  69. package/src/components/shared/text/primary-secondary-text.tsx +186 -0
  70. package/src/components/shared/theme/index.ts +4 -0
  71. package/src/components/shared/theme/theme-menu-items.tsx +43 -0
  72. package/src/components/shared/theme/theme-provider.tsx +39 -0
  73. package/src/components/shared/theme/theme-switch.tsx +36 -0
  74. package/src/components/shared/theme/theme-toggle.tsx +76 -0
  75. package/src/components/shared/time/client-date-formatter.tsx +188 -0
  76. package/src/components/shared/ui-text.tsx +47 -0
  77. package/src/components/ui/__tests__/combobox.test.tsx +40 -0
  78. package/src/components/ui/__tests__/date-picker.test.tsx +53 -0
  79. package/src/components/ui/__tests__/slider.test.tsx +18 -0
  80. package/src/components/ui/__tests__/tabs.test.tsx +33 -0
  81. package/src/components/ui/accordion.tsx +69 -0
  82. package/src/components/ui/alert-dialog.tsx +161 -0
  83. package/src/components/ui/alert.tsx +68 -0
  84. package/src/components/ui/aspect-ratio.tsx +22 -0
  85. package/src/components/ui/attachment.tsx +194 -0
  86. package/src/components/ui/avatar.tsx +92 -0
  87. package/src/components/ui/badge.tsx +48 -0
  88. package/src/components/ui/breadcrumb.tsx +102 -0
  89. package/src/components/ui/button-group.tsx +77 -0
  90. package/src/components/ui/button.tsx +57 -0
  91. package/src/components/ui/calendar.tsx +184 -0
  92. package/src/components/ui/card.tsx +87 -0
  93. package/src/components/ui/carousel.tsx +230 -0
  94. package/src/components/ui/chart.tsx +338 -0
  95. package/src/components/ui/checkbox.tsx +27 -0
  96. package/src/components/ui/collapsible.tsx +17 -0
  97. package/src/components/ui/combobox.tsx +272 -0
  98. package/src/components/ui/command.tsx +180 -0
  99. package/src/components/ui/context-menu.tsx +244 -0
  100. package/src/components/ui/date-picker.tsx +308 -0
  101. package/src/components/ui/dialog.tsx +137 -0
  102. package/src/components/ui/drawer.tsx +208 -0
  103. package/src/components/ui/dropdown-menu.tsx +257 -0
  104. package/src/components/ui/empty.tsx +93 -0
  105. package/src/components/ui/field.tsx +223 -0
  106. package/src/components/ui/form.tsx +161 -0
  107. package/src/components/ui/hover-card.tsx +46 -0
  108. package/src/components/ui/input-group.tsx +145 -0
  109. package/src/components/ui/input-otp.tsx +85 -0
  110. package/src/components/ui/input.tsx +19 -0
  111. package/src/components/ui/item.tsx +187 -0
  112. package/src/components/ui/kbd.tsx +26 -0
  113. package/src/components/ui/label.tsx +19 -0
  114. package/src/components/ui/menubar.tsx +266 -0
  115. package/src/components/ui/navigation-menu.tsx +160 -0
  116. package/src/components/ui/pagination.tsx +117 -0
  117. package/src/components/ui/popover.tsx +76 -0
  118. package/src/components/ui/progress.tsx +66 -0
  119. package/src/components/ui/radio-group.tsx +38 -0
  120. package/src/components/ui/relative-date.tsx +40 -0
  121. package/src/components/ui/resizable.tsx +41 -0
  122. package/src/components/ui/scroll-area.tsx +67 -0
  123. package/src/components/ui/searchable-select.tsx +124 -0
  124. package/src/components/ui/select.tsx +189 -0
  125. package/src/components/ui/separator.tsx +21 -0
  126. package/src/components/ui/sheet.tsx +124 -0
  127. package/src/components/ui/sidebar.tsx +691 -0
  128. package/src/components/ui/skeleton.tsx +13 -0
  129. package/src/components/ui/slider.tsx +55 -0
  130. package/src/components/ui/sonner.tsx +45 -0
  131. package/src/components/ui/spinner.tsx +16 -0
  132. package/src/components/ui/switch.tsx +32 -0
  133. package/src/components/ui/table.tsx +95 -0
  134. package/src/components/ui/tabs.tsx +79 -0
  135. package/src/components/ui/textarea.tsx +17 -0
  136. package/src/components/ui/toggle-group.tsx +86 -0
  137. package/src/components/ui/toggle.tsx +44 -0
  138. package/src/components/ui/tooltip.tsx +54 -0
  139. package/src/hooks/use-copy-to-clipboard.ts +27 -0
  140. package/src/hooks/use-mobile.ts +21 -0
  141. package/src/index.ts +156 -0
  142. package/src/lib/formatters.ts +44 -0
  143. package/src/lib/permission.ts +75 -0
  144. package/src/lib/status-colors.ts +52 -0
  145. package/src/lib/utils.ts +6 -0
  146. package/src/next.ts +15 -0
  147. 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
+ }