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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/cjs/assets/scripts/notification/FullWidthNotification.js +2 -0
  2. package/dist/cjs/assets/scripts/notification/MessageNotification.js +30 -23
  3. package/dist/cjs/assets/scripts/notification/const/classNames.js +3 -0
  4. package/dist/cjs/assets/scripts/notification/const/icons.js +11 -2
  5. package/dist/cjs/assets/scripts/notification/const/index.js +9 -4
  6. package/dist/cjs/assets/scripts/notification/const/sizes.js +6 -1
  7. package/dist/cjs/assets/scripts/notification/const/types.js +1 -10
  8. package/dist/cjs/assets/scripts/notification/messageTemplate.js +20 -8
  9. package/dist/cjs/assets/scripts/notification/utils.js +21 -17
  10. package/dist/cjs/constant/notification.js +15 -1
  11. package/dist/cjs/src/components/feedback-and-status/badge/Badge.js +1 -1
  12. package/dist/cjs/src/components/forms-and-input/combo-box/ComboBox.js +16 -126
  13. package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
  14. package/dist/cjs/src/components/overlays/notification/FloatingNotification.js +4 -7
  15. package/dist/cjs/src/components/overlays/notification/FullWidthNotification.js +13 -8
  16. package/dist/cjs/src/components/overlays/notification/MessageNotification.js +20 -14
  17. package/dist/cjs/src/components/overlays/notification/MessageNotificationParts.js +22 -15
  18. package/dist/cjs/src/components/overlays/notification/Notification.js +6 -0
  19. package/dist/cjs/src/components/overlays/notification/NotificationActionButtons.js +27 -0
  20. package/dist/cjs/src/components/overlays/notification/__tests__/supportTextLink.test.js +58 -0
  21. package/dist/cjs/src/components/overlays/notification/linkTarget.js +18 -0
  22. package/dist/cjs/src/generated/scoped-css.js +1 -1
  23. package/dist/cjs/src/utils/index.js +11 -0
  24. package/dist/cjs/src/utils/url/__tests__/isSafeUrl.test.js +49 -0
  25. package/dist/cjs/src/utils/url/index.js +16 -0
  26. package/dist/cjs/src/utils/url/isSafeUrl.js +32 -0
  27. package/dist/esm/assets/scripts/notification/FullWidthNotification.js +3 -1
  28. package/dist/esm/assets/scripts/notification/MessageNotification.js +32 -25
  29. package/dist/esm/assets/scripts/notification/const/classNames.js +3 -0
  30. package/dist/esm/assets/scripts/notification/const/icons.js +10 -1
  31. package/dist/esm/assets/scripts/notification/const/index.js +2 -3
  32. package/dist/esm/assets/scripts/notification/const/sizes.js +5 -0
  33. package/dist/esm/assets/scripts/notification/const/types.js +1 -8
  34. package/dist/esm/assets/scripts/notification/messageTemplate.js +21 -9
  35. package/dist/esm/assets/scripts/notification/utils.js +19 -16
  36. package/dist/esm/constant/notification.js +14 -0
  37. package/dist/esm/src/components/feedback-and-status/badge/Badge.js +1 -1
  38. package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +17 -127
  39. package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
  40. package/dist/esm/src/components/overlays/notification/FloatingNotification.js +4 -7
  41. package/dist/esm/src/components/overlays/notification/FullWidthNotification.js +13 -8
  42. package/dist/esm/src/components/overlays/notification/MessageNotification.js +21 -15
  43. package/dist/esm/src/components/overlays/notification/MessageNotificationParts.js +22 -15
  44. package/dist/esm/src/components/overlays/notification/Notification.js +6 -0
  45. package/dist/esm/src/components/overlays/notification/NotificationActionButtons.js +21 -0
  46. package/dist/esm/src/components/overlays/notification/__tests__/supportTextLink.test.js +55 -0
  47. package/dist/esm/src/components/overlays/notification/linkTarget.js +12 -0
  48. package/dist/esm/src/generated/scoped-css.js +1 -1
  49. package/dist/esm/src/utils/index.js +3 -1
  50. package/dist/esm/src/utils/url/__tests__/isSafeUrl.test.js +47 -0
  51. package/dist/esm/src/utils/url/index.js +1 -0
  52. package/dist/esm/src/utils/url/isSafeUrl.js +26 -0
  53. package/dist/temp/assets/scripts/notification/FullWidthNotification.js +3 -2
  54. package/dist/temp/assets/scripts/notification/MessageNotification.d.ts +2 -0
  55. package/dist/temp/assets/scripts/notification/MessageNotification.js +30 -25
  56. package/dist/temp/assets/scripts/notification/const/classNames.d.ts +3 -0
  57. package/dist/temp/assets/scripts/notification/const/classNames.js +3 -0
  58. package/dist/temp/assets/scripts/notification/const/icons.d.ts +6 -0
  59. package/dist/temp/assets/scripts/notification/const/icons.js +9 -0
  60. package/dist/temp/assets/scripts/notification/const/index.d.ts +3 -4
  61. package/dist/temp/assets/scripts/notification/const/index.js +2 -3
  62. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +4 -0
  63. package/dist/temp/assets/scripts/notification/const/sizes.js +5 -0
  64. package/dist/temp/assets/scripts/notification/const/types.d.ts +28 -1
  65. package/dist/temp/assets/scripts/notification/const/types.js +1 -8
  66. package/dist/temp/assets/scripts/notification/messageTemplate.d.ts +5 -1
  67. package/dist/temp/assets/scripts/notification/messageTemplate.js +16 -11
  68. package/dist/temp/assets/scripts/notification/utils.d.ts +10 -3
  69. package/dist/temp/assets/scripts/notification/utils.js +21 -18
  70. package/dist/temp/constant/notification.d.ts +13 -0
  71. package/dist/temp/constant/notification.js +14 -0
  72. package/dist/temp/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  73. package/dist/temp/src/components/feedback-and-status/badge/Badge.js +1 -1
  74. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +1 -1
  75. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +13 -119
  76. package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
  77. package/dist/temp/src/components/overlays/notification/FloatingNotification.js +2 -2
  78. package/dist/temp/src/components/overlays/notification/FullWidthNotification.d.ts +6 -1
  79. package/dist/temp/src/components/overlays/notification/FullWidthNotification.js +7 -2
  80. package/dist/temp/src/components/overlays/notification/MessageNotification.d.ts +15 -1
  81. package/dist/temp/src/components/overlays/notification/MessageNotification.js +11 -12
  82. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.d.ts +10 -5
  83. package/dist/temp/src/components/overlays/notification/MessageNotificationParts.js +9 -6
  84. package/dist/temp/src/components/overlays/notification/Notification.d.ts +23 -2
  85. package/dist/temp/src/components/overlays/notification/Notification.js +3 -3
  86. package/dist/temp/src/components/overlays/notification/NotificationActionButtons.d.ts +12 -0
  87. package/dist/temp/src/components/overlays/notification/NotificationActionButtons.js +9 -0
  88. package/dist/temp/src/components/overlays/notification/__tests__/supportTextLink.test.js +73 -0
  89. package/dist/temp/src/components/overlays/notification/linkTarget.d.ts +11 -0
  90. package/dist/temp/src/components/overlays/notification/linkTarget.js +9 -0
  91. package/dist/temp/src/generated/scoped-css.js +1 -1
  92. package/dist/temp/src/utils/index.d.ts +1 -0
  93. package/dist/temp/src/utils/index.js +2 -0
  94. package/dist/temp/src/utils/url/__tests__/isSafeUrl.test.js +61 -0
  95. package/dist/temp/src/utils/url/index.d.ts +1 -0
  96. package/dist/temp/src/utils/url/index.js +1 -0
  97. package/dist/temp/src/utils/url/isSafeUrl.d.ts +8 -0
  98. package/dist/temp/src/utils/url/isSafeUrl.js +29 -0
  99. package/dist/types/assets/scripts/notification/MessageNotification.d.ts +2 -0
  100. package/dist/types/assets/scripts/notification/const/classNames.d.ts +3 -0
  101. package/dist/types/assets/scripts/notification/const/icons.d.ts +6 -0
  102. package/dist/types/assets/scripts/notification/const/index.d.ts +3 -4
  103. package/dist/types/assets/scripts/notification/const/sizes.d.ts +4 -0
  104. package/dist/types/assets/scripts/notification/const/types.d.ts +28 -1
  105. package/dist/types/assets/scripts/notification/messageTemplate.d.ts +5 -1
  106. package/dist/types/assets/scripts/notification/utils.d.ts +10 -3
  107. package/dist/types/constant/notification.d.ts +13 -0
  108. package/dist/types/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  109. package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +1 -1
  110. package/dist/types/src/components/overlays/notification/FullWidthNotification.d.ts +6 -1
  111. package/dist/types/src/components/overlays/notification/MessageNotification.d.ts +15 -1
  112. package/dist/types/src/components/overlays/notification/MessageNotificationParts.d.ts +10 -5
  113. package/dist/types/src/components/overlays/notification/Notification.d.ts +23 -2
  114. package/dist/types/src/components/overlays/notification/NotificationActionButtons.d.ts +12 -0
  115. package/dist/types/src/components/overlays/notification/__tests__/supportTextLink.test.d.ts +1 -0
  116. package/dist/types/src/components/overlays/notification/linkTarget.d.ts +11 -0
  117. package/dist/types/src/utils/index.d.ts +1 -0
  118. package/dist/types/src/utils/url/__tests__/isSafeUrl.test.d.ts +1 -0
  119. package/dist/types/src/utils/url/index.d.ts +1 -0
  120. package/dist/types/src/utils/url/isSafeUrl.d.ts +8 -0
  121. package/dist/ui-admin/assets/styles/style.css +54 -34
  122. package/package.json +1 -1
  123. package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +0 -455
  124. package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +0 -452
  125. package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +0 -293
  126. /package/dist/temp/src/components/{forms-and-input/combo-box/__tests__/ComboBox.test.d.ts → overlays/notification/__tests__/supportTextLink.test.d.ts} +0 -0
  127. /package/dist/{types/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.d.ts → temp/src/utils/url/__tests__/isSafeUrl.test.d.ts} +0 -0
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { ChevronSelectorVertical, SearchLg } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
- import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
+ import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
5
5
  import { useDropdown } from '../../../hooks/dropdown';
