Skip to content

Commit

Permalink
Details accordions (#955)
Browse files Browse the repository at this point in the history
* Moving accordions to details and cleaning up.

* Adding multiselect control.

* Adding active indices control.

* Some fixes for Safari.

* This seems to work for grouping, at least in VoiceOver.

* Commented out import of file that isn't included yet.

* Re-working the JS.

* Some more JS refactoring.

* Re-factoring and cleanup.

* Restore this to 'toggle'.

* Getting caught up with recent changes.

* Getting caught up with recent changes, pt. 2.

* Finished cleaning up JS.

* More JS cleanup.

* Refactoring to get focusing on an accordion based on the URL hash fragment working again.

* Some template cleanup.

* Updated stories to make testing cmd+f searching easier. Code cleanup.

* aria-owns was on the wrong element. Thanks Joe for pointing it out.

---------

Co-authored-by: Sean Adams-Hiett <[email protected]>
Co-authored-by: bspeare <[email protected]>
  • Loading branch information
3 people authored Dec 23, 2024
1 parent 0edba94 commit fa75713
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 239 deletions.
245 changes: 103 additions & 142 deletions src/assets/js/accordion.js
Original file line number Diff line number Diff line change
@@ -1,165 +1,126 @@
(function () {
function Accordion(element) {
let thisAccordion = this;

// Get the accordions, and if the accordion group is multiselectable.
this.accordions = element.getElementsByClassName("accordion__heading");
this.multiSelectible = element.getAttribute('aria-multiselectable') === 'true' || false;

// For each of the accordions...
for (let i = 0; i < this.accordions.length; i++) {

// Get the accordion item's components.
let itemComponents = this.accordionItemComponents(this.accordions[i]);

// Check if the accordion is currently expanded at moment of click.
let expanded = this.isAccordionOpen(itemComponents.btn);

// If it is, un-hide its corresponding panel.
itemComponents.panel.hidden = !expanded;

// When the accordion's button is clicked...
itemComponents.btn.onclick = () => {

// Toggle the corresponding accordion.
this.toggleAccordion(this.accordions[i]);
}
}

// Add a listener that listens for when the URL is changed.
window.addEventListener('popstate', function (event) {

// Activate an accordion based upon the hash parameters in the URL.
thisAccordion.activateAccordionByHash();
/**
* A class for controlling accordion behavior.
*/
class Accordion {
constructor(element) {

// Loop through each accordion item and add a listener for when the accordion is toggled.
const accordionItems = element.querySelectorAll('details');
Array.prototype.forEach.call(accordionItems, (item) => {
// Add a listener for when the details element is toggled.
item.addEventListener('toggle', (event) => {
this.toggleAccordionItem(item, event.newState === 'open');
});

// Add a listener for when the summary element is clicked.
const summary = item.querySelector('summary');
summary.addEventListener('click', (event) => {
this.accordionItemClick(item);
});
});

// Activate any accordion that is defined in the hash parameter if there is one.
this.activateAccordionByHash();
}

// Gets the item components for 'accordion'.
// Returns an object that contains 'btn' and 'panel' elements.
Accordion.prototype.accordionItemComponents = function (accordion) {
let btn = accordion.querySelector('button');
let panel = accordion.nextElementSibling;

return {
'btn': btn,
'panel': panel
}
}

// Define whether 'accordion' is open with 'isOpen'.
Accordion.prototype.accordionOpen = function (accordion, isOpen) {
// Get the accordion item's components.
let itemComponents = this.accordionItemComponents(accordion);

// Set the relevant attributes for 'accordion' based on 'isOpen'.
itemComponents.btn.setAttribute('aria-expanded', isOpen);
itemComponents.btn.setAttribute('aria-selected', isOpen);
itemComponents.panel.hidden = !isOpen;
/**
* Handles the click event for an accordion summary element.
*
* @param accordionItem
* The details element of an accordion item.
*/
accordionItemClick(accordionItem) {

// Adds a bespoke data attribute to the accordion item
// so that we can determine if it was clicked. This is
// necessary because the 'click' event if fired before
// the 'toggle' event and we don't know if the accordion
// item is being opened or closed yet.
accordionItem.setAttribute('data-accordion-clicked', true);
}

// Activate an 'accordion'.
Accordion.prototype.activateAccordion = function (accordion) {

// Checks if multiple accordions can be open at once. If not, closes other accordions.
if (!this.multiSelectible) {
this.collapseAllAccordions();
/**
* Handles the toggle event of an accordion details element.
*
* @param accordionItem
* The details element of an accordion item.
* @param isOpening
* A boolean value indicating the new toggle state.
*/
toggleAccordionItem(accordionItem, isOpening) {

// Set the relevant attributes for 'accordion' based on 'open'.
accordionItem.setAttribute('aria-expanded', isOpening);
accordionItem.setAttribute('aria-selected', isOpening);

// Check if the accordion was clicked.
const clicked = accordionItem.getAttribute('data-accordion-clicked');
if (clicked) {
accordionItem.removeAttribute('data-accordion-clicked');

// If the accordion is not open (but will be)...
if (isOpening) {
// Define historyString here to be used later.
const historyString = '#' + accordionItem.id;

// Change window location to add URL params
if (window.history && history.pushState && historyString !== '#') {
// NOTE: doesn't take into account existing params
history.replaceState("", "", historyString);
}
}
// Else if the accordion is closed...
else {
// Empty the history string.
history.replaceState("", "", null);
}
}

// Open the accordion.
this.accordionOpen(accordion, true);
}

// Activate any accordion that is defined in the hash parameter if there is one.
Accordion.prototype.activateAccordionByHash = function () {
/**
* Opens an accordion based on the hash in the URL.
*/
static focusAccordionItemByHash() {

// Get the hash parameter.
let hash = window.location.hash.substr(1);
const hash = window.location.hash.substr(1);

// If the hash parameter is not empty...
if (hash !== '') {

// Get the accordion to focus.
let accordionToFocus = document.getElementById(hash);
const hashedAccordionItem = document.getElementById(hash);

// If the defined hash parameter finds an element...
if (accordionToFocus !== null) {

// Get the accordion wrapper of the hash parameter and this accordion wrapper to compare later.
let accordionToFocusAccordionWrapper = accordionToFocus.parentElement
let accordionWrapper = this.accordions[0].parentElement;

// If the accordion wrapper defined by the hash and this accordion wrapper are the same...
if (accordionToFocusAccordionWrapper === accordionWrapper) {

// Activate the accordion defined in the hash parameters.
this.activateAccordion(accordionToFocus);
if (hashedAccordionItem !== null) {
// If the summary element is present...
const summary = hashedAccordionItem.querySelector('summary');
if (summary) {
// Trigger click event for summary.
summary.click();
}
}
}
}
}

/**
* Initializes the accordion on each of the specified selectors.
*
* @param selector
*/
function applyAccordion(selector) {
const items = document.querySelectorAll(selector);

Array.prototype.forEach.call(items, (item) => {
new Accordion(item);
});

// Add a listener that listens for when the URL is changed.
window.addEventListener('popstate', (event) => {
// Activate an accordion based upon the hash parameters in the URL.
Accordion.focusAccordionItemByHash();
});

// Collapse all accordions in this accordion group.
Accordion.prototype.collapseAllAccordions = function () {

// For each accordion...
for (let i = 0; i < this.accordions.length; i++) {

// Close it.
this.accordionOpen(this.accordions[i], false);
}
}

// Check if an accordion is open by inspecting the aria attribute of the 'btn' controlling it.
// Returns a boolean.
Accordion.prototype.isAccordionOpen = function (btn) {
return btn.getAttribute('aria-expanded') === 'true' || false;
}

// Toggle a specific 'accordion' open or closed.
Accordion.prototype.toggleAccordion = function (accordion) {
// Get the accordion's button element.
let btn = accordion.querySelector('button');

// Check if the accordion is currently expanded at moment of click.
let expanded = this.isAccordionOpen(btn);

// Checks if multiple accordions can be open at once. If not, closes other accordions.
if (!this.multiSelectible && !expanded) {
this.collapseAllAccordions();
}

// Toggle the accordion.
this.accordionOpen(accordion, !expanded)

// If the accordion is not open (but will be)...
if (!expanded) {

// Define historyString here to be used later.
let historyString = '#' + btn.parentElement.id;

// Change window location to add URL params
if (window.history && history.pushState && historyString !== '#') {
// NOTE: doesn't take into account existing params
history.replaceState("", "", historyString);
}
}

// Else if the accordion is closed...
else {

// Empty the history string.
history.replaceState("", "", " ");
}
}

// Instantiate accordions on the page.
const accordions = document.getElementsByClassName("accordion");
// Activate any accordion that is defined in the hash parameter if there is one.
Accordion.focusAccordionItemByHash();
}

for (let i = 0; i < accordions.length; i++) {
let accordion = new Accordion(accordions[i]);
}
}());
export { applyAccordion }

31 changes: 19 additions & 12 deletions src/components/accordion/Accordion.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,6 @@ export default {
control: 'boolean',
name: 'Multi select'
},
items: {
control: false,
table: {
disable: true,
},
},
},
};

Expand All @@ -27,19 +21,32 @@ const Template = (args) => ({
`,
});

let items = [
{ title: 'Brand Bar', content: '<p>The brand bar must appear at the top of all core websites to create immediate association with the university.</p>' },
{ title: 'Brand Footer', content: '<p>A consistent footer across all web experiences reinforces the connection with Iowa and helps users find important details such as contact information and essential hyperlinks. Footers must appear across all pages of a website.</p>' },
{ title: 'Logo', content: '<p>Block IOWA logo tab in black, hyperlinked to uiowa.edu. Use the ALT text "University of Iowa homepage."</p>' },
{ title: 'Favicon', content: '<p>Although small, favicons play an important role in visually unifying web pages. Favicons are the symbols used for browser tabs, bookmarks, or shortcuts pinned to the home screen or desktop of a phone, tablet, or computer.</p>' },
];

export const Default = Template.bind({});
Default.args = {
multiselectable: false,
items: [
{ title: 'Section 1 title', expanded: true, content: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. </p>' },
{ title: 'Section 2 title', content: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>' },
{ title: 'Section 3 title', content: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>' },
{ title: 'Section 4 title', content: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>' },
],
items: items,
};

export const MultiSelect = Template.bind({});
MultiSelect.args = {
...Default.args,
multiselectable: true,
};

export const OpenByDefault = Template.bind({});
OpenByDefault.args = {
...Default.args,
items: [
{ open: true, ...items[0] },
{ ...items[1] },
{ ...items[2] },
{ ...items[3] },
],
};
Loading

0 comments on commit fa75713

Please sign in to comment.