🐞 fix: Intercept summary click to animate both directions
This commit is contained in:
@@ -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);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user