@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
@@ -7,7 +7,7 @@ import { sideSlotRender } from './utils';
7
7
  */
8
8
  const BADGE_ICON_SIZE = 12;
9
9
  /** new-badge 박스 안에 들어가는 'N' 아이콘 크기. 박스는 sm 16px / md 20px 이며 아이콘은 그보다 작다(패딩 포함). */
10
- const NEW_BADGE_ICON_SIZE = { sm: 12, md: 16 };
10
+ const NEW_BADGE_ICON_SIZE = { sm: 12, md: 14 };
11
11
  const Badge = ({ label, type = 'pill-outline', color = 'neutral', className, leadingIcon, trailingIcon, size = 'xs', }) => {
12
12
  // new-badge: 신규 콘텐츠 'N' 마크 전용 타입. label·color·leadingIcon·trailingIcon은 무시되고
13
13
  // 색은 --pink-600 으로 고정된다. (디자이너 명세: Icon/FeaturedIcon/Badge로 대체 불가한 전용 표시)
@@ -10,7 +10,7 @@ interface ComboBoxProps extends Omit<ComponentPropsWithRef<'div'>, 'size' | 'onC
10
10
  destructive?: boolean;
11
11
  size?: Extract<Size, 'xs' | 'sm'>;
12
12
  optionItems?: OptionType[];
13
- value?: OptionValue | null;
13
+ value?: OptionValue;
14
14
  onChange?: OptionChangeHandler;
15
15
  onSearch?: (searchValue: string) => void;
16
16
  disabled?: boolean;
@@ -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,108 +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)
31
- return JSON.stringify(value || []);
32
- return isEmptyValue(value) ? '' : String(value);
33
- };
34
- /** SelectDropdown 은 null 을 받지 않으므로 undefined 로 바꾼다 */
35
- const toDropdownValue = (value) => value ?? undefined;
36
- /** multiple 용 — 배열이 아니면(null · undefined 포함) 빈 배열 */
37
- const toIdArray = (value) => (Array.isArray(value) ? value : []);
38
- /** 두 id 목록이 같은 id 들로 이루어졌는지 — 순서는 보지 않는다 (사용처가 onChange 값을 정렬해 돌려줘도 같은 값) */
39
- const isSameIds = (currentIds, nextIds) => currentIds.length === nextIds.length && currentIds.every((id) => nextIds.includes(id));
40
- const isSameTags = (nextTags, prevTags) => nextTags.length === prevTags.length &&
41
- nextTags.every((tag, index) => tag.id === prevTags[index].id && tag.label === prevTags[index].label);
42
- const findOption = (optionItems, id) => optionItems.find((option) => option.id === id);
43
- /** 단일 모드 value 에 해당하는 옵션 — 빈 값·배열이면 없음 */
44
- const findSingleOption = (value, optionItems) => {
45
- if (isEmptyValue(value) || Array.isArray(value))
46
- return undefined;
47
- return findOption(optionItems, value);
48
- };
49
- /**
50
- * 단일 모드 Tag — value 와 optionItems 에서 계산한다.
51
- * 검색 필터로 목록에서 빠진 경우에만, 같은 id 로 기억해 둔 Tag 를 대신 쓴다.
52
- */
53
- const resolveSingleTag = (value, optionItems, lastResolvedTag) => {
54
- if (isEmptyValue(value) || Array.isArray(value))
55
- return null;
56
- const found = findOption(optionItems, value);
57
- if (found)
58
- return { id: found.id, label: found.label };
59
- return lastResolvedTag?.id === value ? lastResolvedTag : null;
60
- };
61
- /** 단일 모드 Tag 목록 (0~1개). 목록에서 찾은 Tag 를 기억해 검색 필터 중에도 라벨을 유지한다 */
62
- const useSingleSelectedTags = (value, optionItems, enabled) => {
63
- const [lastResolvedTag, setLastResolvedTag] = useState(null);
64
- const found = enabled ? findSingleOption(value, optionItems) : undefined;
65
- const foundId = found?.id;
66
- const foundLabel = found?.label;
67
- useEffect(() => {
68
- if (foundId === undefined || foundLabel === undefined)
69
- return;
70
- setLastResolvedTag({ id: foundId, label: foundLabel });
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
78
- .map((id) => {
79
- const found = findOption(optionItems, id);
80
- return found ? { id: found.id, label: found.label } : prevTags.find((tag) => tag.id === id);
81
- })
82
- .filter((tag) => tag !== undefined);
83
- // 결과가 같으면 이전 배열을 그대로 돌려준다 — 새 배열로 바꾸면 effect 가 다시 렌더를 일으킨다
84
- return isSameTags(nextTags, prevTags) ? prevTags : nextTags;
85
- };
86
- /**
87
- * multiple 모드 Tag — 「선택 완료」·X 클릭·외부 value 변경 때만 확정 id 를 바꾼다.
88
- * ComboBox 가 직접 onChange 로 내보낸 값은 markEmitted 로 기록해 두고, 그 값이 value 로 돌아오면 한 번만 무시한다(확정 아님).
89
- * 바깥 값이 들어오면 남아 있던 기록도 지운다 — 오래된 기록 때문에 이후 같은 바깥 값이 무시되지 않게 하기 위함.
90
- * optionItems 만 바뀌면 확정 id 는 그대로 두고 라벨만 다시 찾는다 — 「선택 완료」 전 검색으로 Tag 가 확정되지 않게 하기 위함.
91
- */
92
- const useMultipleSelectedTags = (value, optionItems, enabled) => {
93
- const [selectedTags, setSelectedTags] = useState([]);
94
- const lastEmittedRef = useRef(null);
95
- const committedIdsRef = useRef([]);
96
- const prevValueIdsRef = useRef(null);
97
- useEffect(() => {
98
- if (!enabled)
99
- 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
- }
107
- else if (isValueChanged) {
108
- committedIdsRef.current = ids;
109
- lastEmittedRef.current = null;
110
- }
111
- setSelectedTags((prev) => resolveMultipleTags(committedIdsRef.current, optionItems, prev));
112
- }, [enabled, value, optionItems]);
113
- /** 「선택 완료」로 Tag 를 확정한다 */
114
- const commitTags = (ids) => {
115
- committedIdsRef.current = ids;
116
- setSelectedTags((prev) => resolveMultipleTags(ids, optionItems, prev));
117
- };
118
- /** X 클릭 — 확정 id 에서 그 id 만 뺀다. value 기준으로 계산하면 「선택 완료」 전에 클릭한 항목까지 확정된다 */
119
- const removeCommittedTag = (tagId) => {
120
- commitTags(committedIdsRef.current.filter((id) => id !== tagId));
121
- };
122
- /** ComboBox 가 onChange 로 내보낸 값을 기록한다 — value 로 돌아왔을 때 확정으로 보지 않기 위함 */
123
- const markEmitted = (ids) => {
124
- lastEmittedRef.current = ids;
125
- };
126
- return { selectedTags, commitTags, removeCommittedTag, markEmitted };
127
- };
128
26
  const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, hintText, children, size = 'xs', destructive = false, value, optionItems = [], onChange, onSearch, disabled = false, register, maxHeight = defaultMaxHeight, searchValue = '', label, required = false, multiple = false, showFooterButtons = false, maxSelection, onEdit, onComplete, usePortal = false, ...props }, ref) => {
129
27
  const internalRef = useRef(null);
130
28
  // 인라인 패널의 offset parent 가 __content 다. Portal 좌표도 같은 박스를 기준으로 잡아야
@@ -135,8 +33,7 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
135
33
  const { shouldPortal, portalContainer } = usePortalState(usePortal);
136
34
  const [inputValue, setInputValue] = useState(searchValue);
137
35
  const [showAllItems, setShowAllItems] = useState(false);
138
- const singleTags = useSingleSelectedTags(value, optionItems, !multiple);
139
- const { selectedTags, commitTags, removeCommittedTag, markEmitted } = useMultipleSelectedTags(value, optionItems, multiple);
36
+ const [selectedTags, setSelectedTags] = useState([]);
140
37
  const handleOptionSelect = (option) => {
141
38
  if (disabled)
142
39
  return;
@@ -144,12 +41,13 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
144
41
  const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
145
42
  if (newValue === null)
146
43
  return;
147
- markEmitted(newValue);
148
44
  onChange?.(newValue);
149
45
  notifyFormChange(register, multiple, newValue);
150
46
  return;
151
47
  }
152
48
  const newValue = option.id;
49
+ const tag = { id: option.id, label: option.label };
50
+ setSelectedTags([tag]);
153
51
  onChange?.(newValue);
154
52
  notifyFormChange(register, multiple, newValue);
155
53
  };
