@ncds/ui-admin 1.8.21-alpha.9 → 1.8.21

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 (45) hide show
  1. package/dist/cjs/src/components/feedback-and-status/badge/Badge.js +1 -1
  2. package/dist/cjs/src/components/forms-and-input/input-base/InputBase.js +13 -7
  3. package/dist/cjs/src/components/forms-and-input/input-base/__tests__/InputBase.test.js +16 -11
  4. package/dist/cjs/src/components/overlays/notification/CalloutNotification.js +6 -1
  5. package/dist/cjs/src/components/overlays/notification/Notification.js +22 -2
  6. package/dist/cjs/src/components/overlays/notification/__tests__/CalloutNotification.test.js +128 -0
  7. package/dist/cjs/src/components/overlays/notification/__tests__/Notification.test.js +83 -0
  8. package/dist/cjs/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +8 -2
  9. package/dist/cjs/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.js +1 -1
  10. package/dist/cjs/src/generated/scoped-css.js +1 -1
  11. package/dist/esm/src/components/feedback-and-status/badge/Badge.js +1 -1
  12. package/dist/esm/src/components/forms-and-input/input-base/InputBase.js +13 -7
  13. package/dist/esm/src/components/forms-and-input/input-base/__tests__/InputBase.test.js +16 -11
  14. package/dist/esm/src/components/overlays/notification/CalloutNotification.js +6 -1
  15. package/dist/esm/src/components/overlays/notification/Notification.js +22 -2
  16. package/dist/esm/src/components/overlays/notification/__tests__/CalloutNotification.test.js +125 -0
  17. package/dist/esm/src/components/overlays/notification/__tests__/Notification.test.js +80 -0
  18. package/dist/esm/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +9 -3
  19. package/dist/esm/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.js +1 -1
  20. package/dist/esm/src/generated/scoped-css.js +1 -1
  21. package/dist/temp/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  22. package/dist/temp/src/components/feedback-and-status/badge/Badge.js +1 -1
  23. package/dist/temp/src/components/forms-and-input/input-base/InputBase.js +10 -4
  24. package/dist/temp/src/components/forms-and-input/input-base/__tests__/InputBase.test.js +14 -12
  25. package/dist/temp/src/components/overlays/notification/CalloutNotification.d.ts +3 -1
  26. package/dist/temp/src/components/overlays/notification/CalloutNotification.js +5 -1
  27. package/dist/temp/src/components/overlays/notification/Notification.d.ts +12 -2
  28. package/dist/temp/src/components/overlays/notification/Notification.js +9 -4
  29. package/dist/temp/src/components/overlays/notification/__tests__/CalloutNotification.test.d.ts +1 -0
  30. package/dist/temp/src/components/overlays/notification/__tests__/CalloutNotification.test.js +108 -0
  31. package/dist/temp/src/components/overlays/notification/__tests__/Notification.test.d.ts +1 -0
  32. package/dist/temp/src/components/overlays/notification/__tests__/Notification.test.js +71 -0
  33. package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModal.d.ts +1 -1
  34. package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +6 -2
  35. package/dist/temp/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.js +1 -1
  36. package/dist/temp/src/generated/scoped-css.js +1 -1
  37. package/dist/types/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  38. package/dist/types/src/components/overlays/notification/CalloutNotification.d.ts +3 -1
  39. package/dist/types/src/components/overlays/notification/Notification.d.ts +12 -2
  40. package/dist/types/src/components/overlays/notification/__tests__/CalloutNotification.test.d.ts +1 -0
  41. package/dist/types/src/components/overlays/notification/__tests__/Notification.test.d.ts +1 -0
  42. package/dist/types/src/components/overlays/postcode-search-modal/PostcodeSearchModal.d.ts +1 -1
  43. package/dist/ui-admin/assets/styles/style.css +52 -32
  44. package/package.json +1 -1
  45. package/dist/ui-admin/assets/styles/style.scoped.css +0 -8595
@@ -16,4 +16,4 @@ type BadgeProps = {
16
16
  };
17
17
  declare const Badge: ({ label, type, color, className, leadingIcon, trailingIcon, size, }: BadgeProps) => import("react/jsx-runtime").JSX.Element;
18
18
  export { Badge };
19
- export type { BadgeColor, BadgeProps, BadgeSize, BadgeType };
19
+ export type { BadgeType, BadgeColor, BadgeSize, BadgeProps };
@@ -7,7 +7,7 @@ import { sideSlotRender } from './utils';
7
7
  */
