@suphark/ui 0.26.0 → 0.27.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 +55 -0
- package/README.md +31 -3
- package/package.json +5 -3
- package/src/components/app-shell/command-palette.tsx +14 -6
- package/src/components/data-table/data-table-pagination.tsx +7 -5
- package/src/components/data-table/data-table-sortable.tsx +5 -4
- package/src/components/data-table/data-table-toolbar.tsx +15 -4
- package/src/components/data-table/use-data-table.ts +38 -5
- package/src/components/design-system/design-system-showcase.tsx +1 -0
- package/src/components/pwa/pull-to-refresh.tsx +11 -4
- package/src/components/pwa/service-worker-updates.ts +26 -3
- package/src/components/shared/action-menu.tsx +26 -11
- package/src/components/shared/auth-parts.tsx +5 -3
- package/src/components/shared/button/button.tsx +110 -0
- package/src/components/shared/button/confirm-action-button.tsx +9 -13
- package/src/components/shared/button/index.ts +6 -0
- package/src/components/shared/chat-thread.tsx +13 -6
- package/src/components/shared/client-redirect.tsx +5 -9
- package/src/components/shared/dialogs-provider.tsx +31 -0
- package/src/components/shared/form/number-input.tsx +28 -13
- package/src/components/shared/form/tags-input.tsx +26 -21
- package/src/components/shared/form/time-picker.tsx +2 -2
- package/src/components/shared/month-stepper.tsx +4 -4
- package/src/components/shared/notice.tsx +1 -1
- package/src/components/shared/resource-property-filter.tsx +1 -1
- package/src/components/shared/row-actions.tsx +14 -8
- package/src/components/shared/rule-builder.tsx +11 -2
- package/src/components/shared/secret-reveal.tsx +1 -1
- package/src/components/shared/settings-layout.tsx +8 -3
- package/src/components/shared/simple-dialog.tsx +1 -1
- package/src/components/shared/simple-pagination.tsx +1 -1
- package/src/components/shared/simple-tooltip.tsx +11 -6
- package/src/components/shared/step-form.tsx +33 -13
- package/src/components/shared/theme/theme-switch.tsx +1 -1
- package/src/components/shared/theme/theme-toggle.tsx +5 -3
- package/src/components/shared/ui-text.tsx +25 -4
- package/src/components/shared/upload-list.tsx +1 -1
- package/src/hooks/use-table-search-params.ts +9 -4
- package/src/index.ts +7 -1
- package/src/lib/action-result.ts +33 -0
- package/src/lib/safe-url.ts +15 -0
- package/src/styles.css +21 -18
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { cn } from "@suphark/ui/lib/utils";
|
|
5
5
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
6
6
|
import { useUiText } from "./ui-text";
|
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
|
|
12
12
|
import { cn } from "@suphark/ui/lib/utils";
|
|
13
13
|
import type { ReactElement, ReactNode } from "react";
|
|
14
|
+
import { ButtonTooltipSuppress } from "./button/button";
|
|
14
15
|
|
|
15
16
|
type Side = "top" | "bottom" | "left" | "right";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* tooltip สำเร็จรูป: ห่อ element เดียว + ข้อความ (แทน Tooltip/TooltipTrigger/TooltipContent 3 ชิ้น)
|
|
19
20
|
* `content` ว่าง = ไม่มี tooltip (ไม่ต้องเขียนเงื่อนไขเอง) · ปุ่มไอคอนอย่างเดียวยังต้องมี aria-label ของตัวเอง
|
|
21
|
+
* ปุ่มไอคอน (`Button` + `aria-label`) **ไม่ต้องห่อตัวนี้แล้ว** ตั้งแต่ 0.27.0 — ห่อได้ถ้าอยากใช้ข้อความอื่น
|
|
20
22
|
* คำอธิบายยาวหลังไอคอน (i) ใช้ `InfoHint`
|
|
21
23
|
*
|
|
22
24
|
* <SimpleTooltip content="คัดลอกลิงก์"><Button size="icon" aria-label="คัดลอกลิงก์"><Copy /></Button></SimpleTooltip>
|
|
@@ -34,13 +36,16 @@ export function SimpleTooltip({
|
|
|
34
36
|
className?: string;
|
|
35
37
|
}) {
|
|
36
38
|
if (content === null || content === undefined || content === "") return children;
|
|
39
|
+
// ลูกที่เป็น `Button` ของแพ็กเกจจะไม่สร้าง tooltip อัตโนมัติซ้อนขึ้นมาอีกชั้น
|
|
37
40
|
return (
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
<ButtonTooltipSuppress>
|
|
42
|
+
<Tooltip>
|
|
43
|
+
<TooltipTrigger render={children} />
|
|
44
|
+
<TooltipContent side={side} className={cn("max-w-xs text-pretty", className)}>
|
|
45
|
+
{content}
|
|
46
|
+
</TooltipContent>
|
|
47
|
+
</Tooltip>
|
|
48
|
+
</ButtonTooltipSuppress>
|
|
44
49
|
);
|
|
45
50
|
}
|
|
46
51
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
4
|
import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
5
|
+
import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
|
|
5
6
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
7
|
import { CheckIcon } from "lucide-react";
|
|
7
8
|
import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
|
@@ -19,6 +20,21 @@ export interface StepFormStep {
|
|
|
19
20
|
|
|
20
21
|
const CONTROLS = "input, select, textarea";
|
|
21
22
|
|
|
23
|
+
const FOCUSABLE = 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])';
|
|
24
|
+
|
|
25
|
+
/** สิ่งที่โฟกัสได้ (มองเห็น ไม่ใช่ตัวแทนที่ซ่อน) ตัวก่อนหน้าที่ใกล้ `el` ที่สุดในกล่อง */
|
|
26
|
+
function precedingFocusable(section: HTMLElement, el: Element): HTMLElement | undefined {
|
|
27
|
+
const all = [...section.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
|
28
|
+
(c) =>
|
|
29
|
+
c !== el &&
|
|
30
|
+
c.getAttribute("aria-hidden") !== "true" &&
|
|
31
|
+
(c as HTMLInputElement).type !== "hidden" &&
|
|
32
|
+
!(c as HTMLButtonElement).disabled &&
|
|
33
|
+
c.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING,
|
|
34
|
+
);
|
|
35
|
+
return all[all.length - 1];
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
/**
|
|
23
39
|
* ตรวจ constraint ของ input ในกล่อง (required, pattern, min…) — แสดง bubble ของเบราว์เซอร์ที่ช่องแรกที่ผิด
|
|
24
40
|
* input ที่ซ่อนไว้ (ตัวแทนของ Base UI เช่น NumberField, aria-hidden) แสดง bubble ไม่ได้ → คืนข้อความให้แสดงใต้ฟอร์มแทน
|
|
@@ -27,16 +43,13 @@ const CONTROLS = "input, select, textarea";
|
|
|
27
43
|
function reportSection(section: HTMLElement | null): true | false | string {
|
|
28
44
|
if (!section) return true;
|
|
29
45
|
const controls = [...section.querySelectorAll<HTMLInputElement>(CONTROLS)];
|
|
30
|
-
for (const
|
|
46
|
+
for (const el of controls) {
|
|
31
47
|
if (el.type === "hidden" || el.disabled) continue;
|
|
32
48
|
if (!el.checkValidity()) {
|
|
33
49
|
if (el.getAttribute("aria-hidden") === "true") {
|
|
34
|
-
// input ซ่อนของ Base UI
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.reverse()
|
|
38
|
-
.find((c) => c.getAttribute("aria-hidden") !== "true" && c.type !== "hidden")
|
|
39
|
-
?.focus();
|
|
50
|
+
// input ซ่อนของ Base UI (NumberField/Select/Checkbox/Switch/Radio) อยู่ถัดจากตัวควบคุมที่มองเห็นของมัน
|
|
51
|
+
// → โฟกัสสิ่งที่โฟกัสได้ตัวก่อนหน้าที่ใกล้ที่สุด (ปุ่ม combobox/checkbox ก็นับ ไม่ใช่แค่ input)
|
|
52
|
+
precedingFocusable(section, el)?.focus();
|
|
40
53
|
return el.validationMessage || "ค่าไม่ถูกต้อง";
|
|
41
54
|
}
|
|
42
55
|
el.reportValidity();
|
|
@@ -92,6 +105,8 @@ export function StepForm({
|
|
|
92
105
|
const busy = useRef(false);
|
|
93
106
|
/** เวลาที่เปลี่ยนขั้นล่าสุด — คลิกที่สองของดับเบิลคลิก "ถัดไป" ตกที่ปุ่ม "ส่ง" ตำแหน่งเดิม */
|
|
94
107
|
const movedAt = useRef(0);
|
|
108
|
+
/** เวลาที่ส่งเสร็จล่าสุด — กันคลิกซ้ำเมื่อ onSubmit จบเร็ว */
|
|
109
|
+
const submittedAt = useRef(0);
|
|
95
110
|
const last = steps.length - 1;
|
|
96
111
|
const step = steps[current];
|
|
97
112
|
|
|
@@ -132,8 +147,10 @@ export function StepForm({
|
|
|
132
147
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
133
148
|
e.preventDefault();
|
|
134
149
|
if (busy.current) return;
|
|
135
|
-
//
|
|
136
|
-
|
|
150
|
+
// คลิกที่สองของดับเบิลคลิก: หลังเปลี่ยนขั้น (ปุ่มเดิมกลายเป็น "ถัดไป"/"ส่ง" ของขั้นใหม่ — ข้ามขั้นหรือข้ามหน้าตรวจทาน)
|
|
151
|
+
// และหลังส่งเสร็จ (onSubmit ที่ sync/เร็ว ปล่อย busy ก่อนคลิกที่สองมาถึง) ไม่นับ
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
if (now - movedAt.current < 500 || now - submittedAt.current < 500) return;
|
|
137
154
|
busy.current = true;
|
|
138
155
|
try {
|
|
139
156
|
await submitStep();
|
|
@@ -174,13 +191,16 @@ export function StepForm({
|
|
|
174
191
|
}
|
|
175
192
|
if (!formRef.current) return;
|
|
176
193
|
setPending(true);
|
|
194
|
+
const fallback = ui.actionFailed ?? "ดำเนินการไม่สำเร็จ";
|
|
177
195
|
try {
|
|
178
|
-
|
|
196
|
+
// server action ที่คืน { ok: false, error } (FormState) = ล้มเหลว เหมือน throw
|
|
197
|
+
const failed = failedResult(await onSubmit(new FormData(formRef.current)), fallback);
|
|
198
|
+
if (failed) setError(failed);
|
|
179
199
|
} catch (err) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
);
|
|
200
|
+
// redirect() ใน server action = สำเร็จ (Next นำทางให้แล้ว)
|
|
201
|
+
if (!isNavigationError(err)) setError(errorMessage(err, fallback));
|
|
183
202
|
} finally {
|
|
203
|
+
submittedAt.current = Date.now();
|
|
184
204
|
setPending(false);
|
|
185
205
|
}
|
|
186
206
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { MoonIcon, SunIcon } from "lucide-react";
|
|
5
5
|
import { useTheme } from "next-themes";
|
|
6
6
|
import { useEffect, useState } from "react";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
5
|
import {
|
|
5
6
|
DropdownMenu,
|
|
6
7
|
DropdownMenuContent,
|
|
@@ -30,6 +31,8 @@ export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps)
|
|
|
30
31
|
lastPersisted.current = persistedTheme;
|
|
31
32
|
}, [persistedTheme]);
|
|
32
33
|
|
|
34
|
+
const text = useUiText();
|
|
35
|
+
|
|
33
36
|
const handleThemeChange = async (newTheme: ThemeValue) => {
|
|
34
37
|
setTheme(newTheme);
|
|
35
38
|
await onThemeChange?.(newTheme);
|
|
@@ -43,13 +46,12 @@ export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps)
|
|
|
43
46
|
variant="ghost"
|
|
44
47
|
size="icon"
|
|
45
48
|
className="relative h-9 w-9 rounded-md hover:bg-accent hover:text-accent-foreground"
|
|
46
|
-
|
|
49
|
+
aria-label={text.theme?.label ?? "Change theme"}
|
|
47
50
|
/>
|
|
48
51
|
}
|
|
49
52
|
>
|
|
50
53
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
51
54
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
52
|
-
<span className="sr-only">Change theme</span>
|
|
53
55
|
</DropdownMenuTrigger>
|
|
54
56
|
<DropdownMenuContent align="end" className="w-36">
|
|
55
57
|
<DropdownMenuItem
|
|
@@ -81,8 +81,15 @@ export interface UiText {
|
|
|
81
81
|
}>;
|
|
82
82
|
/** ChatThread (optional) */
|
|
83
83
|
chat?: Partial<{ placeholder: string; send: string; typing: string; scrollToLatest: string }>;
|
|
84
|
-
/** SimplePagination (optional) */
|
|
85
|
-
pagination?: Partial<{
|
|
84
|
+
/** SimplePagination / DataTablePagination (optional) */
|
|
85
|
+
pagination?: Partial<{
|
|
86
|
+
previous: string;
|
|
87
|
+
next: string;
|
|
88
|
+
first: string;
|
|
89
|
+
last: string;
|
|
90
|
+
label: string;
|
|
91
|
+
page: string;
|
|
92
|
+
}>;
|
|
86
93
|
/** TableSkeleton/ListSkeleton/CardSkeleton (optional) — ข้อความสำหรับ screen reader */
|
|
87
94
|
loading?: string;
|
|
88
95
|
/** สถานะออนไลน์ของ UserIdentity/Initials (optional) */
|
|
@@ -174,7 +181,14 @@ export const UI_TEXT_TH: UiText = {
|
|
|
174
181
|
typing: "กำลังพิมพ์…",
|
|
175
182
|
scrollToLatest: "ไปข้อความล่าสุด",
|
|
176
183
|
},
|
|
177
|
-
pagination: {
|
|
184
|
+
pagination: {
|
|
185
|
+
previous: "ก่อนหน้า",
|
|
186
|
+
next: "ถัดไป",
|
|
187
|
+
first: "หน้าแรก",
|
|
188
|
+
last: "หน้าสุดท้าย",
|
|
189
|
+
label: "แบ่งหน้า",
|
|
190
|
+
page: "หน้า",
|
|
191
|
+
},
|
|
178
192
|
loading: "กำลังโหลด…",
|
|
179
193
|
presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
|
|
180
194
|
characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
|
|
@@ -264,7 +278,14 @@ export const UI_TEXT_EN: UiText = {
|
|
|
264
278
|
typing: "Typing…",
|
|
265
279
|
scrollToLatest: "Jump to latest",
|
|
266
280
|
},
|
|
267
|
-
pagination: {
|
|
281
|
+
pagination: {
|
|
282
|
+
previous: "Previous",
|
|
283
|
+
next: "Next",
|
|
284
|
+
first: "First page",
|
|
285
|
+
last: "Last page",
|
|
286
|
+
label: "Pagination",
|
|
287
|
+
page: "Page",
|
|
288
|
+
},
|
|
268
289
|
loading: "Loading…",
|
|
269
290
|
presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
|
|
270
291
|
characterCount: "{count} of {max} characters",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { formatBytes } from "@suphark/ui/lib/formatters";
|
|
5
5
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
6
|
import { AlertCircle, Check, ChevronDown, RotateCcw, X } from "lucide-react";
|
|
@@ -45,7 +45,8 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
|
|
|
45
45
|
const [isPending, startTransition] = useTransition();
|
|
46
46
|
|
|
47
47
|
const state = useMemo<TableSearchState>(() => {
|
|
48
|
-
|
|
48
|
+
// ไม่มี prototype: key จาก URL อย่าง "__proto__"/"constructor" เป็นแค่ตัวกรองชื่อหนึ่ง ไม่ไปทับ prototype
|
|
49
|
+
const filters: Record<string, string[]> = Object.create(null);
|
|
49
50
|
for (const key of new Set(searchParams.keys())) {
|
|
50
51
|
if (RESERVED.has(key)) continue;
|
|
51
52
|
const values = searchParams
|
|
@@ -73,16 +74,20 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
|
|
|
73
74
|
|
|
74
75
|
// setter หลายตัวในจังหวะเดียว (เช่น setFilter แล้ว setSort) ต้องต่อจากค่าที่เพิ่งตั้ง ไม่ใช่ searchParams ตอน render
|
|
75
76
|
// (router.replace ยังไม่ทันเปลี่ยน URL → ตัวหลังเขียนทับตัวแรก) — ล้างเมื่อ URL เปลี่ยนจริงแล้ว
|
|
76
|
-
|
|
77
|
+
// จำ URL ที่เราเพิ่งสั่ง (value) คู่กับ URL ตอนสั่ง (base) — ใช้ต่อเฉพาะเมื่อ URL ยังเป็น base เดิม
|
|
78
|
+
// (เดิมล้างตามตัวอ้างอิงของ searchParams ซึ่ง Next คืนตัวเดิมเมื่อ URL ไม่เปลี่ยน → ค่าเก่าค้าง)
|
|
79
|
+
const latest = useRef<{ base: string; value: string } | null>(null);
|
|
77
80
|
useEffect(() => {
|
|
78
81
|
latest.current = null;
|
|
79
82
|
}, [searchParams]);
|
|
80
83
|
|
|
81
84
|
const push = useCallback(
|
|
82
85
|
(mutate: (params: URLSearchParams) => void) => {
|
|
83
|
-
const
|
|
86
|
+
const base = searchParams.toString();
|
|
87
|
+
const pending = latest.current?.base === base ? latest.current.value : base;
|
|
88
|
+
const params = new URLSearchParams(pending);
|
|
84
89
|
mutate(params);
|
|
85
|
-
latest.current = params.toString();
|
|
90
|
+
latest.current = { base, value: params.toString() };
|
|
86
91
|
startTransition(() => {
|
|
87
92
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
|
88
93
|
});
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,12 @@ export {
|
|
|
40
40
|
SaveButton,
|
|
41
41
|
SubmitButton,
|
|
42
42
|
} from "./components/shared/button";
|
|
43
|
+
export {
|
|
44
|
+
Button,
|
|
45
|
+
type ButtonProps,
|
|
46
|
+
type ButtonTooltipSide,
|
|
47
|
+
ButtonTooltipSuppress,
|
|
48
|
+
} from "./components/shared/button/button";
|
|
43
49
|
export {
|
|
44
50
|
type CalendarDayContext,
|
|
45
51
|
type CalendarDayState,
|
|
@@ -296,7 +302,7 @@ export {
|
|
|
296
302
|
BreadcrumbSeparator,
|
|
297
303
|
} from "./components/ui/breadcrumb";
|
|
298
304
|
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions } from "./components/ui/bubble";
|
|
299
|
-
export {
|
|
305
|
+
export { buttonVariants } from "./components/ui/button";
|
|
300
306
|
export {
|
|
301
307
|
ButtonGroup,
|
|
302
308
|
ButtonGroupSeparator,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ตีความผลของ action (server action / async handler) ให้เหมือนกันทุก component — ไม่พึ่ง next
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** ข้อความจาก error ที่ throw (ไม่มี = fallback) */
|
|
6
|
+
export function errorMessage(err: unknown, fallback: string): string {
|
|
7
|
+
return err instanceof Error && err.message ? err.message : fallback;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** action คืน `FormState` แบบ `{ ok: false, error }` (ไม่ได้ throw) = ล้มเหลว → ข้อความ error; อย่างอื่น = null */
|
|
11
|
+
export function failedResult(result: unknown, fallback: string): string | null {
|
|
12
|
+
if (result && typeof result === "object" && "ok" in result && result.ok === false) {
|
|
13
|
+
const error = "error" in result ? result.error : undefined;
|
|
14
|
+
return typeof error === "string" && error ? error : fallback;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* error ที่ Next ใช้สั่งนำทาง (`redirect()`, `notFound()`, `forbidden()`/`unauthorized()`) — ไม่ใช่ความล้มเหลว
|
|
21
|
+
* server action ที่ redirect ทำให้ promise ฝั่ง client reject ด้วย error นี้ขณะที่ router นำทางให้แล้ว
|
|
22
|
+
* → ห้ามแสดงเป็น toast "NEXT_REDIRECT" (ดูจาก `digest` จึงไม่ต้อง import next)
|
|
23
|
+
*/
|
|
24
|
+
export function isNavigationError(err: unknown): boolean {
|
|
25
|
+
if (!err || typeof err !== "object" || !("digest" in err)) return false;
|
|
26
|
+
const digest = (err as { digest: unknown }).digest;
|
|
27
|
+
return (
|
|
28
|
+
typeof digest === "string" &&
|
|
29
|
+
(digest.startsWith("NEXT_REDIRECT") ||
|
|
30
|
+
digest === "NEXT_NOT_FOUND" ||
|
|
31
|
+
digest.startsWith("NEXT_HTTP_ERROR_FALLBACK"))
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL ที่นำทางด้วย script ได้อย่างปลอดภัย (`location.assign/replace`, `window.open`)
|
|
3
|
+
* — ช่องทางเหล่านี้ไม่มีตัวกัน `javascript:` แบบ `<a href>` ของ React หรือ router ของ Next
|
|
4
|
+
* รับเฉพาะ http(s) และ path ภายใน (relative); คืน URL ที่ resolve แล้ว หรือ null ถ้าไม่ปลอดภัย
|
|
5
|
+
* ไม่ได้กัน open redirect ไปโดเมนอื่น — ถ้าค่ามาจาก URL/ผู้ใช้ แอปต้องเช็กปลายทางเอง
|
|
6
|
+
*/
|
|
7
|
+
export function safeNavigableUrl(href: string | null | undefined): string | null {
|
|
8
|
+
if (!href || typeof window === "undefined") return null;
|
|
9
|
+
try {
|
|
10
|
+
const url = new URL(href, window.location.href);
|
|
11
|
+
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/styles.css
CHANGED
|
@@ -297,25 +297,28 @@
|
|
|
297
297
|
scrollbar-color: var(--scrollbar-thumb) transparent;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
/* Global scrollbar styling
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
300
|
+
/* Global scrollbar styling — อยู่ใน @layer base ให้ utility (no-scrollbar, scrollbar-thin, className ของแอป) ชนะได้
|
|
301
|
+
(เดิมไม่มี layer → `*{scrollbar-width:thin}` ทับ no-scrollbar ทุกที่ เช่นคอลัมน์ TimePicker แถบแท็บ) */
|
|
302
|
+
@layer base {
|
|
303
|
+
::-webkit-scrollbar {
|
|
304
|
+
width: 6px;
|
|
305
|
+
height: 6px;
|
|
306
|
+
}
|
|
307
|
+
::-webkit-scrollbar-track {
|
|
308
|
+
background: transparent;
|
|
309
|
+
}
|
|
310
|
+
::-webkit-scrollbar-thumb {
|
|
311
|
+
background-color: var(--scrollbar-thumb);
|
|
312
|
+
border-radius: 9999px;
|
|
313
|
+
}
|
|
314
|
+
::-webkit-scrollbar-thumb:hover {
|
|
315
|
+
background-color: var(--scrollbar-thumb-hover);
|
|
316
|
+
}
|
|
315
317
|
|
|
316
|
-
* {
|
|
317
|
-
|
|
318
|
-
|
|
318
|
+
* {
|
|
319
|
+
scrollbar-width: thin;
|
|
320
|
+
scrollbar-color: var(--scrollbar-thumb) transparent;
|
|
321
|
+
}
|
|
319
322
|
}
|
|
320
323
|
|
|
321
324
|
html,
|