プレスト(前編)
<script> (function(){ const DURATION = 900; // ms(ゆっくりに:1200〜1400でもOK) const EASING = 'cubic-bezier(.2,.8,.2,1)'; const LIST_SELECTOR = '.category-list'; // 同じリスト内で「常に1つだけ開く」 const DETAILS_SELECTOR = 'details.acc'; // heightアニメをPromise化(transitionend取りこぼしに備えタイムアウト併用) function waitTransition(el, prop = 'height', timeout = DURATION + 120) { return new Promise(resolve => { let done = false; const onEnd = (e) => { if (done) return; if (e.propertyName !== prop) return; done = true; el.removeEventListener('transitionend', onEnd); resolve(); }; el.addEventListener('transitionend', onEnd); setTimeout(() => { if (done) return; el.removeEventListener('transitionend', onEnd); resolve(); // フォールバック }, timeout); }); } function syncInitial(details, panel){ panel.style.transition = ''; if(details.open){ panel.style.height = 'auto'; panel.style.opacity = '1'; panel.classList.add('is-open'); }else{ panel.style.height = '0px'; panel.style.opacity = '0'; panel.classList.remove('is-open'); } } async function animateOpen(details, panel){ // ネイティブの開状態を先につけ、アイコン「–」表示 details.open = true; // 開くアニメ:0 → scrollHeight → auto panel.classList.add('is-open'); panel.style.transition = ''; panel.style.opacity = '0'; panel.style.height = '0px'; panel.offsetHeight; // reflow // 実高さを計測 let target = panel.scrollHeight; // 画像/フォントの遅延で高さが伸びるのに追従(軽い保険) const adjustTimer = setTimeout(() => { if (!details.open) return; const h = panel.scrollHeight; if (panel.style.height !== 'auto' && h > target) { target = h; panel.style.height = h + 'px'; } }, 180); panel.style.transition = `height ${DURATION}ms ${EASING}, opacity ${Math.max(400, DURATION-300)}ms ease`; panel.style.height = target + 'px'; panel.style.opacity = '1'; await waitTransition(panel, 'height'); clearTimeout(adjustTimer); // 可変コンテンツ対応のため auto に戻す panel.style.transition = ''; panel.style.height = 'auto'; } async function animateClose(details, panel){ // auto → px → 0(これが超重要) panel.style.transition = ''; panel.style.height = panel.scrollHeight + 'px'; panel.offsetHeight; // reflow panel.style.transition = `height ${DURATION}ms ${EASING}, opacity ${Math.max(400, DURATION-300)}ms ease`; panel.style.height = '0px'; panel.style.opacity = '0'; panel.classList.remove('is-open'); await waitTransition(panel, 'height'); details.open = false; panel.style.transition = ''; } function setupList(listEl){ const detailsList = Array.from(listEl.querySelectorAll(DETAILS_SELECTOR)); // 初期同期 detailsList.forEach(d => { const panel = d.querySelector('.category-panel'); if (panel) syncInitial(d, panel); }); // リスト単位の直列ロック(ここがポイント) let busy = false; let current = detailsList.find(d => d.open) || null; listEl.addEventListener('click', async (e) => { const summary = e.target.closest('summary'); if (!summary) return; const details = summary.closest(DETAILS_SELECTOR); if (!details || !listEl.contains(details)) return; // ネイティブのトグルは止める(JSで一元管理) e.preventDefault(); if (busy) return; // 直列化 busy = true; const panel = details.querySelector('.category-panel'); try{ if (current && current !== details) { // いま開いているものを「アニメで」閉じる → 終わってから開く const currentPanel = current.querySelector('.category-panel'); if (currentPanel) await animateClose(current, currentPanel); current = null; } if (!details.open) { if (panel) await animateOpen(details, panel); current = details; } else { if (panel) await animateClose(details, panel); current = null; } } finally { busy = false; } }); // キーボード操作(Enter/Space)も同じ挙動 listEl.addEventListener('keydown', async (e) => { if (e.key !== 'Enter' && e.key !== ' ') return; const summary = e.target.closest('summary'); if (!summary) return; e.preventDefault(); summary.click(); // クリックハンドラに委譲(直列ロック付きで安全) }); } function init(){ document.querySelectorAll(LIST_SELECTOR).forEach(setupList); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })(); </script>