@ncds/ui-admin 1.8.26-alpha.4 → 1.8.26-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/cjs/assets/scripts/notification/FloatingNotification.js +23 -7
  2. package/dist/cjs/assets/scripts/notification/FullWidthNotification.js +14 -3
  3. package/dist/cjs/assets/scripts/notification/MessageNotification.js +9 -9
  4. package/dist/cjs/assets/scripts/notification/Notification.js +8 -2
  5. package/dist/cjs/assets/scripts/notification/const/icons.js +17 -8
  6. package/dist/cjs/assets/scripts/notification/const/index.js +6 -0
  7. package/dist/cjs/assets/scripts/notification/messageTemplate.js +21 -5
  8. package/dist/cjs/constant/notification.js +8 -3
  9. package/dist/cjs/src/components/overlays/notification/FloatingNotification.js +11 -29
  10. package/dist/cjs/src/components/overlays/notification/FullWidthNotification.js +9 -24
  11. package/dist/cjs/src/components/overlays/notification/MessageNotification.js +25 -9
  12. package/dist/cjs/src/components/overlays/notification/MessageNotificationParts.js +5 -2
  13. package/dist/cjs/src/components/overlays/notification/Notification.js +1 -0
  14. package/dist/cjs/src/components/overlays/notification/__tests__/CalloutNotification.test.js +8 -2
  15. package/dist/cjs/src/components/overlays/notification/__tests__/MessageNotification.test.js +55 -13
  16. package/dist/cjs/src/components/overlays/notification/__tests__/Notification.test.js +176 -0
  17. package/dist/cjs/src/components/overlays/notification/useAutoClose.js +39 -0
  18. package/dist/cjs/src/generated/scoped-css.js +1 -1
  19. package/dist/cjs/src/utils/notification/__tests__/resolveAutoClose.test.js +44 -0
  20. package/dist/cjs/src/utils/notification/index.js +12 -0
  21. package/dist/cjs/src/utils/notification/resolveAutoClose.js +22 -0
  22. package/dist/esm/assets/scripts/notification/FloatingNotification.js +23 -7
  23. package/dist/esm/assets/scripts/notification/FullWidthNotification.js +14 -3
  24. package/dist/esm/assets/scripts/notification/MessageNotification.js +10 -10
  25. package/dist/esm/assets/scripts/notification/Notification.js +8 -2
  26. package/dist/esm/assets/scripts/notification/const/icons.js +16 -7
  27. package/dist/esm/assets/scripts/notification/const/index.js +1 -1
  28. package/dist/esm/assets/scripts/notification/messageTemplate.js +21 -5
  29. package/dist/esm/constant/notification.js +7 -2
  30. package/dist/esm/src/components/overlays/notification/FloatingNotification.js +12 -30
  31. package/dist/esm/src/components/overlays/notification/FullWidthNotification.js +10 -25
  32. package/dist/esm/src/components/overlays/notification/MessageNotification.js +25 -9
  33. package/dist/esm/src/components/overlays/notification/MessageNotificationParts.js +5 -2
  34. package/dist/esm/src/components/overlays/notification/Notification.js +1 -0
  35. package/dist/esm/src/components/overlays/notification/__tests__/CalloutNotification.test.js +9 -3
  36. package/dist/esm/src/components/overlays/notification/__tests__/MessageNotification.test.js +55 -13
  37. package/dist/esm/src/components/overlays/notification/__tests__/Notification.test.js +177 -1
  38. package/dist/esm/src/components/overlays/notification/useAutoClose.js +32 -0
  39. package/dist/esm/src/generated/scoped-css.js +1 -1
  40. package/dist/esm/src/utils/notification/__tests__/resolveAutoClose.test.js +42 -0
  41. package/dist/esm/src/utils/notification/index.js +1 -0
  42. package/dist/esm/src/utils/notification/resolveAutoClose.js +15 -0
  43. package/dist/temp/assets/scripts/notification/FloatingNotification.js +10 -6
  44. package/dist/temp/assets/scripts/notification/FullWidthNotification.js +8 -5
  45. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +1 -1
  46. package/dist/temp/assets/scripts/notification/MessageNotification.js +10 -12
  47. package/dist/temp/assets/scripts/notification/Notification.js +7 -1
  48. package/dist/temp/assets/scripts/notification/const/icons.d.ts +6 -6
  49. package/dist/temp/assets/scripts/notification/const/icons.js +16 -7
  50. package/dist/temp/assets/scripts/notification/const/index.d.ts +1 -1
  51. package/dist/temp/assets/scripts/notification/const/index.js +1 -1
  52. package/dist/temp/assets/scripts/notification/const/types.d.ts +8 -1
  53. package/dist/temp/assets/scripts/notification/messageTemplate.d.ts +1 -0
  54. package/dist/temp/assets/scripts/notification/messageTemplate.js +19 -9
  55. package/dist/temp/constant/notification.d.ts +6 -1
  56. package/dist/temp/constant/notification.js +6 -1
  57. package/dist/temp/src/components/overlays/notification/FloatingNotification.d.ts +8 -0
  58. package/dist/temp/src/components/overlays/notification/FloatingNotification.js +7 -30
  59. package/dist/temp/src/components/overlays/notification/FullWidthNotification.d.ts +7 -0
  60. package/dist/temp/src/components/overlays/notification/FullWidthNotification.js +6 -25
  61. package/dist/temp/src/components/overlays/notification/MessageNotification.d.ts +13 -1
  62. package/dist/temp/src/components/overlays/notification/MessageNotification.js +16 -9
  63. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.d.ts +2 -1
  64. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.js +5 -3
  65. package/dist/temp/src/components/overlays/notification/Notification.d.ts +16 -3
  66. package/dist/temp/src/components/overlays/notification/Notification.js +1 -1
  67. package/dist/temp/src/components/overlays/notification/__tests__/CalloutNotification.test.js +8 -1
  68. package/dist/temp/src/components/overlays/notification/__tests__/MessageNotification.test.js +44 -9
  69. package/dist/temp/src/components/overlays/notification/__tests__/Notification.test.js +128 -1
  70. package/dist/temp/src/components/overlays/notification/useAutoClose.d.ts +15 -0
  71. package/dist/temp/src/components/overlays/notification/useAutoClose.js +25 -0
  72. package/dist/temp/src/generated/scoped-css.js +1 -1
  73. package/dist/temp/src/utils/notification/__tests__/resolveAutoClose.test.d.ts +1 -0
  74. package/dist/temp/src/utils/notification/__tests__/resolveAutoClose.test.js +22 -0
  75. package/dist/temp/src/utils/notification/index.d.ts +1 -0
  76. package/dist/temp/src/utils/notification/index.js +1 -0
  77. package/dist/temp/src/utils/notification/resolveAutoClose.d.ts +14 -0
  78. package/dist/temp/src/utils/notification/resolveAutoClose.js +12 -0
  79. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +1 -1
  80. package/dist/types/assets/scripts/notification/const/icons.d.ts +6 -6
  81. package/dist/types/assets/scripts/notification/const/index.d.ts +1 -1
  82. package/dist/types/assets/scripts/notification/const/types.d.ts +8 -1
  83. package/dist/types/assets/scripts/notification/messageTemplate.d.ts +1 -0
  84. package/dist/types/constant/notification.d.ts +6 -1
  85. package/dist/types/src/components/overlays/notification/FloatingNotification.d.ts +8 -0
  86. package/dist/types/src/components/overlays/notification/FullWidthNotification.d.ts +7 -0
  87. package/dist/types/src/components/overlays/notification/MessageNotification.d.ts +13 -1
  88. package/dist/types/src/components/overlays/notification/MessageNotificationParts.d.ts +2 -1
  89. package/dist/types/src/components/overlays/notification/Notification.d.ts +16 -3
  90. package/dist/types/src/components/overlays/notification/useAutoClose.d.ts +15 -0
  91. package/dist/types/src/utils/notification/__tests__/resolveAutoClose.test.d.ts +1 -0
  92. package/dist/types/src/utils/notification/index.d.ts +1 -0
  93. package/dist/types/src/utils/notification/resolveAutoClose.d.ts +14 -0
  94. package/dist/ui-admin/assets/styles/style.css +17 -11
  95. package/package.json +1 -1
