@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,6 @@
1
+ declare const TABLE_DND_ID_RADIX = 36;
2
+ declare const TABLE_DND_ID_SLICE_START = 2;
3
+ declare const TABLE_DND_ID_SLICE_END = 9;
4
+ declare const PREVIEW_BADGE_SIZE = 24;
5
+ declare const buildDragPreview: (rows: HTMLElement[], rowRect: DOMRect) => HTMLDivElement;
6
+ export { TABLE_DND_ID_RADIX, TABLE_DND_ID_SLICE_START, TABLE_DND_ID_SLICE_END, PREVIEW_BADGE_SIZE, buildDragPreview };
@@ -0,0 +1,120 @@
1
+ const TABLE_DND_ID_RADIX = 36;
2
+ const TABLE_DND_ID_SLICE_START = 2;
3
+ const TABLE_DND_ID_SLICE_END = 9;
4
+ const PREVIEW_MAX_ROWS = 3;
5
+ const PREVIEW_STACK_OFFSET = 5;
6
+ const PREVIEW_MAX_STACK_OFFSET = PREVIEW_STACK_OFFSET * PREVIEW_MAX_ROWS;
7
+ const PREVIEW_BADGE_SIZE = 24;
8
+ const PREVIEW_BADGE_HALF = PREVIEW_BADGE_SIZE / 2;
9
+ // hover의 color-mix 반투명 배경을 우회해 불투명 RGB 배경색을 클래스 기반으로 직접 결정
10
+ const getRowBackground = (rowEl) => {
11
+ const resolveCssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
12
+ const isSelected = rowEl.classList.contains('ncua-table__row--selected');
13
+ const isError = rowEl.classList.contains('ncua-table__row--error');
14
+ const isWarning = rowEl.classList.contains('ncua-table__row--warning');
15
+ if (isSelected && isError)
16
+ return resolveCssVar('--primary-red-50');
17
+ if (isSelected && isWarning)
18
+ return resolveCssVar('--orange-50');
19
+ if (isSelected)
20
+ return resolveCssVar('--gray-50');
21
+ return resolveCssVar('--base-white');
22
+ };
23
+ // cloneNode는 HTML attribute만 복사하므로 checkbox.checked를 직접 동기화
24
+ const cloneRowWithState = (sourceRow) => {
25
+ const clone = sourceRow.cloneNode(true);
26
+ const srcInputs = sourceRow.querySelectorAll('input[type="checkbox"]');
27
+ const clnInputs = clone.querySelectorAll('input[type="checkbox"]');
28
+ srcInputs.forEach((src, i) => {
29
+ const cln = clnInputs[i];
30
+ if (cln)
31
+ cln.checked = src.checked;
32
+ });
33
+ clone.style.backgroundColor = getRowBackground(sourceRow);
34
+ // 열 너비는 테이블 레이아웃 컨텍스트에서 결정되므로 직접 복사 — ceil로 올림해 프리뷰 셀이 실제보다 좁아지지 않게 함
35
+ const srcCells = Array.from(sourceRow.querySelectorAll('td, th'));
36
+ const clnCells = Array.from(clone.querySelectorAll('td, th'));
37
+ srcCells.forEach((src, i) => {
38
+ const cln = clnCells[i];
39
+ if (!cln)
40
+ return;
41
+ const w = `${Math.ceil(src.getBoundingClientRect().width)}px`;
42
+ cln.style.width = w;
43
+ cln.style.minWidth = w;
44
+ cln.style.maxWidth = w;
45
+ });
46
+ return clone;
47
+ };
48
+ const buildCountBadge = (count) => {
49
+ const badge = document.createElement('div');
50
+ badge.className = 'ncua-table-dnd-preview__badge';
51
+ badge.textContent = String(count);
52
+ return badge;
53
+ };
54
+ const buildSummaryCard = (rowRect) => {
55
+ const wrapper = document.createElement('div');
56
+ wrapper.className = 'ncua-table-dnd-preview__summary';
57
+ Object.assign(wrapper.style, {
58
+ top: `${Math.round(PREVIEW_BADGE_HALF + PREVIEW_MAX_STACK_OFFSET)}px`,
59
+ left: `${Math.round(PREVIEW_BADGE_HALF + PREVIEW_MAX_STACK_OFFSET)}px`,
60
+ width: `${Math.round(rowRect.width) - PREVIEW_MAX_STACK_OFFSET}px`,
61
+ height: `${Math.round(rowRect.height)}px`,
62
+ });
63
+ return wrapper;
64
+ };
65
+ const buildRowCard = ({ row, cardLeft, cardTop, cardWidth, rowHeight, zIndex, parentTable, }) => {
66
+ const wrapper = document.createElement('div');
67
+ wrapper.className = 'ncua-table-dnd-preview__card';
68
+ Object.assign(wrapper.style, {
69
+ zIndex: String(zIndex),
70
+ top: `${cardTop}px`,
71
+ left: `${cardLeft}px`,
72
+ width: `${cardWidth}px`,
73
+ height: `${rowHeight}px`,
74
+ backgroundColor: getRowBackground(row),
75
+ });
76
+ const table = document.createElement('table');
77
+ if (parentTable)
78
+ table.className = parentTable.className;
79
+ table.style.width = `${cardWidth}px`;
80
+ const tbody = document.createElement('tbody');
81
+ tbody.className = 'ncua-table__body';
82
+ tbody.appendChild(cloneRowWithState(row));
83
+ table.appendChild(tbody);
84
+ wrapper.appendChild(table);
85
+ return wrapper;
86
+ };
87
+ const buildDragPreview = (rows, rowRect) => {
88
+ const visibleCount = Math.min(rows.length, PREVIEW_MAX_ROWS);
89
+ const hasSummary = rows.length > visibleCount;
90
+ const totalCards = visibleCount + (hasSummary ? 1 : 0);
91
+ const isMulti = rows.length > 1;
92
+ const parentTable = rows[0]?.closest('table') ?? null;
93
+ const rowWidth = Math.round(rowRect.width);
94
+ const rowHeight = Math.round(rowRect.height);
95
+ const container = document.createElement('div');
96
+ container.className = 'ncua-table-dnd-preview';
97
+ Object.assign(container.style, {
98
+ width: `${PREVIEW_BADGE_HALF + rowWidth}px`,
99
+ height: `${PREVIEW_BADGE_HALF + rowHeight + PREVIEW_STACK_OFFSET * (totalCards - 1)}px`,
100
+ });
101
+ if (isMulti)
102
+ container.appendChild(buildCountBadge(rows.length));
103
+ if (hasSummary)
104
+ container.appendChild(buildSummaryCard(rowRect));
105
+ // 뒤쪽 행 카드부터 렌더해서 앞쪽이 위로 올라오게
106
+ for (let i = visibleCount - 1; i >= 0; i--) {
107
+ const stackShift = isMulti ? i * PREVIEW_STACK_OFFSET : 0;
108
+ container.appendChild(buildRowCard({
109
+ row: rows[i],
110
+ cardLeft: Math.round(PREVIEW_BADGE_HALF + stackShift),
111
+ cardTop: Math.round(PREVIEW_BADGE_HALF + stackShift),
112
+ cardWidth: rowWidth - stackShift,
113
+ rowHeight,
114
+ zIndex: visibleCount - i,
115
+ parentTable,
116
+ }));
117
+ }
118
+ return container;
119
+ };
120
+ export { TABLE_DND_ID_RADIX, TABLE_DND_ID_SLICE_START, TABLE_DND_ID_SLICE_END, PREVIEW_BADGE_SIZE, buildDragPreview };
@@ -2,7 +2,7 @@ import type { ComponentProps, ReactNode } from 'react';
2
2
  export type TableType = 'horizontal' | 'vertical';
