@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
@@ -0,0 +1,452 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement, useState } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { ComboBox } from '../ComboBox';
7
+ // React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
8
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
+ const OPTIONS = [{
10
+ id: 1,
11
+ label: '사과'
12
+ }, {
13
+ id: 2,
14
+ label: '바나나'
15
+ }, {
16
+ id: 3,
17
+ label: '체리'
18
+ }];
19
+ /** id 1·2가 빠진 목록 — 검색 필터로 optionItems가 줄어든 상황 */
20
+ const FILTERED_OPTIONS = [{
21
+ id: 3,
22
+ label: '체리'
23
+ }];
24
+ /** optionItems에 없는 id */
25
+ const MISSING_ID = 99;
26
+ /** 바깥에서 reset 으로 넣는 값 — 기존 선택(1·2)과 겹치지 않는 id */
27
+ const RESET_ID = 3;
28
+ /** 「전체 선택」 결과 — OPTIONS 의 모든 id */
29
+ const ALL_IDS = OPTIONS.map(option => option.id);
30
+ let root = null;
31
+ let container = null;
32
+ /** 같은 root에 다시 render하면 props 변경(rerender)이 된다. */
33
+ function render(props) {
34
+ if (!container) {
35
+ container = document.createElement('div');
36
+ document.body.appendChild(container);
37
+ root = createRoot(container);
38
+ }
39
+ act(() => {
40
+ root?.render(/*#__PURE__*/createElement(ComboBox, props));
41
+ });
42
+ return container;
43
+ }
44
+ /** onChange로 받은 값을 value로 되돌려주는 사용처 흉내 (controlled) */
45
+ function Harness(_ref) {
46
+ let {
47
+ initialValue,
48
+ onChangeSpy,
49
+ transform,
50
+ ...rest
51
+ } = _ref;
52
+ const [value, setValue] = useState(initialValue);
53
+ return /*#__PURE__*/createElement(ComboBox, {
54
+ ...rest,
55
+ value,
56
+ onChange: next => {
57
+ onChangeSpy(next);
58
+ setValue(transform ? transform(next) : next);
59
+ }
60
+ });
61
+ }
62
+ function renderHarness(props) {
63
+ if (!container) {
64
+ container = document.createElement('div');
65
+ document.body.appendChild(container);
66
+ root = createRoot(container);
67
+ }
68
+ act(() => {
69
+ root?.render(/*#__PURE__*/createElement(Harness, props));
70
+ });
71
+ return container;
72
+ }
73
+ function getTagTexts(el) {
74
+ return Array.from(el.querySelectorAll('.ncua-combobox__tags .ncua-tag .ncua-tag__text')).map(node => node.textContent ?? '');
75
+ }
76
+ function getCloseButtons(el) {
77
+ return Array.from(el.querySelectorAll('.ncua-combobox__tags .ncua-tag__close'));
78
+ }
79
+ function click(target) {
80
+ if (!target) throw new Error('클릭 대상을 찾을 수 없습니다');
81
+ act(() => {
82
+ target.click();
83
+ });
84
+ }
85
+ /** 「전체」 버튼으로 드롭다운을 연다 (입력값이 없어도 전체 목록 표시). */
86
+ function openDropdown(el) {
87
+ click(el.querySelector('.ncua-input__right-icon'));
88
+ }
89
+ function clickOption(el, label) {
90
+ const option = Array.from(el.querySelectorAll('.ncua-select-dropdown__option')).find(node => node.textContent === label);
91
+ click(option);
92
+ }
93
+ function clickButton(el, label) {
94
+ const button = Array.from(el.querySelectorAll('button')).find(node => node.textContent?.includes(label));
95
+ click(button);
96
+ }
97
+ afterEach(() => {
98
+ act(() => {
99
+ root?.unmount();
100
+ });
101
+ container?.remove();
102
+ root = null;
103
+ container = null;
104
+ });
105
+ describe('ComboBox — 단일 모드 외부 value Tag', () => {
106
+ it('S-01: value에 해당하는 옵션이 있으면 Tag 1개를 라벨로 표시한다', () => {
107
+ const el = render({
108
+ value: 1,
109
+ optionItems: OPTIONS
110
+ });
111
+ expect(getTagTexts(el)).toEqual(['사과']);
112
+ });
113
+ it('S-02: optionItems가 늦게 도착하면 도착 시 Tag를 표시한다', () => {
114
+ const el = render({
115
+ value: 1,
116
+ optionItems: []
117
+ });
118
+ expect(getTagTexts(el)).toEqual([]);
119
+ render({
120
+ value: 1,
121
+ optionItems: OPTIONS
122
+ });
123
+ expect(getTagTexts(el)).toEqual(['사과']);
124
+ });
125
+ it.each([['빈 문자열', ''], ['undefined', undefined], ['null', null]])('S-03: value가 %s이면 Tag를 표시하지 않는다', (_, value) => {
126
+ const el = render({
127
+ value: value,
128
+ optionItems: OPTIONS
129
+ });
130
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
131
+ });
132
+ it.each([['빈 문자열', ''], ['undefined', undefined]])('S-04: Tag 표시 중 value가 %s로 바뀌면 Tag가 사라진다', (_, value) => {
133
+ const el = render({
134
+ value: 1,
135
+ optionItems: OPTIONS
136
+ });
137
+ expect(getTagTexts(el)).toEqual(['사과']);
138
+ render({
139
+ value,
140
+ optionItems: OPTIONS
141
+ });
142
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
143
+ });
144
+ it('S-05: 검색 필터로 optionItems에서 선택 항목이 빠져도 같은 라벨로 Tag를 유지한다', () => {
145
+ const el = render({
146
+ value: 1,
147
+ optionItems: OPTIONS
148
+ });
149
+ expect(getTagTexts(el)).toEqual(['사과']);
150
+ render({
151
+ value: 1,
152
+ optionItems: FILTERED_OPTIONS
153
+ });
154
+ expect(getTagTexts(el)).toEqual(['사과']);
155
+ });
156
+ it('S-06: 목록에 없는 다른 id로 바뀌면 이전 라벨을 쓰지 않고 Tag를 표시하지 않는다', () => {
157
+ const el = render({
158
+ value: 1,
159
+ optionItems: OPTIONS
160
+ });
161
+ expect(getTagTexts(el)).toEqual(['사과']);
162
+ render({
163
+ value: MISSING_ID,
164
+ optionItems: OPTIONS
165
+ });
166
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
167
+ });
168
+ it('S-07: value가 다른 id로 바뀌면 그 id의 라벨로 Tag를 바꾼다', () => {
169
+ const el = render({
170
+ value: 1,
171
+ optionItems: OPTIONS
172
+ });
173
+ render({
174
+ value: 2,
175
+ optionItems: OPTIONS
176
+ });
177
+ expect(getTagTexts(el)).toEqual(['바나나']);
178
+ });
179
+ it('S-08: disabled면 Tag는 표시하고 X 버튼은 표시하지 않는다', () => {
180
+ const el = render({
181
+ value: 1,
182
+ optionItems: OPTIONS,
183
+ disabled: true
184
+ });
185
+ expect(getTagTexts(el)).toEqual(['사과']);
186
+ expect(getCloseButtons(el)).toHaveLength(0);
187
+ });
188
+ it('S-09: value가 0이어도 빈 값으로 보지 않고 Tag를 표시한다', () => {
189
+ const el = render({
190
+ value: 0,
191
+ optionItems: [{
192
+ id: 0,
193
+ label: '없음'
194
+ }, ...OPTIONS]
195
+ });
196
+ expect(getTagTexts(el)).toEqual(['없음']);
197
+ });
198
+ it.each([[0, '0'], [null, '']])('S-12: register 사용 시 value가 %s이면 hidden input 값은 %j이다 (Tag 표시 규칙과 같게 0은 값으로 본다)', (value, expected) => {
199
+ const register = {
200
+ name: 'item',
201
+ onChange: vi.fn(),
202
+ onBlur: vi.fn(),
203
+ ref: vi.fn()
204
+ };
205
+ const el = render({
206
+ value,
207
+ register,
208
+ optionItems: [{
209
+ id: 0,
210
+ label: '없음'
211
+ }, ...OPTIONS]
212
+ });
213
+ expect(el.querySelector('input[type="hidden"]')?.value).toBe(expected);
214
+ });
215
+ it('S-10 (회귀): 옵션을 클릭하면 onChange(id)를 호출하고 Tag를 표시한다', () => {
216
+ const onChangeSpy = vi.fn();
217
+ const el = renderHarness({
218
+ initialValue: '',
219
+ onChangeSpy,
220
+ optionItems: OPTIONS
221
+ });
222
+ openDropdown(el);
223
+ clickOption(el, '바나나');
224
+ expect(onChangeSpy).toHaveBeenCalledWith(2);
225
+ expect(getTagTexts(el)).toEqual(['바나나']);
226
+ });
227
+ it('S-11 (회귀): X를 클릭하면 onChange("")를 호출하고 Tag가 사라진다', () => {
228
+ const onChangeSpy = vi.fn();
229
+ const el = renderHarness({
230
+ initialValue: 1,
231
+ onChangeSpy,
232
+ optionItems: OPTIONS
233
+ });
234
+ click(getCloseButtons(el)[0]);
235
+ expect(onChangeSpy).toHaveBeenCalledWith('');
236
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
237
+ });
238
+ });
239
+ describe('ComboBox — multiple 모드 외부 value Tag', () => {
240
+ it('M-01: value 배열에 해당하는 Tag를 모두 표시한다', () => {
241
+ const el = render({
242
+ multiple: true,
243
+ value: [1, 2],
244
+ optionItems: OPTIONS
245
+ });
246
+ expect(getTagTexts(el)).toEqual(['사과', '바나나']);
247
+ });
248
+ it('M-02: optionItems가 늦게 도착하면 도착 시 Tag를 표시한다', () => {
249
+ const el = render({
250
+ multiple: true,
251
+ value: [1, 2],
252
+ optionItems: []
253
+ });
254
+ expect(getTagTexts(el)).toEqual([]);
255
+ render({
256
+ multiple: true,
257
+ value: [1, 2],
258
+ optionItems: OPTIONS
259
+ });
260
+ expect(getTagTexts(el)).toEqual(['사과', '바나나']);
261
+ });
262
+ it.each([['빈 배열', []], ['undefined', undefined], ['null', null]])('M-03: value가 %s이면 Tag를 표시하지 않는다', (_, value) => {
263
+ const el = render({
264
+ multiple: true,
265
+ value: value,
266
+ optionItems: OPTIONS
267
+ });
268
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
269
+ });
270
+ it.each([['빈 배열', []], ['undefined', undefined]])('M-04: Tag 표시 중 value가 %s로 바뀌면 Tag가 사라진다', (_, value) => {
271
+ const el = render({
272
+ multiple: true,
273
+ value: [1, 2],
274
+ optionItems: OPTIONS
275
+ });
276
+ expect(getTagTexts(el)).toEqual(['사과', '바나나']);
277
+ render({
278
+ multiple: true,
279
+ value,
280
+ optionItems: OPTIONS
281
+ });
282
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
283
+ });
284
+ it('M-05: 바깥에서 value를 바꾸면(reset) 새 value로 Tag를 다시 그린다', () => {
285
+ const el = render({
286
+ multiple: true,
287
+ value: [1, 2],
288
+ optionItems: OPTIONS
289
+ });
290
+ render({
291
+ multiple: true,
292
+ value: [RESET_ID],
293
+ optionItems: OPTIONS
294
+ });
295
+ expect(getTagTexts(el)).toEqual(['체리']);
296
+ });
297
+ it('M-06: 검색 필터로 optionItems에서 선택 항목이 빠져도 Tag를 유지한다', () => {
298
+ const el = render({
299
+ multiple: true,
300
+ value: [1, 2],
301
+ optionItems: OPTIONS
302
+ });
303
+ render({
304
+ multiple: true,
305
+ value: [1, 2],
306
+ optionItems: FILTERED_OPTIONS
307
+ });
308
+ expect(getTagTexts(el)).toEqual(['사과', '바나나']);
309
+ });
310
+ it('M-07: disabled면 Tag는 표시하고 X 버튼은 표시하지 않는다', () => {
311
+ const el = render({
312
+ multiple: true,
313
+ value: [1],
314
+ optionItems: OPTIONS,
315
+ disabled: true
316
+ });
317
+ expect(getTagTexts(el)).toEqual(['사과']);
318
+ expect(getCloseButtons(el)).toHaveLength(0);
319
+ });
320
+ it('M-08 (회귀): 옵션을 클릭하면 onChange만 호출하고 Tag는 「선택 완료」 전까지 그대로다', () => {
321
+ const onChangeSpy = vi.fn();
322
+ const el = renderHarness({
323
+ multiple: true,
324
+ initialValue: [1],
325
+ onChangeSpy,
326
+ optionItems: OPTIONS
327
+ });
328
+ expect(getTagTexts(el)).toEqual(['사과']);
329
+ openDropdown(el);
330
+ clickOption(el, '바나나');
331
+ expect(onChangeSpy).toHaveBeenCalledWith([1, 2]);
332
+ expect(getTagTexts(el)).toEqual(['사과']);
333
+ });
334
+ it('M-09 (회귀): 옵션 클릭 후 「선택 완료」를 누르면 Tag를 반영하고 onComplete를 호출한다', () => {
335
+ const onChangeSpy = vi.fn();
336
+ const onComplete = vi.fn();
337
+ const el = renderHarness({
338
+ multiple: true,
339
+ initialValue: [1],
340
+ onChangeSpy,
341
+ onComplete,
342
+ optionItems: OPTIONS
343
+ });
344
+ openDropdown(el);
345
+ clickOption(el, '바나나');
346
+ clickButton(el, '선택 완료');
347
+ expect(getTagTexts(el)).toEqual(['사과', '바나나']);
348
+ expect(onComplete).toHaveBeenCalledWith([1, 2]);
349
+ });
350
+ it('M-10 (회귀): X를 클릭하면 onChange(남은 배열)를 호출하고 해당 Tag만 제거한다', () => {
351
+ const onChangeSpy = vi.fn();
352
+ const el = renderHarness({
353
+ multiple: true,
354
+ initialValue: [1, 2],
355
+ onChangeSpy,
356
+ optionItems: OPTIONS
357
+ });
358
+ click(getCloseButtons(el)[0]);
359
+ expect(onChangeSpy).toHaveBeenCalledWith([2]);
360
+ expect(getTagTexts(el)).toEqual(['바나나']);
361
+ });
362
+ it('M-11 (회귀): 「전체 선택」을 누르면 onChange(전체 id)만 호출하고 Tag는 그대로다', () => {
363
+ const onChangeSpy = vi.fn();
364
+ const el = renderHarness({
365
+ multiple: true,
366
+ initialValue: [],
367
+ onChangeSpy,
368
+ optionItems: OPTIONS
369
+ });
370
+ openDropdown(el);
371
+ clickButton(el, '전체 선택');
372
+ expect(onChangeSpy).toHaveBeenCalledWith(ALL_IDS);
373
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
374
+ });
375
+ it('M-12: 클릭으로 고른 값과 같은 값을 바깥에서 비웠다가 다시 넣어도 Tag를 표시한다', () => {
376
+ const props = {
377
+ multiple: true,
378
+ optionItems: OPTIONS,
379
+ onChange: vi.fn()
380
+ };
381
+ const el = render({
382
+ ...props,
383
+ value: []
384
+ });
385
+ openDropdown(el);
386
+ clickOption(el, '사과');
387
+ render({
388
+ ...props,
389
+ value: [1]
390
+ });
391
+ clickButton(el, '선택 완료');
392
+ expect(getTagTexts(el)).toEqual(['사과']);
393
+ render({
394
+ ...props,
395
+ value: []
396
+ });
397
+ expect(getTagTexts(el)).toEqual([]);
398
+ render({
399
+ ...props,
400
+ value: [1]
401
+ });
402
+ expect(getTagTexts(el)).toEqual(['사과']);
403
+ });
404
+ it('M-13 (회귀): 클릭 후 「선택 완료」 전에 optionItems가 바뀌어도(검색) Tag는 그대로다', () => {
405
+ const onChangeSpy = vi.fn();
406
+ const el = renderHarness({
407
+ multiple: true,
408
+ initialValue: [1],
409
+ onChangeSpy,
410
+ optionItems: OPTIONS
411
+ });
412
+ openDropdown(el);
413
+ clickOption(el, '바나나');
414
+ renderHarness({
415
+ multiple: true,
416
+ initialValue: [1],
417
+ onChangeSpy,
418
+ optionItems: FILTERED_OPTIONS
419
+ });
420
+ expect(onChangeSpy).toHaveBeenCalledWith([1, 2]);
421
+ expect(getTagTexts(el)).toEqual(['사과']);
422
+ });
423
+ it('M-14 (회귀): 「선택 완료」 전에 클릭한 항목이 있을 때 X를 누르면 그 Tag만 빠지고 클릭한 항목은 확정되지 않는다', () => {
424
+ const onChangeSpy = vi.fn();
425
+ const el = renderHarness({
426
+ multiple: true,
427
+ initialValue: [1],
428
+ onChangeSpy,
429
+ optionItems: OPTIONS
430
+ });
431
+ openDropdown(el);
432
+ clickOption(el, '바나나');
433
+ click(getCloseButtons(el)[0]);
434
+ expect(onChangeSpy).toHaveBeenLastCalledWith([2]);
435
+ expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
436
+ });
437
+ it('M-15 (회귀): 사용처가 onChange 값의 순서를 바꿔 돌려줘도 「선택 완료」 전에는 Tag가 그대로다', () => {
438
+ const onChangeSpy = vi.fn();
439
+ const sortIds = value => Array.isArray(value) ? [...value].sort() : value;
440
+ const el = renderHarness({
441
+ multiple: true,
442
+ initialValue: [RESET_ID],
443
+ onChangeSpy,
444
+ transform: sortIds,
445
+ optionItems: OPTIONS
446
+ });
447
+ openDropdown(el);
448
+ clickOption(el, '사과');
449
+ expect(onChangeSpy).toHaveBeenCalledWith([RESET_ID, 1]);
450
+ expect(getTagTexts(el)).toEqual(['체리']);
451
+ });
452
+ });