@suphark/ui 0.57.0 → 0.59.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 +1125 -1102
- package/package.json +1 -1
- package/src/components/data-table/data-table-column-header.tsx +1 -1
- package/src/components/data-table/data-table-pagination.tsx +70 -0
- package/src/components/data-table/data-table-row-actions.tsx +1 -1
- package/src/components/data-table/data-table-toolbar.tsx +166 -44
- package/src/components/data-table/data-table-view-option.tsx +15 -4
- package/src/components/data-table/data-table.tsx +153 -3
- package/src/components/data-table/index.ts +1 -1
- package/src/components/data-table/types.ts +19 -0
- package/src/components/pwa/pull-to-refresh.tsx +52 -7
- package/src/components/shared/form/labeled-field.tsx +4 -1
- package/src/components/shared/notification-center.tsx +13 -5
- package/src/components/shared/ui-text.tsx +10 -0
- package/src/data-table.ts +1 -0
- package/src/hooks/use-mobile.ts +3 -1
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { cn } from "@suphark/ui/lib/utils";
|
|
4
|
-
import { Loader2, RefreshCw } from "lucide-react";
|
|
4
|
+
import { ArrowDown, Loader2, RefreshCw } from "lucide-react";
|
|
5
5
|
import { useRouter } from "next/navigation";
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const
|
|
8
|
+
/** ระยะเริ่มทำงาน / สูงสุด / แรงต้าน (px) ต่อแบบ — circle ดึงสั้นกว่า หนักมือกว่า */
|
|
9
|
+
const DIMS = {
|
|
10
|
+
pill: { activation: 72, max: 112, resistance: 0.62 },
|
|
11
|
+
circle: { activation: 64, max: 104, resistance: 0.5 },
|
|
12
|
+
} as const;
|
|
13
|
+
/** circle: ขนาดวงและที่พักใต้ขอบบนของ root ตอนกำลังรีเฟรช */
|
|
14
|
+
const CIRCLE_SIZE = 40;
|
|
15
|
+
const CIRCLE_REST = 16;
|
|
10
16
|
const MOBILE_BREAKPOINT = 768;
|
|
11
17
|
/** หมุนอย่างน้อยเท่านี้ — refresh ที่เสร็จทันทีจะไม่กะพริบหายไปเลย */
|
|
12
18
|
const MIN_SPIN_MS = 500;
|
|
@@ -43,11 +49,17 @@ export function PullToRefresh({
|
|
|
43
49
|
text = PULL_TO_REFRESH_TEXT_TH,
|
|
44
50
|
rootSelector = "[data-pull-to-refresh-root]",
|
|
45
51
|
onRefresh,
|
|
52
|
+
variant = "pill",
|
|
46
53
|
}: {
|
|
47
54
|
text?: PullToRefreshText;
|
|
48
55
|
rootSelector?: string;
|
|
49
56
|
/** รีเฟรชเอง (คืน promise = หมุนจนเสร็จ) — ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms */
|
|
50
57
|
onRefresh?: () => Promise<unknown> | void;
|
|
58
|
+
/**
|
|
59
|
+
* `pill` (ค่าเริ่มต้น) = แคปซูลมีข้อความ · `circle` = วงกลม 40px ไอคอนล้วน (ลูกศร → หมุน) อยู่กลาง พักที่ 16px ใต้ขอบบนของ root
|
|
60
|
+
* ข้อความยังอยู่เป็น `sr-only` + `role="status"` สำหรับ screen reader
|
|
61
|
+
*/
|
|
62
|
+
variant?: "pill" | "circle";
|
|
51
63
|
}) {
|
|
52
64
|
const router = useRouter();
|
|
53
65
|
const onRefreshRef = useRef(onRefresh);
|
|
@@ -64,6 +76,7 @@ export function PullToRefresh({
|
|
|
64
76
|
isRefreshingRef.current = isRefreshing;
|
|
65
77
|
|
|
66
78
|
useEffect(() => {
|
|
79
|
+
const dims = DIMS[variant];
|
|
67
80
|
const isRefreshing = () => isRefreshingRef.current;
|
|
68
81
|
const isMobileViewport = () => window.innerWidth < MOBILE_BREAKPOINT;
|
|
69
82
|
const getRoot = () => document.querySelector<HTMLElement>(rootSelector);
|
|
@@ -125,11 +138,11 @@ export function PullToRefresh({
|
|
|
125
138
|
isPullingRef.current = true;
|
|
126
139
|
}
|
|
127
140
|
event.preventDefault();
|
|
128
|
-
setPullDistance(Math.min(
|
|
141
|
+
setPullDistance(Math.min(dims.max, deltaY * dims.resistance));
|
|
129
142
|
};
|
|
130
143
|
|
|
131
144
|
const handleTouchEnd = async () => {
|
|
132
|
-
const shouldRefresh = pullDistanceRef.current >=
|
|
145
|
+
const shouldRefresh = pullDistanceRef.current >= dims.activation;
|
|
133
146
|
resetPull();
|
|
134
147
|
if (!shouldRefresh || isRefreshing()) return;
|
|
135
148
|
isRefreshingRef.current = true;
|
|
@@ -182,12 +195,44 @@ export function PullToRefresh({
|
|
|
182
195
|
window.removeEventListener("touchend", handleTouchEnd);
|
|
183
196
|
window.removeEventListener("touchcancel", resetPull);
|
|
184
197
|
};
|
|
185
|
-
}, [router, rootSelector]);
|
|
198
|
+
}, [router, rootSelector, variant]);
|
|
186
199
|
|
|
187
200
|
const isVisible = pullDistance > 0 || isRefreshing;
|
|
188
|
-
const progress = Math.min(1, pullDistance /
|
|
201
|
+
const progress = Math.min(1, pullDistance / DIMS[variant].activation);
|
|
189
202
|
const label = isRefreshing ? text.refreshing : progress >= 1 ? text.release : text.pull;
|
|
190
203
|
|
|
204
|
+
if (variant === "circle") {
|
|
205
|
+
const armed = !isRefreshing && progress >= 1;
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
role="status"
|
|
209
|
+
aria-live="polite"
|
|
210
|
+
className={cn(
|
|
211
|
+
"pointer-events-none fixed left-1/2 z-50 flex size-10 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-md md:hidden",
|
|
212
|
+
pullDistance === 0 && "transition-[transform,opacity] duration-200 ease-out",
|
|
213
|
+
armed && "text-primary",
|
|
214
|
+
)}
|
|
215
|
+
style={{
|
|
216
|
+
top: rootTop - CIRCLE_SIZE,
|
|
217
|
+
marginLeft: -CIRCLE_SIZE / 2,
|
|
218
|
+
opacity: isRefreshing ? 1 : pullDistance > 0 ? Math.max(0.35, progress) : 0,
|
|
219
|
+
transform: `translateY(${isRefreshing ? CIRCLE_REST + CIRCLE_SIZE : pullDistance}px) scale(${isVisible ? 0.7 + 0.3 * progress : 0.6})`,
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
{isRefreshing ? (
|
|
223
|
+
<Loader2 className="size-5 animate-spin text-primary" aria-hidden="true" />
|
|
224
|
+
) : (
|
|
225
|
+
<ArrowDown
|
|
226
|
+
className="size-5 transition-transform duration-150"
|
|
227
|
+
style={{ transform: `rotate(${armed ? 180 : 0}deg)` }}
|
|
228
|
+
aria-hidden="true"
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
<span className="sr-only">{isVisible ? label : ""}</span>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
191
236
|
return (
|
|
192
237
|
<div
|
|
193
238
|
aria-live="polite"
|
|
@@ -109,11 +109,14 @@ export function SwitchField({
|
|
|
109
109
|
label,
|
|
110
110
|
hint,
|
|
111
111
|
className,
|
|
112
|
+
labelClassName,
|
|
112
113
|
...props
|
|
113
114
|
}: ComponentProps<typeof Switch> & {
|
|
114
115
|
label: ReactNode;
|
|
115
116
|
/** คำอธิบายใต้ label (เยื้องตรงกับ label) */
|
|
116
117
|
hint?: ReactNode;
|
|
118
|
+
/** ทับสไตล์ข้อความป้าย (ค่าเริ่มต้น `text-sm`) เช่น `text-caption font-semibold` */
|
|
119
|
+
labelClassName?: string;
|
|
117
120
|
}) {
|
|
118
121
|
return (
|
|
119
122
|
<Label
|
|
@@ -125,7 +128,7 @@ export function SwitchField({
|
|
|
125
128
|
>
|
|
126
129
|
<Switch className={cn(hint && "mt-0.5")} {...props} />
|
|
127
130
|
<span className="flex flex-col gap-0.5">
|
|
128
|
-
<span className="text-sm">{label}</span>
|
|
131
|
+
<span className={cn("text-sm", labelClassName)}>{label}</span>
|
|
129
132
|
{hint && <span className="text-muted-foreground text-xs">{hint}</span>}
|
|
130
133
|
</span>
|
|
131
134
|
</Label>
|
|
@@ -122,6 +122,8 @@ export interface NotificationCenterProps {
|
|
|
122
122
|
description?: ReactNode;
|
|
123
123
|
/** ไม่มีรายการ — ส่ง element เองได้ทั้งก้อน */
|
|
124
124
|
empty?: ReactNode;
|
|
125
|
+
/** ปุ่มคำสั่งของทั้งกล่องที่หัว เช่น "อ่านทั้งหมดแล้ว" (ซ้ายของ "ดูทั้งหมด") — ไม่ต้องยืม `viewAll` มาใช้ผิดความหมาย */
|
|
126
|
+
headerAction?: ReactNode;
|
|
125
127
|
/** ลิงก์/ปุ่ม "ดูทั้งหมด" มุมขวาบน */
|
|
126
128
|
viewAll?: { label?: ReactNode; onSelect: () => void };
|
|
127
129
|
/** กำลังทำคำสั่งลัดอยู่ — ปิดปุ่มทั้งหมดกันกดซ้ำ */
|
|
@@ -159,6 +161,7 @@ export function NotificationCenter({
|
|
|
159
161
|
description,
|
|
160
162
|
empty,
|
|
161
163
|
viewAll,
|
|
164
|
+
headerAction,
|
|
162
165
|
busy = false,
|
|
163
166
|
variant = "card",
|
|
164
167
|
renderItem,
|
|
@@ -186,11 +189,16 @@ export function NotificationCenter({
|
|
|
186
189
|
</div>
|
|
187
190
|
)}
|
|
188
191
|
</div>
|
|
189
|
-
{viewAll && (
|
|
190
|
-
<
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
{(headerAction || viewAll) && (
|
|
193
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
194
|
+
{headerAction}
|
|
195
|
+
{viewAll && (
|
|
196
|
+
<Button variant="ghost" size="sm" onClick={viewAll.onSelect} className="shrink-0">
|
|
197
|
+
{viewAll.label ?? text.notifications?.viewAll ?? "ดูทั้งหมด"}
|
|
198
|
+
<ChevronRightIcon />
|
|
199
|
+
</Button>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
194
202
|
)}
|
|
195
203
|
</div>
|
|
196
204
|
|
|
@@ -81,6 +81,10 @@ export interface UiText {
|
|
|
81
81
|
table?: Partial<{
|
|
82
82
|
rowsPerPage: string;
|
|
83
83
|
pageOf: string;
|
|
84
|
+
/** `variant="quiet"` — `{from}`/`{to}`/`{total}` และ `{shown}`/`{total}` */
|
|
85
|
+
rangeOf: string;
|
|
86
|
+
shownOf: string;
|
|
87
|
+
density: string;
|
|
84
88
|
rowsSelected: string;
|
|
85
89
|
noResults: string;
|
|
86
90
|
deleteTitle: string;
|
|
@@ -316,6 +320,9 @@ export const UI_TEXT_TH: UiText = {
|
|
|
316
320
|
table: {
|
|
317
321
|
rowsPerPage: "แถวต่อหน้า",
|
|
318
322
|
pageOf: "หน้า {page} จาก {pages}",
|
|
323
|
+
rangeOf: "{from}–{to} จาก {total}",
|
|
324
|
+
shownOf: "{shown} จาก {total} รายการ",
|
|
325
|
+
density: "ความหนาแน่นของแถว",
|
|
319
326
|
rowsSelected: "เลือก {selected} จาก {total} แถว",
|
|
320
327
|
noResults: "ไม่พบข้อมูล",
|
|
321
328
|
deleteTitle: "ยืนยันการลบ",
|
|
@@ -530,6 +537,9 @@ export const UI_TEXT_EN: UiText = {
|
|
|
530
537
|
table: {
|
|
531
538
|
rowsPerPage: "Rows per page",
|
|
532
539
|
pageOf: "Page {page} of {pages}",
|
|
540
|
+
rangeOf: "{from}–{to} of {total}",
|
|
541
|
+
shownOf: "{shown} of {total} items",
|
|
542
|
+
density: "Row density",
|
|
533
543
|
rowsSelected: "{selected} of {total} row(s) selected",
|
|
534
544
|
noResults: "No results",
|
|
535
545
|
deleteTitle: "Are you sure?",
|
package/src/data-table.ts
CHANGED
package/src/hooks/use-mobile.ts
CHANGED
|
@@ -8,12 +8,14 @@ export function useIsMobile() {
|
|
|
8
8
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
|
9
9
|
|
|
10
10
|
React.useEffect(() => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
12
|
+
// เบราว์เซอร์เก่า/jsdom ที่ไม่มี matchMedia: ใช้ค่าตอน mount อย่างเดียว (ไม่ติดตามการปรับขนาด) แทนที่จะพังทั้งหน้า
|
|
13
|
+
if (typeof window.matchMedia !== "function") return;
|
|
11
14
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
12
15
|
const onChange = () => {
|
|
13
16
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
14
17
|
};
|
|
15
18
|
mql.addEventListener("change", onChange);
|
|
16
|
-
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
17
19
|
return () => mql.removeEventListener("change", onChange);
|
|
18
20
|
}, []);
|
|
19
21
|
|