@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,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
|
+
}
|