@ncds/ui-admin 1.8.28-alpha.2 → 1.8.28-alpha.4

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 (56) hide show
  1. package/dist/cjs/assets/scripts/table/rowSpanBlock.js +97 -0
  2. package/dist/cjs/src/components/data-display/table/Table.js +135 -57
  3. package/dist/cjs/src/components/data-display/table/__tests__/Table.dnd.dragCell.test.js +7 -2
  4. package/dist/cjs/src/components/data-display/table/__tests__/Table.dnd.test.js +13 -8
  5. package/dist/cjs/src/components/data-display/table/__tests__/Table.rowGroup.test.js +54 -0
  6. package/dist/cjs/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.js +212 -0
  7. package/dist/cjs/src/components/data-display/table/__tests__/dnd-context.test.js +5 -2
  8. package/dist/cjs/src/components/data-display/table/__tests__/tableSelectableStyle.test.js +75 -0
  9. package/dist/cjs/src/components/data-display/table/dnd-context.js +4 -1
  10. package/dist/cjs/src/components/data-display/table/dnd-move.js +46 -0
  11. package/dist/cjs/src/components/data-display/table/dnd-preview.js +8 -3
  12. package/dist/cjs/src/generated/scoped-css.js +1 -1
  13. package/dist/esm/assets/scripts/table/rowSpanBlock.js +85 -0
  14. package/dist/esm/src/components/data-display/table/Table.js +135 -57
  15. package/dist/esm/src/components/data-display/table/__tests__/Table.dnd.dragCell.test.js +7 -2
  16. package/dist/esm/src/components/data-display/table/__tests__/Table.dnd.test.js +13 -8
  17. package/dist/esm/src/components/data-display/table/__tests__/Table.rowGroup.test.js +51 -0
  18. package/dist/esm/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.js +210 -0
  19. package/dist/esm/src/components/data-display/table/__tests__/dnd-context.test.js +5 -2
  20. package/dist/esm/src/components/data-display/table/__tests__/tableSelectableStyle.test.js +72 -0
  21. package/dist/esm/src/components/data-display/table/dnd-context.js +4 -1
  22. package/dist/esm/src/components/data-display/table/dnd-move.js +38 -0
  23. package/dist/esm/src/components/data-display/table/dnd-preview.js +8 -3
  24. package/dist/esm/src/generated/scoped-css.js +1 -1
  25. package/dist/temp/assets/scripts/table/rowSpanBlock.d.ts +29 -0
  26. package/dist/temp/assets/scripts/table/rowSpanBlock.js +77 -0
  27. package/dist/temp/src/components/data-display/table/Table.d.ts +7 -3
  28. package/dist/temp/src/components/data-display/table/Table.js +107 -47
  29. package/dist/temp/src/components/data-display/table/__tests__/Table.dnd.dragCell.test.js +10 -2
  30. package/dist/temp/src/components/data-display/table/__tests__/Table.dnd.test.js +16 -8
  31. package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroup.test.d.ts +1 -0
  32. package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroup.test.js +52 -0
  33. package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.d.ts +1 -0
  34. package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.js +137 -0
  35. package/dist/temp/src/components/data-display/table/__tests__/dnd-context.test.js +2 -2
  36. package/dist/temp/src/components/data-display/table/__tests__/tableSelectableStyle.test.d.ts +1 -0
  37. package/dist/temp/src/components/data-display/table/__tests__/tableSelectableStyle.test.js +71 -0
  38. package/dist/temp/src/components/data-display/table/dnd-context.d.ts +3 -0
  39. package/dist/temp/src/components/data-display/table/dnd-context.js +1 -0
  40. package/dist/temp/src/components/data-display/table/dnd-move.d.ts +17 -0
  41. package/dist/temp/src/components/data-display/table/dnd-move.js +31 -0
  42. package/dist/temp/src/components/data-display/table/dnd-preview.d.ts +5 -1
  43. package/dist/temp/src/components/data-display/table/dnd-preview.js +7 -3
  44. package/dist/temp/src/components/data-display/table/types.d.ts +14 -5
  45. package/dist/temp/src/generated/scoped-css.js +1 -1
  46. package/dist/types/assets/scripts/table/rowSpanBlock.d.ts +29 -0
  47. package/dist/types/src/components/data-display/table/Table.d.ts +7 -3
  48. package/dist/types/src/components/data-display/table/__tests__/Table.rowGroup.test.d.ts +1 -0
  49. package/dist/types/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.d.ts +1 -0
  50. package/dist/types/src/components/data-display/table/__tests__/tableSelectableStyle.test.d.ts +1 -0
  51. package/dist/types/src/components/data-display/table/dnd-context.d.ts +3 -0
  52. package/dist/types/src/components/data-display/table/dnd-move.d.ts +17 -0
  53. package/dist/types/src/components/data-display/table/dnd-preview.d.ts +5 -1
  54. package/dist/types/src/components/data-display/table/types.d.ts +14 -5
  55. package/dist/ui-admin/assets/styles/style.css +60 -4
  56. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ const SINGLE_ROW = 1;