6
6
  import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
7
7
  import { usePortalState } from '../../../hooks/usePortalState';
@@ -23,112 +23,6 @@ const notifyFormChange = (register, multiple, newValue) => {
23
23
  });
24
24
  }
25
25
  };
26
- /** '' · undefined · null 이면 빈 값. 숫자 0은 빈 값이 아니다 */
27
- const isEmptyValue = value => value === '' || value == null;
28
- /** react-hook-form hidden input 값 — Tag 와 같은 빈 값 규칙(0은 값)을 따른다 */
29
- const toHiddenInputValue = (value, multiple) => {
30
- if (multiple) return JSON.stringify(value || []);
31
- return isEmptyValue(value) ? '' : String(value);
32
- };
33
- /** SelectDropdown 은 null 을 받지 않으므로 undefined 로 바꾼다 */
34
- const toDropdownValue = value => value ?? undefined;
35
- /** multiple 용 — 배열이 아니면(null · undefined 포함) 빈 배열 */
36
- const toIdArray = value => Array.isArray(value) ? value : [];
37
- /** 두 id 목록이 같은 id 들로 이루어졌는지 — 순서는 보지 않는다 (사용처가 onChange 값을 정렬해 돌려줘도 같은 값) */
38
- const isSameIds = (currentIds, nextIds) => currentIds.length === nextIds.length && currentIds.every(id => nextIds.includes(id));
39
- const isSameTags = (nextTags, prevTags) => nextTags.length === prevTags.length && nextTags.every((tag, index) => tag.id === prevTags[index].id && tag.label === prevTags[index].label);
40
- const findOption = (optionItems, id) => optionItems.find(option => option.id === id);
41
- /** 단일 모드 value 에 해당하는 옵션 — 빈 값·배열이면 없음 */
42
- const findSingleOption = (value, optionItems) => {
43
- if (isEmptyValue(value) || Array.isArray(value)) return undefined;
44
- return findOption(optionItems, value);
45
- };
46
- /**
47
- * 단일 모드 Tag — value 와 optionItems 에서 계산한다.
48
- * 검색 필터로 목록에서 빠진 경우에만, 같은 id 로 기억해 둔 Tag 를 대신 쓴다.
49
- */
50
- const resolveSingleTag = (value, optionItems, lastResolvedTag) => {
51
- if (isEmptyValue(value) || Array.isArray(value)) return null;
52
- const found = findOption(optionItems, value);
53
- if (found) return {
54
- id: found.id,
55
- label: found.label
56
- };
57
- return lastResolvedTag?.id === value ? lastResolvedTag : null;
58
- };
59
- /** 단일 모드 Tag 목록 (0~1개). 목록에서 찾은 Tag 를 기억해 검색 필터 중에도 라벨을 유지한다 */
60
- const useSingleSelectedTags = (value, optionItems, enabled) => {
61
- const [lastResolvedTag, setLastResolvedTag] = useState(null);
62
- const found = enabled ? findSingleOption(value, optionItems) : undefined;
63
- const foundId = found?.id;
64
- const foundLabel = found?.label;
65
- useEffect(() => {
66
- if (foundId === undefined || foundLabel === undefined) return;
67
- setLastResolvedTag({
68
- id: foundId,
69
- label: foundLabel
70
- });
71
- }, [foundId, foundLabel]);
72
- const selectedTag = enabled ? resolveSingleTag(value, optionItems, lastResolvedTag) : null;
73
- return selectedTag ? [selectedTag] : [];
74
- };
75
- /** multiple 모드 Tag — 목록에서 못 찾은 id 는 이미 표시 중인 Tag 의 라벨을 쓴다 */
76
- const resolveMultipleTags = (ids, optionItems, prevTags) => {
77
- const nextTags = ids.map(id => {
78
- const found = findOption(optionItems, id);
79
- return found ? {
80
- id: found.id,
81
- label: found.label
82
- } : prevTags.find(tag => tag.id === id);
83
- }).filter(tag => tag !== undefined);
84
- // 결과가 같으면 이전 배열을 그대로 돌려준다 — 새 배열로 바꾸면 effect 가 다시 렌더를 일으킨다
85
- return isSameTags(nextTags, prevTags) ? prevTags : nextTags;
86
- };
87
- /**
88
- * multiple 모드 Tag — 「선택 완료」·X 클릭·외부 value 변경 때만 확정 id 를 바꾼다.
89
- * ComboBox 가 직접 onChange 로 내보낸 값은 markEmitted 로 기록해 두고, 그 값이 value 로 돌아오면 한 번만 무시한다(확정 아님).
90
- * 바깥 값이 들어오면 남아 있던 기록도 지운다 — 오래된 기록 때문에 이후 같은 바깥 값이 무시되지 않게 하기 위함.
91
- * optionItems 만 바뀌면 확정 id 는 그대로 두고 라벨만 다시 찾는다 — 「선택 완료」 전 검색으로 Tag 가 확정되지 않게 하기 위함.
92
- */
93
- const useMultipleSelectedTags = (value, optionItems, enabled) => {
94
- const [selectedTags, setSelectedTags] = useState([]);
95
- const lastEmittedRef = useRef(null);
96
- const committedIdsRef = useRef([]);
97
- const prevValueIdsRef = useRef(null);
98
- useEffect(() => {
99
- if (!enabled) return;
100
- const ids = toIdArray(value);
101
- const isEcho = lastEmittedRef.current !== null && isSameIds(ids, lastEmittedRef.current);
102
- const isValueChanged = prevValueIdsRef.current === null || !isSameIds(ids, prevValueIdsRef.current);
103
- prevValueIdsRef.current = ids;
104
- if (isEcho) {
105
- lastEmittedRef.current = null;
106
- } else if (isValueChanged) {
107
- committedIdsRef.current = ids;
108
- lastEmittedRef.current = null;
109
- }
110
- setSelectedTags(prev => resolveMultipleTags(committedIdsRef.current, optionItems, prev));
111
- }, [enabled, value, optionItems]);
112
- /** 「선택 완료」로 Tag 를 확정한다 */
113
- const commitTags = ids => {
114
- committedIdsRef.current = ids;
115
- setSelectedTags(prev => resolveMultipleTags(ids, optionItems, prev));
116
- };
117
- /** X 클릭 — 확정 id 에서 그 id 만 뺀다. value 기준으로 계산하면 「선택 완료」 전에 클릭한 항목까지 확정된다 */
118
- const removeCommittedTag = tagId => {
119
- commitTags(committedIdsRef.current.filter(id => id !== tagId));
120
- };
121
- /** ComboBox 가 onChange 로 내보낸 값을 기록한다 — value 로 돌아왔을 때 확정으로 보지 않기 위함 */
122
- const markEmitted = ids => {
123
- lastEmittedRef.current = ids;
124
- };
125
- return {
126
- selectedTags,
127
- commitTags,
128
- removeCommittedTag,
129
- markEmitted
130
- };
131
- };
132
26
  const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
