@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,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,11 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+
5
+ export function ClientRedirect({ to }: { to: string }) {
6
+ useEffect(() => {
7
+ window.location.replace(to);
8
+ }, [to]);
9
+
10
+ return null;
11
+ }
@@ -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
+ }