2
+ const collectRanges = rowSpans => rowSpans.flatMap((spans, index) => spans.filter(span => span > SINGLE_ROW).map(span => ({
3
+ start: index,
4
+ end: Math.min(index + span - SINGLE_ROW, rowSpans.length - 1)
5
+ })));
6
+ const overlaps = (a, b) => a.start <= b.end && b.start <= a.end;
7
+ /**
8
+ * 병합을 쪼개지 않고 통째로 옮길 수 있는 최소 행 구간을 구한다.
9
+ * 범위가 겹치면 어느 쪽도 쪼갤 수 없으므로 연결된 범위를 모두 합친다.
10
+ *
11
+ * @param rowSpans 행별 셀 rowSpan 목록. 병합에 덮여 생략된 셀은 포함하지 않는다
12
+ * @param index 기준 행의 위치
13
+ */
14
+ export const computeRowSpanBlock = (rowSpans, index) => {
15
+ const ranges = collectRanges(rowSpans);
16
+ let block = {
17
+ start: index,
18
+ end: index
19
+ };
20
+ let expanded = true;
21
+ while (expanded) {
22
+ expanded = false;
23
+ for (const range of ranges) {
24
+ if (!overlaps(range, block)) continue;
25
+ const start = Math.min(block.start, range.start);
26
+ const end = Math.max(block.end, range.end);
27
+ if (start !== block.start || end !== block.end) {
28
+ block = {
29
+ start,
30
+ end
31
+ };
32
+ expanded = true;
33
+ }
34
+ }
35
+ }
36
+ return block;
37
+ };
38
+ /**
39
+ * 드롭을 받을 수 있는 변. 블록 가운데에 떨어뜨리면 그 블록이 쪼개지므로 경계만 연다.
40
+ */
41
+ export const computeOpenEdges = (rowSpans, index) => {
42
+ const block = computeRowSpanBlock(rowSpans, index);
43
+ const edges = [];
44
+ if (index === block.start) edges.push('top');
45
+ if (index === block.end) edges.push('bottom');
46
+ return edges;
47
+ };
48
+ /** 고른 행들이 끌고 오는 행 전부. 병합 블록을 쪼개면 병합이 깨지므로 블록째 더한다. */
49
+ export const collectBlockIndices = (rowSpans, indices) => {
50
+ const picked = new Set();
51
+ for (const index of indices) {
52
+ const block = computeRowSpanBlock(rowSpans, index);
53
+ for (let row = block.start; row <= block.end; row++) {
54
+ picked.add(row);
55
+ }
56
+ }
57
+ return Array.from(picked).sort((a, b) => a - b);
58
+ };
59
+ /** 행이 속한 `tbody` 와 그 안에서의 위치. 표 밖 요소면 `null`. */
60
+ export const locateRow = rowEl => {
61
+ const body = rowEl.closest('tbody');
62
+ if (!body) return null;
63
+ const index = Array.from(body.rows).indexOf(rowEl);
64
+ return index < 0 ? null : {
65
+ body,
66
+ index
67
+ };
68
+ };
69
+ /** 표 본문의 행별 rowSpan 을 읽어 `computeRowSpanBlock` 에 넘길 형태로 만든다. */
70
+ export const readRowSpans = body => Array.from(body.rows).map(row => Array.from(row.cells).map(cell => cell.rowSpan));
71
+ /** 다중 선택으로 고른 행들이 끌고 오는 행 전부. 행 순서는 표에 놓인 순서를 따른다. */
72
+ export const expandRowsToBlocks = rows => {
73
+ const perBody = new Map();
74
+ for (const row of rows) {
75
+ const located = locateRow(row);
76
+ if (!located) continue;
77
+ const picked = perBody.get(located.body) ?? [];
78
+ picked.push(located.index);
79
+ perBody.set(located.body, picked);
80
+ }
81
+ return Array.from(perBody).flatMap(_ref => {
82
+ let [body, indices] = _ref;
83
+ return collectBlockIndices(readRowSpans(body), indices).map(index => body.rows[index]);
84
+ });
85
+ };
@@ -6,8 +6,10 @@ import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-
6
6
  import { ChevronDown, ChevronSelectorVertical, ChevronUp, DotsGrid02 } from '@ncds/ui-admin-icon';
