@ncds/ui-admin 1.8.25-alpha.3 → 1.8.26-alpha.0

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 (111) hide show
  1. package/dist/cjs/assets/scripts/notification/FullWidthNotification.js +2 -0
  2. package/dist/cjs/assets/scripts/notification/MessageNotification.js +30 -23
  3. package/dist/cjs/assets/scripts/notification/const/classNames.js +3 -0
  4. package/dist/cjs/assets/scripts/notification/const/icons.js +11 -2
  5. package/dist/cjs/assets/scripts/notification/const/index.js +9 -4
  6. package/dist/cjs/assets/scripts/notification/const/sizes.js +6 -1
  7. package/dist/cjs/assets/scripts/notification/const/types.js +1 -10
  8. package/dist/cjs/assets/scripts/notification/messageTemplate.js +20 -8
  9. package/dist/cjs/assets/scripts/notification/utils.js +21 -17
  10. package/dist/cjs/constant/notification.js +15 -1
  11. package/dist/cjs/src/components/overlays/notification/FloatingNotification.js +4 -7
  12. package/dist/cjs/src/components/overlays/notification/FullWidthNotification.js +13 -8
  13. package/dist/cjs/src/components/overlays/notification/MessageNotification.js +20 -14
  14. package/dist/cjs/src/components/overlays/notification/MessageNotificationParts.js +22 -15
  15. package/dist/cjs/src/components/overlays/notification/Notification.js +6 -0
  16. package/dist/cjs/src/components/overlays/notification/NotificationActionButtons.js +27 -0
  17. package/dist/cjs/src/components/overlays/notification/__tests__/supportTextLink.test.js +58 -0
  18. package/dist/cjs/src/components/overlays/notification/linkTarget.js +18 -0
  19. package/dist/cjs/src/generated/scoped-css.js +1 -1
  20. package/dist/cjs/src/utils/index.js +11 -0
  21. package/dist/cjs/src/utils/url/__tests__/isSafeUrl.test.js +49 -0
  22. package/dist/cjs/src/utils/url/index.js +16 -0
  23. package/dist/cjs/src/utils/url/isSafeUrl.js +32 -0
  24. package/dist/esm/assets/scripts/notification/FullWidthNotification.js +3 -1
  25. package/dist/esm/assets/scripts/notification/MessageNotification.js +32 -25
  26. package/dist/esm/assets/scripts/notification/const/classNames.js +3 -0
  27. package/dist/esm/assets/scripts/notification/const/icons.js +10 -1
  28. package/dist/esm/assets/scripts/notification/const/index.js +2 -3
  29. package/dist/esm/assets/scripts/notification/const/sizes.js +5 -0
  30. package/dist/esm/assets/scripts/notification/const/types.js +1 -8
  31. package/dist/esm/assets/scripts/notification/messageTemplate.js +21 -9
  32. package/dist/esm/assets/scripts/notification/utils.js +19 -16
  33. package/dist/esm/constant/notification.js +14 -0
  34. package/dist/esm/src/components/overlays/notification/FloatingNotification.js +4 -7
  35. package/dist/esm/src/components/overlays/notification/FullWidthNotification.js +13 -8
  36. package/dist/esm/src/components/overlays/notification/MessageNotification.js +21 -15
  37. package/dist/esm/src/components/overlays/notification/MessageNotificationParts.js +22 -15
  38. package/dist/esm/src/components/overlays/notification/Notification.js +6 -0
  39. package/dist/esm/src/components/overlays/notification/NotificationActionButtons.js +21 -0
  40. package/dist/esm/src/components/overlays/notification/__tests__/supportTextLink.test.js +55 -0
  41. package/dist/esm/src/components/overlays/notification/linkTarget.js +12 -0
  42. package/dist/esm/src/generated/scoped-css.js +1 -1
  43. package/dist/esm/src/utils/index.js +3 -1
  44. package/dist/esm/src/utils/url/__tests__/isSafeUrl.test.js +47 -0
  45. package/dist/esm/src/utils/url/index.js +1 -0
  46. package/dist/esm/src/utils/url/isSafeUrl.js +26 -0
  47. package/dist/temp/assets/scripts/notification/FullWidthNotification.js +3 -2
  48. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +2 -0
  49. package/dist/temp/assets/scripts/notification/MessageNotification.js +30 -25
  50. package/dist/temp/assets/scripts/notification/const/classNames.d.ts +3 -0
  51. package/dist/temp/assets/scripts/notification/const/classNames.js +3 -0
  52. package/dist/temp/assets/scripts/notification/const/icons.d.ts +6 -0
  53. package/dist/temp/assets/scripts/notification/const/icons.js +9 -0
  54. package/dist/temp/assets/scripts/notification/const/index.d.ts +3 -4
  55. package/dist/temp/assets/scripts/notification/const/index.js +2 -3
  56. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +4 -0
  57. package/dist/temp/assets/scripts/notification/const/sizes.js +5 -0
  58. package/dist/temp/assets/scripts/notification/const/types.d.ts +28 -1
  59. package/dist/temp/assets/scripts/notification/const/types.js +1 -8
  60. package/dist/temp/assets/scripts/notification/messageTemplate.d.ts +5 -1
  61. package/dist/temp/assets/scripts/notification/messageTemplate.js +16 -11
  62. package/dist/temp/assets/scripts/notification/utils.d.ts +10 -3
  63. package/dist/temp/assets/scripts/notification/utils.js +21 -18
  64. package/dist/temp/constant/notification.d.ts +13 -0
  65. package/dist/temp/constant/notification.js +14 -0
  66. package/dist/temp/src/components/overlays/notification/FloatingNotification.js +2 -2
  67. package/dist/temp/src/components/overlays/notification/FullWidthNotification.d.ts +6 -1
  68. package/dist/temp/src/components/overlays/notification/FullWidthNotification.js +7 -2
  69. package/dist/temp/src/components/overlays/notification/MessageNotification.d.ts +15 -1
  70. package/dist/temp/src/components/overlays/notification/MessageNotification.js +11 -12
  71. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.d.ts +10 -5
  72. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.js +9 -6
  73. package/dist/temp/src/components/overlays/notification/Notification.d.ts +23 -2
  74. package/dist/temp/src/components/overlays/notification/Notification.js +3 -3
  75. package/dist/temp/src/components/overlays/notification/NotificationActionButtons.d.ts +12 -0
  76. package/dist/temp/src/components/overlays/notification/NotificationActionButtons.js +9 -0
  77. package/dist/temp/src/components/overlays/notification/__tests__/supportTextLink.test.d.ts +1 -0
  78. package/dist/temp/src/components/overlays/notification/__tests__/supportTextLink.test.js +73 -0
  79. package/dist/temp/src/components/overlays/notification/linkTarget.d.ts +11 -0
  80. package/dist/temp/src/components/overlays/notification/linkTarget.js +9 -0
  81. package/dist/temp/src/generated/scoped-css.js +1 -1
  82. package/dist/temp/src/utils/index.d.ts +1 -0
  83. package/dist/temp/src/utils/index.js +2 -0
  84. package/dist/temp/src/utils/url/__tests__/isSafeUrl.test.d.ts +1 -0
  85. package/dist/temp/src/utils/url/__tests__/isSafeUrl.test.js +61 -0
  86. package/dist/temp/src/utils/url/index.d.ts +1 -0
  87. package/dist/temp/src/utils/url/index.js +1 -0
  88. package/dist/temp/src/utils/url/isSafeUrl.d.ts +8 -0
  89. package/dist/temp/src/utils/url/isSafeUrl.js +29 -0
  90. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +2 -0
  91. package/dist/types/assets/scripts/notification/const/classNames.d.ts +3 -0
  92. package/dist/types/assets/scripts/notification/const/icons.d.ts +6 -0
  93. package/dist/types/assets/scripts/notification/const/index.d.ts +3 -4
  94. package/dist/types/assets/scripts/notification/const/sizes.d.ts +4 -0
  95. package/dist/types/assets/scripts/notification/const/types.d.ts +28 -1
  96. package/dist/types/assets/scripts/notification/messageTemplate.d.ts +5 -1
  97. package/dist/types/assets/scripts/notification/utils.d.ts +10 -3
  98. package/dist/types/constant/notification.d.ts +13 -0
  99. package/dist/types/src/components/overlays/notification/FullWidthNotification.d.ts +6 -1
  100. package/dist/types/src/components/overlays/notification/MessageNotification.d.ts +15 -1
  101. package/dist/types/src/components/overlays/notification/MessageNotificationParts.d.ts +10 -5
  102. package/dist/types/src/components/overlays/notification/Notification.d.ts +23 -2
  103. package/dist/types/src/components/overlays/notification/NotificationActionButtons.d.ts +12 -0
  104. package/dist/types/src/components/overlays/notification/__tests__/supportTextLink.test.d.ts +1 -0
  105. package/dist/types/src/components/overlays/notification/linkTarget.d.ts +11 -0
  106. package/dist/types/src/utils/index.d.ts +1 -0
  107. package/dist/types/src/utils/url/__tests__/isSafeUrl.test.d.ts +1 -0
  108. package/dist/types/src/utils/url/index.d.ts +1 -0
  109. package/dist/types/src/utils/url/isSafeUrl.d.ts +8 -0
  110. package/dist/ui-admin/assets/styles/style.css +42 -20
  111. package/package.json +1 -1
