@ncds/ui-admin 1.8.23-alpha.1 → 1.8.23

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.
@@ -16,4 +16,4 @@ type BadgeProps = {
16
16
  };
17
17
  declare const Badge: ({ label, type, color, className, leadingIcon, trailingIcon, size, }: BadgeProps) => import("react/jsx-runtime").JSX.Element;
18
18
  export { Badge };
19
- export type { BadgeColor, BadgeProps, BadgeSize, BadgeType };
19
+ export type { BadgeType, BadgeColor, BadgeSize, BadgeProps };
@@ -4,10 +4,8 @@ interface BreadcrumbItemProps {
4
4
  }
5
5
  interface BreadcrumbProps {
6
6
  items: BreadcrumbItemProps[];
7
- /** 첫 항목을 홈 아이콘으로 렌더링할지 여부. false 이면 label 텍스트로 렌더링한다. */
8
- showHomeIcon?: boolean;
9
7
  className?: string;
10
8
  }
11
- declare const BreadCrumb: ({ items, showHomeIcon, className }: BreadcrumbProps) => import("react/jsx-runtime").JSX.Element | null;
9
+ declare const BreadCrumb: ({ items, className }: BreadcrumbProps) => import("react/jsx-runtime").JSX.Element | null;
12
10
  export { BreadCrumb };
13
11
  export type { BreadcrumbItemProps, BreadcrumbProps };
@@ -67,6 +67,9 @@
67
67
  --shadow-xs-focused-3px-primary-100: 0px 1px 2px 0px #1018280d, 0px 0px 0px 3px #ffeaee;
68
68
  --shadow-xs-focused-3px-gray-100: 0px 1px 2px 0px #1018280d, 0px 0px 0px 3px #f2f4f7;
69
69
  --shadow-xs-focused-3px-error-100: 0px 1px 2px 0px #1018280d, 0px 0px 0px 3px #fee4e2;
70
+ --shadow-xs-focused-4px-primary-100: 0px 1px 2px 0px #1018280d, 0px 0px 0px 4px #ffeaee;
71
+ --shadow-xs-focused-4px-gray-100: 0px 1px 2px 0px #1018280d, 0px 0px 0px 4px #f2f4f7;
72
+ --shadow-xs-focused-4px-error-100: 0px 1px 2px 0px #1018280d, 0px 0px 0px 4px #fee4e2;
70
73
  --shadow-sm: 0px 1px 3px 0px #1018281a, 0px 1px 2px 0px #1018280f;
71
74
  --shadow-sm-focused-4px-primary-100: 0px 1px 2px 0px #1018280d, 0px 0px 0px 4px #ffeaee;
72
75
  --shadow-sm-focused-4px-gray-100: 0px 1px 3px 0px #1018281a, 0px 1px 2px 0px #1018280f, 0px 0px 0px 4px #f2f4f7;
@@ -324,7 +327,7 @@ button {
324
327
  background-color: var(--primary-red-450);
325
328
  border: 1px solid var(--primary-red-500);
326
329
  color: var(--base-white);
327
- box-shadow: var(--shadow-sm-focused-4px-primary-100);
330
+ box-shadow: var(--shadow-xs-focused-4px-primary-100);
328
331
  }
