@suphark/ui 0.45.1 → 0.47.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,7 +2,23 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
- ## [0.45.1] — 2026-10-08
5
+ ## [0.47.0] — 2026-10-08
6
+
7
+ ### Changed
8
+
9
+ - **`@suphark/ui/sortable` รองรับโครงเดียว: หัวกลุ่มกับแถวรายการเป็นพี่น้องกัน** (`<tr>` ใน `<tbody>` เดียวกัน หรือการ์ดในลิสต์เดียวกัน) — เจ้าของเลือกให้เหลือรูปแบบเดียวเพื่อดูแลง่าย หลัง Pi Config ย้ายจากโครง "`<tbody>` ต่อกลุ่ม" มาแบบนี้แล้ว (71735f0) · **ห้ามให้ node ของกลุ่มครอบแถวรายการไว้** (เช่นแปะ `ref` ของกลุ่มที่ `<tbody>` ที่ห่อแถวคีย์) ไม่งั้นแถวลูกจะเลื่อนสองชั้นและกรอบของก้อนถูกนับซ้ำ · ถอดโค้ดที่เคยรองรับโครงนั้นออก (`insideGroup` ใน context ภายใน, การกันนับซ้ำใน `blockRect`) — ไม่มี props ใดเปลี่ยน แอปที่ทำตามตัวอย่างใน `/components/sortable-list` ไม่ต้องแก้อะไร
10
+
11
+
12
+ ### Added
13
+
14
+ - type `DistributiveOmit<T, K>` / `DistributivePick<T, K>` (ขอจาก Pi Auth): `Omit` ปกติยุบ union ของ props ที่มี discriminant — `Omit<DatePickerProps, "locale">` ทำให้ `name` กลายเป็น `string | [string, string]` แยกตาม `mode` ไม่ได้อีก ตัวห่อของแอปจึงพัง · ใช้ `DistributiveOmit<DatePickerProps, "locale">` แทนได้เลย ไม่ต้อง cast (มีเทสต์ type ที่พิสูจน์แล้วว่าแดงเมื่อถอดตัวแก้)
15
+
16
+
17
+ ### Fixed
18
+
19
+ - `@suphark/ui/sortable`: ลากรายการเข้ากลุ่มอื่นไปวาง "ใต้" แถวหนึ่ง ระหว่างลากอยู่ถูกที่ แต่ปล่อยแล้วกระโดดไปอยู่ "เหนือ" แถวนั้น (เจ้าของเจอในตัวอย่างโครง `<tbody>`) — ตอนข้ามกลุ่มจัดตำแหน่งก่อน/หลังไว้แล้ว แต่ตอนปล่อย `reorderItems` ย้ายไปแทนที่แถวที่ชี้ซ้ำอีกรอบ · ตอนนี้ถ้าปล่อยตอนยังชี้แถวเดิมที่ข้ามกลุ่มเข้ามา ใช้ตำแหน่งที่จัดไว้เลย
20
+ - showcase: `/components/sortable-list` **เอาตัวอย่างโครง `<tbody>` ต่อกลุ่มออก** — เจ้าของเลือกให้มีรูปแบบเดียว (หัวกลุ่ม + แถวรายการเป็น `<tr>` ใน `<tbody>` เดียวกัน) เพื่อดูแลง่าย
21
+
6
22
 
7
23
  ### Fixed
8
24
 
