@carbon-labs/react-ui-shell 0.16.0 → 0.17.0
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/es/components/SideNav.d.ts +4 -0
- package/es/components/SideNav.js +134 -96
- package/es/components/SideNavItems.d.ts +5 -0
- package/es/components/SideNavItems.js +25 -5
- package/es/components/SideNavMenu.js +90 -52
- package/es/components/SideNavMenuItem.js +9 -5
- package/lib/components/SideNav.d.ts +4 -0
- package/lib/components/SideNav.js +132 -94
- package/lib/components/SideNavItems.d.ts +5 -0
- package/lib/components/SideNavItems.js +24 -4
- package/lib/components/SideNavMenu.js +89 -51
- package/lib/components/SideNavMenuItem.js +8 -4
- package/package.json +2 -2
- package/scss/styles/_side-nav.scss +15 -1
|
@@ -34,10 +34,14 @@ export interface SideNavProps extends ComponentProps<'nav'>, TranslateWithId<Tra
|
|
|
34
34
|
isCollapsible?: boolean;
|
|
35
35
|
hideOverlay?: boolean;
|
|
36
36
|
navType: SIDE_NAV_TYPE;
|
|
37
|
+
isTreeview: boolean;
|
|
37
38
|
}
|
|
38
39
|
interface SideNavContextData {
|
|
40
|
+
expanded?: boolean;
|
|
39
41
|
isRail?: boolean;
|
|
40
42
|
navType?: SIDE_NAV_TYPE;
|
|
43
|
+
isTreeview?: boolean;
|
|
44
|
+
setIsTreeview?: (value: boolean) => void;
|
|
41
45
|
}
|
|
42
46
|
export declare const SideNavContext: React.Context<SideNavContextData>;
|
|
43
47
|
export declare const SideNav: React.ForwardRefExoticComponent<Omit<SideNavProps, "ref"> & React.RefAttributes<HTMLElement>>;
|
package/es/components/SideNav.js
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { extends as _extends } from '../_virtual/_rollupPluginBabelHelpers.js';
|
|
9
|
-
import React, { createContext, useRef, isValidElement, useEffect } from 'react';
|
|
9
|
+
import React, { createContext, useState, useRef, isValidElement, useEffect } from 'react';
|
|
10
10
|
import cx from '../_virtual/index.js';
|
|
11
11
|
import PropTypes from 'prop-types';
|
|
12
12
|
import { AriaLabelPropType } from '../prop-types/AriaPropTypes.js';
|
|
13
13
|
import { CARBON_SIDENAV_ITEMS } from './_utils.js';
|
|
14
14
|
import { usePrefix } from '../internal/usePrefix.js';
|
|
15
|
-
import { ArrowUp, ArrowDown, Home, End,
|
|
15
|
+
import { Escape, ArrowUp, ArrowDown, Home, End, Tab } from '../internal/keyboard/keys.js';
|
|
16
16
|
import { match, matches } from '../internal/keyboard/match.js';
|
|
17
17
|
import { useMergedRefs } from '../internal/useMergedRefs.js';
|
|
18
18
|
import { useWindowEvent } from '../internal/useEvent.js';
|
|
@@ -52,6 +52,7 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
52
52
|
isFixedNav = false,
|
|
53
53
|
isRail,
|
|
54
54
|
isPersistent = true,
|
|
55
|
+
isTreeview: isTreeviewProp,
|
|
55
56
|
navType = SIDE_NAV_TYPE.DEFAULT,
|
|
56
57
|
addFocusListeners = true,
|
|
57
58
|
addMouseListeners = true,
|
|
@@ -63,6 +64,7 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
63
64
|
translateWithId: t = defaultTranslateWithId,
|
|
64
65
|
...other
|
|
65
66
|
} = _ref;
|
|
67
|
+
const [internalIsTreeview, setInternalIsTreeview] = useState(isTreeviewProp ?? false);
|
|
66
68
|
const prefix = usePrefix();
|
|
67
69
|
const {
|
|
68
70
|
current: controlled
|
|
@@ -114,7 +116,10 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
114
116
|
// avoid spreading `isSideNavExpanded` to non-Carbon UI Shell children
|
|
115
117
|
return /*#__PURE__*/React.cloneElement(childJsxElement, {
|
|
116
118
|
...(CARBON_SIDENAV_ITEMS.includes(childJsxElement.type?.displayName ?? childJsxElement.type?.name) ? {
|
|
117
|
-
isSideNavExpanded: currentExpansionState
|
|
119
|
+
isSideNavExpanded: currentExpansionState,
|
|
120
|
+
...(childJsxElement.type?.displayName === 'SideNavItems' && {
|
|
121
|
+
accessibilityLabel: accessibilityLabel
|
|
122
|
+
})
|
|
118
123
|
} : {})
|
|
119
124
|
});
|
|
120
125
|
}
|
|
@@ -123,26 +128,28 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
123
128
|
const eventHandlers = {};
|
|
124
129
|
const treeWalkerRef = useRef(null);
|
|
125
130
|
useEffect(() => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
if (internalIsTreeview) {
|
|
132
|
+
treeWalkerRef.current = treeWalkerRef.current ?? document.createTreeWalker(sideNavRef?.current, NodeFilter.SHOW_ELEMENT, {
|
|
133
|
+
acceptNode: function (node) {
|
|
134
|
+
if (!(node instanceof Element)) {
|
|
135
|
+
return NodeFilter.FILTER_SKIP;
|
|
136
|
+
}
|
|
137
|
+
if (node.classList.contains(`${prefix}--side-nav__divider`)) {
|
|
138
|
+
return NodeFilter.FILTER_REJECT;
|
|
139
|
+
}
|
|
140
|
+
if (node.matches(`li.${prefix}--side-nav__item`) || node.matches(`li.${prefix}--side-nav__menu-item`)) {
|
|
141
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
142
|
+
}
|
|
129
143
|
return NodeFilter.FILTER_SKIP;
|
|
130
144
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
return NodeFilter.FILTER_SKIP;
|
|
145
|
+
});
|
|
146
|
+
resetNodeTabIndices();
|
|
147
|
+
const firstElement = sideNavRef?.current?.querySelector('a, button');
|
|
148
|
+
if (firstElement) {
|
|
149
|
+
firstElement.tabIndex = 0;
|
|
138
150
|
}
|
|
139
|
-
});
|
|
140
|
-
resetNodeTabIndices();
|
|
141
|
-
const firstElement = sideNavRef?.current?.querySelector('a, button');
|
|
142
|
-
if (firstElement) {
|
|
143
|
-
firstElement.tabIndex = 0;
|
|
144
151
|
}
|
|
145
|
-
}, [prefix]);
|
|
152
|
+
}, [prefix, internalIsTreeview]);
|
|
146
153
|
|
|
147
154
|
/**
|
|
148
155
|
* Returns the parent SideNavMenu, if node is actually inside one.
|
|
@@ -174,98 +181,111 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
174
181
|
}
|
|
175
182
|
};
|
|
176
183
|
eventHandlers.onKeyDown = event => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// @ts-ignore - `matches` doesn't like the object syntax without missing properties
|
|
184
|
-
{
|
|
185
|
-
code: 'KeyA'
|
|
186
|
-
}])) {
|
|
187
|
-
event.preventDefault();
|
|
188
|
-
}
|
|
189
|
-
treeWalker.currentNode = event.target.closest(`li`) ?? treeWalker?.currentNode;
|
|
190
|
-
let nextFocusNode = null;
|
|
191
|
-
if (match(event, ArrowUp)) {
|
|
192
|
-
const parentNode = parentSideNavMenu(treeWalker.currentNode);
|
|
193
|
-
let previousSideNavMenu = parentNode?.previousElementSibling;
|
|
194
|
-
|
|
195
|
-
// skip the divider
|
|
196
|
-
if (previousSideNavMenu?.classList.contains(`${prefix}--side-nav__divider`)) {
|
|
197
|
-
previousSideNavMenu = previousSideNavMenu?.previousElementSibling;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// when previous sibling is open, go to its last item
|
|
201
|
-
if (previousSideNavMenu?.getAttribute('aria-expanded') == 'true') {
|
|
202
|
-
nextFocusNode = treeWalker.previousNode();
|
|
203
|
-
} else {
|
|
204
|
-
nextFocusNode = treeWalker.previousSibling();
|
|
205
|
-
|
|
206
|
-
// first item in the menu, go back up to SideNavMenu button
|
|
207
|
-
if (nextFocusNode == null) {
|
|
208
|
-
nextFocusNode = parentNode;
|
|
184
|
+
// close menu
|
|
185
|
+
if (match(event, Escape)) {
|
|
186
|
+
if (expanded && !isFixedNav) {
|
|
187
|
+
resetNodeTabIndices();
|
|
188
|
+
if (onSideNavBlur) {
|
|
189
|
+
onSideNavBlur();
|
|
209
190
|
}
|
|
210
191
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
nextFocusNode = treeWalker.nextSibling();
|
|
215
|
-
} else {
|
|
216
|
-
nextFocusNode = treeWalker.nextNode();
|
|
192
|
+
handleToggle(event, false);
|
|
193
|
+
if (href) {
|
|
194
|
+
window.location.href = href;
|
|
217
195
|
}
|
|
218
196
|
}
|
|
219
197
|
|
|
220
|
-
//
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
198
|
+
// Treeview keyboard navigation
|
|
199
|
+
if (treeWalkerRef?.current && internalIsTreeview) {
|
|
200
|
+
const treeWalker = treeWalkerRef.current;
|
|
201
|
+
event.stopPropagation();
|
|
202
|
+
|
|
203
|
+
// stops page from scrolling
|
|
204
|
+
if (matches(event, [ArrowUp, ArrowDown, Home, End,
|
|
205
|
+
// @ts-ignore - `matches` doesn't like the object syntax without missing properties
|
|
206
|
+
{
|
|
207
|
+
code: 'KeyA'
|
|
208
|
+
}])) {
|
|
209
|
+
event.preventDefault();
|
|
224
210
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
211
|
+
treeWalker.currentNode = event.target.closest(`li`) ?? treeWalker?.currentNode;
|
|
212
|
+
let nextFocusNode = null;
|
|
213
|
+
if (match(event, ArrowUp)) {
|
|
214
|
+
const parentNode = parentSideNavMenu(treeWalker.currentNode);
|
|
215
|
+
let previousSideNavMenu = parentNode?.previousElementSibling;
|
|
216
|
+
|
|
217
|
+
// skip the divider
|
|
218
|
+
if (previousSideNavMenu?.classList.contains(`${prefix}--side-nav__divider`)) {
|
|
219
|
+
previousSideNavMenu = previousSideNavMenu?.previousElementSibling;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// when previous sibling is open, go to its last item
|
|
223
|
+
if (previousSideNavMenu?.getAttribute('aria-expanded') == 'true') {
|
|
224
|
+
const allItems = previousSideNavMenu.querySelectorAll(`.${prefix}--side-nav__item`);
|
|
225
|
+
const lastMenu = allItems[allItems.length - 1];
|
|
226
|
+
if (lastMenu && lastMenu.getAttribute('aria-expanded') == 'false') {
|
|
227
|
+
nextFocusNode = lastMenu;
|
|
228
|
+
} else {
|
|
229
|
+
nextFocusNode = treeWalker.previousNode();
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
nextFocusNode = treeWalker.previousSibling();
|
|
233
|
+
|
|
234
|
+
// first item in the menu, go back up to SideNavMenu button
|
|
235
|
+
if (nextFocusNode == null) {
|
|
236
|
+
nextFocusNode = parentNode;
|
|
237
|
+
}
|
|
231
238
|
}
|
|
232
239
|
}
|
|
233
|
-
if (match(event,
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
node.tabIndex = 0;
|
|
240
|
-
node?.focus();
|
|
240
|
+
if (match(event, ArrowDown)) {
|
|
241
|
+
if (treeWalker.currentNode.getAttribute('aria-expanded') == 'false') {
|
|
242
|
+
nextFocusNode = treeWalker.nextSibling();
|
|
243
|
+
if (!nextFocusNode) {
|
|
244
|
+
const parent = parentSideNavMenu(treeWalker.currentNode);
|
|
245
|
+
nextFocusNode = parent?.nextElementSibling;
|
|
241
246
|
}
|
|
247
|
+
} else {
|
|
248
|
+
nextFocusNode = treeWalker.nextNode();
|
|
242
249
|
}
|
|
243
250
|
}
|
|
244
|
-
}
|
|
245
251
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
// Home/End functionality
|
|
253
|
+
if (matches(event, [Home, End])) {
|
|
254
|
+
if (!sideNavRef?.current) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const allItems = Array.from(sideNavRef.current.querySelectorAll('a, button'));
|
|
258
|
+
if (match(event, Home)) {
|
|
259
|
+
const firstElement = allItems[0];
|
|
260
|
+
if (firstElement) {
|
|
261
|
+
firstElement.tabIndex = 0;
|
|
262
|
+
firstElement?.focus();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (match(event, End)) {
|
|
266
|
+
const allItems = Array.from(sideNavRef.current.querySelectorAll('li'));
|
|
267
|
+
const lastVisibleItem = allItems.reverse().find(item => getComputedStyle(item).visibility !== 'hidden');
|
|
268
|
+
if (lastVisibleItem) {
|
|
269
|
+
const node = lastVisibleItem.querySelector('button') ?? lastVisibleItem.querySelector('a');
|
|
270
|
+
if (node) {
|
|
271
|
+
node.tabIndex = 0;
|
|
272
|
+
node?.focus();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
254
275
|
}
|
|
255
276
|
}
|
|
256
|
-
}
|
|
257
277
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (
|
|
262
|
-
|
|
278
|
+
// focus on the focusable element within the node
|
|
279
|
+
if (nextFocusNode && nextFocusNode !== event.target) {
|
|
280
|
+
resetNodeTabIndices();
|
|
281
|
+
if (nextFocusNode instanceof HTMLElement) {
|
|
282
|
+
const node = nextFocusNode.querySelector('button') ?? nextFocusNode.querySelector('a');
|
|
283
|
+
if (node) {
|
|
284
|
+
node.tabIndex = 0;
|
|
285
|
+
node?.focus();
|
|
286
|
+
}
|
|
263
287
|
}
|
|
264
288
|
}
|
|
265
|
-
handleToggle(event, false);
|
|
266
|
-
if (href) {
|
|
267
|
-
window.location.href = href;
|
|
268
|
-
}
|
|
269
289
|
}
|
|
270
290
|
};
|
|
271
291
|
}
|
|
@@ -304,9 +324,27 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
304
324
|
item.tabIndex = -1;
|
|
305
325
|
});
|
|
306
326
|
}
|
|
327
|
+
|
|
328
|
+
// ensure that changes are in sync with internal treeview prop
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
if (isTreeviewProp !== undefined) {
|
|
331
|
+
setInternalIsTreeview(isTreeviewProp);
|
|
332
|
+
}
|
|
333
|
+
}, [isTreeviewProp]);
|
|
334
|
+
|
|
335
|
+
// prevent changes if prop is passed in
|
|
336
|
+
const setIsTreeview = value => {
|
|
337
|
+
if (isTreeviewProp === undefined) {
|
|
338
|
+
setInternalIsTreeview(value);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
307
341
|
return /*#__PURE__*/React.createElement(SideNavContext.Provider, {
|
|
308
342
|
value: {
|
|
309
|
-
isRail
|
|
343
|
+
isRail,
|
|
344
|
+
navType,
|
|
345
|
+
expanded: expanded,
|
|
346
|
+
isTreeview: internalIsTreeview,
|
|
347
|
+
setIsTreeview
|
|
310
348
|
}
|
|
311
349
|
}, isFixedNav || hideOverlay ? null :
|
|
312
350
|
/*#__PURE__*/
|
|
@@ -315,7 +353,7 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
315
353
|
className: overlayClassName,
|
|
316
354
|
onClick: onOverlayClick
|
|
317
355
|
}), /*#__PURE__*/React.createElement("nav", _extends({
|
|
318
|
-
role:
|
|
356
|
+
role: 'navigation',
|
|
319
357
|
tabIndex: -1,
|
|
320
358
|
ref: navRef,
|
|
321
359
|
className: `${prefix}--side-nav__navigation ${className}`,
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import React from 'react';
|
|
8
8
|
export interface SideNavItemsProps {
|
|
9
|
+
/**
|
|
10
|
+
* Object to provide an aria-label to the component when used in treeview,
|
|
11
|
+
* to ensure it meets a11y requirements.
|
|
12
|
+
*/
|
|
13
|
+
accessibilityLabel: object;
|
|
9
14
|
/**
|
|
10
15
|
* Provide a single icon as the child to `SideNavIcon` to render in the
|
|
11
16
|
* container
|
|
@@ -5,18 +5,25 @@
|
|
|
5
5
|
* LICENSE file in the root directory of this source tree.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { extends as _extends } from '../_virtual/_rollupPluginBabelHelpers.js';
|
|
8
9
|
import cx from '../_virtual/index.js';
|
|
9
10
|
import PropTypes from 'prop-types';
|
|
10
|
-
import React from 'react';
|
|
11
|
+
import React, { useContext, useRef, useEffect } from 'react';
|
|
11
12
|
import { CARBON_SIDENAV_ITEMS } from './_utils.js';
|
|
12
13
|
import { usePrefix } from '../internal/usePrefix.js';
|
|
14
|
+
import { SideNavContext } from './SideNav.js';
|
|
13
15
|
|
|
14
16
|
const SideNavItems = _ref => {
|
|
15
17
|
let {
|
|
16
18
|
className: customClassName,
|
|
17
19
|
children,
|
|
18
|
-
isSideNavExpanded
|
|
20
|
+
isSideNavExpanded,
|
|
21
|
+
accessibilityLabel: accessibilityLabel
|
|
19
22
|
} = _ref;
|
|
23
|
+
const {
|
|
24
|
+
isTreeview
|
|
25
|
+
} = useContext(SideNavContext);
|
|
26
|
+
const listRef = useRef(null); // Adjust type if necessary
|
|
20
27
|
const prefix = usePrefix();
|
|
21
28
|
const className = cx([`${prefix}--side-nav__items`], customClassName);
|
|
22
29
|
const childrenWithExpandedState = React.Children.map(children, child => {
|
|
@@ -36,9 +43,22 @@ const SideNavItems = _ref => {
|
|
|
36
43
|
});
|
|
37
44
|
}
|
|
38
45
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// set SideNavLink's role without needing to extend original component
|
|
48
|
+
if (isTreeview && listRef.current) {
|
|
49
|
+
const sideNavItem = listRef.current.querySelectorAll(`.${prefix}--side-nav__item a`);
|
|
50
|
+
sideNavItem.forEach(e => {
|
|
51
|
+
if (!e.hasAttribute('role')) {
|
|
52
|
+
e.setAttribute('role', 'treeitem');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}, [isTreeview]);
|
|
57
|
+
return /*#__PURE__*/React.createElement("ul", _extends({}, isTreeview && accessibilityLabel, {
|
|
58
|
+
ref: listRef,
|
|
59
|
+
className: className,
|
|
60
|
+
role: isTreeview ? 'tree' : ''
|
|
61
|
+
}), childrenWithExpandedState);
|
|
42
62
|
};
|
|
43
63
|
SideNavItems.displayName = 'SideNavItems';
|
|
44
64
|
SideNavItems.propTypes = {
|
|
@@ -13,7 +13,7 @@ import { SideNavIcon } from '@carbon/react';
|
|
|
13
13
|
import { Escape, ArrowLeft, ArrowRight } from '../internal/keyboard/keys.js';
|
|
14
14
|
import { match } from '../internal/keyboard/match.js';
|
|
15
15
|
import { usePrefix } from '../internal/usePrefix.js';
|
|
16
|
-
import { SideNavContext } from './SideNav.js';
|
|
16
|
+
import { SideNavContext, SIDE_NAV_TYPE } from './SideNav.js';
|
|
17
17
|
import { useMergedRefs } from '../internal/useMergedRefs.js';
|
|
18
18
|
import { ChevronDown } from '../node_modules/@carbon/icons-react/es/generated/bucket-3.js';
|
|
19
19
|
|
|
@@ -28,16 +28,21 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
28
28
|
large = false,
|
|
29
29
|
renderIcon: IconElement,
|
|
30
30
|
isSideNavExpanded,
|
|
31
|
-
tabIndex,
|
|
32
31
|
title
|
|
33
32
|
} = _ref;
|
|
34
33
|
const depth = propDepth;
|
|
35
34
|
const {
|
|
36
|
-
|
|
35
|
+
isTreeview,
|
|
36
|
+
expanded,
|
|
37
|
+
navType,
|
|
38
|
+
isRail,
|
|
39
|
+
setIsTreeview
|
|
37
40
|
} = useContext(SideNavContext);
|
|
41
|
+
const sideNavExpanded = expanded;
|
|
38
42
|
const prefix = usePrefix();
|
|
39
43
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
|
40
44
|
const [active, setActive] = useState(isActive);
|
|
45
|
+
const firstLink = useRef(null);
|
|
41
46
|
const [prevExpanded, setPrevExpanded] = useState(defaultExpanded);
|
|
42
47
|
const className = cx({
|
|
43
48
|
[`${prefix}--side-nav__item`]: true,
|
|
@@ -80,23 +85,35 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
80
85
|
return child;
|
|
81
86
|
});
|
|
82
87
|
useEffect(() => {
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
if (navType == SIDE_NAV_TYPE.PANEL) {
|
|
89
|
+
// grab first link to redirect if clicked when not expanded
|
|
90
|
+
if (!firstLink?.current && listRef?.current) {
|
|
91
|
+
const firstLinkElement = listRef.current.querySelector(`.${prefix}--side-nav__menu-item a`);
|
|
92
|
+
firstLink.current = firstLinkElement?.getAttribute('href') ?? '';
|
|
88
93
|
}
|
|
94
|
+
}
|
|
95
|
+
if (depth === 0) return;
|
|
96
|
+
|
|
97
|
+
// if depth is more than 0, that means its nested, thus we set treeview mode
|
|
98
|
+
setIsTreeview?.(true);
|
|
99
|
+
if (isTreeview) {
|
|
100
|
+
const calcButtonOffset = () => {
|
|
101
|
+
// menu with icon
|
|
102
|
+
if (children && IconElement) {
|
|
103
|
+
return depth + 3;
|
|
104
|
+
}
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
// menu without icon
|
|
107
|
+
if (children) {
|
|
108
|
+
return depth * 4;
|
|
109
|
+
}
|
|
110
|
+
return depth;
|
|
111
|
+
};
|
|
112
|
+
if (buttonRef.current) {
|
|
113
|
+
buttonRef.current.style.paddingLeft = `${calcButtonOffset()}rem`;
|
|
93
114
|
}
|
|
94
|
-
return depth;
|
|
95
|
-
};
|
|
96
|
-
if (buttonRef.current) {
|
|
97
|
-
buttonRef.current.style.paddingLeft = `${calcButtonOffset()}rem`;
|
|
98
115
|
}
|
|
99
|
-
}, []);
|
|
116
|
+
}, [isTreeview]);
|
|
100
117
|
|
|
101
118
|
/**
|
|
102
119
|
* Returns the parent SideNavMenu, if node is actually inside one.
|
|
@@ -112,56 +129,71 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
112
129
|
if (match(event, Escape)) {
|
|
113
130
|
setIsExpanded(false);
|
|
114
131
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
event
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
if (isTreeview) {
|
|
133
|
+
const node = event.target;
|
|
134
|
+
const isMenu = node.hasAttribute('aria-expanded');
|
|
135
|
+
const isExpanded = node.getAttribute('aria-expanded');
|
|
136
|
+
const parent = parentSideNavMenu(node);
|
|
137
|
+
if (match(event, ArrowLeft)) {
|
|
138
|
+
event.stopPropagation();
|
|
139
|
+
if (isMenu) {
|
|
140
|
+
// collapse menu
|
|
141
|
+
if (isExpanded == 'true') {
|
|
142
|
+
setIsExpanded(false);
|
|
125
143
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
// go to previous level's side nav menu button
|
|
145
|
+
} else {
|
|
146
|
+
// since we're in a menu, it finds its own <li>, we go up one more
|
|
147
|
+
const previousMenu = parentSideNavMenu(parent);
|
|
148
|
+
const button = previousMenu.querySelector('button');
|
|
149
|
+
button.tabIndex = 0;
|
|
150
|
+
button?.focus();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// go to side nav menu button
|
|
154
|
+
} else if (parent) {
|
|
155
|
+
const button = parent.querySelector('button');
|
|
131
156
|
button.tabIndex = 0;
|
|
132
157
|
button?.focus();
|
|
133
158
|
}
|
|
134
|
-
|
|
135
|
-
// go to side nav menu button
|
|
136
|
-
} else if (parent) {
|
|
137
|
-
const button = parent.querySelector('button');
|
|
138
|
-
button.tabIndex = 0;
|
|
139
|
-
button?.focus();
|
|
140
159
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
event.stopPropagation();
|
|
160
|
+
if (match(event, ArrowRight)) {
|
|
161
|
+
event.stopPropagation();
|
|
144
162
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
163
|
+
// expand menu
|
|
164
|
+
if (isMenu) {
|
|
165
|
+
setIsExpanded(true);
|
|
148
166
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
167
|
+
// if already expanded, focus on first element
|
|
168
|
+
if (isExpanded == 'true') {
|
|
169
|
+
let nextNode = node.nextElementSibling?.querySelector('a, button');
|
|
170
|
+
if (nextNode) {
|
|
171
|
+
nextNode.tabIndex = 0;
|
|
172
|
+
nextNode.focus();
|
|
173
|
+
}
|
|
155
174
|
}
|
|
156
175
|
}
|
|
157
176
|
}
|
|
158
177
|
}
|
|
159
178
|
}
|
|
179
|
+
|
|
180
|
+
// save expanded state before SideNav collapse
|
|
181
|
+
const [lastExpandedState, setLastExpandedState] = useState(isExpanded);
|
|
182
|
+
|
|
183
|
+
// reset when SideNav is panel
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (navType == SIDE_NAV_TYPE.PANEL && !sideNavExpanded) {
|
|
186
|
+
setLastExpandedState(isExpanded);
|
|
187
|
+
setIsExpanded(false);
|
|
188
|
+
} else {
|
|
189
|
+
setIsExpanded(lastExpandedState);
|
|
190
|
+
}
|
|
191
|
+
}, [sideNavExpanded]);
|
|
160
192
|
return (
|
|
161
193
|
/*#__PURE__*/
|
|
162
194
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
163
195
|
React.createElement("li", {
|
|
164
|
-
role:
|
|
196
|
+
role: isTreeview ? 'treeitem' : undefined,
|
|
165
197
|
"aria-expanded": isExpanded,
|
|
166
198
|
className: className,
|
|
167
199
|
ref: listRef,
|
|
@@ -170,11 +202,17 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
170
202
|
"aria-expanded": isExpanded,
|
|
171
203
|
className: buttonClassName,
|
|
172
204
|
onClick: () => {
|
|
173
|
-
|
|
205
|
+
// only when sidenav is panel view
|
|
206
|
+
if (navType == SIDE_NAV_TYPE.PANEL && !isExpanded && firstLink.current && !sideNavExpanded) {
|
|
207
|
+
window.location.href = firstLink.current;
|
|
208
|
+
} else {
|
|
209
|
+
setIsExpanded(!isExpanded);
|
|
210
|
+
setLastExpandedState(!isExpanded);
|
|
211
|
+
}
|
|
174
212
|
},
|
|
175
213
|
ref: menuRef,
|
|
176
214
|
type: "button",
|
|
177
|
-
tabIndex: -1
|
|
215
|
+
tabIndex: isTreeview ? -1 : 0
|
|
178
216
|
}, IconElement && /*#__PURE__*/React.createElement(SideNavIcon, null, /*#__PURE__*/React.createElement(IconElement, null)), /*#__PURE__*/React.createElement("span", {
|
|
179
217
|
className: `${prefix}--side-nav__submenu-title`
|
|
180
218
|
}, title), /*#__PURE__*/React.createElement(SideNavIcon, {
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
import { extends as _extends } from '../_virtual/_rollupPluginBabelHelpers.js';
|
|
9
9
|
import cx from '../_virtual/index.js';
|
|
10
10
|
import PropTypes from 'prop-types';
|
|
11
|
-
import React, { useRef, useEffect } from 'react';
|
|
11
|
+
import React, { useContext, useRef, useEffect } from 'react';
|
|
12
12
|
import { SideNavLinkText } from '@carbon/react';
|
|
13
13
|
import Link from './Link.js';
|
|
14
14
|
import { usePrefix } from '../internal/usePrefix.js';
|
|
15
15
|
import { useMergedRefs } from '../internal/useMergedRefs.js';
|
|
16
|
+
import { SideNavContext } from './SideNav.js';
|
|
16
17
|
|
|
17
18
|
const SideNavMenuItem = /*#__PURE__*/React.forwardRef(function SideNavMenuItem(props, ref) {
|
|
18
19
|
const prefix = usePrefix();
|
|
@@ -24,6 +25,9 @@ const SideNavMenuItem = /*#__PURE__*/React.forwardRef(function SideNavMenuItem(p
|
|
|
24
25
|
isActive,
|
|
25
26
|
...rest
|
|
26
27
|
} = props;
|
|
28
|
+
const {
|
|
29
|
+
isTreeview
|
|
30
|
+
} = useContext(SideNavContext);
|
|
27
31
|
const className = cx(`${prefix}--side-nav__menu-item`, customClassName);
|
|
28
32
|
const depth = propDepth;
|
|
29
33
|
const linkClassName = cx({
|
|
@@ -39,14 +43,14 @@ const SideNavMenuItem = /*#__PURE__*/React.forwardRef(function SideNavMenuItem(p
|
|
|
39
43
|
if (linkRef.current) {
|
|
40
44
|
linkRef.current.style.paddingLeft = `${calcLinkOffset()}rem`;
|
|
41
45
|
}
|
|
42
|
-
}, []);
|
|
46
|
+
}, [isTreeview]);
|
|
43
47
|
return /*#__PURE__*/React.createElement("li", {
|
|
44
|
-
role: "treeitem",
|
|
45
|
-
"aria-selected": isActive ? 'true' : 'false',
|
|
46
48
|
className: className
|
|
47
49
|
}, /*#__PURE__*/React.createElement(Component, _extends({}, rest, {
|
|
50
|
+
"aria-selected": isActive ? 'true' : 'false',
|
|
51
|
+
role: isTreeview ? 'treeitem' : undefined,
|
|
48
52
|
className: linkClassName,
|
|
49
|
-
tabIndex: -1,
|
|
53
|
+
tabIndex: isTreeview ? -1 : 0,
|
|
50
54
|
ref: itemRef
|
|
51
55
|
}), /*#__PURE__*/React.createElement(SideNavLinkText, null, children)));
|
|
52
56
|
});
|
|
@@ -34,10 +34,14 @@ export interface SideNavProps extends ComponentProps<'nav'>, TranslateWithId<Tra
|
|
|
34
34
|
isCollapsible?: boolean;
|
|
35
35
|
hideOverlay?: boolean;
|
|
36
36
|
navType: SIDE_NAV_TYPE;
|
|
37
|
+
isTreeview: boolean;
|
|
37
38
|
}
|
|
38
39
|
interface SideNavContextData {
|
|
40
|
+
expanded?: boolean;
|
|
39
41
|
isRail?: boolean;
|
|
40
42
|
navType?: SIDE_NAV_TYPE;
|
|
43
|
+
isTreeview?: boolean;
|
|
44
|
+
setIsTreeview?: (value: boolean) => void;
|
|
41
45
|
}
|
|
42
46
|
export declare const SideNavContext: React.Context<SideNavContextData>;
|
|
43
47
|
export declare const SideNav: React.ForwardRefExoticComponent<Omit<SideNavProps, "ref"> & React.RefAttributes<HTMLElement>>;
|
|
@@ -54,6 +54,7 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
54
54
|
isFixedNav = false,
|
|
55
55
|
isRail,
|
|
56
56
|
isPersistent = true,
|
|
57
|
+
isTreeview: isTreeviewProp,
|
|
57
58
|
navType = SIDE_NAV_TYPE.DEFAULT,
|
|
58
59
|
addFocusListeners = true,
|
|
59
60
|
addMouseListeners = true,
|
|
@@ -65,6 +66,7 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
65
66
|
translateWithId: t = defaultTranslateWithId,
|
|
66
67
|
...other
|
|
67
68
|
} = _ref;
|
|
69
|
+
const [internalIsTreeview, setInternalIsTreeview] = React.useState(isTreeviewProp ?? false);
|
|
68
70
|
const prefix = usePrefix.usePrefix();
|
|
69
71
|
const {
|
|
70
72
|
current: controlled
|
|
@@ -116,7 +118,10 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
116
118
|
// avoid spreading `isSideNavExpanded` to non-Carbon UI Shell children
|
|
117
119
|
return /*#__PURE__*/React.cloneElement(childJsxElement, {
|
|
118
120
|
...(_utils.CARBON_SIDENAV_ITEMS.includes(childJsxElement.type?.displayName ?? childJsxElement.type?.name) ? {
|
|
119
|
-
isSideNavExpanded: currentExpansionState
|
|
121
|
+
isSideNavExpanded: currentExpansionState,
|
|
122
|
+
...(childJsxElement.type?.displayName === 'SideNavItems' && {
|
|
123
|
+
accessibilityLabel: accessibilityLabel
|
|
124
|
+
})
|
|
120
125
|
} : {})
|
|
121
126
|
});
|
|
122
127
|
}
|
|
@@ -125,26 +130,28 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
125
130
|
const eventHandlers = {};
|
|
126
131
|
const treeWalkerRef = React.useRef(null);
|
|
127
132
|
React.useEffect(() => {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
if (internalIsTreeview) {
|
|
134
|
+
treeWalkerRef.current = treeWalkerRef.current ?? document.createTreeWalker(sideNavRef?.current, NodeFilter.SHOW_ELEMENT, {
|
|
135
|
+
acceptNode: function (node) {
|
|
136
|
+
if (!(node instanceof Element)) {
|
|
137
|
+
return NodeFilter.FILTER_SKIP;
|
|
138
|
+
}
|
|
139
|
+
if (node.classList.contains(`${prefix}--side-nav__divider`)) {
|
|
140
|
+
return NodeFilter.FILTER_REJECT;
|
|
141
|
+
}
|
|
142
|
+
if (node.matches(`li.${prefix}--side-nav__item`) || node.matches(`li.${prefix}--side-nav__menu-item`)) {
|
|
143
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
144
|
+
}
|
|
131
145
|
return NodeFilter.FILTER_SKIP;
|
|
132
146
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
return NodeFilter.FILTER_SKIP;
|
|
147
|
+
});
|
|
148
|
+
resetNodeTabIndices();
|
|
149
|
+
const firstElement = sideNavRef?.current?.querySelector('a, button');
|
|
150
|
+
if (firstElement) {
|
|
151
|
+
firstElement.tabIndex = 0;
|
|
140
152
|
}
|
|
141
|
-
});
|
|
142
|
-
resetNodeTabIndices();
|
|
143
|
-
const firstElement = sideNavRef?.current?.querySelector('a, button');
|
|
144
|
-
if (firstElement) {
|
|
145
|
-
firstElement.tabIndex = 0;
|
|
146
153
|
}
|
|
147
|
-
}, [prefix]);
|
|
154
|
+
}, [prefix, internalIsTreeview]);
|
|
148
155
|
|
|
149
156
|
/**
|
|
150
157
|
* Returns the parent SideNavMenu, if node is actually inside one.
|
|
@@ -176,98 +183,111 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
176
183
|
}
|
|
177
184
|
};
|
|
178
185
|
eventHandlers.onKeyDown = event => {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// @ts-ignore - `matches` doesn't like the object syntax without missing properties
|
|
186
|
-
{
|
|
187
|
-
code: 'KeyA'
|
|
188
|
-
}])) {
|
|
189
|
-
event.preventDefault();
|
|
190
|
-
}
|
|
191
|
-
treeWalker.currentNode = event.target.closest(`li`) ?? treeWalker?.currentNode;
|
|
192
|
-
let nextFocusNode = null;
|
|
193
|
-
if (match.match(event, keys.ArrowUp)) {
|
|
194
|
-
const parentNode = parentSideNavMenu(treeWalker.currentNode);
|
|
195
|
-
let previousSideNavMenu = parentNode?.previousElementSibling;
|
|
196
|
-
|
|
197
|
-
// skip the divider
|
|
198
|
-
if (previousSideNavMenu?.classList.contains(`${prefix}--side-nav__divider`)) {
|
|
199
|
-
previousSideNavMenu = previousSideNavMenu?.previousElementSibling;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// when previous sibling is open, go to its last item
|
|
203
|
-
if (previousSideNavMenu?.getAttribute('aria-expanded') == 'true') {
|
|
204
|
-
nextFocusNode = treeWalker.previousNode();
|
|
205
|
-
} else {
|
|
206
|
-
nextFocusNode = treeWalker.previousSibling();
|
|
207
|
-
|
|
208
|
-
// first item in the menu, go back up to SideNavMenu button
|
|
209
|
-
if (nextFocusNode == null) {
|
|
210
|
-
nextFocusNode = parentNode;
|
|
186
|
+
// close menu
|
|
187
|
+
if (match.match(event, keys.Escape)) {
|
|
188
|
+
if (expanded && !isFixedNav) {
|
|
189
|
+
resetNodeTabIndices();
|
|
190
|
+
if (onSideNavBlur) {
|
|
191
|
+
onSideNavBlur();
|
|
211
192
|
}
|
|
212
193
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
nextFocusNode = treeWalker.nextSibling();
|
|
217
|
-
} else {
|
|
218
|
-
nextFocusNode = treeWalker.nextNode();
|
|
194
|
+
handleToggle(event, false);
|
|
195
|
+
if (href) {
|
|
196
|
+
window.location.href = href;
|
|
219
197
|
}
|
|
220
198
|
}
|
|
221
199
|
|
|
222
|
-
//
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
200
|
+
// Treeview keyboard navigation
|
|
201
|
+
if (treeWalkerRef?.current && internalIsTreeview) {
|
|
202
|
+
const treeWalker = treeWalkerRef.current;
|
|
203
|
+
event.stopPropagation();
|
|
204
|
+
|
|
205
|
+
// stops page from scrolling
|
|
206
|
+
if (match.matches(event, [keys.ArrowUp, keys.ArrowDown, keys.Home, keys.End,
|
|
207
|
+
// @ts-ignore - `matches` doesn't like the object syntax without missing properties
|
|
208
|
+
{
|
|
209
|
+
code: 'KeyA'
|
|
210
|
+
}])) {
|
|
211
|
+
event.preventDefault();
|
|
226
212
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
213
|
+
treeWalker.currentNode = event.target.closest(`li`) ?? treeWalker?.currentNode;
|
|
214
|
+
let nextFocusNode = null;
|
|
215
|
+
if (match.match(event, keys.ArrowUp)) {
|
|
216
|
+
const parentNode = parentSideNavMenu(treeWalker.currentNode);
|
|
217
|
+
let previousSideNavMenu = parentNode?.previousElementSibling;
|
|
218
|
+
|
|
219
|
+
// skip the divider
|
|
220
|
+
if (previousSideNavMenu?.classList.contains(`${prefix}--side-nav__divider`)) {
|
|
221
|
+
previousSideNavMenu = previousSideNavMenu?.previousElementSibling;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// when previous sibling is open, go to its last item
|
|
225
|
+
if (previousSideNavMenu?.getAttribute('aria-expanded') == 'true') {
|
|
226
|
+
const allItems = previousSideNavMenu.querySelectorAll(`.${prefix}--side-nav__item`);
|
|
227
|
+
const lastMenu = allItems[allItems.length - 1];
|
|
228
|
+
if (lastMenu && lastMenu.getAttribute('aria-expanded') == 'false') {
|
|
229
|
+
nextFocusNode = lastMenu;
|
|
230
|
+
} else {
|
|
231
|
+
nextFocusNode = treeWalker.previousNode();
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
nextFocusNode = treeWalker.previousSibling();
|
|
235
|
+
|
|
236
|
+
// first item in the menu, go back up to SideNavMenu button
|
|
237
|
+
if (nextFocusNode == null) {
|
|
238
|
+
nextFocusNode = parentNode;
|
|
239
|
+
}
|
|
233
240
|
}
|
|
234
241
|
}
|
|
235
|
-
if (match.match(event, keys.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
node.tabIndex = 0;
|
|
242
|
-
node?.focus();
|
|
242
|
+
if (match.match(event, keys.ArrowDown)) {
|
|
243
|
+
if (treeWalker.currentNode.getAttribute('aria-expanded') == 'false') {
|
|
244
|
+
nextFocusNode = treeWalker.nextSibling();
|
|
245
|
+
if (!nextFocusNode) {
|
|
246
|
+
const parent = parentSideNavMenu(treeWalker.currentNode);
|
|
247
|
+
nextFocusNode = parent?.nextElementSibling;
|
|
243
248
|
}
|
|
249
|
+
} else {
|
|
250
|
+
nextFocusNode = treeWalker.nextNode();
|
|
244
251
|
}
|
|
245
252
|
}
|
|
246
|
-
}
|
|
247
253
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
254
|
+
// Home/End functionality
|
|
255
|
+
if (match.matches(event, [keys.Home, keys.End])) {
|
|
256
|
+
if (!sideNavRef?.current) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const allItems = Array.from(sideNavRef.current.querySelectorAll('a, button'));
|
|
260
|
+
if (match.match(event, keys.Home)) {
|
|
261
|
+
const firstElement = allItems[0];
|
|
262
|
+
if (firstElement) {
|
|
263
|
+
firstElement.tabIndex = 0;
|
|
264
|
+
firstElement?.focus();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (match.match(event, keys.End)) {
|
|
268
|
+
const allItems = Array.from(sideNavRef.current.querySelectorAll('li'));
|
|
269
|
+
const lastVisibleItem = allItems.reverse().find(item => getComputedStyle(item).visibility !== 'hidden');
|
|
270
|
+
if (lastVisibleItem) {
|
|
271
|
+
const node = lastVisibleItem.querySelector('button') ?? lastVisibleItem.querySelector('a');
|
|
272
|
+
if (node) {
|
|
273
|
+
node.tabIndex = 0;
|
|
274
|
+
node?.focus();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
256
277
|
}
|
|
257
278
|
}
|
|
258
|
-
}
|
|
259
279
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (
|
|
264
|
-
|
|
280
|
+
// focus on the focusable element within the node
|
|
281
|
+
if (nextFocusNode && nextFocusNode !== event.target) {
|
|
282
|
+
resetNodeTabIndices();
|
|
283
|
+
if (nextFocusNode instanceof HTMLElement) {
|
|
284
|
+
const node = nextFocusNode.querySelector('button') ?? nextFocusNode.querySelector('a');
|
|
285
|
+
if (node) {
|
|
286
|
+
node.tabIndex = 0;
|
|
287
|
+
node?.focus();
|
|
288
|
+
}
|
|
265
289
|
}
|
|
266
290
|
}
|
|
267
|
-
handleToggle(event, false);
|
|
268
|
-
if (href) {
|
|
269
|
-
window.location.href = href;
|
|
270
|
-
}
|
|
271
291
|
}
|
|
272
292
|
};
|
|
273
293
|
}
|
|
@@ -306,9 +326,27 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
306
326
|
item.tabIndex = -1;
|
|
307
327
|
});
|
|
308
328
|
}
|
|
329
|
+
|
|
330
|
+
// ensure that changes are in sync with internal treeview prop
|
|
331
|
+
React.useEffect(() => {
|
|
332
|
+
if (isTreeviewProp !== undefined) {
|
|
333
|
+
setInternalIsTreeview(isTreeviewProp);
|
|
334
|
+
}
|
|
335
|
+
}, [isTreeviewProp]);
|
|
336
|
+
|
|
337
|
+
// prevent changes if prop is passed in
|
|
338
|
+
const setIsTreeview = value => {
|
|
339
|
+
if (isTreeviewProp === undefined) {
|
|
340
|
+
setInternalIsTreeview(value);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
309
343
|
return /*#__PURE__*/React.createElement(SideNavContext.Provider, {
|
|
310
344
|
value: {
|
|
311
|
-
isRail
|
|
345
|
+
isRail,
|
|
346
|
+
navType,
|
|
347
|
+
expanded: expanded,
|
|
348
|
+
isTreeview: internalIsTreeview,
|
|
349
|
+
setIsTreeview
|
|
312
350
|
}
|
|
313
351
|
}, isFixedNav || hideOverlay ? null :
|
|
314
352
|
/*#__PURE__*/
|
|
@@ -317,7 +355,7 @@ function SideNavRenderFunction(_ref, ref) {
|
|
|
317
355
|
className: overlayClassName,
|
|
318
356
|
onClick: onOverlayClick
|
|
319
357
|
}), /*#__PURE__*/React.createElement("nav", _rollupPluginBabelHelpers.extends({
|
|
320
|
-
role:
|
|
358
|
+
role: 'navigation',
|
|
321
359
|
tabIndex: -1,
|
|
322
360
|
ref: navRef,
|
|
323
361
|
className: `${prefix}--side-nav__navigation ${className}`,
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import React from 'react';
|
|
8
8
|
export interface SideNavItemsProps {
|
|
9
|
+
/**
|
|
10
|
+
* Object to provide an aria-label to the component when used in treeview,
|
|
11
|
+
* to ensure it meets a11y requirements.
|
|
12
|
+
*/
|
|
13
|
+
accessibilityLabel: object;
|
|
9
14
|
/**
|
|
10
15
|
* Provide a single icon as the child to `SideNavIcon` to render in the
|
|
11
16
|
* container
|
|
@@ -7,18 +7,25 @@
|
|
|
7
7
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
|
+
var _rollupPluginBabelHelpers = require('../_virtual/_rollupPluginBabelHelpers.js');
|
|
10
11
|
var index = require('../_virtual/index.js');
|
|
11
12
|
var PropTypes = require('prop-types');
|
|
12
13
|
var React = require('react');
|
|
13
14
|
var _utils = require('./_utils.js');
|
|
14
15
|
var usePrefix = require('../internal/usePrefix.js');
|
|
16
|
+
var SideNav = require('./SideNav.js');
|
|
15
17
|
|
|
16
18
|
const SideNavItems = _ref => {
|
|
17
19
|
let {
|
|
18
20
|
className: customClassName,
|
|
19
21
|
children,
|
|
20
|
-
isSideNavExpanded
|
|
22
|
+
isSideNavExpanded,
|
|
23
|
+
accessibilityLabel: accessibilityLabel
|
|
21
24
|
} = _ref;
|
|
25
|
+
const {
|
|
26
|
+
isTreeview
|
|
27
|
+
} = React.useContext(SideNav.SideNavContext);
|
|
28
|
+
const listRef = React.useRef(null); // Adjust type if necessary
|
|
22
29
|
const prefix = usePrefix.usePrefix();
|
|
23
30
|
const className = index.default([`${prefix}--side-nav__items`], customClassName);
|
|
24
31
|
const childrenWithExpandedState = React.Children.map(children, child => {
|
|
@@ -38,9 +45,22 @@ const SideNavItems = _ref => {
|
|
|
38
45
|
});
|
|
39
46
|
}
|
|
40
47
|
});
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
// set SideNavLink's role without needing to extend original component
|
|
50
|
+
if (isTreeview && listRef.current) {
|
|
51
|
+
const sideNavItem = listRef.current.querySelectorAll(`.${prefix}--side-nav__item a`);
|
|
52
|
+
sideNavItem.forEach(e => {
|
|
53
|
+
if (!e.hasAttribute('role')) {
|
|
54
|
+
e.setAttribute('role', 'treeitem');
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}, [isTreeview]);
|
|
59
|
+
return /*#__PURE__*/React.createElement("ul", _rollupPluginBabelHelpers.extends({}, isTreeview && accessibilityLabel, {
|
|
60
|
+
ref: listRef,
|
|
61
|
+
className: className,
|
|
62
|
+
role: isTreeview ? 'tree' : ''
|
|
63
|
+
}), childrenWithExpandedState);
|
|
44
64
|
};
|
|
45
65
|
SideNavItems.displayName = 'SideNavItems';
|
|
46
66
|
SideNavItems.propTypes = {
|
|
@@ -30,16 +30,21 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
30
30
|
large = false,
|
|
31
31
|
renderIcon: IconElement,
|
|
32
32
|
isSideNavExpanded,
|
|
33
|
-
tabIndex,
|
|
34
33
|
title
|
|
35
34
|
} = _ref;
|
|
36
35
|
const depth = propDepth;
|
|
37
36
|
const {
|
|
38
|
-
|
|
37
|
+
isTreeview,
|
|
38
|
+
expanded,
|
|
39
|
+
navType,
|
|
40
|
+
isRail,
|
|
41
|
+
setIsTreeview
|
|
39
42
|
} = React.useContext(SideNav.SideNavContext);
|
|
43
|
+
const sideNavExpanded = expanded;
|
|
40
44
|
const prefix = usePrefix.usePrefix();
|
|
41
45
|
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
|
|
42
46
|
const [active, setActive] = React.useState(isActive);
|
|
47
|
+
const firstLink = React.useRef(null);
|
|
43
48
|
const [prevExpanded, setPrevExpanded] = React.useState(defaultExpanded);
|
|
44
49
|
const className = index.default({
|
|
45
50
|
[`${prefix}--side-nav__item`]: true,
|
|
@@ -82,23 +87,35 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
82
87
|
return child;
|
|
83
88
|
});
|
|
84
89
|
React.useEffect(() => {
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
if (navType == SideNav.SIDE_NAV_TYPE.PANEL) {
|
|
91
|
+
// grab first link to redirect if clicked when not expanded
|
|
92
|
+
if (!firstLink?.current && listRef?.current) {
|
|
93
|
+
const firstLinkElement = listRef.current.querySelector(`.${prefix}--side-nav__menu-item a`);
|
|
94
|
+
firstLink.current = firstLinkElement?.getAttribute('href') ?? '';
|
|
90
95
|
}
|
|
96
|
+
}
|
|
97
|
+
if (depth === 0) return;
|
|
98
|
+
|
|
99
|
+
// if depth is more than 0, that means its nested, thus we set treeview mode
|
|
100
|
+
setIsTreeview?.(true);
|
|
101
|
+
if (isTreeview) {
|
|
102
|
+
const calcButtonOffset = () => {
|
|
103
|
+
// menu with icon
|
|
104
|
+
if (children && IconElement) {
|
|
105
|
+
return depth + 3;
|
|
106
|
+
}
|
|
91
107
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
// menu without icon
|
|
109
|
+
if (children) {
|
|
110
|
+
return depth * 4;
|
|
111
|
+
}
|
|
112
|
+
return depth;
|
|
113
|
+
};
|
|
114
|
+
if (buttonRef.current) {
|
|
115
|
+
buttonRef.current.style.paddingLeft = `${calcButtonOffset()}rem`;
|
|
95
116
|
}
|
|
96
|
-
return depth;
|
|
97
|
-
};
|
|
98
|
-
if (buttonRef.current) {
|
|
99
|
-
buttonRef.current.style.paddingLeft = `${calcButtonOffset()}rem`;
|
|
100
117
|
}
|
|
101
|
-
}, []);
|
|
118
|
+
}, [isTreeview]);
|
|
102
119
|
|
|
103
120
|
/**
|
|
104
121
|
* Returns the parent SideNavMenu, if node is actually inside one.
|
|
@@ -114,56 +131,71 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
114
131
|
if (match.match(event, keys.Escape)) {
|
|
115
132
|
setIsExpanded(false);
|
|
116
133
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
event.
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
134
|
+
if (isTreeview) {
|
|
135
|
+
const node = event.target;
|
|
136
|
+
const isMenu = node.hasAttribute('aria-expanded');
|
|
137
|
+
const isExpanded = node.getAttribute('aria-expanded');
|
|
138
|
+
const parent = parentSideNavMenu(node);
|
|
139
|
+
if (match.match(event, keys.ArrowLeft)) {
|
|
140
|
+
event.stopPropagation();
|
|
141
|
+
if (isMenu) {
|
|
142
|
+
// collapse menu
|
|
143
|
+
if (isExpanded == 'true') {
|
|
144
|
+
setIsExpanded(false);
|
|
127
145
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
// go to previous level's side nav menu button
|
|
147
|
+
} else {
|
|
148
|
+
// since we're in a menu, it finds its own <li>, we go up one more
|
|
149
|
+
const previousMenu = parentSideNavMenu(parent);
|
|
150
|
+
const button = previousMenu.querySelector('button');
|
|
151
|
+
button.tabIndex = 0;
|
|
152
|
+
button?.focus();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// go to side nav menu button
|
|
156
|
+
} else if (parent) {
|
|
157
|
+
const button = parent.querySelector('button');
|
|
133
158
|
button.tabIndex = 0;
|
|
134
159
|
button?.focus();
|
|
135
160
|
}
|
|
136
|
-
|
|
137
|
-
// go to side nav menu button
|
|
138
|
-
} else if (parent) {
|
|
139
|
-
const button = parent.querySelector('button');
|
|
140
|
-
button.tabIndex = 0;
|
|
141
|
-
button?.focus();
|
|
142
161
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
event.stopPropagation();
|
|
162
|
+
if (match.match(event, keys.ArrowRight)) {
|
|
163
|
+
event.stopPropagation();
|
|
146
164
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
165
|
+
// expand menu
|
|
166
|
+
if (isMenu) {
|
|
167
|
+
setIsExpanded(true);
|
|
150
168
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
169
|
+
// if already expanded, focus on first element
|
|
170
|
+
if (isExpanded == 'true') {
|
|
171
|
+
let nextNode = node.nextElementSibling?.querySelector('a, button');
|
|
172
|
+
if (nextNode) {
|
|
173
|
+
nextNode.tabIndex = 0;
|
|
174
|
+
nextNode.focus();
|
|
175
|
+
}
|
|
157
176
|
}
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
179
|
}
|
|
161
180
|
}
|
|
181
|
+
|
|
182
|
+
// save expanded state before SideNav collapse
|
|
183
|
+
const [lastExpandedState, setLastExpandedState] = React.useState(isExpanded);
|
|
184
|
+
|
|
185
|
+
// reset when SideNav is panel
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
if (navType == SideNav.SIDE_NAV_TYPE.PANEL && !sideNavExpanded) {
|
|
188
|
+
setLastExpandedState(isExpanded);
|
|
189
|
+
setIsExpanded(false);
|
|
190
|
+
} else {
|
|
191
|
+
setIsExpanded(lastExpandedState);
|
|
192
|
+
}
|
|
193
|
+
}, [sideNavExpanded]);
|
|
162
194
|
return (
|
|
163
195
|
/*#__PURE__*/
|
|
164
196
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
165
197
|
React.createElement("li", {
|
|
166
|
-
role:
|
|
198
|
+
role: isTreeview ? 'treeitem' : undefined,
|
|
167
199
|
"aria-expanded": isExpanded,
|
|
168
200
|
className: className,
|
|
169
201
|
ref: listRef,
|
|
@@ -172,11 +204,17 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
|
|
|
172
204
|
"aria-expanded": isExpanded,
|
|
173
205
|
className: buttonClassName,
|
|
174
206
|
onClick: () => {
|
|
175
|
-
|
|
207
|
+
// only when sidenav is panel view
|
|
208
|
+
if (navType == SideNav.SIDE_NAV_TYPE.PANEL && !isExpanded && firstLink.current && !sideNavExpanded) {
|
|
209
|
+
window.location.href = firstLink.current;
|
|
210
|
+
} else {
|
|
211
|
+
setIsExpanded(!isExpanded);
|
|
212
|
+
setLastExpandedState(!isExpanded);
|
|
213
|
+
}
|
|
176
214
|
},
|
|
177
215
|
ref: menuRef,
|
|
178
216
|
type: "button",
|
|
179
|
-
tabIndex: -1
|
|
217
|
+
tabIndex: isTreeview ? -1 : 0
|
|
180
218
|
}, IconElement && /*#__PURE__*/React.createElement(react.SideNavIcon, null, /*#__PURE__*/React.createElement(IconElement, null)), /*#__PURE__*/React.createElement("span", {
|
|
181
219
|
className: `${prefix}--side-nav__submenu-title`
|
|
182
220
|
}, title), /*#__PURE__*/React.createElement(react.SideNavIcon, {
|
|
@@ -15,6 +15,7 @@ var react = require('@carbon/react');
|
|
|
15
15
|
var Link = require('./Link.js');
|
|
16
16
|
var usePrefix = require('../internal/usePrefix.js');
|
|
17
17
|
var useMergedRefs = require('../internal/useMergedRefs.js');
|
|
18
|
+
var SideNav = require('./SideNav.js');
|
|
18
19
|
|
|
19
20
|
const SideNavMenuItem = /*#__PURE__*/React.forwardRef(function SideNavMenuItem(props, ref) {
|
|
20
21
|
const prefix = usePrefix.usePrefix();
|
|
@@ -26,6 +27,9 @@ const SideNavMenuItem = /*#__PURE__*/React.forwardRef(function SideNavMenuItem(p
|
|
|
26
27
|
isActive,
|
|
27
28
|
...rest
|
|
28
29
|
} = props;
|
|
30
|
+
const {
|
|
31
|
+
isTreeview
|
|
32
|
+
} = React.useContext(SideNav.SideNavContext);
|
|
29
33
|
const className = index.default(`${prefix}--side-nav__menu-item`, customClassName);
|
|
30
34
|
const depth = propDepth;
|
|
31
35
|
const linkClassName = index.default({
|
|
@@ -41,14 +45,14 @@ const SideNavMenuItem = /*#__PURE__*/React.forwardRef(function SideNavMenuItem(p
|
|
|
41
45
|
if (linkRef.current) {
|
|
42
46
|
linkRef.current.style.paddingLeft = `${calcLinkOffset()}rem`;
|
|
43
47
|
}
|
|
44
|
-
}, []);
|
|
48
|
+
}, [isTreeview]);
|
|
45
49
|
return /*#__PURE__*/React.createElement("li", {
|
|
46
|
-
role: "treeitem",
|
|
47
|
-
"aria-selected": isActive ? 'true' : 'false',
|
|
48
50
|
className: className
|
|
49
51
|
}, /*#__PURE__*/React.createElement(Component, _rollupPluginBabelHelpers.extends({}, rest, {
|
|
52
|
+
"aria-selected": isActive ? 'true' : 'false',
|
|
53
|
+
role: isTreeview ? 'treeitem' : undefined,
|
|
50
54
|
className: linkClassName,
|
|
51
|
-
tabIndex: -1,
|
|
55
|
+
tabIndex: isTreeview ? -1 : 0,
|
|
52
56
|
ref: itemRef
|
|
53
57
|
}), /*#__PURE__*/React.createElement(react.SideNavLinkText, null, children)));
|
|
54
58
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carbon-labs/react-ui-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"provenance": true
|
|
@@ -33,5 +33,5 @@
|
|
|
33
33
|
"dependencies": {
|
|
34
34
|
"@ibm/telemetry-js": "^1.9.1"
|
|
35
35
|
},
|
|
36
|
-
"gitHead": "
|
|
36
|
+
"gitHead": "b9ed739e909accebbb96349c611213832b779c84"
|
|
37
37
|
}
|
|
@@ -48,12 +48,20 @@ div:has(.#{$prefix}--header)
|
|
|
48
48
|
font-weight: 600;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
.#{$prefix}--side-nav__icon > svg,
|
|
52
|
+
.#{$prefix}--side-nav__submenu-chevron > svg {
|
|
53
|
+
fill: $icon-primary;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.#{$prefix}--side-nav__link:hover,
|
|
58
|
+
.#{$prefix}--side-nav__submenu:hover {
|
|
59
|
+
.#{$prefix}--side-nav__icon > svg,
|
|
51
60
|
.#{$prefix}--side-nav__submenu-chevron > svg {
|
|
52
61
|
fill: $icon-primary;
|
|
53
62
|
}
|
|
54
63
|
}
|
|
55
64
|
|
|
56
|
-
//----------------------------------------------------------------------------
|
|
57
65
|
// Side-nav Panel
|
|
58
66
|
//----------------------------------------------------------------------------
|
|
59
67
|
.#{$prefix}--side-nav--panel {
|
|
@@ -63,6 +71,12 @@ div:has(.#{$prefix}--header)
|
|
|
63
71
|
margin-inline-end: $spacing-05;
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
.#{$prefix}--side-nav__item.#{$prefix}--side-nav__link:hover {
|
|
75
|
+
.#{$prefix}--side-nav__icon > svg {
|
|
76
|
+
fill: $icon-primary;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
66
80
|
.#{$prefix}--side-nav__item.#{$prefix}--side-nav__item--icon
|
|
67
81
|
a.#{$prefix}--side-nav__link {
|
|
68
82
|
padding-inline-start: $spacing-10;
|