@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
@@ -4,6 +4,7 @@ import { createElement } from 'react';
4
4
  import { createRoot } from 'react-dom/client';
5
5
  import { act } from 'react-dom/test-utils';
6
6
  import { describe, expect, it, vi } from 'vitest';
7
+ import { COLOR } from '../../../../../constant/color';
7
8
  import { InputBase } from '../InputBase';
8
9
  // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
9
10
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -107,6 +108,42 @@ describe('InputBase — 아이콘 사이즈', () => {
107
108
  view.unmount();
108
109
  });
109
110
  });
111
+ describe('InputBase — 슬롯 아이콘 상태 색상', () => {
112
+ // 상태별 색은 CSS 가 결정한다. jsdom 에서는 스타일시트를 로드하지 않으므로
113
+ // "상태 규칙을 타는 클래스가 붙었는지" 와 "지정 색이 인라인으로 남았는지" 로 검증한다.
114
+ const leftIconOf = (container) => container.querySelector('.ncua-input__left-icon');
115
+ it('color 를 지정하지 않으면 상태 색상 클래스가 붙는다', () => {
116
+ // Given/When: color 없이 슬롯 아이콘을 렌더하면
117
+ const view = mountInputBase({ leadingElement: { type: 'icon', icon: SearchLg } });
118
+ // Then: 상태 규칙을 타는 클래스가 붙고, 인라인 색은 지정되지 않는다
119
+ const icon = leftIconOf(view.container);
120
+ expect(icon?.classList.contains('ncua-input__slot-icon')).toBe(true);
121
+ expect(icon?.hasAttribute('color')).toBe(false);
122
+ view.unmount();
123
+ });
124
+ it('color 를 지정하면 상태 색상 클래스가 붙지 않고 지정 색이 유지된다', () => {
125
+ // Given/When: color 를 지정해 렌더하면
126
+ const view = mountInputBase({
127
+ leadingElement: { type: 'icon', icon: SearchLg, color: 'blue500' },
128
+ });
129
+ // Then: 상태 규칙에서 제외되고 지정한 색이 그대로 남는다
130
+ const icon = leftIconOf(view.container);
131
+ expect(icon?.classList.contains('ncua-input__slot-icon')).toBe(false);
132
+ expect(icon?.getAttribute('color')).toBe(COLOR.blue500);
133
+ view.unmount();
134
+ });
135
+ it('slot.className 을 함께 주어도 상태 색상 클래스가 유지된다', () => {
136
+ // Given/When: className 을 추가로 넘기면
137
+ const view = mountInputBase({
138
+ leadingElement: { type: 'icon', icon: SearchLg, className: 'my-icon' },
139
+ });
140
+ // Then: 두 클래스가 모두 붙는다
141
+ const icon = leftIconOf(view.container);
142
+ expect(icon?.classList.contains('ncua-input__slot-icon')).toBe(true);
143
+ expect(icon?.classList.contains('my-icon')).toBe(true);
144
+ view.unmount();
145
+ });
146
+ });
110
147
  describe('InputBase — has-value 상태 클래스', () => {
111
148
  const typeInto = (container, text) => {
112
149
  const input = container.querySelector('input');
@@ -152,3 +189,45 @@ describe('InputBase — has-value 상태 클래스', () => {
152
189
  view.unmount();
153
190
  });
154
191
  });
192
+ describe('InputBase — 텍스트 슬롯', () => {
193
+ it('leadingElement 텍스트는 leading 클래스와 wrap 클래스가 붙는다', () => {
194
+ const { container, unmount } = mountInputBase({
195
+ leadingElement: { type: 'text', text: 'https://' },
196
+ });
197
+ const text = container.querySelector('.ncua-input__leading-text');
198
+ expect(text?.textContent).toBe('https://');
199
+ expect(container.querySelector('.ncua-input__leading-text-wrap')).not.toBeNull();
200
+ unmount();
201
+ });
202
+ it('trailingElement 텍스트는 trailing 클래스와 wrap 클래스가 붙는다', () => {
203
+ const { container, unmount } = mountInputBase({
204
+ trailingElement: { type: 'text', text: 'px' },
205
+ });
206
+ const text = container.querySelector('.ncua-input__trailing-text');
207
+ expect(text?.textContent).toBe('px');
208
+ expect(container.querySelector('.ncua-input__trailing-text-wrap')).not.toBeNull();
209
+ // leading 클래스가 섞이면 모서리·테두리 방향이 반대가 된다
210
+ expect(container.querySelector('.ncua-input__leading-text')).toBeNull();
211
+ unmount();
212
+ });
213
+ it('trailingElement 텍스트는 입력 필드 뒤에 온다', () => {
214
+ const { container, unmount } = mountInputBase({
215
+ trailingElement: { type: 'text', text: 'px' },
216
+ });
217
+ const content = container.querySelector('.ncua-input__content');
218
+ const children = Array.from(content?.children ?? []);
219
+ const fieldIndex = children.findIndex((el) => el.classList.contains('ncua-input__field'));
220
+ const textIndex = children.findIndex((el) => el.classList.contains('ncua-input__trailing-text'));
221
+ expect(fieldIndex).toBeGreaterThanOrEqual(0);
222
+ expect(textIndex).toBeGreaterThan(fieldIndex);
223
+ unmount();
224
+ });
225
+ it('사이즈 modifier 가 함께 붙는다', () => {
226
+ const { container, unmount } = mountInputBase({
227
+ size: 'sm',
228
+ trailingElement: { type: 'text', text: 'px' },
229
+ });
230
+ expect(container.querySelector('.ncua-input__trailing-text--sm')).not.toBeNull();
231
+ unmount();
232
+ });
233
+ });
@@ -19,8 +19,8 @@ export const PasswordInput = forwardRef(({ size = 'xs', ...props }, ref) => {
19
19
  return (_jsx(InputBase, { ref: ref, size: size, type: isVisible ? 'text' : 'password', leadingElement: {
20
20
  type: 'icon',
21
21
  icon: Lock01,
22
- // 색은 CSS가 결정한다 여기서 props.value로 판정하면 uncontrolled Input에서 영원히 빈 값 취급된다
23
- className: 'ncua-input__password-lock-icon',
22
+ // color 넘기지 않아 슬롯 아이콘 공통 규칙(상태별 색)을 그대로 따른다.
23
+ // 여기서 props.value 로 판정하면 uncontrolled Input 에서 영원히 빈 값 취급된다.
24
24
  }, trailingElement: {
25
25
  type: 'custom',
26
26
  placement: 'inside',
@@ -0,0 +1,48 @@
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 { describe, expect, it, vi } from 'vitest';
6
+ import { PasswordInput } from '../PasswordInput';
7
+ // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
8
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
+ function mountPasswordInput(props = {}) {
10
+ const container = document.createElement('div');
11
+ document.body.appendChild(container);
12
+ const root = createRoot(container);
13
+ act(() => {
14
+ root.render(createElement(PasswordInput, props));
15
+ });
16
+ const unmount = () => {
17
+ act(() => {
18
+ root.unmount();
19
+ });
20
+ container.remove();
21
+ };
22
+ return { container, unmount };
23
+ }
24
+ describe('PasswordInput — 자물쇠 아이콘 색상', () => {
25
+ // 자물쇠 색은 CSS 가 결정한다. 인라인 color 를 넘기면 상태 규칙이 무시되므로,
26
+ // "인라인 색이 없고 슬롯 아이콘 공통 클래스를 탄다" 는 것이 색상 반영의 전제다.
27
+ it('자물쇠에 인라인 색을 넘기지 않고 슬롯 아이콘 공통 클래스를 탄다', () => {
28
+ // Given/When: 기본 상태로 렌더하면
29
+ const view = mountPasswordInput();
30
+ // Then: 상태 규칙을 타는 클래스가 붙고 인라인 색은 없다
31
+ const lockIcon = view.container.querySelector('.ncua-input__left-icon');
32
+ expect(lockIcon).not.toBeNull();
33
+ expect(lockIcon?.classList.contains('ncua-input__slot-icon')).toBe(true);
34
+ expect(lockIcon?.hasAttribute('color')).toBe(false);
35
+ view.unmount();
36
+ });
37
+ // 값이 있는 채로 비활성화되면 has-value 가 살아 있어 자물쇠가 진하게 남던 문제
38
+ it('값이 있는 채로 disabled 여도 자물쇠가 상태 규칙 대상으로 남는다', () => {
39
+ // Given/When: 값이 있는 비활성화 상태로 렌더하면
40
+ const view = mountPasswordInput({ value: 'secret', disabled: true, onChange: vi.fn() });
41
+ // Then: 루트에 is-disabled 와 has-value 가 함께 붙고, 자물쇠는 공통 클래스를 유지한다
42
+ const root = view.container.querySelector('.ncua-input');
43
+ expect(root?.classList.contains('is-disabled')).toBe(true);
44
+ expect(root?.classList.contains('has-value')).toBe(true);
45
+ expect(view.container.querySelector('.ncua-input__left-icon')?.classList.contains('ncua-input__slot-icon')).toBe(true);
46
+ view.unmount();
47
+ });
48
+ });
@@ -44,6 +44,7 @@ export * from './navigation/vertical-tab';
44
44
  export * from './overlays/dropdown';
45
45
  export * from './overlays/modal';
46
46
  export * from './overlays/notification';
47
+ export * from './overlays/postcode-search-modal';
47
48
  export * from './overlays/tooltip';
48
49
  export * from './select-dropdown';
49
50
  export * from './shared';
@@ -51,6 +51,7 @@ export * from './navigation/vertical-tab';
51
51
  export * from './overlays/dropdown';
52
52
  export * from './overlays/modal';
53
53
  export * from './overlays/notification';
54
+ export * from './overlays/postcode-search-modal';
54
55
  export * from './overlays/tooltip';
55
56
  // Shared
56
57
  export * from './select-dropdown';
@@ -13,6 +13,10 @@ const DECIMAL_RADIX = 10;
13
13
  const SUPPORTS_STYLE_HOISTING = Number.parseInt(reactVersion, DECIMAL_RADIX) >= STYLE_HOISTING_MIN_REACT_MAJOR;
14
14
  const STYLE_KEY = 'ncua-scoped';
15
15
  const STYLE_HOISTING_PROPS = { href: STYLE_KEY, precedence: 'default' };
16
+ // SSR 은 <style> 의 자식 텍스트를 HTML 이스케이프하지만(' -> &#x27;), 브라우저는 <style>
17
+ // 안을 raw text 로 읽어 되돌리지 않는다. 그러면 CSS 가 깨지고 하이드레이션도 불일치한다.
18
+ // innerHTML 로 넣으면 서버가 원문 그대로 내보낸다.
19
+ const STYLE_CONTENT = { __html: NCUA_SCOPED_CSS };
16
20
  /**
17
21
  * NCUA 스타일을 이 엘리먼트 하위로 한정하는 경계.
18
22
  *
@@ -42,6 +46,6 @@ export const NcuaScope = forwardRef(({ className, children }, ref) => {
42
46
  const styleAlreadyMounted = useContext(NcuaStyleMountedContext);
43
47
  const shouldRenderStyle = SUPPORTS_STYLE_HOISTING || !styleAlreadyMounted;
44
48
  return (_jsxs(NcuaStyleMountedContext.Provider, { value: true, children: [shouldRenderStyle &&
45
- (SUPPORTS_STYLE_HOISTING ? (_jsx("style", { ...STYLE_HOISTING_PROPS, children: NCUA_SCOPED_CSS })) : (_jsx("style", { "data-ncua-scope": STYLE_KEY, children: NCUA_SCOPED_CSS }))), _jsx("div", { ref: ref, className: classnames('ncua-scope', className), children: children })] }));
49
+ (SUPPORTS_STYLE_HOISTING ? (_jsx("style", { ...STYLE_HOISTING_PROPS, dangerouslySetInnerHTML: STYLE_CONTENT })) : (_jsx("style", { "data-ncua-scope": STYLE_KEY, dangerouslySetInnerHTML: STYLE_CONTENT }))), _jsx("div", { ref: ref, className: classnames('ncua-scope', className), children: children })] }));
46
50
  });
47
51
  NcuaScope.displayName = 'NcuaScope';
@@ -9,7 +9,9 @@ export const Pagination = ({ className = '', totalCount, itemCountPerPage = 10,
9
9
  const [start, setStart] = useState(1);
10
10
  const noPrev = breakPoint === 'mo' ? currentPage === 1 : start === 1;
11
11
  const noNext = breakPoint === 'mo' ? currentPage >= totalPage : start + pageCount - 1 >= totalPage;
12
- const showJumpPageButton = totalPage > pageCount;
12
+ // mo CSS 페이지 번호 목록과 first/last 를 숨겨 prev/next 만 남는다.
13
+ // pc 기준(totalPage > pageCount)을 그대로 쓰면 페이지가 적을 때 이동 수단이 하나도 없다.
14
+ const showJumpPageButton = breakPoint === 'mo' ? totalPage > 1 : totalPage > pageCount;
13
15
  const handleClickButton = (pageNum) => {
14
16
  if (isFunction(onPageChange)) {
15
17
  onPageChange(pageNum);
@@ -12,6 +12,12 @@ const CURRENT_PAGE = 91; // 100페이지 기준 마지막 그룹(91~100)을 보
12
12
  const GROUP_START_100_PAGES = 91; // currentPage=91 일 때의 그룹 시작
13
13
  const GROUP_START_50_PAGES = 41; // totalPage=50 으로 줄었을 때의 마지막 그룹 시작
14
14
  const LAST_PAGE_50 = 50;
15
+ /** 페이지 그룹 크기(10) 이하로 나뉘는 건수 — 3페이지 */
16
+ const TOTAL_COUNT_3_PAGES = 30;
17
+ /** TOTAL_COUNT_3_PAGES 기준 총 페이지 수 = 마지막 페이지 번호 */
18
+ const LAST_PAGE_3 = 3;
19
+ /** 페이지가 하나뿐인 건수 */
20
+ const TOTAL_COUNT_1_PAGE = 8;
15
21
  let root = null;
16
22
  let container = null;
17
23
  function render(props) {
@@ -34,6 +40,8 @@ afterEach(() => {
34
40
  container = null;
35
41
  });
36
42
  const pageNums = (el) => Array.from(el.querySelectorAll('.ncua-pagination__page-num')).map((b) => Number(b.textContent));
43
+ const prevOf = (el) => el.querySelector('.ncua-pagination__before');
44
+ const nextOf = (el) => el.querySelector('.ncua-pagination__next');
37
45
  describe('#332 Pagination — totalPage 축소 시 start 보정', () => {
38
46
  it('totalPage 가 줄면 start 를 마지막 그룹으로 보정하고 범위 밖 페이지 버튼을 표시하지 않는다', () => {
39
47
  // 1) 100 페이지에서 91 페이지 보는 중 → 91~100 그룹 표시
@@ -57,3 +65,69 @@ describe('#332 Pagination — totalPage 축소 시 start 보정', () => {
57
65
  expect(Math.max(...nums)).toBeLessThanOrEqual(LAST_PAGE_50);
58
66
  });
59
67
  });
68
+ describe('Pagination — mo 모드 이전/다음 버튼 노출', () => {
69
+ it('페이지가 그룹 크기 이하여도 mo 에서는 이전·다음 버튼이 노출된다', () => {
70
+ // Given/When: 3페이지(그룹 크기 10 이하) 분량을 mo 로 렌더하면
71
+ const el = render({
72
+ totalCount: TOTAL_COUNT_3_PAGES,
73
+ itemCountPerPage: ITEM_COUNT_PER_PAGE,
74
+ pageCount: PAGE_GROUP_SIZE,
75
+ currentPage: 1,
76
+ breakPoint: 'mo',
77
+ });
78
+ // Then: mo 는 페이지 번호 목록이 CSS 로 숨겨지므로 이전·다음 버튼이 있어야 이동할 수 있다
79
+ expect(prevOf(el)).not.toBeNull();
80
+ expect(nextOf(el)).not.toBeNull();
81
+ });
82
+ it('첫 페이지에서는 이전 버튼이, 마지막 페이지에서는 다음 버튼이 비활성이다', () => {
83
+ // Given/When: 3페이지 중 첫 페이지를 mo 로 렌더하면
84
+ const first = render({
85
+ totalCount: TOTAL_COUNT_3_PAGES,
86
+ itemCountPerPage: ITEM_COUNT_PER_PAGE,
87
+ pageCount: PAGE_GROUP_SIZE,
88
+ currentPage: 1,
89
+ breakPoint: 'mo',
90
+ });
91
+ // Then: 이전만 막히고 다음은 누를 수 있다
92
+ expect(prevOf(first)?.disabled).toBe(true);
93
+ expect(nextOf(first)?.disabled).toBe(false);
94
+ // When: 마지막 페이지로 이동하면
95
+ const last = render({
96
+ totalCount: TOTAL_COUNT_3_PAGES,
97
+ itemCountPerPage: ITEM_COUNT_PER_PAGE,
98
+ pageCount: PAGE_GROUP_SIZE,
99
+ currentPage: LAST_PAGE_3,
100
+ breakPoint: 'mo',
101
+ });
102
+ // Then: 반대로 다음이 막힌다
103
+ expect(prevOf(last)?.disabled).toBe(false);
104
+ expect(nextOf(last)?.disabled).toBe(true);
105
+ });
106
+ it('페이지가 하나뿐이면 mo 에서도 이전·다음 버튼을 내보내지 않는다', () => {
107
+ // Given/When: 1페이지 분량을 mo 로 렌더하면
108
+ const el = render({
109
+ totalCount: TOTAL_COUNT_1_PAGE,
110
+ itemCountPerPage: ITEM_COUNT_PER_PAGE,
111
+ pageCount: PAGE_GROUP_SIZE,
112
+ currentPage: 1,
113
+ breakPoint: 'mo',
114
+ });
115
+ // Then: 이동할 곳이 없으므로 버튼도 없다
116
+ expect(prevOf(el)).toBeNull();
117
+ expect(nextOf(el)).toBeNull();
118
+ });
119
+ it('pc 는 기존대로 페이지 그룹을 넘길 때만 점프 버튼을 노출한다', () => {
120
+ // Given/When: 3페이지 분량을 pc 로 렌더하면
121
+ const el = render({
122
+ totalCount: TOTAL_COUNT_3_PAGES,
123
+ itemCountPerPage: ITEM_COUNT_PER_PAGE,
124
+ pageCount: PAGE_GROUP_SIZE,
125
+ currentPage: 1,
126
+ breakPoint: 'pc',
127
+ });
128
+ // Then: 페이지 번호로 직접 이동하므로 점프 버튼은 나오지 않는다
129
+ expect(prevOf(el)).toBeNull();
130
+ expect(nextOf(el)).toBeNull();
131
+ expect(pageNums(el)).toHaveLength(LAST_PAGE_3);
132
+ });
133
+ });
@@ -40,6 +40,12 @@ interface FullWidthNotificationProps extends Omit<ComponentPropsWithoutRef<'div'
40
40
  * 다시보지 않기 이벤트 핸들러 (선택사항)
41
41
  */
42
42
  onHidePermanently?: () => void;
43
+ /**
44
+ * 다시보지 않기 버튼의 라벨 (선택사항)
45
+ * `onHidePermanently`를 지정했을 때만 렌더된다.
46
+ * @default '다시보지 않기'
47
+ */
48
+ hidePermanentlyLabel?: ReactNode;
43
49
  }
44
50
  declare const FullWidthNotification: import("react").ForwardRefExoticComponent<FullWidthNotificationProps & import("react").RefAttributes<HTMLDivElement>>;
45
51
  export type { FullWidthNotificationProps };
@@ -3,6 +3,7 @@ import { AlertTriangle, CheckCircle, InfoCircle, MessageChatSquare, XClose } fro
3
3
  import classNames from 'classnames';
4
4
  import { forwardRef, useEffect, useRef, useState } from 'react';
5
5
  import { COLOR } from '../../../../constant/color';
6
+ import { HIDE_PERMANENTLY_LABEL } from '../../../../constant/notification';
6
7
  const iconMap = {
7
8
  neutral: MessageChatSquare,
8
9
  error: AlertTriangle,
@@ -17,7 +18,7 @@ const iconColorMap = {
17
18
  success: 'green600',
18
19
  info: 'violet600',
19
20
  };
20
- const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, supportTextLink, onHidePermanently, ...rest }, ref) => {
21
+ const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, supportTextLink, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.fullWidth, ...rest }, ref) => {
21
22
  const [shouldRemove, setShouldRemove] = useState(false);
22
23
  const closeIconSize = {
23
24
  width: 20,
@@ -51,6 +52,6 @@ const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neut
51
52
  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: [(() => {
52
53
  const IconComponent = iconMap[color];
53
54
  return (_jsx(IconComponent, { width: 16, height: 16, color: COLOR[iconColorMap[color]], className: "ncua-full-width-notification__icon" }));
54
- })(), _jsxs("div", { className: "ncua-full-width-notification__text-container", children: [_jsx("span", { className: "ncua-full-width-notification__title", children: title }), !supportTextLink && supportingText && (_jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText })), supportTextLink && supportingText && (_jsx("a", { href: supportTextLink, className: "ncua-full-width-notification__link", children: _jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText }) }))] })] }), _jsxs("div", { className: "ncua-full-width-notification__actions-container", children: [actions && (_jsx("div", { className: "ncua-full-width-notification__actions", children: actions.map((action) => (_jsx("button", { onClick: action.onClick, className: classNames('ncua-notification__action-button', `ncua-notification__action-button--${action.hierarchy || 'text'}`), children: action.label }, `${action.label}-${action.hierarchy}`))) })), onHidePermanently && (_jsx("button", { type: "button", className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--text', 'ncua-full-width-notification__link'), onClick: onHidePermanently, children: "\uB2E4\uC2DC\uBCF4\uC9C0 \uC54A\uAE30" })), 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]] }) }))] })] }) }) }));
55
+ })(), _jsxs("div", { className: "ncua-full-width-notification__text-container", children: [_jsx("span", { className: "ncua-full-width-notification__title", children: title }), !supportTextLink && supportingText && (_jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText })), supportTextLink && supportingText && (_jsx("a", { href: supportTextLink, className: "ncua-full-width-notification__link", children: _jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText }) }))] })] }), _jsxs("div", { className: "ncua-full-width-notification__actions-container", children: [actions && (_jsx("div", { className: "ncua-full-width-notification__actions", children: actions.map((action) => (_jsx("button", { onClick: action.onClick, className: classNames('ncua-notification__action-button', `ncua-notification__action-button--${action.hierarchy || 'text'}`), children: action.label }, `${action.label}-${action.hierarchy}`))) })), onHidePermanently && (_jsx("button", { type: "button", className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--text', 'ncua-full-width-notification__link'), onClick: onHidePermanently, children: hidePermanentlyLabel })), onClose && (_jsx("button", { type: "button", className: "ncua-full-width-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { ...closeIconSize, color: COLOR[iconColorMap[color]] }) }))] })] }) }) }));
55
56
  });
56
57
  export { FullWidthNotification };
@@ -35,6 +35,12 @@ interface MessageNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>,
35
35
  * 다시보지 않기 이벤트 핸들러 (선택사항)
36
36
  */
37
37
  onHidePermanently?: () => void;
38
+ /**
39
+ * 다시보지 않기 버튼의 라벨 (선택사항)
40
+ * `onHidePermanently`를 지정했을 때만 렌더된다.
41
+ * @default '다시 보지 않기'
42
+ */
43
+ hidePermanentlyLabel?: ReactNode;
38
44
  }
39
45
  declare const MessageNotification: import("react").ForwardRefExoticComponent<MessageNotificationProps & import("react").RefAttributes<HTMLDivElement>>;
40
46
  export type { MessageNotificationProps };
@@ -1,10 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertCircle, AlertTriangle, CheckCircle, Pin02, XClose } from '@ncds/ui-admin-icon';
2
+ import { AlertCircle, AlertTriangle, CheckCircle, Pin02 } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
4
  import { forwardRef } from 'react';
5
5
  import { COLOR } from '../../../../constant/color';
6
- import { Button } from '../../action/button';
7
- import { FeaturedIcon, } from '../../image-and-icons/featured-icon/FeaturedIcon';
6
+ import { MessageNotificationActions, MessageNotificationContent } from './MessageNotificationParts';
8
7
  const iconMap = {
9
8
  neutral: Pin02,
10
9
  error: AlertTriangle,
@@ -17,18 +16,10 @@ const iconColorMap = {
17
16
  warning: 'orange500',
18
17
  success: 'green600',
19
18
  };
20
- const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, className, actions, onHidePermanently, ...rest }, ref) => {
19
+ const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, className, actions, onHidePermanently, hidePermanentlyLabel, ...rest }, ref) => {
21
20
  // message 타입은 neutral, error, warning, success 4가지 색상만 지원
22
21
  const validColor = color === 'info' ? 'neutral' : color;
23
- const iconColor = validColor;
24
- const featuredIconProps = {
25
- icon: Icon || iconMap[validColor] || Pin02,
26
- size: 'lg',
27
- color: iconColor,
28
- theme: 'light-circle',
29
- };
30
- const closeIconColor = COLOR[iconColorMap[validColor] || 'gray700'];
31
- return (_jsx("div", { ref: ref, className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, className), role: "alert", ...rest, children: _jsx("div", { className: "ncua-message-notification__container", children: _jsxs("div", { className: "ncua-message-notification__content", children: [_jsxs("div", { className: "ncua-message-notification__content-wrapper", children: [iconMap[validColor] && (_jsx(FeaturedIcon, { ...featuredIconProps, className: "ncua-message-notification__icon" })), _jsxs("div", { className: "ncua-message-notification__text-container", children: [_jsx("span", { className: "ncua-message-notification__title", children: title }), supportingText && _jsx("span", { className: "ncua-message-notification__supporting-text", children: supportingText })] })] }), _jsx("div", { className: "ncua-message-notification__actions-container", children: actions && (_jsx("div", { className: "ncua-message-notification__actions", children: actions.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action?.onClick }, `${action.label}-${action.hierarchy}`))) })) }), _jsxs("div", { className: "ncua-message-notification__footer-container", children: [onHidePermanently && (_jsx("button", { type: "button", className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--link', 'ncua-message-notification__hide-link'), onClick: onHidePermanently, children: "\uB2E4\uC2DC \uBCF4\uC9C0 \uC54A\uAE30" })), onClose && (_jsx("button", { type: "button", className: "ncua-message-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { width: 20, height: 20, color: closeIconColor }) }))] })] }) }) }));
22
+ return (_jsx("div", { ref: ref, className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, className), role: "alert", ...rest, children: _jsx("div", { className: "ncua-message-notification__container", children: _jsxs("div", { className: "ncua-message-notification__content", children: [_jsx(MessageNotificationContent, { title: title, supportingText: supportingText, icon: Icon || iconMap[validColor], iconColor: validColor, showIcon: Boolean(iconMap[validColor]) }), _jsx(MessageNotificationActions, { actions: actions, onClose: onClose, onHidePermanently: onHidePermanently, hidePermanentlyLabel: hidePermanentlyLabel, closeIconColor: COLOR[iconColorMap[validColor] || 'gray700'] })] }) }) }));
32
23
  });
33
24
  MessageNotification.displayName = 'MessageNotification';
34
25
  export { MessageNotification };
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { SlotIconComponent } from '../../../types/side-slot';
3
+ import { type FeaturedIconColor } from '../../image-and-icons/featured-icon/FeaturedIcon';
4
+ import type { NotificationAction } from './Notification';
5
+ interface MessageNotificationContentProps {
6
+ title: ReactNode;
7
+ supportingText?: ReactNode;
8
+ icon?: SlotIconComponent;
9
+ iconColor: FeaturedIconColor;
10
+ showIcon: boolean;
11
+ }
12
+ /** 아이콘 + 제목/서포팅 텍스트 영역. */
13
+ declare const MessageNotificationContent: ({ title, supportingText, icon, iconColor, showIcon, }: MessageNotificationContentProps) => import("react/jsx-runtime").JSX.Element;
14
+ interface MessageNotificationActionsProps {
15
+ actions?: NotificationAction[];
16
+ onClose?: () => void;
17
+ onHidePermanently?: () => void;
18
+ hidePermanentlyLabel?: ReactNode;
19
+ closeIconColor: string;
20
+ }
21
+ /**
22
+ * 액션 버튼 그룹 + 닫기 그룹을 감싸는 우측 영역.
23
+ * 이 컨테이너의 gap(12px)이 두 그룹 사이 간격을 만든다.
24
+ * 표시할 요소가 하나도 없으면 빈 컨테이너를 남기지 않는다.
25
+ */
26
+ declare const MessageNotificationActions: ({ actions, onClose, onHidePermanently, hidePermanentlyLabel, closeIconColor, }: MessageNotificationActionsProps) => import("react/jsx-runtime").JSX.Element | null;
27
+ export type { MessageNotificationActionsProps, MessageNotificationContentProps };
28
+ export { MessageNotificationActions, MessageNotificationContent };
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pin02, XClose } from '@ncds/ui-admin-icon';
3
+ import { HIDE_PERMANENTLY_LABEL } from '../../../../constant/notification';
4
+ import { Button } from '../../action/button';
5
+ import { FeaturedIcon, } from '../../image-and-icons/featured-icon/FeaturedIcon';
6
+ /** 아이콘 + 제목/서포팅 텍스트 영역. */
7
+ const MessageNotificationContent = ({ title, supportingText, icon, iconColor, showIcon, }) => {
8
+ const featuredIconProps = {
9
+ icon: icon || Pin02,
10
+ size: 'md',
11
+ color: iconColor,
12
+ theme: 'light-circle',
13
+ };
14
+ return (_jsxs("div", { className: "ncua-message-notification__content-wrapper", children: [showIcon && _jsx(FeaturedIcon, { ...featuredIconProps, className: "ncua-message-notification__icon" }), _jsxs("div", { className: "ncua-message-notification__text-container", children: [_jsx("span", { className: "ncua-message-notification__title", children: title }), supportingText && _jsx("span", { className: "ncua-message-notification__supporting-text", children: supportingText })] })] }));
15
+ };
16
+ /**
17
+ * 액션 버튼 그룹 + 닫기 그룹을 감싸는 우측 영역.
18
+ * 이 컨테이너의 gap(12px)이 두 그룹 사이 간격을 만든다.
19
+ * 표시할 요소가 하나도 없으면 빈 컨테이너를 남기지 않는다.
20
+ */
21
+ const MessageNotificationActions = ({ actions, onClose, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.message, closeIconColor, }) => {
22
+ const hasActions = Boolean(actions?.length);
23
+ const hasFooter = Boolean(onHidePermanently || onClose);
24
+ if (!hasActions && !hasFooter)
25
+ return null;
26
+ return (_jsxs("div", { className: "ncua-message-notification__actions-container", children: [hasActions && (_jsx("div", { className: "ncua-message-notification__actions", children: actions?.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action?.onClick }, `${action.label}-${action.hierarchy}`))) })), hasFooter && (_jsxs("div", { className: "ncua-message-notification__footer-container", children: [onHidePermanently && (_jsx("button", { type: "button", className: "ncua-notification__action-button ncua-message-notification__hide-link", onClick: onHidePermanently, children: hidePermanentlyLabel })), onClose && (_jsx("button", { type: "button", className: "ncua-message-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { width: 20, height: 20, color: closeIconColor }) }))] }))] }));
27
+ };
28
+ export { MessageNotificationActions, MessageNotificationContent };
@@ -16,9 +16,10 @@ interface NotificationAction {
16
16
  */
17
17
  onClick?: () => void;
18
18
  /**
19
- * 액션 버튼 색상 (3가지만 지원)
19
+ * 액션 버튼 색상. `ButtonTheme` 중 알림에서 허용하는 4가지만 지원한다.
20
+ * @default 'text'
20
21
  */
21
- hierarchy?: Extract<ButtonTheme, 'text' | 'text-gray'>;
22
+ hierarchy?: Extract<ButtonTheme, 'secondary' | 'secondary-gray' | 'text' | 'text-gray'>;
22
23
  }
23
24
  interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
24
25
  /**
@@ -67,6 +68,14 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
67
68
  * message, full-width 타입에서 사용 가능
68
69
  */
69
70
  onHidePermanently?: () => void;
71
+ /**
72
+ * 다시보지 않기 버튼의 라벨 (선택사항)
73
+ * message, full-width 타입에서 사용 가능하며 `onHidePermanently`를 지정했을 때만 렌더된다.
74
+ *
75
+ * 기본값은 타입별로 다르다 — message는 `'다시 보지 않기'`, full-width는 `'다시보지 않기'`.
76
+ * 표기가 통일되어 있지 않은 것은 현행 유지이며, 통일은 별도 결정 사항이다.
77
+ */
78
+ hidePermanentlyLabel?: ReactNode;
70
79
  /**
71
80
  * Portal 마운트 여부. `floating` 타입에서만 동작한다.
72
81
  *