@@ -5,12 +5,12 @@ import { forwardRef, useEffect, useId, useRef, useState } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { MEDIA_QUERY } from '../../../constant/breakpoint';
7
7
  import { useMediaQuery } from '../../../hooks/useMediaQuery';
8
- import { Button } from '../../action/button';
9
8
  import { ButtonCloseX } from '../../action/button/ButtonCloseX';
10
9
  import { FeaturedIcon, } from '../../image-and-icons/featured-icon/FeaturedIcon';
11
10
  // 호스트 싱글톤은 vanilla/React 양쪽이 동일한 함수를 공유한다 — host.ts 가 가까운 진입점 역할을
12
11
  // 하므로 React 컴포넌트는 deep relative path 로 vanilla internals 를 직접 import 하지 않는다.
13
12
  import { mountFloatingNotificationHost } from './host';
13
+ import { NotificationActionButtons } from './NotificationActionButtons';
14
14
  /**
15
15
  * 색상별 a11y role.
16
16
  * - error/warning → role="alert" (implicit aria-live="assertive": 즉시 발화)
@@ -110,7 +110,7 @@ const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutr
110
110
  const card = (
111
111
  // a11y/예측가능성 — caller 가 className/id 등 일부는 덮어쓸 수 있게 {...rest} 를 먼저 펼치고,
112
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 }))] }));
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: _jsx(NotificationActionButtons, { actions: actions }) }))] })] }) }), onClose && (_jsx(ButtonCloseX, { theme: "light", className: "ncua-floating-notification__close-button", size: isMobile ? 'sm' : 'xs', onClick: onClose }))] }));
114
114
  // 기본은 인라인 렌더 — 부모 JSX 트리에 카드를 그대로 둔다.
115
115
  if (!portal) {
116
116
  return card;
@@ -1,5 +1,5 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
- import type { NotificationAction, NotificationColor } from './Notification';
2
+ import type { NotificationAction, NotificationColor, NotificationLinkTarget } from './Notification';
3
3
  interface FullWidthNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
4
4
  /**
5
5
  * 알림 제목 텍스트
@@ -36,6 +36,11 @@ interface FullWidthNotificationProps extends Omit<ComponentPropsWithoutRef<'div'
36
36
  * info 링크 영역 (선택사항)
37
37
  */
