@ncds/ui-admin 1.8.21-alpha.4 → 1.8.21-alpha.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 (53) hide show
  1. package/dist/cjs/assets/scripts/notification/MessageNotification.js +6 -46
  2. package/dist/cjs/assets/scripts/notification/const/classNames.js +3 -0
  3. package/dist/cjs/assets/scripts/notification/const/index.js +1 -1
  4. package/dist/cjs/assets/scripts/notification/const/sizes.js +4 -2
  5. package/dist/cjs/assets/scripts/notification/messageTemplate.js +77 -0
  6. package/dist/cjs/assets/scripts/notification/utils.js +40 -4
  7. package/dist/cjs/src/components/overlays/notification/MessageNotification.js +12 -54
  8. package/dist/cjs/src/components/overlays/notification/MessageNotificationParts.js +90 -0
  9. package/dist/cjs/src/components/overlays/notification/__tests__/MessageNotification.test.js +246 -0
  10. package/dist/cjs/src/generated/scoped-css.js +1 -1
  11. package/dist/esm/assets/scripts/notification/MessageNotification.js +8 -48
  12. package/dist/esm/assets/scripts/notification/const/classNames.js +3 -0
  13. package/dist/esm/assets/scripts/notification/const/index.js +3 -2
  14. package/dist/esm/assets/scripts/notification/const/sizes.js +4 -2
  15. package/dist/esm/assets/scripts/notification/messageTemplate.js +71 -0
  16. package/dist/esm/assets/scripts/notification/utils.js +39 -4
  17. package/dist/esm/src/components/overlays/notification/MessageNotification.js +13 -55
  18. package/dist/esm/src/components/overlays/notification/MessageNotificationParts.js +83 -0
  19. package/dist/esm/src/components/overlays/notification/__tests__/MessageNotification.test.js +243 -0
  20. package/dist/esm/src/generated/scoped-css.js +1 -1
  21. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +0 -3
  22. package/dist/temp/assets/scripts/notification/MessageNotification.js +7 -43
  23. package/dist/temp/assets/scripts/notification/const/classNames.d.ts +1 -0
  24. package/dist/temp/assets/scripts/notification/const/classNames.js +3 -0
  25. package/dist/temp/assets/scripts/notification/const/index.d.ts +3 -3
  26. package/dist/temp/assets/scripts/notification/const/index.js +3 -2
  27. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +2 -2
  28. package/dist/temp/assets/scripts/notification/const/sizes.js +4 -2
  29. package/dist/temp/assets/scripts/notification/const/types.d.ts +20 -2
  30. package/dist/temp/assets/scripts/notification/messageTemplate.d.ts +14 -0
  31. package/dist/temp/assets/scripts/notification/messageTemplate.js +69 -0
  32. package/dist/temp/assets/scripts/notification/utils.d.ts +19 -2
  33. package/dist/temp/assets/scripts/notification/utils.js +39 -5
  34. package/dist/temp/src/components/overlays/notification/MessageNotification.js +3 -12
  35. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.d.ts +27 -0
  36. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.js +27 -0
  37. package/dist/temp/src/components/overlays/notification/Notification.d.ts +3 -2
  38. package/dist/temp/src/components/overlays/notification/__tests__/MessageNotification.test.d.ts +1 -0
  39. package/dist/temp/src/components/overlays/notification/__tests__/MessageNotification.test.js +190 -0
  40. package/dist/temp/src/generated/scoped-css.js +1 -1
  41. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +0 -3
  42. package/dist/types/assets/scripts/notification/const/classNames.d.ts +1 -0
  43. package/dist/types/assets/scripts/notification/const/index.d.ts +3 -3
  44. package/dist/types/assets/scripts/notification/const/sizes.d.ts +2 -2
  45. package/dist/types/assets/scripts/notification/const/types.d.ts +20 -2
  46. package/dist/types/assets/scripts/notification/messageTemplate.d.ts +14 -0
  47. package/dist/types/assets/scripts/notification/utils.d.ts +19 -2
  48. package/dist/types/src/components/overlays/notification/MessageNotificationParts.d.ts +27 -0
  49. package/dist/types/src/components/overlays/notification/Notification.d.ts +3 -2
  50. package/dist/types/src/components/overlays/notification/__tests__/MessageNotification.test.d.ts +1 -0
  51. package/dist/ui-admin/assets/styles/style.css +20 -21
  52. package/dist/ui-admin/assets/styles/style.scoped.css +20 -21
  53. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  import { FeaturedIcon } from '../featuredIcon';
2
2
  import { sanitizeHtml } from '../utils/sanitize';
