@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,463 @@
1
+ "use client";
2
+
3
+ import {
4
+ AddNewButton,
5
+ CancelButton,
6
+ ConfirmButton,
7
+ DeleteButton,
8
+ PrintButton,
9
+ SaveButton,
10
+ } from "@suphark/ui/components/shared/button";
11
+ import {
12
+ Accordion,
13
+ AccordionContent,
14
+ AccordionItem,
15
+ AccordionTrigger,
16
+ } from "@suphark/ui/components/ui/accordion";
17
+ import { Avatar, AvatarFallback, AvatarImage } from "@suphark/ui/components/ui/avatar";
18
+ import { Badge } from "@suphark/ui/components/ui/badge";
19
+ import { Button } from "@suphark/ui/components/ui/button";
20
+ import { ButtonGroup, ButtonGroupSeparator } from "@suphark/ui/components/ui/button-group";
21
+ import {
22
+ Card,
23
+ CardContent,
24
+ CardDescription,
25
+ CardFooter,
26
+ CardHeader,
27
+ CardTitle,
28
+ } from "@suphark/ui/components/ui/card";
29
+ import {
30
+ Collapsible,
31
+ CollapsibleContent,
32
+ CollapsibleTrigger,
33
+ } from "@suphark/ui/components/ui/collapsible";
34
+ import { Progress } from "@suphark/ui/components/ui/progress";
35
+ import { Separator } from "@suphark/ui/components/ui/separator";
36
+ import { Spinner } from "@suphark/ui/components/ui/spinner";
37
+ import { RefreshButton } from "@suphark/ui/next";
38
+ import {
39
+ AlertCircle,
40
+ CheckCircle2,
41
+ ChevronDown,
42
+ Clock,
43
+ Download,
44
+ FileSpreadsheet,
45
+ Filter,
46
+ Package,
47
+ Plus,
48
+ Search,
49
+ Trash2,
50
+ User,
51
+ XCircle,
52
+ } from "lucide-react";
53
+ import * as React from "react";
54
+
55
+ export function ShowcasePrimitives() {
56
+ const [loadingButtonActive, setLoadingButtonActive] = React.useState(false);
57
+ const [collapsibleOpen, setCollapsibleOpen] = React.useState(false);
58
+ const [progressVal, setProgressVal] = React.useState(68);
59
+
60
+ const buttonVariants = [
61
+ { variant: "default", label: "Default / Primary", desc: "Action หลักสูงสุดในแต่ละ Card/Page" },
62
+ { variant: "secondary", label: "Secondary", desc: "Supporting action หรือปุ่มระดับรอง" },
63
+ { variant: "outline", label: "Outline", desc: "Action ทั่วไป, Filter, Export, Preview" },
64
+ { variant: "ghost", label: "Ghost", desc: "Action ในตาราง หรือ Toolbar ที่ต้องการความโปร่งตา" },
65
+ {
66
+ variant: "destructive",
67
+ label: "Destructive",
68
+ desc: "การลบ, ยกเลิกสิทธิ์, หรือการกระทำที่ย้อนคืนไม่ได้",
69
+ },
70
+ { variant: "link", label: "Link", desc: "ข้อความนำทางในเนื้อหาแบบ Inline" },
71
+ ] as const;
72
+
73
+ const buttonSizes = [
74
+ { size: "xs", label: "xs (Dense Toolbar)" },
75
+ { size: "sm", label: "sm (Table Action)" },
76
+ { size: "default", label: "default (Form Main)" },
77
+ { size: "lg", label: "lg (Prominent CTA)" },
78
+ ] as const;
79
+
80
+ const statusBadges = [
81
+ {
82
+ label: "Approved / สมบูรณ์",
83
+ variant: "default",
84
+ icon: CheckCircle2,
85
+ className: "bg-success text-success-foreground border-transparent",
86
+ },
87
+ { label: "Under Review / รออนุมัติ", variant: "secondary", icon: Clock, className: "" },
88
+ { label: "Draft / ร่าง", variant: "outline", icon: Package, className: "" },
89
+ { label: "Rejected / ไม่อนุมัติ", variant: "destructive", icon: XCircle, className: "" },
90
+ {
91
+ label: "Pending Vendor / รอผู้ขาย",
92
+ variant: "outline",
93
+ icon: AlertCircle,
94
+ className: "border-amber-500/40 text-amber-600 bg-amber-50/50 dark:bg-amber-950/20",
95
+ },
96
+ ];
97
+
98
+ return (
99
+ <div className="flex flex-col gap-8">
100
+ {/* Section Header */}
101
+ <div className="flex flex-col gap-1">
102
+ <div className="flex items-center gap-2">
103
+ <Badge variant="outline">Core Primitives</Badge>
104
+ <span className="text-xs text-muted-foreground">
105
+ Buttons, Badges, Cards, Avatars & Structure
106
+ </span>
107
+ </div>
108
+ <h2 className="text-xl font-bold tracking-tight text-foreground">
109
+ คอมโพเนนต์พื้นฐานและการแสดงผลโครงสร้าง
110
+ </h2>
111
+ <p className="text-sm text-muted-foreground">
112
+ ปุ่มกด, ป้ายสถานะ, การ์ดแสดงผล, และการประกอบคอมโพเนนต์ตามมาตรฐาน shadcn/ui
113
+ </p>
114
+ </div>
115
+
116
+ {/* Button Matrix */}
117
+ <Card>
118
+ <CardHeader>
119
+ <div className="flex items-center justify-between">
120
+ <div>
121
+ <CardTitle className="text-base">Button Hierarchy & Variants</CardTitle>
122
+ <CardDescription className="text-xs">
123
+ ลำดับความสำคัญของปุ่มและขนาดตามความเหมาะสมของหน้าจอ
124
+ </CardDescription>
125
+ </div>
126
+ <Badge variant="secondary">shadcn/ui Button</Badge>
127
+ </div>
128
+ </CardHeader>
129
+ <CardContent className="flex flex-col gap-6">
130
+ {/* Variants row */}
131
+ <div className="flex flex-col gap-2">
132
+ <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
133
+ 1. Button Variants
134
+ </h4>
135
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
136
+ {buttonVariants.map((item) => (
137
+ <div
138
+ key={item.variant}
139
+ className="flex flex-col gap-2 rounded-md border p-3 bg-card"
140
+ >
141
+ <div className="flex items-center justify-between">
142
+ <Button variant={item.variant} size="sm">
143
+ {item.label}
144
+ </Button>
145
+ <code className="text-xs text-muted-foreground font-mono">
146
+ variant="{item.variant}"
147
+ </code>
148
+ </div>
149
+ <p className="text-xs text-muted-foreground">{item.desc}</p>
150
+ </div>
151
+ ))}
152
+ </div>
153
+ </div>
154
+
155
+ <Separator />
156
+
157
+ {/* Sizes and Icon Buttons */}
158
+ <div className="grid gap-6 md:grid-cols-2">
159
+ {/* Sizes */}
160
+ <div className="flex flex-col gap-3">
161
+ <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
162
+ 2. Button Sizes
163
+ </h4>
164
+ <div className="flex flex-wrap items-center gap-2">
165
+ {buttonSizes.map((item) => (
166
+ <Button key={item.size} size={item.size}>
167
+ {item.label}
168
+ </Button>
169
+ ))}
170
+ </div>
171
+ </div>
172
+
173
+ {/* Icon buttons & Loading State */}
174
+ <div className="flex flex-col gap-3">
175
+ <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
176
+ 3. Icon Buttons & Loading State
177
+ </h4>
178
+ <div className="flex flex-wrap items-center gap-2">
179
+ <Button size="icon" aria-label="เพิ่มข้อมูลใหม่">
180
+ <Plus />
181
+ </Button>
182
+ <Button size="icon" variant="outline" aria-label="ค้นหาข้อมูล">
183
+ <Search />
184
+ </Button>
185
+ <Button size="icon" variant="ghost" aria-label="ฟิลเตอร์ข้อมูล">
186
+ <Filter />
187
+ </Button>
188
+ <Button size="icon" variant="destructive" aria-label="ลบรายการ">
189
+ <Trash2 />
190
+ </Button>
191
+
192
+ {/* Loading state button */}
193
+ <Button
194
+ disabled={loadingButtonActive}
195
+ onClick={() => {
196
+ setLoadingButtonActive(true);
197
+ setTimeout(() => setLoadingButtonActive(false), 2000);
198
+ }}
199
+ >
200
+ {loadingButtonActive && <Spinner data-icon="inline-start" />}
201
+ {loadingButtonActive ? "กำลังบันทึก..." : "คลิกทดสอบ Loading"}
202
+ </Button>
203
+ </div>
204
+ <p className="text-xs text-muted-foreground">
205
+ * shadcn rule: Loading button ใช้{" "}
206
+ <code>&lt;Spinner data-icon="inline-start" /&gt;</code> ร่วมกับ <code>disabled</code>
207
+ </p>
208
+ </div>
209
+ </div>
210
+
211
+ <Separator />
212
+
213
+ {/* ButtonGroup */}
214
+ <div className="flex flex-col gap-3">
215
+ <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
216
+ 4. ButtonGroup Primitive
217
+ </h4>
218
+ <div className="flex flex-wrap items-center gap-4">
219
+ <ButtonGroup>
220
+ <Button variant="outline" size="sm">
221
+ Day
222
+ </Button>
223
+ <Button variant="outline" size="sm">
224
+ Week
225
+ </Button>
226
+ <Button variant="outline" size="sm">
227
+ Month
228
+ </Button>
229
+ <Button variant="outline" size="sm">
230
+ Year
231
+ </Button>
232
+ </ButtonGroup>
233
+
234
+ <ButtonGroup>
235
+ <Button variant="outline" size="sm">
236
+ <FileSpreadsheet data-icon="inline-start" />
237
+ Excel
238
+ </Button>
239
+ <ButtonGroupSeparator />
240
+ <Button variant="outline" size="sm">
241
+ <Download data-icon="inline-start" />
242
+ PDF
243
+ </Button>
244
+ </ButtonGroup>
245
+ </div>
246
+ </div>
247
+ </CardContent>
248
+ </Card>
249
+
250
+ {/* Shared Button Wrappers */}
251
+ <Card>
252
+ <CardHeader>
253
+ <div className="flex items-center justify-between">
254
+ <div>
255
+ <CardTitle className="text-base">Shared Action Button Wrappers</CardTitle>
256
+ <CardDescription className="text-xs">
257
+ คอมโพเนนต์ปุ่มมาตรฐานพร้อมสไตล์และไอคอนจาก <code>src/components/shared/button/*</code>
258
+ </CardDescription>
259
+ </div>
260
+ <Badge variant="outline">Shared Wrappers</Badge>
261
+ </div>
262
+ </CardHeader>
263
+ <CardContent>
264
+ <div className="flex flex-wrap items-center gap-3">
265
+ <AddNewButton label="เพิ่มรายการใหม่" />
266
+ <SaveButton />
267
+ <RefreshButton />
268
+ <PrintButton />
269
+ <ConfirmButton />
270
+ <CancelButton />
271
+ <DeleteButton />
272
+ </div>
273
+ </CardContent>
274
+ </Card>
275
+
276
+ {/* Badges & Status Indicators */}
277
+ <Card>
278
+ <CardHeader>
279
+ <CardTitle className="text-base">Badge & Status Indicators</CardTitle>
280
+ <CardDescription className="text-xs">
281
+ ป้ายสถานะสำหรับ workflow การจัดซื้อและกระบวนการอนุมัติ
282
+ </CardDescription>
283
+ </CardHeader>
284
+ <CardContent className="flex flex-col gap-4">
285
+ <div className="flex flex-wrap items-center gap-2">
286
+ <Badge>Default</Badge>
287
+ <Badge variant="secondary">Secondary</Badge>
288
+ <Badge variant="outline">Outline</Badge>
289
+ <Badge variant="destructive">Destructive</Badge>
290
+ </div>
291
+
292
+ <Separator />
293
+
294
+ <div className="flex flex-wrap items-center gap-3">
295
+ {statusBadges.map((badge) => {
296
+ const Icon = badge.icon;
297
+ return (
298
+ <Badge
299
+ key={badge.label}
300
+ variant={badge.variant as any}
301
+ className={`gap-1.5 py-1 px-2.5 ${badge.className}`}
302
+ >
303
+ <Icon className="size-3.5" />
304
+ {badge.label}
305
+ </Badge>
306
+ );
307
+ })}
308
+ </div>
309
+ </CardContent>
310
+ </Card>
311
+
312
+ {/* Full Card Composition & Avatar */}
313
+ <div className="grid gap-6 lg:grid-cols-2">
314
+ {/* Full Card Composition */}
315
+ <Card>
316
+ <CardHeader>
317
+ <div className="flex items-center justify-between">
318
+ <Badge variant="outline">RFQ-2026-0082</Badge>
319
+ <span className="text-xs text-muted-foreground">Updated today</span>
320
+ </div>
321
+ <CardTitle className="text-lg">Package Evaluation Summary</CardTitle>
322
+ <CardDescription className="text-xs">
323
+ โครงสร้าง Card แบบเต็มรูปแบบ (CardHeader / Title / Description / Content / Footer)
324
+ </CardDescription>
325
+ </CardHeader>
326
+ <CardContent className="flex flex-col gap-3">
327
+ <p className="text-sm text-muted-foreground">
328
+ แพ็กเกจจัดซื้อวัสดุงานระบบ MEP อยู่ระหว่างการตรวจสอบคุณสมบัติของผู้รับเหมา 3 ราย
329
+ </p>
330
+ <div className="flex items-center justify-between rounded-md bg-muted/50 p-3 text-xs">
331
+ <span>งบประมาณประมาณการ (Estimated Budget)</span>
332
+ <span className="font-semibold font-mono">฿ 12,450,000.00</span>
333
+ </div>
334
+ </CardContent>
335
+ <CardFooter className="flex items-center justify-between border-t pt-3">
336
+ <span className="text-xs text-muted-foreground">Approver: Project Director</span>
337
+ <Button size="sm">ดูรายละเอียดการประกวดราคา</Button>
338
+ </CardFooter>
339
+ </Card>
340
+
341
+ {/* Avatars, Progress & Collapsible */}
342
+ <div className="flex flex-col gap-4">
343
+ {/* Avatars */}
344
+ <Card>
345
+ <CardHeader className="pb-2">
346
+ <CardTitle className="text-sm">Avatar & AvatarFallback</CardTitle>
347
+ </CardHeader>
348
+ <CardContent className="flex items-center gap-4">
349
+ <Avatar className="size-10">
350
+ <AvatarImage
351
+ src="https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&auto=format&fit=crop&q=80"
352
+ alt="User"
353
+ />
354
+ <AvatarFallback>SP</AvatarFallback>
355
+ </Avatar>
356
+ <Avatar className="size-10">
357
+ <AvatarFallback className="bg-primary text-primary-foreground font-semibold">
358
+ PM
359
+ </AvatarFallback>
360
+ </Avatar>
361
+ <Avatar className="size-10">
362
+ <AvatarFallback>
363
+ <User className="size-4" />
364
+ </AvatarFallback>
365
+ </Avatar>
366
+ <div className="flex flex-col">
367
+ <span className="font-medium text-xs">Procurement Team</span>
368
+ <span className="text-xs text-muted-foreground">3 reviewers active</span>
369
+ </div>
370
+ </CardContent>
371
+ </Card>
372
+
373
+ {/* Progress & Spinner */}
374
+ <Card>
375
+ <CardHeader className="pb-2">
376
+ <div className="flex items-center justify-between">
377
+ <CardTitle className="text-sm">Progress & Spinner</CardTitle>
378
+ <span className="font-mono text-xs text-muted-foreground">{progressVal}%</span>
379
+ </div>
380
+ </CardHeader>
381
+ <CardContent className="flex flex-col gap-3">
382
+ <Progress value={progressVal} />
383
+ <div className="flex items-center justify-between">
384
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
385
+ <Spinner /> กำลังประมวลผลการคำนวณราคาเฉลี่ย...
386
+ </div>
387
+ <Button
388
+ size="xs"
389
+ variant="outline"
390
+ onClick={() => setProgressVal((prev) => (prev >= 100 ? 20 : prev + 20))}
391
+ >
392
+ เพิ่ม Progress
393
+ </Button>
394
+ </div>
395
+ </CardContent>
396
+ </Card>
397
+ </div>
398
+ </div>
399
+
400
+ {/* Accordion & Collapsible */}
401
+ <div className="grid gap-6 lg:grid-cols-2">
402
+ {/* Accordion */}
403
+ <Card>
404
+ <CardHeader className="pb-2">
405
+ <CardTitle className="text-base">Accordion Primitive</CardTitle>
406
+ <CardDescription className="text-xs">สำหรับคำถามที่พบบ่อยหรือเงื่อนไขสัญญา</CardDescription>
407
+ </CardHeader>
408
+ <CardContent>
409
+ <Accordion className="w-full">
410
+ <AccordionItem value="item-1">
411
+ <AccordionTrigger className="text-xs font-medium">
412
+ เกณฑ์การคัดเลือกผู้ชนะการประกวดราคา (Award Criteria)
413
+ </AccordionTrigger>
414
+ <AccordionContent className="text-xs text-muted-foreground">
415
+ พิจารณาคะแนนเทคนิค 40% และข้อเสนอด้านราคา 60% โดยต้องผ่านเกณฑ์คุณสมบัติต่ำสุดที่กำหนด
416
+ </AccordionContent>
417
+ </AccordionItem>
418
+ <AccordionItem value="item-2">
419
+ <AccordionTrigger className="text-xs font-medium">
420
+ หลักประกันสัญญาและการชำระเงินงวดแรก (Payment Terms)
421
+ </AccordionTrigger>
422
+ <AccordionContent className="text-xs text-muted-foreground">
423
+ วางเงินค้ำประกันธนาคาร 10% ของมูลค่าสัญญา และจ่ายเงินล่วงหน้าเมื่อส่งมอบแผนงานอนุมัติ
424
+ </AccordionContent>
425
+ </AccordionItem>
426
+ </Accordion>
427
+ </CardContent>
428
+ </Card>
429
+
430
+ {/* Collapsible */}
431
+ <Card>
432
+ <CardHeader className="pb-2">
433
+ <CardTitle className="text-base">Collapsible Primitive</CardTitle>
434
+ <CardDescription className="text-xs">สำหรับกล่องฟิลเตอร์ขั้นสูงหรือข้อมูลทางเลือก</CardDescription>
435
+ </CardHeader>
436
+ <CardContent>
437
+ <Collapsible
438
+ open={collapsibleOpen}
439
+ onOpenChange={setCollapsibleOpen}
440
+ className="flex flex-col gap-2"
441
+ >
442
+ <div className="flex items-center justify-between rounded-md border p-3 bg-muted/30">
443
+ <span className="text-xs font-medium">
444
+ ตัวกรองขั้นสูง (Advanced Procurement Filters)
445
+ </span>
446
+ <CollapsibleTrigger render={<Button variant="ghost" size="xs" />}>
447
+ <ChevronDown
448
+ className={`size-3.5 transition-transform ${collapsibleOpen ? "rotate-180" : ""}`}
449
+ />
450
+ </CollapsibleTrigger>
451
+ </div>
452
+ <CollapsibleContent className="flex flex-col gap-2 rounded-md border border-dashed p-3 text-xs text-muted-foreground">
453
+ <p>• กรองตามประเภทงบประมาณ: CAPEX / OPEX</p>
454
+ <p>• กรองตามช่วงระยะเวลาส่งมอบ (Lead Time)</p>
455
+ <p>• กรองเฉพาะคู่ค้าที่มีใบรับรอง ISO 9001 / ISO 14001</p>
456
+ </CollapsibleContent>
457
+ </Collapsible>
458
+ </CardContent>
459
+ </Card>
460
+ </div>
461
+ </div>
462
+ );
463
+ }
@@ -0,0 +1,180 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { MultiSelectCombobox, type MultiSelectOption } from "../multi-select-combobox";
4
+
5
+ class ResizeObserverMock {
6
+ observe() {}
7
+ unobserve() {}
8
+ disconnect() {}
9
+ }
10
+
11
+ beforeEach(() => {
12
+ globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver;
13
+ window.HTMLElement.prototype.scrollIntoView = vi.fn();
14
+ });
15
+
16
+ const mockOptions: MultiSelectOption[] = [
17
+ { label: "หมวดงานโครงสร้าง", value: "structure" },
18
+ { label: "หมวดงานสถาปัตย์", value: "architecture" },
19
+ { label: "หมวดงานระบบไฟฟ้า", value: "electrical" },
20
+ { label: "หมวดงานสุขาภิบาล", value: "sanitary" },
21
+ ];
22
+
23
+ describe("MultiSelectCombobox", () => {
24
+ it("renders with placeholder when no value is selected", () => {
25
+ const onChange = vi.fn();
26
+ render(
27
+ <MultiSelectCombobox
28
+ options={mockOptions}
29
+ value={[]}
30
+ onChange={onChange}
31
+ placeholder="เลือกหมวดหมู่งาน..."
32
+ />,
33
+ );
34
+
35
+ expect(screen.getByText("เลือกหมวดหมู่งาน...")).toBeInTheDocument();
36
+ });
37
+
38
+ it("renders selected items always at the top of the dropdown list", async () => {
39
+ const onChange = vi.fn();
40
+ // 'electrical' is selected
41
+ render(
42
+ <MultiSelectCombobox options={mockOptions} value={["electrical"]} onChange={onChange} />,
43
+ );
44
+
45
+ const trigger = screen.getByRole("combobox");
46
+ fireEvent.click(trigger);
47
+
48
+ // Selected item 'electrical' should be rendered first under "รายการที่เลือก"
49
+ expect(screen.getByText("รายการที่เลือก")).toBeInTheDocument();
50
+ expect(screen.getByText("ตัวเลือกทั้งหมด")).toBeInTheDocument();
51
+
52
+ const optionItems = screen.getAllByRole("option");
53
+ expect(optionItems[0]).toHaveTextContent("หมวดงานระบบไฟฟ้า");
54
+ expect(optionItems[1]).toHaveTextContent("หมวดงานโครงสร้าง");
55
+ expect(optionItems[2]).toHaveTextContent("หมวดงานสถาปัตย์");
56
+ expect(optionItems[3]).toHaveTextContent("หมวดงานสุขาภิบาล");
57
+ });
58
+
59
+ it("allows selecting an unselected item and calling onChange with added value", () => {
60
+ const onChange = vi.fn();
61
+ render(<MultiSelectCombobox options={mockOptions} value={["structure"]} onChange={onChange} />);
62
+
63
+ const trigger = screen.getByRole("combobox");
64
+ fireEvent.click(trigger);
65
+
66
+ // Click 'architecture'
67
+ const archOption = screen.getByText("หมวดงานสถาปัตย์");
68
+ fireEvent.click(archOption);
69
+
70
+ expect(onChange).toHaveBeenCalledWith(["structure", "architecture"]);
71
+ });
72
+
73
+ it("allows unselecting an already selected item from the dropdown", () => {
74
+ const onChange = vi.fn();
75
+ render(
76
+ <MultiSelectCombobox
77
+ options={mockOptions}
78
+ value={["structure", "electrical"]}
79
+ onChange={onChange}
80
+ />,
81
+ );
82
+
83
+ const trigger = screen.getByRole("combobox");
84
+ fireEvent.click(trigger);
85
+
86
+ // Click 'structure' option in dropdown to toggle off
87
+ const optionItems = screen.getAllByRole("option");
88
+ const structOption = optionItems.find((el) => el.textContent?.includes("หมวดงานโครงสร้าง"));
89
+ expect(structOption).toBeDefined();
90
+ fireEvent.click(structOption!);
91
+
92
+ expect(onChange).toHaveBeenCalledWith(["electrical"]);
93
+ });
94
+
95
+ it("removes a selected item when clicking the badge remove X button", () => {
96
+ const onChange = vi.fn();
97
+ render(
98
+ <MultiSelectCombobox
99
+ options={mockOptions}
100
+ value={["structure", "sanitary"]}
101
+ onChange={onChange}
102
+ />,
103
+ );
104
+
105
+ const removeBtn = screen.getByLabelText("ลบ หมวดงานโครงสร้าง");
106
+ fireEvent.click(removeBtn);
107
+
108
+ expect(onChange).toHaveBeenCalledWith(["sanitary"]);
109
+ });
110
+
111
+ it("clears all selected items when clicking the clear all button", () => {
112
+ const onChange = vi.fn();
113
+ render(
114
+ <MultiSelectCombobox
115
+ options={mockOptions}
116
+ value={["structure", "sanitary"]}
117
+ onChange={onChange}
118
+ />,
119
+ );
120
+
121
+ const clearAllBtn = screen.getByLabelText("ล้างทั้งหมด");
122
+ fireEvent.click(clearAllBtn);
123
+
124
+ expect(onChange).toHaveBeenCalledWith([]);
125
+ });
126
+
127
+ it("limits badges display with maxDisplay and shows overflow badge count", () => {
128
+ const onChange = vi.fn();
129
+ render(
130
+ <MultiSelectCombobox
131
+ options={mockOptions}
132
+ value={["structure", "architecture", "electrical", "sanitary"]}
133
+ onChange={onChange}
134
+ maxDisplay={2}
135
+ />,
136
+ );
137
+
138
+ expect(screen.getByText("หมวดงานโครงสร้าง")).toBeInTheDocument();
139
+ expect(screen.getByText("หมวดงานสถาปัตย์")).toBeInTheDocument();
140
+ expect(screen.queryByText("หมวดงานระบบไฟฟ้า")).not.toBeInTheDocument();
141
+ expect(screen.getByText("+2 รายการ")).toBeInTheDocument();
142
+ });
143
+
144
+ it("filters options via search input preserving selected items on top", async () => {
145
+ const onChange = vi.fn();
146
+ render(
147
+ <MultiSelectCombobox options={mockOptions} value={["electrical"]} onChange={onChange} />,
148
+ );
149
+
150
+ const trigger = screen.getByRole("combobox");
151
+ fireEvent.click(trigger);
152
+
153
+ const searchInput = screen.getByPlaceholderText("ค้นหา...");
154
+ fireEvent.change(searchInput, { target: { value: "งาน" } });
155
+
156
+ // All matching options still show, with selected 'electrical' on top
157
+ const options = screen.getAllByRole("option");
158
+ expect(options[0]).toHaveTextContent("หมวดงานระบบไฟฟ้า");
159
+ });
160
+
161
+ it("supports select all action when showSelectAll is true", () => {
162
+ const onChange = vi.fn();
163
+ render(
164
+ <MultiSelectCombobox
165
+ options={mockOptions}
166
+ value={["structure"]}
167
+ onChange={onChange}
168
+ showSelectAll={true}
169
+ />,
170
+ );
171
+
172
+ const trigger = screen.getByRole("combobox");
173
+ fireEvent.click(trigger);
174
+
175
+ const selectAllBtn = screen.getByText("เลือกทั้งหมด");
176
+ fireEvent.click(selectAllBtn);
177
+
178
+ expect(onChange).toHaveBeenCalledWith(["structure", "architecture", "electrical", "sanitary"]);
179
+ });
180
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { toIdentifier } from "../name-id-fields";
3
+
4
+ describe("toIdentifier", () => {
5
+ it("kebab: slug จากชื่อองค์กร", () => {
6
+ expect(toIdentifier("Acme PCL", "kebab")).toBe("acme-pcl");
7
+ expect(toIdentifier(" Acme Co., Ltd. ", "kebab")).toBe("acme-co-ltd");
8
+ expect(toIdentifier("Café Résumé", "kebab")).toBe("cafe-resume");
9
+ });
10
+ it("camel: key ของ attribute", () => {
11
+ expect(toIdentifier("Cost Center", "camel")).toBe("costCenter");
12
+ expect(toIdentifier("2FA Enabled", "camel")).toBe("x2faEnabled");
13
+ });
14
+ it("upper: รหัสตำแหน่ง", () => {
15
+ expect(toIdentifier("Senior Director", "upper")).toBe("SENIOR_DIRECTOR");
16
+ });
17
+ it("ชื่อไทยล้วน → ว่าง (ให้ผู้ใช้ตั้งเอง); ผสม → เอาเฉพาะละติน", () => {
18
+ expect(toIdentifier("ฝ่ายวิศวกรรม", "kebab")).toBe("");
19
+ expect(toIdentifier("ฝ่าย IT Support", "kebab")).toBe("it-support");
20
+ });
21
+ it("ตัดความยาวแล้วไม่ทิ้งขีดท้าย", () => {
22
+ expect(toIdentifier("alpha beta gamma", "kebab", 11)).toBe("alpha-beta");
23
+ });
24
+ });