From a3d8d233ccf4594b2113a910482569773444cf92 Mon Sep 17 00:00:00 2001 From: nuria1110 Date: Thu, 12 Dec 2024 11:17:26 +0000 Subject: [PATCH] feat(action-popover): allow menu button to be focused programmatically Exposes `focusButton` method to allow consumers to programmatically focus on the menu button using ref. fix #5589 --- .../action-popover.component.tsx | 458 +++++++++--------- .../action-popover/action-popover.mdx | 7 + .../action-popover/action-popover.stories.tsx | 62 ++- .../action-popover/action-popover.test.tsx | 76 ++- src/components/action-popover/index.ts | 1 + 5 files changed, 386 insertions(+), 218 deletions(-) diff --git a/src/components/action-popover/action-popover.component.tsx b/src/components/action-popover/action-popover.component.tsx index e97b5afdf7..f1179c3606 100644 --- a/src/components/action-popover/action-popover.component.tsx +++ b/src/components/action-popover/action-popover.component.tsx @@ -4,6 +4,8 @@ import React, { useMemo, useEffect, useRef, + useImperativeHandle, + forwardRef, } from "react"; import { MarginProps } from "styled-system"; import invariant from "invariant"; @@ -70,249 +72,273 @@ export interface ActionPopoverProps extends MarginProps { "aria-describedby"?: string; } +export type ActionPopoverHandle = { + focusButton: () => void; +} | null; + const onOpenDefault = () => {}; const onCloseDefault = () => {}; -export const ActionPopover = ({ - children, - id, - onOpen = onOpenDefault, - onClose = onCloseDefault, - rightAlignMenu, - renderButton, - placement = "bottom", - horizontalAlignment = "left", - submenuPosition = "left", - "aria-label": ariaLabel, - "aria-labelledby": ariaLabelledBy, - "aria-describedby": ariaDescribedBy, - ...rest -}: ActionPopoverProps) => { - const l = useLocale(); - const [isOpen, setOpenState] = useState(false); - const [focusIndex, setFocusIndex] = useState(0); - const [guid] = useState(createGuid()); - const buttonRef = useRef(null); - const menu = useRef(null); - - const hasProperChildren = useMemo(() => { - const incorrectChild = React.Children.toArray(children).find( - (child: React.ReactNode) => { - if (!React.isValidElement(child)) { - return true; - } +export const ActionPopover = forwardRef< + ActionPopoverHandle, + ActionPopoverProps +>( + ( + { + children, + id, + onOpen = onOpenDefault, + onClose = onCloseDefault, + rightAlignMenu, + renderButton, + placement = "bottom", + horizontalAlignment = "left", + submenuPosition = "left", + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + ...rest + }, + ref, + ) => { + const l = useLocale(); + const [isOpen, setOpenState] = useState(false); + const [focusIndex, setFocusIndex] = useState(0); + const [guid] = useState(createGuid()); + const buttonRef = useRef(null); + const menu = useRef(null); + + const hasProperChildren = useMemo(() => { + const incorrectChild = React.Children.toArray(children).find( + (child: React.ReactNode) => { + if (!React.isValidElement(child)) { + return true; + } + + return ( + child.type !== ActionPopoverItem && + child.type !== ActionPopoverDivider + ); + }, + ); - return ( - child.type !== ActionPopoverItem && - child.type !== ActionPopoverDivider - ); - }, + return !incorrectChild; + }, [children]); + + const items = useMemo(() => getItems(children), [children]); + + const firstFocusableItem = findFirstFocusableItem(items); + + const lastFocusableItem = findLastFocusableItem(items); + + invariant( + hasProperChildren, + `ActionPopover only accepts children of type \`${ActionPopoverItem.displayName}\`` + + ` and \`${ActionPopoverDivider.displayName}\`.`, ); - return !incorrectChild; - }, [children]); + const mappedPlacement = useMemo(() => { + if (placement === "top" && !rightAlignMenu) { + return "top-end"; + } - const items = useMemo(() => getItems(children), [children]); + if (placement === "top" && rightAlignMenu) { + return "top-start"; + } - const firstFocusableItem = findFirstFocusableItem(items); + if (placement === "bottom" && rightAlignMenu) { + return "bottom-start"; + } - const lastFocusableItem = findLastFocusableItem(items); + return "bottom-end"; + }, [placement, rightAlignMenu]); - invariant( - hasProperChildren, - `ActionPopover only accepts children of type \`${ActionPopoverItem.displayName}\`` + - ` and \`${ActionPopoverDivider.displayName}\`.`, - ); + const setOpen = useCallback( + (value) => { + if (value && !isOpen) { + onOpen(); + } + if (!value && isOpen) { + onClose(); + } + setOpenState(value); + }, + [isOpen, onOpen, onClose], + ); - const mappedPlacement = useMemo(() => { - if (placement === "top" && !rightAlignMenu) { - return "top-end"; - } + const focusButton = useCallback(() => { + const button = buttonRef.current?.querySelector( + "[data-element='action-popover-button']", + ); - if (placement === "top" && rightAlignMenu) { - return "top-start"; - } + button?.focus(); + }, []); - if (placement === "bottom" && rightAlignMenu) { - return "bottom-start"; - } + useImperativeHandle( + ref, + () => ({ + focusButton() { + focusButton(); + }, + }), + [focusButton], + ); - return "bottom-end"; - }, [placement, rightAlignMenu]); + const onButtonClick = useCallback( + (e) => { + e.stopPropagation(); + const isOpening = !isOpen; + setFocusIndex(firstFocusableItem); + setOpen(isOpening); + if (!isOpening) { + // Closing the menu should focus the MenuButton + focusButton(); + } + }, + [isOpen, firstFocusableItem, setOpen, focusButton], + ); - const setOpen = useCallback( - (value) => { - if (value && !isOpen) { - onOpen(); - } - if (!value && isOpen) { - onClose(); - } - setOpenState(value); - }, - [isOpen, onOpen, onClose], - ); + // Keyboard commands implemented as recommended by WAI-ARIA best practices + // https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-actions.html + + const onButtonKeyDown = useCallback( + (e) => { + if ( + Events.isSpaceKey(e) || + Events.isDownKey(e) || + Events.isEnterKey(e) || + Events.isUpKey(e) + ) { + e.preventDefault(); + e.stopPropagation(); + setFocusIndex( + Events.isUpKey(e) ? lastFocusableItem : firstFocusableItem, + ); + setOpen(true); + } + }, + [firstFocusableItem, lastFocusableItem, setOpen], + ); - const focusButton = useCallback(() => { - const button = buttonRef.current?.querySelector( - "[data-element='action-popover-button']", + const handleEscapeKey = useCallback( + (e) => { + /* istanbul ignore else */ + if (Events.isEscKey(e)) { + setOpen(false); + focusButton(); + } + }, + [setOpen, focusButton], ); - button?.focus(); - }, []); - - const onButtonClick = useCallback( - (e) => { - e.stopPropagation(); - const isOpening = !isOpen; - setFocusIndex(firstFocusableItem); - setOpen(isOpening); - if (!isOpening) { - // Closing the menu should focus the MenuButton - focusButton(); - } - }, - [isOpen, firstFocusableItem, setOpen, focusButton], - ); + useModalManager({ + open: isOpen, + closeModal: handleEscapeKey, + modalRef: buttonRef, + }); - // Keyboard commands implemented as recommended by WAI-ARIA best practices - // https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-actions.html + useEffect(() => { + const handler = ({ target }: MouseEvent) => { + // If the event didn't come from part of this component, close the menu. + // There will be multiple document click listeners but we cant prevent propagation because it will interfere with + // other instances on the same page - const onButtonKeyDown = useCallback( - (e) => { - if (Events.isSpaceKey(e) || Events.isDownKey(e) || Events.isEnterKey(e)) { - e.preventDefault(); - e.stopPropagation(); - setFocusIndex(firstFocusableItem); - setOpen(true); - } else if (Events.isUpKey(e)) { - e.preventDefault(); - e.stopPropagation(); - setFocusIndex(lastFocusableItem); - setOpen(true); - } - }, - [firstFocusableItem, lastFocusableItem, setOpen], - ); - - const handleEscapeKey = useCallback( - (e) => { - /* istanbul ignore else */ - if (Events.isEscKey(e)) { - setOpen(false); - focusButton(); - } - }, - [setOpen, focusButton], - ); - - useModalManager({ - open: isOpen, - closeModal: handleEscapeKey, - modalRef: buttonRef, - }); - - useEffect(() => { - const handler = ({ target }: MouseEvent) => { - // If the event didn't come from part of this component, close the menu. - // There will be multiple document click listeners but we cant prevent propagation because it will interfere with - // other instances on the same page - - const isInMenu = menu?.current?.contains(target as Node); - const isInButton = buttonRef?.current?.contains(target as Node); - - if (!isInMenu && !isInButton) { - setOpen(false); + const isInMenu = menu?.current?.contains(target as Node); + const isInButton = buttonRef?.current?.contains(target as Node); + + if (!isInMenu && !isInButton) { + setOpen(false); + } + }; + const event = "click"; + document.addEventListener(event, handler, { capture: true }); + + return function cleanup() { + document.removeEventListener(event, handler, { capture: true }); + }; + }, [setOpen]); + + const menuButton = (menuID: string) => { + if (renderButton) { + return renderButton({ + tabIndex: isOpen ? -1 : 0, + "data-element": "action-popover-button", + ariaAttributes: { + "aria-haspopup": "true", + "aria-label": ariaLabel || l.actionPopover.ariaLabel(), + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + "aria-controls": menuID, + "aria-expanded": `${isOpen}`, + }, + }); } + + return ( + + + + ); }; - const event = "click"; - document.addEventListener(event, handler, { capture: true }); - return function cleanup() { - document.removeEventListener(event, handler, { capture: true }); + const parentID = id || `ActionPopoverButton_${guid}`; + const menuID = `ActionPopoverMenu_${guid}`; + const menuProps = { + buttonRef, + parentID, + setFocusIndex, + focusIndex, + menuID, + isOpen, + setOpen, + rightAlignMenu, + placement, + horizontalAlignment, }; - }, [setOpen]); - - const menuButton = (menuID: string) => { - if (renderButton) { - return renderButton({ - tabIndex: isOpen ? -1 : 0, - "data-element": "action-popover-button", - ariaAttributes: { - "aria-haspopup": "true", - "aria-label": ariaLabel || l.actionPopover.ariaLabel(), - "aria-labelledby": ariaLabelledBy, - "aria-describedby": ariaDescribedBy, - "aria-controls": menuID, - "aria-expanded": `${isOpen}`, - }, - }); - } return ( - - - + {menuButton(menuID)} + + {isOpen && ( + + + {children} + + + )} + + ); - }; - - const parentID = id || `ActionPopoverButton_${guid}`; - const menuID = `ActionPopoverMenu_${guid}`; - const menuProps = { - buttonRef, - parentID, - setFocusIndex, - focusIndex, - menuID, - isOpen, - setOpen, - rightAlignMenu, - placement, - horizontalAlignment, - }; - - return ( - - {menuButton(menuID)} - - {isOpen && ( - - - {children} - - - )} - - - ); -}; + }, +); export default ActionPopover; diff --git a/src/components/action-popover/action-popover.mdx b/src/components/action-popover/action-popover.mdx index b86d812bc7..b9eacc6ee9 100644 --- a/src/components/action-popover/action-popover.mdx +++ b/src/components/action-popover/action-popover.mdx @@ -190,6 +190,13 @@ It is possible to utilise the selected and onClick props on the FlatTableRow to +### Focusing the Menu Button programmatically + +The component exposes a `focusButton` function that supports programmatically focusing the menu button which can be called +by passing a `ref` to the component. + + + ## Props ### ActionPopover Props diff --git a/src/components/action-popover/action-popover.stories.tsx b/src/components/action-popover/action-popover.stories.tsx index 0c6ad97987..c64921f3d3 100644 --- a/src/components/action-popover/action-popover.stories.tsx +++ b/src/components/action-popover/action-popover.stories.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { Meta, StoryObj } from "@storybook/react"; import { ActionPopover, @@ -6,6 +6,8 @@ import { ActionPopoverItem, ActionPopoverMenu, ActionPopoverMenuButton, + RenderButtonProps, + ActionPopoverHandle, } from "."; import Link from "../link"; import Box from "../box"; @@ -20,6 +22,7 @@ import { import Confirm from "../confirm"; import { Accordion } from "../accordion"; import Dialog from "../dialog"; +import Button from "../button"; import generateStyledSystemProps from "../../../.storybook/utils/styled-system-props"; @@ -739,3 +742,60 @@ export const ActionPopoverNestedInDialog: Story = () => { ); }; ActionPopoverNestedInDialog.storyName = "Action Popover Nested in Dialog"; + +export const FocusButtonProgrammatically = () => { + const ref = useRef(null); + const refMore = useRef(null); + + const renderButton = (props: RenderButtonProps) => ( + + More + + ); + + return ( + <> + + + {}}> + Email Invoice + + + {}} icon="delete"> + Delete + + + + + + {}}> + Email Invoice + + + {}} icon="delete"> + Delete + + + + ); +}; +FocusButtonProgrammatically.storyName = "Focus Button Programmatically"; diff --git a/src/components/action-popover/action-popover.test.tsx b/src/components/action-popover/action-popover.test.tsx index 4725f19198..3df7bec4ab 100644 --- a/src/components/action-popover/action-popover.test.tsx +++ b/src/components/action-popover/action-popover.test.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import { ThemeProvider } from "styled-components"; import * as floatingUi from "@floating-ui/dom"; import { render, screen, act } from "@testing-library/react"; @@ -12,7 +12,9 @@ import { ActionPopoverMenu, ActionPopoverMenuButton, ActionPopoverProps, + ActionPopoverHandle, } from "./index"; +import Button from "../button"; import iconUnicodes from "../icon/icon-unicodes"; import guid from "../../__internal__/utils/helpers/guid"; @@ -902,6 +904,35 @@ test("an error is thrown, with appropriate error message, if invalid children ar globalConsoleSpy.mockRestore(); }); +test("should call the exposed `focusButton` method and focus the toggle button", async () => { + const MockComponent = () => { + const ref = useRef(null); + + return ( + <> + + + {}}>foo + + + ); + }; + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const button = screen.getByRole("button", { name: "Focus" }); + await user.click(button); + + expect(screen.getByRole("button", { name: "actions" })).toHaveFocus(); +}); + describe("when an item has a submenu with default (left) alignment", () => { it("renders the appropriate icon with the correct alignment", async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); @@ -1992,6 +2023,49 @@ describe("when the renderButton prop is passed", () => { expect(menuButton).toHaveAccessibleName("test label"); expect(menuButton).toHaveAccessibleDescription("test description"); }); + + it("should call the exposed `focusButton` method and focus the menu button", async () => { + const MockComponent = () => { + const ref = useRef(null); + + return ( + <> + + ( + + Foo + + )} + > + {}}>foo + + + ); + }; + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render(); + + const button = screen.getByRole("button", { name: "Focus" }); + await user.click(button); + + expect(screen.getByRole("button", { name: "Foo" })).toHaveFocus(); + }); }); describe("When ActionPopoverMenu contains multiple disabled items", () => { diff --git a/src/components/action-popover/index.ts b/src/components/action-popover/index.ts index 8c35254020..9167bc24cd 100644 --- a/src/components/action-popover/index.ts +++ b/src/components/action-popover/index.ts @@ -8,3 +8,4 @@ export { default as ActionPopoverMenuButton } from "./action-popover-menu-button export type { ActionPopoverMenuButtonProps } from "./action-popover-menu-button/action-popover-menu-button.component"; export { default as ActionPopoverDivider } from "./action-popover-divider/action-popover-divider.component"; export type { RenderButtonProps } from "./action-popover.component"; +export type { ActionPopoverHandle } from "./action-popover.component";