@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +31 -3
  3. package/package.json +5 -3
  4. package/src/components/app-shell/command-palette.tsx +14 -6
  5. package/src/components/data-table/data-table-pagination.tsx +7 -5
  6. package/src/components/data-table/data-table-sortable.tsx +5 -4
  7. package/src/components/data-table/data-table-toolbar.tsx +15 -4
  8. package/src/components/data-table/use-data-table.ts +38 -5
  9. package/src/components/design-system/design-system-showcase.tsx +1 -0
  10. package/src/components/pwa/pull-to-refresh.tsx +11 -4
  11. package/src/components/pwa/service-worker-updates.ts +26 -3
  12. package/src/components/shared/action-menu.tsx +26 -11
  13. package/src/components/shared/auth-parts.tsx +5 -3
  14. package/src/components/shared/button/button.tsx +110 -0
  15. package/src/components/shared/button/confirm-action-button.tsx +9 -13
  16. package/src/components/shared/button/index.ts +6 -0
  17. package/src/components/shared/chat-thread.tsx +13 -6
  18. package/src/components/shared/client-redirect.tsx +5 -9
  19. package/src/components/shared/dialogs-provider.tsx +31 -0
  20. package/src/components/shared/form/number-input.tsx +28 -13
  21. package/src/components/shared/form/tags-input.tsx +26 -21
  22. package/src/components/shared/form/time-picker.tsx +2 -2
  23. package/src/components/shared/month-stepper.tsx +4 -4
  24. package/src/components/shared/notice.tsx +1 -1
  25. package/src/components/shared/resource-property-filter.tsx +1 -1
  26. package/src/components/shared/row-actions.tsx +14 -8
  27. package/src/components/shared/rule-builder.tsx +11 -2
  28. package/src/components/shared/secret-reveal.tsx +1 -1
  29. package/src/components/shared/settings-layout.tsx +8 -3
  30. package/src/components/shared/simple-dialog.tsx +1 -1
  31. package/src/components/shared/simple-pagination.tsx +1 -1
  32. package/src/components/shared/simple-tooltip.tsx +11 -6
  33. package/src/components/shared/step-form.tsx +33 -13
  34. package/src/components/shared/theme/theme-switch.tsx +1 -1
  35. package/src/components/shared/theme/theme-toggle.tsx +5 -3
  36. package/src/components/shared/ui-text.tsx +25 -4
  37. package/src/components/shared/upload-list.tsx +1 -1
  38. package/src/hooks/use-table-search-params.ts +9 -4
  39. package/src/index.ts +7 -1
  40. package/src/lib/action-result.ts +33 -0
  41. package/src/lib/safe-url.ts +15 -0
  42. package/src/styles.css +21 -18
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { Button as ButtonBase } from "@suphark/ui/components/ui/button";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
5
+ import {
6
+ Children,
7
+ type ComponentProps,
8
+ createContext,
9
+ Fragment,
10
+ isValidElement,
11
+ type ReactNode,
12
+ useContext,
13
+ } from "react";
14
+
15
+ export type ButtonTooltipSide = "top" | "bottom" | "left" | "right";
16
+
17
+ export interface ButtonProps extends ComponentProps<typeof ButtonBase> {
18
+ /**
19
+ * ข้อความ tooltip — ไม่ใส่ = ใช้ `aria-label` (หรือ `<span className="sr-only">` แบบ shadcn)
20
+ * ให้เองเมื่อปุ่ม "มีแต่ไอคอน" (ไม่มี children ที่เป็นข้อความ)
21
+ * ใส่ข้อความ = ทับค่าอัตโนมัติ (ใช้กับปุ่มที่มีข้อความแล้วแต่ยังอยากอธิบายเพิ่ม) · `false` = ปิด
22
+ */
23
+ tooltip?: ReactNode | false;
24
+ tooltipSide?: ButtonTooltipSide;
25
+ /** หน่วงก่อนโชว์ (ms) — ไม่ใส่ = ตาม `TooltipProvider` ของแอป หรือ 600 ตามค่าของ Base UI */
26
+ tooltipDelay?: number;
27
+ }
28
+
29
+ const NoAutoTooltip = createContext(false);
30
+
31
+ /**
32
+ * ปิด tooltip อัตโนมัติของ `Button` ทั้งซับทรี — `SimpleTooltip` ใส่ให้แล้ว
33
+ * (ปุ่มที่ถูกห่อ tooltip อยู่แล้วจะไม่ขึ้นสองชั้น) ใช้เองได้เมื่อแอปห่อ tooltip ด้วยกลไกอื่น
34
+ */
35
+ export function ButtonTooltipSuppress({ children }: { children: ReactNode }) {
36
+ return <NoAutoTooltip.Provider value={true}>{children}</NoAutoTooltip.Provider>;
37
+ }
38
+
39
+ /** ข้อความใน `<span className="sr-only">` (รูปแบบของ shadcn ที่ใช้แทน aria-label) */
40
+ function srOnlyText(children: ReactNode): string | null {
41
+ let found: string | null = null;
42
+ Children.forEach(children, (child) => {
43
+ if (found || !isValidElement(child)) return;
44
+ const props = child.props as { className?: unknown; children?: ReactNode };
45
+ if (child.type === Fragment) {
46
+ found = srOnlyText(props.children);
47
+ return;
48
+ }
49
+ if (typeof props.className !== "string" || !props.className.split(/\s+/).includes("sr-only"))
50
+ return;
51
+ if (typeof props.children === "string" && props.children.trim() !== "")
52
+ found = props.children.trim();
53
+ });
54
+ return found;
55
+ }
56
+
57
+ /** มี children ที่เป็นข้อความจริง ๆ ไหม (ไอคอน/`<span className="sr-only">` ไม่นับ) */
58
+ function hasTextChild(children: ReactNode): boolean {
59
+ let found = false;
60
+ Children.forEach(children, (child) => {
61
+ if (found) return;
62
+ if (typeof child === "string") {
63
+ if (child.trim() !== "") found = true;
64
+ return;
65
+ }
66
+ if (typeof child === "number") {
67
+ found = true;
68
+ return;
69
+ }
70
+ // <>{icon} ข้อความ</> — Children.forEach ไม่ไล่เข้า fragment ให้
71
+ if (isValidElement(child) && child.type === Fragment) {
72
+ const inner = (child.props as { children?: ReactNode }).children;
73
+ if (hasTextChild(inner)) found = true;
74
+ }
75
+ });
76
+ return found;
77
+ }
78
+
79
+ /**
80
+ * ปุ่มของแพ็กเกจ = `Button` ของ shadcn + **tooltip อัตโนมัติสำหรับปุ่มไอคอน**
81
+ * มาตรฐานของทุกโครงการ: ไอคอนที่ไม่มีข้อความกำกับ ต้องบอกได้ว่าคืออะไรเมื่อวางเมาส์
82
+ * → ถ้ามี `aria-label` และ children ไม่มีข้อความ จะห่อ `Tooltip` ให้เอง (ทำที่นี่ที่เดียว
83
+ * call site ทุกที่จึงได้ทันที รวมถึงปุ่มที่เป็น trigger ของ dialog/menu เพราะปุ่มอยู่ชั้นในสุด)
84
+ * ปิดเป็นรายปุ่มด้วย `tooltip={false}`; ทับข้อความด้วย `tooltip="…"`
85
+ * ไฟล์ shadcn ดิบอยู่ที่ `components/ui/button.tsx` (ห้ามแก้มือ) — barrel export ตัวนี้
86
+ */
87
+ export function Button({ tooltip, tooltipSide = "top", tooltipDelay, ...props }: ButtonProps) {
88
+ const suppressed = useContext(NoAutoTooltip);
89
+ const label =
90
+ typeof props["aria-label"] === "string" && props["aria-label"].trim() !== ""
91
+ ? props["aria-label"]
92
+ : srOnlyText(props.children);
93
+ const explicit = tooltip !== undefined && tooltip !== null && tooltip !== "";
94
+ const auto =
95
+ !suppressed &&
96
+ tooltip === undefined &&
97
+ typeof label === "string" &&
98
+ !hasTextChild(props.children);
99
+ const content = tooltip === false ? null : explicit ? tooltip : auto ? label : null;
100
+
101
+ if (content === null || content === undefined) return <ButtonBase {...props} />;
102
+ return (
103
+ <Tooltip>
104
+ <TooltipTrigger delay={tooltipDelay} render={<ButtonBase {...props} />} />
105
+ <TooltipContent side={tooltipSide} className="max-w-xs text-pretty">
106
+ {content}
107
+ </TooltipContent>
108
+ </Tooltip>
109
+ );
110
+ }
@@ -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
  }
