@carbon-labs/react-ui-shell 0.42.0 → 0.44.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.
@@ -43,6 +43,8 @@ interface SideNavContextData {
43
43
  navType?: SIDE_NAV_TYPE;
44
44
  isTreeview?: boolean;
45
45
  setIsTreeview?: (value: boolean) => void;
46
+ currentPrimaryMenu?: string;
47
+ setCurrentPrimaryMenu?: (value: string) => void;
46
48
  }
47
49
  export declare const SideNavContext: React.Context<SideNavContextData>;
48
50
  export declare const SideNav: React.ForwardRefExoticComponent<Omit<SideNavProps, "ref"> & React.RefAttributes<HTMLElement>>;
@@ -75,6 +75,7 @@ function SideNavRenderFunction(_ref, ref) {
75
75
  const expanded = controlled ? expandedProp : expandedState;
76
76
  const sideNavRef = useRef(null);
77
77
  const navRef = useMergedRefs([sideNavRef, ref]);
78
+ const [currentPrimaryMenu, setCurrentPrimaryMenu] = useState();
78
79
  const sideNavToggleText = expandedState ? t('collapse.sidenav') : t('expand.sidenav');
79
80
  const handleToggle = function (event) {
80
81
  let value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : !expanded;
@@ -149,11 +150,22 @@ function SideNavRenderFunction(_ref, ref) {
149
150
  resetNodeTabIndices();
150
151
  }
151
152
  }, [prefix, internalIsTreeview]);