38
38
  supportTextLink?: string;
39
+ /**
40
+ * 텍스트 링크를 여는 대상 (선택사항). `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`
41
+ * @default '_self'
42
+ */
43
+ supportTextLinkTarget?: NotificationLinkTarget;
39
44
  /**
40
45
  * 다시보지 않기 이벤트 핸들러 (선택사항)
41
46
  */
@@ -4,6 +4,9 @@ import classNames from 'classnames';
4
4
  import { forwardRef, useEffect, useRef, useState } from 'react';
5
5
  import { COLOR } from '../../../../constant/color';
6
6
  import { HIDE_PERMANENTLY_LABEL } from '../../../../constant/notification';
7
+ import { isSafeUrl } from '../../../utils/url';
8
+ import { getLinkTargetAttributes } from './linkTarget';
9
+ import { NotificationActionButtons } from './NotificationActionButtons';
7
10
  const iconMap = {
8
11
  neutral: MessageChatSquare,
9
12
  error: AlertTriangle,
@@ -18,8 +21,10 @@ const iconColorMap = {
18
21
  success: 'green600',
19
22
  info: 'violet600',
20
23
  };
21
- const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, supportTextLink, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.fullWidth, ...rest }, ref) => {
24
+ const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, supportTextLink, supportTextLinkTarget, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.fullWidth, ...rest }, ref) => {
22
25
  const [shouldRemove, setShouldRemove] = useState(false);
26
+ // unsafe URL(javascript: 등)이면 링크 없이 텍스트만 렌더한다 — Vanilla `renderSupportingText` 와 같은 정책.
27
+ const safeSupportTextLink = supportTextLink && isSafeUrl(supportTextLink) ? supportTextLink : undefined;
23
28
  const closeIconSize = {
24
29
  width: 20,
25
30
  height: 20,
@@ -52,6 +57,6 @@ const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neut
52
57
  return (_jsx("div", { ref: ref, className: classNames('ncua-full-width-notification', `ncua-full-width-notification--${color}`, className), role: "alert", ...rest, children: _jsx("div", { className: "ncua-full-width-notification__container", children: _jsxs("div", { className: "ncua-full-width-notification__content", children: [_jsxs("div", { className: "ncua-full-width-notification__content-wrapper", children: [(() => {
53
58
  const IconComponent = iconMap[color];
54
59
  return (_jsx(IconComponent, { width: 16, height: 16, color: COLOR[iconColorMap[color]], className: "ncua-full-width-notification__icon" }));
55
- })(), _jsxs("div", { className: "ncua-full-width-notification__text-container", children: [_jsx("span", { className: "ncua-full-width-notification__title", children: title }), !supportTextLink && supportingText && (_jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText })), supportTextLink && supportingText && (_jsx("a", { href: supportTextLink, className: "ncua-full-width-notification__link", children: _jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText }) }))] })] }), _jsxs("div", { className: "ncua-full-width-notification__actions-container", children: [actions && (_jsx("div", { className: "ncua-full-width-notification__actions", children: actions.map((action) => (_jsx("button", { onClick: action.onClick, className: classNames('ncua-notification__action-button', `ncua-notification__action-button--${action.hierarchy || 'text'}`), children: action.label }, `${action.label}-${action.hierarchy}`))) })), onHidePermanently && (_jsx("button", { type: "button", className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--text', 'ncua-full-width-notification__link'), onClick: onHidePermanently, children: hidePermanentlyLabel })), onClose && (_jsx("button", { type: "button", className: "ncua-full-width-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { ...closeIconSize, color: COLOR[iconColorMap[color]] }) }))] })] }) }) }));
60
+ })(), _jsxs("div", { className: "ncua-full-width-notification__text-container", children: [_jsx("span", { className: "ncua-full-width-notification__title", children: title }), !safeSupportTextLink && supportingText && (_jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText })), safeSupportTextLink && supportingText && (_jsx("a", { href: safeSupportTextLink, className: "ncua-full-width-notification__link", ...getLinkTargetAttributes(supportTextLinkTarget), children: _jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText }) }))] })] }), _jsxs("div", { className: "ncua-full-width-notification__actions-container", children: [actions && (_jsx("div", { className: "ncua-full-width-notification__actions", children: _jsx(NotificationActionButtons, { actions: actions }) })), onHidePermanently && (_jsx("button", { type: "button", className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--text', 'ncua-full-width-notification__link'), onClick: onHidePermanently, children: hidePermanentlyLabel })), onClose && (_jsx("button", { type: "button", className: "ncua-full-width-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { ...closeIconSize, color: COLOR[iconColorMap[color]] }) }))] })] }) }) }));
56
61
  });
57
62
  export { FullWidthNotification };
@@ -1,6 +1,6 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import type { SlotIconComponent } from '../../../types/side-slot';
3
- import type { NotificationAction, NotificationColor } from './Notification';
3
+ import type { NotificationAction, NotificationColor, NotificationLinkTarget, NotificationVariant } from './Notification';
4
4
  interface MessageNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
5
5
  /**
6
6
  * 알림 제목 텍스트
@@ -41,6 +41,20 @@ interface MessageNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>,
41
41
  * @default '다시 보지 않기'
42
42
  */
43
43
  hidePermanentlyLabel?: ReactNode;
44
+ /**
45
+ * 표시 변형. `'block'`은 서브 페이지 공지용(흰 배경, FeaturedIcon sm, 제목 + 텍스트 링크 1행, info 유지)
46
+ * @default 'default'
47
+ */
48
+ variant?: NotificationVariant;
49
+ /**
50
+ * 텍스트 링크 URL. `variant="block"`에서만 동작하며 `supportingText`가 링크 텍스트가 된다.
51
+ */
52
+ supportTextLink?: string;
53
+ /**
54
+ * 텍스트 링크를 여는 대상. `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`
55
+ * @default '_self'
56
+ */
57
+ supportTextLinkTarget?: NotificationLinkTarget;
44
58
  }
45
59
  declare const MessageNotification: import("react").ForwardRefExoticComponent<MessageNotificationProps & import("react").RefAttributes<HTMLDivElement>>;
46
60
  export type { MessageNotificationProps };
@@ -1,25 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertCircle, AlertTriangle, CheckCircle, Pin02 } from '@ncds/ui-admin-icon';
2
+ import { AlertCircle, AlertTriangle, CheckCircle, InfoCircle, Pin02 } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
4
  import { forwardRef } from 'react';
5
- import { COLOR } from '../../../../constant/color';
5
+ import { MESSAGE_CLOSE_ICON_COLOR } from '../../../../constant/notification';
6
6
  import { MessageNotificationActions, MessageNotificationContent } from './MessageNotificationParts';
7
7
  const iconMap = {
8
8
  neutral: Pin02,
9
9
  error: AlertTriangle,
10
10
  warning: AlertCircle,
11
11
  success: CheckCircle,
12
+ // info 는 variant="block" 에서만 도달한다 (default 는 neutral 로 폴백).
13
+ info: InfoCircle,
12
14
  };
13
- const iconColorMap = {
14
- neutral: 'gray700',
15
- error: 'red500',
16
- warning: 'orange500',
17
- success: 'green600',
18
- };
19
- const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, className, actions, onHidePermanently, hidePermanentlyLabel, ...rest }, ref) => {
20
- // message 타입은 neutral, error, warning, success 4가지 색상만 지원
21
- const validColor = color === 'info' ? 'neutral' : color;
22
- return (_jsx("div", { ref: ref, className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, className), role: "alert", ...rest, children: _jsx("div", { className: "ncua-message-notification__container", children: _jsxs("div", { className: "ncua-message-notification__content", children: [_jsx(MessageNotificationContent, { title: title, supportingText: supportingText, icon: Icon || iconMap[validColor], iconColor: validColor, showIcon: Boolean(iconMap[validColor]) }), _jsx(MessageNotificationActions, { actions: actions, onClose: onClose, onHidePermanently: onHidePermanently, hidePermanentlyLabel: hidePermanentlyLabel, closeIconColor: COLOR[iconColorMap[validColor] || 'gray700'] })] }) }) }));
15
+ const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, className, actions, onHidePermanently, hidePermanentlyLabel, variant = 'default', supportTextLink, supportTextLinkTarget, ...rest }, ref) => {
16
+ const isBlock = variant === 'block';
17
+ // default 는 neutral, error, warning, success 4가지 색상만 지원 — info 는 block 에서만 유지한다.
18
+ const validColor = !isBlock && color === 'info' ? 'neutral' : color;
19
+ // FeaturedIcon 에는 info 색이 없다. block+info 의 violet 톤은 message SCSS 가 덮어쓴다.
20
+ const featuredIconColor = validColor === 'info' ? 'neutral' : validColor;
21
+ return (_jsx("div", { ref: ref, className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, { 'ncua-message-notification--block': isBlock }, className), role: "alert", ...rest, children: _jsx("div", { className: "ncua-message-notification__container", children: _jsxs("div", { className: "ncua-message-notification__content", children: [_jsx(MessageNotificationContent, { title: title, supportingText: supportingText, supportTextLink: isBlock ? supportTextLink : undefined, supportTextLinkTarget: supportTextLinkTarget, icon: Icon || iconMap[validColor], iconColor: featuredIconColor, iconSize: isBlock ? 'sm' : 'md' }), _jsx(MessageNotificationActions, { actions: actions, onClose: onClose, onHidePermanently: onHidePermanently, hidePermanentlyLabel: hidePermanentlyLabel, closeIconColor: MESSAGE_CLOSE_ICON_COLOR[validColor] })] }) }) }));
23
22
  });
