From be3c1d7cb920ac60efbbff8eee4530f4c176e914 Mon Sep 17 00:00:00 2001 From: Sarah Higley Date: Tue, 23 Mar 2021 17:00:41 -0700 Subject: [PATCH 1/2] update accordion example code, clean up styles, update docs to remove arrow key nav and forced single open state in the basic example. --- examples/accordion/accordion.html | 297 +++++++++-------------- examples/accordion/css/accordion.css | 44 ++-- examples/accordion/js/accordion.js | 161 ++++--------- examples/index.html | 1 - test/tests/accordion_accordion.js | 336 +++++---------------------- 5 files changed, 242 insertions(+), 597 deletions(-) diff --git a/examples/accordion/accordion.html b/examples/accordion/accordion.html index 06d1e25e85..11697238d6 100644 --- a/examples/accordion/accordion.html +++ b/examples/accordion/accordion.html @@ -29,168 +29,133 @@

Accordion Example

The below example section contains a simple personal information input form divided into 3 sections that demonstrates the - design pattern for accordion. - In this implementation, one panel of the accordion is always expanded, and only one panel may - be expanded at a time. + design pattern for accordion.

Example

-
-
- -
-

- -

-
-
- -
-

- - -

-

- - -

-

- - -

-

- - -

-

- - -

-

- - -

-
-
+
+
+

+ +

+
+
+ +
+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+
-

- -

- +

+ +

+
-
-

Accessibility Features

-

- The visual design includes features intended to help users understand that the accordion provides enhanced keyboard navigation functions. - When an accordion header button has keyboard focus, the styling of the accordion container and all its header buttons is changed. -

-

When any accordion header button receives focus:

- -

The focused accordion header button:

- -

Keyboard Support

@@ -223,32 +188,6 @@

Keyboard Support

- - - - - - - - - - - - - - - -
Down Arrow -
    -
  • When focus is on an accordion header, moves focus to the next accordion header.
  • -
  • When focus is on last accordion header, moves focus to first accordion header.
  • -
-
Up Arrow -
    -
  • When focus is on an accordion header, moves focus to the previous accordion header.
  • -
  • When focus is on first accordion header, moves focus to last accordion header.
  • -
-
HomeWhen focus is on an accordion header, moves focus to the first accordion header.
EndWhen focus is on an accordion header, moves focus to the last accordion header.
@@ -295,14 +234,6 @@

Role, Property, State, and Tabindex Attributes

Points to the ID of the panel which the header controls. - - - aria-disabled="true" - button - - If the accordion panel is expanded and is not allowed to be collapsed, then set to true. - - region @@ -339,7 +270,7 @@

HTML Source Code

