@suphark/ui 0.38.0 → 0.40.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 +51 -1
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/components/design-system/sections/showcase-forms.tsx +1 -1
- package/src/components/shared/date/date-picker.tsx +377 -0
- package/src/components/shared/form/form-field-date.tsx +1 -1
- package/src/components/shared/ui-text.tsx +43 -0
- package/src/components/sortable/sortable-list.tsx +254 -71
- package/src/components/ui/date-picker.tsx +8 -306
- package/src/index.ts +6 -1
- package/src/lib/sortable-order.ts +170 -33
- package/src/sortable.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.40.1] — 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `DatePicker`: ปฏิทินใน popup มีที่ว่างซ้ายขวามาก (เจ้าของแจ้ง) — กล่องกว้างตายตัว 320–340px แต่ช่องวันที่ 28px ปฏิทินจึงกว้างแค่ ~200px · ตอนนี้ช่องวันที่ 40px (กดง่ายขึ้นด้วย) และกล่องกว้างพอดีปฏิทิน 306px (commit แรกหลุด format ของ biome — แก้ในรุ่นเดียวกัน)
|
|
10
|
+
|
|
11
|
+
## [0.40.0] — 2026-10-07
|
|
12
|
+
|
|
13
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.40.1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`DatePicker` ใช้ในฟอร์มและหลายภาษาได้** (ขอจาก Pi Auth — `<input type="date">` แสดง "10/02/2027" อ่านได้ทั้ง 2 ต.ค. และ 10 ก.พ.):
|
|
19
|
+
- `locale` (BCP 47): แสดงผ่าน Intl `{ day, month: "short", year }` ทั้งในช่อง ปฏิทิน และวันที่สัมพัทธ์ — `"en-GB"` = "2 Oct 2027", `"en"` = "Oct 2, 2027", `"th"` = "2 ต.ค. 2570" · ไม่ส่ง = รูปแบบไทยเดิม
|
|
20
|
+
- `name` (+ `defaultValue` แบบไม่ควบคุม, `value`/`onChange` ไม่บังคับแล้ว): ส่ง `YYYY-MM-DD` ตามวันที่ local ไปกับ FormData ของ `<form action>` · `required` ทำงานกับการตรวจของเบราว์เซอร์และตาข่ายฟอร์ม (ชื่อช่องจาก `LabeledField`)
|
|
21
|
+
- `showRelative` (ค่าเริ่มต้น true) · `toDateInputValue(date)` export ให้ใช้เอง · type `DatePickerProps`
|
|
22
|
+
- `UiText.datePicker` (placeholder, ทางลัด, ปุ่ม) ไทย/อังกฤษในตัว
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- `DatePicker` ย้ายไป `components/shared/date/date-picker.tsx` — ไม่ได้มาจาก shadcn CLI (registry ของ base-nova ไม่มี date-picker) แต่ยกมาจาก supplychain; `components/ui/date-picker` ยัง re-export ให้ import path เดิมใช้ได้
|
|
27
|
+
- ข้อความเดิมที่ฝังไว้ย้ายไป UiText: placeholder ค่าเริ่มต้นจาก "เลือกวันที่อ้างอิงราคา..." (ของ supplychain) เป็น "เลือกวันที่", ทางลัดเป็นภาษาไทย ("วันนี้", "+1 สัปดาห์" …) แทนอังกฤษปนไทย, หัวข้อ "Quick Preset" → "ทางลัด"
|
|
28
|
+
- สีดิบ (slate/emerald) → semantic token · ปุ่ม "ตกลง" ของช่องพิมพ์วันที่เป็น `type="button"` (เดิม submit — ส่งฟอร์มแม่ได้ถ้าไม่ได้กด Enter) · ปุ่มล้างมี `aria-label` และกดด้วยคีย์บอร์ดได้
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `value="YYYY-MM-DD"` อ่านเป็น UTC แล้วเลื่อนวันในโซนเวลาตะวันตก → อ่านเป็นวันที่ local
|
|
33
|
+
- วันที่สัมพัทธ์ ("(พรุ่งนี้)") แสดงหลัง mount — ขึ้นกับวันของเครื่องผู้ใช้ จึง hydration mismatch ได้ข้ามวัน/โซนเวลา (ค้างใน todo)
|
|
34
|
+
- showcase: `/components/date-picker` เพิ่มตัวอย่างในฟอร์ม server action (`name` + `locale="en-GB"` + `required`)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`SortableProvider freeLayout` — ชั้นบนสุดจัดได้อิสระ** (เจ้าของแจ้งว่าเดิม "ล็อก" ให้รายการไม่มีกลุ่มอยู่บนเสมอ กลุ่มอยู่ล่างเสมอ): กลุ่มกับรายการไม่มีกลุ่มสลับที่กันได้ทุกแบบ — กลุ่มขึ้นไปเหนือรายการ, รายการไปอยู่ระหว่างกลุ่ม, เอารายการออกจากกลุ่มไปวางก่อนกลุ่มไหนก็ได้ · หัวกลุ่ม: **ครึ่งบน = วางก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก** · ระหว่างลากทุกอย่างในชั้นบนสุดเลื่อนหลบเป็นก้อน (กลุ่ม = หัว + รายการ) · โซนท้ายตาราง = วางท้ายสุด · ไม่เปิด = แบบเดิม (แอปที่ยังเรนเดอร์รายการไม่มีกลุ่มก่อนไม่เปลี่ยนพฤติกรรม)
|
|
40
|
+
- `SortableOrder.root` (optional) + `rootEntries(order)` (ลำดับชั้นบนสุดสำหรับวนเรนเดอร์), `moveRootEntry`, `moveItemToRoot`, type `SortableRootEntry` · `sortableOrderToPayload(order).root` + `sortableOrderOf(..., { root })` สำหรับบันทึก/โหลด — `groups` / `items[UNGROUPED]` ยังตรงกับ root เสมอ (payload เดิมใช้ต่อได้)
|
|
41
|
+
- showcase: `/components/sortable-list` เปิด `freeLayout` และเรนเดอร์ตาม `rootEntries`
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- (จาก 0.38.1 ที่ไม่ได้ขึ้น npm แยก) ลากรายการเข้ากลุ่มแล้ววางเป็นรายการแรกไม่ได้ — ดูด้านล่าง
|
|
46
|
+
- โซน "ท้ายตาราง" (`useSortableDropZone(UNGROUPED)`) ถูก collision เปลี่ยนเป้าหมายเป็นรายการไม่มีกลุ่มตัวแรก → วางแล้วไปโผล่บนสุดแทนท้ายสุด
|
|
47
|
+
|
|
48
|
+
## [0.38.1] — 2026-10-07
|
|
49
|
+
|
|
50
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.39.0
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- **`@suphark/ui/sortable` ลากรายการเข้ากลุ่มแล้ววางเป็นรายการแรกไม่ได้** (เจ้าของแจ้ง): ระหว่างลากขึ้นเป็นตัวแรกถูก แต่พอปล่อยกลับไปเป็นตัวที่สอง — ตอนชี้หัวกลุ่ม collision เลือก "รายการที่ใกล้ที่สุดที่ไม่ใช่ตัวที่ลาก" (= ตัวแรกเดิม) แล้ว `onDragEnd` ย้ายไปแทนที่ตัวนั้น · ตอนนี้ชี้หัวกลุ่ม = รายการแรกที่แสดงอยู่ **รวมตัวที่ลากเอง** · ยืนยันบน demo: ลากจากนอกกลุ่ม / จากกลุ่มอื่น / จากท้ายกลุ่มเดียวกันขึ้นหัวกลุ่ม → เป็นตัวแรกทุกกรณี, วางครึ่งล่างของแถวยังไปอยู่ "หลัง" แถวนั้นตามเดิม (ทำซ้ำบน production v0.38.0 ได้ก่อนแก้)
|
|
55
|
+
|
|
6
56
|
|
|
7
57
|
### Fixed
|
|
8
58
|
|
package/README.md
CHANGED
|
@@ -235,6 +235,7 @@ const [order, setOrder] = useState(() =>
|
|
|
235
235
|
- `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
|
|
236
236
|
- การ์ดลอยตามเมาส์: `renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}` (วาดใน portal จึงเป็น `div` ได้ ไม่ต้องเป็น `<tr>`) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่: `useSortableDropZone(id, { onDwell: collapsed ? open : undefined })` ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของ `UNGROUPED` = ต่อท้าย
|
|
237
237
|
- เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
|
|
238
|
+
- จัดชั้นบนสุดอิสระ (กลุ่มกับรายการไม่มีกลุ่มสลับกันได้): `SortableProvider freeLayout` + เรนเดอร์ตาม `rootEntries(order)` (ครอบทั้งหมดด้วย `<SortableGroup itemIds={order.items[UNGROUPED]}>` แล้วแต่ละกลุ่มมี `SortableGroup` ของตัวเองข้างใน) + บันทึก `payload.root` และส่งกลับ `sortableOrderOf(..., { root })` ตอนโหลด · หัวกลุ่มครึ่งบน = ก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่ม
|
|
238
239
|
- `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
|
|
239
240
|
- `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
|
|
240
241
|
- ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.1",
|
|
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 เอง",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { Combobox } from "@suphark/ui/components/shared/combobox";
|
|
5
|
+
import { DatePicker } from "@suphark/ui/components/shared/date/date-picker";
|
|
5
6
|
import { MultiSelectCombobox } from "@suphark/ui/components/shared/multi-select-combobox";
|
|
6
7
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
7
8
|
import {
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
CardHeader,
|
|
12
13
|
CardTitle,
|
|
13
14
|
} from "@suphark/ui/components/ui/card";
|
|
14
|
-
import { DatePicker } from "@suphark/ui/components/ui/date-picker";
|
|
15
15
|
import {
|
|
16
16
|
Field,
|
|
17
17
|
FieldDescription,
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
+
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
6
|
+
import {
|
|
7
|
+
InputGroup,
|
|
8
|
+
InputGroupAddon,
|
|
9
|
+
InputGroupButton,
|
|
10
|
+
InputGroupInput,
|
|
11
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
12
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
13
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
14
|
+
import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
|
|
15
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
16
|
+
import { addDays, addMonths, addWeeks, subDays, subMonths, subWeeks } from "date-fns";
|
|
17
|
+
import { CalendarIcon, Check, Clock, X } from "lucide-react";
|
|
18
|
+
import { useMemo, useState } from "react";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Smart date parser supporting intuitive Thai & international date inputs:
|
|
22
|
+
* - "1/7" -> 1 July of current year
|
|
23
|
+
* - "1/7/25" -> 1 July 2025 CE
|
|
24
|
+
* - "1/7/68" -> 1 July 2025 CE (BE 2568)
|
|
25
|
+
* - "1/7/2568" -> 1 July 2025 CE (BE 2568)
|
|
26
|
+
*/
|
|
27
|
+
export function parseSmartDateInput(input: string): Date | null {
|
|
28
|
+
if (!input?.trim()) return null;
|
|
29
|
+
const clean = input.trim().replace(/\s+/g, "");
|
|
30
|
+
|
|
31
|
+
const parts = clean.split(/[/\-.]/);
|
|
32
|
+
if (parts.length < 2 || parts.length > 3) return null;
|
|
33
|
+
|
|
34
|
+
const day = parseInt(parts[0] ?? "", 10);
|
|
35
|
+
const month = parseInt(parts[1] ?? "", 10);
|
|
36
|
+
if (Number.isNaN(day) || Number.isNaN(month) || day < 1 || day > 31 || month < 1 || month > 12) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const currentYear = new Date().getFullYear();
|
|
41
|
+
let year = currentYear;
|
|
42
|
+
|
|
43
|
+
if (parts.length === 3 && parts[2]) {
|
|
44
|
+
const rawYear = parseInt(parts[2], 10);
|
|
45
|
+
if (Number.isNaN(rawYear)) return null;
|
|
46
|
+
|
|
47
|
+
if (rawYear > 2400) {
|
|
48
|
+
// Full Buddhist Era year (e.g. 2568 -> 2025 CE)
|
|
49
|
+
year = rawYear - 543;
|
|
50
|
+
} else if (rawYear >= 50 && rawYear < 100) {
|
|
51
|
+
// Short BE year (e.g. 68 -> 2568 -> 2025 CE)
|
|
52
|
+
year = 2500 + rawYear - 543;
|
|
53
|
+
} else if (rawYear < 50) {
|
|
54
|
+
// Short AD year (e.g. 25 -> 2025 CE)
|
|
55
|
+
year = 2000 + rawYear;
|
|
56
|
+
} else {
|
|
57
|
+
year = rawYear;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const d = new Date(year, month - 1, day);
|
|
62
|
+
if (d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day) {
|
|
63
|
+
return d;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type DateInput = Date | string | number | null | undefined;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* แปลงค่าที่รับมาเป็น Date — `"YYYY-MM-DD"` อ่านเป็นวันที่ local
|
|
73
|
+
* (`new Date("2027-10-02")` อ่านเป็น UTC เที่ยงคืน → ฝั่งตะวันตกของ UTC กลายเป็นวันก่อนหน้า)
|
|
74
|
+
*/
|
|
75
|
+
function toDate(value: DateInput): Date | undefined {
|
|
76
|
+
if (value === null || value === undefined || value === "") return undefined;
|
|
77
|
+
if (typeof value === "string") {
|
|
78
|
+
const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
79
|
+
if (ymd) return new Date(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3]));
|
|
80
|
+
}
|
|
81
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
82
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `YYYY-MM-DD` ตามวันที่ local (ไม่ใช่ toISOString ที่เลื่อนวันตาม timezone) — ค่าที่ส่งในฟอร์ม */
|
|
86
|
+
export function toDateInputValue(date: Date | null | undefined): string {
|
|
87
|
+
if (!date) return "";
|
|
88
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
89
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** "2 Oct 2027" / "2 ต.ค. 2570" ตาม locale — ไม่ส่ง locale = รูปแบบไทยเดิม (เดือนย่อ + พ.ศ.) */
|
|
93
|
+
function formatDate(date: Date, locale: string | undefined): string {
|
|
94
|
+
if (!locale) return formatThaiFullDate(date);
|
|
95
|
+
return new Intl.DateTimeFormat(locale, {
|
|
96
|
+
day: "numeric",
|
|
97
|
+
month: "short",
|
|
98
|
+
year: "numeric",
|
|
99
|
+
}).format(date);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** "พรุ่งนี้" / "in 3 days" — นับเป็นวันตามปฏิทิน */
|
|
103
|
+
function formatRelative(date: Date, locale: string | undefined): string {
|
|
104
|
+
if (!locale) return formatRelativeThaiDate(date);
|
|
105
|
+
const startOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
106
|
+
const days = Math.round((startOf(date) - startOf(new Date())) / 86_400_000);
|
|
107
|
+
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(days, "day");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface DatePickerProps {
|
|
111
|
+
id?: string;
|
|
112
|
+
/** ควบคุมจากภายนอก (`"YYYY-MM-DD"` อ่านเป็นวันที่ local) — ใช้แบบไม่ควบคุมให้ส่ง `defaultValue` แทน */
|
|
113
|
+
value?: DateInput;
|
|
114
|
+
/** ค่าเริ่มต้นแบบไม่ควบคุม (ใช้คู่ `name` ใน `<form action>`) — `null` = เริ่มว่าง */
|
|
115
|
+
defaultValue?: DateInput;
|
|
116
|
+
onChange?: (date: Date | null) => void;
|
|
117
|
+
/**
|
|
118
|
+
* ชื่อฟิลด์ในฟอร์ม — ส่งค่า `YYYY-MM-DD` (วันที่ local) ไปกับ FormData ของ `<form action>`/server action
|
|
119
|
+
* ว่าง = ส่ง `""`
|
|
120
|
+
*/
|
|
121
|
+
name?: string;
|
|
122
|
+
/** ต้องเลือกวันที่ก่อนส่งฟอร์ม — ไม่เลือกแล้วกดส่ง เบราว์เซอร์บล็อกและตาข่ายฟอร์มบอกชื่อช่องให้ */
|
|
123
|
+
required?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* ภาษาของวันที่ (BCP 47 เช่น "en", "th") — แสดงแบบ "2 Oct 2027" / "2 ต.ค. 2570" ทั้งในช่อง ปฏิทิน และวันที่สัมพัทธ์
|
|
126
|
+
* ไม่ส่ง = รูปแบบไทยเดิม · ข้อความอื่นมาจาก `UiText.datePicker`
|
|
127
|
+
*/
|
|
128
|
+
locale?: string;
|
|
129
|
+
/** ไม่ส่ง = `UiText.datePicker.placeholder` */
|
|
130
|
+
placeholder?: string;
|
|
131
|
+
disabled?: boolean;
|
|
132
|
+
className?: string;
|
|
133
|
+
allowClear?: boolean;
|
|
134
|
+
showPresets?: boolean;
|
|
135
|
+
showSmartInput?: boolean;
|
|
136
|
+
/** แสดงวันที่สัมพัทธ์ต่อท้าย เช่น "(พรุ่งนี้)" — ค่าเริ่มต้น true */
|
|
137
|
+
showRelative?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function DatePicker({
|
|
141
|
+
id,
|
|
142
|
+
value,
|
|
143
|
+
defaultValue,
|
|
144
|
+
onChange,
|
|
145
|
+
name,
|
|
146
|
+
required = false,
|
|
147
|
+
locale,
|
|
148
|
+
placeholder,
|
|
149
|
+
disabled = false,
|
|
150
|
+
className,
|
|
151
|
+
allowClear = true,
|
|
152
|
+
showPresets = true,
|
|
153
|
+
showSmartInput = true,
|
|
154
|
+
showRelative = true,
|
|
155
|
+
}: DatePickerProps) {
|
|
156
|
+
const text = useUiText().datePicker;
|
|
157
|
+
const mounted = useMounted();
|
|
158
|
+
const [open, setOpen] = useState(false);
|
|
159
|
+
const [smartText, setSmartText] = useState("");
|
|
160
|
+
// ไม่ควบคุม = มี defaultValue หรือไม่มีทั้ง value และ onChange (เช่นใช้แค่ name ในฟอร์ม)
|
|
161
|
+
const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
|
|
162
|
+
const [own, setOwn] = useState<Date | null>(() => toDate(defaultValue) ?? null);
|
|
163
|
+
|
|
164
|
+
const selectedDate = controlled ? toDate(value) : (own ?? undefined);
|
|
165
|
+
const isValidDate = selectedDate !== undefined;
|
|
166
|
+
|
|
167
|
+
const parsedSmartDate = useMemo(() => parseSmartDateInput(smartText), [smartText]);
|
|
168
|
+
|
|
169
|
+
const change = (date: Date | null) => {
|
|
170
|
+
if (!controlled) setOwn(date);
|
|
171
|
+
onChange?.(date);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const handleSelectDate = (date: Date | undefined) => {
|
|
175
|
+
change(date ?? null);
|
|
176
|
+
setOpen(false);
|
|
177
|
+
setSmartText("");
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const applyPreset = (date: Date | null) => {
|
|
181
|
+
change(date);
|
|
182
|
+
setOpen(false);
|
|
183
|
+
setSmartText("");
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const handleSmartSubmit = () => {
|
|
187
|
+
if (parsedSmartDate) {
|
|
188
|
+
change(parsedSmartDate);
|
|
189
|
+
setOpen(false);
|
|
190
|
+
setSmartText("");
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const today = new Date();
|
|
195
|
+
const displayMonth = parsedSmartDate || selectedDate;
|
|
196
|
+
const presets: Array<[string, Date]> = [
|
|
197
|
+
[text?.today ?? "วันนี้", today],
|
|
198
|
+
[text?.yesterday ?? "เมื่อวาน", subDays(today, 1)],
|
|
199
|
+
[text?.tomorrow ?? "พรุ่งนี้", addDays(today, 1)],
|
|
200
|
+
[text?.nextWeek ?? "+1 สัปดาห์", addWeeks(today, 1)],
|
|
201
|
+
[text?.lastWeek ?? "-1 สัปดาห์", subWeeks(today, 1)],
|
|
202
|
+
[text?.nextMonth ?? "+1 เดือน", addMonths(today, 1)],
|
|
203
|
+
[text?.lastMonth ?? "-1 เดือน", subMonths(today, 1)],
|
|
204
|
+
];
|
|
205
|
+
// ปฏิทินใช้ locale เดียวกับช่อง (ชื่อเดือน/วัน/ปี) — ไม่ส่ง locale = ค่าเดิมของ Calendar
|
|
206
|
+
const calendarFormatters = locale
|
|
207
|
+
? {
|
|
208
|
+
formatMonthDropdown: (d: Date) => d.toLocaleString(locale, { month: "short" }),
|
|
209
|
+
formatYearDropdown: (d: Date) => d.toLocaleString(locale, { year: "numeric" }),
|
|
210
|
+
formatWeekdayName: (d: Date) => d.toLocaleString(locale, { weekday: "narrow" }),
|
|
211
|
+
formatCaption: (d: Date) => d.toLocaleString(locale, { month: "long", year: "numeric" }),
|
|
212
|
+
}
|
|
213
|
+
: undefined;
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<>
|
|
217
|
+
<Popover
|
|
218
|
+
open={open}
|
|
219
|
+
onOpenChange={(o) => {
|
|
220
|
+
setOpen(o);
|
|
221
|
+
if (!o) setSmartText("");
|
|
222
|
+
}}
|
|
223
|
+
>
|
|
224
|
+
<PopoverTrigger
|
|
225
|
+
render={
|
|
226
|
+
<Button
|
|
227
|
+
id={id}
|
|
228
|
+
variant="outline"
|
|
229
|
+
disabled={disabled}
|
|
230
|
+
aria-required={required || undefined}
|
|
231
|
+
className={cn(
|
|
232
|
+
"h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
|
|
233
|
+
!isValidDate && "text-muted-foreground",
|
|
234
|
+
className,
|
|
235
|
+
)}
|
|
236
|
+
/>
|
|
237
|
+
}
|
|
238
|
+
>
|
|
239
|
+
<div className="flex items-center gap-2 truncate">
|
|
240
|
+
<CalendarIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
241
|
+
<span className="truncate">
|
|
242
|
+
{isValidDate ? (
|
|
243
|
+
<>
|
|
244
|
+
<span className="font-medium text-foreground">
|
|
245
|
+
{formatDate(selectedDate, locale)}
|
|
246
|
+
</span>
|
|
247
|
+
{/* วันที่สัมพัทธ์ขึ้นกับ "วันนี้" ของเครื่องผู้ใช้ — แสดงหลัง mount กัน hydration mismatch */}
|
|
248
|
+
{showRelative && mounted && (
|
|
249
|
+
<span className="ml-1 text-muted-foreground text-xs">
|
|
250
|
+
({formatRelative(selectedDate, locale)})
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
253
|
+
</>
|
|
254
|
+
) : (
|
|
255
|
+
<span>{placeholder ?? text?.placeholder ?? "เลือกวันที่"}</span>
|
|
256
|
+
)}
|
|
257
|
+
</span>
|
|
258
|
+
</div>
|
|
259
|
+
{allowClear && isValidDate && !disabled && (
|
|
260
|
+
<span
|
|
261
|
+
role="button"
|
|
262
|
+
tabIndex={0}
|
|
263
|
+
aria-label={text?.clear ?? "ล้างวันที่"}
|
|
264
|
+
className="ml-1 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
265
|
+
onClick={(e) => {
|
|
266
|
+
e.stopPropagation();
|
|
267
|
+
change(null);
|
|
268
|
+
}}
|
|
269
|
+
onKeyDown={(e) => {
|
|
270
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
e.stopPropagation();
|
|
273
|
+
change(null);
|
|
274
|
+
}}
|
|
275
|
+
>
|
|
276
|
+
<X className="h-3.5 w-3.5" />
|
|
277
|
+
</span>
|
|
278
|
+
)}
|
|
279
|
+
</PopoverTrigger>
|
|
280
|
+
{/* กว้างพอดีปฏิทิน: ช่องวันที่ 40px × 7 + p-3 + ขอบ (เดิมกล่อง 320–340px แต่ช่อง 28px → ว่างซ้ายขวามาก) */}
|
|
281
|
+
<PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
|
|
282
|
+
{/* Smart Date Text Input */}
|
|
283
|
+
{showSmartInput && (
|
|
284
|
+
<div className="mb-3 flex flex-col gap-1.5 border-b pb-2.5">
|
|
285
|
+
<InputGroup className="h-8">
|
|
286
|
+
<InputGroupInput
|
|
287
|
+
value={smartText}
|
|
288
|
+
onChange={(e) => setSmartText(e.target.value)}
|
|
289
|
+
onKeyDown={(event) => {
|
|
290
|
+
if (event.key === "Enter") {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
event.stopPropagation();
|
|
293
|
+
handleSmartSubmit();
|
|
294
|
+
}
|
|
295
|
+
}}
|
|
296
|
+
placeholder={text?.smartPlaceholder ?? "เช่น 1/7, 1/7/25, 1-7-2568..."}
|
|
297
|
+
className="h-8 text-xs"
|
|
298
|
+
/>
|
|
299
|
+
<InputGroupAddon align="inline-end">
|
|
300
|
+
<InputGroupButton
|
|
301
|
+
type="button"
|
|
302
|
+
variant="default"
|
|
303
|
+
size="xs"
|
|
304
|
+
onClick={handleSmartSubmit}
|
|
305
|
+
disabled={!parsedSmartDate}
|
|
306
|
+
>
|
|
307
|
+
{text?.apply ?? "ตกลง"}
|
|
308
|
+
</InputGroupButton>
|
|
309
|
+
</InputGroupAddon>
|
|
310
|
+
</InputGroup>
|
|
311
|
+
{parsedSmartDate && (
|
|
312
|
+
<span className="flex items-center gap-1 font-medium text-success text-xs">
|
|
313
|
+
<Check className="size-3" />
|
|
314
|
+
{formatDate(parsedSmartDate, locale)}
|
|
315
|
+
</span>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{/* Quick Presets Section (Flex Wrap & Clean Spacing) */}
|
|
321
|
+
{showPresets && (
|
|
322
|
+
<div className="mb-3 border-b pb-2.5">
|
|
323
|
+
<div className="mb-1.5 flex items-center gap-1 font-semibold text-muted-foreground text-xs">
|
|
324
|
+
<Clock className="h-3 w-3" />
|
|
325
|
+
{text?.presets ?? "ทางลัด"}
|
|
326
|
+
</div>
|
|
327
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
328
|
+
{presets.map(([label, date]) => (
|
|
329
|
+
<Button
|
|
330
|
+
key={label}
|
|
331
|
+
type="button"
|
|
332
|
+
variant="outline"
|
|
333
|
+
size="sm"
|
|
334
|
+
className="h-6 px-2 font-normal text-xs hover:bg-muted"
|
|
335
|
+
onClick={() => applyPreset(date)}
|
|
336
|
+
>
|
|
337
|
+
{label}
|
|
338
|
+
</Button>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
{/* Visual DayPicker Calendar */}
|
|
345
|
+
<div className="flex w-full justify-center">
|
|
346
|
+
<Calendar
|
|
347
|
+
mode="single"
|
|
348
|
+
selected={selectedDate}
|
|
349
|
+
onSelect={handleSelectDate}
|
|
350
|
+
disabled={disabled}
|
|
351
|
+
captionLayout="dropdown"
|
|
352
|
+
startMonth={new Date(1990, 0)}
|
|
353
|
+
endMonth={new Date(2040, 11)}
|
|
354
|
+
defaultMonth={displayMonth}
|
|
355
|
+
formatters={calendarFormatters}
|
|
356
|
+
className="p-0 [--cell-size:--spacing(10)]"
|
|
357
|
+
/>
|
|
358
|
+
</div>
|
|
359
|
+
</PopoverContent>
|
|
360
|
+
</Popover>
|
|
361
|
+
{name !== undefined && (
|
|
362
|
+
// ค่าที่ส่งไปกับฟอร์ม — ใช้ input ข้อความที่มองไม่เห็นแทน type="hidden" เพราะ hidden ไม่ถูกตรวจ `required`
|
|
363
|
+
// อยู่หลังปุ่มเสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ปุ่ม ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
|
|
364
|
+
<input
|
|
365
|
+
name={name}
|
|
366
|
+
value={toDateInputValue(selectedDate)}
|
|
367
|
+
onChange={() => {}}
|
|
368
|
+
required={required}
|
|
369
|
+
disabled={disabled}
|
|
370
|
+
tabIndex={-1}
|
|
371
|
+
aria-hidden
|
|
372
|
+
className="sr-only"
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
375
|
+
</>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
@@ -175,6 +175,21 @@ export interface UiText {
|
|
|
175
175
|
characterCount?: string;
|
|
176
176
|
/** NumberInput (optional) */
|
|
177
177
|
number?: Partial<{ increment: string; decrement: string }>;
|
|
178
|
+
/** DatePicker (optional) — ทางลัดวันที่ + ช่องพิมพ์วันที่ */
|
|
179
|
+
datePicker?: Partial<{
|
|
180
|
+
placeholder: string;
|
|
181
|
+
smartPlaceholder: string;
|
|
182
|
+
apply: string;
|
|
183
|
+
presets: string;
|
|
184
|
+
today: string;
|
|
185
|
+
yesterday: string;
|
|
186
|
+
tomorrow: string;
|
|
187
|
+
nextWeek: string;
|
|
188
|
+
lastWeek: string;
|
|
189
|
+
nextMonth: string;
|
|
190
|
+
lastMonth: string;
|
|
191
|
+
clear: string;
|
|
192
|
+
}>;
|
|
178
193
|
/** TimePicker / DateTimePicker (optional) */
|
|
179
194
|
time?: Partial<{
|
|
180
195
|
choose: string;
|
|
@@ -340,6 +355,20 @@ export const UI_TEXT_TH: UiText = {
|
|
|
340
355
|
presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
|
|
341
356
|
characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
|
|
342
357
|
number: { increment: "เพิ่ม", decrement: "ลด" },
|
|
358
|
+
datePicker: {
|
|
359
|
+
placeholder: "เลือกวันที่",
|
|
360
|
+
smartPlaceholder: "เช่น 1/7, 1/7/25, 1-7-2568...",
|
|
361
|
+
apply: "ตกลง",
|
|
362
|
+
presets: "ทางลัด",
|
|
363
|
+
today: "วันนี้",
|
|
364
|
+
yesterday: "เมื่อวาน",
|
|
365
|
+
tomorrow: "พรุ่งนี้",
|
|
366
|
+
nextWeek: "+1 สัปดาห์",
|
|
367
|
+
lastWeek: "-1 สัปดาห์",
|
|
368
|
+
nextMonth: "+1 เดือน",
|
|
369
|
+
lastMonth: "-1 เดือน",
|
|
370
|
+
clear: "ล้างวันที่",
|
|
371
|
+
},
|
|
343
372
|
time: {
|
|
344
373
|
choose: "เลือกเวลา",
|
|
345
374
|
hour: "ชั่วโมง",
|
|
@@ -511,6 +540,20 @@ export const UI_TEXT_EN: UiText = {
|
|
|
511
540
|
presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
|
|
512
541
|
characterCount: "{count} of {max} characters",
|
|
513
542
|
number: { increment: "Increase", decrement: "Decrease" },
|
|
543
|
+
datePicker: {
|
|
544
|
+
placeholder: "Pick a date",
|
|
545
|
+
smartPlaceholder: "e.g. 1/7, 1/7/25, 1-7-2025…",
|
|
546
|
+
apply: "Apply",
|
|
547
|
+
presets: "Shortcuts",
|
|
548
|
+
today: "Today",
|
|
549
|
+
yesterday: "Yesterday",
|
|
550
|
+
tomorrow: "Tomorrow",
|
|
551
|
+
nextWeek: "+1 week",
|
|
552
|
+
lastWeek: "-1 week",
|
|
553
|
+
nextMonth: "+1 month",
|
|
554
|
+
lastMonth: "-1 month",
|
|
555
|
+
clear: "Clear date",
|
|
556
|
+
},
|
|
514
557
|
time: {
|
|
515
558
|
choose: "Choose time",
|
|
516
559
|
hour: "Hour",
|