@ncds/ui-admin 1.8.25-alpha.3 → 1.8.25

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 (23) hide show
  1. package/dist/cjs/src/components/feedback-and-status/badge/Badge.js +1 -1
  2. package/dist/cjs/src/components/forms-and-input/combo-box/ComboBox.js +126 -16
  3. package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
  4. package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +455 -0
  5. package/dist/cjs/src/generated/scoped-css.js +1 -1
  6. package/dist/esm/src/components/feedback-and-status/badge/Badge.js +1 -1
  7. package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +127 -17
  8. package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
  9. package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +452 -0
  10. package/dist/esm/src/generated/scoped-css.js +1 -1
  11. package/dist/temp/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  12. package/dist/temp/src/components/feedback-and-status/badge/Badge.js +1 -1
  13. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +1 -1
  14. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +119 -13
  15. package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
  16. package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.d.ts +1 -0
  17. package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +293 -0
  18. package/dist/temp/src/generated/scoped-css.js +1 -1
  19. package/dist/types/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  20. package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +1 -1
  21. package/dist/types/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.d.ts +1 -0
  22. package/dist/ui-admin/assets/styles/style.css +14 -12
  23. package/package.json +1 -1
@@ -16,7 +16,7 @@ const BADGE_ICON_SIZE = 12;
16
16
  /** new-badge 박스 안에 들어가는 'N' 아이콘 크기. 박스는 sm 16px / md 20px 이며 아이콘은 그보다 작다(패딩 포함). */
17
17
  const NEW_BADGE_ICON_SIZE = {
18
18
  sm: 12,
19
- md: 14
19
+ md: 16
20
20
  };
21
21
  const Badge = _ref => {
22
22
  let {
@@ -30,6 +30,112 @@ const notifyFormChange = (register, multiple, newValue) => {
30
30
  });
31
31
  }
32
32
  };
