Skip to content

Instantly share code, notes, and snippets.

@tak-dcxi
Last active May 7, 2024 02:17
Show Gist options
  • Save tak-dcxi/098898621e93045e42cc8fd8962466a8 to your computer and use it in GitHub Desktop.
Save tak-dcxi/098898621e93045e42cc8fd8962466a8 to your computer and use it in GitHub Desktop.
initializeAccordion
export type AccordionOptions = {
buttonSelector: string | undefined
panelSelector: string | undefined
duration?: number
easing?: string
printAll?: boolean
}
const defaultOptions: AccordionOptions = {
buttonSelector: undefined,
panelSelector: undefined,
duration: 300,
easing: 'ease-in-out',
printAll: false,
}
const initializeAccordion = (element: HTMLElement, options: AccordionOptions = defaultOptions): void => {
if (!element) return
const mergedOptions = { ...defaultOptions, ...options }
const button = element.querySelector(`${mergedOptions.buttonSelector}`) as HTMLAnchorElement
const panel = element.querySelector(`${mergedOptions.panelSelector}`) as HTMLElement
if (!button || !panel) {
console.error('initializeAccordion: button or panel is not found.')
return
}
const panelId = panel.getAttribute('id')
if (!panelId) {
console.error('initializeAccordion: panel id is required.')
return
}
setAttribute(button, panel, panelId)
button.addEventListener('click', (event) => handleClick(event, element, button, panel, mergedOptions), false)
button.addEventListener('keydown', (event) => handleKeyDown(event, element, button, panel, mergedOptions), false)
panel.addEventListener('beforematch', () => handleBeforeMatch(element, button, panel, mergedOptions), true)
if (mergedOptions.printAll) {
window.addEventListener('beforeprint', () => handleBeforePrint(element, button, panel))
window.addEventListener('afterprint', () => handleAfterPrint(element, button, panel))
}
}
const setAttribute = (button: HTMLAnchorElement, panel: HTMLElement, panelId: string): void => {
button.setAttribute('role', 'button')
button.setAttribute('aria-expanded', String(!panel.hasAttribute('hidden')))
button.setAttribute('aria-controls', panelId)
}
const isOpened = (button: HTMLAnchorElement): boolean => {
return button.getAttribute('aria-expanded') === 'true'
}
let isAnimating: boolean = false
type AnimationOptions = Omit<AccordionOptions, 'buttonSelector' | 'panelSelector' | 'printAll'>
const toggleAccordion = (
button: HTMLAnchorElement,
panel: HTMLElement,
options: AccordionOptions,
show: boolean,
): void => {
if (isOpened(button) === show) return
isAnimating = true
if (show) panel.removeAttribute('hidden')
button.setAttribute('aria-expanded', String(show))
panel.style.willChange = 'max-block-size'
panel.style.overflow = 'clip'
const { blockSize } = window.getComputedStyle(panel)
const keyframes = show
? [{ maxBlockSize: '0' }, { maxBlockSize: blockSize }]
: [{ maxBlockSize: blockSize }, { maxBlockSize: '0' }]
const isPrefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const animationOptions: AnimationOptions = {
duration: isPrefersReduced ? 0 : Math.max(0, options.duration || 0),
easing: options.easing,
}
const onAnimationEnd = () => {
requestAnimationFrame(() => {
panel.style.willChange = ''
panel.style.overflow = ''
if (!show) panel.setAttribute('hidden', 'until-found')
isAnimating = false
})
}
requestAnimationFrame(() => {
const animation = panel.animate(keyframes, animationOptions)
animation.addEventListener('finish', onAnimationEnd)
})
}
const hideOtherAccordion = (element: HTMLElement, options: AccordionOptions, animation = true) => {
const accordionName = element.getAttribute('data-name')
if (!accordionName) return
const allAccordions = [...document.querySelectorAll(`[data-name="${accordionName}"]`)] as HTMLElement[]
const otherAccordions = allAccordions.filter(
(otherAccordion) =>
otherAccordion !== element &&
isOpened(otherAccordion.querySelector(`${options.buttonSelector}`) as HTMLAnchorElement),
)
otherAccordions.forEach((otherAccordion) => {
const otherButton = otherAccordion.querySelector(`${options.buttonSelector}`) as HTMLAnchorElement
const otherPanel = otherAccordion.querySelector(`${options.panelSelector}`) as HTMLElement
if (!(otherButton || otherPanel)) return
if (animation) {
toggleAccordion(otherButton, otherPanel, options, false)
} else {
otherButton.setAttribute('aria-expanded', 'false')
otherPanel.setAttribute('hidden', 'until-found')
}
})
}
const handleClick = (
event: MouseEvent | KeyboardEvent,
element: HTMLElement,
button: HTMLAnchorElement,
panel: HTMLElement,
options: AccordionOptions,
): void => {
event.preventDefault()
if (isAnimating) return
toggleAccordion(button, panel, options, !isOpened(button))
if (isOpened(button)) hideOtherAccordion(element, options)
}
const handleKeyDown = (
event: KeyboardEvent,
element: HTMLElement,
button: HTMLAnchorElement,
panel: HTMLElement,
options: AccordionOptions,
): void => {
if (event.key === ' ') {
handleClick(event, element, button, panel, options)
}
}
const handleBeforeMatch = (
element: HTMLElement,
button: HTMLAnchorElement,
panel: HTMLElement,
options: AccordionOptions,
): void => {
button.setAttribute('aria-expanded', 'true')
hideOtherAccordion(element, options, false)
}
const openStatusAttribute = 'data-open-status'
const handleBeforePrint = (element: HTMLElement, button: HTMLAnchorElement, panel: HTMLElement): void => {
if (!element) return
const isStatusOpen = button.getAttribute('aria-expanded') === 'true'
element.setAttribute(openStatusAttribute, String(isStatusOpen))
button.setAttribute('aria-expanded', 'true')
panel.removeAttribute('hidden')
}
const handleAfterPrint = (element: HTMLElement, button: HTMLAnchorElement, panel: HTMLElement): void => {
if (!element) return
const isStatusOpen = element.getAttribute(openStatusAttribute) === 'true'
button.setAttribute('aria-expanded', String(isStatusOpen))
if (!isStatusOpen) panel.setAttribute('hidden', 'until-found')
element.removeAttribute(openStatusAttribute)
}
export default initializeAccordion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment