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