@@ -232,12 +130,11 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
232
130
  color: 'gray300',
233
131
  };
234
132
  const currentSelectedValues = multiple && Array.isArray(value) ? value : [];
235
- const { buttonText: selectAllButtonText, toggleSelectAll, removeTag, isMaxSelectionActive, tryToggle, } = useMultiSelect(currentSelectedValues, optionItems, { maxSelection });
133
+ const { buttonText: selectAllButtonText, toggleSelectAll, getSelectedTagsData, removeTag, isMaxSelectionActive, tryToggle, } = useMultiSelect(currentSelectedValues, optionItems, { maxSelection });
236
134
  const handleSelectAll = () => {
237
135
  if (!multiple || !onChange)
238
136
  return;
239
137
  const newSelectedValues = toggleSelectAll();
240
- markEmitted(newSelectedValues);
241
138
  onChange(newSelectedValues);
242
139
  };
243
140
  const handleEdit = () => {
@@ -246,36 +143,33 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
246
143
  const handleComplete = (completedValue) => {
247
144
  if (!multiple)
248
145
  return;
249
- commitTags(toIdArray(completedValue));
146
+ const tags = getSelectedTagsData();
147
+ setSelectedTags(tags);
250
148
  setInputValue('');
251
149
  closeDropdown();
252
150
  onComplete?.(completedValue);
253
151
  };
254
152
  const handleRemoveTag = (tagId) => {
255
- // X 버튼은 disabled 에서 숨기지만, 다른 호출 경로가 생겨도 값이 지워지지 않게 함수에서도 막는다
256
- if (disabled)
257
- return;
258
153
  if (!onChange)
259
154
  return;
260
155
  if (multiple) {
261
156
  const newSelectedValues = removeTag(tagId);
262
- markEmitted(newSelectedValues);
263
157
  onChange(newSelectedValues);
264
- removeCommittedTag(tagId);
158
+ setSelectedTags((prev) => prev.filter((tag) => tag.id !== tagId));
265
159
  return;
266
160
  }
267
161
  onChange('');
162
+ setSelectedTags([]);
268
163
  };
269
164
  const comboboxId = `combobox-options-${id || 'default'}`;
270
165
  const hasVisibleOptions = showAllItems || inputValue;
271
166
  const displayedOptions = hasVisibleOptions ? optionItems : [];
272
167
  const showNoResults = !hasVisibleOptions || optionItems.length === 0;
273
168
  const showFooter = multiple || showFooterButtons;
274
- const hiddenInputValue = toHiddenInputValue(value, multiple);
275
- const tags = multiple ? selectedTags : singleTags;
276
- const hasTags = tags.length > 0;
169
+ const hiddenInputValue = multiple ? JSON.stringify(value || []) : String(value || '');
170
+ const hasTags = selectedTags.length > 0;
277
171
  const { className: portalPanelClassName, style: portalPanelStyle } = resolvePortalPanelBinding(shouldPortal, floatingStyle);
278
- const selectDropdownNode = (_jsxs(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: displayedOptions, value: toDropdownValue(value), focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: comboboxId, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, isKeyboardNavigation: isKeyboardNavigation, multiple: multiple, showFooterButtons: showFooter, selectAllButtonText: selectAllButtonText, showSelectAllAction: !isMaxSelectionActive, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, componentType: "combobox", className: portalPanelClassName, style: portalPanelStyle, children: [showNoResults ? _jsx("li", { className: "ncua-select-dropdown__no-results", children: "\uC77C\uCE58\uD558\uB294 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) : null, children] }));
172
+ const selectDropdownNode = (_jsxs(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: displayedOptions, value: value, focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: comboboxId, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, isKeyboardNavigation: isKeyboardNavigation, multiple: multiple, showFooterButtons: showFooter, selectAllButtonText: selectAllButtonText, showSelectAllAction: !isMaxSelectionActive, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, componentType: "combobox", className: portalPanelClassName, style: portalPanelStyle, children: [showNoResults ? _jsx("li", { className: "ncua-select-dropdown__no-results", children: "\uC77C\uCE58\uD558\uB294 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) : null, children] }));
279
173
  const { inlineDropdown, portaledDropdown } = resolveDropdownMounts({
280
174
  node: selectDropdownNode,
281
175
  shouldPortal,
@@ -289,7 +183,7 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
289
183
  }, className), ...props, children: [_jsxs("div", { ref: contentRef, className: "ncua-combobox__content", children: [_jsx(InputBase, { ref: inputRef, size: size, label: label, required: required, placeholder: placeholder, value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onClick: handleInputClick, disabled: disabled, destructive: destructive, leadingElement: leadingElement, trailingElement: trailingElement, role: "combobox", "aria-expanded": isOpen, "aria-haspopup": "listbox", "aria-controls": comboboxId, "aria-disabled": disabled, autoComplete: "off", clearText: !!inputValue, onClearText: () => {
290
184
  setInputValue('');
291
185
  onSearch?.('');
292
- } }), inlineDropdown, portaledDropdown] }), register && _jsx("input", { type: "hidden", ...register, value: hiddenInputValue }), hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), hasTags && (_jsx("div", { className: "ncua-combobox__tags", children: tags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: !disabled, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
186
+ } }), inlineDropdown, portaledDropdown] }), register && _jsx("input", { type: "hidden", ...register, value: hiddenInputValue }), hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), hasTags && (_jsx("div", { className: "ncua-combobox__tags", children: selectedTags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: true, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
293
187
  });
294
188
  ComboBox.displayName = 'ComboBox';
295
189
  export { defaultMaxHeight, ComboBox };
@@ -333,7 +333,7 @@ describe('C-9 기본 경로 회귀 기준선', () => {
333
333
  expect(onSearch).toHaveBeenCalledWith('사');
334
334
  expect(view.content.querySelectorAll('.ncua-select-dropdown__option')).toHaveLength(createOptions().length);
335
335
  });
336
- it('옵션을 선택하면 onChange 가 호출된다', () => {
336
+ it('옵션을 선택하면 onChange 가 호출되고 선택 태그가 표시된다', () => {
337
337
  // Given: 옵션이 노출된 기본 모드 ComboBox 에서
338
338
  const onChange = vi.fn();
339
339
  const view = mountComboBox({ props: { onChange } });
@@ -342,9 +342,9 @@ describe('C-9 기본 경로 회귀 기준선', () => {
342
342
  act(() => {
343
343
  requireElement(view.content, '.ncua-select-dropdown__option').click();
344
344
  });
345
- // Then: onChange 가 선택 값으로 호출된다
346
- // 선택 태그는 value 를 따르므로(#530) onChange 값을 value 로 돌려주는 경우의 태그 표시는 ComboBox.test.ts S-10 에서 검증한다
345
+ // Then: onChange 가 선택 값으로 호출되고 태그가 표시된다
347
346
  expect(onChange).toHaveBeenCalledWith('apple');
347
+ expect(view.container.querySelectorAll('.ncua-combobox__tags .ncua-tag')).toHaveLength(1);
348
348
  });
349
349
  it('Escape 를 누르면 인라인 패널이 닫힌다', () => {
350
350
  // 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": 즉시 발화)
@@ -110,7 +110,7 @@ const FloatingNotification = forwardRef(({ title, supportingText, color = 'neutr
110
110
  const card = (
111
111
  // a11y/예측가능성 — caller 가 className/id 등 일부는 덮어쓸 수 있게 {...rest} 를 먼저 펼치고,
112
112
  // role/aria-labelledby/aria-describedby 같은 컴포넌트 본질 prop 은 그 뒤에 두어 우선 적용.
113
- _jsxs("div", { ...rest, ref: ref, className: classNames('ncua-floating-notification', `ncua-floating-notification--${color}`, className), role: role, "aria-labelledby": titleId, "aria-describedby": supportingText ? descId : undefined, children: [_jsx("div", { className: "ncua-floating-notification__content", children: _jsxs("div", { className: "ncua-floating-notification__container", children: [iconMap[color] && _jsx(FeaturedIcon, { ...featuredIconProps, size: isMobile ? 'md' : 'sm' }), _jsxs("div", { className: "ncua-floating-notification__text-container", children: [_jsx("div", { className: "ncua-floating-notification__title-wrapper", children: _jsx("span", { id: titleId, className: "ncua-floating-notification__title", children: title }) }), supportingText && (_jsx("span", { id: descId, className: "ncua-floating-notification__supporting-text", children: supportingText })), actions && (_jsx("div", { className: "ncua-floating-notification__actions", children: actions.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action?.onClick }, `${action.label}-${action.hierarchy}`))) }))] })] }) }), onClose && (_jsx(ButtonCloseX, { theme: "light", className: "ncua-floating-notification__close-button", size: isMobile ? 'sm' : 'xs', onClick: onClose }))] }));
113
+ _jsxs("div", { ...rest, ref: ref, className: classNames('ncua-floating-notification', `ncua-floating-notification--${color}`, className), role: role, "aria-labelledby": titleId, "aria-describedby": supportingText ? descId : undefined, children: [_jsx("div", { className: "ncua-floating-notification__content", children: _jsxs("div", { className: "ncua-floating-notification__container", children: [iconMap[color] && _jsx(FeaturedIcon, { ...featuredIconProps, size: isMobile ? 'md' : 'sm' }), _jsxs("div", { className: "ncua-floating-notification__text-container", children: [_jsx("div", { className: "ncua-floating-notification__title-wrapper", children: _jsx("span", { id: titleId, className: "ncua-floating-notification__title", children: title }) }), supportingText && (_jsx("span", { id: descId, className: "ncua-floating-notification__supporting-text", children: supportingText })), actions && (_jsx("div", { className: "ncua-floating-notification__actions", children: _jsx(NotificationActionButtons, { actions: actions }) }))] })] }) }), onClose && (_jsx(ButtonCloseX, { theme: "light", className: "ncua-floating-notification__close-button", size: isMobile ? 'sm' : 'xs', onClick: onClose }))] }));
114
114
  // 기본은 인라인 렌더 — 부모 JSX 트리에 카드를 그대로 둔다.
115
115
  if (!portal) {
116
116
  return card;
@@ -1,5 +1,5 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
- import type { NotificationAction, NotificationColor } from './Notification';
2
+ import type { NotificationAction, NotificationColor, NotificationLinkTarget } from './Notification';
3
3
  interface FullWidthNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
4
4
  /**
5
5
  * 알림 제목 텍스트
@@ -36,6 +36,11 @@ interface FullWidthNotificationProps extends Omit<ComponentPropsWithoutRef<'div'
36
36
  * info 링크 영역 (선택사항)
37
37
  */
38
38
  supportTextLink?: string;
39
+ /**
40
+ * 텍스트 링크를 여는 대상 (선택사항). `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`
41
+ * @default '_self'
42
+ */
43
+ supportTextLinkTarget?: NotificationLinkTarget;
39
44
  /**
40
45
  * 다시보지 않기 이벤트 핸들러 (선택사항)
41
46
  */
@@ -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,
@@ -18,8 +21,10 @@ const iconColorMap = {
18
21
  success: 'green600',
19
22
  info: 'violet600',
20
23
  };
21
- const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, supportTextLink, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.fullWidth, ...rest }, ref) => {
24
+ const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neutral', onClose, className, actions, autoClose = 0, supportTextLink, supportTextLinkTarget, onHidePermanently, hidePermanentlyLabel = HIDE_PERMANENTLY_LABEL.fullWidth, ...rest }, ref) => {
22
25
  const [shouldRemove, setShouldRemove] = useState(false);
26
+ // unsafe URL(javascript: 등)이면 링크 없이 텍스트만 렌더한다 — Vanilla `renderSupportingText` 와 같은 정책.
27
+ const safeSupportTextLink = supportTextLink && isSafeUrl(supportTextLink) ? supportTextLink : undefined;
23
28
  const closeIconSize = {
24
29
  width: 20,
25
30
  height: 20,
@@ -52,6 +57,6 @@ const FullWidthNotification = forwardRef(({ title, supportingText, color = 'neut
52
57
  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: [(() => {
53
58
  const IconComponent = iconMap[color];
54
59
  return (_jsx(IconComponent, { width: 16, height: 16, color: COLOR[iconColorMap[color]], className: "ncua-full-width-notification__icon" }));
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]] }) }))] })] }) }) }));
60
+ })(), _jsxs("div", { className: "ncua-full-width-notification__text-container", children: [_jsx("span", { className: "ncua-full-width-notification__title", children: title }), !safeSupportTextLink && supportingText && (_jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText })), safeSupportTextLink && supportingText && (_jsx("a", { href: safeSupportTextLink, className: "ncua-full-width-notification__link", ...getLinkTargetAttributes(supportTextLinkTarget), children: _jsx("span", { className: "ncua-full-width-notification__supporting-text", children: supportingText }) }))] })] }), _jsxs("div", { className: "ncua-full-width-notification__actions-container", children: [actions && (_jsx("div", { className: "ncua-full-width-notification__actions", children: _jsx(NotificationActionButtons, { actions: actions }) })), onHidePermanently && (_jsx("button", { type: "button", className: classNames('ncua-notification__action-button', 'ncua-notification__action-button--text', 'ncua-full-width-notification__link'), onClick: onHidePermanently, children: hidePermanentlyLabel })), onClose && (_jsx("button", { type: "button", className: "ncua-full-width-notification__close-button", onClick: onClose, "aria-label": "\uC54C\uB9BC \uB2EB\uAE30", children: _jsx(XClose, { ...closeIconSize, color: COLOR[iconColorMap[color]] }) }))] })] }) }) }));
56
61
  });
