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

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 { BadgeType, BadgeColor, BadgeSize, BadgeProps };
19
+ export type { BadgeColor, BadgeProps, BadgeSize, BadgeType };
@@ -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로 대체 불가한 전용 표시)
@@ -4,8 +4,10 @@ interface BreadcrumbItemProps {
4
4
  }
5
5
  interface BreadcrumbProps {
6
6
  items: BreadcrumbItemProps[];
7
+ /** 첫 항목을 홈 아이콘으로 렌더링할지 여부. false 이면 label 텍스트로 렌더링한다. */
8
+ showHomeIcon?: boolean;
7
9
  className?: string;
8
10
  }
9
- declare const BreadCrumb: ({ items, className }: BreadcrumbProps) => import("react/jsx-runtime").JSX.Element | null;
11
+ declare const BreadCrumb: ({ items, showHomeIcon, className }: BreadcrumbProps) => import("react/jsx-runtime").JSX.Element | null;
10
12
  export { BreadCrumb };
11
13
  export type { BreadcrumbItemProps, BreadcrumbProps };
@@ -1,19 +1,23 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Home01 } from '@ncds/ui-admin-icon';
3
3
  import classNames from 'classnames';
4
- const BreadcrumbItem = ({ href, children }) => {
4
+ const BreadcrumbItem = ({ href, ariaLabel, children }) => {
5
5
  const ElementType = href ? 'a' : 'span';
6
- return (_jsx(ElementType, { href: href, className: "ncua-breadcrumb__item", children: children }));
6
+ return (_jsx(ElementType, { href: href,
7
+ // <span> 은 generic role 이라 aria-label 이 무시될 수 있다. 링크는 이미 이름을 지원하므로 그대로 둔다
8
+ role: !href && ariaLabel ? 'img' : undefined, "aria-label": ariaLabel, className: "ncua-breadcrumb__item", children: children }));
7
9
  };
8
- const BreadCrumb = ({ items, className }) => {
10
+ const BreadCrumb = ({ items, showHomeIcon = true, className }) => {
9
11
  if (!items || items.length === 0)
10
12
  return null;
11
13
  const renderItem = (item, index) => {
12
14
  const isLast = index === items.length - 1;
13
- const content = index === 0 ? _jsx(Home01, { width: 16, height: 16, color: "gray500" }) : item.label;
15
+ // 아이콘 여부는 한 곳에서만 판정한다 — 콘텐츠·모디파이어·aria-label 이 모두 이 값을 따른다
16
+ const isHome = showHomeIcon && index === 0;
14
17
  return (_jsx("li", { className: classNames('ncua-breadcrumb__list-item', {
18
+ 'ncua-breadcrumb__list-item--home': isHome,
15
19
  'ncua-breadcrumb__list-item--active': isLast,
16
- }), children: _jsx(BreadcrumbItem, { href: isLast ? undefined : item.href, children: content }) }, index));
20
+ }), children: _jsx(BreadcrumbItem, { href: isLast ? undefined : item.href, ariaLabel: isHome ? item.label : undefined, children: isHome ? _jsx(Home01, { width: 16, height: 16, color: "gray500" }) : item.label }) }, index));
17
21
  };
18
22
  return (_jsx("nav", { "aria-label": "breadcrumb", className: classNames('ncua-breadcrumb', className), children: _jsx("ol", { className: "ncua-breadcrumb__list", children: items.map(renderItem) }) }));
19
23
  };