7
7
  import classNames from 'classnames';
8
8
  import { Children, forwardRef, useCallback, useContext, useEffect, useRef, useState } from 'react';
9
+ import { computeOpenEdges, readRowSpans } from '../../../../assets/scripts/table/rowSpanBlock';
9
10
  import { FloatingProvider } from '../../../contexts/FloatingContext';
10
11
  import { RowDndContext, TableDndContext } from './dnd-context';
12
+ import { DRAG_ID_ATTRIBUTE, findRowSpanBlock, locateRow, planMoveIds } from './dnd-move';
11
13
  import { buildDragPreview, PREVIEW_BADGE_SIZE, TABLE_DND_ID_RADIX, TABLE_DND_ID_SLICE_END, TABLE_DND_ID_SLICE_START } from './dnd-preview';
12
14
  import { TABLE_HEADER_HEIGHT, useTableHorizontalScrollbar, useTableVerticalScrollbar } from './useTableScrollbars';
13
15
  // 가로 스크롤 디자인 기준 폭 — 14인치 모니터 + LNB 고려한 디자인 권장 너비
@@ -46,49 +48,51 @@ const Header = _ref => {
46
48
  });
47
49
  };
48
50
  Header.displayName = 'Table.Header';
51
+ // 묶음마다 호출되므로 핸들러 없는 묶음이 있는 묶음을 덮지 않게 둔다.
52
+ // 거둘 때는 자기가 넣은 것만 비운다 — 조건부로 넘긴 핸들러를 끄면 드롭도 멈춰야 한다.
53
+ const useRowDropHandler = onRowDrop => {
54
+ const {
55
+ onRowDropRef
56
+ } = useContext(TableDndContext);
57
+ useEffect(() => {
58
+ if (!onRowDrop) {
59
+ return;
60
+ }
61
+ onRowDropRef.current = onRowDrop;
62
+ return () => {
63
+ if (onRowDropRef.current === onRowDrop) {
64
+ onRowDropRef.current = undefined;
65
+ }
66
+ };
67
+ }, [onRowDrop, onRowDropRef]);
68
+ };
49
69
  const Body = _ref2 => {
50
70
  let {
51
71
  children,
52
72
  className,
53
73
  onRowDrop
54
74
  } = _ref2;
55
- const {
56
- tableId
57
- } = useContext(TableDndContext);
58
- // onRowDrop 참조를 ref로 유지 — 콜백이 매 렌더마다 재생성되어도 effect가 재구독하지 않도록
59
- const onRowDropRef = useRef(onRowDrop);
60
- onRowDropRef.current = onRowDrop;
61
- useEffect(() => {
62
- return monitorForElements({
63
- canMonitor: _ref3 => {
64
- let {
65
- source
66
- } = _ref3;
67
- return source.data.tableId === tableId;
68
- },
69
- onDrop: _ref4 => {
70
- let {
71
- source,
72
- location
73
- } = _ref4;
74
- if (!onRowDropRef.current) return;
75
- const target = location.current.dropTargets[0];
76
- if (!target) return;
77
- const fromId = source.data.id;
78
- const toId = target.data.id;
79
- if (typeof fromId !== 'string' || typeof toId !== 'string' || fromId === toId) return;
80
- const edge = extractClosestEdge(target.data);
81
- onRowDropRef.current(fromId, toId, edge === 'bottom' ? 'bottom' : 'top');
82
- }
83
- });
84
- }, [tableId]);
75
+ useRowDropHandler(onRowDrop);
85
76
  return _jsx("tbody", {
86
77
  className: classNames('ncua-table__body', className),
87
78
  children: children
88
79
  });
89
80
  };
