🐞 fix: Intercept summary click to animate both directions
Deploy to Dreamhost (dev) / build (push) Successful in 34s
Sync TODOs with Issues / sync_todos (push) Successful in 6s

This commit is contained in:
Keith Solomon
2026-07-25 15:50:59 -05:00
parent 01072f6788
commit 06e09e8285
+67 -25
View File
@@ -1,27 +1,33 @@
/** /**
* Team Grid block — bio expand/collapse animation. * Team Grid block — bio expand/collapse animation.
* *
* Native <details>/<summary> handles the toggle, but CSS transitions on * Native <details>/<summary> handles accessibility (keyboard, screen
* max-height need a concrete target value to animate to. This module * reader), but its default toggle behavior hides the children of the
* measures each bio's natural height on open and animates max-height * details element synchronously when [open] is removed. That makes
* from current → measured (open) or current → 0 (close) so the * CSS transitions impossible on the close direction, because by the
* transition fires in both directions. * time the toggle event fires, the element is already hidden.
*
* This module intercepts the click on the summary, prevents the default
* toggle, animates max-height manually, and toggles [open] after the
* animation completes. The bio is always at a known state during the
* animation.
* *
* The module is enqueued per-block by the team-grid PHP template. It * The module is enqueued per-block by the team-grid PHP template. It
* auto-initializes on import: queries the DOM for any existing * auto-initializes on import: queries the DOM for any existing
* .team-grid__details elements and attaches a toggle handler to each. * .team-grid__details elements and attaches a click handler to each.
*/ */
const DETAILS_SELECTOR = '.team-grid__details'; const DETAILS_SELECTOR = '.team-grid__details';
const BIO_WRAP_SELECTOR = '.team-grid__bio-wrap'; const BIO_WRAP_SELECTOR = '.team-grid__bio-wrap';
const DURATION_MS = 300;
const setMaxHeight = (el, value) => { const setMaxHeight = (el, value) => {
el.style.maxHeight = typeof value === 'number' ? `${value}px` : value; el.style.maxHeight = typeof value === 'number' ? `${value}px` : value;
}; };
const measureBioHeight = (bioWrap) => { const measureBioHeight = (bioWrap) => {
// Temporarily allow the inner content to render at its natural size so // Temporarily allow the inner content to render at its natural size
// scrollHeight reports the real height rather than the clipped height. // so scrollHeight reports the real height rather than the clipped height.
const previousMax = bioWrap.style.maxHeight; const previousMax = bioWrap.style.maxHeight;
const previousOverflow = bioWrap.style.overflow; const previousOverflow = bioWrap.style.overflow;
@@ -36,7 +42,7 @@ const measureBioHeight = (bioWrap) => {
return height; return height;
}; };
const animateOpen = (bioWrap) => { const animateOpen = (bioWrap, onComplete) => {
const target = measureBioHeight(bioWrap); const target = measureBioHeight(bioWrap);
setMaxHeight(bioWrap, '0px'); setMaxHeight(bioWrap, '0px');
@@ -50,17 +56,21 @@ const animateOpen = (bioWrap) => {
setMaxHeight(bioWrap, `${target}px`); setMaxHeight(bioWrap, `${target}px`);
}); });
// After the transition completes, leave max-height at the measured const onEnd = (event) => {
// pixel value. Resetting to 'none' breaks the close animation: the if (event.propertyName !== 'max-height' || event.target !== bioWrap) {
// browser treats 'none' as a discrete keyword value and won't return;
// transition from it to a length. The pixel value gives the close }
// animation a clean from-state to interpolate from. bioWrap.removeEventListener('transitionend', onEnd);
if (onComplete) {
onComplete();
}
};
bioWrap.addEventListener('transitionend', onEnd);
}; };
const animateClose = (bioWrap) => { const animateClose = (bioWrap, onComplete) => {
// Capture the current rendered height so the browser has a concrete // The bio is currently open and rendered at its natural height.
// starting value for the transition. Without this, going from // Capture it before we change anything.
// 'none' to '0px' snaps instead of animating.
const current = bioWrap.getBoundingClientRect().height; const current = bioWrap.getBoundingClientRect().height;
setMaxHeight(bioWrap, `${current}px`); setMaxHeight(bioWrap, `${current}px`);
@@ -72,6 +82,40 @@ const animateClose = (bioWrap) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
setMaxHeight(bioWrap, '0px'); setMaxHeight(bioWrap, '0px');
}); });
const onEnd = (event) => {
if (event.propertyName !== 'max-height' || event.target !== bioWrap) {
return;
}
bioWrap.removeEventListener('transitionend', onEnd);
if (onComplete) {
onComplete();
}
};
bioWrap.addEventListener('transitionend', onEnd);
};
const handleClick = (details, bioWrap, event) => {
// Only intercept clicks on the summary (or its descendants).
const summary = details.querySelector('summary');
if (!summary || !summary.contains(event.target)) {
return;
}
// Prevent the browser's default toggle so we can animate first.
event.preventDefault();
if (details.open) {
// Closing: animate to 0, then remove [open] so the children hide.
animateClose(bioWrap, () => {
details.open = false;
});
} else {
// Opening: set [open] first (so the children are visible), then
// measure and animate to the natural height.
details.open = true;
animateOpen(bioWrap);
}
}; };
const attach = (details) => { const attach = (details) => {
@@ -81,17 +125,15 @@ const attach = (details) => {
details.dataset.teamGridBound = 'true'; details.dataset.teamGridBound = 'true';
const bioWrap = details.querySelector(BIO_WRAP_SELECTOR); const bioWrap = details.querySelector(BIO_WRAP_SELECTOR);
if (!bioWrap) { if (!bioWrap) {
return; return;
} }
details.addEventListener('toggle', () => { // Use click (not toggle) so we can preventDefault. Keyboard activation
if (details.open) { // (Enter/Space on the summary) also dispatches a click event, so this
animateOpen(bioWrap); // covers both mouse and keyboard.
} else { details.addEventListener('click', (event) => {
animateClose(bioWrap); handleClick(details, bioWrap, event);
}
}); });
}; };