@ncds/ui-admin 1.8.3 → 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 (217) 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/index.js +33 -0
  23. package/dist/cjs/src/components/layout/block-container/BlockContainer.js +38 -0
  24. package/dist/cjs/src/components/layout/block-container/index.js +16 -0
  25. package/dist/cjs/src/components/layout/block-header/BlockHeader.js +107 -0
  26. package/dist/cjs/src/components/layout/block-header/SubTitle.js +56 -0
  27. package/dist/cjs/src/components/layout/block-header/index.js +27 -0
  28. package/dist/cjs/src/components/layout/page-title/PageTitle.js +95 -0
  29. package/dist/cjs/src/components/layout/page-title/index.js +16 -0
  30. package/dist/cjs/src/components/overlays/dropdown/Dropdown.js +47 -19
  31. package/dist/cjs/src/components/overlays/notification/CalloutNotification.js +25 -0
  32. package/dist/cjs/src/components/overlays/notification/FloatingNotification.js +86 -13
  33. package/dist/cjs/src/components/overlays/notification/Notification.js +7 -0
  34. package/dist/cjs/src/components/overlays/notification/host.js +12 -0
  35. package/dist/cjs/src/components/overlays/tooltip/Tooltip.js +57 -44
  36. package/dist/cjs/src/components/select-dropdown/SelectDropdown.js +2 -1
  37. package/dist/cjs/src/contexts/FloatingContext.js +11 -0
  38. package/dist/cjs/src/contexts/index.js +16 -0
  39. package/dist/cjs/src/hooks/index.js +11 -0
  40. package/dist/cjs/src/hooks/useFloatingPosition.js +78 -0
  41. package/dist/cjs/src/hooks/usePortalState.js +17 -0
  42. package/dist/cjs/src/utils/dropdown/maxSelection.js +35 -0
  43. package/dist/cjs/src/utils/dropdown/multiSelect.js +72 -15
  44. package/dist/esm/assets/scripts/featuredIcon.js +80 -0
  45. package/dist/esm/assets/scripts/notification/FloatingNotification.js +171 -0
  46. package/dist/esm/assets/scripts/notification/FullWidthNotification.js +126 -0
  47. package/dist/esm/assets/scripts/notification/MessageNotification.js +152 -0
  48. package/dist/esm/assets/scripts/notification/Notification.js +113 -0
  49. package/dist/esm/assets/scripts/notification/const/classNames.js +44 -0
  50. package/dist/esm/assets/scripts/notification/const/icons.js +25 -0
  51. package/dist/esm/assets/scripts/notification/const/index.js +4 -0
  52. package/dist/esm/assets/scripts/notification/const/sizes.js +40 -0
  53. package/dist/esm/assets/scripts/notification/const/types.js +8 -0
  54. package/dist/esm/assets/scripts/notification/index.js +10 -0
  55. package/dist/esm/assets/scripts/notification/positionSync.js +171 -0
  56. package/dist/esm/assets/scripts/notification/utils.js +109 -0
  57. package/dist/esm/assets/scripts/shared/ButtonCloseX.js +37 -0
  58. package/dist/esm/assets/scripts/utils/sanitize.js +31 -0
  59. package/dist/esm/src/components/data-display/data-grid/DataGrid.js +5 -1
  60. package/dist/esm/src/components/data-display/table/Table.js +118 -96
  61. package/dist/esm/src/components/data-display/table/useTableScrollbars.js +179 -0
  62. package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +11 -10
  63. package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +5 -2
  64. package/dist/esm/src/components/forms-and-input/select-box/SelectBox.js +67 -29
  65. package/dist/esm/src/components/index.js +3 -0
  66. package/dist/esm/src/components/layout/block-container/BlockContainer.js +31 -0
  67. package/dist/esm/src/components/layout/block-container/index.js +1 -0
  68. package/dist/esm/src/components/layout/block-header/BlockHeader.js +100 -0
  69. package/dist/esm/src/components/layout/block-header/SubTitle.js +49 -0
  70. package/dist/esm/src/components/layout/block-header/index.js +2 -0
  71. package/dist/esm/src/components/layout/page-title/PageTitle.js +88 -0
  72. package/dist/esm/src/components/layout/page-title/index.js +1 -0
  73. package/dist/esm/src/components/overlays/dropdown/Dropdown.js +47 -19
  74. package/dist/esm/src/components/overlays/notification/CalloutNotification.js +19 -0
  75. package/dist/esm/src/components/overlays/notification/FloatingNotification.js +86 -14
  76. package/dist/esm/src/components/overlays/notification/Notification.js +7 -0
  77. package/dist/esm/src/components/overlays/notification/host.js +9 -0
  78. package/dist/esm/src/components/overlays/tooltip/Tooltip.js +58 -45
  79. package/dist/esm/src/components/select-dropdown/SelectDropdown.js +2 -1
  80. package/dist/esm/src/contexts/FloatingContext.js +4 -0
  81. package/dist/esm/src/contexts/index.js +1 -0
  82. package/dist/esm/src/hooks/index.js +1 -0
  83. package/dist/esm/src/hooks/useFloatingPosition.js +71 -0
  84. package/dist/esm/src/hooks/usePortalState.js +10 -0
  85. package/dist/esm/src/utils/dropdown/maxSelection.js +27 -0
  86. package/dist/esm/src/utils/dropdown/multiSelect.js +70 -14
  87. package/dist/temp/assets/scripts/featuredIcon.d.ts +22 -0
  88. package/dist/temp/assets/scripts/featuredIcon.js +79 -0
  89. package/dist/temp/assets/scripts/notification/FloatingNotification.d.ts +24 -0
  90. package/dist/temp/assets/scripts/notification/FloatingNotification.js +156 -0
  91. package/dist/temp/assets/scripts/notification/FullWidthNotification.d.ts +21 -0
  92. package/dist/temp/assets/scripts/notification/FullWidthNotification.js +111 -0
  93. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +22 -0
  94. package/dist/temp/assets/scripts/notification/MessageNotification.js +140 -0
  95. package/dist/temp/assets/scripts/notification/Notification.d.ts +22 -0
  96. package/dist/temp/assets/scripts/notification/Notification.js +112 -0
  97. package/dist/temp/assets/scripts/notification/const/classNames.d.ts +43 -0
  98. package/dist/temp/assets/scripts/notification/const/classNames.js +44 -0
  99. package/dist/temp/assets/scripts/notification/const/icons.d.ts +25 -0
  100. package/dist/temp/assets/scripts/notification/const/icons.js +25 -0
  101. package/dist/temp/assets/scripts/notification/const/index.d.ts +5 -0
  102. package/dist/temp/assets/scripts/notification/const/index.js +4 -0
  103. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +32 -0
  104. package/dist/temp/assets/scripts/notification/const/sizes.js +40 -0
  105. package/dist/temp/assets/scripts/notification/const/types.d.ts +19 -0
  106. package/dist/temp/assets/scripts/notification/const/types.js +8 -0
  107. package/dist/temp/assets/scripts/notification/index.d.ts +8 -0
  108. package/dist/temp/assets/scripts/notification/index.js +10 -0
  109. package/dist/temp/assets/scripts/notification/positionSync.d.ts +50 -0
  110. package/dist/temp/assets/scripts/notification/positionSync.js +170 -0
  111. package/dist/temp/assets/scripts/notification/utils.d.ts +8 -0
  112. package/dist/temp/assets/scripts/notification/utils.js +115 -0
  113. package/dist/temp/assets/scripts/shared/ButtonCloseX.d.ts +5 -0
  114. package/dist/temp/assets/scripts/shared/ButtonCloseX.js +33 -0
  115. package/dist/temp/assets/scripts/utils/sanitize.d.ts +22 -0
  116. package/dist/temp/assets/scripts/utils/sanitize.js +31 -0
  117. package/dist/temp/src/components/data-display/data-grid/DataGrid.js +1 -1
  118. package/dist/temp/src/components/data-display/data-grid/DataGrid.types.d.ts +7 -0
  119. package/dist/temp/src/components/data-display/table/Table.d.ts +4 -1
  120. package/dist/temp/src/components/data-display/table/Table.js +53 -68
  121. package/dist/temp/src/components/data-display/table/types.d.ts +18 -0
  122. package/dist/temp/src/components/data-display/table/useTableScrollbars.d.ts +25 -0
  123. package/dist/temp/src/components/data-display/table/useTableScrollbars.js +136 -0
  124. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +8 -0
  125. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +7 -11
  126. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +1 -1
  127. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.d.ts +13 -0
  128. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.js +30 -3
  129. package/dist/temp/src/components/index.d.ts +3 -0
  130. package/dist/temp/src/components/index.js +3 -0
  131. package/dist/temp/src/components/layout/block-container/BlockContainer.d.ts +19 -0
  132. package/dist/temp/src/components/layout/block-container/BlockContainer.js +11 -0
  133. package/dist/temp/src/components/layout/block-container/index.d.ts +1 -0
  134. package/dist/temp/src/components/layout/block-container/index.js +1 -0
  135. package/dist/temp/src/components/layout/block-header/BlockHeader.d.ts +23 -0
  136. package/dist/temp/src/components/layout/block-header/BlockHeader.js +21 -0
  137. package/dist/temp/src/components/layout/block-header/SubTitle.d.ts +19 -0
  138. package/dist/temp/src/components/layout/block-header/SubTitle.js +8 -0
  139. package/dist/temp/src/components/layout/block-header/index.d.ts +2 -0
  140. package/dist/temp/src/components/layout/block-header/index.js +2 -0
  141. package/dist/temp/src/components/layout/page-title/PageTitle.d.ts +22 -0
  142. package/dist/temp/src/components/layout/page-title/PageTitle.js +19 -0
  143. package/dist/temp/src/components/layout/page-title/index.d.ts +1 -0
  144. package/dist/temp/src/components/layout/page-title/index.js +1 -0
  145. package/dist/temp/src/components/overlays/dropdown/Dropdown.d.ts +5 -0
  146. package/dist/temp/src/components/overlays/dropdown/Dropdown.js +35 -11
  147. package/dist/temp/src/components/overlays/notification/CalloutNotification.d.ts +9 -0
  148. package/dist/temp/src/components/overlays/notification/CalloutNotification.js +6 -0
  149. package/dist/temp/src/components/overlays/notification/FloatingNotification.d.ts +15 -0
  150. package/dist/temp/src/components/overlays/notification/FloatingNotification.js +81 -13
  151. package/dist/temp/src/components/overlays/notification/Notification.d.ts +18 -3
  152. package/dist/temp/src/components/overlays/notification/Notification.js +4 -0
  153. package/dist/temp/src/components/overlays/notification/host.d.ts +9 -0
  154. package/dist/temp/src/components/overlays/notification/host.js +9 -0
  155. package/dist/temp/src/components/overlays/tooltip/Tooltip.d.ts +5 -1
  156. package/dist/temp/src/components/overlays/tooltip/Tooltip.js +25 -22
  157. package/dist/temp/src/components/select-dropdown/SelectDropdown.d.ts +6 -0
  158. package/dist/temp/src/components/select-dropdown/SelectDropdown.js +2 -2
  159. package/dist/temp/src/contexts/FloatingContext.d.ts +6 -0
  160. package/dist/temp/src/contexts/FloatingContext.js +4 -0
  161. package/dist/temp/src/contexts/index.d.ts +1 -0
  162. package/dist/temp/src/contexts/index.js +1 -0
  163. package/dist/temp/src/hooks/index.d.ts +1 -0
  164. package/dist/temp/src/hooks/index.js +1 -0
  165. package/dist/temp/src/hooks/useFloatingPosition.d.ts +19 -0
  166. package/dist/temp/src/hooks/useFloatingPosition.js +55 -0
  167. package/dist/temp/src/hooks/usePortalState.d.ts +6 -0
  168. package/dist/temp/src/hooks/usePortalState.js +7 -0
  169. package/dist/temp/src/utils/dropdown/maxSelection.d.ts +24 -0
  170. package/dist/temp/src/utils/dropdown/maxSelection.js +28 -0
  171. package/dist/temp/src/utils/dropdown/multiSelect.d.ts +42 -2
  172. package/dist/temp/src/utils/dropdown/multiSelect.js +66 -13
  173. package/dist/types/assets/scripts/featuredIcon.d.ts +22 -0
  174. package/dist/types/assets/scripts/notification/FloatingNotification.d.ts +24 -0
  175. package/dist/types/assets/scripts/notification/FullWidthNotification.d.ts +21 -0
  176. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +22 -0
  177. package/dist/types/assets/scripts/notification/Notification.d.ts +22 -0
  178. package/dist/types/assets/scripts/notification/const/classNames.d.ts +43 -0
  179. package/dist/types/assets/scripts/notification/const/icons.d.ts +25 -0
  180. package/dist/types/assets/scripts/notification/const/index.d.ts +5 -0
  181. package/dist/types/assets/scripts/notification/const/sizes.d.ts +32 -0
  182. package/dist/types/assets/scripts/notification/const/types.d.ts +19 -0
  183. package/dist/types/assets/scripts/notification/index.d.ts +8 -0
  184. package/dist/types/assets/scripts/notification/positionSync.d.ts +50 -0
  185. package/dist/types/assets/scripts/notification/utils.d.ts +8 -0
  186. package/dist/types/assets/scripts/shared/ButtonCloseX.d.ts +5 -0
  187. package/dist/types/assets/scripts/utils/sanitize.d.ts +22 -0
  188. package/dist/types/src/components/data-display/data-grid/DataGrid.types.d.ts +7 -0
  189. package/dist/types/src/components/data-display/table/Table.d.ts +4 -1
  190. package/dist/types/src/components/data-display/table/types.d.ts +18 -0
  191. package/dist/types/src/components/data-display/table/useTableScrollbars.d.ts +25 -0
  192. package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +8 -0
  193. package/dist/types/src/components/forms-and-input/select-box/SelectBox.d.ts +13 -0
  194. package/dist/types/src/components/index.d.ts +3 -0
  195. package/dist/types/src/components/layout/block-container/BlockContainer.d.ts +19 -0
  196. package/dist/types/src/components/layout/block-container/index.d.ts +1 -0
  197. package/dist/types/src/components/layout/block-header/BlockHeader.d.ts +23 -0
  198. package/dist/types/src/components/layout/block-header/SubTitle.d.ts +19 -0
  199. package/dist/types/src/components/layout/block-header/index.d.ts +2 -0
  200. package/dist/types/src/components/layout/page-title/PageTitle.d.ts +22 -0
  201. package/dist/types/src/components/layout/page-title/index.d.ts +1 -0
  202. package/dist/types/src/components/overlays/dropdown/Dropdown.d.ts +5 -0
  203. package/dist/types/src/components/overlays/notification/CalloutNotification.d.ts +9 -0
  204. package/dist/types/src/components/overlays/notification/FloatingNotification.d.ts +15 -0
  205. package/dist/types/src/components/overlays/notification/Notification.d.ts +18 -3
  206. package/dist/types/src/components/overlays/notification/host.d.ts +9 -0
  207. package/dist/types/src/components/overlays/tooltip/Tooltip.d.ts +5 -1
  208. package/dist/types/src/components/select-dropdown/SelectDropdown.d.ts +6 -0
  209. package/dist/types/src/contexts/FloatingContext.d.ts +6 -0
  210. package/dist/types/src/contexts/index.d.ts +1 -0
  211. package/dist/types/src/hooks/index.d.ts +1 -0
  212. package/dist/types/src/hooks/useFloatingPosition.d.ts +19 -0
  213. package/dist/types/src/hooks/usePortalState.d.ts +6 -0
  214. package/dist/types/src/utils/dropdown/maxSelection.d.ts +24 -0
  215. package/dist/types/src/utils/dropdown/multiSelect.d.ts +42 -2
  216. package/dist/ui-admin/assets/styles/style.css +596 -64
  217. package/package.json +1 -1
