@suphark/ui 0.25.1 → 0.26.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +4 -3
  3. package/package.json +5 -4
  4. package/src/components/app-shell/command-palette.tsx +13 -5
  5. package/src/components/data-table/data-table-sortable.tsx +5 -4
  6. package/src/components/data-table/data-table-toolbar.tsx +14 -3
  7. package/src/components/data-table/use-data-table.ts +38 -5
  8. package/src/components/design-system/design-system-showcase.tsx +64 -15
  9. package/src/components/pwa/pull-to-refresh.tsx +11 -4
  10. package/src/components/pwa/service-worker-updates.ts +26 -3
  11. package/src/components/shared/action-menu.tsx +25 -10
  12. package/src/components/shared/auth-parts.tsx +4 -2
  13. package/src/components/shared/button/base/action-button.tsx +7 -14
  14. package/src/components/shared/button/base/link-button.tsx +5 -14
  15. package/src/components/shared/button/confirm-action-button.tsx +9 -13
  16. package/src/components/shared/chat-thread.tsx +13 -6
  17. package/src/components/shared/client-redirect.tsx +5 -9
  18. package/src/components/shared/dialogs-provider.tsx +31 -0
  19. package/src/components/shared/form/number-input.tsx +36 -14
  20. package/src/components/shared/form/tags-input.tsx +26 -21
  21. package/src/components/shared/form/time-picker.tsx +12 -2
  22. package/src/components/shared/month-stepper.tsx +3 -3
  23. package/src/components/shared/row-actions.tsx +13 -7
  24. package/src/components/shared/rule-builder.tsx +10 -1
  25. package/src/components/shared/settings-layout.tsx +8 -3
  26. package/src/components/shared/step-form.tsx +33 -13
  27. package/src/components/ui/input.tsx +1 -1
  28. package/src/components/ui/native-select.tsx +1 -1
  29. package/src/components/ui/select.tsx +1 -1
  30. package/src/components/ui/textarea.tsx +1 -1
  31. package/src/hooks/use-table-search-params.ts +9 -4
  32. package/src/lib/action-result.ts +33 -0
  33. package/src/lib/safe-url.ts +15 -0
  34. package/src/styles.css +21 -18
@@ -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 [i, el] of controls.entries()) {
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
- controls
36
- .slice(0, i)
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
- if (current === last && Date.now() - movedAt.current < 500) return;
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
- await onSubmit(new FormData(formRef.current));
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
- setError(
181
- err instanceof Error && err.message ? err.message : (ui.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
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
  };
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-body transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
12
12
  className,
13
13
  )}
14
14
  {...props}
@@ -19,7 +19,7 @@ function NativeSelect({ className, size = "default", ...props }: NativeSelectPro
19
19
  <select
20
20
  data-slot="native-select"
21
21
  data-size={size}
22
- className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
22
+ className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-body transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
23
23
  {...props}
24
24
  />
25
25
  <ChevronDownIcon
@@ -40,7 +40,7 @@ function SelectTrigger({
40
40
  data-slot="select-trigger"
41
41
  data-size={size}
42
42
  className={cn(
43
- "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
43
+ "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-body whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
44
44
  className,
45
45
  )}
46
46
  {...props}
@@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
6
  <textarea
7
7
  data-slot="textarea"
8
8
  className={cn(
9
- "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
9
+ "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-body transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
10
10
  className,
11
11
  )}
12
12
  {...props}
@@ -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
- const filters: Record<string, string[]> = {};
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
- const latest = useRef<string | null>(null);
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 params = new URLSearchParams(latest.current ?? searchParams.toString());
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
  });
@@ -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
- ::-webkit-scrollbar {
302
- width: 6px;
303
- height: 6px;
304
- }
305
- ::-webkit-scrollbar-track {
306
- background: transparent;
307
- }
308
- ::-webkit-scrollbar-thumb {
309
- background-color: var(--scrollbar-thumb);
310
- border-radius: 9999px;
311
- }
312
- ::-webkit-scrollbar-thumb:hover {
313
- background-color: var(--scrollbar-thumb-hover);
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
- scrollbar-width: thin;
318
- scrollbar-color: var(--scrollbar-thumb) transparent;
318
+ * {
319
+ scrollbar-width: thin;
320
+ scrollbar-color: var(--scrollbar-thumb) transparent;
321
+ }
319
322
  }
320
323
 
321
324
  html,