@ncds/ui-admin 1.8.9 → 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.
Files changed (22) hide show
  1. package/dist/cjs/src/components/index.js +11 -0
  2. package/dist/cjs/src/components/navigation/context-tab/ContextTab.js +233 -0
  3. package/dist/cjs/src/components/navigation/context-tab/index.js +16 -0
  4. package/dist/cjs/src/components/navigation/context-tab/useContextTabScroll.js +84 -0
  5. package/dist/esm/src/components/index.js +1 -0
  6. package/dist/esm/src/components/navigation/context-tab/ContextTab.js +226 -0
  7. package/dist/esm/src/components/navigation/context-tab/index.js +1 -0
  8. package/dist/esm/src/components/navigation/context-tab/useContextTabScroll.js +77 -0
  9. package/dist/temp/src/components/index.d.ts +1 -0
  10. package/dist/temp/src/components/index.js +1 -0
  11. package/dist/temp/src/components/navigation/context-tab/ContextTab.d.ts +18 -0
  12. package/dist/temp/src/components/navigation/context-tab/ContextTab.js +103 -0
  13. package/dist/temp/src/components/navigation/context-tab/index.d.ts +1 -0
  14. package/dist/temp/src/components/navigation/context-tab/index.js +1 -0
  15. package/dist/temp/src/components/navigation/context-tab/useContextTabScroll.d.ts +22 -0
  16. package/dist/temp/src/components/navigation/context-tab/useContextTabScroll.js +65 -0
  17. package/dist/types/src/components/index.d.ts +1 -0
  18. package/dist/types/src/components/navigation/context-tab/ContextTab.d.ts +18 -0
  19. package/dist/types/src/components/navigation/context-tab/index.d.ts +1 -0
  20. package/dist/types/src/components/navigation/context-tab/useContextTabScroll.d.ts +22 -0
  21. package/dist/ui-admin/assets/styles/style.css +158 -0
  22. package/package.json +1 -1
@@ -410,6 +410,17 @@ Object.keys(_breadCrumb).forEach(function (key) {
410
410
  }
411
411
  });
412
412
  });
413
+ var _contextTab = require("./navigation/context-tab");
414
+ Object.keys(_contextTab).forEach(function (key) {
415
+ if (key === "default" || key === "__esModule") return;
416
+ if (key in exports && exports[key] === _contextTab[key]) return;
417
+ Object.defineProperty(exports, key, {
418
+ enumerable: true,
419
+ get: function () {
420
+ return _contextTab[key];
421
+ }
422
+ });
423
+ });
413
424
  var _horizontalTab = require("./navigation/horizontal-tab");