57
62
  export { FullWidthNotification };
@@ -1,6 +1,6 @@
1
1
  import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
2
2
  import type { SlotIconComponent } from '../../../types/side-slot';
3
- import type { NotificationAction, NotificationColor } from './Notification';
3
+ import type { NotificationAction, NotificationColor, NotificationLinkTarget, NotificationVariant } from './Notification';
4
4
  interface MessageNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title'> {
5
5
  /**
6
6
  * 알림 제목 텍스트
@@ -41,6 +41,20 @@ interface MessageNotificationProps extends Omit<ComponentPropsWithoutRef<'div'>,
41
41
  * @default '다시 보지 않기'
42
42
  */
43
43
  hidePermanentlyLabel?: ReactNode;
44
+ /**
45
+ * 표시 변형. `'block'`은 서브 페이지 공지용(흰 배경, FeaturedIcon sm, 제목 + 텍스트 링크 1행, info 유지)
46
+ * @default 'default'
47
+ */
48
+ variant?: NotificationVariant;
49
+ /**
50
+ * 텍스트 링크 URL. `variant="block"`에서만 동작하며 `supportingText`가 링크 텍스트가 된다.
51
+ */
52
+ supportTextLink?: string;
53
+ /**
54
+ * 텍스트 링크를 여는 대상. `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`
55
+ * @default '_self'
56
+ */
57
+ supportTextLinkTarget?: NotificationLinkTarget;
44
58
  }
