@ncds/ui-admin 1.8.9 → 1.8.12

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 (44) hide show
  1. package/dist/cjs/src/components/data-display/data-grid/DataGrid.js +3 -1
  2. package/dist/cjs/src/components/data-display/table/Table.js +326 -75
  3. package/dist/cjs/src/components/data-display/table/dnd-context.js +15 -0
  4. package/dist/cjs/src/components/data-display/table/dnd-preview.js +127 -0
  5. package/dist/cjs/src/components/index.js +11 -0
  6. package/dist/cjs/src/components/navigation/context-tab/ContextTab.js +233 -0
  7. package/dist/cjs/src/components/navigation/context-tab/index.js +16 -0
  8. package/dist/cjs/src/components/navigation/context-tab/useContextTabScroll.js +84 -0
  9. package/dist/esm/src/components/data-display/data-grid/DataGrid.js +3 -1
  10. package/dist/esm/src/components/data-display/table/Table.js +328 -77
  11. package/dist/esm/src/components/data-display/table/dnd-context.js +10 -0
  12. package/dist/esm/src/components/data-display/table/dnd-preview.js +121 -0
  13. package/dist/esm/src/components/index.js +1 -0
  14. package/dist/esm/src/components/navigation/context-tab/ContextTab.js +226 -0
  15. package/dist/esm/src/components/navigation/context-tab/index.js +1 -0
  16. package/dist/esm/src/components/navigation/context-tab/useContextTabScroll.js +77 -0
  17. package/dist/temp/src/components/data-display/data-grid/DataGrid.js +1 -1
  18. package/dist/temp/src/components/data-display/data-grid/DataGrid.types.d.ts +5 -0
  19. package/dist/temp/src/components/data-display/table/Table.d.ts +14 -4
  20. package/dist/temp/src/components/data-display/table/Table.js +172 -14
  21. package/dist/temp/src/components/data-display/table/dnd-context.d.ts +12 -0
  22. package/dist/temp/src/components/data-display/table/dnd-context.js +10 -0
  23. package/dist/temp/src/components/data-display/table/dnd-preview.d.ts +6 -0
  24. package/dist/temp/src/components/data-display/table/dnd-preview.js +120 -0
  25. package/dist/temp/src/components/data-display/table/types.d.ts +24 -1
  26. package/dist/temp/src/components/index.d.ts +1 -0
  27. package/dist/temp/src/components/index.js +1 -0
  28. package/dist/temp/src/components/navigation/context-tab/ContextTab.d.ts +18 -0
  29. package/dist/temp/src/components/navigation/context-tab/ContextTab.js +103 -0
  30. package/dist/temp/src/components/navigation/context-tab/index.d.ts +1 -0
  31. package/dist/temp/src/components/navigation/context-tab/index.js +1 -0
  32. package/dist/temp/src/components/navigation/context-tab/useContextTabScroll.d.ts +22 -0
  33. package/dist/temp/src/components/navigation/context-tab/useContextTabScroll.js +65 -0
  34. package/dist/types/src/components/data-display/data-grid/DataGrid.types.d.ts +5 -0
  35. package/dist/types/src/components/data-display/table/Table.d.ts +14 -4
  36. package/dist/types/src/components/data-display/table/dnd-context.d.ts +12 -0
  37. package/dist/types/src/components/data-display/table/dnd-preview.d.ts +6 -0
  38. package/dist/types/src/components/data-display/table/types.d.ts +24 -1
  39. package/dist/types/src/components/index.d.ts +1 -0
  40. package/dist/types/src/components/navigation/context-tab/ContextTab.d.ts +18 -0
  41. package/dist/types/src/components/navigation/context-tab/index.d.ts +1 -0
  42. package/dist/types/src/components/navigation/context-tab/useContextTabScroll.d.ts +22 -0
  43. package/dist/ui-admin/assets/styles/style.css +324 -2
  44. package/package.json +2 -2
