@ncds/ui-admin 1.8.4 → 1.8.5

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 (177) 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/overlays/dropdown/Dropdown.js +47 -19
  23. package/dist/cjs/src/components/overlays/notification/CalloutNotification.js +25 -0
  24. package/dist/cjs/src/components/overlays/notification/FloatingNotification.js +86 -13
  25. package/dist/cjs/src/components/overlays/notification/Notification.js +7 -0
  26. package/dist/cjs/src/components/overlays/notification/host.js +12 -0
  27. package/dist/cjs/src/components/overlays/tooltip/Tooltip.js +57 -44
  28. package/dist/cjs/src/components/select-dropdown/SelectDropdown.js +2 -1
  29. package/dist/cjs/src/contexts/FloatingContext.js +11 -0
  30. package/dist/cjs/src/contexts/index.js +16 -0
  31. package/dist/cjs/src/hooks/index.js +11 -0
  32. package/dist/cjs/src/hooks/useFloatingPosition.js +78 -0
  33. package/dist/cjs/src/hooks/usePortalState.js +17 -0
  34. package/dist/cjs/src/utils/dropdown/maxSelection.js +35 -0
  35. package/dist/cjs/src/utils/dropdown/multiSelect.js +72 -15
  36. package/dist/esm/assets/scripts/featuredIcon.js +80 -0
  37. package/dist/esm/assets/scripts/notification/FloatingNotification.js +171 -0
  38. package/dist/esm/assets/scripts/notification/FullWidthNotification.js +126 -0
  39. package/dist/esm/assets/scripts/notification/MessageNotification.js +152 -0
  40. package/dist/esm/assets/scripts/notification/Notification.js +113 -0
  41. package/dist/esm/assets/scripts/notification/const/classNames.js +44 -0
  42. package/dist/esm/assets/scripts/notification/const/icons.js +25 -0
  43. package/dist/esm/assets/scripts/notification/const/index.js +4 -0
  44. package/dist/esm/assets/scripts/notification/const/sizes.js +40 -0
  45. package/dist/esm/assets/scripts/notification/const/types.js +8 -0
  46. package/dist/esm/assets/scripts/notification/index.js +10 -0
  47. package/dist/esm/assets/scripts/notification/positionSync.js +171 -0
  48. package/dist/esm/assets/scripts/notification/utils.js +109 -0
  49. package/dist/esm/assets/scripts/shared/ButtonCloseX.js +37 -0
  50. package/dist/esm/assets/scripts/utils/sanitize.js +31 -0
  51. package/dist/esm/src/components/data-display/data-grid/DataGrid.js +5 -1
  52. package/dist/esm/src/components/data-display/table/Table.js +118 -96
  53. package/dist/esm/src/components/data-display/table/useTableScrollbars.js +179 -0
  54. package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +11 -10
  55. package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +5 -2
  56. package/dist/esm/src/components/forms-and-input/select-box/SelectBox.js +67 -29
  57. package/dist/esm/src/components/overlays/dropdown/Dropdown.js +47 -19
  58. package/dist/esm/src/components/overlays/notification/CalloutNotification.js +19 -0
  59. package/dist/esm/src/components/overlays/notification/FloatingNotification.js +86 -14
  60. package/dist/esm/src/components/overlays/notification/Notification.js +7 -0
  61. package/dist/esm/src/components/overlays/notification/host.js +9 -0
  62. package/dist/esm/src/components/overlays/tooltip/Tooltip.js +58 -45
  63. package/dist/esm/src/components/select-dropdown/SelectDropdown.js +2 -1
  64. package/dist/esm/src/contexts/FloatingContext.js +4 -0
  65. package/dist/esm/src/contexts/index.js +1 -0
  66. package/dist/esm/src/hooks/index.js +1 -0
  67. package/dist/esm/src/hooks/useFloatingPosition.js +71 -0
  68. package/dist/esm/src/hooks/usePortalState.js +10 -0
  69. package/dist/esm/src/utils/dropdown/maxSelection.js +27 -0
  70. package/dist/esm/src/utils/dropdown/multiSelect.js +70 -14
  71. package/dist/temp/assets/scripts/featuredIcon.d.ts +22 -0
  72. package/dist/temp/assets/scripts/featuredIcon.js +79 -0
  73. package/dist/temp/assets/scripts/notification/FloatingNotification.d.ts +24 -0
  74. package/dist/temp/assets/scripts/notification/FloatingNotification.js +156 -0
  75. package/dist/temp/assets/scripts/notification/FullWidthNotification.d.ts +21 -0
  76. package/dist/temp/assets/scripts/notification/FullWidthNotification.js +111 -0
  77. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +22 -0
  78. package/dist/temp/assets/scripts/notification/MessageNotification.js +140 -0
  79. package/dist/temp/assets/scripts/notification/Notification.d.ts +22 -0
  80. package/dist/temp/assets/scripts/notification/Notification.js +112 -0
  81. package/dist/temp/assets/scripts/notification/const/classNames.d.ts +43 -0
  82. package/dist/temp/assets/scripts/notification/const/classNames.js +44 -0
  83. package/dist/temp/assets/scripts/notification/const/icons.d.ts +25 -0
  84. package/dist/temp/assets/scripts/notification/const/icons.js +25 -0
  85. package/dist/temp/assets/scripts/notification/const/index.d.ts +5 -0
  86. package/dist/temp/assets/scripts/notification/const/index.js +4 -0
  87. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +32 -0
  88. package/dist/temp/assets/scripts/notification/const/sizes.js +40 -0
  89. package/dist/temp/assets/scripts/notification/const/types.d.ts +19 -0
  90. package/dist/temp/assets/scripts/notification/const/types.js +8 -0
  91. package/dist/temp/assets/scripts/notification/index.d.ts +8 -0
  92. package/dist/temp/assets/scripts/notification/index.js +10 -0
  93. package/dist/temp/assets/scripts/notification/positionSync.d.ts +50 -0
  94. package/dist/temp/assets/scripts/notification/positionSync.js +170 -0
  95. package/dist/temp/assets/scripts/notification/utils.d.ts +8 -0
  96. package/dist/temp/assets/scripts/notification/utils.js +115 -0
  97. package/dist/temp/assets/scripts/shared/ButtonCloseX.d.ts +5 -0
  98. package/dist/temp/assets/scripts/shared/ButtonCloseX.js +33 -0
  99. package/dist/temp/assets/scripts/utils/sanitize.d.ts +22 -0
  100. package/dist/temp/assets/scripts/utils/sanitize.js +31 -0
  101. package/dist/temp/src/components/data-display/data-grid/DataGrid.js +1 -1
  102. package/dist/temp/src/components/data-display/data-grid/DataGrid.types.d.ts +7 -0
  103. package/dist/temp/src/components/data-display/table/Table.d.ts +4 -1
  104. package/dist/temp/src/components/data-display/table/Table.js +53 -68
  105. package/dist/temp/src/components/data-display/table/types.d.ts +18 -0
  106. package/dist/temp/src/components/data-display/table/useTableScrollbars.d.ts +25 -0
  107. package/dist/temp/src/components/data-display/table/useTableScrollbars.js +136 -0
  108. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +8 -0
  109. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +7 -11
  110. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +1 -1
  111. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.d.ts +13 -0
  112. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.js +30 -3
  113. package/dist/temp/src/components/overlays/dropdown/Dropdown.d.ts +5 -0
  114. package/dist/temp/src/components/overlays/dropdown/Dropdown.js +35 -11
  115. package/dist/temp/src/components/overlays/notification/CalloutNotification.d.ts +9 -0
  116. package/dist/temp/src/components/overlays/notification/CalloutNotification.js +6 -0
  117. package/dist/temp/src/components/overlays/notification/FloatingNotification.d.ts +15 -0
  118. package/dist/temp/src/components/overlays/notification/FloatingNotification.js +81 -13
  119. package/dist/temp/src/components/overlays/notification/Notification.d.ts +18 -3
  120. package/dist/temp/src/components/overlays/notification/Notification.js +4 -0
  121. package/dist/temp/src/components/overlays/notification/host.d.ts +9 -0
  122. package/dist/temp/src/components/overlays/notification/host.js +9 -0
  123. package/dist/temp/src/components/overlays/tooltip/Tooltip.d.ts +5 -1
  124. package/dist/temp/src/components/overlays/tooltip/Tooltip.js +25 -22
  125. package/dist/temp/src/components/select-dropdown/SelectDropdown.d.ts +6 -0
  126. package/dist/temp/src/components/select-dropdown/SelectDropdown.js +2 -2
  127. package/dist/temp/src/contexts/FloatingContext.d.ts +6 -0
  128. package/dist/temp/src/contexts/FloatingContext.js +4 -0
  129. package/dist/temp/src/contexts/index.d.ts +1 -0
  130. package/dist/temp/src/contexts/index.js +1 -0
  131. package/dist/temp/src/hooks/index.d.ts +1 -0
  132. package/dist/temp/src/hooks/index.js +1 -0
  133. package/dist/temp/src/hooks/useFloatingPosition.d.ts +19 -0
  134. package/dist/temp/src/hooks/useFloatingPosition.js +55 -0
  135. package/dist/temp/src/hooks/usePortalState.d.ts +6 -0
  136. package/dist/temp/src/hooks/usePortalState.js +7 -0
  137. package/dist/temp/src/utils/dropdown/maxSelection.d.ts +24 -0
  138. package/dist/temp/src/utils/dropdown/maxSelection.js +28 -0
  139. package/dist/temp/src/utils/dropdown/multiSelect.d.ts +42 -2
  140. package/dist/temp/src/utils/dropdown/multiSelect.js +66 -13
  141. package/dist/types/assets/scripts/featuredIcon.d.ts +22 -0
  142. package/dist/types/assets/scripts/notification/FloatingNotification.d.ts +24 -0
  143. package/dist/types/assets/scripts/notification/FullWidthNotification.d.ts +21 -0
  144. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +22 -0
  145. package/dist/types/assets/scripts/notification/Notification.d.ts +22 -0
  146. package/dist/types/assets/scripts/notification/const/classNames.d.ts +43 -0
  147. package/dist/types/assets/scripts/notification/const/icons.d.ts +25 -0
  148. package/dist/types/assets/scripts/notification/const/index.d.ts +5 -0
  149. package/dist/types/assets/scripts/notification/const/sizes.d.ts +32 -0
  150. package/dist/types/assets/scripts/notification/const/types.d.ts +19 -0
  151. package/dist/types/assets/scripts/notification/index.d.ts +8 -0
  152. package/dist/types/assets/scripts/notification/positionSync.d.ts +50 -0
  153. package/dist/types/assets/scripts/notification/utils.d.ts +8 -0
  154. package/dist/types/assets/scripts/shared/ButtonCloseX.d.ts +5 -0
  155. package/dist/types/assets/scripts/utils/sanitize.d.ts +22 -0
  156. package/dist/types/src/components/data-display/data-grid/DataGrid.types.d.ts +7 -0
  157. package/dist/types/src/components/data-display/table/Table.d.ts +4 -1
  158. package/dist/types/src/components/data-display/table/types.d.ts +18 -0
  159. package/dist/types/src/components/data-display/table/useTableScrollbars.d.ts +25 -0
  160. package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +8 -0
  161. package/dist/types/src/components/forms-and-input/select-box/SelectBox.d.ts +13 -0
  162. package/dist/types/src/components/overlays/dropdown/Dropdown.d.ts +5 -0
  163. package/dist/types/src/components/overlays/notification/CalloutNotification.d.ts +9 -0
  164. package/dist/types/src/components/overlays/notification/FloatingNotification.d.ts +15 -0
  165. package/dist/types/src/components/overlays/notification/Notification.d.ts +18 -3
  166. package/dist/types/src/components/overlays/notification/host.d.ts +9 -0
  167. package/dist/types/src/components/overlays/tooltip/Tooltip.d.ts +5 -1
  168. package/dist/types/src/components/select-dropdown/SelectDropdown.d.ts +6 -0
  169. package/dist/types/src/contexts/FloatingContext.d.ts +6 -0
  170. package/dist/types/src/contexts/index.d.ts +1 -0
  171. package/dist/types/src/hooks/index.d.ts +1 -0
  172. package/dist/types/src/hooks/useFloatingPosition.d.ts +19 -0
  173. package/dist/types/src/hooks/usePortalState.d.ts +6 -0
  174. package/dist/types/src/utils/dropdown/maxSelection.d.ts +24 -0
  175. package/dist/types/src/utils/dropdown/multiSelect.d.ts +42 -2
  176. package/dist/ui-admin/assets/styles/style.css +304 -64
  177. package/package.json +1 -1