@@ -4,12 +4,18 @@ import classNames from 'classnames';
4
4
  import { forwardRef } from 'react';
5
5
  import { MESSAGE_CLOSE_ICON_COLOR } from '../../../../constant/notification';
6
6
  import { MessageNotificationActions, MessageNotificationContent } from './MessageNotificationParts';
7
- const iconMap = {
7
+ import { useAutoClose } from './useAutoClose';
8
+ const DEFAULT_ICON_MAP = {
8
9
  neutral: Pin02,
9
10
  error: AlertTriangle,
10
11
  warning: AlertCircle,
11
12
  success: CheckCircle,
12
- // info 는 variant="block" 에서만 도달한다 (default 는 neutral 로 폴백).
13
+ // 디자인 기준 default 의 info 는 neutral 과 같은 핀이다.
14
+ info: Pin02
15
+ };
16
+ // block 의 info 는 InfoCircle 을 유지한다 (#536).
17
+ const BLOCK_ICON_MAP = {
18
+ ...DEFAULT_ICON_MAP,
13
19
  info: InfoCircle
14
20
  };
15
21
  const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
@@ -19,6 +25,8 @@ const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
19
25
  icon: Icon,
20
26
  color = 'neutral',
21
27
  onClose,
28
+ hideCloseButton = false,
29
+ autoClose,
22
30
  className,
23
31
  actions,
24
32
  onHidePermanently,
@@ -29,13 +37,20 @@ const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
29
37
  ...rest
30
38
  } = _ref;