@@ -1,56 +1,124 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AlertCircle, AlertTriangle, CheckCircle, Pin02 } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
- import { forwardRef, useEffect, useRef, useState } from 'react';
4
+ import { forwardRef, useEffect, useId, useRef, useState } from 'react';
5
+ import { createPortal } from 'react-dom';
5
6
  import { MEDIA_QUERY } from '../../../constant/breakpoint';
6
7
  import { useMediaQuery } from '../../../hooks/useMediaQuery';
7
8
  import { Button } from '../../action/button';
8
9
  import { ButtonCloseX } from '../../action/button/ButtonCloseX';
9
10
  import { FeaturedIcon, } from '../../image-and-icons/featured-icon/FeaturedIcon';
11
+ // 호스트 싱글톤은 vanilla/React 양쪽이 동일한 함수를 공유한다 — host.ts 가 가까운 진입점 역할을
12
+ // 하므로 React 컴포넌트는 deep relative path 로 vanilla internals 를 직접 import 하지 않는다.
13
+ import { mountFloatingNotificationHost } from './host';
14
+ /**
15
+ * 색상별 a11y role.
16
+ * - error/warning → role="alert" (implicit aria-live="assertive": 즉시 발화)
17
+ * - 그 외 → role="status" (implicit aria-live="polite": 현재 발화 끝난 후 발화)
18
+ * role 이 implicit live-region 을 가져오므로 호스트 컨테이너에는 aria-live 를 두지 않는다.
19
+ */
20
+ const ASSERTIVE_COLORS = {
21
+ error: true,
22
+ warning: true,
23
+ };
10
24
  const iconMap = {
11
25
  neutral: Pin02,
12
26
  error: AlertTriangle,
13
27
  warning: AlertCircle,
14
28
  success: CheckCircle,
15
- // info는 floating에서는 지원하지 않음
29
+ // info full-width 전용이라 floating 아이콘 매핑 없음.
30
+ };
31
+ /**
32
+ * NotificationColor → FeaturedIconColor 매핑.
33
+ * `info` 는 FeaturedIconColor 에 존재하지 않으므로 `neutral` 로 안전 fallback.
34
+ * `satisfies` 가 매핑 누락을 컴파일 타임에 강제하므로 향후 색상이 추가되면 즉시 타입 에러로 잡힌다.
35
+ */
36
+ const iconColorMap = {
37
+ neutral: 'neutral',
38
+ error: 'error',
39
+ warning: 'warning',
40
+ success: 'success',
41
+ info: 'neutral',
16
42
  };
