Skip to content
This repository was archived by the owner on Feb 9, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions packages/accordion/src/__tests__/accordion.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -39,7 +39,7 @@ beforeEach(() => {
</div> <!-- //.accordion -->
</div> <!-- //.accordion-content -->

<button class="accordion-header" type="button">Accordion Header</button>
<button class="accordion-header" type="button">Accordion Header 4</button>
<div class="accordion-content">
<h2 class="accordion-label">Accordion Heading</h2>
<p>here the content of 4th tab <a href="#">link</a></p>
Expand Down Expand Up @@ -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');
Expand Down
80 changes: 63 additions & 17 deletions packages/accordion/src/accordion.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default class Accordion {
* @param options The acccordion options.
*/
constructor(element, options = {}) {
this.element = element;
this.evtCallbacks = {};

// Defaults
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -288,25 +296,25 @@ 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;
}
event.preventDefault();
break;
// Down arrow
case 40:
case 'ArrowDown':
linkIndex++;
if (linkIndex > accordionLinks.length - 1) {
linkIndex = 0;
Expand All @@ -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;
}
}