@ncds/ui-admin 1.8.8 → 1.8.11
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.
- package/dist/cjs/src/components/feedback-and-status/badge/Badge.js +19 -0
- package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +17 -12
- package/dist/cjs/src/components/index.js +11 -0
- package/dist/cjs/src/components/navigation/context-tab/ContextTab.js +233 -0
- package/dist/cjs/src/components/navigation/context-tab/index.js +16 -0
- package/dist/cjs/src/components/navigation/context-tab/useContextTabScroll.js +84 -0
- package/dist/esm/src/components/feedback-and-status/badge/Badge.js +19 -0
- package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +17 -12
- package/dist/esm/src/components/index.js +1 -0
- package/dist/esm/src/components/navigation/context-tab/ContextTab.js +226 -0
- package/dist/esm/src/components/navigation/context-tab/index.js +1 -0
- package/dist/esm/src/components/navigation/context-tab/useContextTabScroll.js +77 -0
- package/dist/temp/src/components/feedback-and-status/badge/Badge.d.ts +3 -2
- package/dist/temp/src/components/feedback-and-status/badge/Badge.js +10 -0
- package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +15 -12
- package/dist/temp/src/components/index.d.ts +1 -0
- package/dist/temp/src/components/index.js +1 -0
- package/dist/temp/src/components/navigation/context-tab/ContextTab.d.ts +18 -0
- package/dist/temp/src/components/navigation/context-tab/ContextTab.js +103 -0
- package/dist/temp/src/components/navigation/context-tab/index.d.ts +1 -0
- package/dist/temp/src/components/navigation/context-tab/index.js +1 -0
- package/dist/temp/src/components/navigation/context-tab/useContextTabScroll.d.ts +22 -0
- package/dist/temp/src/components/navigation/context-tab/useContextTabScroll.js +65 -0
- package/dist/types/src/components/feedback-and-status/badge/Badge.d.ts +3 -2
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/navigation/context-tab/ContextTab.d.ts +18 -0
- package/dist/types/src/components/navigation/context-tab/index.d.ts +1 -0
- package/dist/types/src/components/navigation/context-tab/useContextTabScroll.d.ts +22 -0
- package/dist/ui-admin/assets/styles/style.css +201 -1
- package/package.json +2 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Check, ChevronLeft, ChevronRight, Menu01 } from '@ncds/ui-admin-icon';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { Button } from '../../action/button/Button';
|
|
6
|
+
import { Badge } from '../../feedback-and-status/badge/Badge';
|
|
7
|
+
import { useContextTabScroll } from './useContextTabScroll';
|
|
8
|
+
const DEFAULT_VISIBLE_TABS_COUNT = 7; // Figma 시안 정합 — Tab Bar 한 페이지 노출 탭 수
|
|
9
|
+
// 컨트롤 버튼은 Button(onlyIcon)으로 렌더한다. 명세 편차: §2.4는 32×32를 명시하나
|
|
10
|
+
// Button 사이즈에 32가 없어 아이콘 16px이 맞는 xs(28×28)를 사용한다. (명세 현행화 대기)
|
|
11
|
+
const CONTROL_BUTTON_SIZE = 'xs';
|
|
12
|
+
const CHECK_ICON_SIZE = 14;
|
|
13
|
+
const MIN_VISIBLE_CONTEXTS = 2;
|
|
14
|
+
/**
|
|
15
|
+
* 컨텍스트 항목의 신규(N) 배지를 Tab Bar·Dropdown 공통으로 렌더한다.
|
|
16
|
+
* Badge의 `new-badge` 타입(신규 콘텐츠 전용, 색·크기 고정)을 사용한다.
|
|
17
|
+
*/
|
|
18
|
+
const renderItemBadges = item => {
|
|
19
|
+
if (!item) return null;
|
|
20
|
+
if (item.isNew) return _jsx(Badge, {
|
|
21
|
+
type: "new-badge",
|
|
22
|
+
size: "sm"
|
|
23
|
+
});
|
|
24
|
+
if (item.badgeLabel) return _jsx(Badge, {
|
|
25
|
+
type: "pill-outline",
|
|
26
|
+
label: item.badgeLabel,
|
|
27
|
+
size: "xs"
|
|
28
|
+
});
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* 스크린리더용 접근성 레이블. N 배지는 시각 전용(아이콘)이라 SR이 의미를 못 읽으므로,
|
|
33
|
+
* "신규"를 풀어 aria-label에 합성한다 (DES-SPEC-030-1 §7). 신규가 아니면 undefined를 반환해
|
|
34
|
+
* 버튼의 보이는 텍스트가 그대로 접근성 이름이 되게 한다.
|
|
35
|
+
*/
|
|
36
|
+
const getAccessibleLabel = item => item.isNew ? `${item.label}, 신규` : undefined;
|
|
37
|
+
const ContextTab = _ref => {
|
|
38
|
+
let {
|
|
39
|
+
menus = [],
|
|
40
|
+
activeTab,
|
|
41
|
+
onTabChange,
|
|
42
|
+
visibleTabsCount = DEFAULT_VISIBLE_TABS_COUNT,
|
|
43
|
+
className
|
|
44
|
+
} = _ref;
|
|
45
|
+
const containerRef = useRef(null);
|
|
46
|
+
const triggerRef = useRef(null);
|
|
47
|
+
const panelRef = useRef(null);
|
|
48
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
49
|
+
// Tab Bar의 가로 스크롤·페이징(에지 계산·활성 탭 자동 스크롤)은 훅으로 분리한다.
|
|
50
|
+
const {
|
|
51
|
+
barRef,
|
|
52
|
+
isBeginning,
|
|
53
|
+
isEnd,
|
|
54
|
+
scrollByPage
|
|
55
|
+
} = useContextTabScroll({
|
|
56
|
+
activeTab,
|
|
57
|
+
menus,
|
|
58
|
+
visibleTabsCount
|
|
59
|
+
});
|
|
60
|
+
// Dropdown panel 닫기 — 외부 클릭 / ESC. ESC는 trigger로 포커스 복원, 외부 클릭은 클릭 위치 유지 (DES-SPEC-030-1 §1.5/§7)
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!isOpen) return;
|
|
63
|
+
const handlePointerDown = event => {
|
|
64
|
+
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
65
|
+
setIsOpen(false);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const handleKeyDown = event => {
|
|
69
|
+
if (event.key === 'Escape') {
|
|
70
|
+
setIsOpen(false);
|
|
71
|
+
triggerRef.current?.focus();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener('mousedown', handlePointerDown);
|
|
75
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
76
|
+
return () => {
|
|
77
|
+
document.removeEventListener('mousedown', handlePointerDown);
|
|
78
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
79
|
+
};
|
|
80
|
+
}, [isOpen]);
|
|
81
|
+
// Dropdown panel이 열리면 첫(비활성 아닌) 항목으로 포커스를 이동한다 (DES-SPEC-030-1 §7)
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!isOpen) return;
|
|
84
|
+
const firstOption = panelRef.current?.querySelector('.ncua-context-tab__option:not(:disabled)');
|
|
85
|
+
firstOption?.focus();
|
|
86
|
+
}, [isOpen]);
|
|
87
|
+
// 컨텍스트가 2개 미만이면 ContextTab을 노출하지 않는다 (DES-SPEC-030-1 §1.2 / F6)
|
|
88
|
+
if (menus.length < MIN_VISIBLE_CONTEXTS) return null;
|
|
89
|
+
const handleSelect = item => {
|
|
90
|
+
if (item.disabled) return;
|
|
91
|
+
onTabChange?.(item.id);
|
|
92
|
+
};
|
|
93
|
+
const handleOptionSelect = item => {
|
|
94
|
+
if (item.disabled) return;
|
|
95
|
+
onTabChange?.(item.id);
|
|
96
|
+
setIsOpen(false);
|
|
97
|
+
triggerRef.current?.focus();
|
|
98
|
+
};
|
|
99
|
+
const renderOption = item => {
|
|
100
|
+
const isActive = item.id === activeTab;
|
|
101
|
+
return _jsx("li", {
|
|
102
|
+
role: "none",
|
|
103
|
+
children: _jsxs("button", {
|
|
104
|
+
type: "button",
|
|
105
|
+
role: "menuitem",
|
|
106
|
+
"aria-current": isActive || undefined,
|
|
107
|
+
"aria-disabled": item.disabled || undefined,
|
|
108
|
+
"aria-label": getAccessibleLabel(item),
|
|
109
|
+
disabled: item.disabled,
|
|
110
|
+
className: classNames('ncua-context-tab__option', {
|
|
111
|
+
'is-active': isActive
|
|
112
|
+
}),
|
|
113
|
+
onClick: () => handleOptionSelect(item),
|
|
114
|
+
children: [_jsx("span", {
|
|
115
|
+
className: "ncua-context-tab__option-label",
|
|
116
|
+
title: item.label,
|
|
117
|
+
children: item.label
|
|
118
|
+
}), renderItemBadges(item), isActive && _jsx(Check, {
|
|
119
|
+
className: "ncua-context-tab__option-check",
|
|
120
|
+
width: CHECK_ICON_SIZE,
|
|
121
|
+
height: CHECK_ICON_SIZE
|
|
122
|
+
})]
|
|
123
|
+
})
|
|
124
|
+
}, item.id);
|
|
125
|
+
};
|
|
126
|
+
// Dropdown panel은 2-column이며, 좌측 column = 짝수 인덱스 / 우측 column = 홀수 인덱스로
|
|
127
|
+
// row-major 읽기 순서를 유지한다 (Figma 시안 정합).
|
|
128
|
+
const leftColumn = menus.filter((_, index) => index % 2 === 0);
|
|
129
|
+
const rightColumn = menus.filter((_, index) => index % 2 === 1);
|
|
130
|
+
// 한 페이지 안에 모두 들어가면(스크롤 불필요) 탭이 영역을 균등하게 꽉 채우도록 한다.
|
|
131
|
+
// 초과 시에는 visibleTabsCount 등분 고정 폭으로 두고 가로 스크롤로 페이징한다.
|
|
132
|
+
const isFill = menus.length <= visibleTabsCount;
|
|
133
|
+
return _jsxs("div", {
|
|
134
|
+
ref: containerRef,
|
|
135
|
+
className: classNames('ncua-context-tab', className),
|
|
136
|
+
children: [_jsx(Button, {
|
|
137
|
+
ref: triggerRef,
|
|
138
|
+
onlyIcon: true,
|
|
139
|
+
hierarchy: "tertiary-gray",
|
|
140
|
+
size: CONTROL_BUTTON_SIZE,
|
|
141
|
+
className: "ncua-context-tab__control ncua-context-tab__control--menu",
|
|
142
|
+
label: "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
|
|
143
|
+
"aria-label": "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
|
|
144
|
+
"aria-haspopup": "menu",
|
|
145
|
+
"aria-expanded": isOpen,
|
|
146
|
+
leadingIcon: {
|
|
147
|
+
type: 'icon',
|
|
148
|
+
icon: Menu01
|
|
149
|
+
},
|
|
150
|
+
onClick: () => setIsOpen(prev => !prev)
|
|
151
|
+
}), _jsx("div", {
|
|
152
|
+
ref: barRef,
|
|
153
|
+
className: classNames('ncua-context-tab__bar', {
|
|
154
|
+
'is-fill': isFill
|
|
155
|
+
}),
|
|
156
|
+
role: "tablist",
|
|
157
|
+
style: {
|
|
158
|
+
'--ncua-context-tab-visible': visibleTabsCount
|
|
159
|
+
},
|
|
160
|
+
children: menus.map(item => {
|
|
161
|
+
const isActive = item.id === activeTab;
|
|
162
|
+
return _jsxs("button", {
|
|
163
|
+
type: "button",
|
|
164
|
+
role: "tab",
|
|
165
|
+
"aria-selected": isActive,
|
|
166
|
+
"aria-disabled": item.disabled || undefined,
|
|
167
|
+
"aria-label": getAccessibleLabel(item),
|
|
168
|
+
disabled: item.disabled,
|
|
169
|
+
title: item.label,
|
|
170
|
+
className: classNames('ncua-context-tab__tab', {
|
|
171
|
+
'is-active': isActive,
|
|
172
|
+
'is-disabled': item.disabled
|
|
173
|
+
}),
|
|
174
|
+
onClick: () => handleSelect(item),
|
|
175
|
+
children: [_jsx("span", {
|
|
176
|
+
className: "ncua-context-tab__tab-label",
|
|
177
|
+
children: item.label
|
|
178
|
+
}), renderItemBadges(item)]
|
|
179
|
+
}, item.id);
|
|
180
|
+
})
|
|
181
|
+
}), _jsxs("div", {
|
|
182
|
+
className: "ncua-context-tab__nav",
|
|
183
|
+
children: [_jsx(Button, {
|
|
184
|
+
onlyIcon: true,
|
|
185
|
+
hierarchy: "tertiary-gray",
|
|
186
|
+
size: CONTROL_BUTTON_SIZE,
|
|
187
|
+
className: "ncua-context-tab__control ncua-context-tab__control--prev",
|
|
188
|
+
label: "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
|
|
189
|
+
"aria-label": "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
|
|
190
|
+
disabled: isBeginning,
|
|
191
|
+
leadingIcon: {
|
|
192
|
+
type: 'icon',
|
|
193
|
+
icon: ChevronLeft
|
|
194
|
+
},
|
|
195
|
+
onClick: () => scrollByPage(-1)
|
|
196
|
+
}), _jsx(Button, {
|
|
197
|
+
onlyIcon: true,
|
|
198
|
+
hierarchy: "tertiary-gray",
|
|
199
|
+
size: CONTROL_BUTTON_SIZE,
|
|
200
|
+
className: "ncua-context-tab__control ncua-context-tab__control--next",
|
|
201
|
+
label: "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
|
|
202
|
+
"aria-label": "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
|
|
203
|
+
disabled: isEnd,
|
|
204
|
+
leadingIcon: {
|
|
205
|
+
type: 'icon',
|
|
206
|
+
icon: ChevronRight
|
|
207
|
+
},
|
|
208
|
+
onClick: () => scrollByPage(1)
|
|
209
|
+
})]
|
|
210
|
+
}), isOpen && _jsxs("div", {
|
|
211
|
+
ref: panelRef,
|
|
212
|
+
className: "ncua-context-tab__panel",
|
|
213
|
+
role: "menu",
|
|
214
|
+
children: [_jsx("ul", {
|
|
215
|
+
className: "ncua-context-tab__panel-column",
|
|
216
|
+
role: "none",
|
|
217
|
+
children: leftColumn.map(renderOption)
|
|
218
|
+
}), _jsx("ul", {
|
|
219
|
+
className: "ncua-context-tab__panel-column",
|
|
220
|
+
role: "none",
|
|
221
|
+
children: rightColumn.map(renderOption)
|
|
222
|
+
})]
|
|
223
|
+
})]
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
export { ContextTab };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ContextTab';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
const EDGE_THRESHOLD = 1; // 스크롤 끝 판정 시 소수점 오차 보정용 여유값(px)
|
|
3
|
+
/**
|
|
4
|
+
* ContextTab Tab Bar의 네이티브 가로 스크롤·페이징 메커니즘을 담당하는 훅.
|
|
5
|
+
*
|
|
6
|
+
* - `barRef`: 스크롤 컨테이너(ref)
|
|
7
|
+
* - `isBeginning` / `isEnd`: 좌/우 네비 버튼의 disabled 판정
|
|
8
|
+
* - `scrollByPage`: `<` `>` 클릭 시 한 페이지(보이는 폭)만큼 이동 — scroll-snap이 탭 경계에 맞춰 정렬
|
|
9
|
+
* - `activeTab` 변경 시 해당 탭이 보이는 페이지로 자동 스크롤
|
|
10
|
+
*/
|
|
11
|
+
export const useContextTabScroll = _ref => {
|
|
12
|
+
let {
|
|
13
|
+
activeTab,
|
|
14
|
+
menus,
|
|
15
|
+
visibleTabsCount
|
|
16
|
+
} = _ref;
|
|
17
|
+
const barRef = useRef(null);
|
|
18
|
+
const [isBeginning, setIsBeginning] = useState(true);
|
|
19
|
+
const [isEnd, setIsEnd] = useState(true);
|
|
20
|
+
// effect가 menus 식별자에 묶이지 않도록 최신 menus를 ref로 보관한다.
|
|
21
|
+
const menusRef = useRef(menus);
|
|
22
|
+
menusRef.current = menus;
|
|
23
|
+
// Tab Bar의 스크롤 위치로 좌/우 네비 버튼의 활성 여부를 계산한다.
|
|
24
|
+
const updateEdges = useCallback(() => {
|
|
25
|
+
const bar = barRef.current;
|
|
26
|
+
if (!bar) return;
|
|
27
|
+
setIsBeginning(bar.scrollLeft <= EDGE_THRESHOLD);
|
|
28
|
+
setIsEnd(bar.scrollLeft + bar.clientWidth >= bar.scrollWidth - EDGE_THRESHOLD);
|
|
29
|
+
}, []);
|
|
30
|
+
// `<` `>` 클릭 시 보이는 폭(한 페이지)만큼 스크롤한다. scroll-snap이 탭 경계에 맞춰 정렬한다.
|
|
31
|
+
const scrollByPage = direction => {
|
|
32
|
+
const bar = barRef.current;
|
|
33
|
+
if (!bar) return;
|
|
34
|
+
bar.scrollBy({
|
|
35
|
+
left: direction * bar.clientWidth,
|
|
36
|
+
behavior: 'smooth'
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
// 활성 탭이 항상 viewport 안에 보이도록 — activeTab 변경 시에만 해당 페이지로 이동 (DES-SPEC-030-1 §1.6).
|
|
40
|
+
// menus를 deps에 넣지 않아(ref로 최신값 참조) 사용자가 다른 페이지를 보는 중 부모 리렌더로 페이지가 되돌아가는 것을 막는다.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const bar = barRef.current;
|
|
43
|
+
if (!bar || activeTab == null) return;
|
|
44
|
+
const activeIndex = menusRef.current.findIndex(menu => menu.id === activeTab);
|
|
45
|
+
if (activeIndex < 0) return;
|
|
46
|
+
const pageStart = Math.floor(activeIndex / visibleTabsCount) * visibleTabsCount;
|
|
47
|
+
const target = bar.children[pageStart];
|
|
48
|
+
if (target) bar.scrollTo({
|
|
49
|
+
left: target.offsetLeft,
|
|
50
|
+
behavior: 'smooth'
|
|
51
|
+
});
|
|
52
|
+
}, [activeTab, visibleTabsCount]);
|
|
53
|
+
// 스크롤·리사이즈·컨텍스트 수 변경 시 좌/우 네비 활성 여부를 다시 계산한다.
|
|
54
|
+
// menus.length·visibleTabsCount 변경 시 scrollWidth가 바뀌지만 ResizeObserver는 clientWidth만 감지하므로,
|
|
55
|
+
// 두 값을 재실행 트리거로 deps에 둔다(본문에서 직접 읽지 않음).
|
|
56
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: 위 값들은 에지 재계산을 위한 의도적 트리거
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const bar = barRef.current;
|
|
59
|
+
if (!bar) return;
|
|
60
|
+
updateEdges();
|
|
61
|
+
bar.addEventListener('scroll', updateEdges, {
|
|
62
|
+
passive: true
|
|
63
|
+
});
|
|
64
|
+
const observer = new ResizeObserver(updateEdges);
|
|
65
|
+
observer.observe(bar);
|
|
66
|
+
return () => {
|
|
67
|
+
bar.removeEventListener('scroll', updateEdges);
|
|
68
|
+
observer.disconnect();
|
|
69
|
+
};
|
|
70
|
+
}, [updateEdges, menus.length, visibleTabsCount]);
|
|
71
|
+
return {
|
|
72
|
+
barRef,
|
|
73
|
+
isBeginning,
|
|
74
|
+
isEnd,
|
|
75
|
+
scrollByPage
|
|
76
|
+
};
|
|
77
|
+
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ColorTone } from '../../../../constant/color';
|
|
2
2
|
import type { Size } from '../../../../constant/size';
|
|
3
3
|
import type { SideSlotType } from '../../../types/side-slot';
|
|
4
|
-
type BadgeType = 'pill-outline' | 'pill-dark-color';
|
|
4
|
+
type BadgeType = 'pill-outline' | 'pill-dark-color' | 'new-badge';
|
|
5
5
|
type BadgeColor = Extract<ColorTone, 'neutral' | 'error' | 'warning' | 'success' | 'blue' | 'pink' | 'disabled'>;
|
|
6
6
|
type BadgeSize = Extract<Size, 'xs' | 'sm' | 'md'>;
|
|
7
7
|
type BadgeProps = {
|
|
8
|
-
|
|
8
|
+
/** new-badge 타입에서는 사용되지 않는다(무시됨). 그 외 타입에서는 필수로 전달한다. */
|
|
9
|
+
label?: string;
|
|
9
10
|
type?: BadgeType;
|
|
10
11
|
color?: BadgeColor;
|
|
11
12
|
className?: string;
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { New } from '@ncds/ui-admin-icon';
|
|
2
3
|
import classNames from 'classnames';
|
|
3
4
|
import { sideSlotRender } from './utils';
|
|
4
5
|
/**
|
|
5
6
|
* 뱃지 컴포넌트의 아이콘은 디자인 시스템에서 12px 고정으로 정의되어 있습니다.
|
|
6
7
|
*/
|
|
7
8
|
const BADGE_ICON_SIZE = 12;
|
|
9
|
+
/** new-badge 박스 안에 들어가는 'N' 아이콘 크기. 박스는 sm 16px / md 20px 이며 아이콘은 그보다 작다(패딩 포함). */
|
|
10
|
+
const NEW_BADGE_ICON_SIZE = { sm: 12, md: 16 };
|
|
8
11
|
const Badge = ({ label, type = 'pill-outline', color = 'neutral', className, leadingIcon, trailingIcon, size = 'xs', }) => {
|
|
12
|
+
// new-badge: 신규 콘텐츠 'N' 마크 전용 타입. label·color·leadingIcon·trailingIcon은 무시되고
|
|
13
|
+
// 색은 --pink-600 으로 고정된다. (디자이너 명세: Icon/FeaturedIcon/Badge로 대체 불가한 전용 표시)
|
|
14
|
+
if (type === 'new-badge') {
|
|
15
|
+
const newBadgeSize = size === 'md' ? 'md' : 'sm';
|
|
16
|
+
const iconSize = NEW_BADGE_ICON_SIZE[newBadgeSize];
|
|
17
|
+
return (_jsx("span", { className: classNames('ncua-badge', 'ncua-badge--new-badge', `ncua-badge--new-badge-${newBadgeSize}`, className), children: _jsx(New, { width: iconSize, height: iconSize }) }));
|
|
18
|
+
}
|
|
9
19
|
return (_jsxs("span", { className: classNames('ncua-badge', `ncua-badge--${type}`, `ncua-badge--${color}`, `ncua-badge--${size}`, className), children: [leadingIcon && sideSlotRender({ slot: leadingIcon, defaultIconSize: BADGE_ICON_SIZE }), _jsx("span", { className: "ncua-badge__label", children: label }), trailingIcon && sideSlotRender({ slot: trailingIcon, defaultIconSize: BADGE_ICON_SIZE })] }));
|
|
10
20
|
};
|
|
11
21
|
export { Badge };
|
|
@@ -7,6 +7,18 @@ import { Tooltip } from '../../overlays/tooltip';
|
|
|
7
7
|
import { HintText, Label } from '../../shared';
|
|
8
8
|
import { FileInputErrorType as ImageFileInputErrorType } from '../file-input/FileInput';
|
|
9
9
|
import { ImagePreview } from './components/ImagePreview';
|
|
10
|
+
const toInvalidFile = (file, errorType) => ({
|
|
11
|
+
name: file.name,
|
|
12
|
+
size: file.size,
|
|
13
|
+
type: file.type,
|
|
14
|
+
lastModified: file.lastModified,
|
|
15
|
+
webkitRelativePath: file.webkitRelativePath,
|
|
16
|
+
arrayBuffer: () => file.arrayBuffer(),
|
|
17
|
+
stream: () => file.stream(),
|
|
18
|
+
text: () => file.text(),
|
|
19
|
+
slice: (...args) => file.slice(...args),
|
|
20
|
+
errorType,
|
|
21
|
+
});
|
|
10
22
|
export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = false, maxFileSize, maxFileCount, value, onChange, onFileSelect, onFail, buttonLabel = '파일 찾기', imagePreviewTooltipLabel = '이미지 업로드', disabled, label, hintItems, validation, destructive, isRequired, showHelpIcon, hintText, showFileTagList = true, showHintText = true, showFileInput = true, ...props }, ref) => {
|
|
11
23
|
const fileInputRef = useRef(null);
|
|
12
24
|
useImperativeHandle(ref, () => fileInputRef.current);
|
|
@@ -48,25 +60,16 @@ export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = fals
|
|
|
48
60
|
const invalidFiles = [];
|
|
49
61
|
for (const file of fileList) {
|
|
50
62
|
if (files.some((f) => f.name === file.name && f.size === file.size)) {
|
|
51
|
-
invalidFiles.push(
|
|
52
|
-
...file,
|
|
53
|
-
errorType: ImageFileInputErrorType.ALREADY_UPLOADED,
|
|
54
|
-
});
|
|
63
|
+
invalidFiles.push(toInvalidFile(file, ImageFileInputErrorType.ALREADY_UPLOADED));
|
|
55
64
|
continue;
|
|
56
65
|
}
|
|
57
66
|
if (!!maxFileSize && file.size > maxFileSize) {
|
|
58
|
-
invalidFiles.push(
|
|
59
|
-
...file,
|
|
60
|
-
errorType: ImageFileInputErrorType.EXCEED_MAX_FILE_SIZE,
|
|
61
|
-
});
|
|
67
|
+
invalidFiles.push(toInvalidFile(file, ImageFileInputErrorType.EXCEED_MAX_FILE_SIZE));
|
|
62
68
|
continue;
|
|
63
69
|
}
|
|
64
70
|
// Skip max count check if maxFileCount is 1 (allow replacement)
|
|
65
71
|
if (!!maxFileCount && maxFileCount !== 1 && files.length + validFiles.length >= maxFileCount) {
|
|
66
|
-
invalidFiles.push(
|
|
67
|
-
...file,
|
|
68
|
-
errorType: ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT,
|
|
69
|
-
});
|
|
72
|
+
invalidFiles.push(toInvalidFile(file, ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT));
|
|
70
73
|
continue;
|
|
71
74
|
}
|
|
72
75
|
validFiles.push(file);
|
|
@@ -36,6 +36,7 @@ export * from './layout/block-header';
|
|
|
36
36
|
export * from './layout/divider';
|
|
37
37
|
export * from './layout/page-title';
|
|
38
38
|
export * from './navigation/bread-crumb';
|
|
39
|
+
export * from './navigation/context-tab';
|
|
39
40
|
export * from './navigation/horizontal-tab';
|
|
40
41
|
export * from './navigation/pagination';
|
|
41
42
|
export * from './navigation/vertical-tab';
|
|
@@ -42,6 +42,7 @@ export * from './layout/divider';
|
|
|
42
42
|
export * from './layout/page-title';
|
|
43
43
|
// Navigation
|
|
44
44
|
export * from './navigation/bread-crumb';
|
|
45
|
+
export * from './navigation/context-tab';
|
|
45
46
|
export * from './navigation/horizontal-tab';
|
|
46
47
|
export * from './navigation/pagination';
|
|
47
48
|
export * from './navigation/vertical-tab';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface ContextTabItemProps {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
isNew?: boolean;
|
|
5
|
+
/** pill-outline 배지로 표시할 텍스트. 있으면 비신규 항목에도 배지를 노출한다. */
|
|
6
|
+
badgeLabel?: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
interface ContextTabProps {
|
|
10
|
+
menus?: ContextTabItemProps[];
|
|
11
|
+
activeTab?: string;
|
|
12
|
+
onTabChange?: (id: string) => void;
|
|
13
|
+
visibleTabsCount?: number;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
declare const ContextTab: ({ menus, activeTab, onTabChange, visibleTabsCount, className, }: ContextTabProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
17
|
+
export { ContextTab };
|
|
18
|
+
export type { ContextTabItemProps, ContextTabProps };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Check, ChevronLeft, ChevronRight, Menu01 } from '@ncds/ui-admin-icon';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { Button } from '../../action/button/Button';
|
|
6
|
+
import { Badge } from '../../feedback-and-status/badge/Badge';
|
|
7
|
+
import { useContextTabScroll } from './useContextTabScroll';
|
|
8
|
+
const DEFAULT_VISIBLE_TABS_COUNT = 7; // Figma 시안 정합 — Tab Bar 한 페이지 노출 탭 수
|
|
9
|
+
// 컨트롤 버튼은 Button(onlyIcon)으로 렌더한다. 명세 편차: §2.4는 32×32를 명시하나
|
|
10
|
+
// Button 사이즈에 32가 없어 아이콘 16px이 맞는 xs(28×28)를 사용한다. (명세 현행화 대기)
|
|
11
|
+
const CONTROL_BUTTON_SIZE = 'xs';
|
|
12
|
+
const CHECK_ICON_SIZE = 14;
|
|
13
|
+
const MIN_VISIBLE_CONTEXTS = 2;
|
|
14
|
+
/**
|
|
15
|
+
* 컨텍스트 항목의 신규(N) 배지를 Tab Bar·Dropdown 공통으로 렌더한다.
|
|
16
|
+
* Badge의 `new-badge` 타입(신규 콘텐츠 전용, 색·크기 고정)을 사용한다.
|
|
17
|
+
*/
|
|
18
|
+
const renderItemBadges = (item) => {
|
|
19
|
+
if (!item)
|
|
20
|
+
return null;
|
|
21
|
+
if (item.isNew)
|
|
22
|
+
return _jsx(Badge, { type: "new-badge", size: "sm" });
|
|
23
|
+
if (item.badgeLabel)
|
|
24
|
+
return _jsx(Badge, { type: "pill-outline", label: item.badgeLabel, size: "xs" });
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* 스크린리더용 접근성 레이블. N 배지는 시각 전용(아이콘)이라 SR이 의미를 못 읽으므로,
|
|
29
|
+
* "신규"를 풀어 aria-label에 합성한다 (DES-SPEC-030-1 §7). 신규가 아니면 undefined를 반환해
|
|
30
|
+
* 버튼의 보이는 텍스트가 그대로 접근성 이름이 되게 한다.
|
|
31
|
+
*/
|
|
32
|
+
const getAccessibleLabel = (item) => item.isNew ? `${item.label}, 신규` : undefined;
|
|
33
|
+
const ContextTab = ({ menus = [], activeTab, onTabChange, visibleTabsCount = DEFAULT_VISIBLE_TABS_COUNT, className, }) => {
|
|
34
|
+
const containerRef = useRef(null);
|
|
35
|
+
const triggerRef = useRef(null);
|
|
36
|
+
const panelRef = useRef(null);
|
|
37
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
38
|
+
// Tab Bar의 가로 스크롤·페이징(에지 계산·활성 탭 자동 스크롤)은 훅으로 분리한다.
|
|
39
|
+
const { barRef, isBeginning, isEnd, scrollByPage } = useContextTabScroll({ activeTab, menus, visibleTabsCount });
|
|
40
|
+
// Dropdown panel 닫기 — 외부 클릭 / ESC. ESC는 trigger로 포커스 복원, 외부 클릭은 클릭 위치 유지 (DES-SPEC-030-1 §1.5/§7)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!isOpen)
|
|
43
|
+
return;
|
|
44
|
+
const handlePointerDown = (event) => {
|
|
45
|
+
if (containerRef.current && !containerRef.current.contains(event.target)) {
|
|
46
|
+
setIsOpen(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const handleKeyDown = (event) => {
|
|
50
|
+
if (event.key === 'Escape') {
|
|
51
|
+
setIsOpen(false);
|
|
52
|
+
triggerRef.current?.focus();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
document.addEventListener('mousedown', handlePointerDown);
|
|
56
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
57
|
+
return () => {
|
|
58
|
+
document.removeEventListener('mousedown', handlePointerDown);
|
|
59
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
60
|
+
};
|
|
61
|
+
}, [isOpen]);
|
|
62
|
+
// Dropdown panel이 열리면 첫(비활성 아닌) 항목으로 포커스를 이동한다 (DES-SPEC-030-1 §7)
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!isOpen)
|
|
65
|
+
return;
|
|
66
|
+
const firstOption = panelRef.current?.querySelector('.ncua-context-tab__option:not(:disabled)');
|
|
67
|
+
firstOption?.focus();
|
|
68
|
+
}, [isOpen]);
|
|
69
|
+
// 컨텍스트가 2개 미만이면 ContextTab을 노출하지 않는다 (DES-SPEC-030-1 §1.2 / F6)
|
|
70
|
+
if (menus.length < MIN_VISIBLE_CONTEXTS)
|
|
71
|
+
return null;
|
|
72
|
+
const handleSelect = (item) => {
|
|
73
|
+
if (item.disabled)
|
|
74
|
+
return;
|
|
75
|
+
onTabChange?.(item.id);
|
|
76
|
+
};
|
|
77
|
+
const handleOptionSelect = (item) => {
|
|
78
|
+
if (item.disabled)
|
|
79
|
+
return;
|
|
80
|
+
onTabChange?.(item.id);
|
|
81
|
+
setIsOpen(false);
|
|
82
|
+
triggerRef.current?.focus();
|
|
83
|
+
};
|
|
84
|
+
const renderOption = (item) => {
|
|
85
|
+
const isActive = item.id === activeTab;
|
|
86
|
+
return (_jsx("li", { role: "none", children: _jsxs("button", { type: "button", role: "menuitem", "aria-current": isActive || undefined, "aria-disabled": item.disabled || undefined, "aria-label": getAccessibleLabel(item), disabled: item.disabled, className: classNames('ncua-context-tab__option', { 'is-active': isActive }), onClick: () => handleOptionSelect(item), children: [_jsx("span", { className: "ncua-context-tab__option-label", title: item.label, children: item.label }), renderItemBadges(item), isActive && (_jsx(Check, { className: "ncua-context-tab__option-check", width: CHECK_ICON_SIZE, height: CHECK_ICON_SIZE }))] }) }, item.id));
|
|
87
|
+
};
|
|
88
|
+
// Dropdown panel은 2-column이며, 좌측 column = 짝수 인덱스 / 우측 column = 홀수 인덱스로
|
|
89
|
+
// row-major 읽기 순서를 유지한다 (Figma 시안 정합).
|
|
90
|
+
const leftColumn = menus.filter((_, index) => index % 2 === 0);
|
|
91
|
+
const rightColumn = menus.filter((_, index) => index % 2 === 1);
|
|
92
|
+
// 한 페이지 안에 모두 들어가면(스크롤 불필요) 탭이 영역을 균등하게 꽉 채우도록 한다.
|
|
93
|
+
// 초과 시에는 visibleTabsCount 등분 고정 폭으로 두고 가로 스크롤로 페이징한다.
|
|
94
|
+
const isFill = menus.length <= visibleTabsCount;
|
|
95
|
+
return (_jsxs("div", { ref: containerRef, className: classNames('ncua-context-tab', className), children: [_jsx(Button, { ref: triggerRef, onlyIcon: true, hierarchy: "tertiary-gray", size: CONTROL_BUTTON_SIZE, className: "ncua-context-tab__control ncua-context-tab__control--menu", label: "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-label": "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-haspopup": "menu", "aria-expanded": isOpen, leadingIcon: { type: 'icon', icon: Menu01 }, onClick: () => setIsOpen((prev) => !prev) }), _jsx("div", { ref: barRef, className: classNames('ncua-context-tab__bar', { 'is-fill': isFill }), role: "tablist", style: { '--ncua-context-tab-visible': visibleTabsCount }, children: menus.map((item) => {
|
|
96
|
+
const isActive = item.id === activeTab;
|
|
97
|
+
return (_jsxs("button", { type: "button", role: "tab", "aria-selected": isActive, "aria-disabled": item.disabled || undefined, "aria-label": getAccessibleLabel(item), disabled: item.disabled, title: item.label, className: classNames('ncua-context-tab__tab', {
|
|
98
|
+
'is-active': isActive,
|
|
99
|
+
'is-disabled': item.disabled,
|
|
100
|
+
}), onClick: () => handleSelect(item), children: [_jsx("span", { className: "ncua-context-tab__tab-label", children: item.label }), renderItemBadges(item)] }, item.id));
|
|
101
|
+
}) }), _jsxs("div", { className: "ncua-context-tab__nav", children: [_jsx(Button, { onlyIcon: true, hierarchy: "tertiary-gray", size: CONTROL_BUTTON_SIZE, className: "ncua-context-tab__control ncua-context-tab__control--prev", label: "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-label": "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", disabled: isBeginning, leadingIcon: { type: 'icon', icon: ChevronLeft }, onClick: () => scrollByPage(-1) }), _jsx(Button, { onlyIcon: true, hierarchy: "tertiary-gray", size: CONTROL_BUTTON_SIZE, className: "ncua-context-tab__control ncua-context-tab__control--next", label: "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", "aria-label": "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D", disabled: isEnd, leadingIcon: { type: 'icon', icon: ChevronRight }, onClick: () => scrollByPage(1) })] }), isOpen && (_jsxs("div", { ref: panelRef, className: "ncua-context-tab__panel", role: "menu", children: [_jsx("ul", { className: "ncua-context-tab__panel-column", role: "none", children: leftColumn.map(renderOption) }), _jsx("ul", { className: "ncua-context-tab__panel-column", role: "none", children: rightColumn.map(renderOption) })] }))] }));
|
|
102
|
+
};
|
|
103
|
+
export { ContextTab };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ContextTab';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './ContextTab';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface UseContextTabScrollParams {
|
|
2
|
+
activeTab?: string;
|
|
3
|
+
menus: {
|
|
4
|
+
id: string;
|
|
5
|
+
}[];
|
|
6
|
+
visibleTabsCount: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* ContextTab Tab Bar의 네이티브 가로 스크롤·페이징 메커니즘을 담당하는 훅.
|
|
10
|
+
*
|
|
11
|
+
* - `barRef`: 스크롤 컨테이너(ref)
|
|
12
|
+
* - `isBeginning` / `isEnd`: 좌/우 네비 버튼의 disabled 판정
|
|
13
|
+
* - `scrollByPage`: `<` `>` 클릭 시 한 페이지(보이는 폭)만큼 이동 — scroll-snap이 탭 경계에 맞춰 정렬
|
|
14
|
+
* - `activeTab` 변경 시 해당 탭이 보이는 페이지로 자동 스크롤
|
|
15
|
+
*/
|
|
16
|
+
export declare const useContextTabScroll: ({ activeTab, menus, visibleTabsCount }: UseContextTabScrollParams) => {
|
|
17
|
+
barRef: import("react").RefObject<HTMLDivElement>;
|
|
18
|
+
isBeginning: boolean;
|
|
19
|
+
isEnd: boolean;
|
|
20
|
+
scrollByPage: (direction: 1 | -1) => void;
|
|
21
|
+
};
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
const EDGE_THRESHOLD = 1; // 스크롤 끝 판정 시 소수점 오차 보정용 여유값(px)
|
|
3
|
+
/**
|
|
4
|
+
* ContextTab Tab Bar의 네이티브 가로 스크롤·페이징 메커니즘을 담당하는 훅.
|
|
5
|
+
*
|
|
6
|
+
* - `barRef`: 스크롤 컨테이너(ref)
|
|
7
|
+
* - `isBeginning` / `isEnd`: 좌/우 네비 버튼의 disabled 판정
|
|
8
|
+
* - `scrollByPage`: `<` `>` 클릭 시 한 페이지(보이는 폭)만큼 이동 — scroll-snap이 탭 경계에 맞춰 정렬
|
|
9
|
+
* - `activeTab` 변경 시 해당 탭이 보이는 페이지로 자동 스크롤
|
|
10
|
+
*/
|
|
11
|
+
export const useContextTabScroll = ({ activeTab, menus, visibleTabsCount }) => {
|
|
12
|
+
const barRef = useRef(null);
|
|
13
|
+
const [isBeginning, setIsBeginning] = useState(true);
|
|
14
|
+
const [isEnd, setIsEnd] = useState(true);
|
|
15
|
+
// effect가 menus 식별자에 묶이지 않도록 최신 menus를 ref로 보관한다.
|
|
16
|
+
const menusRef = useRef(menus);
|
|
17
|
+
menusRef.current = menus;
|
|
18
|
+
// Tab Bar의 스크롤 위치로 좌/우 네비 버튼의 활성 여부를 계산한다.
|
|
19
|
+
const updateEdges = useCallback(() => {
|
|
20
|
+
const bar = barRef.current;
|
|
21
|
+
if (!bar)
|
|
22
|
+
return;
|
|
23
|
+
setIsBeginning(bar.scrollLeft <= EDGE_THRESHOLD);
|
|
24
|
+
setIsEnd(bar.scrollLeft + bar.clientWidth >= bar.scrollWidth - EDGE_THRESHOLD);
|
|
25
|
+
}, []);
|
|
26
|
+
// `<` `>` 클릭 시 보이는 폭(한 페이지)만큼 스크롤한다. scroll-snap이 탭 경계에 맞춰 정렬한다.
|
|
27
|
+
const scrollByPage = (direction) => {
|
|
28
|
+
const bar = barRef.current;
|
|
29
|
+
if (!bar)
|
|
30
|
+
return;
|
|
31
|
+
bar.scrollBy({ left: direction * bar.clientWidth, behavior: 'smooth' });
|
|
32
|
+
};
|
|
33
|
+
// 활성 탭이 항상 viewport 안에 보이도록 — activeTab 변경 시에만 해당 페이지로 이동 (DES-SPEC-030-1 §1.6).
|
|
34
|
+
// menus를 deps에 넣지 않아(ref로 최신값 참조) 사용자가 다른 페이지를 보는 중 부모 리렌더로 페이지가 되돌아가는 것을 막는다.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const bar = barRef.current;
|
|
37
|
+
if (!bar || activeTab == null)
|
|
38
|
+
return;
|
|
39
|
+
const activeIndex = menusRef.current.findIndex((menu) => menu.id === activeTab);
|
|
40
|
+
if (activeIndex < 0)
|
|
41
|
+
return;
|
|
42
|
+
const pageStart = Math.floor(activeIndex / visibleTabsCount) * visibleTabsCount;
|
|
43
|
+
const target = bar.children[pageStart];
|
|
44
|
+
if (target)
|
|
45
|
+
bar.scrollTo({ left: target.offsetLeft, behavior: 'smooth' });
|
|
46
|
+
}, [activeTab, visibleTabsCount]);
|
|
47
|
+
// 스크롤·리사이즈·컨텍스트 수 변경 시 좌/우 네비 활성 여부를 다시 계산한다.
|
|
48
|
+
// menus.length·visibleTabsCount 변경 시 scrollWidth가 바뀌지만 ResizeObserver는 clientWidth만 감지하므로,
|
|
49
|
+
// 두 값을 재실행 트리거로 deps에 둔다(본문에서 직접 읽지 않음).
|
|
50
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: 위 값들은 에지 재계산을 위한 의도적 트리거
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const bar = barRef.current;
|
|
53
|
+
if (!bar)
|
|
54
|
+
return;
|
|
55
|
+
updateEdges();
|
|
56
|
+
bar.addEventListener('scroll', updateEdges, { passive: true });
|
|
57
|
+
const observer = new ResizeObserver(updateEdges);
|
|
58
|
+
observer.observe(bar);
|
|
59
|
+
return () => {
|
|
60
|
+
bar.removeEventListener('scroll', updateEdges);
|
|
61
|
+
observer.disconnect();
|
|
62
|
+
};
|
|
63
|
+
}, [updateEdges, menus.length, visibleTabsCount]);
|
|
64
|
+
return { barRef, isBeginning, isEnd, scrollByPage };
|
|
65
|
+
};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ColorTone } from '../../../../constant/color';
|
|
2
2
|
import type { Size } from '../../../../constant/size';
|
|
3
3
|
import type { SideSlotType } from '../../../types/side-slot';
|
|
4
|
-
type BadgeType = 'pill-outline' | 'pill-dark-color';
|
|
4
|
+
type BadgeType = 'pill-outline' | 'pill-dark-color' | 'new-badge';
|
|
5
5
|
type BadgeColor = Extract<ColorTone, 'neutral' | 'error' | 'warning' | 'success' | 'blue' | 'pink' | 'disabled'>;
|
|
6
6
|
type BadgeSize = Extract<Size, 'xs' | 'sm' | 'md'>;
|
|
7
7
|
type BadgeProps = {
|
|
8
|
-
|
|
8
|
+
/** new-badge 타입에서는 사용되지 않는다(무시됨). 그 외 타입에서는 필수로 전달한다. */
|
|
9
|
+
label?: string;
|
|
9
10
|
type?: BadgeType;
|
|
10
11
|
color?: BadgeColor;
|
|
11
12
|
className?: string;
|
|
@@ -36,6 +36,7 @@ export * from './layout/block-header';
|
|
|
36
36
|
export * from './layout/divider';
|
|
37
37
|
export * from './layout/page-title';
|
|
38
38
|
export * from './navigation/bread-crumb';
|
|
39
|
+
export * from './navigation/context-tab';
|
|
39
40
|
export * from './navigation/horizontal-tab';
|
|
40
41
|
export * from './navigation/pagination';
|
|
41
42
|
export * from './navigation/vertical-tab';
|