@@ -0,0 +1,226 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Check, ChevronLeft, ChevronRight, Menu01 } from '@ncds/ui-admin-icon';
3
+ import classNames from 'classnames';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import { Button } from '../../action/button/Button';
6
+ import { Badge } from '../../feedback-and-status/badge/Badge';
7
+ import { useContextTabScroll } from './useContextTabScroll';
8
+ const DEFAULT_VISIBLE_TABS_COUNT = 7; // Figma 시안 정합 — Tab Bar 한 페이지 노출 탭 수
9
+ // 컨트롤 버튼은 Button(onlyIcon)으로 렌더한다. 명세 편차: §2.4는 32×32를 명시하나
10
+ // Button 사이즈에 32가 없어 아이콘 16px이 맞는 xs(28×28)를 사용한다. (명세 현행화 대기)
11
+ const CONTROL_BUTTON_SIZE = 'xs';
12
+ const CHECK_ICON_SIZE = 14;
13
+ const MIN_VISIBLE_CONTEXTS = 2;
14
+ /**
15
+ * 컨텍스트 항목의 신규(N) 배지를 Tab Bar·Dropdown 공통으로 렌더한다.
16
+ * Badge의 `new-badge` 타입(신규 콘텐츠 전용, 색·크기 고정)을 사용한다.
17
+ */
18
+ const renderItemBadges = item => {
19
+ if (!item) return null;
20
+ if (item.isNew) return _jsx(Badge, {
21
+ type: "new-badge",
22
+ size: "sm"
23
+ });
24
+ if (item.badgeLabel) return _jsx(Badge, {
25
+ type: "pill-outline",
26
+ label: item.badgeLabel,
27
+ size: "xs"
28
+ });
29
+ return null;
30
+ };
31
+ /**
32
+ * 스크린리더용 접근성 레이블. N 배지는 시각 전용(아이콘)이라 SR이 의미를 못 읽으므로,
33
+ * "신규"를 풀어 aria-label에 합성한다 (DES-SPEC-030-1 §7). 신규가 아니면 undefined를 반환해
34
+ * 버튼의 보이는 텍스트가 그대로 접근성 이름이 되게 한다.
35
+ */
36
+ const getAccessibleLabel = item => item.isNew ? `${item.label}, 신규` : undefined;
37
+ const ContextTab = _ref => {
38
+ let {
39
+ menus = [],
40
+ activeTab,
41
+ onTabChange,
42
+ visibleTabsCount = DEFAULT_VISIBLE_TABS_COUNT,
43
+ className
44
+ } = _ref;
45
+ const containerRef = useRef(null);
46
+ const triggerRef = useRef(null);
47
+ const panelRef = useRef(null);
48
+ const [isOpen, setIsOpen] = useState(false);
49
+ // Tab Bar의 가로 스크롤·페이징(에지 계산·활성 탭 자동 스크롤)은 훅으로 분리한다.
50
+ const {
51
+ barRef,
52
+ isBeginning,
53
+ isEnd,
54
+ scrollByPage
55
+ } = useContextTabScroll({
56
+ activeTab,
57
+ menus,
58
+ visibleTabsCount
59
+ });
60
+ // Dropdown panel 닫기 — 외부 클릭 / ESC. ESC는 trigger로 포커스 복원, 외부 클릭은 클릭 위치 유지 (DES-SPEC-030-1 §1.5/§7)
61
+ useEffect(() => {
62
+ if (!isOpen) return;
63
+ const handlePointerDown = event => {
64
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
65
+ setIsOpen(false);
66
+ }
67
+ };
68
+ const handleKeyDown = event => {
69
+ if (event.key === 'Escape') {
70
+ setIsOpen(false);
71
+ triggerRef.current?.focus();
72
+ }
73
+ };
74
+ document.addEventListener('mousedown', handlePointerDown);
75
+ document.addEventListener('keydown', handleKeyDown);
76
+ return () => {
77
+ document.removeEventListener('mousedown', handlePointerDown);
78
+ document.removeEventListener('keydown', handleKeyDown);
79
+ };
80
+ }, [isOpen]);
81
+ // Dropdown panel이 열리면 첫(비활성 아닌) 항목으로 포커스를 이동한다 (DES-SPEC-030-1 §7)
82
+ useEffect(() => {
83
+ if (!isOpen) return;
84
+ const firstOption = panelRef.current?.querySelector('.ncua-context-tab__option:not(:disabled)');
85
+ firstOption?.focus();
86
+ }, [isOpen]);
87
+ // 컨텍스트가 2개 미만이면 ContextTab을 노출하지 않는다 (DES-SPEC-030-1 §1.2 / F6)
88
+ if (menus.length < MIN_VISIBLE_CONTEXTS) return null;
89
+ const handleSelect = item => {
90
+ if (item.disabled) return;
91
+ onTabChange?.(item.id);
92
+ };
93
+ const handleOptionSelect = item => {
94
+ if (item.disabled) return;
95
+ onTabChange?.(item.id);
96
+ setIsOpen(false);
97
+ triggerRef.current?.focus();
98
+ };
99
+ const renderOption = item => {
100
+ const isActive = item.id === activeTab;
101
+ return _jsx("li", {
102
+ role: "none",
103
+ children: _jsxs("button", {
104
+ type: "button",
105
+ role: "menuitem",
106
+ "aria-current": isActive || undefined,
107
+ "aria-disabled": item.disabled || undefined,
108
+ "aria-label": getAccessibleLabel(item),
109
+ disabled: item.disabled,
110
+ className: classNames('ncua-context-tab__option', {
111
+ 'is-active': isActive
112
+ }),
113
+ onClick: () => handleOptionSelect(item),
114
+ children: [_jsx("span", {
115
+ className: "ncua-context-tab__option-label",
116
+ title: item.label,
117
+ children: item.label
118
+ }), renderItemBadges(item), isActive && _jsx(Check, {
119
+ className: "ncua-context-tab__option-check",
120
+ width: CHECK_ICON_SIZE,
121
+ height: CHECK_ICON_SIZE
122
+ })]
123
+ })
124
+ }, item.id);
125
+ };
126
+ // Dropdown panel은 2-column이며, 좌측 column = 짝수 인덱스 / 우측 column = 홀수 인덱스로
127
+ // row-major 읽기 순서를 유지한다 (Figma 시안 정합).
128
+ const leftColumn = menus.filter((_, index) => index % 2 === 0);
129
+ const rightColumn = menus.filter((_, index) => index % 2 === 1);
130
+ // 한 페이지 안에 모두 들어가면(스크롤 불필요) 탭이 영역을 균등하게 꽉 채우도록 한다.
131
+ // 초과 시에는 visibleTabsCount 등분 고정 폭으로 두고 가로 스크롤로 페이징한다.
132
+ const isFill = menus.length <= visibleTabsCount;
133
+ return _jsxs("div", {
134
+ ref: containerRef,
135
+ className: classNames('ncua-context-tab', className),
136
+ children: [_jsx(Button, {
137
+ ref: triggerRef,
138
+ onlyIcon: true,
139
+ hierarchy: "tertiary-gray",
140
+ size: CONTROL_BUTTON_SIZE,
141
+ className: "ncua-context-tab__control ncua-context-tab__control--menu",
142
+ label: "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
143
+ "aria-label": "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
144
+ "aria-haspopup": "menu",
145
+ "aria-expanded": isOpen,
146
+ leadingIcon: {
147
+ type: 'icon',
148
+ icon: Menu01
149
+ },
150
+ onClick: () => setIsOpen(prev => !prev)
151
+ }), _jsx("div", {
152
+ ref: barRef,
153
+ className: classNames('ncua-context-tab__bar', {
154
+ 'is-fill': isFill
155
+ }),
156
+ role: "tablist",
157
+ style: {
158
+ '--ncua-context-tab-visible': visibleTabsCount
159
+ },
160
+ children: menus.map(item => {
161
+ const isActive = item.id === activeTab;
162
+ return _jsxs("button", {
163
+ type: "button",
164
+ role: "tab",
165
+ "aria-selected": isActive,
166
+ "aria-disabled": item.disabled || undefined,
167
+ "aria-label": getAccessibleLabel(item),
168
+ disabled: item.disabled,
169
+ title: item.label,
170
+ className: classNames('ncua-context-tab__tab', {
171
+ 'is-active': isActive,
172
+ 'is-disabled': item.disabled
173
+ }),
174
+ onClick: () => handleSelect(item),
175
+ children: [_jsx("span", {
176
+ className: "ncua-context-tab__tab-label",
177
+ children: item.label
178
+ }), renderItemBadges(item)]
179
+ }, item.id);
180
+ })
181
+ }), _jsxs("div", {
182
+ className: "ncua-context-tab__nav",
183
+ children: [_jsx(Button, {
184
+ onlyIcon: true,
185
+ hierarchy: "tertiary-gray",
186
+ size: CONTROL_BUTTON_SIZE,
187
+ className: "ncua-context-tab__control ncua-context-tab__control--prev",
188
+ label: "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
189
+ "aria-label": "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
190
+ disabled: isBeginning,
191
+ leadingIcon: {
192
+ type: 'icon',
193
+ icon: ChevronLeft
194
+ },
195
+ onClick: () => scrollByPage(-1)
196
+ }), _jsx(Button, {
197
+ onlyIcon: true,
198
+ hierarchy: "tertiary-gray",
199
+ size: CONTROL_BUTTON_SIZE,
200
+ className: "ncua-context-tab__control ncua-context-tab__control--next",
201
+ label: "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
202
+ "aria-label": "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
203
+ disabled: isEnd,
204
+ leadingIcon: {
205
+ type: 'icon',
206
+ icon: ChevronRight
207
+ },
208
+ onClick: () => scrollByPage(1)
209
+ })]
210
+ }), isOpen && _jsxs("div", {
211
+ ref: panelRef,
212
+ className: "ncua-context-tab__panel",
213
+ role: "menu",
214
+ children: [_jsx("ul", {
215
+ className: "ncua-context-tab__panel-column",
216
+ role: "none",
217
+ children: leftColumn.map(renderOption)
218
+ }), _jsx("ul", {
219
+ className: "ncua-context-tab__panel-column",
220
+ role: "none",
221
+ children: rightColumn.map(renderOption)
222
+ })]
223
+ })]
224
+ });
225
+ };
226
+ export { ContextTab };
@@ -0,0 +1 @@
1
+ export * from './ContextTab';
@@ -0,0 +1,77 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ const EDGE_THRESHOLD = 1; // 스크롤 끝 판정 시 소수점 오차 보정용 여유값(px)
3
+ /**
4
+ * ContextTab Tab Bar의 네이티브 가로 스크롤·페이징 메커니즘을 담당하는 훅.
5
+ *
6
+ * - `barRef`: 스크롤 컨테이너(ref)
7
+ * - `isBeginning` / `isEnd`: 좌/우 네비 버튼의 disabled 판정
8
+ * - `scrollByPage`: `<` `>` 클릭 시 한 페이지(보이는 폭)만큼 이동 — scroll-snap이 탭 경계에 맞춰 정렬
9
+ * - `activeTab` 변경 시 해당 탭이 보이는 페이지로 자동 스크롤
10
+ */
11
+ export const useContextTabScroll = _ref => {
12
+ let {
13
+ activeTab,
14
+ menus,
15
+ visibleTabsCount
16
+ } = _ref;
17
+ const barRef = useRef(null);
18
+ const [isBeginning, setIsBeginning] = useState(true);
19
+ const [isEnd, setIsEnd] = useState(true);
20
+ // effect가 menus 식별자에 묶이지 않도록 최신 menus를 ref로 보관한다.
21
+ const menusRef = useRef(menus);
22
+ menusRef.current = menus;
23
+ // Tab Bar의 스크롤 위치로 좌/우 네비 버튼의 활성 여부를 계산한다.
24
+ const updateEdges = useCallback(() => {
25
+ const bar = barRef.current;
26
+ if (!bar) return;
27
+ setIsBeginning(bar.scrollLeft <= EDGE_THRESHOLD);
28
+ setIsEnd(bar.scrollLeft + bar.clientWidth >= bar.scrollWidth - EDGE_THRESHOLD);
29
+ }, []);
30
+ // `<` `>` 클릭 시 보이는 폭(한 페이지)만큼 스크롤한다. scroll-snap이 탭 경계에 맞춰 정렬한다.
31
+ const scrollByPage = direction => {
32
+ const bar = barRef.current;
33
+ if (!bar) return;
34
+ bar.scrollBy({
35
+ left: direction * bar.clientWidth,
36
+ behavior: 'smooth'
37
+ });
38
+ };
39
+ // 활성 탭이 항상 viewport 안에 보이도록 — activeTab 변경 시에만 해당 페이지로 이동 (DES-SPEC-030-1 §1.6).
40
+ // menus를 deps에 넣지 않아(ref로 최신값 참조) 사용자가 다른 페이지를 보는 중 부모 리렌더로 페이지가 되돌아가는 것을 막는다.
41
+ useEffect(() => {
42
+ const bar = barRef.current;
43
+ if (!bar || activeTab == null) return;
44
+ const activeIndex = menusRef.current.findIndex(menu => menu.id === activeTab);
45
+ if (activeIndex < 0) return;
46
+ const pageStart = Math.floor(activeIndex / visibleTabsCount) * visibleTabsCount;
47
+ const target = bar.children[pageStart];
48
+ if (target) bar.scrollTo({
49
+ left: target.offsetLeft,
50
+ behavior: 'smooth'
51
+ });
52
+ }, [activeTab, visibleTabsCount]);
53
+ // 스크롤·리사이즈·컨텍스트 수 변경 시 좌/우 네비 활성 여부를 다시 계산한다.
54
+ // menus.length·visibleTabsCount 변경 시 scrollWidth가 바뀌지만 ResizeObserver는 clientWidth만 감지하므로,
55
+ // 두 값을 재실행 트리거로 deps에 둔다(본문에서 직접 읽지 않음).
56
+ // biome-ignore lint/correctness/useExhaustiveDependencies: 위 값들은 에지 재계산을 위한 의도적 트리거
57
+ useEffect(() => {
58
+ const bar = barRef.current;
59
+ if (!bar) return;
60
+ updateEdges();
61
+ bar.addEventListener('scroll', updateEdges, {
62
+ passive: true
63
+ });
64
+ const observer = new ResizeObserver(updateEdges);
65
+ observer.observe(bar);
66
+ return () => {
67
+ bar.removeEventListener('scroll', updateEdges);
68
+ observer.disconnect();
69
+ };
70
+ }, [updateEdges, menus.length, visibleTabsCount]);
71
+ return {
72
+ barRef,
73
+ isBeginning,
74
+ isEnd,
75
+ scrollByPage
76
+ };
77
+ };
@@ -19,7 +19,7 @@ const ActionBar = ({ children, className, position = 'top', align = 'space-betwe
19
19
  'ncua-data-grid__action-bar--space-between': align === 'space-between',
20
20
  }), ...rest, children: children }));
