@ncds/ui-admin 1.8.21-alpha.1 → 1.8.21-alpha.11

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 (125) hide show
  1. package/dist/cjs/assets/scripts/notification/FullWidthNotification.js +17 -6
  2. package/dist/cjs/assets/scripts/notification/MessageNotification.js +10 -48
  3. package/dist/cjs/assets/scripts/notification/const/classNames.js +3 -0
  4. package/dist/cjs/assets/scripts/notification/const/index.js +1 -1
  5. package/dist/cjs/assets/scripts/notification/const/sizes.js +4 -2
  6. package/dist/cjs/assets/scripts/notification/messageTemplate.js +106 -0
  7. package/dist/cjs/assets/scripts/notification/utils.js +40 -4
  8. package/dist/cjs/constant/notification.js +21 -0
  9. package/dist/cjs/src/components/forms-and-input/input-base/InputBase.js +30 -18
  10. package/dist/cjs/src/components/forms-and-input/input-base/__tests__/InputBase.test.js +116 -0
  11. package/dist/cjs/src/components/forms-and-input/password-input/PasswordInput.js +3 -3
  12. package/dist/cjs/src/components/forms-and-input/password-input/__tests__/PasswordInput.test.js +59 -0
  13. package/dist/cjs/src/components/index.js +11 -0
  14. package/dist/cjs/src/components/layout/ncua-scope/NcuaScope.js +8 -2
  15. package/dist/cjs/src/components/navigation/pagination/Pagination.js +3 -1
  16. package/dist/cjs/src/components/navigation/pagination/__tests__/Pagination.test.js +74 -0
  17. package/dist/cjs/src/components/overlays/notification/FullWidthNotification.js +3 -1
  18. package/dist/cjs/src/components/overlays/notification/MessageNotification.js +14 -54
  19. package/dist/cjs/src/components/overlays/notification/MessageNotificationParts.js +92 -0
  20. package/dist/cjs/src/components/overlays/notification/__tests__/MessageNotification.test.js +246 -0
  21. package/dist/cjs/src/components/overlays/notification/__tests__/hidePermanentlyLabel.test.js +209 -0
  22. package/dist/cjs/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +259 -0
  23. package/dist/cjs/src/components/overlays/postcode-search-modal/PostcodeSearchModalTip.js +68 -0
  24. package/dist/cjs/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.js +623 -0
  25. package/dist/cjs/src/components/overlays/postcode-search-modal/index.js +16 -0
  26. package/dist/cjs/src/generated/scoped-css.js +1 -1
  27. package/dist/esm/assets/scripts/notification/FullWidthNotification.js +17 -6
  28. package/dist/esm/assets/scripts/notification/MessageNotification.js +12 -50
  29. package/dist/esm/assets/scripts/notification/const/classNames.js +3 -0
  30. package/dist/esm/assets/scripts/notification/const/index.js +3 -2
  31. package/dist/esm/assets/scripts/notification/const/sizes.js +4 -2
  32. package/dist/esm/assets/scripts/notification/messageTemplate.js +100 -0
  33. package/dist/esm/assets/scripts/notification/utils.js +39 -4
  34. package/dist/esm/constant/notification.js +15 -0
  35. package/dist/esm/src/components/forms-and-input/input-base/InputBase.js +30 -18
  36. package/dist/esm/src/components/forms-and-input/input-base/__tests__/InputBase.test.js +116 -0
  37. package/dist/esm/src/components/forms-and-input/password-input/PasswordInput.js +3 -3
  38. package/dist/esm/src/components/forms-and-input/password-input/__tests__/PasswordInput.test.js +56 -0
  39. package/dist/esm/src/components/index.js +1 -0
  40. package/dist/esm/src/components/layout/ncua-scope/NcuaScope.js +8 -2
  41. package/dist/esm/src/components/navigation/pagination/Pagination.js +3 -1
  42. package/dist/esm/src/components/navigation/pagination/__tests__/Pagination.test.js +74 -0
  43. package/dist/esm/src/components/overlays/notification/FullWidthNotification.js +3 -1
  44. package/dist/esm/src/components/overlays/notification/MessageNotification.js +15 -55
  45. package/dist/esm/src/components/overlays/notification/MessageNotificationParts.js +85 -0
  46. package/dist/esm/src/components/overlays/notification/__tests__/MessageNotification.test.js +243 -0
  47. package/dist/esm/src/components/overlays/notification/__tests__/hidePermanentlyLabel.test.js +206 -0
  48. package/dist/esm/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +252 -0
  49. package/dist/esm/src/components/overlays/postcode-search-modal/PostcodeSearchModalTip.js +61 -0
  50. package/dist/esm/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.js +620 -0
  51. package/dist/esm/src/components/overlays/postcode-search-modal/index.js +1 -0
  52. package/dist/esm/src/generated/scoped-css.js +1 -1
  53. package/dist/temp/assets/scripts/notification/FullWidthNotification.d.ts +6 -0
  54. package/dist/temp/assets/scripts/notification/FullWidthNotification.js +14 -5
  55. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +0 -3
  56. package/dist/temp/assets/scripts/notification/MessageNotification.js +9 -44
  57. package/dist/temp/assets/scripts/notification/const/classNames.d.ts +1 -0
  58. package/dist/temp/assets/scripts/notification/const/classNames.js +3 -0
  59. package/dist/temp/assets/scripts/notification/const/index.d.ts +3 -3
  60. package/dist/temp/assets/scripts/notification/const/index.js +3 -2
  61. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +2 -2
  62. package/dist/temp/assets/scripts/notification/const/sizes.js +4 -2
  63. package/dist/temp/assets/scripts/notification/const/types.d.ts +30 -2
  64. package/dist/temp/assets/scripts/notification/messageTemplate.d.ts +15 -0
  65. package/dist/temp/assets/scripts/notification/messageTemplate.js +79 -0
  66. package/dist/temp/assets/scripts/notification/utils.d.ts +19 -2
  67. package/dist/temp/assets/scripts/notification/utils.js +39 -5
  68. package/dist/temp/constant/notification.d.ts +15 -0
  69. package/dist/temp/constant/notification.js +15 -0
  70. package/dist/temp/src/components/forms-and-input/input-base/InputBase.d.ts +1 -1
  71. package/dist/temp/src/components/forms-and-input/input-base/InputBase.js +26 -14
  72. package/dist/temp/src/components/forms-and-input/input-base/__tests__/InputBase.test.js +79 -0
  73. package/dist/temp/src/components/forms-and-input/password-input/PasswordInput.js +2 -2
  74. package/dist/temp/src/components/forms-and-input/password-input/__tests__/PasswordInput.test.d.ts +1 -0
  75. package/dist/temp/src/components/forms-and-input/password-input/__tests__/PasswordInput.test.js +48 -0
  76. package/dist/temp/src/components/index.d.ts +1 -0
  77. package/dist/temp/src/components/index.js +1 -0
  78. package/dist/temp/src/components/layout/ncua-scope/NcuaScope.js +5 -1
  79. package/dist/temp/src/components/navigation/pagination/Pagination.js +3 -1
  80. package/dist/temp/src/components/navigation/pagination/__tests__/Pagination.test.js +74 -0
  81. package/dist/temp/src/components/overlays/notification/FullWidthNotification.d.ts +6 -0
  82. package/dist/temp/src/components/overlays/notification/FullWidthNotification.js +3 -2
  83. package/dist/temp/src/components/overlays/notification/MessageNotification.d.ts +6 -0
  84. package/dist/temp/src/components/overlays/notification/MessageNotification.js +4 -13
  85. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.d.ts +28 -0
  86. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.js +28 -0
  87. package/dist/temp/src/components/overlays/notification/Notification.d.ts +11 -2
  88. package/dist/temp/src/components/overlays/notification/__tests__/MessageNotification.test.d.ts +1 -0
  89. package/dist/temp/src/components/overlays/notification/__tests__/MessageNotification.test.js +190 -0
  90. package/dist/temp/src/components/overlays/notification/__tests__/hidePermanentlyLabel.test.d.ts +1 -0
  91. package/dist/temp/src/components/overlays/notification/__tests__/hidePermanentlyLabel.test.js +160 -0
  92. package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModal.d.ts +45 -0
  93. package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +125 -0
  94. package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModalTip.d.ts +16 -0
  95. package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModalTip.js +24 -0
  96. package/dist/temp/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.d.ts +1 -0
  97. package/dist/temp/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.js +503 -0
  98. package/dist/temp/src/components/overlays/postcode-search-modal/index.d.ts +1 -0
  99. package/dist/temp/src/components/overlays/postcode-search-modal/index.js +1 -0
  100. package/dist/temp/src/generated/scoped-css.js +1 -1
  101. package/dist/types/assets/scripts/notification/FullWidthNotification.d.ts +6 -0
  102. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +0 -3
  103. package/dist/types/assets/scripts/notification/const/classNames.d.ts +1 -0
  104. package/dist/types/assets/scripts/notification/const/index.d.ts +3 -3
  105. package/dist/types/assets/scripts/notification/const/sizes.d.ts +2 -2
  106. package/dist/types/assets/scripts/notification/const/types.d.ts +30 -2
  107. package/dist/types/assets/scripts/notification/messageTemplate.d.ts +15 -0
  108. package/dist/types/assets/scripts/notification/utils.d.ts +19 -2
  109. package/dist/types/constant/notification.d.ts +15 -0
  110. package/dist/types/src/components/forms-and-input/input-base/InputBase.d.ts +1 -1
  111. package/dist/types/src/components/forms-and-input/password-input/__tests__/PasswordInput.test.d.ts +1 -0
  112. package/dist/types/src/components/index.d.ts +1 -0
  113. package/dist/types/src/components/overlays/notification/FullWidthNotification.d.ts +6 -0
  114. package/dist/types/src/components/overlays/notification/MessageNotification.d.ts +6 -0
  115. package/dist/types/src/components/overlays/notification/MessageNotificationParts.d.ts +28 -0
  116. package/dist/types/src/components/overlays/notification/Notification.d.ts +11 -2
  117. package/dist/types/src/components/overlays/notification/__tests__/MessageNotification.test.d.ts +1 -0
  118. package/dist/types/src/components/overlays/notification/__tests__/hidePermanentlyLabel.test.d.ts +1 -0
  119. package/dist/types/src/components/overlays/postcode-search-modal/PostcodeSearchModal.d.ts +45 -0
  120. package/dist/types/src/components/overlays/postcode-search-modal/PostcodeSearchModalTip.d.ts +16 -0
  121. package/dist/types/src/components/overlays/postcode-search-modal/__tests__/PostcodeSearchModal.test.d.ts +1 -0
  122. package/dist/types/src/components/overlays/postcode-search-modal/index.d.ts +1 -0
  123. package/dist/ui-admin/assets/styles/style.css +279 -37
  124. package/dist/ui-admin/assets/styles/style.scoped.css +279 -37
  125. package/package.json +1 -1
