@carbon-labs/react-ui-shell 0.41.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.
@@ -21,6 +21,7 @@ export interface SideNavProps extends ComponentProps<'nav'>, TranslateWithId<Tra
21
21
  defaultExpanded?: boolean;
22
22
  isChildOfHeader?: boolean;
23
23
  onToggle?: (event: FocusEvent<HTMLElement> | KeyboardEvent<HTMLElement> | boolean, value: boolean) => void;
24
+ hideRailBreakpointDown?: 'sm' | 'md' | 'lg' | 'xlg' | 'max';
24
25
  href?: string;
25
26
  isFixedNav?: boolean;
26
27
  isRail?: boolean;
@@ -42,6 +43,8 @@ interface SideNavContextData {
42
43
  navType?: SIDE_NAV_TYPE;
43
44
  isTreeview?: boolean;
44
45
  setIsTreeview?: (value: boolean) => void;
46
+ currentPrimaryMenu?: string;
47
+ setCurrentPrimaryMenu?: (value: string) => void;
45
48
  }
46
49
  export declare const SideNavContext: React.Context<SideNavContextData>;
47
50
  export declare const SideNav: React.ForwardRefExoticComponent<Omit<SideNavProps, "ref"> & React.RefAttributes<HTMLElement>>;
@@ -48,6 +48,7 @@ function SideNavRenderFunction(_ref, ref) {
48
48
  children,
49
49
  onToggle,
50
50
  className: customClassName,
51
+ hideRailBreakpointDown,
51
52
  href,
52
53
  isFixedNav = false,
53
54
  isRail,
@@ -74,6 +75,7 @@ function SideNavRenderFunction(_ref, ref) {
74
75
  const expanded = controlled ? expandedProp : expandedState;
75
76
  const sideNavRef = useRef(null);
76
77
  const navRef = useMergedRefs([sideNavRef, ref]);
78
+ const [currentPrimaryMenu, setCurrentPrimaryMenu] = useState();
77
79
  const sideNavToggleText = expandedState ? t('collapse.sidenav') : t('expand.sidenav');
78
80
  const handleToggle = function (event) {
79
81
  let value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : !expanded;
@@ -95,6 +97,7 @@ function SideNavRenderFunction(_ref, ref) {
95
97
  [`${prefix}--side-nav`]: true,
96
98
  [`${prefix}--side-nav--expanded`]: expanded || expandedViaHoverState,
97
99
  [`${prefix}--side-nav--collapsed`]: !expanded && isFixedNav,
100
+ [`${prefix}--side-nav--hide-rail-breakpoint-down-${hideRailBreakpointDown}`]: hideRailBreakpointDown,
98
101
  [`${prefix}--side-nav--rail`]: isRail,
99
102
  [`${prefix}--side-nav--panel`]: navType === SIDE_NAV_TYPE.PANEL,
100
103
  [`${prefix}--side-nav--ux`]: isChildOfHeader,
@@ -147,11 +150,22 @@ function SideNavRenderFunction(_ref, ref) {
147
150
  resetNodeTabIndices();
148
151
  }
149
152
  }, [prefix, internalIsTreeview]);
153
+ const smMediaQuery = `(min-width: ${breakpoints.sm.width})`;
154
+ const isSm = useMatchMedia(smMediaQuery);
150
155
  useEffect(() => {
151
156
  if (sideNavRef.current) {
157
+ const backButton = sideNavRef?.current.querySelector(`.${prefix}--side-nav__back-button`);
152
158
  const firstElement = sideNavRef?.current?.querySelector('a, button');
153
- if (firstElement && (navType == SIDE_NAV_TYPE.PANEL || expanded)) {
154
- 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
+ }
155
169
  }
156
170
  }
157
171
  }, [expanded]);
@@ -219,15 +233,17 @@ function SideNavRenderFunction(_ref, ref) {
219
233
  let nextFocusNode = null;
220
234
  if (match(event, ArrowUp)) {
221
235
  const parentNode = parentSideNavMenu(treeWalker.currentNode);
222
- let previousSideNavMenu = parentNode?.previousElementSibling;
223
-
236
+ let previousSideNavMenu = treeWalker.currentNode?.previousSibling;
224
237
  // skip the divider
225
238
  if (previousSideNavMenu?.classList.contains(`${prefix}--side-nav__divider`)) {
226
239
  previousSideNavMenu = previousSideNavMenu?.previousElementSibling;
227
240
  }
228
-
229
- // when previous sibling is open, go to its last item
230
- 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') {
231
247
  const allItems = previousSideNavMenu.querySelectorAll(`.${prefix}--side-nav__item`);
232
248
  const lastMenu = allItems[allItems.length - 1];
233
249
  if (lastMenu && lastMenu.getAttribute('aria-expanded') == 'false') {
@@ -239,7 +255,7 @@ function SideNavRenderFunction(_ref, ref) {
239
255
  nextFocusNode = treeWalker.previousSibling();
240
256
 
241
257
  // first item in the menu, go back up to SideNavMenu button
242
- if (nextFocusNode == null) {
258
+ if (nextFocusNode == null && !parentNode.classList.contains(`${prefix}--side-nav__item--primary`)) {
243
259
  nextFocusNode = parentNode;
244
260
  }
245
261
  }
@@ -251,6 +267,11 @@ function SideNavRenderFunction(_ref, ref) {
251
267
  const parent = parentSideNavMenu(treeWalker.currentNode);
252
268
  nextFocusNode = parent?.nextElementSibling;
253
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
+ }
254
275
  } else {
255
276
  nextFocusNode = treeWalker.nextNode();
256
277
  }
@@ -325,7 +346,7 @@ function SideNavRenderFunction(_ref, ref) {
325
346
  function resetNodeTabIndices() {
326
347
  const items = sideNavRef?.current?.querySelectorAll('[tabIndex="0"]') ?? [];
327
348
  items.forEach(item => {
328
- 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`)) {
329
350
  return;
330
351
  }
331
352
  item.tabIndex = -1;
@@ -356,7 +377,9 @@ function SideNavRenderFunction(_ref, ref) {
356
377
  isRail,
357
378
  navType,
358
379
  isTreeview: internalIsTreeview,
359
- setIsTreeview
380
+ setIsTreeview,
381
+ currentPrimaryMenu,
382
+ setCurrentPrimaryMenu
360
383
  }
361
384
  }, isFixedNav || hideOverlay ? null :
362
385
  /*#__PURE__*/
@@ -410,6 +433,12 @@ SideNav.propTypes = {
410
433
  * If `true`, the overlay will be hidden. Defaults to `false`.
411
434
  */
412
435
  hideOverlay: PropTypes.bool,
436
+ /**
437
+ * Specify the breakpoint at which the SideNav will be hidden.
438
+ * Can be one of `sm`, `md`, `lg`, `xlg`, or `max`.
439
+ * Only applies when `isRail` is `true`.
440
+ */
441
+ hideRailBreakpointDown: PropTypes.oneOf(['sm', 'md', 'lg', 'xlg', 'max']),
413
442
  /**
414
443
  * Provide the `href` to the id of the element on your package that is the
415
444
  * main content.
@@ -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