45
59
  declare const MessageNotification: import("react").ForwardRefExoticComponent<MessageNotificationProps & import("react").RefAttributes<HTMLDivElement>>;
46
60
  export type { MessageNotificationProps };
@@ -1,25 +1,24 @@
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
11
  success: CheckCircle,
12
+ // info 는 variant="block" 에서만 도달한다 (default 는 neutral 로 폴백).
13
+ info: InfoCircle,
12
14
  };
13
- const iconColorMap = {
14
- neutral: 'gray700',
15
- error: 'red500',
16
- warning: 'orange500',
17
- success: 'green600',
18
- };
19
- const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, className, actions, onHidePermanently, hidePermanentlyLabel, ...rest }, ref) => {
20
- // message 타입은 neutral, error, warning, success 4가지 색상만 지원
21
- const validColor = color === 'info' ? 'neutral' : color;
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'] })] }) }) }));
15
+ const MessageNotification = forwardRef(({ title, supportingText, icon: Icon, color = 'neutral', onClose, className, actions, onHidePermanently, hidePermanentlyLabel, variant = 'default', supportTextLink, supportTextLinkTarget, ...rest }, ref) => {
16
+ const isBlock = variant === 'block';
17
+ // default 는 neutral, error, warning, success 4가지 색상만 지원 — info 는 block 에서만 유지한다.
18
+ const validColor = !isBlock && color === 'info' ? 'neutral' : color;
19
+ // FeaturedIcon 에는 info 색이 없다. block+info 의 violet 톤은 message SCSS 가 덮어쓴다.
20
+ const featuredIconColor = validColor === 'info' ? 'neutral' : validColor;
21
+ return (_jsx("div", { ref: ref, className: classNames('ncua-message-notification', `ncua-message-notification--${validColor}`, { 'ncua-message-notification--block': isBlock }, className), role: "alert", ...rest, children: _jsx("div", { className: "ncua-message-notification__container", children: _jsxs("div", { className: "ncua-message-notification__content", children: [_jsx(MessageNotificationContent, { title: title, supportingText: supportingText, supportTextLink: isBlock ? supportTextLink : undefined, supportTextLinkTarget: supportTextLinkTarget, icon: Icon || iconMap[validColor], iconColor: featuredIconColor, iconSize: isBlock ? 'sm' : 'md' }), _jsx(MessageNotificationActions, { actions: actions, onClose: onClose, onHidePermanently: onHidePermanently, hidePermanentlyLabel: hidePermanentlyLabel, closeIconColor: MESSAGE_CLOSE_ICON_COLOR[validColor] })] }) }) }));
23
22
  });
24
23
  MessageNotification.displayName = 'MessageNotification';
25
24
  export { MessageNotification };
@@ -1,16 +1,21 @@
1
1
  import type { ReactNode } from 'react';
2
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';
3
+ import { type FeaturedIconColor, type FeaturedIconSize } from '../../image-and-icons/featured-icon/FeaturedIcon';
4
+ import type { NotificationAction, NotificationLinkTarget } from './Notification';
5
5
  interface MessageNotificationContentProps {
6
6
  title: ReactNode;
7
7
  supportingText?: ReactNode;
8
+ /** 지정 시 `supportingText`를 이 URL 의 텍스트 링크로 렌더한다 (block 변형 전용). http/https·상대경로가 아니면 텍스트만 렌더. */
9
+ supportTextLink?: string;
10
+ /** 텍스트 링크를 여는 대상. `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`. */
11
+ supportTextLinkTarget?: NotificationLinkTarget;
8
12
  icon?: SlotIconComponent;
9
13
  iconColor: FeaturedIconColor;
10
- showIcon: boolean;
14
+ /** default 는 md(40/20), block 은 sm(32/16). */
15
+ iconSize: FeaturedIconSize;
11
16
  }
12
- /** 아이콘 + 제목/서포팅 텍스트 영역. */
13
- declare const MessageNotificationContent: ({ title, supportingText, icon, iconColor, showIcon, }: MessageNotificationContentProps) => import("react/jsx-runtime").JSX.Element;
17
+ /** 아이콘 + 제목/서포팅 텍스트(또는 텍스트 링크) 영역. */
18
+ declare const MessageNotificationContent: ({ title, supportingText, supportTextLink, supportTextLinkTarget, icon, iconColor, iconSize, }: MessageNotificationContentProps) => import("react/jsx-runtime").JSX.Element;
14
19
  interface MessageNotificationActionsProps {
15
20
  actions?: NotificationAction[];
16
21
  onClose?: () => void;
@@ -1,17 +1,20 @@
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
- /** 아이콘 + 제목/서포팅 텍스트 영역. */
7
- const MessageNotificationContent = ({ title, supportingText, icon, iconColor, showIcon, }) => {
6
+ import { getLinkTargetAttributes } from './linkTarget';
7
+ import { NotificationActionButtons } from './NotificationActionButtons';
8
+ /** 아이콘 + 제목/서포팅 텍스트(또는 텍스트 링크) 영역. */
9
+ const MessageNotificationContent = ({ title, supportingText, supportTextLink, supportTextLinkTarget, icon, iconColor, iconSize, }) => {
8
10
  const featuredIconProps = {
9
11
  icon: icon || Pin02,
10
- size: 'md',
12
+ size: iconSize,
11
13
  color: iconColor,
12
14
  theme: 'light-circle',
13
15
  };
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 })] })] }));
16
+ const supportingTextNode = supportingText && (_jsx("span", { className: "ncua-message-notification__supporting-text", children: supportingText }));
17
+ return (_jsxs("div", { className: "ncua-message-notification__content-wrapper", children: [_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 }), supportTextLink && isSafeUrl(supportTextLink) && supportingTextNode ? (_jsx("a", { href: supportTextLink, className: "ncua-message-notification__link", ...getLinkTargetAttributes(supportTextLinkTarget), children: supportingTextNode })) : (supportingTextNode)] })] }));
15
18
  };
