@ncds/ui-admin 1.8.4 → 1.8.6

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 (187) hide show
  1. package/dist/cjs/assets/scripts/featuredIcon.js +87 -0
  2. package/dist/cjs/assets/scripts/notification/FloatingNotification.js +178 -0
  3. package/dist/cjs/assets/scripts/notification/FullWidthNotification.js +133 -0
  4. package/dist/cjs/assets/scripts/notification/MessageNotification.js +159 -0
  5. package/dist/cjs/assets/scripts/notification/Notification.js +120 -0
  6. package/dist/cjs/assets/scripts/notification/const/classNames.js +50 -0
  7. package/dist/cjs/assets/scripts/notification/const/icons.js +31 -0
  8. package/dist/cjs/assets/scripts/notification/const/index.js +87 -0
  9. package/dist/cjs/assets/scripts/notification/const/sizes.js +46 -0
  10. package/dist/cjs/assets/scripts/notification/const/types.js +14 -0
  11. package/dist/cjs/assets/scripts/notification/index.js +116 -0
  12. package/dist/cjs/assets/scripts/notification/positionSync.js +180 -0
  13. package/dist/cjs/assets/scripts/notification/utils.js +122 -0
  14. package/dist/cjs/assets/scripts/shared/ButtonCloseX.js +45 -0
  15. package/dist/cjs/assets/scripts/utils/sanitize.js +39 -0
  16. package/dist/cjs/src/components/data-display/data-grid/DataGrid.js +5 -1
  17. package/dist/cjs/src/components/data-display/table/Table.js +118 -96
  18. package/dist/cjs/src/components/data-display/table/useTableScrollbars.js +187 -0
  19. package/dist/cjs/src/components/forms-and-input/combo-box/ComboBox.js +11 -10
  20. package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +5 -2
  21. package/dist/cjs/src/components/forms-and-input/select-box/SelectBox.js +67 -29
  22. package/dist/cjs/src/components/forms-and-input/slider/Slider.js +2 -3
  23. package/dist/cjs/src/components/overlays/dropdown/Dropdown.js +47 -19
  24. package/dist/cjs/src/components/overlays/notification/CalloutNotification.js +25 -0
  25. package/dist/cjs/src/components/overlays/notification/FloatingNotification.js +86 -13
  26. package/dist/cjs/src/components/overlays/notification/Notification.js +7 -0
  27. package/dist/cjs/src/components/overlays/notification/host.js +12 -0
  28. package/dist/cjs/src/components/overlays/tooltip/Tooltip.js +57 -44
  29. package/dist/cjs/src/components/select-dropdown/SelectDropdown.js +2 -1
  30. package/dist/cjs/src/contexts/FloatingContext.js +11 -0
  31. package/dist/cjs/src/contexts/index.js +16 -0
  32. package/dist/cjs/src/hooks/index.js +11 -0
  33. package/dist/cjs/src/hooks/useFloatingPosition.js +78 -0
  34. package/dist/cjs/src/hooks/usePortalState.js +17 -0
  35. package/dist/cjs/src/types/component-meta.js +8 -1
  36. package/dist/cjs/src/utils/dropdown/maxSelection.js +35 -0
  37. package/dist/cjs/src/utils/dropdown/multiSelect.js +72 -15
  38. package/dist/esm/assets/scripts/featuredIcon.js +80 -0
  39. package/dist/esm/assets/scripts/notification/FloatingNotification.js +171 -0
  40. package/dist/esm/assets/scripts/notification/FullWidthNotification.js +126 -0
  41. package/dist/esm/assets/scripts/notification/MessageNotification.js +152 -0
  42. package/dist/esm/assets/scripts/notification/Notification.js +113 -0
  43. package/dist/esm/assets/scripts/notification/const/classNames.js +44 -0
  44. package/dist/esm/assets/scripts/notification/const/icons.js +25 -0
  45. package/dist/esm/assets/scripts/notification/const/index.js +4 -0
  46. package/dist/esm/assets/scripts/notification/const/sizes.js +40 -0
  47. package/dist/esm/assets/scripts/notification/const/types.js +8 -0
  48. package/dist/esm/assets/scripts/notification/index.js +10 -0
  49. package/dist/esm/assets/scripts/notification/positionSync.js +171 -0
  50. package/dist/esm/assets/scripts/notification/utils.js +109 -0
  51. package/dist/esm/assets/scripts/shared/ButtonCloseX.js +37 -0
  52. package/dist/esm/assets/scripts/utils/sanitize.js +31 -0
  53. package/dist/esm/src/components/data-display/data-grid/DataGrid.js +5 -1
  54. package/dist/esm/src/components/data-display/table/Table.js +118 -96
  55. package/dist/esm/src/components/data-display/table/useTableScrollbars.js +179 -0
  56. package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +11 -10
  57. package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +5 -2
  58. package/dist/esm/src/components/forms-and-input/select-box/SelectBox.js +67 -29
  59. package/dist/esm/src/components/forms-and-input/slider/Slider.js +1 -2
  60. package/dist/esm/src/components/overlays/dropdown/Dropdown.js +47 -19
  61. package/dist/esm/src/components/overlays/notification/CalloutNotification.js +19 -0
  62. package/dist/esm/src/components/overlays/notification/FloatingNotification.js +86 -14
  63. package/dist/esm/src/components/overlays/notification/Notification.js +7 -0
  64. package/dist/esm/src/components/overlays/notification/host.js +9 -0
  65. package/dist/esm/src/components/overlays/tooltip/Tooltip.js +58 -45
  66. package/dist/esm/src/components/select-dropdown/SelectDropdown.js +2 -1
  67. package/dist/esm/src/contexts/FloatingContext.js +4 -0
  68. package/dist/esm/src/contexts/index.js +1 -0
  69. package/dist/esm/src/hooks/index.js +1 -0
  70. package/dist/esm/src/hooks/useFloatingPosition.js +71 -0
  71. package/dist/esm/src/hooks/usePortalState.js +10 -0
  72. package/dist/esm/src/types/component-meta.js +5 -1
  73. package/dist/esm/src/utils/dropdown/maxSelection.js +27 -0
  74. package/dist/esm/src/utils/dropdown/multiSelect.js +70 -14
  75. package/dist/temp/assets/scripts/featuredIcon.d.ts +22 -0
  76. package/dist/temp/assets/scripts/featuredIcon.js +79 -0
  77. package/dist/temp/assets/scripts/notification/FloatingNotification.d.ts +24 -0
  78. package/dist/temp/assets/scripts/notification/FloatingNotification.js +156 -0
  79. package/dist/temp/assets/scripts/notification/FullWidthNotification.d.ts +21 -0
  80. package/dist/temp/assets/scripts/notification/FullWidthNotification.js +111 -0
  81. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +22 -0
  82. package/dist/temp/assets/scripts/notification/MessageNotification.js +140 -0
  83. package/dist/temp/assets/scripts/notification/Notification.d.ts +22 -0
  84. package/dist/temp/assets/scripts/notification/Notification.js +112 -0
  85. package/dist/temp/assets/scripts/notification/const/classNames.d.ts +43 -0
  86. package/dist/temp/assets/scripts/notification/const/classNames.js +44 -0
  87. package/dist/temp/assets/scripts/notification/const/icons.d.ts +25 -0
  88. package/dist/temp/assets/scripts/notification/const/icons.js +25 -0
  89. package/dist/temp/assets/scripts/notification/const/index.d.ts +5 -0
  90. package/dist/temp/assets/scripts/notification/const/index.js +4 -0
  91. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +32 -0
  92. package/dist/temp/assets/scripts/notification/const/sizes.js +40 -0
  93. package/dist/temp/assets/scripts/notification/const/types.d.ts +19 -0
  94. package/dist/temp/assets/scripts/notification/const/types.js +8 -0
  95. package/dist/temp/assets/scripts/notification/index.d.ts +8 -0
  96. package/dist/temp/assets/scripts/notification/index.js +10 -0
  97. package/dist/temp/assets/scripts/notification/positionSync.d.ts +50 -0
  98. package/dist/temp/assets/scripts/notification/positionSync.js +170 -0
  99. package/dist/temp/assets/scripts/notification/utils.d.ts +8 -0
  100. package/dist/temp/assets/scripts/notification/utils.js +115 -0
  101. package/dist/temp/assets/scripts/shared/ButtonCloseX.d.ts +5 -0
  102. package/dist/temp/assets/scripts/shared/ButtonCloseX.js +33 -0
  103. package/dist/temp/assets/scripts/utils/sanitize.d.ts +22 -0
  104. package/dist/temp/assets/scripts/utils/sanitize.js +31 -0
  105. package/dist/temp/src/components/data-display/data-grid/DataGrid.js +1 -1
  106. package/dist/temp/src/components/data-display/data-grid/DataGrid.types.d.ts +7 -0
  107. package/dist/temp/src/components/data-display/table/Table.d.ts +4 -1
  108. package/dist/temp/src/components/data-display/table/Table.js +53 -68
  109. package/dist/temp/src/components/data-display/table/types.d.ts +18 -0
  110. package/dist/temp/src/components/data-display/table/useTableScrollbars.d.ts +25 -0
  111. package/dist/temp/src/components/data-display/table/useTableScrollbars.js +136 -0
  112. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +8 -0
  113. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +7 -11
  114. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +1 -1
  115. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.d.ts +13 -0
  116. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.js +30 -3
  117. package/dist/temp/src/components/forms-and-input/slider/Slider.d.ts +0 -1
  118. package/dist/temp/src/components/forms-and-input/slider/Slider.js +0 -1
  119. package/dist/temp/src/components/overlays/dropdown/Dropdown.d.ts +5 -0
  120. package/dist/temp/src/components/overlays/dropdown/Dropdown.js +35 -11
  121. package/dist/temp/src/components/overlays/notification/CalloutNotification.d.ts +9 -0
  122. package/dist/temp/src/components/overlays/notification/CalloutNotification.js +6 -0
  123. package/dist/temp/src/components/overlays/notification/FloatingNotification.d.ts +15 -0
  124. package/dist/temp/src/components/overlays/notification/FloatingNotification.js +81 -13
  125. package/dist/temp/src/components/overlays/notification/Notification.d.ts +18 -3
  126. package/dist/temp/src/components/overlays/notification/Notification.js +4 -0
  127. package/dist/temp/src/components/overlays/notification/host.d.ts +9 -0
  128. package/dist/temp/src/components/overlays/notification/host.js +9 -0
  129. package/dist/temp/src/components/overlays/tooltip/Tooltip.d.ts +5 -1
  130. package/dist/temp/src/components/overlays/tooltip/Tooltip.js +25 -22
  131. package/dist/temp/src/components/select-dropdown/SelectDropdown.d.ts +6 -0
  132. package/dist/temp/src/components/select-dropdown/SelectDropdown.js +2 -2
  133. package/dist/temp/src/contexts/FloatingContext.d.ts +6 -0
  134. package/dist/temp/src/contexts/FloatingContext.js +4 -0
  135. package/dist/temp/src/contexts/index.d.ts +1 -0
  136. package/dist/temp/src/contexts/index.js +1 -0
  137. package/dist/temp/src/hooks/index.d.ts +1 -0
  138. package/dist/temp/src/hooks/index.js +1 -0
  139. package/dist/temp/src/hooks/useFloatingPosition.d.ts +19 -0
  140. package/dist/temp/src/hooks/useFloatingPosition.js +55 -0
  141. package/dist/temp/src/hooks/usePortalState.d.ts +6 -0
  142. package/dist/temp/src/hooks/usePortalState.js +7 -0
  143. package/dist/temp/src/types/component-meta.d.ts +6 -2
  144. package/dist/temp/src/types/component-meta.js +14 -1
  145. package/dist/temp/src/utils/dropdown/maxSelection.d.ts +24 -0
  146. package/dist/temp/src/utils/dropdown/maxSelection.js +28 -0
  147. package/dist/temp/src/utils/dropdown/multiSelect.d.ts +42 -2
  148. package/dist/temp/src/utils/dropdown/multiSelect.js +66 -13
  149. package/dist/types/assets/scripts/featuredIcon.d.ts +22 -0
  150. package/dist/types/assets/scripts/notification/FloatingNotification.d.ts +24 -0
  151. package/dist/types/assets/scripts/notification/FullWidthNotification.d.ts +21 -0
  152. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +22 -0
  153. package/dist/types/assets/scripts/notification/Notification.d.ts +22 -0
  154. package/dist/types/assets/scripts/notification/const/classNames.d.ts +43 -0
  155. package/dist/types/assets/scripts/notification/const/icons.d.ts +25 -0
  156. package/dist/types/assets/scripts/notification/const/index.d.ts +5 -0
  157. package/dist/types/assets/scripts/notification/const/sizes.d.ts +32 -0
  158. package/dist/types/assets/scripts/notification/const/types.d.ts +19 -0
  159. package/dist/types/assets/scripts/notification/index.d.ts +8 -0
  160. package/dist/types/assets/scripts/notification/positionSync.d.ts +50 -0
  161. package/dist/types/assets/scripts/notification/utils.d.ts +8 -0
  162. package/dist/types/assets/scripts/shared/ButtonCloseX.d.ts +5 -0
  163. package/dist/types/assets/scripts/utils/sanitize.d.ts +22 -0
  164. package/dist/types/src/components/data-display/data-grid/DataGrid.types.d.ts +7 -0
  165. package/dist/types/src/components/data-display/table/Table.d.ts +4 -1
  166. package/dist/types/src/components/data-display/table/types.d.ts +18 -0
  167. package/dist/types/src/components/data-display/table/useTableScrollbars.d.ts +25 -0
  168. package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +8 -0
  169. package/dist/types/src/components/forms-and-input/select-box/SelectBox.d.ts +13 -0
  170. package/dist/types/src/components/forms-and-input/slider/Slider.d.ts +0 -1
  171. package/dist/types/src/components/overlays/dropdown/Dropdown.d.ts +5 -0
  172. package/dist/types/src/components/overlays/notification/CalloutNotification.d.ts +9 -0
  173. package/dist/types/src/components/overlays/notification/FloatingNotification.d.ts +15 -0
  174. package/dist/types/src/components/overlays/notification/Notification.d.ts +18 -3
  175. package/dist/types/src/components/overlays/notification/host.d.ts +9 -0
  176. package/dist/types/src/components/overlays/tooltip/Tooltip.d.ts +5 -1
  177. package/dist/types/src/components/select-dropdown/SelectDropdown.d.ts +6 -0
  178. package/dist/types/src/contexts/FloatingContext.d.ts +6 -0
  179. package/dist/types/src/contexts/index.d.ts +1 -0
  180. package/dist/types/src/hooks/index.d.ts +1 -0
  181. package/dist/types/src/hooks/useFloatingPosition.d.ts +19 -0
  182. package/dist/types/src/hooks/usePortalState.d.ts +6 -0
  183. package/dist/types/src/types/component-meta.d.ts +6 -2
  184. package/dist/types/src/utils/dropdown/maxSelection.d.ts +24 -0
  185. package/dist/types/src/utils/dropdown/multiSelect.d.ts +42 -2
  186. package/dist/ui-admin/assets/styles/style.css +312 -64
  187. package/package.json +1 -1
