Code
import { useId, useState } from 'react';
function getAccordionHeaderId(accordionId, value) {
return accordionId + '-header-' + value;
}
function getAccordionPanelId(accordionId, value) {
return accordionId + '-panel-' + value;
}
export default function Accordion({ sections }) {
const accordionId = useId();
const [openSections, setOpenSections] = useState(
new Set(),
);
function focusOnSection(index) {
document
.getElementById(
getAccordionHeaderId(
accordionId,
sections[index].value,
),
)
.focus();
}
return (
<div
className="accordion"
onKeyDown={(event) => {
const activeItemValue =
document.activeElement.getAttribute(
'data-accordion-value',
);
// Only respond to these interactions if
// an accordion title is in focus.
if (activeItemValue == null) {
return;
}
switch (event.code) {
case 'ArrowUp': {
const index = sections.findIndex(
({ value: itemValue }) =>
itemValue === activeItemValue,
);
focusOnSection(
(index - 1 + sections.length) %
sections.length,
);
break;
}
case 'ArrowDown': {
const index = sections.findIndex(
({ value: itemValue }) =>
itemValue === activeItemValue,
);
focusOnSection((index + 1) % sections.length);
break;
}
case 'Home': {
focusOnSection(0);
break;
}
case 'End': {
focusOnSection(sections.length - 1);
break;
}
default:
break;
}
}}>
{sections.map(({ value, title, contents }) => {
const isExpanded = openSections.has(value);
const headerId = getAccordionHeaderId(
accordionId,
value,
);
const panelId = getAccordionPanelId(
accordionId,
value,
);
return (
<div className="accordion-item" key={value}>
<button
aria-controls={panelId}
aria-expanded={isExpanded}
id={headerId}
className="accordion-item-title"
type="button"
data-accordion-value={value}
onClick={() => {
const newOpenSections = new Set(
openSections,
);
newOpenSections.has(value)
? newOpenSections.delete(value)
: newOpenSections.add(value);
setOpenSections(newOpenSections);
}}>
{title}{' '}
<span
aria-hidden={true}
className={[
'accordion-icon',
isExpanded && 'accordion-icon--rotated',
]
.filter(Boolean)
.join(' ')}
/>
</button>
<div
aria-labelledby={headerId}
role="region"
className="accordion-item-contents"
id={panelId}
hidden={!isExpanded}>
{contents}
</div>
</div>
);
})}
</div>
);
}Instructions
Listening for Keyboard Events
Firstly it’s crucial to know the difference between the keypress vs the keydown event. The keypress event is only fired when a key that produces a character value is pressed down. This would exclude the Up, Down, Home, and End keys which are requirements of this question. The keypress event is also deprecated and shouldn’t be used. For these reasons, we should be using the keydown event. We’ll add the onKeyDown prop to root <div> element.
To know which key is being pressed, we can use event.key or event.code on the event passed to onKeyDown’s callback. There are some differences between event.key vs event.code but for the purposes of this question it doesn’t make a difference and can be ignored. We’ll just use event.code.
Responding to Keyboard Events
Note that these keyboard events should only be responded to if the focus is currently on one of the accordion headers. We can get the currently focused DOM element on the page with document.activeElement, and check if it has a data-accordion-value attribute, which is added to all the accordion header buttons.
Next we read the event.code property and respond with custom code depending on its value. A switch case is suitable for such a situation:
ArrowUp: Focus on the previous header or “wrap around” to the last one if the focus was on the first header. We first find the index of the currently focused header, decrease it by one, and use modulo arithmetic to elegantly handle the “wrap around”.ArrowDown: Focus on the next header or “wrap around” to the first one if the focus was on the last. We first find the index of the currently focused header, increase it by one, and use modulo arithmetic to elegantly handle the “wrap around”.Home: Focus on the first header.End: Focus on the last header.
A valid keyboard even should focus on a new header button. Since we know the id of each header, we can imperatively call .focus() on it by using document.getElementById() with the desired header element id. This approach is non-idiomatic by usual React standards but is acceptable during interviews.
Test Cases
- Expanding/collapsing panels
- When a header is focused, hitting Enter or Space should toggle the associated panel between the expanded/collapsed state.
- Switching header focus
- Tab key should focus on the next focusable element, which in our example is the next header element. It should focus on panel contents if any of them contains focusable elements. This scenario is not testable since we only allow text contents in our accordion.
- Shift + Tab keys should focus on the previous focusable element, which in our example is the previous header element. It should focus on panel contents if any of them contains focusable elements. This scenario is not testable since we only allow text contents in our accordion.
- Up key should focus on the previous header element and focus on the last header element if the first header had focus, skipping any focusable elements within the contents.
- Right key should focus on the next header element and focus on the first header element if the last header had focus, skipping any focusable elements within the contents.
- Home key should focus the first header element.
- End key should focus on the last header element. and focus on the last header element if the first header had focus.