@@ -4,6 +4,12 @@
4
4
  */
5
5
  export { AddNewButton } from "./add-new-button";
6
6
  export { ActionButton, type ActionButtonProps } from "./base/action-button";
7
+ export {
8
+ Button,
9
+ type ButtonProps,
10
+ type ButtonTooltipSide,
11
+ ButtonTooltipSuppress,
12
+ } from "./button";
7
13
  export { CancelButton } from "./cancel-button";
8
14
  export { ConfirmButton } from "./confirm-button";
9
15
  export { CopyButton, type CopyButtonProps } from "./copy-button";
@@ -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, Ref } 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,9 +14,11 @@ 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
+ // มือถือ ≥16px (iOS ไม่ซูมตอนโฟกัส) — จอกว้างค่อยเล็กลง
21
+ sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-base md:text-sm" },
20
22
  default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-body" },
21
23
  } as const;
22
24
 
@@ -91,6 +93,16 @@ export function NumberInput({
91
93
  autoFocus?: boolean;
92
94
  }) {
93
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]);
94
106
  const s = SIZE[size];
95
107
  const buttonClass = cn(
96
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",
@@ -113,25 +125,16 @@ export function NumberInput({
113
125
  required={required}
114
126
  disabled={disabled}
115
127
  readOnly={readOnly}
116
- inputRef={relaxHiddenStep}
128
+ inputRef={hiddenRef}
117
129
  className={cn("w-full", className)}
118
130
  >
119
131
  <NumberField.Group
120
132
  data-slot="number-input"
121
133
  className={cn(
122
- "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",
123
135
  s.group,
124
136
  )}
125
137
  >
126
- {!hideButtons && (
127
- <NumberField.Decrement
128
- aria-label={text?.decrement ?? "ลด"}
129
- className={cn(buttonClass, "border-r border-input")}
130
- >
131
- <MinusIcon />
132
- </NumberField.Decrement>
133
- )}
134
- {prefix && <span className={cn(affix, !hideButtons && "pr-0")}>{prefix}</span>}
135
138
  <NumberField.Input
136
139
  ref={inputRef}
137
140
  autoFocus={autoFocus}
@@ -146,6 +149,18 @@ export function NumberInput({
146
149
  inputClassName,
147
150
  )}
148
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
+ )}
149
164
  {suffix && <span className={cn(affix, !hideButtons && "pl-0")}>{suffix}</span>}
150
165
  {!hideButtons && (
151
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-body outline-none placeholder:text-muted-foreground"
188
- />
189
194
  </div>
190
195
  {error && (
191
196
  <p id={errorId} role="alert" className="text-destructive text-xs">
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import {
5
5
  InputGroup,
6
6
  InputGroupAddon,
@@ -308,7 +308,7 @@ export function TimePicker({
308
308
  size="xs"
309
309
  variant="ghost"
310
310
  onClick={() => {
311
- setTime(nowTime());
311
+ setTime(roundTime(nowTime(), roundToStep ? minuteStep : 1));
312
312
  setDraft(null);
313
313
  }}
314
314
  >
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import {
5
5
  compareMonth,
6
6
  currentMonthKey,
@@ -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,
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
3
4
  import { Alert, AlertAction, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
4
- import { Button } from "@suphark/ui/components/ui/button";
5
5
  import { cn } from "@suphark/ui/lib/utils";
6
6
  import { CircleAlertIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, XIcon } from "lucide-react";
7
7
  import { type ComponentProps, type ReactNode, useState } from "react";
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
3
4
  import { Badge } from "@suphark/ui/components/ui/badge";
4
- import { Button } from "@suphark/ui/components/ui/button";
5
5
  import { Checkbox } from "@suphark/ui/components/ui/checkbox";
6
6
  import { Empty, EmptyHeader, EmptyTitle } from "@suphark/ui/components/ui/empty";
7
7
  import { Field, FieldGroup, FieldLabel } from "@suphark/ui/components/ui/field";
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import {
5
5
  DropdownMenu,
6
6
  DropdownMenuContent,
@@ -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
  </>
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import {
5
5
  NativeSelect,
6
6
  NativeSelectOptGroup,
@@ -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}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/ui/button";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { copyText } from "@suphark/ui/lib/copy-text";
5
5
  import { CheckIcon, CopyIcon } from "lucide-react";
6
6
  import { useState } from "react";
@@ -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;