@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,312 @@
1
+ "use client";
2
+
3
+ import { MultiSelectCombobox } from "@suphark/ui/components/shared/multi-select-combobox";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import {
6
+ Command,
7
+ CommandEmpty,
8
+ CommandGroup,
9
+ CommandInput,
10
+ CommandItem,
11
+ CommandList,
12
+ } from "@suphark/ui/components/ui/command";
13
+ import {
14
+ FormControl,
15
+ FormField,
16
+ FormItem,
17
+ FormLabel,
18
+ FormMessage,
19
+ } from "@suphark/ui/components/ui/form";
20
+ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from "@suphark/ui/components/ui/select";
28
+ import { cn } from "@suphark/ui/lib/utils";
29
+ import { Check, ChevronsUpDown } from "lucide-react";
30
+ import * as React from "react";
31
+ import type { ControllerRenderProps, FieldValues, Path, PathValue } from "react-hook-form";
32
+
33
+ // Unified Option Type
34
+ export type SelectOption = {
35
+ value: string;
36
+ label: string;
37
+ // Combobox specific fields (optional)
38
+ subLabel?: string;
39
+ superLabel?: string;
40
+ searchableValue?: string;
41
+ };
42
+
43
+ // Base Props
44
+ type BaseProps<T extends FieldValues> = {
45
+ control: any; // Using any to bypass deep generic reconciliation errors with Control<T, any> in RHF 7
46
+ name: Path<T>;
47
+ label: string;
48
+ options: SelectOption[];
49
+ placeholder?: string;
50
+ disabled?: boolean;
51
+ required?: boolean;
52
+ formItemClassName?: string;
53
+ description?: string;
54
+ };
55
+
56
+ // Discriminated Union Props
57
+ type FormFieldSelectProps<T extends FieldValues> = BaseProps<T> &
58
+ (
59
+ | {
60
+ variant?: "select"; // Default
61
+ onValueChange?: (value: string) => void;
62
+ }
63
+ | {
64
+ variant: "combobox";
65
+ searchPlaceholder?: string;
66
+ searchable?: boolean;
67
+ modal?: boolean;
68
+ emptyContent?: React.ReactNode;
69
+ }
70
+ | {
71
+ variant: "multiselect";
72
+ searchPlaceholder?: string;
73
+ creatable?: boolean;
74
+ modal?: boolean;
75
+ }
76
+ );
77
+
78
+ // --- Combobox Internal Logic ---
79
+ function ComboboxContent<T extends FieldValues>({
80
+ field,
81
+ options,
82
+ disabled,
83
+ placeholder,
84
+ searchPlaceholder,
85
+ searchable = true,
86
+ modal,
87
+ emptyContent,
88
+ required,
89
+ ariaLabel,
90
+ }: {
91
+ field: ControllerRenderProps<T, Path<T>>;
92
+ options: SelectOption[];
93
+ disabled?: boolean;
94
+ placeholder?: string;
95
+ searchPlaceholder?: string;
96
+ searchable?: boolean;
97
+ modal?: boolean;
98
+ emptyContent?: React.ReactNode;
99
+ required?: boolean;
100
+ ariaLabel: string;
101
+ }) {
102
+ const [open, setOpen] = React.useState(false);
103
+
104
+ // Fix: Handle null/undefined field.value safely
105
+ const fieldVal = field.value ?? "";
106
+ const selectedOption = options.find((option) => option.value === fieldVal);
107
+ const selectedLabel = selectedOption?.label || placeholder;
108
+ const selectedCommandValue = selectedOption
109
+ ? selectedOption.searchableValue || selectedOption.label
110
+ : undefined;
111
+
112
+ return (
113
+ <Popover open={open} onOpenChange={setOpen} modal={modal}>
114
+ <PopoverTrigger render={<FormControl />}>
115
+ <Button
116
+ variant="outline"
117
+ role="combobox"
118
+ aria-label={ariaLabel}
119
+ aria-expanded={open}
120
+ aria-required={required}
121
+ className={cn(
122
+ "w-full justify-between",
123
+ !fieldVal && "text-muted-foreground",
124
+ disabled && "cursor-not-allowed opacity-50",
125
+ )}
126
+ disabled={disabled}
127
+ >
128
+ <span className="truncate">{selectedLabel}</span>
129
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
130
+ </Button>
131
+ </PopoverTrigger>
132
+ <PopoverContent className="w-[--anchor-width] p-0">
133
+ <Command defaultValue={selectedCommandValue}>
134
+ {searchable && <CommandInput placeholder={searchPlaceholder || "Search..."} />}
135
+ <CommandList>
136
+ {searchable ? <CommandEmpty>{emptyContent ?? "No results found."}</CommandEmpty> : null}
137
+ <CommandGroup>
138
+ {options.map((option) => (
139
+ <CommandItem
140
+ key={option.value}
141
+ value={option.searchableValue || option.label}
142
+ onSelect={() => {
143
+ field.onChange(
144
+ (option.value === fieldVal ? "" : option.value) as PathValue<T, Path<T>>,
145
+ );
146
+ setOpen(false);
147
+ }}
148
+ >
149
+ <Check
150
+ className={cn(
151
+ "mr-2 h-4 w-4",
152
+ fieldVal === option.value ? "opacity-100" : "opacity-0",
153
+ )}
154
+ />
155
+ <div>
156
+ {option.superLabel && (
157
+ <p className="text-muted-foreground text-xs">{option.superLabel}</p>
158
+ )}
159
+ <span>{option.label}</span>
160
+ {option.subLabel && (
161
+ <p className="text-muted-foreground text-xs">{option.subLabel}</p>
162
+ )}
163
+ </div>
164
+ </CommandItem>
165
+ ))}
166
+ </CommandGroup>
167
+ </CommandList>
168
+ </Command>
169
+ </PopoverContent>
170
+ </Popover>
171
+ );
172
+ }
173
+
174
+ // --- Multiselect Combobox Internal Logic ---
175
+ function MultiSelectComboboxContent<T extends FieldValues>({
176
+ field,
177
+ options,
178
+ disabled,
179
+ placeholder,
180
+ searchPlaceholder,
181
+ creatable = false,
182
+ modal,
183
+ ariaLabel,
184
+ }: {
185
+ field: ControllerRenderProps<T, Path<T>>;
186
+ options: SelectOption[];
187
+ disabled?: boolean;
188
+ placeholder?: string;
189
+ searchPlaceholder?: string;
190
+ creatable?: boolean;
191
+ modal?: boolean;
192
+ ariaLabel: string;
193
+ }) {
194
+ const rawValue = field.value || [];
195
+ const value = Array.isArray(rawValue) ? rawValue : [];
196
+
197
+ return (
198
+ <MultiSelectCombobox
199
+ options={options}
200
+ value={value}
201
+ onChange={(nextVal) => field.onChange(nextVal as PathValue<T, Path<T>>)}
202
+ disabled={disabled}
203
+ placeholder={placeholder}
204
+ searchPlaceholder={searchPlaceholder}
205
+ creatable={creatable}
206
+ modal={modal}
207
+ triggerRender={<FormControl />}
208
+ ariaLabel={ariaLabel}
209
+ />
210
+ );
211
+ }
212
+
213
+ export function FormFieldSelect<T extends FieldValues>({
214
+ control,
215
+ name,
216
+ label,
217
+ options,
218
+ placeholder,
219
+ formItemClassName,
220
+ description,
221
+ disabled,
222
+ required,
223
+ ...props
224
+ }: FormFieldSelectProps<T>) {
225
+ // Safeguard: Deduplicate options by value to prevent React key warnings
226
+ const uniqueOptions = React.useMemo(() => {
227
+ const seen = new Set<string>();
228
+ return options.filter((option) => {
229
+ if (!option.value || seen.has(option.value)) return false;
230
+ seen.add(option.value);
231
+ return true;
232
+ });
233
+ }, [options]);
234
+
235
+ return (
236
+ <FormField
237
+ control={control}
238
+ name={name}
239
+ render={({ field }) => (
240
+ <FormItem className={formItemClassName}>
241
+ <FormLabel>
242
+ {label}{" "}
243
+ {required ? (
244
+ <span className="text-destructive" aria-hidden="true">
245
+ *
246
+ </span>
247
+ ) : null}
248
+ {required ? <span className="sr-only"> required</span> : null}
249
+ </FormLabel>
250
+
251
+ {props.variant === "combobox" ? (
252
+ <ComboboxContent
253
+ field={field}
254
+ options={uniqueOptions}
255
+ disabled={disabled}
256
+ placeholder={placeholder}
257
+ searchPlaceholder={props.searchPlaceholder}
258
+ searchable={props.searchable}
259
+ modal={props.modal}
260
+ emptyContent={props.emptyContent}
261
+ required={required}
262
+ ariaLabel={label}
263
+ />
264
+ ) : props.variant === "multiselect" ? (
265
+ <MultiSelectComboboxContent
266
+ field={field}
267
+ options={uniqueOptions}
268
+ disabled={disabled}
269
+ placeholder={placeholder}
270
+ searchPlaceholder={props.searchPlaceholder}
271
+ creatable={props.creatable}
272
+ modal={props.modal}
273
+ ariaLabel={label}
274
+ />
275
+ ) : (
276
+ <Select
277
+ items={uniqueOptions}
278
+ onValueChange={(val) => {
279
+ if (val !== null) {
280
+ field.onChange(val);
281
+ if (props.variant === "select" && props.onValueChange) {
282
+ props.onValueChange(val as any);
283
+ }
284
+ }
285
+ }}
286
+ value={field.value}
287
+ disabled={disabled}
288
+ >
289
+ <FormControl>
290
+ <SelectTrigger aria-required={required}>
291
+ <SelectValue placeholder={placeholder}>
292
+ {(val) => uniqueOptions.find((o) => o.value === val)?.label ?? val}
293
+ </SelectValue>
294
+ </SelectTrigger>
295
+ </FormControl>
296
+ <SelectContent>
297
+ {uniqueOptions.map((option) => (
298
+ <SelectItem key={option.value} value={option.value}>
299
+ {option.label}
300
+ </SelectItem>
301
+ ))}
302
+ </SelectContent>
303
+ </Select>
304
+ )}
305
+
306
+ {description && <p className="text-muted-foreground text-sm">{description}</p>}
307
+ <FormMessage />
308
+ </FormItem>
309
+ )}
310
+ />
311
+ );
312
+ }
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectGroup,
7
+ SelectItem,
8
+ SelectLabel,
9
+ SelectSeparator,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from "@suphark/ui/components/ui/select";
13
+ import { cn } from "@suphark/ui/lib/utils";
14
+ import * as React from "react";
15
+
16
+ export interface SimpleSelectOption<T extends string = string> {
17
+ value: T;
18
+ label: React.ReactNode;
19
+ disabled?: boolean;
20
+ }
21
+
22
+ export interface SimpleSelectGroup<T extends string = string> {
23
+ label?: string;
24
+ options: SimpleSelectOption<T>[];
25
+ }
26
+
27
+ export type SimpleSelectOptions<T extends string = string> =
28
+ | SimpleSelectOption<T>[]
29
+ | SimpleSelectGroup<T>[];
30
+
31
+ export interface SimpleSelectProps<T extends string = string> {
32
+ value?: T;
33
+ defaultValue?: T;
34
+ onValueChange?: (value: T) => void;
35
+ options: SimpleSelectOptions<T>;
36
+ placeholder?: string;
37
+ fallbackLabel?: (value: T) => React.ReactNode;
38
+ disabled?: boolean;
39
+ size?: "sm" | "default";
40
+ className?: string;
41
+ triggerClassName?: string;
42
+ contentClassName?: string;
43
+ ariaLabel?: string;
44
+ }
45
+
46
+ function isGroupList<T extends string>(
47
+ options: SimpleSelectOptions<T>,
48
+ ): options is SimpleSelectGroup<T>[] {
49
+ const first = options[0];
50
+ return first !== undefined && "options" in first;
51
+ }
52
+
53
+ export function SimpleSelect<T extends string = string>({
54
+ value,
55
+ defaultValue,
56
+ onValueChange,
57
+ options,
58
+ placeholder,
59
+ fallbackLabel,
60
+ disabled,
61
+ size = "default",
62
+ className,
63
+ triggerClassName,
64
+ contentClassName,
65
+ ariaLabel,
66
+ }: SimpleSelectProps<T>) {
67
+ const { flatOptions, groups } = React.useMemo(() => {
68
+ if (isGroupList(options)) {
69
+ const flat = options.flatMap((g) => g.options);
70
+ return { flatOptions: flat, groups: options };
71
+ }
72
+ return {
73
+ flatOptions: options,
74
+ groups: [{ options }],
75
+ };
76
+ }, [options]);
77
+
78
+ const items = React.useMemo(
79
+ () =>
80
+ flatOptions.map((opt) => ({
81
+ value: opt.value,
82
+ label: opt.label,
83
+ })),
84
+ [flatOptions],
85
+ );
86
+
87
+ const optionsMap = React.useMemo(() => {
88
+ const map = new Map<string, React.ReactNode>();
89
+ for (const opt of flatOptions) {
90
+ map.set(opt.value, opt.label);
91
+ }
92
+ return map;
93
+ }, [flatOptions]);
94
+
95
+ const resolveLabel = React.useCallback(
96
+ (val: unknown) => {
97
+ if (val === undefined || val === null || val === "") {
98
+ return placeholder;
99
+ }
100
+ const strVal = String(val);
101
+ if (optionsMap.has(strVal)) {
102
+ return optionsMap.get(strVal);
103
+ }
104
+ if (fallbackLabel) {
105
+ return fallbackLabel(strVal as T);
106
+ }
107
+ return strVal;
108
+ },
109
+ [optionsMap, fallbackLabel, placeholder],
110
+ );
111
+
112
+ return (
113
+ <Select
114
+ items={items}
115
+ value={value}
116
+ // base-ui-ok: wrapper ตั้งใจเปิดให้ใช้ได้ทั้งแบบ controlled และ uncontrolled
117
+ // ผู้เรียกเลือกส่งอย่างใดอย่างหนึ่ง ค่าที่ส่งมาจึงไม่ใช่ค่าที่เปลี่ยนไปมา
118
+ defaultValue={defaultValue}
119
+ onValueChange={(val) => {
120
+ if (val !== null && onValueChange) {
121
+ onValueChange(val as T);
122
+ }
123
+ }}
124
+ disabled={disabled}
125
+ >
126
+ <SelectTrigger
127
+ size={size}
128
+ aria-label={ariaLabel}
129
+ className={cn("w-full", triggerClassName, className)}
130
+ >
131
+ <SelectValue placeholder={placeholder}>{(val) => resolveLabel(val)}</SelectValue>
132
+ </SelectTrigger>
133
+ <SelectContent className={contentClassName}>
134
+ {groups.map((group, gIdx) => (
135
+ <React.Fragment key={group.label || `group-${gIdx}`}>
136
+ {gIdx > 0 && <SelectSeparator />}
137
+ <SelectGroup>
138
+ {group.label && (
139
+ <SelectLabel className="text-muted-foreground text-xs">{group.label}</SelectLabel>
140
+ )}
141
+ {group.options.map((opt) => (
142
+ <SelectItem key={opt.value} value={opt.value} disabled={opt.disabled}>
143
+ {opt.label}
144
+ </SelectItem>
145
+ ))}
146
+ </SelectGroup>
147
+ </React.Fragment>
148
+ ))}
149
+ </SelectContent>
150
+ </Select>
151
+ );
152
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogTrigger,
12
+ } from "@suphark/ui/components/ui/dialog";
13
+ import { useRouter } from "next/navigation";
14
+ import {
15
+ type ReactElement,
16
+ type ReactNode,
17
+ startTransition,
18
+ useActionState,
19
+ useEffect,
20
+ useState,
21
+ } from "react";
22
+ import { FormMessage, type FormState } from "./form-message";
23
+ import { useUiText } from "./ui-text";
24
+
25
+ type FormAction = (prev: FormState, formData: FormData) => Promise<FormState>;
26
+
27
+ /**
28
+ * ฟอร์ม "สร้าง/แก้ไข" ในกล่องโต้ตอบ — เปิดจากปุ่ม (trigger) ปิดเองเมื่อสำเร็จ
29
+ * ใช้แทนฟอร์มที่เคยวางค้างไว้บนหน้า เพื่อให้หน้า list สะอาดและมีการกระทำหลักอันเดียว
30
+ */
31
+ export function FormDialog({
32
+ trigger,
33
+ title,
34
+ description,
35
+ action,
36
+ submitLabel,
37
+ children,
38
+ open: controlledOpen,
39
+ onOpenChange,
40
+ keepOpen = false,
41
+ contentClassName,
42
+ }: {
43
+ /** ปุ่มเปิด (ต้องเป็น <Button> ของ shadcn — Base UI จะใช้ตัวนี้เป็น trigger โดยตรง) */
44
+ trigger?: ReactElement;
45
+ title: ReactNode;
46
+ description?: ReactNode;
47
+ action: FormAction;
48
+ submitLabel?: string;
49
+ children: ReactNode;
50
+ /** ควบคุมจากภายนอก (เช่นเปิดจาก RowActions) */
51
+ open?: boolean;
52
+ onOpenChange?: (open: boolean) => void;
53
+ /** ผลลัพธ์ต้องอ่านหลังสำเร็จ (เช่น secret แสดงครั้งเดียว) → ไม่ปิดเอง */
54
+ keepOpen?: boolean;
55
+ /** ความกว้าง/คลาสของ DialogContent (ค่าเริ่มต้น sm:max-w-md) */
56
+ contentClassName?: string;
57
+ }) {
58
+ const router = useRouter();
59
+ const text = useUiText();
60
+ const [uncontrolled, setUncontrolled] = useState(false);
61
+ const open = controlledOpen ?? uncontrolled;
62
+ const setOpen = onOpenChange ?? setUncontrolled;
63
+ const [state, formAction, pending] = useActionState<FormState, FormData>(action, null);
64
+ const [submitted, setSubmitted] = useState(0);
65
+
66
+ // biome-ignore lint/correctness/useExhaustiveDependencies: ทำงานเมื่อผลของการ submit เปลี่ยนเท่านั้น
67
+ useEffect(() => {
68
+ if (state?.ok && !keepOpen) {
69
+ setOpen(false);
70
+ router.refresh();
71
+ } else if (state?.ok) {
72
+ router.refresh();
73
+ }
74
+ }, [state, submitted]);
75
+
76
+ return (
77
+ <Dialog open={open} onOpenChange={setOpen}>
78
+ {trigger && <DialogTrigger render={trigger} />}
79
+ <DialogContent className={contentClassName ?? "sm:max-w-md"}>
80
+ <form
81
+ // ไม่ใช้ `action=` — React 19 รีเซ็ตฟอร์ม uncontrolled หลัง action จบทุกครั้ง (แม้คืน error)
82
+ // ทำให้ค่าที่พิมพ์หาย; เรียก action ใน transition เองแทน ฟอร์มจึงคงค่าไว้ให้แก้ต่อได้
83
+ onSubmit={(e) => {
84
+ e.preventDefault();
85
+ const fd = new FormData(e.currentTarget);
86
+ setSubmitted((n) => n + 1);
87
+ startTransition(() => formAction(fd));
88
+ }}
89
+ className="flex flex-col gap-4"
90
+ >
91
+ <DialogHeader>
92
+ <DialogTitle>{title}</DialogTitle>
93
+ {description && <DialogDescription>{description}</DialogDescription>}
94
+ </DialogHeader>
95
+ <div className="flex flex-col gap-3">{children}</div>
96
+ <FormMessage state={state} />
97
+ <DialogFooter>
98
+ <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
99
+ {text.cancel}
100
+ </Button>
101
+ <Button type="submit" disabled={pending}>
102
+ {submitLabel ?? text.save}
103
+ </Button>
104
+ </DialogFooter>
105
+ </form>
106
+ </DialogContent>
107
+ </Dialog>
108
+ );
109
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { cn } from "@suphark/ui/lib/utils";
4
+ import { useUiText } from "./ui-text";
5
+
6
+ export type FormState = { ok: true; message?: string } | { ok: false; error: string } | null;
7
+
8
+ /** ข้อความผลของ server action ใต้ฟอร์ม */
9
+ export function FormMessage({
10
+ state,
11
+ className,
12
+ }: {
13
+ state: FormState | undefined;
14
+ className?: string;
15
+ }) {
16
+ const text = useUiText();
17
+ if (!state) return null;
18
+ return (
19
+ <p
20
+ role={state.ok ? "status" : "alert"}
21
+ className={cn(
22
+ "text-xs",
23
+ state.ok ? "text-emerald-700 dark:text-emerald-300" : "text-destructive",
24
+ className,
25
+ )}
26
+ >
27
+ {state.ok ? (state.message ?? text.success) : state.error}
28
+ </p>
29
+ );
30
+ }
@@ -0,0 +1,33 @@
1
+ import { Avatar, AvatarFallback, AvatarImage } from "@suphark/ui/components/ui/avatar";
2
+ import { cn } from "@suphark/ui/lib/utils";
3
+ import type { ComponentProps } from "react";
4
+
5
+ /** ตัวอักษรย่อจากชื่อ (ไทย/อังกฤษ): "สมชาย ใจดี" → "สจ", "Suphark A." → "SA" */
6
+ export function initialsOf(name: string | null | undefined, fallback = "?"): string {
7
+ const parts = (name ?? "").trim().split(/\s+/).filter(Boolean);
8
+ if (parts.length === 0) return fallback;
9
+ const first = [...(parts[0] ?? "")][0] ?? "";
10
+ const last = parts.length > 1 ? ([...(parts[parts.length - 1] ?? "")][0] ?? "") : "";
11
+ return (first + last).toUpperCase();
12
+ }
13
+
14
+ /** Avatar ของ shadcn + ตัวย่อชื่อ + สีพื้นคงที่ต่อชื่อ (ไม่ต้องมีรูป) */
15
+ export function Initials({
16
+ name,
17
+ src,
18
+ className,
19
+ ...props
20
+ }: ComponentProps<typeof Avatar> & { name: string | null | undefined; src?: string | null }) {
21
+ const hue = [...(name ?? "")].reduce((h, c) => (h * 31 + c.codePointAt(0)!) % 360, 7);
22
+ return (
23
+ <Avatar className={cn("shrink-0", className)} {...props}>
24
+ {src && <AvatarImage src={src} alt="" />}
25
+ <AvatarFallback
26
+ className="text-[0.7rem] font-medium"
27
+ style={{ backgroundColor: `oklch(0.93 0.04 ${hue})`, color: `oklch(0.4 0.08 ${hue})` }}
28
+ >
29
+ {initialsOf(name)}
30
+ </AvatarFallback>
31
+ </Avatar>
32
+ );
33
+ }