@ncds/ui-admin 1.8.26-alpha.3 → 1.8.26-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.
- package/dist/cjs/assets/scripts/notification/FloatingNotification.js +23 -7
- package/dist/cjs/assets/scripts/notification/FullWidthNotification.js +14 -3
- package/dist/cjs/assets/scripts/notification/MessageNotification.js +3 -0
- package/dist/cjs/assets/scripts/notification/Notification.js +8 -2
- package/dist/cjs/assets/scripts/notification/messageTemplate.js +21 -5
- package/dist/cjs/constant/notification.js +8 -2
- package/dist/cjs/src/components/overlays/notification/FloatingNotification.js +10 -28
- package/dist/cjs/src/components/overlays/notification/FullWidthNotification.js +9 -24
- package/dist/cjs/src/components/overlays/notification/MessageNotification.js +12 -0
- package/dist/cjs/src/components/overlays/notification/MessageNotificationParts.js +5 -2
- package/dist/cjs/src/components/overlays/notification/Notification.js +1 -0
- package/dist/cjs/src/components/overlays/notification/__tests__/CalloutNotification.test.js +8 -2
- package/dist/cjs/src/components/overlays/notification/__tests__/Notification.test.js +176 -0
- package/dist/cjs/src/components/overlays/notification/useAutoClose.js +39 -0
- package/dist/cjs/src/utils/notification/__tests__/resolveAutoClose.test.js +44 -0
- package/dist/cjs/src/utils/notification/index.js +12 -0
- package/dist/cjs/src/utils/notification/resolveAutoClose.js +22 -0
- package/dist/esm/assets/scripts/notification/FloatingNotification.js +23 -7
- package/dist/esm/assets/scripts/notification/FullWidthNotification.js +14 -3
- package/dist/esm/assets/scripts/notification/MessageNotification.js +3 -0
- package/dist/esm/assets/scripts/notification/Notification.js +8 -2
- package/dist/esm/assets/scripts/notification/messageTemplate.js +21 -5
- package/dist/esm/constant/notification.js +7 -1
- package/dist/esm/src/components/overlays/notification/FloatingNotification.js +11 -29
- package/dist/esm/src/components/overlays/notification/FullWidthNotification.js +10 -25
- package/dist/esm/src/components/overlays/notification/MessageNotification.js +12 -0
- package/dist/esm/src/components/overlays/notification/MessageNotificationParts.js +5 -2
- package/dist/esm/src/components/overlays/notification/Notification.js +1 -0
- package/dist/esm/src/components/overlays/notification/__tests__/CalloutNotification.test.js +9 -3
- package/dist/esm/src/components/overlays/notification/__tests__/Notification.test.js +177 -1
- package/dist/esm/src/components/overlays/notification/useAutoClose.js +32 -0
- package/dist/esm/src/utils/notification/__tests__/resolveAutoClose.test.js +42 -0
- package/dist/esm/src/utils/notification/index.js +1 -0
- package/dist/esm/src/utils/notification/resolveAutoClose.js +15 -0
- package/dist/temp/assets/scripts/notification/FloatingNotification.js +10 -6
- package/dist/temp/assets/scripts/notification/FullWidthNotification.js +8 -5
- package/dist/temp/assets/scripts/notification/MessageNotification.js +3 -1
- package/dist/temp/assets/scripts/notification/Notification.js +7 -1
- package/dist/temp/assets/scripts/notification/const/types.d.ts +7 -0
- package/dist/temp/assets/scripts/notification/messageTemplate.d.ts +1 -0
- package/dist/temp/assets/scripts/notification/messageTemplate.js +19 -9
- package/dist/temp/constant/notification.d.ts +6 -0
- package/dist/temp/constant/notification.js +6 -0
- package/dist/temp/src/components/overlays/notification/FloatingNotification.d.ts +8 -0
- package/dist/temp/src/components/overlays/notification/FloatingNotification.js +6 -29
- package/dist/temp/src/components/overlays/notification/FullWidthNotification.d.ts +7 -0
- package/dist/temp/src/components/overlays/notification/FullWidthNotification.js +6 -25
- package/dist/temp/src/components/overlays/notification/MessageNotification.d.ts +12 -0
- package/dist/temp/src/components/overlays/notification/MessageNotification.js +8 -2
- package/dist/temp/src/components/overlays/notification/MessageNotificationParts.d.ts +2 -1
- package/dist/temp/src/components/overlays/notification/MessageNotificationParts.js +5 -3
- package/dist/temp/src/components/overlays/notification/Notification.d.ts +14 -1
- package/dist/temp/src/components/overlays/notification/Notification.js +1 -1
- package/dist/temp/src/components/overlays/notification/__tests__/CalloutNotification.test.js +8 -1
- package/dist/temp/src/components/overlays/notification/__tests__/Notification.test.js +128 -1
- package/dist/temp/src/components/overlays/notification/useAutoClose.d.ts +15 -0
- package/dist/temp/src/components/overlays/notification/useAutoClose.js +25 -0
- package/dist/temp/src/utils/notification/__tests__/resolveAutoClose.test.d.ts +1 -0
- package/dist/temp/src/utils/notification/__tests__/resolveAutoClose.test.js +22 -0
- package/dist/temp/src/utils/notification/index.d.ts +1 -0
- package/dist/temp/src/utils/notification/index.js +1 -0
- package/dist/temp/src/utils/notification/resolveAutoClose.d.ts +14 -0
- package/dist/temp/src/utils/notification/resolveAutoClose.js +12 -0
- package/dist/types/assets/scripts/notification/const/types.d.ts +7 -0
- package/dist/types/assets/scripts/notification/messageTemplate.d.ts +1 -0
- package/dist/types/constant/notification.d.ts +6 -0
- package/dist/types/src/components/overlays/notification/FloatingNotification.d.ts +8 -0
- package/dist/types/src/components/overlays/notification/FullWidthNotification.d.ts +7 -0
- package/dist/types/src/components/overlays/notification/MessageNotification.d.ts +12 -0
- package/dist/types/src/components/overlays/notification/MessageNotificationParts.d.ts +2 -1
- package/dist/types/src/components/overlays/notification/Notification.d.ts +14 -1
- package/dist/types/src/components/overlays/notification/useAutoClose.d.ts +15 -0
- package/dist/types/src/utils/notification/__tests__/resolveAutoClose.test.d.ts +1 -0
- package/dist/types/src/utils/notification/index.d.ts +1 -0
- package/dist/types/src/utils/notification/resolveAutoClose.d.ts +14 -0
- package/package.json +1 -1
|
@@ -12,7 +12,7 @@ const ACTION_BUTTON_SIZE = 'xs';
|
|
|
12
12
|
* 동일한 클래스 트리를 산출해야 한다 — SCSS 한 벌을 두 구현이 공유한다.
|
|
13
13
|
*/
|
|
14
14
|
export function buildMessageTemplate(params) {
|
|
15
|
-
const { title, supportingText, supportTextLink, color, actions, onClose, onHidePermanently, hidePermanentlyLabel } = params;
|
|
15
|
+
const { title, supportingText, supportTextLink, color, actions, onClose, hideCloseButton, onHidePermanently, hidePermanentlyLabel, } = params;
|
|
16
16
|
return `
|
|
17
17
|
<div class="${CLASS_NAMES.MESSAGE.CONTAINER}">
|
|
18
18
|
<div class="${CLASS_NAMES.MESSAGE.CONTENT}">
|
|
@@ -22,7 +22,7 @@ export function buildMessageTemplate(params) {
|
|
|
22
22
|
${renderSupportingText(supportingText, CLASS_NAMES.MESSAGE.SUPPORTING_TEXT, supportTextLink, CLASS_NAMES.MESSAGE.LINK)}
|
|
23
23
|
</div>
|
|
24
24
|
</div>
|
|
25
|
-
${renderActionsContainer({ actions, onClose, onHidePermanently, hidePermanentlyLabel, color })}
|
|
25
|
+
${renderActionsContainer({ actions, onClose, hideCloseButton, onHidePermanently, hidePermanentlyLabel, color })}
|
|
26
26
|
</div>
|
|
27
27
|
</div>
|
|
28
28
|
`;
|
|
@@ -33,22 +33,31 @@ export function buildMessageTemplate(params) {
|
|
|
33
33
|
* 표시할 요소가 하나도 없으면 빈 컨테이너를 남기지 않는다.
|
|
34
34
|
*/
|
|
35
35
|
function renderActionsContainer(params) {
|
|
36
|
-
const { actions, onClose, onHidePermanently, hidePermanentlyLabel, color } = params;
|
|
36
|
+
const { actions, onClose, hideCloseButton, onHidePermanently, hidePermanentlyLabel, color } = params;
|
|
37
37
|
const actionsHtml = renderActions(actions ?? [], CLASS_NAMES.MESSAGE.ACTIONS, ACTION_BUTTON_SIZE);
|
|
38
|
-
const footerHtml = renderFooterContainer({
|
|
38
|
+
const footerHtml = renderFooterContainer({
|
|
39
|
+
onClose,
|
|
40
|
+
hideCloseButton,
|
|
41
|
+
onHidePermanently,
|
|
42
|
+
hidePermanentlyLabel,
|
|
43
|
+
color,
|
|
44
|
+
});
|
|
39
45
|
if (!actionsHtml && !footerHtml)
|
|
40
46
|
return '';
|
|
41
47
|
return `<div class="${CLASS_NAMES.MESSAGE.ACTIONS_CONTAINER}">${actionsHtml}${footerHtml}</div>`;
|
|
42
48
|
}
|
|
43
49
|
/** 다시 보지 않기 + 닫기 그룹. 전용 래퍼를 쓰는 이유는 `CLASS_NAMES.MESSAGE.FOOTER_CONTAINER` 참조. */
|
|
44
50
|
function renderFooterContainer(params) {
|
|
45
|
-
const { onClose, onHidePermanently, hidePermanentlyLabel, color } = params;
|
|
46
|
-
|
|
51
|
+
const { onClose, hideCloseButton, onHidePermanently, hidePermanentlyLabel, color } = params;
|
|
52
|
+
// footer 컨테이너와 닫기 버튼이 같은 판정을 보게 해 빈 footer 가 남지 않게 한다.
|
|
53
|
+
// cf. React `MessageNotificationParts.tsx` 의 showCloseButton — 두 구현이 같은 판정을 유지해야 한다.
|
|
54
|
+
const showCloseButton = Boolean(onClose) && !hideCloseButton;
|
|
55
|
+
if (!onHidePermanently && !showCloseButton)
|
|
47
56
|
return '';
|
|
48
57
|
return `
|
|
49
58
|
<div class="${CLASS_NAMES.MESSAGE.FOOTER_CONTAINER}">
|
|
50
59
|
${renderHidePermanentlyButton(onHidePermanently, hidePermanentlyLabel)}
|
|
51
|
-
${renderCloseButton(onClose, color)}
|
|
60
|
+
${renderCloseButton({ onClose, hideCloseButton, color })}
|
|
52
61
|
</div>
|
|
53
62
|
`;
|
|
54
63
|
}
|
|
@@ -72,8 +81,9 @@ function renderHidePermanentlyButton(onHidePermanently, hidePermanentlyLabel) {
|
|
|
72
81
|
* 닫기(X) 버튼. 아이콘 색은 React `<XClose color>`와 같이 svg 의 `color` 속성으로 주고
|
|
73
82
|
* path 는 `stroke="currentColor"`로 상속받는다 — 두 구현이 같은 마크업을 낸다.
|
|
74
83
|
*/
|
|
75
|
-
function renderCloseButton(
|
|
76
|
-
|
|
84
|
+
function renderCloseButton(params) {
|
|
85
|
+
const { onClose, hideCloseButton = false, color = 'neutral' } = params;
|
|
86
|
+
if (!onClose || hideCloseButton)
|
|
77
87
|
return '';
|
|
78
88
|
const iconColor = MESSAGE_CLOSE_ICON_COLOR[color];
|
|
79
89
|
return `
|
|
@@ -26,3 +26,9 @@ export declare const MESSAGE_CLOSE_ICON_COLOR: {
|
|
|
26
26
|
readonly success: string;
|
|
27
27
|
readonly info: string;
|
|
28
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* floating(토스트)에서 `hideCloseButton`인데 `autoClose`를 지정하지 않았을 때 적용되는 자동 닫힘 시간(ms).
|
|
31
|
+
* 닫기 버튼이 없는 토스트가 영구히 남지 않게 한다 — DES-SPEC-027 §5.3 Toast Alert 3초.
|
|
32
|
+
* full-width · message 는 부모가 언마운트로 닫으므로 적용하지 않는다.
|
|
33
|
+
*/
|
|
34
|
+
export declare const HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS = 3000;
|
|
@@ -27,3 +27,9 @@ export const MESSAGE_CLOSE_ICON_COLOR = {
|
|
|
27
27
|
success: COLOR.green600,
|
|
28
28
|
info: COLOR.violet600,
|
|
29
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* floating(토스트)에서 `hideCloseButton`인데 `autoClose`를 지정하지 않았을 때 적용되는 자동 닫힘 시간(ms).
|
|
32
|
+
* 닫기 버튼이 없는 토스트가 영구히 남지 않게 한다 — DES-SPEC-027 §5.3 Toast Alert 3초.
|
|
33
|
+
* full-width · message 는 부모가 언마운트로 닫으므로 적용하지 않는다.
|
|
34
|
+
*/
|
|
35
|
+
export const HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS = 3000;
|
|
@@ -18,6 +18,13 @@ interface FloatingNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>
|
|
|
18
18
|
* 닫기 버튼 클릭 이벤트 핸들러
|
|
19
19
|
*/
|
|
20
20
|
onClose?: () => void;
|
|
21
|
+
/**
|
|
22
|
+
* 닫기(X) 버튼 숨김 여부. `true`면 `onClose`가 있어도 X 버튼을 렌더하지 않는다.
|
|
23
|
+
* autoClose 만료·모바일 전환 시 `onClose` 호출은 그대로 유지된다.
|
|
24
|
+
* `autoClose` 미지정이면 3초 뒤 자동으로 닫힌다. 부모가 직접 언마운트하려면 `autoClose={0}`을 명시한다.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
hideCloseButton?: boolean;
|
|
21
28
|
/**
|
|
22
29
|
* 추가 CSS 클래스
|
|
23
30
|
*/
|
|
@@ -28,6 +35,7 @@ interface FloatingNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>
|
|
|
28
35
|
actions?: NotificationAction[];
|
|
29
36
|
/**
|
|
30
37
|
* 자동 닫기 시간 (밀리초 단위, 0이면 자동으로 닫히지 않음)
|
|
38
|
+
* 미지정 시 0. 단 `hideCloseButton`이면 3초.
|
|
31
39
|
* @default 0
|
|
32
40
|
*/
|
|
33
41
|
autoClose?: number;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { AlertCircle, AlertTriangle, CheckCircle, Pin02 } from '@ncds/ui-admin-icon';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
|
-
import { forwardRef, useEffect, useId,
|
|
4
|
+
import { forwardRef, useEffect, useId, 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';
|
|
@@ -11,6 +11,7 @@ import { FeaturedIcon, } from '../../image-and-icons/featured-icon/FeaturedIcon'
|
|
|
11
11
|
// 하므로 React 컴포넌트는 deep relative path 로 vanilla internals 를 직접 import 하지 않는다.
|
|
12
12
|
import { mountFloatingNotificationHost } from './host';
|
|
13
13
|
import { NotificationActionButtons } from './NotificationActionButtons';
|
|
14
|
+
import { useAutoClose } from './useAutoClose';
|
|
14
15
|
/**
|
|
15
16
|
* 색상별 a11y role.
|
|
16
17
|
* - error/warning → role="alert" (implicit aria-live="assertive": 즉시 발화)
|
|
@@ -60,8 +61,9 @@ function useMountFloatingNotificationHost(enabled) {
|
|
|
60
61
|
}, [enabled]);
|
|
61
62
|
return host;
|
|
62
63
|
}
|
|
63
|
-
const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose
|
|
64
|
-
|
|
64
|
+
const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, hideCloseButton = false, className, actions, autoClose, portal = false, ...rest }, ref) => {
|
|
65
|
+
// floating 은 닫기 버튼이 없는데 autoClose 도 없으면 영구히 남으므로 기본 3초를 준다.
|
|
66
|
+
const shouldRemove = useAutoClose({ autoClose, hideCloseButton, onClose });
|
|
65
67
|
const featuredIconProps = {
|
|
66
68
|
icon: iconMap[color] || Pin02,
|
|
67
69
|
size: 'sm',
|
|
@@ -71,31 +73,6 @@ const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutr
|
|
|
71
73
|
const isMobile = useMediaQuery(MEDIA_QUERY.mobile, {
|
|
72
74
|
onMatched: onClose,
|
|
73
75
|
});
|
|
74
|
-
// onClose 는 매 렌더 새 함수 ref 일 수 있으므로 ref 로 latest 만 보관.
|
|
75
|
-
// → autoClose 타이머 effect 의 deps 에서 onClose 를 빼서 타이머 재시작 회피.
|
|
76
|
-
// render 본문에서 직접 할당 — useEffect 로 미루면 같은 commit 안에서 fire 되는 타이머가 stale onClose 를 볼 위험.
|
|
77
|
-
const onCloseRef = useRef(onClose);
|
|
78
|
-
onCloseRef.current = onClose;
|
|
79
|
-
// autoClose 타이머 관리
|
|
80
|
-
const timerRef = useRef(null);
|
|
81
|
-
// onClose 는 onCloseRef 로 latest 만 추적하므로 deps 에서 제외 (의도된 패턴).
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
// autoClose 가 0 보다 크면 타이머 설정.
|
|
84
|
-
if (autoClose > 0) {
|
|
85
|
-
timerRef.current = setTimeout(() => {
|
|
86
|
-
onCloseRef.current?.();
|
|
87
|
-
// DOM 에서 바로 제거
|
|
88
|
-
setShouldRemove(true);
|
|
89
|
-
}, autoClose);
|
|
90
|
-
}
|
|
91
|
-
// cleanup: 컴포넌트 언마운트 또는 autoClose 변경 시 타이머 정리
|
|
92
|
-
return () => {
|
|
93
|
-
if (timerRef.current) {
|
|
94
|
-
clearTimeout(timerRef.current);
|
|
95
|
-
timerRef.current = null;
|
|
96
|
-
}
|
|
97
|
-
};
|
|
98
|
-
}, [autoClose]);
|
|
99
76
|
// portal=true 일 때만 호스트가 필요하다.
|
|
100
77
|
const host = useMountFloatingNotificationHost(portal);
|
|
101
78
|
// a11y — 카드 root 의 role 과 제목/본문 ID. 색상에 따라 alert(error/warning) / status(나머지) 로 분기.
|
|
@@ -110,7 +87,7 @@ const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutr
|
|
|
110
87
|
const card = (
|
|
111
88
|
// a11y/예측가능성 — caller 가 className/id 등 일부는 덮어쓸 수 있게 {...rest} 를 먼저 펼치고,
|
|
112
89
|
// 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: _jsx(NotificationActionButtons, { actions: actions }) }))] })] }) }), onClose && (_jsx(ButtonCloseX, { theme: "light", className: "ncua-floating-notification__close-button", size: isMobile ? 'sm' : 'xs', onClick: onClose }))] }));
|
|
90
|
+
_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 && !hideCloseButton && (_jsx(ButtonCloseX, { theme: "light", className: "ncua-floating-notification__close-button", size: isMobile ? 'sm' : 'xs', onClick: onClose }))] }));
|
|
114
91
|
// 기본은 인라인 렌더 — 부모 JSX 트리에 카드를 그대로 둔다.
|
|
115
92
|
if (!portal) {
|
|
116
93
|
return card;
|
|
@@ -18,6 +18,13 @@ interface FullWidthNotificationProps extends Omit<ComponentPropsWithoutRef<'div'
|
|
|
18
18
|
* 닫기 버튼 클릭 이벤트 핸들러
|
|
19
19
|
*/
|
|
20
20
|
onClose?: () => void;
|
|
21
|
+
/**
|
|
22
|
+
* 닫기(X) 버튼 숨김 여부. `true`면 `onClose`가 있어도 X 버튼을 렌더하지 않는다.
|
|
23
|
+
* autoClose 만료 시 `onClose` 호출은 그대로 유지된다.
|
|
24
|
+
* 기본 자동 닫힘은 없다 — 부모가 언마운트로 닫거나 `autoClose`를 명시한다.
|
|
25
|
+
* @default false
|
|
26
|
+
*/
|
|
27
|
+
hideCloseButton?: boolean;
|
|
21
28
|
/**
|
|
22
29
|
* 추가 CSS 클래스
|
|
23
30
|
*/
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { AlertTriangle, CheckCircle, InfoCircle, MessageChatSquare, XClose } from '@ncds/ui-admin-icon';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
|
-
import { forwardRef
|
|
4
|
+
import { forwardRef } from 'react';
|
|
5
5
|
import { COLOR } from '../../../../constant/color';
|
|
6
6
|
import { HIDE_PERMANENTLY_LABEL } from '../../../../constant/notification';
|
|
7
7
|
import { isSafeUrl } from '../../../utils/url';
|
|
8
8
|
import { getLinkTargetAttributes } from './linkTarget';
|
|
9
9
|
import { NotificationActionButtons } from './NotificationActionButtons';
|
|
10
|
+
import { useAutoClose } from './useAutoClose';
|
|
10
11
|
const iconMap = {
|
|
11
12
|
neutral: MessageChatSquare,
|
|
12
13
|
error: AlertTriangle,
|
|
@@ -21,35 +22,15 @@ const iconColorMap = {
|
|
|
21
22
|
success: 'green600',
|
|
22
23
|
info: 'violet600',
|
|
23
24
|
};
|
|
24
|
-
const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose
|
|
25
|
-
|
|
25
|
+
const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, hideCloseButton = false, className, actions, autoClose, supportTextLink, supportTextLinkTarget, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.fullWidth, ...rest }, ref) => {
|
|
26
|
+
// full-width 는 부모가 언마운트로 닫으므로 hideCloseButton 기본 autoClose 를 적용하지 않는다.
|
|
27
|
+
const shouldRemove = useAutoClose({ autoClose, onClose });
|
|
26
28
|
// unsafe URL(javascript: 등)이면 링크 없이 텍스트만 렌더한다 — Vanilla `renderSupportingText` 와 같은 정책.
|
|
27
29
|
const safeSupportTextLink = supportTextLink && isSafeUrl(supportTextLink) ? supportTextLink : undefined;
|
|
28
30
|
const closeIconSize = {
|
|
29
31
|
width: 20,
|
|
30
32
|
height: 20,
|
|
31
33
|
};
|
|
32
|
-
// autoClose 타이머 관리
|
|
33
|
-
const timerRef = useRef(null);
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
// autoClose가 0보다 크면 무조건 타이머 설정
|
|
36
|
-
if (autoClose > 0) {
|
|
37
|
-
timerRef.current = setTimeout(() => {
|
|
38
|
-
if (onClose) {
|
|
39
|
-
onClose();
|
|
40
|
-
}
|
|
41
|
-
// DOM에서 바로 제거
|
|
42
|
-
setShouldRemove(true);
|
|
43
|
-
}, autoClose);
|
|
44
|
-
}
|
|
45
|
-
// cleanup 함수: 컴포넌트 언마운트 시 타이머 정리
|
|
46
|
-
return () => {
|
|
47
|
-
if (timerRef.current) {
|
|
48
|
-
clearTimeout(timerRef.current);
|
|
49
|
-
timerRef.current = null;
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
}, [autoClose, onClose]);
|
|
53
34
|
// DOM에서 완전히 제거
|
|
54
35
|
if (shouldRemove) {
|
|
55
36
|
return null;
|
|
@@ -57,6 +38,6 @@ const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neut
|
|
|
57
38
|
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: [(() => {
|
|
58
39
|
const IconComponent = iconMap[color];
|
|
59
40
|
return (_jsx(IconComponent, { width: 16, height: 16, color: COLOR[iconColorMap[color]], className: "ncua-full-width-notification__icon" }));
|
|
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]] }) }))] })] }) }) }));
|
|
41
|
+
})(), _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 && !hideCloseButton && (_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]] }) }))] })] }) }) }));
|
|
61
42
|
});
|
|
62
43
|
export { FullWidthNotification };
|
|
@@ -23,6 +23,18 @@ interface MessageNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>,
|
|
|
23
23
|
* 닫기 버튼 클릭 이벤트 핸들러
|
|
24
24
|
*/
|
|
25
25
|
onClose?: () => void;
|
|
26
|
+
/**
|
|
27
|
+
* 닫기(X) 버튼 숨김 여부. `true`면 `onClose`가 있어도 X 버튼을 렌더하지 않는다.
|
|
28
|
+
* autoClose 만료 시 `onClose` 호출은 그대로 유지된다.
|
|
29
|
+
* 기본 자동 닫힘은 없다 — 부모가 언마운트로 닫거나 `autoClose`를 명시한다.
|
|
30
|
+
* @default false
|
|
31
|
+
*/
|
|
32
|
+
hideCloseButton?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* 자동 닫기 시간 (밀리초 단위, 0이면 자동으로 닫히지 않음)
|
|
35
|
+
* @default 0
|
|
36
|
+
*/
|
|
37
|
+
autoClose?: number;
|
|
26
38
|
/**
|
|
27
39
|
* 추가 CSS 클래스
|
|
28
40
|
*/
|
|
@@ -4,6 +4,7 @@ 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
|
+
import { useAutoClose } from './useAutoClose';
|
|
7
8
|
const iconMap = {
|
|
8
9
|
neutral: Pin02,
|
|
9
10
|
error: AlertTriangle,
|
|
@@ -12,13 +13,18 @@ const iconMap = {
|
|
|
12
13
|
// info 는 variant="block" 에서만 도달한다 (default 는 neutral 로 폴백).
|
|
13
14
|
info: InfoCircle,
|
|
14
15
|
};
|
|
15
|
-
const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, className, actions, onHidePermanently, hidePermanentlyLabel, variant = 'default', supportTextLink, supportTextLinkTarget, ...rest }, ref) => {
|
|
16
|
+
const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, hideCloseButton = false, autoClose, className, actions, onHidePermanently, hidePermanentlyLabel, variant = 'default', supportTextLink, supportTextLinkTarget, ...rest }, ref) => {
|
|
16
17
|
const isBlock = variant === 'block';
|
|
17
18
|
// default 는 neutral, error, warning, success 4가지 색상만 지원 — info 는 block 에서만 유지한다.
|
|
18
19
|
const validColor = !isBlock && color === 'info' ? 'neutral' : color;
|
|
19
20
|
// FeaturedIcon 에는 info 색이 없다. block+info 의 violet 톤은 message SCSS 가 덮어쓴다.
|
|
20
21
|
const featuredIconColor = validColor === 'info' ? 'neutral' : validColor;
|
|
21
|
-
|
|
22
|
+
// message 는 부모가 언마운트로 닫으므로 hideCloseButton 기본 autoClose 를 적용하지 않는다.
|
|
23
|
+
const shouldRemove = useAutoClose({ autoClose, onClose });
|
|
24
|
+
if (shouldRemove) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
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, hideCloseButton: hideCloseButton, onHidePermanently: onHidePermanently, hidePermanentlyLabel: hidePermanentlyLabel, closeIconColor: MESSAGE_CLOSE_ICON_COLOR[validColor] })] }) }) }));
|
|
22
28
|
});
|
|
23
29
|
MessageNotification.displayName = 'MessageNotification';
|
|
24
30
|
export { MessageNotification };
|
|
@@ -19,6 +19,7 @@ declare const MessageNotificationContent: ({ title, supportingText, supportTextL
|
|
|
19
19
|
interface MessageNotificationActionsProps {
|
|
20
20
|
actions?: NotificationAction[];
|
|
21
21
|
onClose?: () => void;
|
|
22
|
+
hideCloseButton?: boolean;
|
|
22
23
|
onHidePermanently?: () => void;
|
|
23
24
|
hidePermanentlyLabel?: ReactNode;
|
|
24
25
|
closeIconColor: string;
|
|
@@ -28,6 +29,6 @@ interface MessageNotificationActionsProps {
|
|
|
28
29
|
* 이 컨테이너의 gap(12px)이 두 그룹 사이 간격을 만든다.
|
|
29
30
|
* 표시할 요소가 하나도 없으면 빈 컨테이너를 남기지 않는다.
|
|
30
31
|
*/
|
|
31
|
-
declare const MessageNotificationActions: ({ actions, onClose, onHidePermanently, hidePermanentlyLabel, closeIconColor, }: MessageNotificationActionsProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
32
|
+
declare const MessageNotificationActions: ({ actions, onClose, hideCloseButton, onHidePermanently, hidePermanentlyLabel, closeIconColor, }: MessageNotificationActionsProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
32
33
|
export type { MessageNotificationActionsProps, MessageNotificationContentProps };
|
|
33
34
|
export { MessageNotificationActions, MessageNotificationContent };
|
|
@@ -21,11 +21,13 @@ const MessageNotificationContent = ({ title, supportingText, supportTextLink, su
|
|
|
21
21
|
* 이 컨테이너의 gap(12px)이 두 그룹 사이 간격을 만든다.
|
|
22
22
|
* 표시할 요소가 하나도 없으면 빈 컨테이너를 남기지 않는다.
|
|
23
23
|
*/
|
|
24
|
-
const MessageNotificationActions = ({ actions, onClose, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.message, closeIconColor, }) => {
|
|
24
|
+
const MessageNotificationActions = ({ actions, onClose, hideCloseButton = false, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.message, closeIconColor, }) => {
|
|
25
25
|
const hasActions = Boolean(actions?.length);
|
|
26
|
-
|
|
26
|
+
// footer 컨테이너와 닫기 버튼이 같은 판정을 보게 해 빈 footer 가 남지 않게 한다.
|
|
27
|
+
const showCloseButton = Boolean(onClose) && !hideCloseButton;
|
|
28
|
+
const hasFooter = Boolean(onHidePermanently) || showCloseButton;
|
|
27
29
|
if (!hasActions && !hasFooter)
|
|
28
30
|
return null;
|
|
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 })),
|
|
31
|
+
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 })), showCloseButton && (_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 }) }))] }))] }));
|
|
30
32
|
};
|
|
31
33
|
export { MessageNotificationActions, MessageNotificationContent };
|
|
@@ -51,6 +51,18 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
|
|
|
51
51
|
* 닫기 버튼 클릭 이벤트 핸들러
|
|
52
52
|
*/
|
|
53
53
|
onClose?: () => void;
|
|
54
|
+
/**
|
|
55
|
+
* 닫기(X) 버튼 숨김 여부. floating · full-width · message 타입에서 동작한다.
|
|
56
|
+
* `true`면 `onClose`가 있어도 X 버튼을 렌더하지 않는다.
|
|
57
|
+
* autoClose 만료(및 floating의 모바일 전환) 시 `onClose` 호출은 그대로 유지되므로,
|
|
58
|
+
* "닫기 버튼 없는 자동 닫힘 토스트"(DES-SPEC-027 §5.3)에서 부모가 닫힘 시점을 받을 수 있다.
|
|
59
|
+
*
|
|
60
|
+
* floating(토스트)은 닫을 수단이 사라지지 않도록 `autoClose` 미지정 시 3초 뒤 자동으로 닫힌다
|
|
61
|
+
* (부모가 직접 언마운트해 닫으려면 `autoClose={0}` 명시). full-width · message 는 부모가 언마운트로 닫으므로
|
|
62
|
+
* `autoClose`를 명시했을 때만 자동으로 닫힌다.
|
|
63
|
+
* @default false
|
|
64
|
+
*/
|
|
65
|
+
hideCloseButton?: boolean;
|
|
54
66
|
/**
|
|
55
67
|
* 추가 CSS 클래스
|
|
56
68
|
*/
|
|
@@ -62,7 +74,8 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
|
|
|
62
74
|
actions?: NotificationAction[];
|
|
63
75
|
/**
|
|
64
76
|
* 자동 닫기 시간 (밀리초 단위, 0이면 자동으로 닫히지 않음)
|
|
65
|
-
* floating
|
|
77
|
+
* floating, full-width, message 타입에서 동작
|
|
78
|
+
* 미지정 시 0. 단 floating 에서 `hideCloseButton`이면 3초(DES-SPEC-027 §5.3).
|
|
66
79
|
* @default 0
|
|
67
80
|
*/
|
|
68
81
|
autoClose?: number;
|
|
@@ -19,7 +19,7 @@ const Notification = forwardRef(
|
|
|
19
19
|
}
|
|
20
20
|
if (type === 'callout') {
|
|
21
21
|
// 나머지 3개 type 이 공유하는 prop 은 callout 이 지원하지 않으므로 여기서 제외한다.
|
|
22
|
-
const { icon: _icon, supportingText: _supportingText, actions: _actions, autoClose: _autoClose, onClose: _onClose, onHidePermanently: _onHidePermanently, hidePermanentlyLabel: _hidePermanentlyLabel, portal: _portal, ...calloutProps } = rest;
|
|
22
|
+
const { icon: _icon, supportingText: _supportingText, actions: _actions, autoClose: _autoClose, onClose: _onClose, hideCloseButton: _hideCloseButton, onHidePermanently: _onHidePermanently, hidePermanentlyLabel: _hidePermanentlyLabel, portal: _portal, ...calloutProps } = rest;
|
|
23
23
|
return (_jsx(CalloutNotification, { color: color, width: width, ...calloutProps, ref: ref }));
|
|
24
24
|
}
|
|
25
25
|
return null;
|
package/dist/temp/src/components/overlays/notification/__tests__/CalloutNotification.test.js
CHANGED
|
@@ -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 경고 발생)
|
|
@@ -31,6 +31,7 @@ afterEach(() => {
|
|
|
31
31
|
container?.remove();
|
|
32
32
|
root = null;
|
|
33
33
|
container = null;
|
|
34
|
+
vi.restoreAllMocks();
|
|
34
35
|
});
|
|
35
36
|
describe('CalloutNotification — width prop', () => {
|
|
36
37
|
it('width 미지정 시 너비 클래스를 붙이지 않는다 (부모 너비를 채우는 기본 스타일 유지)', () => {
|
|
@@ -67,6 +68,7 @@ describe('Notification type="callout" — 위임 계약', () => {
|
|
|
67
68
|
});
|
|
68
69
|
// Notification 의 callout 분기가 제외하는 prop 전체. 여기 누락된 prop 은 DOM 속성으로 출력된다.
|
|
69
70
|
it('callout 이 지원하지 않는 prop 은 DOM 속성으로 새지 않는다', () => {
|
|
71
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
70
72
|
const element = renderNotification({
|
|
71
73
|
icon: () => null,
|
|
72
74
|
supportingText: '보조 문구',
|
|
@@ -77,6 +79,7 @@ describe('Notification type="callout" — 위임 계약', () => {
|
|
|
77
79
|
onClose: () => undefined,
|
|
78
80
|
onHidePermanently: () => undefined,
|
|
79
81
|
hidePermanentlyLabel: '다시 보지 않기',
|
|
82
|
+
hideCloseButton: true,
|
|
80
83
|
});
|
|
81
84
|
for (const attr of [
|
|
82
85
|
'icon',
|
|
@@ -88,10 +91,14 @@ describe('Notification type="callout" — 위임 계약', () => {
|
|
|
88
91
|
'onclose',
|
|
89
92
|
'onhidepermanently',
|
|
90
93
|
'hidepermanentlylabel',
|
|
94
|
+
'hideclosebutton',
|
|
91
95
|
]) {
|
|
92
96
|
expect(element.hasAttribute(attr), attr).toBe(false);
|
|
93
97
|
}
|
|
94
98
|
expect(element.textContent).toBe('안내 문구');
|
|
99
|
+
// 불리언 true 는 React 가 미지 속성으로 출력하지 않아 hasAttribute 로는 누수를 못 잡는다 — 경고 로그로 검출한다.
|
|
100
|
+
const leaked = consoleError.mock.calls.some((call) => call.some((arg) => String(arg).includes('hideCloseButton')));
|
|
101
|
+
expect(leaked).toBe(false);
|
|
95
102
|
});
|
|
96
103
|
it('ref 를 callout 루트 엘리먼트로 전달한다', () => {
|
|
97
104
|
let captured = null;
|
|
@@ -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;
|
|
@@ -69,3 +70,129 @@ describe('Notification — type 별 위임', () => {
|
|
|
69
70
|
expect(element).toBeNull();
|
|
70
71
|
});
|
|
71
72
|
});
|
|
73
|
+
const CLOSE_BUTTON = {
|
|
74
|
+
floating: '.ncua-floating-notification__close-button',
|
|
75
|
+
'full-width': '.ncua-full-width-notification__close-button',
|
|
76
|
+
message: '.ncua-message-notification__close-button',
|
|
77
|
+
};
|
|
78
|
+
const MESSAGE_FOOTER = '.ncua-message-notification__footer-container';
|
|
79
|
+
const MESSAGE_HIDE_LINK = '.ncua-message-notification__hide-link';
|
|
80
|
+
/** DES-SPEC-027 §5.3 성공 토스트 자동 닫힘 시간. */
|
|
81
|
+
const AUTO_CLOSE_MS = 3000;
|
|
82
|
+
describe('Notification — hideCloseButton', () => {
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
vi.useRealTimers();
|
|
85
|
+
});
|
|
86
|
+
it('R-01: floating — onClose 가 있어도 hideCloseButton 이면 닫기 버튼을 렌더하지 않는다', () => {
|
|
87
|
+
const element = render({ type: 'floating', onClose: vi.fn(), hideCloseButton: true });
|
|
88
|
+
expect(element?.querySelector(CLOSE_BUTTON.floating)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
it('R-02: full-width — onClose 가 있어도 hideCloseButton 이면 닫기 버튼을 렌더하지 않는다', () => {
|
|
91
|
+
const element = render({ type: 'full-width', onClose: vi.fn(), hideCloseButton: true });
|
|
92
|
+
expect(element?.querySelector(CLOSE_BUTTON['full-width'])).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
it('R-03: message — hideCloseButton 이면 닫기 버튼과 빈 footer 컨테이너를 모두 렌더하지 않는다', () => {
|
|
95
|
+
const element = render({ type: 'message', onClose: vi.fn(), hideCloseButton: true });
|
|
96
|
+
expect(element?.querySelector(CLOSE_BUTTON.message)).toBeNull();
|
|
97
|
+
expect(element?.querySelector(MESSAGE_FOOTER)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
it.each(['floating', 'full-width', 'message'])('R-04: type=%s — hideCloseButton 미지정(기본 false)이면 onClose 만으로 닫기 버튼을 렌더한다', (type) => {
|
|
100
|
+
const element = render({ type, onClose: vi.fn() });
|
|
101
|
+
expect(element?.querySelector(CLOSE_BUTTON[type])).not.toBeNull();
|
|
102
|
+
});
|
|
103
|
+
it.each(['floating', 'full-width', 'message'])('R-05: type=%s — onClose 없이 hideCloseButton 만 주면 닫기 버튼 없이 오류 없이 렌더한다', (type) => {
|
|
104
|
+
const element = render({ type, hideCloseButton: true });
|
|
105
|
+
expect(element).not.toBeNull();
|
|
106
|
+
expect(element?.querySelector(CLOSE_BUTTON[type])).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
it('R-06: floating — hideCloseButton 이어도 autoClose 만료 시 onClose 가 1회 호출되고 DOM 에서 제거된다', () => {
|
|
109
|
+
vi.useFakeTimers();
|
|
110
|
+
const onClose = vi.fn();
|
|
111
|
+
render({ type: 'floating', autoClose: AUTO_CLOSE_MS, hideCloseButton: true, onClose });
|
|
112
|
+
act(() => {
|
|
113
|
+
vi.advanceTimersByTime(AUTO_CLOSE_MS);
|
|
114
|
+
});
|
|
115
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(container?.querySelector('.ncua-floating-notification')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
it('R-07: full-width — hideCloseButton 이어도 autoClose 만료 시 onClose 가 1회 호출되고 DOM 에서 제거된다', () => {
|
|
119
|
+
vi.useFakeTimers();
|
|
120
|
+
const onClose = vi.fn();
|
|
121
|
+
render({ type: 'full-width', autoClose: AUTO_CLOSE_MS, hideCloseButton: true, onClose });
|
|
122
|
+
act(() => {
|
|
123
|
+
vi.advanceTimersByTime(AUTO_CLOSE_MS);
|
|
124
|
+
});
|
|
125
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
126
|
+
expect(container?.querySelector('.ncua-full-width-notification')).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
it('R-09: message — hideCloseButton + onHidePermanently 이면 footer 에 다시 보지 않기만 렌더한다', () => {
|
|
129
|
+
const element = render({
|
|
130
|
+
type: 'message',
|
|
131
|
+
onClose: vi.fn(),
|
|
132
|
+
hideCloseButton: true,
|
|
133
|
+
onHidePermanently: vi.fn(),
|
|
134
|
+
});
|
|
135
|
+
expect(element?.querySelector(MESSAGE_FOOTER)).not.toBeNull();
|
|
136
|
+
expect(element?.querySelector(MESSAGE_HIDE_LINK)).not.toBeNull();
|
|
137
|
+
expect(element?.querySelector(CLOSE_BUTTON.message)).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
it('R-10: message — hideCloseButton 이어도 autoClose 만료 시 onClose 가 1회 호출되고 DOM 에서 제거된다', () => {
|
|
140
|
+
vi.useFakeTimers();
|
|
141
|
+
const onClose = vi.fn();
|
|
142
|
+
render({ type: 'message', autoClose: AUTO_CLOSE_MS, hideCloseButton: true, onClose });
|
|
143
|
+
act(() => {
|
|
144
|
+
vi.advanceTimersByTime(AUTO_CLOSE_MS);
|
|
145
|
+
});
|
|
146
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(container?.querySelector('.ncua-message-notification')).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('Notification — hideCloseButton 기본 autoClose', () => {
|
|
151
|
+
afterEach(() => {
|
|
152
|
+
vi.useRealTimers();
|
|
153
|
+
});
|
|
154
|
+
it('R-11: floating — hideCloseButton 만 주고 autoClose 를 생략하면 3초 뒤 onClose 가 호출되고 제거된다', () => {
|
|
155
|
+
vi.useFakeTimers();
|
|
156
|
+
const onClose = vi.fn();
|
|
157
|
+
render({ type: 'floating', hideCloseButton: true, onClose });
|
|
158
|
+
act(() => {
|
|
159
|
+
vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS - 1);
|
|
160
|
+
});
|
|
161
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
162
|
+
act(() => {
|
|
163
|
+
vi.advanceTimersByTime(1);
|
|
164
|
+
});
|
|
165
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
166
|
+
expect(container?.firstElementChild).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
it.each(['full-width', 'message'])('R-14: type=%s — hideCloseButton 만 주고 autoClose 를 생략해도 자동으로 닫히지 않는다 (부모 언마운트 용도)', (type) => {
|
|
169
|
+
vi.useFakeTimers();
|
|
170
|
+
const onClose = vi.fn();
|
|
171
|
+
render({ type, hideCloseButton: true, onClose });
|
|
172
|
+
act(() => {
|
|
173
|
+
vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS * 2);
|
|
174
|
+
});
|
|
175
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
176
|
+
expect(container?.firstElementChild).not.toBeNull();
|
|
177
|
+
});
|
|
178
|
+
it('R-12: floating — hideCloseButton + autoClose={0} 을 명시하면 자동으로 닫히지 않는다 (부모 언마운트 용도)', () => {
|
|
179
|
+
vi.useFakeTimers();
|
|
180
|
+
const onClose = vi.fn();
|
|
181
|
+
render({ type: 'floating', hideCloseButton: true, autoClose: 0, onClose });
|
|
182
|
+
act(() => {
|
|
183
|
+
vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS * 2);
|
|
184
|
+
});
|
|
185
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
186
|
+
expect(container?.querySelector('.ncua-floating-notification')).not.toBeNull();
|
|
187
|
+
});
|
|
188
|
+
it('R-13: floating — hideCloseButton 이 아니면 autoClose 생략 시 기본 자동 닫힘이 없다 (회귀)', () => {
|
|
189
|
+
vi.useFakeTimers();
|
|
190
|
+
const onClose = vi.fn();
|
|
191
|
+
render({ type: 'floating', onClose });
|
|
192
|
+
act(() => {
|
|
193
|
+
vi.advanceTimersByTime(HIDE_CLOSE_BUTTON_AUTO_CLOSE_MS * 2);
|
|
194
|
+
});
|
|
195
|
+
expect(onClose).not.toHaveBeenCalled();
|
|
196
|
+
expect(container?.querySelector('.ncua-floating-notification')).not.toBeNull();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface UseAutoCloseParams {
|
|
2
|
+
autoClose?: number;
|
|
3
|
+
/** floating 만 넘긴다 — 닫기 버튼 없는 토스트의 기본 3초 자동 닫힘. */
|
|
4
|
+
hideCloseButton?: boolean;
|
|
5
|
+
onClose?: () => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* autoClose 타이머. 만료 시 `onClose`를 호출하고 `true`(제거됨)를 반환한다.
|
|
9
|
+
* floating · full-width · message 가 공유한다.
|
|
10
|
+
*
|
|
11
|
+
* `onClose`는 매 렌더 새 함수일 수 있어 ref 로 latest 만 보관하고 deps 에서 뺀다 —
|
|
12
|
+
* 인라인 핸들러를 넘겨도 부모 리렌더마다 타이머가 리셋되지 않는다.
|
|
13
|
+
*/
|
|
14
|
+
export declare const useAutoClose: ({ autoClose, hideCloseButton, onClose }: UseAutoCloseParams) => boolean;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
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 = ({ autoClose, hideCloseButton, onClose }) => {
|
|
11
|
+
const [shouldRemove, setShouldRemove] = useState(false);
|
|
12
|
+
const onCloseRef = useRef(onClose);
|
|
13
|
+
onCloseRef.current = onClose;
|
|
14
|
+
const autoCloseMs = resolveAutoClose({ autoClose, hideCloseButton });
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (autoCloseMs <= 0)
|
|
17
|
+
return;
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
onCloseRef.current?.();
|
|
20
|
+
setShouldRemove(true);
|
|
21
|
+
}, autoCloseMs);
|
|
22
|
+
return () => clearTimeout(timer);
|
|
23
|
+
}, [autoCloseMs]);
|
|
24
|
+
return shouldRemove;
|
|
25
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|