24
23
  MessageNotification.displayName = 'MessageNotification';
25
24
  export { MessageNotification };
@@ -1,16 +1,21 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { SlotIconComponent } from '../../../types/side-slot';
3
- import { type FeaturedIconColor } from '../../image-and-icons/featured-icon/FeaturedIcon';
4
- import type { NotificationAction } from './Notification';
3
+ import { type FeaturedIconColor, type FeaturedIconSize } from '../../image-and-icons/featured-icon/FeaturedIcon';
4
+ import type { NotificationAction, NotificationLinkTarget } from './Notification';
5
5
  interface MessageNotificationContentProps {
6
6
  title: ReactNode;
7
7
  supportingText?: ReactNode;
8
+ /** 지정 시 `supportingText`를 이 URL 의 텍스트 링크로 렌더한다 (block 변형 전용). http/https·상대경로가 아니면 텍스트만 렌더. */
9
+ supportTextLink?: string;
10
+ /** 텍스트 링크를 여는 대상. `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`. */
11
+ supportTextLinkTarget?: NotificationLinkTarget;
8
12
  icon?: SlotIconComponent;
9
13
  iconColor: FeaturedIconColor;
10
- showIcon: boolean;
14
+ /** default 는 md(40/20), block 은 sm(32/16). */
15
+ iconSize: FeaturedIconSize;
11
16
  }
