@carbon/react
Version:
React components for the Carbon Design System
258 lines (248 loc) • 8.82 kB
JavaScript
/**
* Copyright IBM Corp. 2016, 2023
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/
import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js';
import React, { useRef, isValidElement, createContext } from 'react';
import cx from 'classnames';
import PropTypes from 'prop-types';
import { AriaLabelPropType } from '../../prop-types/AriaPropTypes.js';
import { CARBON_SIDENAV_ITEMS } from './_utils.js';
import { usePrefix } from '../../internal/usePrefix.js';
import { Tab, Escape } from '../../internal/keyboard/keys.js';
import { match } from '../../internal/keyboard/match.js';
import { useMergedRefs } from '../../internal/useMergedRefs.js';
import { useWindowEvent } from '../../internal/useEvent.js';
import { useDelayedState } from '../../internal/useDelayedState.js';
import { breakpoints } from '@carbon/layout';
import { useMatchMedia } from '../../internal/useMatchMedia.js';
// TO-DO: comment back in when footer is added for rails
// import SideNavFooter from './SideNavFooter';
const SideNavContext = /*#__PURE__*/createContext({});
function SideNavRenderFunction({
expanded: expandedProp,
defaultExpanded = false,
isChildOfHeader = true,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
children,
onToggle,
className: customClassName,
// TO-DO: comment back in when footer is added for rails
// translateById: t = (id) => translations[id],
href,
isFixedNav = false,
isRail,
isPersistent = true,
addFocusListeners = true,
addMouseListeners = true,
onOverlayClick,
onSideNavBlur,
enterDelayMs = 100,
...other
}, ref) {
const prefix = usePrefix();
const {
current: controlled
} = useRef(expandedProp !== undefined);
const [expandedState, setExpandedState] = useDelayedState(defaultExpanded);
const [expandedViaHoverState, setExpandedViaHoverState] = useDelayedState(defaultExpanded);
const expanded = controlled ? expandedProp : expandedState;
const sideNavRef = useRef(null);
const navRef = useMergedRefs([sideNavRef, ref]);
const handleToggle = (event, value = !expanded) => {
if (!controlled) {
setExpandedState(value, enterDelayMs);
}
if (onToggle) {
onToggle(event, value);
}
if (controlled || isRail) {
setExpandedViaHoverState(value, enterDelayMs);
}
};
const accessibilityLabel = {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy
};
// TO-DO: comment back in when footer is added for rails
// const assistiveText = expanded
// ? t('carbon.sidenav.state.open')
// : t('carbon.sidenav.state.closed');
const className = cx(customClassName, {
[`${prefix}--side-nav`]: true,
[`${prefix}--side-nav--expanded`]: expanded || expandedViaHoverState,
[`${prefix}--side-nav--collapsed`]: !expanded && isFixedNav,
[`${prefix}--side-nav--rail`]: isRail,
[`${prefix}--side-nav--ux`]: isChildOfHeader,
[`${prefix}--side-nav--hidden`]: !isPersistent
});
const overlayClassName = cx({
[`${prefix}--side-nav__overlay`]: true,
[`${prefix}--side-nav__overlay-active`]: expanded || expandedViaHoverState
});
let childrenToRender = children;
// Pass the expansion state as a prop, so children can update themselves to match
childrenToRender = React.Children.map(children, child => {
// if we are controlled, check for if we have hovered over or the expanded state, else just use the expanded state (uncontrolled)
const currentExpansionState = controlled ? expandedViaHoverState || expanded : expanded;
if (/*#__PURE__*/isValidElement(child)) {
const childJsxElement = child;
// avoid spreading `isSideNavExpanded` to non-Carbon UI Shell children
return /*#__PURE__*/React.cloneElement(childJsxElement, {
...(CARBON_SIDENAV_ITEMS.includes(childJsxElement.type?.displayName ?? childJsxElement.type?.name) ? {
isSideNavExpanded: currentExpansionState
} : {})
});
}
return child;
});
const eventHandlers = {};
if (addFocusListeners) {
eventHandlers.onFocus = event => {
if (!event.currentTarget.contains(event.relatedTarget) && isRail) {
handleToggle(event, true);
}
};
eventHandlers.onBlur = event => {
if (!event.currentTarget.contains(event.relatedTarget)) {
handleToggle(event, false);
}
if (!event.currentTarget.contains(event.relatedTarget) && expanded && !isFixedNav) {
if (onSideNavBlur) {
onSideNavBlur();
}
}
};
eventHandlers.onKeyDown = event => {
if (match(event, Escape)) {
handleToggle(event, false);
if (href) {
window.location.href = href;
}
}
};
}
if (addMouseListeners && isRail) {
eventHandlers.onMouseEnter = () => {
handleToggle(true, true);
};
eventHandlers.onMouseLeave = () => {
setExpandedState(false);
setExpandedViaHoverState(false);
handleToggle(false, false);
};
eventHandlers.onClick = () => {
//if delay is enabled, and user intentionally clicks it to see it expanded immediately
setExpandedState(true);
setExpandedViaHoverState(true);
handleToggle(true, true);
};
}
useWindowEvent('keydown', event => {
const focusedElement = document.activeElement;
if (match(event, Tab) && expanded && !isFixedNav && sideNavRef.current && focusedElement?.classList.contains(`${prefix}--header__menu-toggle`) && !focusedElement.closest('nav')) {
sideNavRef.current.focus();
}
});
const lgMediaQuery = `(min-width: ${breakpoints.lg.width})`;
const isLg = useMatchMedia(lgMediaQuery);
return /*#__PURE__*/React.createElement(SideNavContext.Provider, {
value: {
isRail
}
}, isFixedNav ? null :
/*#__PURE__*/
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
React.createElement("div", {
className: overlayClassName,
onClick: onOverlayClick
}), /*#__PURE__*/React.createElement("nav", _extends({
tabIndex: -1,
ref: navRef,
className: `${prefix}--side-nav__navigation ${className}`,
inert: !isRail ? !(expanded || isLg) : undefined
}, accessibilityLabel, eventHandlers, other), childrenToRender));
}
const SideNav = /*#__PURE__*/React.forwardRef(SideNavRenderFunction);
SideNav.displayName = 'SideNav';
SideNav.propTypes = {
/**
* Required props for accessibility label on the underlying menu
*/
...AriaLabelPropType,
/**
* Specify whether focus and blur listeners are added. They are by default.
*/
addFocusListeners: PropTypes.bool,
/**
* Specify whether mouse entry/exit listeners are added. They are by default.
*/
addMouseListeners: PropTypes.bool,
/**
* Optionally provide a custom class to apply to the underlying `<li>` node
*/
className: PropTypes.string,
/**
* If `true`, the SideNav will be open on initial render.
*/
defaultExpanded: PropTypes.bool,
/**
* Specify the duration in milliseconds to delay before displaying the side navigation
*/
enterDelayMs: PropTypes.number,
/**
* If `true`, the SideNav will be expanded, otherwise it will be collapsed.
* Using this prop causes SideNav to become a controlled component.
*/
expanded: PropTypes.bool,
/**
* Provide the `href` to the id of the element on your package that is the
* main content.
*/
href: PropTypes.string,
/**
* Optionally provide a custom class to apply to the underlying `<li>` node
*/
isChildOfHeader: PropTypes.bool,
/**
* Specify if sideNav is standalone
*/
isFixedNav: PropTypes.bool,
/**
* Specify if the sideNav will be persistent above the lg breakpoint
*/
isPersistent: PropTypes.bool,
/**
* Optional prop to display the side nav rail.
*/
isRail: PropTypes.bool,
/**
* An optional listener that is called when the SideNav overlay is clicked
*
* @param {object} event
*/
onOverlayClick: PropTypes.func,
/**
* An optional listener that is called a callback to collapse the SideNav
*/
onSideNavBlur: PropTypes.func,
/**
* An optional listener that is called when an event that would cause
* toggling the SideNav occurs.
*
* @param {object} event
* @param {boolean} value
*/
onToggle: PropTypes.func
/**
* Provide a custom function for translating all message ids within this
* component. This function will take in two arguments: the message Id and the
* state of the component. From this, you should return a string representing
* the label you want displayed or read by screen readers.
*/
// translateById: PropTypes.func,
};
export { SideNavContext, SideNav as default };