diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ac75261..59ac914cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Currently, this repo is in Prerelease. When it is released, this project will ad - Exports `useMediaQuery` and `chakra` from Chakra UI. - Updates `Accordion` to close focused panel when "esc" key is pressed. - Updates the `TextInput` and `SearchBar` components to better associate the input element to the entire component's helper text. +- Updates `Accordion` to close panel when element within panel is focused and "esc" key is pressed. ## 3.1.4 (May 23, 2024) diff --git a/src/components/Accordion/Accordion.mdx b/src/components/Accordion/Accordion.mdx index 84c6609b1..9deb30dbb 100644 --- a/src/components/Accordion/Accordion.mdx +++ b/src/components/Accordion/Accordion.mdx @@ -118,7 +118,7 @@ const accordionData = [ - The `ariaLabel` key available within the `accordionData` array entries can set a unique value for each accordion within an accordion group. - If the user presses the 'esc' key and focus is on an open accordion panel, - that panel will close. + that panel will close and focus will return to the accordion's button. Resources: diff --git a/src/components/Accordion/Accordion.test.tsx b/src/components/Accordion/Accordion.test.tsx index 99473d83d..9e001f6cf 100644 --- a/src/components/Accordion/Accordion.test.tsx +++ b/src/components/Accordion/Accordion.test.tsx @@ -83,8 +83,13 @@ export const accordionData = [ label: "Tom Nook", panel: (

- Tom Nook, known in Japan as Tanukichi, is a fictional character - in the Animal Crossing series who operates the village store. + Tom Nook, + + {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} + known in Japan as Tanukichi + + , is a fictional character in the Animal Crossing series who operates + the village store.

), }, @@ -146,7 +151,7 @@ describe("Accordion", () => { const accordionLabel = screen.getByRole("button", { name: "Tom Nook" }); let accordionPanelContent = screen.queryByText( - /known in Japan as Tanukichi/i + /operates the village store/i ); expect(accordionLabel).toHaveAttribute("aria-expanded", "false"); // The panel's content should not be in the DOM unless the Accordion is open. @@ -154,25 +159,39 @@ describe("Accordion", () => { userEvent.click(accordionLabel); - accordionPanelContent = screen.queryByText(/known in Japan as Tanukichi/i); + accordionPanelContent = screen.queryByText(/operates the village store/i); expect(accordionLabel).toHaveAttribute("aria-expanded", "true"); expect(accordionPanelContent).toBeInTheDocument(); }); - it("closes the accordion when the 'esc' key is pressed", async () => { - render(); + it("closes the accordion when the button is in focus and the 'esc' key is pressed", async () => { + render(); - const accordionLabel = screen.getByRole("button", { name: "Tom Nook" }); - let accordionPanelContent = screen.queryByText( - /known in Japan as Tanukichi/i - ); - expect(accordionLabel).toHaveAttribute("aria-expanded", "true"); - expect(accordionPanelContent).toBeInTheDocument(); + const accordionButton = screen.getByRole("button"); - await userEvent.type(accordionLabel, "[Escape]"); + await userEvent.click(accordionButton); - expect(accordionLabel).toHaveAttribute("aria-expanded", "false"); - expect(accordionPanelContent).not.toBeInTheDocument(); + expect(accordionButton.getAttribute("aria-expanded")).toEqual("true"); + + await userEvent.keyboard("[Escape]"); + + expect(accordionButton).toHaveAttribute("aria-expanded", "false"); + }); + + it("closes the accordion when an element in the panel is focused and the 'esc' key is pressed", async () => { + render(); + + const accordionButton = screen.getByRole("button"); + + await userEvent.click(accordionButton); + + expect(accordionButton.getAttribute("aria-expanded")).toEqual("true"); + + const linkInPanel = screen.getByRole("link"); + + await userEvent.type(linkInPanel, "[Escape]"); + + expect(accordionButton).toHaveAttribute("aria-expanded", "false"); }); it("always renders its content when isAlwaysRendered is true", () => { @@ -180,7 +199,7 @@ describe("Accordion", () => { const accordionLabel = screen.getByRole("button", { name: "Tom Nook" }); let accordionPanelContent = screen.queryByText( - /known in Japan as Tanukichi/i + /operates the village store/i ); expect(accordionLabel).toHaveAttribute("aria-expanded", "false"); expect(accordionPanelContent).toBeInTheDocument(); @@ -226,6 +245,29 @@ describe("Accordion", () => { expect(accordion3).toHaveAttribute("aria-expanded", "true"); }); + it("closes only the focused accordion when there are multiple accordions and the 'esc' key is pressed", async () => { + render(); + + const accordion1 = screen.getByRole("button", { name: "Tom Nook" }); + const accordion3 = screen.getByRole("button", { name: "K.K. Slider" }); + + expect(accordion1).toHaveAttribute("aria-expanded", "false"); + expect(accordion3).toHaveAttribute("aria-expanded", "false"); + + userEvent.click(accordion1); + userEvent.click(accordion3); + + expect(accordion1).toHaveAttribute("aria-expanded", "true"); + expect(accordion3).toHaveAttribute("aria-expanded", "true"); + + const linkInPanel1 = screen.getByRole("link"); + + userEvent.type(linkInPanel1, "[Escape]"); + + expect(accordion1).toHaveAttribute("aria-expanded", "false"); + expect(accordion3).toHaveAttribute("aria-expanded", "true"); + }); + describe("Check aria-label values", () => { it("renders with attribute generated from component prop", () => { render( diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx index 15089afc6..39f2267fb 100644 --- a/src/components/Accordion/Accordion.tsx +++ b/src/components/Accordion/Accordion.tsx @@ -224,16 +224,41 @@ export const Accordion: ChakraComponent< isDefaultOpen ? [0] : [] ); + // If the accordionData doesn't already contain refs for the panel + // buttons, add them now. + const updatedAccordionData = accordionData.map((item) => ({ + ...item, + buttonInteractionRef: item.buttonInteractionRef || React.createRef(), + })); + const handleKeyDown = (e) => { - // If the 'esc' key is pressed, - if (e.keyCode === 27) { - const focusedPanelIndex = Number(e.target.dataset.index); - // collapse the currently focused panel. - // (Nothing will happen if the currently - // focused panel is already collapsed.) + // If the 'esc' key is pressed, find the panel the + // user is focused on or within, and remove it as + // an expanded panel. (Nothing will happen if the + // panel is already collapsed.) + if (e.code === "Escape") { + let focusedPanelIndex; + if (e.target.dataset.index) { + // If the user is focused on an accordion button... + focusedPanelIndex = Number(e.target.dataset.index); + } else { + // If the user is focused on an element within the panel... + focusedPanelIndex = Number( + e.target.closest("[role='region']").id.split("-").pop() + ); + } + setExpandedPanels( expandedPanels.filter((i) => i !== focusedPanelIndex) ); + + // If something *inside* the accordion was in focus and 'esc' was clicked, + // return focus to the accordion panel + if (updatedAccordionData[focusedPanelIndex].buttonInteractionRef) { + updatedAccordionData[ + focusedPanelIndex + ].buttonInteractionRef.current.focus(); + } } }; @@ -248,7 +273,7 @@ export const Accordion: ChakraComponent< {...rest} > {getElementsFromData( - accordionData, + updatedAccordionData, ariaLabel, id, isAlwaysRendered, diff --git a/src/components/Accordion/accordionChangelogData.ts b/src/components/Accordion/accordionChangelogData.ts index a3e6dbe7f..2cc54e80d 100644 --- a/src/components/Accordion/accordionChangelogData.ts +++ b/src/components/Accordion/accordionChangelogData.ts @@ -14,7 +14,10 @@ export const changelogData: ChangelogData[] = [ version: "3.1.4", type: "Update", affects: ["Accessibility", "Functionality"], - notes: ["Adds logic to close accordion when 'esc' key is pressed"], + notes: [ + "Adds logic to close accordion when accordion button is focused and 'esc' key is pressed", + "Adds logic to close accordion when element within panel is focused and 'esc' key is pressed", + ], }, { date: "2024-03-14", diff --git a/src/components/MultiSelect/MultiSelect.mdx b/src/components/MultiSelect/MultiSelect.mdx index 502d334d4..e8e995f0d 100644 --- a/src/components/MultiSelect/MultiSelect.mdx +++ b/src/components/MultiSelect/MultiSelect.mdx @@ -95,6 +95,9 @@ Accordion: - Use the `aria-controls` attribute to associate the control with the panel. - The open and close icons are decorative (`aria-hidden` is `true`). - Visible focus goes around full button and full button is clickable. +- Because the `MultiSelect` utilizes the `Accordion`, the same functionality applies when + the user presses the 'esc' key and focus is on an open panel: that panel will close and + focus will return to the panel's button. Checkbox: diff --git a/src/components/MultiSelect/MultiSelect.test.tsx b/src/components/MultiSelect/MultiSelect.test.tsx index 3d458602f..088e7a027 100644 --- a/src/components/MultiSelect/MultiSelect.test.tsx +++ b/src/components/MultiSelect/MultiSelect.test.tsx @@ -263,6 +263,34 @@ describe.skip("MultiSelect", () => { ).not.toBeInTheDocument(); }); + it("closes the multiselect when the 'esc' key is pressed", async () => { + render( + null} + onClear={() => null} + /> + ); + + const multiSelectButton = screen.getByRole("button"); + const multiSelectCheckbox = screen.getByRole("checkbox", { name: /dogs/i }); + + expect(multiSelectButton.getAttribute("aria-expanded")).toEqual("true"); + + await userEvent.click(multiSelectCheckbox); + + await userEvent.keyboard("[Escape]"); + + expect(multiSelectButton).toHaveAttribute("aria-expanded", "false"); + }); + it("should allow user to toggle menu by clicking menu button or use the 'Enter'/'Spacebar' key", () => { render( = + const accordionButtonRef: React.RefObject = useRef(); const expandToggleButtonRef: React.RefObject = useRef(); @@ -364,7 +364,7 @@ export const MultiSelect: ChakraComponent< { accordionType: "default", // Pass the ref for interaction with the accordion button. - buttonInteractionRef: accordianButtonRef, + buttonInteractionRef: accordionButtonRef, label: accordionLabel, panel: accordionPanel, }, @@ -387,7 +387,7 @@ export const MultiSelect: ChakraComponent< selectedItemsString={selectedItemsString} selectedItemsCount={selectedItemsCount} onClear={onClear} - accordianButtonRef={accordianButtonRef} + accordionButtonRef={accordionButtonRef} /> )} diff --git a/src/components/MultiSelect/MultiSelectItemsCountButton.tsx b/src/components/MultiSelect/MultiSelectItemsCountButton.tsx index 2e13dfda2..782a9135f 100644 --- a/src/components/MultiSelect/MultiSelectItemsCountButton.tsx +++ b/src/components/MultiSelect/MultiSelectItemsCountButton.tsx @@ -24,7 +24,7 @@ export interface MultiSelectItemsCountButtonProps { /** The action to perform for key down event. */ onKeyDown?: () => void; /** Ref to the Accordion Button element. */ - accordianButtonRef: any; + accordionButtonRef: any; } /** @@ -42,7 +42,7 @@ const MultiSelectItemsCountButton = forwardRef< isOpen, multiSelectId, multiSelectLabelText, - accordianButtonRef, + accordionButtonRef, onClear, selectedItemsString, selectedItemsCount, @@ -66,7 +66,7 @@ const MultiSelectItemsCountButton = forwardRef< onClear && onClear(); // Set focus on the Accordion Button when close the // selected items count button. - accordianButtonRef.current?.focus(); + accordionButtonRef.current?.focus(); }} __css={styles} >