33
+ /** '' · undefined · null 이면 빈 값. 숫자 0은 빈 값이 아니다 */
34
+ const isEmptyValue = value => value === '' || value == null;
35
+ /** react-hook-form hidden input 값 — Tag 와 같은 빈 값 규칙(0은 값)을 따른다 */
36
+ const toHiddenInputValue = (value, multiple) => {
37
+ if (multiple) return JSON.stringify(value || []);
38
+ return isEmptyValue(value) ? '' : String(value);
39
+ };
40
+ /** SelectDropdown 은 null 을 받지 않으므로 undefined 로 바꾼다 */
41
+ const toDropdownValue = value => value ?? undefined;
42
+ /** multiple 용 — 배열이 아니면(null · undefined 포함) 빈 배열 */
43
+ const toIdArray = value => Array.isArray(value) ? value : [];
44
+ /** 두 id 목록이 같은 id 들로 이루어졌는지 — 순서는 보지 않는다 (사용처가 onChange 값을 정렬해 돌려줘도 같은 값) */
45
+ const isSameIds = (currentIds, nextIds) => currentIds.length === nextIds.length && currentIds.every(id => nextIds.includes(id));
46
+ const isSameTags = (nextTags, prevTags) => nextTags.length === prevTags.length && nextTags.every((tag, index) => tag.id === prevTags[index].id && tag.label === prevTags[index].label);
47
+ const findOption = (optionItems, id) => optionItems.find(option => option.id === id);
48
+ /** 단일 모드 value 에 해당하는 옵션 — 빈 값·배열이면 없음 */
49
+ const findSingleOption = (value, optionItems) => {
50
+ if (isEmptyValue(value) || Array.isArray(value)) return undefined;
51
+ return findOption(optionItems, value);
52
+ };
53
+ /**
54
+ * 단일 모드 Tag — value 와 optionItems 에서 계산한다.
55
+ * 검색 필터로 목록에서 빠진 경우에만, 같은 id 로 기억해 둔 Tag 를 대신 쓴다.
56
+ */
57
+ const resolveSingleTag = (value, optionItems, lastResolvedTag) => {
58
+ if (isEmptyValue(value) || Array.isArray(value)) return null;
59
+ const found = findOption(optionItems, value);
60
+ if (found) return {
61
+ id: found.id,
62
+ label: found.label
63
+ };
64
+ return lastResolvedTag?.id === value ? lastResolvedTag : null;
65
+ };
66
+ /** 단일 모드 Tag 목록 (0~1개). 목록에서 찾은 Tag 를 기억해 검색 필터 중에도 라벨을 유지한다 */
67
+ const useSingleSelectedTags = (value, optionItems, enabled) => {
68
+ const [lastResolvedTag, setLastResolvedTag] = (0, _react.useState)(null);
69
+ const found = enabled ? findSingleOption(value, optionItems) : undefined;
70
+ const foundId = found?.id;
71
+ const foundLabel = found?.label;
72
+ (0, _react.useEffect)(() => {
73
+ if (foundId === undefined || foundLabel === undefined) return;
74
+ setLastResolvedTag({
75
+ id: foundId,
76
+ label: foundLabel
77
+ });
78
+ }, [foundId, foundLabel]);
79
+ const selectedTag = enabled ? resolveSingleTag(value, optionItems, lastResolvedTag) : null;
80
+ return selectedTag ? [selectedTag] : [];
81
+ };
82
+ /** multiple 모드 Tag — 목록에서 못 찾은 id 는 이미 표시 중인 Tag 의 라벨을 쓴다 */
83
+ const resolveMultipleTags = (ids, optionItems, prevTags) => {
84
+ const nextTags = ids.map(id => {
85
+ const found = findOption(optionItems, id);
86
+ return found ? {
87
+ id: found.id,
88
+ label: found.label
89
+ } : prevTags.find(tag => tag.id === id);
90
+ }).filter(tag => tag !== undefined);
91
+ // 결과가 같으면 이전 배열을 그대로 돌려준다 — 새 배열로 바꾸면 effect 가 다시 렌더를 일으킨다
92
+ return isSameTags(nextTags, prevTags) ? prevTags : nextTags;
93
+ };
94
+ /**
95
+ * multiple 모드 Tag — 「선택 완료」·X 클릭·외부 value 변경 때만 확정 id 를 바꾼다.
96
+ * ComboBox 가 직접 onChange 로 내보낸 값은 markEmitted 로 기록해 두고, 그 값이 value 로 돌아오면 한 번만 무시한다(확정 아님).
97
+ * 바깥 값이 들어오면 남아 있던 기록도 지운다 — 오래된 기록 때문에 이후 같은 바깥 값이 무시되지 않게 하기 위함.
98
+ * optionItems 만 바뀌면 확정 id 는 그대로 두고 라벨만 다시 찾는다 — 「선택 완료」 전 검색으로 Tag 가 확정되지 않게 하기 위함.
99
+ */
100
+ const useMultipleSelectedTags = (value, optionItems, enabled) => {
101
+ const [selectedTags, setSelectedTags] = (0, _react.useState)([]);
102
+ const lastEmittedRef = (0, _react.useRef)(null);
103
+ const committedIdsRef = (0, _react.useRef)([]);
104
+ const prevValueIdsRef = (0, _react.useRef)(null);
105
+ (0, _react.useEffect)(() => {
106
+ if (!enabled) return;
107
+ const ids = toIdArray(value);
108
+ const isEcho = lastEmittedRef.current !== null && isSameIds(ids, lastEmittedRef.current);
109
+ const isValueChanged = prevValueIdsRef.current === null || !isSameIds(ids, prevValueIdsRef.current);
110
+ prevValueIdsRef.current = ids;
111
+ if (isEcho) {
112
+ lastEmittedRef.current = null;
113
+ } else if (isValueChanged) {
114
+ committedIdsRef.current = ids;
115
+ lastEmittedRef.current = null;
116
+ }
117
+ setSelectedTags(prev => resolveMultipleTags(committedIdsRef.current, optionItems, prev));
118
+ }, [enabled, value, optionItems]);
119
+ /** 「선택 완료」로 Tag 를 확정한다 */
120
+ const commitTags = ids => {
121
+ committedIdsRef.current = ids;
122
+ setSelectedTags(prev => resolveMultipleTags(ids, optionItems, prev));
123
+ };
124
+ /** X 클릭 — 확정 id 에서 그 id 만 뺀다. value 기준으로 계산하면 「선택 완료」 전에 클릭한 항목까지 확정된다 */
125
+ const removeCommittedTag = tagId => {
126
+ commitTags(committedIdsRef.current.filter(id => id !== tagId));
127
+ };
128
+ /** ComboBox 가 onChange 로 내보낸 값을 기록한다 — value 로 돌아왔을 때 확정으로 보지 않기 위함 */
129
+ const markEmitted = ids => {
130
+ lastEmittedRef.current = ids;
131
+ };
132
+ return {
133
+ selectedTags,
134
+ commitTags,
135
+ removeCommittedTag,
136
+ markEmitted
137
+ };
138
+ };
33
139
  const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, ref) => {
34
140
  let {
35
141
  placeholder = '검색하세요',
@@ -69,22 +175,24 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, r
69
175
  } = (0, _usePortalState.usePortalState)(usePortal);
70
176
  const [inputValue, setInputValue] = (0, _react.useState)(searchValue);
71
177
  const [showAllItems, setShowAllItems] = (0, _react.useState)(false);
72
- const [selectedTags, setSelectedTags] = (0, _react.useState)([]);
178
+ const singleTags = useSingleSelectedTags(value, optionItems, !multiple);
179
+ const {
180
+ selectedTags,
181
+ commitTags,
182
+ removeCommittedTag,
183
+ markEmitted
184
+ } = useMultipleSelectedTags(value, optionItems, multiple);
73
185
  const handleOptionSelect = option => {
74
186
  if (disabled) return;
75
187
  if (multiple) {
76
188
  const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
77
189
  if (newValue === null) return;
190
+ markEmitted(newValue);
78
191
  onChange?.(newValue);
79
192
  notifyFormChange(register, multiple, newValue);
80
193
  return;
81
194
  }
82
195
  const newValue = option.id;
83
- const tag = {
84
- id: option.id,
85
- label: option.label
86
- };
87
- setSelectedTags([tag]);
88
196
  onChange?.(newValue);
89
197
  notifyFormChange(register, multiple, newValue);
90
198
  };
@@ -191,7 +299,6 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, r
191
299
  const {
192
300
  buttonText: selectAllButtonText,
193
301
  toggleSelectAll,
194
- getSelectedTagsData,
195
302
  removeTag,
196
303
  isMaxSelectionActive,
197
304
  tryToggle
@@ -201,6 +308,7 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, r
201
308
  const handleSelectAll = () => {
202
309
  if (!multiple || !onChange) return;
203
310
  const newSelectedValues = toggleSelectAll();
311
+ markEmitted(newSelectedValues);
204
312
  onChange(newSelectedValues);
205
313
  };
206
314
  const handleEdit = () => {
@@ -208,30 +316,32 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, r
208
316
  };
209
317
  const handleComplete = completedValue => {
210
318
  if (!multiple) return;
211
- const tags = getSelectedTagsData();
212
- setSelectedTags(tags);
319
+ commitTags(toIdArray(completedValue));
213
320
  setInputValue('');
214
321
  closeDropdown();
215
322
  onComplete?.(completedValue);
216
323
  };
217
324
  const handleRemoveTag = tagId => {
325
+ // X 버튼은 disabled 에서 숨기지만, 다른 호출 경로가 생겨도 값이 지워지지 않게 함수에서도 막는다
326
+ if (disabled) return;
218
327
  if (!onChange) return;
219
328
  if (multiple) {
220
329
  const newSelectedValues = removeTag(tagId);
330
+ markEmitted(newSelectedValues);
221
331
  onChange(newSelectedValues);
222
- setSelectedTags(prev => prev.filter(tag => tag.id !== tagId));
332
+ removeCommittedTag(tagId);
223
333
  return;
224
334
  }
225
335
  onChange('');
226
- setSelectedTags([]);
227
336
  };
228
337
  const comboboxId = `combobox-options-${id || 'default'}`;
229
338
  const hasVisibleOptions = showAllItems || inputValue;
230
339
  const displayedOptions = hasVisibleOptions ? optionItems : [];
231
340
  const showNoResults = !hasVisibleOptions || optionItems.length === 0;
232
341
  const showFooter = multiple || showFooterButtons;
233
- const hiddenInputValue = multiple ? JSON.stringify(value || []) : String(value || '');
234
- const hasTags = selectedTags.length > 0;
342
+ const hiddenInputValue = toHiddenInputValue(value, multiple);
343
+ const tags = multiple ? selectedTags : singleTags;
344
+ const hasTags = tags.length > 0;
235
345
  const {
236
346
  className: portalPanelClassName,
237
347
  style: portalPanelStyle
@@ -242,7 +352,7 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, r
242
352
  direction: dropdownDirection,
243
353
  size: size,
244
354
  options: displayedOptions,
245
- value: value,
355
+ value: toDropdownValue(value),
246
356
  focusedIndex: focusedIndex,
247
357
  maxHeight: maxHeight,
248
358
  listboxId: comboboxId,
@@ -321,10 +431,10 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, r
321
431
  })]