21
21
  ActionBar.displayName = 'DataGrid.ActionBar';
22
- const DataGridTable = forwardRef(({ children, className, type = 'horizontal', fixedHeader, maxHeight, hoverable, selectable, horizontalScroll, minWidth, }, ref) => (_jsx("div", { ref: ref, className: classNames('ncua-data-grid__table', className), children: _jsx(NcuaTable, { className: "ncua-table--in-data-grid", type: type, fixedHeader: fixedHeader, maxHeight: maxHeight, hoverable: hoverable, selectable: selectable, horizontalScroll: horizontalScroll, minWidth: minWidth, children: children }) })));
22
+ const DataGridTable = forwardRef(({ children, className, type = 'horizontal', fixedHeader, maxHeight, hoverable, selectable, horizontalScroll, minWidth, draggable, }, ref) => (_jsx("div", { ref: ref, className: classNames('ncua-data-grid__table', className), children: _jsx(NcuaTable, { className: "ncua-table--in-data-grid", type: type, fixedHeader: fixedHeader, maxHeight: maxHeight, hoverable: hoverable, selectable: selectable, horizontalScroll: horizontalScroll, minWidth: minWidth, draggable: draggable, children: children }) })));
23
23
  DataGridTable.displayName = 'DataGrid.Table';
24
24
  const Pagination = ({ children, className, ...rest }) => (_jsx("div", { className: classNames('ncua-data-grid__pagination', className), ...rest, children: children }));
