Skip to content

Instantly share code, notes, and snippets.

@alexander-schranz
Last active December 11, 2023 15:06
Show Gist options
  • Select an option

  • Save alexander-schranz/8a8bf16843f4b677b8d1dd44efefe9f5 to your computer and use it in GitHub Desktop.

Select an option

Save alexander-schranz/8a8bf16843f4b677b8d1dd44efefe9f5 to your computer and use it in GitHub Desktop.
Accessibility aware expander component to hide and show a container
import { Controller } from '@hotwired/stimulus';
/**
* A stimulus controller to handle aria-expanded and aria-controls correctly:
* See also:
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-controls
*
* It is specially build for dialog tags or tags hidden via hidden attribute.
* It is not considered for elements behave differently in a responsive sites,
* such things on the same element should be avoided for accessible sites.
*/
export default class extends Controller<HTMLButtonElement> {
connect() {
// make sure all aria-expanded are set correctly base on aria-controls element attribute visible state
this.updateExpanders();
// TODO listener close on click outside
}
toggle = () => {
const container = this.getContainer();
if (container.tagName === 'DIALOG') { // when the target is a dialog tag we use the offical dialog apis to trigger correct changes
if (container.open) {
container.close();
} else {
container.showModal();
}
this.updateExpanders();
return;
}
// for none dialog tags we just use hidden attribute
container.hidden = !this.container.hidden;
this.updateExpanders();
};
getContainer = (): HTMLElement => {
const containerId = this.element.getAttribute('aria-controls');
if (!containerId) {
throw new Error('The data "aria-controls" need to be defined on the element.');
}
const container = document.getElementById(containerId);
if (!container) {
throw new Error('Could not find element with id "' + containerId + '" defined as "aria-controls" attribute.');
}
};
updateExpanders = (): void => {
const expanders = document.querySelectorAll('[aria-controls=' + this.element.getAttribute('aria-controls') + ']');
const expandedValue = this.isExpanded() ? 'true' : 'false';
expanders.forEach((expander) => {
expander.setAttribute('aria-expanded', expandedValue);
});
// TODO add handling for aria-haspopup
};
isExpanded = (): boolean => {
const container = this.getContainer();
return container.tagName === 'DIALOG' // dialog work via open attribute while other elements via hidden attribute
? container.open
: !container.hidden);
}
button[aria-expanded=true] {
// add some styling depending on aria attribute or hide or show a specific child icon
}
<button data-controller="expander" data-action="expander#toggle" aria-controls="overlay" aria-expanded="false">
Open Overlay
</button>
<dialog id="overlay" hidden aria-labelledby="overay-title">
<button data-controller="expander" data-action="expander#toggle" aria-controls="overlay" aria-expanded="false">
Close Overlay
</button>
<h3 id="overlay-title">Overlay Title</h3>
<div>
Some Content
</div>
</dialog>
<button data-controller="expander" data-action="expander#toggle" aria-controls="overlay" aria-expanded="false">
Another Button
</button>
// ---------------------------------------------
// Ignore this drafts currently:
// ---------------------------------------------
// close dialog on click outside:
connect() {
if (container.tagName === 'DIALOG') {
container.addEventListener('click', () => {
const rect = container.getBoundingClientRect();
const isInDialog = (
rect.top <= event.clientY
&& event.clientY <= rect.top + rect.height
&& rect.left <= event.clientX
&& event.clientX <= rect.left + rect.width
);
if (!isInDialog) {
container.close();
this.updateExpanded('false');
}
});
}
}
// drafts update ariaPoppUp
updateExpanded = (expandedValue) => {
// ...
const ariaHasPopupEl = this.el.getAttribute('aria-haspopup');
const ariaHasPopup = ariaHasPopupEl ? ariaHasPopupEl : 'false';
if (ariaHasPopup !== 'false') {
if ('true' === expandedValue) {
document.addEventListener('click', this.hideExpanded);
} else {
document.removeEventListener('click', this.hideExpanded);
}
}
};
hideExpanded = (event) => {
const target = event.target;
if (target.id !== this.el.id && !this.el.contains(event.target)) {
this.onClick();
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment