diff --git a/packages/accordion/src/__tests__/accordion.js b/packages/accordion/src/__tests__/accordion.js index 9be97b9..f8759f5 100644 --- a/packages/accordion/src/__tests__/accordion.js +++ b/packages/accordion/src/__tests__/accordion.js @@ -1,4 +1,4 @@ -import { screen } from '@testing-library/dom'; +import { screen, fireEvent } from '@testing-library/dom'; import userEvent from '@testing-library/user-event'; import { render, injectCSS } from 'test-utils/dom'; import { axe } from 'jest-axe'; @@ -39,7 +39,7 @@ beforeEach(() => { - +

Accordion Heading

here the content of 4th tab link

@@ -181,6 +181,39 @@ test('nested accordion works', async () => { expect(nestedAccordionContent).not.toBeVisible(); }); +test('keyboard navigation works', async () => { + new Accordion('.accordion'); + + const header1 = screen.getByText('Accordion Header 1'); + const header2 = screen.getByText('Accordion Header 2'); + const nestedAccordionHeader = screen.getByText('Accordion Header with Nested Accordion'); + const nestedAccordionContent = screen.getByTestId('accordion-content-nested'); + const subAccordionHeader = screen.getAllByText('Nested Accordion Header'); + const header4 = screen.getByText('Accordion Header 4'); + + fireEvent.keyDown(header1, { key: 'ArrowDown' }); + expect(header2).toHaveFocus(); + + fireEvent.keyDown(header2, { key: 'ArrowUp' }); + expect(header1).toHaveFocus(); + + fireEvent.click(nestedAccordionHeader); + expect(nestedAccordionContent).toBeVisible(); + expect(await axe(document.querySelector('.accordion'))).toHaveNoViolations(); + + fireEvent.keyDown(nestedAccordionHeader, { key: 'ArrowDown' }); + expect(subAccordionHeader[0]).toHaveFocus(); + + fireEvent.keyDown(subAccordionHeader[0], { key: 'ArrowDown' }); + expect(subAccordionHeader[1]).toHaveFocus(); + + fireEvent.keyDown(subAccordionHeader[1], { key: 'Home' }); + expect(header1).toHaveFocus(); + + fireEvent.keyDown(header1, { key: 'End' }); + expect(header4).toHaveFocus(); +}); + test('destroying accordion works', async () => { const originalMarkup = document.querySelector('.accordion').innerHTML; const header1 = screen.getByText('Accordion Header 1'); diff --git a/packages/accordion/src/accordion.js b/packages/accordion/src/accordion.js index f08198f..b11ade7 100644 --- a/packages/accordion/src/accordion.js +++ b/packages/accordion/src/accordion.js @@ -6,6 +6,7 @@ export default class Accordion { * @param options The acccordion options. */ constructor(element, options = {}) { + this.element = element; this.evtCallbacks = {}; // Defaults @@ -149,19 +150,26 @@ export default class Accordion { setupAccordion(accordionArea, accordionAreaIndex) { const [accordionLinks, accordionContent] = this.getAccordionLinksAndContent(accordionArea); - // Handle keydown event to move between accordion items - this.addEventListener(accordionArea, 'keydown', (event) => { - const selectedElement = event.target; - const key = event.which; - - // Make sure the selected element is a header and a direct descendant of the current accordionArea - if ( - selectedElement.classList.contains('accordion-header') && - selectedElement.parentNode === accordionArea - ) { - this.accessKeyBindings(accordionLinks, selectedElement, key, event); - } - }); + // Add keydown event only to top level content areas. + if (!this.isNestedAccordionArea(accordionArea)) { + // Handle keydown event to move between accordion items + this.addEventListener(accordionArea, 'keydown', (event) => { + const selectedElement = event.target; + const { key } = event; + + // Make sure the selected element is a header and is child of an accordionArea + if ( + selectedElement.classList.contains('accordion-header') && + selectedElement.parentNode === selectedElement.closest(this.element) + ) { + const allFocusableAccordionLinks = this.getAllFocusableAccordionLinks( + accordionArea, + ); + + this.accessKeyBindings(allFocusableAccordionLinks, selectedElement, key, event); + } + }); + } // Set ARIA attributes for accordion links accordionLinks.forEach((accordionLink, index) => { @@ -288,17 +296,17 @@ export default class Accordion { switch (key) { // End key - case 35: + case 'End': linkIndex = accordionLinks.length - 1; event.preventDefault(); break; // Home key - case 36: + case 'Home': linkIndex = 0; event.preventDefault(); break; // Up arrow - case 38: + case 'ArrowUp': linkIndex--; if (linkIndex < 0) { linkIndex = accordionLinks.length - 1; @@ -306,7 +314,7 @@ export default class Accordion { event.preventDefault(); break; // Down arrow - case 40: + case 'ArrowDown': linkIndex++; if (linkIndex > accordionLinks.length - 1) { linkIndex = 0; @@ -320,4 +328,42 @@ export default class Accordion { const newLinkIndex = linkIndex; accordionLinks[newLinkIndex].focus(); } + + /** + * Check if accordionArea is nested. + * + * @param {element} accordionArea The accordionArea to be checked. + * + * @returns {boolean} + */ + isNestedAccordionArea(accordionArea) { + return !!accordionArea.parentElement.closest(this.element); + } + + /** + * Return all accordion links that can receive focus. + * + * @param {element} accordionArea The accordionArea to scope changes. + * + * @returns {Array} + */ + getAllFocusableAccordionLinks(accordionArea) { + const allAccordionLinks = accordionArea.querySelectorAll('.accordion-header'); + const focusableAccordionLinks = Array.prototype.slice + .call(allAccordionLinks) + .filter((link) => { + const parentAccordionContent = link.closest('.accordion-content'); + + if ( + !parentAccordionContent || + parentAccordionContent.classList.contains('is-active') + ) { + return link; + } + + return null; + }); + + return focusableAccordionLinks; + } }