@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
|
@@ -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
|
-
|
|
18
|
-
export
|
|
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(
|
|
162
|
-
|
|
165
|
+
const failed = failedResult(await onSend(sent.trim()), failedText ?? "ดำเนินการไม่สำเร็จ");
|
|
166
|
+
if (failed) toast.error(failed);
|
|
167
|
+
else clearSent();
|
|
163
168
|
} catch (err) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} catch {
|
|
13
|
+
const url = safeNavigableUrl(to);
|
|
14
|
+
if (!url) {
|
|
15
|
+
console.error("ClientRedirect: ปฏิเสธปลายทางที่ไม่ใช่ http(s)");
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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/
|
|
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({
|
|
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/
|
|
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
|
|
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;
|