@@ -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 };
@@ -49,6 +49,11 @@ export type DropdownBaseProps = {
49
49
  className?: string;
50
50
  opened?: boolean;
51
51
  closeOnClickOutside?: boolean;
52
+ /**
53
+ * 메뉴를 React Portal로 body에 렌더한다.
54
+ * 미지정 시 FloatingContext.preferPortal 값을 따른다 (DataGrid.Table horizontalScroll 내부에서는 자동 true).
55
+ */
56
+ usePortal?: boolean;
52
57
  };
53
58
  export type ActionDropdownProps = DropdownBaseProps & {
54
59
  variant?: 'action';
@@ -7,6 +7,9 @@ import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-sc
7
7
  import { attachClosestEdge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
8
8
  import { DotsGrid02, DotsVertical, Eye, EyeOff } from '@ncds/ui-admin-icon';
9
9
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
10
+ import { createPortal } from 'react-dom';
11
+ import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
12
+ import { usePortalState } from '../../../hooks/usePortalState';
10
13
  import { Button } from '../../action/button';
11
14
  import { applyDraftToItems, arrayReorderByEdge, hasDraftChanged, initDraftState } from './utils';
12
15
  const DROPDOWN_ID_RADIX = 36;
@@ -82,14 +85,25 @@ const SortableConfigItem = ({ item, isVisible, showVisibilityToggle, sortable, d
82
85
  }
83
86
  }, children: _jsx(DotsGrid02, {}) })), _jsx("div", { className: "ncua-dropdown__item-content", children: _jsxs("div", { className: "ncua-dropdown__item-text-group", children: [ItemIcon && _jsx(ItemIcon, { className: "ncua-dropdown__item-icon" }), _jsx("span", { className: "ncua-dropdown__item-text", children: item.text })] }) }), showVisibilityToggle && (_jsx("button", { type: "button", className: "ncua-dropdown__item-visibility", "aria-label": `${item.text} 노출`, "aria-pressed": isVisible, onClick: () => onToggleVisibility(item.id), children: isVisible ? _jsx(Eye, {}) : _jsx(EyeOff, {}) }))] }));
