Skip to content

Commit

Permalink
Merge pull request #64 from communitiesuk/handle-arrow-key-events
Browse files Browse the repository at this point in the history
Handle arrow key events in the accordion component
  • Loading branch information
urquha committed Apr 28, 2023
2 parents 2a91cae + 48eb79f commit fe3e14f
Show file tree
Hide file tree
Showing 2 changed files with 197 additions and 42 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "uk_gov_dash_components",
"version": "1.12.1",
"version": "1.13.0",
"description": "Dash components for Gov UK",
"repository": {
"type": "git",
Expand Down
237 changes: 196 additions & 41 deletions src/lib/fragments/Accordion.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,178 @@ import xtype from 'xtypejs';
import { defaultProps, propTypes } from '../components/Accordion.react';
import './accordion.css';

const EventOrigin = {
SECTION_CONTENT: "section content",
SECTION_HEADING: "section heading",
SHOW_ALL_BUTTON: "show all button",
};

const AccordionContentClassName = "govuk-accordion__section-content";
const AccordionButtonClassName = "accordion-button govuk-accordion__section-button";

class Accordion extends Component {
constructor(props) {
super(props);
this.state = {
sectionsOpen: new Array(this.props.accordionHeadings.length).fill(false),
allSectionsAreOpen: false,
}
this.expandCollapseAllButtonRef = React.createRef();
this.contentRefs = this.props.children.map(() => React.createRef());
this.headerRefs = this.props.accordionHeadings.map(() => React.createRef());
}

openOrCloseAccordionSection = (index) => {
let sectionsOpen = [...this.state.sectionsOpen];
sectionsOpen[index] = !sectionsOpen[index];
this.setState({sectionsOpen: sectionsOpen, allSectionsAreOpen: sectionsOpen.every(sectionIsOpen => sectionIsOpen)});
this.setState({ sectionsOpen: sectionsOpen, allSectionsAreOpen: sectionsOpen.every(sectionIsOpen => sectionIsOpen) });
}

showOrHideAllAccordionSections = () => {
let sectionsOpen = new Array(this.state.sectionsOpen.length).fill(this.state.allSectionsAreOpen ? false : true);
this.setState({ sectionsOpen: sectionsOpen, allSectionsAreOpen : !this.state.allSectionsAreOpen});
}
this.setState({ sectionsOpen: sectionsOpen, allSectionsAreOpen: !this.state.allSectionsAreOpen });
}

// This method handles the Up arrow key event for an accordion component
// depending on the origin of the event (Show All button, section heading, or section content).
handleUpArrow = (index, eventOrigin) => {
let newIndex = index;

switch (eventOrigin) {
case EventOrigin.SHOW_ALL_BUTTON: // go to previous element on page
const expandCollapseAllButtonElement = this.expandCollapseAllButtonRef.current;
const previousElement = this.findFocusableElement(expandCollapseAllButtonElement, "previous");
if (previousElement) {
previousElement.focus();
}
return;
case EventOrigin.SECTION_HEADING:
if (index === 0) { // if the index of the section heading is 0 focus the show all button
const expandCollapseAllButtonElement = this.expandCollapseAllButtonRef.current;
if (expandCollapseAllButtonElement) {
expandCollapseAllButtonElement.focus();
return;
}
} else { // previous section is closed so focus the previous heading
newIndex = index - 1;
}
break;
case EventOrigin.SECTION_CONTENT: // focus the heading at the same level as the content
newIndex = index;
break;
default:
break;
}

// focus the heading at level newIndex
const headingToFocus = this.headerRefs[newIndex].current;
if (headingToFocus) {
headingToFocus.focus();
}
}

// This method handles the Down arrow key event for an accordion component
// depending on the origin of the event (Show All button, section heading, or section content).
handleDownArrow = (index, eventOrigin) => {
const numberSections = this.props.accordionHeadings.length;
let newIndex = index;

switch (eventOrigin) {
case EventOrigin.SHOW_ALL_BUTTON:
newIndex = 0; // go to accordion heading 0
break;
case EventOrigin.SECTION_HEADING:
newIndex = index + 1;
break;
}

if (newIndex >= 0 && newIndex < numberSections) {
const nextHeading = this.headerRefs[newIndex].current;
if (nextHeading) {
nextHeading.focus();
return;
}
} else if (newIndex >= numberSections) { // focus on the next element on the page
const currentHeading = this.headerRefs[index].current;
}
}

handleKeyEvent = (event, index) => {
let eventOrigin;

if (index === -1) {
eventOrigin = EventOrigin.SHOW_ALL_BUTTON;
} else if (event.target.className === AccordionContentClassName) {
eventOrigin = EventOrigin.SECTION_CONTENT;
} else if (event.target.className === AccordionButtonClassName) {
eventOrigin = EventOrigin.SECTION_HEADING;
} else {
return;
}

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.handleDownArrow(index, eventOrigin);
break;
case 'ArrowUp':
event.preventDefault();
this.handleUpArrow(index, eventOrigin);
break;
default:
break;
}
}

findFocusableElement(element, direction) {
if (!element) {
return
}
const focusableElements = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex="0"], [contenteditable]';
const focusable = Array.from(document.querySelectorAll(focusableElements));
const currentIndex = focusable.indexOf(element);
if (currentIndex === -1) {
console.warn("Element not found in the list of focusable elements.");
return null;
}

let targetIndex;
if (direction === "next") {
targetIndex = currentIndex + 1;
} else if (direction === "previous") {
targetIndex = currentIndex - 1;
} else {
throw new Error("Invalid direction. Must be either 'next' or 'previous'.");
}
return focusable[targetIndex] || null;
}