322
432
  }), hasTags && (0, _jsxRuntime.jsx)("div", {
323
433
  className: "ncua-combobox__tags",
324
- children: selectedTags.map(tag => (0, _jsxRuntime.jsx)(_tag.Tag, {
434
+ children: tags.map(tag => (0, _jsxRuntime.jsx)(_tag.Tag, {
325
435
  text: tag.label,
326
436
  size: "sm",
327
- close: true,
437
+ close: !disabled,
328
438
  onButtonClick: () => handleRemoveTag(tag.id)
329
439
  }, tag.id))
330
440
  })]
@@ -476,7 +476,7 @@ const clickButtonByLabel = (scope, label) => {
476
476
  (0, _vitest.expect)(onSearch).toHaveBeenCalledWith('사');
477
477
  (0, _vitest.expect)(view.content.querySelectorAll('.ncua-select-dropdown__option')).toHaveLength(createOptions().length);
478
478
  });
479
- (0, _vitest.it)('옵션을 선택하면 onChange 가 호출되고 선택 태그가 표시된다', () => {
479
+ (0, _vitest.it)('옵션을 선택하면 onChange 가 호출된다', () => {
480
480
  // Given: 옵션이 노출된 기본 모드 ComboBox 에서
481
481
  const onChange = _vitest.vi.fn();
482
482
  const view = mountComboBox({
@@ -489,9 +489,9 @@ const clickButtonByLabel = (scope, label) => {
489
489
  (0, _testUtils.act)(() => {
490
490
  requireElement(view.content, '.ncua-select-dropdown__option').click();
491
491
  });
492
- // Then: onChange 가 선택 값으로 호출되고 태그가 표시된다
492
+ // Then: onChange 가 선택 값으로 호출된다
493
+ // 선택 태그는 value 를 따르므로(#530) onChange 값을 value 로 돌려주는 경우의 태그 표시는 ComboBox.test.ts S-10 에서 검증한다
493
494
  (0, _vitest.expect)(onChange).toHaveBeenCalledWith('apple');
494
- (0, _vitest.expect)(view.container.querySelectorAll('.ncua-combobox__tags .ncua-tag')).toHaveLength(1);
495
495
  });
496
496
  (0, _vitest.it)('Escape 를 누르면 인라인 패널이 닫힌다', () => {
497
497
  // Given: 열려 있는 기본 모드 ComboBox 에서