17
- const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, ...rest }, ref) => {
43
+ /**
44
+ * `.ncua-floating-notification-host` 싱글톤을 보장하고 반환하는 훅.
45
+ *
46
+ * 이름의 `useMount...` 는 단순 조회가 아닌 **DOM 부수효과** 를 동반함을 신호한다 —
47
+ * 내부적으로 `mountFloatingNotificationHost()` 를 호출해 document.body 에 호스트를 append 하고
48
+ * (없으면) 글로벌 scroll/resize/MutationObserver 까지 부착한다. 호스트는 페이지 lifetime
49
+ * 싱글톤이므로 언마운트 시 제거하지 않는다.
50
+ *
51
+ * `mountFloatingNotificationHost` 는 **동기 함수** 이므로 effect 안의 호출 → setHost 가 같은 tick
52
+ * 에 끝나 cancellation race 가 없다. cancel flag 는 의미 없어 두지 않는다.
53
+ */
54
+ function useMountFloatingNotificationHost(enabled) {
55
+ const [host, setHost] = useState(null);
56
+ useEffect(() => {
57
+ if (!enabled)
58
+ return;
59
+ setHost(mountFloatingNotificationHost());
60
+ }, [enabled]);
61
+ return host;
62
+ }
63
+ const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, portal = false, ...rest }, ref) => {
18
64
  const [shouldRemove, setShouldRemove] = useState(false);
19
- const iconColor = color;
20
65
  const featuredIconProps = {
21
66
  icon: iconMap[color] || Pin02,
22
67
  size: 'sm',
23
- color: iconColor,
68
+ color: iconColorMap[color],
24
69
  theme: 'dark-circle',
25
70
  };
26
71
  const isMobile = useMediaQuery(MEDIA_QUERY.mobile, {
27
72
  onMatched: onClose,
28
73
  });
74
+ // onClose 는 매 렌더 새 함수 ref 일 수 있으므로 ref 로 latest 만 보관.
75
+ // → autoClose 타이머 effect 의 deps 에서 onClose 를 빼서 타이머 재시작 회피.
76
+ // render 본문에서 직접 할당 — useEffect 로 미루면 같은 commit 안에서 fire 되는 타이머가 stale onClose 를 볼 위험.
77
+ const onCloseRef = useRef(onClose);
78
+ onCloseRef.current = onClose;
29
79
  // autoClose 타이머 관리
30
80
  const timerRef = useRef(null);
81
+ // onClose 는 onCloseRef 로 latest 만 추적하므로 deps 에서 제외 (의도된 패턴).
31
82
  useEffect(() => {
32
- // autoClose가 0보다 크면 무조건 타이머 설정
83
+ // autoClose 가 0 보다 크면 타이머 설정.
33
84
  if (autoClose > 0) {
34
85
  timerRef.current = setTimeout(() => {
35
- if (onClose) {
36
- onClose();
37
- }
38
- // DOM에서 바로 제거
86
+ onCloseRef.current?.();
87
+ // DOM 에서 바로 제거
39
88
  setShouldRemove(true);
40
89
  }, autoClose);
41
90
  }
42
- // cleanup 함수: 컴포넌트 언마운트 시 타이머 정리
91
+ // cleanup: 컴포넌트 언마운트 또는 autoClose 변경 시 타이머 정리
43
92
  return () => {
44
93
  if (timerRef.current) {
45
94
  clearTimeout(timerRef.current);
46
95
  timerRef.current = null;
47
96
  }
48
97
  };
49
- }, [autoClose, onClose]);
98
+ }, [autoClose]);
99
+ // portal=true 일 때만 호스트가 필요하다.
100
+ const host = useMountFloatingNotificationHost(portal);
101
+ // a11y — 카드 root 의 role 과 제목/본문 ID. 색상에 따라 alert(error/warning) / status(나머지) 로 분기.
102
+ const reactId = useId();
103
+ const titleId = `${reactId}-title`;
104
+ const descId = `${reactId}-desc`;
105
+ const role = ASSERTIVE_COLORS[color] ? 'alert' : 'status';
50
106
  // DOM에서 완전히 제거