133
27
  let {
134
28
  placeholder = '검색하세요',
@@ -168,24 +62,22 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
168
62
  } = usePortalState(usePortal);
169
63
  const [inputValue, setInputValue] = useState(searchValue);
170
64
  const [showAllItems, setShowAllItems] = useState(false);
171
- const singleTags = useSingleSelectedTags(value, optionItems, !multiple);
172
- const {
173
- selectedTags,
174
- commitTags,
175
- removeCommittedTag,
176
- markEmitted
177
- } = useMultipleSelectedTags(value, optionItems, multiple);
65
+ const [selectedTags, setSelectedTags] = useState([]);
178
66
  const handleOptionSelect = option => {
179
67
  if (disabled) return;
180
68
  if (multiple) {
181
69
  const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
182
70
  if (newValue === null) return;
183
- markEmitted(newValue);
184
71
  onChange?.(newValue);
185
72
  notifyFormChange(register, multiple, newValue);
186
73
  return;
187
74
  }
188
75
  const newValue = option.id;
76
+ const tag = {
77
+ id: option.id,
78
+ label: option.label
79
+ };
80
+ setSelectedTags([tag]);
189
81
  onChange?.(newValue);
190
82
  notifyFormChange(register, multiple, newValue);
191
83
  };
@@ -292,6 +184,7 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
292
184
  const {
293
185
  buttonText: selectAllButtonText,
294
186
  toggleSelectAll,
187
+ getSelectedTagsData,
295
188
  removeTag,
296
189
  isMaxSelectionActive,
297
190
  tryToggle
@@ -301,7 +194,6 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
301
194
  const handleSelectAll = () => {
302
195
  if (!multiple || !onChange) return;
303
196
  const newSelectedValues = toggleSelectAll();
304
- markEmitted(newSelectedValues);
305
197
  onChange(newSelectedValues);
306
198
  };
307
199
  const handleEdit = () => {
@@ -309,32 +201,30 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
309
201
  };
310
202
  const handleComplete = completedValue => {
311
203
  if (!multiple) return;
312
- commitTags(toIdArray(completedValue));
204
+ const tags = getSelectedTagsData();
205
+ setSelectedTags(tags);
313
206
  setInputValue('');
314
207
  closeDropdown();
315
208
  onComplete?.(completedValue);
316
209
  };
317
210
  const handleRemoveTag = tagId => {
318
- // X 버튼은 disabled 에서 숨기지만, 다른 호출 경로가 생겨도 값이 지워지지 않게 함수에서도 막는다
319
- if (disabled) return;
320
211
  if (!onChange) return;
321
212
  if (multiple) {
322
213
  const newSelectedValues = removeTag(tagId);
323
- markEmitted(newSelectedValues);
324
214
  onChange(newSelectedValues);
325
- removeCommittedTag(tagId);
215
+ setSelectedTags(prev => prev.filter(tag => tag.id !== tagId));
326
216
  return;
327
217
  }
328
218
  onChange('');
219
+ setSelectedTags([]);
329
220
  };
330
221
  const comboboxId = `combobox-options-${id || 'default'}`;
331
222
  const hasVisibleOptions = showAllItems || inputValue;
332
223
  const displayedOptions = hasVisibleOptions ? optionItems : [];
333
224
  const showNoResults = !hasVisibleOptions || optionItems.length === 0;
334
225
  const showFooter = multiple || showFooterButtons;
335
- const hiddenInputValue = toHiddenInputValue(value, multiple);
336
- const tags = multiple ? selectedTags : singleTags;
337
- const hasTags = tags.length > 0;
226
+ const hiddenInputValue = multiple ? JSON.stringify(value || []) : String(value || '');
227
+ const hasTags = selectedTags.length > 0;
338
228
  const {
339
229
  className: portalPanelClassName,
340
230
  style: portalPanelStyle
@@ -345,7 +235,7 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
345
235
  direction: dropdownDirection,
346
236
  size: size,
347
237
  options: displayedOptions,
348
- value: toDropdownValue(value),
238
+ value: value,
349
239
  focusedIndex: focusedIndex,
350
240
  maxHeight: maxHeight,
351
241
  listboxId: comboboxId,
@@ -424,10 +314,10 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
424
314
  })]