414
425
  Object.keys(_horizontalTab).forEach(function (key) {
415
426
  if (key === "default" || key === "__esModule") return;
@@ -0,0 +1,233 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.ContextTab = void 0;
7
+ var _jsxRuntime = require("react/jsx-runtime");
8
+ var _uiAdminIcon = require("@ncds/ui-admin-icon");
9
+ var _classnames = _interopRequireDefault(require("classnames"));
10
+ var _react = require("react");
11
+ var _Button = require("../../action/button/Button");
12
+ var _Badge = require("../../feedback-and-status/badge/Badge");
13
+ var _useContextTabScroll = require("./useContextTabScroll");
14
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
15
+ const DEFAULT_VISIBLE_TABS_COUNT = 7; // Figma 시안 정합 — Tab Bar 한 페이지 노출 탭 수
16
+ // 컨트롤 버튼은 Button(onlyIcon)으로 렌더한다. 명세 편차: §2.4는 32×32를 명시하나
17
+ // Button 사이즈에 32가 없어 아이콘 16px이 맞는 xs(28×28)를 사용한다. (명세 현행화 대기)
18
+ const CONTROL_BUTTON_SIZE = 'xs';
19
+ const CHECK_ICON_SIZE = 14;
20
+ const MIN_VISIBLE_CONTEXTS = 2;
21
+ /**
22
+ * 컨텍스트 항목의 신규(N) 배지를 Tab Bar·Dropdown 공통으로 렌더한다.
23
+ * Badge의 `new-badge` 타입(신규 콘텐츠 전용, 색·크기 고정)을 사용한다.
24
+ */
25
+ const renderItemBadges = item => {
26
+ if (!item) return null;
27
+ if (item.isNew) return (0, _jsxRuntime.jsx)(_Badge.Badge, {
28
+ type: "new-badge",
29
+ size: "sm"
30
+ });
31
+ if (item.badgeLabel) return (0, _jsxRuntime.jsx)(_Badge.Badge, {
32
+ type: "pill-outline",
33
+ label: item.badgeLabel,
34
+ size: "xs"
35
+ });
36
+ return null;
37
+ };
38
+ /**
39
+ * 스크린리더용 접근성 레이블. N 배지는 시각 전용(아이콘)이라 SR이 의미를 못 읽으므로,
40
+ * "신규"를 풀어 aria-label에 합성한다 (DES-SPEC-030-1 §7). 신규가 아니면 undefined를 반환해
41
+ * 버튼의 보이는 텍스트가 그대로 접근성 이름이 되게 한다.
42
+ */
43
+ const getAccessibleLabel = item => item.isNew ? `${item.label}, 신규` : undefined;
44
+ const ContextTab = _ref => {
45
+ let {
46
+ menus = [],
47
+ activeTab,
48
+ onTabChange,
49
+ visibleTabsCount = DEFAULT_VISIBLE_TABS_COUNT,
50
+ className
51
+ } = _ref;
52
+ const containerRef = (0, _react.useRef)(null);
53
+ const triggerRef = (0, _react.useRef)(null);
54
+ const panelRef = (0, _react.useRef)(null);
55
+ const [isOpen, setIsOpen] = (0, _react.useState)(false);
56
+ // Tab Bar의 가로 스크롤·페이징(에지 계산·활성 탭 자동 스크롤)은 훅으로 분리한다.
57
+ const {
58
+ barRef,
59
+ isBeginning,
60
+ isEnd,
61
+ scrollByPage
62
+ } = (0, _useContextTabScroll.useContextTabScroll)({
63
+ activeTab,
64
+ menus,
65
+ visibleTabsCount
66
+ });
67
+ // Dropdown panel 닫기 — 외부 클릭 / ESC. ESC는 trigger로 포커스 복원, 외부 클릭은 클릭 위치 유지 (DES-SPEC-030-1 §1.5/§7)
68
+ (0, _react.useEffect)(() => {
69
+ if (!isOpen) return;
70
+ const handlePointerDown = event => {
71
+ if (containerRef.current && !containerRef.current.contains(event.target)) {
72
+ setIsOpen(false);
73
+ }
74
+ };
75
+ const handleKeyDown = event => {
76
+ if (event.key === 'Escape') {
77
+ setIsOpen(false);
78
+ triggerRef.current?.focus();
79
+ }
80
+ };
81
+ document.addEventListener('mousedown', handlePointerDown);
82
+ document.addEventListener('keydown', handleKeyDown);
83
+ return () => {
84
+ document.removeEventListener('mousedown', handlePointerDown);
85
+ document.removeEventListener('keydown', handleKeyDown);
86
+ };
87
+ }, [isOpen]);
88
+ // Dropdown panel이 열리면 첫(비활성 아닌) 항목으로 포커스를 이동한다 (DES-SPEC-030-1 §7)
89
+ (0, _react.useEffect)(() => {
90
+ if (!isOpen) return;
91
+ const firstOption = panelRef.current?.querySelector('.ncua-context-tab__option:not(:disabled)');
92
+ firstOption?.focus();
93
+ }, [isOpen]);
94
+ // 컨텍스트가 2개 미만이면 ContextTab을 노출하지 않는다 (DES-SPEC-030-1 §1.2 / F6)
95
+ if (menus.length < MIN_VISIBLE_CONTEXTS) return null;
96
+ const handleSelect = item => {
97
+ if (item.disabled) return;
98
+ onTabChange?.(item.id);
99
+ };
100
+ const handleOptionSelect = item => {
101
+ if (item.disabled) return;
102
+ onTabChange?.(item.id);
103
+ setIsOpen(false);
104
+ triggerRef.current?.focus();
105
+ };
106
+ const renderOption = item => {
107
+ const isActive = item.id === activeTab;
108
+ return (0, _jsxRuntime.jsx)("li", {
109
+ role: "none",
110
+ children: (0, _jsxRuntime.jsxs)("button", {
111
+ type: "button",
112
+ role: "menuitem",
113
+ "aria-current": isActive || undefined,
114
+ "aria-disabled": item.disabled || undefined,
115
+ "aria-label": getAccessibleLabel(item),
116
+ disabled: item.disabled,
117
+ className: (0, _classnames.default)('ncua-context-tab__option', {
118
+ 'is-active': isActive
119
+ }),
120
+ onClick: () => handleOptionSelect(item),
121
+ children: [(0, _jsxRuntime.jsx)("span", {
122
+ className: "ncua-context-tab__option-label",
123
+ title: item.label,
124
+ children: item.label
125
+ }), renderItemBadges(item), isActive && (0, _jsxRuntime.jsx)(_uiAdminIcon.Check, {
126
+ className: "ncua-context-tab__option-check",
127
+ width: CHECK_ICON_SIZE,
128
+ height: CHECK_ICON_SIZE
129
+ })]
130
+ })
131
+ }, item.id);
132
+ };
133
+ // Dropdown panel은 2-column이며, 좌측 column = 짝수 인덱스 / 우측 column = 홀수 인덱스로
134
+ // row-major 읽기 순서를 유지한다 (Figma 시안 정합).
135
+ const leftColumn = menus.filter((_, index) => index % 2 === 0);
136
+ const rightColumn = menus.filter((_, index) => index % 2 === 1);
137
+ // 한 페이지 안에 모두 들어가면(스크롤 불필요) 탭이 영역을 균등하게 꽉 채우도록 한다.
138
+ // 초과 시에는 visibleTabsCount 등분 고정 폭으로 두고 가로 스크롤로 페이징한다.
139
+ const isFill = menus.length <= visibleTabsCount;
140
+ return (0, _jsxRuntime.jsxs)("div", {
141
+ ref: containerRef,
142
+ className: (0, _classnames.default)('ncua-context-tab', className),
143
+ children: [(0, _jsxRuntime.jsx)(_Button.Button, {
144
+ ref: triggerRef,
145
+ onlyIcon: true,
146
+ hierarchy: "tertiary-gray",
147
+ size: CONTROL_BUTTON_SIZE,
148
+ className: "ncua-context-tab__control ncua-context-tab__control--menu",
149
+ label: "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
150
+ "aria-label": "\uC804\uCCB4 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
151
+ "aria-haspopup": "menu",
152
+ "aria-expanded": isOpen,
153
+ leadingIcon: {
154
+ type: 'icon',
155
+ icon: _uiAdminIcon.Menu01
156
+ },
157
+ onClick: () => setIsOpen(prev => !prev)
158
+ }), (0, _jsxRuntime.jsx)("div", {
159
+ ref: barRef,
160
+ className: (0, _classnames.default)('ncua-context-tab__bar', {
161
+ 'is-fill': isFill
162
+ }),
163
+ role: "tablist",
164
+ style: {
165
+ '--ncua-context-tab-visible': visibleTabsCount
166
+ },
167
+ children: menus.map(item => {
168
+ const isActive = item.id === activeTab;
169
+ return (0, _jsxRuntime.jsxs)("button", {
170
+ type: "button",
171
+ role: "tab",
172
+ "aria-selected": isActive,
173
+ "aria-disabled": item.disabled || undefined,
174
+ "aria-label": getAccessibleLabel(item),
175
+ disabled: item.disabled,
176
+ title: item.label,
177
+ className: (0, _classnames.default)('ncua-context-tab__tab', {
178
+ 'is-active': isActive,
179
+ 'is-disabled': item.disabled
180
+ }),
181
+ onClick: () => handleSelect(item),
182
+ children: [(0, _jsxRuntime.jsx)("span", {
183
+ className: "ncua-context-tab__tab-label",
184
+ children: item.label
185
+ }), renderItemBadges(item)]
186
+ }, item.id);
187
+ })
188
+ }), (0, _jsxRuntime.jsxs)("div", {
189
+ className: "ncua-context-tab__nav",
190
+ children: [(0, _jsxRuntime.jsx)(_Button.Button, {
191
+ onlyIcon: true,
192
+ hierarchy: "tertiary-gray",
193
+ size: CONTROL_BUTTON_SIZE,
194
+ className: "ncua-context-tab__control ncua-context-tab__control--prev",
195
+ label: "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
196
+ "aria-label": "\uC774\uC804 \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
197
+ disabled: isBeginning,
198
+ leadingIcon: {
199
+ type: 'icon',
200
+ icon: _uiAdminIcon.ChevronLeft
201
+ },
202
+ onClick: () => scrollByPage(-1)
203
+ }), (0, _jsxRuntime.jsx)(_Button.Button, {
204
+ onlyIcon: true,
205
+ hierarchy: "tertiary-gray",
206
+ size: CONTROL_BUTTON_SIZE,
207
+ className: "ncua-context-tab__control ncua-context-tab__control--next",
208
+ label: "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
209
+ "aria-label": "\uB2E4\uC74C \uCEE8\uD14D\uC2A4\uD2B8 \uBAA9\uB85D",
210
+ disabled: isEnd,
211
+ leadingIcon: {
212
+ type: 'icon',
213
+ icon: _uiAdminIcon.ChevronRight
214
+ },
215
+ onClick: () => scrollByPage(1)
216
+ })]
217
+ }), isOpen && (0, _jsxRuntime.jsxs)("div", {
218
+ ref: panelRef,
219
+ className: "ncua-context-tab__panel",
220
+ role: "menu",
221
+ children: [(0, _jsxRuntime.jsx)("ul", {
222
+ className: "ncua-context-tab__panel-column",
223
+ role: "none",
224
+ children: leftColumn.map(renderOption)
225
+ }), (0, _jsxRuntime.jsx)("ul", {
226
+ className: "ncua-context-tab__panel-column",
227
+ role: "none",
228
+ children: rightColumn.map(renderOption)
229
+ })]
230
+ })]
231
+ });
232
+ };
233
+ exports.ContextTab = ContextTab;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ var _ContextTab = require("./ContextTab");
7
+ Object.keys(_ContextTab).forEach(function (key) {
8
+ if (key === "default" || key === "__esModule") return;
9
+ if (key in exports && exports[key] === _ContextTab[key]) return;
10
+ Object.defineProperty(exports, key, {
11
+ enumerable: true,
12
+ get: function () {
13
+ return _ContextTab[key];
14
+ }
15
+ });
16
+ });
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.useContextTabScroll = void 0;
7
+ var _react = require("react");
8
+ const EDGE_THRESHOLD = 1; // 스크롤 끝 판정 시 소수점 오차 보정용 여유값(px)
9
+ /**
10
+ * ContextTab Tab Bar의 네이티브 가로 스크롤·페이징 메커니즘을 담당하는 훅.
11
+ *
12
+ * - `barRef`: 스크롤 컨테이너(ref)
13
+ * - `isBeginning` / `isEnd`: 좌/우 네비 버튼의 disabled 판정
14
+ * - `scrollByPage`: `<` `>` 클릭 시 한 페이지(보이는 폭)만큼 이동 — scroll-snap이 탭 경계에 맞춰 정렬
15
+ * - `activeTab` 변경 시 해당 탭이 보이는 페이지로 자동 스크롤
16
+ */
17
+ const useContextTabScroll = _ref => {
18
+ let {
19
+ activeTab,
20
+ menus,
21
+ visibleTabsCount
22
+ } = _ref;
23
+ const barRef = (0, _react.useRef)(null);
24
+ const [isBeginning, setIsBeginning] = (0, _react.useState)(true);
25
+ const [isEnd, setIsEnd] = (0, _react.useState)(true);
26
+ // effect가 menus 식별자에 묶이지 않도록 최신 menus를 ref로 보관한다.
27
+ const menusRef = (0, _react.useRef)(menus);
28
+ menusRef.current = menus;
29
+ // Tab Bar의 스크롤 위치로 좌/우 네비 버튼의 활성 여부를 계산한다.
30
+ const updateEdges = (0, _react.useCallback)(() => {
31
+ const bar = barRef.current;
32
+ if (!bar) return;
33
+ setIsBeginning(bar.scrollLeft <= EDGE_THRESHOLD);
34
+ setIsEnd(bar.scrollLeft + bar.clientWidth >= bar.scrollWidth - EDGE_THRESHOLD);
35
+ }, []);
36
+ // `<` `>` 클릭 시 보이는 폭(한 페이지)만큼 스크롤한다. scroll-snap이 탭 경계에 맞춰 정렬한다.
37
+ const scrollByPage = direction => {
38
+ const bar = barRef.current;
39
+ if (!bar) return;
40
+ bar.scrollBy({
41
+ left: direction * bar.clientWidth,
42
+ behavior: 'smooth'
43
+ });
44
+ };
45
+ // 활성 탭이 항상 viewport 안에 보이도록 — activeTab 변경 시에만 해당 페이지로 이동 (DES-SPEC-030-1 §1.6).
46
+ // menus를 deps에 넣지 않아(ref로 최신값 참조) 사용자가 다른 페이지를 보는 중 부모 리렌더로 페이지가 되돌아가는 것을 막는다.
47
+ (0, _react.useEffect)(() => {
48
+ const bar = barRef.current;
49
+ if (!bar || activeTab == null) return;
50
+ const activeIndex = menusRef.current.findIndex(menu => menu.id === activeTab);
51
+ if (activeIndex < 0) return;
52
+ const pageStart = Math.floor(activeIndex / visibleTabsCount) * visibleTabsCount;
53
+ const target = bar.children[pageStart];
54
+ if (target) bar.scrollTo({
55
+ left: target.offsetLeft,
56
+ behavior: 'smooth'
57
+ });
58
+ }, [activeTab, visibleTabsCount]);
59
+ // 스크롤·리사이즈·컨텍스트 수 변경 시 좌/우 네비 활성 여부를 다시 계산한다.
60
+ // menus.length·visibleTabsCount 변경 시 scrollWidth가 바뀌지만 ResizeObserver는 clientWidth만 감지하므로,
61
+ // 두 값을 재실행 트리거로 deps에 둔다(본문에서 직접 읽지 않음).
62
+ // biome-ignore lint/correctness/useExhaustiveDependencies: 위 값들은 에지 재계산을 위한 의도적 트리거
63
+ (0, _react.useEffect)(() => {
64
+ const bar = barRef.current;
65
+ if (!bar) return;
66
+ updateEdges();
67
+ bar.addEventListener('scroll', updateEdges, {
68
+ passive: true
69
+ });
70
+ const observer = new ResizeObserver(updateEdges);
71
+ observer.observe(bar);
72
+ return () => {
73
+ bar.removeEventListener('scroll', updateEdges);
74
+ observer.disconnect();
75
+ };
76
+ }, [updateEdges, menus.length, visibleTabsCount]);
77
+ return {
78
+ barRef,
79
+ isBeginning,
80
+ isEnd,
81
+ scrollByPage
82
+ };
83
+ };
84
+ exports.useContextTabScroll = useContextTabScroll;
@@ -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,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
+ };
@@ -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
+ };
@@ -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';
@@ -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 @@
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 {};
@@ -5522,6 +5522,164 @@ button {
5522
5522
  box-shadow: -2px 0 var(--gray-700);
5523
5523
  }