25
25
  Pagination.displayName = 'DataGrid.Pagination';
@@ -36,6 +36,11 @@ export type DataGridTableProps = {
36
36
  horizontalScroll?: boolean;
37
37
  /** 가로 스크롤 트리거 임계 너비. horizontalScroll=true 일 때만 의미가 있다. */
38
38
  minWidth?: string | number;
39
+ /**
40
+ * 행 드래그앤드롭 활성화. Table.Row에 dragId, Table.Body에 onRowDrop,
41
+ * 헤더에 Table.DragHeaderCell, 각 행에 Table.DragCell을 함께 사용한다.
42
+ */
43
+ draggable?: boolean;
39
44
  };
40
45
  export type DataGridPaginationProps = ComponentProps<'div'> & {
41
46
  children: ReactNode;
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
- import type { SortDirection, TableBodyProps, TableColGroupProps, TableEmptyProps, TableFooterProps, TableHeaderProps, TablePaginationProps } from './types';
3
- export declare const Table: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & {
2
+ import type { SortDirection, TableBodyProps, TableColGroupProps, TableDragCellProps, TableDragHeaderCellProps, TableEmptyProps, TableFooterProps, TableHeaderProps, TablePaginationProps } from './types';
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;
6
6
  maxHeight?: string | number | undefined;
@@ -8,6 +8,7 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
8
8
  selectable?: boolean | undefined;
9
9
  horizontalScroll?: boolean | undefined;
10
10
  minWidth?: string | number | undefined;
11
+ draggable?: boolean | undefined;
11
12
  children: ReactNode;
12
13
  } & import("react").RefAttributes<HTMLDivElement>> & {
13
14
  Header: {
@@ -15,13 +16,22 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
15
16
  displayName: string;
16
17
  };
17
18
  Body: {
18
- ({ children, className }: TableBodyProps): import("react/jsx-runtime").JSX.Element;
19
+ ({ children, className, onRowDrop }: TableBodyProps): import("react/jsx-runtime").JSX.Element;
19
20
  displayName: string;
20
21
  };
21
22
  Row: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLTableRowElement>, HTMLTableRowElement>, "ref"> & {
22
23
  selected?: boolean | undefined;
23
24
  status?: import("./types").RowStatus | undefined;
25
+ dragId?: string | undefined;
24
26
  } & import("react").RefAttributes<HTMLTableRowElement>>;
27
+ DragHeaderCell: {
28
+ ({ className, children }: TableDragHeaderCellProps): import("react/jsx-runtime").JSX.Element;
29
+ displayName: string;
30
+ };
31
+ DragCell: {
32
+ ({ disabled, className, children }: TableDragCellProps): import("react/jsx-runtime").JSX.Element;
33
+ displayName: string;
34
+ };
25
35
  HeaderCell: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").ThHTMLAttributes<HTMLTableHeaderCellElement>, HTMLTableHeaderCellElement>, "ref"> & {
26
36
  sortDirection?: SortDirection | undefined;
27
37
  onSort?: (() => void) | undefined;
@@ -48,4 +58,4 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
48
58
  displayName: string;
49
59
  };
50
60
  };
