diff --git a/CHANGELOG.mdx b/CHANGELOG.mdx index 230da4c40..889a178c8 100644 --- a/CHANGELOG.mdx +++ b/CHANGELOG.mdx @@ -22,6 +22,10 @@ Prefix the change with one of these keywords: - Added structured issue forms and automatic triage labeling. - Updated footer links of FutureFunder +### Fixed + +- `DescriptionAccordion` to use the native js to hide/show content + ## [0.29.4] ### Added diff --git a/docs/UTILITIES.md b/docs/UTILITIES.md index 3866a22e2..2458341a0 100644 --- a/docs/UTILITIES.md +++ b/docs/UTILITIES.md @@ -25,6 +25,22 @@ import { utils } from '@carletonuniversity/rds' const widthClass = utils.maxWidthClasses['7xl'] ``` +### Description accordion behavior + +The Description React component supports its own accordion interaction. For static or +server-rendered Description markup, import the vanilla runtime from the RDS package and +initialize it once from the theme's JavaScript entry point: + +```js +import { initDescriptionAccordions } from '@carletonuniversity/rds/dist/vanilla-js/cu-description.js' + +initDescriptionAccordions() +``` + +The runtime uses delegated click and keyboard events, so it also works with accordion +markup added after initialization. It returns a cleanup function for applications that +need to remove the listeners. + ### File Locations ``` diff --git a/lib/components/Description/DescriptionAccordion.tsx b/lib/components/Description/DescriptionAccordion.tsx index 202f174c5..b53fee534 100644 --- a/lib/components/Description/DescriptionAccordion.tsx +++ b/lib/components/Description/DescriptionAccordion.tsx @@ -1,6 +1,6 @@ +import { useState } from 'react' import { styles } from './Description.Styles' import { Icon } from '../Icon/Icon' -import { toggleAccordion } from './script' export interface DescriptionAccordionProps { term: string @@ -8,6 +8,7 @@ export interface DescriptionAccordionProps { } export const DescriptionAccordion = ({ term, children }: DescriptionAccordionProps) => { + const [isExpanded, setIsExpanded] = useState(false) const termLabel = term && typeof term === 'string' ? 'accordion-' + term.toLowerCase().replace(/ +/g, '-') : '' return ( @@ -15,22 +16,30 @@ export const DescriptionAccordion = ({ term, children }: DescriptionAccordionPro
- diff --git a/lib/components/Description/script.ts b/lib/components/Description/script.ts deleted file mode 100644 index 2a1a616c4..000000000 --- a/lib/components/Description/script.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const toggleAccordion = (event: React.MouseEvent) => { - const target = event.currentTarget, - icon = target.querySelector('.accordion__icon'), - accordion = target.closest('.accordion'), - content = accordion ? accordion.querySelector('.accordion__content') : null, - hidden = content != null ? content.hidden : null - - if (hidden) { - target.setAttribute('aria-expanded', 'true') - if (icon) { - icon.classList.remove('rotate-0') - icon.classList.add('rotate-90') - } - } else { - target.setAttribute('aria-expanded', 'false') - if (icon) { - icon.classList.add('rotate-0') - icon.classList.remove('rotate-90') - } - } - - if (content) { - content.hidden = !hidden - } -} diff --git a/package.json b/package.json index 575fe06be..ca05073fb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "require": "./dist/rds.umd.js" }, "./dist/style.css": "./dist/style.css", + "./dist/vanilla-js/cu-description.js": "./dist/vanilla-js/cu-description.js", "./dist/vanilla-js/cu-motion.js": "./dist/vanilla-js/cu-motion.js" }, "files": [ diff --git a/public/vanilla-js/cu-description.js b/public/vanilla-js/cu-description.js new file mode 100644 index 000000000..c95ed7142 --- /dev/null +++ b/public/vanilla-js/cu-description.js @@ -0,0 +1,71 @@ +const initializedRoots = new WeakMap() + +const getAccordionButton = (target) => { + return target && target.nodeType === Node.ELEMENT_NODE ? target.closest('.accordion__button') : null +} + +const toggleAccordion = (button) => { + const accordion = button.closest('.accordion') + + if (!accordion) { + console.error('RDS Description accordion button is missing its .accordion container.', button) + return + } + + const content = Array.from(accordion.children).find((child) => child.matches('.accordion__content')) + + if (!content) { + console.error('RDS Description accordion is missing its .accordion__content element.', accordion) + return + } + + const isExpanded = content.hidden + const icon = button.querySelector('.accordion__icon') + + button.setAttribute('aria-expanded', String(isExpanded)) + content.hidden = !isExpanded + + if (icon) { + icon.classList.toggle('rotate-90', isExpanded) + icon.classList.toggle('rotate-0', !isExpanded) + } +} + +/** + * Add delegated click and keyboard behavior to Description.Accordion markup. + * Delegation also handles accordions inserted after initialization. + * + * @param {Document|Element} root Element whose descendants should be initialized. + * @returns {() => void} Remove the delegated event listeners. + */ +export function initDescriptionAccordions(root = document) { + const existingCleanup = initializedRoots.get(root) + if (existingCleanup) return existingCleanup + + const handleClick = (event) => { + const button = getAccordionButton(event.target) + if (button) toggleAccordion(button) + } + + const handleKeydown = (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + + const button = getAccordionButton(event.target) + if (!button || button.matches('button, input, select, textarea, a[href]')) return + + event.preventDefault() + toggleAccordion(button) + } + + root.addEventListener('click', handleClick) + root.addEventListener('keydown', handleKeydown) + + const cleanup = () => { + root.removeEventListener('click', handleClick) + root.removeEventListener('keydown', handleKeydown) + initializedRoots.delete(root) + } + + initializedRoots.set(root, cleanup) + return cleanup +}