@suphark/ui 0.4.2 → 0.5.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +43 -1
  3. package/package.json +3 -1
  4. package/src/components/app-shell/app-nav.tsx +1 -1
  5. package/src/components/app-shell/app-shell.tsx +31 -5
  6. package/src/components/app-shell/header-breadcrumbs.tsx +2 -1
  7. package/src/components/app-shell/header-crumb.tsx +1 -1
  8. package/src/components/app-shell/index.ts +1 -1
  9. package/src/components/app-shell/sub-nav.tsx +3 -3
  10. package/src/components/app-shell/top-tabs.tsx +1 -1
  11. package/src/components/app-shell/user-menu.tsx +1 -1
  12. package/src/components/data-table/data-table-column-header.tsx +1 -1
  13. package/src/components/data-table/data-table-row-actions.tsx +1 -3
  14. package/src/components/data-table/use-data-table.ts +4 -1
  15. package/src/components/design-system/design-system-showcase.tsx +1 -1
  16. package/src/components/pwa/install-prompt-store.ts +26 -0
  17. package/src/components/pwa/pull-to-refresh.tsx +147 -0
  18. package/src/components/pwa/pwa-install-card.tsx +165 -0
  19. package/src/components/pwa/pwa-service-worker-registration.tsx +63 -0
  20. package/src/components/pwa/service-worker-updates.ts +76 -0
  21. package/src/components/shared/button/confirm-action-button.tsx +16 -2
  22. package/src/components/shared/button/delete-many-button.tsx +7 -3
  23. package/src/components/shared/button/refresh-button.tsx +2 -1
  24. package/src/components/shared/combobox.tsx +2 -1
  25. package/src/components/shared/confirm-dialog.tsx +3 -0
  26. package/src/components/shared/dialogs-provider.tsx +270 -0
  27. package/src/components/shared/form/form-field-combobox.tsx +1 -1
  28. package/src/components/shared/form/form-field-input.tsx +8 -2
  29. package/src/components/shared/form/form-field-radio-cards.tsx +1 -1
  30. package/src/components/shared/form/form-field-select.tsx +9 -5
  31. package/src/components/shared/layout/error-state.tsx +16 -5
  32. package/src/components/shared/link/transition-link.tsx +2 -0
  33. package/src/components/shared/row-actions.tsx +13 -3
  34. package/src/components/shared/ui-text.tsx +24 -0
  35. package/src/hooks/use-confirm.tsx +63 -0
  36. package/src/hooks/use-tracked-state.ts +35 -0
  37. package/src/index.ts +13 -0
  38. package/src/kanban.ts +9 -0
  39. package/src/lib/formatters.ts +5 -3
  40. package/src/lib/kanban-collision.ts +118 -0
  41. package/src/pwa.ts +24 -0
  42. package/src/styles.css +12 -0
@@ -1,4 +1,4 @@
1
- import { differenceInDays, formatDistanceToNow } from "date-fns";
1
+ import { differenceInCalendarDays, formatDistanceToNow } from "date-fns";
2
2
  import { th } from "date-fns/locale";
3
3
 
4
4
  /**
@@ -6,15 +6,17 @@ import { th } from "date-fns/locale";
6
6
  * ยกมาจาก supplychain `lib/utils/formatters.ts` เฉพาะส่วนที่ไม่ผูก domain
7
7
  */
8
8
 
9
- /** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "3 วันที่แล้ว", "2 เดือนที่แล้ว" */
9
+ /** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "พรุ่งนี้", "10 วันที่ผ่านมา", "ใน 2 เดือน" */
10
10
  export function formatRelativeThaiDate(date: Date | string | number | null | undefined): string {
11
11
  if (!date) return "ไม่ได้ระบุ";
12
12
  const d = new Date(date);
13
13
  if (Number.isNaN(d.getTime())) return "ไม่ได้ระบุ";
14
14
 
15
- const diffDays = Math.abs(differenceInDays(new Date(), d));
15
+ // เทียบเป็นวันปฏิทิน (ไม่ใช่ 24 ชม.) และคงเครื่องหมาย — อนาคต 1 วันคือ "พรุ่งนี้" ไม่ใช่ "เมื่อวาน"
16
+ const diffDays = differenceInCalendarDays(new Date(), d);
16
17
  if (diffDays === 0) return "วันนี้";
17
18
  if (diffDays === 1) return "เมื่อวาน";
19
+ if (diffDays === -1) return "พรุ่งนี้";
18
20
 
19
21
  return formatDistanceToNow(d, { addSuffix: true, locale: th });
20
22
  }
