@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.
@@ -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
- const ACTIVATION_DISTANCE = 72;
9
- const MAX_PULL_DISTANCE = 112;
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(MAX_PULL_DISTANCE, deltaY * 0.62));
141
+ setPullDistance(Math.min(dims.max, deltaY * dims.resistance));
129
142
  };
130
143
 
131
144
  const handleTouchEnd = async () => {
132
- const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
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 / ACTIVATION_DISTANCE);
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
- <Button variant="ghost" size="sm" onClick={viewAll.onSelect} className="shrink-0">
191
- {viewAll.label ?? text.notifications?.viewAll ?? "ดูทั้งหมด"}
192
- <ChevronRightIcon />
193
- </Button>
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
@@ -12,6 +12,7 @@ export {
12
12
  DataTableColumnHeader,
13
13
  DataTableDateRangeFilter,
14
14
  DataTableFacetedFilter,
15
+ type DataTableMobileSlot,
15
16
  DataTablePagination,
16
17
  type DataTableProps,
17
18
  DataTableToolbar,
@@ -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