@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,188 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatDistanceToNow, type Locale } from "date-fns";
|
|
4
|
+
import { enUS, th } from "date-fns/locale"; // Import locales you need
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Props for the ClientDateFormatter component.
|
|
9
|
+
*/
|
|
10
|
+
interface ClientDateFormatterProps {
|
|
11
|
+
/**
|
|
12
|
+
* The date value to format. Can be a Date object, ISO string, or timestamp number.
|
|
13
|
+
*/
|
|
14
|
+
date: Date | string | number | null | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Determines the format type: 'standard' for regular date/time, 'relative' for distance from now.
|
|
17
|
+
* Defaults to 'standard'.
|
|
18
|
+
*/
|
|
19
|
+
formatType?: "standard" | "relative";
|
|
20
|
+
/**
|
|
21
|
+
* Optional Intl.DateTimeFormat options (used only if formatType is 'standard').
|
|
22
|
+
* Defaults to { day: 'numeric', month: 'short', year: 'numeric' }.
|
|
23
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat#options
|
|
24
|
+
*/
|
|
25
|
+
options?: Intl.DateTimeFormatOptions;
|
|
26
|
+
/**
|
|
27
|
+
* The locale to use for formatting. Affects both 'standard' and 'relative' formats.
|
|
28
|
+
* Defaults to 'th-TH' (maps to 'th' for date-fns).
|
|
29
|
+
*/
|
|
30
|
+
locale?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The time zone to use (used only if formatType is 'standard').
|
|
33
|
+
* Defaults to 'Asia/Bangkok' (GMT+7).
|
|
34
|
+
*/
|
|
35
|
+
timeZone?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Placeholder text to display while the component is mounting or if the date is invalid.
|
|
38
|
+
* Defaults to "-".
|
|
39
|
+
*/
|
|
40
|
+
placeholder?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Optional CSS class name(s) to apply to the span element.
|
|
43
|
+
*/
|
|
44
|
+
className?: string;
|
|
45
|
+
/**
|
|
46
|
+
* If formatType is 'relative', automatically update the time every interval (in milliseconds).
|
|
47
|
+
* Set to 0 or undefined to disable automatic updates. Defaults to 60000 (1 minute).
|
|
48
|
+
*/
|
|
49
|
+
updateInterval?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Helper to map locale string to date-fns locale object
|
|
53
|
+
const getLocaleObject = (localeString: string): Locale => {
|
|
54
|
+
switch (localeString.toLowerCase().split("-")[0]) {
|
|
55
|
+
case "th":
|
|
56
|
+
return th;
|
|
57
|
+
default:
|
|
58
|
+
return enUS;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function ClientDateFormatter({
|
|
63
|
+
date,
|
|
64
|
+
formatType = "standard",
|
|
65
|
+
options: userOptions,
|
|
66
|
+
locale = "th-TH",
|
|
67
|
+
timeZone = "Asia/Bangkok",
|
|
68
|
+
placeholder = "-",
|
|
69
|
+
className,
|
|
70
|
+
updateInterval = 60000,
|
|
71
|
+
}: ClientDateFormatterProps) {
|
|
72
|
+
const [isMounted, setIsMounted] = useState(false);
|
|
73
|
+
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
74
|
+
|
|
75
|
+
// [EDIT] 👇 Wrap formatDate logic in useCallback
|
|
76
|
+
const formatDateCallback = useCallback(() => {
|
|
77
|
+
if (date == null) {
|
|
78
|
+
return placeholder;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const dateObj = new Date(date);
|
|
83
|
+
if (Number.isNaN(dateObj.getTime())) {
|
|
84
|
+
console.warn("ClientDateFormatter received an invalid date:", date);
|
|
85
|
+
return placeholder;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const localeObj = getLocaleObject(locale);
|
|
89
|
+
|
|
90
|
+
if (formatType === "relative") {
|
|
91
|
+
return formatDistanceToNow(dateObj, {
|
|
92
|
+
addSuffix: true,
|
|
93
|
+
locale: localeObj,
|
|
94
|
+
});
|
|
95
|
+
} else {
|
|
96
|
+
const defaultOptions: Intl.DateTimeFormatOptions = {
|
|
97
|
+
day: "numeric",
|
|
98
|
+
month: "short",
|
|
99
|
+
year: "numeric",
|
|
100
|
+
timeZone: timeZone,
|
|
101
|
+
};
|
|
102
|
+
const finalOptions = { ...defaultOptions, ...userOptions };
|
|
103
|
+
const formatter = new Intl.DateTimeFormat(locale, finalOptions);
|
|
104
|
+
return formatter.format(dateObj);
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error("Error formatting date in ClientDateFormatter:", error);
|
|
108
|
+
return placeholder;
|
|
109
|
+
}
|
|
110
|
+
// Add dependencies that formatDate relies on
|
|
111
|
+
}, [date, formatType, locale, placeholder, timeZone, userOptions]);
|
|
112
|
+
|
|
113
|
+
const [, setTick] = useState(0);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
queueMicrotask(() => {
|
|
117
|
+
setIsMounted(true);
|
|
118
|
+
});
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!isMounted) return;
|
|
123
|
+
|
|
124
|
+
if (formatType === "relative" && updateInterval && updateInterval > 0) {
|
|
125
|
+
intervalRef.current = setInterval(() => {
|
|
126
|
+
setTick((t) => t + 1);
|
|
127
|
+
}, updateInterval);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return () => {
|
|
131
|
+
if (intervalRef.current) {
|
|
132
|
+
clearInterval(intervalRef.current);
|
|
133
|
+
intervalRef.current = null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}, [isMounted, updateInterval, formatType]);
|
|
137
|
+
|
|
138
|
+
if (!isMounted) {
|
|
139
|
+
return <span className={className}>{placeholder}</span>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Use tick as a dependency so it re-renders (state change triggers it)
|
|
143
|
+
return <span className={className}>{formatDateCallback()}</span>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
|
|
148
|
+
ตัวอย่าง
|
|
149
|
+
|
|
150
|
+
import { ClientDateFormatter } from '@/components/shared/client-date-formatter';
|
|
151
|
+
|
|
152
|
+
// ...
|
|
153
|
+
|
|
154
|
+
// แสดงวันที่แบบไทย (Default)
|
|
155
|
+
<ClientDateFormatter date={someDate} />
|
|
156
|
+
|
|
157
|
+
// แสดงแบบ Relative Time (เช่น "ประมาณ 2 ชั่วโมงที่แล้ว") อัปเดตทุกนาที (default)
|
|
158
|
+
<ClientDateFormatter date={auditLog.createdAt} formatType="relative" />
|
|
159
|
+
|
|
160
|
+
// แสดงแบบ Relative Time ภาษาอังกฤษ อัปเดตทุก 30 วินาที
|
|
161
|
+
<ClientDateFormatter
|
|
162
|
+
date={auditLog.createdAt}
|
|
163
|
+
formatType="relative"
|
|
164
|
+
locale="en-US"
|
|
165
|
+
updateInterval={30000}
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
// แสดงแบบ Relative Time แต่ไม่อัปเดตอัตโนมัติ
|
|
169
|
+
<ClientDateFormatter
|
|
170
|
+
date={auditLog.createdAt}
|
|
171
|
+
formatType="relative"
|
|
172
|
+
updateInterval={0} // Disable interval
|
|
173
|
+
/>
|
|
174
|
+
|
|
175
|
+
// แสดงวันที่เวลาแบบ Standard ภาษาอังกฤษ โซนเวลา New York
|
|
176
|
+
<ClientDateFormatter
|
|
177
|
+
date={someDate}
|
|
178
|
+
formatType="standard" // Explicitly standard
|
|
179
|
+
locale="en-US"
|
|
180
|
+
timeZone="America/New_York"
|
|
181
|
+
options={{ hour: '2-digit', minute: '2-digit' }}
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
*/
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
/** ข้อความคงที่ที่ component ของ @pi/ui ใช้เอง — แอปส่งชุดภาษาอื่นเข้ามาผ่าน UiTextProvider */
|
|
6
|
+
export interface UiText {
|
|
7
|
+
cancel: string;
|
|
8
|
+
done: string;
|
|
9
|
+
save: string;
|
|
10
|
+
actions: string;
|
|
11
|
+
copy: string;
|
|
12
|
+
copied: string;
|
|
13
|
+
showOnce: string;
|
|
14
|
+
success: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const UI_TEXT_TH: UiText = {
|
|
18
|
+
cancel: "ยกเลิก",
|
|
19
|
+
done: "เสร็จแล้ว",
|
|
20
|
+
save: "บันทึก",
|
|
21
|
+
actions: "การกระทำ",
|
|
22
|
+
copy: "คัดลอก",
|
|
23
|
+
copied: "คัดลอกแล้ว",
|
|
24
|
+
showOnce: "แสดงครั้งเดียว — คัดลอกเก็บไว้ทันที",
|
|
25
|
+
success: "สำเร็จ",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const UI_TEXT_EN: UiText = {
|
|
29
|
+
cancel: "Cancel",
|
|
30
|
+
done: "Done",
|
|
31
|
+
save: "Save",
|
|
32
|
+
actions: "Actions",
|
|
33
|
+
copy: "Copy",
|
|
34
|
+
copied: "Copied",
|
|
35
|
+
showOnce: "Shown once — copy it now",
|
|
36
|
+
success: "Done",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|
|
40
|
+
|
|
41
|
+
export function UiTextProvider({ text, children }: { text: UiText; children: ReactNode }) {
|
|
42
|
+
return <UiTextContext.Provider value={text}>{children}</UiTextContext.Provider>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useUiText(): UiText {
|
|
46
|
+
return useContext(UiTextContext);
|
|
47
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Combobox, ComboboxContent, ComboboxInput, ComboboxItem, ComboboxList } from "../combobox";
|
|
4
|
+
|
|
5
|
+
describe("ComboboxContent", () => {
|
|
6
|
+
it("renders selectable items inside a supplied modal container", () => {
|
|
7
|
+
const onValueChange = vi.fn();
|
|
8
|
+
|
|
9
|
+
render(<ModalCombobox onValueChange={onValueChange} />);
|
|
10
|
+
|
|
11
|
+
const option = screen.getByText("ครัว");
|
|
12
|
+
|
|
13
|
+
fireEvent.click(option);
|
|
14
|
+
|
|
15
|
+
expect(onValueChange).toHaveBeenCalledWith("ครัว", expect.anything());
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function ModalCombobox({
|
|
20
|
+
onValueChange,
|
|
21
|
+
}: {
|
|
22
|
+
onValueChange: (value: string | null, eventDetails: unknown) => void;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<div data-testid="modal-container">
|
|
26
|
+
<Combobox items={["ครัว"]} open onValueChange={onValueChange}>
|
|
27
|
+
<ComboboxInput />
|
|
28
|
+
<ComboboxContent>
|
|
29
|
+
<ComboboxList>
|
|
30
|
+
{(item) => (
|
|
31
|
+
<ComboboxItem key={item} value={item}>
|
|
32
|
+
{item}
|
|
33
|
+
</ComboboxItem>
|
|
34
|
+
)}
|
|
35
|
+
</ComboboxList>
|
|
36
|
+
</ComboboxContent>
|
|
37
|
+
</Combobox>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { DatePicker } from "../date-picker";
|
|
4
|
+
|
|
5
|
+
function renderInsideForm(onChange = vi.fn(), onSubmit = vi.fn()) {
|
|
6
|
+
const view = render(
|
|
7
|
+
<form
|
|
8
|
+
onSubmit={(event) => {
|
|
9
|
+
event.preventDefault();
|
|
10
|
+
onSubmit();
|
|
11
|
+
}}
|
|
12
|
+
>
|
|
13
|
+
<DatePicker
|
|
14
|
+
value={new Date(2025, 9, 10)}
|
|
15
|
+
onChange={onChange}
|
|
16
|
+
showPresets={false}
|
|
17
|
+
allowClear={false}
|
|
18
|
+
/>
|
|
19
|
+
<button type="submit">Save parent form</button>
|
|
20
|
+
</form>,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
fireEvent.click(screen.getByRole("button", { name: /10 ต.ค. 2568/ }));
|
|
24
|
+
const smartInput = screen.getByPlaceholderText("เช่น 1/7, 1/7/25, 1-7-2568...");
|
|
25
|
+
fireEvent.change(smartInput, { target: { value: "16/10/2025" } });
|
|
26
|
+
|
|
27
|
+
return { ...view, smartInput };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("DatePicker smart input", () => {
|
|
31
|
+
it("confirms a typed date without submitting its parent form when clicking ตกลง", () => {
|
|
32
|
+
const onChange = vi.fn();
|
|
33
|
+
const onSubmit = vi.fn();
|
|
34
|
+
const { container } = renderInsideForm(onChange, onSubmit);
|
|
35
|
+
|
|
36
|
+
fireEvent.click(screen.getByRole("button", { name: "ตกลง" }));
|
|
37
|
+
|
|
38
|
+
expect(container.querySelectorAll("form")).toHaveLength(1);
|
|
39
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
40
|
+
expect(onChange).toHaveBeenCalledWith(new Date(2025, 9, 16));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("confirms a typed date without submitting its parent form when pressing Enter", () => {
|
|
44
|
+
const onChange = vi.fn();
|
|
45
|
+
const onSubmit = vi.fn();
|
|
46
|
+
const { smartInput } = renderInsideForm(onChange, onSubmit);
|
|
47
|
+
|
|
48
|
+
fireEvent.keyDown(smartInput, { key: "Enter", code: "Enter" });
|
|
49
|
+
|
|
50
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
51
|
+
expect(onChange).toHaveBeenCalledWith(new Date(2025, 9, 16));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Slider } from "../slider";
|
|
4
|
+
|
|
5
|
+
describe("Slider", () => {
|
|
6
|
+
it("renders the standard accessible slider contract", async () => {
|
|
7
|
+
const { container } = render(<Slider aria-label="ระดับ" defaultValue={[25]} />);
|
|
8
|
+
|
|
9
|
+
expect(screen.getByRole("group", { name: "ระดับ" })).toBeInTheDocument();
|
|
10
|
+
expect(container.querySelector('[data-slot="slider"]')).toHaveAttribute(
|
|
11
|
+
"data-orientation",
|
|
12
|
+
"horizontal",
|
|
13
|
+
);
|
|
14
|
+
await waitFor(() => {
|
|
15
|
+
expect(container.querySelector('input[type="range"]')).toHaveAttribute("aria-valuenow", "25");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../tabs";
|
|
4
|
+
|
|
5
|
+
describe("Tabs", () => {
|
|
6
|
+
it("uses the orientation data attributes supplied by Base UI", () => {
|
|
7
|
+
const { container } = render(<ExampleTabs />);
|
|
8
|
+
|
|
9
|
+
expect(container.firstElementChild).toHaveAttribute("data-horizontal");
|
|
10
|
+
expect(container.firstElementChild).toHaveAttribute("data-orientation", "horizontal");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("changes the active panel when a trigger is selected", () => {
|
|
14
|
+
render(<ExampleTabs />);
|
|
15
|
+
|
|
16
|
+
fireEvent.click(screen.getByRole("tab", { name: "รายละเอียด" }));
|
|
17
|
+
|
|
18
|
+
expect(screen.getByRole("tabpanel")).toHaveTextContent("เนื้อหารายละเอียด");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function ExampleTabs() {
|
|
23
|
+
return (
|
|
24
|
+
<Tabs defaultValue="overview">
|
|
25
|
+
<TabsList>
|
|
26
|
+
<TabsTrigger value="overview">ภาพรวม</TabsTrigger>
|
|
27
|
+
<TabsTrigger value="details">รายละเอียด</TabsTrigger>
|
|
28
|
+
</TabsList>
|
|
29
|
+
<TabsContent value="overview">เนื้อหาภาพรวม</TabsContent>
|
|
30
|
+
<TabsContent value="details">เนื้อหารายละเอียด</TabsContent>
|
|
31
|
+
</Tabs>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
|
|
2
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
3
|
+
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
|
6
|
+
return (
|
|
7
|
+
<AccordionPrimitive.Root
|
|
8
|
+
data-slot="accordion"
|
|
9
|
+
className={cn("flex w-full flex-col", className)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
|
16
|
+
return (
|
|
17
|
+
<AccordionPrimitive.Item
|
|
18
|
+
data-slot="accordion-item"
|
|
19
|
+
className={cn("not-last:border-b", className)}
|
|
20
|
+
{...props}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
|
|
26
|
+
return (
|
|
27
|
+
<AccordionPrimitive.Header className="flex">
|
|
28
|
+
<AccordionPrimitive.Trigger
|
|
29
|
+
data-slot="accordion-trigger"
|
|
30
|
+
className={cn(
|
|
31
|
+
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left font-medium text-sm outline-none transition-all hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
|
32
|
+
className,
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
<ChevronDownIcon
|
|
38
|
+
data-slot="accordion-trigger-icon"
|
|
39
|
+
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
|
|
40
|
+
/>
|
|
41
|
+
<ChevronUpIcon
|
|
42
|
+
data-slot="accordion-trigger-icon"
|
|
43
|
+
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
|
|
44
|
+
/>
|
|
45
|
+
</AccordionPrimitive.Trigger>
|
|
46
|
+
</AccordionPrimitive.Header>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function AccordionContent({ className, children, ...props }: AccordionPrimitive.Panel.Props) {
|
|
51
|
+
return (
|
|
52
|
+
<AccordionPrimitive.Panel
|
|
53
|
+
data-slot="accordion-content"
|
|
54
|
+
className="overflow-hidden text-sm data-closed:animate-accordion-up data-open:animate-accordion-down"
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
<div
|
|
58
|
+
className={cn(
|
|
59
|
+
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
|
60
|
+
className,
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</div>
|
|
65
|
+
</AccordionPrimitive.Panel>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
|
|
4
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import type * as React from "react";
|
|
7
|
+
|
|
8
|
+
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
|
9
|
+
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
|
13
|
+
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
|
17
|
+
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
|
|
21
|
+
return (
|
|
22
|
+
<AlertDialogPrimitive.Backdrop
|
|
23
|
+
data-slot="alert-dialog-overlay"
|
|
24
|
+
className={cn(
|
|
25
|
+
"data-open:fade-in-0 data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 data-closed:animate-out data-open:animate-in supports-backdrop-filter:backdrop-blur-xs",
|
|
26
|
+
className,
|
|
27
|
+
)}
|
|
28
|
+
{...props}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function AlertDialogContent({
|
|
34
|
+
className,
|
|
35
|
+
size = "default",
|
|
36
|
+
...props
|
|
37
|
+
}: AlertDialogPrimitive.Popup.Props & {
|
|
38
|
+
size?: "default" | "sm";
|
|
39
|
+
}) {
|
|
40
|
+
return (
|
|
41
|
+
<AlertDialogPortal>
|
|
42
|
+
<AlertDialogOverlay />
|
|
43
|
+
<AlertDialogPrimitive.Popup
|
|
44
|
+
data-slot="alert-dialog-content"
|
|
45
|
+
data-size={size}
|
|
46
|
+
className={cn(
|
|
47
|
+
"group/alert-dialog-content data-open:fade-in-0 data-open:zoom-in-95 data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground outline-none ring-1 ring-foreground/10 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-closed:animate-out data-open:animate-in data-[size=default]:sm:max-w-sm",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
/>
|
|
52
|
+
</AlertDialogPortal>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
data-slot="alert-dialog-header"
|
|
60
|
+
className={cn(
|
|
61
|
+
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
|
62
|
+
className,
|
|
63
|
+
)}
|
|
64
|
+
{...props}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
data-slot="alert-dialog-footer"
|
|
73
|
+
className={cn(
|
|
74
|
+
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
|
75
|
+
className,
|
|
76
|
+
)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-slot="alert-dialog-media"
|
|
86
|
+
className={cn(
|
|
87
|
+
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
|
88
|
+
className,
|
|
89
|
+
)}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function AlertDialogTitle({
|
|
96
|
+
className,
|
|
97
|
+
...props
|
|
98
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
|
99
|
+
return (
|
|
100
|
+
<AlertDialogPrimitive.Title
|
|
101
|
+
data-slot="alert-dialog-title"
|
|
102
|
+
className={cn(
|
|
103
|
+
"font-medium text-base sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
|
104
|
+
className,
|
|
105
|
+
)}
|
|
106
|
+
{...props}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function AlertDialogDescription({
|
|
112
|
+
className,
|
|
113
|
+
...props
|
|
114
|
+
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
|
115
|
+
return (
|
|
116
|
+
<AlertDialogPrimitive.Description
|
|
117
|
+
data-slot="alert-dialog-description"
|
|
118
|
+
className={cn(
|
|
119
|
+
"text-balance text-muted-foreground text-sm md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
{...props}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
|
|
128
|
+
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function AlertDialogCancel({
|
|
132
|
+
className,
|
|
133
|
+
variant = "outline",
|
|
134
|
+
size = "default",
|
|
135
|
+
...props
|
|
136
|
+
}: AlertDialogPrimitive.Close.Props &
|
|
137
|
+
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
|
138
|
+
return (
|
|
139
|
+
<AlertDialogPrimitive.Close
|
|
140
|
+
data-slot="alert-dialog-cancel"
|
|
141
|
+
className={cn(className)}
|
|
142
|
+
render={<Button variant={variant} size={size} />}
|
|
143
|
+
{...props}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export {
|
|
149
|
+
AlertDialog,
|
|
150
|
+
AlertDialogAction,
|
|
151
|
+
AlertDialogCancel,
|
|
152
|
+
AlertDialogContent,
|
|
153
|
+
AlertDialogDescription,
|
|
154
|
+
AlertDialogFooter,
|
|
155
|
+
AlertDialogHeader,
|
|
156
|
+
AlertDialogMedia,
|
|
157
|
+
AlertDialogOverlay,
|
|
158
|
+
AlertDialogPortal,
|
|
159
|
+
AlertDialogTitle,
|
|
160
|
+
AlertDialogTrigger,
|
|
161
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
|
|
5
|
+
const alertVariants = cva(
|
|
6
|
+
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 has-data-[slot=alert-action]:pr-18 *:[svg:not([class*='size-'])]:size-4 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-card text-card-foreground",
|
|
11
|
+
destructive:
|
|
12
|
+
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: {
|
|
16
|
+
variant: "default",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
function Alert({
|
|
22
|
+
className,
|
|
23
|
+
variant,
|
|
24
|
+
...props
|
|
25
|
+
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
data-slot="alert"
|
|
29
|
+
role="alert"
|
|
30
|
+
className={cn(alertVariants({ variant }), className)}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
data-slot="alert-title"
|
|
40
|
+
className={cn(
|
|
41
|
+
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
|
42
|
+
className,
|
|
43
|
+
)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="alert-description"
|
|
53
|
+
className={cn(
|
|
54
|
+
"text-balance text-muted-foreground text-sm md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
63
|
+
return (
|
|
64
|
+
<div data-slot="alert-action" className={cn("absolute top-2 right-2", className)} {...props} />
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { Alert, AlertAction, AlertDescription, AlertTitle };
|