16
19
  /**
17
20
  * 액션 버튼 그룹 + 닫기 그룹을 감싸는 우측 영역.
@@ -23,6 +26,6 @@ const MessageNotificationActions = ({ actions, onClose, onHidePermanently, hideP
23
26
  const hasFooter = Boolean(onHidePermanently || onClose);
24
27
  if (!hasActions && !hasFooter)
25
28
  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 }) }))] }))] }));
29
+ return (_jsxs("div", { className: "ncua-message-notification__actions-container", children: [hasActions && (_jsx("div", { className: "ncua-message-notification__actions", children: _jsx(NotificationActionButtons, { actions: actions ?? [] }) })), hasFooter && (_jsxs("div", { className: "ncua-message-notification__footer-container", children: [onHidePermanently && (_jsx("button", { type: "button", className: "ncua-notification__action-button ncua-message-notification__hide-link", onClick: onHidePermanently, children: hidePermanentlyLabel })), 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
30
  };
28
31
  export { MessageNotificationActions, MessageNotificationContent };
@@ -6,6 +6,9 @@ import { type CalloutNotificationProps, type CalloutNotificationWidth } from './
6
6
  type NotificationType = 'floating' | 'full-width' | 'message' | 'callout';
7
7
  type NotificationColor = Extract<ColorTone, 'neutral' | 'error' | 'warning' | 'success' | 'info'>;
8
8
  type NotificationSize = 'desktop' | 'mobile';
9
+ type NotificationVariant = 'default' | 'block';
10
+ /** 텍스트 링크(`supportTextLink`)를 여는 대상. `'_blank'`이면 새 탭 + `rel="noopener noreferrer"`. */
11
+ type NotificationLinkTarget = '_self' | '_blank';
9
12
  interface NotificationAction {
10
13
  /**
11
14
  * 액션 버튼 텍스트
@@ -78,9 +81,27 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
78
81
  hidePermanentlyLabel?: ReactNode;
79
82
  /**
80
83
  * supportingText 에 연결할 링크 URL (선택사항)
81
- * full-width 타입에서 사용 가능
84
+ * full-width 타입, message 타입의 `variant="block"`에서 사용 가능
82
85
  */
83
86
  supportTextLink?: string;
87
+ /**
88
+ * 텍스트 링크를 여는 대상 (선택사항). `supportTextLink`가 렌더될 때만 동작한다.
89
+ * `'_blank'`이면 새 탭으로 열고 `rel="noopener noreferrer"`를 함께 붙인다.
90
+ * full-width 타입, message 타입의 `variant="block"`에서 사용 가능
91
+ * @default '_self'
92
+ */
93
+ supportTextLinkTarget?: NotificationLinkTarget;
94
+ /**
95
+ * message 타입의 표시 변형 (선택사항). message 타입에서만 동작한다.
96
+ *
97
+ * - `'default'`: 기존 message (color 별 배경·테두리, 제목 + 본문 2행, info → neutral 폴백)
98
+ * - `'block'`: 서브 페이지 공지용. 흰 배경·gray-100 테두리·shadow-sm, FeaturedIcon sm,
99
+ * 제목 + 텍스트 링크 1행(`supportingText` 가 링크 텍스트, `supportTextLink` 가 href).
100
+ * `color="info"` 를 그대로 렌더한다.
101
+ *
102
+ * @default 'default'
103
+ */
104
+ variant?: NotificationVariant;
84
105
  /**
85
106
  * 너비 동작 (선택사항)
86
107
  * callout 타입에서만 사용 가능. 미지정 시 부모 너비를 채운다(100%). 'fit' 은 콘텐츠 너비에 맞춘다
@@ -101,6 +122,6 @@ interface NotificationProps extends Omit<ComponentPropsWithoutRef<'div'>, 'title
101
122
  portal?: boolean;
102
123
  }
103
124
  declare const Notification: import("react").ForwardRefExoticComponent<NotificationProps & import("react").RefAttributes<HTMLDivElement>>;
104
- export type { NotificationType, NotificationColor, NotificationSize, NotificationAction, NotificationProps };
125
+ export type { NotificationType, NotificationColor, NotificationSize, NotificationVariant, NotificationLinkTarget, NotificationAction, NotificationProps, };
105
126
  export type { CalloutNotificationProps, CalloutNotificationWidth };
106
127
  export { Notification };
@@ -7,15 +7,15 @@ import { MessageNotification } from './MessageNotification';
7
7
  const Notification = forwardRef(
8
8
  // 단일 type 에만 유효한 prop 은 rest 에서 분리해 해당 분기로만 명시 전달한다.
9
9
  // rest 에 남기면 다른 type 의 하위 컴포넌트가 div 로 스프레드해 DOM 속성으로 출력된다.
10
- ({ type = 'floating', color = 'neutral', width, supportTextLink, ...rest }, ref) => {
10
+ ({ type = 'floating', color = 'neutral', width, supportTextLink, supportTextLinkTarget, variant, ...rest }, ref) => {
11
11
  if (type === 'floating') {
12
12
  return _jsx(FloatingNotification, { color: color, ...rest, ref: ref });
13
13
  }
14
14
  if (type === 'full-width') {
15
- return (_jsx(FullWidthNotification, { color: color, supportTextLink: supportTextLink, ...rest, ref: ref }));
15
+ return (_jsx(FullWidthNotification, { color: color, supportTextLink: supportTextLink, supportTextLinkTarget: supportTextLinkTarget, ...rest, ref: ref }));
16
16
  }
17
17
  if (type === 'message') {
18
- return _jsx(MessageNotification, { color: color, ...rest, ref: ref });
18
+ return (_jsx(MessageNotification, { color: color, variant: variant, supportTextLink: supportTextLink, supportTextLinkTarget: supportTextLinkTarget, ...rest, ref: ref }));
19
19
  }
20
20
  if (type === 'callout') {
21
21
  // 나머지 3개 type 이 공유하는 prop 은 callout 이 지원하지 않으므로 여기서 제외한다.
@@ -0,0 +1,12 @@
1
+ import type { NotificationAction } from './Notification';
2
+ interface NotificationActionButtonsProps {
3
+ actions: NotificationAction[];
4
+ }
5
+ /**
6
+ * 알림 액션 버튼 목록. 모든 type 이 같은 규격(`ncua-btn--xs` + hierarchy, 기본 `text`)을 쓴다(#535).
7
+ * Vanilla `renderActions`(assets/scripts/notification/utils.ts)와 같은 클래스를 산출해야 한다.
8
+ * 감싸는 래퍼(`__actions`)는 type 마다 클래스가 달라 호출부가 담당한다.
9
+ */
10
+ declare const NotificationActionButtons: ({ actions }: NotificationActionButtonsProps) => import("react/jsx-runtime").JSX.Element;
11
+ export type { NotificationActionButtonsProps };
12
+ export { NotificationActionButtons };
@@ -0,0 +1,9 @@
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 = ({ actions }) => (_jsx(_Fragment, { children: actions.map((action) => (_jsx(Button, { size: "xs", hierarchy: action.hierarchy || 'text', label: action.label, onClick: action.onClick }, `${action.label}-${action.hierarchy}`))) }));
9
+ export { NotificationActionButtons };