51
107
  if (shouldRemove) {
52
108
  return null;
53
109
  }
54
- return (_jsxs("div", { ref: ref, className: classNames('ncua-floating-notification', `ncua-floating-notification--${color}`, className), role: "alert", ...rest, children: [_jsx("div", { className: "ncua-floating-notification__content", children: _jsxs("div", { className: "ncua-floating-notification__container", children: [iconMap[color] && _jsx(FeaturedIcon, { ...featuredIconProps, size: isMobile ? 'md' : 'sm' }), _jsxs("div", { className: "ncua-floating-notification__text-container", children: [_jsx("div", { className: "ncua-floating-notification__title-wrapper", children: _jsx("span", { className: "ncua-floating-notification__title", children: title }) }), supportingText && _jsx("span", { className: "ncua-floating-notification__supporting-text", children: supportingText }), actions && (_jsx("div", { className: "ncua-floating-notification__actions", children: actions.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action?.onClick }, `${action.label}-${action.hierarchy}`))) }))] })] }) }), onClose && (_jsx(ButtonCloseX, { theme: "light", className: "ncua-floating-notification__close-button", size: isMobile ? 'sm' : 'xs', onClick: onClose }))] }));
110
+ const card = (
111
+ // a11y/예측가능성 — caller 가 className/id 등 일부는 덮어쓸 수 있게 {...rest} 를 먼저 펼치고,
112
+ // role/aria-labelledby/aria-describedby 같은 컴포넌트 본질 prop 은 그 뒤에 두어 우선 적용.
113
+ _jsxs("div", { ...rest, ref: ref, className: classNames('ncua-floating-notification', `ncua-floating-notification--${color}`, className), role: role, "aria-labelledby": titleId, "aria-describedby": supportingText ? descId : undefined, children: [_jsx("div", { className: "ncua-floating-notification__content", children: _jsxs("div", { className: "ncua-floating-notification__container", children: [iconMap[color] && _jsx(FeaturedIcon, { ...featuredIconProps, size: isMobile ? 'md' : 'sm' }), _jsxs("div", { className: "ncua-floating-notification__text-container", children: [_jsx("div", { className: "ncua-floating-notification__title-wrapper", children: _jsx("span", { id: titleId, className: "ncua-floating-notification__title", children: title }) }), supportingText && (_jsx("span", { id: descId, className: "ncua-floating-notification__supporting-text", children: supportingText })), actions && (_jsx("div", { className: "ncua-floating-notification__actions", children: actions.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action?.onClick }, `${action.label}-${action.hierarchy}`))) }))] })] }) }), onClose && (_jsx(ButtonCloseX, { theme: "light", className: "ncua-floating-notification__close-button", size: isMobile ? 'sm' : 'xs', onClick: onClose }))] }));
114
+ // 기본은 인라인 렌더 — 부모 JSX 트리에 카드를 그대로 둔다.
115
+ if (!portal) {
116
+ return card;
117
+ }
118
+ // portal=true 인데 SSR이거나 첫 렌더(호스트 미생성)에서는 null. useEffect 후 host 설정되면 재렌더되어 portal 마운트.
119
+ if (!host) {
120
+ return null;
121
+ }
122
+ return createPortal(card, host);
55
123
  });
56
124
  export { FloatingNotification };
@@ -2,7 +2,8 @@ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import type { ColorTone } from '../../../../constant/color';
3
3
  import type { SlotIconComponent } from '../../../types/side-slot';
4
4
  import type { ButtonTheme } from '../../action/button';
5
- type NotificationType = 'floating' | 'full-width' | 'message';
5
+ import { type CalloutNotificationProps } from './CalloutNotification';
6
+ type NotificationType = 'floating' | 'full-width' | 'message' | 'callout';
6
7
  type NotificationColor = Extract<ColorTone, 'neutral' | 'error' | 'warning' | 'success' | 'info'>;
7
8
  type NotificationSize = 'desktop' | 'mobile';
8
9
  interface NotificationAction {
@@ -26,9 +27,9 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
26
27
  */
27
28
  type?: NotificationType;
28
29
  /**
29
- * 알림 제목 텍스트
30
+ * 알림 제목 텍스트 (callout 유형에서는 안내 텍스트로 사용)
30
31
  */
31
- title: ReactNode;
32
+ title?: ReactNode;
32
33
  /**
33
34
  * 알림 본문 텍스트 (선택사항)
34
35
  */
@@ -66,7 +67,21 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
66
67
  * message, full-width 타입에서 사용 가능
67
68
  */
68
69
  onHidePermanently?: () => void;
70
+ /**
71
+ * Portal 마운트 여부. `floating` 타입에서만 동작한다.
72
+ *
73
+ * 기본값 `false`에서는 부모 JSX 트리 안에 카드로 그대로 렌더된다.
74
+ * `true`로 설정하면 자동으로 document.body의 `.ncua-floating-notification-host`
75
+ * 호스트에 createPortal로 마운트되어 우측 상단에 노출되고 LIFO로 스택된다
76
+ * — 최신 토스트가 상단에 노출되고 이전 토스트들이 아래로 12px씩 겹쳐 쌓인다
77
+ * (vanilla `Notification.show()`와 동일, `--ncua-floating-notification-stack-overlap`
78
+ * 변수로 조정 가능).
79
+ *
80
+ * @default false
81
+ */
82
+ portal?: boolean;
69
83
  }
70
84
  declare const Notification: import("react").ForwardRefExoticComponent<NotificationProps & import("react").RefAttributes<HTMLDivElement>>;
71
85
  export type { NotificationType, NotificationColor, NotificationSize, NotificationAction, NotificationProps };
86
+ export type { CalloutNotificationProps };
72
87
  export { Notification };
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef } from 'react';
3
+ import { CalloutNotification } from './CalloutNotification';
3
4
  import { FloatingNotification } from './FloatingNotification';
4
5
  import { FullWidthNotification } from './FullWidthNotification';
5
6
  import { MessageNotification } from './MessageNotification';