@@ -29,6 +29,13 @@ export type DataGridTableProps = {
29
29
  maxHeight?: string | number;
30
30
  hoverable?: boolean;
31
31
  selectable?: boolean;
32
+ /**
33
+ * 가로 스크롤 활성화 시 외부 wrapper에 overflow-x: auto를 적용하고,
34
+ * 내부의 SelectBox·Dropdown이 FloatingProvider를 통해 자동으로 Portal 렌더로 전환된다.
35
+ */
36
+ horizontalScroll?: boolean;
37
+ /** 가로 스크롤 트리거 임계 너비. horizontalScroll=true 일 때만 의미가 있다. */
38
+ minWidth?: string | number;
32
39
  };
33
40
  export type DataGridPaginationProps = ComponentProps<'div'> & {
34
41
  children: ReactNode;
@@ -6,6 +6,8 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
6
6
  maxHeight?: string | number | undefined;
7
7
  hoverable?: boolean | undefined;
8
8
  selectable?: boolean | undefined;
9
+ horizontalScroll?: boolean | undefined;
10
+ minWidth?: string | number | undefined;
9
11
  children: ReactNode;
10
12
  } & import("react").RefAttributes<HTMLDivElement>> & {
11
13
  Header: {
@@ -24,6 +26,7 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
24
26
  sortDirection?: SortDirection | undefined;
25
27
  onSort?: (() => void) | undefined;
26
28
  width?: string | number | undefined;
29
+ minWidth?: string | number | undefined;
27
30
  } & import("react").RefAttributes<HTMLTableCellElement>>;
28
31
  Cell: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").TdHTMLAttributes<HTMLTableDataCellElement>, HTMLTableDataCellElement>, "ref"> & {
29
32
  isHeader?: boolean | undefined;
@@ -37,7 +40,7 @@ export declare const Table: import("react").ForwardRefExoticComponent<Omit<impor
37
40
  displayName: string;
38
41
  };
