Last active
December 11, 2023 15:06
-
-
Save alexander-schranz/8a8bf16843f4b677b8d1dd44efefe9f5 to your computer and use it in GitHub Desktop.
Accessibility aware expander component to hide and show a container
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| button[aria-expanded=true] { | |
| // add some styling depending on aria attribute or hide or show a specific child icon | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // --------------------------------------------- | |
| // 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