@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.
- package/CHANGELOG.md +61 -0
- package/README.md +4 -3
- package/package.json +5 -4
- package/src/components/app-shell/command-palette.tsx +13 -5
- package/src/components/data-table/data-table-sortable.tsx +5 -4
- package/src/components/data-table/data-table-toolbar.tsx +14 -3
- package/src/components/data-table/use-data-table.ts +38 -5
- package/src/components/design-system/design-system-showcase.tsx +64 -15
- 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 +25 -10
- package/src/components/shared/auth-parts.tsx +4 -2
- package/src/components/shared/button/base/action-button.tsx +7 -14
- package/src/components/shared/button/base/link-button.tsx +5 -14
- package/src/components/shared/button/confirm-action-button.tsx +9 -13
- 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 +36 -14
- package/src/components/shared/form/tags-input.tsx +26 -21
- package/src/components/shared/form/time-picker.tsx +12 -2
- package/src/components/shared/month-stepper.tsx +3 -3
- package/src/components/shared/row-actions.tsx +13 -7
- package/src/components/shared/rule-builder.tsx +10 -1
- package/src/components/shared/settings-layout.tsx +8 -3
- package/src/components/shared/step-form.tsx +33 -13
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/native-select.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/textarea.tsx +1 -1
- package/src/hooks/use-table-search-params.ts +9 -4
- package/src/lib/action-result.ts +33 -0
- package/src/lib/safe-url.ts +15 -0
- 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
|
-
<
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
<
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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,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
|
-
|
|
20
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
</>
|
|
@@ -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}
|
|
@@ -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;
|