39
42
  ColGroup: {
40
- ({ widths }: TableColGroupProps): import("react/jsx-runtime").JSX.Element;
43
+ ({ widths, minWidths }: TableColGroupProps): import("react/jsx-runtime").JSX.Element;
41
44
  displayName: string;
42
45
  };
43
46
  Empty: {
@@ -1,13 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { ChevronDown, ChevronSelectorVertical, ChevronUp } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
- import { Children, forwardRef, useEffect, useRef, } from 'react';
5
- // ──────────────────────────────────────────────
6
- // $table-header-height 동기화 sticky thead가 차지하는 높이를 maxHeight에 보상
7
- const TABLE_HEADER_HEIGHT = 40;
8
- // 스크롤바 트랙 상하 여백 합계 (top 8px + bottom 8px) — SCSS &--fixed-header &__scrollbar 오프셋과 동기화
9
- const SCROLLBAR_TRACK_OFFSET = 16;
10
- const SCROLLBAR_THUMB_MIN_HEIGHT = 40;
4
+ import { Children, forwardRef, useRef, } from 'react';
5
+ import { FloatingProvider } from '../../../contexts/FloatingContext';
6
+ import { TABLE_HEADER_HEIGHT, useTableHorizontalScrollbar, useTableVerticalScrollbar } from './useTableScrollbars';
7
+ // 가로 스크롤 디자인 기준 폭 — 14인치 모니터 + LNB 고려한 디자인 권장 너비
8
+ const DEFAULT_HORIZONTAL_SCROLL_MIN_WIDTH = 1140;
9
+ const FLOATING_PORTAL_VALUE = { preferPortal: true };
10
+ // TABLE_HEADER_HEIGHT·DEFAULT_HORIZONTAL_SCROLL_MIN_WIDTH를 CSS 커스텀 프로퍼티로 주입 — SCSS fallback 단일 소스
11
+ const WRAPPER_STYLE = {
12
+ '--ncua-table-header-height': `${TABLE_HEADER_HEIGHT}px`,
13
+ '--ncua-table-default-min-width': `${DEFAULT_HORIZONTAL_SCROLL_MIN_WIDTH}px`,
14
+ };
11
15
  // Sort Icons (@ncds/ui-admin-icon)
12
16
  // ──────────────────────────────────────────────
13
17
  const SORT_ICONS = {
@@ -33,12 +37,12 @@ const Row = forwardRef(({ children, className, selected, status, ...rest }, ref)
33
37
  'ncua-table__row--error': status === 'error',
34
38
  }), ...rest, children: children })));
35
39
  Row.displayName = 'Table.Row';
36
- const HeaderCell = forwardRef(({ children, className, sortDirection, onSort, width, style, ...rest }, ref) => {
40
+ const HeaderCell = forwardRef(({ children, className, sortDirection, onSort, width, minWidth, style, ...rest }, ref) => {
37
41
  const isSortable = sortDirection !== undefined && onSort !== undefined;
38
42
  const SortIcon = isSortable ? SORT_ICONS[sortDirection] : undefined;
39
43
  return (_jsx("th", { ref: ref, className: classNames('ncua-table__header-cell', className, {
40
44
  'ncua-table__header-cell--sortable': isSortable,
41
- }), style: { ...style, width }, "aria-sort": isSortable ? ARIA_SORT_MAP[sortDirection] : undefined, onClick: isSortable ? onSort : undefined, ...rest, children: isSortable && SortIcon ? (_jsxs("span", { className: "ncua-table__header-cell-inner", children: [_jsx("span", { className: "ncua-table__header-cell-text", children: children }), _jsx("span", { className: "ncua-table__sort-icon", children: _jsx(SortIcon, { width: 16, height: 16 }) })] })) : (children) }));
45
+ }), style: { ...style, width, minWidth }, "aria-sort": isSortable ? ARIA_SORT_MAP[sortDirection] : undefined, onClick: isSortable ? onSort : undefined, ...rest, children: isSortable && SortIcon ? (_jsxs("span", { className: "ncua-table__header-cell-inner", children: [_jsx("span", { className: "ncua-table__header-cell-text", children: children }), _jsx("span", { className: "ncua-table__sort-icon", children: _jsx(SortIcon, { width: 16, height: 16 }) })] })) : (children) }));
42
46
  });
43
47
  HeaderCell.displayName = 'Table.HeaderCell';
