From fa757132116d1dee57dee2f79f3a0d5929debe45 Mon Sep 17 00:00:00 2001 From: Alan Way Date: Mon, 23 Dec 2024 09:15:32 -0600 Subject: [PATCH] Details accordions (#955) * 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 Co-authored-by: bspeare --- src/assets/js/accordion.js | 245 ++++++++---------- src/components/accordion/Accordion.stories.js | 31 ++- src/components/accordion/Accordion.vue | 78 +++--- src/components/accordion/AccordionDocs.mdx | 33 +++ src/scss/components/accordion.scss | 111 ++++---- 5 files changed, 259 insertions(+), 239 deletions(-) create mode 100644 src/components/accordion/AccordionDocs.mdx diff --git a/src/assets/js/accordion.js b/src/assets/js/accordion.js index 46dd17ab1e..2d71438bf1 100644 --- a/src/assets/js/accordion.js +++ b/src/assets/js/accordion.js @@ -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 } diff --git a/src/components/accordion/Accordion.stories.js b/src/components/accordion/Accordion.stories.js index 2dcde48897..ba6a7de8f3 100644 --- a/src/components/accordion/Accordion.stories.js +++ b/src/components/accordion/Accordion.stories.js @@ -8,12 +8,6 @@ export default { control: 'boolean', name: 'Multi select' }, - items: { - control: false, - table: { - disable: true, - }, - }, }, }; @@ -27,15 +21,17 @@ const Template = (args) => ({ `, }); +let items = [ + { title: 'Brand Bar', content: '

The brand bar must appear at the top of all core websites to create immediate association with the university.

' }, + { title: 'Brand Footer', content: '

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.

' }, + { title: 'Logo', content: '

Block IOWA logo tab in black, hyperlinked to uiowa.edu. Use the ALT text "University of Iowa homepage."

' }, + { title: 'Favicon', content: '

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.

' }, +]; + export const Default = Template.bind({}); Default.args = { multiselectable: false, - items: [ - { title: 'Section 1 title', expanded: true, content: '

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.

' }, - { title: 'Section 2 title', content: '

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.

' }, - { title: 'Section 3 title', content: '

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.

' }, - { title: 'Section 4 title', content: '

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.

' }, - ], + items: items, }; export const MultiSelect = Template.bind({}); @@ -43,3 +39,14 @@ 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] }, + ], +}; diff --git a/src/components/accordion/Accordion.vue b/src/components/accordion/Accordion.vue index de8957c721..a05f82017c 100644 --- a/src/components/accordion/Accordion.vue +++ b/src/components/accordion/Accordion.vue @@ -1,7 +1,8 @@ diff --git a/src/components/accordion/AccordionDocs.mdx b/src/components/accordion/AccordionDocs.mdx new file mode 100644 index 0000000000..faeaba5bae --- /dev/null +++ b/src/components/accordion/AccordionDocs.mdx @@ -0,0 +1,33 @@ +import {Meta, Title, Source, Primary, Controls, Stories } from '@storybook/blocks'; +import {version} from '/package.json'; + + + + + +## Usage +Make sure you are familiar with the [**Getting Started**](../?path=/docs/introduction#getting-started) guide first. + +Add the following files to your project: +- <strong><a href={`https://github.com/uiowa/uids/tree/gh-pages/docs/v${version}/dist/css/components/logo.css`}>logo.css</a></strong> +- <strong><a href={`https://github.com/uiowa/uids/tree/gh-pages/docs/v${version}/dist/css/components/brand-bar.css`}>brand-bar.css</a></strong> + +Refer to the HTML tab for example HTML. + +<Primary withSource="none" /> + +## Inputs + +The component accepts the following inputs (props): + +<Controls /> +<Source /> + +--- + +## Additional variations + +Listed below are additional variations of the component. + +<Stories /> + diff --git a/src/scss/components/accordion.scss b/src/scss/components/accordion.scss index 3401961499..deedec34af 100644 --- a/src/scss/components/accordion.scss +++ b/src/scss/components/accordion.scss @@ -1,72 +1,81 @@ @use "sass:color"; @import '../abstracts/variables'; @import '../abstracts/utilities'; +.accordion__item { + &:not(:first-child) { + margin-top: 1rem; + } +} .accordion { - .accordion__heading { - margin: 0; - margin-top: 1rem; - font-size: 1rem; - font-weight: $font-weight-bold; + --trans-time: 0.2s; + $accordion-grey: #edeceb; + --accordion-grey: #{$accordion-grey}; + + summary { + list-style: none; + } - &:not(:first-child) { - margin-top: 1rem; + details { + &[open] { + i, svg { + transform: rotate(0deg) translate(0px, 0.1em); + background-color: #fafafa; + } } - button { - all: inherit; - font-size: 1.2rem; - width: 100%; - background-color: #edeceb; - color: $secondary; - padding: $md; + i, svg { + transform: rotate(-180deg) translate(0px, -0.1em); + transition: all var(--trans-time) ease-in-out; + transition-property: transform, background-color; + background-color: transparent; + width: $h5-font-size; + height: $h5-font-size; + padding: 4px; display: flex; - justify-content: space-between; - transition: all 0.2s ease-in-out; - transition-property: background-color; - cursor: pointer; - - &:focus { - outline: 5px auto -webkit-focus-ring-color; - } + justify-content: center; + align-items: center; + margin: -0.3rem 0; + border-radius: 50%; + } + } - i, svg { - color: currentColor; - transform: rotate(-180deg) translate(0px, -0.1em); - transition: all 0.3s ease-in-out; - transition-property: transform, background-color; - background-color: transparent; - width: 30px !important; - height: 30px; - padding: 4px; - display: flex; - justify-content: center; - align-items: center; - margin: -0.3rem 0; - border-radius: 50%; - } + .accordion__heading h2 { + all: inherit; + display: flex; + font-size: $content-font-size; + font-weight: $font-weight-bold; + padding: $md; + justify-content: space-between; + -webkit-justify-content: space-between; + align-items: center; + cursor: pointer; + width: 100%; - &:hover, &:focus { - background-color: color.scale(#edeceb, $lightness: -4%); + &:focus { + outline: 2px auto -webkit-focus-ring-color; + } + } - i { - transform: rotate(-180deg) translate(0px, -0.2em); - } + summary { + width: 100%; + background-color: var(--accordion-grey); + display: flex; + cursor: pointer; - &[aria-expanded="true"] i, &[aria-expanded="true"] svg { - transform: rotate(0deg) translate(0px, 0em); - } - } + &:hover, &:focus { + background-color: color.scale($accordion-grey, $lightness: -4%); + } - &[aria-expanded="true"] i, &[aria-expanded="true"] svg { - transform: rotate(0deg) translate(0px, 0.1em); - background-color: #fafafa; - } + &::-webkit-details-marker { + visibility: hidden; } } .accordion__content { padding: $md; - border: 1px solid #edeceb; + border: 1px solid var(--accordion-grey); } } + +