@@ -13,6 +14,9 @@ const Notification = forwardRef(({ type = 'floating', color = 'neutral', ...rest
13
14
  if (type === 'message') {
14
15
  return _jsx(MessageNotification, { color: color, ...rest, ref: ref });
15
16
  }
17
+ if (type === 'callout') {
18
+ return _jsx(CalloutNotification, { color: color, ...rest });
19
+ }
16
20
  return null;
17
21
  });
18
22
  Notification.displayName = 'Notification';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Floating Notification 호스트 관리 — React 측 진입점.
3
+ *
4
+ * 실제 구현은 vanilla/React 가 공유하는 `assets/scripts/notification/positionSync.ts` 에 있고,
5
+ * 외부 노출은 그 폴더의 public barrel(`assets/scripts/notification/index.ts`)이 담당한다.
6
+ * 이 모듈은 React 컴포넌트가 vanilla 내부 파일을 직접 참조하지 않도록 barrel 을 경유하는
7
+ * 한 단계 re-export 만 제공한다 — positionSync.ts 의 위치/이름이 바뀌어도 host.ts 만 영향.
8
+ */
9
+ export { mountFloatingNotificationHost } from '../../../../assets/scripts/notification';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Floating Notification 호스트 관리 — React 측 진입점.
3
+ *
4
+ * 실제 구현은 vanilla/React 가 공유하는 `assets/scripts/notification/positionSync.ts` 에 있고,
5
+ * 외부 노출은 그 폴더의 public barrel(`assets/scripts/notification/index.ts`)이 담당한다.
6
+ * 이 모듈은 React 컴포넌트가 vanilla 내부 파일을 직접 참조하지 않도록 barrel 을 경유하는
7
+ * 한 단계 re-export 만 제공한다 — positionSync.ts 의 위치/이름이 바뀌어도 host.ts 만 영향.
8
+ */
9
+ export { mountFloatingNotificationHost } from '../../../../assets/scripts/notification';
@@ -12,6 +12,10 @@ interface TooltipProps {
12
12
  iconColor?: string;
13
13
  iconStyle?: 'help-circle' | 'alert-circle';
14
14
  zIndex?: number;
15
+ /** 외부에서 직접 패널 표시 여부를 제어 */
16
+ forceVisible?: boolean;
17
+ /** true이면 Portal 없이 앵커 내부에 패널을 인라인 렌더링. CSS 기반 위치 지정이 필요한 경우 사용 */
18
+ disablePortal?: boolean;
15
19
  }
16
- export declare const Tooltip: ({ tooltipType, iconType, position, size, title, content, hideArrow, type, iconColor, iconStyle, className, zIndex, }: TooltipProps) => import("react/jsx-runtime").JSX.Element;
20
+ export declare const Tooltip: ({ tooltipType, iconType, position, size, title, content, hideArrow, type, iconColor, iconStyle, className, zIndex, forceVisible, disablePortal, }: TooltipProps) => import("react/jsx-runtime").JSX.Element;
17
21
  export {};
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AlertCircle, AlertCircleFill, HelpCircle, HelpCircleFill } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -74,7 +74,19 @@ const computePanelCoords = (prefer, anchor, panel) => {
74
74
  top = Math.max(VIEWPORT_MARGIN, Math.min(top, window.innerHeight - panel.height - VIEWPORT_MARGIN));
75
75
  return { top: Math.round(top), left: Math.round(left), calculatedPosition };
76
76
  };