51
- export type { RowStatus, SortDirection, TableBodyProps, TableCellProps, TableColGroupProps, TableEmptyProps, TableFooterProps, TableHeaderCellProps, TableHeaderProps, TablePaginationProps, TableProps, TableRowProps, TableType, } from './types';
61
+ export type { RowStatus, SortDirection, TableBodyProps, TableCellProps, TableColGroupProps, TableDragCellProps, TableDragHeaderCellProps, TableEmptyProps, TableFooterProps, TableHeaderCellProps, TableHeaderProps, TablePaginationProps, TableProps, TableRowProps, TableType, } from './types';
@@ -1,8 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ChevronDown, ChevronSelectorVertical, ChevronUp } from '@ncds/ui-admin-icon';
2
+ import { dropTargetForElements, draggable as makeDraggable, monitorForElements, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
3
+ import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview';
4
+ import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
5
+ import { attachClosestEdge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
6
+ import { ChevronDown, ChevronSelectorVertical, ChevronUp, DotsGrid02 } from '@ncds/ui-admin-icon';
3
7
  import classNames from 'classnames';
4
- import { Children, forwardRef, useRef, } from 'react';
8
+ import { Children, forwardRef, useCallback, useContext, useEffect, useRef, useState, } from 'react';
5
9
  import { FloatingProvider } from '../../../contexts/FloatingContext';
10
+ import { RowDndContext, TableDndContext } from './dnd-context';
11
+ import { buildDragPreview, PREVIEW_BADGE_SIZE, TABLE_DND_ID_RADIX, TABLE_DND_ID_SLICE_END, TABLE_DND_ID_SLICE_START, } from './dnd-preview';
6
12
  import { TABLE_HEADER_HEIGHT, useTableHorizontalScrollbar, useTableVerticalScrollbar } from './useTableScrollbars';
7
13
  // 가로 스크롤 디자인 기준 폭 — 14인치 모니터 + LNB 고려한 디자인 권장 너비
8
14
  const DEFAULT_HORIZONTAL_SCROLL_MIN_WIDTH = 1140;
@@ -29,14 +35,145 @@ const ARIA_SORT_MAP = {
29
35
  // ──────────────────────────────────────────────
30
36
  const Header = ({ children, className }) => (_jsx("thead", { className: classNames('ncua-table__header', className), children: children }));
31
37
  Header.displayName = 'Table.Header';
32
- const Body = ({ children, className }) => (_jsx("tbody", { className: classNames('ncua-table__body', className), children: children }));
38
+ const Body = ({ children, className, onRowDrop }) => {
39
+ const { tableId } = useContext(TableDndContext);
40
+ // onRowDrop 참조를 ref로 유지 — 콜백이 매 렌더마다 재생성되어도 effect가 재구독하지 않도록
41
+ const onRowDropRef = useRef(onRowDrop);
42
+ onRowDropRef.current = onRowDrop;
43
+ useEffect(() => {
44
+ return monitorForElements({
45
+ canMonitor: ({ source }) => source.data.tableId === tableId,
46
+ onDrop: ({ source, location }) => {
47
+ if (!onRowDropRef.current)
48
+ return;
49
+ const target = location.current.dropTargets[0];
50
+ if (!target)
51
+ return;
52
+ const fromId = source.data.id;
53
+ const toId = target.data.id;
54
+ if (typeof fromId !== 'string' || typeof toId !== 'string' || fromId === toId)
55
+ return;
56
+ const edge = extractClosestEdge(target.data);
57
+ onRowDropRef.current(fromId, toId, edge === 'bottom' ? 'bottom' : 'top');
58
+ },
59
+ });
60
+ }, [tableId]);
61
+ return _jsx("tbody", { className: classNames('ncua-table__body', className), children: children });
62
+ };
33
63
  Body.displayName = 'Table.Body';
34
- const Row = forwardRef(({ children, className, selected, status, ...rest }, ref) => (_jsx("tr", { ref: ref, className: classNames('ncua-table__row', className, {
35
- 'ncua-table__row--selected': selected,
36
- 'ncua-table__row--warning': status === 'warning',
37
- 'ncua-table__row--error': status === 'error',
38
- }), ...rest, children: children })));
64
+ const Row = forwardRef(({ children, className, selected, status, dragId, ...rest }, forwardedRef) => {
65
+ const { isDraggable, tableId } = useContext(TableDndContext);
66
+ const internalRef = useRef(null);
67
+ const [closestEdge, setClosestEdge] = useState(null);
68
+ const [isDragging, setIsDragging] = useState(false);
69
+ const setRef = useCallback((el) => {
70
+ internalRef.current = el;
71
+ if (typeof forwardedRef === 'function') {
72
+ forwardedRef(el);
73
+ }
74
+ else if (forwardedRef) {
75
+ forwardedRef.current = el;
76
+ }
77
+ }, [forwardedRef]);
78
+ useEffect(() => {
79
+ if (!isDraggable || !dragId || !internalRef.current)
80
+ return;
81
+ return dropTargetForElements({
82
+ element: internalRef.current,
83
+ getData: ({ input, element }) => attachClosestEdge({ id: dragId, tableId }, { input, element, allowedEdges: ['top', 'bottom'] }),
84
+ onDragEnter: ({ self, source }) => {
85
+ if (source.data.id === dragId)
86
+ return;
87
+ const edge = extractClosestEdge(self.data);
88
+ setClosestEdge(edge === 'bottom' && internalRef.current?.nextElementSibling ? null : edge);
89
+ },
90
+ onDrag: ({ self, source }) => {
91
+ if (source.data.id === dragId)
92
+ return;
93
+ const edge = extractClosestEdge(self.data);
94
+ setClosestEdge(edge === 'bottom' && internalRef.current?.nextElementSibling ? null : edge);
95
+ },
96
+ onDragLeave: () => setClosestEdge(null),
97
+ onDrop: () => setClosestEdge(null),
98
+ });
99
+ }, [isDraggable, dragId, tableId]);
100
+ return (_jsx(RowDndContext.Provider, { value: { dragId, setIsDragging }, children: _jsx("tr", { ref: setRef, "data-draggable-id": dragId, className: classNames('ncua-table__row', className, {
101
+ 'ncua-table__row--selected': selected,
102
+ 'ncua-table__row--warning': status === 'warning',
103
+ 'ncua-table__row--error': status === 'error',
104
+ 'ncua-table__row--drag-over-top': closestEdge === 'top',
105
+ 'ncua-table__row--drag-over-bottom': closestEdge === 'bottom',
106
+ 'is-dragging': isDragging,
107
+ }), ...rest, children: children }) }));
108
+ });
39
109
  Row.displayName = 'Table.Row';
110
+ 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
+ DragHeaderCell.displayName = 'Table.DragHeaderCell';
112
+ const DragCell = ({ disabled, className, children }) => {
113
+ const { isDraggable, tableId } = useContext(TableDndContext);
114
+ const { dragId, setIsDragging } = useContext(RowDndContext);
115
+ const handleRef = useRef(null);
116
+ useEffect(() => {
117
+ if (!isDraggable || !dragId || !handleRef.current || disabled)
118
+ return;
119
+ let overlay = null;
120
+ let removePointerMove = null;
121
+ let pendingPreview = null;
122
+ return makeDraggable({
123
+ element: handleRef.current,
124
+ getInitialData: () => ({ id: dragId, tableId }),
125
+ onGenerateDragPreview: ({ nativeSetDragImage, location }) => {
126
+ if (!handleRef.current)
127
+ return;
128
+ const rowEl = handleRef.current.closest('tr');
129
+ if (!rowEl)
130
+ return;
131
+ disableNativeDragPreview({ nativeSetDragImage });
132
+ const rowRect = rowEl.getBoundingClientRect();
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
+ };
145
+ },
146
+ onDragStart: ({ location }) => {
147
+ setIsDragging(true);
148
+ if (!pendingPreview)
149
+ return;
150
+ const { rows, rowRect, offsetX, offsetY } = pendingPreview;
151
+ overlay = buildDragPreview(rows, rowRect);
152
+ overlay.style.left = `${Math.round(location.initial.input.clientX) - offsetX}px`;
153
+ overlay.style.top = `${Math.round(location.initial.input.clientY) - offsetY}px`;
154
+ document.body.appendChild(overlay);
155
+ const onDragOver = (e) => {
156
+ if (!overlay)
157
+ return;
158
+ overlay.style.left = `${e.clientX - offsetX}px`;
159
+ overlay.style.top = `${e.clientY - offsetY}px`;
160
+ };
161
+ document.addEventListener('dragover', onDragOver);
162
+ removePointerMove = () => document.removeEventListener('dragover', onDragOver);
163
+ },
164
+ onDrop: () => {
165
+ setIsDragging(false);
166
+ removePointerMove?.();
167
+ removePointerMove = null;
168
+ overlay?.remove();
169
+ overlay = null;
170
+ pendingPreview = null;
171
+ },
172
+ });
173
+ }, [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] }) }));
175
+ };
176
+ DragCell.displayName = 'Table.DragCell';
40
177
  const HeaderCell = forwardRef(({ children, className, sortDirection, onSort, width, minWidth, style, ...rest }, ref) => {
41
178
  const isSortable = sortDirection !== undefined && onSort !== undefined;
42
179
  const SortIcon = isSortable ? SORT_ICONS[sortDirection] : undefined;
@@ -64,9 +201,14 @@ const ColGroup = ({ widths, minWidths }) => {
64
201
  return `${width}px`;
65
202
  return width;
66
203
  };
67
- return (_jsx("colgroup", { children: widths.map((width, index) => (
68
- // biome-ignore lint/suspicious/noArrayIndexKey: colgroup columns never reorder or change
69
- _jsx("col", { style: { width: resolveColWidth(width), minWidth: resolveColWidth(minWidths?.[index]) } }, index))) }));
204
+ return (_jsx("colgroup", { children: widths.map((width, index) => {
205
+ const colStyle = {
206
+ width: resolveColWidth(width),
207
+ minWidth: resolveColWidth(minWidths?.[index]),
208
+ };
209
+ // biome-ignore lint/suspicious/noArrayIndexKey: colgroup columns never reorder or change
210
+ return _jsx("col", { style: colStyle }, index);
211
+ }) }));
70
212
  };
71
213
  ColGroup.displayName = 'Table.ColGroup';
72
214
  const Empty = ({ colSpan, children }) => (_jsx("tr", { children: _jsx("td", { colSpan: colSpan, className: "ncua-table__empty", role: "status", "aria-live": "polite", children: children || '등록된 게시물이 없습니다.' }) }));
@@ -108,13 +250,17 @@ const sortChildren = (children) => {
108
250
  // ──────────────────────────────────────────────
109
251
  // Main Table component
110
252
  // ──────────────────────────────────────────────
111
- const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, maxHeight, hoverable = true, selectable = false, horizontalScroll = false, minWidth, children, className, ...rest }, ref) => {
253
+ const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, maxHeight, hoverable = true, selectable = false, horizontalScroll = false, minWidth, draggable = false, children, className, ...rest }, ref) => {
254
+ const tableIdRef = useRef(`ncua-table-${Math.random().toString(TABLE_DND_ID_RADIX).slice(TABLE_DND_ID_SLICE_START, TABLE_DND_ID_SLICE_END)}`);
255
+ const tableId = tableIdRef.current;
256
+ const effectiveDraggable = draggable && type === 'horizontal';
112
257
  const tableClasses = classNames('ncua-table', className, {
113
258
  'ncua-table--horizontal': type === 'horizontal',
114
259
  'ncua-table--vertical': type === 'vertical',
115
260
  'ncua-table--fixed-header': fixedHeader,
116
261
  'ncua-table--hoverable': hoverable && type === 'horizontal',
117
262
  'ncua-table--selectable': selectable,
263
+ 'ncua-table--draggable': effectiveDraggable,
118
264
  });
119
265
  const { headerContent, colGroupContent, tableContent, footerContent, paginationContent } = sortChildren(children);
120
266
  const scrollStyle = fixedHeader && maxHeight
@@ -148,12 +294,22 @@ const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, m
148
294
  hScrollbarRef,
149
295
  hThumbRef,
150
296
  });
