<div id="cwf-accordion--example-accordion--single" class="cwf-accordion">
<div class="cwf-accordion__wrapper">
<div id="cwf-accordion__panel--example-accordion--single--1" class="cwf-accordion__panel" role="dialog" aria-labelledby="cwf-accordion__title--example-accordion--single--1" aria-describedby="cwf-accordion__body--example-accordion--single--1">
<div class="cwf-accordion__heading" role="button" tabindex="0" aria-controls="cwf-accordion__overflow--example-accordion--single--1" aria-expanded="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="cwf-accordion__chevron" role="presentation">
<!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path fill="currentColor" d="M416 352c-8.188 0-16.38-3.125-22.62-9.375L224 173.3l-169.4 169.4c-12.5 12.5-32.75 12.5-45.25 0s-12.5-32.75 0-45.25l192-192c12.5-12.5 32.75-12.5 45.25 0l192 192c12.5 12.5 12.5 32.75 0 45.25C432.4 348.9 424.2 352 416 352z" />
</svg> <strong id="cwf-accordion__title--example-accordion--single--1" class="cwf-accordion__title">
Panel title
</strong>
</div>
<div id="cwf-accordion__overflow--example-accordion--single--1" class="cwf-accordion__overflow">
<div id="cwf-accordion__body--example-accordion--single--1" class="cwf-accordion__body">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed imperdiet
risus suscipit sapien congue pretium. In hac habitasse platea dictumst.
Donec vel nisi quis dui aliquam porta. Sed nec dolor ullamcorper velit
suscipit elementum. Suspendisse et augue vitae tellus congue mollis.
Integer sem ex, rhoncus eget cursus non, rhoncus eget ipsum. Praesent
ullamcorper facilisis diam, sit amet commodo sem auctor egestas. <a href="https://www.vcu.edu/">
Virginia Commonwealth University
</a> Vivamus vestibulum, ligula vitae molestie finibus, est lacus ornare
nisl, quis ultrices massa mi vitae augue. Pellentesque nulla lectus, placerat
id tellus ac, fringilla aliquam velit. Aliquam ullamcorper consectetur urna,
vitae maximus nunc malesuada eget. Vestibulum lobortis ut quam a congue.
Phasellus facilisis erat at feugiat faucibus. Curabitur cursus dolor in laoreet
sodales.
</p>
</div>
</div>
</div>
</div>
</div>
{% set id = id ?? panels[0].id %}
{% set accordionSuffix = id ? '--' ~ id : '' %}
{% set accordionId = 'cwf-accordion' ~ accordionSuffix %}
<div id="{{ accordionId }}" class="cwf-accordion">
{%- if panels.length > 1 -%}
<button class="cwf-accordion__toggle"
aria-controls="{{ accordionId }}"
data-action="true">
Expand All
</button>
{%- endif -%}
<div class="cwf-accordion__wrapper">
{%- for panel in panels -%}
{% set panelIndex = panel.id ?? loop.index %}
{% set panelSuffix = id is defined
? id ~ '--' ~ panelIndex
: panelIndex
%}
{% set panelId = 'cwf-accordion__panel--' ~ panelSuffix %}
{% set panelTitleId = 'cwf-accordion__title--' ~ panelSuffix %}
{% set panelBodyId = 'cwf-accordion__body--' ~ panelSuffix %}
{% set panelOverflowId = 'cwf-accordion__overflow--' ~ panelSuffix
%}
<div id="{{ panelId }}"
class="cwf-accordion__panel"
role="dialog"
aria-labelledby="{{ panelTitleId }}"
aria-describedby="{{ panelBodyId }}">
<div class="cwf-accordion__heading"
role="button"
tabindex="0"
aria-controls="{{ panelOverflowId }}"
aria-expanded="{{ panel.open ?? false }}">
{% include '../../shared/icons/chevron-up-solid.svg' with {
class: 'cwf-accordion__chevron',
role: 'presentation'
} %}
<strong id="{{ panelTitleId }}"
class="cwf-accordion__title">
{{ panel.title }}
</strong>
</div>
<div id="{{ panelOverflowId }}" class="cwf-accordion__overflow">
<div id="{{ panelBodyId }}" class="cwf-accordion__body">
{{ panel.body }}
</div>
</div>
</div>
{%- endfor -%}
</div>
</div>
{
"id": "example-accordion--single",
"panels": [
{
"open": true,
"title": "Panel title",
"body": "<p>\n Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed imperdiet\n risus suscipit sapien congue pretium. In hac habitasse platea dictumst.\n Donec vel nisi quis dui aliquam porta. Sed nec dolor ullamcorper velit\n suscipit elementum. Suspendisse et augue vitae tellus congue mollis.\n Integer sem ex, rhoncus eget cursus non, rhoncus eget ipsum. Praesent\n ullamcorper facilisis diam, sit amet commodo sem auctor egestas. <a href=\"https://www.vcu.edu/\">\n Virginia Commonwealth University\n </a> Vivamus vestibulum, ligula vitae molestie finibus, est lacus ornare\n nisl, quis ultrices massa mi vitae augue. Pellentesque nulla lectus, placerat\n id tellus ac, fringilla aliquam velit. Aliquam ullamcorper consectetur urna,\n vitae maximus nunc malesuada eget. Vestibulum lobortis ut quam a congue.\n Phasellus facilisis erat at feugiat faucibus. Curabitur cursus dolor in laoreet\n sodales.\n</p>"
}
]
}
// Accordion component styles
@use "../../shared/animation";
@use "../../shared/style";
@use "../../shared/theme";
// Selector prefix
$prefix: "cwf" !default;
// Accordion foreground (text) colors
$color--light: style.color("gray-light") !default;
$color--medium: style.color("gray-dark") !default;
$color--dark: style.color("black") !default;
// Accordion background colors
$background-color--light: style.color("white-dark") !default;
$background-color--medium: style.darken("white-dark", 5.25%) !default;
$background-color--dark: style.lighten("white-darkest", 33%) !default;
.#{$prefix}-accordion {
display: flex;
flex-direction: column;
align-items: flex-end;
@include style.spacing;
--cwf-accordion--foreground-light-color: #{$color--light};
--cwf-accordion--foreground-medium-color: #{$color--medium};
--cwf-accordion--foreground-dark-color: #{$color--dark};
--cwf-accordion--background-light-color: #{$background-color--light};
--cwf-accordion--background-medium-color: #{$background-color--medium};
--cwf-accordion--background-dark-color: #{$background-color--dark};
}
.#{$prefix}-accordion__toggle {
margin-bottom: 0.5rem;
padding: 0;
border: none;
background-color: transparent;
font-size: 1rem;
text-decoration: underline;
font-family: theme.font--sans-serif();
color: var(--cwf-accordion--foreground-light-color);
@include animation.transition(color);
@include style.cursor;
&:hover,
&:focus {
color: var(--cwf-accordion--foreground-medium-color);
}
}
.#{$prefix}-accordion__wrapper {
width: 100%;
margin-bottom: -1px;
overflow: hidden;
border: 1px solid var(--cwf-accordion--background-dark-color);
}
.#{$prefix}-accordion__panel {
&:not(:first-child) {
border-top: 1px solid var(--cwf-accordion--background-dark-color);
}
&:last-child {
margin-bottom: 0;
}
}
.#{$prefix}-accordion__heading {
display: flex;
align-items: center;
margin-bottom: -1px;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--cwf-accordion--background-dark-color);
background-color: var(--cwf-accordion--background-light-color);
font-family: theme.font--sans-serif();
color: var(--cwf-accordion--foreground-medium-color);
@include animation.transition(background-color, color);
@include style.cursor;
&:hover,
&:focus {
background-color: var(--cwf-accordion--background-medium-color);
color: var(--cwf-accordion--foreground-dark-color);
}
}
.#{$prefix}-accordion__heading[aria-expanded="false"] {
color: var(--cwf-accordion--foreground-light-color);
&:hover,
&:focus {
color: var(--cwf-accordion--foreground-medium-color);
}
}
.#{$prefix}-accordion__chevron {
min-width: 1rem;
width: 1rem;
margin-right: 1.5rem;
@include animation.transition(color, transform);
}
.#{$prefix}-accordion__heading[aria-expanded="false"],
.#{$prefix}-accordion__heading--closing {
& .#{$prefix}-accordion__chevron {
@include animation.flip;
}
}
.#{$prefix}-accordion__title {
font-size: 1rem;
@include animation.transition(color);
}
.#{$prefix}-accordion__overflow {
display: block;
overflow: hidden;
@include animation.transition(height);
}
.#{$prefix}-accordion__heading[aria-expanded="false"]
~ .#{$prefix}-accordion__overflow {
display: none;
}
.#{$prefix}-accordion__body {
padding: 1.5rem;
@include style.children;
}
// The default component class
import { Component } from '../../shared/component.js';
// Check whether reduced motion is enabled locally or globally
import { reducedMotion } from '../../shared/media/index.js';
// Provide functionality to all accordions
export class Accordion extends Component {
constructor({
prefix = 'cwf',
accordion = 'accordion',
toggle = 'accordion__toggle',
panel = 'accordion__panel',
heading = 'accordion__heading',
closing = 'accordion__heading--closing',
overflow = 'accordion__overflow',
body = 'accordion__body'
} = {}) {
super({
prefix,
classes: {
accordion,
toggle,
panel,
heading,
closing,
overflow,
body
},
references: {}
});
// Initialize an object for the current event
this.current = {};
// Bind "this" to the necessary methods
this.onClick = this.onClick.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onHashChange = this.onHashChange.bind(this);
}
// Get references to all accordion elements
getReferences() {
// Attempt to grab all toggles,
this.references.toggles = Array.from(
document.querySelectorAll(this.selectors.toggle)
);
// ... accordions,...
this.references.accordions = Array.from(
document.querySelectorAll(this.selectors.accordion)
);
// .. and accordion panels from the page
this.references.panels = [];
this.references.accordions.forEach((accordion) => {
const panels = Array.from(
accordion.querySelectorAll(this.selectors.panel)
);
panels.forEach((panel) => {
const heading = panel.querySelector(this.selectors.heading);
const overflow = panel.querySelector(this.selectors.overflow);
const body = panel.querySelector(this.selectors.body);
this.references.panels.push({
accordion,
panel,
heading,
overflow,
body
});
});
});
}
// Calculate an element's height (including padding, margin, and border)
calcElHeight(element) {
// Get the computed styles and top/bottom margins of the element...
const styles = window.getComputedStyle(element),
margins =
parseFloat(styles.marginTop) + parseFloat(styles.marginBottom);
// ... and return their sum rounded up in pixels
return Math.ceil(element.offsetHeight + margins) + 'px';
}
// Open an accordion panel
openPanel({ heading, overflow, body }) {
// Focus the heading of the panel if instructed to do so
if (this.current.focus) heading.focus();
// If the panel is already opened, do nothing else
if (heading.getAttribute('aria-expanded') === 'true') return;
// If the current type is "hash", or if reduced motion is enabled locally or globally,...
if (this.current.type === 'hash' || reducedMotion())
// ... simply open the panel with no animation
return heading.setAttribute('aria-expanded', true);
// Set the overflow's height to zero
overflow.style.height = 0;
// End the height transition
function transitionEnd({ propertyName }) {
// If the transition property isn't height, do nothing
if (propertyName !== 'height') return;
// Unbind from the overflow's transitionend event...
overflow.removeEventListener('transitionend', transitionEnd);
// ... and remove its height property
overflow.style.removeProperty('height');
}
// Start the height transition
function transitionStart() {
// Bind to the overflow's transitionend event...
overflow.addEventListener('transitionend', transitionEnd);
// ... and set its height to that of the body's
window.requestAnimationFrame(
() => (overflow.style.height = this.calcElHeight(body))
);
}
// Set the heading's aria-expanded attribute to true...
heading.setAttribute('aria-expanded', true);
// ... and start the height transition
window.requestAnimationFrame(transitionStart.bind(this));
}
// Close an accordion panel
closePanel({ heading, overflow, body }) {
// If the panel is already closed, do nothing else
if (heading.getAttribute('aria-expanded') === 'false') return;
// If the current type is "hash", or if reduced motion is enabled locally or globally,...
if (this.current.type === 'hash' || reducedMotion())
// ... simply close the panel with no animation
return heading.setAttribute('aria-expanded', false);
// Grab the closing class from the global selectors
const { closing } = this.classes;
// Set the overflow's height to that of the body's
overflow.style.height = this.calcElHeight(body);
// End the height transition
function transitionEnd({ propertyName }) {
// If the transition property isn't height, do nothing
if (propertyName !== 'height') return;
// Unbind from the overflow's transitionend event,...
overflow.removeEventListener('transitionend', transitionEnd);
// ... set the heading's aria-expanded to false,...
heading.setAttribute('aria-expanded', false);
// ... remove the closing class from the heading,...
heading.classList.remove(closing);
// ... and remove the overflow's height property
overflow.style.removeProperty('height');
}
// Start the height transition
function transitionStart() {
// Add a class to the heading signifying the panel is closing,...
heading.classList.add(closing);
// ... bind to the overflow's transitionend event,...
overflow.addEventListener('transitionend', transitionEnd);
// ... and set its height to zero
window.requestAnimationFrame(() => (overflow.style.height = 0));
}
// Start the height transition
window.requestAnimationFrame(transitionStart.bind(this));
}
// Set the the toggle button's action (expand or collapse)
setToggle(action) {
// Attempt to get the toggle to update, either from the current state or accordion...
const toggle =
this.current.toggle ||
this.references.toggles.find(
(toggle) =>
toggle.getAttribute('aria-controls') ===
this.current.accordion.id
);
// ... and if it wasn't found, do nothing else
if (!toggle) return;
// Finally, set the toggle's text...
toggle.textContent = action ? 'Expand All' : 'Collapse All';
// ... and action data attribute
toggle.setAttribute('data-action', action);
}
// Toggle the panels
togglePanels() {
// For each panel of the current accordion,...
this.current.panels.forEach(({ panel, heading, overflow, body }) => {
// Ensure the heading does not have a closing class...
heading.classList.remove(this.classes.closing);
// ... and the overflow does not have a height set,...
overflow.style.removeProperty('height');
// ... and store all relevant panel elements
const target = { heading, overflow, body };
// If the type of action was not from a toggle and the panel does not equal the current panel, close it
if (this.current.type !== 'toggle' && panel !== this.current.panel)
return this.closePanel(target);
// If the current action is to open, open the panel
if (this.current.action) return this.openPanel(target);
// Otherwise, close the panel
return this.closePanel(target);
});
// Set the toggle (flip if the action was from a toggle, otherwise always open)
this.setToggle(
this.current.type === 'toggle' ? !this.current.action : true
);
// Finally, reset the current accordion
this.current = {};
}
// Find an element by type
findElementByType(type, element, target) {
// Define ways of finding an element based on type...
const instructions = {
toggle: {
accordion: document.getElementById(
target.getAttribute('aria-controls')
)
},
heading: {
accordion: target.parentNode.parentNode.parentNode,
panel: target.parentNode
},
hash: {
accordion: target.parentNode.parentNode,
panel: target
}
};
// ... and return the proper element
return instructions[type][element];
}
// Set action by type and target
setActionByTypeAndTarget(type, target) {
// If the type is "toggle", return the target's action dataset attribute...
if (type === 'toggle') return target.dataset.action === 'true';
// ... otherwise, return the opposite of the target's aria-expanded attribute
return target.getAttribute('aria-expanded') !== 'true';
}
// Set the current accordion
setCurrentAccordion(type, target, focus = false) {
// Grab all necessary references and data...
const accordion = this.findElementByType(type, 'accordion', target),
panel =
type !== 'toggle'
? this.findElementByType(type, 'panel', target)
: undefined,
toggle = type === 'toggle' ? target : undefined,
panels = this.references.panels.filter(
(panel) => panel.accordion === accordion
),
action =
type !== 'hash'
? this.setActionByTypeAndTarget(type, target)
: true;
// ... and set the current accordion
this.current = {
type,
accordion,
panel,
panels,
toggle,
action,
focus
};
}
// Handle click events
onClick(event) {
// Grab the current target of the event
const { currentTarget } = event;
// If the current target is a toggle, find its associated accordion accordingly...
if (currentTarget.classList.contains(this.classes.toggle))
this.setCurrentAccordion('toggle', currentTarget);
// ... and if the current target is a panel heading, find its associated accordion accordingly
if (currentTarget.classList.contains(this.classes.heading))
this.setCurrentAccordion('heading', currentTarget);
// Finally, prevent the default behavior...
event.preventDefault();
// ... and toggle the panels
return this.togglePanels(event);
}
// Focus a panel heading
focusHeading({ key, currentTarget }) {
// Grab the first/last panel as well as the max index
const firstPanel = this.current.panels[0],
maxIndex = this.current.panels.length - 1,
lastPanel = this.current.panels[maxIndex];
// If the "Home" key was pressed, focus the first panel heading...
if (key === 'Home') return firstPanel.heading.focus();
// ... and if the "End" key was pressed, focus the last panel heading
if (key === 'End') return lastPanel.heading.focus();
// Get the currently focused panel and its index
const currentPanel = this.current.panels.find(
(panel) => panel.heading === currentTarget
),
currentIndex = this.current.panels.indexOf(currentPanel);
// If the up arrow key was pressed,...
if (key === 'ArrowUp') {
// ... focus the last panel heading if the first panel heading is focused,...
if (!currentIndex) return lastPanel.heading.focus();
// ... otherwise focus the previous panel's heading
return this.current.panels[currentIndex - 1].heading.focus();
}
// If the down arrow key was pressed,...
if (key === 'ArrowDown') {
// ... focus the first panel heading if the last panel is focused,...
if (currentIndex === maxIndex) return firstPanel.heading.focus();
// ... otherwise focus the next panel's heading
return this.current.panels[currentIndex + 1].heading.focus();
}
}
// Handle keydown events
onKeyDown(event) {
// Grab the target and key of the event and create a list of valid keys
const { key, currentTarget } = event;
// If a valid key has not been pressed, do nothing else
if (
!['Enter', ' ', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key)
)
return;
// Otherwise, find the heading's associated accordion,...
this.setCurrentAccordion('heading', currentTarget);
// ... prevent the default behavior
event.preventDefault();
switch (key) {
// When the "Enter" or space key is pressed...
case 'Enter':
case ' ':
// ... toggle the panels
return this.togglePanels(event);
// When the up or down arrow keys are pressed...
case 'ArrowUp':
case 'ArrowDown':
// ... focus a panel heading relative to the current target
return this.focusHeading({ key, currentTarget });
// When the "Home" or "End" keys are pressed...
case 'Home':
case 'End':
// ... focus their respective panel heading
return this.focusHeading({ key });
}
}
// Handle hash change events
onHashChange(event) {
// Grab the location hash...
const { hash } = window.location,
id = hash.substr(1),
// ... and see if there's a valid target with that ID
target = this.references.panels.find(
(element) => element.panel.id === id
);
// If no target was matched, do nothing else
if (!target) return;
// Set the current accordion...
this.setCurrentAccordion('hash', target.panel, true);
// ... and toggle the panels
return this.togglePanels(event);
}
// Mount/unmount accordion functionality
mount(run) {
super.mount(run);
// If mounting, get/store the references
if (run) this.getReferences();
// If no accordions were found on the page,...
if (!this.references.accordions.length) {
// ... and unmounting,...
if (!run) {
// ... delete the accordions references,...
delete this.references.accordions;
// ... otherwise, do nothing else
} else return;
}
// If accordion toggles were found on the page,...
if (this.references.toggles.length) {
// ... listen to their click events
this.references.toggles.forEach((toggle) =>
toggle[this.listener]('click', this.onClick)
);
}
// If unmounting, delete all accordion toggle references
if (!run) delete this.references.toggles;
// If accordion panels were found on the page,...
if (this.references.panels.length) {
// .. for each panel heading,...
this.references.panels.forEach(({ heading }) => {
// ... bind to its click/keydown events
heading[this.listener]('click', this.onClick);
heading[this.listener]('keydown', this.onKeyDown);
});
}
// If unmounting, delete all accordion panel references
if (!run) delete this.references.panels;
// Handle location hash panel toggling on page load (if mounting)...
if (run) this.onHashChange();
// ... and on every hash change after that
window[this.listener]('hashchange', this.onHashChange);
}
}
The accordion component is a group of collapsable panels that hide or reveal their content when its header is clicked. When an accordion panel heading is clicked, all other accordion panels that are expanded will collapse. The accordion can be used with a single collapsible panel or multiple. When more than one collapsible panel is used within an accordion, a toggle button is added, allowing users to expand/collapse all accordion panels at once.
Whether you are using an accordion with a single panel or multiple, ensure that all panels are wrapped in a div.cwf-accordion__wrapper
element. Each individual panel will have a div.cwf-accordion__panel
element.
If you are using this component with a group of accordions ensure you are rendering the expand/collapse all toggle button with a class of .cwf-accordion__toggle
. This is automatically done with the core Twig macro and CMS implementation.
Each accordion panel heading is listening for click events or keydown events when focused.
The following events will trigger the panel to expand or collapse:
The following keydown events will change panel heading focus:
Accordions are implemented in T4 as the “Accordion Panel” plugin, meaning its classes are .plugin-
prefixed instead of .cwf-
prefixed.
The plugin outputs a single panel, and using 1 or more sequential plugins group them together as an entire accordion. The expand/collapse all toggle button is automatically included when more than one panel is used within the accordion group.
In the “Name” field of the “Accordion Panel” plugin, the following case-insensitive keywords can be used to change the area in which the accordion panel appears:
Feature
- Moves the accordion panel to the feature area of the page (below the main navigation, above the sidebars and main content).Sidebar
- Moves the accordion panel to the sidebar area of the page (right of the main content).Footer
- Moves the accordion panel to the footer area of the page (above the footer, below the sidebars and main content).This plugin can also be used within the global “Site-Feature”, “Site-Sidebar”, and “Site-Footer” sections to have it displayed globally within the feature, sidebar, and footer areas respectively.
In the “Injectors”* or “Name” field of the “Accordion Panel” plugin, the following injectors can be used:
id:{custom_id}
- Overrides the default, T4 ID of the accordion panel with a custom ID.class:{custom_classes}
- Adds custom classes to the accordion panel.style:{custom_styles}
- Adds custom styles to a style
attribute of the accordion panel.before:{custom_html}
- Adds custom HTML before the accordion panel.after:{custom_html}
- Adds custom HTML after the accordion panel.area:{feature|sidebar|footer}
* - Moves the card to the feature (below the main navigation), sidebar (right of the main column), or footer area (above the footer).* These features are only supported on T41.