77
- export const Tooltip = ({ tooltipType = 'white', iconType = 'stroke', position = 'auto', size = 'sm', title, content, hideArrow = false, type = 'short', iconColor = 'var(--gray-300)', iconStyle = 'help-circle', className, zIndex, }) => {
77
+ const buildPanelStyle = (disablePortal, coords, visible, zIndex) => {
78
+ const opacity = visible ? 1 : 0;
79
+ if (disablePortal)
80
+ return { opacity };
81
+ return { top: `${coords.top}px`, left: `${coords.left}px`, opacity, ...(zIndex && { zIndex }) };
82
+ };
83
+ const renderIcon = (iconStyle, iconType, iconSize, iconColor) => {
84
+ if (iconStyle === 'help-circle') {
85
+ return iconType === 'stroke' ? (_jsx(HelpCircle, { width: iconSize, height: iconSize, color: iconColor })) : (_jsx(HelpCircleFill, { width: iconSize, height: iconSize, color: iconColor }));
86
+ }
87
+ return iconType === 'stroke' ? (_jsx(AlertCircle, { width: iconSize, height: iconSize, color: iconColor })) : (_jsx(AlertCircleFill, { width: iconSize, height: iconSize, color: iconColor }));
88
+ };
89
+ export const Tooltip = ({ tooltipType = 'white', iconType = 'stroke', position = 'auto', size = 'sm', title, content, hideArrow = false, type = 'short', iconColor = 'var(--gray-300)', iconStyle = 'help-circle', className, zIndex, forceVisible, disablePortal = false, }) => {
78
90
  const iconSize = size === 'sm' ? ICON_SIZE_SM : ICON_SIZE_DEFAULT;
79
91
  const anchorRef = useRef(null);
80
92
  const panelRef = useRef(null);
@@ -84,6 +96,7 @@ export const Tooltip = ({ tooltipType = 'white', iconType = 'stroke', position =
84
96
  const [calculatedPosition, setCalculatedPosition] = useState(position === 'auto' ? 'bottom' : position);
85
97
  const [isVisible, setIsVisible] = useState(false);
86
98
  const [isManuallyClose, setIsManuallyClose] = useState(false);
99
+ const effectiveVisible = forceVisible ?? isVisible;
87
100
  useEffect(() => {
88
101
  setMounted(true);
89
102
  return () => {
@@ -94,16 +107,16 @@ export const Tooltip = ({ tooltipType = 'white', iconType = 'stroke', position =
94
107
  };
95
108
  }, []);
96
109
  const updatePosition = useCallback(() => {
97
- if (!anchorRef.current || !panelRef.current)
110
+ if (disablePortal || !anchorRef.current || !panelRef.current)
98
111
  return;
99
112
  const anchor = anchorRef.current.getBoundingClientRect();
100
113
  const panel = panelRef.current.getBoundingClientRect();
101
114
  const next = computePanelCoords(position, anchor, panel);
102
115
  setCoords({ top: next.top, left: next.left });
103
116
  setCalculatedPosition(next.calculatedPosition);
104
- }, [position]);
117
+ }, [position, disablePortal]);
105
118
  useEffect(() => {
106
- if (!isVisible)
119
+ if (!effectiveVisible || disablePortal)
107
120
  return;
108
121
  updatePosition();
109
122
  window.addEventListener('resize', updatePosition, { passive: true });
@@ -112,14 +125,11 @@ export const Tooltip = ({ tooltipType = 'white', iconType = 'stroke', position =
112
125
  window.removeEventListener('resize', updatePosition);
113
126
  window.removeEventListener('scroll', updatePosition, { capture: true });
114
127
  };
115
- }, [isVisible, updatePosition]);
128
+ }, [effectiveVisible, updatePosition, disablePortal]);
116
129
  const handleMouseEnter = useCallback(() => {
117
130
  if (isManuallyClose)
118
131
  return;
119
- // opacity 전환 전에 좌표 확정 (ref 가드는 updatePosition 내부)
120
132
  updatePosition();
121
- // 웹폰트 로드·max-content 재계산 등 비동기 layout 안정화 후 한 번 더 보정
122
- // 빠른 hover in/out 시 이전 프레임 요청은 취소해 중복/unmount 후 실행 방지
123
133
  if (rafIdRef.current !== null)
124
134
  cancelAnimationFrame(rafIdRef.current);
125
135
  rafIdRef.current = requestAnimationFrame(() => {
@@ -144,19 +154,12 @@ export const Tooltip = ({ tooltipType = 'white', iconType = 'stroke', position =
144
154
  'ncua-tooltip--stroke': iconType === 'stroke',
145
155
  'ncua-tooltip--auto': position === 'auto',
146
156
  }, className), [size, type, hideArrow, iconType, position, className]);
147
- const panelClassName = useMemo(() => classNames('ncua-tooltip-panel', 'ncua-tooltip__bg', `ncua-tooltip__bg--${tooltipType}`, `ncua-tooltip__bg--${finalPosition}`, {
148
- 'ncua-tooltip__bg--visible': isVisible,
149
- 'ncua-tooltip__bg--force-hidden': isManuallyClose,
150
- }), [tooltipType, finalPosition, isVisible, isManuallyClose]);
151
- const panelStyle = {
152
- top: `${coords.top}px`,
153
- left: `${coords.left}px`,
154
- opacity: isVisible ? 1 : 0,
155
- ...(zIndex && { zIndex }),
156
- };
157
+ const panelClassName = useMemo(() => classNames({ 'ncua-tooltip-panel': !disablePortal }, 'ncua-tooltip__bg', `ncua-tooltip__bg--${tooltipType}`, `ncua-tooltip__bg--${finalPosition}`, {
158
+ 'ncua-tooltip__bg--visible': effectiveVisible,
159
+ 'ncua-tooltip__bg--force-hidden': isManuallyClose && !forceVisible,
160
+ }), [tooltipType, finalPosition, effectiveVisible, isManuallyClose, forceVisible, disablePortal]);
161
+ const panelStyle = buildPanelStyle(disablePortal, coords, effectiveVisible, zIndex);
157
162
  const portalTarget = mounted ? resolvePortalTarget() : null;
158
163
  const panel = (_jsxs("span", { ref: panelRef, className: panelClassName, style: panelStyle, children: [title && _jsx("span", { className: "ncua-tooltip__title", children: title }), content && _jsx("span", { className: "ncua-tooltip__content", children: content }), type === 'long' && (_jsx(ButtonCloseX, { className: "ncua-tooltip__close-button", size: "xs", theme: tooltipType === 'white' ? 'dark' : 'light', onClick: handleCloseClick, "aria-label": "\uD234\uD301 \uB2EB\uAE30" }))] }));
159
- return (_jsxs(_Fragment, { children: [_jsxs("span", { ref: anchorRef, className: tooltipClassName, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [iconStyle === 'help-circle' &&
160
- (iconType === 'stroke' ? (_jsx(HelpCircle, { width: iconSize, height: iconSize, color: iconColor })) : (_jsx(HelpCircleFill, { width: iconSize, height: iconSize, color: iconColor }))), iconStyle === 'alert-circle' &&
161
- (iconType === 'stroke' ? (_jsx(AlertCircle, { width: iconSize, height: iconSize, color: iconColor })) : (_jsx(AlertCircleFill, { width: iconSize, height: iconSize, color: iconColor })))] }), portalTarget && createPortal(panel, portalTarget)] }));
164
+ return (_jsxs("span", { ref: anchorRef, className: tooltipClassName, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, children: [renderIcon(iconStyle, iconType, iconSize, iconColor), disablePortal ? panel : portalTarget && createPortal(panel, portalTarget)] }));
162
165
  };
@@ -17,6 +17,12 @@ type SelectDropdownProps = ComponentPropsWithRef<'div'> & {
17
17
  multiple?: boolean;
18
18
  showFooterButtons?: boolean;
19
19
  selectAllButtonText?: string;
20
+ /**
21
+ * footer의 "전체 선택" Link 노출 여부 (default: `true`).
22
+ * footer 자체(편집/선택 완료 버튼)는 영향받지 않는다.
23
+ * 호출자가 도메인 조건(예: 최대 선택 개수 제한)에 따라 `false`로 내리면 Link만 숨겨진다.
24
+ */
25
+ showSelectAllAction?: boolean;
20
26
  onSelectAll?: () => void;
21
27
  onEdit?: () => void;
22
28
  onComplete?: () => void;
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import classNames from 'classnames';
3
3
  import { forwardRef } from 'react';
4
4
  import { Button } from '../action/button';
5
- const SelectDropdown = forwardRef(({ isOpen, direction = 'down', size = 'xs', options, value, focusedIndex, maxHeight, listboxId = 'selectbox-options-default', onOptionSelect, onMouseMove, isKeyboardNavigation, onOptionHover, children, multiple = false, showFooterButtons = false, selectAllButtonText = '전체 선택', onSelectAll, onEdit, onComplete, className, activeDescendantId, componentType, align = 'left', ...props }, ref) => {
5
+ const SelectDropdown = forwardRef(({ isOpen, direction = 'down', size = 'xs', options, value, focusedIndex, maxHeight, listboxId = 'selectbox-options-default', onOptionSelect, onMouseMove, isKeyboardNavigation, onOptionHover, children, multiple = false, showFooterButtons = false, selectAllButtonText = '전체 선택', showSelectAllAction = true, onSelectAll, onEdit, onComplete, className, activeDescendantId, componentType, align = 'left', ...props }, ref) => {
6
6
  if (!isOpen)
7
7
  return null;
8
8
  return (_jsxs("div", { ref: ref, className: classNames('ncua-select-dropdown', `ncua-select-dropdown--${direction}`, `ncua-select-dropdown--${size}`, {
@@ -20,7 +20,7 @@ const SelectDropdown = forwardRef(({ isOpen, direction = 'down', size = 'xs', op
20
20
  'ncua-select-dropdown__option--selected': isSelected,
21
21
  'ncua-select-dropdown__option--focused': isFocused,
22
22
  }), onClick: () => onOptionSelect(option), onMouseEnter: handleMouseEnter, role: "option", "aria-selected": isSelected, children: [option.icon && (_jsx("span", { className: "ncua-select-dropdown__option-icon", children: _jsx(option.icon, { width: 16, height: 16 }) })), _jsx("span", { className: "ncua-select-dropdown__option-text", children: option.label })] }, option.id));
23
- }), children] }) }), showFooterButtons && (_jsx("div", { className: "ncua-select-dropdown__footer", children: _jsxs("div", { className: "ncua-select-dropdown__footer-buttons", children: [_jsx("div", { className: "ncua-select-dropdown__footer-left", children: multiple && (_jsx(Button, { label: selectAllButtonText, hierarchy: "text", size: "xs", onClick: onSelectAll, underline: true })) }), _jsxs("div", { className: "ncua-select-dropdown__footer-right", children: [_jsx(Button, { label: "\uD3B8\uC9D1", hierarchy: "secondary-gray", size: "xs", onClick: onEdit }), multiple && _jsx(Button, { label: "\uC120\uD0DD \uC644\uB8CC", hierarchy: "secondary", size: "xs", onClick: onComplete })] })] }) }))] }));
23
+ }), children] }) }), showFooterButtons && (_jsx("div", { className: "ncua-select-dropdown__footer", children: _jsxs("div", { className: "ncua-select-dropdown__footer-buttons", children: [_jsx("div", { className: "ncua-select-dropdown__footer-left", children: multiple && showSelectAllAction && (_jsx(Button, { label: selectAllButtonText, hierarchy: "text", size: "xs", onClick: onSelectAll, underline: true })) }), _jsxs("div", { className: "ncua-select-dropdown__footer-right", children: [_jsx(Button, { label: "\uD3B8\uC9D1", hierarchy: "secondary-gray", size: "xs", onClick: onEdit }), multiple && _jsx(Button, { label: "\uC120\uD0DD \uC644\uB8CC", hierarchy: "secondary", size: "xs", onClick: onComplete })] })] }) }))] }));
24
24
  });