@@ -0,0 +1,118 @@
1
+ import {
2
+ type Collision,
3
+ type CollisionDetection,
4
+ closestCenter,
5
+ closestCorners,
6
+ pointerWithin,
7
+ type UniqueIdentifier,
8
+ } from "@dnd-kit/core";
9
+
10
+ type DetectionArgs = Parameters<CollisionDetection>[0];
11
+
12
+ /** id ของการ์ดในแต่ละคอลัมน์ (key = id คอลัมน์) — ส่ง Map ที่ memo ไว้ หรือฟังก์ชันที่คืน Map ล่าสุด */
13
+ export type KanbanColumnItems =
14
+ | ReadonlyMap<string, ReadonlySet<string>>
15
+ | (() => ReadonlyMap<string, ReadonlySet<string>>);
16
+
17
+ export interface KanbanCollisionOptions {
18
+ columnItems: KanbanColumnItems;
19
+ /**
20
+ * แปลง droppable id ให้เป็น id คอลัมน์ — สำหรับ droppable อื่นของคอลัมน์ที่ไม่ใช่รายการการ์ด
21
+ * (เช่น handle ลากเรียงคอลัมน์ `column-drag:<id>`) คืน `null` ถ้าไม่ใช่คอลัมน์
22
+ * ค่าเริ่มต้น: id ที่เป็น key ใน `columnItems`
23
+ */
24
+ resolveColumnId?: (id: UniqueIdentifier) => string | null;
25
+ }
26
+
27
+ // การ์ดที่ใกล้ pointer ที่สุดตามแนวตั้ง (fallback closestCenter เมื่อไม่มี pointer เช่น keyboard)
28
+ function nearestToPointer(
29
+ args: DetectionArgs,
30
+ candidates: DetectionArgs["droppableContainers"],
31
+ ): Collision | null {
32
+ if (candidates.length === 0) return null;
33
+ const pointer = args.pointerCoordinates;
34
+ if (!pointer) {
35
+ return closestCenter({ ...args, droppableContainers: candidates })[0] ?? null;
36
+ }
37
+ let best: Collision | null = null;
38
+ let bestDistance = Number.POSITIVE_INFINITY;
39
+ for (const container of candidates) {
40
+ const rect = args.droppableRects.get(container.id);
41
+ if (!rect) continue;
42
+ const distance = Math.abs(pointer.y - (rect.top + rect.height / 2));
43
+ if (distance < bestDistance) {
44
+ bestDistance = distance;
45
+ best = { id: container.id, data: { droppableContainer: container, value: distance } };
46
+ }
47
+ }
48
+ return best;
49
+ }
50
+
51
+ /**
52
+ * Collision detection สำหรับบอร์ดแบบ Kanban (dnd-kit): หลายคอลัมน์ แต่ละคอลัมน์เป็น SortableContext
53
+ *
54
+ * ปัญหาที่แก้: `pointerWithin` ตรง ๆ จะรายงาน "คอลัมน์" เป็น `over` ทุกครั้งที่ pointer อยู่ในช่องว่างระหว่างการ์ด
55
+ * หรือใต้การ์ดสุดท้าย → dnd-kit ล้าง transform ของทุกการ์ด (การ์ดเด้งกลับ) และปล่อยตรงนั้นจะไปท้ายคอลัมน์
56
+ * ตัวนี้แปลง pointer ที่อยู่ในคอลัมน์แต่ไม่ทับการ์ดให้เป็น "การ์ดที่ใกล้ที่สุดในคอลัมน์นั้น" (ไม่นับการ์ดที่กำลังลาก
57
+ * เพราะ rect ของมันตาม slot ที่เลื่อนไปแล้ว) และรายงาน droppable ของรายการคอลัมน์เฉพาะเมื่อคอลัมน์ว่าง
58
+ *
59
+ * ลากคอลัมน์ (active id แปลงเป็นคอลัมน์ได้) → pointerWithin ปกติ, fallback closestCorners
60
+ *
61
+ * const columnItems = useMemo(() => new Map(columns.map((c) => [c.id, new Set(c.cardIds)])), [columns]);
62
+ * const collisionDetection = useMemo(
63
+ * () => createKanbanCollisionDetection({ columnItems, resolveColumnId: (id) => columnIdFromHandle(id) }),
64
+ * [columnItems],
65
+ * );
66
+ * <DndContext collisionDetection={collisionDetection} …>
67
+ *
68
+ * ข้อควรระวังฝั่ง CSS: wrapper ของการ์ดห้าม `overflow-hidden` — dnd-kit ใส่ transform ที่ node ที่ `setNodeRef`
69
+ * ถ้าถูกครอบด้วย overflow-hidden การ์ดที่ขยับหลบจะถูก clip หายไป
70
+ */
71
+ export function createKanbanCollisionDetection({
72
+ columnItems,
73
+ resolveColumnId,
74
+ }: KanbanCollisionOptions): CollisionDetection {
75
+ const getColumns = () => (typeof columnItems === "function" ? columnItems() : columnItems);
76
+
77
+ const columnOf = (id: UniqueIdentifier): string | null => {
78
+ const columns = getColumns();
79
+ const key = String(id);
80
+ if (columns.has(key)) return key;
81
+ if (resolveColumnId) {
82
+ const resolved = resolveColumnId(id);
83
+ if (resolved !== null && columns.has(resolved)) return resolved;
84
+ }
85
+ return null;
86
+ };
87
+
88
+ return (args) => {
89
+ const pointerCollisions = pointerWithin(args);
90
+ const isColumnDrag = columnOf(args.active.id) !== null;
91
+ if (isColumnDrag) {
92
+ return pointerCollisions.length > 0 ? pointerCollisions : closestCorners(args);
93
+ }
94
+
95
+ const cardCollision = pointerCollisions.find((collision) => columnOf(collision.id) === null);
96
+ if (cardCollision) return [cardCollision];
97
+
98
+ const columnCollision = pointerCollisions.find((collision) => columnOf(collision.id) !== null);
99
+ if (!columnCollision) return closestCorners(args);
100
+
101
+ const columnId = columnOf(columnCollision.id) as string;
102
+ const ids = getColumns().get(columnId);
103
+ const cardContainers = args.droppableContainers.filter(
104
+ (container) => container.id !== args.active.id && ids?.has(String(container.id)),
105
+ );
106
+ const nearest = nearestToPointer(args, cardContainers);
107
+ if (nearest) return [nearest];
108
+
109
+ // คอลัมน์ว่าง: รายงาน droppable ของรายการคอลัมน์ (ไม่ใช่ handle) ให้ onDragOver ย้ายการ์ดเข้าคอลัมน์
110
+ const listContainer = args.droppableContainers.find(
111
+ (container) => String(container.id) === columnId,
112
+ );
113
+ if (listContainer) {
114
+ return [{ id: listContainer.id, data: { droppableContainer: listContainer, value: 0 } }];
115
+ }
116
+ return [columnCollision];
117
+ };
118
+ }
package/src/pwa.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @suphark/ui/pwa — ชุด Progressive Web App: ลงทะเบียน/อัปเดต service worker, การ์ดชวนติดตั้ง, ดึงลงเพื่อรีเฟรช
3
+ * `PullToRefresh` ใช้ next/navigation จึงแยกจาก barrel หลักเหมือน `@suphark/ui/next`
4
+ */
5
+ export {
6
+ type BeforeInstallPromptEvent,
7
+ getInstallPrompt,
8
+ setInstallPrompt,
9
+ subscribeToInstallPrompt,
10
+ } from "./components/pwa/install-prompt-store";
11
+ export {
12
+ PULL_TO_REFRESH_TEXT_EN,
13
+ PULL_TO_REFRESH_TEXT_TH,
14
+ PullToRefresh,
15
+ type PullToRefreshText,
16
+ } from "./components/pwa/pull-to-refresh";
17
+ export {
18
+ PWA_INSTALL_CARD_TEXT_EN,
19
+ PWA_INSTALL_CARD_TEXT_TH,
20
+ PwaInstallCard,
21
+ type PwaInstallCardText,
22
+ } from "./components/pwa/pwa-install-card";
23
+ export { PwaServiceWorkerRegistration } from "./components/pwa/pwa-service-worker-registration";
24
+ export { canReloadNow, watchServiceWorkerUpdates } from "./components/pwa/service-worker-updates";
package/src/styles.css CHANGED
@@ -216,3 +216,15 @@ body,
216
216
  .font-sans {
217
217
  font-family: var(--font-sans);
218
218
  }
219
+
220
+ /* ผู้ใช้ตั้ง "ลดการเคลื่อนไหว" → ตัด animation/transition ของทุก component (tw-animate, animate-spin, dialog zoom …) */
221
+ @media (prefers-reduced-motion: reduce) {
222
+ *,
223
+ ::before,
224
+ ::after {
225
+ animation-duration: 0.01ms !important;
226
+ animation-iteration-count: 1 !important;
227
+ transition-duration: 0.01ms !important;
228
+ scroll-behavior: auto !important;
229
+ }
230
+ }