@@ -0,0 +1,161 @@
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 = [{ href: '/', label: '홈' }, { href: '/docs', label: '문서' }, { label: '현재 페이지' }];
13
+ let root = null;
14
+ let container = null;
15
+ function render(props) {
16
+ if (!container) {
17
+ container = document.createElement('div');
18
+ document.body.appendChild(container);
19
+ root = createRoot(container);
20
+ }
21
+ act(() => {
22
+ root?.render(createElement(BreadCrumb, props));
23
+ });
24
+ return container;
25
+ }
26
+ afterEach(() => {
27
+ if (root && container) {
28
+ act(() => root?.unmount());
29
+ container.remove();
30
+ }
31
+ root = null;
32
+ container = null;
33
+ });
34
+ const listItems = (el) => Array.from(el.querySelectorAll(LIST_ITEM));
35
+ /** <li> 안에서 실제 포커스를 받는 요소(<a> 또는 <span>) */
36
+ const itemOf = (li) => li.querySelector(ITEM);
37
+ describe('BreadCrumb — showHomeIcon 아이콘/라벨 분기', () => {
38
+ it('AC-1: showHomeIcon 미지정이면 첫 항목을 홈 아이콘으로 렌더한다', () => {
39
+ // Given/When: showHomeIcon 을 넘기지 않고 렌더하면
40
+ const el = render({ items: DOC_ITEMS });
41
+ // Then: 첫 항목은 --home 모디파이어를 달고 svg 를 품으며, label 텍스트는 노출되지 않는다
42
+ const [first] = listItems(el);
43
+ expect(first.classList.contains(HOME_MODIFIER)).toBe(true);
44
+ expect(first.querySelector('svg')).not.toBeNull();
45
+ expect(first.textContent).not.toContain(DOC_ITEMS[0].label);
46
+ });
47
+ it('AC-2: showHomeIcon={false} 이면 첫 항목을 label 텍스트로 렌더한다', () => {
48
+ // Given/When: 라벨 모드로 렌더하면
49
+ const el = render({ items: DOC_ITEMS, showHomeIcon: false });
50
+ // Then: 아이콘 대신 label 텍스트가 나오고 --home 은 붙지 않는다
51
+ const [first] = listItems(el);
52
+ expect(first.classList.contains(HOME_MODIFIER)).toBe(false);
53
+ expect(first.querySelector('svg')).toBeNull();
54
+ expect(first.textContent).toBe(DOC_ITEMS[0].label);
55
+ });
56
+ it('AC-3: 홈 아이콘 항목의 링크에 label 값이 aria-label 로 부여된다', () => {
57
+ // Given/When: 아이콘 모드로 렌더하면
58
+ const el = render({ items: DOC_ITEMS });
59
+ // Then: 접근 가능한 이름은 <li> 가 아니라 포커스를 받는 <a> 에 붙는다
60
+ const [first] = listItems(el);
61
+ const anchor = itemOf(first);
62
+ expect(anchor?.tagName).toBe('A');
63
+ expect(anchor?.getAttribute('aria-label')).toBe(DOC_ITEMS[0].label);
64
+ });
65
+ it('AC-4(라벨 모드): 어떤 항목에도 --home 과 aria-label 이 붙지 않는다', () => {
66
+ // Given/When: 라벨 모드로 렌더하면
67
+ const el = render({ items: DOC_ITEMS, showHomeIcon: false });
68
+ // Then: --home 은 전 항목에서 사라지고, 첫 항목의 aria-label 속성 자체가 출력되지 않는다
69
+ const items = listItems(el);
70
+ expect(items.every((li) => !li.classList.contains(HOME_MODIFIER))).toBe(true);
71
+ expect(itemOf(items[0])?.hasAttribute('aria-label')).toBe(false);
72
+ });
73
+ it('AC-4(아이콘 모드): --home 은 첫 항목에만 붙는다', () => {
74
+ // Given/When: 아이콘 모드로 렌더하면
75
+ const el = render({ items: DOC_ITEMS });
76
+ // Then: 홈 아이콘인 첫 항목에만 붙고 나머지에는 붙지 않는다
77
+ const items = listItems(el);
78
+ expect(items.filter((li) => li.classList.contains(HOME_MODIFIER))).toHaveLength(1);
79
+ expect(items[0].classList.contains(HOME_MODIFIER)).toBe(true);
80
+ });
81
+ it('AC-5: 마지막 항목은 showHomeIcon 과 무관하게 --active 를 달고 링크가 아니다', () => {
82
+ // Given/When: 아이콘 모드로 렌더하면
83
+ const iconMode = listItems(render({ items: DOC_ITEMS }));
84
+ const iconLast = iconMode[iconMode.length - 1];
85
+ // Then: --active 가 붙고 <span> 이라 클릭 이동이 없다
86
+ expect(iconLast.classList.contains(ACTIVE_MODIFIER)).toBe(true);
87
+ expect(itemOf(iconLast)?.tagName).toBe('SPAN');
88
+ // When: 라벨 모드로 다시 렌더해도
89
+ const labelMode = listItems(render({ items: DOC_ITEMS, showHomeIcon: false }));
90
+ const labelLast = labelMode[labelMode.length - 1];
91
+ // Then: 판정은 showHomeIcon 과 독립이다
92
+ expect(labelLast.classList.contains(ACTIVE_MODIFIER)).toBe(true);
93
+ expect(itemOf(labelLast)?.tagName).toBe('SPAN');
94
+ });
95
+ it('AC-6: 단일 항목이면 첫 항목이 곧 마지막 항목으로 처리된다', () => {
96
+ // Given/When: 항목이 하나뿐인 배열을 아이콘 모드로 렌더하면
97
+ const single = [{ href: '/', label: '홈' }];
98
+ const [iconOnly] = listItems(render({ items: single }));
99
+ // Then: --home 과 --active 가 동시에 붙고 링크가 아니다
100
+ expect(iconOnly.classList.contains(HOME_MODIFIER)).toBe(true);
101
+ expect(iconOnly.classList.contains(ACTIVE_MODIFIER)).toBe(true);
102
+ expect(itemOf(iconOnly)?.tagName).toBe('SPAN');
103
+ expect(iconOnly.querySelector('svg')).not.toBeNull();
104
+ // When: 같은 배열을 라벨 모드로 렌더하면
105
+ const [labelOnly] = listItems(render({ items: single, showHomeIcon: false }));
106
+ // Then: --active 만 붙고 label 텍스트가 나온다
107
+ expect(labelOnly.classList.contains(HOME_MODIFIER)).toBe(false);
108
+ expect(labelOnly.classList.contains(ACTIVE_MODIFIER)).toBe(true);
109
+ expect(labelOnly.textContent).toBe(single[0].label);
110
+ });
111
+ it('AC-7: 기존 호출부 입력의 렌더 결과는 --home·aria-label 추가분 외에 변하지 않는다', () => {
112
+ // Given/When: DocBreadcrumbs 와 같은 입력을 showHomeIcon 미지정으로 렌더하면
113
+ const el = render({ items: DOC_ITEMS });
114
+ const items = listItems(el);
115
+ // Then: 요소 종류·텍스트·href 는 변경 전과 같고, 첫 항목 svg 도 그대로다
116
+ expect(items).toHaveLength(DOC_ITEMS.length);
117
+ expect(items.map((li) => itemOf(li)?.tagName)).toEqual(['A', 'A', 'SPAN']);
118
+ expect(items.map((li) => itemOf(li)?.getAttribute('href'))).toEqual(['/', '/docs', null]);
119
+ expect(items.map((li) => li.textContent)).toEqual(['', DOC_ITEMS[1].label, DOC_ITEMS[2].label]);
120
+ expect(items[0].querySelector('svg')).not.toBeNull();
121
+ // Then: <li> 클래스는 기존 베이스/--active 를 그대로 유지하고, 늘어난 것은 --home 뿐이다
122
+ expect(items.map((li) => Array.from(li.classList).sort())).toEqual([
123
+ ['ncua-breadcrumb__list-item', HOME_MODIFIER].sort(),
124
+ ['ncua-breadcrumb__list-item'],
125
+ ['ncua-breadcrumb__list-item', ACTIVE_MODIFIER].sort(),
126
+ ]);
127
+ // Then: <a>/<span> 에 늘어난 속성은 첫 항목의 aria-label 뿐이다
128
+ expect(itemOf(items[1])?.hasAttribute('aria-label')).toBe(false);
129
+ expect(itemOf(items[2])?.hasAttribute('aria-label')).toBe(false);
130
+ });
131
+ it('비링크 홈 항목에는 role="img" 를 부여해 aria-label 이 무시되지 않게 한다', () => {
132
+ // Given/When: 첫 항목에 href 가 없어 <span> 으로 렌더되는 입력(meta usageExamples.default 형태)을 아이콘 모드로 렌더하면
133
+ const el = render({
134
+ items: [{ label: '대시보드' }, { href: '/products', label: '상품' }, { label: '상품 상세' }],
135
+ });
136
+ // Then: generic role 의 <span> 은 aria-label 을 노출하지 못하므로 role="img" 로 이름을 살린다
137
+ const [first] = listItems(el);
138
+ const home = itemOf(first);
139
+ expect(home?.tagName).toBe('SPAN');
140
+ expect(home?.getAttribute('role')).toBe('img');
141
+ expect(home?.getAttribute('aria-label')).toBe('대시보드');
142
+ });
143
+ it('링크인 홈 항목에는 role 을 부여하지 않는다', () => {
144
+ // Given/When: 첫 항목에 href 가 있어 <a> 로 렌더되는 입력을 아이콘 모드로 렌더하면
145
+ const el = render({ items: DOC_ITEMS });
146
+ // Then: 링크는 이미 접근 가능한 이름을 지원하므로 role 을 덧씌우지 않는다 (ADR-3)
147
+ const items = listItems(el);
148
+ const home = itemOf(items[0]);
149
+ expect(home?.tagName).toBe('A');
150
+ expect(home?.hasAttribute('role')).toBe(false);
151
+ // Then: 홈이 아닌 항목에도 role 은 출력되지 않는다
152
+ expect(items.slice(1).every((li) => !itemOf(li)?.hasAttribute('role'))).toBe(true);
153
+ });
154
+ it('경계: items 가 빈 배열이면 아무것도 렌더하지 않는다', () => {
155
+ // Given/When: 빈 배열을 양쪽 모드로 렌더하면
156
+ const el = render({ items: [], showHomeIcon: false });
157
+ // Then: <nav> 자체가 만들어지지 않는다 (기존 동작 유지)
158
+ expect(el.querySelector('.ncua-breadcrumb')).toBeNull();
159
+ expect(el.innerHTML).toBe('');
160
+ });
161
+ });