@suphark/ui 0.23.0 → 0.25.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 +36 -0
- package/package.json +1 -1
- package/src/components/data-table/index.ts +9 -9
- package/src/components/shared/auth-layout.tsx +6 -1
- package/src/components/shared/form/number-input.tsx +144 -0
- package/src/components/shared/form/tags-input.tsx +192 -0
- package/src/components/shared/form/textarea-field.tsx +109 -0
- package/src/components/shared/form/time-picker.tsx +421 -0
- package/src/components/shared/initials.tsx +12 -2
- package/src/components/shared/notice.tsx +54 -4
- package/src/components/shared/presence-dot.tsx +31 -0
- package/src/components/shared/skeletons.tsx +176 -0
- package/src/components/shared/step-form.tsx +283 -0
- package/src/components/shared/ui-text.tsx +77 -0
- package/src/components/shared/user-avatars.tsx +68 -0
- package/src/components/shared/user-identity.tsx +5 -0
- package/src/data-table.ts +12 -1
- package/src/index.ts +434 -66
- package/src/next.ts +38 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Skeleton } from "@suphark/ui/components/ui/skeleton";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { useUiText } from "./ui-text";
|
|
7
|
+
|
|
8
|
+
/** ความกว้างสลับกันให้ดูเป็นข้อความจริง (คงที่ทุก render — ไม่ใช้ random กัน hydration mismatch) */
|
|
9
|
+
const WIDTHS = ["w-3/4", "w-1/2", "w-2/3", "w-5/6", "w-2/5"] as const;
|
|
10
|
+
const widthAt = (i: number) => WIDTHS[i % WIDTHS.length];
|
|
11
|
+
|
|
12
|
+
const GRID_COLS: Record<number, string> = {
|
|
13
|
+
1: "",
|
|
14
|
+
2: "sm:grid-cols-2",
|
|
15
|
+
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
16
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** กล่องนอกของ skeleton: role=status + ข้อความ "กำลังโหลด…" สำหรับ screen reader */
|
|
20
|
+
function LoadingRegion({
|
|
21
|
+
slot,
|
|
22
|
+
label,
|
|
23
|
+
className,
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
slot: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}) {
|
|
31
|
+
const text = useUiText().loading ?? "กำลังโหลด…";
|
|
32
|
+
return (
|
|
33
|
+
<div data-slot={slot} role="status" aria-busy="true" className={className}>
|
|
34
|
+
<span className="sr-only">{label ?? text}</span>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* โครงตารางระหว่างโหลด (loading.tsx ของหน้ารายการ) — หัวตาราง + แถวตามจำนวน คอลัมน์แรกกว้างกว่า
|
|
42
|
+
*
|
|
43
|
+
* export default function Loading() { return <TableSkeleton rows={8} columns={5} /> }
|
|
44
|
+
*/
|
|
45
|
+
export function TableSkeleton({
|
|
46
|
+
rows = 5,
|
|
47
|
+
columns = 4,
|
|
48
|
+
header = true,
|
|
49
|
+
label,
|
|
50
|
+
className,
|
|
51
|
+
}: {
|
|
52
|
+
rows?: number;
|
|
53
|
+
columns?: number;
|
|
54
|
+
/** แสดงแถวหัวตาราง */
|
|
55
|
+
header?: boolean;
|
|
56
|
+
/** ข้อความ screen reader (ค่าเริ่มต้น UiText.loading) */
|
|
57
|
+
label?: string;
|
|
58
|
+
className?: string;
|
|
59
|
+
}) {
|
|
60
|
+
const template = { gridTemplateColumns: `2fr repeat(${Math.max(columns - 1, 0)}, 1fr)` };
|
|
61
|
+
const cols = Array.from({ length: columns }, (_, c) => c);
|
|
62
|
+
return (
|
|
63
|
+
<LoadingRegion
|
|
64
|
+
slot="table-skeleton"
|
|
65
|
+
label={label}
|
|
66
|
+
className={cn("overflow-hidden rounded-lg border", className)}
|
|
67
|
+
>
|
|
68
|
+
{header && (
|
|
69
|
+
<div className="grid items-center gap-4 border-b bg-muted/50 px-3 py-2.5" style={template}>
|
|
70
|
+
{cols.map((c) => (
|
|
71
|
+
<Skeleton key={c} className="h-3.5 w-1/2" />
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
{Array.from({ length: rows }, (_, r) => (
|
|
76
|
+
<div
|
|
77
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: แถวโครงคงที่ ไม่มีการเรียงใหม่
|
|
78
|
+
key={r}
|
|
79
|
+
className="grid items-center gap-4 border-b px-3 py-3 last:border-b-0"
|
|
80
|
+
style={template}
|
|
81
|
+
>
|
|
82
|
+
{cols.map((c) => (
|
|
83
|
+
<Skeleton key={c} className={cn("h-4", widthAt(r + c))} />
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
))}
|
|
87
|
+
</LoadingRegion>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* โครงรายการระหว่างโหลด — avatar (เลือกได้) + ข้อความ 1–2 บรรทัดต่อแถว
|
|
93
|
+
*
|
|
94
|
+
* <ListSkeleton rows={4} avatar lines={2} />
|
|
95
|
+
*/
|
|
96
|
+
export function ListSkeleton({
|
|
97
|
+
rows = 5,
|
|
98
|
+
avatar = true,
|
|
99
|
+
lines = 2,
|
|
100
|
+
label,
|
|
101
|
+
className,
|
|
102
|
+
}: {
|
|
103
|
+
rows?: number;
|
|
104
|
+
avatar?: boolean;
|
|
105
|
+
/** จำนวนบรรทัดข้อความต่อแถว (1 หรือ 2) */
|
|
106
|
+
lines?: 1 | 2;
|
|
107
|
+
label?: string;
|
|
108
|
+
className?: string;
|
|
109
|
+
}) {
|
|
110
|
+
return (
|
|
111
|
+
<LoadingRegion
|
|
112
|
+
slot="list-skeleton"
|
|
113
|
+
label={label}
|
|
114
|
+
className={cn("flex flex-col gap-4", className)}
|
|
115
|
+
>
|
|
116
|
+
{Array.from({ length: rows }, (_, r) => (
|
|
117
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: แถวโครงคงที่ ไม่มีการเรียงใหม่
|
|
118
|
+
<div key={r} className="flex items-center gap-3">
|
|
119
|
+
{avatar && <Skeleton className="size-9 shrink-0 rounded-full" />}
|
|
120
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
121
|
+
<Skeleton className={cn("h-4", widthAt(r))} />
|
|
122
|
+
{lines === 2 && <Skeleton className={cn("h-3", widthAt(r + 2))} />}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</LoadingRegion>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* โครงการ์ดระหว่างโหลด — การ์ดตัวเลข (`variant="stat"` ค่าเริ่มต้น แบบ StatGrid) หรือการ์ดเนื้อหา (`variant="content"`)
|
|
132
|
+
*
|
|
133
|
+
* <CardSkeleton count={4} /> <CardSkeleton count={3} variant="content" />
|
|
134
|
+
*/
|
|
135
|
+
export function CardSkeleton({
|
|
136
|
+
count = 3,
|
|
137
|
+
variant = "stat",
|
|
138
|
+
label,
|
|
139
|
+
className,
|
|
140
|
+
}: {
|
|
141
|
+
count?: number;
|
|
142
|
+
variant?: "stat" | "content";
|
|
143
|
+
label?: string;
|
|
144
|
+
className?: string;
|
|
145
|
+
}) {
|
|
146
|
+
return (
|
|
147
|
+
<LoadingRegion
|
|
148
|
+
slot="card-skeleton"
|
|
149
|
+
label={label}
|
|
150
|
+
className={cn("grid gap-4", GRID_COLS[Math.min(Math.max(count, 1), 4)], className)}
|
|
151
|
+
>
|
|
152
|
+
{Array.from({ length: count }, (_, i) => (
|
|
153
|
+
<div
|
|
154
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: การ์ดโครงคงที่ ไม่มีการเรียงใหม่
|
|
155
|
+
key={i}
|
|
156
|
+
className="flex flex-col gap-3 rounded-xl border bg-card p-4"
|
|
157
|
+
>
|
|
158
|
+
{variant === "stat" ? (
|
|
159
|
+
<>
|
|
160
|
+
<Skeleton className="h-3.5 w-1/2" />
|
|
161
|
+
<Skeleton className="h-7 w-2/3" />
|
|
162
|
+
<Skeleton className="h-3 w-1/3" />
|
|
163
|
+
</>
|
|
164
|
+
) : (
|
|
165
|
+
<>
|
|
166
|
+
<Skeleton className="h-5 w-2/3" />
|
|
167
|
+
<Skeleton className="h-3.5 w-full" />
|
|
168
|
+
<Skeleton className={cn("h-3.5", widthAt(i))} />
|
|
169
|
+
<Skeleton className="mt-2 h-8 w-24" />
|
|
170
|
+
</>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
))}
|
|
174
|
+
</LoadingRegion>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { CheckIcon } from "lucide-react";
|
|
7
|
+
import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useUiText } from "./ui-text";
|
|
9
|
+
|
|
10
|
+
export interface StepFormStep {
|
|
11
|
+
id: string;
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
description?: ReactNode;
|
|
14
|
+
/** ช่องกรอกของขั้นนี้ — ใส่ `name` ตามปกติ ค่าทุกขั้นรวมใน FormData เดียวตอนส่ง */
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
/** ตรวจเพิ่มจาก required/pattern ของ input — คืน string = ข้อความ error (ไม่ไปต่อ) */
|
|
17
|
+
validate?: (formData: FormData) => string | null | undefined | Promise<string | null | undefined>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CONTROLS = "input, select, textarea";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ตรวจ constraint ของ input ในกล่อง (required, pattern, min…) — แสดง bubble ของเบราว์เซอร์ที่ช่องแรกที่ผิด
|
|
24
|
+
* input ที่ซ่อนไว้ (ตัวแทนของ Base UI เช่น NumberField, aria-hidden) แสดง bubble ไม่ได้ → คืนข้อความให้แสดงใต้ฟอร์มแทน
|
|
25
|
+
* คืน true = ผ่าน, string = ข้อความ error, false = แสดง bubble แล้ว
|
|
26
|
+
*/
|
|
27
|
+
function reportSection(section: HTMLElement | null): true | false | string {
|
|
28
|
+
if (!section) return true;
|
|
29
|
+
for (const el of section.querySelectorAll<HTMLInputElement>(CONTROLS)) {
|
|
30
|
+
if (el.type === "hidden" || el.disabled) continue;
|
|
31
|
+
if (!el.checkValidity()) {
|
|
32
|
+
if (el.getAttribute("aria-hidden") === "true") {
|
|
33
|
+
// โฟกัสช่องที่มองเห็นในกลุ่มเดียวกัน (ถ้ามี)
|
|
34
|
+
el.parentElement?.querySelector<HTMLElement>("input:not([aria-hidden=true])")?.focus();
|
|
35
|
+
return el.validationMessage || "ค่าไม่ถูกต้อง";
|
|
36
|
+
}
|
|
37
|
+
el.reportValidity();
|
|
38
|
+
el.focus();
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ฟอร์มหลายขั้น (wizard) สำเร็จรูป — แถบขั้นตอน + เนื้อหาทีละขั้น + ปุ่มย้อนกลับ/ถัดไป/ส่ง ในตัว
|
|
47
|
+
* ทุกขั้นอยู่ใน `<form>` เดียว (ขั้นที่ไม่แสดงถูกซ่อนไว้ ค่ายังอยู่) → `onSubmit(formData)` ได้ค่าครบทุกขั้น ส่งต่อ server action ได้ตรง ๆ
|
|
48
|
+
* กด "ถัดไป" ตรวจ required/pattern ของขั้นนั้น + `validate` ก่อนไปต่อ; ขั้นที่ผ่านแล้วกดที่แถบเพื่อย้อนกลับได้
|
|
49
|
+
* ขั้นสุดท้ายใช้เป็นหน้าตรวจทานได้ (content อ่านค่าจาก state ของแอปเอง)
|
|
50
|
+
*
|
|
51
|
+
* <StepForm onSubmit={createApp} submitLabel="สร้างแอป" steps={[
|
|
52
|
+
* { id: "basics", title: "ข้อมูลพื้นฐาน", content: <LabeledField label="ชื่อ"><Input name="name" required /></LabeledField> },
|
|
53
|
+
* { id: "oauth", title: "OAuth", content: <TagsInput name="redirectUris" /> ,
|
|
54
|
+
* validate: (fd) => (fd.getAll("redirectUris").length ? null : "ต้องมี redirect URI อย่างน้อย 1 รายการ") },
|
|
55
|
+
* ]} />
|
|
56
|
+
*/
|
|
57
|
+
export function StepForm({
|
|
58
|
+
steps,
|
|
59
|
+
onSubmit,
|
|
60
|
+
submitLabel,
|
|
61
|
+
initialStep = 0,
|
|
62
|
+
onStepChange,
|
|
63
|
+
footer,
|
|
64
|
+
className,
|
|
65
|
+
}: {
|
|
66
|
+
steps: ReadonlyArray<StepFormStep>;
|
|
67
|
+
/** เรียกเมื่อกดส่งที่ขั้นสุดท้าย — throw / reject = แสดงข้อความ error ใต้ฟอร์ม */
|
|
68
|
+
onSubmit: (formData: FormData) => unknown | Promise<unknown>;
|
|
69
|
+
submitLabel?: ReactNode;
|
|
70
|
+
initialStep?: number;
|
|
71
|
+
onStepChange?: (index: number, id: string) => void;
|
|
72
|
+
/** ต่อท้ายแถวปุ่มฝั่งซ้าย เช่น ปุ่มยกเลิก */
|
|
73
|
+
footer?: ReactNode;
|
|
74
|
+
className?: string;
|
|
75
|
+
}) {
|
|
76
|
+
const ui = useUiText();
|
|
77
|
+
const text = ui.stepForm;
|
|
78
|
+
const [current, setCurrent] = useState(initialStep);
|
|
79
|
+
const [reached, setReached] = useState(initialStep);
|
|
80
|
+
const [error, setError] = useState<string | null>(null);
|
|
81
|
+
const [pending, setPending] = useState(false);
|
|
82
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
83
|
+
const sectionRefs = useRef<(HTMLElement | null)[]>([]);
|
|
84
|
+
const headingRefs = useRef<(HTMLHeadingElement | null)[]>([]);
|
|
85
|
+
const moved = useRef(false);
|
|
86
|
+
const last = steps.length - 1;
|
|
87
|
+
const step = steps[current];
|
|
88
|
+
|
|
89
|
+
// ย้ายโฟกัสไปหัวข้อของขั้นใหม่ (screen reader รู้ว่าเปลี่ยนขั้น) — ไม่ทำตอน mount
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!moved.current) return;
|
|
92
|
+
headingRefs.current[current]?.focus();
|
|
93
|
+
}, [current]);
|
|
94
|
+
|
|
95
|
+
const go = (index: number) => {
|
|
96
|
+
moved.current = true;
|
|
97
|
+
setError(null);
|
|
98
|
+
setCurrent(index);
|
|
99
|
+
setReached((r) => Math.max(r, index));
|
|
100
|
+
const target = steps[index];
|
|
101
|
+
if (target) onStepChange?.(index, target.id);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** ตรวจขั้น index — คืน true ถ้าผ่าน */
|
|
105
|
+
const check = async (index: number): Promise<boolean> => {
|
|
106
|
+
const native = reportSection(sectionRefs.current[index] ?? null);
|
|
107
|
+
if (native !== true) {
|
|
108
|
+
if (typeof native === "string") setError(native);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const s = steps[index];
|
|
112
|
+
if (s?.validate && formRef.current) {
|
|
113
|
+
const message = await s.validate(new FormData(formRef.current));
|
|
114
|
+
if (message) {
|
|
115
|
+
setError(message);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
if (pending) return;
|
|
125
|
+
setError(null);
|
|
126
|
+
if (current < last) {
|
|
127
|
+
if (await check(current)) go(current + 1);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// ขั้นสุดท้าย: ตรวจทุกขั้นอีกครั้งแบบเงียบ ขั้นไหนไม่ผ่านพากลับไปขั้นนั้นแล้วค่อยแสดงจุดที่ผิด
|
|
131
|
+
for (let i = 0; i <= last; i++) {
|
|
132
|
+
const section = sectionRefs.current[i];
|
|
133
|
+
const invalid = section
|
|
134
|
+
? [...section.querySelectorAll<HTMLInputElement>(CONTROLS)].some(
|
|
135
|
+
(el) => el.type !== "hidden" && !el.disabled && !el.checkValidity(),
|
|
136
|
+
)
|
|
137
|
+
: false;
|
|
138
|
+
const s = steps[i];
|
|
139
|
+
const message =
|
|
140
|
+
!invalid && s?.validate && formRef.current
|
|
141
|
+
? await s.validate(new FormData(formRef.current))
|
|
142
|
+
: null;
|
|
143
|
+
if (invalid || message) {
|
|
144
|
+
if (i !== current) go(i);
|
|
145
|
+
if (message) setError(message);
|
|
146
|
+
else
|
|
147
|
+
requestAnimationFrame(() => {
|
|
148
|
+
const native = reportSection(sectionRefs.current[i] ?? null);
|
|
149
|
+
if (typeof native === "string") setError(native);
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!formRef.current) return;
|
|
155
|
+
setPending(true);
|
|
156
|
+
try {
|
|
157
|
+
await onSubmit(new FormData(formRef.current));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
setError(
|
|
160
|
+
err instanceof Error && err.message ? err.message : (ui.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
|
|
161
|
+
);
|
|
162
|
+
} finally {
|
|
163
|
+
setPending(false);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<form
|
|
169
|
+
ref={formRef}
|
|
170
|
+
noValidate
|
|
171
|
+
onSubmit={handleSubmit}
|
|
172
|
+
data-slot="step-form"
|
|
173
|
+
className={cn("flex flex-col gap-6", className)}
|
|
174
|
+
>
|
|
175
|
+
<nav aria-label={text?.label ?? "ขั้นตอน"}>
|
|
176
|
+
<p className="mb-2 text-muted-foreground text-xs sm:hidden">
|
|
177
|
+
{(text?.progress ?? "ขั้นที่ {current} จาก {total}")
|
|
178
|
+
.replace("{current}", String(current + 1))
|
|
179
|
+
.replace("{total}", String(steps.length))}
|
|
180
|
+
</p>
|
|
181
|
+
<ol className="flex items-center gap-2">
|
|
182
|
+
{steps.map((s, i) => {
|
|
183
|
+
const done = i < reached;
|
|
184
|
+
const active = i === current;
|
|
185
|
+
const clickable = i <= reached && !active && !pending;
|
|
186
|
+
return (
|
|
187
|
+
<li
|
|
188
|
+
key={s.id}
|
|
189
|
+
className={cn("flex min-w-0 items-center gap-2", i < last && "flex-1")}
|
|
190
|
+
>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
disabled={!clickable}
|
|
194
|
+
aria-current={active ? "step" : undefined}
|
|
195
|
+
onClick={() => go(i)}
|
|
196
|
+
className="flex min-w-0 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-default"
|
|
197
|
+
>
|
|
198
|
+
<span
|
|
199
|
+
className={cn(
|
|
200
|
+
"flex size-7 shrink-0 items-center justify-center rounded-full border text-sm tabular-nums transition-colors",
|
|
201
|
+
active && "border-primary bg-primary text-primary-foreground",
|
|
202
|
+
!active && done && "border-primary text-primary",
|
|
203
|
+
!active && !done && "text-muted-foreground",
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
{!active && done ? <CheckIcon className="size-4" /> : i + 1}
|
|
207
|
+
</span>
|
|
208
|
+
<span
|
|
209
|
+
className={cn(
|
|
210
|
+
"hidden truncate text-sm sm:inline",
|
|
211
|
+
active ? "font-medium" : "text-muted-foreground",
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{s.title}
|
|
215
|
+
</span>
|
|
216
|
+
</button>
|
|
217
|
+
{i < last && (
|
|
218
|
+
<span
|
|
219
|
+
aria-hidden
|
|
220
|
+
className={cn("h-px min-w-4 flex-1", i < reached ? "bg-primary" : "bg-border")}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</li>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</ol>
|
|
227
|
+
</nav>
|
|
228
|
+
|
|
229
|
+
{steps.map((s, i) => (
|
|
230
|
+
<section
|
|
231
|
+
key={s.id}
|
|
232
|
+
ref={(el) => {
|
|
233
|
+
sectionRefs.current[i] = el;
|
|
234
|
+
}}
|
|
235
|
+
hidden={i !== current}
|
|
236
|
+
aria-labelledby={`${s.id}-step-title`}
|
|
237
|
+
className="flex flex-col gap-4"
|
|
238
|
+
>
|
|
239
|
+
<div className="flex flex-col gap-1">
|
|
240
|
+
<h2
|
|
241
|
+
id={`${s.id}-step-title`}
|
|
242
|
+
ref={(el) => {
|
|
243
|
+
headingRefs.current[i] = el;
|
|
244
|
+
}}
|
|
245
|
+
tabIndex={-1}
|
|
246
|
+
className="font-semibold text-base outline-none"
|
|
247
|
+
>
|
|
248
|
+
{s.title}
|
|
249
|
+
</h2>
|
|
250
|
+
{s.description && <p className="text-muted-foreground text-sm">{s.description}</p>}
|
|
251
|
+
</div>
|
|
252
|
+
{s.content}
|
|
253
|
+
</section>
|
|
254
|
+
))}
|
|
255
|
+
|
|
256
|
+
{error && (
|
|
257
|
+
<p role="alert" className="text-destructive text-sm">
|
|
258
|
+
{error}
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
|
263
|
+
{footer}
|
|
264
|
+
<div className="ml-auto flex gap-2">
|
|
265
|
+
{current > 0 && (
|
|
266
|
+
<Button
|
|
267
|
+
type="button"
|
|
268
|
+
variant="outline"
|
|
269
|
+
disabled={pending}
|
|
270
|
+
onClick={() => go(current - 1)}
|
|
271
|
+
>
|
|
272
|
+
{text?.previous ?? "ย้อนกลับ"}
|
|
273
|
+
</Button>
|
|
274
|
+
)}
|
|
275
|
+
<Button type="submit" disabled={pending || !step}>
|
|
276
|
+
{pending && <Spinner />}
|
|
277
|
+
{current < last ? (text?.next ?? "ถัดไป") : (submitLabel ?? text?.submit ?? "ส่ง")}
|
|
278
|
+
</Button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</form>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
@@ -83,6 +83,33 @@ export interface UiText {
|
|
|
83
83
|
chat?: Partial<{ placeholder: string; send: string; typing: string; scrollToLatest: string }>;
|
|
84
84
|
/** SimplePagination (optional) */
|
|
85
85
|
pagination?: Partial<{ previous: string; next: string; label: string; page: string }>;
|
|
86
|
+
/** TableSkeleton/ListSkeleton/CardSkeleton (optional) — ข้อความสำหรับ screen reader */
|
|
87
|
+
loading?: string;
|
|
88
|
+
/** สถานะออนไลน์ของ UserIdentity/Initials (optional) */
|
|
89
|
+
presence?: Partial<{ online: string; away: string; busy: string; offline: string }>;
|
|
90
|
+
/** TextareaField (optional) — ข้อความ screen reader ของตัวนับ ใช้ {count} {max} */
|
|
91
|
+
characterCount?: string;
|
|
92
|
+
/** NumberInput (optional) */
|
|
93
|
+
number?: Partial<{ increment: string; decrement: string }>;
|
|
94
|
+
/** TimePicker / DateTimePicker (optional) */
|
|
95
|
+
time?: Partial<{
|
|
96
|
+
choose: string;
|
|
97
|
+
hour: string;
|
|
98
|
+
minute: string;
|
|
99
|
+
now: string;
|
|
100
|
+
clear: string;
|
|
101
|
+
dateTimePlaceholder: string;
|
|
102
|
+
}>;
|
|
103
|
+
/** TagsInput (optional) — remove ใช้ {tag}, duplicate ใช้ {tag}, max ใช้ {max} */
|
|
104
|
+
tags?: Partial<{ placeholder: string; remove: string; duplicate: string; max: string }>;
|
|
105
|
+
/** StepForm (optional) — progress ใช้ {current} {total} */
|
|
106
|
+
stepForm?: Partial<{
|
|
107
|
+
label: string;
|
|
108
|
+
previous: string;
|
|
109
|
+
next: string;
|
|
110
|
+
submit: string;
|
|
111
|
+
progress: string;
|
|
112
|
+
}>;
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
export const UI_TEXT_TH: UiText = {
|
|
@@ -147,6 +174,31 @@ export const UI_TEXT_TH: UiText = {
|
|
|
147
174
|
scrollToLatest: "ไปข้อความล่าสุด",
|
|
148
175
|
},
|
|
149
176
|
pagination: { previous: "ก่อนหน้า", next: "ถัดไป", label: "แบ่งหน้า", page: "หน้า" },
|
|
177
|
+
loading: "กำลังโหลด…",
|
|
178
|
+
presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
|
|
179
|
+
characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
|
|
180
|
+
number: { increment: "เพิ่ม", decrement: "ลด" },
|
|
181
|
+
time: {
|
|
182
|
+
choose: "เลือกเวลา",
|
|
183
|
+
hour: "ชั่วโมง",
|
|
184
|
+
minute: "นาที",
|
|
185
|
+
now: "ตอนนี้",
|
|
186
|
+
clear: "ล้าง",
|
|
187
|
+
dateTimePlaceholder: "เลือกวันและเวลา",
|
|
188
|
+
},
|
|
189
|
+
tags: {
|
|
190
|
+
placeholder: "พิมพ์แล้วกด Enter",
|
|
191
|
+
remove: "ลบ {tag}",
|
|
192
|
+
duplicate: "มี “{tag}” แล้ว",
|
|
193
|
+
max: "เพิ่มได้สูงสุด {max} รายการ",
|
|
194
|
+
},
|
|
195
|
+
stepForm: {
|
|
196
|
+
label: "ขั้นตอน",
|
|
197
|
+
previous: "ย้อนกลับ",
|
|
198
|
+
next: "ถัดไป",
|
|
199
|
+
submit: "ส่ง",
|
|
200
|
+
progress: "ขั้นที่ {current} จาก {total}",
|
|
201
|
+
},
|
|
150
202
|
};
|
|
151
203
|
|
|
152
204
|
export const UI_TEXT_EN: UiText = {
|
|
@@ -211,6 +263,31 @@ export const UI_TEXT_EN: UiText = {
|
|
|
211
263
|
scrollToLatest: "Jump to latest",
|
|
212
264
|
},
|
|
213
265
|
pagination: { previous: "Previous", next: "Next", label: "Pagination", page: "Page" },
|
|
266
|
+
loading: "Loading…",
|
|
267
|
+
presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
|
|
268
|
+
characterCount: "{count} of {max} characters",
|
|
269
|
+
number: { increment: "Increase", decrement: "Decrease" },
|
|
270
|
+
time: {
|
|
271
|
+
choose: "Choose time",
|
|
272
|
+
hour: "Hour",
|
|
273
|
+
minute: "Minute",
|
|
274
|
+
now: "Now",
|
|
275
|
+
clear: "Clear",
|
|
276
|
+
dateTimePlaceholder: "Pick date and time",
|
|
277
|
+
},
|
|
278
|
+
tags: {
|
|
279
|
+
placeholder: "Type and press Enter",
|
|
280
|
+
remove: "Remove {tag}",
|
|
281
|
+
duplicate: "“{tag}” is already added",
|
|
282
|
+
max: "Up to {max} items",
|
|
283
|
+
},
|
|
284
|
+
stepForm: {
|
|
285
|
+
label: "Steps",
|
|
286
|
+
previous: "Back",
|
|
287
|
+
next: "Next",
|
|
288
|
+
submit: "Submit",
|
|
289
|
+
progress: "Step {current} of {total}",
|
|
290
|
+
},
|
|
214
291
|
};
|
|
215
292
|
|
|
216
293
|
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { AvatarGroup, AvatarGroupCount } from "@suphark/ui/components/ui/avatar";
|
|
2
|
+
import { Initials } from "./initials";
|
|
3
|
+
import type { Presence } from "./presence-dot";
|
|
4
|
+
|
|
5
|
+
export interface UserAvatarItem {
|
|
6
|
+
/** key ของ React (ไม่ส่ง = ใช้ชื่อ) */
|
|
7
|
+
id?: string;
|
|
8
|
+
name: string | null | undefined;
|
|
9
|
+
src?: string | null;
|
|
10
|
+
status?: Presence;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* รูปผู้ใช้ซ้อนกัน + "+N" — ผู้รับผิดชอบงาน, สมาชิกทีม, คนที่ดูอยู่ (แทน AvatarGroup + Avatar + AvatarImage + AvatarFallback + AvatarGroupCount)
|
|
15
|
+
* ไม่มีรูป = ตัวย่อชื่อสีคงที่ต่อชื่อ; ชี้ที่รูปเห็นชื่อ, ที่ "+N" เห็นชื่อที่เหลือ; screen reader อ่านชื่อทั้งหมด
|
|
16
|
+
* ใช้จาก server component ได้
|
|
17
|
+
*
|
|
18
|
+
* <UserAvatars users={task.assignees} max={3} size="sm" />
|
|
19
|
+
*/
|
|
20
|
+
export function UserAvatars({
|
|
21
|
+
users,
|
|
22
|
+
max = 4,
|
|
23
|
+
size = "default",
|
|
24
|
+
label,
|
|
25
|
+
className,
|
|
26
|
+
}: {
|
|
27
|
+
users: ReadonlyArray<UserAvatarItem>;
|
|
28
|
+
/** จำนวนรูปที่แสดงก่อนรวบเป็น "+N" (นับรวมช่อง +N) */
|
|
29
|
+
max?: number;
|
|
30
|
+
size?: "sm" | "default" | "lg";
|
|
31
|
+
/** ข้อความนำหน้ารายชื่อสำหรับ screen reader เช่น "ผู้รับผิดชอบ" */
|
|
32
|
+
label?: string;
|
|
33
|
+
className?: string;
|
|
34
|
+
}) {
|
|
35
|
+
const overflow = users.length > max;
|
|
36
|
+
const shown = overflow ? users.slice(0, Math.max(max - 1, 1)) : users;
|
|
37
|
+
const rest = users.slice(shown.length);
|
|
38
|
+
const names = users.map((u) => u.name?.trim() || "—").join(", ");
|
|
39
|
+
return (
|
|
40
|
+
<AvatarGroup
|
|
41
|
+
data-slot="user-avatars"
|
|
42
|
+
role="group"
|
|
43
|
+
aria-label={label ? `${label}: ${names}` : names}
|
|
44
|
+
className={className}
|
|
45
|
+
>
|
|
46
|
+
{shown.map((u, i) => (
|
|
47
|
+
<Initials
|
|
48
|
+
key={u.id ?? `${u.name ?? ""}-${i}`}
|
|
49
|
+
name={u.name}
|
|
50
|
+
src={u.src}
|
|
51
|
+
status={u.status}
|
|
52
|
+
size={size}
|
|
53
|
+
title={u.name ?? undefined}
|
|
54
|
+
aria-hidden
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
{rest.length > 0 && (
|
|
58
|
+
<AvatarGroupCount
|
|
59
|
+
aria-hidden
|
|
60
|
+
title={rest.map((u) => u.name?.trim() || "—").join(", ")}
|
|
61
|
+
className="text-label font-medium"
|
|
62
|
+
>
|
|
63
|
+
+{rest.length}
|
|
64
|
+
</AvatarGroupCount>
|
|
65
|
+
)}
|
|
66
|
+
</AvatarGroup>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Initials } from "@suphark/ui/components/shared/initials";
|
|
2
2
|
import { cn } from "@suphark/ui/lib/utils";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
|
+
import type { Presence } from "./presence-dot";
|
|
4
5
|
|
|
5
6
|
export interface UserIdentityProps {
|
|
6
7
|
name: string | null | undefined;
|
|
@@ -14,6 +15,8 @@ export interface UserIdentityProps {
|
|
|
14
15
|
hint?: ReactNode;
|
|
15
16
|
/** sm = ใช้ในแถวตาราง (avatar size-7), default = size-9 */
|
|
16
17
|
size?: "sm" | "default";
|
|
18
|
+
/** จุดสถานะบน avatar: online / away / busy / offline */
|
|
19
|
+
status?: Presence;
|
|
17
20
|
/** ซ่อน avatar (เมื่อแถวมีรูปอยู่แล้ว) */
|
|
18
21
|
hideAvatar?: boolean;
|
|
19
22
|
className?: string;
|
|
@@ -30,6 +33,7 @@ export function UserIdentity({
|
|
|
30
33
|
badge,
|
|
31
34
|
hint,
|
|
32
35
|
size = "default",
|
|
36
|
+
status,
|
|
33
37
|
hideAvatar = false,
|
|
34
38
|
className,
|
|
35
39
|
}: UserIdentityProps) {
|
|
@@ -43,6 +47,7 @@ export function UserIdentity({
|
|
|
43
47
|
<Initials
|
|
44
48
|
name={displayName ?? (typeof email === "string" ? email : "")}
|
|
45
49
|
src={src}
|
|
50
|
+
status={status}
|
|
46
51
|
className={size === "sm" ? "size-7" : "size-9"}
|
|
47
52
|
/>
|
|
48
53
|
)}
|
package/src/data-table.ts
CHANGED
|
@@ -7,7 +7,18 @@
|
|
|
7
7
|
* ```
|
|
8
8
|
* state ของตารางใน URL (`useTableSearchParams`) ต้องใช้ next/navigation → อยู่ที่ `@suphark/ui/next`
|
|
9
9
|
*/
|
|
10
|
-
export
|
|
10
|
+
export {
|
|
11
|
+
DataTable,
|
|
12
|
+
DataTableColumnHeader,
|
|
13
|
+
DataTableDateRangeFilter,
|
|
14
|
+
DataTableFacetedFilter,
|
|
15
|
+
DataTablePagination,
|
|
16
|
+
type DataTableProps,
|
|
17
|
+
DataTableToolbar,
|
|
18
|
+
DataTableViewOptions,
|
|
19
|
+
type FacetedFilterConfig,
|
|
20
|
+
useDataTable,
|
|
21
|
+
} from "./components/data-table";
|
|
11
22
|
export { DataTableRowActions } from "./components/data-table/data-table-row-actions";
|
|
12
23
|
export { DeleteManyButton } from "./components/shared/button/delete-many-button";
|
|
13
24
|
export {
|