90
81
  Body.displayName = 'Table.Body';
91
- const Row = /*#__PURE__*/forwardRef((_ref5, forwardedRef) => {
82
+ const RowGroup = _ref3 => {
83
+ let {
84
+ children,
85
+ className,
86
+ onRowDrop
87
+ } = _ref3;
88
+ useRowDropHandler(onRowDrop);
89
+ return _jsx("tbody", {
90
+ className: classNames('ncua-table__body', 'ncua-table__row-group', className),
91
+ children: children
92
+ });
93
+ };
94
+ RowGroup.displayName = 'Table.RowGroup';
95
+ const Row = /*#__PURE__*/forwardRef((_ref4, forwardedRef) => {
92
96
  let {
93
97
  children,
94
98
  className,
@@ -96,7 +100,7 @@ const Row = /*#__PURE__*/forwardRef((_ref5, forwardedRef) => {
96
100
  status,
97
101
  dragId,
98
102
  ...rest
99
- } = _ref5;
103
+ } = _ref4;
100
104
  const {
101
105
  isDraggable,
102
106
  tableId
@@ -114,8 +118,32 @@ const Row = /*#__PURE__*/forwardRef((_ref5, forwardedRef) => {
114
118
  }, [forwardedRef]);
115
119
  useEffect(() => {
116
120
  if (!isDraggable || !dragId || !internalRef.current) return;
121
+ // 끌고 있는 블록 안쪽은 드롭 대상이 아니다 — 거기 놓으면 병합이 쪼개진다
122
+ const isInsideDraggedBlock = data => {
123
+ const {
124
+ blockBody,
125
+ blockStart,
126
+ blockEnd
127
+ } = data;
128
+ if (typeof blockStart !== 'number' || typeof blockEnd !== 'number' || blockStart < 0) return false;
129
+ const located = internalRef.current ? locateRow(internalRef.current) : null;
130
+ if (!located || located.bodyIndex !== blockBody) return false;
131
+ return located.index >= blockStart && located.index <= blockEnd;
132
+ };
133
+ // 병합 블록 가운데에 놓으면 그 블록이 쪼개진다 — 받을 수 있는 변만 연다
134
+ const openEdges = () => {
135
+ const located = internalRef.current ? locateRow(internalRef.current) : null;
136
+ if (!located) return ['top', 'bottom'];
137
+ return computeOpenEdges(readRowSpans(located.body), located.index);
138
+ };
117
139
  return dropTargetForElements({
118
140
  element: internalRef.current,
141
+ canDrop: _ref5 => {
142
+ let {
143
+ source
144
+ } = _ref5;
145
+ return !isInsideDraggedBlock(source.data) && openEdges().length > 0;
146
+ },
119
147
  getData: _ref6 => {
120
148
  let {
121
149
  input,
@@ -127,7 +155,7 @@ const Row = /*#__PURE__*/forwardRef((_ref5, forwardedRef) => {
127
155
  }, {
128
156
  input,
129
157
  element,
130
- allowedEdges: ['top', 'bottom']
158
+ allowedEdges: openEdges()
131
159
  });
132
160
  },
133
161
  onDragEnter: _ref7 => {
@@ -195,10 +223,28 @@ const DragHeaderCell = _ref9 => {
195
223
  });
196
224
  };
197
225
  DragHeaderCell.displayName = 'Table.DragHeaderCell';
226
+ // 미리보기에 그릴 행과 배지에 적을 건수를 정한다 — 묶음은 행이 여러 개여도 한 건이다
227
+ const planDragPreview = (rowEl, input) => {
228
+ const rowRect = rowEl.getBoundingClientRect();
229
+ const badgeHalf = PREVIEW_BADGE_SIZE / 2;
230
+ // 묶음마다 `tbody` 가 생기므로 표 전체에서 모은다 — 자기 `tbody` 기준이면 자기 묶음만 보인다.
231
+ // 대상은 `Table.Body` 가 그린 `tbody` 뿐이다 — 맨 `tbody` 의 행까지 세면 TB-10 의 단일 프리뷰가 깨진다
232
+ const tableEl = rowEl.classList.contains('ncua-table__row--selected') ? rowEl.closest('table') : null;
233
+ const selectedRows = tableEl ? Array.from(tableEl.querySelectorAll('.ncua-table__body > .ncua-table__row--selected')) : [];
234
+ const isMultiSelect = selectedRows.length > 1;
235
+ return {
236
+ rows: isMultiSelect ? selectedRows : findRowSpanBlock(rowEl)?.rows ?? [rowEl],
237
+ itemCount: isMultiSelect ? selectedRows.length : 1,
238
+ rowRect,
239
+ offsetX: Math.round(input.clientX - rowRect.left) + badgeHalf,
240
+ offsetY: Math.round(input.clientY - rowRect.top) + badgeHalf
241
+ };
242
+ };
198
243
  const DragCell = _ref0 => {
199
244
  let {
200
245
  disabled,
201
246
  className,
247
+ rowSpan,
202
248
  children
203
249
  } = _ref0;
204
250
  const {
@@ -217,34 +263,31 @@ const DragCell = _ref0 => {
217
263
  let pendingPreview = null;
218
264
  return makeDraggable({
219
265
  element: handleRef.current,
220
- getInitialData: () => ({
221
- id: dragId,
222
- tableId
223
- }),
266
+ getInitialData: () => {
267
+ const rowEl = handleRef.current?.closest('tr');
268
+ const block = rowEl ? findRowSpanBlock(rowEl) : null;
269
+ // 블록 전체가 움직이므로 소비자에게는 블록 첫 행을 출발점으로 알린다
270
+ const blockId = block?.rows[0]?.getAttribute(DRAG_ID_ATTRIBUTE) ?? dragId;
271
+ return {
272
+ id: blockId,
273
+ tableId,
274
+ moveIds: rowEl ? planMoveIds(rowEl) : [],
275
+ blockBody: block?.bodyIndex ?? -1,
276
+ blockStart: block?.start ?? -1,
277
+ blockEnd: block?.end ?? -1
278
+ };
279
+ },
224
280
  onGenerateDragPreview: _ref1 => {
225
281
  let {
226
282
  nativeSetDragImage,
227
283
  location
228
284
  } = _ref1;
229
- if (!handleRef.current) return;
230
- const rowEl = handleRef.current.closest('tr');
285
+ const rowEl = handleRef.current?.closest('tr');
231
286
  if (!rowEl) return;
232
287
  disableNativeDragPreview({
233
288
  nativeSetDragImage
234
289
  });
235
- const rowRect = rowEl.getBoundingClientRect();
236
- const badgeHalf = PREVIEW_BADGE_SIZE / 2;
237
- const offsetX = Math.round(location.initial.input.clientX - rowRect.left) + badgeHalf;
238
- const offsetY = Math.round(location.initial.input.clientY - rowRect.top) + badgeHalf;
239
- const isSelected = rowEl.classList.contains('ncua-table__row--selected');
240
- const bodyEl = rowEl.closest('.ncua-table__body');
241
- const selectedRows = isSelected && bodyEl ? Array.from(bodyEl.querySelectorAll('.ncua-table__row--selected')) : [];
242
- pendingPreview = {
243
- rows: selectedRows.length > 1 ? selectedRows : [rowEl],
244
- rowRect,
245
- offsetX,
246
- offsetY
247
- };
290
+ pendingPreview = planDragPreview(rowEl, location.initial.input);
248
291
  },
249
292
  onDragStart: _ref10 => {
250
293
  let {
@@ -254,11 +297,12 @@ const DragCell = _ref0 => {
254
297
  if (!pendingPreview) return;
255
298
  const {
256
299
  rows,
300
+ itemCount,
257
301
  rowRect,
258
302
  offsetX,
259
303
  offsetY
260
304
  } = pendingPreview;
261
- overlay = buildDragPreview(rows, rowRect);
305
+ overlay = buildDragPreview(rows, rowRect, itemCount);
262
306
  overlay.style.left = `${Math.round(location.initial.input.clientX) - offsetX}px`;
263
307
  overlay.style.top = `${Math.round(location.initial.input.clientY) - offsetY}px`;
264
308
  document.body.appendChild(overlay);
@@ -282,6 +326,7 @@ const DragCell = _ref0 => {
282
326
  }, [isDraggable, dragId, tableId, disabled, setIsDragging]);
283
327
  return _jsx("td", {
284
328
  className: classNames('ncua-table__drag-cell', className),
329
+ rowSpan: rowSpan,
285
330
  children: _jsxs("span", {
286
331
  className: "ncua-table__drag-cell-inner",
287
332
  children: [isDraggable && _jsx("button", {
@@ -454,9 +499,21 @@ const sortChildren = children => {
454
499
  });
455
500
  return result;
456
501
  };
457
- // ──────────────────────────────────────────────
458
- // Main Table component
459
- // ──────────────────────────────────────────────
502
+ const readRowDrop = (sourceData, target) => {
503
+ if (!target) return null;
504
+ const fromId = sourceData.id;
505
+ const toId = target.data.id;
506
+ if (typeof fromId !== 'string' || typeof toId !== 'string' || fromId === toId) return null;
507
+ const isStringArray = v => Array.isArray(v) && v.every(x => typeof x === 'string');
508
+ const moveIds = isStringArray(sourceData.moveIds) ? sourceData.moveIds : [fromId];
509
+ const edge = extractClosestEdge(target.data) === 'bottom' ? 'bottom' : 'top';
510
+ return {
511
+ fromId,
512
+ toId,
513
+ edge,
514
+ moveIds
515
+ };
516
+ };
460
517
  const TableComponent = /*#__PURE__*/forwardRef((_ref17, ref) => {
461
518
  let {
462
519
  type = 'horizontal',
@@ -473,6 +530,25 @@ const TableComponent = /*#__PURE__*/forwardRef((_ref17, ref) => {
473
530
  } = _ref17;
474
531
  const tableIdRef = useRef(`ncua-table-${Math.random().toString(TABLE_DND_ID_RADIX).slice(TABLE_DND_ID_SLICE_START, TABLE_DND_ID_SLICE_END)}`);
475
532
  const tableId = tableIdRef.current;
533
+ // 콜백이 매 렌더마다 재생성되어도 effect 가 재구독하지 않도록 ref 로 유지한다.
534
+ const onRowDropRef = useRef(undefined);
535
+ useEffect(() => monitorForElements({
536
+ canMonitor: _ref18 => {
537
+ let {
538
+ source
539
+ } = _ref18;
540
+ return source.data.tableId === tableId;
541
+ },
542
+ onDrop: _ref19 => {
543
+ let {
544
+ source,
545
+ location
546
+ } = _ref19;
547
+ const drop = readRowDrop(source.data, location.current.dropTargets[0]);
548
+ if (!drop) return;
549
+ onRowDropRef.current?.(drop.fromId, drop.toId, drop.edge, drop.moveIds);
550
+ }
551
+ }), [tableId]);
476
552
  const effectiveDraggable = draggable && type === 'horizontal';
477
553
  const tableClasses = classNames('ncua-table', className, {
478
554
  'ncua-table--horizontal': type === 'horizontal',
@@ -561,7 +637,8 @@ const TableComponent = /*#__PURE__*/forwardRef((_ref17, ref) => {
561
637
  };
562
638
  const dndContextValue = {
563
639
  isDraggable: effectiveDraggable,
564
- tableId
640
+ tableId,
641
+ onRowDropRef
565
642
  };
566
643
  // horizontalScroll=true 시 외곽 wrapper + FloatingProvider 부착.
567
644
  // 핵심 — __h-scroll-container 는 <table>(또는 scroll-area) 만 감싸고, footer/pagination 은
@@ -638,6 +715,7 @@ TableComponent.displayName = 'Table';
638
715
  export const Table = Object.assign(TableComponent, {
639
716
  Header,
640
717
  Body,
718
+ RowGroup,
641
719
  Row,
642
720
  DragHeaderCell,
643
721
  DragCell,
@@ -49,12 +49,17 @@ describe('Table.DragCell 핸들 등록 (TB-08)', () => {
49
49
  expect(draggableArgsOf('a').element).toBe(handle);
50
50
  expect(view.querySelectorAll('button.ncua-table__drag-handle')).toHaveLength(ROW_IDS.length);
51
51
  });
52
- it('TB-08 getInitialData 는 {id: dragId, tableId} 를 돌려준다', () => {
52
+ it('TB-08 getInitialData 는 {id: dragId, tableId} 와 옮길 행·블록 좌표를 돌려준다', () => {
53
53
  render(draggableTable(vi.fn()));
54
54
  const tableId = sourceDataOf('b').tableId;
55
+ // 병합이 없으면 블록은 자기 행 한 칸이고 옮길 행도 자기 자신뿐이다
55
56
  expect(draggableArgsOf('a').getInitialData?.({})).toEqual({
56
57
  id: 'a',
57
- tableId
58
+ tableId,
59
+ moveIds: ['a'],
60
+ blockBody: 0,
61
+ blockStart: 0,
62
+ blockEnd: 0
58
63
  });
59
64
  });
60
65
  it('TB-08 disabled 면 button 을 disabled 로 렌더하고 draggable 로 등록하지 않는다', () => {
@@ -30,14 +30,14 @@ describe('Table 행 드롭 → onRowDrop (TB-01)', () => {
30
30
  const source = sourceDataOf('a');
31
31
  dropOnMonitors(source, [targetDataOf('b', LOWER_HALF_Y, source)]);
32
32
  expect(onRowDrop).toHaveBeenCalledTimes(1);
33
- expect(onRowDrop).toHaveBeenCalledWith('a', 'b', 'bottom');
33
+ expect(onRowDrop).toHaveBeenCalledWith('a', 'b', 'bottom', ['a']);
34
34
  });
35
35
  it('TB-01 다른 행의 위쪽 절반에 놓으면 edge 는 "top" 이다', () => {
36
36
  const onRowDrop = vi.fn();
37
37
  render(draggableTable(onRowDrop));
38
38
  const source = sourceDataOf('c');
39
39
  dropOnMonitors(source, [targetDataOf('a', UPPER_HALF_Y, source)]);
40
- expect(onRowDrop).toHaveBeenCalledWith('c', 'a', 'top');
40
+ expect(onRowDrop).toHaveBeenCalledWith('c', 'a', 'top', ['c']);
41
41
  });
42
42
  it('TB-01 target 에 closest edge 정보가 없으면 "top" 으로 전달한다', () => {
43
43
  const onRowDrop = vi.fn();
@@ -47,7 +47,7 @@ describe('Table 행 드롭 → onRowDrop (TB-01)', () => {
47
47
  id: 'b',
48
48
  tableId: source.tableId
49
49
  }]);
50
- expect(onRowDrop).toHaveBeenCalledWith('a', 'b', 'top');
50
+ expect(onRowDrop).toHaveBeenCalledWith('a', 'b', 'top', ['a']);
51
51
  });
52
52
  it('TB-01 여러 drop target 이 겹치면 가장 안쪽(첫 번째) target 기준으로 전달한다', () => {
53
53
  const onRowDrop = vi.fn();
@@ -55,15 +55,20 @@ describe('Table 행 드롭 → onRowDrop (TB-01)', () => {
55
55
  const source = sourceDataOf('a');
56
56
  dropOnMonitors(source, [targetDataOf('c', LOWER_HALF_Y, source), targetDataOf('b', UPPER_HALF_Y, source)]);
57
57
  expect(onRowDrop).toHaveBeenCalledTimes(1);
58
- expect(onRowDrop).toHaveBeenCalledWith('a', 'c', 'bottom');
58
+ expect(onRowDrop).toHaveBeenCalledWith('a', 'c', 'bottom', ['a']);
59
59
  });
60
60
  it('TB-01 행의 source·target data 에 이 테이블의 tableId 가 실린다', () => {
61
61
  render(draggableTable(vi.fn()));
62
62
  const source = sourceDataOf('a');
63
63
  const target = targetDataOf('b', LOWER_HALF_Y, source);
64
+ // 블록 좌표는 병합 블록 드롭 판정에 쓰인다. 병합이 없으면 자기 행 한 칸이다
64
65
  expect(source).toEqual({
65
66
  id: 'a',
66
- tableId: expect.stringMatching(/^ncua-table-/)
67
+ tableId: expect.stringMatching(/^ncua-table-/),
68
+ moveIds: ['a'],
69
+ blockBody: 0,
70
+ blockStart: 0,
71
+ blockEnd: 0
67
72
  });
68
73
  expect(target.id).toBe('b');
69
74
  expect(target.tableId).toBe(source.tableId);
@@ -134,7 +139,7 @@ describe('Table.Body canMonitor — 테이블 격리 (TB-04)', () => {
134
139
  id: 'b',
135
140
  tableId: source.tableId
136
141
  }]);
137
- expect(first).toHaveBeenCalledWith('a', 'b', 'top');
142
+ expect(first).toHaveBeenCalledWith('a', 'b', 'top', ['a']);
138
143
  expect(second).not.toHaveBeenCalled();
139
144
  expect(sourceDataOf('a', sectionOf('second')).tableId).not.toBe(source.tableId);
140
145
  });
@@ -164,7 +169,7 @@ describe('Table.Body canMonitor — 테이블 격리 (TB-04)', () => {
164
169
  id: 'c',
165
170
  tableId: foreignTarget.tableId
166
171
  }]);
167
- expect(first).toHaveBeenCalledWith('a', 'c', 'top');
172
+ expect(first).toHaveBeenCalledWith('a', 'c', 'top', ['a']);
168
173
  expect(second).not.toHaveBeenCalled();
169
174
  });
170
175
  it.todo('TB-04 다른 테이블의 행 위에 놓으면 어느 테이블의 onRowDrop 도 호출하지 않는다 (canDrop 또는 target tableId 검사)');
@@ -181,7 +186,7 @@ describe('Table.Body onRowDrop ref 갱신 (TB-05)', () => {
181
186
  expect(monitors()).toHaveLength(1);
182
187
  expect(monitors()[0]).toBe(monitorBefore);
183
188
  expect(first).not.toHaveBeenCalled();
184
- expect(latest).toHaveBeenCalledWith('a', 'b', 'bottom');
189
+ expect(latest).toHaveBeenCalledWith('a', 'b', 'bottom', ['a']);
185
190
  });
186
191
  it('TB-05 onRowDrop 을 undefined 로 바꾸면 이후 drop 은 무시된다', () => {
187
192
  const first = vi.fn();
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * `Table.RowGroup` 의 DOM 계약만 다룬다.
4
+ * hover·selected 배경은 CSS 캐스케이드 결과이므로 jsdom 으로 판정할 수 없다 (브라우저에서 확인).
5
+ */
6
+ import { createElement } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { act } from 'react-dom/test-utils';
9
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
10
+ import { Table } from '../Table';
11
+ let container;
12
+ let root;
13
+ beforeEach(() => {
14
+ container = document.createElement('div');
15
+ document.body.appendChild(container);
16
+ act(() => {
17
+ root = createRoot(container);
18
+ });
19
+ });
20
+ afterEach(() => {
21
+ act(() => {
22
+ root.unmount();
23
+ });
24
+ container.remove();
25
+ });
26
+ // `children` 은 TableRowGroupProps 의 필수 prop 이라 createElement 의 props 인자에 담는다.
27
+ const rowGroupProps = className => ({
28
+ className,
29
+ children: /*#__PURE__*/createElement('tr', null, /*#__PURE__*/createElement('td', null, '셀'))
30
+ });
31
+ const renderRowGroup = className => {
32
+ act(() => {
33
+ root.render(/*#__PURE__*/createElement('table', null, /*#__PURE__*/createElement(Table.RowGroup, rowGroupProps(className))));
34
+ });
35
+ const tbody = container.querySelector('tbody');
36
+ if (!tbody) throw new Error('tbody 가 렌더되지 않았다.');
37
+ return tbody;
38
+ };
39
+ describe('Table.RowGroup', () => {
40
+ it('ncua-table__body 와 ncua-table__row-group 을 가진 tbody 를 렌더한다', () => {
41
+ const tbody = renderRowGroup();
42
+ expect(tbody.classList.contains('ncua-table__body')).toBe(true);
43
+ expect(tbody.classList.contains('ncua-table__row-group')).toBe(true);
44
+ });
45
+ it('전달한 className 을 기본 클래스와 함께 붙인다', () => {
46
+ const tbody = renderRowGroup('custom-group');
47
+ expect(tbody.classList.contains('custom-group')).toBe(true);
48
+ expect(tbody.classList.contains('ncua-table__body')).toBe(true);
49
+ expect(tbody.classList.contains('ncua-table__row-group')).toBe(true);
50
+ });
51
+ });