@suphark/ui 0.53.0 → 0.57.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 CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.57.0] — 2026-10-24
6
+
7
+ ยกจาก pi-done (รอบสองของคำขอย้าย component ที่แอปทำเอง)
8
+
9
+ ### Added
10
+ - `RowTable` / `RowTableHeader` / `RowTableRow` / `RowTableCell` / `RowTableActions` / `RowTableGroup` — ตาราง grid ที่หัว/แถว/แถวกลุ่มใช้คอลัมน์ชุดเดียว (`columns` + `mobileColumns` เป็น `grid-template-columns` ผ่านตัวแปร CSS) หัวซ่อนบนมือถือ ช่อง `hideOnMobile` ซ่อนบนมือถือ ปุ่มท้ายแถวโผล่ตอนชี้/โฟกัส (เห็นตลอดบนจอสัมผัส) — แทนที่ pi-done เขียนซ้ำ 4 ที่
11
+ - `ListSection` / `ListSectionHeader` — กลุ่มรายการแบบไม่มีกล่อง (หัว + จำนวน + ปุ่มขวา + เส้นบาง + แถวคั่นเส้น) = `SectionPanel plain` + `TaskGroupHeader`
12
+ - `SidebarNavItem` / `SidebarCountBadge` — แถวเมนูข้างแบบเรียบ (active = `bg-primary/10 text-primary`, ตัวเลขเป็นข้อความเทา ไม่ใช่ pill, 44px บนจอสัมผัส, `draggingOver`)
13
+ - `ProgressBar tone="auto"` — เขียวเมื่อครบ (value ≥ max) นอกนั้นสีหลัก (ครอบคลุม `ProgressBar` ของ pi-done: ใช้ `<ProgressBar value={percent} tone="auto" className="w-10" />`)
14
+ - showcase: หน้า `/components/row-table`, `/components/list-section`, `/components/sidebar-nav-item`
15
+
16
+ ## [0.56.0] — 2026-10-24
17
+
18
+ ### Added
19
+ - `DatePicker` `minDate` / `maxDate` (`"YYYY-MM-DD"` | Date, รวมวันนั้น) ทั้งวันเดียวและ `mode="range"` (ขอโดย DueBell แทนการกันที่ `onChange`): ปฏิทินกดวันนอกช่วงไม่ได้ · เดือน/ปีเลื่อนไปนอกช่วงไม่ได้ (ลูกศรเดือน + ตารางเลือกเดือน) · ค่าที่พิมพ์แล้วอยู่นอกช่วง = ไม่ยืนยัน (Enter/Tab/ปุ่มผล) และกล่องผลแสดงเหตุผลสีแดงแทน · ค่าที่ส่งเข้ามาเองนอกช่วงไม่ถูกแก้ ช่องขึ้น `aria-invalid` · ข้อความ `UiText.datePicker.beforeMin` / `afterMax` (`{date}` ตาม `locale` ของช่อง) แอปทับได้
20
+
21
+ ## [0.55.0] — 2026-10-24
22
+
23
+ ยกจาก pi-done (รอบแรกของคำขอย้าย component ที่แอปทำเอง)
24
+
25
+ ### Added
26
+ - `ResponsivePopover` / `ResponsivePopoverTrigger` / `ResponsivePopoverContent` / `ResponsivePopoverItem` / `useIsResponsiveSheet()` — Popover บนจอ ≥ 768px · แผ่นล่าง (Drawer) บนมือถือ: สูงตามเนื้อหา, `footer` ค้างล่าง, ปัดลงปิด; `useIsResponsiveSheet()` ให้ picker ขยายพื้นที่แตะ (`ResponsivePopoverItem` = 44px ในแผ่น) · `sheetContentClassName` ทับสไตล์ตัวแผ่น
27
+ - `SwitchField` — สวิตช์ + ป้ายในตัว (คลิกป้ายสลับ, `hint` ได้, ป้ายสูง 44px บนจอสัมผัส) แทน `<label><Switch/>…</label>` ที่ต้อง biome-ignore เพราะ Switch เป็น span
28
+ - `PullToRefresh` (`@suphark/ui/pwa`) prop `onRefresh` — รีเฟรชเองได้ (แอป local-first ที่ `router.refresh()` ไปไม่ถึง) · คืน promise = หมุนจนเสร็จ ขั้นต่ำ 500ms (ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms เหมือนเดิม)
29
+
30
+ ### Fixed
31
+ - `PullToRefresh`: วงโผล่ใต้ขอบบนของ root (เดิมติดขอบจอ จมใต้ header ที่ sticky) และ `z-50` · เริ่มดึงได้เฉพาะเมื่อแตะ "ในส่วน root" ที่เลื่อนอยู่บนสุด — sheet/dialog ที่ portal ออกไปนอก root จึงปัดลงของตัวเองได้ ไม่ถูกแย่งไปรีเฟรชหน้า (root เป็น `<html>`/`<body>` ทำงานเหมือนเดิม)
32
+ - showcase: หน้า `/components/responsive-popover` ใหม่ · labeled-field มีตัวอย่าง `SwitchField`
33
+
34
+ ## [0.54.0] — 2026-10-24
35
+
36
+ ### Added
37
+ - `AccessibleCalendar` — `Calendar` ที่ลูกศร/Home/End/PageUp/PageDown เลือกวันด้วยคีย์บอร์ดได้จริง (props เหมือน `Calendar` ทุกอย่าง) · ใช้แทน `Calendar` ดิบเมื่อวางปฏิทินเอง (ปุ่มวันของ shadcn ไม่ผูก `ref` ลูกศรจึงเลื่อนได้แค่สถานะภายใน) · `FocusableDayButton` ย้ายจาก `date-picker.tsx` มาอยู่ `shared/date/accessible-calendar.tsx`
38
+
39
+ ### Fixed
40
+ - `DateRangePicker` (ตัวกรอง) และ `DateTimePicker`: ปฏิทินใช้ `AccessibleCalendar` — เลือกวันด้วยคีย์บอร์ดได้จริง (เดิมโฟกัสค้างที่เดิม)
41
+ - `DateRangePicker`: ข้อความ placeholder/"ล้างช่วงวันที่" มาจาก `UiText.datePicker` (`rangePlaceholder`, ใหม่ `clearRange`) — แอปที่ไม่ส่ง `placeholder` และใช้ UiText ไทยจะเห็นข้อความไทยแทนอังกฤษ; ส่ง `placeholder` เองยังชนะเสมอ
42
+ - showcase: `/components/calendar` ใช้ `AccessibleCalendar`
43
+
5
44
  ## [0.53.0] — 2026-10-23
6
45
 
7
46
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.53.0",
3
+ "version": "0.57.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -8,6 +8,8 @@ import { useEffect, useRef, useState } from "react";
8
8
  const ACTIVATION_DISTANCE = 72;
9
9
  const MAX_PULL_DISTANCE = 112;
10
10
  const MOBILE_BREAKPOINT = 768;
11
+ /** หมุนอย่างน้อยเท่านี้ — refresh ที่เสร็จทันทีจะไม่กะพริบหายไปเลย */
12
+ const MIN_SPIN_MS = 500;
11
13
 