153
+ const smMediaQuery = `(min-width: ${breakpoints.sm.width})`;
154
+ const isSm = useMatchMedia(smMediaQuery);
152
155
  useEffect(() => {
153
156
  if (sideNavRef.current) {
157
+ const backButton = sideNavRef?.current.querySelector(`.${prefix}--side-nav__back-button`);
154
158
  const firstElement = sideNavRef?.current?.querySelector('a, button');
155
- if (firstElement && (navType == SIDE_NAV_TYPE.PANEL || expanded)) {
156
- firstElement.tabIndex = 0;
159
+ if (navType == SIDE_NAV_TYPE.PANEL || expanded) {
160
+ if (isSm && backButton) {
161
+ backButton.tabIndex = 0;
162
+ const firstElementAfterBack = backButton.nextElementSibling?.querySelector('a, button');
163
+ if (firstElementAfterBack) {
164
+ firstElementAfterBack.tabIndex = 0;
165
+ }
166
+ } else if (firstElement) {
167
+ firstElement.tabIndex = 0;
168
+ }
157
169
  }
158
170
  }
159
171
  }, [expanded]);
@@ -221,15 +233,17 @@ function SideNavRenderFunction(_ref, ref) {
221
233
  let nextFocusNode = null;
222
234
  if (match(event, ArrowUp)) {
223
235
  const parentNode = parentSideNavMenu(treeWalker.currentNode);
224
- let previousSideNavMenu = parentNode?.previousElementSibling;
225
-
236
+ let previousSideNavMenu = treeWalker.currentNode?.previousSibling;
226
237
  // skip the divider
227
238
  if (previousSideNavMenu?.classList.contains(`${prefix}--side-nav__divider`)) {
228
239
  previousSideNavMenu = previousSideNavMenu?.previousElementSibling;
229
240
  }
230
-
231
- // when previous sibling is open, go to its last item
232
- if (previousSideNavMenu?.getAttribute('aria-expanded') == 'true') {
241
+ if (previousSideNavMenu?.classList.contains(`${prefix}--side-nav__item--primary`)) {
242
+ nextFocusNode = previousSideNavMenu;
243
+ } else if (treeWalker.currentNode.classList.contains(`${prefix}--side-nav__item--primary`)) {
244
+ nextFocusNode = treeWalker.currentNode.previousSibling;
245
+ } // when previous sibling is open, go to its last item
246
+ else if (previousSideNavMenu?.getAttribute('aria-expanded') == 'true') {
233
247
  const allItems = previousSideNavMenu.querySelectorAll(`.${prefix}--side-nav__item`);
234
248
  const lastMenu = allItems[allItems.length - 1];
235
249
  if (lastMenu && lastMenu.getAttribute('aria-expanded') == 'false') {
@@ -241,7 +255,7 @@ function SideNavRenderFunction(_ref, ref) {
241
255
  nextFocusNode = treeWalker.previousSibling();
242
256
 
243
257
  // first item in the menu, go back up to SideNavMenu button
244
- if (nextFocusNode == null) {
258
+ if (nextFocusNode == null && !parentNode.classList.contains(`${prefix}--side-nav__item--primary`)) {
245
259
  nextFocusNode = parentNode;
246
260
  }
247
261
  }
@@ -253,6 +267,11 @@ function SideNavRenderFunction(_ref, ref) {
253
267
  const parent = parentSideNavMenu(treeWalker.currentNode);
254
268
  nextFocusNode = parent?.nextElementSibling;
255
269
  }
270
+ } else if (treeWalker.currentNode.classList.contains(`${prefix}--side-nav__item--primary`)) {
271
+ nextFocusNode = treeWalker.currentNode.nextSibling;
272
+ if (nextFocusNode?.classList.contains(`${prefix}--side-nav__divider`)) {
273
+ nextFocusNode = nextFocusNode.nextSibling;
274
+ }
256
275
  } else {
257
276
  nextFocusNode = treeWalker.nextNode();
258
277
  }
@@ -327,7 +346,9 @@ function SideNavRenderFunction(_ref, ref) {
327
346
  function resetNodeTabIndices() {
328
347
  const items = sideNavRef?.current?.querySelectorAll('[tabIndex="0"]') ?? [];
329
348
  items.forEach(item => {
330
- if (item.classList.contains(`${prefix}--side-nav__toggle`)) {
349
+ if (item.classList.contains(`${prefix}--side-nav__toggle`) || item.classList.contains(`${prefix}--side-nav__back-button`) ||
350
+ // checks if a link is in primary nav
351
+ item.classList.contains(`${prefix}--side-nav__link`) && item.closest(`ul`)?.getAttribute('aria-label') === ariaLabel) {
331
352
  return;
332
353
  }
333
354
  item.tabIndex = -1;
@@ -358,7 +379,9 @@ function SideNavRenderFunction(_ref, ref) {
358
379
  isRail,
359
380
  navType,
360
381
  isTreeview: internalIsTreeview,
361
- setIsTreeview
382
+ setIsTreeview,
383
+ currentPrimaryMenu,
384
+ setCurrentPrimaryMenu
362
385
  }
363
386
  }, isFixedNav || hideOverlay ? null :
364
387
  /*#__PURE__*/
@@ -21,7 +21,8 @@ const SideNavItems = _ref => {
21
21
  accessibilityLabel: accessibilityLabel
22
22
  } = _ref;
23
23
  const {
24
- isTreeview
24
+ isTreeview,
25
+ currentPrimaryMenu
25
26
  } = useContext(SideNavContext);
26
27
  const listRef = useRef(null); // Adjust type if necessary
27
28
  const prefix = usePrefix();
@@ -57,6 +58,7 @@ const SideNavItems = _ref => {
57
58
  return /*#__PURE__*/React.createElement("ul", _extends({}, isTreeview && accessibilityLabel, {
58
59
  ref: listRef,
59
60
  className: className,
61
+ tabIndex: currentPrimaryMenu ? -1 : undefined,
60
62
  role: isTreeview ? 'tree' : ''
61
63
  }), childrenWithExpandedState);
62
64
  };
@@ -6,6 +6,15 @@
6
6
  */
7
7
  import React from 'react';
8
8
  export interface SideNavMenuProps {
9
+ /**
10
+ * Title for back button in sm screen
11
+ */
12
+ backButtonTitle?: string;
13
+ /**
14
+ * A custom icon to render on the back button in sm screen
15
+ * default is ArrowLeft
16
+ */
17
+ backButtonRenderIcon?: React.ComponentType;
9
18
  /**
10
19
  * An optional CSS class to apply to the component.
11
20
  */
@@ -23,6 +32,10 @@ export interface SideNavMenuProps {
23
32
  * SideNavMenu depth to determine spacing
24
33
  */
25
34
  depth?: number;
35
+ /**
36
+ * Provide a unique id
37
+ */
38
+ id?: string;
26
39
  /**
27
40
  * Indicates whether the SideNavMenu is active.
28
41
  */
@@ -44,6 +57,12 @@ export interface SideNavMenuProps {
44
57
  * Indicates if the side navigation container is expanded or collapsed.
45
58
  */
46
59
  isSideNavExpanded?: boolean;
60
+ /**
61
+ * Specifies if this is the primary SideNav.
62
+ * If true, child components will open to the right,
63
+ * creating the double-wide navigation layout
64
+ */
65
+ primary?: boolean;
47
66
  /**
48
67
  * The boolean to show the flyout menu has been selected.
49
68
  */
@@ -5,33 +5,45 @@
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
7
 
8
+ import { breakpoints } from '../node_modules/@carbon/layout/es/index.js';
8
9
  import cx from '../_virtual/index.js';
9
10
  import PropTypes from 'prop-types';
10
11
  import React, { useContext, useState, useRef, useEffect } from 'react';
11
12
  import { CARBON_SIDENAV_ITEMS } from './_utils.js';
12
- import { SideNavIcon } from '@carbon/react';
13
- import { Escape, ArrowLeft, ArrowRight } from '../internal/keyboard/keys.js';
13
+ import { useId } from '../internal/useId.js';
14
+ import { SideNavIcon, Button } from '@carbon/react';
15
+ import { Escape, ArrowLeft as ArrowLeft$1, ArrowRight } from '../internal/keyboard/keys.js';
14
16
  import { match } from '../internal/keyboard/match.js';
15
17
  import { usePrefix } from '../internal/usePrefix.js';
16
18
  import { SideNavContext, SIDE_NAV_TYPE } from './SideNav.js';
17
19
  import { useMergedRefs } from '../internal/useMergedRefs.js';
18
20
  import { SharkFinIcon } from './SharkFinIcon.js';
19
21
  import { SideNavFlyoutMenu } from './SideNavFlyoutMenu.js';
20
- import { ChevronDown } from '../node_modules/@carbon/icons-react/es/generated/bucket-3.js';
22
+ import { SideNavItems } from './SideNavItems.js';
23
+ import { useMatchMedia } from '../internal/useMatchMedia.js';
24
+ import { ArrowLeft } from '../node_modules/@carbon/icons-react/es/generated/bucket-0.js';
25
+ import { ChevronRight, ChevronDown } from '../node_modules/@carbon/icons-react/es/generated/bucket-3.js';
21
26
 
22
- var _SharkFinIcon, _ChevronDown;
27
+ var _ArrowLeft, _SharkFinIcon, _ChevronRight, _ChevronDown;
28
+ const smMediaQuery = `(max-width: ${breakpoints.md.width})`;
23
29
  const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref) {
24
30
  let {
31
+ backButtonRenderIcon = () => _ArrowLeft || (_ArrowLeft = /*#__PURE__*/React.createElement(ArrowLeft, {
32
+ size: 16
33
+ })),
34
+ backButtonTitle = 'My products',
25
35
  className: customClassName,
26
36
  children,
27
37
  defaultExpanded = false,
28
38
  depth: propDepth,
39
+ id,
29
40
  isActive = false,
30
41
  large = false,
31
42
  renderIcon: IconElement,
32
43
  isSideNavExpanded,
33
44
  title,
34
- onMenuToggle
45
+ onMenuToggle,
46
+ primary
35
47
  } = _ref;
36
48
  const depth = propDepth;
37
49
  const {
@@ -46,10 +58,19 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
46
58
  const [isExpanded, setIsExpanded] = useState(defaultExpanded);
47
59
  const [active, setActive] = useState(isActive);
48
60
  const firstLink = useRef(null);
61
+ const backButtonRef = useRef(null);
62
+ const uid = useId('side-nav-menu');
63
+ const uniqueId = id || uid;
49
64
  const [prevExpanded, setPrevExpanded] = useState(defaultExpanded);
65
+ const [isSecondaryOpen, setSecondaryOpen] = useState(defaultExpanded);
66
+ const {
67
+ currentPrimaryMenu,
68
+ setCurrentPrimaryMenu
69
+ } = useContext(SideNavContext);
50
70
  const className = cx({
51
71
  [`${prefix}--side-nav__item`]: true,
52
- [`${prefix}--side-nav__item--active`]: active || hasActiveDescendant(children) && !isExpanded,
72
+ [`${prefix}--side-nav__item--primary`]: primary,
73
+ [`${prefix}--side-nav__item--active`]: !primary && (active || hasActiveDescendant(children) && !isExpanded),
53
74
  [`${prefix}--side-nav__item--icon`]: IconElement,
54
75
  [`${prefix}--side-nav__item--large`]: large,
55
76
  [customClassName]: !!customClassName
@@ -58,6 +79,10 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
58
79
  [`${prefix}--side-nav__submenu`]: true,
59
80
  [`${prefix}--side-nav__submenu--active`]: active || hasActiveDescendant(children) && isExpanded
60
81
  });
82
+ const primaryClassNames = cx({
83
+ [`${prefix}--side-nav__menu-secondary-wrapper`]: true,
84
+ [`${prefix}--side-nav__menu-secondary-wrapper-expanded`]: isSideNavExpanded && isSecondaryOpen && currentPrimaryMenu === uniqueId
85
+ });
61
86
  const buttonRef = useRef(null);
62
87
  const listRef = useRef(null);
63
88
  const menuRef = useMergedRefs([buttonRef, ref]);
@@ -159,7 +184,7 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
159
184
  const isMenu = node.hasAttribute('aria-expanded');
160
185
  const isExpanded = node.getAttribute('aria-expanded');
161
186
  const parent = parentSideNavMenu(node);
162
- if (match(event, ArrowLeft)) {
187
+ if (match(event, ArrowLeft$1)) {
163
188
  event.stopPropagation();
164
189
 
165
190
  // collapse menu
@@ -168,10 +193,11 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
168
193
  if (onMenuToggle) {
169
194
  onMenuToggle();
170
195
  }
171
- setIsExpanded(false);
172
-
196
+ if (!primary && isExpanded) {
197
+ setIsExpanded(false);
198
+ }
173
199
  // go to previous level's side nav menu button
174
- } else {
200
+ } else if (!isSm) {
175
201
  // since we're in a menu, it finds its own <li>, we go up one more
176
202
  const previousMenu = parentSideNavMenu(parent);
177
203
  const button = previousMenu.querySelector('button');
@@ -181,33 +207,74 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
181
207
 
182
208
  // go to side nav menu button
183
209
  } else if (parent) {
184
- const button = parent.querySelector('button');
185
- button.tabIndex = 0;
186
- button?.focus();
210
+ if (parent.hasAttribute('aria-expanded')) {
211
+ const button = parent.querySelector('button');
212
+ if (button) {
213
+ button.tabIndex = 0;
214
+ button.focus();
215
+ }
216
+ } else if (!isSm) {
217
+ const previousMenu = parentSideNavMenu(parent);
218
+ const button = previousMenu.querySelector('button');
219
+ button.tabIndex = 0;
220
+ button?.focus();
221
+ }
187
222
  }
188
223
  }
189
224
  if (match(event, ArrowRight)) {
225
+ setIsExpanded(true);
226
+ if (primary && node.hasAttribute('aria-expanded')) {
227
+ event.preventDefault();
228
+ }
190
229
  event.stopPropagation();
191
230
 
192
231
  // expand menu when sidenav is expanded
193
232
  if (isMenu && sideNavExpanded) {
194
- setIsExpanded(true);
195
233
  if (onMenuToggle) {
196
234
  onMenuToggle();
197
235
  }
198
236
 
199
237
  // if already expanded, focus on first element
200
- if (isExpanded == 'true') {
201
- let nextNode = node.nextElementSibling?.querySelector('a, button');
238
+ if (isExpanded == 'true' || isSm) {
239
+ const nextNode = node.nextElementSibling?.querySelector('a, button');
202
240
  if (nextNode) {
203
241
  nextNode.tabIndex = 0;
204
242
  nextNode.focus();
205
243
  }
244
+ if (isSm) {
245
+ const nextNodeAfterBackButton = nextNode.nextElementSibling?.querySelector('a, button');
246
+ if (nextNodeAfterBackButton) {
247
+ nextNodeAfterBackButton.tabIndex = 0;
248
+ }
249
+ }
206
250
  }
207
251
  }
208
252
  }
209
253
  }
210
254
  }
255
+ function handleOnBackButtonClick(event) {
256
+ const node = event.target;
257
+ const parent = parentSideNavMenu(node);
258
+ const button = parent.querySelector('button');
259
+ if (button) {
260
+ button.tabIndex = 0;
261
+ button.focus();
262
+ }
263
+ setIsExpanded(false);
264
+ }
265
+ useEffect(() => {
266
+ if (isExpanded && primary && setCurrentPrimaryMenu) {
267
+ setCurrentPrimaryMenu(uniqueId);
268
+ }
269
+ setSecondaryOpen(isExpanded);
270
+ }, [isExpanded]);
271
+ useEffect(() => {
272
+ if (currentPrimaryMenu !== uniqueId) {
273
+ setIsExpanded(false);
274
+ } else {
275
+ setIsExpanded(true);
276
+ }
277
+ }, [currentPrimaryMenu]);
211
278
 
212
279
  // save expanded state before SideNav collapse
213
280
  const [lastExpandedState, setLastExpandedState] = useState(isExpanded);
@@ -222,6 +289,14 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
222
289
  }
223
290
  }, [sideNavExpanded]);
224
291
  const [openPopover, setOpenPopover] = React.useState(false);
292
+ const isSm = useMatchMedia(smMediaQuery);
293
+
294
+ // keeps the secondary open when moving from small to large breakpoints
295
+ useEffect(() => {
296
+ if (!isSm && uniqueId === currentPrimaryMenu) {
297
+ setIsExpanded(true);
298
+ }
299
+ }, [isSm]);
225
300
  const content =
226
301
  /*#__PURE__*/
227
302
  // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
@@ -230,7 +305,8 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
230
305
  "aria-expanded": isExpanded,
231
306
  className: className,
232
307
  ref: listRef,
233
- onKeyDown: handleKeyDown
308
+ onKeyDown: handleKeyDown,
309
+ id: uniqueId
234
310
  }, /*#__PURE__*/React.createElement("button", {
235
311
  "aria-expanded": isExpanded,
236
312
  className: buttonClassName,
@@ -243,10 +319,13 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
243
319
  if (navType == SIDE_NAV_TYPE.PANEL && !isExpanded && firstLink.current && !sideNavExpanded) {
244
320
  setOpenPopover(!openPopover);
245
321
  // window.location.href = firstLink.current;
246
- } else {
322
+ } else if (isSm || !primary || currentPrimaryMenu !== uniqueId) {
247
323
  setIsExpanded(!isExpanded);
248
324
  setLastExpandedState(!isExpanded);
249
325
  }
326
+ if (isSm && backButtonRef.current) {
327
+ backButtonRef.current.focus();
328
+ }
250
329
  },
251
330
  ref: menuRef,
252
331
  type: "button",
@@ -260,9 +339,24 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
260
339
  }, title), /*#__PURE__*/React.createElement(SideNavIcon, {
261
340
  className: `${prefix}--side-nav__submenu-chevron`,
262
341
  small: true
263
- }, _ChevronDown || (_ChevronDown = /*#__PURE__*/React.createElement(ChevronDown, {
342
+ }, primary ? _ChevronRight || (_ChevronRight = /*#__PURE__*/React.createElement(ChevronRight, {
264
343
  size: 20
265
- })))), /*#__PURE__*/React.createElement("ul", {
344
+ })) : _ChevronDown || (_ChevronDown = /*#__PURE__*/React.createElement(ChevronDown, {
345
+ size: 20
346
+ })))), primary ? /*#__PURE__*/React.createElement("div", {
347
+ className: primaryClassNames
348
+ }, /*#__PURE__*/React.createElement(SideNavItems, {
349
+ accessibilityLabel: {
350
+ 'aria-label': `${title} submenu`
351
+ }
352
+ }, isSm && /*#__PURE__*/React.createElement(Button, {
353
+ ref: backButtonRef,
354
+ kind: "ghost",
355
+ size: "md",
356
+ onClick: handleOnBackButtonClick,
357
+ className: `${prefix}--side-nav__back-button`,
358
+ renderIcon: backButtonRenderIcon
359
+ }, backButtonTitle), childrenToRender)) : /*#__PURE__*/React.createElement("ul", {
266
360
  className: `${prefix}--side-nav__menu`,
267
361
  role: "group"
268
362
  }, childrenToRender));
@@ -275,6 +369,15 @@ const SideNavMenu = /*#__PURE__*/React.forwardRef(function SideNavMenu(_ref, ref
275
369
  });
276
370
  SideNavMenu.displayName = 'SideNavMenu';
277
371
  SideNavMenu.propTypes = {
372
+ /**
373
+ * A custom icon to render on the back button in sm screen
374
+ */
375
+ // @ts-expect-error - PropTypes are unable to cover this case.
376
+ backButtonRenderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
377
+ /**
378
+ * Title for back button in sm screen
379
+ */
380
+ backButtonTitle: PropTypes.string,
278
381
  /**
279
382
  * Provide <SideNavMenuItem>'s inside of the `SideNavMenu`
280
383
  */
@@ -293,6 +396,10 @@ SideNavMenu.propTypes = {
293
396
  * SideNavMenu depth to determine spacing
294
397
  */
295
398
  depth: PropTypes.number,
399
+ /**
400
+ * Provide a unique id
401
+ */
402
+ id: PropTypes.string,
296
403
  /**
297
404
  * Specify whether the `SideNavMenu` is "active". `SideNavMenu` should be
298
405
  * considered active if one of its menu items are a link for the current