31
39
  const isBlock = variant === 'block';
32
- // default 는 neutral, error, warning, success 4가지 색상만 지원 — info 는 block 에서만 유지한다.
33
- const validColor = !isBlock && color === 'info' ? 'neutral' : color;
34
- // FeaturedIcon 에는 info 색이 없다. block+info 의 violet 톤은 message SCSS 가 덮어쓴다.
35
- const featuredIconColor = validColor === 'info' ? 'neutral' : validColor;
40
+ const iconMap = isBlock ? BLOCK_ICON_MAP : DEFAULT_ICON_MAP;
41
+ // FeaturedIcon 에는 info 색이 없다. info 의 violet 톤은 message SCSS 가 덮어쓴다.
42
+ const featuredIconColor = color === 'info' ? 'neutral' : color;
43
+ // message 는 부모가 언마운트로 닫으므로 hideCloseButton 기본 autoClose 를 적용하지 않는다.
44
+ const shouldRemove = useAutoClose({
45
+ autoClose,
46
+ onClose
47
+ });
48
+ if (shouldRemove) {
49
+ return null;
50
+ }
36
51
  return _jsx("div", {
37
52
  ref: ref,
38
- className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, {
53
+ className: classNames('ncua-message-notification', `ncua-message-notification--${color}`, {
39
54
  'ncua-message-notification--block': isBlock
40
55
  }, className),
41
56
  role: "alert",
@@ -49,15 +64,16 @@ const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
49
64
  supportingText: supportingText,
50
65
  supportTextLink: isBlock ? supportTextLink : undefined,
51
66
  supportTextLinkTarget: supportTextLinkTarget,
52
- icon: Icon || iconMap[validColor],
67
+ icon: Icon || iconMap[color],
53
68
  iconColor: featuredIconColor,
54
69
  iconSize: isBlock ? 'sm' : 'md'
55
70
  }), _jsx(MessageNotificationActions, {
56
71
  actions: actions,
57
72
  onClose: onClose,
73
+ hideCloseButton: hideCloseButton,
58
74
  onHidePermanently: onHidePermanently,
59
75
  hidePermanentlyLabel: hidePermanentlyLabel,
60
- closeIconColor: MESSAGE_CLOSE_ICON_COLOR[validColor]
76
+ closeIconColor: MESSAGE_CLOSE_ICON_COLOR[color]
61
77
  })]
62
78
  })
63
79
  })