12
14
  export interface PullToRefreshText {
13
15
  pull: string;
@@ -28,20 +30,29 @@ export const PULL_TO_REFRESH_TEXT_EN: PullToRefreshText = {
28
30
  };
29
31
 
30
32
  /**
31
- * ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `router.refresh()` เมื่อดึงเกินระยะ
32
- * ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า (ดึงได้เมื่อเลื่อนอยู่บนสุดเท่านั้น)
33
+ * ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `onRefresh` (ไม่ส่ง = `router.refresh()`) เมื่อดึงเกินระยะ
34
+ * ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า — ดึงได้เมื่อแตะ "ในนั้น" และเลื่อนอยู่บนสุดเท่านั้น
35
+ * (sheet/dialog ที่ portal ออกไปนอก root จึงปัดลงของตัวเองได้ ไม่ถูกแย่งไปรีเฟรชหน้า) · วงโผล่ใต้ขอบบนของ root
36
+ * (ไม่จมใต้ header ที่ sticky) และหมุนจนกว่า `onRefresh` จะเสร็จ (ขั้นต่ำ 500ms)
33
37
  *
34
38
  * <main data-pull-to-refresh-root className="overflow-y-auto">…</main>
35
- * <PullToRefresh />
39
+ * <PullToRefresh /> // รีเฟรช server component
40
+ * <PullToRefresh onRefresh={() => store.sync()} /> // แอป local-first / ดึงข้อมูลเอง
36
41
  */
37
42
  export function PullToRefresh({
38
43
  text = PULL_TO_REFRESH_TEXT_TH,
39
44
  rootSelector = "[data-pull-to-refresh-root]",
45
+ onRefresh,
40
46
  }: {
41
47
  text?: PullToRefreshText;
42
48
  rootSelector?: string;
49
+ /** รีเฟรชเอง (คืน promise = หมุนจนเสร็จ) — ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms */
50
+ onRefresh?: () => Promise<unknown> | void;
43
51
  }) {
44
52
  const router = useRouter();
53
+ const onRefreshRef = useRef(onRefresh);
54
+ onRefreshRef.current = onRefresh;
55
+ const [rootTop, setRootTop] = useState(0);
45
56
  const startRef = useRef<{ x: number; y: number } | null>(null);
46
57
  const isPullingRef = useRef(false);
47
58
  const [pullDistance, setPullDistance] = useState(0);
@@ -81,6 +92,13 @@ export function PullToRefresh({
81
92
  resetPull();
82
93
  return;
83
94
  }
95
+ // root ปกติ = ต้องแตะ "ในนั้น" (portal ของ sheet/dialog อยู่นอก root จึงไม่เริ่มดึง) · root เป็น <html>/<body> ครอบทุกอย่างอยู่แล้ว
96
+ const whole = root === document.documentElement || root === document.body;
97
+ if (!whole && !(event.target instanceof Node && root.contains(event.target))) {
98
+ resetPull();
99
+ return;
100
+ }
101
+ setRootTop(whole ? 0 : Math.max(0, root.getBoundingClientRect().top));
84
102
  startRef.current = { x: touch.clientX, y: touch.clientY };
85
103
  isPullingRef.current = false;
86
104
  };
@@ -110,13 +128,27 @@ export function PullToRefresh({
110
128
  setPullDistance(Math.min(MAX_PULL_DISTANCE, deltaY * 0.62));
111
129
  };
112
130
 
113
- const handleTouchEnd = () => {
131
+ const handleTouchEnd = async () => {
114
132
  const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
115
133
  resetPull();
116
134
  if (!shouldRefresh || isRefreshing()) return;
135
+ isRefreshingRef.current = true;
117
136
  setIsRefreshing(true);
118
- router.refresh();
119
- window.setTimeout(() => setIsRefreshing(false), 700);
137
+ const startedAt = Date.now();
138
+ const custom = onRefreshRef.current;
139
+ try {
140
+ if (custom) await custom();
141
+ else {
142
+ router.refresh();
143
+ await new Promise((resolve) => setTimeout(resolve, 700));
144
+ }
145
+ } catch {
146
+ // งาน refresh รายงานข้อผิดพลาดเอง — วงแค่หยุดหมุน
147
+ }
148
+ const wait = MIN_SPIN_MS - (Date.now() - startedAt);
149
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
150
+ isRefreshingRef.current = false;
151
+ setIsRefreshing(false);
120
152
  };
121
153
 
122
154
  // touchmove ต้อง non-passive (preventDefault ระหว่างดึง) ซึ่งทำให้ browser ต้องรอ JS ก่อนเลื่อนหน้าทุกครั้ง
@@ -160,10 +192,14 @@ export function PullToRefresh({
160
192
  <div
161
193
  aria-live="polite"
162
194
  className={cn(
163
- "pointer-events-none fixed top-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full border bg-card/95 px-3.5 py-2 text-xs font-medium text-foreground shadow-lg backdrop-blur-md transition-all duration-150 md:hidden",
195
+ "pointer-events-none fixed left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full border bg-card/95 px-3.5 py-2 text-xs font-medium text-foreground shadow-lg backdrop-blur-md transition-all duration-150 md:hidden",
164
196
  isVisible ? "translate-y-0 opacity-100" : "-translate-y-8 opacity-0",
165
197
  )}
166
- style={{ transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)` }}
198
+ style={{
199
+ // วงอยู่ใต้ขอบบนของ root (ไม่ใช่ขอบจอ) จึงไม่โดน header ที่ sticky บัง
200
+ top: rootTop + 12,
201
+ transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)`,
202
+ }}
167
203
  >
168
204
  {isRefreshing ? (
169
205
  <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { Calendar, CalendarDayButton } from "@suphark/ui/components/ui/calendar";
4
+ import { type ComponentProps, useEffect, useMemo, useRef } from "react";
5
+
6
+ /**
7
+ * ปุ่มวันของปฏิทินที่ย้ายโฟกัสจริงตามลูกศรคีย์บอร์ด — `CalendarDayButton` ของ shadcn (ไฟล์ห้ามแก้มือ) มี effect สั่ง
8
+ * `ref.current?.focus()` แต่ไม่เคยผูก `ref` กับปุ่ม ลูกศรจึงเลื่อนได้แค่สถานะภายในของ react-day-picker โฟกัสจริงอยู่ที่เดิม
9
+ * (ใช้เลือกวันด้วยคีย์บอร์ดไม่ได้) — ผูก ref ผ่าน `{...props}` ที่ CalendarDayButton ส่งต่อให้ปุ่ม
10
+ */
11
+ export function FocusableDayButton(props: ComponentProps<typeof CalendarDayButton>) {
12
+ const ref = useRef<HTMLButtonElement>(null);
13
+ const focused = props.modifiers.focused;
14
+ useEffect(() => {
15
+ if (focused) ref.current?.focus();
16
+ }, [focused]);
17
+ // React 19: ref เป็น prop ธรรมดาที่ถูก spread ต่อไปถึง <Button> — แต่ type ของ react-day-picker ไม่ประกาศ ref
18
+ return <CalendarDayButton {...props} {...({ ref } as Record<string, unknown>)} />;
19
+ }
20
+
21
+ /**
22
+ * `Calendar` ที่ใช้ลูกศร/Home/End/PageUp/PageDown เลือกวันด้วยคีย์บอร์ดได้จริง — ใช้แทน `Calendar` ดิบเสมอเมื่อวางปฏิทินเอง
23
+ * (`Calendar` ของ shadcn ย้ายโฟกัสวันไม่ได้ ดูคำอธิบายที่ `FocusableDayButton`) · props เหมือน `Calendar` ทุกอย่าง
24
+ * ส่ง `components.DayButton` เองได้ (ทับตัวนี้)
25
+ */
26
+ export function AccessibleCalendar({
27
+ components,
28
+ locale,
29
+ ...props
30
+ }: ComponentProps<typeof Calendar>) {
31
+ // ผูก locale เข้ากับปุ่มวัน (Calendar ของ shadcn ส่ง locale ให้ปุ่มของมันเอง แต่ปุ่มที่เราแทนต้องส่งต่อเอง)
32
+ const DayButton = useMemo(
33
+ () =>
34
+ function DayButtonWithLocale(buttonProps: ComponentProps<typeof CalendarDayButton>) {
35
+ return <FocusableDayButton locale={locale} {...buttonProps} />;
36
+ },
37
+ [locale],
38
+ );
39
+ return <Calendar locale={locale} components={{ DayButton, ...components }} {...props} />;
40
+ }
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
4
+ import { FocusableDayButton } from "@suphark/ui/components/shared/date/accessible-calendar";
4
5
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
- import { Calendar, CalendarDayButton } from "@suphark/ui/components/ui/calendar";
6
+ import { Calendar } from "@suphark/ui/components/ui/calendar";
6
7
  import {
7
8
  InputGroup,
8
9
  InputGroupAddon,
@@ -26,11 +27,11 @@ import {
26
27
  createContext,
27
28
  type ReactNode,
28
29
  useContext,
29
- useEffect,
30
30
  useId,
31
31
  useRef,
32
32
  useState,
33
33
  } from "react";
34
+ import type { Matcher } from "react-day-picker";
34
35
 
35
36
  /**
36
37
  * Smart date parser supporting intuitive Thai & international date inputs:
@@ -196,6 +197,13 @@ interface DatePickerBaseProps {
196
197
  showPresets?: boolean;
197
198
  /** พิมพ์ในช่องได้ (ค่าเริ่มต้น true) · false = ช่องอ่านอย่างเดียว กดแล้วเปิดปฏิทิน */
198
199
  showSmartInput?: boolean;
200
+ /**
201
+ * วันต่ำสุด/สูงสุดที่เลือกได้ (`"YYYY-MM-DD"` | Date, รวมวันนั้นด้วย) — ใช้ได้ทั้งวันเดียวและ `mode="range"` (ทั้งสองฝั่งต้องอยู่ในช่วง)
202
+ * ปฏิทินกดวันนอกช่วงไม่ได้ · ค่าที่พิมพ์แล้วอยู่นอกช่วง = ไม่ยืนยัน + บอกเหตุผลใต้ช่อง (`UiText.datePicker.beforeMin`/`afterMax`)
203
+ * ค่าที่ส่งเข้ามาเองนอกช่วงไม่ถูกแก้ แค่ช่องขึ้นสถานะผิด (`aria-invalid`)
204
+ */
205
+ minDate?: DateInput;
206
+ maxDate?: DateInput;
199
207
  }
200
208
 
201
209
  export interface DatePickerSingleProps extends DatePickerBaseProps {
@@ -302,21 +310,6 @@ function calendarFormatters(locale?: string) {
302
310
  };
303
311
  }
304
312
 
305
- /**
306
- * ปุ่มวันของปฏิทินที่ย้ายโฟกัสจริงตามลูกศรคีย์บอร์ด — `CalendarDayButton` ของ shadcn (ไฟล์ห้ามแก้มือ) มี effect สั่ง
307
- * `ref.current?.focus()` แต่ไม่เคยผูก `ref` กับปุ่ม ลูกศรจึงเลื่อนได้แค่สถานะภายในของ react-day-picker โฟกัสจริงอยู่ที่เดิม
308
- * (ใช้เลือกวันด้วยคีย์บอร์ดไม่ได้) — ผูก ref ผ่าน `{...props}` ที่ CalendarDayButton ส่งต่อให้ปุ่ม
309
- */
310
- function FocusableDayButton(props: ComponentProps<typeof CalendarDayButton>) {
311
- const ref = useRef<HTMLButtonElement>(null);
312
- const focused = props.modifiers.focused;
313
- useEffect(() => {
314
- if (focused) ref.current?.focus();
315
- }, [focused]);
316
- // React 19: ref เป็น prop ธรรมดาที่ถูก spread ต่อไปถึง <Button> — แต่ type ของ react-day-picker ไม่ประกาศ ref
317
- return <CalendarDayButton {...props} {...({ ref } as Record<string, unknown>)} />;
318
- }
319
-
320
313
  /** คลิกชื่อเดือนในหัวปฏิทิน → สลับไปตารางเลือกเดือน/ปี (อยู่ใน shell) */
321
314
  const OpenMonthsContext = createContext<(() => void) | null>(null);
322
315
 
@@ -341,10 +334,14 @@ function MonthGrid({
341
334
  month,
342
335
  locale,
343
336
  onPick,
337
+ startMonth,
338
+ endMonth,
344
339
  }: {
345
340
  month: Date;
346
341
  locale: string | undefined;
347
342
  onPick: (month: Date) => void;
343
+ startMonth: Date;
344
+ endMonth: Date;
348
345
  }) {
349
346
  const tag = locale ?? "th";
350
347
  const [year, setYear] = useState(month.getFullYear());
@@ -355,11 +352,23 @@ function MonthGrid({
355
352
  return (
356
353
  <div className="flex flex-col gap-1">
357
354
  <div className="flex h-8 items-center justify-between">
358
- <button type="button" aria-label="-" className={step} onClick={() => setYear(year - 1)}>
355
+ <button
356
+ type="button"
357
+ aria-label="-"
358
+ className={step}
359
+ disabled={year <= startMonth.getFullYear()}
360
+ onClick={() => setYear(year - 1)}
361
+ >
359
362
  <ChevronLeftIcon className="size-4" />
360
363
  </button>
361
364
  <span className="font-semibold text-sm">{yearLabel(year)}</span>
362
- <button type="button" aria-label="+" className={step} onClick={() => setYear(year + 1)}>
365
+ <button
366
+ type="button"
367
+ aria-label="+"
368
+ className={step}
369
+ disabled={year >= endMonth.getFullYear()}
370
+ onClick={() => setYear(year + 1)}
371
+ >
363
372
  <ChevronRightIcon className="size-4" />
364
373
  </button>
365
374
  </div>
@@ -367,14 +376,17 @@ function MonthGrid({
367
376
  {Array.from({ length: 12 }, (_, m) => {
368
377
  const current = year === month.getFullYear() && m === month.getMonth();
369
378
  const thisMonth = year === today.getFullYear() && m === today.getMonth();
379
+ const at = new Date(year, m, 1).getTime();
380
+ const outside = at < startMonth.getTime() || at > endMonth.getTime();
370
381
  const label = new Date(2000, m, 1).toLocaleString(tag, { month: "short" });
371
382
  return (
372
383
  <button
373
384
  key={label}
374
385
  type="button"
386
+ disabled={outside}
375
387
  onClick={() => onPick(new Date(year, m, 1))}
376
388
  className={cn(
377
- "h-[3.25rem] rounded-md text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
389
+ "h-[3.25rem] rounded-md text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40",
378
390
  current ? "bg-primary font-semibold text-primary-foreground" : "hover:bg-accent",
379
391
  !current && thisMonth && "bg-muted font-medium",
380
392
  )}
@@ -388,10 +400,55 @@ function MonthGrid({
388
400
  );
389
401
  }
390
402
 
403
+ /** ช่วงวันที่ที่เลือกได้ (`minDate`/`maxDate`) — ใช้กับ disabled ของปฏิทิน เดือนที่เลื่อนไปได้ และเหตุผลของค่าที่พิมพ์ */
404
+ interface Bounds {
405
+ lo: Date | undefined;
406
+ hi: Date | undefined;
407
+ /** ข้อความเหตุผลที่วันนี้เลือกไม่ได้ (null = เลือกได้) */
408
+ reason: (date: Date) => string | null;
409
+ matchers: Matcher[];
410
+ startMonth: Date;
411
+ endMonth: Date;
412
+ }
413
+
414
+ function makeBounds(
415
+ minDate: DateInput,
416
+ maxDate: DateInput,
417
+ locale: string | undefined,
418
+ text: { beforeMin?: string; afterMax?: string } | undefined,
419
+ ): Bounds {
420
+ const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
421
+ const min = toDate(minDate);
422
+ const max = toDate(maxDate);
423
+ const lo = min && day(min);
424
+ const hi = max && day(max);
425
+ const reason = (date: Date) => {
426
+ const d = day(date);
427
+ if (lo && d.getTime() < lo.getTime())
428
+ return (text?.beforeMin ?? "เลือกได้ตั้งแต่วันที่ {date}").replace("{date}", formatDate(lo, locale));
429
+ if (hi && d.getTime() > hi.getTime())
430
+ return (text?.afterMax ?? "เลือกได้ถึงวันที่ {date}").replace("{date}", formatDate(hi, locale));
431
+ return null;
432
+ };
433
+ const matchers: Matcher[] = [];
434
+ if (lo) matchers.push({ before: lo });
435
+ if (hi) matchers.push({ after: hi });
436
+ return {
437
+ lo,
438
+ hi,
439
+ reason,
440
+ matchers,
441
+ startMonth: lo ? new Date(lo.getFullYear(), lo.getMonth(), 1) : new Date(1990, 0),
442
+ endMonth: hi ? new Date(hi.getFullYear(), hi.getMonth(), 1) : new Date(2040, 11),
443
+ };
444
+ }
445
+
391
446
  /** ผลอ่านจากข้อความที่พิมพ์ — `label` ที่คนอ่านออก + เดือนที่ปฏิทินควรเลื่อนไปแสดง */
392
447
  interface DatePreview {
393
448
  label: string;
394
449
  month: Date;
450
+ /** อ่านออกแต่อยู่นอก `minDate`/`maxDate` — ข้อความเหตุผล (ไม่ยืนยัน ไม่ไฮไลต์ในปฏิทิน) */
451
+ blocked?: string;
395
452
  }
396
453
  interface SinglePreview extends DatePreview {
397
454
  date: Date;
@@ -412,6 +469,10 @@ interface ShellApi<T extends DatePreview> {
412
469
  interface ShellProps<T extends DatePreview> {
413
470
  id?: string;
414
471
  locale?: string;
472
+ /** ค่าที่เลือกอยู่อยู่นอก `minDate`/`maxDate` — ช่องขึ้นสถานะผิด */
473
+ invalid?: boolean;
474
+ startMonth: Date;
475
+ endMonth: Date;
415
476
  disabled: boolean;
416
477
  className?: string;
417
478
  required: boolean;
@@ -437,6 +498,9 @@ interface ShellProps<T extends DatePreview> {
437
498
  function DateFieldShell<T extends DatePreview>({
438
499
  id,
439
500
  locale,
501
+ invalid = false,
502
+ startMonth,
503
+ endMonth,
440
504
  disabled,
441
505
  className,
442
506
  required,
@@ -595,6 +659,7 @@ function DateFieldShell<T extends DatePreview>({
595
659
  aria-haspopup="dialog"
596
660
  aria-expanded={open}
597
661
  aria-required={required || undefined}
662
+ aria-invalid={invalid || undefined}
598
663
  aria-describedby={open && editing && trimmed !== "" ? previewId : undefined}
599
664
  onMouseDown={() => {
600
665
  pointerFocus.current = !editingRef.current;
@@ -675,7 +740,7 @@ function DateFieldShell<T extends DatePreview>({
675
740
  </InputGroupButton>
676
741
  </InputGroupAddon>
677
742
  <InputGroupAddon align="inline-end">
678
- {editing && typing && preview && (
743
+ {editing && typing && preview && !preview.blocked && (
679
744
  <kbd className="rounded border px-1 font-mono text-caption text-muted-foreground leading-4">
680
745
  Enter
681
746
  </kbd>
@@ -729,7 +794,11 @@ function DateFieldShell<T extends DatePreview>({
729
794
  >
730
795
  {typing && trimmed !== "" && editing && (
731
796
  <div id={previewId} role="status">
732
- {preview ? (
797
+ {preview?.blocked ? (
798
+ <p className="rounded-lg border border-destructive-border bg-destructive-subtle px-2.5 py-1.5 text-caption text-destructive-subtle-foreground shadow-sm">
799
+ {preview.blocked}
800
+ </p>
801
+ ) : preview ? (
733
802
  <button
734
803
  type="button"
735
804
  onClick={apply}
@@ -783,6 +852,8 @@ function DateFieldShell<T extends DatePreview>({
783
852
  <MonthGrid
784
853
  month={month}
785
854
  locale={locale}
855
+ startMonth={startMonth}
856
+ endMonth={endMonth}
786
857
  onPick={(next) => {
787
858
  setMonth(next);
788
859
  setView("days");
@@ -862,8 +933,11 @@ function SingleDatePicker({
862
933
  className,
863
934
  showSmartInput = true,
864
935
  showRelative = true,
936
+ minDate,
937
+ maxDate,
865
938
  }: DatePickerSingleProps) {
866
939
  const text = useUiText().datePicker;
940
+ const bounds = makeBounds(minDate, maxDate, locale, text);
867
941
  const mounted = useMounted();
868
942
  // ไม่ควบคุม = มี defaultValue หรือไม่มีทั้ง value และ onChange (เช่นใช้แค่ name ในฟอร์ม)
869
943
  const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
@@ -882,6 +956,7 @@ function SingleDatePicker({
882
956
  date,
883
957
  month: date,
884
958
  label: `${weekdayShort(date, locale)} ${formatDate(date, locale)} · ${formatRelative(date, locale)}`,
959
+ blocked: bounds.reason(date) ?? undefined,
885
960
  };
886
961
  };
887
962
 
@@ -898,6 +973,9 @@ function SingleDatePicker({
898
973
  <DateFieldShell<SinglePreview>
899
974
  id={id}
900
975
  locale={locale}
976
+ invalid={Boolean(selectedDate && bounds.reason(selectedDate))}
977
+ startMonth={bounds.startMonth}
978
+ endMonth={bounds.endMonth}
901
979
  disabled={disabled}
902
980
  className={className}
903
981
  required={required}
@@ -924,7 +1002,7 @@ function SingleDatePicker({
924
1002
  return true;
925
1003
  }
926
1004
  const next = parse(input);
927
- if (!next) return false;
1005
+ if (!next || next.blocked) return false;
928
1006
  change(next.date);
929
1007
  return true;
930
1008
  }}
@@ -935,17 +1013,19 @@ function SingleDatePicker({
935
1013
  <Calendar
936
1014
  mode="single"
937
1015
  // ข้อความที่พิมพ์อยู่ไฮไลต์ในปฏิทินทันที — คลิกวันที่ไฮไลต์อยู่ = ยืนยันวันนั้น (ไม่ใช่ล้างค่าแบบ toggle)
938
- selected={api.preview?.date ?? selectedDate}
1016
+ selected={
1017
+ (api.preview && !api.preview.blocked ? api.preview.date : undefined) ?? selectedDate
1018
+ }
939
1019
  onSelect={(date) => {
940
1020
  change(date ?? api.preview?.date ?? selectedDate ?? null);
941
1021
  api.close();
942
1022
  }}
943
1023
  month={api.month}
944
1024
  onMonthChange={api.setMonth}
945
- disabled={disabled}
1025
+ disabled={disabled ? true : bounds.matchers}
946
1026
  captionLayout="label"
947
- startMonth={new Date(1990, 0)}
948
- endMonth={new Date(2040, 11)}
1027
+ startMonth={bounds.startMonth}
1028
+ endMonth={bounds.endMonth}
949
1029
  formatters={calendarFormatters(locale)}
950
1030
  components={{ DayButton: FocusableDayButton, CaptionLabel: CaptionButton }}
951
1031
  className="p-0 [--cell-size:--spacing(10)]"
@@ -996,8 +1076,11 @@ function RangeDatePicker({
996
1076
  className,
997
1077
  allowClear = true,
998
1078
  showSmartInput = true,
1079
+ minDate,
1080
+ maxDate,
999
1081
  }: DatePickerRangeProps) {
1000
1082
  const text = useUiText().datePicker;
1083
+ const bounds = makeBounds(minDate, maxDate, locale, text);
1001
1084
  const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
1002
1085
  const [own, setOwn] = useState<DatePickerRange | null>(() => toRange(defaultValue) ?? null);
1003
1086
  // ระหว่างคลิกเลือก: คลิกแรก = วันเริ่ม (ยังไม่ปิด) คลิกที่สอง = วันสุดท้าย แล้วปิด
@@ -1022,6 +1105,7 @@ function RangeDatePicker({
1022
1105
  return {
1023
1106
  range,
1024
1107
  month: range.from,
1108
+ blocked: bounds.reason(range.from) ?? bounds.reason(range.to) ?? undefined,
1025
1109
  label:
1026
1110
  days === 1
1027
1111
  ? `${weekdayShort(range.from, locale)} ${formatDate(range.from, locale)}`
@@ -1042,6 +1126,9 @@ function RangeDatePicker({
1042
1126
  <DateFieldShell<RangePreview>
1043
1127
  id={id}
1044
1128
  locale={locale}
1129
+ invalid={Boolean(selected && (bounds.reason(selected.from) || bounds.reason(selected.to)))}
1130
+ startMonth={bounds.startMonth}
1131
+ endMonth={bounds.endMonth}
1045
1132
  disabled={disabled}
1046
1133
  className={className}
1047
1134
  required={required}
@@ -1065,7 +1152,7 @@ function RangeDatePicker({
1065
1152
  return true;
1066
1153
  }
1067
1154
  const next = parse(input);
1068
- if (!next) return false;
1155
+ if (!next || next.blocked) return false;
1069
1156
  change(next.range);
1070
1157
  return true;
1071
1158
  }}
@@ -1077,7 +1164,12 @@ function RangeDatePicker({
1077
1164
  <>
1078
1165
  <Calendar
1079
1166
  mode="range"
1080
- selected={start ? { from: start, to: undefined } : (api.preview?.range ?? selected)}
1167
+ selected={
1168
+ start
1169
+ ? { from: start, to: undefined }
1170
+ : ((api.preview && !api.preview.blocked ? api.preview.range : undefined) ??
1171
+ selected)
1172
+ }
1081
1173
  onSelect={(_range, day) => {
1082
1174
  // คุมสองคลิกเอง: react-day-picker คลิกแรกให้ from = to (ช่วงหนึ่งวัน) จึงแยกคลิกแรกไม่ออก
1083
1175
  if (!start) {
@@ -1093,10 +1185,10 @@ function RangeDatePicker({
1093
1185
  }}
1094
1186
  month={api.month}
1095
1187
  onMonthChange={api.setMonth}
1096
- disabled={disabled}
1188
+ disabled={disabled ? true : bounds.matchers}
1097
1189
  captionLayout="label"
1098
- startMonth={new Date(1990, 0)}
1099
- endMonth={new Date(2040, 11)}
1190
+ startMonth={bounds.startMonth}
1191
+ endMonth={bounds.endMonth}
1100
1192
  formatters={calendarFormatters(locale)}
1101
1193
  components={{ DayButton: FocusableDayButton, CaptionLabel: CaptionButton }}
1102
1194
  className="p-0 [--cell-size:--spacing(10)]"
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { AccessibleCalendar } from "@suphark/ui/components/shared/date/accessible-calendar";
5
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
6
  import { Badge } from "@suphark/ui/components/ui/badge";
5
- import { Calendar } from "@suphark/ui/components/ui/calendar";
6
7
  import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
7
8
  import { Separator } from "@suphark/ui/components/ui/separator";
8
9
  import { cn } from "@suphark/ui/lib/utils";
@@ -25,7 +26,7 @@ interface DateRangePickerProps {
25
26
  export function DateRangePicker({
26
27
  value,
27
28
  onValueChange,
28
- placeholder = "Pick a date range",
29
+ placeholder,
29
30
  className,
30
31
  triggerClassName,
31
32
  align = "start",
@@ -33,6 +34,8 @@ export function DateRangePicker({
33
34
  title,
34
35
  variant = "outline",
35
36
  }: DateRangePickerProps) {
37
+ const text = useUiText().datePicker;
38
+ const hint = placeholder ?? text?.rangePlaceholder ?? "Pick a date range";
36
39
  return (
37
40
  <div className={cn("flex items-center gap-2", className)}>
38
41
  <Popover>
@@ -62,7 +65,7 @@ export function DateRangePicker({
62
65
  format(value.from, "PP")
63
66
  )
64
67
  ) : (
65
- <span>{placeholder}</span>
68
+ <span>{hint}</span>
66
69
  ))}
67
70
  {title && value?.from && (
68
71
  <>
@@ -80,7 +83,7 @@ export function DateRangePicker({
80
83
  )}
81
84
  </PopoverTrigger>
82
85
  <PopoverContent className="w-auto p-0" align={align}>
83
- <Calendar
86
+ <AccessibleCalendar
84
87
  autoFocus
85
88
  mode="range"
86
89
  defaultMonth={value?.from}
@@ -95,7 +98,7 @@ export function DateRangePicker({
95
98
  className="w-full font-normal text-xs"
96
99
  onClick={() => onValueChange(undefined)}
97
100
  >
98
- Clear selection
101
+ {text?.clearRange ?? "Clear selection"}
99
102
  </Button>
100
103
  </div>
101
104
  )}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
- import { Calendar } from "@suphark/ui/components/ui/calendar";
4
+ import { AccessibleCalendar } from "@suphark/ui/components/shared/date/accessible-calendar";
5
5
  import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
6
6
  import { useMounted } from "@suphark/ui/hooks/use-mounted";
7
7
  import { cn } from "@suphark/ui/lib/utils";
@@ -114,7 +114,7 @@ export function DateTimePicker({
114
114
  )}
115
115
  <PopoverContent align="start" className="w-auto gap-0 p-0">
116
116
  <div className="flex flex-col sm:flex-row">
117
- <Calendar
117
+ <AccessibleCalendar
118
118
  mode="single"
119
119
  locale={th}
120
120
  selected={date ?? undefined}
@@ -1,6 +1,7 @@
1
1
  import { Checkbox } from "@suphark/ui/components/ui/checkbox";
2
2
  import { Field, FieldDescription, FieldError, FieldLabel } from "@suphark/ui/components/ui/field";
3
3
  import { Label } from "@suphark/ui/components/ui/label";
4
+ import { Switch } from "@suphark/ui/components/ui/switch";
4
5
  import { cn } from "@suphark/ui/lib/utils";
5
6
  import type { ComponentProps, ReactNode } from "react";
6
7
 
@@ -97,3 +98,36 @@ export function CheckboxField({
97
98
  </Label>
98
99
  );
99
100
  }
101
+
102
+ /**
103
+ * สวิตช์ + ป้ายในตัว (คลิกป้ายก็สลับ) — ใช้แทน `<label><Switch />…</label>` ที่ต้อง biome-ignore เพราะ Switch เป็น span
104
+ * `size` ส่งต่อให้ Switch · `className` ทับที่ป้ายทั้งก้อน (ใส่สไตล์ hover/ตัวหนังสือได้) · ป้ายใหญ่ขึ้นเป็น 44px บนจอสัมผัส
105
+ *
106
+ * <SwitchField size="sm" checked={showDone} onCheckedChange={setShowDone} label="แสดงที่เสร็จแล้ว" />
107
+ */
108
+ export function SwitchField({
109
+ label,
110
+ hint,
111
+ className,
112
+ ...props
113
+ }: ComponentProps<typeof Switch> & {
114
+ label: ReactNode;
115
+ /** คำอธิบายใต้ label (เยื้องตรงกับ label) */
116
+ hint?: ReactNode;
117
+ }) {
118
+ return (
119
+ <Label
120
+ className={cn(
121
+ "flex cursor-pointer items-center gap-2 font-normal pointer-coarse:min-h-11",
122
+ hint && "items-start",
123
+ className,
124
+ )}
125
+ >
126
+ <Switch className={cn(hint && "mt-0.5")} {...props} />
127
+ <span className="flex flex-col gap-0.5">
128
+ <span className="text-sm">{label}</span>
129
+ {hint && <span className="text-muted-foreground text-xs">{hint}</span>}
130
+ </span>
131
+ </Label>
132
+ );
133
+ }
@@ -0,0 +1,85 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ export type ListSectionTone = "default" | "danger" | "success" | "accent" | "muted";
5
+
6
+ const TONE: Record<ListSectionTone, string> = {
7
+ default: "text-foreground/90",
8
+ danger: "text-destructive",
9
+ success: "text-success",
10
+ accent: "text-primary",
11
+ muted: "text-muted-foreground",
12
+ };
13
+
14
+ /** หัวกลุ่มของรายการ: ชื่อ (สีตาม tone) + จำนวน + ปุ่มขวา + เส้นบางใต้หัว — ใช้เดี่ยว ๆ หรือผ่าน `ListSection` */
15
+ export function ListSectionHeader({
16
+ title,
17
+ count,
18
+ action,
19
+ tone = "default",
20
+ as: Heading = "h2",
21
+ className,
22
+ }: {
23
+ title: ReactNode;
24
+ count?: number;
25
+ action?: ReactNode;
26
+ tone?: ListSectionTone;
27
+ as?: "h2" | "h3" | "h4" | "p";
28
+ className?: string;
29
+ }) {
30
+ return (
31
+ <div
32
+ data-slot="list-section-header"
33
+ className={cn("mb-1 flex min-h-9 items-center justify-between border-b pb-1", className)}
34
+ >
35
+ <div className="flex min-w-0 items-center gap-2">
36
+ <Heading
37
+ className={cn(
38
+ "flex min-w-0 items-center gap-2 truncate font-semibold text-caption",
39
+ TONE[tone],
40
+ )}
41
+ >
42
+ {title}
43
+ </Heading>
44
+ {typeof count === "number" && (
45
+ <span className="rounded-md bg-muted px-1.5 py-0.5 font-semibold text-caption text-muted-foreground tabular-nums">
46
+ {count}
47
+ </span>
48
+ )}
49
+ </div>
50
+ {action}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * กลุ่มของรายการแบบ "ไม่มีกล่อง" (รูปแบบ List): หัวกลุ่ม + เส้นบาง + แถวคั่นด้วยเส้น — ยกจาก `SectionPanel plain` ของ pi-done
57
+ * แถวข้างในคั่นเส้นให้เอง (`divide-y`) · อยากได้กล่องใช้ `SectionCard`
58
+ *
59
+ * <ListSection title="เกินกำหนด" tone="danger" count={3} action={<Button variant="link" size="xs">ทำทั้งหมด</Button>}>
60
+ * {tasks.map((t) => <TaskRow key={t.id} … />)}
61
+ * </ListSection>
62
+ */
63
+ export function ListSection({
64
+ title,
65
+ count,
66
+ action,
67
+ tone,
68
+ headingAs,
69
+ children,
70
+ className,
71
+ ...props
72
+ }: Omit<ComponentProps<"section">, "title"> & {
73
+ title: ReactNode;
74
+ count?: number;
75
+ action?: ReactNode;
76
+ tone?: ListSectionTone;
77
+ headingAs?: "h2" | "h3" | "h4" | "p";
78
+ }) {
79
+ return (
80
+ <section data-slot="list-section" className={cn("py-1", className)} {...props}>
81
+ <ListSectionHeader title={title} count={count} action={action} tone={tone} as={headingAs} />
82
+ <div className="divide-y divide-border/50">{children}</div>
83
+ </section>
84
+ );
85
+ }
@@ -44,7 +44,8 @@ export function ProgressBar({
44
44
  showValue?: boolean;
45
45
  /** ปรับข้อความตัวเลข เช่น (v) => `${v} / ${max}` */
46
46
  format?: (value: number | null, max: number) => ReactNode;
47
- tone?: ProgressTone;
47
+ /** `auto` = เขียวเมื่อครบ (value ≥ max) นอกนั้นสีหลัก — แถบงานย่อยที่เต็มแล้วเปลี่ยนสีเอง */
48
+ tone?: ProgressTone | "auto";
48
49
  /** sm = h-1 (ค่าเริ่มต้น), md = h-2, lg = h-3 */
49
50
  size?: "sm" | "md" | "lg";
50
51
  trackClassName?: string;
@@ -76,7 +77,13 @@ export function ProgressBar({
76
77
  <ProgressTrack
77
78
  className={cn(size === "md" ? "h-2" : size === "lg" ? "h-3" : "h-1", trackClassName)}
78
79
  >
79
- <ProgressIndicator className={cn(TONE[tone], indicatorClassName)} style={indicatorStyle} />
80
+ <ProgressIndicator
81
+ className={cn(
82
+ TONE[tone === "auto" ? (value != null && value >= max ? "success" : "default") : tone],
83
+ indicatorClassName,
84
+ )}
85
+ style={indicatorStyle}
86
+ />
80
87
  </ProgressTrack>
81
88
  </ProgressPrimitive.Root>
82
89
  );
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ import {
4
+ Drawer,
5
+ DrawerContent,
6
+ DrawerDescription,
7
+ DrawerTitle,
8
+ DrawerTrigger,
9
+ } from "@suphark/ui/components/ui/drawer";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
11
+ import { useIsMobile } from "@suphark/ui/hooks/use-mobile";
12
+ import { cn } from "@suphark/ui/lib/utils";
13
+ import {
14
+ type ComponentProps,
15
+ createContext,
16
+ type KeyboardEventHandler,
17
+ type MouseEventHandler,
18
+ type PointerEventHandler,
19
+ type ReactElement,
20
+ type ReactNode,
21
+ useContext,
22
+ } from "react";
23
+
24
+ const ResponsivePopoverContext = createContext(false);
25
+
26
+ /**
27
+ * อยู่ในโหมดแผ่นล่าง (จอ < 768px) หรือไม่ — ใช้ขยายพื้นที่แตะของ picker ใน `ResponsivePopover`
28
+ * (นอก `ResponsivePopover` = false)
29
+ */
30
+ export function useIsResponsiveSheet() {
31
+ return useContext(ResponsivePopoverContext);
32
+ }
33
+
34
+ export interface ResponsivePopoverProps {
35
+ open?: boolean;
36
+ onOpenChange?: (open: boolean) => void;
37
+ children: ReactNode;
38
+ }
39
+
40
+ /**
41
+ * Popover บนจอกว้าง (≥ 768px) · แผ่นล่าง (Drawer) บนมือถือ: สูงตามเนื้อหา, footer ค้างล่าง, ปัดลงเพื่อปิด
42
+ * ใช้กับ picker ที่เปิดจากปุ่ม (สถานะ/ลำดับความสำคัญ/วันที่ …) — ในแผ่นใช้ `useIsResponsiveSheet()` ขยายแถวเป็น 44px
43
+ *
44
+ * <ResponsivePopover open={open} onOpenChange={setOpen}>
45
+ * <ResponsivePopoverTrigger render={<Button variant="outline">สถานะ</Button>} />
46
+ * <ResponsivePopoverContent title="เลือกสถานะ" className="w-56 p-1">
47
+ * <ResponsivePopoverItem onClick={() => pick("todo")}>ต้องทำ</ResponsivePopoverItem>
48
+ * </ResponsivePopoverContent>
49
+ * </ResponsivePopover>
50
+ */
51
+ export function ResponsivePopover({ open, onOpenChange, children }: ResponsivePopoverProps) {
52
+ const isMobile = useIsMobile();
53
+ const handleOpenChange = onOpenChange ? (next: boolean) => onOpenChange(next) : undefined;
54
+
55
+ return (
56
+ <ResponsivePopoverContext.Provider value={isMobile}>
57
+ {isMobile ? (
58
+ <Drawer open={open} onOpenChange={handleOpenChange} showSwipeHandle>
59
+ {children}
60
+ </Drawer>
61
+ ) : (
62
+ <Popover open={open} onOpenChange={handleOpenChange}>
63
+ {children}
64
+ </Popover>
65
+ )}
66
+ </ResponsivePopoverContext.Provider>
67
+ );
68
+ }
69
+
70
+ export interface ResponsivePopoverTriggerProps {
71
+ render: ReactElement;
72
+ nativeButton?: boolean;
73
+ disabled?: boolean;
74
+ children?: ReactNode;
75
+ }
76
+
77
+ export function ResponsivePopoverTrigger(props: ResponsivePopoverTriggerProps) {
78
+ const isSheet = useIsResponsiveSheet();
79
+ return isSheet ? <DrawerTrigger {...props} /> : <PopoverTrigger {...props} />;
80
+ }
81
+
82
+ export interface ResponsivePopoverContentProps {
83
+ /** หัวแผ่นบนมือถือ และเป็นชื่อสำหรับ screen reader เสมอ */
84
+ title: string;
85
+ description?: string;
86
+ /** ซ่อนหัวแผ่น (ยังอ่านออกเสียง) เมื่อเนื้อหาบอกชัดอยู่แล้ว */
87
+ hideTitle?: boolean;
88
+ /** class ของ Popover เท่านั้น (ความกว้าง, padding …) */
89
+ className?: string;
90
+ /** class ของส่วนเนื้อหาที่เลื่อนได้ในแผ่น */
91
+ sheetClassName?: string;
92
+ /** class ของตัวแผ่นเอง (ทับ `bg-background text-left`) */
93
+ sheetContentClassName?: string;
94
+ /** ค้างใต้เนื้อหาที่เลื่อน (ในแผ่น) หรือต่อท้ายเนื้อหา (ใน Popover) */
95
+ footer?: ReactNode;
96
+ align?: "start" | "center" | "end";
97
+ side?: "top" | "bottom" | "left" | "right";
98
+ sideOffset?: number;
99
+ onClick?: MouseEventHandler<HTMLDivElement>;
100
+ onPointerDown?: PointerEventHandler<HTMLDivElement>;
101
+ onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
102
+ children: ReactNode;
103
+ }
104
+
105
+ export function ResponsivePopoverContent({
106
+ title,
107
+ description,
108
+ hideTitle = false,
109
+ className,
110
+ sheetClassName,
111
+ sheetContentClassName,
112
+ footer,
113
+ align = "start",
114
+ side,
115
+ sideOffset = 4,
116
+ children,
117
+ ...eventProps
118
+ }: ResponsivePopoverContentProps) {
119
+ const isSheet = useIsResponsiveSheet();
120
+
121
+ if (!isSheet) {
122
+ return (
123
+ <PopoverContent
124
+ align={align}
125
+ side={side}
126
+ sideOffset={sideOffset}
127
+ className={className}
128
+ {...eventProps}
129
+ >
130
+ {children}
131
+ {footer}
132
+ </PopoverContent>
133
+ );
134
+ }
135
+
136
+ return (
137
+ <DrawerContent className={cn("bg-background text-left", sheetContentClassName)} {...eventProps}>
138
+ <DrawerTitle className={cn("px-4 pt-1 pb-1 font-semibold text-sm", hideTitle && "sr-only")}>
139
+ {title}
140
+ </DrawerTitle>
141
+ <DrawerDescription className="sr-only">{description ?? title}</DrawerDescription>
142
+ <div
143
+ className={cn(
144
+ "min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain",
145
+ !footer && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
146
+ sheetClassName,
147
+ )}
148
+ >
149
+ {children}
150
+ </div>
151
+ {footer ? <div className="shrink-0 pb-[env(safe-area-inset-bottom)]">{footer}</div> : null}
152
+ </DrawerContent>
153
+ );
154
+ }
155
+
156
+ /** แถวเมนูที่สูงเป็นพื้นที่แตะ 44px เมื่ออยู่ในแผ่นล่าง (36px ใน Popover) */
157
+ export function ResponsivePopoverItem({
158
+ className,
159
+ type = "button",
160
+ ...props
161
+ }: ComponentProps<"button">) {
162
+ const isSheet = useIsResponsiveSheet();
163
+ return (
164
+ <button
165
+ type={type}
166
+ className={cn(
167
+ "flex w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
168
+ isSheet ? "h-11" : "h-9",
169
+ className,
170
+ )}
171
+ {...props}
172
+ />
173
+ );
174
+ }
@@ -0,0 +1,133 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { ComponentProps, CSSProperties } from "react";
3
+
4
+ /**
5
+ * ตารางแถว (grid) ที่หัว/แถว/แถวกลุ่มใช้คอลัมน์ชุดเดียวกัน — หัวซ่อนบนมือถือ แถวเหลือคอลัมน์ที่เลือก (ปกติชื่อ + ปุ่ม)
6
+ * ปุ่มท้ายแถวโผล่ตอนชี้ (`RowTableActions`) แต่เห็นตลอดบนมือถือ/จอสัมผัส · ยกจาก pi-done (เขียนซ้ำ 4 ที่)
7
+ * ต่างจาก `DataList` (รายการแถวไม่มีหัวคอลัมน์) และ `SimpleTable` (`<table>` จริงที่เรียง/แบ่งหน้าได้): ตัวนี้เหมาะกับรายการที่ไม่กี่คอลัมน์ ไม่ต้องเรียงข้อมูล
8
+ *
9
+ * <RowTable columns="minmax(0,1fr) 3.5rem 7rem" mobileColumns="minmax(0,1fr) auto">
10
+ * <RowTableHeader><span>ชื่อ</span><span className="text-right">เปิด</span><span className="sr-only">คำสั่ง</span></RowTableHeader>
11
+ * <RowTableRow>
12
+ * <RowTableCell>โปรเจกต์ A</RowTableCell>
13
+ * <RowTableCell hideOnMobile align="end">12</RowTableCell>
14
+ * <RowTableActions><Button size="icon-sm" aria-label="แก้ไข">…</Button></RowTableActions>
15
+ * </RowTableRow>
16
+ * </RowTable>
17
+ */
18
+ export function RowTable({
19
+ columns,
20
+ mobileColumns = "minmax(0,1fr) auto",
21
+ className,
22
+ style,
23
+ ...props
24
+ }: ComponentProps<"div"> & {
25
+ /** `grid-template-columns` ตั้งแต่จอ md ขึ้นไป เช่น "minmax(0,1fr) 3.5rem 7rem" (จำนวนช่องต้องเท่าลูกของหัว/แถว) */
26
+ columns: string;
27
+ /** `grid-template-columns` ของมือถือ (ค่าเริ่มต้น "minmax(0,1fr) auto") — ซ่อนช่องที่เหลือด้วย `hideOnMobile` */
28
+ mobileColumns?: string;
29
+ }) {
30
+ return (
31
+ <div
32
+ data-slot="row-table"
33
+ style={
34
+ {
35
+ "--row-cols": columns,
36
+ "--row-cols-mobile": mobileColumns,
37
+ ...style,
38
+ } as CSSProperties
39
+ }
40
+ className={cn("overflow-hidden rounded-xl border bg-card text-card-foreground", className)}
41
+ {...props}
42
+ />
43
+ );
44
+ }
45
+
46
+ const GRID = "grid grid-cols-(--row-cols-mobile) items-center gap-x-4 md:grid-cols-(--row-cols)";
47
+
48
+ /** หัวคอลัมน์ — ซ่อนบนมือถือ */
49
+ export function RowTableHeader({ className, ...props }: ComponentProps<"div">) {
50
+ return (
51
+ <div
52
+ data-slot="row-table-header"
53
+ className={cn(
54
+ GRID,
55
+ "hidden h-10 border-b bg-muted/40 px-2 font-semibold text-label text-muted-foreground md:grid",
56
+ className,
57
+ )}
58
+ {...props}
59
+ />
60
+ );
61
+ }
62
+
63
+ export function RowTableRow({ className, ...props }: ComponentProps<"div">) {
64
+ return (
65
+ <div
66
+ data-slot="row-table-row"
67
+ className={cn(
68
+ GRID,
69
+ "group/row min-h-13 border-b px-2 last:border-b-0 hover:bg-muted/40",
70
+ className,
71
+ )}
72
+ {...props}
73
+ />
74
+ );
75
+ }
76
+
77
+ /** ช่องของแถว — `hideOnMobile` ซ่อนบนมือถือ (ต้องตรงกับ `mobileColumns`) */
78
+ export function RowTableCell({
79
+ hideOnMobile = false,
80
+ align = "start",
81
+ className,
82
+ ...props
83
+ }: ComponentProps<"div"> & {
84
+ hideOnMobile?: boolean;
85
+ align?: "start" | "end";
86
+ }) {
87
+ return (
88
+ <div
89
+ data-slot="row-table-cell"
90
+ className={cn(
91
+ "min-w-0 text-sm",
92
+ align === "end" && "text-right tabular-nums",
93
+ hideOnMobile && "hidden md:block",
94
+ className,
95
+ )}
96
+ {...props}
97
+ />
98
+ );
99
+ }
100
+
101
+ /** ปุ่มท้ายแถว — โผล่ตอนชี้/โฟกัสในแถว (md ขึ้นไป) · มือถือและจอสัมผัสเห็นตลอด (`reveal={false}` = เห็นตลอดทุกจอ) */
102
+ export function RowTableActions({
103
+ reveal = true,
104
+ className,
105
+ ...props
106
+ }: ComponentProps<"div"> & { reveal?: boolean }) {
107
+ return (
108
+ <div
109
+ data-slot="row-table-actions"
110
+ className={cn(
111
+ "flex shrink-0 items-center justify-end gap-0.5",
112
+ reveal &&
113
+ "transition-opacity md:opacity-0 md:group-focus-within/row:opacity-100 md:group-hover/row:opacity-100 pointer-coarse:opacity-100",
114
+ className,
115
+ )}
116
+ {...props}
117
+ />
118
+ );
119
+ }
120
+
121
+ /** แถวหัวกลุ่ม (โฟลเดอร์) คั่นระหว่างแถว — ครอบทั้งความกว้าง ไม่ใช้คอลัมน์ของตาราง */
122
+ export function RowTableGroup({ className, ...props }: ComponentProps<"div">) {
123
+ return (
124
+ <div
125
+ data-slot="row-table-group"
126
+ className={cn(
127
+ "flex h-10 items-center gap-1.5 border-b bg-muted/20 px-3 font-semibold text-caption text-muted-foreground",
128
+ className,
129
+ )}
130
+ {...props}
131
+ />
132
+ );
133
+ }
@@ -0,0 +1,62 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { ComponentProps } from "react";
3
+
4
+ /**
5
+ * แถวเมนูข้างแบบเรียบ (ใช้เมื่อประกอบ sidebar เอง ไม่ใช่ `AppNav`): หน้าปัจจุบันอ่านเป็นสีหลัก (`bg-primary/10 text-primary`)
6
+ * สูงขั้นต่ำ 32px และ 44px บนจอสัมผัส · `draggingOver` ไฮไลต์ตอนลากรายการมาวาง · ยกจาก pi-done
7
+ * เป็น `div` (วาง `Link`/ปุ่มไว้ข้างใน) เพื่อให้ใช้เป็นที่วางของการลากได้
8
+ *
9
+ * <SidebarNavItem active={path === "/inbox"}>
10
+ * <Link href="/inbox" className="flex-1">กล่องเข้า</Link>
11
+ * <SidebarCountBadge count={unread} active={path === "/inbox"} />
12
+ * </SidebarNavItem>
13
+ */
14
+ export function SidebarNavItem({
15
+ active = false,
16
+ draggingOver = false,
17
+ className,
18
+ ...props
19
+ }: ComponentProps<"div"> & { active?: boolean; draggingOver?: boolean }) {
20
+ return (
21
+ <div
22
+ data-slot="sidebar-nav-item"
23
+ data-active={active || undefined}
24
+ className={cn(
25
+ "relative flex min-h-8 items-center justify-between rounded-md border border-transparent px-2.5 py-1 text-sm transition-[background-color,transform,border-color,color] duration-150 ease-out pointer-coarse:min-h-11",
26
+ active
27
+ ? "bg-primary/10 font-semibold text-primary"
28
+ : "font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/60 hover:text-foreground",
29
+ draggingOver && "scale-[1.02] border-primary/20 bg-primary/10 text-primary shadow-2xs",
30
+ className,
31
+ )}
32
+ {...props}
33
+ />
34
+ );
35
+ }
36
+
37
+ /** ตัวเลขท้ายแถวเมนู — ข้อความเทา ไม่ใช่ pill (เมนูอ่านชื่อก่อน) · 0 ซ่อน · เกิน `max` แสดง "999+" */
38
+ export function SidebarCountBadge({
39
+ count,
40
+ active = false,
41
+ max = 999,
42
+ className,
43
+ }: {
44
+ count: number;
45
+ active?: boolean;
46
+ max?: number;
47
+ className?: string;
48
+ }) {
49
+ if (count <= 0) return null;
50
+ return (
51
+ <span
52
+ data-slot="sidebar-count-badge"
53
+ className={cn(
54
+ "shrink-0 text-caption tabular-nums",
55
+ active ? "font-semibold text-primary" : "text-muted-foreground/80",
56
+ className,
57
+ )}
58
+ >
59
+ {count > max ? `${max}+` : count}
60
+ </span>
61
+ );
62
+ }
@@ -213,6 +213,11 @@ export interface UiText {
213
213
  dayCount: string;
214
214
  allShortcuts: string;
215
215
  tapToFill: string;
216
+ /** `minDate`/`maxDate` — `{date}` = วันที่ขอบเขต (ตาม `locale` ของช่อง) */
217
+ beforeMin: string;
218
+ afterMax: string;
219
+ /** `DateRangePicker` (ตัวกรอง) */
220
+ clearRange: string;
216
221
  last7Days: string;
217
222
  last30Days: string;
218
223
  thisMonth: string;
@@ -418,6 +423,9 @@ export const UI_TEXT_TH: UiText = {
418
423
  dayCount: "{count} วัน",
419
424
  allShortcuts: "พิมพ์ลัดทั้งหมด",
420
425
  tapToFill: "กดเพื่อเติมในช่อง",
426
+ beforeMin: "เลือกได้ตั้งแต่วันที่ {date}",
427
+ afterMax: "เลือกได้ถึงวันที่ {date}",
428
+ clearRange: "ล้างช่วงวันที่",
421
429
  last7Days: "7 วันล่าสุด",
422
430
  last30Days: "30 วันล่าสุด",
423
431
  thisMonth: "เดือนนี้",
@@ -629,6 +637,9 @@ export const UI_TEXT_EN: UiText = {
629
637
  dayCount: "{count} days",
630
638
  allShortcuts: "All shortcuts",
631
639
  tapToFill: "Tap to fill the field",
640
+ beforeMin: "Pick {date} or later",
641
+ afterMax: "Pick {date} or earlier",
642
+ clearRange: "Clear selection",
632
643
  last7Days: "Last 7 days",
633
644
  last30Days: "Last 30 days",
634
645
  thisMonth: "This month",
package/src/index.ts CHANGED
@@ -77,6 +77,7 @@ export {
77
77
  } from "./components/shared/confirm-dialog";
78
78
  export { CountBadge } from "./components/shared/count-badge";
79
79
  export { DataList, DataRow } from "./components/shared/data-list";
80
+ export { AccessibleCalendar } from "./components/shared/date/accessible-calendar";
80
81
  export {
81
82
  DatePicker,
82
83
  type DatePickerProps,
@@ -131,7 +132,7 @@ export {
131
132
  type FormInvalidNotice,
132
133
  useFormInvalidNotice,
133
134
  } from "./components/shared/form/form-invalid-notice";
134
- export { CheckboxField, LabeledField } from "./components/shared/form/labeled-field";
135
+ export { CheckboxField, LabeledField, SwitchField } from "./components/shared/form/labeled-field";
135
136
  export { NumberInput } from "./components/shared/form/number-input";
136
137
  export { SearchInput, type SearchInputProps } from "./components/shared/form/search-input";
137
138
  export {
@@ -175,6 +176,11 @@ export {
175
176
  type LanguageSwitchProps,
176
177
  } from "./components/shared/language/language-switch";
177
178
  export { ErrorState, type RouteError } from "./components/shared/layout/error-state";
179
+ export {
180
+ ListSection,
181
+ ListSectionHeader,
182
+ type ListSectionTone,
183
+ } from "./components/shared/list-section";
178
184
  export {
179
185
  MonthStepper,
180
186
  type MonthStepperProps,
@@ -211,6 +217,24 @@ export {
211
217
  type RecordTone,
212
218
  recordAccentColor,
213
219
  } from "./components/shared/record-card";
220
+ export {
221
+ ResponsivePopover,
222
+ ResponsivePopoverContent,
223
+ type ResponsivePopoverContentProps,
224
+ ResponsivePopoverItem,
225
+ type ResponsivePopoverProps,
226
+ ResponsivePopoverTrigger,
227
+ type ResponsivePopoverTriggerProps,
228
+ useIsResponsiveSheet,
229
+ } from "./components/shared/responsive-popover";
230
+ export {
231
+ RowTable,
232
+ RowTableActions,
233
+ RowTableCell,
234
+ RowTableGroup,
235
+ RowTableHeader,
236
+ RowTableRow,
237
+ } from "./components/shared/row-table";
214
238
  export {
215
239
  describeCondition,
216
240
  emptyGroup,
@@ -242,6 +266,7 @@ export { SelectionButton } from "./components/shared/selection-button";
242
266
  export { SettingsLayout, type SettingsSection } from "./components/shared/settings-layout";
243
267
  export { DangerZone, SettingsRow, SwitchRow } from "./components/shared/settings-row";
244
268
  export { SetupChecklist, type SetupChecklistStep } from "./components/shared/setup-checklist";
269
+ export { SidebarCountBadge, SidebarNavItem } from "./components/shared/sidebar-nav-item";
245
270
  export { SimpleDialog, SimpleSheet } from "./components/shared/simple-dialog";
246
271
  export { paginationRange, SimplePagination } from "./components/shared/simple-pagination";
247
272
  export {