25
25
  SelectDropdown.displayName = 'SelectDropdown';
26
26
  export { SelectDropdown };
@@ -0,0 +1,6 @@
1
+ export type FloatingContextValue = {
2
+ preferPortal: boolean;
3
+ portalContainer?: HTMLElement | null;
4
+ };
5
+ export declare const FloatingProvider: import("react").Provider<FloatingContextValue | null>;
6
+ export declare const useFloatingContext: () => FloatingContextValue | null;
@@ -0,0 +1,4 @@
1
+ import { createContext, useContext } from 'react';
2
+ const FloatingContext = createContext(null);
3
+ export const FloatingProvider = FloatingContext.Provider;
4
+ export const useFloatingContext = () => useContext(FloatingContext);
@@ -0,0 +1 @@
1
+ export * from './FloatingContext';
@@ -0,0 +1 @@
1
+ export * from './FloatingContext';
@@ -1,4 +1,5 @@
1
1
  export * from './dropdown';
2
2
  export * from './useCallbackRef';
3
+ export * from './useFloatingPosition';
3
4
  export * from './useMediaQuery';
4
5
  export * from './useMergeRefs';
@@ -2,5 +2,6 @@
2
2
  // 드롭다운 관련 hooks
3
3
  export * from './dropdown';
4
4
  export * from './useCallbackRef';
5
+ export * from './useFloatingPosition';
5
6
  export * from './useMediaQuery';
6
7
  export * from './useMergeRefs';