@@ -0,0 +1,190 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { MessageNotification } from '../MessageNotification';
7
+ // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
8
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
+ const SELECTOR = {
10
+ ROOT: '.ncua-message-notification',
11
+ CONTENT: '.ncua-message-notification__content',
12
+ CONTENT_WRAPPER: '.ncua-message-notification__content-wrapper',
13
+ ICON: '.ncua-message-notification__icon',
14
+ ACTIONS_CONTAINER: '.ncua-message-notification__actions-container',
15
+ ACTIONS: '.ncua-message-notification__actions',
16
+ FOOTER_CONTAINER: '.ncua-message-notification__footer-container',
17
+ HIDE_LINK: '.ncua-message-notification__hide-link',
18
+ CLOSE_BUTTON: '.ncua-message-notification__close-button',
19
+ };
20
+ const mounted = [];
21
+ function renderMessageNotification(props = {}) {
22
+ const container = document.createElement('div');
23
+ document.body.appendChild(container);
24
+ const root = createRoot(container);
25
+ act(() => {
26
+ root.render(createElement(MessageNotification, { title: '알림 제목', ...props }));
27
+ });
28
+ mounted.push({ root, container });
29
+ return container;
30
+ }
31
+ afterEach(() => {
32
+ for (const { root, container } of mounted.splice(0)) {
33
+ act(() => {
34
+ root.unmount();
35
+ });
36
+ container.remove();
37
+ }
38
+ });
39
+ describe('MessageNotification (React) — DOM 구조 계약', () => {
40
+ it('U-1: __footer-container가 __actions-container의 자식으로 렌더된다', () => {
41
+ // Given: 다시 보지 않기·닫기 핸들러가 주어지고
42
+ // When: MessageNotification을 렌더하면
43
+ const container = renderMessageNotification({ onHidePermanently: vi.fn(), onClose: vi.fn() });
44
+ // Then: footer-container는 actions-container의 직계 자식이다 (content의 직계 자식이 아니다)
45
+ const actionsContainer = container.querySelector(SELECTOR.ACTIONS_CONTAINER);
46
+ const footerContainer = container.querySelector(SELECTOR.FOOTER_CONTAINER);
47
+ expect(actionsContainer).not.toBeNull();
48
+ expect(footerContainer).not.toBeNull();
49
+ expect(footerContainer?.parentElement).toBe(actionsContainer);
50
+ });
51
+ it('U-2: actions 지정 시 __actions와 __footer-container가 __actions-container 안에 순서대로 공존한다', () => {
52
+ // Given: 액션 버튼 2개와 다시 보지 않기·닫기 핸들러가 주어지고
53
+ const actions = [
54
+ { label: '조회하기', hierarchy: 'text', onClick: vi.fn() },
55
+ { label: '포인트 충전', hierarchy: 'text-gray', onClick: vi.fn() },
56
+ ];
57
+ // When: 렌더하면
58
+ const container = renderMessageNotification({ actions, onHidePermanently: vi.fn(), onClose: vi.fn() });
59
+ // Then: actions-container의 자식은 [actions, footer-container] 순서다
60
+ const actionsContainer = container.querySelector(SELECTOR.ACTIONS_CONTAINER);
61
+ const actionsGroup = container.querySelector(SELECTOR.ACTIONS);
62
+ const footerContainer = container.querySelector(SELECTOR.FOOTER_CONTAINER);
63
+ expect(actionsContainer).not.toBeNull();
64
+ expect(Array.from(actionsContainer?.children ?? [])).toEqual([actionsGroup, footerContainer]);
65
+ });
66
+ it('U-3: onHidePermanently·onClose·actions 미지정 시 빈 컨테이너가 남지 않는다', () => {
67
+ // Given/When: 우측 그룹에 표시할 요소가 하나도 없는 상태로 렌더하면
68
+ const container = renderMessageNotification();
69
+ // Then: actions-container도 footer-container도 렌더되지 않는다
70
+ expect(container.querySelector(SELECTOR.ACTIONS_CONTAINER)).toBeNull();
71
+ expect(container.querySelector(SELECTOR.FOOTER_CONTAINER)).toBeNull();
72
+ // 본문 영역은 그대로 유지된다
73
+ expect(container.querySelector(SELECTOR.CONTENT_WRAPPER)).not.toBeNull();
74
+ });
75
+ it('U-3-a: actions만 지정하면 actions-container만 남고 footer-container는 없다', () => {
76
+ // Given/When: 액션 버튼만 있고 닫기 그룹이 없으면
77
+ const container = renderMessageNotification({
78
+ actions: [{ label: '조회하기', hierarchy: 'text', onClick: vi.fn() }],
79
+ });
80
+ // Then: actions-container는 존재하고 그 자식은 actions 그룹 하나뿐이다
81
+ const actionsContainer = container.querySelector(SELECTOR.ACTIONS_CONTAINER);
82
+ const actionsGroup = container.querySelector(SELECTOR.ACTIONS);
83
+ expect(actionsContainer).not.toBeNull();
84
+ expect(container.querySelector(SELECTOR.FOOTER_CONTAINER)).toBeNull();
85
+ expect(Array.from(actionsContainer?.children ?? [])).toEqual([actionsGroup]);
86
+ });
87
+ it('U-3-b: onClose만 지정하면 footer-container에 닫기 버튼만 담긴다', () => {
88
+ // Given/When: 닫기 핸들러만 지정하면
89
+ const container = renderMessageNotification({ onClose: vi.fn() });
90
+ // Then: footer-container에는 닫기 버튼 1개만 존재하고 hide-link는 없다
91
+ const footerContainer = container.querySelector(SELECTOR.FOOTER_CONTAINER);
92
+ expect(footerContainer?.children).toHaveLength(1);
93
+ expect(footerContainer?.querySelector(SELECTOR.CLOSE_BUTTON)).not.toBeNull();
94
+ expect(footerContainer?.querySelector(SELECTOR.HIDE_LINK)).toBeNull();
95
+ });
96
+ it('U-4: FeaturedIcon이 md 사이즈(40px 원 / 20px 아이콘)로 렌더된다', () => {
97
+ // Given/When: error 색상으로 렌더하면
98
+ const container = renderMessageNotification({ color: 'error' });
99
+ // Then: featured-icon은 --md 수식자를 가지며 내부 SVG는 20px다
100
+ const icon = container.querySelector(SELECTOR.ICON);
101
+ expect(icon).not.toBeNull();
102
+ expect(icon?.classList.contains('ncua-featured-icon--md')).toBe(true);
103
+ expect(icon?.classList.contains('ncua-featured-icon--lg')).toBe(false);
104
+ expect(icon?.querySelector('svg')?.getAttribute('width')).toBe('20');
105
+ });
106
+ it('hide-link는 구현별 수식자(--link/--text) 없이 공통 클래스 2개만 갖는다', () => {
107
+ // Given/When: 다시 보지 않기 핸들러를 지정해 렌더하면
108
+ const container = renderMessageNotification({ onHidePermanently: vi.fn() });
109
+ // Then: SCSS 미정의 수식자 --link에 의존하지 않는다
110
+ const hideLink = container.querySelector(SELECTOR.HIDE_LINK);
111
+ expect(hideLink?.className.split(' ').filter(Boolean).sort()).toEqual([
112
+ 'ncua-message-notification__hide-link',
113
+ 'ncua-notification__action-button',
114
+ ]);
115
+ });
116
+ });
117
+ describe('MessageNotification (React) — 공개 API 회귀 방지', () => {
118
+ it('지원하지 않는 color("info")는 neutral로 폴백한다', () => {
119
+ // Given/When: message 타입이 지원하지 않는 info 색상을 넘기면
120
+ const container = renderMessageNotification({ color: 'info' });
121
+ // Then: neutral 수식자로 렌더되고 info 수식자는 없다
122
+ const root = container.querySelector(SELECTOR.ROOT);
123
+ expect(root?.classList.contains('ncua-message-notification--neutral')).toBe(true);
124
+ expect(root?.classList.contains('ncua-message-notification--info')).toBe(false);
125
+ });
126
+ it('닫기/다시 보지 않기 클릭 시 핸들러가 호출되고 접근성 속성이 유지된다', () => {
127
+ // Given: 핸들러를 지정해 렌더하고
128
+ const onClose = vi.fn();
129
+ const onHidePermanently = vi.fn();
130
+ const container = renderMessageNotification({ onClose, onHidePermanently });
131
+ // When: 각 버튼을 클릭하면
132
+ act(() => {
133
+ container.querySelector(SELECTOR.CLOSE_BUTTON)?.click();
134
+ container.querySelector(SELECTOR.HIDE_LINK)?.click();
135
+ });
136
+ // Then: 핸들러가 각각 1회 호출되고 role/aria-label이 유지된다
137
+ expect(onClose).toHaveBeenCalledTimes(1);
138
+ expect(onHidePermanently).toHaveBeenCalledTimes(1);
139
+ expect(container.querySelector(SELECTOR.ROOT)?.getAttribute('role')).toBe('alert');
140
+ expect(container.querySelector(SELECTOR.CLOSE_BUTTON)?.getAttribute('aria-label')).toBe('알림 닫기');
141
+ });
142
+ });
143
+ describe('MessageNotification (React) — 액션 버튼 색상(hierarchy)', () => {
144
+ const SIZE_MODIFIER = /^ncua-btn--(xs|sm|md|lg|xl)$/;
145
+ /** 사이즈를 제외한 `ncua-btn--*` 수식자 = 색상(hierarchy) 수식자. */
146
+ function buttonHierarchyModifiers(root) {
147
+ return Array.from(root.querySelectorAll('.ncua-btn')).map((button) => Array.from(button.classList).find((name) => name.startsWith('ncua-btn--') && !SIZE_MODIFIER.test(name)) ?? '');
148
+ }
149
+ it('Figma TO-BE 조합(secondary-gray / secondary)을 ncua-btn 수식자로 반영한다', () => {
150
+ // Given/When: 조회하기 secondary-gray, 포인트 충전 secondary로 렌더하면
151
+ const container = renderMessageNotification({
152
+ actions: [
153
+ { label: '조회하기', hierarchy: 'secondary-gray', onClick: vi.fn() },
154
+ { label: '포인트 충전', hierarchy: 'secondary', onClick: vi.fn() },
155
+ ],
156
+ });
157
+ // Then: ButtonTheme와 동일한 수식자가 순서대로 붙는다
158
+ expect(buttonHierarchyModifiers(container)).toEqual(['ncua-btn--secondary-gray', 'ncua-btn--secondary']);
159
+ });
160
+ it('text / text-gray도 계속 지원한다', () => {
161
+ // Given/When: 기존 어휘로 렌더하면
162
+ const container = renderMessageNotification({
163
+ actions: [
164
+ { label: '조회하기', hierarchy: 'text', onClick: vi.fn() },
165
+ { label: '포인트 충전', hierarchy: 'text-gray', onClick: vi.fn() },
166
+ ],
167
+ });
168
+ // Then: 기존 수식자가 그대로 유지된다 (회귀 방지)
169
+ expect(buttonHierarchyModifiers(container)).toEqual(['ncua-btn--text', 'ncua-btn--text-gray']);
170
+ });
171
+ it('hierarchy를 생략하면 기본값 text로 렌더한다', () => {
172
+ // Given/When: 색상을 지정하지 않은 액션을 렌더하면
173
+ const container = renderMessageNotification({ actions: [{ label: '확인', onClick: vi.fn() }] });
174
+ // Then: Button의 기본값(secondary-gray)이 아니라 알림의 기본값 text가 적용된다
175
+ expect(buttonHierarchyModifiers(container)).toEqual(['ncua-btn--text']);
176
+ });
177
+ it('액션 버튼 클릭 시 onClick이 호출된다', () => {
178
+ // Given: secondary 색상 액션을 렌더하고
179
+ const onClick = vi.fn();
180
+ const container = renderMessageNotification({
181
+ actions: [{ label: '포인트 충전', hierarchy: 'secondary', onClick }],
182
+ });
183
+ // When: 버튼을 클릭하면
184
+ act(() => {
185
+ container.querySelector('.ncua-btn')?.click();
186
+ });
187
+ // Then: 핸들러가 1회 호출된다
188
+ expect(onClick).toHaveBeenCalledTimes(1);
189
+ });
190
+ });
@@ -0,0 +1,160 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { FullWidthNotification } from '../FullWidthNotification';
7
+ import { MessageNotification } from '../MessageNotification';
8
+ import { Notification } from '../Notification';
9
+ // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
10
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
11
+ const SELECTOR = {
12
+ /** message 타입 — 고도몰이 textContent 덮어쓰기에 쓰는 하위호환 계약 선택자. */
13
+ MESSAGE_HIDE_LINK: '.ncua-message-notification__hide-link',
14
+ /** full-width 타입 — 다시보지 않기 버튼. */
15
+ FULL_WIDTH_LINK: '.ncua-full-width-notification__link',
16
+ ACTION_BUTTON: '.ncua-notification__action-button',
17
+ };
18
+ /** 각 타입의 현행 기본 문구. 표기 통일은 별도 결정 사항이므로 서로 다른 값을 그대로 유지한다. */
19
+ const DEFAULT_LABEL = {
20
+ MESSAGE: '다시 보지 않기',
21
+ FULL_WIDTH: '다시보지 않기',
22
+ };
23
+ const mounted = [];
24
+ function render(element) {
25
+ const container = document.createElement('div');
26
+ document.body.appendChild(container);
27
+ const root = createRoot(container);
28
+ act(() => {
29
+ root.render(element);
30
+ });
31
+ mounted.push({ root, container });
32
+ return container;
33
+ }
34
+ function renderMessage(props = {}) {
35
+ return render(createElement(MessageNotification, { title: '알림 제목', ...props }));
36
+ }
37
+ function renderFullWidth(props = {}) {
38
+ return render(createElement(FullWidthNotification, { title: '알림 제목', ...props }));
39
+ }
40
+ function renderNotification(props = {}) {
41
+ return render(createElement(Notification, { title: '알림 제목', ...props }));
42
+ }
43
+ afterEach(() => {
44
+ for (const { root, container } of mounted.splice(0)) {
45
+ act(() => {
46
+ root.unmount();
47
+ });
48
+ container.remove();
49
+ }
50
+ });
51
+ describe('hidePermanentlyLabel (React) — message 타입', () => {
52
+ it('R-1: prop 미지정 시 기본 문구 "다시 보지 않기"가 렌더된다', () => {
53
+ // Given: hidePermanentlyLabel 없이 onHidePermanently만 주어지고
54
+ // When: MessageNotification을 렌더하면
55
+ const container = renderMessage({ onHidePermanently: vi.fn() });
56
+ // Then: 현행 기본 문구가 그대로 노출된다 (시각적 변화 0)
57
+ const hideLink = container.querySelector(SELECTOR.MESSAGE_HIDE_LINK);
58
+ expect(hideLink?.textContent).toBe(DEFAULT_LABEL.MESSAGE);
59
+ });
60
+ it('R-2: prop 지정 시 그 값이 렌더된다', () => {
61
+ // Given: 커스텀 라벨이 주어지고
62
+ // When: 렌더하면
63
+ const container = renderMessage({ onHidePermanently: vi.fn(), hidePermanentlyLabel: '30일간 보지 않기' });
64
+ // Then: 하드코딩 문구 대신 전달한 값이 노출된다
65
+ const hideLink = container.querySelector(SELECTOR.MESSAGE_HIDE_LINK);
66
+ expect(hideLink?.textContent).toBe('30일간 보지 않기');
67
+ });
68
+ it('R-3: ReactNode 라벨을 받아 엘리먼트로 렌더한다', () => {
69
+ // Given: 문자열이 아닌 ReactNode 라벨이 주어지고
70
+ const label = createElement('span', { className: 'custom-hide-label' }, '커스텀 노드');
71
+ // When: 렌더하면
72
+ const container = renderMessage({ onHidePermanently: vi.fn(), hidePermanentlyLabel: label });
73
+ // Then: 노드가 hide-link 내부에 그대로 마운트된다
74
+ const hideLink = container.querySelector(SELECTOR.MESSAGE_HIDE_LINK);
75
+ expect(hideLink?.querySelector('.custom-hide-label')?.textContent).toBe('커스텀 노드');
76
+ });
77
+ it('R-4: 라벨을 바꿔도 __hide-link 클래스 계약이 유지된다', () => {
78
+ // Given: 커스텀 라벨이 주어지고
79
+ // When: 렌더하면
80
+ const container = renderMessage({ onHidePermanently: vi.fn(), hidePermanentlyLabel: '커스텀' });
81
+ // Then: 고도몰의 textContent 덮어쓰기가 계속 동작하도록 기존 클래스가 모두 남는다
82
+ const hideLink = container.querySelector(SELECTOR.MESSAGE_HIDE_LINK);
83
+ expect(hideLink).not.toBeNull();
84
+ expect(hideLink?.classList.contains('ncua-notification__action-button')).toBe(true);
85
+ expect(hideLink?.classList.contains('ncua-message-notification__hide-link')).toBe(true);
86
+ });
87
+ it('R-5: onHidePermanently 없이 라벨만 주면 버튼이 렌더되지 않는다', () => {
88
+ // Given: 핸들러 없이 라벨만 주어지고
89
+ // When: 렌더하면
90
+ const container = renderMessage({ hidePermanentlyLabel: '커스텀' });
91
+ // Then: 라벨만으로는 버튼이 생기지 않는다 (핸들러가 렌더 조건)
92
+ expect(container.querySelector(SELECTOR.MESSAGE_HIDE_LINK)).toBeNull();
93
+ });
94
+ it('R-6: 라벨이 DOM 속성으로 새어나가지 않는다', () => {
95
+ // Given: 커스텀 라벨이 주어지고
96
+ // When: 렌더하면
97
+ const container = renderMessage({ onHidePermanently: vi.fn(), hidePermanentlyLabel: '커스텀' });
98
+ // Then: rest 스프레드를 타고 루트 div의 속성으로 흘러들지 않는다
99
+ const root = container.querySelector('.ncua-message-notification');
100
+ expect(root?.hasAttribute('hidepermanentlylabel')).toBe(false);
101
+ });
102
+ });
103
+ describe('hidePermanentlyLabel (React) — full-width 타입', () => {
104
+ it('R-7: prop 미지정 시 기본 문구 "다시보지 않기"가 렌더된다', () => {
105
+ // Given: hidePermanentlyLabel 없이 onHidePermanently만 주어지고
106
+ // When: FullWidthNotification을 렌더하면
107
+ const container = renderFullWidth({ onHidePermanently: vi.fn() });
108
+ // Then: message 타입과 다른 현행 문구가 그대로 유지된다 (표기 통일은 별도 결정)
109
+ const hideButton = container.querySelector(SELECTOR.FULL_WIDTH_LINK);
110
+ expect(hideButton?.textContent).toBe(DEFAULT_LABEL.FULL_WIDTH);
111
+ });
112
+ it('R-8: prop 지정 시 그 값이 렌더된다', () => {
113
+ // Given: 커스텀 라벨이 주어지고
114
+ // When: 렌더하면
115
+ const container = renderFullWidth({ onHidePermanently: vi.fn(), hidePermanentlyLabel: '오늘 하루 보지 않기' });
116
+ // Then: 전달한 값이 노출된다
117
+ const hideButton = container.querySelector(SELECTOR.FULL_WIDTH_LINK);
118
+ expect(hideButton?.textContent).toBe('오늘 하루 보지 않기');
119
+ });
120
+ it('R-9: 라벨을 바꿔도 기존 클래스 계약이 유지된다', () => {
121
+ // Given: 커스텀 라벨이 주어지고
122
+ // When: 렌더하면
123
+ const container = renderFullWidth({ onHidePermanently: vi.fn(), hidePermanentlyLabel: '커스텀' });
124
+ // Then: 액션 버튼 클래스가 그대로 남는다
125
+ const hideButton = container.querySelector(SELECTOR.FULL_WIDTH_LINK);
126
+ expect(hideButton?.classList.contains('ncua-notification__action-button')).toBe(true);
127
+ expect(hideButton?.classList.contains('ncua-notification__action-button--text')).toBe(true);
128
+ });
129
+ it('R-10: onHidePermanently 없이 라벨만 주면 버튼이 렌더되지 않는다', () => {
130
+ // Given: 핸들러 없이 라벨만 주어지고
131
+ // When: 렌더하면
132
+ const container = renderFullWidth({ hidePermanentlyLabel: '커스텀' });
133
+ // Then: 액션 버튼이 생기지 않는다
134
+ expect(container.querySelector(SELECTOR.ACTION_BUTTON)).toBeNull();
135
+ });
136
+ });
137
+ describe('hidePermanentlyLabel (React) — Notification 공유 Props 경유', () => {
138
+ it('R-11: type="message"에서 공유 Props의 라벨이 전달된다', () => {
139
+ // Given: 공유 Notification props로 라벨이 주어지고
140
+ // When: type="message"로 렌더하면
141
+ const container = renderNotification({
142
+ type: 'message',
143
+ onHidePermanently: vi.fn(),
144
+ hidePermanentlyLabel: '공유 프롭 라벨',
145
+ });
146
+ // Then: message 구현까지 라벨이 도달한다
147
+ expect(container.querySelector(SELECTOR.MESSAGE_HIDE_LINK)?.textContent).toBe('공유 프롭 라벨');
148
+ });
149
+ it('R-12: type="full-width"에서 공유 Props의 라벨이 전달된다', () => {
150
+ // Given: 공유 Notification props로 라벨이 주어지고
151
+ // When: type="full-width"로 렌더하면
152
+ const container = renderNotification({
153
+ type: 'full-width',
154
+ onHidePermanently: vi.fn(),
155
+ hidePermanentlyLabel: '공유 프롭 라벨',
156
+ });
157
+ // Then: full-width 구현까지 라벨이 도달한다
158
+ expect(container.querySelector(SELECTOR.FULL_WIDTH_LINK)?.textContent).toBe('공유 프롭 라벨');
159
+ });
160
+ });
@@ -0,0 +1,45 @@
1
+ import { type PostcodeSearchModalTipProps } from './PostcodeSearchModalTip';
2
+ /** 검색 결과 아이템 하나. */
3
+ interface PostcodeSearchResult {
4
+ /** 우편번호(5자리) */
5
+ zipCode: string;
6
+ /** 도로명주소(도로명 + 건물번호 + 참고항목 등) */
7
+ roadAddress: string;
8
+ /** 지번주소 */
9
+ jibunAddress: string;
10
+ }
11
+ /**
12
+ * 컴포넌트가 표시할 수 있는 에러코드(행정안전부 도로명주소 API 기준).
13
+ * 기획서에 직접 적힌 코드는 E0006·E0008·E0009·E0012·E0013 이고,
14
+ * E0010·E0011 은 기획서의 "E0009~E0013 통합" 범위 표기에서 채운 값이다.
15
+ */
16
+ type PostcodeSearchErrorCode = 'E0006' | 'E0008' | 'E0009' | 'E0010' | 'E0011' | 'E0012' | 'E0013';
17
+ interface PostcodeSearchModalProps {
18
+ open: boolean;
19
+ onClose: () => void;
20
+ zIndex?: number;
21
+ className?: string;
22
+ searchQuery: string;
23
+ onSearchQueryChange: (value: string) => void;
24
+ onSearch: () => void;
25
+ results: PostcodeSearchResult[];
26
+ totalCount: number;
27
+ currentPage: number;
28
+ onPageChange: (page: number) => void;
29
+ /** 페이지당 아이템 수. 기획서 기본값 15 */
30
+ pageSize?: number;
31
+ /** 검색 API 응답을 기다리는 중인지 여부. 응답 전에 "결과 없음" 이 먼저 뜨는 것을 막는다. */
32
+ loading?: boolean;
33
+ errorCode?: PostcodeSearchErrorCode;
34
+ onSelect: (result: PostcodeSearchResult) => void;
35
+ }
36
+ declare const PostcodeSearchModal: {
37
+ ({ open, onClose, zIndex, className, searchQuery, onSearchQueryChange, onSearch, results, totalCount, currentPage, onPageChange, pageSize, loading, errorCode, onSelect, }: PostcodeSearchModalProps): import("react/jsx-runtime").JSX.Element;
38
+ displayName: string;
39
+ Tip: {
40
+ ({ className }: PostcodeSearchModalTipProps): import("react/jsx-runtime").JSX.Element;
41
+ displayName: string;
42
+ };
43
+ };
44
+ export { PostcodeSearchModal };
45
+ export type { PostcodeSearchErrorCode, PostcodeSearchModalProps, PostcodeSearchModalTipProps, PostcodeSearchResult };
@@ -0,0 +1,125 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import classnames from 'classnames';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { Button } from '../../action/button';
5
+ import { EmptyState } from '../../feedback-and-status/empty-state';
6
+ import { InputBase } from '../../forms-and-input/input-base';
7
+ import { Radio } from '../../forms-and-input/radio';
8
+ import { Pagination } from '../../navigation/pagination';
9
+ import { Modal } from '../modal';
10
+ import { Tip } from './PostcodeSearchModalTip';
11
+ /** 기획서 기준값 */
12
+ const PAGE_SIZE = 15;
13
+ /** 페이지 번호 그룹 크기 — 초과 시 Pagination 이 점프 버튼을 노출한다 */
14
+ const PAGER_WINDOW = 10;
15
+ /** 반응형 전환 기준 — assets/styles/base/_variable.scss 의 $breakpoint 와 동일 */
16
+ const MOBILE_BREAKPOINT = 768;
17
+ /** 본문 스크롤 컨테이너. Modal.Content 가 overflow-y:auto 를 갖는다. */
18
+ const SCROLL_CONTAINER_SELECTOR = '.ncua-modal__content';
19
+ const MODAL_TITLE = '주소 찾기';
20
+ const SEARCH_PLACEHOLDER = '도로명, 지번, 건물명 검색';
21
+ const SEARCH_BUTTON_LABEL = '검색';
22
+ const EMPTY_TITLE = '검색 결과가 없습니다.';
23
+ const EMPTY_DESCRIPTION = '검색어를 다시 확인해 주세요.';
24
+ const ROAD_BADGE_LABEL = '도로명';
25
+ const JIBUN_BADGE_LABEL = '지번';
26
+ // 라디오 그룹 이름. `ncua-` 로 시작하면 MCP 의 BEM 추출기가 CSS 클래스로 오인하므로 접두사를 뺀다.
27
+ const RESULT_RADIO_NAME = 'postcode-search-modal-result';
28
+ /** E0006·E0008 외 나머지 코드는 모두 같은 문구를 쓴다(기획서: E0009~E0013 통합). */
29
+ const DEFAULT_ERROR_HINT = '검색어를 다시 입력해주세요.';
30
+ const ERROR_HINT = {
31
+ E0006: '시도명으로는 검색이 불가합니다.',
32
+ E0008: '검색어를 두 글자 이상 입력해주세요.',
33
+ };
34
+ /** 선택 표시를 판정하는 키. 우편번호만으로는 같은 건물의 다른 지번을 구분하지 못한다. */
35
+ const resultKey = ({ zipCode, roadAddress, jibunAddress }) => `${zipCode}|${roadAddress}|${jibunAddress}`;
36
+ /** SSR(도큐사우루스 등)에서는 초기값을 false(=PC)로 두고 mount 이후 갱신한다. */
37
+ const useIsMobileViewport = () => {
38
+ const [isMobile, setIsMobile] = useState(false);
39
+ useEffect(() => {
40
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
41
+ return;
42
+ const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
43
+ const sync = () => setIsMobile(mediaQuery.matches);
44
+ sync();
45
+ mediaQuery.addEventListener('change', sync);
46
+ return () => {
47
+ mediaQuery.removeEventListener('change', sync);
48
+ };
49
+ }, []);
50
+ return isMobile;
51
+ };
52
+ /**
53
+ * 본문이 위로 스크롤됐는지 여부.
54
+ * 고정된 검색 영역과 스크롤되는 결과 사이의 경계선을 스크롤 중에만 그리기 위해 쓴다.
55
+ * enabled 가 false 면 감시를 걸지 않고 항상 false 를 돌려준다.
56
+ */
57
+ const useIsContentScrolled = (rootRef, enabled) => {
58
+ const [isScrolled, setIsScrolled] = useState(false);
59
+ useEffect(() => {
60
+ if (!enabled) {
61
+ setIsScrolled(false);
62
+ return;
63
+ }
64
+ const scroller = rootRef.current?.closest(SCROLL_CONTAINER_SELECTOR);
65
+ if (!scroller)
66
+ return;
67
+ const sync = () => setIsScrolled(scroller.scrollTop > 0);
68
+ // 결과가 바뀌어도 스크롤 위치는 남을 수 있어 구독 시점의 값으로 먼저 맞춘다.
69
+ sync();
70
+ scroller.addEventListener('scroll', sync, { passive: true });
71
+ return () => {
72
+ scroller.removeEventListener('scroll', sync);
73
+ };
74
+ }, [rootRef, enabled]);
75
+ return isScrolled;
76
+ };
77
+ const PostcodeSearchModal = ({ open, onClose, zIndex, className, searchQuery, onSearchQueryChange, onSearch, results, totalCount, currentPage, onPageChange, pageSize = PAGE_SIZE, loading = false, errorCode, onSelect, }) => {
78
+ const isMobile = useIsMobileViewport();
79
+ const rootRef = useRef(null);
80
+ // 선택 표시는 컴포넌트가 직접 들고 있는다. 사용처는 onSelect 로 값만 받으면 된다.
81
+ // 우편번호는 한 페이지에 중복될 수 있어 주소 전체를 키로 쓴다.
82
+ const [selectedKey, setSelectedKey] = useState();
83
+ // 검색이 실행된 검색어. 입력 중에는 갱신되지 않는다.
84
+ // 초기값은 마운트 시점의 searchQuery — 사용처가 검색어를 채운 채로 열면 그 검색은 이미 실행된 것으로 본다.
85
+ const [searchedQuery, setSearchedQuery] = useState(searchQuery);
86
+ // Modal 은 닫혀도 이 컴포넌트를 언마운트하지 않아 내부 상태가 다음 열기까지 남는다.
87
+ // 열리는 순간 이전 세션의 선택·검색 상태를 지운다.
88
+ // biome-ignore lint/correctness/useExhaustiveDependencies: searchQuery 를 deps 에 넣으면 입력할 때마다 초기화된다
89
+ useEffect(() => {
90
+ if (!open)
91
+ return;
92
+ setSelectedKey(undefined);
93
+ setSearchedQuery(searchQuery);
94
+ }, [open]);
95
+ const hasError = Boolean(errorCode);
96
+ const hasResults = !hasError && results.length > 0;
97
+ // 검색을 실행한 검색어와 현재 입력값이 같을 때만 "결과 없음"으로 본다.
98
+ // 입력 중에는 두 값이 달라지고, 응답을 기다리는 동안에는 loading 이 막는다.
99
+ const showEmptyState = !hasError && !loading && results.length === 0 && searchQuery !== '' && searchQuery === searchedQuery;
100
+ // 결과 목록이 있을 때만 스크롤이 생기므로 그때만 감시한다.
101
+ const isContentScrolled = useIsContentScrolled(rootRef, open && hasResults);
102
+ const handleSearchQueryChange = (event) => {
103
+ onSearchQueryChange(event.target.value);
104
+ };
105
+ const handleSearch = () => {
106
+ setSearchedQuery(searchQuery);
107
+ onSearch();
108
+ };
109
+ const handleSearchKeyDown = (event) => {
110
+ // 한글 조합 중의 Enter 는 글자 확정이므로 검색으로 보지 않는다.
111
+ if (event.key !== 'Enter' || event.nativeEvent.isComposing)
112
+ return;
113
+ event.preventDefault();
114
+ handleSearch();
115
+ };
116
+ 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
+ '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: "\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
+ setSelectedKey(resultKey(result));
120
+ onSelect(result);
121
+ }, 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 }) }))] }) })] }));
122
+ };
123
+ PostcodeSearchModal.displayName = 'PostcodeSearchModal';
124
+ PostcodeSearchModal.Tip = Tip;
125
+ export { PostcodeSearchModal };
@@ -0,0 +1,16 @@
1
+ interface PostcodeSearchModalTipProps {
2
+ className?: string;
3
+ }
4
+ /** Tip 카드 첫 줄 안내 문구 (기획서 TIPS.searchTitle) */
5
+ declare const TIP_TITLE = "\uC544\uB798 \uC870\uD569\uC73C\uB85C \uAC80\uC0C9\uD558\uBA74 \uC6D0\uD558\uB294 \uC8FC\uC18C\uB97C \uB354 \uC27D\uAC8C \uCC3E\uC744 \uC218 \uC788\uC5B4\uC694.";
6
+ /**
7
+ * 검색어 조합을 안내하는 Tip 카드.
8
+ * 결과 목록이 없는 화면(초기·오류·결과 없음)에서만 노출된다.
9
+ * 결과가 있을 때는 모달이 안내 문구 한 줄만 검색 영역에 붙여 고정한다.
10
+ */
11
+ declare const Tip: {
12
+ ({ className }: PostcodeSearchModalTipProps): import("react/jsx-runtime").JSX.Element;
13
+ displayName: string;
14
+ };
15
+ export { Tip, TIP_TITLE };
16
+ export type { PostcodeSearchModalTipProps };
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AnnotationInfoFill } from '@ncds/ui-admin-icon';
3
+ import classnames from 'classnames';
4
+ const TIP_LABEL = 'Tip';
5
+ /** Tip 카드 첫 줄 안내 문구 (기획서 TIPS.searchTitle) */
6
+ const TIP_TITLE = '아래 조합으로 검색하면 원하는 주소를 더 쉽게 찾을 수 있어요.';
7
+ /** Tip 에 노출할 검색어 조합과 예시 주소 (기획서 TIPS.searchTips) */
8
+ const TIP_ITEMS = [
9
+ { combination: '도로명 + 건물번호', example: '디지털로26길 43' },
10
+ { combination: '지역명(동·리) + 번지', example: '구로동 235-3' },
11
+ { combination: '지역명(동·리) + 건물명(아파트명)', example: '구로동 대륭포스트타워8차' },
12
+ ];
13
+ const TIP_EXAMPLE_PREFIX = '예)';
14
+ const TIP_ICON_SIZE = 16;
15
+ /**
16
+ * 검색어 조합을 안내하는 Tip 카드.
17
+ * 결과 목록이 없는 화면(초기·오류·결과 없음)에서만 노출된다.
18
+ * 결과가 있을 때는 모달이 안내 문구 한 줄만 검색 영역에 붙여 고정한다.
19
+ */
20
+ const Tip = ({ className }) => {
21
+ return (_jsxs("div", { className: classnames('ncua-postcode-search-modal__tip', className), children: [_jsxs("p", { className: "ncua-postcode-search-modal__tip-label", children: [_jsx(AnnotationInfoFill, { className: "ncua-postcode-search-modal__tip-icon", width: TIP_ICON_SIZE, height: TIP_ICON_SIZE }), TIP_LABEL] }), _jsx("p", { className: "ncua-postcode-search-modal__tip-title", children: TIP_TITLE }), _jsx("ul", { className: "ncua-postcode-search-modal__tip-list", children: TIP_ITEMS.map(({ combination, example }) => (_jsxs("li", { className: "ncua-postcode-search-modal__tip-item", children: [_jsx("span", { children: combination }), _jsx("span", { children: `${TIP_EXAMPLE_PREFIX} ${example}` })] }, combination))) })] }));
22
+ };
23
+ Tip.displayName = 'PostcodeSearchModal.Tip';
24
+ export { Tip, TIP_TITLE };