44
48
  const Cell = forwardRef(({ children, className, isHeader, ...rest }, ref) => {
@@ -52,9 +56,9 @@ const Footer = ({ children, className }) => (_jsx("div", { className: classNames
52
56
  Footer.displayName = 'Table.Footer';
53
57
  const Pagination = ({ children, className }) => (_jsx("div", { className: classNames('ncua-table__pagination', className), children: children }));
54
58
  Pagination.displayName = 'Table.Pagination';
55
- const ColGroup = ({ widths }) => {
59
+ const ColGroup = ({ widths, minWidths }) => {
56
60
  const resolveColWidth = (width) => {
57
- if (width === 'auto')
61
+ if (width === undefined || width === 'auto')
58
62
  return undefined;
59
63
  if (typeof width === 'number')
60
64
  return `${width}px`;
@@ -62,7 +66,7 @@ const ColGroup = ({ widths }) => {
62
66
  };
63
67
  return (_jsx("colgroup", { children: widths.map((width, index) => (
64
68
  // biome-ignore lint/suspicious/noArrayIndexKey: colgroup columns never reorder or change
65
- _jsx("col", { style: { width: resolveColWidth(width) } }, index))) }));
69
+ _jsx("col", { style: { width: resolveColWidth(width), minWidth: resolveColWidth(minWidths?.[index]) } }, index))) }));
66
70
  };
67
71
  ColGroup.displayName = 'Table.ColGroup';
68
72
  const Empty = ({ colSpan, children }) => (_jsx("tr", { children: _jsx("td", { colSpan: colSpan, className: "ncua-table__empty", role: "status", "aria-live": "polite", children: children || '등록된 게시물이 없습니다.' }) }));
@@ -104,7 +108,7 @@ const sortChildren = (children) => {
104
108
  // ──────────────────────────────────────────────
105
109
  // Main Table component
106
110
  // ──────────────────────────────────────────────
107
- const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, maxHeight, hoverable = true, selectable = false, children, className, ...rest }, ref) => {
111
+ const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, maxHeight, hoverable = true, selectable = false, horizontalScroll = false, minWidth, children, className, ...rest }, ref) => {
108
112
  const tableClasses = classNames('ncua-table', className, {
109
113
  'ncua-table--horizontal': type === 'horizontal',
110
114
  'ncua-table--vertical': type === 'vertical',
@@ -123,64 +127,45 @@ const TableComponent = forwardRef(({ type = 'horizontal', fixedHeader = false, m
123
127
  // Custom scrollbar refs (used only in fixed-header mode)
124
128
  const scrollContainerRef = useRef(null);
125
129
  const scrollAreaRef = useRef(null);
130
+ const scrollbarRef = useRef(null);
126
131
  const thumbRef = useRef(null);
127
- useEffect(() => {
128
- if (!fixedHeader || !maxHeight)
129
- return;
130
- const scrollEl = scrollContainerRef.current;
131
- const thumbEl = thumbRef.current;
132
- if (!scrollEl || !thumbEl)
133
- return;
134
- const update = () => {
135
- const { scrollTop, scrollHeight, clientHeight } = scrollEl;
136
- if (scrollHeight <= clientHeight) {
137
- thumbEl.style.height = '0';
138
- return;
139
- }
140
- const trackHeight = (scrollAreaRef.current?.clientHeight ?? clientHeight) - TABLE_HEADER_HEIGHT - SCROLLBAR_TRACK_OFFSET;
141
- const thumbHeight = Math.max(SCROLLBAR_THUMB_MIN_HEIGHT, (clientHeight / scrollHeight) * trackHeight);
142
- const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackHeight - thumbHeight);
143
- thumbEl.style.height = `${thumbHeight}px`;
144
- thumbEl.style.transform = `translateY(${thumbTop}px)`;
145
- };
146
- scrollEl.addEventListener('scroll', update, { passive: true });
147
- const observer = new ResizeObserver(update);
148
- observer.observe(scrollEl);
149
- update();
150
- return () => {
151
- scrollEl.removeEventListener('scroll', update);
152
- observer.disconnect();
153
- };
154
- }, [fixedHeader, maxHeight]);
155
- const handleThumbMouseDown = (e) => {
156
- e.preventDefault();
157
- const scrollEl = scrollContainerRef.current;
158
- const thumbEl = thumbRef.current;
159
- const areaEl = scrollAreaRef.current;
160
- if (!scrollEl || !thumbEl)
161
- return;
162
- areaEl?.setAttribute('data-dragging', '');
163
- const startY = e.clientY;
164
- const startScrollTop = scrollEl.scrollTop;
165
- const { scrollHeight, clientHeight } = scrollEl;
166
- const thumbHeight = thumbEl.offsetHeight;
167
- const scrollRatio = (scrollHeight - clientHeight) / (clientHeight - thumbHeight);
168
- const onMove = (ev) => {
169
- scrollEl.scrollTop = startScrollTop + (ev.clientY - startY) * scrollRatio;
170
- };
171
- const onUp = () => {
172
- areaEl?.removeAttribute('data-dragging');
173
- document.removeEventListener('mousemove', onMove);
174
- document.removeEventListener('mouseup', onUp);
132
+ // 가로 스크롤바 refs (horizontalScroll 모드)
133
+ const hScrollContainerRef = useRef(null);
134
+ const hScrollbarRef = useRef(null);
135
+ const hThumbRef = useRef(null);
136
+ const fixedScrollEnabled = !!(fixedHeader && maxHeight);
137
+ const { handleThumbMouseDown } = useTableVerticalScrollbar({
138
+ enabled: fixedScrollEnabled,
139
+ scrollContainerRef,
140
+ scrollAreaRef,
141
+ thumbRef,
142
+ });
143
+ const { handleHThumbMouseDown } = useTableHorizontalScrollbar({
144
+ enabled: horizontalScroll,
145
+ hScrollContainerRef,
146
+ hScrollbarRef,
147
+ hThumbRef,
148
+ });
149
+ // <colgroup> + <thead> + <tbody> 묶음 — fixed-header 분기와 horizontalScroll 분기 모두에서 재사용
150
+ const renderTable = () => (_jsxs("table", { className: "ncua-table__table", role: "table", children: [colGroupContent, headerContent, tableContent] }));
151
+ // fixed-header 시 scroll-area + scrollbar 래핑, 아니면 <table> 그대로.
152
+ // withScrollbar=false 이면 scrollbar를 제외 — horizontalScroll 분기에서 scrollbar를
153
+ // h-scroll-container 형제 위치에 별도 렌더해 가로 스크롤 시 viewport 우측에 자연 고정.
154
+ 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());
155
+ // horizontalScroll=true 외곽 wrapper + FloatingProvider 부착.
156
+ // 핵심 — __h-scroll-container 는 <table>(또는 scroll-area) 만 감싸고, footer/pagination 은
157
+ // 그 바깥에서 항상 고정 위치. 세로 스크롤바는 h-scroll-container 형제로 배치되어
158
+ // 가로 스크롤에 영향받지 않고 .ncua-table 우측에 absolute 고정된다.
159
+ if (horizontalScroll) {
160
+ const resolvedMinWidth = minWidth ?? DEFAULT_HORIZONTAL_SCROLL_MIN_WIDTH;
161
+ // CSS 변수로 전달 — SCSS 에서 max(100%, var(--ncua-table-min-width)) 로 부모 너비를 항상 보장한다.
162
+ // (inline min-width 를 직접 주면 부모보다 작은 값에서 wrapper 가 좁아져 콘텐츠가 깨짐)
163
+ const innerStyle = {
164
+ '--ncua-table-min-width': typeof resolvedMinWidth === 'number' ? `${resolvedMinWidth}px` : resolvedMinWidth,
175
165
  };
176
- document.addEventListener('mousemove', onMove);
177
- document.addEventListener('mouseup', onUp);
178
- };
179
- if (fixedHeader && maxHeight) {
180
- return (_jsxs("div", { ref: ref, className: "ncua-table-wrapper", children: [_jsxs("div", { className: tableClasses, ...rest, children: [_jsxs("div", { ref: scrollAreaRef, className: "ncua-table__scroll-area", children: [_jsx("div", { ref: scrollContainerRef, className: "ncua-table__scroll-container", style: scrollStyle, children: _jsxs("table", { className: "ncua-table__table", role: "table", children: [colGroupContent, headerContent, tableContent] }) }), _jsx("div", { className: "ncua-table__scrollbar", "aria-hidden": "true", children: _jsx("div", { ref: thumbRef, className: "ncua-table__scrollbar-thumb", onMouseDown: handleThumbMouseDown }) })] }), footerContent] }), paginationContent] }));
166
+ 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] }) }));
181
167
  }
182
- const tableElement = (_jsxs("table", { className: "ncua-table__table", role: "table", children: [colGroupContent, headerContent, tableContent] }));
183
- return (_jsxs("div", { ref: ref, className: "ncua-table-wrapper", children: [_jsxs("div", { className: tableClasses, ...rest, children: [tableElement, footerContent] }), paginationContent] }));
168
+ return (_jsxs("div", { ref: ref, className: "ncua-table-wrapper", style: WRAPPER_STYLE, children: [_jsxs("div", { className: tableClasses, ...rest, children: [renderScrollableArea(), footerContent] }), paginationContent] }));
184
169
  });
