@suphark/ui 0.5.1 → 0.6.1
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 +60 -0
- package/README.md +20 -0
- package/package.json +5 -1
- package/scripts/audit.mjs +218 -0
- package/src/components/app-shell/app-brand.tsx +1 -0
- package/src/components/app-shell/app-nav.tsx +26 -12
- package/src/components/data-table/data-table-faceted-filter.tsx +14 -15
- package/src/components/data-table/data-table-row-actions.tsx +2 -1
- package/src/components/data-table/use-data-table.ts +7 -11
- package/src/components/design-system/sections/showcase-data-display.tsx +2 -2
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
- package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
- package/src/components/pwa/pull-to-refresh.tsx +11 -5
- package/src/components/shared/action-form.tsx +78 -0
- package/src/components/shared/button/floating-save-button.tsx +1 -1
- package/src/components/shared/form/form-field-color-select.tsx +1 -1
- package/src/components/shared/form/form-field-combobox.tsx +15 -6
- package/src/components/shared/form/form-field-date.tsx +1 -1
- package/src/components/shared/form/form-field-input.tsx +6 -6
- package/src/components/shared/form/form-field-radio-cards.tsx +65 -24
- package/src/components/shared/form/labeled-field.tsx +61 -0
- package/src/components/shared/form-dialog.tsx +1 -3
- package/src/components/shared/form-message.tsx +4 -5
- package/src/components/shared/navigation/stepper.tsx +96 -0
- package/src/components/shared/notice.tsx +1 -0
- package/src/components/shared/number/formatted-number.tsx +39 -102
- package/src/components/shared/status-badge.tsx +1 -0
- package/src/components/shared/theme/theme-toggle.tsx +11 -8
- package/src/components/shared/time/client-date-formatter.tsx +47 -154
- package/src/components/ui/relative-date.tsx +1 -10
- package/src/hooks/use-mounted.ts +16 -0
- package/src/hooks/use-ticker.ts +53 -0
- package/src/index.ts +6 -1
- package/src/lib/intl-cache.ts +31 -0
- package/src/lib/status-colors.ts +1 -0
- package/src/next.ts +1 -0
|
@@ -1,60 +1,45 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
4
|
+
import { getNumberFormat } from "@suphark/ui/lib/intl-cache";
|
|
5
|
+
import { useMemo } from "react";
|
|
4
6
|
|
|
5
7
|
/** ค่าทศนิยมแบบ object (เช่น Prisma.Decimal, decimal.js) — ขอแค่ toString() ให้ตัวเลข */
|
|
6
8
|
export interface DecimalLike {
|
|
7
9
|
toString(): string;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
* Props for the FormattedNumber component.
|
|
12
|
-
*/
|
|
13
12
|
interface FormattedNumberProps {
|
|
14
|
-
/**
|
|
15
|
-
* The value to format. Can be a Decimal-like object (Prisma.Decimal / decimal.js), number, string, null, or undefined.
|
|
16
|
-
*/
|
|
13
|
+
/** Decimal-like (Prisma.Decimal / decimal.js), number, string, null หรือ undefined */
|
|
17
14
|
value: DecimalLike | number | string | null | undefined;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Optional Intl.NumberFormat options to customize the output.
|
|
21
|
-
* Defaults to { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 }.
|
|
22
|
-
*/
|
|
15
|
+
/** Intl.NumberFormatOptions — ค่าเริ่มต้น { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 } */
|
|
23
16
|
options?: Intl.NumberFormatOptions;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The locale to use for formatting. Defaults to 'th-TH'.
|
|
27
|
-
*/
|
|
17
|
+
/** ค่าเริ่มต้น th-TH */
|
|
28
18
|
locale?: string;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Placeholder text to display while mounting or if the value is invalid/null/undefined.
|
|
32
|
-
* Defaults to "-".
|
|
33
|
-
*/
|
|
19
|
+
/** ข้อความก่อน mount / ค่าไม่ถูกต้อง ค่าเริ่มต้น "-" */
|
|
34
20
|
placeholder?: string;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Optional CSS class name(s) to apply to the span element.
|
|
38
|
-
*/
|
|
39
21
|
className?: string;
|
|
40
22
|
}
|
|
41
23
|
|
|
24
|
+
const DEFAULT_OPTIONS: Intl.NumberFormatOptions = {
|
|
25
|
+
style: "decimal",
|
|
26
|
+
minimumFractionDigits: 2,
|
|
27
|
+
maximumFractionDigits: 2,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function toNumber(value: FormattedNumberProps["value"]): number {
|
|
31
|
+
if (value === null || value === undefined || value === "") return Number.NaN;
|
|
32
|
+
if (typeof value === "object") return Number(value.toString());
|
|
33
|
+
return Number(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
42
36
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* // No decimals
|
|
50
|
-
* <FormattedNumber value={12345} options={{ minimumFractionDigits: 0, maximumFractionDigits: 0 }} />
|
|
51
|
-
* * @example
|
|
52
|
-
* // Currency format
|
|
53
|
-
* <FormattedNumber
|
|
54
|
-
* value={12345.67}
|
|
55
|
-
* options={{ style: 'currency', currency: 'THB' }}
|
|
56
|
-
* locale="th-TH"
|
|
57
|
-
* />
|
|
37
|
+
* แสดงตัวเลขตาม locale ฝั่ง client หลัง mount (กัน hydration mismatch จาก locale ของ server)
|
|
38
|
+
* ตัว Intl.NumberFormat ถูก cache ต่อ locale+options — ตารางหลายร้อยเซลล์ไม่สร้างซ้ำ
|
|
39
|
+
*
|
|
40
|
+
* <FormattedNumber value={prismaDecimal} />
|
|
41
|
+
* <FormattedNumber value={12345} options={{ maximumFractionDigits: 0 }} />
|
|
42
|
+
* <FormattedNumber value={12345.67} options={{ style: "currency", currency: "THB" }} />
|
|
58
43
|
*/
|
|
59
44
|
export function FormattedNumber({
|
|
60
45
|
value,
|
|
@@ -63,71 +48,23 @@ export function FormattedNumber({
|
|
|
63
48
|
placeholder = "-",
|
|
64
49
|
className,
|
|
65
50
|
}: FormattedNumberProps) {
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
useEffect(() => {
|
|
70
|
-
// Set mounted to true only on the client-side
|
|
71
|
-
setIsMounted(true);
|
|
72
|
-
}, []);
|
|
73
|
-
|
|
74
|
-
// Memoize default options
|
|
75
|
-
const defaultOptions: Intl.NumberFormatOptions = useMemo(
|
|
76
|
-
() => ({
|
|
77
|
-
style: "decimal",
|
|
78
|
-
minimumFractionDigits: 2,
|
|
79
|
-
maximumFractionDigits: 2,
|
|
80
|
-
}),
|
|
81
|
-
[],
|
|
82
|
-
);
|
|
51
|
+
const mounted = useMounted();
|
|
52
|
+
const optionsKey = userOptions ? JSON.stringify(userOptions) : "";
|
|
53
|
+
const numeric = toNumber(value);
|
|
83
54
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!isMounted) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let numericValue: number;
|
|
91
|
-
|
|
92
|
-
// 1. Check for null/undefined/empty string explicitly to show placeholder
|
|
93
|
-
if (value === null || value === undefined || value === "") {
|
|
94
|
-
setFormattedValue(placeholder);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 2. Convert value to number
|
|
55
|
+
const text = useMemo(() => {
|
|
56
|
+
if (!mounted || Number.isNaN(numeric)) return placeholder;
|
|
99
57
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
numericValue = Number(value);
|
|
106
|
-
}
|
|
107
|
-
} catch (_e) {
|
|
108
|
-
// Handle potential errors during conversion
|
|
109
|
-
numericValue = NaN;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// 3. Check if conversion resulted in a valid number
|
|
113
|
-
if (Number.isNaN(numericValue)) {
|
|
114
|
-
setFormattedValue(placeholder);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 4. Format the valid number
|
|
119
|
-
try {
|
|
120
|
-
// Merge user options with defaults (user options take precedence)
|
|
121
|
-
const finalOptions = { ...defaultOptions, ...userOptions };
|
|
122
|
-
|
|
123
|
-
const formatter = new Intl.NumberFormat(locale, finalOptions);
|
|
124
|
-
setFormattedValue(formatter.format(numericValue));
|
|
58
|
+
const opts = {
|
|
59
|
+
...DEFAULT_OPTIONS,
|
|
60
|
+
...(optionsKey ? (JSON.parse(optionsKey) as Intl.NumberFormatOptions) : {}),
|
|
61
|
+
};
|
|
62
|
+
return getNumberFormat(locale, opts).format(numeric);
|
|
125
63
|
} catch (error) {
|
|
126
|
-
console.error("
|
|
127
|
-
|
|
64
|
+
console.error("FormattedNumber:", error);
|
|
65
|
+
return placeholder;
|
|
128
66
|
}
|
|
129
|
-
}, [
|
|
67
|
+
}, [mounted, numeric, optionsKey, locale, placeholder]);
|
|
130
68
|
|
|
131
|
-
|
|
132
|
-
return <span className={className}>{formattedValue}</span>;
|
|
69
|
+
return <span className={className}>{text}</span>;
|
|
133
70
|
}
|
|
@@ -19,13 +19,16 @@ interface ThemeToggleProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps) {
|
|
22
|
-
const { setTheme
|
|
22
|
+
const { setTheme } = useTheme();
|
|
23
23
|
|
|
24
|
+
// ใช้ค่าที่บันทึกไว้ (server) เฉพาะเมื่อค่านั้นเปลี่ยน (รวมครั้งแรกตอน mount) — ห้ามผูก `theme` เป็น dep
|
|
25
|
+
// ไม่งั้นผู้ใช้กดเปลี่ยนแล้ว effect เห็น theme ใหม่ ≠ persistedTheme เก่า (server action ยังไม่กลับ) แล้ว set กลับทันที
|
|
26
|
+
const lastPersisted = React.useRef<string | null | undefined>(undefined);
|
|
27
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: ตั้งใจ sync เฉพาะเมื่อ persistedTheme เปลี่ยน
|
|
24
28
|
React.useEffect(() => {
|
|
25
|
-
if (persistedTheme && persistedTheme !==
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}, [persistedTheme, theme, setTheme]);
|
|
29
|
+
if (persistedTheme && persistedTheme !== lastPersisted.current) setTheme(persistedTheme);
|
|
30
|
+
lastPersisted.current = persistedTheme;
|
|
31
|
+
}, [persistedTheme]);
|
|
29
32
|
|
|
30
33
|
const handleThemeChange = async (newTheme: ThemeValue) => {
|
|
31
34
|
setTheme(newTheme);
|
|
@@ -53,21 +56,21 @@ export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps)
|
|
|
53
56
|
onClick={() => handleThemeChange("light")}
|
|
54
57
|
className="flex cursor-pointer items-center gap-2"
|
|
55
58
|
>
|
|
56
|
-
<Sun className="h-4 w-4 text-
|
|
59
|
+
<Sun className="h-4 w-4 text-warning" />
|
|
57
60
|
<span>Light</span>
|
|
58
61
|
</DropdownMenuItem>
|
|
59
62
|
<DropdownMenuItem
|
|
60
63
|
onClick={() => handleThemeChange("dark")}
|
|
61
64
|
className="flex cursor-pointer items-center gap-2"
|
|
62
65
|
>
|
|
63
|
-
<Moon className="h-4 w-4 text-
|
|
66
|
+
<Moon className="h-4 w-4 text-primary" />
|
|
64
67
|
<span>Dark</span>
|
|
65
68
|
</DropdownMenuItem>
|
|
66
69
|
<DropdownMenuItem
|
|
67
70
|
onClick={() => handleThemeChange("system")}
|
|
68
71
|
className="flex cursor-pointer items-center gap-2"
|
|
69
72
|
>
|
|
70
|
-
<Laptop className="h-4 w-4 text-
|
|
73
|
+
<Laptop className="h-4 w-4 text-muted-foreground" />
|
|
71
74
|
<span>System</span>
|
|
72
75
|
</DropdownMenuItem>
|
|
73
76
|
</DropdownMenuContent>
|
|
@@ -1,64 +1,43 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
4
|
+
import { useTicker } from "@suphark/ui/hooks/use-ticker";
|
|
5
|
+
import { getDateTimeFormat } from "@suphark/ui/lib/intl-cache";
|
|
6
|
+
import { formatDistance, type Locale } from "date-fns";
|
|
7
|
+
import { enUS, th } from "date-fns/locale";
|
|
8
|
+
import { useMemo } from "react";
|
|
6
9
|
|
|
7
|
-
/**
|
|
8
|
-
* Props for the ClientDateFormatter component.
|
|
9
|
-
*/
|
|
10
10
|
interface ClientDateFormatterProps {
|
|
11
|
-
/**
|
|
12
|
-
* The date value to format. Can be a Date object, ISO string, or timestamp number.
|
|
13
|
-
*/
|
|
11
|
+
/** Date, ISO string หรือ timestamp */
|
|
14
12
|
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
|
-
*/
|
|
13
|
+
/** `standard` = วันที่/เวลาตาม Intl (ค่าเริ่มต้น), `relative` = "x นาทีที่แล้ว" */
|
|
19
14
|
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
|
-
*/
|
|
15
|
+
/** ตัวเลือก Intl.DateTimeFormat (เฉพาะ standard) — ค่าเริ่มต้น { day: 'numeric', month: 'short', year: 'numeric' } */
|
|
25
16
|
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
|
-
*/
|
|
17
|
+
/** ค่าเริ่มต้น th-TH (relative ใช้ date-fns locale th/enUS ตามตัวหน้า) */
|
|
30
18
|
locale?: string;
|
|
31
|
-
/**
|
|
32
|
-
* The time zone to use (used only if formatType is 'standard').
|
|
33
|
-
* Defaults to 'Asia/Bangkok' (GMT+7).
|
|
34
|
-
*/
|
|
19
|
+
/** โซนเวลา (เฉพาะ standard) ค่าเริ่มต้น Asia/Bangkok */
|
|
35
20
|
timeZone?: string;
|
|
36
|
-
/**
|
|
37
|
-
* Placeholder text to display while the component is mounting or if the date is invalid.
|
|
38
|
-
* Defaults to "-".
|
|
39
|
-
*/
|
|
21
|
+
/** ข้อความก่อน mount / วันที่ไม่ถูกต้อง ค่าเริ่มต้น "-" */
|
|
40
22
|
placeholder?: string;
|
|
41
|
-
/**
|
|
42
|
-
* Optional CSS class name(s) to apply to the span element.
|
|
43
|
-
*/
|
|
44
23
|
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
|
-
*/
|
|
24
|
+
/** relative: คำนวณใหม่ทุก N ms (ค่าเริ่มต้น 60000) — 0/undefined = ไม่อัปเดตเอง; ทุก instance แชร์ timer เดียว */
|
|
49
25
|
updateInterval?: number;
|
|
50
26
|
}
|
|
51
27
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
28
|
+
const getLocaleObject = (localeString: string): Locale =>
|
|
29
|
+
localeString.toLowerCase().split("-")[0] === "th" ? th : enUS;
|
|
30
|
+
|
|
31
|
+
const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = {
|
|
32
|
+
day: "numeric",
|
|
33
|
+
month: "short",
|
|
34
|
+
year: "numeric",
|
|
60
35
|
};
|
|
61
36
|
|
|
37
|
+
/**
|
|
38
|
+
* แสดงวันที่ฝั่ง client หลัง mount เท่านั้น (กัน hydration mismatch จากโซนเวลา/"ตอนนี้" ของ server)
|
|
39
|
+
* ก่อน mount และเมื่อวันที่ไม่ถูกต้องแสดง `placeholder`
|
|
40
|
+
*/
|
|
62
41
|
export function ClientDateFormatter({
|
|
63
42
|
date,
|
|
64
43
|
formatType = "standard",
|
|
@@ -69,120 +48,34 @@ export function ClientDateFormatter({
|
|
|
69
48
|
className,
|
|
70
49
|
updateInterval = 60000,
|
|
71
50
|
}: ClientDateFormatterProps) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
51
|
+
const mounted = useMounted();
|
|
52
|
+
// relative เท่านั้นที่ต้อง tick — standard ไม่ขึ้นกับ "ตอนนี้"
|
|
53
|
+
const tick = useTicker(mounted && formatType === "relative" ? updateInterval : 0);
|
|
54
|
+
// key ของ options สำหรับ cache/memo (object literal จากผู้เรียกเปลี่ยน identity ทุก render)
|
|
55
|
+
const optionsKey = userOptions ? JSON.stringify(userOptions) : "";
|
|
56
|
+
// "ตอนนี้" ผูกกับ tick — relative จึงคำนวณใหม่ตาม interval (ไม่ใช่ทุก render)
|
|
57
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: tick คือสัญญาณให้อ่านเวลาใหม่
|
|
58
|
+
const now = useMemo(() => new Date(), [tick]);
|
|
59
|
+
|
|
60
|
+
const text = useMemo(() => {
|
|
61
|
+
if (!mounted || date == null) return placeholder;
|
|
62
|
+
const dateObj = new Date(date);
|
|
63
|
+
if (Number.isNaN(dateObj.getTime())) return placeholder;
|
|
81
64
|
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
65
|
if (formatType === "relative") {
|
|
91
|
-
return
|
|
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);
|
|
66
|
+
return formatDistance(dateObj, now, { addSuffix: true, locale: getLocaleObject(locale) });
|
|
105
67
|
}
|
|
68
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
69
|
+
...DEFAULT_OPTIONS,
|
|
70
|
+
timeZone,
|
|
71
|
+
...(optionsKey ? (JSON.parse(optionsKey) as Intl.DateTimeFormatOptions) : {}),
|
|
72
|
+
};
|
|
73
|
+
return getDateTimeFormat(locale, opts).format(dateObj);
|
|
106
74
|
} catch (error) {
|
|
107
|
-
console.error("
|
|
75
|
+
console.error("ClientDateFormatter:", error);
|
|
108
76
|
return placeholder;
|
|
109
77
|
}
|
|
110
|
-
|
|
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
|
-
}
|
|
78
|
+
}, [mounted, date, formatType, locale, timeZone, optionsKey, placeholder, now]);
|
|
129
79
|
|
|
130
|
-
|
|
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>;
|
|
80
|
+
return <span className={className}>{text}</span>;
|
|
144
81
|
}
|
|
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
|
-
*/
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
3
4
|
import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
|
|
4
5
|
import { Calendar } from "lucide-react";
|
|
5
|
-
import { useSyncExternalStore } from "react";
|
|
6
6
|
|
|
7
7
|
interface RelativeDateProps {
|
|
8
8
|
date: Date | string | number | null | undefined;
|
|
@@ -15,15 +15,6 @@ interface RelativeDateProps {
|
|
|
15
15
|
emptyText?: string;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// true เฉพาะฝั่ง client หลัง hydrate — server/ตอน hydrate ได้ false เสมอ
|
|
19
|
-
const subscribeNoop = () => () => {};
|
|
20
|
-
const useMounted = () =>
|
|
21
|
-
useSyncExternalStore(
|
|
22
|
-
subscribeNoop,
|
|
23
|
-
() => true,
|
|
24
|
-
() => false,
|
|
25
|
-
);
|
|
26
|
-
|
|
27
18
|
/**
|
|
28
19
|
* วันที่แบบสัมพัทธ์ภาษาไทย ("วันนี้", "เมื่อวาน", "3 วันที่ผ่านมา") + วันที่เต็มใน tooltip
|
|
29
20
|
* คำนวณเฉพาะฝั่ง client หลัง mount: "ตอนนี้" ของ server (UTC, module โหลดไว้ก่อน) กับของ browser
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
const subscribeNoop = () => () => {};
|
|
6
|
+
const getClient = () => true;
|
|
7
|
+
const getServer = () => false;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* true เฉพาะฝั่ง client หลัง hydrate — บน server และระหว่าง hydrate ได้ false เสมอ
|
|
11
|
+
* ใช้กับค่าที่ server กับ browser คำนวณต่างกันได้ (เวลา "ตอนนี้", โซนเวลา, locale ของเครื่อง, localStorage)
|
|
12
|
+
* ผ่าน `useSyncExternalStore` จึงไม่มี render รอบเพิ่มแบบ `useEffect(() => setMounted(true))`
|
|
13
|
+
*/
|
|
14
|
+
export function useMounted(): boolean {
|
|
15
|
+
return useSyncExternalStore(subscribeNoop, getClient, getServer);
|
|
16
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* นาฬิกากลางต่อ interval — ทุก component ที่ขอ interval เท่ากันแชร์ `setInterval` ตัวเดียว
|
|
7
|
+
* (ตาราง 50 แถวที่แสดง "x นาทีที่แล้ว" เดิมมี 50 timers) — เริ่มเมื่อมีผู้ใช้ตัวแรก หยุดเมื่อตัวสุดท้ายถอด
|
|
8
|
+
*/
|
|
9
|
+
type Ticker = { listeners: Set<() => void>; tick: number; handle: number | null };
|
|
10
|
+
const tickers = new Map<number, Ticker>();
|
|
11
|
+
|
|
12
|
+
function getTicker(intervalMs: number): Ticker {
|
|
13
|
+
let t = tickers.get(intervalMs);
|
|
14
|
+
if (!t) {
|
|
15
|
+
t = { listeners: new Set(), tick: 0, handle: null };
|
|
16
|
+
tickers.set(intervalMs, t);
|
|
17
|
+
}
|
|
18
|
+
return t;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function subscribe(intervalMs: number, listener: () => void): () => void {
|
|
22
|
+
const t = getTicker(intervalMs);
|
|
23
|
+
t.listeners.add(listener);
|
|
24
|
+
if (t.handle === null) {
|
|
25
|
+
t.handle = window.setInterval(() => {
|
|
26
|
+
t.tick += 1;
|
|
27
|
+
for (const l of t.listeners) l();
|
|
28
|
+
}, intervalMs);
|
|
29
|
+
}
|
|
30
|
+
return () => {
|
|
31
|
+
t.listeners.delete(listener);
|
|
32
|
+
if (t.listeners.size === 0 && t.handle !== null) {
|
|
33
|
+
window.clearInterval(t.handle);
|
|
34
|
+
t.handle = null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const subscribeNoop = () => () => {};
|
|
40
|
+
const zero = () => 0;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* คืนตัวเลขที่เพิ่มทุก `intervalMs` (ใช้เป็น dep ให้คำนวณใหม่) — `0`/undefined = ไม่ tick
|
|
44
|
+
* บน server ได้ 0 เสมอ
|
|
45
|
+
*/
|
|
46
|
+
export function useTicker(intervalMs: number | undefined): number {
|
|
47
|
+
const active = typeof intervalMs === "number" && intervalMs > 0;
|
|
48
|
+
return useSyncExternalStore(
|
|
49
|
+
active ? (l) => subscribe(intervalMs, l) : subscribeNoop,
|
|
50
|
+
active ? () => getTicker(intervalMs).tick : zero,
|
|
51
|
+
zero,
|
|
52
|
+
);
|
|
53
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
export type { DateRange } from "react-day-picker";
|
|
14
14
|
// sonner: `toast()` คู่กับ <Toaster /> — แอปไม่ต้องติดตั้ง sonner เอง
|
|
15
15
|
export { toast } from "sonner";
|
|
16
|
+
export { ActionForm } from "./components/shared/action-form";
|
|
16
17
|
export * from "./components/shared/button";
|
|
17
18
|
export { ClientRedirect } from "./components/shared/client-redirect";
|
|
18
19
|
export { Combobox } from "./components/shared/combobox";
|
|
@@ -51,6 +52,7 @@ export {
|
|
|
51
52
|
export { FormFieldRadioCards } from "./components/shared/form/form-field-radio-cards";
|
|
52
53
|
export { FormFieldRadioGroup } from "./components/shared/form/form-field-radio-group";
|
|
53
54
|
export { FormFieldSelect, type SelectOption } from "./components/shared/form/form-field-select";
|
|
55
|
+
export { CheckboxField, LabeledField } from "./components/shared/form/labeled-field";
|
|
54
56
|
export {
|
|
55
57
|
SimpleSelect,
|
|
56
58
|
type SimpleSelectGroup,
|
|
@@ -58,7 +60,7 @@ export {
|
|
|
58
60
|
type SimpleSelectOptions,
|
|
59
61
|
type SimpleSelectProps,
|
|
60
62
|
} from "./components/shared/form/simple-select";
|
|
61
|
-
export { FormMessage, type FormState } from "./components/shared/form-message";
|
|
63
|
+
export { type FormAction, FormMessage, type FormState } from "./components/shared/form-message";
|
|
62
64
|
export { Initials, initialsOf } from "./components/shared/initials";
|
|
63
65
|
export { ErrorState, type RouteError } from "./components/shared/layout/error-state";
|
|
64
66
|
export {
|
|
@@ -156,9 +158,12 @@ export * from "./components/ui/tooltip";
|
|
|
156
158
|
export { useConfirm } from "./hooks/use-confirm";
|
|
157
159
|
export { useCopyToClipboard } from "./hooks/use-copy-to-clipboard";
|
|
158
160
|
export { useIsMobile } from "./hooks/use-mobile";
|
|
161
|
+
export { useMounted } from "./hooks/use-mounted";
|
|
162
|
+
export { useTicker } from "./hooks/use-ticker";
|
|
159
163
|
export { useTrackedState } from "./hooks/use-tracked-state";
|
|
160
164
|
export { copyText, selectContents } from "./lib/copy-text";
|
|
161
165
|
export { formatRelativeThaiDate, formatThaiFullDate } from "./lib/formatters";
|
|
166
|
+
export { getDateTimeFormat, getNumberFormat } from "./lib/intl-cache";
|
|
162
167
|
export {
|
|
163
168
|
type AtomicPermissionRequirement,
|
|
164
169
|
type CheckPermissionOptions,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cache ตัว formatter ของ Intl — constructor ของ Intl.NumberFormat/DateTimeFormat แพง (โหลดข้อมูล locale)
|
|
3
|
+
* ตารางที่มีเซลล์ตัวเลข/วันที่หลายร้อยช่องจะสร้างซ้ำทุกเซลล์ทุก render ถ้าไม่ cache
|
|
4
|
+
* key = locale + options (JSON) — options ของเราเป็น object เล็ก ๆ ไม่มี key แปลก
|
|
5
|
+
*/
|
|
6
|
+
const numberFormats = new Map<string, Intl.NumberFormat>();
|
|
7
|
+
const dateTimeFormats = new Map<string, Intl.DateTimeFormat>();
|
|
8
|
+
|
|
9
|
+
function keyOf(locale: string, options: object | undefined): string {
|
|
10
|
+
return `${locale}|${options ? JSON.stringify(options) : ""}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getNumberFormat(locale: string, options?: Intl.NumberFormatOptions) {
|
|
14
|
+
const key = keyOf(locale, options);
|
|
15
|
+
let f = numberFormats.get(key);
|
|
16
|
+
if (!f) {
|
|
17
|
+
f = new Intl.NumberFormat(locale, options);
|
|
18
|
+
numberFormats.set(key, f);
|
|
19
|
+
}
|
|
20
|
+
return f;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getDateTimeFormat(locale: string, options?: Intl.DateTimeFormatOptions) {
|
|
24
|
+
const key = keyOf(locale, options);
|
|
25
|
+
let f = dateTimeFormats.get(key);
|
|
26
|
+
if (!f) {
|
|
27
|
+
f = new Intl.DateTimeFormat(locale, options);
|
|
28
|
+
dateTimeFormats.set(key, f);
|
|
29
|
+
}
|
|
30
|
+
return f;
|
|
31
|
+
}
|
package/src/lib/status-colors.ts
CHANGED
package/src/next.ts
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
PageBreadcrumb,
|
|
16
16
|
type PageBreadcrumbItem,
|
|
17
17
|
} from "./components/shared/navigation/page-breadcrumb";
|
|
18
|
+
export { Stepper, type StepperStep, type StepStatus } from "./components/shared/navigation/stepper";
|
|
18
19
|
export { type RowAction, type RowActionItem, RowActions } from "./components/shared/row-actions";
|
|
19
20
|
export { RowMenu, type RowMenuDialog, type RowMenuItem } from "./components/shared/row-menu";
|
|
20
21
|
export {
|