297
+ // fixedHeader 모드에서 드래그 시 자동 스크롤
298
+ useEffect(() => {
299
+ if (!effectiveDraggable || !fixedScrollEnabled || !scrollContainerRef.current)
300
+ return;
301
+ return autoScrollForElements({ element: scrollContainerRef.current });
302
+ }, [effectiveDraggable, fixedScrollEnabled]);
151
303
  // <colgroup> + <thead> + <tbody> 묶음 — fixed-header 분기와 horizontalScroll 분기 모두에서 재사용
152
304
  const renderTable = () => (_jsxs("table", { className: "ncua-table__table", role: "table", children: [colGroupContent, headerContent, tableContent] }));
153
305
  // fixed-header 시 scroll-area + scrollbar 래핑, 아니면 <table> 그대로.
154
306
  // withScrollbar=false 이면 scrollbar를 제외 — horizontalScroll 분기에서 scrollbar를
155
307
  // h-scroll-container 형제 위치에 별도 렌더해 가로 스크롤 시 viewport 우측에 자연 고정.
156
308
  const renderScrollableArea = (includeVerticalScrollbar = true) => fixedScrollEnabled ? (_jsxs("div", { ref: scrollAreaRef, className: "ncua-table__scroll-area", children: [_jsx("div", { ref: scrollContainerRef, className: "ncua-table__scroll-container", style: scrollStyle, children: renderTable() }), includeVerticalScrollbar && (_jsx("div", { ref: scrollbarRef, className: "ncua-table__scrollbar", "aria-hidden": "true", children: _jsx("div", { ref: thumbRef, className: "ncua-table__scrollbar-thumb", onMouseDown: handleThumbMouseDown }) }))] })) : (renderTable());