8
8
  const BADGE_ICON_SIZE = 12;
9
9
  /** new-badge 박스 안에 들어가는 'N' 아이콘 크기. 박스는 sm 16px / md 20px 이며 아이콘은 그보다 작다(패딩 포함). */
10
- const NEW_BADGE_ICON_SIZE = { sm: 12, md: 14 };
10
+ const NEW_BADGE_ICON_SIZE = { sm: 12, md: 16 };
11
11
  const Badge = ({ label, type = 'pill-outline', color = 'neutral', className, leadingIcon, trailingIcon, size = 'xs', }) => {
12
12
  // new-badge: 신규 콘텐츠 'N' 마크 전용 타입. label·color·leadingIcon·trailingIcon은 무시되고
13
13
  // 색은 --pink-600 으로 고정된다. (디자이너 명세: Icon/FeaturedIcon/Badge로 대체 불가한 전용 표시)
@@ -5,12 +5,18 @@ import { forwardRef, useEffect, useRef, useState } from 'react';
5
5
  import { COLOR } from '../../../../constant/color';
6
6
  import { HintText } from '../../shared/hintText/HintText';
7
7
  import { Label } from '../../shared/label/Label';
8
- // 슬롯 아이콘(leadingElement/trailingElement) 상태 아이콘(clear·validation·destructive)은 같은 크기를 쓴다.
8
+ // 슬롯 아이콘(leadingElement/trailingElement) 인풋 크기에 맞춰 커진다.
9
9
  // slot.size 를 명시하면 그 값이 우선한다.
10
10
  const iconSvgSize = {
11
11
  xs: 14,
12
12
  sm: 20,
13
13
  };
14
+ // 상태 아이콘(clear·validation·destructive)은 인풋 크기와 무관하게 16px 로 고정한다.
15
+ // 슬롯 아이콘은 sm 에서 20px 로 커지므로 두 상수를 하나로 합치지 않는다.
16
+ const statusIconSvgSize = {
17
+ xs: 14,
18
+ sm: 16,
19
+ };
14
20
  const InputBase = forwardRef(({ size = 'xs', required, label, hintText, disabled, fullWidth = false, validation, destructive, leadingElement, trailingElement, showHelpIcon, maxLength, showTextCount, clearText, onClearText, className, ...props }, ref) => {
15
21
  const inputRef = useRef(null);
16
22
  // controlled 인풋은 props.value 로 길이를 알 수 있으므로 상태를 두지 않는다.
@@ -76,14 +82,14 @@ const InputBase = forwardRef(({ size = 'xs', required, label, hintText, disabled
76
82
  const renderClearButton = () => {
77
83
  if (!clearText)
78
84
  return null;
79
- return (_jsx("button", { type: "button", className: classNames('ncua-input__icon-wrap', 'ncua-input__right-icon', 'ncua-input__clear'), onClick: onClearText, children: _jsx(X, { className: "ncua-input__clear-icon", width: iconSvgSize[size], height: iconSvgSize[size] }) }));
85
+ return (_jsx("button", { type: "button", className: classNames('ncua-input__icon-wrap', 'ncua-input__right-icon', 'ncua-input__clear'), onClick: onClearText, children: _jsx(X, { className: "ncua-input__clear-icon", width: statusIconSvgSize[size], height: statusIconSvgSize[size] }) }));
80
86
  };
81
87
  const renderStatusIcon = () => {
82
88
  if (destructive) {
83
- return (_jsx("div", { className: "ncua-input__icon-wrap ncua-input__right-icon", children: _jsx(InfoCircle, { className: "ncua-input__destructive-icon", width: iconSvgSize[size], height: iconSvgSize[size] }) }));
89
+ return (_jsx("div", { className: "ncua-input__icon-wrap ncua-input__right-icon", children: _jsx(InfoCircle, { className: "ncua-input__destructive-icon", width: statusIconSvgSize[size], height: statusIconSvgSize[size] }) }));
84
90
  }
85
91
  if (validation) {
86
- return (_jsx("div", { className: "ncua-input__icon-wrap ncua-input__right-icon", children: _jsx(CheckCircle, { className: "ncua-input__validation-icon", width: iconSvgSize[size], height: iconSvgSize[size] }) }));
92
+ return (_jsx("div", { className: "ncua-input__icon-wrap ncua-input__right-icon", children: _jsx(CheckCircle, { className: "ncua-input__validation-icon", width: statusIconSvgSize[size], height: statusIconSvgSize[size] }) }));
87
93
  }
88
94
  return null;
89
95
  };
@@ -64,11 +64,12 @@ describe('InputBase — 아이콘 사이즈', () => {
64
64
  const svg = container.querySelector(selector);
65
65
  return { width: svg?.getAttribute('width'), height: svg?.getAttribute('height') };
66
66
  };
67
- // 슬롯 아이콘과 상태 아이콘은 같은 크기를 쓴다 과거 상태 아이콘만 sm=16으로 어긋나 있었다
67
+ // 슬롯 아이콘은 인풋 크기에 연동돼 sm 에서 20px 커지지만,
68
+ // 상태 아이콘(clear·validation·destructive)은 16px 로 고정한다.
68
69
  it.each([
69
- ['xs', '14'],
70
- ['sm', '20'],
71
- ])('size=%s 일 때 슬롯 아이콘과 상태 아이콘이 모두 %spx로 렌더된다', (size, expected) => {
70
+ ['xs', '14', '14'],
71
+ ['sm', '20', '16'],
72
+ ])('size=%s 일 때 슬롯 아이콘은 %spx, 상태 아이콘은 %spx로 렌더된다', (size, slotExpected, statusExpected) => {
72
73
  // Given/When: 슬롯 아이콘과 clear 버튼, destructive 상태를 함께 렌더하면
73
74
  const view = mountInputBase({
74
75
  size,
@@ -77,20 +78,21 @@ describe('InputBase — 아이콘 사이즈', () => {
77
78
  onClearText: vi.fn(),
78
79
  leadingElement: { type: 'icon', icon: SearchLg },
79
80
  });
80
- // Then: 슬롯·clear·destructive 아이콘의 크기가 서로 어긋나지 않는다
81
- const expectedSize = { width: expected, height: expected };
82
- expect(iconSizeOf(view.container, '.ncua-input__left-icon')).toEqual(expectedSize);
83
- expect(iconSizeOf(view.container, '.ncua-input__clear-icon')).toEqual(expectedSize);
84
- expect(iconSizeOf(view.container, '.ncua-input__destructive-icon')).toEqual(expectedSize);
81
+ // Then: 슬롯 아이콘만 인풋 크기를 따라가고, 상태 아이콘은 16px 을 넘지 않는다
82
+ const slotSize = { width: slotExpected, height: slotExpected };
83
+ const statusSize = { width: statusExpected, height: statusExpected };
84
+ expect(iconSizeOf(view.container, '.ncua-input__left-icon')).toEqual(slotSize);
85
+ expect(iconSizeOf(view.container, '.ncua-input__clear-icon')).toEqual(statusSize);
86
+ expect(iconSizeOf(view.container, '.ncua-input__destructive-icon')).toEqual(statusSize);
85
87
  view.unmount();
86
88
  });
87
89
  it.each([
88
90
  ['xs', '14'],
89
- ['sm', '20'],
90
- ])('size=%s 일 때 validation 아이콘도 %spx로 렌더된다', (size, expected) => {
91
+ ['sm', '16'],
92
+ ])('size=%s 일 때 validation 아이콘은 %spx로 렌더된다', (size, expected) => {
91
93
  // Given/When: validation 상태로 렌더하면
92
94
  const view = mountInputBase({ size, validation: true });
93
- // Then: 다른 아이콘과 같은 크기다
95
+ // Then: 다른 상태 아이콘과 같은 크기다
94
96
  expect(iconSizeOf(view.container, '.ncua-input__validation-icon')).toEqual({
95
97
  width: expected,
96
98
  height: expected,
@@ -1,9 +1,11 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import type { NotificationColor } from './Notification';
3
+ type CalloutNotificationWidth = 'full' | 'fit';
3
4
  interface CalloutNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
4
5
  color?: NotificationColor;
5
6
  title?: ReactNode;
7
+ width?: CalloutNotificationWidth;
6
8
  }
7
9
  declare const CalloutNotification: import("react").ForwardRefExoticComponent<CalloutNotificationProps & import("react").RefAttributes<HTMLDivElement>>;
8
- export type { CalloutNotificationProps };
10
+ export type { CalloutNotificationProps, CalloutNotificationWidth };
9
11
  export { CalloutNotification };
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import classNames from 'classnames';
3
3
  import { forwardRef } from 'react';
4
- const CalloutNotification = forwardRef(({ color = 'neutral', className, title, ...rest }, ref) => (_jsx("div", { ref: ref, className: classNames('ncua-callout-notification', `ncua-callout-notification--${color}`, className), ...rest, children: title })));
4
+ const WIDTH_CLASS = {
5
+ full: 'ncua-callout-notification--full',
6
+ fit: 'ncua-callout-notification--fit',
7
+ };
8
+ const CalloutNotification = forwardRef(({ color = 'neutral', className, title, width, ...rest }, ref) => (_jsx("div", { ref: ref, className: classNames('ncua-callout-notification', `ncua-callout-notification--${color}`, width && WIDTH_CLASS[width], className), ...rest, children: title })));
5
9
  CalloutNotification.displayName = 'CalloutNotification';
6
10
  export { CalloutNotification };
@@ -2,7 +2,7 @@ import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import type { ColorTone } from '../../../../constant/color';
3
3
  import type { SlotIconComponent } from '../../../types/side-slot';
4
4
  import type { ButtonTheme } from '../../action/button';
5
- import { type CalloutNotificationProps } from './CalloutNotification';
5
+ import { type CalloutNotificationProps, type CalloutNotificationWidth } from './CalloutNotification';
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';
@@ -76,6 +76,16 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
76
76
  * 표기가 통일되어 있지 않은 것은 현행 유지이며, 통일은 별도 결정 사항이다.
77
77
  */
78
78
  hidePermanentlyLabel?: ReactNode;
79
+ /**
80
+ * supportingText 에 연결할 링크 URL (선택사항)
81
+ * full-width 타입에서 사용 가능
82
+ */
83
+ supportTextLink?: string;
84
+ /**
85
+ * 너비 동작 (선택사항)
86
+ * callout 타입에서만 사용 가능. 미지정 시 부모 너비를 채운다(100%). 'fit' 은 콘텐츠 너비에 맞춘다
87
+ */
88
+ width?: CalloutNotificationWidth;
79
89
  /**
80
90
  * Portal 마운트 여부. `floating` 타입에서만 동작한다.
81
91
  *
@@ -92,5 +102,5 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
92
102
  }
93
103
  declare const Notification: import("react").ForwardRefExoticComponent<NotificationProps & import("react").RefAttributes<HTMLDivElement>>;
94
104
  export type { NotificationType, NotificationColor, NotificationSize, NotificationAction, NotificationProps };
95
- export type { CalloutNotificationProps };
105
+ export type { CalloutNotificationProps, CalloutNotificationWidth };
96
106
  export { Notification };
@@ -1,21 +1,26 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef } from 'react';
3
- import { CalloutNotification } from './CalloutNotification';
3
+ import { CalloutNotification, } from './CalloutNotification';
4
4
  import { FloatingNotification } from './FloatingNotification';
5
5
  import { FullWidthNotification } from './FullWidthNotification';
6
6
  import { MessageNotification } from './MessageNotification';
7
- const Notification = forwardRef(({ type = 'floating', color = 'neutral', ...rest }, ref) => {
7
+ const Notification = forwardRef(
8
+ // 단일 type 에만 유효한 prop 은 rest 에서 분리해 해당 분기로만 명시 전달한다.
9
+ // rest 에 남기면 다른 type 의 하위 컴포넌트가 div 로 스프레드해 DOM 속성으로 출력된다.
10
+ ({ type = 'floating', color = 'neutral', width, supportTextLink, ...rest }, ref) => {
8
11
  if (type === 'floating') {
9
12
  return _jsx(FloatingNotification, { color: color, ...rest, ref: ref });
10
13
  }
11
14
  if (type === 'full-width') {
12
- return _jsx(FullWidthNotification, { color: color, ...rest, ref: ref });
15
+ return (_jsx(FullWidthNotification, { color: color, supportTextLink: supportTextLink, ...rest, ref: ref }));
13
16
  }
14
17
  if (type === 'message') {
15
18
  return _jsx(MessageNotification, { color: color, ...rest, ref: ref });
16
19
  }
17
20
  if (type === 'callout') {
18
- return _jsx(CalloutNotification, { color: color, ...rest });
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;
23
+ return (_jsx(CalloutNotification, { color: color, width: width, ...calloutProps, ref: ref }));
19
24
  }
20
25
  return null;
21
26
  });
@@ -0,0 +1,108 @@
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 { CalloutNotification } from '../CalloutNotification';
7
+ import { Notification } from '../Notification';
8
+ // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
9
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
10
+ const BASE = 'ncua-callout-notification';
11
+ let root = null;
12
+ let container = null;
13
+ const mount = (element) => {
14
+ container = document.createElement('div');
15
+ document.body.appendChild(container);
16
+ root = createRoot(container);
17
+ act(() => {
18
+ root?.render(element);
19
+ });
20
+ const rendered = container.firstElementChild;
21
+ if (!(rendered instanceof HTMLElement))
22
+ throw new Error('callout 이 렌더되지 않았다');
23
+ return rendered;
24
+ };
25
+ const renderCallout = (props = {}) => mount(createElement(CalloutNotification, { title: '안내 문구', ...props }));
26
+ const renderNotification = (props = {}) => mount(createElement(Notification, { type: 'callout', title: '안내 문구', ...props }));
27
+ afterEach(() => {
28
+ act(() => {
29
+ root?.unmount();
30
+ });
31
+ container?.remove();
32
+ root = null;
33
+ container = null;
34
+ });
35
+ describe('CalloutNotification — width prop', () => {
36
+ it('width 미지정 시 너비 클래스를 붙이지 않는다 (부모 너비를 채우는 기본 스타일 유지)', () => {
37
+ const element = renderCallout();
38
+ expect(element.classList.contains('ncua-callout-notification--full')).toBe(false);
39
+ expect(element.classList.contains('ncua-callout-notification--fit')).toBe(false);
40
+ });
41
+ it("width='full' 은 --full modifier 를 붙인다", () => {
42
+ const element = renderCallout({ width: 'full' });
43
+ expect(element.classList.contains('ncua-callout-notification--full')).toBe(true);
44
+ expect(element.classList.contains('ncua-callout-notification--fit')).toBe(false);
45
+ });
46
+ it("width='fit' 은 --fit modifier 를 붙인다", () => {
47
+ const element = renderCallout({ width: 'fit' });
48
+ expect(element.classList.contains('ncua-callout-notification--fit')).toBe(true);
49
+ expect(element.classList.contains('ncua-callout-notification--full')).toBe(false);
50
+ });
51
+ it('width 는 DOM 속성으로 새지 않는다', () => {
52
+ const element = renderCallout({ width: 'fit' });
53
+ expect(element.hasAttribute('width')).toBe(false);
54
+ });
55
+ it('width 는 베이스/컬러 클래스와 외부 className 을 유지한 채 추가된다', () => {
56
+ const element = renderCallout({ width: 'fit', color: 'info', className: 'custom' });
57
+ expect(element.classList.contains(BASE)).toBe(true);
58
+ expect(element.classList.contains(`${BASE}--info`)).toBe(true);
59
+ expect(element.classList.contains('custom')).toBe(true);
60
+ });
61
+ });
62
+ describe('Notification type="callout" — 위임 계약', () => {
63
+ it('width 를 CalloutNotification 으로 전달한다', () => {
64
+ const element = renderNotification({ width: 'fit' });
65
+ expect(element.classList.contains(BASE)).toBe(true);
66
+ expect(element.classList.contains('ncua-callout-notification--fit')).toBe(true);
67
+ });
68
+ // Notification 의 callout 분기가 제외하는 prop 전체. 여기 누락된 prop 은 DOM 속성으로 출력된다.
69
+ it('callout 이 지원하지 않는 prop 은 DOM 속성으로 새지 않는다', () => {
70
+ const element = renderNotification({
71
+ icon: () => null,
72
+ supportingText: '보조 문구',
73
+ supportTextLink: 'https://example.com',
74
+ actions: [{ label: '확인', onClick: () => undefined }],
75
+ autoClose: 3000,
76
+ portal: true,
77
+ onClose: () => undefined,
78
+ onHidePermanently: () => undefined,
79
+ hidePermanentlyLabel: '다시 보지 않기',
80
+ });
81
+ for (const attr of [
82
+ 'icon',
83
+ 'supportingtext',
84
+ 'supporttextlink',
85
+ 'actions',
86
+ 'autoclose',
87
+ 'portal',
88
+ 'onclose',
89
+ 'onhidepermanently',
90
+ 'hidepermanentlylabel',
91
+ ]) {
92
+ expect(element.hasAttribute(attr), attr).toBe(false);
93
+ }
94
+ expect(element.textContent).toBe('안내 문구');
95
+ });
96
+ it('ref 를 callout 루트 엘리먼트로 전달한다', () => {
97
+ let captured = null;
98
+ mount(createElement(Notification, {
99
+ type: 'callout',
100
+ title: '안내 문구',
101
+ ref: (node) => {
102
+ captured = node;
103
+ },
104
+ }));
105
+ expect(captured).not.toBeNull();
106
+ expect(captured.classList.contains(BASE)).toBe(true);
107
+ });
108
+ });
@@ -0,0 +1,71 @@
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, beforeAll, describe, expect, it } from 'vitest';
6
+ import { Notification } from '../Notification';
7
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
8
+ let root = null;
9
+ let container = null;
10
+ beforeAll(() => {
11
+ // jsdom 미구현 API 보정 — FloatingNotification 의 모바일 리스너가 window.matchMedia 를 호출한다.
12
+ if (typeof window.matchMedia !== 'function') {
13
+ const stub = (query) => ({
14
+ matches: false,
15
+ media: query,
16
+ onchange: null,
17
+ addEventListener: () => undefined,
18
+ removeEventListener: () => undefined,
19
+ addListener: () => undefined,
20
+ removeListener: () => undefined,
21
+ dispatchEvent: () => false,
22
+ });
23
+ window.matchMedia = stub;
24
+ }
25
+ });
26
+ // 정의되지 않은 type 의 런타임 동작까지 고정하므로 props 를 넓게 받는다.
27
+ const render = (props) => {
28
+ container = document.createElement('div');
29
+ document.body.appendChild(container);
30
+ root = createRoot(container);
31
+ act(() => {
32
+ root?.render(createElement(Notification, { title: '안내 문구', ...props }));
33
+ });
34
+ const rendered = container.firstElementChild;
35
+ return rendered instanceof HTMLElement ? rendered : null;
36
+ };
37
+ afterEach(() => {
38
+ act(() => {
39
+ root?.unmount();
40
+ });
41
+ container?.remove();
42
+ root = null;
43
+ container = null;
44
+ });
45
+ describe('Notification — type 별 위임', () => {
46
+ it.each([
47
+ ['floating', 'ncua-floating-notification'],
48
+ ['full-width', 'ncua-full-width-notification'],
49
+ ['message', 'ncua-message-notification'],
50
+ ['callout', 'ncua-callout-notification'],
51
+ ])('type=%s 는 %s 를 렌더한다', (type, baseClass) => {
52
+ const element = render({ type });
53
+ expect(element?.classList.contains(baseClass)).toBe(true);
54
+ });
55
+ it('type 미지정은 floating 으로 렌더한다', () => {
56
+ const element = render({});
57
+ expect(element?.classList.contains('ncua-floating-notification')).toBe(true);
58
+ });
59
+ it.each(['floating', 'full-width', 'message'])('type=%s 에 callout 전용 width 를 주어도 DOM 속성으로 새지 않는다', (type) => {
60
+ const element = render({ type, width: 'fit' });
61
+ expect(element?.hasAttribute('width')).toBe(false);
62
+ });
63
+ it.each(['floating', 'message'])('type=%s 에 full-width 전용 supportTextLink 를 주어도 DOM 속성으로 새지 않는다', (type) => {
64
+ const element = render({ type, supportingText: '보조', supportTextLink: 'https://example.com' });
65
+ expect(element?.hasAttribute('supporttextlink')).toBe(false);
66
+ });
67
+ it('정의되지 않은 type 은 아무것도 렌더하지 않는다', () => {
68
+ const element = render({ type: 'banner' });
69
+ expect(element).toBeNull();
70
+ });
71
+ });
@@ -38,7 +38,7 @@ declare const PostcodeSearchModal: {
38
38
  displayName: string;
39
39
  Tip: {
40
40
  ({ className }: PostcodeSearchModalTipProps): import("react/jsx-runtime").JSX.Element;
41
- displayName: string; /** 반응형 전환 기준 — assets/styles/base/_variable.scss 의 $breakpoint 와 동일 */
41
+ displayName: string;
42
42
  };
43
43
  };
44
44
  export { PostcodeSearchModal };
@@ -7,11 +7,13 @@ import { InputBase } from '../../forms-and-input/input-base';
7
7
  import { Radio } from '../../forms-and-input/radio';
8
8
  import { Pagination } from '../../navigation/pagination';
9
9
  import { Modal } from '../modal';
10
- import { TIP_TITLE, Tip } from './PostcodeSearchModalTip';
10
+ import { Tip } from './PostcodeSearchModalTip';
11
11
  /** 기획서 기준값 */
12
12
  const PAGE_SIZE = 15;
13
13
  /** 페이지 번호 그룹 크기 — 초과 시 Pagination 이 점프 버튼을 노출한다 */
14
14
  const PAGER_WINDOW = 10;
15
+ /** 검색 조합 안내 문구를 띄우는 기준 — 결과가 이 수를 넘을 때만 노출한다 */
16
+ const SEARCH_HINT_MIN_TOTAL_COUNT = 30;
15
17
  /** 반응형 전환 기준 — assets/styles/base/_variable.scss 의 $breakpoint 와 동일 */
16
18
  const MOBILE_BREAKPOINT = 768;
17
19
  /** 본문 스크롤 컨테이너. Modal.Content 가 overflow-y:auto 를 갖는다. */
@@ -94,6 +96,8 @@ const PostcodeSearchModal = ({ open, onClose, zIndex, className, searchQuery, on
94
96
  }, [open]);
95
97
  const hasError = Boolean(errorCode);
96
98
  const hasResults = !hasError && results.length > 0;
99
+ // 결과가 적으면 굳이 검색 조합을 안내하지 않는다.
100
+ const showSearchHint = hasResults && totalCount > SEARCH_HINT_MIN_TOTAL_COUNT;
97
101
  // 검색을 실행한 검색어와 현재 입력값이 같을 때만 "결과 없음"으로 본다.
98
102
  // 입력 중에는 두 값이 달라지고, 응답을 기다리는 동안에는 loading 이 막는다.
99
103
  const showEmptyState = !hasError && !loading && results.length === 0 && searchQuery !== '' && searchQuery === searchedQuery;
@@ -115,7 +119,7 @@ const PostcodeSearchModal = ({ open, onClose, zIndex, className, searchQuery, on
115
119
  };
116
120
  return (_jsxs(Modal, { isOpen: open, onClose: onClose, size: "md", zIndex: zIndex, className: className, children: [_jsx(Modal.Header, { title: MODAL_TITLE, onClose: onClose, showDivider: true }), _jsx(Modal.Content, { children: _jsxs("div", { className: "ncua-postcode-search-modal", ref: rootRef, children: [_jsxs("div", { className: classnames('ncua-postcode-search-modal__search-area', {
117
121
  'is-fixed': hasResults && isContentScrolled,
118
- }), children: [_jsxs("div", { className: "ncua-postcode-search-modal__search", children: [_jsx(InputBase, { className: "ncua-postcode-search-modal__search-input", size: "sm", fullWidth: true, value: searchQuery, onChange: handleSearchQueryChange, onKeyDown: handleSearchKeyDown, placeholder: SEARCH_PLACEHOLDER, destructive: hasError, hintText: errorCode ? (ERROR_HINT[errorCode] ?? DEFAULT_ERROR_HINT) : undefined }), _jsx(Button, { className: "ncua-postcode-search-modal__search-button", label: SEARCH_BUTTON_LABEL, size: "sm", hierarchy: "secondary", onClick: handleSearch })] }), hasResults && (_jsxs("div", { children: [_jsx("p", { className: "ncua-postcode-search-modal__result-title", children: TIP_TITLE }), _jsx("p", { className: "ncua-postcode-search-modal__result-desc", children: "\uB3C4\uB85C\uBA85+\uAC74\uBB3C\uBC88\uD638, \uC9C0\uC5ED\uBA85+\uC9C0\uBC88, \uC9C0\uC5ED\uBA85+\uAC74\uBB3C\uBA85(\uC544\uD30C\uD2B8\uBA85)" })] }))] }), !hasResults && _jsx(PostcodeSearchModal.Tip, {}), hasResults && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "ncua-postcode-search-modal__count", children: ["\uAC80\uC0C9 ", _jsx("b", { children: totalCount }), "\uAC1C"] }), _jsx("ul", { className: "ncua-postcode-search-modal__list", children: results.map((result, index) => (_jsx("li", { className: "ncua-postcode-search-modal__item", children: _jsx(Radio, { name: RESULT_RADIO_NAME, value: result.zipCode, checked: selectedKey === resultKey(result), onChange: () => {
122
+ }), children: [_jsxs("div", { className: "ncua-postcode-search-modal__search", children: [_jsx(InputBase, { className: "ncua-postcode-search-modal__search-input", size: "sm", fullWidth: true, value: searchQuery, clearText: !!searchQuery, onClearText: () => onSearchQueryChange(''), onChange: handleSearchQueryChange, onKeyDown: handleSearchKeyDown, placeholder: SEARCH_PLACEHOLDER, destructive: hasError, hintText: errorCode ? (ERROR_HINT[errorCode] ?? DEFAULT_ERROR_HINT) : undefined }), _jsx(Button, { className: "ncua-postcode-search-modal__search-button", label: SEARCH_BUTTON_LABEL, size: "sm", hierarchy: "secondary", onClick: handleSearch })] }), showSearchHint && (_jsxs("div", { children: [_jsx("p", { className: "ncua-postcode-search-modal__result-title", children: "\uC544\uB798 \uC870\uD569\uC73C\uB85C \uAC80\uC0C9 \uC2DC \uC8FC\uC18C\uB97C \uB354 \uC27D\uAC8C \uCC3E\uC744 \uC218 \uC788\uC5B4\uC694." }), _jsx("p", { className: "ncua-postcode-search-modal__result-desc", children: "\uB3C4\uB85C\uBA85+\uAC74\uBB3C\uBC88\uD638, \uC9C0\uC5ED\uBA85+\uC9C0\uBC88, \uC9C0\uC5ED\uBA85+\uAC74\uBB3C\uBA85(\uC544\uD30C\uD2B8\uBA85)" })] }))] }), !hasResults && _jsx(PostcodeSearchModal.Tip, {}), hasResults && (_jsxs(_Fragment, { children: [_jsxs("p", { className: "ncua-postcode-search-modal__count", children: ["\uAC80\uC0C9 ", _jsx("b", { children: totalCount }), "\uAC1C"] }), _jsx("ul", { className: "ncua-postcode-search-modal__list", children: results.map((result, index) => (_jsx("li", { className: "ncua-postcode-search-modal__item", children: _jsx(Radio, { name: RESULT_RADIO_NAME, value: result.zipCode, checked: selectedKey === resultKey(result), onChange: () => {
119
123
  setSelectedKey(resultKey(result));
120
124
  onSelect(result);
121
125
  }, text: _jsxs(_Fragment, { children: [_jsxs("span", { className: "ncua-postcode-search-modal__addr-line", children: [_jsx("span", { className: "ncua-postcode-search-modal__badge", children: ROAD_BADGE_LABEL }), _jsx("span", { className: "ncua-postcode-search-modal__item-road", children: result.roadAddress }), _jsx("span", { className: "ncua-postcode-search-modal__item-zip", children: result.zipCode })] }), _jsxs("span", { className: "ncua-postcode-search-modal__addr-line", children: [_jsx("span", { className: "ncua-postcode-search-modal__badge", children: JIBUN_BADGE_LABEL }), _jsx("span", { className: "ncua-postcode-search-modal__item-jibun", children: result.jibunAddress })] })] }) }) }, `${result.zipCode}-${index}`))) }), _jsx("div", { className: "ncua-postcode-search-modal__pagination", children: _jsx(Pagination, { totalCount: totalCount, itemCountPerPage: pageSize, pageCount: PAGER_WINDOW, currentPage: currentPage, breakPoint: isMobile ? 'mo' : 'pc', onPageChange: onPageChange }) })] })), showEmptyState && (_jsx("div", { className: "ncua-postcode-search-modal__empty", children: _jsx(EmptyState, { title: EMPTY_TITLE, description: EMPTY_DESCRIPTION }) }))] }) })] }));
@@ -250,7 +250,7 @@ describe('PostcodeSearchModal — Case 7: 결과 여러 페이지', () => {
250
250
  expect(countLabelOf(el)?.textContent).toBe(`검색 ${LARGE_RESULT_COUNT}개`);
251
251
  expect(itemsOf(el)).toHaveLength(PAGE_SIZE);
252
252
  expect(tipOf(el)).toBeNull();
253
- expect(resultTitleOf(el)?.textContent).toBe('아래 조합으로 검색하면 원하는 주소를 더 쉽게 찾을 수 있어요.');
253
+ expect(resultTitleOf(el)?.textContent).toBe('아래 조합으로 검색 주소를 더 쉽게 찾을 수 있어요.');
254
254
  expect(resultDescOf(el)?.textContent).toBe('도로명+건물번호, 지역명+지번, 지역명+건물명(아파트명)');
255
255
  });
256
256
  it('검색 인풋과 안내 문구가 같은 고정 영역 안에 묶이고 결과 목록은 그 밖에 있다', () => {