185
170
  TableComponent.displayName = 'Table';
186
171
  // ──────────────────────────────────────────────
@@ -8,6 +8,17 @@ export type TableProps = Omit<ComponentProps<'div'>, 'ref'> & {
8
8
  maxHeight?: string | number;
9
9
  hoverable?: boolean;
10
10
  selectable?: boolean;
11
+ /**
12
+ * 가로 스크롤 활성화. 외곽에 overflow-x: auto wrapper가 추가되며,
13
+ * 내부의 SelectBox·Dropdown 등 floating 컴포넌트가 FloatingProvider를 통해
14
+ * 자동으로 React Portal 렌더로 전환된다.
15
+ */
16
+ horizontalScroll?: boolean;
17
+ /**
18
+ * 가로 스크롤 트리거 임계 너비. horizontalScroll=true 일 때만 의미가 있으며,
19
+ * 기본값은 1140 (14인치 모니터 + LNB 기준 디자인 권장 너비).
20
+ */
21
+ minWidth?: string | number;
11
22
  children: ReactNode;
12
23
  };
13
24
  export type TableHeaderProps = {
@@ -26,6 +37,11 @@ export type TableHeaderCellProps = Omit<ComponentProps<'th'>, 'ref'> & {
26
37
  sortDirection?: SortDirection;
27
38
  onSort?: () => void;
28
39
  width?: string | number;
40
+ /**
41
+ * 셀 최소 너비. 가로 스크롤 정책에 따라 글자수 기준으로 설정한다.
42
+ * 예: 제목·메인 10자 ≈ 160px, 이름·일자 5자 ≈ 80px, 버튼 xxs ≈ 40~60px, 기타 2자 ≈ 32px.
43
+ */
44
+ minWidth?: string | number;
29
45
  };
30
46
  export type TableCellProps = Omit<ComponentProps<'td'>, 'ref'> & {
31
47
  isHeader?: boolean;
@@ -40,6 +56,8 @@ export type TablePaginationProps = {
40
56
  };
41
57
  export type TableColGroupProps = {
42
58
  widths: (string | number)[];
59
+ /** 컬럼별 최소 너비. 가로 스크롤 시 셀이 더 좁아지지 않도록 강제한다. */
60
+ minWidths?: (string | number)[];
43
61
  };
44
62
  export type TableEmptyProps = {
45
63
  colSpan: number;
@@ -0,0 +1,25 @@
1
+ import { type MouseEvent, type RefObject } from 'react';
2
+ export declare const TABLE_HEADER_HEIGHT = 40;
3
+ export declare const SCROLLBAR_THUMB_MIN_HEIGHT = 40;
4
+ export declare const H_SCROLLBAR_THUMB_MIN_WIDTH = 40;
5
+ export declare const H_SCROLLBAR_SIDE_GAP = 8;
6
+ export declare const SCROLLBAR_TRACK_OFFSET = 16;
7
+ type VerticalScrollbarOptions = {
8
+ enabled: boolean;
9
+ scrollContainerRef: RefObject<HTMLDivElement | null>;
10
+ scrollAreaRef: RefObject<HTMLDivElement | null>;
11
+ thumbRef: RefObject<HTMLDivElement | null>;
12
+ };
13
+ export declare const useTableVerticalScrollbar: ({ enabled, scrollContainerRef, scrollAreaRef, thumbRef, }: VerticalScrollbarOptions) => {
14
+ handleThumbMouseDown: (e: MouseEvent<HTMLDivElement>) => void;
15
+ };
16
+ type HorizontalScrollbarOptions = {
17
+ enabled: boolean;
18
+ hScrollContainerRef: RefObject<HTMLDivElement | null>;
19
+ hScrollbarRef: RefObject<HTMLDivElement | null>;
20
+ hThumbRef: RefObject<HTMLDivElement | null>;
21
+ };
22
+ export declare const useTableHorizontalScrollbar: ({ enabled, hScrollContainerRef, hScrollbarRef, hThumbRef, }: HorizontalScrollbarOptions) => {
23
+ handleHThumbMouseDown: (e: MouseEvent<HTMLDivElement>) => void;
24
+ };
25
+ export {};
@@ -0,0 +1,136 @@
1
+ import { useEffect } from 'react';
2
+ // ──────────────────────────────────────────────
3
+ // 상수 — Table.tsx 와 _table.scss 양쪽에서 동기화 필요
4
+ // ──────────────────────────────────────────────
5
+ // $table-header-height
6
+ export const TABLE_HEADER_HEIGHT = 40;
7
+ // 세로/가로 thumb 최소 크기
8
+ export const SCROLLBAR_THUMB_MIN_HEIGHT = 40;
9
+ export const H_SCROLLBAR_THUMB_MIN_WIDTH = 40;
10
+ // SCSS .ncua-table__h-scrollbar { left/right: var(--spacing-s) = 8px } 와 동기화
11
+ export const H_SCROLLBAR_SIDE_GAP = 8;
12
+ // 세로 트랙 상하 여백 합계 — top 8 + bottom 8 = 16 (header 회피분 40 은 별도 처리)
13
+ // biome-ignore lint/style/useExportsLast: 상수는 문서 주석과 함께 상단에 정의
14
+ export const SCROLLBAR_TRACK_OFFSET = 16;
15
+ const startDrag = (e, options) => {
16
+ e.preventDefault();
17
+ const { axis, scrollEl, thumbEl, draggingTarget = scrollEl, sideGap = 0 } = options;
18
+ draggingTarget.setAttribute('data-dragging', '');
19
+ if (axis === 'y') {
20
+ const startY = e.clientY;
21
+ const startScrollTop = scrollEl.scrollTop;
22
+ const { scrollHeight, clientHeight } = scrollEl;
23
+ const thumbHeight = thumbEl.offsetHeight;
24
+ const ratio = (scrollHeight - clientHeight) / (clientHeight - thumbHeight);
25
+ const onMove = (ev) => {
26
+ scrollEl.scrollTop = startScrollTop + (ev.clientY - startY) * ratio;
27
+ };
28
+ const onUp = () => {
29
+ draggingTarget.removeAttribute('data-dragging');
30
+ document.removeEventListener('mousemove', onMove);
31
+ document.removeEventListener('mouseup', onUp);
32
+ };
33
+ document.addEventListener('mousemove', onMove);
34
+ document.addEventListener('mouseup', onUp);
35
+ return;
36
+ }
37
+ const startX = e.clientX;
38
+ const startScrollLeft = scrollEl.scrollLeft;
39
+ const { scrollWidth, clientWidth } = scrollEl;
40
+ const thumbWidth = thumbEl.offsetWidth;
41
+ const trackWidth = clientWidth - sideGap * 2;
42
+ const ratio = (scrollWidth - clientWidth) / (trackWidth - thumbWidth);
43
+ const onMove = (ev) => {
44
+ scrollEl.scrollLeft = startScrollLeft + (ev.clientX - startX) * ratio;
45
+ };
46
+ const onUp = () => {
47
+ draggingTarget.removeAttribute('data-dragging');
48
+ document.removeEventListener('mousemove', onMove);
49
+ document.removeEventListener('mouseup', onUp);
50
+ };
51
+ document.addEventListener('mousemove', onMove);
52
+ document.addEventListener('mouseup', onUp);
53
+ };
54
+ export const useTableVerticalScrollbar = ({ enabled, scrollContainerRef, scrollAreaRef, thumbRef, }) => {
55
+ useEffect(() => {
56
+ if (!enabled)
57
+ return;
58
+ const scrollEl = scrollContainerRef.current;
59
+ const thumbEl = thumbRef.current;
60
+ if (!scrollEl || !thumbEl)
61
+ return;
62
+ const update = () => {
63
+ const { scrollTop, scrollHeight, clientHeight } = scrollEl;
64
+ if (scrollHeight <= clientHeight) {
65
+ thumbEl.style.height = '0';
66
+ return;
67
+ }
68
+ const trackHeight = (scrollAreaRef.current?.clientHeight ?? clientHeight) - TABLE_HEADER_HEIGHT - SCROLLBAR_TRACK_OFFSET;
69
+ const thumbHeight = Math.max(SCROLLBAR_THUMB_MIN_HEIGHT, (clientHeight / scrollHeight) * trackHeight);
70
+ const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackHeight - thumbHeight);
71
+ thumbEl.style.height = `${thumbHeight}px`;
72
+ thumbEl.style.transform = `translateY(${thumbTop}px)`;
73
+ };
74
+ scrollEl.addEventListener('scroll', update, { passive: true });
75
+ const observer = new ResizeObserver(update);
76
+ observer.observe(scrollEl);
77
+ if (scrollAreaRef.current)
78
+ observer.observe(scrollAreaRef.current);
79
+ update();
80
+ return () => {
81
+ scrollEl.removeEventListener('scroll', update);
82
+ observer.disconnect();
83
+ };
84
+ }, [enabled, scrollContainerRef, scrollAreaRef, thumbRef]);
85
+ const handleThumbMouseDown = (e) => {
86
+ const scrollEl = scrollContainerRef.current;
87
+ const thumbEl = thumbRef.current;
88
+ const areaEl = scrollAreaRef.current;
89
+ if (!scrollEl || !thumbEl)
90
+ return;
91
+ startDrag(e, { axis: 'y', scrollEl, thumbEl, draggingTarget: areaEl ?? scrollEl });
92
+ };
93
+ return { handleThumbMouseDown };
94
+ };
95
+ export const useTableHorizontalScrollbar = ({ enabled, hScrollContainerRef, hScrollbarRef, hThumbRef, }) => {
96
+ useEffect(() => {
97
+ if (!enabled)
98
+ return;
99
+ const hScrollEl = hScrollContainerRef.current;
100
+ const hScrollbarEl = hScrollbarRef.current;
101
+ const hThumbEl = hThumbRef.current;
102
+ if (!hScrollEl || !hScrollbarEl || !hThumbEl)
103
+ return;
104
+ const update = () => {
105
+ const { scrollLeft, scrollWidth, clientWidth } = hScrollEl;
106
+ if (scrollWidth <= clientWidth) {
107
+ hThumbEl.style.width = '0';
108
+ return;
109
+ }
110
+ // transform으로 스크롤 오프셋 보정 — reflow 없이 compositor-only 이동
111
+ hScrollbarEl.style.transform = `translateX(${scrollLeft}px)`;
112
+ hScrollbarEl.style.width = `${clientWidth - H_SCROLLBAR_SIDE_GAP * 2}px`;
113
+ const trackWidth = clientWidth - H_SCROLLBAR_SIDE_GAP * 2;
114
+ const thumbWidth = Math.max(H_SCROLLBAR_THUMB_MIN_WIDTH, (clientWidth / scrollWidth) * trackWidth);
115
+ const thumbLeft = (scrollLeft / (scrollWidth - clientWidth)) * (trackWidth - thumbWidth);
116
+ hThumbEl.style.width = `${thumbWidth}px`;
117
+ hThumbEl.style.transform = `translateX(${thumbLeft}px)`;
118
+ };
119
+ hScrollEl.addEventListener('scroll', update, { passive: true });
120
+ const ro = new ResizeObserver(update);
121
+ ro.observe(hScrollEl);
122
+ update();
123
+ return () => {
124
+ hScrollEl.removeEventListener('scroll', update);
125
+ ro.disconnect();
126
+ };
127
+ }, [enabled, hScrollContainerRef, hScrollbarRef, hThumbRef]);
128
+ const handleHThumbMouseDown = (e) => {
129
+ const hScrollEl = hScrollContainerRef.current;
130
+ const hThumbEl = hThumbRef.current;
131
+ if (!hScrollEl || !hThumbEl)
132
+ return;
133
+ startDrag(e, { axis: 'x', scrollEl: hScrollEl, thumbEl: hThumbEl, sideGap: H_SCROLLBAR_SIDE_GAP });
134
+ };
135
+ return { handleHThumbMouseDown };
136
+ };
@@ -21,6 +21,14 @@ interface ComboBoxProps extends Omit<ComponentPropsWithRef<'div'>, 'size' | 'onC
21
21
  required?: boolean;
22
22
  multiple?: boolean;
23
23
  showFooterButtons?: boolean;
24
+ /**
25
+ * 최대 선택 가능 개수 (multiple=true에서만 유효).
26
+ * - `0` 이상의 정수: 제한 활성 → footer의 "전체선택" Link 미노출 (DES-SPEC-009 §3.10.1).
27
+ * - `0`은 모든 새 선택을 차단한다.
28
+ * - 양수는 도달 후 추가 선택을 무시한다 (이미 선택된 항목 해제는 정상).
29
+ * - 음수 / 비정수 / `null` / `undefined`: 제한 없음.
30
+ */
31
+ maxSelection?: number | null;
24
32
  onEdit?: () => void;
25
33
  }
26
34
  declare const ComboBox: import("react").ForwardRefExoticComponent<Omit<ComboBoxProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
@@ -10,13 +10,6 @@ import { SelectDropdown } from '../../select-dropdown';
10
10
  import { HintText } from '../../shared/hintText/HintText';
11
11
  import { InputBase } from '../input-base/InputBase';
12
12
  const defaultMaxHeight = 275;
13
- const toggleMultiSelectValue = (currentValue, optionId) => {
14
- const currentValues = Array.isArray(currentValue) ? currentValue : [];
15
- if (currentValues.includes(optionId)) {
16
- return currentValues.filter((v) => v !== optionId);
17
- }
18
- return [...currentValues, optionId];
19
- };
20
13
  const notifyFormChange = (register, multiple, newValue) => {
21
14
  if (register?.onChange) {
22
15
  register.onChange({
@@ -27,7 +20,7 @@ const notifyFormChange = (register, multiple, newValue) => {
27
20
  });
28
21
  }
29
22
  };
30
- const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, hintText, children, size = 'xs', destructive = false, value, optionItems = [], onChange, onSearch, disabled = false, register, maxHeight = defaultMaxHeight, searchValue = '', label, required = false, multiple = false, showFooterButtons = false, onEdit, ...props }, ref) => {
23
+ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, hintText, children, size = 'xs', destructive = false, value, optionItems = [], onChange, onSearch, disabled = false, register, maxHeight = defaultMaxHeight, searchValue = '', label, required = false, multiple = false, showFooterButtons = false, maxSelection, onEdit, ...props }, ref) => {
31
24
  const internalRef = useRef(null);
32
25
  const dropdownRef = useRef(null);
33
26
  const inputRef = useRef(null);
@@ -38,7 +31,9 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
38
31
  if (disabled)
39
32
  return;
40
33
  if (multiple) {
41
- const newValue = toggleMultiSelectValue(value ?? [], option.id);
34
+ const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
35
+ if (newValue === null)
36
+ return;
42
37
  onChange?.(newValue);
43
38
  notifyFormChange(register, multiple, newValue);
44
39
  return;
@@ -103,6 +98,7 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
103
98
  // 나머지는 useDropdown 훅에서 처리
104
99
  dropdownHandleKeyDown(e);
105
100
  };
101
+ // biome-ignore lint/style/noNonNullAssertion: forwardRef 패턴에서 internalRef는 첫 렌더 후 항상 존재
106
102
  useImperativeHandle(ref, () => internalRef.current, []);
107
103
  const trailingElement = {
108
104
  type: 'custom',
@@ -116,7 +112,7 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
116
112
  color: 'gray300',
117
113
  };
118
114
  const currentSelectedValues = multiple && Array.isArray(value) ? value : [];
119
- const { buttonText: selectAllButtonText, toggleSelectAll, getSelectedTagsData, removeTag, } = useMultiSelect(currentSelectedValues, optionItems);
115
+ const { buttonText: selectAllButtonText, toggleSelectAll, getSelectedTagsData, removeTag, isMaxSelectionActive, tryToggle, } = useMultiSelect(currentSelectedValues, optionItems, { maxSelection });
120
116
  const handleSelectAll = () => {
121
117
  if (!multiple || !onChange)
122
118
  return;
@@ -160,7 +156,7 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
160
156
  }, className), ...props, children: [_jsxs("div", { className: "ncua-combobox__content", children: [_jsx(InputBase, { ref: inputRef, size: size, label: label, required: required, placeholder: placeholder, value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onClick: handleInputClick, disabled: disabled, destructive: destructive, leadingElement: leadingElement, trailingElement: trailingElement, role: "combobox", "aria-expanded": isOpen, "aria-haspopup": "listbox", "aria-controls": comboboxId, "aria-disabled": disabled, autoComplete: "off", clearText: !!inputValue, onClearText: () => {
161
157
  setInputValue('');
162
158
  onSearch?.('');
163
- } }), _jsxs(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: displayedOptions, value: value, focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: comboboxId, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, isKeyboardNavigation: isKeyboardNavigation, multiple: multiple, showFooterButtons: showFooter, selectAllButtonText: selectAllButtonText, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, componentType: "combobox", children: [showNoResults ? _jsx("li", { className: "ncua-select-dropdown__no-results", children: "\uC77C\uCE58\uD558\uB294 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) : null, children] })] }), register && _jsx("input", { type: "hidden", ...register, value: hiddenInputValue }), hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), hasTags && (_jsx("div", { className: "ncua-combobox__tags", children: selectedTags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: true, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
159
+ } }), _jsxs(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: displayedOptions, value: value, focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: comboboxId, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, isKeyboardNavigation: isKeyboardNavigation, multiple: multiple, showFooterButtons: showFooter, selectAllButtonText: selectAllButtonText, showSelectAllAction: !isMaxSelectionActive, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, componentType: "combobox", children: [showNoResults ? _jsx("li", { className: "ncua-select-dropdown__no-results", children: "\uC77C\uCE58\uD558\uB294 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) : null, children] })] }), register && _jsx("input", { type: "hidden", ...register, value: hiddenInputValue }), hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), hasTags && (_jsx("div", { className: "ncua-combobox__tags", children: selectedTags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: true, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
164
160
  });
165
161
  ComboBox.displayName = 'ComboBox';
166
162
  export { defaultMaxHeight, ComboBox };
@@ -85,7 +85,7 @@ export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = fals
85
85
  };
86
86
  const renderImagePreview = (files = []) => {
87
87
  const showEmptySlot = maxFileCount ? files.length < maxFileCount : files.length === 0;
88
- return (_jsxs("div", { className: "ncua-image-file-input__previews", children: [files.map((file, index) => (_jsx(ImagePreview, { file: file, onRemove: () => handleRemoveFile(index) }, `${file.name}-${index}`))), showEmptySlot && (_jsxs("div", { className: "ncua-image-file-input__empty-slot-wrapper", onMouseEnter: () => !disabled && setIsButtonHovered(true), onMouseLeave: () => setIsButtonHovered(false), onClick: handleBrowseClick, children: [_jsx(Button, { onlyIcon: true, size: size, className: classNames('ncua-image-file-input__preview-container'), onClick: handleBrowseClick, disabled: disabled, label: imagePreviewTooltipLabel }), isButtonHovered && !disabled && _jsx(Tooltip, { content: imagePreviewTooltipLabel, position: "bottom" })] }))] }));
88
+ return (_jsxs("div", { className: "ncua-image-file-input__previews", children: [files.map((file, index) => (_jsx(ImagePreview, { file: file, onRemove: () => handleRemoveFile(index) }, `${file.name}-${index}`))), showEmptySlot && (_jsxs("div", { className: "ncua-image-file-input__empty-slot-wrapper", onMouseEnter: () => !disabled && setIsButtonHovered(true), onMouseLeave: () => setIsButtonHovered(false), onClick: handleBrowseClick, children: [_jsx(Button, { onlyIcon: true, size: size, className: classNames('ncua-image-file-input__preview-container'), onClick: handleBrowseClick, disabled: disabled, label: imagePreviewTooltipLabel }), _jsx(Tooltip, { content: imagePreviewTooltipLabel, position: "bottom", tooltipType: "black", forceVisible: isButtonHovered && !disabled, disablePortal: true })] }))] }));
89
89
  };
90
90
  const renderHintList = () => {
91
91
  if (!hintItems || hintItems.length === 0)
@@ -19,8 +19,21 @@ type SelectBoxProps = Omit<ComponentPropsWithRef<'div'>, 'size' | 'onChange'> &
19
19
  register?: UseFormRegisterReturn;
20
20
  maxHeight?: number;
21
21
  multiple?: boolean;
22
+ /**
23
+ * 최대 선택 가능 개수 (multiple=true에서만 유효).
24
+ * - `0` 이상의 정수: 제한 활성 → footer "전체선택" Link 미노출.
25
+ * - `0`은 모든 새 선택을 차단한다.
26
+ * - 양수는 도달 후 추가 선택을 무시한다 (이미 선택된 항목 해제는 정상).
27
+ * - 음수 / 비정수 / `null` / `undefined`: 제한 없음.
28
+ */
29
+ maxSelection?: number | null;
22
30
  onEdit?: () => void;
23
31
  align?: 'left' | 'right';
32
+ /**
33
+ * 옵션 패널을 React Portal로 body에 렌더한다.
34
+ * 미지정 시 FloatingContext.preferPortal 값을 따른다 (DataGrid.Table horizontalScroll 내부에서는 자동 true).
35
+ */
36
+ usePortal?: boolean;
24
37
  };
25
38
  declare const SelectBox: import("react").ForwardRefExoticComponent<Omit<SelectBoxProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
26
39
  export type { SelectBoxProps };
@@ -2,8 +2,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { ChevronDown } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
4
  import { forwardRef, useCallback, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from 'react';
5
+ import { createPortal } from 'react-dom';
5
6
  import { COLOR } from '../../../../constant/color';
6
7
  import { useDropdown, useScrollLock } from '../../../hooks/dropdown';
8
+ import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
9
+ import { usePortalState } from '../../../hooks/usePortalState';
7
10
  import { useMultiSelect } from '../../../utils/dropdown/multiSelect';
8
11
  import { Tag } from '../../feedback-and-status/tag';
9
12
  import { SelectDropdown } from '../../select-dropdown';
@@ -37,9 +40,12 @@ function DisplayValue({ displayValue }) {
37
40
  }
38
41
  return (_jsxs("div", { className: "ncua-selectbox__value-container", children: [displayValue.icon && (_jsx("span", { className: "ncua-selectbox__value-icon", children: _jsx(displayValue.icon, { width: 16, height: 16 }) })), _jsx("span", { className: "ncua-selectbox__value-text", children: displayValue.label })] }));
39
42
  }
40
- const SelectBox = forwardRef(({ placeholder = '선택하세요', disabledPlaceholder = false, hintText, size = 'xs', type = 'default', autoWidth = true, destructive = false, value, optionItems = [], disabled = false, maxHeight = DEFAULT_MAX_HEIGHT, multiple = false, align = 'left', id, className, children, register, onChange, onEdit, ...props }, ref) => {
43
+ const SelectBox = forwardRef(({ placeholder = '선택하세요', disabledPlaceholder = false, hintText, size = 'xs', type = 'default', autoWidth = true, destructive = false, value, optionItems = [], disabled = false, maxHeight = DEFAULT_MAX_HEIGHT, multiple = false, maxSelection, align = 'left', usePortal, id, className, children, register, onChange, onEdit, ...props }, ref
44
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 옵션/멀티/태그/포탈 등 필수 분기 통합
45
+ ) => {
41
46
  const internalRef = useRef(null);
42
47
  const dropdownRef = useRef(null);
48
+ const { shouldPortal, portalContainer } = usePortalState(usePortal);
43
49
  const [selectedTags, setSelectedTags] = useState([]);
44
50
  const selectedOption = useMemo(() => {
45
51
  if (multiple)
@@ -55,6 +61,14 @@ const SelectBox = forwardRef(({ placeholder = '선택하세요', disabledPlaceho
55
61
  const handleOptionSelect = (option) => {
56
62
  if (disabled)
57
63
  return;
64
+ if (multiple) {
65
+ const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
66
+ if (newValue === null)
67
+ return;
68
+ onChange?.(newValue);
69
+ notifyRegister(register, newValue, multiple);
70
+ return;
71
+ }
58
72
  const newValue = computeNewValue(option, value, multiple);
59
73
  onChange?.(newValue);
60
74
  notifyRegister(register, newValue, multiple);
@@ -82,7 +96,7 @@ const SelectBox = forwardRef(({ placeholder = '선택하세요', disabledPlaceho
82
96
  }, [handleMouseMove, setFocusedIndex]);
83
97
  // Multiple select 관련 로직
84
98
  const currentSelectedValues = multiple && Array.isArray(value) ? value : [];
85
- const { buttonText: selectAllButtonText, toggleSelectAll, getSelectedTagsData, removeTag, } = useMultiSelect(currentSelectedValues, optionItems);
99
+ const { buttonText: selectAllButtonText, toggleSelectAll, getSelectedTagsData, removeTag, isMaxSelectionActive, tryToggle, } = useMultiSelect(currentSelectedValues, optionItems, { maxSelection });
86
100
  const handleSelectAll = () => {
87
101
  if (!multiple || !onChange)
88
102
  return;
@@ -109,13 +123,26 @@ const SelectBox = forwardRef(({ placeholder = '선택하세요', disabledPlaceho
109
123
  }
110
124
  };
111
125
  useScrollLock(isOpen, dropdownRef);
126
+ // biome-ignore lint/correctness/useExhaustiveDependencies: optionItems 변경 시 너비 재계산 필요
112
127
  useLayoutEffect(() => {
113
128
  if (autoWidth && isOpen && dropdownRef.current && internalRef.current) {
114
129
  const dropdownWidth = dropdownRef.current.offsetWidth;
115
130
  internalRef.current.style.width = `${dropdownWidth}px`;
116
131
  }
117
132
  }, [autoWidth, isOpen, optionItems]);
133
+ const floatingStyle = useFloatingPosition({
134
+ enabled: shouldPortal,
135
+ isOpen,
136
+ triggerRef: internalRef,
137
+ floatingRef: dropdownRef,
138
+ direction: dropdownDirection,
139
+ align,
140
+ matchTriggerWidth: true,
141
+ });
142
+ // biome-ignore lint/style/noNonNullAssertion: forwardRef 패턴에서 internalRef는 항상 존재
118
143
  useImperativeHandle(ref, () => internalRef.current, []);
144
+ const selectDropdownNode = (_jsx(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: optionItems, value: value, focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: `selectbox-options-${id || 'default'}`, multiple: multiple, showFooterButtons: multiple, selectAllButtonText: selectAllButtonText, showSelectAllAction: !isMaxSelectionActive, componentType: "selectbox", isKeyboardNavigation: isKeyboardNavigation, activeDescendantId: activeDescendantId, align: align, className: shouldPortal ? 'ncua-select-dropdown--portal' : undefined, style: shouldPortal && floatingStyle ? floatingStyle : undefined, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, onOptionHover: handleOptionHover, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, children: children }));
145
+ const portaledDropdown = shouldPortal && portalContainer && isOpen ? createPortal(selectDropdownNode, portalContainer) : null;
119
146
  return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: internalRef, className: classNames('ncua-selectbox', `ncua-selectbox--${size}`, {
120
147
  'ncua-selectbox--open': isOpen,
121
148
  'ncua-selectbox--disabled': disabled,
@@ -124,7 +151,7 @@ const SelectBox = forwardRef(({ placeholder = '선택하세요', disabledPlaceho
124
151
  destructive: destructive,
125
152
  }, className), ...props, children: [_jsxs("div", { className: classNames('ncua-selectbox__content'), onClick: toggleDropdown, onKeyDown: handleKeyDown, tabIndex: disabled ? -1 : 0, role: "combobox", "aria-expanded": isOpen, "aria-haspopup": "listbox", "aria-controls": `selectbox-options-${id || 'default'}`, "aria-disabled": disabled, "aria-label": selectedOption ? selectedOption.label : placeholder, "aria-activedescendant": activeDescendantId, children: [_jsxs("div", { className: "ncua-selectbox__content-inner", children: [_jsx("div", { className: "ncua-selectbox__value", children: _jsx(DisplayValue, { displayValue: displayValue }) }), _jsx(ChevronDown, { width: size === 'xs' ? ICON_SIZE_XS : ICON_SIZE_SM, height: size === 'xs' ? ICON_SIZE_XS : ICON_SIZE_SM, color: disabled ? COLOR.gray300 : COLOR.gray500, className: classNames('ncua-selectbox__arrow', {
126
153
  'ncua-selectbox__arrow--up': isOpen,
127
- }) })] }), _jsx(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: optionItems, value: value, focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: `selectbox-options-${id || 'default'}`, multiple: multiple, showFooterButtons: multiple, selectAllButtonText: selectAllButtonText, componentType: "selectbox", isKeyboardNavigation: isKeyboardNavigation, activeDescendantId: activeDescendantId, align: align, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, onOptionHover: handleOptionHover, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, children: children })] }), hintText && (_jsx(HintText, { destructive: destructive, className: "ncua-hint-text", children: hintText })), register && (_jsx("input", { type: "hidden", ...register, value: multiple ? JSON.stringify(value || []) : String(value || '') }))] }), selectedTags.length > 0 && (_jsx("div", { className: "ncua-selectbox__tags", children: selectedTags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: true, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
154
+ }) })] }), !shouldPortal && selectDropdownNode] }), hintText && (_jsx(HintText, { destructive: destructive, className: "ncua-hint-text", children: hintText })), register && (_jsx("input", { type: "hidden", ...register, value: multiple ? JSON.stringify(value || []) : String(value || '') }))] }), selectedTags.length > 0 && (_jsx("div", { className: "ncua-selectbox__tags", children: selectedTags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: true, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) })), portaledDropdown] }));
128
155
  });
129
156
  SelectBox.displayName = 'SelectBox';
130
157
  export { DEFAULT_MAX_HEIGHT, SelectBox };
@@ -11,4 +11,3 @@ export interface SliderProps {
11
11
  className?: string;
12
12
  }
13
13
  export declare const Slider: ({ min, max, step, value, onChange, labelPosition, disabled, className, }: SliderProps) => import("react/jsx-runtime").JSX.Element;
14
- export default Slider;