425
315
  }), hasTags && _jsx("div", {
426
316
  className: "ncua-combobox__tags",
427
- children: tags.map(tag => _jsx(Tag, {
317
+ children: selectedTags.map(tag => _jsx(Tag, {
428
318
  text: tag.label,
429
319
  size: "sm",
430
- close: !disabled,
320
+ close: true,
431
321
  onButtonClick: () => handleRemoveTag(tag.id)
432
322
  }, tag.id))
433
323
  })]
@@ -473,7 +473,7 @@ describe('C-9 기본 경로 회귀 기준선', () => {
473
473
  expect(onSearch).toHaveBeenCalledWith('사');
474
474
  expect(view.content.querySelectorAll('.ncua-select-dropdown__option')).toHaveLength(createOptions().length);
475
475
  });
476
- it('옵션을 선택하면 onChange 가 호출된다', () => {
476
+ it('옵션을 선택하면 onChange 가 호출되고 선택 태그가 표시된다', () => {
477
477
  // Given: 옵션이 노출된 기본 모드 ComboBox 에서
478
478
  const onChange = vi.fn();
479
479
  const view = mountComboBox({
@@ -486,9 +486,9 @@ describe('C-9 기본 경로 회귀 기준선', () => {
486
486
  act(() => {
487
487
  requireElement(view.content, '.ncua-select-dropdown__option').click();
488
488
  });
489
- // Then: onChange 가 선택 값으로 호출된다
490
- // 선택 태그는 value 를 따르므로(#530) onChange 값을 value 로 돌려주는 경우의 태그 표시는 ComboBox.test.ts S-10 에서 검증한다
489
+ // Then: onChange 가 선택 값으로 호출되고 태그가 표시된다
491
490
  expect(onChange).toHaveBeenCalledWith('apple');
491
+ expect(view.container.querySelectorAll('.ncua-combobox__tags .ncua-tag')).toHaveLength(1);
492
492
  });
493
493
  it('Escape 를 누르면 인라인 패널이 닫힌다', () => {
494
494
  // Given: 열려 있는 기본 모드 ComboBox 에서
@@ -5,12 +5,12 @@ import { forwardRef, useEffect, useId, useRef, useState } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { MEDIA_QUERY } from '../../../constant/breakpoint';
7
7
  import { useMediaQuery } from '../../../hooks/useMediaQuery';
8
- import { Button } from '../../action/button';
9
8
  import { ButtonCloseX } from '../../action/button/ButtonCloseX';
10
9
  import { FeaturedIcon } from '../../image-and-icons/featured-icon/FeaturedIcon';
11
10
  // 호스트 싱글톤은 vanilla/React 양쪽이 동일한 함수를 공유한다 — host.ts 가 가까운 진입점 역할을
12
11
  // 하므로 React 컴포넌트는 deep relative path 로 vanilla internals 를 직접 import 하지 않는다.
13
12
  import { mountFloatingNotificationHost } from './host';
13
+ import { NotificationActionButtons } from './NotificationActionButtons';
14
14
  /**
15
15
  * 색상별 a11y role.
16
16
  * - error/warning → role="alert" (implicit aria-live="assertive": 즉시 발화)
@@ -149,12 +149,9 @@ const FloatingNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
149
149
  children: supportingText
150
150
  }), actions && _jsx("div", {
151
151
  className: "ncua-floating-notification__actions",
152
- children: actions.map(action => _jsx(Button, {
153
- size: "xs",
154
- hierarchy: action.hierarchy || 'text',
155
- label: action.label,
156
- onClick: action?.onClick
157
- }, `${action.label}-${action.hierarchy}`))
152
+ children: _jsx(NotificationActionButtons, {
153
+ actions: actions
154
+ })
158
155
  })]
159
156
  })]
160
157
  })
@@ -4,6 +4,9 @@ import classNames from 'classnames';
4
4
  import { forwardRef, useEffect, useRef, useState } from 'react';
5
5
  import { COLOR } from '../../../../constant/color';
6
6
  import { HIDE_PERMANENTLY_LABEL } from '../../../../constant/notification';
7
+ import { isSafeUrl } from '../../../utils/url';
8
+ import { getLinkTargetAttributes } from './linkTarget';
9
+ import { NotificationActionButtons } from './NotificationActionButtons';
7
10
  const iconMap = {
8
11
  neutral: MessageChatSquare,
9
12
  error: AlertTriangle,
@@ -28,11 +31,14 @@ const FullWidthNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
28
31
  actions,
29
32
  autoClose = 0,
30
33
  supportTextLink,
34
+ supportTextLinkTarget,
31
35
  onHidePermanently,
32
36
  hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.fullWidth,
33
37
  ...rest
34
38
  } = _ref;
35
39
  const [shouldRemove, setShouldRemove] = useState(false);
40
+ // unsafe URL(javascript: 등)이면 링크 없이 텍스트만 렌더한다 — Vanilla `renderSupportingText` 와 같은 정책.
41
+ const safeSupportTextLink = supportTextLink && isSafeUrl(supportTextLink) ? supportTextLink : undefined;
36
42
  const closeIconSize = {
37
43
  width: 20,
38
44
  height: 20
@@ -86,12 +92,13 @@ const FullWidthNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
86
92
  children: [_jsx("span", {
87
93
  className: "ncua-full-width-notification__title",
88
94
  children: title
89
- }), !supportTextLink && supportingText && _jsx("span", {
95
+ }), !safeSupportTextLink && supportingText && _jsx("span", {
90
96
  className: "ncua-full-width-notification__supporting-text",
91
97
  children: supportingText
92
- }), supportTextLink && supportingText && _jsx("a", {
93
- href: supportTextLink,
98
+ }), safeSupportTextLink && supportingText && _jsx("a", {
99
+ href: safeSupportTextLink,
94
100
  className: "ncua-full-width-notification__link",
101
+ ...getLinkTargetAttributes(supportTextLinkTarget),
95
102
  children: _jsx("span", {
96
103
  className: "ncua-full-width-notification__supporting-text",
97
104
  children: supportingText
@@ -102,11 +109,9 @@ const FullWidthNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
102
109
  className: "ncua-full-width-notification__actions-container",
103
110
  children: [actions && _jsx("div", {
104
111
  className: "ncua-full-width-notification__actions",
105
- children: actions.map(action => _jsx("button", {
106
- onClick: action.onClick,
107
- className: classNames('ncua-notification__action-button', `ncua-notification__action-button--${action.hierarchy || 'text'}`),
108
- children: action.label
109
- }, `${action.label}-${action.hierarchy}`))
112
+ children: _jsx(NotificationActionButtons, {
113
+ actions: actions
114
+ })
110
115
  }), onHidePermanently && _jsx("button", {
111
116
  type: "button",
112
117
  className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--text', 'ncua-full-width-notification__link'),
@@ -1,20 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { AlertCircle, AlertTriangle, CheckCircle, Pin02 } from '@ncds/ui-admin-icon';
2
+ import { AlertCircle, AlertTriangle, CheckCircle, InfoCircle, Pin02 } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
4
  import { forwardRef } from 'react';
5
- import { COLOR } from '../../../../constant/color';
5
+ import { MESSAGE_CLOSE_ICON_COLOR } from '../../../../constant/notification';
6
6
  import { MessageNotificationActions, MessageNotificationContent } from './MessageNotificationParts';
7
7
  const iconMap = {
8
8
  neutral: Pin02,
9
9
  error: AlertTriangle,
10
10
  warning: AlertCircle,
11
- success: CheckCircle
12
- };
13
- const iconColorMap = {
14
- neutral: 'gray700',
15
- error: 'red500',
16
- warning: 'orange500',
17
- success: 'green600'
11
+ success: CheckCircle,
12
+ // info 는 variant="block" 에서만 도달한다 (default 는 neutral 로 폴백).
13
+ info: InfoCircle
18
14
  };
19
15
  const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
20
16
  let {
@@ -27,13 +23,21 @@ const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
27
23
  actions,
28
24
  onHidePermanently,
29
25
  hidePermanentlyLabel,
26
+ variant = 'default',
27
+ supportTextLink,
28
+ supportTextLinkTarget,
30
29
  ...rest
31
30
  } = _ref;
32
- // message 타입은 neutral, error, warning, success 4가지 색상만 지원
33
- const validColor = color === 'info' ? 'neutral' : color;
31
+ const isBlock = variant === 'block';
32
+ // default 는 neutral, error, warning, success 4가지 색상만 지원 — info 는 block 에서만 유지한다.
33
+ const validColor = !isBlock && color === 'info' ? 'neutral' : color;
34
+ // FeaturedIcon 에는 info 색이 없다. block+info 의 violet 톤은 message SCSS 가 덮어쓴다.
35
+ const featuredIconColor = validColor === 'info' ? 'neutral' : validColor;
34
36
  return _jsx("div", {
35
37
  ref: ref,
36
- className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, className),
38
+ className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, {
39
+ 'ncua-message-notification--block': isBlock
40
+ }, className),
37
41
  role: "alert",
38
42
  ...rest,
39
43
  children: _jsx("div", {
@@ -43,15 +47,17 @@ const MessageNotification = /*#__PURE__*/forwardRef((_ref, ref) => {
43
47
  children: [_jsx(MessageNotificationContent, {
44
48
  title: title,
45
49
  supportingText: supportingText,
50
+ supportTextLink: isBlock ? supportTextLink : undefined,
51
+ supportTextLinkTarget: supportTextLinkTarget,
46
52
  icon: Icon || iconMap[validColor],
47
- iconColor: validColor,
48
- showIcon: Boolean(iconMap[validColor])
53
+ iconColor: featuredIconColor,
54
+ iconSize: isBlock ? 'sm' : 'md'
49
55
  }), _jsx(MessageNotificationActions, {
50
56
  actions: actions,
51
57
  onClose: onClose,
52
58
  onHidePermanently: onHidePermanently,
53
59
  hidePermanentlyLabel: hidePermanentlyLabel,
54
- closeIconColor: COLOR[iconColorMap[validColor] || 'gray700']
60
+ closeIconColor: MESSAGE_CLOSE_ICON_COLOR[validColor]
55
61
  })]
56
62
  })
57
63
  })
@@ -1,26 +1,34 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Pin02, XClose } from '@ncds/ui-admin-icon';
3
3
  import { HIDE_PERMANENTLY_LABEL } from '../../../../constant/notification';
4
- import { Button } from '../../action/button';
4
+ import { isSafeUrl } from '../../../utils/url';
5
5
  import { FeaturedIcon } from '../../image-and-icons/featured-icon/FeaturedIcon';
6
- /** 아이콘 + 제목/서포팅 텍스트 영역. */
6
+ import { getLinkTargetAttributes } from './linkTarget';
7
+ import { NotificationActionButtons } from './NotificationActionButtons';
8
+ /** 아이콘 + 제목/서포팅 텍스트(또는 텍스트 링크) 영역. */
7
9
  const MessageNotificationContent = _ref => {
8
10
  let {
9
11
  title,
10
12
  supportingText,
13
+ supportTextLink,
14
+ supportTextLinkTarget,
11
15
  icon,
12
16
  iconColor,
13
- showIcon
17
+ iconSize
14
18
  } = _ref;
15
19
  const featuredIconProps = {
16
20
  icon: icon || Pin02,
17
- size: 'md',
21
+ size: iconSize,
18
22
  color: iconColor,
19
23
  theme: 'light-circle'
20
24
  };
25
+ const supportingTextNode = supportingText && _jsx("span", {
26
+ className: "ncua-message-notification__supporting-text",
27
+ children: supportingText
28
+ });
21
29
  return _jsxs("div", {
22
30
  className: "ncua-message-notification__content-wrapper",
23
- children: [showIcon && _jsx(FeaturedIcon, {
31
+ children: [_jsx(FeaturedIcon, {
24
32
  ...featuredIconProps,
25
33
  className: "ncua-message-notification__icon"
26
34
  }), _jsxs("div", {
@@ -28,10 +36,12 @@ const MessageNotificationContent = _ref => {
28
36
  children: [_jsx("span", {
29
37
  className: "ncua-message-notification__title",
30
38
  children: title
31
- }), supportingText && _jsx("span", {
32
- className: "ncua-message-notification__supporting-text",
33
- children: supportingText
34
- })]
39
+ }), supportTextLink && isSafeUrl(supportTextLink) && supportingTextNode ? _jsx("a", {
40
+ href: supportTextLink,
41
+ className: "ncua-message-notification__link",
42
+ ...getLinkTargetAttributes(supportTextLinkTarget),
43
+ children: supportingTextNode
44
+ }) : supportingTextNode]
35
45
  })]
36
46
  });
37
47
  };
@@ -55,12 +65,9 @@ const MessageNotificationActions = _ref2 => {
55
65
  className: "ncua-message-notification__actions-container",
56
66
  children: [hasActions && _jsx("div", {
57
67
  className: "ncua-message-notification__actions",
58
- children: actions?.map(action => _jsx(Button, {
59
- size: "xs",
60
- hierarchy: action.hierarchy || 'text',
61
- label: action.label,
62
- onClick: action?.onClick
63
- }, `${action.label}-${action.hierarchy}`))
68
+ children: _jsx(NotificationActionButtons, {
69
+ actions: actions ?? []
70
+ })
64
71
  }), hasFooter && _jsxs("div", {
65
72
  className: "ncua-message-notification__footer-container",
66
73
  children: [onHidePermanently && _jsx("button", {
@@ -13,6 +13,8 @@ const Notification = /*#__PURE__*/forwardRef(
13
13
  color = 'neutral',
14
14
  width,
15
15
  supportTextLink,
16
+ supportTextLinkTarget,
17
+ variant,
16
18
  ...rest
17
19
  } = _ref;
18
20
  if (type === 'floating') {
@@ -26,6 +28,7 @@ const Notification = /*#__PURE__*/forwardRef(
26
28
  return _jsx(FullWidthNotification, {
27
29
  color: color,
28
30
  supportTextLink: supportTextLink,
31
+ supportTextLinkTarget: supportTextLinkTarget,
29
32
  ...rest,
30
33
  ref: ref
31
34
  });
@@ -33,6 +36,9 @@ const Notification = /*#__PURE__*/forwardRef(
33
36
  if (type === 'message') {
34
37
  return _jsx(MessageNotification, {
35
38
  color: color,
39
+ variant: variant,
40
+ supportTextLink: supportTextLink,
41
+ supportTextLinkTarget: supportTextLinkTarget,
36
42
  ...rest,
37
43
  ref: ref
38
44
  });
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Button } from '../../action/button';
3
+ /**
4
+ * 알림 액션 버튼 목록. 모든 type 이 같은 규격(`ncua-btn--xs` + hierarchy, 기본 `text`)을 쓴다(#535).
5
+ * Vanilla `renderActions`(assets/scripts/notification/utils.ts)와 같은 클래스를 산출해야 한다.
6
+ * 감싸는 래퍼(`__actions`)는 type 마다 클래스가 달라 호출부가 담당한다.
7
+ */
8
+ const NotificationActionButtons = _ref => {
9
+ let {
10
+ actions
11
+ } = _ref;
12
+ return _jsx(_Fragment, {
13
+ children: actions.map(action => _jsx(Button, {
14
+ size: "xs",
15
+ hierarchy: action.hierarchy || 'text',
16
+ label: action.label,
17
+ onClick: action.onClick
18
+ }, `${action.label}-${action.hierarchy}`))
19
+ });
20
+ };
21
+ export { NotificationActionButtons };
@@ -0,0 +1,55 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import { FullWidthNotification } from '../FullWidthNotification';
7
+ import { MessageNotification } from '../MessageNotification';
8
+ /**
9
+ * React 텍스트 링크(`supportTextLink`)의 프로토콜 검사 (#538 리뷰 H2).
10
+ * Vanilla `renderSupportingText` 와 같은 정책 — unsafe URL 이면 링크 없이 텍스트만 렌더한다.
11
+ */
12
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
13
+ const cleanups = [];
14
+ afterEach(() => {
15
+ for (const cleanup of cleanups.splice(0)) cleanup();
16
+ });
17
+ function mount(element) {
18
+ const container = document.createElement('div');
19
+ document.body.appendChild(container);
20
+ const root = createRoot(container);
21
+ act(() => {
22
+ root.render(element);
23
+ });
24
+ cleanups.push(() => {
25
+ act(() => root.unmount());
26
+ container.remove();
27
+ });
28
+ return container;
29
+ }
30
+ const IMPLEMENTATIONS = [['MessageNotification(block)', supportTextLink => mount(/*#__PURE__*/createElement(MessageNotification, {
31
+ title: '공지',
32
+ variant: 'block',
33
+ supportingText: '자세히 보기',
34
+ supportTextLink
35
+ })), '.ncua-message-notification__supporting-text'], ['FullWidthNotification', supportTextLink => mount(/*#__PURE__*/createElement(FullWidthNotification, {
36
+ title: '공지',
37
+ supportingText: '자세히 보기',
38
+ supportTextLink
39
+ })), '.ncua-full-width-notification__supporting-text']];
40
+ const UNSAFE = ['javascript:alert(1)', ' JavaScript:alert(1)', 'JAVASCRIPT:alert(1)', '\tjavascript:alert(1)', 'data:text/html,<script>alert(1)</script>'];
41
+ const SAFE = ['https://example.com/notice/1', 'http://example.com/notice/1', '/notice/1', 'notice/1'];
42
+ describe.each(IMPLEMENTATIONS)('%s — supportTextLink 프로토콜 검사', (_, render, supportingTextSelector) => {
43
+ it.each(UNSAFE)('unsafe %j → 링크 없이 텍스트만 렌더한다', url => {
44
+ const container = render(url);
45
+ expect(container.querySelector('a')).toBeNull();
46
+ expect(container.querySelector(supportingTextSelector)?.textContent).toBe('자세히 보기');
47
+ });
48
+ it.each(SAFE)('safe %j → 그대로 링크로 렌더한다', url => {
49
+ const container = render(url);
50
+ const anchors = container.querySelectorAll('a');
51
+ expect(anchors).toHaveLength(1);
52
+ expect(anchors[0].getAttribute('href')).toBe(url);
53
+ expect(anchors[0].querySelector(supportingTextSelector)?.textContent).toBe('자세히 보기');
54
+ });
55
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 텍스트 링크(`supportTextLink`)의 `<a>`에 붙일 target·rel 속성.
3
+ *
4
+ * `'_blank'`일 때만 새 탭 + `rel="noopener noreferrer"`를 반환하고, `'_self'`(기본)는 아무 속성도 붙이지 않는다.
5
+ * Vanilla `applySupportTextLinkTarget`과 같은 마크업을 산출해야 한다.
6
+ */
7
+ export function getLinkTargetAttributes(target) {
8
+ return target === '_blank' ? {
9
+ target: '_blank',
10
+ rel: 'noopener noreferrer'
11
+ } : undefined;
12
+ }