From 176c86907e3de0c01b31a0f196cbcee0de8c9843 Mon Sep 17 00:00:00 2001 From: Adam Bottega Date: Wed, 3 Jan 2024 15:21:23 +1100 Subject: [PATCH] Fixing sidenav focus issues (#253) * Fixing sidenav focus issues * fix accessibility for side nav and code cleanup --------- Co-authored-by: Karan Mehta <95593651+KaranAtTeamForm@users.noreply.github.com> --- lib/components/SideNav/NavItem.js | 258 +++++++++++++ lib/components/SideNav/SideNav.stories.js | 6 +- lib/components/SideNav/index.js | 422 ++-------------------- 3 files changed, 294 insertions(+), 392 deletions(-) create mode 100644 lib/components/SideNav/NavItem.js diff --git a/lib/components/SideNav/NavItem.js b/lib/components/SideNav/NavItem.js new file mode 100644 index 00000000..40ef45d6 --- /dev/null +++ b/lib/components/SideNav/NavItem.js @@ -0,0 +1,258 @@ +import React from "react"; +import styled from "styled-components"; +import { css } from "@styled-system/css"; +import { themeGet } from "@styled-system/theme-get"; +import PropTypes from "prop-types"; +import Icon from "../Icon"; +import Popover from "../Popover"; + +const SideNavItemLink = styled("div")( + (props) => + css({ + "& > a": { + cursor: "pointer", + padding: "s", + textDecoration: "none", + borderRadius: themeGet("radii.2")(props), + width: "100%", + path: { + transition: themeGet("transition.transitionDefault")(props), + fill: themeGet("colors.greyDarker")(props) + }, + fontFamily: themeGet("fonts.main")(props), + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + // width: "100%", + transition: themeGet("transition.transitionDefault")(props), + bg: "transparent", + fontSize: "1.4rem", + fontWeight: themeGet("fontWeights.1")(props), + "&:hover, &:focus": { + path: { + fill: themeGet("colors.primary")(props) + } + }, + "@media screen and (max-width: 900px)": { + width: "auto" + }, + "&:focus": { + outline: "0", + color: themeGet("colors.primary")(props), + path: { + fill: themeGet("colors.primary")(props) + } + } + } + }), + (props) => + props.active && + css({ + "& > a": { + bg: themeGet("colors.primary")(props), + path: { + fill: themeGet("colors.white")(props) + }, + "&:hover, &:focus": { + path: { + fill: themeGet("colors.white")(props) + } + }, + "&:focus": { + path: { + fill: themeGet("colors.white")(props) + } + } + } + }) +); + +const BadgeNumber = styled("div")((props) => + css({ + position: "absolute", + height: "16px", + width: "16px", + bg: themeGet("colors.danger")(props), + fontSize: "1rem", + fontWeight: themeGet("fontWeights.2")(props), + color: themeGet("colors.white")(props), + borderRadius: "50%", + top: "0", + right: "0", + display: "flex", + alignItems: "center", + justifyContent: "center" + }) +); + +const BadgeDot = styled("div")((props) => + css({ + position: "absolute", + height: "8px", + width: "8px", + bg: themeGet("colors.primary")(props), + borderRadius: "50%", + top: "2px", + right: "0" + }) +); + +const SideNavItemPopover = styled(Popover)((props) => + css({ + width: "100%", + marginBottom: props.bottomAligned ? "0" : "s", + marginTop: props.bottomAligned ? "s" : "0", + "&:hover,&:focus-within": { + "& .popoverText": { + opacity: "1", + zIndex: "100", + visibility: "visible", + pointerEvents: "auto", + display: "initial" + } + }, + "&:focus-within": { + "& .popoverText": { + opacity: props.active ? "0" : "1", + zIndex: props.active ? "-100" : "100", + visibility: props.active ? "hidden" : "visible", + pointerEvents: props.active ? "none" : "auto", + display: props.active ? "none" : "initial" + } + }, + "@media screen and (max-width: 900px)": { + width: "auto", + marginBottom: "0", + marginTop: "0", + "&:hover,&:focus-within": { + "& .popoverText": { + opacity: "0", + zIndex: "-100", + visibility: "hidden", + pointerEvents: "none", + justifyContent: "space-around", + display: "none" + } + } + } + }) +); + +const SideNavItem = styled("button")( + (props) => + css({ + fontFamily: themeGet("fonts.main")(props), + position: "relative", + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "100%", + borderRadius: themeGet("radii.2")(props), + transition: themeGet("transition.transitionDefault")(props), + bg: "transparent", + cursor: "pointer", + border: "none", + padding: "s", + fontSize: "1.4rem", + fontWeight: themeGet("fontWeights.1")(props), + color: themeGet("colors.greyDarker")(props), + path: { + transition: themeGet("transition.transitionDefault")(props), + fill: themeGet("colors.greyDarker")(props) + }, + "&:hover, &:focus": { + outline: "0", + color: themeGet("colors.primary")(props), + path: { + fill: themeGet("colors.primary")(props) + } + }, + "@media screen and (max-width: 900px)": { + width: "auto" + } + }), + (props) => + props.active && + css({ + bg: themeGet("colors.primary")(props), + path: { + fill: themeGet("colors.white")(props) + }, + + "&:hover, &:focus": { + path: { + fill: themeGet("colors.white")(props) + } + } + }) +); + +const NavItem = ({ + item, + Component, + activeItem, + handleItemClick, + navItemRefs +}) => { + // Use the ternary operator to render the appropriate component based on actionType + const accessibilityProps = { + ...(item.actionType === "component" && { + "aria-expanded": item.index === activeItem ? "true" : "false" + }), + "aria-label": item.name + }; + + return item.actionType === "link" ? ( + + handleItemClick(item)} + ref={(el) => (navItemRefs.current[item.index] = el)} + > + + + + + + ) : ( + + handleItemClick(item)} + bottomAligned={item.bottomAligned} + {...accessibilityProps} + ref={(el) => (navItemRefs.current[item.index] = el)} + > + {item.badgeNumber && {item.badgeNumber}} + {item.badgeDot && } + + + + ); +}; + +NavItem.propTypes = { + item: PropTypes.object, + Component: PropTypes.elementType, + activeItem: PropTypes.string, + handleItemClick: PropTypes.func, + navItemRefs: PropTypes.object +}; + +export default NavItem; diff --git a/lib/components/SideNav/SideNav.stories.js b/lib/components/SideNav/SideNav.stories.js index d9f94248..f6fa0f9a 100644 --- a/lib/components/SideNav/SideNav.stories.js +++ b/lib/components/SideNav/SideNav.stories.js @@ -4,6 +4,7 @@ import SideNav from "."; import { BrowserRouter as Router, Route, Link } from "react-router-dom"; import { H5, P } from "../Typography"; import Box from "../Box"; +import Button from "../Button"; export default { title: "Components/SideNav", @@ -58,7 +59,10 @@ const Announcements = () => (
Announcements
-

Announcements content

+

Announcements content

+ ); diff --git a/lib/components/SideNav/index.js b/lib/components/SideNav/index.js index 6d71f104..cc7ef529 100644 --- a/lib/components/SideNav/index.js +++ b/lib/components/SideNav/index.js @@ -7,6 +7,7 @@ import Icon from "../Icon"; import Popover from "../Popover"; import Divider from "../Divider"; import Box from "../Box"; +import NavItem from "./NavItem"; const SideNavWrapper = styled("div")((props) => css({ @@ -50,11 +51,7 @@ const SideNavItems = styled("div")((props) => alignItems: "center", justifyContent: "flex-start", textAlign: "center", - // "&:hover, &:focus": { - // "& .sideNavItemName": { - // display: "block" - // } - // }, + "@media screen and (max-width: 900px)": { height: themeGet("appScale.navBarSize")(props), flexDirection: "row", @@ -67,15 +64,16 @@ const SideNavItems = styled("div")((props) => const PanelControlTooltip = styled(Popover)((props) => css({ - alignSelf: props.showItemNames ? "flex-end" : "center", + alignSelf: "center", position: props.hideExpandedControl ? "absolute" : "relative", right: props.hideExpandedControl ? "r" : "initial", + top: "r", display: props.hideExpandedControl ? "block" : "none !important", borderTop: props.hideExpandedControl ? "none" : "solid 1px", borderTopColor: themeGet("colors.greyLighter")(props), paddingTop: props.hideExpandedControl ? "0" : "r", width: props.hideExpandedControl ? "auto" : "100%", - justifyContent: props.showItemNames ? "flex-end" : "center", + justifyContent: "center", "&:focus": { outline: "0" }, @@ -104,185 +102,6 @@ const PanelControl = styled("button")((props) => }) ); -const SideNavItemPopover = styled(Popover)((props) => - css({ - width: "100%", - marginBottom: props.bottomAligned ? "0" : "s", - marginTop: props.bottomAligned ? "s" : "0", - "&:hover,&:focus-within": { - "& .popoverText": { - opacity: props.showItemNames ? "0" : "1", - zIndex: props.showItemNames ? "-100" : "100", - visibility: props.showItemNames ? "hidden" : "visible", - pointerEvents: props.showItemNames ? "none" : "auto", - display: props.showItemNames ? "none" : "initial" - } - }, - "&:focus-within": { - "& .popoverText": { - opacity: props.active ? "0" : "1", - zIndex: props.active ? "-100" : "100", - visibility: props.active ? "hidden" : "visible", - pointerEvents: props.active ? "none" : "auto", - display: props.active ? "none" : "initial" - } - }, - "@media screen and (max-width: 900px)": { - width: "auto", - marginBottom: "0", - marginTop: "0", - "&:hover,&:focus-within": { - "& .popoverText": { - opacity: "0", - zIndex: "-100", - visibility: "hidden", - pointerEvents: "none", - justifyContent: "space-around", - display: "none" - } - // "&:hover, &:focus": { - // "& .sideNavItemName": { - // display: "none" - // } - // } - } - } - }) -); - -const SideNavItem = styled("button")( - (props) => - css({ - fontFamily: themeGet("fonts.main")(props), - position: "relative", - display: "flex", - alignItems: "center", - justifyContent: "center", - width: "100%", - borderRadius: themeGet("radii.2")(props), - transition: themeGet("transition.transitionDefault")(props), - bg: "transparent", - cursor: "pointer", - border: "none", - padding: "s", - // marginBottom: props.bottomAligned ? "0" : "s", - // marginTop: props.bottomAligned ? "s" : "0", - fontSize: "1.4rem", - fontWeight: themeGet("fontWeights.1")(props), - color: themeGet("colors.greyDarker")(props), - path: { - transition: themeGet("transition.transitionDefault")(props), - fill: themeGet("colors.greyDarker")(props) - }, - "&:hover, &:focus": { - outline: "0", - color: themeGet("colors.primary")(props), - path: { - fill: themeGet("colors.primary")(props) - }, - "& .sideNavItemName": { - color: themeGet("colors.primary")(props) - } - // "& .sideNavItemName": { - // color: themeGet("colors.primary")(props) - // } - }, - "@media screen and (max-width: 900px)": { - width: "auto" - } - }), - (props) => - props.active && - css({ - bg: themeGet("colors.primary")(props), - path: { - fill: themeGet("colors.white")(props) - }, - // "& .sideNavItemName": { - // color: themeGet("colors.white")(props) - // }, - "&:hover, &:focus": { - path: { - fill: themeGet("colors.white")(props) - }, - "& .sideNavItemName": { - color: themeGet("colors.white")(props) - } - // "& .sideNavItemName": { - // color: themeGet("colors.white")(props) - // } - } - }) -); - -const SideNavItemLink = styled("div")( - (props) => - css({ - "& > a": { - cursor: "pointer", - padding: "s", - textDecoration: "none", - borderRadius: themeGet("radii.2")(props), - width: "100%", - path: { - transition: themeGet("transition.transitionDefault")(props), - fill: themeGet("colors.greyDarker")(props) - }, - fontFamily: themeGet("fonts.main")(props), - position: "relative", - display: "flex", - alignItems: "center", - justifyContent: "center", - // width: "100%", - transition: themeGet("transition.transitionDefault")(props), - bg: "transparent", - fontSize: "1.4rem", - fontWeight: themeGet("fontWeights.1")(props), - "&:hover, &:focus": { - "& .sideNavItemName": { - color: themeGet("colors.primary")(props) - }, - path: { - fill: themeGet("colors.primary")(props) - } - }, - "@media screen and (max-width: 900px)": { - width: "auto" - }, - "&:focus": { - outline: "0", - color: themeGet("colors.primary")(props), - path: { - fill: themeGet("colors.primary")(props) - } - } - } - }), - (props) => - props.active && - css({ - "& > a": { - bg: themeGet("colors.primary")(props), - path: { - fill: themeGet("colors.white")(props) - }, - "&:hover, &:focus": { - path: { - fill: themeGet("colors.white")(props) - } - // "& .sideNavItemName": { - // color: themeGet("colors.white")(props) - // } - }, - "&:focus": { - path: { - fill: themeGet("colors.white")(props) - } - } - } - }) -); - const BottomAlignedNavItems = styled("div")(() => css({ marginTop: "auto", @@ -297,49 +116,6 @@ const BottomAlignedNavItems = styled("div")(() => }) ); -const BadgeNumber = styled("div")((props) => - css({ - position: "absolute", - height: "16px", - width: "16px", - bg: themeGet("colors.danger")(props), - fontSize: "1rem", - fontWeight: themeGet("fontWeights.2")(props), - color: themeGet("colors.white")(props), - borderRadius: "50%", - top: "0", - right: "0", - display: "flex", - alignItems: "center", - justifyContent: "center" - }) -); -const BadgeDot = styled("div")((props) => - css({ - position: "absolute", - height: "8px", - width: "8px", - bg: themeGet("colors.primary")(props), - borderRadius: "50%", - top: "2px", - right: "0" - }) -); - -// const SideNavItemName = styled("div")((props) => -// css({ -// lineHeight: "0", -// marginLeft: themeGet("space.r")(props), -// fontSize: themeGet("fontSizes.1")(props), -// whiteSpace: "nowrap", -// textDecoration: "none", -// color: themeGet("colors.greyDarker")(props), -// "&:hover, &:focus": { -// color: themeGet("colors.primary")(props) -// } -// }) -// ); - const SideNavExpanded = styled("div")((props) => css({ position: "relative", @@ -370,6 +146,9 @@ const SideNavExpanded = styled("div")((props) => overflowY: "auto", padding: "r", borderLeft: `solid 1px ${themeGet("colors.greyLighter")(props)}`, + "&:focus": { + outline: "0" + }, "@media screen and (max-width: 900px)": { width: "100%", minWidth: "initial", @@ -386,78 +165,14 @@ const SideNavExpanded = styled("div")((props) => }) ); -const NavItem = ({ - item, - Component, - activeItem, - handleItemClick - // showItemNames -}) => { - // Use the ternary operator to render the appropriate component based on actionType - const accessibilityProps = { - ...(item.actionType === "component" && { - "aria-expanded": item.index === activeItem ? "true" : "false" - }), - "aria-label": item.name - }; - return item.actionType === "link" ? ( - - handleItemClick(item)} - > - - - {/* {showItemNames && ( - - {item.name} - - )} */} - - - - ) : ( - - handleItemClick(item)} - bottomAligned={item.bottomAligned} - {...accessibilityProps} - > - {item.badgeNumber && {item.badgeNumber}} - {item.badgeDot && } - - {/* {showItemNames && ( - - {item.name} - - )} */} - - - ); -}; - const SideNav = ({ items, sideNavHeight }) => { const [expandedItem, setExpandedItem] = useState(null); const [activeItem, setActiveItem] = useState(null); + // Initialize a ref for SideNavExpanded + const expandedRef = useRef(null); + const navItemRefs = useRef({}); + const handleItemClick = (item) => { const { index: itemIndex, actionType, onClick: onButtonClick } = item; if (actionType === "link" || actionType === "button") { @@ -466,6 +181,11 @@ const SideNav = ({ items, sideNavHeight }) => { } else { setExpandedItem(itemIndex === expandedItem ? null : itemIndex); onButtonClick && onButtonClick(item); + + itemIndex === expandedItem && + navItemRefs.current && + navItemRefs.current[itemIndex] && + navItemRefs.current[itemIndex].focus(); } setActiveItem(itemIndex === activeItem ? null : itemIndex); @@ -473,6 +193,7 @@ const SideNav = ({ items, sideNavHeight }) => { // Split items into two arrays based on the bottomAligned prop const allItems = items.map((item, index) => ({ ...item, index })); + const topAlignedItems = allItems.filter( (item) => !item.bottomAligned && !item.pageSpecific ); @@ -500,9 +221,6 @@ const SideNav = ({ items, sideNavHeight }) => { return () => window.removeEventListener("resize", debounceResize); }); - // Initialize a ref for SideNavExpanded - const expandedRef = useRef(null); - // Use a useEffect to focus on the expanded item useEffect(() => { if (expandedItem !== null && expandedRef.current) { @@ -510,44 +228,13 @@ const SideNav = ({ items, sideNavHeight }) => { } }, [expandedItem]); - // Toggle nav item names on click of panel control button - // const [showItemNames, setShowItemNames] = useState(false); - // const toggleItemNames = () => { - // setShowItemNames((prevState) => !prevState); - // }; + const handleBlur = (item) => { + handleItemClick(item); + }; return ( - {/* - - - {showItemNames === true ? ( - - ) : ( - - )} - - - */} {topAlignedItems.map((item) => { if (item.hide) { return null; @@ -558,9 +245,9 @@ const SideNav = ({ items, sideNavHeight }) => { key={item.index} item={item} Component={Component} - // showItemNames={showItemNames} activeItem={activeItem} handleItemClick={handleItemClick} + navItemRefs={navItemRefs} /> ); })} @@ -579,55 +266,15 @@ const SideNav = ({ items, sideNavHeight }) => { key={item.index} item={item} Component={Component} - // showItemNames={showItemNames} activeItem={activeItem} handleItemClick={handleItemClick} + navItemRefs={navItemRefs} /> ); })} )} - {/* {bottomAlignedItems.length > 0 && // Render the special-container only if there are bottom-aligned items - (isGreaterThan900 ? ( - <> - - {bottomAlignedItems.map((item) => { - const Component = item.component; - return ( - - ); - })} - - - - {showItemNames === true ? ( - - ) : ( - - )} - - - */} + {bottomAlignedItems.length > 0 && // Render the special-container only if there are bottom-aligned items (isGreaterThan900 ? ( @@ -641,9 +288,9 @@ const SideNav = ({ items, sideNavHeight }) => { key={item.index} item={item} Component={Component} - // showItemNames={showItemNames} activeItem={activeItem} handleItemClick={handleItemClick} + navItemRefs={navItemRefs} /> ); })} @@ -659,9 +306,9 @@ const SideNav = ({ items, sideNavHeight }) => { key={item.index} item={item} Component={Component} - // showItemNames={showItemNames} activeItem={activeItem} handleItemClick={handleItemClick} + navItemRefs={navItemRefs} /> ); }) @@ -671,10 +318,13 @@ const SideNav = ({ items, sideNavHeight }) => { allItems[expandedItem] && allItems[expandedItem].component ? ( + {allItems[expandedItem].component()} { handleItemClick(allItems[expandedItem])} aria-label="toggle panel" - ref={expandedRef} + onBlur={() => handleBlur(allItems[expandedItem])} > {isGreaterThan900 === true ? ( @@ -694,21 +344,12 @@ const SideNav = ({ items, sideNavHeight }) => { )} - {allItems[expandedItem].component()} ) : null} ); }; -NavItem.propTypes = { - item: PropTypes.object, - Component: PropTypes.elementType, - activeItem: PropTypes.string, - handleItemClick: PropTypes.func - // showItemNames: PropTypes.bool -}; - SideNav.propTypes = { sideNavHeight: PropTypes.string.isRequired, // Used to specify the height of the sideNav items: PropTypes.arrayOf( @@ -725,8 +366,7 @@ SideNav.propTypes = { onClick: PropTypes.func }) ).isRequired, - LinkComponent: PropTypes.elementType // React Router Link component + LinkComponent: PropTypes.elementType }; -/** @component */ export default SideNav;