309
+ const dndContextValue = {
310
+ isDraggable: effectiveDraggable,
311
+ tableId,
312
+ };
157
313
  // horizontalScroll=true 시 외곽 wrapper + FloatingProvider 부착.
158
314
  // 핵심 — __h-scroll-container 는 <table>(또는 scroll-area) 만 감싸고, footer/pagination 은
159
315
  // 그 바깥에서 항상 고정 위치. 세로 스크롤바는 h-scroll-container 형제로 배치되어
@@ -165,9 +321,9 @@ const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, m
165
321
  const innerStyle = {
166
322
  '--ncua-table-min-width': typeof resolvedMinWidth === 'number' ? `${resolvedMinWidth}px` : resolvedMinWidth,
167
323
  };
168
- return (_jsx(FloatingProvider, { value: FLOATING_PORTAL_VALUE, children: _jsxs("div", { ref: ref, className: "ncua-table-wrapper", style: WRAPPER_STYLE, children: [_jsxs("div", { className: tableClasses, ...rest, children: [_jsxs("div", { ref: hScrollContainerRef, className: "ncua-table__h-scroll-container", children: [_jsx("div", { className: "ncua-table__h-scroll-inner", style: innerStyle, children: renderScrollableArea(false) }), _jsx("div", { ref: hScrollbarRef, className: "ncua-table__h-scrollbar", "aria-hidden": "true", children: _jsx("div", { ref: hThumbRef, className: "ncua-table__h-scrollbar-thumb", onMouseDown: handleHThumbMouseDown }) })] }), fixedScrollEnabled && (_jsx("div", { ref: scrollbarRef, className: "ncua-table__scrollbar", "aria-hidden": "true", children: _jsx("div", { ref: thumbRef, className: "ncua-table__scrollbar-thumb", onMouseDown: handleThumbMouseDown }) })), footerContent] }), paginationContent] }) }));
324
+ return (_jsx(TableDndContext.Provider, { value: dndContextValue, children: _jsx(FloatingProvider, { value: FLOATING_PORTAL_VALUE, children: _jsxs("div", { ref: ref, className: "ncua-table-wrapper", style: WRAPPER_STYLE, children: [_jsxs("div", { className: tableClasses, ...rest, children: [_jsxs("div", { ref: hScrollContainerRef, className: "ncua-table__h-scroll-container", children: [_jsx("div", { className: "ncua-table__h-scroll-inner", style: innerStyle, children: renderScrollableArea(false) }), _jsx("div", { ref: hScrollbarRef, className: "ncua-table__h-scrollbar", "aria-hidden": "true", children: _jsx("div", { ref: hThumbRef, className: "ncua-table__h-scrollbar-thumb", onMouseDown: handleHThumbMouseDown }) })] }), fixedScrollEnabled && (_jsx("div", { ref: scrollbarRef, className: "ncua-table__scrollbar", "aria-hidden": "true", children: _jsx("div", { ref: thumbRef, className: "ncua-table__scrollbar-thumb", onMouseDown: handleThumbMouseDown }) })), footerContent] }), paginationContent] }) }) }));
169
325
  }