@@ -0,0 +1,19 @@
1
+ import { type CSSProperties, type RefObject } from 'react';
2
+ type FloatingDirection = 'up' | 'down';
3
+ type UseFloatingPositionParams = {
4
+ enabled: boolean;
5
+ isOpen: boolean;
6
+ triggerRef: RefObject<HTMLElement | null>;
7
+ floatingRef: RefObject<HTMLElement | null>;
8
+ direction: FloatingDirection;
9
+ offset?: number;
10
+ align?: 'left' | 'right';
11
+ /**
12
+ * true면 floating 요소의 width를 trigger 너비에 맞춘다.
13
+ * Portal 모드에서 부모가 body로 바뀌면서 min-width: 100% 같은 CSS가 viewport 너비로
14
+ * 폭주하는 것을 막는다 (예: SelectBox 옵션 패널).
15
+ */
16
+ matchTriggerWidth?: boolean;
17
+ };
18
+ export declare const useFloatingPosition: ({ enabled, isOpen, triggerRef, floatingRef, direction, offset, align, matchTriggerWidth, }: UseFloatingPositionParams) => CSSProperties | null;
19
+ export {};
@@ -0,0 +1,55 @@
1
+ import { useLayoutEffect, useState } from 'react';
2
+ const FLOATING_Z_INDEX = 1500;
3
+ export const useFloatingPosition = ({ enabled, isOpen, triggerRef, floatingRef, direction, offset = 4, align = 'left', matchTriggerWidth = false, }) => {
4
+ const [style, setStyle] = useState(null);
5
+ useLayoutEffect(() => {
6
+ if (!enabled || !isOpen) {
7
+ setStyle(null);
8
+ return;
9
+ }
10
+ const trigger = triggerRef.current;
11
+ if (!trigger)
12
+ return;
13
+ const calculatePosition = (trigger, floatingHeight, floatingWidth) => {
14
+ const top = direction === 'up' ? trigger.top - floatingHeight - offset : trigger.bottom + offset;
15
+ const left = align === 'right' ? trigger.right - floatingWidth : trigger.left;
16
+ return { top, left };
17
+ };
18
+ const update = () => {
19
+ const t = triggerRef.current;
20
+ const f = floatingRef.current;
21
+ if (!t)
22
+ return;
23
+ const r = t.getBoundingClientRect();
24
+ const floatingHeight = f?.offsetHeight ?? 0;
25
+ const floatingWidth = matchTriggerWidth ? r.width : (f?.offsetWidth ?? r.width);
26
+ const { top, left } = calculatePosition(r, floatingHeight, floatingWidth);
27
+ // matchTriggerWidth=true: width는 max-content(콘텐츠 자연 너비), min-width만 trigger 너비로 보장.
28
+ // 옵션이 짧으면 trigger 너비, 길면 자연 확장 — 기존 absolute 모드와 동일한 UX.
29
+ setStyle({
30
+ position: 'fixed',
31
+ top,
32
+ left,
33
+ zIndex: FLOATING_Z_INDEX,
34
+ ...(matchTriggerWidth ? { minWidth: r.width } : {}),
35
+ });
36
+ };
37
+ update();
38
+ window.addEventListener('scroll', update, true);
39
+ window.addEventListener('resize', update);
40
+ const triggerObserver = new ResizeObserver(update);
41
+ triggerObserver.observe(trigger);
42
+ let floatingObserver;
43
+ if (floatingRef.current) {
44
+ floatingObserver = new ResizeObserver(update);
45
+ floatingObserver.observe(floatingRef.current);
46
+ }
47
+ return () => {
48
+ window.removeEventListener('scroll', update, true);
49
+ window.removeEventListener('resize', update);
50
+ triggerObserver.disconnect();
51
+ floatingObserver?.disconnect();
52
+ };
53
+ }, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
54
+ return style;
55
+ };
@@ -0,0 +1,6 @@
1
+ type UsePortalStateResult = {
2
+ shouldPortal: boolean;
3
+ portalContainer: HTMLElement | null;
4
+ };
5
+ export declare const usePortalState: (usePortalProp?: boolean) => UsePortalStateResult;
6
+ export {};
@@ -0,0 +1,7 @@
1
+ import { useFloatingContext } from '../contexts/FloatingContext';
2
+ export const usePortalState = (usePortalProp) => {
3
+ const floatingContext = useFloatingContext();
4
+ const shouldPortal = usePortalProp ?? floatingContext?.preferPortal ?? false;
5
+ const portalContainer = floatingContext?.portalContainer ?? (typeof document !== 'undefined' ? document.body : null);
6
+ return { shouldPortal, portalContainer };
7
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * SelectBox / ComboBox 공통 — 최대 선택 개수 제한 활성 여부 판정.
3
+ *
4
+ * React 훅(`useMultiSelect`)과 vanilla 드롭다운(`DropdownModel`)가 동일 로직을 공유하므로
5
+ * 두 곳에 정의가 갈라지지 않도록 단일 source-of-truth로 둔다.
6
+ *
7
+ * **계약**
8
+ * - `0` 이상의 정수 → 활성 (단, `0`은 모든 새 선택을 차단하는 의미)
9
+ * - 음수 / 비정수 / `undefined` / `null` → 비활성 (제한 없는 것과 동일)
10
+ *
11
+ * `0`을 활성으로 보는 이유: 호출자가 `maxSelection={0}`을 명시적으로 전달했을 때
12
+ * "선택 자체를 막겠다"는 의도로 해석하기 위함. 음수는 일반적으로 "값 없음"의 sentinel이므로 비활성.
13
+ */
14
+ export declare const isMaxSelectionLimitActive: (maxSelection?: number | null) => maxSelection is number;
15
+ /**
16
+ * 현재 선택 길이가 최대치에 도달했는지 단일 함수로 판정.
17
+ *
18
+ * 호출 측에서 `>=`을 직접 작성하지 않도록 의미를 노출하고, type predicate가 내부에서 적용되어
19
+ * `maxSelection`의 narrowing이 보장된다 (외부 predicate 호출로 인한 narrowing 누락 방지).
20
+ *
21
+ * - 제한 비활성(음수 / null / undefined / 비정수): 항상 `false`.
22
+ * - 활성: `currentLength >= maxSelection`.
23
+ */
24
+ export declare const isMaxSelectionReached: (currentLength: number, maxSelection?: number | null) => boolean;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * SelectBox / ComboBox 공통 — 최대 선택 개수 제한 활성 여부 판정.
3
+ *
4
+ * React 훅(`useMultiSelect`)과 vanilla 드롭다운(`DropdownModel`)가 동일 로직을 공유하므로
5
+ * 두 곳에 정의가 갈라지지 않도록 단일 source-of-truth로 둔다.
6
+ *
7
+ * **계약**
8
+ * - `0` 이상의 정수 → 활성 (단, `0`은 모든 새 선택을 차단하는 의미)
9
+ * - 음수 / 비정수 / `undefined` / `null` → 비활성 (제한 없는 것과 동일)
10
+ *
11
+ * `0`을 활성으로 보는 이유: 호출자가 `maxSelection={0}`을 명시적으로 전달했을 때
12
+ * "선택 자체를 막겠다"는 의도로 해석하기 위함. 음수는 일반적으로 "값 없음"의 sentinel이므로 비활성.
13
+ */
14
+ export const isMaxSelectionLimitActive = (maxSelection) => typeof maxSelection === 'number' && Number.isInteger(maxSelection) && maxSelection >= 0;
15
+ /**
16
+ * 현재 선택 길이가 최대치에 도달했는지 단일 함수로 판정.
17
+ *
18
+ * 호출 측에서 `>=`을 직접 작성하지 않도록 의미를 노출하고, type predicate가 내부에서 적용되어
19
+ * `maxSelection`의 narrowing이 보장된다 (외부 predicate 호출로 인한 narrowing 누락 방지).
20
+ *
21
+ * - 제한 비활성(음수 / null / undefined / 비정수): 항상 `false`.
22
+ * - 활성: `currentLength >= maxSelection`.
23
+ */
24
+ export const isMaxSelectionReached = (currentLength, maxSelection) => {
25
+ if (!isMaxSelectionLimitActive(maxSelection))
26
+ return false;
27
+ return currentLength >= maxSelection;
28
+ };
@@ -19,13 +19,53 @@ export declare const getSelectedTags: (selectedValues: (string | number)[], opti
19
19
  * 태그 제거 시 선택값에서 해당 ID를 제거하는 유틸 함수
20
20
  */
21
21
  export declare const removeTagFromSelected: (selectedValues: (string | number)[], tagIdToRemove: string | number) => (string | number)[];
22
+ export interface UseMultiSelectOptions {
23
+ /** 전체 선택 버튼의 라벨 (default: '전체 선택') */
24
+ selectText?: string;
25
+ /** 전체 해제 버튼의 라벨 (default: '전체 해제') */
26
+ deselectText?: string;
27
+ /**
28
+ * 최대 선택 가능 개수 (선택).
29
+ * - `0` 이상의 정수: 제한 활성 (`isMaxSelectionActive: true`).
30
+ * - `0`은 모든 새 선택을 차단한다 (이미 선택된 항목 해제는 정상 동작).
31
+ * - 양수는 해당 개수 도달 후 추가 선택을 무시한다.
32
+ * - 음수 / 비정수 / `null` / `undefined`: 제한 없음.
33
+ */
34
+ maxSelection?: number | null;
35
+ }
22
36
  /**
23
- * 전체 선택 관련 로직을 번에 처리하는 커스텀 훅
37
+ * 최대 선택 개수 관련 응집을 별도 훅으로 분리.
38
+ *
39
+ * - `isMaxSelectionActive`: 제한 활성 여부.
40
+ * - `isMaxReached`: 현재 선택 길이가 최대치에 도달했는지.
41
+ * - `canAdd(currentLength)`: 새 항목 추가가 허용되는지 (도달 시 `false`).
42
+ *
43
+ * 도메인 규칙(`>=` 비교 등)을 외부로 노출하지 않고 헬퍼를 통해 의미만 반환한다.
44
+ * `useMultiSelect`가 내부에서 사용하지만, 단독으로도 필요한 호출자가 쓸 수 있게 export한다.
24
45
  */
25
- export declare const useMultiSelect: (selectedValues: (string | number)[], options: MultiSelectOption[], selectText?: string, deselectText?: string) => {
46
+ export declare const useMaxSelection: (selectedValues: (string | number)[], maxSelection?: number | null) => {
47
+ isMaxSelectionActive: boolean;
48
+ isMaxReached: boolean;
49
+ canAdd: (currentLength: number) => boolean;
50
+ };
51
+ /**
52
+ * 전체 선택 관련 로직을 한 번에 처리하는 커스텀 훅.
53
+ *
54
+ * 시그니처는 호출자가 maxSelection만 지정하더라도 텍스트 라벨을 건너뛰지 않도록 옵션 객체로 받는다.
55
+ *
56
+ * **반환의 `tryToggle`**: multiple 토글 결과를 직접 돌려준다.
57
+ * - 새 배열 반환: 정상 토글 (추가 또는 해제).
58
+ * - `null` 반환: 최대 개수 도달로 추가가 차단됨 (호출자는 변경 없이 종료해야 함).
59
+ *
60
+ * 호출자가 `>= maxSelection` 같은 도메인 규칙을 직접 검사하지 않도록 결과만 노출한다.
61
+ */
62
+ export declare const useMultiSelect: (selectedValues: (string | number)[], options: MultiSelectOption[], config?: UseMultiSelectOptions) => {
26
63
  isAllSelected: boolean;
27
64
  buttonText: string;
28
65
  toggleSelectAll: () => (string | number)[];
29
66
  getSelectedTagsData: () => SelectedTag[];
30
67
  removeTag: (tagId: string | number) => (string | number)[];
68
+ isMaxSelectionActive: boolean;
69
+ isMaxReached: boolean;
70
+ tryToggle: (optionId: string | number, currentArray: (string | number)[] | undefined) => (string | number)[] | null;
31
71
  };