84
87
  };
88
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: 액션/설정 두 variant 통합 + drag-and-drop 필수 분기
85
89
  export const Dropdown = (props) => {
86
- const { trigger, align = 'left', header, groups, className, opened = false, closeOnClickOutside = true } = props;
90
+ const { trigger, align = 'left', header, groups, className, opened = false, closeOnClickOutside = true, usePortal, } = props;
87
91
  const variant = props.variant ?? 'action';
88
92
  const closeOnClickItem = variant === 'action' ? (props.closeOnClickItem ?? true) : false;
89
93
  const [isOpen, setIsOpen] = useState(opened);
90
94
  const dropdownRef = useRef(null);
91
95
  const triggerRef = useRef(null);
96
+ const menuRef = useRef(null);
92
97
  const menuItemsRef = useRef(null);
98
+ const { shouldPortal, portalContainer } = usePortalState(usePortal);
99
+ const floatingStyle = useFloatingPosition({
100
+ enabled: shouldPortal,
101
+ isOpen,
102
+ triggerRef,
103
+ floatingRef: menuRef,
104
+ direction: 'down',
105
+ align,
106
+ });
93
107
  const dropdownIdRef = useRef(`ncua-dropdown-${Math.random().toString(DROPDOWN_ID_RADIX).slice(DROPDOWN_ID_SLICE_START, DROPDOWN_ID_SLICE_END)}`);
94
108
  const dropdownId = dropdownIdRef.current;
95
109
  useEffect(() => {
@@ -144,7 +158,10 @@ export const Dropdown = (props) => {
144
158
  triggerRef.current?.focus();
145
159
  };
146
160
  const handleClickOutside = (event) => {
147
- if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
161
+ const target = event.target;
162
+ const insideContainer = dropdownRef.current?.contains(target) ?? false;
163
+ const insidePortaledMenu = menuRef.current?.contains(target) ?? false;
164
+ if (!insideContainer && !insidePortaledMenu) {
148
165
  setIsOpen(false);
149
166
  }
150
167
  };
@@ -156,6 +173,7 @@ export const Dropdown = (props) => {
156
173
  }
157
174
  }
158
175
  };
176
+ // biome-ignore lint/correctness/useExhaustiveDependencies: handleClickOutside는 안정적 참조
159
177
  useEffect(() => {
160
178
  if (closeOnClickOutside) {
161
179
  document.addEventListener('mousedown', handleClickOutside);
@@ -165,6 +183,7 @@ export const Dropdown = (props) => {
165
183
  }
166
184
  }, [closeOnClickOutside]);
167
185
  // ESC 키로 닫고 trigger로 포커스 복귀
186
+ // biome-ignore lint/correctness/useExhaustiveDependencies: closeAndRestoreFocus는 안정적 참조
168
187
  useEffect(() => {
169
188
  if (!isOpen)
170
189
  return;
@@ -261,13 +280,18 @@ export const Dropdown = (props) => {
261
280
  const dropdownClasses = ['ncua-dropdown', className, align === 'right' ? 'ncua-dropdown--right' : '']
262
281
  .filter(Boolean)
263
282
  .join(' ');
264
- return (_jsxs("div", { className: dropdownClasses, ref: dropdownRef, children: [renderTrigger(), isOpen && (_jsxs("div", { className: "ncua-dropdown__menu", role: variant === 'config' ? 'dialog' : 'menu', "aria-label": variant === 'config' ? '설정' : undefined, children: [renderHeader(), _jsx("div", { ref: menuItemsRef, className: "ncua-dropdown__menu-items", children: groups.map((group) => {
265
- // config variant uses draft.order to drive the rendered order
266
- const orderedItems = variant === 'config' && draft
267
- ? draft.order
268
- .map((id) => group.items.find((i) => i.id === id))
269
- .filter((i) => i !== undefined)
270
- : group.items;
271
- return (_jsx("div", { className: "ncua-dropdown__group", children: orderedItems.map((item) => variant === 'config' ? renderConfigItem(item, group.sortable === true) : renderActionItem(item)) }, group.items[0]?.id));
272
- }) }), renderFooter()] }))] }));
283
+ const menuClasses = ['ncua-dropdown__menu', shouldPortal ? 'ncua-dropdown__menu--portal' : '']
284
+ .filter(Boolean)
285
+ .join(' ');
286
+ const menuNode = isOpen ? (_jsxs("div", { ref: menuRef, className: menuClasses, role: variant === 'config' ? 'dialog' : 'menu', "aria-label": variant === 'config' ? '설정' : undefined, style: shouldPortal && floatingStyle ? floatingStyle : undefined, children: [renderHeader(), _jsx("div", { ref: menuItemsRef, className: "ncua-dropdown__menu-items", children: groups.map((group) => {
287
+ // config variant uses draft.order to drive the rendered order
288
+ const orderedItems = variant === 'config' && draft
289
+ ? draft.order
290
+ .map((id) => group.items.find((i) => i.id === id))
291
+ .filter((i) => i !== undefined)
292
+ : group.items;
293
+ return (_jsx("div", { className: "ncua-dropdown__group", children: orderedItems.map((item) => variant === 'config' ? renderConfigItem(item, group.sortable === true) : renderActionItem(item)) }, group.items[0]?.id));
294
+ }) }), renderFooter()] })) : null;
295
+ const portaledMenu = shouldPortal && portalContainer && menuNode ? createPortal(menuNode, portalContainer) : null;
296
+ return (_jsxs("div", { className: dropdownClasses, ref: dropdownRef, children: [renderTrigger(), !shouldPortal && menuNode, portaledMenu] }));
273
297
  };
@@ -0,0 +1,9 @@
1
+ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
+ import type { NotificationColor } from './Notification';
3
+ interface CalloutNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
4
+ color?: NotificationColor;
5
+ title?: ReactNode;
6
+ }
7
+ declare const CalloutNotification: import("react").ForwardRefExoticComponent<CalloutNotificationProps & import("react").RefAttributes<HTMLDivElement>>;
8
+ export type { CalloutNotificationProps };
9
+ export { CalloutNotification };
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import classNames from 'classnames';
3
+ import { forwardRef } from 'react';
4
+ const CalloutNotification = forwardRef(({ color = 'neutral', className, title, ...rest }, ref) => (_jsx("div", { ref: ref, className: classNames('ncua-callout-notification', `ncua-callout-notification--${color}`, className), ...rest, children: title })));
5
+ CalloutNotification.displayName = 'CalloutNotification';
6
+ export { CalloutNotification };
@@ -31,6 +31,21 @@ interface FloatingNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>
31
31
  * @default 0
32
32
  */
33
33
  autoClose?: number;
34
+ /**
35
+ * Portal 마운트 여부.
36
+ *
37
+ * - `false` (기본값): 부모 JSX 트리 안에 카드로 그대로 렌더된다.
38
+ * 특정 컨테이너 안에서 표시하거나, 인라인 미리보기 (docs/스토리북) 용도.
39
+ *
40
+ * - `true`: document.body의 `.ncua-floating-notification-host` 싱글톤에
41
+ * `createPortal`로 마운트되어 우측 상단에 노출. 다중 발생 시 최신 토스트가
42
+ * 상단에 노출되고 이전 토스트들이 아래로 12px씩 겹쳐 쌓인다 (LIFO,
43
+ * `--ncua-floating-notification-stack-overlap` 변수로 조정 가능).
44
+ * Vanilla(CDN) 측 `new ncua.Notification({type:'floating'}).show()` 와 동일한 동작.
45
+ *
46
+ * @default false
47
+ */
48
+ portal?: boolean;
34
49
  }
35
50
  declare const FloatingNotification: import("react").ForwardRefExoticComponent<FloatingNotificationProps & import("react").RefAttributes<HTMLDivElement>>;
36
51
  export type { FloatingNotificationProps };