3
- import { CLASS_NAMES, FLOATING_ICON_MAP, MESSAGE_SIZES, SVG_ICONS } from './const';
4
- import { bindNotificationEvents, createWrapperElement, renderActions, renderSupportingText, setupAutoClose } from './utils';
3
+ import { CLASS_NAMES, FLOATING_ICON_MAP, MESSAGE_SIZES } from './const';
4
+ import { buildMessageTemplate } from './messageTemplate';
5
+ import { bindNotificationEvents, createWrapperElement, setupAutoClose } from './utils';
5
6
  export class MessageNotification {
6
7
  constructor(options) {
7
8
  this.options = {
@@ -41,11 +42,14 @@ export class MessageNotification {
41
42
  svgString: iconSvg,
42
43
  theme: 'light-circle',
43
44
  color: actualColor,
44
- size: MESSAGE_SIZES.FEATURED_ICON
45
+ size: MESSAGE_SIZES.FEATURED_ICON,
46
+ // React 구현과 동일하게 `__icon`(flex-shrink: 0)을 부여한다.
47
+ // 누락 시 긴 타이틀에서 아이콘이 찌그러진다.
48
+ className: CLASS_NAMES.MESSAGE.ICON
45
49
  });
46
50
  featuredIconElement = this.featuredIcon.getElement();
47
51
  }
48
- wrapper.innerHTML = sanitizeHtml(this.buildTemplate({
52
+ wrapper.innerHTML = sanitizeHtml(buildMessageTemplate({
49
53
  title,
50
54
  supportingText,
51
55
  actions,
@@ -61,50 +65,6 @@ export class MessageNotification {
61
65
  }
62
66
  return wrapper;
63
67
  }
64
- buildTemplate(params) {
65
- const {
66
- title,
67
- supportingText,
68
- actions,
69
- onClose,
70
- onHidePermanently
71
- } = params;
72
- return `
73
- <div class="${CLASS_NAMES.MESSAGE.CONTAINER}">
74
- <div class="${CLASS_NAMES.MESSAGE.CONTENT}">
75
- <div class="${CLASS_NAMES.MESSAGE.CONTENT_WRAPPER}">
76
- <div class="${CLASS_NAMES.MESSAGE.TEXT_CONTAINER}">
77
- <span class="${CLASS_NAMES.MESSAGE.TITLE}">${title}</span>
78
- ${renderSupportingText(supportingText, CLASS_NAMES.MESSAGE.SUPPORTING_TEXT)}
79
- </div>
80
- </div>
81
- <div class="${CLASS_NAMES.MESSAGE.ACTIONS_CONTAINER}">
82
- ${actions && actions.length > 0 ? renderActions(actions, CLASS_NAMES.MESSAGE.ACTIONS) : ''}
83
- </div>
84
- <div class="${CLASS_NAMES.MESSAGE.ACTIONS_CONTAINER}">
85
- ${this.renderHidePermanentlyButton(onHidePermanently)}
86
- ${this.renderCloseButton(onClose)}
87
- </div>
88
- </div>
89
- </div>
90
- `;
91
- }
92
- renderHidePermanentlyButton(onHidePermanently) {
93
- if (!onHidePermanently) return '';
94
- return `
95
- <button type="button" class="${CLASS_NAMES.COMMON.ACTION_BUTTON} ${CLASS_NAMES.COMMON.ACTION_BUTTON}--text ${CLASS_NAMES.MESSAGE.HIDE_LINK}" data-hide-permanently="true">
96
- 다시 보지 않기
97
- </button>
98
- `;
99
- }
100
- renderCloseButton(onClose) {
101
- if (!onClose) return '';
102
- return `
103
- <button type="button" class="${CLASS_NAMES.MESSAGE.CLOSE_BUTTON}" aria-label="알림 닫기">
104
- ${SVG_ICONS['x-close'](MESSAGE_SIZES.CLOSE_BUTTON).replace('stroke="currentColor"', `stroke="#2F2F30"`)}
105
- </button>
106
- `;
107
- }
108
68
  bindEvents() {
109
69
  bindNotificationEvents(this.element, this.options.actions, this.options.onClose, () => this.remove());
110
70
  // 다시보지 않기 버튼 이벤트 바인딩
@@ -35,6 +35,9 @@ export const CLASS_NAMES = {
35
35
  SUPPORTING_TEXT: 'ncua-message-notification__supporting-text',
36
36
  ACTIONS_CONTAINER: 'ncua-message-notification__actions-container',
37
37
  ACTIONS: 'ncua-message-notification__actions',
38
+ // 다시 보지 않기 + 닫기 그룹 전용 래퍼. ACTIONS_CONTAINER를 재사용하면
39
+ // 해당 클래스의 gap(12px)이 그룹 내부에도 적용되어 8px 규격이 깨진다.
40
+ FOOTER_CONTAINER: 'ncua-message-notification__footer-container',
38
41
  HIDE_LINK: 'ncua-message-notification__hide-link',
39
42
  CLOSE_BUTTON: 'ncua-message-notification__close-button'
40
43
  },
@@ -1,4 +1,5 @@
1
- export { SVG_ICONS, FLOATING_ICON_MAP, FULL_WIDTH_ICON_MAP, ICON_MAP } from './icons';
1
+ // Re-export all constants and types from separate files
2
2
  export { CLASS_NAMES } from './classNames';
3
- export { FEATURED_ICON_SIZES, ICON_PIXEL_SIZES, CLOSE_BUTTON_SIZES, CLOSE_BUTTON_SVG_SIZES, FULL_WIDTH_SIZES, MESSAGE_SIZES, getSizes } from './sizes';
3
+ export { FLOATING_ICON_MAP, FULL_WIDTH_ICON_MAP, ICON_MAP, SVG_ICONS } from './icons';
4
+ export { CLOSE_BUTTON_SIZES, CLOSE_BUTTON_SVG_SIZES, FEATURED_ICON_SIZES, FULL_WIDTH_SIZES, getSizes, ICON_PIXEL_SIZES, MESSAGE_SIZES } from './sizes';
4
5
  export { MESSAGE_CLOSE_ICON_COLORS } from './types';
@@ -26,9 +26,11 @@ export const FULL_WIDTH_SIZES = {
26
26
  CLOSE_BUTTON: '20'
27
27
  };
28
28
  // Message 알림 고정 사이즈
29
+ // FEATURED_ICON(원 크기)과 ICON_PIXEL(내부 SVG 크기)은 항상 짝을 맞춰야 한다.
30
+ // 'md' = 40px 원 / 20px 아이콘 (FeaturedIcon iconSizeMap 기준)
29
31
  export const MESSAGE_SIZES = {
30
- FEATURED_ICON: 'lg',
31
- ICON_PIXEL: '24',
32
+ FEATURED_ICON: 'md',
33
+ ICON_PIXEL: '20',
32
34
  CLOSE_BUTTON: '20'
33
35
  };
34
36
  // 사이즈 유틸리티 함수들
@@ -0,0 +1,71 @@
1
+ import { CLASS_NAMES, MESSAGE_SIZES, SVG_ICONS } from './const';
2
+ import { renderActions, renderSupportingText } from './utils';
3
+ /**
4
+ * message 타입 액션 버튼 사이즈. TO-BE 규격 28px이며 React `<Button size="xs">`와 같다.
5
+ * floating / full-width는 이 값을 쓰지 않고 `renderActions`의 기본값('sm')을 유지한다.
6
+ */
7
+ const ACTION_BUTTON_SIZE = 'xs';
8
+ /**
9
+ * Message Notification의 마크업을 만든다.
10
+ * React 구현(`src/components/overlays/notification/MessageNotification.tsx`)과
11
+ * 동일한 클래스 트리를 산출해야 한다 — SCSS 한 벌을 두 구현이 공유한다.
12
+ */
13
+ export function buildMessageTemplate(params) {
14
+ const {
15
+ title,
16
+ supportingText,
17
+ actions,
18
+ onClose,
19
+ onHidePermanently
20
+ } = params;
21
+ return `
22
+ <div class="${CLASS_NAMES.MESSAGE.CONTAINER}">
23
+ <div class="${CLASS_NAMES.MESSAGE.CONTENT}">
24
+ <div class="${CLASS_NAMES.MESSAGE.CONTENT_WRAPPER}">
25
+ <div class="${CLASS_NAMES.MESSAGE.TEXT_CONTAINER}">
26
+ <span class="${CLASS_NAMES.MESSAGE.TITLE}">${title}</span>
27
+ ${renderSupportingText(supportingText, CLASS_NAMES.MESSAGE.SUPPORTING_TEXT)}
28
+ </div>
29
+ </div>
30
+ ${renderActionsContainer(actions, onClose, onHidePermanently)}
31
+ </div>
32
+ </div>
33
+ `;
34
+ }
35
+ /**
36
+ * 액션 버튼 그룹과 닫기 그룹을 함께 감싸는 우측 영역.
37
+ * 이 컨테이너의 gap(12px)이 두 그룹 사이 간격을 만든다.
38
+ * 표시할 요소가 하나도 없으면 빈 컨테이너를 남기지 않는다.
39
+ */
40
+ function renderActionsContainer(actions, onClose, onHidePermanently) {
41
+ const actionsHtml = renderActions(actions ?? [], CLASS_NAMES.MESSAGE.ACTIONS, ACTION_BUTTON_SIZE);
42
+ const footerHtml = renderFooterContainer(onClose, onHidePermanently);
43
+ if (!actionsHtml && !footerHtml) return '';
44
+ return `<div class="${CLASS_NAMES.MESSAGE.ACTIONS_CONTAINER}">${actionsHtml}${footerHtml}</div>`;
45
+ }
46
+ /** 다시 보지 않기 + 닫기 그룹. 전용 래퍼를 쓰는 이유는 `CLASS_NAMES.MESSAGE.FOOTER_CONTAINER` 참조. */
47
+ function renderFooterContainer(onClose, onHidePermanently) {
48
+ if (!onHidePermanently && !onClose) return '';
49
+ return `
50
+ <div class="${CLASS_NAMES.MESSAGE.FOOTER_CONTAINER}">
51
+ ${renderHidePermanentlyButton(onHidePermanently)}
52
+ ${renderCloseButton(onClose)}
53
+ </div>
54
+ `;
55
+ }
56
+ function renderHidePermanentlyButton(onHidePermanently) {
57
+ if (!onHidePermanently) return '';
58
+ return `
59
+ <button type="button" class="${CLASS_NAMES.COMMON.ACTION_BUTTON} ${CLASS_NAMES.MESSAGE.HIDE_LINK}" data-hide-permanently="true">
60
+ 다시 보지 않기
61
+ </button>
62
+ `;
63
+ }
64
+ function renderCloseButton(onClose) {
65
+ if (!onClose) return '';
66
+ return `
67
+ <button type="button" class="${CLASS_NAMES.MESSAGE.CLOSE_BUTTON}" aria-label="알림 닫기">
68
+ ${SVG_ICONS['x-close'](MESSAGE_SIZES.CLOSE_BUTTON).replace('stroke="currentColor"', 'stroke="#2F2F30"')}
69
+ </button>
70
+ `;
71
+ }
@@ -45,16 +45,51 @@ export function renderSupportingText(supportingText, className, supportTextLink)
45
45
  // unsafe URL이거나 link가 없으면 링크 없이 텍스트만 렌더링
46
46
  return `<span class="${className}">${supportingText}</span>`;
47
47
  }
48
+ /** 액션 버튼 색상을 지정하지 않았을 때의 기본값. React `MessageNotificationParts`와 같다. */
49
+ const DEFAULT_ACTION_HIERARCHY = 'text';
50
+ /**
51
+ * 액션 버튼의 색상 수식자를 현행 어휘로 정규화한다.
52
+ *
53
+ * 구 어휘(`'link'` / `'link-gray'`)는 Link 컴포넌트의 색상 이름을 빌려 쓴 것이라
54
+ * `ncua-btn--link`에 대응하는 CSS가 존재하지 않았다. 기존 CDN 사용처를 깨지 않기 위해
55
+ * 타입에서 제거하는 대신 이 지점에서 실제 Button 테마로 매핑한다.
56
+ *
57
+ * 렌더(`renderActions`)와 이벤트 바인딩(`bindNotificationEvents`)이 이 함수를 함께 써야
58
+ * `data-action` 키가 어긋나지 않는다.
59
+ */
60
+ export function resolveActionHierarchy(hierarchy) {
61
+ switch (hierarchy) {
62
+ case undefined:
63
+ return DEFAULT_ACTION_HIERARCHY;
64
+ case 'link':
65
+ return 'text';
66
+ case 'link-gray':
67
+ return 'text-gray';
68
+ default:
69
+ return hierarchy;
70
+ }
71
+ }
72
+ /** 액션 버튼의 클릭 라우팅 키. 렌더와 바인딩이 같은 규칙으로 만들어야 한다. */
73
+ function buildActionKey(action) {
74
+ return `${action.label}-${resolveActionHierarchy(action.hierarchy)}`;
75
+ }
76
+ /**
77
+ * 액션 버튼 그룹 HTML을 만든다.
78
+ *
79
+ * `size` 기본값 'sm'은 floating / full-width 타입의 기존 렌더링을 그대로 보존하기 위한 것이다.
80
+ * message 타입만 TO-BE 규격(28px)에 맞춰 'xs'를 넘긴다 — React `<Button size="xs">`와 동일.
81
+ */
48
82
  export function renderActions(actions, wrapperClass) {
83
+ let size = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'sm';
49
84
  // 액션이 없으면 빈 문자열 반환
50
85
  if (!actions || actions.length === 0) {
51
86
  return '';
52
87
  }
53
88
  const buttonsHtml = actions.map(action => {
54
89
  const buttonHtml = `
55
- <button
56
- class="ncua-btn ncua-btn--sm ncua-btn--${action.hierarchy || 'text'}"
57
- data-action="${action.label}-${action.hierarchy}"
90
+ <button
91
+ class="ncua-btn ncua-btn--${size} ncua-btn--${resolveActionHierarchy(action.hierarchy)}"
92
+ data-action="${buildActionKey(action)}"
58
93
  >
59
94
  ${action.label}
60
95
  </button>`;
@@ -80,7 +115,7 @@ export function bindNotificationEvents(element, actions, onClose, onRemove) {
80
115
  if (actionData) {
81
116
  let matchedAction = null;
82
117
  for (const action of actions) {
83
- const expectedDataAction = `${action.label}-${action.hierarchy || 'link'}`;
118
+ const expectedDataAction = buildActionKey(action);
84
119
  if (actionData === expectedDataAction) {
85
120
  matchedAction = action;
86
121
  break;
@@ -1,10 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertCircle, AlertTriangle, CheckCircle, Pin02, XClose } from '@ncds/ui-admin-icon';
2
+ import { AlertCircle, AlertTriangle, CheckCircle, Pin02 } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
4
  import { forwardRef } from 'react';
5
5
  import { COLOR } from '../../../../constant/color';
6
- import { Button } from '../../action/button';
7
- import { FeaturedIcon } from '../../image-and-icons/featured-icon/FeaturedIcon';
6
+ import { MessageNotificationActions, MessageNotificationContent } from './MessageNotificationParts';
8
7
  const iconMap = {
9
8
  neutral: Pin02,
10
9
  error: AlertTriangle,
@@ -31,14 +30,6 @@ const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
31
30
  } = _ref;
32
31
  // message 타입은 neutral, error, warning, success 4가지 색상만 지원
33
32
  const validColor = color === 'info' ? 'neutral' : color;
34
- const iconColor = validColor;
35
- const featuredIconProps = {
36
- icon: Icon || iconMap[validColor] || Pin02,
37
- size: 'lg',
38
- color: iconColor,
39
- theme: 'light-circle'
40
- };
41
- const closeIconColor = COLOR[iconColorMap[validColor] || 'gray700'];
42
33
  return _jsx("div", {
43
34
  ref: ref,
44
35
  className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, className),
@@ -48,50 +39,17 @@ const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
48
39
  className: "ncua-message-notification__container",
49
40
  children: _jsxs("div", {
50
41
  className: "ncua-message-notification__content",
51
- children: [_jsxs("div", {
52
- className: "ncua-message-notification__content-wrapper",
53
- children: [iconMap[validColor] && _jsx(FeaturedIcon, {
54
- ...featuredIconProps,
55
- className: "ncua-message-notification__icon"
56
- }), _jsxs("div", {
57
- className: "ncua-message-notification__text-container",
58
- children: [_jsx("span", {
59
- className: "ncua-message-notification__title",
60
- children: title
61
- }), supportingText && _jsx("span", {
62
- className: "ncua-message-notification__supporting-text",
63
- children: supportingText
64
- })]
65
- })]
66
- }), _jsx("div", {
67
- className: "ncua-message-notification__actions-container",
68
- children: actions && _jsx("div", {
69
- className: "ncua-message-notification__actions",
70
- children: actions.map(action => _jsx(Button, {
71
- size: "xs",
72
- hierarchy: action.hierarchy || 'text',
73
- label: action.label,
74
- onClick: action?.onClick
75
- }, `${action.label}-${action.hierarchy}`))
76
- })
77
- }), _jsxs("div", {
78
- className: "ncua-message-notification__footer-container",
79
- children: [onHidePermanently && _jsx("button", {
80
- type: "button",
81
- className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--link', 'ncua-message-notification__hide-link'),
82
- onClick: onHidePermanently,
83
- children: "\uB2E4\uC2DC \uBCF4\uC9C0 \uC54A\uAE30"
84
- }), onClose && _jsx("button", {
85
- type: "button",
86
- className: "ncua-message-notification__close-button",
87
- onClick: onClose,
88
- "aria-label": "\uC54C\uB9BC \uB2EB\uAE30",
89
- children: _jsx(XClose, {
90
- width: 20,
91
- height: 20,
92
- color: closeIconColor
93
- })
94
- })]
42
+ children: [_jsx(MessageNotificationContent, {
43
+ title: title,
44
+ supportingText: supportingText,
45
+ icon: Icon || iconMap[validColor],
46
+ iconColor: validColor,
47
+ showIcon: Boolean(iconMap[validColor])
48
+ }), _jsx(MessageNotificationActions, {
49
+ actions: actions,
50
+ onClose: onClose,
51
+ onHidePermanently: onHidePermanently,
52
+ closeIconColor: COLOR[iconColorMap[validColor] || 'gray700']
95
53
  })]
96
54
  })
97
55
  })
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pin02, XClose } from '@ncds/ui-admin-icon';
3
+ import { Button } from '../../action/button';
4
+ import { FeaturedIcon } from '../../image-and-icons/featured-icon/FeaturedIcon';
5
+ /** 아이콘 + 제목/서포팅 텍스트 영역. */
6
+ const MessageNotificationContent = _ref => {
7
+ let {
8
+ title,
9
+ supportingText,
10
+ icon,
11
+ iconColor,
12
+ showIcon
13
+ } = _ref;
14
+ const featuredIconProps = {
15
+ icon: icon || Pin02,
16
+ size: 'md',
17
+ color: iconColor,
18
+ theme: 'light-circle'
19
+ };
20
+ return _jsxs("div", {
21
+ className: "ncua-message-notification__content-wrapper",
22
+ children: [showIcon && _jsx(FeaturedIcon, {
23
+ ...featuredIconProps,
24
+ className: "ncua-message-notification__icon"
25
+ }), _jsxs("div", {
26
+ className: "ncua-message-notification__text-container",
27
+ children: [_jsx("span", {
28
+ className: "ncua-message-notification__title",
29
+ children: title
30
+ }), supportingText && _jsx("span", {
31
+ className: "ncua-message-notification__supporting-text",
32
+ children: supportingText
33
+ })]
34
+ })]
35
+ });
36
+ };
37
+ /**
38
+ * 액션 버튼 그룹 + 닫기 그룹을 감싸는 우측 영역.
39
+ * 이 컨테이너의 gap(12px)이 두 그룹 사이 간격을 만든다.
40
+ * 표시할 요소가 하나도 없으면 빈 컨테이너를 남기지 않는다.
41
+ */
42
+ const MessageNotificationActions = _ref2 => {
43
+ let {
44
+ actions,
45
+ onClose,
46
+ onHidePermanently,
47
+ closeIconColor
48
+ } = _ref2;
49
+ const hasActions = Boolean(actions?.length);
50
+ const hasFooter = Boolean(onHidePermanently || onClose);
51
+ if (!hasActions && !hasFooter) return null;
52
+ return _jsxs("div", {
53
+ className: "ncua-message-notification__actions-container",
54
+ children: [hasActions && _jsx("div", {
55
+ className: "ncua-message-notification__actions",
56
+ children: actions?.map(action => _jsx(Button, {
57
+ size: "xs",
58
+ hierarchy: action.hierarchy || 'text',
59
+ label: action.label,
60
+ onClick: action?.onClick
61
+ }, `${action.label}-${action.hierarchy}`))
62
+ }), hasFooter && _jsxs("div", {
63
+ className: "ncua-message-notification__footer-container",
64
+ children: [onHidePermanently && _jsx("button", {
65
+ type: "button",
66
+ className: "ncua-notification__action-button ncua-message-notification__hide-link",
67
+ onClick: onHidePermanently,
68
+ children: "\uB2E4\uC2DC \uBCF4\uC9C0 \uC54A\uAE30"
69
+ }), onClose && _jsx("button", {
70
+ type: "button",
71
+ className: "ncua-message-notification__close-button",
72
+ onClick: onClose,
73
+ "aria-label": "\uC54C\uB9BC \uB2EB\uAE30",
74
+ children: _jsx(XClose, {
75
+ width: 20,
76
+ height: 20,
77
+ color: closeIconColor
78
+ })
79
+ })]
80
+ })]
81
+ });
82
+ };
83
+ export { MessageNotificationActions, MessageNotificationContent };
@@ -0,0 +1,243 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { MessageNotification } from '../MessageNotification';
7
+ // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
8
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
+ const SELECTOR = {
10
+ ROOT: '.ncua-message-notification',
11
+ CONTENT: '.ncua-message-notification__content',
12
+ CONTENT_WRAPPER: '.ncua-message-notification__content-wrapper',
13
+ ICON: '.ncua-message-notification__icon',
14
+ ACTIONS_CONTAINER: '.ncua-message-notification__actions-container',
15
+ ACTIONS: '.ncua-message-notification__actions',
16
+ FOOTER_CONTAINER: '.ncua-message-notification__footer-container',
17
+ HIDE_LINK: '.ncua-message-notification__hide-link',
18
+ CLOSE_BUTTON: '.ncua-message-notification__close-button'
19
+ };
20
+ const mounted = [];
21
+ function renderMessageNotification() {
22
+ let props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
23
+ const container = document.createElement('div');
24
+ document.body.appendChild(container);
25
+ const root = createRoot(container);
26
+ act(() => {
27
+ root.render(/*#__PURE__*/createElement(MessageNotification, {
28
+ title: '알림 제목',
29
+ ...props
30
+ }));
31
+ });
32
+ mounted.push({
33
+ root,
34
+ container
35
+ });
36
+ return container;
37
+ }
38
+ afterEach(() => {
39
+ for (const {
40
+ root,
41
+ container
42
+ } of mounted.splice(0)) {
43
+ act(() => {
44
+ root.unmount();
45
+ });
46
+ container.remove();
47
+ }
48
+ });
49
+ describe('MessageNotification (React) — DOM 구조 계약', () => {
50
+ it('U-1: __footer-container가 __actions-container의 자식으로 렌더된다', () => {
51
+ // Given: 다시 보지 않기·닫기 핸들러가 주어지고
52
+ // When: MessageNotification을 렌더하면
53
+ const container = renderMessageNotification({
54
+ onHidePermanently: vi.fn(),
55
+ onClose: vi.fn()
56
+ });
57
+ // Then: footer-container는 actions-container의 직계 자식이다 (content의 직계 자식이 아니다)
58
+ const actionsContainer = container.querySelector(SELECTOR.ACTIONS_CONTAINER);
59
+ const footerContainer = container.querySelector(SELECTOR.FOOTER_CONTAINER);
60
+ expect(actionsContainer).not.toBeNull();
61
+ expect(footerContainer).not.toBeNull();
62
+ expect(footerContainer?.parentElement).toBe(actionsContainer);
63
+ });
64
+ it('U-2: actions 지정 시 __actions와 __footer-container가 __actions-container 안에 순서대로 공존한다', () => {
65
+ // Given: 액션 버튼 2개와 다시 보지 않기·닫기 핸들러가 주어지고
66
+ const actions = [{
67
+ label: '조회하기',
68
+ hierarchy: 'text',
69
+ onClick: vi.fn()
70
+ }, {
71
+ label: '포인트 충전',
72
+ hierarchy: 'text-gray',
73
+ onClick: vi.fn()
74
+ }];
75
+ // When: 렌더하면
76
+ const container = renderMessageNotification({
77
+ actions,
78
+ onHidePermanently: vi.fn(),
79
+ onClose: vi.fn()
80
+ });
81
+ // Then: actions-container의 자식은 [actions, footer-container] 순서다
82
+ const actionsContainer = container.querySelector(SELECTOR.ACTIONS_CONTAINER);
83
+ const actionsGroup = container.querySelector(SELECTOR.ACTIONS);
84
+ const footerContainer = container.querySelector(SELECTOR.FOOTER_CONTAINER);
85
+ expect(actionsContainer).not.toBeNull();
86
+ expect(Array.from(actionsContainer?.children ?? [])).toEqual([actionsGroup, footerContainer]);
87
+ });
88
+ it('U-3: onHidePermanently·onClose·actions 미지정 시 빈 컨테이너가 남지 않는다', () => {
89
+ // Given/When: 우측 그룹에 표시할 요소가 하나도 없는 상태로 렌더하면
90
+ const container = renderMessageNotification();
91
+ // Then: actions-container도 footer-container도 렌더되지 않는다
92
+ expect(container.querySelector(SELECTOR.ACTIONS_CONTAINER)).toBeNull();
93
+ expect(container.querySelector(SELECTOR.FOOTER_CONTAINER)).toBeNull();
94
+ // 본문 영역은 그대로 유지된다
95
+ expect(container.querySelector(SELECTOR.CONTENT_WRAPPER)).not.toBeNull();
96
+ });
97
+ it('U-3-a: actions만 지정하면 actions-container만 남고 footer-container는 없다', () => {
98
+ // Given/When: 액션 버튼만 있고 닫기 그룹이 없으면
99
+ const container = renderMessageNotification({
100
+ actions: [{
101
+ label: '조회하기',
102
+ hierarchy: 'text',
103
+ onClick: vi.fn()
104
+ }]
105
+ });
106
+ // Then: actions-container는 존재하고 그 자식은 actions 그룹 하나뿐이다
107
+ const actionsContainer = container.querySelector(SELECTOR.ACTIONS_CONTAINER);
108
+ const actionsGroup = container.querySelector(SELECTOR.ACTIONS);
109
+ expect(actionsContainer).not.toBeNull();
110
+ expect(container.querySelector(SELECTOR.FOOTER_CONTAINER)).toBeNull();
111
+ expect(Array.from(actionsContainer?.children ?? [])).toEqual([actionsGroup]);
112
+ });
113
+ it('U-3-b: onClose만 지정하면 footer-container에 닫기 버튼만 담긴다', () => {
114
+ // Given/When: 닫기 핸들러만 지정하면
115
+ const container = renderMessageNotification({
116
+ onClose: vi.fn()
117
+ });
118
+ // Then: footer-container에는 닫기 버튼 1개만 존재하고 hide-link는 없다
119
+ const footerContainer = container.querySelector(SELECTOR.FOOTER_CONTAINER);
120
+ expect(footerContainer?.children).toHaveLength(1);
121
+ expect(footerContainer?.querySelector(SELECTOR.CLOSE_BUTTON)).not.toBeNull();
122
+ expect(footerContainer?.querySelector(SELECTOR.HIDE_LINK)).toBeNull();
123
+ });
124
+ it('U-4: FeaturedIcon이 md 사이즈(40px 원 / 20px 아이콘)로 렌더된다', () => {
125
+ // Given/When: error 색상으로 렌더하면
126
+ const container = renderMessageNotification({
127
+ color: 'error'
128
+ });
129
+ // Then: featured-icon은 --md 수식자를 가지며 내부 SVG는 20px다
130
+ const icon = container.querySelector(SELECTOR.ICON);
131
+ expect(icon).not.toBeNull();
132
+ expect(icon?.classList.contains('ncua-featured-icon--md')).toBe(true);
133
+ expect(icon?.classList.contains('ncua-featured-icon--lg')).toBe(false);
134
+ expect(icon?.querySelector('svg')?.getAttribute('width')).toBe('20');
135
+ });
136
+ it('hide-link는 구현별 수식자(--link/--text) 없이 공통 클래스 2개만 갖는다', () => {
137
+ // Given/When: 다시 보지 않기 핸들러를 지정해 렌더하면
138
+ const container = renderMessageNotification({
139
+ onHidePermanently: vi.fn()
140
+ });
141
+ // Then: SCSS 미정의 수식자 --link에 의존하지 않는다
142
+ const hideLink = container.querySelector(SELECTOR.HIDE_LINK);
143
+ expect(hideLink?.className.split(' ').filter(Boolean).sort()).toEqual(['ncua-message-notification__hide-link', 'ncua-notification__action-button']);
144
+ });
145
+ });
146
+ describe('MessageNotification (React) — 공개 API 회귀 방지', () => {
147
+ it('지원하지 않는 color("info")는 neutral로 폴백한다', () => {
148
+ // Given/When: message 타입이 지원하지 않는 info 색상을 넘기면
149
+ const container = renderMessageNotification({
150
+ color: 'info'
151
+ });
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);
156
+ });
157
+ it('닫기/다시 보지 않기 클릭 시 핸들러가 호출되고 접근성 속성이 유지된다', () => {
158
+ // Given: 핸들러를 지정해 렌더하고
159
+ const onClose = vi.fn();
160
+ const onHidePermanently = vi.fn();
161
+ const container = renderMessageNotification({
162
+ onClose,
163
+ onHidePermanently
164
+ });
165
+ // When: 각 버튼을 클릭하면
166
+ act(() => {
167
+ container.querySelector(SELECTOR.CLOSE_BUTTON)?.click();
168
+ container.querySelector(SELECTOR.HIDE_LINK)?.click();
169
+ });
170
+ // Then: 핸들러가 각각 1회 호출되고 role/aria-label이 유지된다
171
+ expect(onClose).toHaveBeenCalledTimes(1);
172
+ expect(onHidePermanently).toHaveBeenCalledTimes(1);
173
+ expect(container.querySelector(SELECTOR.ROOT)?.getAttribute('role')).toBe('alert');
174
+ expect(container.querySelector(SELECTOR.CLOSE_BUTTON)?.getAttribute('aria-label')).toBe('알림 닫기');
175
+ });
176
+ });
177
+ describe('MessageNotification (React) — 액션 버튼 색상(hierarchy)', () => {
178
+ const SIZE_MODIFIER = /^ncua-btn--(xs|sm|md|lg|xl)$/;
179
+ /** 사이즈를 제외한 `ncua-btn--*` 수식자 = 색상(hierarchy) 수식자. */
180
+ function buttonHierarchyModifiers(root) {
181
+ return Array.from(root.querySelectorAll('.ncua-btn')).map(button => Array.from(button.classList).find(name => name.startsWith('ncua-btn--') && !SIZE_MODIFIER.test(name)) ?? '');
182
+ }
183
+ it('Figma TO-BE 조합(secondary-gray / secondary)을 ncua-btn 수식자로 반영한다', () => {
184
+ // Given/When: 조회하기 secondary-gray, 포인트 충전 secondary로 렌더하면
185
+ const container = renderMessageNotification({
186
+ actions: [{
187
+ label: '조회하기',
188
+ hierarchy: 'secondary-gray',
189
+ onClick: vi.fn()
190
+ }, {
191
+ label: '포인트 충전',
192
+ hierarchy: 'secondary',
193
+ onClick: vi.fn()
194
+ }]
195
+ });
196
+ // Then: ButtonTheme와 동일한 수식자가 순서대로 붙는다
197
+ expect(buttonHierarchyModifiers(container)).toEqual(['ncua-btn--secondary-gray', 'ncua-btn--secondary']);
198
+ });
199
+ it('text / text-gray도 계속 지원한다', () => {
200
+ // Given/When: 기존 어휘로 렌더하면
201
+ const container = renderMessageNotification({
202
+ actions: [{
203
+ label: '조회하기',
204
+ hierarchy: 'text',
205
+ onClick: vi.fn()
206
+ }, {
207
+ label: '포인트 충전',
208
+ hierarchy: 'text-gray',
209
+ onClick: vi.fn()
210
+ }]
211
+ });
212
+ // Then: 기존 수식자가 그대로 유지된다 (회귀 방지)
213
+ expect(buttonHierarchyModifiers(container)).toEqual(['ncua-btn--text', 'ncua-btn--text-gray']);
214
+ });
215
+ it('hierarchy를 생략하면 기본값 text로 렌더한다', () => {
216
+ // Given/When: 색상을 지정하지 않은 액션을 렌더하면
217
+ const container = renderMessageNotification({
218
+ actions: [{
219
+ label: '확인',
220
+ onClick: vi.fn()
221
+ }]
222
+ });
223
+ // Then: Button의 기본값(secondary-gray)이 아니라 알림의 기본값 text가 적용된다
224
+ expect(buttonHierarchyModifiers(container)).toEqual(['ncua-btn--text']);
225
+ });
226
+ it('액션 버튼 클릭 시 onClick이 호출된다', () => {
227
+ // Given: secondary 색상 액션을 렌더하고
228
+ const onClick = vi.fn();
229
+ const container = renderMessageNotification({
230
+ actions: [{
231
+ label: '포인트 충전',
232
+ hierarchy: 'secondary',
233
+ onClick
234
+ }]
235
+ });
236
+ // When: 버튼을 클릭하면
237
+ act(() => {
238
+ container.querySelector('.ncua-btn')?.click();
239
+ });
240
+ // Then: 핸들러가 1회 호출된다
241
+ expect(onClick).toHaveBeenCalledTimes(1);
242
+ });
243
+ });