@carbon-labs/react-ui-shell 0.15.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/README.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # @carbon-labs/react-ui-shell
2
2
 
3
+ ![npm version](https://img.shields.io/npm/v/@carbon-labs/react-ui-shell)
4
+ ![License](https://img.shields.io/github/license/carbon-design-system/carbon-labs)
5
+
6
+ The `@carbon-labs/react-ui-shell` package extends UI Shell components from
7
+ `@carbon/react`, providing additional enhancements while maintaining
8
+ compatibility.
9
+
10
+ ## 📦 Getting started
11
+
12
+ To install `@carbon-labs/react-ui-shell` in your project, you will need to run
13
+ the following command using [npm](https://www.npmjs.com/):
14
+
15
+ ```bash
16
+ npm install -S @carbon-labs/react-ui-shell @carbon/react
17
+ ```
18
+
19
+ If you prefer [Yarn](https://yarnpkg.com/en/), use the following command
20
+ instead:
21
+
22
+ ```bash
23
+ yarn add @carbon-labs/react-ui-shell @carbon/react
24
+ ```
25
+
26
+ ## ⚡ Usage
27
+
28
+ To use this package you will need to import components from both `@carbon/react`
29
+ and `@carbon-labs/react-ui-shell`o compose the UI Shell. The following
30
+ components are provided by `@carbon-labs/react-ui-shell`:
31
+
32
+ - `SideNav`
33
+ - `SideNavItems`
34
+ - `SideNavMenu`
35
+ - `SideNavMenuItem`
36
+ - `HeaderPanel`
37
+
38
+ ```jsx
39
+ import { SideNav } from '@carbon-labs/react-ui-shell/es/index';
40
+ import { Header } from '@carbon/react';
41
+
42
+ function MyComponent() {
43
+ return (
44
+ <Header>
45
+ <SideNav>...</SideNav>>
46
+ </Header>
47
+ );
48
+ }
49
+ ```
50
+
51
+ ### SCSS
52
+
53
+ Import the styles from `@carbon/react` and `@carbon-labs/react-ui-shell` in your
54
+ stylesheet:
55
+
56
+ ```scss
57
+ @use '@carbon/react' with (
58
+ $font-path: '@ibm/plex'
59
+ );
60
+ @use '@carbon-labs/react-ui-shell/scss/ui-shell';
61
+ ```
62
+
63
+ ### Storybook
64
+
65
+ You can explore the available components, see different configuration options,
66
+ and learn how to compose them in
67
+ [Storybook](https://labs.carbondesignsystem.com/?path=/docs/react_components-uishell--overview).
68
+
69
+ ## 🙌 Contributing
70
+
71
+ Want to contribute to `@carbon-labs`? Read through the Carbon Labs
72
+ [contribution section](https://pages.github.ibm.com/carbon/ibm-products/contributing/carbon-labs/#carbon-labs-in-code)
73
+ before diving into our developer guide:
74
+
75
+ - [Developer Guide](https://github.com/carbon-design-system/carbon-labs/blob/main/docs/developing.md)
76
+
77
+ ## 📝 License
78
+
79
+ Licensed under the
80
+ [Apache 2.0 License](https://github.com/carbon-design-system/carbon-labs/blob/main/LICENSE).
81
+
3
82
  ## <picture><source height="20" width="20" media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ibm-telemetry/telemetry-js/main/docs/images/ibm-telemetry-dark.svg"><source height="20" width="20" media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/ibm-telemetry/telemetry-js/main/docs/images/ibm-telemetry-light.svg"><img height="20" width="20" alt="IBM Telemetry" src="https://raw.githubusercontent.com/ibm-telemetry/telemetry-js/main/docs/images/ibm-telemetry-light.svg"></picture> IBM Telemetry
4
83
 
5
84
  This package uses IBM Telemetry to collect de-identified and anonymized metrics
@@ -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>>;
@@ -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, Escape, Tab } from '../internal/keyboard/keys.js';
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
- treeWalkerRef.current = treeWalkerRef.current ?? document.createTreeWalker(sideNavRef?.current, NodeFilter.SHOW_ELEMENT, {
127
- acceptNode: function (node) {
128
- if (!(node instanceof Element)) {
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
- if (node.classList.contains(`${prefix}--side-nav__divider`)) {
132
- return NodeFilter.FILTER_REJECT;
133
- }
134
- if (node.matches(`li.${prefix}--side-nav__item`) || node.matches(`li.${prefix}--side-nav__menu-item`)) {
135
- return NodeFilter.FILTER_ACCEPT;
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
- if (!treeWalkerRef.current) return;
178
- const treeWalker = treeWalkerRef.current;
179
- event.stopPropagation();
180
-
181
- // stops page from scrolling
182
- if (matches(event, [ArrowUp, ArrowDown, Home, End,
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
- if (match(event, ArrowDown)) {
213
- if (treeWalker.currentNode.getAttribute('aria-expanded') == 'false') {
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
- // Home/End functionality
221
- if (matches(event, [Home, End])) {
222
- if (!sideNavRef?.current) {
223
- return;
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
- const allItems = Array.from(sideNavRef.current.querySelectorAll('a, button'));
226
- if (match(event, Home)) {
227
- const firstElement = allItems[0];
228
- if (firstElement) {
229
- firstElement.tabIndex = 0;
230
- firstElement?.focus();
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, End)) {
234
- const allItems = Array.from(sideNavRef.current.querySelectorAll('li'));
235
- const lastVisibleItem = allItems.reverse().find(item => getComputedStyle(item).visibility !== 'hidden');
236
- if (lastVisibleItem) {
237
- const node = lastVisibleItem.querySelector('button') ?? lastVisibleItem.querySelector('a');
238
- if (node) {
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
- // focus on the focusable element within the node
247
- if (nextFocusNode && nextFocusNode !== event.target) {
248
- resetNodeTabIndices();
249
- if (nextFocusNode instanceof HTMLElement) {
250
- const node = nextFocusNode.querySelector('button') ?? nextFocusNode.querySelector('a');
251
- if (node) {
252
- node.tabIndex = 0;
253
- node?.focus();
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
- // close menu
259
- if (match(event, Escape)) {
260
- if (expanded && !isFixedNav) {
261
- if (onSideNavBlur) {
262
- onSideNavBlur();
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: "tree",
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
- return /*#__PURE__*/React.createElement("ul", {
40
- className: className
41
- }, childrenWithExpandedState);
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 = {