@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,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><Spinner data-icon="inline-start" /></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
|
+
});
|