@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
@@ -1,12 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/ui/button";
4
- import {
5
- Tooltip,
6
- TooltipContent,
7
- TooltipProvider,
8
- TooltipTrigger,
9
- } from "@suphark/ui/components/ui/tooltip";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
10
5
  import {
11
6
  type CheckPermissionOptions,
12
7
  type CheckPermissionRequirement,
@@ -118,14 +113,12 @@ export function ActionButton({
118
113
  // 5. Wrap with Tooltip if provided or auto-generated
119
114
  if (finalTooltip) {
120
115
  return (
121
- <TooltipProvider>
122
- <Tooltip>
123
- <TooltipTrigger render={buttonContent as React.ReactElement} />
124
- <TooltipContent side={tooltipSide}>
125
- {typeof finalTooltip === "string" ? <p>{finalTooltip}</p> : finalTooltip}
126
- </TooltipContent>
127
- </Tooltip>
128
- </TooltipProvider>
116
+ <Tooltip>
117
+ <TooltipTrigger delay={0} render={buttonContent as React.ReactElement} />
118
+ <TooltipContent side={tooltipSide}>
119
+ {typeof finalTooltip === "string" ? <p>{finalTooltip}</p> : finalTooltip}
120
+ </TooltipContent>
121
+ </Tooltip>
129
122
  );
130
123
  }
131
124
 
@@ -2,12 +2,7 @@
2
2
 
3
3
  // [FIX 1.1] นำเข้าเฉพาะ Button
4
4
  import { Button } from "@suphark/ui/components/ui/button";
5
- import {
6
- Tooltip,
7
- TooltipContent,
8
- TooltipProvider,
9
- TooltipTrigger,
10
- } from "@suphark/ui/components/ui/tooltip";
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
11
6
  import {
12
7
  type CheckPermissionOptions,
13
8
  type CheckPermissionRequirement,
@@ -114,14 +109,10 @@ export function LinkButton({
114
109
  // 3. Wrap with Tooltip if provided
115
110
  if (tooltip) {
116
111
  return (
117
- <TooltipProvider>
118
- <Tooltip>
119
- <TooltipTrigger render={buttonContent as React.ReactElement} />
120
- <TooltipContent>
121
- {typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}
122
- </TooltipContent>
123
- </Tooltip>
124
- </TooltipProvider>
112
+ <Tooltip>
113
+ <TooltipTrigger delay={0} render={buttonContent as React.ReactElement} />
114
+ <TooltipContent>{typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}</TooltipContent>
115
+ </Tooltip>
125
116
  );
126
117
  }
127
118
 
@@ -10,23 +10,13 @@ import {
10
10
  normalizeConfirm,
11
11
  } from "@suphark/ui/components/shared/confirm-dialog";
12
12
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
13
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
13
14
  import { useRouter } from "next/navigation";
14
15
  import { useState, useTransition } from "react";
15
16
  import { toast } from "sonner";
16
17
 
17
- /** ข้อความจาก error ที่แสดงให้ผู้ใช้ได้ (Error.message ที่ไม่ว่าง ไม่งั้นใช้ fallback) */
18
- export function errorMessage(err: unknown, fallback: string): string {
19
- return err instanceof Error && err.message ? err.message : fallback;
20
- }
21
-
22
- /** action คืน `FormState` แบบ `{ ok: false, error }` (ไม่ได้ throw) = ล้มเหลว → ข้อความ error; อย่างอื่น = null */
23
- export function failedResult(result: unknown, fallback: string): string | null {
24
- if (result && typeof result === "object" && "ok" in result && result.ok === false) {
25
- const error = "error" in result ? result.error : undefined;
26
- return typeof error === "string" && error ? error : fallback;
27
- }
28
- return null;
29
- }
18
+ // ตัวช่วยตีความผล action ย้ายไป lib/action-result (ไม่พึ่ง next — ใช้ได้จาก barrel) — คง export เดิมไว้
19
+ export { errorMessage, failedResult } from "@suphark/ui/lib/action-result";
30
20
 
31
21
  /**
32
22
  * ปุ่มเดี่ยวที่ถามยืนยัน (ConfirmDialog) ก่อนเรียก server action — ใช้แทน `<form action>` + window.confirm
@@ -92,6 +82,12 @@ export function ConfirmActionButton({
92
82
  return;
93
83
  }
94
84
  } catch (err) {
85
+ // action redirect() = สำเร็จ (Next นำทางให้แล้ว) — ปิด dialog เงียบ ๆ ไม่แสดง "NEXT_REDIRECT"
86
+ if (isNavigationError(err)) {
87
+ onDone?.();
88
+ setOpen(false);
89
+ return;
90
+ }
95
91
  toast.error(errorMessage(err, fallback));
96
92
  return;
97
93
  }
@@ -24,6 +24,7 @@ import {
24
24
  MessageScrollerViewport,
25
25
  } from "@suphark/ui/components/ui/message-scroller";
26
26
  import { Spinner } from "@suphark/ui/components/ui/spinner";
27
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
27
28
  import { cn } from "@suphark/ui/lib/utils";
28
29
  import { ArrowDown, SendHorizontal } from "lucide-react";
29
30
  import { type ReactNode, useState } from "react";
@@ -156,15 +157,21 @@ function ChatComposer({
156
157
 
157
158
  const send = async () => {
158
159
  if (!canSend) return;
160
+ const sent = value;
159
161
  setPending(true);
162
+ // ล้างช่องเฉพาะเมื่อผู้ใช้ยังไม่ได้พิมพ์ต่อระหว่างรอส่ง (ไม่ทิ้งข้อความใหม่)
163
+ const clearSent = () => setValue((current) => (current === sent ? "" : current));
160
164
  try {
161
- await onSend(value.trim());
162
- setValue("");
165
+ const failed = failedResult(await onSend(sent.trim()), failedText ?? "ดำเนินการไม่สำเร็จ");
166
+ if (failed) toast.error(failed);
167
+ else clearSent();
163
168
  } catch (err) {
164
- // ส่งไม่สำเร็จ: คงข้อความไว้ในช่องให้ลองใหม่ + แจ้งผู้ใช้ (เดิมเงียบ — unhandled rejection)
165
- toast.error(
166
- err instanceof Error && err.message ? err.message : (failedText ?? "ดำเนินการไม่สำเร็จ"),
167
- );
169
+ if (isNavigationError(err)) {
170
+ clearSent();
171
+ return;
172
+ }
173
+ // ส่งไม่สำเร็จ: คงข้อความไว้ในช่องให้ลองใหม่ + แจ้งผู้ใช้
174
+ toast.error(errorMessage(err, failedText ?? "ดำเนินการไม่สำเร็จ"));
168
175
  } finally {
169
176
  setPending(false);
170
177
  }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { safeNavigableUrl } from "@suphark/ui/lib/safe-url";
3
4
  import { useEffect } from "react";
4
5
 
5
6
  /**
@@ -9,17 +10,12 @@ import { useEffect } from "react";
9
10
  */
10
11
  export function ClientRedirect({ to }: { to: string }) {
11
12
  useEffect(() => {
12
- let url: URL;
13
- try {
14
- url = new URL(to, window.location.href);
15
- } catch {
13
+ const url = safeNavigableUrl(to);
14
+ if (!url) {
15
+ console.error("ClientRedirect: ปฏิเสธปลายทางที่ไม่ใช่ http(s)");
16
16
  return;
17
17
  }
18
- if (url.protocol !== "http:" && url.protocol !== "https:") {
19
- console.error(`ClientRedirect: ปฏิเสธปลายทางที่ไม่ใช่ http(s): ${url.protocol}`);
20
- return;
21
- }
22
- window.location.replace(url.href);
18
+ window.location.replace(url);
23
19
  }, [to]);
24
20
 
25
21
  return null;
@@ -12,11 +12,13 @@ import {
12
12
  } from "@suphark/ui/components/ui/alert-dialog";
13
13
  import { Checkbox } from "@suphark/ui/components/ui/checkbox";
14
14
  import { Input } from "@suphark/ui/components/ui/input";
15
+ import { Label } from "@suphark/ui/components/ui/label";
15
16
  import {
16
17
  createContext,
17
18
  type ReactNode,
18
19
  useCallback,
19
20
  useContext,
21
+ useId,
20
22
  useMemo,
21
23
  useRef,
22
24
  useState,
@@ -104,6 +106,7 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
104
106
  setRequest(next);
105
107
  }, []);
106
108
 
109
+ const typeToConfirmId = useId();
107
110
  const settle = useCallback((value: Result) => {
108
111
  resolveRef.current?.(value);
109
112
  resolveRef.current = null;
@@ -120,6 +123,7 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
120
123
  }),
121
124
  confirm: (spec) =>
122
125
  new Promise((resolve) => {
126
+ setPromptValue(""); // ช่องพิมพ์ยืนยัน (requireText) เริ่มว่างทุกครั้ง
123
127
  open({ ...normalizeConfirm(spec), mode: "confirm" }, (value) => resolve(value === true));
124
128
  }),
125
129
  prompt: (spec) =>
@@ -196,6 +200,28 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
196
200
  />
197
201
  )}
198
202
 
203
+ {request.mode === "confirm" && request.requireText && (
204
+ // พิมพ์ข้อความยืนยันก่อนกด (เหมือน ConfirmDialog — เดิม useDialogs().confirm ข้าม requireText ไปเงียบ ๆ)
205
+ <div className="flex flex-col gap-1.5">
206
+ <Label htmlFor={typeToConfirmId} className="font-normal text-sm">
207
+ {request.requireTextLabel ?? (
208
+ <>
209
+ {text.confirmTypeToConfirm ?? "พิมพ์"}{" "}
210
+ <span className="font-medium">{request.requireText}</span>{" "}
211
+ {text.confirmTypeToConfirmSuffix ?? "เพื่อยืนยัน"}
212
+ </>
213
+ )}
214
+ </Label>
215
+ <Input
216
+ id={typeToConfirmId}
217
+ value={promptValue}
218
+ onChange={(event) => setPromptValue(event.target.value)}
219
+ autoComplete="off"
220
+ spellCheck={false}
221
+ />
222
+ </div>
223
+ )}
224
+
199
225
  {request.mode === "checklist" && (
200
226
  <div className="flex flex-col gap-2">
201
227
  {request.options?.map((option) => {
@@ -247,6 +273,11 @@ export function DialogsProvider({ children }: { children: ReactNode }) {
247
273
  ) : (
248
274
  <AlertDialogAction
249
275
  variant={request.destructive ? "destructive" : "default"}
276
+ disabled={
277
+ request.mode === "confirm" &&
278
+ Boolean(request.requireText) &&
279
+ promptValue.trim() !== request.requireText
280
+ }
250
281
  onClick={() => settle(confirmResult())}
251
282
  >
252
283
  {request.confirmLabel ?? confirmLabelByMode[request.mode]}
@@ -3,7 +3,7 @@
3
3
  import { NumberField } from "@base-ui/react/number-field";
4
4
  import { cn } from "@suphark/ui/lib/utils";
5
5
  import { MinusIcon, PlusIcon } from "lucide-react";
6
- import type { ReactNode } from "react";
6
+ import { type ReactNode, type Ref, useCallback, useEffect, useRef } from "react";
7
7
  import { useUiText } from "../ui-text";
8
8
 
9
9
  /**
@@ -14,10 +14,12 @@ import { useUiText } from "../ui-text";
14
14
  function relaxHiddenStep(el: HTMLInputElement | null) {
15
15
  if (el) el.step = "any";
16
16
  }
17
+ // หมายเหตุ: เมื่อ prop step เปลี่ยน React เขียน attribute step ใหม่ → effect ใน NumberInput ตั้ง "any" ซ้ำ
17
18
 
18
19
  const SIZE = {
19
- sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-sm" },
20
- default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-base md:text-sm" },
20
+ // มือถือ ≥16px (iOS ไม่ซูมตอนโฟกัส) — จอกว้างค่อยเล็กลง
21
+ sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-base md:text-sm" },
22
+ default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-body" },
21
23
  } as const;
22
24
 
23
25
  /**
@@ -53,6 +55,8 @@ export function NumberInput({
53
55
  "aria-label": ariaLabel,
54
56
  "aria-describedby": ariaDescribedBy,
55
57
  "aria-invalid": ariaInvalid,
58
+ inputRef,
59
+ autoFocus,
56
60
  }: {
57
61
  value?: number | null;
58
62
  defaultValue?: number;
@@ -84,8 +88,21 @@ export function NumberInput({
84
88
  "aria-label"?: string;
85
89
  "aria-describedby"?: string;
86
90
  "aria-invalid"?: boolean | "true" | "false";
91
+ /** ref ของช่องที่ผู้ใช้พิมพ์ (เช่น focus() เอง) */
92
+ inputRef?: Ref<HTMLInputElement>;
93
+ autoFocus?: boolean;
87
94
  }) {
88
95
  const text = useUiText().number;
96
+ const hiddenEl = useRef<HTMLInputElement | null>(null);
97
+ const hiddenRef = useCallback((el: HTMLInputElement | null) => {
98
+ hiddenEl.current = el;
99
+ relaxHiddenStep(el);
100
+ }, []);
101
+ // step เปลี่ยนหลัง mount → React เขียน attribute step ใหม่ → ตั้ง "any" ซ้ำ
102
+ // biome-ignore lint/correctness/useExhaustiveDependencies: step เป็นตัวกระตุ้น — React เขียน attribute step ใหม่ทุกครั้งที่ prop เปลี่ยน
103
+ useEffect(() => {
104
+ relaxHiddenStep(hiddenEl.current);
105
+ }, [step]);
89
106
  const s = SIZE[size];
90
107
  const buttonClass = cn(
91
108
  "flex shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-50",
@@ -108,26 +125,19 @@ export function NumberInput({
108
125
  required={required}
109
126
  disabled={disabled}
110
127
  readOnly={readOnly}
111
- inputRef={relaxHiddenStep}
128
+ inputRef={hiddenRef}
112
129
  className={cn("w-full", className)}
113
130
  >
114
131
  <NumberField.Group
115
132
  data-slot="number-input"
116
133
  className={cn(
117
- "flex w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-disabled:opacity-50 dark:bg-input/30",
134
+ "flex w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-[input:disabled]:opacity-50 dark:bg-input/30",
118
135
  s.group,
119
136
  )}
120
137
  >
121
- {!hideButtons && (
122
- <NumberField.Decrement
123
- aria-label={text?.decrement ?? "ลด"}
124
- className={cn(buttonClass, "border-r border-input")}
125
- >
126
- <MinusIcon />
127
- </NumberField.Decrement>
128
- )}
129
- {prefix && <span className={cn(affix, !hideButtons && "pr-0")}>{prefix}</span>}
130
138
  <NumberField.Input
139
+ ref={inputRef}
140
+ autoFocus={autoFocus}
131
141
  placeholder={placeholder}
132
142
  aria-label={ariaLabel}
133
143
  aria-describedby={ariaDescribedBy}
@@ -139,6 +149,18 @@ export function NumberInput({
139
149
  inputClassName,
140
150
  )}
141
151
  />
152
+ {/* ปุ่มลด/prefix อยู่หลัง input ใน DOM (label ที่ครอบช่องนี้คลิกแล้วเลือก input ไม่ใช่ปุ่มลด) แต่แสดงด้านซ้ายด้วย order */}
153
+ {!hideButtons && (
154
+ <NumberField.Decrement
155
+ aria-label={text?.decrement ?? "ลด"}
156
+ className={cn(buttonClass, "order-first border-r border-input")}
157
+ >
158
+ <MinusIcon />
159
+ </NumberField.Decrement>
160
+ )}
161
+ {prefix && (
162
+ <span className={cn(affix, "order-first", !hideButtons && "pr-0")}>{prefix}</span>
163
+ )}
142
164
  {suffix && <span className={cn(affix, !hideButtons && "pl-0")}>{suffix}</span>}
143
165
  {!hideButtons && (
144
166
  <NumberField.Increment
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { cn } from "@suphark/ui/lib/utils";
4
4
  import { XIcon } from "lucide-react";
5
- import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from "react";
5
+ import { type ClipboardEvent, type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
6
6
  import { useUiText } from "../ui-text";
7
7
 
8
8
  /**
@@ -59,6 +59,10 @@ export function TagsInput({
59
59
  const [error, setError] = useState<string | null>(null);
60
60
  const inputRef = useRef<HTMLInputElement>(null);
61
61
  const errorId = useId();
62
+ // ค่าที่ถูกปฏิเสธยังค้างในช่อง = ช่อง invalid จริง → ฟอร์มไม่ส่งรายการที่ขาดค่านี้ไปเงียบ ๆ
63
+ useEffect(() => {
64
+ inputRef.current?.setCustomValidity(error && draft.trim() ? error : "");
65
+ }, [error, draft]);
62
66
  const full = max != null && tags.length >= max;
63
67
 
64
68
  const commit = (next: string[]) => {
@@ -144,6 +148,27 @@ export function TagsInput({
144
148
  disabled && "pointer-events-none opacity-50",
145
149
  )}
146
150
  >
151
+ {/* ช่องพิมพ์อยู่ก่อนป้ายใน DOM (label ที่ครอบอยู่คลิกแล้วเลือกช่องพิมพ์ ไม่ใช่ปุ่มลบป้ายแรก) แต่แสดงท้ายด้วย order-last */}
152
+ <input
153
+ ref={inputRef}
154
+ id={id}
155
+ value={draft}
156
+ disabled={disabled || full}
157
+ placeholder={full ? undefined : (placeholder ?? text?.placeholder ?? "พิมพ์แล้วกด Enter")}
158
+ aria-label={ariaLabel}
159
+ aria-describedby={describedBy}
160
+ aria-invalid={error || ariaInvalid ? true : undefined}
161
+ onChange={(e) => {
162
+ setDraft(e.currentTarget.value);
163
+ if (error) setError(null);
164
+ }}
165
+ onKeyDown={onKeyDown}
166
+ onPaste={onPaste}
167
+ onBlur={() => {
168
+ if (draft.trim()) setDraft(add([draft]));
169
+ }}
170
+ className="order-last h-6 min-w-24 flex-1 bg-transparent px-1 text-body outline-none placeholder:text-muted-foreground"
171
+ />
147
172
  {tags.map((tag, i) => (
148
173
  <span
149
174
  // biome-ignore lint/suspicious/noArrayIndexKey: allowDuplicates ทำให้ค่าซ้ำได้ ใช้ตำแหน่งร่วม
@@ -166,26 +191,6 @@ export function TagsInput({
166
191
  {name && <input type="hidden" name={name} value={tag} />}
167
192
  </span>
168
193
  ))}
169
- <input
170
- ref={inputRef}
171
- id={id}
172
- value={draft}
173
- disabled={disabled || full}
174
- placeholder={full ? undefined : (placeholder ?? text?.placeholder ?? "พิมพ์แล้วกด Enter")}
175
- aria-label={ariaLabel}
176
- aria-describedby={describedBy}
177
- aria-invalid={error || ariaInvalid ? true : undefined}
178
- onChange={(e) => {
179
- setDraft(e.currentTarget.value);
180
- if (error) setError(null);
181
- }}
182
- onKeyDown={onKeyDown}
183
- onPaste={onPaste}
184
- onBlur={() => {
185
- if (draft.trim()) setDraft(add([draft]));
186
- }}
187
- className="h-6 min-w-24 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground md:text-sm"
188
- />
189
194
  </div>
190
195
  {error && (
191
196
  <p id={errorId} role="alert" className="text-destructive text-xs">
@@ -45,6 +45,13 @@ export function parseTimeInput(input: string): TimeValue | null {
45
45
  return `${pad(h)}:${pad(m)}`;
46
46
  }
47
47
 
48
+ /** ปัดนาทีลงให้ลงตัวกับ step (step 1 = ไม่เปลี่ยน) */
49
+ function roundTime(value: TimeValue | null, step: number): TimeValue | null {
50
+ if (!value || step <= 1) return value;
51
+ const [h, m] = value.split(":").map(Number);
52
+ return `${pad(h ?? 0)}:${pad(Math.floor((m ?? 0) / step) * step)}`;
53
+ }
54
+
48
55
  export function splitTime(value: TimeValue | null | undefined): [number, number] | null {
49
56
  const parsed = value ? parseTimeInput(value) : null;
50
57
  if (!parsed) return null;
@@ -192,6 +199,7 @@ export function TimePicker({
192
199
  defaultValue = null,
193
200
  onValueChange,
194
201
  minuteStep = 5,
202
+ roundToStep = false,
195
203
  name,
196
204
  id,
197
205
  required,
@@ -206,6 +214,8 @@ export function TimePicker({
206
214
  defaultValue?: TimeValue | null;
207
215
  onValueChange?: (value: TimeValue | null) => void;
208
216
  minuteStep?: number;
217
+ /** ปัดนาทีที่พิมพ์ลงตาม minuteStep (เช่น step 60 + พิมพ์ 14:30 → 14:00) — ค่าเริ่มต้นเก็บตามที่พิมพ์ */
218
+ roundToStep?: boolean;
209
219
  name?: string;
210
220
  id?: string;
211
221
  required?: boolean;
@@ -238,7 +248,7 @@ export function TimePicker({
238
248
  setDraft(null);
239
249
  return;
240
250
  }
241
- const parsed = parseTimeInput(draft);
251
+ const parsed = roundTime(parseTimeInput(draft), roundToStep ? minuteStep : 1);
242
252
  if (parsed) {
243
253
  setTime(parsed);
244
254
  setDraft(null);
@@ -298,7 +308,7 @@ export function TimePicker({
298
308
  size="xs"
299
309
  variant="ghost"
300
310
  onClick={() => {
301
- setTime(nowTime());
311
+ setTime(roundTime(nowTime(), roundToStep ? minuteStep : 1));
302
312
  setDraft(null);
303
313
  }}
304
314
  >
@@ -31,6 +31,9 @@ export interface MonthStepperProps {
31
31
  className?: string;
32
32
  }
33
33
 
34
+ /** "เดือนนี้" ของ MonthStepper/useMonthCursor คิดตามเวลาไทย — แอปของเราใช้ในไทย และต้องได้ค่าเดียวกันทั้ง server และ browser */
35
+ const APP_TIME_ZONE = "Asia/Bangkok";
36
+
34
37
  /**
35
38
  * เลือกเดือนแบบ ‹ เดือน › — กดชื่อเดือนเพื่อกลับเดือนนี้ (ยกจาก DueBell `MonthSelector`)
36
39
  * ใช้คู่กับ `CalendarMonthGrid month` ได้เลย (รูปแบบ `"YYYY-MM"` เดียวกัน)
@@ -38,9 +41,6 @@ export interface MonthStepperProps {
38
41
  * const { month, setMonth } = useMonthCursor();
39
42
  * <MonthStepper value={month} onValueChange={setMonth} />
40
43
  */
41
- /** "เดือนนี้" ของ MonthStepper/useMonthCursor คิดตามเวลาไทย — แอปของเราใช้ในไทย และต้องได้ค่าเดียวกันทั้ง server และ browser */
42
- const APP_TIME_ZONE = "Asia/Bangkok";
43
-
44
44
  export function MonthStepper({
45
45
  value,
46
46
  onValueChange,
@@ -8,12 +8,12 @@ import {
8
8
  DropdownMenuSeparator,
9
9
  DropdownMenuTrigger,
10
10
  } from "@suphark/ui/components/ui/dropdown-menu";
11
+ import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
11
12
  import { MoreHorizontalIcon } from "lucide-react";
12
13
  import Link from "next/link";
13
14
  import { useRouter } from "next/navigation";
14
15
  import { useState, useTransition } from "react";
15
16
  import { toast } from "sonner";
16
- import { errorMessage, failedResult } from "./button/confirm-action-button";
17
17
  import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
18
18
  import { useUiText } from "./ui-text";
19
19
 
@@ -44,12 +44,15 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
44
44
  const [open, setOpen] = useState(false);
45
45
  const [confirming, setConfirming] = useState<RowActionItem | null>(null);
46
46
 
47
- function run(it: RowActionItem) {
47
+ /** `done` = เรียกเมื่อสำเร็จ (ใช้ปิด ConfirmDialog — ล้มเหลวคง dialog ไว้ให้ลองใหม่) */
48
+ function run(it: RowActionItem, done?: () => void) {
48
49
  if (it.href) {
50
+ done?.();
49
51
  router.push(it.href);
50
52
  return;
51
53
  }
52
54
  if (it.onSelect) {
55
+ done?.();
53
56
  it.onSelect();
54
57
  return;
55
58
  }
@@ -67,9 +70,15 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
67
70
  return;
68
71
  }
69
72
  } catch (err) {
73
+ // action redirect() = สำเร็จ (Next นำทางให้แล้ว) — ไม่แสดง "NEXT_REDIRECT"
74
+ if (isNavigationError(err)) {
75
+ done?.();
76
+ return;
77
+ }
70
78
  toast.error(errorMessage(err, fallback));
71
79
  return;
72
80
  }
81
+ done?.();
73
82
  router.refresh();
74
83
  });
75
84
  }
@@ -126,11 +135,8 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
126
135
  requireText={spec.requireText}
127
136
  requireTextLabel={spec.requireTextLabel}
128
137
  destructive={spec.destructive ?? confirming.destructive}
129
- onConfirm={() => {
130
- const it = confirming;
131
- setConfirming(null);
132
- run(it);
133
- }}
138
+ pending={pending}
139
+ onConfirm={() => run(confirming, () => setConfirming(null))}
134
140
  />
135
141
  )}
136
142
  </>
@@ -160,6 +160,12 @@ function keyOf(item: object): string {
160
160
  return k;
161
161
  }
162
162
 
163
+ /** object ใหม่ที่มาแทนของเดิม (แก้ค่าในเงื่อนไข) ใช้ key เดิม — ไม่งั้นช่องที่กำลังพิมพ์ re-mount แล้วหลุดโฟกัสทุกตัวอักษร */
164
+ function keepKey<T extends object>(prev: object, next: T): T {
165
+ if (prev !== next && !keys.has(next)) keys.set(next, keyOf(prev));
166
+ return next;
167
+ }
168
+
163
169
  export interface RuleBuilderProps<F extends string = string> {
164
170
  value: RuleCondition<F>;
165
171
  /** ส่งต้นไม้ใหม่ทั้งก้อน (immutable) */
@@ -317,7 +323,10 @@ function ConditionNode<F extends string>({
317
323
  <ConditionNode
318
324
  value={item}
319
325
  onChange={(next) =>
320
- onChange({ ...value, items: value.items.map((x, j) => (j === i ? next : x)) })
326
+ onChange({
327
+ ...value,
328
+ items: value.items.map((x, j) => (j === i ? keepKey(x, next) : x)),
329
+ })
321
330
  }
322
331
  ctx={ctx}
323
332
  depth={depth + 1}
@@ -82,9 +82,14 @@ export function SettingsLayout({
82
82
  /** มือถือ: กลับไปรายการ — ปุ่ม "กลับ" ถูกซ่อน จึงคืนโฟกัสให้หัวข้อที่เพิ่งเปิด (ไม่งั้นโฟกัสหลุดไป body) */
83
83
  const back = (id: string) => {
84
84
  setMobileDetail(false);
85
- requestAnimationFrame(() =>
86
- navRef.current?.querySelector<HTMLElement>(`[data-section-id="${CSS.escape(id)}"]`)?.focus(),
87
- );
85
+ requestAnimationFrame(() => {
86
+ const nav = navRef.current;
87
+ // หัวข้อที่เปิดอยู่ถูกกรองออกจากรายการด้วยคำค้น → โฟกัสช่องค้นหา/หัวข้อแรกแทน (ไม่ปล่อยหลุดไป body)
88
+ const target =
89
+ nav?.querySelector<HTMLElement>(`[data-section-id="${CSS.escape(id)}"]`) ??
90
+ nav?.querySelector<HTMLElement>("input, [data-section-id]");
91
+ target?.focus();
92
+ });
88
93
  };
89
94
 
90
95
  if (!current) return null;