@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,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ActionButton,
|
|
5
|
+
type ActionButtonProps,
|
|
6
|
+
} from "@suphark/ui/components/shared/button/base/action-button";
|
|
7
|
+
import { Plus, Save } from "lucide-react";
|
|
8
|
+
import type * as React from "react";
|
|
9
|
+
|
|
10
|
+
interface SubmitButtonProps extends ActionButtonProps {
|
|
11
|
+
/**
|
|
12
|
+
* Toggles the button's default text/icon between Create and Edit modes.
|
|
13
|
+
* Defaults to 'false' (Create mode).
|
|
14
|
+
*/
|
|
15
|
+
isEditing?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* (Optional) Custom text to display when isEditing is false.
|
|
18
|
+
* Defaults to 'Create New'.
|
|
19
|
+
*/
|
|
20
|
+
createText?: React.ReactNode;
|
|
21
|
+
/**
|
|
22
|
+
* (Optional) Custom text to display when isEditing is true.
|
|
23
|
+
* Defaults to 'Save'.
|
|
24
|
+
*/
|
|
25
|
+
editText?: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A reusable submit button component for forms.
|
|
30
|
+
*
|
|
31
|
+
* This component wraps `ActionButton` and sets `type="submit"` by default.
|
|
32
|
+
* It's designed to be used within a form and receive its loading state
|
|
33
|
+
* from the form's `isSubmitting` state.
|
|
34
|
+
*
|
|
35
|
+
* @param {boolean} [isLoading] - (Required) Pass the form's `isSubmitting` state here.
|
|
36
|
+
* @param {boolean} [isEditing] - (Optional) Toggles between Create (false) and Edit (true) modes.
|
|
37
|
+
* @param {React.ReactNode} [children] - (Optional) Explicitly sets the button text, overriding defaults.
|
|
38
|
+
* @param {React.ReactNode} [createText] - (Optional) Default text for 'Create' mode if children is not set.
|
|
39
|
+
* @param {React.ReactNode} [editText] - (Optional) Default text for 'Edit' mode if children is not set.
|
|
40
|
+
* @param {React.ReactElement} [icon] - (Optional) Overrides the default icon.
|
|
41
|
+
*/
|
|
42
|
+
export function SubmitButton({
|
|
43
|
+
children,
|
|
44
|
+
icon,
|
|
45
|
+
isLoading,
|
|
46
|
+
isEditing = false,
|
|
47
|
+
createText = "Create New",
|
|
48
|
+
editText = "Save",
|
|
49
|
+
...props
|
|
50
|
+
}: SubmitButtonProps) {
|
|
51
|
+
const buttonText = children ?? (isEditing ? editText : createText);
|
|
52
|
+
const buttonIcon = icon ?? (isEditing ? <Save /> : <Plus />);
|
|
53
|
+
return (
|
|
54
|
+
<ActionButton type="submit" isLoading={isLoading} icon={buttonIcon} {...props}>
|
|
55
|
+
{buttonText}
|
|
56
|
+
</ActionButton>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
Command,
|
|
6
|
+
CommandEmpty,
|
|
7
|
+
CommandGroup,
|
|
8
|
+
CommandInput,
|
|
9
|
+
CommandItem,
|
|
10
|
+
CommandList,
|
|
11
|
+
} from "@suphark/ui/components/ui/command";
|
|
12
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
13
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
14
|
+
import { Check, ChevronsUpDown } from "lucide-react";
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
|
|
17
|
+
interface ComboboxProps {
|
|
18
|
+
options: { label: string; value: string }[];
|
|
19
|
+
value: string;
|
|
20
|
+
onSelect: (value: string) => void;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
searchPlaceholder?: string;
|
|
23
|
+
emptyText?: string;
|
|
24
|
+
ariaLabel?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* ขึ้นบรรทัดใหม่เมื่อข้อความยาวเกินช่อง แทนการตัดเป็น "..."
|
|
29
|
+
*
|
|
30
|
+
* ต้องจัดการในตัวนี้เอง ส่ง className จากข้างนอกอย่างเดียวไม่พอ เพราะข้อความถูกห่อด้วย
|
|
31
|
+
* span ที่ตั้ง truncate ไว้ ซึ่งบังคับไม่ให้ขึ้นบรรทัดใหม่เสมอ
|
|
32
|
+
*/
|
|
33
|
+
wrapText?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function Combobox({
|
|
37
|
+
options,
|
|
38
|
+
value,
|
|
39
|
+
onSelect,
|
|
40
|
+
placeholder = "Select...",
|
|
41
|
+
searchPlaceholder = "Search...",
|
|
42
|
+
emptyText = "No results found.",
|
|
43
|
+
ariaLabel,
|
|
44
|
+
className,
|
|
45
|
+
disabled,
|
|
46
|
+
wrapText = false,
|
|
47
|
+
}: ComboboxProps) {
|
|
48
|
+
const [open, setOpen] = React.useState(false);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
52
|
+
<PopoverTrigger
|
|
53
|
+
render={
|
|
54
|
+
<Button
|
|
55
|
+
type="button"
|
|
56
|
+
variant="outline"
|
|
57
|
+
role="combobox"
|
|
58
|
+
aria-label={
|
|
59
|
+
ariaLabel ??
|
|
60
|
+
(value ? options.find((option) => option.value === value)?.label : placeholder)
|
|
61
|
+
}
|
|
62
|
+
aria-expanded={open}
|
|
63
|
+
className={cn(
|
|
64
|
+
"w-full justify-between font-normal",
|
|
65
|
+
!value && "text-muted-foreground",
|
|
66
|
+
wrapText && "h-auto min-h-9 whitespace-normal py-2 text-left",
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
disabled={disabled}
|
|
70
|
+
/>
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<span
|
|
74
|
+
className={cn("min-w-0", wrapText ? "wrap-break-word whitespace-normal" : "truncate")}
|
|
75
|
+
>
|
|
76
|
+
{value ? options.find((option) => option.value === value)?.label : placeholder}
|
|
77
|
+
</span>
|
|
78
|
+
<ChevronsUpDown
|
|
79
|
+
className={cn(
|
|
80
|
+
"ml-2 h-4 w-4 shrink-0 opacity-50 print:hidden",
|
|
81
|
+
wrapText && "self-start",
|
|
82
|
+
disabled && "hidden",
|
|
83
|
+
)}
|
|
84
|
+
/>
|
|
85
|
+
</PopoverTrigger>
|
|
86
|
+
<PopoverContent className="w-[--anchor-width] p-0">
|
|
87
|
+
<Command>
|
|
88
|
+
<CommandInput placeholder={searchPlaceholder} />
|
|
89
|
+
<CommandList>
|
|
90
|
+
<CommandEmpty>{emptyText}</CommandEmpty>
|
|
91
|
+
<CommandGroup>
|
|
92
|
+
{options.map((option) => (
|
|
93
|
+
<CommandItem
|
|
94
|
+
key={option.value}
|
|
95
|
+
value={option.label}
|
|
96
|
+
onSelect={() => {
|
|
97
|
+
onSelect(option.value === value ? "" : option.value);
|
|
98
|
+
setOpen(false);
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<Check
|
|
102
|
+
className={cn(
|
|
103
|
+
"mr-2 h-4 w-4",
|
|
104
|
+
value === option.value ? "opacity-100" : "opacity-0",
|
|
105
|
+
)}
|
|
106
|
+
/>
|
|
107
|
+
{option.label}
|
|
108
|
+
</CommandItem>
|
|
109
|
+
))}
|
|
110
|
+
</CommandGroup>
|
|
111
|
+
</CommandList>
|
|
112
|
+
</Command>
|
|
113
|
+
</PopoverContent>
|
|
114
|
+
</Popover>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* รายการแถว (แทนตารางหนัก ๆ): แต่ละแถวมีชื่อหลัก, บรรทัดรอง, ป้ายสถานะ และเมนู "…"
|
|
6
|
+
* อ่านง่ายบนมือถือกว่าตาราง และบังคับให้แต่ละแถวมีข้อมูลเท่าที่จำเป็น
|
|
7
|
+
*/
|
|
8
|
+
export function DataList({
|
|
9
|
+
children,
|
|
10
|
+
className,
|
|
11
|
+
empty,
|
|
12
|
+
count,
|
|
13
|
+
}: {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
className?: string;
|
|
16
|
+
/** แสดงเมื่อ count === 0 */
|
|
17
|
+
empty?: ReactNode;
|
|
18
|
+
count?: number;
|
|
19
|
+
}) {
|
|
20
|
+
if (count === 0 && empty) return <>{empty}</>;
|
|
21
|
+
return (
|
|
22
|
+
<ul className={cn("divide-y rounded-xl border bg-card text-card-foreground", className)}>
|
|
23
|
+
{children}
|
|
24
|
+
</ul>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function DataRow({
|
|
29
|
+
leading,
|
|
30
|
+
title,
|
|
31
|
+
subtitle,
|
|
32
|
+
meta,
|
|
33
|
+
badges,
|
|
34
|
+
actions,
|
|
35
|
+
className,
|
|
36
|
+
}: {
|
|
37
|
+
/** avatar / ไอคอน */
|
|
38
|
+
leading?: ReactNode;
|
|
39
|
+
title: ReactNode;
|
|
40
|
+
subtitle?: ReactNode;
|
|
41
|
+
/** ข้อมูลเสริมด้านขวา (เช่น วันที่, path) */
|
|
42
|
+
meta?: ReactNode;
|
|
43
|
+
badges?: ReactNode;
|
|
44
|
+
actions?: ReactNode;
|
|
45
|
+
className?: string;
|
|
46
|
+
}) {
|
|
47
|
+
return (
|
|
48
|
+
<li className={cn("flex items-center gap-3 px-4 py-3", className)}>
|
|
49
|
+
{leading}
|
|
50
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
51
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
52
|
+
<span className="truncate text-sm font-medium">{title}</span>
|
|
53
|
+
{badges}
|
|
54
|
+
</div>
|
|
55
|
+
{subtitle && <span className="truncate text-xs text-muted-foreground">{subtitle}</span>}
|
|
56
|
+
</div>
|
|
57
|
+
{meta && (
|
|
58
|
+
<div className="hidden shrink-0 text-right text-xs text-muted-foreground sm:block">
|
|
59
|
+
{meta}
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
{actions && <div className="shrink-0">{actions}</div>}
|
|
63
|
+
</li>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
4
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
|
+
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
6
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
7
|
+
import { Separator } from "@suphark/ui/components/ui/separator";
|
|
8
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
9
|
+
import { format } from "date-fns";
|
|
10
|
+
import { CalendarIcon } from "lucide-react";
|
|
11
|
+
import type { DateRange } from "react-day-picker";
|
|
12
|
+
|
|
13
|
+
interface DateRangePickerProps {
|
|
14
|
+
value: DateRange | undefined;
|
|
15
|
+
onValueChange: (range: DateRange | undefined) => void;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
triggerClassName?: string;
|
|
19
|
+
align?: "start" | "end" | "center";
|
|
20
|
+
showClear?: boolean;
|
|
21
|
+
title?: string;
|
|
22
|
+
variant?: "default" | "outline" | "ghost";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function DateRangePicker({
|
|
26
|
+
value,
|
|
27
|
+
onValueChange,
|
|
28
|
+
placeholder = "Pick a date range",
|
|
29
|
+
className,
|
|
30
|
+
triggerClassName,
|
|
31
|
+
align = "start",
|
|
32
|
+
showClear = true,
|
|
33
|
+
title,
|
|
34
|
+
variant = "outline",
|
|
35
|
+
}: DateRangePickerProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={cn("flex items-center gap-2", className)}>
|
|
38
|
+
<Popover>
|
|
39
|
+
<PopoverTrigger
|
|
40
|
+
render={
|
|
41
|
+
<Button
|
|
42
|
+
variant={variant}
|
|
43
|
+
size="sm"
|
|
44
|
+
className={cn(
|
|
45
|
+
"h-8 justify-start text-left font-normal",
|
|
46
|
+
title && "border-dashed",
|
|
47
|
+
value?.from && title && "border-solid bg-accent",
|
|
48
|
+
!value && "text-muted-foreground",
|
|
49
|
+
triggerClassName,
|
|
50
|
+
)}
|
|
51
|
+
/>
|
|
52
|
+
}
|
|
53
|
+
>
|
|
54
|
+
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
55
|
+
{title ||
|
|
56
|
+
(value?.from ? (
|
|
57
|
+
value.to ? (
|
|
58
|
+
<>
|
|
59
|
+
{format(value.from, "PP")} - {format(value.to, "PP")}
|
|
60
|
+
</>
|
|
61
|
+
) : (
|
|
62
|
+
format(value.from, "PP")
|
|
63
|
+
)
|
|
64
|
+
) : (
|
|
65
|
+
<span>{placeholder}</span>
|
|
66
|
+
))}
|
|
67
|
+
{title && value?.from && (
|
|
68
|
+
<>
|
|
69
|
+
<Separator orientation="vertical" className="mx-2 h-4" />
|
|
70
|
+
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
|
|
71
|
+
1
|
|
72
|
+
</Badge>
|
|
73
|
+
<div className="hidden space-x-1 lg:flex">
|
|
74
|
+
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
|
|
75
|
+
{format(value.from, "PP")}
|
|
76
|
+
{value.to && <> - {format(value.to, "PP")}</>}
|
|
77
|
+
</Badge>
|
|
78
|
+
</div>
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
</PopoverTrigger>
|
|
82
|
+
<PopoverContent className="w-auto p-0" align={align}>
|
|
83
|
+
<Calendar
|
|
84
|
+
autoFocus
|
|
85
|
+
mode="range"
|
|
86
|
+
defaultMonth={value?.from}
|
|
87
|
+
selected={value}
|
|
88
|
+
onSelect={onValueChange}
|
|
89
|
+
numberOfMonths={2}
|
|
90
|
+
/>
|
|
91
|
+
{showClear && value?.from && (
|
|
92
|
+
<div className="border-t p-3">
|
|
93
|
+
<Button
|
|
94
|
+
variant="ghost"
|
|
95
|
+
className="w-full font-normal text-xs"
|
|
96
|
+
onClick={() => onValueChange(undefined)}
|
|
97
|
+
>
|
|
98
|
+
Clear selection
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
</PopoverContent>
|
|
103
|
+
</Popover>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/** รายการ label/value สองคอลัมน์ */
|
|
5
|
+
export function DescriptionList({
|
|
6
|
+
items,
|
|
7
|
+
className,
|
|
8
|
+
}: {
|
|
9
|
+
items: Array<[ReactNode, ReactNode]>;
|
|
10
|
+
className?: string;
|
|
11
|
+
}) {
|
|
12
|
+
return (
|
|
13
|
+
<dl
|
|
14
|
+
className={cn(
|
|
15
|
+
"grid grid-cols-[minmax(120px,max-content)_1fr] gap-x-4 gap-y-1.5 text-sm",
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
>
|
|
19
|
+
{items.map(([k, v], i) => (
|
|
20
|
+
<div key={typeof k === "string" ? k : i} className="contents">
|
|
21
|
+
<dt className="text-muted-foreground">{k}</dt>
|
|
22
|
+
<dd className="min-w-0 break-words">{v}</dd>
|
|
23
|
+
</div>
|
|
24
|
+
))}
|
|
25
|
+
</dl>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { TableCell, TableRow } from "@suphark/ui/components/ui/table";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/** แถว "ยังไม่มีข้อมูล" ของตาราง */
|
|
5
|
+
export function EmptyRow({ colSpan, children }: { colSpan: number; children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<TableRow className="hover:bg-transparent">
|
|
8
|
+
<TableCell colSpan={colSpan} className="py-6 text-center text-muted-foreground">
|
|
9
|
+
{children}
|
|
10
|
+
</TableCell>
|
|
11
|
+
</TableRow>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/** หน้าว่าง = คำเชิญให้ทำขั้นถัดไป: ไอคอน, ประโยคเดียวว่าคืออะไร, ปุ่มเดียวว่าทำอะไรต่อ */
|
|
5
|
+
export function EmptyState({
|
|
6
|
+
icon,
|
|
7
|
+
title,
|
|
8
|
+
description,
|
|
9
|
+
action,
|
|
10
|
+
className,
|
|
11
|
+
}: {
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
title: ReactNode;
|
|
14
|
+
description?: ReactNode;
|
|
15
|
+
action?: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={cn(
|
|
21
|
+
"flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center",
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
>
|
|
25
|
+
{icon && <div className="mb-1 text-muted-foreground [&_svg]:size-8">{icon}</div>}
|
|
26
|
+
<p className="text-sm font-medium">{title}</p>
|
|
27
|
+
{description && <p className="max-w-sm text-sm text-muted-foreground">{description}</p>}
|
|
28
|
+
{action && <div className="mt-2">{action}</div>}
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { useUiText } from "./ui-text";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* บล็อก .env สำเร็จรูป: คัดลอกทั้งก้อนปุ่มเดียว (Clerk-style "quick copy")
|
|
10
|
+
* ค่าที่เป็น secret แสดงจริง (หน้าที่เรียกต้องเป็นหน้า "แสดงครั้งเดียว" อยู่แล้ว)
|
|
11
|
+
*/
|
|
12
|
+
export function EnvSnippet({
|
|
13
|
+
title = ".env.local",
|
|
14
|
+
lines,
|
|
15
|
+
copyLabel,
|
|
16
|
+
}: {
|
|
17
|
+
title?: string;
|
|
18
|
+
/** [key, value] ตามลำดับ; value ว่าง = ให้ผู้ใช้เติมเอง */
|
|
19
|
+
lines: Array<[key: string, value: string]>;
|
|
20
|
+
copyLabel?: string;
|
|
21
|
+
}) {
|
|
22
|
+
const text = useUiText();
|
|
23
|
+
const [copied, setCopied] = useState(false);
|
|
24
|
+
const content = lines.map(([k, v]) => `${k}=${v}`).join("\n");
|
|
25
|
+
async function copy() {
|
|
26
|
+
try {
|
|
27
|
+
await navigator.clipboard.writeText(`${content}\n`);
|
|
28
|
+
setCopied(true);
|
|
29
|
+
setTimeout(() => setCopied(false), 1500);
|
|
30
|
+
} catch {
|
|
31
|
+
// clipboard ไม่ได้ (http/permission) — ผู้ใช้ยังเลือกข้อความคัดลอกเองได้
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<div className="overflow-hidden rounded-lg border bg-card">
|
|
36
|
+
<div className="flex items-center justify-between gap-2 border-b bg-muted px-3 py-1.5">
|
|
37
|
+
<span className="font-mono text-xs text-muted-foreground">{title}</span>
|
|
38
|
+
<Button type="button" variant="outline" size="xs" onClick={copy}>
|
|
39
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
40
|
+
{copied ? text.copied : (copyLabel ?? text.copy)}
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
<pre className="overflow-x-auto px-3 py-2.5 font-mono text-xs leading-6">
|
|
44
|
+
{lines.map(([k, v]) => (
|
|
45
|
+
<div key={k}>
|
|
46
|
+
<span className="text-muted-foreground">{k}=</span>
|
|
47
|
+
<span className="break-all">{v}</span>
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</pre>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** สุ่ม secret สำหรับ session cookie ของแอป (base64url 48 ไบต์ ≥ 32 ตัวอักษรตามที่ SDK ต้องการ) */
|
|
56
|
+
export function randomSecret(bytes = 48): string {
|
|
57
|
+
const buf = new Uint8Array(bytes);
|
|
58
|
+
crypto.getRandomValues(buf);
|
|
59
|
+
let s = "";
|
|
60
|
+
for (const b of buf) s += String.fromCharCode(b);
|
|
61
|
+
return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
|
|
62
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { SimpleSelect, type SimpleSelectGroup, type SimpleSelectOption } from "../simple-select";
|
|
4
|
+
|
|
5
|
+
describe("SimpleSelect Component", () => {
|
|
6
|
+
it("renders flat options and displays label instead of raw ID", () => {
|
|
7
|
+
const options: SimpleSelectOption[] = [
|
|
8
|
+
{ value: "none", label: "ไม่ระบุ" },
|
|
9
|
+
{ value: "550e8400-e29b-41d4-a716-446655440000", label: "โครงการ A" },
|
|
10
|
+
{ value: "660e8400-e29b-41d4-a716-446655440001", label: "โครงการ B" },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
render(
|
|
14
|
+
<SimpleSelect
|
|
15
|
+
value="550e8400-e29b-41d4-a716-446655440000"
|
|
16
|
+
options={options}
|
|
17
|
+
onValueChange={vi.fn()}
|
|
18
|
+
/>,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// Should display the label "โครงการ A"
|
|
22
|
+
expect(screen.getByText("โครงการ A")).toBeInTheDocument();
|
|
23
|
+
// Should NOT display the raw UUID
|
|
24
|
+
expect(screen.queryByText("550e8400-e29b-41d4-a716-446655440000")).not.toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("renders grouped options and displays label for selected item", () => {
|
|
28
|
+
const groups: SimpleSelectGroup[] = [
|
|
29
|
+
{
|
|
30
|
+
label: "กลุ่มที่ 1",
|
|
31
|
+
options: [
|
|
32
|
+
{ value: "step-1::field-1", label: "เลขที่เอกสาร (Text)" },
|
|
33
|
+
{ value: "step-1::field-2", label: "คู่ค้า (Select)" },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
label: "กลุ่มที่ 2",
|
|
38
|
+
options: [{ value: "step-2::field-3", label: "ยอดเงิน (Number)" }],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
render(<SimpleSelect value="step-1::field-2" options={groups} onValueChange={vi.fn()} />);
|
|
43
|
+
|
|
44
|
+
expect(screen.getByText("คู่ค้า (Select)")).toBeInTheDocument();
|
|
45
|
+
expect(screen.queryByText("step-1::field-2")).not.toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("uses fallbackLabel when the selected value is not in options", () => {
|
|
49
|
+
const options: SimpleSelectOption[] = [{ value: "opt-1", label: "ตัวเลือก 1" }];
|
|
50
|
+
|
|
51
|
+
render(
|
|
52
|
+
<SimpleSelect
|
|
53
|
+
value="orphaned-step-id::field-abc"
|
|
54
|
+
options={options}
|
|
55
|
+
fallbackLabel={(val) => `ค้นพบจากประวัติ: ${val.split("::").pop()}`}
|
|
56
|
+
onValueChange={vi.fn()}
|
|
57
|
+
/>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(screen.getByText("ค้นพบจากประวัติ: field-abc")).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders placeholder when value is empty or unset", () => {
|
|
64
|
+
const options: SimpleSelectOption[] = [{ value: "opt-1", label: "ตัวเลือก 1" }];
|
|
65
|
+
|
|
66
|
+
render(
|
|
67
|
+
<SimpleSelect value="" options={options} placeholder="กรุณาเลือก..." onValueChange={vi.fn()} />,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(screen.getByText("กรุณาเลือก...")).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
4
|
+
import {
|
|
5
|
+
FormControl,
|
|
6
|
+
FormDescription,
|
|
7
|
+
FormField,
|
|
8
|
+
FormItem,
|
|
9
|
+
FormLabel,
|
|
10
|
+
} from "@suphark/ui/components/ui/form";
|
|
11
|
+
import { Switch } from "@suphark/ui/components/ui/switch";
|
|
12
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
13
|
+
import type { ComponentProps } from "react";
|
|
14
|
+
import type { Control, FieldValues, Path } from "react-hook-form";
|
|
15
|
+
|
|
16
|
+
type FormFieldBooleanProps<T extends FieldValues> = {
|
|
17
|
+
control: Control<T>;
|
|
18
|
+
name: Path<T>;
|
|
19
|
+
label: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
} & (
|
|
23
|
+
| {
|
|
24
|
+
variant?: "checkbox"; // Default
|
|
25
|
+
checkboxProps?: ComponentProps<typeof Checkbox>;
|
|
26
|
+
className?: string; // Container className
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
variant: "switch";
|
|
30
|
+
switchProps?: ComponentProps<typeof Switch>;
|
|
31
|
+
className?: string; // Container className
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export function FormFieldBoolean<T extends FieldValues>({
|
|
36
|
+
control,
|
|
37
|
+
name,
|
|
38
|
+
label,
|
|
39
|
+
description,
|
|
40
|
+
disabled,
|
|
41
|
+
className,
|
|
42
|
+
...props
|
|
43
|
+
}: FormFieldBooleanProps<T>) {
|
|
44
|
+
return (
|
|
45
|
+
<FormField
|
|
46
|
+
control={control}
|
|
47
|
+
name={name}
|
|
48
|
+
render={({ field }) => {
|
|
49
|
+
if (props.variant === "switch") {
|
|
50
|
+
return (
|
|
51
|
+
<FormItem
|
|
52
|
+
className={cn(
|
|
53
|
+
"flex flex-row items-center justify-between rounded-lg border p-4",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
<div className="space-y-0.5">
|
|
58
|
+
<FormLabel className="text-base">{label}</FormLabel>
|
|
59
|
+
{description && <FormDescription>{description}</FormDescription>}
|
|
60
|
+
</div>
|
|
61
|
+
<FormControl>
|
|
62
|
+
<Switch
|
|
63
|
+
checked={field.value}
|
|
64
|
+
onCheckedChange={field.onChange}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
{...props.switchProps}
|
|
67
|
+
/>
|
|
68
|
+
</FormControl>
|
|
69
|
+
</FormItem>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Default: Checkbox
|
|
74
|
+
return (
|
|
75
|
+
<FormItem className={cn("flex flex-row items-start space-x-3 space-y-0", className)}>
|
|
76
|
+
<FormControl>
|
|
77
|
+
<Checkbox
|
|
78
|
+
checked={field.value}
|
|
79
|
+
onCheckedChange={field.onChange}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
{...props.checkboxProps}
|
|
82
|
+
/>
|
|
83
|
+
</FormControl>
|
|
84
|
+
<div className="space-y-1 leading-none">
|
|
85
|
+
<FormLabel>{label}</FormLabel>
|
|
86
|
+
{description && <FormDescription>{description}</FormDescription>}
|
|
87
|
+
</div>
|
|
88
|
+
</FormItem>
|
|
89
|
+
);
|
|
90
|
+
}}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|