12
- /** 아이콘 + 제목/서포팅 텍스트 영역. */
13
- declare const MessageNotificationContent: ({ title, supportingText, icon, iconColor, showIcon, }: MessageNotificationContentProps) => import("react/jsx-runtime").JSX.Element;
17
+ /** 아이콘 + 제목/서포팅 텍스트(또는 텍스트 링크) 영역. */
18
+ declare const MessageNotificationContent: ({ title, supportingText, supportTextLink, supportTextLinkTarget, icon, iconColor, iconSize, }: MessageNotificationContentProps) => import("react/jsx-runtime").JSX.Element;
14
19
  interface MessageNotificationActionsProps {
15
20
  actions?: NotificationAction[];
16
21
  onClose?: () => void;
@@ -1,17 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Pin02, XClose } from '@ncds/ui-admin-icon';
3
3
  import { HIDE_PERMANENTLY_LABEL } from '../../../../constant/notification';
4
- import { Button } from '../../action/button';
4
+ import { isSafeUrl } from '../../../utils/url';
5
5
  import { FeaturedIcon, } from '../../image-and-icons/featured-icon/FeaturedIcon';
6
- /** 아이콘 + 제목/서포팅 텍스트 영역. */
7
- const MessageNotificationContent = ({ title, supportingText, icon, iconColor, showIcon, }) => {
6
+ import { getLinkTargetAttributes } from './linkTarget';
7
+ import { NotificationActionButtons } from './NotificationActionButtons';
8
+ /** 아이콘 + 제목/서포팅 텍스트(또는 텍스트 링크) 영역. */
9
+ const MessageNotificationContent = ({ title, supportingText, supportTextLink, supportTextLinkTarget, icon, iconColor, iconSize, }) => {
8
10
  const featuredIconProps = {
9
11
  icon: icon || Pin02,
10
- size: 'md',
12
+ size: iconSize,
11
13
  color: iconColor,
12
14
  theme: 'light-circle',
13
15
  };
14
- return (_jsxs("div", { className: "ncua-message-notification__content-wrapper", children: [showIcon && _jsx(FeaturedIcon, { ...featuredIconProps, className: "ncua-message-notification__icon" }), _jsxs("div", { className: "ncua-message-notification__text-container", children: [_jsx("span", { className: "ncua-message-notification__title", children: title }), supportingText && _jsx("span", { className: "ncua-message-notification__supporting-text", children: supportingText })] })] }));
16
+ const supportingTextNode = supportingText && (_jsx("span", { className: "ncua-message-notification__supporting-text", children: supportingText }));
17
+ return (_jsxs("div", { className: "ncua-message-notification__content-wrapper", children: [_jsx(FeaturedIcon, { ...featuredIconProps, className: "ncua-message-notification__icon" }), _jsxs("div", { className: "ncua-message-notification__text-container", children: [_jsx("span", { className: "ncua-message-notification__title", children: title }), supportTextLink && isSafeUrl(supportTextLink) && supportingTextNode ? (_jsx("a", { href: supportTextLink, className: "ncua-message-notification__link", ...getLinkTargetAttributes(supportTextLinkTarget), children: supportingTextNode })) : (supportingTextNode)] })] }));
15
18
  };
16
19
  /**
17
20
  * 액션 버튼 그룹 + 닫기 그룹을 감싸는 우측 영역.
@@ -23,6 +26,6 @@ const MessageNotificationActions = ({ actions, onClose, onHidePermanently, hideP
23
26
  const hasFooter = Boolean(onHidePermanently || onClose);
24
27
  if (!hasActions && !hasFooter)
25
28
  return null;
26
- return (_jsxs("div", { className: "ncua-message-notification__actions-container", children: [hasActions && (_jsx("div", { className: "ncua-message-notification__actions", children: actions?.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action?.onClick }, `${action.label}-${action.hierarchy}`))) })), hasFooter && (_jsxs("div", { className: "ncua-message-notification__footer-container", children: [onHidePermanently && (_jsx("button", { type: "button", className: "ncua-notification__action-button ncua-message-notification__hide-link", onClick: onHidePermanently, children: hidePermanentlyLabel })), onClose && (_jsx("button", { type: "button", className: "ncua-message-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { width: 20, height: 20, color: closeIconColor }) }))] }))] }));
29
+ return (_jsxs("div", { className: "ncua-message-notification__actions-container", children: [hasActions && (_jsx("div", { className: "ncua-message-notification__actions", children: _jsx(NotificationActionButtons, { actions: actions ?? [] }) })), hasFooter && (_jsxs("div", { className: "ncua-message-notification__footer-container", children: [onHidePermanently && (_jsx("button", { type: "button", className: "ncua-notification__action-button ncua-message-notification__hide-link", onClick: onHidePermanently, children: hidePermanentlyLabel })), onClose && (_jsx("button", { type: "button", className: "ncua-message-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { width: 20, height: 20, color: closeIconColor }) }))] }))] }));
27
30
  };
28
31
  export { MessageNotificationActions, MessageNotificationContent };
@@ -6,6 +6,9 @@ import { type CalloutNotificationProps, type CalloutNotificationWidth } from './
6
6
  type NotificationType = 'floating' | 'full-width' | 'message' | 'callout';
7
7
  type NotificationColor = Extract<ColorTone, 'neutral' | 'error' | 'warning' | 'success' | 'info'>;
8
8
  type NotificationSize = 'desktop' | 'mobile';
9
+ type NotificationVariant = 'default' | 'block';
10
+ /** 텍스트 링크(`supportTextLink`)를 여는 대상. `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`. */
11
+ type NotificationLinkTarget = '_self' | '_blank';
9
12
  interface NotificationAction {
10
13
  /**
11
14
  * 액션 버튼 텍스트
@@ -78,9 +81,27 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
78
81
  hidePermanentlyLabel?: ReactNode;
79
82
  /**
80
83
  * supportingText 에 연결할 링크 URL (선택사항)
81
- * full-width 타입에서 사용 가능
84
+ * full-width 타입, message 타입의 `variant="block"`에서 사용 가능
82
85
  */
83
86
  supportTextLink?: string;
87
+ /**
88
+ * 텍스트 링크를 여는 대상 (선택사항). `supportTextLink`가 렌더될 때만 동작한다.
89
+ * `'_blank'`이면 새 탭으로 열고 `rel="noopener noreferrer"`를 함께 붙인다.
90
+ * full-width 타입, message 타입의 `variant="block"`에서 사용 가능
91
+ * @default '_self'
92
+ */
93
+ supportTextLinkTarget?: NotificationLinkTarget;
94
+ /**
95
+ * message 타입의 표시 변형 (선택사항). message 타입에서만 동작한다.
96
+ *
97
+ * - `'default'`: 기존 message (color 별 배경·테두리, 제목 + 본문 2행, info → neutral 폴백)
98
+ * - `'block'`: 서브 페이지 공지용. 흰 배경·gray-100 테두리·shadow-sm, FeaturedIcon sm,
99
+ * 제목 + 텍스트 링크 1행(`supportingText` 가 링크 텍스트, `supportTextLink` 가 href).
100
+ * `color="info"` 를 그대로 렌더한다.
101
+ *
102
+ * @default 'default'
103
+ */
104
+ variant?: NotificationVariant;
84
105
  /**
85
106
  * 너비 동작 (선택사항)
86
107
  * callout 타입에서만 사용 가능. 미지정 시 부모 너비를 채운다(100%). 'fit' 은 콘텐츠 너비에 맞춘다
@@ -101,6 +122,6 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
101
122
  portal?: boolean;
102
123
  }
103
124
  declare const Notification: import("react").ForwardRefExoticComponent<NotificationProps & import("react").RefAttributes<HTMLDivElement>>;
104
- export type { NotificationType, NotificationColor, NotificationSize, NotificationAction, NotificationProps };
125
+ export type { NotificationType, NotificationColor, NotificationSize, NotificationVariant, NotificationLinkTarget, NotificationAction, NotificationProps, };
105
126
  export type { CalloutNotificationProps, CalloutNotificationWidth };
106
127
  export { Notification };
@@ -7,15 +7,15 @@ import { MessageNotification } from './MessageNotification';
7
7
  const Notification = forwardRef(
8
8
  // 단일 type 에만 유효한 prop 은 rest 에서 분리해 해당 분기로만 명시 전달한다.
9
9
  // rest 에 남기면 다른 type 의 하위 컴포넌트가 div 로 스프레드해 DOM 속성으로 출력된다.
10
- ({ type = 'floating', color = 'neutral', width, supportTextLink, ...rest }, ref) => {
10
+ ({ type = 'floating', color = 'neutral', width, supportTextLink, supportTextLinkTarget, variant, ...rest }, ref) => {
11
11
  if (type === 'floating') {
12
12
  return _jsx(FloatingNotification, { color: color, ...rest, ref: ref });
13
13
  }
14
14
  if (type === 'full-width') {
15
- return (_jsx(FullWidthNotification, { color: color, supportTextLink: supportTextLink, ...rest, ref: ref }));
15
+ return (_jsx(FullWidthNotification, { color: color, supportTextLink: supportTextLink, supportTextLinkTarget: supportTextLinkTarget, ...rest, ref: ref }));
16
16
  }
17
17
  if (type === 'message') {
18
- return _jsx(MessageNotification, { color: color, ...rest, ref: ref });
18
+ return (_jsx(MessageNotification, { color: color, variant: variant, supportTextLink: supportTextLink, supportTextLinkTarget: supportTextLinkTarget, ...rest, ref: ref }));
19
19
  }
20
20
  if (type === 'callout') {
21
21
  // 나머지 3개 type 이 공유하는 prop 은 callout 이 지원하지 않으므로 여기서 제외한다.
@@ -0,0 +1,12 @@
1
+ import type { NotificationAction } from './Notification';
2
+ interface NotificationActionButtonsProps {
3
+ actions: NotificationAction[];
4
+ }
5
+ /**
6
+ * 알림 액션 버튼 목록. 모든 type 이 같은 규격(`ncua-btn--xs` + hierarchy, 기본 `text`)을 쓴다(#535).
7
+ * Vanilla `renderActions`(assets/scripts/notification/utils.ts)와 같은 클래스를 산출해야 한다.
8
+ * 감싸는 래퍼(`__actions`)는 type 마다 클래스가 달라 호출부가 담당한다.
9
+ */
10
+ declare const NotificationActionButtons: ({ actions }: NotificationActionButtonsProps) => import("react/jsx-runtime").JSX.Element;
11
+ export type { NotificationActionButtonsProps };
12
+ export { NotificationActionButtons };
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Button } from '../../action/button';
3
+ /**
4
+ * 알림 액션 버튼 목록. 모든 type 이 같은 규격(`ncua-btn--xs` + hierarchy, 기본 `text`)을 쓴다(#535).
5
+ * Vanilla `renderActions`(assets/scripts/notification/utils.ts)와 같은 클래스를 산출해야 한다.
6
+ * 감싸는 래퍼(`__actions`)는 type 마다 클래스가 달라 호출부가 담당한다.
7
+ */
8
+ const NotificationActionButtons = ({ actions }) => (_jsx(_Fragment, { children: actions.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action.onClick }, `${action.label}-${action.hierarchy}`))) }));
9
+ export { NotificationActionButtons };
@@ -0,0 +1,73 @@
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 } from 'vitest';
6
+ import { FullWidthNotification } from '../FullWidthNotification';
7
+ import { MessageNotification } from '../MessageNotification';
8
+ /**
9
+ * React 텍스트 링크(`supportTextLink`)의 프로토콜 검사 (#538 리뷰 H2).
10
+ * Vanilla `renderSupportingText` 와 같은 정책 — unsafe URL 이면 링크 없이 텍스트만 렌더한다.
11
+ */
12
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
13
+ const cleanups = [];
14
+ afterEach(() => {
15
+ for (const cleanup of cleanups.splice(0))
16
+ cleanup();
17
+ });
18
+ function mount(element) {
19
+ const container = document.createElement('div');
20
+ document.body.appendChild(container);
21
+ const root = createRoot(container);
22
+ act(() => {
23
+ root.render(element);
24
+ });
25
+ cleanups.push(() => {
26
+ act(() => root.unmount());
27
+ container.remove();
28
+ });
29
+ return container;
30
+ }
31
+ const IMPLEMENTATIONS = [
32
+ [
33
+ 'MessageNotification(block)',
34
+ (supportTextLink) => mount(createElement(MessageNotification, {
35
+ title: '공지',
36
+ variant: 'block',
37
+ supportingText: '자세히 보기',
38
+ supportTextLink,
39
+ })),
40
+ '.ncua-message-notification__supporting-text',
41
+ ],
42
+ [
43
+ 'FullWidthNotification',
44
+ (supportTextLink) => mount(createElement(FullWidthNotification, {
45
+ title: '공지',
46
+ supportingText: '자세히 보기',
47
+ supportTextLink,
48
+ })),
49
+ '.ncua-full-width-notification__supporting-text',
50
+ ],
51
+ ];
52
+ const UNSAFE = [
53
+ 'javascript:alert(1)',
54
+ ' JavaScript:alert(1)',
55
+ 'JAVASCRIPT:alert(1)',
56
+ '\tjavascript:alert(1)',
57
+ 'data:text/html,<script>alert(1)</script>',
58
+ ];
59
+ const SAFE = ['https://example.com/notice/1', 'http://example.com/notice/1', '/notice/1', 'notice/1'];
60
+ describe.each(IMPLEMENTATIONS)('%s — supportTextLink 프로토콜 검사', (_, render, supportingTextSelector) => {
61
+ it.each(UNSAFE)('unsafe %j → 링크 없이 텍스트만 렌더한다', (url) => {
62
+ const container = render(url);
63
+ expect(container.querySelector('a')).toBeNull();
64
+ expect(container.querySelector(supportingTextSelector)?.textContent).toBe('자세히 보기');
65
+ });
66
+ it.each(SAFE)('safe %j → 그대로 링크로 렌더한다', (url) => {
67
+ const container = render(url);
68
+ const anchors = container.querySelectorAll('a');
69
+ expect(anchors).toHaveLength(1);
70
+ expect(anchors[0].getAttribute('href')).toBe(url);
71
+ expect(anchors[0].querySelector(supportingTextSelector)?.textContent).toBe('자세히 보기');
72
+ });
73
+ });
@@ -0,0 +1,11 @@
1
+ import type { NotificationLinkTarget } from './Notification';
2
+ /**
3
+ * 텍스트 링크(`supportTextLink`)의 `<a>`에 붙일 target·rel 속성.
4
+ *
5
+ * `'_blank'`일 때만 새 탭 + `rel="noopener noreferrer"`를 반환하고, `'_self'`(기본)는 아무 속성도 붙이지 않는다.
6
+ * Vanilla `applySupportTextLinkTarget`과 같은 마크업을 산출해야 한다.
7
+ */
8
+ export declare function getLinkTargetAttributes(target?: NotificationLinkTarget): {
9
+ readonly target: "_blank";
10
+ readonly rel: "noopener noreferrer";
11
+ } | undefined;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 텍스트 링크(`supportTextLink`)의 `<a>`에 붙일 target·rel 속성.
3
+ *
4
+ * `'_blank'`일 때만 새 탭 + `rel="noopener noreferrer"`를 반환하고, `'_self'`(기본)는 아무 속성도 붙이지 않는다.
5
+ * Vanilla `applySupportTextLinkTarget`과 같은 마크업을 산출해야 한다.
6
+ */
7
+ export function getLinkTargetAttributes(target) {
8
+ return target === '_blank' ? { target: '_blank', rel: 'noopener noreferrer' } : undefined;
9
+ }