3
3
  export type SortDirection = 'asc' | 'desc' | 'none';
4
4
  export type RowStatus = 'warning' | 'error';
5
- export type TableProps = Omit<ComponentProps<'div'>, 'ref'> & {
5
+ export type TableProps = Omit<ComponentProps<'div'>, 'ref' | 'draggable'> & {
6
6
  type?: TableType;
7
7
  fixedHeader?: boolean;
8
8
  maxHeight?: string | number;
@@ -19,6 +19,11 @@ export type TableProps = Omit<ComponentProps<'div'>, 'ref'> & {
19
19
  * 기본값은 1140 (14인치 모니터 + LNB 기준 디자인 권장 너비).
20
20
  */
21
21
  minWidth?: string | number;
22
+ /**
23
+ * 행 드래그앤드롭 활성화. Table.Row에 dragId, Table.Body에 onRowDrop,
24
+ * 헤더에 Table.DragHeaderCell, 각 행에 Table.DragCell을 함께 사용한다.
25
+ */
26
+ draggable?: boolean;
22
27
  children: ReactNode;
23
28
  };
24
29
  export type TableHeaderProps = {
@@ -28,10 +33,28 @@ export type TableHeaderProps = {
28
33
  export type TableBodyProps = {
29
34
  children: ReactNode;
30
35
  className?: string;
36
+ /**
37
+ * draggable=true 테이블에서 행이 재정렬될 때 호출된다.
38
+ * fromId → toId 로 이동, edge는 toId 행의 위(top)/아래(bottom) 삽입 위치.
39
+ */
40
+ onRowDrop?: (fromId: string, toId: string, edge: 'top' | 'bottom') => void;
31
41
  };
32
42
  export type TableRowProps = Omit<ComponentProps<'tr'>, 'ref'> & {
33
43
  selected?: boolean;
34
44
  status?: RowStatus;
45
+ /** 드래그앤드롭 식별자. draggable 테이블에서 필수. */
46
+ dragId?: string;
47
+ };
48
+ export type TableDragCellProps = {
49
+ disabled?: boolean;
50
+ className?: string;
51
+ /** 드래그 셀에 함께 배치할 콘텐츠 (예: CheckboxInput). 아이콘 우측 8px gap으로 배치된다. */
52
+ children?: ReactNode;
53
+ };
54
+ export type TableDragHeaderCellProps = {
55
+ className?: string;
56
+ /** 헤더 드래그 셀에 함께 배치할 콘텐츠 (예: 전체선택 CheckboxInput). 아이콘 우측 8px gap으로 배치된다. */
57
+ children?: ReactNode;
35
58
  };
36
59
  export type TableHeaderCellProps = Omit<ComponentProps<'th'>, 'ref'> & {
37
60
  sortDirection?: SortDirection;
@@ -36,6 +36,7 @@ export * from './layout/block-header';
36
36
  export * from './layout/divider';
37
37
  export * from './layout/page-title';
38
38
  export * from './navigation/bread-crumb';
39
+ export * from './navigation/context-tab';
39
40
  export * from './navigation/horizontal-tab';
40
41
  export * from './navigation/pagination';
41
42
  export * from './navigation/vertical-tab';
@@ -42,6 +42,7 @@ export * from './layout/divider';
42
42
  export * from './layout/page-title';
43
43
  // Navigation
44
44
  export * from './navigation/bread-crumb';
45
+ export * from './navigation/context-tab';
45
46
  export * from './navigation/horizontal-tab';
46
47
  export * from './navigation/pagination';
47
48
  export * from './navigation/vertical-tab';
@@ -0,0 +1,18 @@
1
+ interface ContextTabItemProps {
2
+ id: string;
3
+ label: string;
4
+ isNew?: boolean;
5
+ /** pill-outline 배지로 표시할 텍스트. 있으면 비신규 항목에도 배지를 노출한다. */
6
+ badgeLabel?: string;
7
+ disabled?: boolean;
8
+ }
9
+ interface ContextTabProps {
10
+ menus?: ContextTabItemProps[];
11
+ activeTab?: string;
12
+ onTabChange?: (id: string) => void;
13
+ visibleTabsCount?: number;
14
+ className?: string;
15
+ }
16
+ declare const ContextTab: ({ menus, activeTab, onTabChange, visibleTabsCount, className, }: ContextTabProps) => import("react/jsx-runtime").JSX.Element | null;
17
+ export { ContextTab };
18
+ export type { ContextTabItemProps, ContextTabProps };
@@ -0,0 +1,103 @@
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)
20
+ return null;
21
+ if (item.isNew)
22
+ return _jsx(Badge, { type: "new-badge", size: "sm" });
23
+ if (item.badgeLabel)
24
+ return _jsx(Badge, { type: "pill-outline", label: item.badgeLabel, size: "xs" });
25
+ return null;
26
+ };
27
+ /**
28
+ * 스크린리더용 접근성 레이블. N 배지는 시각 전용(아이콘)이라 SR이 의미를 못 읽으므로,
29
+ * "신규"를 풀어 aria-label에 합성한다 (DES-SPEC-030-1 §7). 신규가 아니면 undefined를 반환해
30
+ * 버튼의 보이는 텍스트가 그대로 접근성 이름이 되게 한다.
31
+ */
32
+ const getAccessibleLabel = (item) => item.isNew ? `${item.label}, 신규` : undefined;
33
+ const ContextTab = ({ menus = [], activeTab, onTabChange, visibleTabsCount = DEFAULT_VISIBLE_TABS_COUNT, className, }) => {
34
+ const containerRef = useRef(null);
35
+ const triggerRef = useRef(null);
36
+ const panelRef = useRef(null);
37
+ const [isOpen, setIsOpen] = useState(false);
38
+ // Tab Bar의 가로 스크롤·페이징(에지 계산·활성 탭 자동 스크롤)은 훅으로 분리한다.
39
+ const { barRef, isBeginning, isEnd, scrollByPage } = useContextTabScroll({ activeTab, menus, visibleTabsCount });
40
+ // Dropdown panel 닫기 — 외부 클릭 / ESC. ESC는 trigger로 포커스 복원, 외부 클릭은 클릭 위치 유지 (DES-SPEC-030-1 §1.5/§7)
41
+ useEffect(() => {
42
+ if (!isOpen)
43
+ return;
44
+ const handlePointerDown = (event) => {
45
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
46
+ setIsOpen(false);
47
+ }
48
+ };
49
+ const handleKeyDown = (event) => {
50
+ if (event.key === 'Escape') {
51
+ setIsOpen(false);
52
+ triggerRef.current?.focus();
53
+ }
54
+ };
55
+ document.addEventListener('mousedown', handlePointerDown);
56
+ document.addEventListener('keydown', handleKeyDown);
57
+ return () => {
58
+ document.removeEventListener('mousedown', handlePointerDown);
59
+ document.removeEventListener('keydown', handleKeyDown);
60
+ };
61
+ }, [isOpen]);
62
+ // Dropdown panel이 열리면 첫(비활성 아닌) 항목으로 포커스를 이동한다 (DES-SPEC-030-1 §7)
63
+ useEffect(() => {
64
+ if (!isOpen)
65
+ return;
66
+ const firstOption = panelRef.current?.querySelector('.ncua-context-tab__option:not(:disabled)');
67
+ firstOption?.focus();
68
+ }, [isOpen]);
69
+ // 컨텍스트가 2개 미만이면 ContextTab을 노출하지 않는다 (DES-SPEC-030-1 §1.2 / F6)
70
+ if (menus.length < MIN_VISIBLE_CONTEXTS)
71
+ return null;
72
+ const handleSelect = (item) => {
73
+ if (item.disabled)
74
+ return;
75
+ onTabChange?.(item.id);
76
+ };
77
+ const handleOptionSelect = (item) => {
78
+ if (item.disabled)
79
+ return;
80
+ onTabChange?.(item.id);
81
+ setIsOpen(false);
82
+ triggerRef.current?.focus();
83
+ };
84
+ const renderOption = (item) => {
85
+ const isActive = item.id === activeTab;
86
+ return (_jsx("li", { role: "none", children: _jsxs("button", { type: "button", role: "menuitem", "aria-current": isActive || undefined, "aria-disabled": item.disabled || undefined, "aria-label": getAccessibleLabel(item), disabled: item.disabled, className: classNames('ncua-context-tab__option', { 'is-active': isActive }), onClick: () => handleOptionSelect(item), children: [_jsx("span", { className: "ncua-context-tab__option-label", title: item.label, children: item.label }), renderItemBadges(item), isActive && (_jsx(Check, { className: "ncua-context-tab__option-check", width: CHECK_ICON_SIZE, height: CHECK_ICON_SIZE }))] }) }, item.id));
87
+ };
88
+ // Dropdown panel은 2-column이며, 좌측 column = 짝수 인덱스 / 우측 column = 홀수 인덱스로
89
+ // row-major 읽기 순서를 유지한다 (Figma 시안 정합).
90
+ const leftColumn = menus.filter((_, index) => index % 2 === 0);
91
+ const rightColumn = menus.filter((_, index) => index % 2 === 1);
92
+ // 한 페이지 안에 모두 들어가면(스크롤 불필요) 탭이 영역을 균등하게 꽉 채우도록 한다.
93
+ // 초과 시에는 visibleTabsCount 등분 고정 폭으로 두고 가로 스크롤로 페이징한다.
94
+ const isFill = menus.length <= visibleTabsCount;
95
+ return (_jsxs("div", { ref: containerRef, className: classNames('ncua-context-tab', className), children: [_jsx(Button, { ref: triggerRef, onlyIcon: true, hierarchy: "tertiary-gray", size: CONTROL_BUTTON_SIZE, className: "ncua-context-tab__control ncua-context-tab__control--menu", label: "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-label": "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-haspopup": "menu", "aria-expanded": isOpen, leadingIcon: { type: 'icon', icon: Menu01 }, onClick: () => setIsOpen((prev) => !prev) }), _jsx("div", { ref: barRef, className: classNames('ncua-context-tab__bar', { 'is-fill': isFill }), role: "tablist", style: { '--ncua-context-tab-visible': visibleTabsCount }, children: menus.map((item) => {
96
+ const isActive = item.id === activeTab;
97
+ return (_jsxs("button", { type: "button", role: "tab", "aria-selected": isActive, "aria-disabled": item.disabled || undefined, "aria-label": getAccessibleLabel(item), disabled: item.disabled, title: item.label, className: classNames('ncua-context-tab__tab', {
98
+ 'is-active': isActive,
99
+ 'is-disabled': item.disabled,
100
+ }), onClick: () => handleSelect(item), children: [_jsx("span", { className: "ncua-context-tab__tab-label", children: item.label }), renderItemBadges(item)] }, item.id));
101
+ }) }), _jsxs("div", { className: "ncua-context-tab__nav", children: [_jsx(Button, { onlyIcon: true, hierarchy: "tertiary-gray", size: CONTROL_BUTTON_SIZE, className: "ncua-context-tab__control ncua-context-tab__control--prev", label: "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-label": "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", disabled: isBeginning, leadingIcon: { type: 'icon', icon: ChevronLeft }, onClick: () => scrollByPage(-1) }), _jsx(Button, { onlyIcon: true, hierarchy: "tertiary-gray", size: CONTROL_BUTTON_SIZE, className: "ncua-context-tab__control ncua-context-tab__control--next", label: "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-label": "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", disabled: isEnd, leadingIcon: { type: 'icon', icon: ChevronRight }, onClick: () => scrollByPage(1) })] }), isOpen && (_jsxs("div", { ref: panelRef, className: "ncua-context-tab__panel", role: "menu", children: [_jsx("ul", { className: "ncua-context-tab__panel-column", role: "none", children: leftColumn.map(renderOption) }), _jsx("ul", { className: "ncua-context-tab__panel-column", role: "none", children: rightColumn.map(renderOption) })] }))] }));
102
+ };
103
+ export { ContextTab };
@@ -0,0 +1 @@
1
+ export * from './ContextTab';
@@ -0,0 +1 @@
1
+ export * from './ContextTab';
@@ -0,0 +1,22 @@
1
+ interface UseContextTabScrollParams {
2
+ activeTab?: string;
3
+ menus: {
4
+ id: string;
5
+ }[];
6
+ visibleTabsCount: number;
7
+ }
8
+ /**
9
+ * ContextTab Tab Bar의 네이티브 가로 스크롤·페이징 메커니즘을 담당하는 훅.
10
+ *
11
+ * - `barRef`: 스크롤 컨테이너(ref)
12
+ * - `isBeginning` / `isEnd`: 좌/우 네비 버튼의 disabled 판정
13
+ * - `scrollByPage`: `<` `>` 클릭 시 한 페이지(보이는 폭)만큼 이동 — scroll-snap이 탭 경계에 맞춰 정렬
14
+ * - `activeTab` 변경 시 해당 탭이 보이는 페이지로 자동 스크롤
15
+ */
16
+ export declare const useContextTabScroll: ({ activeTab, menus, visibleTabsCount }: UseContextTabScrollParams) => {
17
+ barRef: import("react").RefObject<HTMLDivElement>;
18
+ isBeginning: boolean;
19
+ isEnd: boolean;
20
+ scrollByPage: (direction: 1 | -1) => void;
21
+ };
22
+ export {};
@@ -0,0 +1,65 @@
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 = ({ activeTab, menus, visibleTabsCount }) => {
12
+ const barRef = useRef(null);
13
+ const [isBeginning, setIsBeginning] = useState(true);
14
+ const [isEnd, setIsEnd] = useState(true);
15
+ // effect가 menus 식별자에 묶이지 않도록 최신 menus를 ref로 보관한다.
16
+ const menusRef = useRef(menus);
17
+ menusRef.current = menus;
18
+ // Tab Bar의 스크롤 위치로 좌/우 네비 버튼의 활성 여부를 계산한다.
19
+ const updateEdges = useCallback(() => {
20
+ const bar = barRef.current;
21
+ if (!bar)
22
+ return;
23
+ setIsBeginning(bar.scrollLeft <= EDGE_THRESHOLD);
24
+ setIsEnd(bar.scrollLeft + bar.clientWidth >= bar.scrollWidth - EDGE_THRESHOLD);
25
+ }, []);
26
+ // `<` `>` 클릭 시 보이는 폭(한 페이지)만큼 스크롤한다. scroll-snap이 탭 경계에 맞춰 정렬한다.
27
+ const scrollByPage = (direction) => {
28
+ const bar = barRef.current;
29
+ if (!bar)
30
+ return;
31
+ bar.scrollBy({ left: direction * bar.clientWidth, behavior: 'smooth' });
32
+ };
33
+ // 활성 탭이 항상 viewport 안에 보이도록 — activeTab 변경 시에만 해당 페이지로 이동 (DES-SPEC-030-1 §1.6).
34
+ // menus를 deps에 넣지 않아(ref로 최신값 참조) 사용자가 다른 페이지를 보는 중 부모 리렌더로 페이지가 되돌아가는 것을 막는다.
35
+ useEffect(() => {
36
+ const bar = barRef.current;
37
+ if (!bar || activeTab == null)
38
+ return;
39
+ const activeIndex = menusRef.current.findIndex((menu) => menu.id === activeTab);
40
+ if (activeIndex < 0)
41
+ return;
42
+ const pageStart = Math.floor(activeIndex / visibleTabsCount) * visibleTabsCount;
43
+ const target = bar.children[pageStart];
44
+ if (target)
45
+ bar.scrollTo({ left: target.offsetLeft, behavior: 'smooth' });
46
+ }, [activeTab, visibleTabsCount]);
47
+ // 스크롤·리사이즈·컨텍스트 수 변경 시 좌/우 네비 활성 여부를 다시 계산한다.
48
+ // menus.length·visibleTabsCount 변경 시 scrollWidth가 바뀌지만 ResizeObserver는 clientWidth만 감지하므로,
49
+ // 두 값을 재실행 트리거로 deps에 둔다(본문에서 직접 읽지 않음).
50
+ // biome-ignore lint/correctness/useExhaustiveDependencies: 위 값들은 에지 재계산을 위한 의도적 트리거
51
+ useEffect(() => {
52
+ const bar = barRef.current;
53
+ if (!bar)
54
+ return;
55
+ updateEdges();
56
+ bar.addEventListener('scroll', updateEdges, { passive: true });
57
+ const observer = new ResizeObserver(updateEdges);
58
+ observer.observe(bar);
59
+ return () => {
60
+ bar.removeEventListener('scroll', updateEdges);
61
+ observer.disconnect();
62
+ };
63
+ }, [updateEdges, menus.length, visibleTabsCount]);
64
+ return { barRef, isBeginning, isEnd, scrollByPage };
65
+ };
@@ -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';
@@ -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,6 @@
1
+ declare const TABLE_DND_ID_RADIX = 36;
2
+ declare const TABLE_DND_ID_SLICE_START = 2;
3
+ declare const TABLE_DND_ID_SLICE_END = 9;
4
+ declare const PREVIEW_BADGE_SIZE = 24;
5
+ declare const buildDragPreview: (rows: HTMLElement[], rowRect: DOMRect) => HTMLDivElement;
6
+ export { TABLE_DND_ID_RADIX, TABLE_DND_ID_SLICE_START, TABLE_DND_ID_SLICE_END, PREVIEW_BADGE_SIZE, buildDragPreview };
@@ -2,7 +2,7 @@ import type { ComponentProps, ReactNode } from 'react';
2
2
  export type TableType = 'horizontal' | 'vertical';
3
3
  export type SortDirection = 'asc' | 'desc' | 'none';
4
4
  export type RowStatus = 'warning' | 'error';
5
- export type TableProps = Omit<ComponentProps<'div'>, 'ref'> & {
5
+ export type TableProps = Omit<ComponentProps<'div'>, 'ref' | 'draggable'> & {
6
6
  type?: TableType;
7
7
  fixedHeader?: boolean;
8
8
  maxHeight?: string | number;
@@ -19,6 +19,11 @@ export type TableProps = Omit<ComponentProps<'div'>, 'ref'> & {
19
19
  * 기본값은 1140 (14인치 모니터 + LNB 기준 디자인 권장 너비).
20
20
  */
21
21
  minWidth?: string | number;
22
+ /**
23
+ * 행 드래그앤드롭 활성화. Table.Row에 dragId, Table.Body에 onRowDrop,
24
+ * 헤더에 Table.DragHeaderCell, 각 행에 Table.DragCell을 함께 사용한다.
25
+ */
26
+ draggable?: boolean;
22
27
  children: ReactNode;
23
28
  };
24
29
  export type TableHeaderProps = {
@@ -28,10 +33,28 @@ export type TableHeaderProps = {
28
33
  export type TableBodyProps = {
29
34
  children: ReactNode;
30
35
  className?: string;
36
+ /**
37
+ * draggable=true 테이블에서 행이 재정렬될 때 호출된다.
38
+ * fromId → toId 로 이동, edge는 toId 행의 위(top)/아래(bottom) 삽입 위치.
39
+ */
40
+ onRowDrop?: (fromId: string, toId: string, edge: 'top' | 'bottom') => void;
31
41
  };
32
42
  export type TableRowProps = Omit<ComponentProps<'tr'>, 'ref'> & {
33
43
  selected?: boolean;
34
44
  status?: RowStatus;
45
+ /** 드래그앤드롭 식별자. draggable 테이블에서 필수. */
46
+ dragId?: string;
47
+ };
48
+ export type TableDragCellProps = {
49
+ disabled?: boolean;
50
+ className?: string;
51
+ /** 드래그 셀에 함께 배치할 콘텐츠 (예: CheckboxInput). 아이콘 우측 8px gap으로 배치된다. */
52
+ children?: ReactNode;
53
+ };
54
+ export type TableDragHeaderCellProps = {
55
+ className?: string;
56
+ /** 헤더 드래그 셀에 함께 배치할 콘텐츠 (예: 전체선택 CheckboxInput). 아이콘 우측 8px gap으로 배치된다. */
57
+ children?: ReactNode;
35
58
  };
36
59
  export type TableHeaderCellProps = Omit<ComponentProps<'th'>, 'ref'> & {
37
60
  sortDirection?: SortDirection;
@@ -36,6 +36,7 @@ export * from './layout/block-header';
36
36
  export * from './layout/divider';
37
37
  export * from './layout/page-title';
38
38
  export * from './navigation/bread-crumb';
39
+ export * from './navigation/context-tab';
39
40
  export * from './navigation/horizontal-tab';
40
41
  export * from './navigation/pagination';
41
42
  export * from './navigation/vertical-tab';
@@ -0,0 +1,18 @@
1
+ interface ContextTabItemProps {
2
+ id: string;
3
+ label: string;
4
+ isNew?: boolean;
5
+ /** pill-outline 배지로 표시할 텍스트. 있으면 비신규 항목에도 배지를 노출한다. */
6
+ badgeLabel?: string;
7
+ disabled?: boolean;
8
+ }
9
+ interface ContextTabProps {
10
+ menus?: ContextTabItemProps[];
11
+ activeTab?: string;
12
+ onTabChange?: (id: string) => void;
13
+ visibleTabsCount?: number;
14
+ className?: string;
15
+ }
16
+ declare const ContextTab: ({ menus, activeTab, onTabChange, visibleTabsCount, className, }: ContextTabProps) => import("react/jsx-runtime").JSX.Element | null;
17
+ export { ContextTab };
18
+ export type { ContextTabItemProps, ContextTabProps };
@@ -0,0 +1 @@
1
+ export * from './ContextTab';
@@ -0,0 +1,22 @@
1
+ interface UseContextTabScrollParams {
2
+ activeTab?: string;
3
+ menus: {
4
+ id: string;
5
+ }[];
6
+ visibleTabsCount: number;
7
+ }
8
+ /**
9
+ * ContextTab Tab Bar의 네이티브 가로 스크롤·페이징 메커니즘을 담당하는 훅.
10
+ *
11
+ * - `barRef`: 스크롤 컨테이너(ref)
12
+ * - `isBeginning` / `isEnd`: 좌/우 네비 버튼의 disabled 판정
13
+ * - `scrollByPage`: `<` `>` 클릭 시 한 페이지(보이는 폭)만큼 이동 — scroll-snap이 탭 경계에 맞춰 정렬
14
+ * - `activeTab` 변경 시 해당 탭이 보이는 페이지로 자동 스크롤
15
+ */
16
+ export declare const useContextTabScroll: ({ activeTab, menus, visibleTabsCount }: UseContextTabScrollParams) => {
17
+ barRef: import("react").RefObject<HTMLDivElement>;
18
+ isBeginning: boolean;
19
+ isEnd: boolean;
20
+ scrollByPage: (direction: 1 | -1) => void;
21
+ };
22
+ export {};