@ncds/ui-admin 1.8.28-alpha.3 → 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.
- package/dist/cjs/assets/scripts/table/rowSpanBlock.js +97 -0
- package/dist/cjs/src/components/data-display/table/Table.js +135 -57
- package/dist/cjs/src/components/data-display/table/__tests__/Table.dnd.dragCell.test.js +7 -2
- package/dist/cjs/src/components/data-display/table/__tests__/Table.dnd.test.js +13 -8
- package/dist/cjs/src/components/data-display/table/__tests__/Table.rowGroup.test.js +54 -0
- package/dist/cjs/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.js +212 -0
- package/dist/cjs/src/components/data-display/table/__tests__/dnd-context.test.js +5 -2
- package/dist/cjs/src/components/data-display/table/__tests__/tableSelectableStyle.test.js +75 -0
- package/dist/cjs/src/components/data-display/table/dnd-context.js +4 -1
- package/dist/cjs/src/components/data-display/table/dnd-move.js +46 -0
- package/dist/cjs/src/components/data-display/table/dnd-preview.js +8 -3
- package/dist/cjs/src/generated/scoped-css.js +1 -1
- package/dist/esm/assets/scripts/table/rowSpanBlock.js +85 -0
- package/dist/esm/src/components/data-display/table/Table.js +135 -57
- package/dist/esm/src/components/data-display/table/__tests__/Table.dnd.dragCell.test.js +7 -2
- package/dist/esm/src/components/data-display/table/__tests__/Table.dnd.test.js +13 -8
- package/dist/esm/src/components/data-display/table/__tests__/Table.rowGroup.test.js +51 -0
- package/dist/esm/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.js +210 -0
- package/dist/esm/src/components/data-display/table/__tests__/dnd-context.test.js +5 -2
- package/dist/esm/src/components/data-display/table/__tests__/tableSelectableStyle.test.js +72 -0
- package/dist/esm/src/components/data-display/table/dnd-context.js +4 -1
- package/dist/esm/src/components/data-display/table/dnd-move.js +38 -0
- package/dist/esm/src/components/data-display/table/dnd-preview.js +8 -3
- package/dist/esm/src/generated/scoped-css.js +1 -1
- package/dist/temp/assets/scripts/table/rowSpanBlock.d.ts +29 -0
- package/dist/temp/assets/scripts/table/rowSpanBlock.js +77 -0
- package/dist/temp/src/components/data-display/table/Table.d.ts +7 -3
- package/dist/temp/src/components/data-display/table/Table.js +107 -47
- package/dist/temp/src/components/data-display/table/__tests__/Table.dnd.dragCell.test.js +10 -2
- package/dist/temp/src/components/data-display/table/__tests__/Table.dnd.test.js +16 -8
- package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroup.test.d.ts +1 -0
- package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroup.test.js +52 -0
- package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.d.ts +1 -0
- package/dist/temp/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.js +137 -0
- package/dist/temp/src/components/data-display/table/__tests__/dnd-context.test.js +2 -2
- package/dist/temp/src/components/data-display/table/__tests__/tableSelectableStyle.test.d.ts +1 -0
- package/dist/temp/src/components/data-display/table/__tests__/tableSelectableStyle.test.js +71 -0
- package/dist/temp/src/components/data-display/table/dnd-context.d.ts +3 -0
- package/dist/temp/src/components/data-display/table/dnd-context.js +1 -0
- package/dist/temp/src/components/data-display/table/dnd-move.d.ts +17 -0
- package/dist/temp/src/components/data-display/table/dnd-move.js +31 -0
- package/dist/temp/src/components/data-display/table/dnd-preview.d.ts +5 -1
- package/dist/temp/src/components/data-display/table/dnd-preview.js +7 -3
- package/dist/temp/src/components/data-display/table/types.d.ts +14 -5
- package/dist/temp/src/generated/scoped-css.js +1 -1
- package/dist/types/assets/scripts/table/rowSpanBlock.d.ts +29 -0
- package/dist/types/src/components/data-display/table/Table.d.ts +7 -3
- package/dist/types/src/components/data-display/table/__tests__/Table.rowGroup.test.d.ts +1 -0
- package/dist/types/src/components/data-display/table/__tests__/Table.rowGroupDnd.test.d.ts +1 -0
- package/dist/types/src/components/data-display/table/__tests__/tableSelectableStyle.test.d.ts +1 -0
- package/dist/types/src/components/data-display/table/dnd-context.d.ts +3 -0
- package/dist/types/src/components/data-display/table/dnd-move.d.ts +17 -0
- package/dist/types/src/components/data-display/table/dnd-preview.d.ts +5 -1
- package/dist/types/src/components/data-display/table/types.d.ts +14 -5
- package/dist/ui-admin/assets/styles/style.css +60 -4
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
type RowSpanEdge = 'top' | 'bottom';
|
|
2
|
+
type RowSpanBlock = {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* 병합을 쪼개지 않고 통째로 옮길 수 있는 최소 행 구간을 구한다.
|
|
8
|
+
* 범위가 겹치면 어느 쪽도 쪼갤 수 없으므로 연결된 범위를 모두 합친다.
|
|
9
|
+
*
|
|
10
|
+
* @param rowSpans 행별 셀 rowSpan 목록. 병합에 덮여 생략된 셀은 포함하지 않는다
|
|
11
|
+
* @param index 기준 행의 위치
|
|
12
|
+
*/
|
|
13
|
+
export declare const computeRowSpanBlock: (rowSpans: readonly (readonly number[])[], index: number) => RowSpanBlock;
|
|
14
|
+
/**
|
|
15
|
+
* 드롭을 받을 수 있는 변. 블록 가운데에 떨어뜨리면 그 블록이 쪼개지므로 경계만 연다.
|
|
16
|
+
*/
|
|
17
|
+
export declare const computeOpenEdges: (rowSpans: readonly (readonly number[])[], index: number) => RowSpanEdge[];
|
|
18
|
+
/** 고른 행들이 끌고 오는 행 전부. 병합 블록을 쪼개면 병합이 깨지므로 블록째 더한다. */
|
|
19
|
+
export declare const collectBlockIndices: (rowSpans: number[][], indices: number[]) => number[];
|
|
20
|
+
/** 행이 속한 `tbody` 와 그 안에서의 위치. 표 밖 요소면 `null`. */
|
|
21
|
+
export declare const locateRow: (rowEl: HTMLTableRowElement) => {
|
|
22
|
+
body: HTMLTableSectionElement;
|
|
23
|
+
index: number;
|
|
24
|
+
} | null;
|
|
25
|
+
/** 표 본문의 행별 rowSpan 을 읽어 `computeRowSpanBlock` 에 넘길 형태로 만든다. */
|
|
26
|
+
export declare const readRowSpans: (body: HTMLTableSectionElement) => number[][];
|
|
27
|
+
export type { RowSpanBlock, RowSpanEdge };
|
|
28
|
+
/** 다중 선택으로 고른 행들이 끌고 오는 행 전부. 행 순서는 표에 놓인 순서를 따른다. */
|
|
29
|
+
export declare const expandRowsToBlocks: (rows: HTMLTableRowElement[]) => HTMLTableRowElement[];
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const SINGLE_ROW = 1;
|
|
2
|
+
const collectRanges = (rowSpans) => rowSpans.flatMap((spans, index) => spans
|
|
3
|
+
.filter((span) => span > SINGLE_ROW)
|
|
4
|
+
.map((span) => ({ start: index, end: Math.min(index + span - SINGLE_ROW, rowSpans.length - 1) })));
|
|
5
|
+
const overlaps = (a, b) => a.start <= b.end && b.start <= a.end;
|
|
6
|
+
/**
|
|
7
|
+
* 병합을 쪼개지 않고 통째로 옮길 수 있는 최소 행 구간을 구한다.
|
|
8
|
+
* 범위가 겹치면 어느 쪽도 쪼갤 수 없으므로 연결된 범위를 모두 합친다.
|
|
9
|
+
*
|
|
10
|
+
* @param rowSpans 행별 셀 rowSpan 목록. 병합에 덮여 생략된 셀은 포함하지 않는다
|
|
11
|
+
* @param index 기준 행의 위치
|
|
12
|
+
*/
|
|
13
|
+
export const computeRowSpanBlock = (rowSpans, index) => {
|
|
14
|
+
const ranges = collectRanges(rowSpans);
|
|
15
|
+
let block = { start: index, end: index };
|
|
16
|
+
let expanded = true;
|
|
17
|
+
while (expanded) {
|
|
18
|
+
expanded = false;
|
|
19
|
+
for (const range of ranges) {
|
|
20
|
+
if (!overlaps(range, block))
|
|
21
|
+
continue;
|
|
22
|
+
const start = Math.min(block.start, range.start);
|
|
23
|
+
const end = Math.max(block.end, range.end);
|
|
24
|
+
if (start !== block.start || end !== block.end) {
|
|
25
|
+
block = { start, end };
|
|
26
|
+
expanded = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return block;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* 드롭을 받을 수 있는 변. 블록 가운데에 떨어뜨리면 그 블록이 쪼개지므로 경계만 연다.
|
|
34
|
+
*/
|
|
35
|
+
export const computeOpenEdges = (rowSpans, index) => {
|
|
36
|
+
const block = computeRowSpanBlock(rowSpans, index);
|
|
37
|
+
const edges = [];
|
|
38
|
+
if (index === block.start)
|
|
39
|
+
edges.push('top');
|
|
40
|
+
if (index === block.end)
|
|
41
|
+
edges.push('bottom');
|
|
42
|
+
return edges;
|
|
43
|
+
};
|
|
44
|
+
/** 고른 행들이 끌고 오는 행 전부. 병합 블록을 쪼개면 병합이 깨지므로 블록째 더한다. */
|
|
45
|
+
export const collectBlockIndices = (rowSpans, indices) => {
|
|
46
|
+
const picked = new Set();
|
|
47
|
+
for (const index of indices) {
|
|
48
|
+
const block = computeRowSpanBlock(rowSpans, index);
|
|
49
|
+
for (let row = block.start; row <= block.end; row++) {
|
|
50
|
+
picked.add(row);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return Array.from(picked).sort((a, b) => a - b);
|
|
54
|
+
};
|
|
55
|
+
/** 행이 속한 `tbody` 와 그 안에서의 위치. 표 밖 요소면 `null`. */
|
|
56
|
+
export const locateRow = (rowEl) => {
|
|
57
|
+
const body = rowEl.closest('tbody');
|
|
58
|
+
if (!body)
|
|
59
|
+
return null;
|
|
60
|
+
const index = Array.from(body.rows).indexOf(rowEl);
|
|
61
|
+
return index < 0 ? null : { body, index };
|
|
62
|
+
};
|
|
63
|
+
/** 표 본문의 행별 rowSpan 을 읽어 `computeRowSpanBlock` 에 넘길 형태로 만든다. */
|
|
64
|
+
export const readRowSpans = (body) => Array.from(body.rows).map((row) => Array.from(row.cells).map((cell) => cell.rowSpan));
|
|
65
|
+
/** 다중 선택으로 고른 행들이 끌고 오는 행 전부. 행 순서는 표에 놓인 순서를 따른다. */
|
|
66
|
+
export const expandRowsToBlocks = (rows) => {
|
|
67
|
+
const perBody = new Map();
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
const located = locateRow(row);
|
|
70
|
+
if (!located)
|
|
71
|
+
continue;
|
|
72
|
+
const picked = perBody.get(located.body) ?? [];
|
|
73
|
+
picked.push(located.index);
|
|
74
|
+
perBody.set(located.body, picked);
|
|
75
|
+
}
|
|
76
|
+
return Array.from(perBody).flatMap(([body, indices]) => collectBlockIndices(readRowSpans(body), indices).map((index) => body.rows[index]));
|
|
77
|
+
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import type { SortDirection, TableBodyProps, TableColGroupProps, TableDragCellProps, TableDragHeaderCellProps, TableEmptyProps, TableFooterProps, TableHeaderProps, TablePaginationProps } from './types';
|
|
2
|
+
import type { SortDirection, TableBodyProps, TableColGroupProps, TableDragCellProps, TableDragHeaderCellProps, TableEmptyProps, TableFooterProps, TableHeaderProps, TablePaginationProps, TableRowGroupProps } from './types';
|
|
3
3
|
export declare const Table: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "draggable" | "ref"> & {
|
|
4
4
|
type?: import("./types").TableType | undefined;
|
|
5
5
|
fixedHeader?: boolean | undefined;
|
|
@@ -19,6 +19,10 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
|
|
|
19
19
|
({ children, className, onRowDrop }: TableBodyProps): import("react/jsx-runtime").JSX.Element;
|
|
20
20
|
displayName: string;
|
|
21
21
|
};
|
|
22
|
+
RowGroup: {
|
|
23
|
+
({ children, className, onRowDrop }: TableRowGroupProps): import("react/jsx-runtime").JSX.Element;
|
|
24
|
+
displayName: string;
|
|
25
|
+
};
|
|
22
26
|
Row: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLTableRowElement>, HTMLTableRowElement>, "ref"> & {
|
|
23
27
|
selected?: boolean | undefined;
|
|
24
28
|
status?: import("./types").RowStatus | undefined;
|
|
@@ -29,7 +33,7 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
|
|
|
29
33
|
displayName: string;
|
|
30
34
|
};
|
|
31
35
|
DragCell: {
|
|
32
|
-
({ disabled, className, children }: TableDragCellProps): import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
({ disabled, className, rowSpan, children }: TableDragCellProps): import("react/jsx-runtime").JSX.Element;
|
|
33
37
|
displayName: string;
|
|
34
38
|
};
|
|
35
39
|
HeaderCell: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").ThHTMLAttributes<HTMLTableHeaderCellElement>, HTMLTableHeaderCellElement>, "ref"> & {
|
|
@@ -58,4 +62,4 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
|
|
|
58
62
|
displayName: string;
|
|
59
63
|
};
|
|
60
64
|
};
|
|
61
|
-
export type { RowStatus, SortDirection, TableBodyProps, TableCellProps, TableColGroupProps, TableDragCellProps, TableDragHeaderCellProps, TableEmptyProps, TableFooterProps, TableHeaderCellProps, TableHeaderProps, TablePaginationProps, TableProps, TableRowProps, TableType, } from './types';
|
|
65
|
+
export type { RowStatus, SortDirection, TableBodyProps, TableCellProps, TableColGroupProps, TableDragCellProps, TableDragHeaderCellProps, TableEmptyProps, TableFooterProps, TableHeaderCellProps, TableHeaderProps, TablePaginationProps, TableProps, TableRowDropHandler, TableRowGroupProps, TableRowProps, TableType, } from './types';
|
|
@@ -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 고려한 디자인 권장 너비
|
|
@@ -35,32 +37,32 @@ const ARIA_SORT_MAP = {
|
|
|
35
37
|
// ──────────────────────────────────────────────
|
|
36
38
|
const Header = ({ children, className }) => (_jsx("thead", { className: classNames('ncua-table__header', className), children: children }));
|
|
37
39
|
Header.displayName = 'Table.Header';
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const onRowDropRef =
|
|
42
|
-
onRowDropRef.current = onRowDrop;
|
|
40
|
+
// 묶음마다 호출되므로 핸들러 없는 묶음이 있는 묶음을 덮지 않게 둔다.
|
|
41
|
+
// 거둘 때는 자기가 넣은 것만 비운다 — 조건부로 넘긴 핸들러를 끄면 드롭도 멈춰야 한다.
|
|
42
|
+
const useRowDropHandler = (onRowDrop) => {
|
|
43
|
+
const { onRowDropRef } = useContext(TableDndContext);
|
|
43
44
|
useEffect(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
onRowDropRef.current(fromId, toId, edge === 'bottom' ? 'bottom' : 'top');
|
|
58
|
-
},
|
|
59
|
-
});
|
|
60
|
-
}, [tableId]);
|
|
45
|
+
if (!onRowDrop) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
onRowDropRef.current = onRowDrop;
|
|
49
|
+
return () => {
|
|
50
|
+
if (onRowDropRef.current === onRowDrop) {
|
|
51
|
+
onRowDropRef.current = undefined;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}, [onRowDrop, onRowDropRef]);
|
|
55
|
+
};
|
|
56
|
+
const Body = ({ children, className, onRowDrop }) => {
|
|
57
|
+
useRowDropHandler(onRowDrop);
|
|
61
58
|
return _jsx("tbody", { className: classNames('ncua-table__body', className), children: children });
|
|
62
59
|
};
|
|
63
60
|
Body.displayName = 'Table.Body';
|
|
61
|
+
const RowGroup = ({ children, className, onRowDrop }) => {
|
|
62
|
+
useRowDropHandler(onRowDrop);
|
|
63
|
+
return _jsx("tbody", { className: classNames('ncua-table__body', 'ncua-table__row-group', className), children: children });
|
|
64
|
+
};
|
|
65
|
+
RowGroup.displayName = 'Table.RowGroup';
|
|
64
66
|
const Row = forwardRef(({ children, className, selected, status, dragId, ...rest }, forwardedRef) => {
|
|
65
67
|
const { isDraggable, tableId } = useContext(TableDndContext);
|
|
66
68
|
const internalRef = useRef(null);
|
|
@@ -78,9 +80,27 @@ const Row = forwardRef(({ children, className, selected, status, dragId, ...rest
|
|
|
78
80
|
useEffect(() => {
|
|
79
81
|
if (!isDraggable || !dragId || !internalRef.current)
|
|
80
82
|
return;
|
|
83
|
+
// 끌고 있는 블록 안쪽은 드롭 대상이 아니다 — 거기 놓으면 병합이 쪼개진다
|
|
84
|
+
const isInsideDraggedBlock = (data) => {
|
|
85
|
+
const { blockBody, blockStart, blockEnd } = data;
|
|
86
|
+
if (typeof blockStart !== 'number' || typeof blockEnd !== 'number' || blockStart < 0)
|
|
87
|
+
return false;
|
|
88
|
+
const located = internalRef.current ? locateRow(internalRef.current) : null;
|
|
89
|
+
if (!located || located.bodyIndex !== blockBody)
|
|
90
|
+
return false;
|
|
91
|
+
return located.index >= blockStart && located.index <= blockEnd;
|
|
92
|
+
};
|
|
93
|
+
// 병합 블록 가운데에 놓으면 그 블록이 쪼개진다 — 받을 수 있는 변만 연다
|
|
94
|
+
const openEdges = () => {
|
|
95
|
+
const located = internalRef.current ? locateRow(internalRef.current) : null;
|
|
96
|
+
if (!located)
|
|
97
|
+
return ['top', 'bottom'];
|
|
98
|
+
return computeOpenEdges(readRowSpans(located.body), located.index);
|
|
99
|
+
};
|
|
81
100
|
return dropTargetForElements({
|
|
82
101
|
element: internalRef.current,
|
|
83
|
-
|
|
102
|
+
canDrop: ({ source }) => !isInsideDraggedBlock(source.data) && openEdges().length > 0,
|
|
103
|
+
getData: ({ input, element }) => attachClosestEdge({ id: dragId, tableId }, { input, element, allowedEdges: openEdges() }),
|
|
84
104
|
onDragEnter: ({ self, source }) => {
|
|
85
105
|
if (source.data.id === dragId)
|
|
86
106
|
return;
|
|
@@ -109,7 +129,26 @@ const Row = forwardRef(({ children, className, selected, status, dragId, ...rest
|
|
|
109
129
|
Row.displayName = 'Table.Row';
|
|
110
130
|
const DragHeaderCell = ({ className, children }) => (_jsx("th", { className: classNames('ncua-table__header-cell', 'ncua-table__drag-header-cell', className), children: _jsxs("span", { className: "ncua-table__drag-cell-inner", children: [_jsx("span", { className: "ncua-table__drag-header-icon", "aria-hidden": "true", children: _jsx(DotsGrid02, { width: 16, height: 16 }) }), children] }) }));
|
|
111
131
|
DragHeaderCell.displayName = 'Table.DragHeaderCell';
|
|
112
|
-
|
|
132
|
+
// 미리보기에 그릴 행과 배지에 적을 건수를 정한다 — 묶음은 행이 여러 개여도 한 건이다
|
|
133
|
+
const planDragPreview = (rowEl, input) => {
|
|
134
|
+
const rowRect = rowEl.getBoundingClientRect();
|
|
135
|
+
const badgeHalf = PREVIEW_BADGE_SIZE / 2;
|
|
136
|
+
// 묶음마다 `tbody` 가 생기므로 표 전체에서 모은다 — 자기 `tbody` 기준이면 자기 묶음만 보인다.
|
|
137
|
+
// 대상은 `Table.Body` 가 그린 `tbody` 뿐이다 — 맨 `tbody` 의 행까지 세면 TB-10 의 단일 프리뷰가 깨진다
|
|
138
|
+
const tableEl = rowEl.classList.contains('ncua-table__row--selected') ? rowEl.closest('table') : null;
|
|
139
|
+
const selectedRows = tableEl
|
|
140
|
+
? Array.from(tableEl.querySelectorAll('.ncua-table__body > .ncua-table__row--selected'))
|
|
141
|
+
: [];
|
|
142
|
+
const isMultiSelect = selectedRows.length > 1;
|
|
143
|
+
return {
|
|
144
|
+
rows: isMultiSelect ? selectedRows : (findRowSpanBlock(rowEl)?.rows ?? [rowEl]),
|
|
145
|
+
itemCount: isMultiSelect ? selectedRows.length : 1,
|
|
146
|
+
rowRect,
|
|
147
|
+
offsetX: Math.round(input.clientX - rowRect.left) + badgeHalf,
|
|
148
|
+
offsetY: Math.round(input.clientY - rowRect.top) + badgeHalf,
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
const DragCell = ({ disabled, className, rowSpan, children }) => {
|
|
113
152
|
const { isDraggable, tableId } = useContext(TableDndContext);
|
|
114
153
|
const { dragId, setIsDragging } = useContext(RowDndContext);
|
|
115
154
|
const handleRef = useRef(null);
|
|
@@ -121,34 +160,33 @@ const DragCell = ({ disabled, className, children }) => {
|
|
|
121
160
|
let pendingPreview = null;
|
|
122
161
|
return makeDraggable({
|
|
123
162
|
element: handleRef.current,
|
|
124
|
-
getInitialData: () =>
|
|
163
|
+
getInitialData: () => {
|
|
164
|
+
const rowEl = handleRef.current?.closest('tr');
|
|
165
|
+
const block = rowEl ? findRowSpanBlock(rowEl) : null;
|
|
166
|
+
// 블록 전체가 움직이므로 소비자에게는 블록 첫 행을 출발점으로 알린다
|
|
167
|
+
const blockId = block?.rows[0]?.getAttribute(DRAG_ID_ATTRIBUTE) ?? dragId;
|
|
168
|
+
return {
|
|
169
|
+
id: blockId,
|
|
170
|
+
tableId,
|
|
171
|
+
moveIds: rowEl ? planMoveIds(rowEl) : [],
|
|
172
|
+
blockBody: block?.bodyIndex ?? -1,
|
|
173
|
+
blockStart: block?.start ?? -1,
|
|
174
|
+
blockEnd: block?.end ?? -1,
|
|
175
|
+
};
|
|
176
|
+
},
|
|
125
177
|
onGenerateDragPreview: ({ nativeSetDragImage, location }) => {
|
|
126
|
-
|
|
127
|
-
return;
|
|
128
|
-
const rowEl = handleRef.current.closest('tr');
|
|
178
|
+
const rowEl = handleRef.current?.closest('tr');
|
|
129
179
|
if (!rowEl)
|
|
130
180
|
return;
|
|
131
181
|
disableNativeDragPreview({ nativeSetDragImage });
|
|
132
|
-
|
|
133
|
-
const badgeHalf = PREVIEW_BADGE_SIZE / 2;
|
|
134
|
-
const offsetX = Math.round(location.initial.input.clientX - rowRect.left) + badgeHalf;
|
|
135
|
-
const offsetY = Math.round(location.initial.input.clientY - rowRect.top) + badgeHalf;
|
|
136
|
-
const isSelected = rowEl.classList.contains('ncua-table__row--selected');
|
|
137
|
-
const bodyEl = rowEl.closest('.ncua-table__body');
|
|
138
|
-
const selectedRows = isSelected && bodyEl ? Array.from(bodyEl.querySelectorAll('.ncua-table__row--selected')) : [];
|
|
139
|
-
pendingPreview = {
|
|
140
|
-
rows: selectedRows.length > 1 ? selectedRows : [rowEl],
|
|
141
|
-
rowRect,
|
|
142
|
-
offsetX,
|
|
143
|
-
offsetY,
|
|
144
|
-
};
|
|
182
|
+
pendingPreview = planDragPreview(rowEl, location.initial.input);
|
|
145
183
|
},
|
|
146
184
|
onDragStart: ({ location }) => {
|
|
147
185
|
setIsDragging(true);
|
|
148
186
|
if (!pendingPreview)
|
|
149
187
|
return;
|
|
150
|
-
const { rows, rowRect, offsetX, offsetY } = pendingPreview;
|
|
151
|
-
overlay = buildDragPreview(rows, rowRect);
|
|
188
|
+
const { rows, itemCount, rowRect, offsetX, offsetY } = pendingPreview;
|
|
189
|
+
overlay = buildDragPreview(rows, rowRect, itemCount);
|
|
152
190
|
overlay.style.left = `${Math.round(location.initial.input.clientX) - offsetX}px`;
|
|
153
191
|
overlay.style.top = `${Math.round(location.initial.input.clientY) - offsetY}px`;
|
|
154
192
|
document.body.appendChild(overlay);
|
|
@@ -171,7 +209,7 @@ const DragCell = ({ disabled, className, children }) => {
|
|
|
171
209
|
},
|
|
172
210
|
});
|
|
173
211
|
}, [isDraggable, dragId, tableId, disabled, setIsDragging]);
|
|
174
|
-
return (_jsx("td", { className: classNames('ncua-table__drag-cell', className), children: _jsxs("span", { className: "ncua-table__drag-cell-inner", children: [isDraggable && (_jsx("button", { ref: handleRef, type: "button", className: "ncua-table__drag-handle", "aria-label": "\uD589 \uC21C\uC11C \uBCC0\uACBD", disabled: disabled, children: _jsx(DotsGrid02, {}) })), children] }) }));
|
|
212
|
+
return (_jsx("td", { className: classNames('ncua-table__drag-cell', className), rowSpan: rowSpan, children: _jsxs("span", { className: "ncua-table__drag-cell-inner", children: [isDraggable && (_jsx("button", { ref: handleRef, type: "button", className: "ncua-table__drag-handle", "aria-label": "\uD589 \uC21C\uC11C \uBCC0\uACBD", disabled: disabled, children: _jsx(DotsGrid02, {}) })), children] }) }));
|
|
175
213
|
};
|
|
176
214
|
DragCell.displayName = 'Table.DragCell';
|
|
177
215
|
const HeaderCell = forwardRef(({ children, className, sortDirection, onSort, width, minWidth, style, ...rest }, ref) => {
|
|
@@ -247,12 +285,32 @@ const sortChildren = (children) => {
|
|
|
247
285
|
});
|
|
248
286
|
return result;
|
|
249
287
|
};
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
288
|
+
const readRowDrop = (sourceData, target) => {
|
|
289
|
+
if (!target)
|
|
290
|
+
return null;
|
|
291
|
+
const fromId = sourceData.id;
|
|
292
|
+
const toId = target.data.id;
|
|
293
|
+
if (typeof fromId !== 'string' || typeof toId !== 'string' || fromId === toId)
|
|
294
|
+
return null;
|
|
295
|
+
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
296
|
+
const moveIds = isStringArray(sourceData.moveIds) ? sourceData.moveIds : [fromId];
|
|
297
|
+
const edge = extractClosestEdge(target.data) === 'bottom' ? 'bottom' : 'top';
|
|
298
|
+
return { fromId, toId, edge, moveIds };
|
|
299
|
+
};
|
|
253
300
|
const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, maxHeight, hoverable = true, selectable = false, horizontalScroll = false, minWidth, draggable = false, children, className, ...rest }, ref) => {
|
|
254
301
|
const tableIdRef = useRef(`ncua-table-${Math.random().toString(TABLE_DND_ID_RADIX).slice(TABLE_DND_ID_SLICE_START, TABLE_DND_ID_SLICE_END)}`);
|
|
255
302
|
const tableId = tableIdRef.current;
|
|
303
|
+
// 콜백이 매 렌더마다 재생성되어도 effect 가 재구독하지 않도록 ref 로 유지한다.
|
|
304
|
+
const onRowDropRef = useRef(undefined);
|
|
305
|
+
useEffect(() => monitorForElements({
|
|
306
|
+
canMonitor: ({ source }) => source.data.tableId === tableId,
|
|
307
|
+
onDrop: ({ source, location }) => {
|
|
308
|
+
const drop = readRowDrop(source.data, location.current.dropTargets[0]);
|
|
309
|
+
if (!drop)
|
|
310
|
+
return;
|
|
311
|
+
onRowDropRef.current?.(drop.fromId, drop.toId, drop.edge, drop.moveIds);
|
|
312
|
+
},
|
|
313
|
+
}), [tableId]);
|
|
256
314
|
const effectiveDraggable = draggable && type === 'horizontal';
|
|
257
315
|
const tableClasses = classNames('ncua-table', className, {
|
|
258
316
|
'ncua-table--horizontal': type === 'horizontal',
|
|
@@ -310,6 +368,7 @@ const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, m
|
|
|
310
368
|
const dndContextValue = {
|
|
311
369
|
isDraggable: effectiveDraggable,
|
|
312
370
|
tableId,
|
|
371
|
+
onRowDropRef,
|
|
313
372
|
};
|
|
314
373
|
// horizontalScroll=true 시 외곽 wrapper + FloatingProvider 부착.
|
|
315
374
|
// 핵심 — __h-scroll-container 는 <table>(또는 scroll-area) 만 감싸고, footer/pagination 은
|
|
@@ -333,6 +392,7 @@ TableComponent.displayName = 'Table';
|
|
|
333
392
|
export const Table = Object.assign(TableComponent, {
|
|
334
393
|
Header,
|
|
335
394
|
Body,
|
|
395
|
+
RowGroup,
|
|
336
396
|
Row,
|
|
337
397
|
DragHeaderCell,
|
|
338
398
|
DragCell,
|
|
@@ -47,10 +47,18 @@ describe('Table.DragCell 핸들 등록 (TB-08)', () => {
|
|
|
47
47
|
expect(draggableArgsOf('a').element).toBe(handle);
|
|
48
48
|
expect(view.querySelectorAll('button.ncua-table__drag-handle')).toHaveLength(ROW_IDS.length);
|
|
49
49
|
});
|
|
50
|
-
it('TB-08 getInitialData 는 {id: dragId, tableId}
|
|
50
|
+
it('TB-08 getInitialData 는 {id: dragId, tableId} 와 옮길 행·블록 좌표를 돌려준다', () => {
|
|
51
51
|
render(draggableTable(vi.fn()));
|
|
52
52
|
const tableId = sourceDataOf('b').tableId;
|
|
53
|
-
|
|
53
|
+
// 병합이 없으면 블록은 자기 행 한 칸이고 옮길 행도 자기 자신뿐이다
|
|
54
|
+
expect(draggableArgsOf('a').getInitialData?.({})).toEqual({
|
|
55
|
+
id: 'a',
|
|
56
|
+
tableId,
|
|
57
|
+
moveIds: ['a'],
|
|
58
|
+
blockBody: 0,
|
|
59
|
+
blockStart: 0,
|
|
60
|
+
blockEnd: 0,
|
|
61
|
+
});
|
|
54
62
|
});
|
|
55
63
|
it('TB-08 disabled 면 button 을 disabled 로 렌더하고 draggable 로 등록하지 않는다', () => {
|
|
56
64
|
render(customTable([{ id: 'a' }, { id: 'b', disabled: true }]));
|
|
@@ -30,21 +30,21 @@ 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();
|
|
44
44
|
render(draggableTable(onRowDrop));
|
|
45
45
|
const source = sourceDataOf('a');
|
|
46
46
|
dropOnMonitors(source, [{ id: 'b', tableId: source.tableId }]);
|
|
47
|
-
expect(onRowDrop).toHaveBeenCalledWith('a', 'b', 'top');
|
|
47
|
+
expect(onRowDrop).toHaveBeenCalledWith('a', 'b', 'top', ['a']);
|
|
48
48
|
});
|
|
49
49
|
it('TB-01 여러 drop target 이 겹치면 가장 안쪽(첫 번째) target 기준으로 전달한다', () => {
|
|
50
50
|
const onRowDrop = vi.fn();
|
|
@@ -52,13 +52,21 @@ describe('Table 행 드롭 → onRowDrop (TB-01)', () => {
|
|
|
52
52
|
const source = sourceDataOf('a');
|
|
53
53
|
dropOnMonitors(source, [targetDataOf('c', LOWER_HALF_Y, source), targetDataOf('b', UPPER_HALF_Y, source)]);
|
|
54
54
|
expect(onRowDrop).toHaveBeenCalledTimes(1);
|
|
55
|
-
expect(onRowDrop).toHaveBeenCalledWith('a', 'c', 'bottom');
|
|
55
|
+
expect(onRowDrop).toHaveBeenCalledWith('a', 'c', 'bottom', ['a']);
|
|
56
56
|
});
|
|
57
57
|
it('TB-01 행의 source·target data 에 이 테이블의 tableId 가 실린다', () => {
|
|
58
58
|
render(draggableTable(vi.fn()));
|
|
59
59
|
const source = sourceDataOf('a');
|
|
60
60
|
const target = targetDataOf('b', LOWER_HALF_Y, source);
|
|
61
|
-
|
|
61
|
+
// 블록 좌표는 병합 블록 드롭 판정에 쓰인다. 병합이 없으면 자기 행 한 칸이다
|
|
62
|
+
expect(source).toEqual({
|
|
63
|
+
id: 'a',
|
|
64
|
+
tableId: expect.stringMatching(/^ncua-table-/),
|
|
65
|
+
moveIds: ['a'],
|
|
66
|
+
blockBody: 0,
|
|
67
|
+
blockStart: 0,
|
|
68
|
+
blockEnd: 0,
|
|
69
|
+
});
|
|
62
70
|
expect(target.id).toBe('b');
|
|
63
71
|
expect(target.tableId).toBe(source.tableId);
|
|
64
72
|
});
|
|
@@ -103,7 +111,7 @@ describe('Table.Body canMonitor — 테이블 격리 (TB-04)', () => {
|
|
|
103
111
|
render(twoTables(first, second));
|
|
104
112
|
const source = sourceDataOf('a', sectionOf('first'));
|
|
105
113
|
dropOnMonitors(source, [{ id: 'b', tableId: source.tableId }]);
|
|
106
|
-
expect(first).toHaveBeenCalledWith('a', 'b', 'top');
|
|
114
|
+
expect(first).toHaveBeenCalledWith('a', 'b', 'top', ['a']);
|
|
107
115
|
expect(second).not.toHaveBeenCalled();
|
|
108
116
|
expect(sourceDataOf('a', sectionOf('second')).tableId).not.toBe(source.tableId);
|
|
109
117
|
});
|
|
@@ -126,7 +134,7 @@ describe('Table.Body canMonitor — 테이블 격리 (TB-04)', () => {
|
|
|
126
134
|
const source = sourceDataOf('a', sectionOf('first'));
|
|
127
135
|
const foreignTarget = sourceDataOf('c', sectionOf('second'));
|
|
128
136
|
dropOnMonitors(source, [{ id: 'c', tableId: foreignTarget.tableId }]);
|
|
129
|
-
expect(first).toHaveBeenCalledWith('a', 'c', 'top');
|
|
137
|
+
expect(first).toHaveBeenCalledWith('a', 'c', 'top', ['a']);
|
|
130
138
|
expect(second).not.toHaveBeenCalled();
|
|
131
139
|
});
|
|
132
140
|
it.todo('TB-04 다른 테이블의 행 위에 놓으면 어느 테이블의 onRowDrop 도 호출하지 않는다 (canDrop 또는 target tableId 검사)');
|
|
@@ -143,7 +151,7 @@ describe('Table.Body onRowDrop ref 갱신 (TB-05)', () => {
|
|
|
143
151
|
expect(monitors()).toHaveLength(1);
|
|
144
152
|
expect(monitors()[0]).toBe(monitorBefore);
|
|
145
153
|
expect(first).not.toHaveBeenCalled();
|
|
146
|
-
expect(latest).toHaveBeenCalledWith('a', 'b', 'bottom');
|
|
154
|
+
expect(latest).toHaveBeenCalledWith('a', 'b', 'bottom', ['a']);
|
|
147
155
|
});
|
|
148
156
|
it('TB-05 onRowDrop 을 undefined 로 바꾸면 이후 drop 은 무시된다', () => {
|
|
149
157
|
const first = vi.fn();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
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: createElement('tr', null, createElement('td', null, '셀')),
|
|
30
|
+
});
|
|
31
|
+
const renderRowGroup = (className) => {
|
|
32
|
+
act(() => {
|
|
33
|
+
root.render(createElement('table', null, createElement(Table.RowGroup, rowGroupProps(className))));
|
|
34
|
+
});
|
|
35
|
+
const tbody = container.querySelector('tbody');
|
|
36
|
+
if (!tbody)
|
|
37
|
+
throw new Error('tbody 가 렌더되지 않았다.');
|
|
38
|
+
return tbody;
|
|
39
|
+
};
|
|
40
|
+
describe('Table.RowGroup', () => {
|
|
41
|
+
it('ncua-table__body 와 ncua-table__row-group 을 가진 tbody 를 렌더한다', () => {
|
|
42
|
+
const tbody = renderRowGroup();
|
|
43
|
+
expect(tbody.classList.contains('ncua-table__body')).toBe(true);
|
|
44
|
+
expect(tbody.classList.contains('ncua-table__row-group')).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('전달한 className 을 기본 클래스와 함께 붙인다', () => {
|
|
47
|
+
const tbody = renderRowGroup('custom-group');
|
|
48
|
+
expect(tbody.classList.contains('custom-group')).toBe(true);
|
|
49
|
+
expect(tbody.classList.contains('ncua-table__body')).toBe(true);
|
|
50
|
+
expect(tbody.classList.contains('ncua-table__row-group')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|