diff --git a/examples/accordion/css/accordion.css b/examples/accordion/css/accordion.css index e95fa5add2..c84ae62233 100644 --- a/examples/accordion/css/accordion.css +++ b/examples/accordion/css/accordion.css @@ -1,4 +1,4 @@ -.Accordion { +.accordion { margin: 0; padding: 0; border: 2px solid hsl(0, 0%, 52%); @@ -6,24 +6,24 @@ width: 20em; } -.Accordion h3 { +.accordion h3 { margin: 0; padding: 0; } -.Accordion.focus { +.accordion:focus-within { border-color: hsl(216, 94%, 43%); } -.Accordion.focus h3 { +.accordion:focus-within h3 { background-color: hsl(0, 0%, 97%); } -.Accordion > * + * { +.accordion > * + * { border-top: 1px solid hsl(0, 0%, 52%); } -.Accordion-trigger { +.accordion-trigger { background: none; color: hsl(0, 0%, 13%); display: block; @@ -37,28 +37,34 @@ outline: none; } -.Accordion-trigger:focus, -.Accordion-trigger:hover { +.accordion-trigger:focus, +.accordion-trigger:hover { background: hsl(216, 94%, 94%); } -.Accordion-trigger:focus { +.accordion-trigger:focus { outline: 4px solid transparent; } -.Accordion *:first-child .Accordion-trigger { +.accordion > *:first-child .accordion-trigger, +.accordion > *:first-child { border-radius: 5px 5px 0 0; } +.accordion > *:last-child .accordion-trigger, +.accordion > *:last-child { + border-radius: 0 0 5px 5px; +} + button { border-style: none; } -.Accordion button::-moz-focus-inner { +.accordion button::-moz-focus-inner { border: 0; } -.Accordion-title { +.accordion-title { display: block; pointer-events: none; border: transparent 2px solid; @@ -67,11 +73,11 @@ button { outline: none; } -.Accordion-trigger:focus .Accordion-title { +.accordion-trigger:focus .accordion-title { border-color: hsl(216, 94%, 43%); } -.Accordion-icon { +.accordion-icon { border: solid currentColor; border-width: 0 2px 2px 0; height: 0.5rem; @@ -83,22 +89,22 @@ button { width: 0.5rem; } -.Accordion-trigger:focus .Accordion-icon, -.Accordion-trigger:hover .Accordion-icon { +.accordion-trigger:focus .accordion-icon, +.accordion-trigger:hover .accordion-icon { border-color: hsl(216, 94%, 43%); } -.Accordion-trigger[aria-expanded="true"] .Accordion-icon { +.accordion-trigger[aria-expanded="true"] .accordion-icon { transform: translateY(-50%) rotate(-135deg); } -.Accordion-panel { +.accordion-panel { margin: 0; padding: 1em 1.5em; } /* For Edge bug https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/4806035/ */ -.Accordion-panel[hidden] { +.accordion-panel[hidden] { display: none; } diff --git a/examples/accordion/js/accordion.js b/examples/accordion/js/accordion.js index c162b452c5..fd471a9635 100644 --- a/examples/accordion/js/accordion.js +++ b/examples/accordion/js/accordion.js @@ -7,129 +7,54 @@ 'use strict'; -Array.prototype.slice - .call(document.querySelectorAll('.Accordion')) - .forEach(function (accordion) { - // Allow for multiple accordion sections to be expanded at the same time - var allowMultiple = accordion.hasAttribute('data-allow-multiple'); - // Allow for each toggle to both open and close individually - var allowToggle = allowMultiple - ? allowMultiple - : accordion.hasAttribute('data-allow-toggle'); +class Accordion { + constructor(domNode) { + this.rootEl = domNode; + this.buttonEl = this.rootEl.querySelector('button[aria-expanded]'); - // Create the array of toggle elements for the accordion group - var triggers = Array.prototype.slice.call( - accordion.querySelectorAll('.Accordion-trigger') - ); + const controlsId = this.buttonEl.getAttribute('aria-controls'); + this.contentEl = document.getElementById(controlsId); - accordion.addEventListener('click', function (event) { - var target = event.target; + this.open = this.buttonEl.getAttribute('aria-expanded') === 'true'; - if (target.classList.contains('Accordion-trigger')) { - // Check if the current toggle is expanded. - var isExpanded = target.getAttribute('aria-expanded') == 'true'; - var active = accordion.querySelector('[aria-expanded="true"]'); + // add event listeners + this.buttonEl.addEventListener('click', this.onButtonClick.bind(this)); + } - // without allowMultiple, close the open accordion - if (!allowMultiple && active && active !== target) { - // Set the expanded state on the triggering element - active.setAttribute('aria-expanded', 'false'); - // Hide the accordion sections, using aria-controls to specify the desired section - document - .getElementById(active.getAttribute('aria-controls')) - .setAttribute('hidden', ''); + onButtonClick() { + this.toggle(!this.open); + } - // When toggling is not allowed, clean up disabled state - if (!allowToggle) { - active.removeAttribute('aria-disabled'); - } - } - - if (!isExpanded) { - // Set the expanded state on the triggering element - target.setAttribute('aria-expanded', 'true'); - // Hide the accordion sections, using aria-controls to specify the desired section - document - .getElementById(target.getAttribute('aria-controls')) - .removeAttribute('hidden'); - - // If toggling is not allowed, set disabled state on trigger - if (!allowToggle) { - target.setAttribute('aria-disabled', 'true'); - } - } else if (allowToggle && isExpanded) { - // Set the expanded state on the triggering element - target.setAttribute('aria-expanded', 'false'); - // Hide the accordion sections, using aria-controls to specify the desired section - document - .getElementById(target.getAttribute('aria-controls')) - .setAttribute('hidden', ''); - } - - event.preventDefault(); - } - }); - - // Bind keyboard behaviors on the main accordion container - accordion.addEventListener('keydown', function (event) { - var target = event.target; - var key = event.which.toString(); - - // 33 = Page Up, 34 = Page Down - var ctrlModifier = event.ctrlKey && key.match(/33|34/); - - // Is this coming from an accordion header? - if (target.classList.contains('Accordion-trigger')) { - // Up/ Down arrow and Control + Page Up/ Page Down keyboard operations - // 38 = Up, 40 = Down - if (key.match(/38|40/) || ctrlModifier) { - var index = triggers.indexOf(target); - var direction = key.match(/34|40/) ? 1 : -1; - var length = triggers.length; - var newIndex = (index + length + direction) % length; - - triggers[newIndex].focus(); - - event.preventDefault(); - } else if (key.match(/35|36/)) { - // 35 = End, 36 = Home keyboard operations - switch (key) { - // Go to first accordion - case '36': - triggers[0].focus(); - break; - // Go to last accordion - case '35': - triggers[triggers.length - 1].focus(); - break; - } - event.preventDefault(); - } - } - }); - - // These are used to style the accordion when one of the buttons has focus - accordion - .querySelectorAll('.Accordion-trigger') - .forEach(function (trigger) { - trigger.addEventListener('focus', function () { - accordion.classList.add('focus'); - }); - - trigger.addEventListener('blur', function () { - accordion.classList.remove('focus'); - }); - }); + toggle(open) { + // don't do anything if the open state doesn't change + if (open === this.open) { + return; + } - // Minor setup: will set disabled state, via aria-disabled, to an - // expanded/ active accordion which is not allowed to be toggled close - if (!allowToggle) { - // Get the first expanded/ active accordion - var expanded = accordion.querySelector('[aria-expanded="true"]'); + // update the internal state + this.open = open; - // If an expanded/ active accordion is found, disable - if (expanded) { - expanded.setAttribute('aria-disabled', 'true'); - } + // handle DOM updates + this.buttonEl.setAttribute('aria-expanded', `${open}`); + if (open) { + this.contentEl.removeAttribute('hidden'); + } else { + this.contentEl.setAttribute('hidden', ''); } - }); + } + + // Add public open and close methods for convenience + open() { + this.toggle(true); + } + + close() { + this.toggle(false); + } +} + +// init accordions +const accordions = document.querySelectorAll('.accordion h3'); +accordions.forEach((accordionEl) => { + new Accordion(accordionEl); +}); diff --git a/examples/index.html b/examples/index.html index 13eb4991ed..9eb1b56188 100644 --- a/examples/index.html +++ b/examples/index.html @@ -496,7 +496,6 @@

Examples By Properties and States

aria-disabled diff --git a/test/tests/accordion_accordion.js b/test/tests/accordion_accordion.js index 0b1eced61b..1d12aedcb7 100644 --- a/test/tests/accordion_accordion.js +++ b/test/tests/accordion_accordion.js @@ -6,8 +6,8 @@ const assertAriaLabelledby = require('../util/assertAriaLabelledby'); const exampleFile = 'accordion/accordion.html'; const ex = { - buttonSelector: '#coding-arena button', - panelSelector: '#coding-arena [role="region"]', + buttonSelector: '#ex1 button', + panelSelector: '#ex1 [role="region"]', buttonsInOrder: ['#accordion1id', '#accordion2id', '#accordion3id'], firstPanelInputSelectors: [ '#cufc1', @@ -81,24 +81,16 @@ ariaTest( async (t) => { const buttons = await t.context.queryElements(t, ex.buttonSelector); - for (let expandIndex = 0; expandIndex < buttons.length; expandIndex++) { - // Click a heading to expand the section - await buttons[expandIndex].click(); - - for (let index = 0; index < buttons.length; index++) { - const expandedValue = index === expandIndex ? 'true' : 'false'; - t.is( - await buttons[index].getAttribute('aria-expanded'), - expandedValue, - 'Accordion button at index ' + - expandIndex + - ' has been clicked, therefore ' + - '"aria-expanded" on button ' + - index + - ' should be "' + - expandedValue - ); - } + for (let index = 0; index < buttons.length; index++) { + // first accordion starts open + const value = await buttons[index].getAttribute('aria-expanded'); + const expectedValue = index === 0 ? 'true' : 'false'; + + t.is( + value, + expectedValue, + `Accordion button ${index} should have aria-expanded="${expectedValue}"` + ); } } ); @@ -112,35 +104,6 @@ ariaTest( } ); -ariaTest( - '"aria-disabled" set on expanded sections', - exampleFile, - 'button-aria-disabled', - async (t) => { - const buttons = await t.context.queryElements(t, ex.buttonSelector); - - for (let expandIndex = 0; expandIndex < buttons.length; expandIndex++) { - // Click a heading to expand the section - await buttons[expandIndex].click(); - - for (let index = 0; index < buttons.length; index++) { - const disabledValue = index === expandIndex ? 'true' : null; - t.is( - await buttons[index].getAttribute('aria-disabled'), - disabledValue, - 'Accordion button at index ' + - expandIndex + - ' has been clicked, therefore ' + - '"aria-disabled" on button ' + - index + - ' should be "' + - disabledValue - ); - } - } - } -); - ariaTest( 'role "region" exists on accordion panels', exampleFile, @@ -176,48 +139,42 @@ ariaTest( // Keys ariaTest( - 'ENTER key expands section', + 'ENTER key toggles section', exampleFile, 'key-enter-or-space', async (t) => { const buttons = await t.context.queryElements(t, ex.buttonSelector); const panels = await t.context.queryElements(t, ex.panelSelector); - for (let expandIndex of [1, 2, 0]) { + for (let expandIndex of [1, 2]) { await buttons[expandIndex].sendKeys(Key.ENTER); - - t.true( - await panels[expandIndex].isDisplayed(), - 'Sending key ENTER to button at index ' + - expandIndex + - ' should expand the region.' + const panelDisplay = await panels[expandIndex].isDisplayed(); + const buttonAria = await buttons[expandIndex].getAttribute( + 'aria-expanded' ); - t.is( - await buttons[expandIndex].getAttribute('aria-expanded'), - 'true', - 'Sending key ENTER to button at index ' + - expandIndex + - ' set aria-expanded to "true".' + t.true( + panelDisplay, + `Pressing enter on button ${expandIndex} should expand the region.` ); - t.is( - await buttons[expandIndex].getAttribute('aria-disabled'), + buttonAria, 'true', - 'Sending key ENTER to button at index ' + - expandIndex + - ' set aria-disable to "true".' - ); - - await buttons[expandIndex].sendKeys(Key.ENTER); - - t.true( - await panels[expandIndex].isDisplayed(), - 'Sending key ENTER twice to button at index ' + - expandIndex + - ' do nothing.' + `Pressing enter on button ${expandIndex} sets aria-expanded to "true".` ); } + + // first panel starts open; enter should close + await buttons[0].sendKeys(Key.ENTER); + t.false( + await panels[0].isDisplayed(), + 'Pressing enter on first button collapses the region' + ); + t.is( + await buttons[0].getAttribute('aria-expanded'), + 'false', + `Pressing enter on first button sets aria-expanded to "false".` + ); } ); @@ -229,41 +186,35 @@ ariaTest( const buttons = await t.context.queryElements(t, ex.buttonSelector); const panels = await t.context.queryElements(t, ex.panelSelector); - for (let expandIndex of [1, 2, 0]) { + for (let expandIndex of [1, 2]) { await buttons[expandIndex].sendKeys(Key.SPACE); - - t.true( - await panels[expandIndex].isDisplayed(), - 'Sending key SPACE to button at index ' + - expandIndex + - ' should expand the region.' + const panelDisplay = await panels[expandIndex].isDisplayed(); + const buttonAria = await buttons[expandIndex].getAttribute( + 'aria-expanded' ); - t.is( - await buttons[expandIndex].getAttribute('aria-expanded'), - 'true', - 'Sending key SPACE to button at index ' + - expandIndex + - ' set aria-expanded to "true".' + t.true( + panelDisplay, + `Pressing space on button ${expandIndex} should expand the region.` ); - t.is( - await buttons[expandIndex].getAttribute('aria-disabled'), + buttonAria, 'true', - 'Sending key SPACE to button at index ' + - expandIndex + - ' set aria-disable to "true".' - ); - - await buttons[expandIndex].sendKeys(Key.SPACE); - - t.true( - await panels[expandIndex].isDisplayed(), - 'Sending key SPACE twice to button at index ' + - expandIndex + - ' do nothing.' + `Pressing space on button ${expandIndex} sets aria-expanded to "true".` ); } + + // first panel starts open; space should close + await buttons[0].sendKeys(Key.SPACE); + t.false( + await panels[0].isDisplayed(), + 'Pressing space on first button collapses the region' + ); + t.is( + await buttons[0].getAttribute('aria-expanded'), + 'false', + `Pressing space on first button sets aria-expanded to "false".` + ); } ); @@ -274,9 +225,7 @@ ariaTest( async (t) => { const buttons = await t.context.queryElements(t, ex.buttonSelector); - // Open a panel - await buttons[0].click(); - + // verify that open panel is in the tab order let elementsInOrder = [ ex.buttonsInOrder[0], ...ex.firstPanelInputSelectors, @@ -299,39 +248,13 @@ ariaTest( .sendKeys(Key.TAB); } - // Open the next panel - await buttons[1].click(); - - elementsInOrder = [ - ex.buttonsInOrder[0], - ex.buttonsInOrder[1], - ...ex.secondPanelInputSelectors, - ex.buttonsInOrder[2], - ]; - - // Send TAB to the first panel button - firstElement = elementsInOrder.shift(); - await t.context.session.findElement(By.css(firstElement)).sendKeys(Key.TAB); - - // Confirm focus moves through remaining items - for (let itemSelector of elementsInOrder) { - t.true( - await focusMatchesElement(t, itemSelector), - 'Focus should reach element: ' + itemSelector - ); - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.TAB); - } - - // Open the next panel - await buttons[2].click(); + // Close the first panel, and verify that tab does not go through the closed panel + await buttons[0].click(); elementsInOrder = [ ex.buttonsInOrder[0], ex.buttonsInOrder[1], ex.buttonsInOrder[2], - ...ex.thirdPanelInputSelectors, ]; // Send TAB to the first panel button @@ -358,12 +281,11 @@ ariaTest( async (t) => { const buttons = await t.context.queryElements(t, ex.buttonSelector); - // Open a panel + // Close first panel await buttons[0].click(); let elementsInOrder = [ ex.buttonsInOrder[0], - ...ex.firstPanelInputSelectors, ex.buttonsInOrder[1], ex.buttonsInOrder[2], ]; @@ -386,7 +308,7 @@ ariaTest( .sendKeys(Key.chord(Key.SHIFT, Key.TAB)); } - // Open the next panel + // Open one panel await buttons[1].click(); elementsInOrder = [ @@ -413,143 +335,5 @@ ariaTest( .findElement(By.css(itemSelector)) .sendKeys(Key.chord(Key.SHIFT, Key.TAB)); } - - // Open the next panel - await buttons[2].click(); - - elementsInOrder = [ - ex.buttonsInOrder[0], - ex.buttonsInOrder[1], - ex.buttonsInOrder[2], - ...ex.thirdPanelInputSelectors, - ]; - - // Send TAB to the last focusable item - lastElement = elementsInOrder.pop(); - await t.context.session - .findElement(By.css(lastElement)) - .sendKeys(Key.chord(Key.SHIFT, Key.TAB)); - - // Confirm focus moves through remaining items - for (let index = elementsInOrder.length - 1; index >= 0; index--) { - let itemSelector = elementsInOrder[index]; - t.true( - await focusMatchesElement(t, itemSelector), - 'Focus should reach element: ' + itemSelector - ); - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.chord(Key.SHIFT, Key.TAB)); - } - } -); - -ariaTest( - 'DOWN ARROW moves focus between headers', - exampleFile, - 'key-down-arrow', - async (t) => { - // Confirm focus moves through remaining items - for (let index = 0; index < ex.buttonsInOrder.length - 1; index++) { - let itemSelector = ex.buttonsInOrder[index]; - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.ARROW_DOWN); - - t.true( - await focusMatchesElement(t, ex.buttonsInOrder[index + 1]), - 'Focus should reach element: ' + ex.buttonsInOrder[index + 1] - ); - } - - // Focus should wrap to first item - let itemSelector = ex.buttonsInOrder[ex.buttonsInOrder.length - 1]; - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.ARROW_DOWN); - - t.true( - await focusMatchesElement(t, ex.buttonsInOrder[0]), - 'Focus should reach element: ' + ex.buttonsInOrder[0] - ); - } -); - -ariaTest( - 'UP ARROW moves focus between headers', - exampleFile, - 'key-up-arrow', - async (t) => { - // Confirm focus moves through remaining items - for (let index = ex.buttonsInOrder.length - 1; index > 0; index--) { - let itemSelector = ex.buttonsInOrder[index]; - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.ARROW_UP); - - t.true( - await focusMatchesElement(t, ex.buttonsInOrder[index - 1]), - 'Focus should reach element: ' + ex.buttonsInOrder[index - 1] - ); - } - - // Focus should wrap to last item - let itemSelector = ex.buttonsInOrder[0]; - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.ARROW_UP); - - t.true( - await focusMatchesElement( - t, - ex.buttonsInOrder[ex.buttonsInOrder.length - 1] - ), - 'Focus should reach element: ' + - ex.buttonsInOrder[ex.buttonsInOrder.length - 1] - ); - } -); - -ariaTest( - 'HOME key will always move focus to first button', - exampleFile, - 'key-home', - async (t) => { - const lastIndex = ex.buttonsInOrder.length - 1; - - // Confirm focus moves through remaining items - for (let index = 0; index <= lastIndex; index++) { - let itemSelector = ex.buttonsInOrder[index]; - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.HOME); - - t.true( - await focusMatchesElement(t, ex.buttonsInOrder[0]), - 'Focus should reach element: ' + ex.buttonsInOrder[0] - ); - } - } -); - -ariaTest( - 'END key will always move focus to last button', - exampleFile, - 'key-end', - async (t) => { - const lastIndex = ex.buttonsInOrder.length - 1; - - // Confirm focus moves through remaining items - for (let index = lastIndex; index >= 0; index--) { - let itemSelector = ex.buttonsInOrder[index]; - await t.context.session - .findElement(By.css(itemSelector)) - .sendKeys(Key.END); - - t.true( - await focusMatchesElement(t, ex.buttonsInOrder[lastIndex]), - 'Focus should reach element: ' + ex.buttonsInOrder[lastIndex] - ); - } } ); From 22c25d4e45d4786e2e2ce93b41b18d3b33e2b8b6 Mon Sep 17 00:00:00 2001 From: Sarah Higley Date: Wed, 24 Mar 2021 09:50:00 -0700 Subject: [PATCH 2/2] accordion group open behavior --- examples/accordion/accordion.html | 12 +++ examples/accordion/js/accordion.js | 114 ++++++++++++++++++++++++++--- 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/examples/accordion/accordion.html b/examples/accordion/accordion.html index 11697238d6..13e2f00001 100644 --- a/examples/accordion/accordion.html +++ b/examples/accordion/accordion.html @@ -31,6 +31,18 @@

Accordion Example

that demonstrates the design pattern for accordion.

+ +

Example Usage Options

+

+ This example demonstrates two different accordion group behaviors. + The default behavior is to treat each accordion separately, and allow each to individually expand and collapse. + Check this checkbox to instead force exactly one accordion within the group to be open at all times. +

+ +

Example

diff --git a/examples/accordion/js/accordion.js b/examples/accordion/js/accordion.js index fd471a9635..4659c68379 100644 --- a/examples/accordion/js/accordion.js +++ b/examples/accordion/js/accordion.js @@ -7,42 +7,58 @@ 'use strict'; +// Single Accordion +// options are: +// { onAccordionClick?: function, disableOpenButton?: boolean } class Accordion { - constructor(domNode) { + constructor(domNode, options = {}) { this.rootEl = domNode; this.buttonEl = this.rootEl.querySelector('button[aria-expanded]'); + this.options = options; const controlsId = this.buttonEl.getAttribute('aria-controls'); this.contentEl = document.getElementById(controlsId); - this.open = this.buttonEl.getAttribute('aria-expanded') === 'true'; + this.isOpen = this.buttonEl.getAttribute('aria-expanded') === 'true'; // add event listeners this.buttonEl.addEventListener('click', this.onButtonClick.bind(this)); } onButtonClick() { - this.toggle(!this.open); + const { onAccordionClick = this.toggle } = this.options; + + onAccordionClick(!this.isOpen, this); } toggle(open) { // don't do anything if the open state doesn't change - if (open === this.open) { + if (open === this.isOpen) { return; } // update the internal state - this.open = open; + this.isOpen = open; // handle DOM updates this.buttonEl.setAttribute('aria-expanded', `${open}`); if (open) { this.contentEl.removeAttribute('hidden'); + if (this.options.disableOpenButton) { + this.buttonEl.setAttribute('aria-disabled', 'true'); + } } else { this.contentEl.setAttribute('hidden', ''); + if (this.options.disableOpenButton) { + this.buttonEl.removeAttribute('aria-disabled'); + } } } + updateOptions(options) { + this.options = options; + } + // Add public open and close methods for convenience open() { this.toggle(true); @@ -53,8 +69,88 @@ class Accordion { } } +// Accordion Group +const defaultGroupOptions = { + forceOneOpen: false, + initialIndex: 0, +}; + +class AccordionGroup { + constructor(accordionEls, options = defaultGroupOptions) { + const accordionOptions = { + onAccordionClick: this.onAccordionClick.bind(this), + disableOpenButton: options.forceOneOpen, + }; + + this.accordions = accordionEls.map( + (el) => new Accordion(el, accordionOptions) + ); + this.options = options; + this.openIndex = options.initialIndex; + + if (options.forceOneOpen) { + this.resetOpenState(this.openIndex); + } + } + + onAccordionClick(open, accordion) { + const index = this.accordions.indexOf(accordion); + + if (this.options.forceOneOpen) { + this.updateOpenIndex(index); + } else { + open ? accordion.open() : accordion.close(); + } + } + + resetOpenState(openIndex) { + this.accordions.forEach((accordion, i) => { + if (i === openIndex) { + accordion.open(); + } else { + accordion.close(); + } + }); + } + + // public method to change whether or not to manage accordion open state + updateOpenBehavior(forceOpen) { + if (forceOpen !== this.options.forceOneOpen) { + // reset open state of all accordions in the group + this.options.forceOneOpen = forceOpen; + forceOpen && this.resetOpenState(this.openIndex); + + // update the disableOpenButton option for individual accordions + const accordionOptions = { + onAccordionClick: this.onAccordionClick.bind(this), + disableOpenButton: forceOpen, + }; + this.accordions.forEach((accordion) => { + accordion.updateOptions(accordionOptions); + }); + } + } + + updateOpenIndex(newIndex) { + if (newIndex === this.openIndex) { + return; + } + + this.accordions[this.openIndex].close(); + this.accordions[newIndex].open(); + + this.openIndex = newIndex; + } +} + // init accordions -const accordions = document.querySelectorAll('.accordion h3'); -accordions.forEach((accordionEl) => { - new Accordion(accordionEl); -}); +const accordionEls = [...document.querySelectorAll('.accordion h3')]; +const accordionGroup = new AccordionGroup(accordionEls); + +// listen to arrow key checkbox +var behaviorSwitch = document.getElementById('arrow-behavior-switch'); +if (behaviorSwitch) { + behaviorSwitch.addEventListener('change', function () { + accordionGroup.updateOpenBehavior(behaviorSwitch.checked); + }); +}