329
332
  .ncua-btn--primary:disabled, .ncua-btn--primary.is-disable {
330
333
  background-color: var(--primary-red-100);
@@ -370,7 +373,7 @@ button {
370
373
  background-color: var(--base-white);
371
374
  border: 1px solid var(--gray-200);
372
375
  color: var(--gray-700);
373
- box-shadow: var(--shadow-sm-focused-4px-gray-100);
376
+ box-shadow: var(--shadow-xs-focused-4px-gray-100);
374
377
  }
375
378
  .ncua-btn--secondary-gray:disabled, .ncua-btn--secondary-gray.is-disable {
376
379
  background-color: var(--base-white);
@@ -956,7 +959,7 @@ button {
956
959
  }
957
960
  .ncua-checkbox-input.destructive :where(input):focus {
958
961
  border-color: var(--primary-red-600);
959
- box-shadow: var(--shadow-sm-focused-4px-error-100);
962
+ box-shadow: var(--shadow-xs-focused-4px-error-100);
960
963
  }
961
964
  .ncua-checkbox-input:hover {
962
965
  border-color: var(--gray-600);
@@ -1737,7 +1740,7 @@ button {
1737
1740
  border-color: var(--primary-red-600);
1738
1741
  }
1739
1742
  .ncua-input.destructive:where(:focus-within) .ncua-input__field {
1740
- box-shadow: var(--shadow-sm-focused-4px-error-100);
1743
+ box-shadow: var(--shadow-xs-focused-4px-error-100);
1741
1744
  }
1742
1745
  .ncua-input.is-disabled .ncua-input__content > :not(button) {
1743
1746
  background-color: var(--gray-50);
@@ -1927,7 +1930,7 @@ button {
1927
1930
  border-color: var(--primary-red-600);
1928
1931
  }
1929
1932
  .ncua-input--textarea.destructive textarea:focus {
1930
- box-shadow: var(--shadow-sm-focused-4px-error-100);
1933
+ box-shadow: var(--shadow-xs-focused-4px-error-100);
1931
1934
  }
1932
1935
 
1933
1936
  .ncua-input--textarea--xs textarea {
@@ -3189,7 +3192,7 @@ button {
3189
3192
  }
3190
3193
  .ncua-radio-input.destructive :where(input):focus {
3191
3194
  border-color: var(--primary-red-600);
3192
- box-shadow: var(--shadow-sm-focused-4px-error-100);
3195
+ box-shadow: var(--shadow-xs-focused-4px-error-100);
3193
3196
  }
3194
3197
 
3195
3198
  .ncua-radio-field {
@@ -3691,7 +3694,7 @@ button {
3691
3694
  border-color: var(--primary-red-600);
3692
3695
  }
3693
3696
  .ncua-select.destructive .ncua-select__content:focus-within {
3694
- box-shadow: var(--shadow-sm-focused-4px-error-100);
3697
+ box-shadow: var(--shadow-xs-focused-4px-error-100);
3695
3698
  }
3696
3699
  .ncua-select--xs .ncua-select__content {
3697
3700
  height: var(--select-height-xs);
@@ -4032,7 +4035,7 @@ button {
4032
4035
  border-color: var(--primary-red-600);
4033
4036
  }
4034
4037
  .ncua-selectbox.destructive .ncua-selectbox__content:focus {
4035
- box-shadow: var(--shadow-sm-focused-4px-error-100);
4038
+ box-shadow: var(--shadow-xs-focused-4px-error-100);
4036
4039
  }
4037
4040
  .ncua-selectbox--xs .ncua-selectbox__content {
4038
4041
  height: var(--select-height-xs);
@@ -5194,7 +5197,7 @@ button {
5194
5197
  font-weight: var(--font-weights-commerce-sans-1);
5195
5198
  cursor: pointer;
5196
5199
  }
5197
- .ncua-breadcrumb__list-item--home {
5200
+ .ncua-breadcrumb__list-item:first-child {
5198
5201
  height: 24px;
5199
5202
  padding: 4px;
5200
5203
  }
@@ -5325,16 +5328,15 @@ button {
5325
5328
  justify-content: center;
5326
5329
  color: var(--pink-600);
5327
5330
  background-color: var(--pink-200);
5331
+ border-radius: 5px;
5328
5332
  }
5329
5333
  .ncua-badge--new-badge-sm {
5330
5334
  width: 16px;
5331
5335
  height: 16px;
5332
- border-radius: 3px;
5333
5336
  }
5334
5337
  .ncua-badge--new-badge-md {
5335
5338
  width: 20px;
5336
5339
  height: 20px;
5337
- border-radius: 4px;
5338
5340
  }
5339
5341
 
5340
5342
  .ncua-badge-group {
@@ -6596,7 +6598,7 @@ button {
6596
6598
  bottom: 0;
6597
6599
  width: 3px;
6598
6600
  background: var(--base-black);
6599
- z-index: var(--z-index-1);
6601
+ z-index: 1;
6600
6602
  }
6601
6603
  .ncua-table__row--warning.ncua-table__row--selected:nth-child(n) {
6602
6604
  background: var(--orange-50);
@@ -6741,7 +6743,7 @@ button {
6741
6743
  .ncua-table--fixed-header .ncua-table__header-cell {
6742
6744
  position: sticky;
6743
6745
  top: 0;
6744
- z-index: var(--z-index-sticky);
6746
+ z-index: 10;
6745
6747
  padding: 0 var(--spacing-m);
6746
6748
  height: var(--ncua-table-header-height);
6747
6749
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ncds/ui-admin",
3
- "version": "1.8.23-alpha.1",
3
+ "version": "1.8.23",
4
4
  "description": "nhn-commerce의 어드민 디자인 시스템입니다.",
5
5
  "scripts": {
6
6
  "barrel": "node barrel.js",
@@ -1,207 +0,0 @@
1
- "use strict";
2
-
3
- var _react = require("react");
4
- var _client = require("react-dom/client");
5
- var _testUtils = require("react-dom/test-utils");
6
- var _vitest = require("vitest");
7
- var _BreadCrumb = require("../BreadCrumb");
8
- // @vitest-environment jsdom
9
-
10
- const LIST_ITEM = '.ncua-breadcrumb__list-item';
11
- const HOME_MODIFIER = 'ncua-breadcrumb__list-item--home';
12
- const ACTIVE_MODIFIER = 'ncua-breadcrumb__list-item--active';
13
- const ITEM = '.ncua-breadcrumb__item';
14
- /** DocBreadcrumbs(유일한 사내 호출부)와 같은 형태의 입력 — AC-7 회귀 기준 */
15
- const DOC_ITEMS = [{
16
- href: '/',
17
- label: '홈'
18
- }, {
19
- href: '/docs',
20
- label: '문서'
21
- }, {
22
- label: '현재 페이지'
23
- }];
24
- let root = null;
25
- let container = null;
26
- function render(props) {
27
- if (!container) {
28
- container = document.createElement('div');
29
- document.body.appendChild(container);
30
- root = (0, _client.createRoot)(container);
31
- }
32
- (0, _testUtils.act)(() => {
33
- root?.render(/*#__PURE__*/(0, _react.createElement)(_BreadCrumb.BreadCrumb, props));
34
- });
35
- return container;
36
- }
37
- (0, _vitest.afterEach)(() => {
38
- if (root && container) {
39
- (0, _testUtils.act)(() => root?.unmount());
40
- container.remove();
41
- }
42
- root = null;
43
- container = null;
44
- });
45
- const listItems = el => Array.from(el.querySelectorAll(LIST_ITEM));
46
- /** <li> 안에서 실제 포커스를 받는 요소(<a> 또는 <span>) */
47
- const itemOf = li => li.querySelector(ITEM);
48
- (0, _vitest.describe)('BreadCrumb — showHomeIcon 아이콘/라벨 분기', () => {
49
- (0, _vitest.it)('AC-1: showHomeIcon 미지정이면 첫 항목을 홈 아이콘으로 렌더한다', () => {
50
- // Given/When: showHomeIcon 을 넘기지 않고 렌더하면
51
- const el = render({
52
- items: DOC_ITEMS
53
- });
54
- // Then: 첫 항목은 --home 모디파이어를 달고 svg 를 품으며, label 텍스트는 노출되지 않는다
55
- const [first] = listItems(el);
56
- (0, _vitest.expect)(first.classList.contains(HOME_MODIFIER)).toBe(true);
57
- (0, _vitest.expect)(first.querySelector('svg')).not.toBeNull();
58
- (0, _vitest.expect)(first.textContent).not.toContain(DOC_ITEMS[0].label);
59
- });
60
- (0, _vitest.it)('AC-2: showHomeIcon={false} 이면 첫 항목을 label 텍스트로 렌더한다', () => {
61
- // Given/When: 라벨 모드로 렌더하면
62
- const el = render({
63
- items: DOC_ITEMS,
64
- showHomeIcon: false
65
- });
66
- // Then: 아이콘 대신 label 텍스트가 나오고 --home 은 붙지 않는다
67
- const [first] = listItems(el);
68
- (0, _vitest.expect)(first.classList.contains(HOME_MODIFIER)).toBe(false);
69
- (0, _vitest.expect)(first.querySelector('svg')).toBeNull();
70
- (0, _vitest.expect)(first.textContent).toBe(DOC_ITEMS[0].label);
71
- });
72
- (0, _vitest.it)('AC-3: 홈 아이콘 항목의 링크에 label 값이 aria-label 로 부여된다', () => {
73
- // Given/When: 아이콘 모드로 렌더하면
74
- const el = render({
75
- items: DOC_ITEMS
76
- });
77
- // Then: 접근 가능한 이름은 <li> 가 아니라 포커스를 받는 <a> 에 붙는다
78
- const [first] = listItems(el);
79
- const anchor = itemOf(first);
80
- (0, _vitest.expect)(anchor?.tagName).toBe('A');
81
- (0, _vitest.expect)(anchor?.getAttribute('aria-label')).toBe(DOC_ITEMS[0].label);
82
- });
83
- (0, _vitest.it)('AC-4(라벨 모드): 어떤 항목에도 --home 과 aria-label 이 붙지 않는다', () => {
84
- // Given/When: 라벨 모드로 렌더하면
85
- const el = render({
86
- items: DOC_ITEMS,
87
- showHomeIcon: false
88
- });
89
- // Then: --home 은 전 항목에서 사라지고, 첫 항목의 aria-label 속성 자체가 출력되지 않는다
90
- const items = listItems(el);
91
- (0, _vitest.expect)(items.every(li => !li.classList.contains(HOME_MODIFIER))).toBe(true);
92
- (0, _vitest.expect)(itemOf(items[0])?.hasAttribute('aria-label')).toBe(false);
93
- });
94
- (0, _vitest.it)('AC-4(아이콘 모드): --home 은 첫 항목에만 붙는다', () => {
95
- // Given/When: 아이콘 모드로 렌더하면
96
- const el = render({
97
- items: DOC_ITEMS
98
- });
99
- // Then: 홈 아이콘인 첫 항목에만 붙고 나머지에는 붙지 않는다
100
- const items = listItems(el);
101
- (0, _vitest.expect)(items.filter(li => li.classList.contains(HOME_MODIFIER))).toHaveLength(1);
102
- (0, _vitest.expect)(items[0].classList.contains(HOME_MODIFIER)).toBe(true);
103
- });
104
- (0, _vitest.it)('AC-5: 마지막 항목은 showHomeIcon 과 무관하게 --active 를 달고 링크가 아니다', () => {
105
- // Given/When: 아이콘 모드로 렌더하면
106
- const iconMode = listItems(render({
107
- items: DOC_ITEMS
108
- }));
109
- const iconLast = iconMode[iconMode.length - 1];
110
- // Then: --active 가 붙고 <span> 이라 클릭 이동이 없다
111
- (0, _vitest.expect)(iconLast.classList.contains(ACTIVE_MODIFIER)).toBe(true);
112
- (0, _vitest.expect)(itemOf(iconLast)?.tagName).toBe('SPAN');
113
- // When: 라벨 모드로 다시 렌더해도
114
- const labelMode = listItems(render({
115
- items: DOC_ITEMS,
116
- showHomeIcon: false
117
- }));
118
- const labelLast = labelMode[labelMode.length - 1];
119
- // Then: 판정은 showHomeIcon 과 독립이다
120
- (0, _vitest.expect)(labelLast.classList.contains(ACTIVE_MODIFIER)).toBe(true);
121
- (0, _vitest.expect)(itemOf(labelLast)?.tagName).toBe('SPAN');
122
- });
123
- (0, _vitest.it)('AC-6: 단일 항목이면 첫 항목이 곧 마지막 항목으로 처리된다', () => {
124
- // Given/When: 항목이 하나뿐인 배열을 아이콘 모드로 렌더하면
125
- const single = [{
126
- href: '/',
127
- label: '홈'
128
- }];
129
- const [iconOnly] = listItems(render({
130
- items: single
131
- }));
132
- // Then: --home 과 --active 가 동시에 붙고 링크가 아니다
133
- (0, _vitest.expect)(iconOnly.classList.contains(HOME_MODIFIER)).toBe(true);
134
- (0, _vitest.expect)(iconOnly.classList.contains(ACTIVE_MODIFIER)).toBe(true);
135
- (0, _vitest.expect)(itemOf(iconOnly)?.tagName).toBe('SPAN');
136
- (0, _vitest.expect)(iconOnly.querySelector('svg')).not.toBeNull();
137
- // When: 같은 배열을 라벨 모드로 렌더하면
138
- const [labelOnly] = listItems(render({
139
- items: single,
140
- showHomeIcon: false
141
- }));
142
- // Then: --active 만 붙고 label 텍스트가 나온다
143
- (0, _vitest.expect)(labelOnly.classList.contains(HOME_MODIFIER)).toBe(false);
144
- (0, _vitest.expect)(labelOnly.classList.contains(ACTIVE_MODIFIER)).toBe(true);
145
- (0, _vitest.expect)(labelOnly.textContent).toBe(single[0].label);
146
- });
147
- (0, _vitest.it)('AC-7: 기존 호출부 입력의 렌더 결과는 --home·aria-label 추가분 외에 변하지 않는다', () => {
148
- // Given/When: DocBreadcrumbs 와 같은 입력을 showHomeIcon 미지정으로 렌더하면
149
- const el = render({
150
- items: DOC_ITEMS
151
- });
152
- const items = listItems(el);
153
- // Then: 요소 종류·텍스트·href 는 변경 전과 같고, 첫 항목 svg 도 그대로다
154
- (0, _vitest.expect)(items).toHaveLength(DOC_ITEMS.length);
155
- (0, _vitest.expect)(items.map(li => itemOf(li)?.tagName)).toEqual(['A', 'A', 'SPAN']);
156
- (0, _vitest.expect)(items.map(li => itemOf(li)?.getAttribute('href'))).toEqual(['/', '/docs', null]);
157
- (0, _vitest.expect)(items.map(li => li.textContent)).toEqual(['', DOC_ITEMS[1].label, DOC_ITEMS[2].label]);
158
- (0, _vitest.expect)(items[0].querySelector('svg')).not.toBeNull();
159
- // Then: <li> 클래스는 기존 베이스/--active 를 그대로 유지하고, 늘어난 것은 --home 뿐이다
160
- (0, _vitest.expect)(items.map(li => Array.from(li.classList).sort())).toEqual([['ncua-breadcrumb__list-item', HOME_MODIFIER].sort(), ['ncua-breadcrumb__list-item'], ['ncua-breadcrumb__list-item', ACTIVE_MODIFIER].sort()]);
161
- // Then: <a>/<span> 에 늘어난 속성은 첫 항목의 aria-label 뿐이다
162
- (0, _vitest.expect)(itemOf(items[1])?.hasAttribute('aria-label')).toBe(false);
163
- (0, _vitest.expect)(itemOf(items[2])?.hasAttribute('aria-label')).toBe(false);
164
- });
165
- (0, _vitest.it)('비링크 홈 항목에는 role="img" 를 부여해 aria-label 이 무시되지 않게 한다', () => {
166
- // Given/When: 첫 항목에 href 가 없어 <span> 으로 렌더되는 입력(meta usageExamples.default 형태)을 아이콘 모드로 렌더하면
167
- const el = render({
168
- items: [{
169
- label: '대시보드'
170
- }, {
171
- href: '/products',
172
- label: '상품'
173
- }, {
174
- label: '상품 상세'
175
- }]
176
- });
177
- // Then: generic role 의 <span> 은 aria-label 을 노출하지 못하므로 role="img" 로 이름을 살린다
178
- const [first] = listItems(el);
179
- const home = itemOf(first);
180
- (0, _vitest.expect)(home?.tagName).toBe('SPAN');
181
- (0, _vitest.expect)(home?.getAttribute('role')).toBe('img');
182
- (0, _vitest.expect)(home?.getAttribute('aria-label')).toBe('대시보드');
183
- });
184
- (0, _vitest.it)('링크인 홈 항목에는 role 을 부여하지 않는다', () => {
185
- // Given/When: 첫 항목에 href 가 있어 <a> 로 렌더되는 입력을 아이콘 모드로 렌더하면
186
- const el = render({
187
- items: DOC_ITEMS
188
- });
189
- // Then: 링크는 이미 접근 가능한 이름을 지원하므로 role 을 덧씌우지 않는다 (ADR-3)
190
- const items = listItems(el);
191
- const home = itemOf(items[0]);
192
- (0, _vitest.expect)(home?.tagName).toBe('A');
193
- (0, _vitest.expect)(home?.hasAttribute('role')).toBe(false);
194
- // Then: 홈이 아닌 항목에도 role 은 출력되지 않는다
195
- (0, _vitest.expect)(items.slice(1).every(li => !itemOf(li)?.hasAttribute('role'))).toBe(true);
196
- });
197
- (0, _vitest.it)('경계: items 가 빈 배열이면 아무것도 렌더하지 않는다', () => {
198
- // Given/When: 빈 배열을 양쪽 모드로 렌더하면
199
- const el = render({
200
- items: [],
201
- showHomeIcon: false
202
- });
203
- // Then: <nav> 자체가 만들어지지 않는다 (기존 동작 유지)
204
- (0, _vitest.expect)(el.querySelector('.ncua-breadcrumb')).toBeNull();
205
- (0, _vitest.expect)(el.innerHTML).toBe('');
206
- });
207
- });
@@ -1,204 +0,0 @@
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 { BreadCrumb } from '../BreadCrumb';
7
- const LIST_ITEM = '.ncua-breadcrumb__list-item';
8
- const HOME_MODIFIER = 'ncua-breadcrumb__list-item--home';
9
- const ACTIVE_MODIFIER = 'ncua-breadcrumb__list-item--active';
10
- const ITEM = '.ncua-breadcrumb__item';
11
- /** DocBreadcrumbs(유일한 사내 호출부)와 같은 형태의 입력 — AC-7 회귀 기준 */
12
- const DOC_ITEMS = [{
13
- href: '/',
14
- label: '홈'
15
- }, {
16
- href: '/docs',
17
- label: '문서'
18
- }, {
19
- label: '현재 페이지'
20
- }];
21
- let root = null;
22
- let container = null;
23
- function render(props) {
24
- if (!container) {
25
- container = document.createElement('div');
26
- document.body.appendChild(container);
27
- root = createRoot(container);
28
- }
29
- act(() => {
30
- root?.render(/*#__PURE__*/createElement(BreadCrumb, props));
31
- });
32
- return container;
33
- }
34
- afterEach(() => {
35
- if (root && container) {
36
- act(() => root?.unmount());
37
- container.remove();
38
- }
39
- root = null;
40
- container = null;
41
- });
42
- const listItems = el => Array.from(el.querySelectorAll(LIST_ITEM));
43
- /** <li> 안에서 실제 포커스를 받는 요소(<a> 또는 <span>) */
44
- const itemOf = li => li.querySelector(ITEM);
45
- describe('BreadCrumb — showHomeIcon 아이콘/라벨 분기', () => {
46
- it('AC-1: showHomeIcon 미지정이면 첫 항목을 홈 아이콘으로 렌더한다', () => {
47
- // Given/When: showHomeIcon 을 넘기지 않고 렌더하면
48
- const el = render({
49
- items: DOC_ITEMS
50
- });
51
- // Then: 첫 항목은 --home 모디파이어를 달고 svg 를 품으며, label 텍스트는 노출되지 않는다
52
- const [first] = listItems(el);
53
- expect(first.classList.contains(HOME_MODIFIER)).toBe(true);
54
- expect(first.querySelector('svg')).not.toBeNull();
55
- expect(first.textContent).not.toContain(DOC_ITEMS[0].label);
56
- });
57
- it('AC-2: showHomeIcon={false} 이면 첫 항목을 label 텍스트로 렌더한다', () => {
58
- // Given/When: 라벨 모드로 렌더하면
59
- const el = render({
60
- items: DOC_ITEMS,
61
- showHomeIcon: false
62
- });
63
- // Then: 아이콘 대신 label 텍스트가 나오고 --home 은 붙지 않는다
64
- const [first] = listItems(el);
65
- expect(first.classList.contains(HOME_MODIFIER)).toBe(false);
66
- expect(first.querySelector('svg')).toBeNull();
67
- expect(first.textContent).toBe(DOC_ITEMS[0].label);
68
- });
69
- it('AC-3: 홈 아이콘 항목의 링크에 label 값이 aria-label 로 부여된다', () => {
70
- // Given/When: 아이콘 모드로 렌더하면
71
- const el = render({
72
- items: DOC_ITEMS
73
- });
74
- // Then: 접근 가능한 이름은 <li> 가 아니라 포커스를 받는 <a> 에 붙는다
75
- const [first] = listItems(el);
76
- const anchor = itemOf(first);
77
- expect(anchor?.tagName).toBe('A');
78
- expect(anchor?.getAttribute('aria-label')).toBe(DOC_ITEMS[0].label);
79
- });
80
- it('AC-4(라벨 모드): 어떤 항목에도 --home 과 aria-label 이 붙지 않는다', () => {
81
- // Given/When: 라벨 모드로 렌더하면
82
- const el = render({
83
- items: DOC_ITEMS,
84
- showHomeIcon: false
85
- });
86
- // Then: --home 은 전 항목에서 사라지고, 첫 항목의 aria-label 속성 자체가 출력되지 않는다
87
- const items = listItems(el);
88
- expect(items.every(li => !li.classList.contains(HOME_MODIFIER))).toBe(true);
89
- expect(itemOf(items[0])?.hasAttribute('aria-label')).toBe(false);
90
- });
91
- it('AC-4(아이콘 모드): --home 은 첫 항목에만 붙는다', () => {
92
- // Given/When: 아이콘 모드로 렌더하면
93
- const el = render({
94
- items: DOC_ITEMS
95
- });
96
- // Then: 홈 아이콘인 첫 항목에만 붙고 나머지에는 붙지 않는다
97
- const items = listItems(el);
98
- expect(items.filter(li => li.classList.contains(HOME_MODIFIER))).toHaveLength(1);
99
- expect(items[0].classList.contains(HOME_MODIFIER)).toBe(true);
100
- });
101
- it('AC-5: 마지막 항목은 showHomeIcon 과 무관하게 --active 를 달고 링크가 아니다', () => {
102
- // Given/When: 아이콘 모드로 렌더하면
103
- const iconMode = listItems(render({
104
- items: DOC_ITEMS
105
- }));
106
- const iconLast = iconMode[iconMode.length - 1];
107
- // Then: --active 가 붙고 <span> 이라 클릭 이동이 없다
108
- expect(iconLast.classList.contains(ACTIVE_MODIFIER)).toBe(true);
109
- expect(itemOf(iconLast)?.tagName).toBe('SPAN');
110
- // When: 라벨 모드로 다시 렌더해도
111
- const labelMode = listItems(render({
112
- items: DOC_ITEMS,
113
- showHomeIcon: false
114
- }));
115
- const labelLast = labelMode[labelMode.length - 1];
116
- // Then: 판정은 showHomeIcon 과 독립이다
117
- expect(labelLast.classList.contains(ACTIVE_MODIFIER)).toBe(true);
118
- expect(itemOf(labelLast)?.tagName).toBe('SPAN');
119
- });
120
- it('AC-6: 단일 항목이면 첫 항목이 곧 마지막 항목으로 처리된다', () => {
121
- // Given/When: 항목이 하나뿐인 배열을 아이콘 모드로 렌더하면
122
- const single = [{
123
- href: '/',
124
- label: '홈'
125
- }];
126
- const [iconOnly] = listItems(render({
127
- items: single
128
- }));
129
- // Then: --home 과 --active 가 동시에 붙고 링크가 아니다
130
- expect(iconOnly.classList.contains(HOME_MODIFIER)).toBe(true);
131
- expect(iconOnly.classList.contains(ACTIVE_MODIFIER)).toBe(true);
132
- expect(itemOf(iconOnly)?.tagName).toBe('SPAN');
133
- expect(iconOnly.querySelector('svg')).not.toBeNull();
134
- // When: 같은 배열을 라벨 모드로 렌더하면
135
- const [labelOnly] = listItems(render({
136
- items: single,
137
- showHomeIcon: false
138
- }));
139
- // Then: --active 만 붙고 label 텍스트가 나온다
140
- expect(labelOnly.classList.contains(HOME_MODIFIER)).toBe(false);
141
- expect(labelOnly.classList.contains(ACTIVE_MODIFIER)).toBe(true);
142
- expect(labelOnly.textContent).toBe(single[0].label);
143
- });
144
- it('AC-7: 기존 호출부 입력의 렌더 결과는 --home·aria-label 추가분 외에 변하지 않는다', () => {
145
- // Given/When: DocBreadcrumbs 와 같은 입력을 showHomeIcon 미지정으로 렌더하면
146
- const el = render({
147
- items: DOC_ITEMS
148
- });
149
- const items = listItems(el);
150
- // Then: 요소 종류·텍스트·href 는 변경 전과 같고, 첫 항목 svg 도 그대로다
151
- expect(items).toHaveLength(DOC_ITEMS.length);
152
- expect(items.map(li => itemOf(li)?.tagName)).toEqual(['A', 'A', 'SPAN']);
153
- expect(items.map(li => itemOf(li)?.getAttribute('href'))).toEqual(['/', '/docs', null]);
154
- expect(items.map(li => li.textContent)).toEqual(['', DOC_ITEMS[1].label, DOC_ITEMS[2].label]);
155
- expect(items[0].querySelector('svg')).not.toBeNull();
156
- // Then: <li> 클래스는 기존 베이스/--active 를 그대로 유지하고, 늘어난 것은 --home 뿐이다
157
- expect(items.map(li => Array.from(li.classList).sort())).toEqual([['ncua-breadcrumb__list-item', HOME_MODIFIER].sort(), ['ncua-breadcrumb__list-item'], ['ncua-breadcrumb__list-item', ACTIVE_MODIFIER].sort()]);
158
- // Then: <a>/<span> 에 늘어난 속성은 첫 항목의 aria-label 뿐이다
159
- expect(itemOf(items[1])?.hasAttribute('aria-label')).toBe(false);
160
- expect(itemOf(items[2])?.hasAttribute('aria-label')).toBe(false);
161
- });
162
- it('비링크 홈 항목에는 role="img" 를 부여해 aria-label 이 무시되지 않게 한다', () => {
163
- // Given/When: 첫 항목에 href 가 없어 <span> 으로 렌더되는 입력(meta usageExamples.default 형태)을 아이콘 모드로 렌더하면
164
- const el = render({
165
- items: [{
166
- label: '대시보드'
167
- }, {
168
- href: '/products',
169
- label: '상품'
170
- }, {
171
- label: '상품 상세'
172
- }]
173
- });
174
- // Then: generic role 의 <span> 은 aria-label 을 노출하지 못하므로 role="img" 로 이름을 살린다
175
- const [first] = listItems(el);
176
- const home = itemOf(first);
177
- expect(home?.tagName).toBe('SPAN');
178
- expect(home?.getAttribute('role')).toBe('img');
179
- expect(home?.getAttribute('aria-label')).toBe('대시보드');
180
- });
181
- it('링크인 홈 항목에는 role 을 부여하지 않는다', () => {
182
- // Given/When: 첫 항목에 href 가 있어 <a> 로 렌더되는 입력을 아이콘 모드로 렌더하면
183
- const el = render({
184
- items: DOC_ITEMS
185
- });
186
- // Then: 링크는 이미 접근 가능한 이름을 지원하므로 role 을 덧씌우지 않는다 (ADR-3)
187
- const items = listItems(el);
188
- const home = itemOf(items[0]);
189
- expect(home?.tagName).toBe('A');
190
- expect(home?.hasAttribute('role')).toBe(false);
191
- // Then: 홈이 아닌 항목에도 role 은 출력되지 않는다
192
- expect(items.slice(1).every(li => !itemOf(li)?.hasAttribute('role'))).toBe(true);
193
- });
194
- it('경계: items 가 빈 배열이면 아무것도 렌더하지 않는다', () => {
195
- // Given/When: 빈 배열을 양쪽 모드로 렌더하면
196
- const el = render({
197
- items: [],
198
- showHomeIcon: false
199
- });
200
- // Then: <nav> 자체가 만들어지지 않는다 (기존 동작 유지)
201
- expect(el.querySelector('.ncua-breadcrumb')).toBeNull();
202
- expect(el.innerHTML).toBe('');
203
- });
204
- });