@@ -54,12 +54,15 @@ const MessageNotificationActions = _ref2 => {
54
54
  let {
55
55
  actions,
56
56
  onClose,
57
+ hideCloseButton = false,
57
58
  onHidePermanently,
58
59
  hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.message,
59
60
  closeIconColor
60
61
  } = _ref2;
61
62
  const hasActions = Boolean(actions?.length);
62
- const hasFooter = Boolean(onHidePermanently || onClose);
63
+ // footer 컨테이너와 닫기 버튼이 같은 판정을 보게 해 빈 footer 가 남지 않게 한다.
64
+ const showCloseButton = Boolean(onClose) && !hideCloseButton;
65
+ const hasFooter = Boolean(onHidePermanently) || showCloseButton;
63
66
  if (!hasActions && !hasFooter) return null;
64
67
  return _jsxs("div", {
65
68
  className: "ncua-message-notification__actions-container",
@@ -75,7 +78,7 @@ const MessageNotificationActions = _ref2 => {
75
78
  className: "ncua-notification__action-button ncua-message-notification__hide-link",
76
79
  onClick: onHidePermanently,
77
80
  children: hidePermanentlyLabel
78
- }), onClose && _jsx("button", {
81
+ }), showCloseButton && _jsx("button", {
79
82
  type: "button",
80
83
  className: "ncua-message-notification__close-button",
81
84
  onClick: onClose,
@@ -51,6 +51,7 @@ const Notification = /*#__PURE__*/forwardRef(
51
51
  actions: _actions,
52
52
  autoClose: _autoClose,
53
53
  onClose: _onClose,
54
+ hideCloseButton: _hideCloseButton,
54
55
  onHidePermanently: _onHidePermanently,
55
56
  hidePermanentlyLabel: _hidePermanentlyLabel,
56
57
  portal: _portal,
@@ -2,7 +2,7 @@
2
2
  import { createElement } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
4
  import { act } from 'react-dom/test-utils';
5
- import { afterEach, describe, expect, it } from 'vitest';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
6
  import { CalloutNotification } from '../CalloutNotification';
7
7
  import { Notification } from '../Notification';
8
8
  // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
@@ -43,6 +43,7 @@ afterEach(() => {
43
43
  container?.remove();
44
44
  root = null;
45
45
  container = null;
46
+ vi.restoreAllMocks();
46
47
  });
47
48
  describe('CalloutNotification — width prop', () => {
48
49
  it('width 미지정 시 너비 클래스를 붙이지 않는다 (부모 너비를 채우는 기본 스타일 유지)', () => {
@@ -91,6 +92,7 @@ describe('Notification type="callout" — 위임 계약', () => {
91
92
  });
92
93
  // Notification 의 callout 분기가 제외하는 prop 전체. 여기 누락된 prop 은 DOM 속성으로 출력된다.
93
94
  it('callout 이 지원하지 않는 prop 은 DOM 속성으로 새지 않는다', () => {
95
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
94
96
  const element = renderNotification({
95
97
  icon: () => null,
96
98
  supportingText: '보조 문구',
@@ -103,12 +105,16 @@ describe('Notification type="callout" — 위임 계약', () => {
103
105
  portal: true,
104
106
  onClose: () => undefined,
105
107
  onHidePermanently: () => undefined,
106
- hidePermanentlyLabel: '다시 보지 않기'
108
+ hidePermanentlyLabel: '다시 보지 않기',
109
+ hideCloseButton: true
107
110
  });
108
- for (const attr of ['icon', 'supportingtext', 'supporttextlink', 'actions', 'autoclose', 'portal', 'onclose', 'onhidepermanently', 'hidepermanentlylabel']) {
111
+ for (const attr of ['icon', 'supportingtext', 'supporttextlink', 'actions', 'autoclose', 'portal', 'onclose', 'onhidepermanently', 'hidepermanentlylabel', 'hideclosebutton']) {
109
112
  expect(element.hasAttribute(attr), attr).toBe(false);
110
113
  }
111
114
  expect(element.textContent).toBe('안내 문구');
115
+ // 불리언 true 는 React 가 미지 속성으로 출력하지 않아 hasAttribute 로는 누수를 못 잡는다 — 경고 로그로 검출한다.
116
+ const leaked = consoleError.mock.calls.some(call => call.some(arg => String(arg).includes('hideCloseButton')));
117
+ expect(leaked).toBe(false);
112
118
  });
113
119
  it('ref 를 callout 루트 엘리먼트로 전달한다', () => {
114
120
  let captured = null;
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment jsdom
2
+ import { InfoCircle, Pin02 } from '@ncds/ui-admin-icon';
2
3
  import { createElement } from 'react';
3
4
  import { createRoot } from 'react-dom/client';
4
5
  import { act } from 'react-dom/test-utils';
@@ -18,16 +19,12 @@ const SELECTOR = {
18
19
  CLOSE_BUTTON: '.ncua-message-notification__close-button'
19
20
  };
20
21
  const mounted = [];
21
- function renderMessageNotification() {
22
- let props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
22
+ function mount(element) {
23
23
  const container = document.createElement('div');
24
24
  document.body.appendChild(container);
25
25
  const root = createRoot(container);
26
26
  act(() => {
27
- root.render(/*#__PURE__*/createElement(MessageNotification, {
28
- title: '알림 제목',
29
- ...props
30
- }));
27
+ root.render(element);
31
28
  });
32
29
  mounted.push({
33
30
  root,
@@ -35,6 +32,20 @@ function renderMessageNotification() {
35
32
  });
36
33
  return container;
37
34
  }
35
+ function renderMessageNotification() {
36
+ let props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
37
+ return mount(/*#__PURE__*/createElement(MessageNotification, {
38
+ title: '알림 제목',
39
+ ...props
40
+ }));
41
+ }
42
+ /** 아이콘 컴포넌트를 단독 렌더해 `path[d]` 를 얻는다. 아이콘 판정은 d 비교로 한다. */
43
+ function iconPathD(Icon) {
44
+ return mount(/*#__PURE__*/createElement(Icon)).querySelector('path')?.getAttribute('d') ?? null;
45
+ }
46
+ function renderedIconPathD(container) {
47
+ return container.querySelector(`${SELECTOR.ICON} svg path`)?.getAttribute('d') ?? null;
48
+ }
38
49
  afterEach(() => {
39
50
  for (const {
40
51
  root,
@@ -143,17 +154,48 @@ describe('MessageNotification (React) — DOM 구조 계약', () => {
143
154
  expect(hideLink?.className.split(' ').filter(Boolean).sort()).toEqual(['ncua-message-notification__hide-link', 'ncua-notification__action-button']);
144
155
  });
145
156
  });
146
- describe('MessageNotification (React) — 공개 API 회귀 방지', () => {
147
- it('지원하지 않는 color("info")는 neutral로 폴백한다', () => {
148
- // Given/When: message 타입이 지원하지 않는 info 색상을 넘기면
157
+ describe('MessageNotification (React) — color="info" (#568)', () => {
158
+ it('R-01: color="info" 는 neutral 로 폴백하지 않고 --info 수식자로 렌더한다', () => {
159
+ // Given/When: variant 미지정, info 색상으로 렌더하면
160
+ const root = renderMessageNotification({
161
+ color: 'info'
162
+ }).querySelector(SELECTOR.ROOT);
163
+ // Then: info 수식자가 있고 neutral 수식자는 없다
164
+ expect(root?.classList.contains('ncua-message-notification--info')).toBe(true);
165
+ expect(root?.classList.contains('ncua-message-notification--neutral')).toBe(false);
166
+ });
167
+ it('R-02: 기본 info 아이콘은 neutral 과 같은 Pin02 다', () => {
168
+ // Given/When: variant 미지정, info 색상으로 렌더하면
149
169
  const container = renderMessageNotification({
150
170
  color: 'info'
151
171
  });
152
- // Then: neutral 수식자로 렌더되고 info 수식자는 없다
153
- const root = container.querySelector(SELECTOR.ROOT);
154
- expect(root?.classList.contains('ncua-message-notification--neutral')).toBe(true);
155
- expect(root?.classList.contains('ncua-message-notification--info')).toBe(false);
172
+ // Then: 아이콘 path 가 Pin02 와 같다
173
+ const expected = iconPathD(Pin02);
174
+ expect(expected).not.toBeNull();
175
+ expect(renderedIconPathD(container)).toBe(expected);
156
176
  });
177
+ it('R-03: info 의 FeaturedIcon 은 neutral·md 로 렌더한다 (violet 톤은 SCSS 가 덮는다)', () => {
178
+ // Given/When: variant 미지정, info 색상으로 렌더하면
179
+ const icon = renderMessageNotification({
180
+ color: 'info'
181
+ }).querySelector(SELECTOR.ICON);
182
+ // Then: FeaturedIcon 에는 info 색이 없으므로 neutral 수식자와 md 사이즈가 유지된다
183
+ expect(icon?.classList.contains('ncua-featured-icon--neutral')).toBe(true);
184
+ expect(icon?.classList.contains('ncua-featured-icon--md')).toBe(true);
185
+ });
186
+ it('R-04: block 의 info 아이콘은 InfoCircle 을 유지한다', () => {
187
+ // Given/When: block 변형에 info 색상으로 렌더하면
188
+ const container = renderMessageNotification({
189
+ variant: 'block',
190
+ color: 'info'
191
+ });
192
+ // Then: 아이콘 path 가 InfoCircle 과 같다 (#536 회귀)
193
+ const expected = iconPathD(InfoCircle);
194
+ expect(expected).not.toBeNull();
195
+ expect(renderedIconPathD(container)).toBe(expected);
196
+ });
197
+ });
198
+ describe('MessageNotification (React) — 공개 API 회귀 방지', () => {
157
199
  it('닫기/다시 보지 않기 클릭 시 핸들러가 호출되고 접근성 속성이 유지된다', () => {
158
200
  // Given: 핸들러를 지정해 렌더하고
159
201
  const onClose = vi.fn();
@@ -2,7 +2,8 @@
2
2
  import { createElement } from 'react';
3
3
  import { createRoot } from 'react-dom/client';
4
4
  import { act } from 'react-dom/test-utils';
5
- import { afterEach, beforeAll, describe, expect, it } from 'vitest';
5
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
6
+ import { HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS } from '../../../../../constant/notification';
6
7
  import { Notification } from '../Notification';
7
8
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
8
9
  let root = null;
@@ -77,4 +78,179 @@ describe('Notification — type 별 위임', () => {
77
78
  });
78
79
  expect(element).toBeNull();
79
80
  });
81
+ });
82
+ const CLOSE_BUTTON = {
83
+ floating: '.ncua-floating-notification__close-button',
84
+ 'full-width': '.ncua-full-width-notification__close-button',
85
+ message: '.ncua-message-notification__close-button'
86
+ };
87
+ const MESSAGE_FOOTER = '.ncua-message-notification__footer-container';
88
+ const MESSAGE_HIDE_LINK = '.ncua-message-notification__hide-link';
89
+ /** DES-SPEC-027 §5.3 성공 토스트 자동 닫힘 시간. */
90
+ const AUTO_CLOSE_MS = 3000;
91
+ describe('Notification — hideCloseButton', () => {
92
+ afterEach(() => {
93
+ vi.useRealTimers();
94
+ });
95
+ it('R-01: floating — onClose 가 있어도 hideCloseButton 이면 닫기 버튼을 렌더하지 않는다', () => {
96
+ const element = render({
97
+ type: 'floating',
98
+ onClose: vi.fn(),
99
+ hideCloseButton: true
100
+ });
101
+ expect(element?.querySelector(CLOSE_BUTTON.floating)).toBeNull();
102
+ });
103
+ it('R-02: full-width — onClose 가 있어도 hideCloseButton 이면 닫기 버튼을 렌더하지 않는다', () => {
104
+ const element = render({
105
+ type: 'full-width',
106
+ onClose: vi.fn(),
107
+ hideCloseButton: true
108
+ });
109
+ expect(element?.querySelector(CLOSE_BUTTON['full-width'])).toBeNull();
110
+ });
111
+ it('R-03: message — hideCloseButton 이면 닫기 버튼과 빈 footer 컨테이너를 모두 렌더하지 않는다', () => {
112
+ const element = render({
113
+ type: 'message',
114
+ onClose: vi.fn(),
115
+ hideCloseButton: true
116
+ });
117
+ expect(element?.querySelector(CLOSE_BUTTON.message)).toBeNull();
118
+ expect(element?.querySelector(MESSAGE_FOOTER)).toBeNull();
119
+ });
120
+ it.each(['floating', 'full-width', 'message'])('R-04: type=%s — hideCloseButton 미지정(기본 false)이면 onClose 만으로 닫기 버튼을 렌더한다', type => {
121
+ const element = render({
122
+ type,
123
+ onClose: vi.fn()
124
+ });
125
+ expect(element?.querySelector(CLOSE_BUTTON[type])).not.toBeNull();
126
+ });
127
+ it.each(['floating', 'full-width', 'message'])('R-05: type=%s — onClose 없이 hideCloseButton 만 주면 닫기 버튼 없이 오류 없이 렌더한다', type => {
128
+ const element = render({
129
+ type,
130
+ hideCloseButton: true
131
+ });
132
+ expect(element).not.toBeNull();
133
+ expect(element?.querySelector(CLOSE_BUTTON[type])).toBeNull();
134
+ });
135
+ it('R-06: floating — hideCloseButton 이어도 autoClose 만료 시 onClose 가 1회 호출되고 DOM 에서 제거된다', () => {
136
+ vi.useFakeTimers();
137
+ const onClose = vi.fn();
138
+ render({
139
+ type: 'floating',
140
+ autoClose: AUTO_CLOSE_MS,
141
+ hideCloseButton: true,
142
+ onClose
143
+ });
144
+ act(() => {
145
+ vi.advanceTimersByTime(AUTO_CLOSE_MS);
146
+ });
147
+ expect(onClose).toHaveBeenCalledTimes(1);
148
+ expect(container?.querySelector('.ncua-floating-notification')).toBeNull();
149
+ });
150
+ it('R-07: full-width — hideCloseButton 이어도 autoClose 만료 시 onClose 가 1회 호출되고 DOM 에서 제거된다', () => {
151
+ vi.useFakeTimers();
152
+ const onClose = vi.fn();
153
+ render({
154
+ type: 'full-width',
155
+ autoClose: AUTO_CLOSE_MS,
156
+ hideCloseButton: true,
157
+ onClose
158
+ });
159
+ act(() => {
160
+ vi.advanceTimersByTime(AUTO_CLOSE_MS);
161
+ });
162
+ expect(onClose).toHaveBeenCalledTimes(1);
163
+ expect(container?.querySelector('.ncua-full-width-notification')).toBeNull();
164
+ });
165
+ it('R-09: message — hideCloseButton + onHidePermanently 이면 footer 에 다시 보지 않기만 렌더한다', () => {
166
+ const element = render({
167
+ type: 'message',
168
+ onClose: vi.fn(),
169
+ hideCloseButton: true,
170
+ onHidePermanently: vi.fn()
171
+ });
172
+ expect(element?.querySelector(MESSAGE_FOOTER)).not.toBeNull();
173
+ expect(element?.querySelector(MESSAGE_HIDE_LINK)).not.toBeNull();
174
+ expect(element?.querySelector(CLOSE_BUTTON.message)).toBeNull();
175
+ });
176
+ it('R-10: message — hideCloseButton 이어도 autoClose 만료 시 onClose 가 1회 호출되고 DOM 에서 제거된다', () => {
177
+ vi.useFakeTimers();
178
+ const onClose = vi.fn();
179
+ render({
180
+ type: 'message',
181
+ autoClose: AUTO_CLOSE_MS,
182
+ hideCloseButton: true,
183
+ onClose
184
+ });
185
+ act(() => {
186
+ vi.advanceTimersByTime(AUTO_CLOSE_MS);
187
+ });
188
+ expect(onClose).toHaveBeenCalledTimes(1);
189
+ expect(container?.querySelector('.ncua-message-notification')).toBeNull();
190
+ });
191
+ });
192
+ describe('Notification — hideCloseButton 기본 autoClose', () => {
193
+ afterEach(() => {
194
+ vi.useRealTimers();
195
+ });
196
+ it('R-11: floating — hideCloseButton 만 주고 autoClose 를 생략하면 3초 뒤 onClose 가 호출되고 제거된다', () => {
197
+ vi.useFakeTimers();
198
+ const onClose = vi.fn();
199
+ render({
200
+ type: 'floating',
201
+ hideCloseButton: true,
202
+ onClose
203
+ });
204
+ act(() => {
205
+ vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS - 1);
206
+ });
207
+ expect(onClose).not.toHaveBeenCalled();
208
+ act(() => {
209
+ vi.advanceTimersByTime(1);
210
+ });
211
+ expect(onClose).toHaveBeenCalledTimes(1);
212
+ expect(container?.firstElementChild).toBeNull();
213
+ });
214
+ it.each(['full-width', 'message'])('R-14: type=%s — hideCloseButton 만 주고 autoClose 를 생략해도 자동으로 닫히지 않는다 (부모 언마운트 용도)', type => {
215
+ vi.useFakeTimers();
216
+ const onClose = vi.fn();
217
+ render({
218
+ type,
219
+ hideCloseButton: true,
220
+ onClose
221
+ });
222
+ act(() => {
223
+ vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS * 2);
224
+ });
225
+ expect(onClose).not.toHaveBeenCalled();
226
+ expect(container?.firstElementChild).not.toBeNull();
227
+ });
228
+ it('R-12: floating — hideCloseButton + autoClose={0} 을 명시하면 자동으로 닫히지 않는다 (부모 언마운트 용도)', () => {
229
+ vi.useFakeTimers();
230
+ const onClose = vi.fn();
231
+ render({
232
+ type: 'floating',
233
+ hideCloseButton: true,
234
+ autoClose: 0,
235
+ onClose
236
+ });
237
+ act(() => {
238
+ vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS * 2);
239
+ });
240
+ expect(onClose).not.toHaveBeenCalled();
241
+ expect(container?.querySelector('.ncua-floating-notification')).not.toBeNull();
242
+ });
243
+ it('R-13: floating — hideCloseButton 이 아니면 autoClose 생략 시 기본 자동 닫힘이 없다 (회귀)', () => {
244
+ vi.useFakeTimers();
245
+ const onClose = vi.fn();
246
+ render({
247
+ type: 'floating',
248
+ onClose
249
+ });
250
+ act(() => {
251
+ vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS * 2);
252
+ });
253
+ expect(onClose).not.toHaveBeenCalled();
254
+ expect(container?.querySelector('.ncua-floating-notification')).not.toBeNull();
255
+ });
80
256
  });
@@ -0,0 +1,32 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { resolveAutoClose } from '../../../utils/notification';
3
+ /**
4
+ * autoClose 타이머. 만료 시 `onClose`를 호출하고 `true`(제거됨)를 반환한다.
5
+ * floating · full-width · message 가 공유한다.
6
+ *
7
+ * `onClose`는 매 렌더 새 함수일 수 있어 ref 로 latest 만 보관하고 deps 에서 뺀다 —
8
+ * 인라인 핸들러를 넘겨도 부모 리렌더마다 타이머가 리셋되지 않는다.
9
+ */
10
+ export const useAutoClose = _ref => {
11
+ let {
12
+ autoClose,
13
+ hideCloseButton,
14
+ onClose
15
+ } = _ref;
16
+ const [shouldRemove, setShouldRemove] = useState(false);
17
+ const onCloseRef = useRef(onClose);
18
+ onCloseRef.current = onClose;
19
+ const autoCloseMs = resolveAutoClose({
20
+ autoClose,
21
+ hideCloseButton
22
+ });
23
+ useEffect(() => {
24
+ if (autoCloseMs <= 0) return;
25
+ const timer = setTimeout(() => {
26
+ onCloseRef.current?.();
27
+ setShouldRemove(true);
28
+ }, autoCloseMs);
29
+ return () => clearTimeout(timer);
30
+ }, [autoCloseMs]);
31
+ return shouldRemove;
32
+ };