render() {
let accordionContent
if (this.props.children.length === 1 || xtype.type(this.props.children) !== 'array'){
let accordionContent
if (this.props.children.length === 1 || xtype.type(this.props.children) !== 'array') {
accordionContent = this.renderAccordionSection(0, this.props.children, this.state.sectionsOpen[0])
}
else {
else {
accordionContent = this.props.children.map((accordionSectionContent, index) => this.renderAccordionSection(index, accordionSectionContent, this.state.sectionsOpen[index]))
}
return (
<div className="js-enabled">
<div className="govuk-accordion" data-module="govuk-accordion" id={this.props.id}>
<div
className="govuk-accordion"
data-module="govuk-accordion"
id={this.props.id}
>
<div className='govuk-accordion__controls'>
<button type='button' className='govuk-accordion__show-all' onClick={this.showOrHideAllAccordionSections}>
<span className= {this.state.allSectionsAreOpen ? "govuk-accordion-nav__chevron" : "govuk-accordion-nav__chevron govuk-accordion-nav__chevron--down"} ></span>
<span className="govuk-accordion__section-toggle-text"> {this.state.allSectionsAreOpen ? "Hide all sections" : "Show all sections"} </span>
<button
type='button'
className='govuk-accordion__show-all'
onClick={this.showOrHideAllAccordionSections}
onKeyDown={(event) => this.handleKeyEvent(event, -1)}
aria-label={`Toggle ${this.props.accordionHeadings.length} panels, ${this.state.allSectionsAreOpen ? "collapse" : "expand"} all`}
ref={this.expandCollapseAllButtonRef}
>
<span className={this.state.allSectionsAreOpen ? "govuk-accordion-nav__chevron" : "govuk-accordion-nav__chevron govuk-accordion-nav__chevron--down"} ></span>
<span className="govuk-accordion__section-toggle-text"> {this.state.allSectionsAreOpen ? "Hide all sections" : "Show all sections"} </span>
</button>
</div>
{accordionContent}
Expand All @@ -50,39 +186,58 @@ class Accordion extends Component {

renderAccordionSection(index, accordionSectionContent, sectionIsOpen) {
const accordionHeading = this.props.accordionHeadings[index]
return (
<div className={sectionIsOpen ? "govuk-accordion__section govuk-accordion__section--expanded" : "govuk-accordion__section"}>
<div className="govuk-accordion__section-header">
<h2 className="govuk-accordion__section-heading">
<button className="accordion-button govuk-accordion__section-button" type="button" aria-controls={`accordion-default-content-${index}`} aria-expanded={sectionIsOpen} onClick={() => this.openOrCloseAccordionSection(index)}>
<span className="govuk-accordion__section-heading-text" >
<span className="govuk-accordion__section-heading-text-focus"> {accordionHeading}
</span>
</span>
<span className="govuk-visually-hidden govuk-accordion__section-heading-divider"></span>
<span className="govuk-accordion__section-toggle" data-nosnippet>
<span className="govuk-accordion__section-toggle-focus">
<span className="moj-side-navigation__item--collapsed">
</span>
</span>
<span className= {sectionIsOpen ? "govuk-accordion-nav__chevron" : "govuk-accordion-nav__chevron govuk-accordion-nav__chevron--down"} ></span>
<span className="govuk-accordion__section-toggle-focus govuk-accordion__section-toggle-text"> {sectionIsOpen ? "Hide" : "Show"}
<span className="govuk-visually-hidden"> this section</span>
</span>
const contentId = `accordion-default-content-${index}`;
return (
<div
className={sectionIsOpen ? "govuk-accordion__section govuk-accordion__section--expanded" : "govuk-accordion__section"}
>
<div className="govuk-accordion__section-header">
<h2 className="govuk-accordion__section-heading">
<button
className={AccordionButtonClassName}
type="button"
aria-controls={contentId}
aria-label={
sectionIsOpen
? `Heading at level ${index} is ${accordionHeading},,,, section is open, select to close`
: `Heading at level ${index} is ${accordionHeading},,,, section is closed, select to open`
}
onClick={() => this.openOrCloseAccordionSection(index)}
onKeyDown={(event) => this.handleKeyEvent(event, index)}
ref={this.headerRefs[index]}
>
<span className="govuk-accordion__section-heading-text" >
<span className="govuk-accordion__section-heading-text-focus"> {accordionHeading}
</span>
</span>
<span className="govuk-visually-hidden govuk-accordion__section-heading-divider"></span>
<span className="govuk-accordion__section-toggle" data-nosnippet>
<span className="govuk-accordion__section-toggle-focus">
<span className="moj-side-navigation__item--collapsed">
</span>
</button>
</h2>
</div>
<div className="govuk-accordion__section-content" aria-labelledby={`accordion-default-heading-${index}`}>
<p className='govuk-body'>{accordionSectionContent}</p>
</div>
</div>
)
}
</span>
<span className={sectionIsOpen ? "govuk-accordion-nav__chevron" : "govuk-accordion-nav__chevron govuk-accordion-nav__chevron--down"} ></span>
<span className="govuk-accordion__section-toggle-focus govuk-accordion__section-toggle-text"> {sectionIsOpen ? "Hide" : "Show"}
</span>
</span>
</button>
</h2>
</div>
<div
className={AccordionContentClassName}
id={contentId}
onKeyDown={(event) => this.handleKeyEvent(event, index)}
tabIndex="0" //set this to make the content focusable for arrow key events
aria-label={`Content at level ${index}`}
ref={this.contentRefs[index]}
>
<p className='govuk-body'>{accordionSectionContent}
</p>
</div>
</div>
)
}



}

Accordion.defaultProps = defaultProps;
Accordion.propTypes = propTypes;
Expand Down

0 comments on commit fe3e14f

Please sign in to comment.