package/README.md CHANGED
@@ -236,6 +236,7 @@ const [order, setOrder] = useState(() =>
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
238
  - จัดชั้นบนสุดอิสระ (กลุ่มกับรายการไม่มีกลุ่มสลับกันได้): `SortableProvider freeLayout` + เรนเดอร์ตาม `rootEntries(order)` (ครอบทั้งหมดด้วย `<SortableGroup itemIds={order.items[UNGROUPED]}>` แล้วแต่ละกลุ่มมี `SortableGroup` ของตัวเองข้างใน) + บันทึก `payload.root` และส่งกลับ `sortableOrderOf(..., { root })` ตอนโหลด · หัวกลุ่มครึ่งบน = ก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่ม
239
+ - ห่อ component ที่ props เป็น union (เช่น `DatePicker` ที่มี `mode="range"`): ใช้ `DistributiveOmit`/`DistributivePick` แทน `Omit`/`Pick` ไม่งั้น union ถูกยุบจนแยกตาม `mode` ไม่ได้
239
240
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
240
241
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
241
242
  - ข้อความ 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.45.1",
3
+ "version": "0.47.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 เอง",
@@ -153,8 +153,6 @@ interface SortableState {
153
153
  selected: ReadonlySet<string>;
154
154
  select: (id: string, mode: SelectMode) => void;
155
155
  groupOf: (itemId: string) => string | undefined;
156
- /** แถวรายการอยู่ใน node ของกลุ่มเองไหม (กลุ่มเป็น <tbody> ทั้งก้อน) — ถ้าใช่ เลื่อนตามพ่ออยู่แล้ว */
157
- insideGroup: (itemId: string, groupId: string) => boolean;
158
156
  register: (kind: SortableKind, id: string, node: HTMLElement | null) => void;
159
157
  shifts: ShiftStore;
160
158
  }
@@ -173,7 +171,6 @@ const SortableStateContext = createContext<SortableState>({
173
171
  selected: EMPTY,
174
172
  select: () => {},
175
173
  groupOf: () => undefined,
176
- insideGroup: () => false,
177
174
  register: () => {},
178
175
  shifts: createShiftStore(),
179
176
  });
@@ -319,6 +316,9 @@ export function SortableProvider({
319
316
  // กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
320
317
  const lastOverId = useRef<UniqueIdentifier | null>(null);
321
318
  const movedAcross = useRef(false);
319
+ // เป้าหมายตอนย้ายข้ามกลุ่มครั้งล่าสุด — ตำแหน่งถูกจัดไว้แล้ว (ก่อน/หลังตามครึ่งแถว) ถ้าปล่อยตอนยังชี้ตัวเดิม
320
+ // ห้าม reorderItems ซ้ำ (arrayMove ไปแทนที่ตัวนั้น = วาง "หลัง" กลายเป็น "ก่อน")
321
+ const crossedAt = useRef<UniqueIdentifier | null>(null);
322
322
  const beforeDrag = useRef<SortableOrder | null>(null);
323
323
  // อ่านลำดับล่าสุดตอนปล่อย (onDragOver เปลี่ยนไปหลายรอบแล้ว prop ยังไม่ทัน)
324
324
  const latest = useRef(order);
@@ -396,14 +396,16 @@ export function SortableProvider({
396
396
  } else map.delete(rowId);
397
397
  }, []);
398
398
 
399
- /** กรอบของทั้งก้อน: หัวกลุ่ม + แถวรายการที่แสดงอยู่ (ไม่สนใจ transform ระหว่างลาก) */
399
+ /**
400
+ * กรอบของทั้งก้อน: หัวกลุ่ม + แถวรายการที่แสดงอยู่ (ไม่สนใจ transform ระหว่างลาก)
401
+ * หัวกลุ่มกับแถวรายการต้องเป็นพี่น้องกัน (`<tr>` ใน `<tbody>` เดียวกัน / การ์ดในลิสต์เดียวกัน)
402
+ * ห้ามให้ node ของกลุ่มครอบแถวรายการไว้ ไม่งั้นกรอบนี้นับซ้ำและแถวลูกจะเลื่อนสองชั้น
403
+ */
400
404
  const blockRect = useCallback((groupId: string, header: HTMLElement) => {
401
405
  let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
402
406
  for (const itemId of latest.current.items[groupId] ?? []) {
403
407
  const node = nodes.current.items.get(itemId);
404
- // แถวที่อยู่ "ใน" node ของกลุ่มเอง (แอปแปะ ref กลุ่มที่ <tbody>) — กรอบของกลุ่มครอบอยู่แล้ว
405
- // และกรอบของแถวลูกติด transform ของพ่อ (ignoreTransform ไม่ช่วย) → นับซ้ำแล้วก้อนใหญ่เกินจริง
406
- if (!node?.isConnected || header.contains(node)) continue;
408
+ if (!node?.isConnected) continue;
407
409
  const r = getClientRect(node, { ignoreTransform: true });
408
410
  top = Math.min(top, r.top);
409
411
  bottom = Math.max(bottom, r.bottom);
@@ -626,6 +628,7 @@ export function SortableProvider({
626
628
 
627
629
  function onDragStart({ active }: DragStartEvent) {
628
630
  beforeDrag.current = latest.current;
631
+ crossedAt.current = null;
629
632
  lastOverId.current = null;
630
633
  const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
631
634
  const raw = rawOf(active.id);
@@ -679,6 +682,7 @@ export function SortableProvider({
679
682
  const raw = rawOf(active.id);
680
683
  const apply = (next: SortableOrder) => {
681
684
  if (next === current) return;
685
+ crossedAt.current = over.id;
682
686
  movedAcross.current = true;
683
687
  latest.current = next;
684
688
  onOrderChange(next);
@@ -694,6 +698,8 @@ export function SortableProvider({
694
698
  const from = containerOf(active.id, current);
695
699
  const to = containerOf(over.id, current);
696
700
  if (!from || !to || from === to) {
701
+ // ชี้ตัวอื่นในกลุ่มเดียวกันแล้ว = เรียงต่อแบบปกติ (ไม่ใช่ตำแหน่งที่จัดไว้ตอนข้ามกลุ่ม)
702
+ if (over.id !== crossedAt.current) crossedAt.current = null;
697
703
  updateTarget(active, over, keyboard, pointerY);
698
704
  return;
699
705
  }
@@ -778,7 +784,12 @@ export function SortableProvider({
778
784
  index,
779
785
  );
780
786
  }
781
- } else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
787
+ } else if (
788
+ over &&
789
+ kindOf(active.id) === "i" &&
790
+ kindOf(over.id) === "i" &&
791
+ over.id !== crossedAt.current
792
+ ) {
782
793
  next = reorderItems(current, rawOf(active.id), rawOf(over.id));
783
794
  }
784
795
  // bulk: ตัวที่เลือกไว้ตัวอื่นตามมารวมที่จุดวางของตัวที่จับ (เรียงตามลำดับเดิม)
@@ -925,11 +936,6 @@ export function SortableProvider({
925
936
  const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
926
937
 
927
938
  const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
928
- const insideGroup = useCallback((itemId: string, groupId: string) => {
929
- const group = nodes.current.groups.get(groupId);
930
- const item = nodes.current.items.get(itemId);
931
- return Boolean(group && item && group !== item && group.contains(item));
932
- }, []);
933
939
  const rootDrag =
934
940
  activeDrag !== null &&
935
941
  (activeDrag.kind === "group" ||
@@ -956,7 +962,6 @@ export function SortableProvider({
956
962
  selected,
957
963
  select,
958
964
  groupOf,
959
- insideGroup,
960
965
  register,
961
966
  shifts,
962
967
  }),
@@ -974,7 +979,6 @@ export function SortableProvider({
974
979
  selected,
975
980
  select,
976
981
  groupOf,
977
- insideGroup,
978
982
  register,
979
983
  shifts,
980
984
  ],
@@ -1124,12 +1128,7 @@ export function useSortableRow({
1124
1128
  ? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
1125
1129
  { ...style, opacity: 0.4 }
1126
1130
  : { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
1127
- } else if (
1128
- type === "item" &&
1129
- state.rootDrag &&
1130
- shift &&
1131
- !(groupId && state.insideGroup(id, groupId))
1132
- ) {
1131
+ } else if (type === "item" && state.rootDrag && shift) {
1133
1132
  const own = state.activeGroup !== null && state.activeGroup === groupId;
1134
1133
  style = {
1135
1134
  transform: shift.transform,
package/src/index.ts CHANGED
@@ -730,6 +730,7 @@ export {
730
730
  statusColorThemes,
731
731
  TAILWIND_COLORS,
732
732
  } from "./lib/status-colors";
733
+ export type { DistributiveOmit, DistributivePick } from "./lib/types";
733
734
  export {
734
735
  TYPOGRAPHY_ROLES,
735
736
  type TypographyRole,
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Type helper ที่แอปใช้ตอนห่อ component ของแพ็กเกจ — ไม่มีโค้ดตอนรัน
3
+ */
4
+
5
+ /**
6
+ * `Omit` ที่ทำทีละสมาชิกของ union — props ของ component ที่มี `mode`/variant
7
+ * (เช่น `DatePickerProps` ที่เป็น `DatePickerSingleProps | DatePickerRangeProps`)
8
+ * ถ้าใช้ `Omit` ปกติจะถูกยุบเป็นก้อนเดียว แยกตาม discriminant ไม่ได้อีก (Pi Auth เจอตอนห่อ `DateField`)
9
+ *
10
+ * type DateFieldProps = DistributiveOmit<DatePickerProps, "locale"> & { label: string };
11
+ * // แยกตาม props.mode ได้ตามปกติ ไม่ต้อง cast
12
+ */
13
+ export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
14
+
15
+ /** `Pick` ที่ทำทีละสมาชิกของ union (คู่กับ `DistributiveOmit`) */
16
+ export type DistributivePick<T, K extends PropertyKey> = T extends unknown
17
+ ? Pick<T, Extract<keyof T, K>>
18
+ : never;