5524
5524
 
5525
+ .ncua-context-tab {
5526
+ position: relative;
5527
+ display: flex;
5528
+ align-items: center;
5529
+ gap: 8px;
5530
+ padding-inline: 4px;
5531
+ border: 1px solid var(--gray-100);
5532
+ border-radius: 8px;
5533
+ background-color: var(--gray-100);
5534
+ }
5535
+ .ncua-context-tab__bar {
5536
+ position: relative;
5537
+ display: flex;
5538
+ flex: 1;
5539
+ gap: 8px;
5540
+ min-width: 0;
5541
+ padding-block: 4px;
5542
+ padding-inline-end: 4px;
5543
+ margin-inline-end: -4px;
5544
+ overflow-x: auto;
5545
+ scroll-snap-type: x mandatory;
5546
+ scrollbar-width: none;
5547
+ }
5548
+ .ncua-context-tab__bar::-webkit-scrollbar {
5549
+ display: none;
5550
+ }
5551
+ .ncua-context-tab__bar.is-fill .ncua-context-tab__tab {
5552
+ flex: 1 1 0;
5553
+ }
5554
+ .ncua-context-tab__tab {
5555
+ border: 0;
5556
+ padding: 0;
5557
+ background-color: transparent;
5558
+ cursor: pointer;
5559
+ display: flex;
5560
+ align-items: center;
5561
+ justify-content: center;
5562
+ gap: 4px;
5563
+ flex: 0 0 calc((100% - (var(--ncua-context-tab-visible, 7) - 1) * 8px) / var(--ncua-context-tab-visible, 7));
5564
+ min-width: 0;
5565
+ scroll-snap-align: start;
5566
+ padding: 5px 12px;
5567
+ border-radius: 6px;
5568
+ font-size: var(--font-size-sm);
5569
+ line-height: var(--line-heights-sm);
5570
+ font-weight: 700;
5571
+ color: var(--gray-400);
5572
+ white-space: nowrap;
5573
+ }
5574
+ .ncua-context-tab__tab-label {
5575
+ overflow: hidden;
5576
+ display: block;
5577
+ text-overflow: ellipsis;
5578
+ white-space: nowrap;
5579
+ word-wrap: break-word;
5580
+ min-width: 0;
5581
+ }
5582
+ .ncua-context-tab__tab:hover {
5583
+ color: var(--gray-500);
5584
+ }
5585
+ .ncua-context-tab__tab.is-active {
5586
+ color: var(--gray-700);
5587
+ }
5588
+ .ncua-context-tab__tab.is-disabled, .ncua-context-tab__tab:disabled {
5589
+ color: var(--gray-300);
5590
+ cursor: not-allowed;
5591
+ }
5592
+ .ncua-context-tab__tab.is-disabled:hover, .ncua-context-tab__tab:disabled:hover {
5593
+ background-color: transparent;
5594
+ box-shadow: none;
5595
+ }
5596
+ .ncua-context-tab__control {
5597
+ flex-shrink: 0;
5598
+ }
5599
+ .ncua-context-tab__control--menu.ncua-btn--xs, .ncua-context-tab__control--prev.ncua-btn--xs, .ncua-context-tab__control--next.ncua-btn--xs {
5600
+ width: 32px;
5601
+ height: 32px;
5602
+ transition: none;
5603
+ }
5604
+ .ncua-context-tab__tab:hover, .ncua-context-tab__tab.is-active, .ncua-context-tab__control--menu.ncua-btn--xs[aria-expanded]:hover, .ncua-context-tab__control--menu.ncua-btn--xs[aria-expanded=true], .ncua-context-tab__control--prev.ncua-btn--xs:not(.is-disable):hover, .ncua-context-tab__control--next.ncua-btn--xs:not(.is-disable):hover {
5605
+ background-color: var(--base-white);
5606
+ box-shadow: var(--shadow-sm);
5607
+ }
5608
+ .ncua-context-tab__nav {
5609
+ display: flex;
5610
+ align-items: center;
5611
+ gap: 4px;
5612
+ flex-shrink: 0;
5613
+ }
5614
+ .ncua-context-tab__panel {
5615
+ position: absolute;
5616
+ top: calc(100% + 4px);
5617
+ left: -1px;
5618
+ z-index: 10;
5619
+ display: flex;
5620
+ width: 100%;
5621
+ max-height: 188px;
5622
+ overflow-y: auto;
5623
+ border: 1px solid var(--gray-100);
5624
+ border-radius: 8px;
5625
+ background-color: var(--base-white);
5626
+ box-shadow: var(--shadow-lg);
5627
+ }
5628
+ .ncua-context-tab__panel-column {
5629
+ flex: 1 1 0;
5630
+ min-width: 0;
5631
+ padding: 4px 0;
5632
+ list-style: none;
5633
+ margin: 0;
5634
+ }
5635
+ .ncua-context-tab__panel-column li {
5636
+ padding: 2px 6px;
5637
+ }
5638
+ .ncua-context-tab__option {
5639
+ border: 0;
5640
+ padding: 0;
5641
+ background-color: transparent;
5642
+ cursor: pointer;
5643
+ display: flex;
5644
+ align-items: center;
5645
+ gap: 8px;
5646
+ width: 100%;
5647
+ min-width: 120px;
5648
+ padding: 7px 10px;
5649
+ border-radius: 6px;
5650
+ font-size: var(--font-size-xs);
5651
+ line-height: var(--line-heights-xs);
5652
+ font-weight: 500;
5653
+ color: var(--gray-500);
5654
+ text-align: left;
5655
+ }
5656
+ .ncua-context-tab__option-label {
5657
+ overflow: hidden;
5658
+ display: block;
5659
+ text-overflow: ellipsis;
5660
+ white-space: nowrap;
5661
+ word-wrap: break-word;
5662
+ min-width: 0;
5663
+ }
5664
+ .ncua-context-tab__option-check {
5665
+ flex-shrink: 0;
5666
+ margin-inline-start: auto;
5667
+ color: var(--gray-700);
5668
+ }
5669
+ .ncua-context-tab__option:hover {
5670
+ background-color: var(--gray-50);
5671
+ }
5672
+ .ncua-context-tab__option:disabled {
5673
+ color: var(--gray-300);
5674
+ cursor: not-allowed;
5675
+ }
5676
+ .ncua-context-tab__option:disabled:hover {
5677
+ background-color: transparent;
5678
+ }
5679
+ .ncua-context-tab .ncua-badge {
5680
+ flex-shrink: 0;
5681
+ }
5682
+
5525
5683
  .ncua-progress-circle {
5526
5684
  position: relative;
5527
5685
  display: inline-flex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ncds/ui-admin",
3
- "version": "1.8.9",
3
+ "version": "1.8.11",
4
4
  "description": "nhn-commerce의 어드민 디자인 시스템입니다.",
5
5
  "scripts": {
6
6
  "barrel": "node barrel.js",