170
- return (_jsxs("div", { ref: ref, className: "ncua-table-wrapper", style: WRAPPER_STYLE, children: [_jsxs("div", { className: tableClasses, ...rest, children: [renderScrollableArea(), footerContent] }), paginationContent] }));
326
+ return (_jsx(TableDndContext.Provider, { value: dndContextValue, children: _jsxs("div", { ref: ref, className: "ncua-table-wrapper", style: WRAPPER_STYLE, children: [_jsxs("div", { className: tableClasses, ...rest, children: [renderScrollableArea(), footerContent] }), paginationContent] }) }));
171
327
  });
172
328
  TableComponent.displayName = 'Table';
173
329
  // ──────────────────────────────────────────────
@@ -177,6 +333,8 @@ export const Table = Object.assign(TableComponent, {
177
333
  Header,
178
334
  Body,
179
335
  Row,
336
+ DragHeaderCell,
337
+ DragCell,
180
338
  HeaderCell,
181
339
  Cell,
182
340
  Footer,
@@ -0,0 +1,12 @@
1
+ type TableDndContextValue = {
2
+ isDraggable: boolean;
3
+ tableId: string;
4
+ };
5
+ declare const TableDndContext: import("react").Context<TableDndContextValue>;
6
+ type RowDndContextValue = {
7
+ dragId: string | undefined;
8
+ setIsDragging: (v: boolean) => void;
9
+ };
10
+ declare const RowDndContext: import("react").Context<RowDndContextValue>;
11
+ export type { TableDndContextValue, RowDndContextValue };
12
+ export { TableDndContext, RowDndContext };
@@ -0,0 +1,10 @@
1
+ import { createContext } from 'react';
2
+ const TableDndContext = createContext({
3
+ isDraggable: false,
4
+ tableId: '',
5
+ });
6
+ const RowDndContext = createContext({
7
+ dragId: undefined,
8
+ setIsDragging: (_v) => undefined,
9
+ });
10
+ export { TableDndContext, RowDndContext };