1.5
Code refactoring Profile fixes Table fixes Animation additions
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// useColumnPopup — owns the column-header popup AND the event-tag popup.
|
||||
//
|
||||
// Both popups behave identically:
|
||||
// • click an anchor → toggle the popup open
|
||||
// • hover an anchor for N ms → open
|
||||
// • move into the popup → keep it open
|
||||
// • leave the popup → close after 200 ms
|
||||
// • click outside / scroll / resize → close immediately
|
||||
//
|
||||
// Returns everything app.js needs to wire up the th cells and event-tag
|
||||
// spans, plus the popup state objects for rendering the floating panels.
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from '../../vendor/preact-hooks.js';
|
||||
|
||||
export function useColumnPopup() {
|
||||
// ─── Column header popup ────────────────────────────────────────────
|
||||
const [colPopup, setColPopup] = useState(null);
|
||||
const colPopupRef = useRef(null);
|
||||
const colPopupThRef = useRef(null);
|
||||
const hoverTimerRef = useRef(null);
|
||||
const closeTimerRef = useRef(null);
|
||||
|
||||
// ─── Event tag popup ────────────────────────────────────────────────
|
||||
// Holds { events[], slideIndex, x, y, arrowLeft, below }
|
||||
// When multiple events are in the popup they auto-cycle with a crossfade.
|
||||
const [eventTagPopup, setEventTagPopup] = useState(null);
|
||||
const [evSlideIndex, setEvSlideIndex] = useState(0);
|
||||
// 'entering' | 'exiting' | null — drives CSS crossfade classes
|
||||
const [evTransition, setEvTransition] = useState(null);
|
||||
const prevSlideIndexRef = useRef(0);
|
||||
const eventTagPopupRef = useRef(null);
|
||||
const evHoverTimerRef = useRef(null);
|
||||
const evCloseTimerRef = useRef(null);
|
||||
const evSlideTimerRef = useRef(null);
|
||||
|
||||
const calcPopupPos = (thEl) => {
|
||||
const rect = thEl.getBoundingClientRect();
|
||||
const popupW = 260, popupH = 110, margin = 8, gap = 6;
|
||||
let x = rect.left + rect.width / 2;
|
||||
x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
|
||||
const below = rect.top < popupH + gap + margin;
|
||||
const y = below ? rect.bottom + gap : rect.top - gap;
|
||||
const popupLeft = x - popupW / 2;
|
||||
const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16));
|
||||
return { x, y, arrowLeft, below };
|
||||
};
|
||||
|
||||
const openPopup = (key, thEl) => {
|
||||
colPopupThRef.current = thEl;
|
||||
setColPopup({ key, ...calcPopupPos(thEl) });
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
setColPopup(null);
|
||||
colPopupThRef.current = null;
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
clearTimeout(closeTimerRef.current);
|
||||
};
|
||||
|
||||
const handleThClick = (key, e) => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
clearTimeout(closeTimerRef.current);
|
||||
if (colPopup?.key === key) { closePopup(); return; }
|
||||
openPopup(key, e.currentTarget);
|
||||
};
|
||||
|
||||
const handleThEnter = (key, e) => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
clearTimeout(closeTimerRef.current);
|
||||
// If a different popup is open, close it immediately and start fresh timer
|
||||
if (colPopup && colPopup.key !== key) closePopup();
|
||||
if (colPopup?.key === key) return; // already showing this one
|
||||
const thEl = e.currentTarget;
|
||||
hoverTimerRef.current = setTimeout(() => openPopup(key, thEl), 1000);
|
||||
};
|
||||
|
||||
// Leaving a th: just cancel the pending open. Don't auto-close —
|
||||
// the user might be moving into the popup, or just passing through.
|
||||
const handleThLeave = () => {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
};
|
||||
|
||||
// Popup mouse handlers: keep it open while hovering, close on leave.
|
||||
const handlePopupEnter = () => clearTimeout(closeTimerRef.current);
|
||||
const handlePopupLeave = () => { closeTimerRef.current = setTimeout(closePopup, 200); };
|
||||
|
||||
useEffect(() => {
|
||||
if (!colPopup) return;
|
||||
const onClickOutside = (e) => {
|
||||
if (colPopupRef.current && !colPopupRef.current.contains(e.target)) closePopup();
|
||||
};
|
||||
// Close on scroll (avoids scroll-linked jank) or resize
|
||||
const onScrollOrResize = () => closePopup();
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
|
||||
window.addEventListener('resize', onScrollOrResize, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
window.removeEventListener('scroll', onScrollOrResize, { capture: true });
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
};
|
||||
}, [colPopup]);
|
||||
|
||||
// ─── Event tag popup helpers ─────────────────────────────────────────
|
||||
const calcEventPopupPos = (spanEl) => {
|
||||
const rect = spanEl.getBoundingClientRect();
|
||||
const popupW = 260, popupH = 80, margin = 8, gap = 6;
|
||||
let x = rect.left + rect.width / 2;
|
||||
x = Math.max(popupW / 2 + margin, Math.min(x, window.innerWidth - popupW / 2 - margin));
|
||||
const below = rect.top < popupH + gap + margin;
|
||||
const y = below ? rect.bottom + gap : rect.top - gap;
|
||||
const popupLeft = x - popupW / 2;
|
||||
const arrowLeft = Math.max(16, Math.min(rect.left + rect.width / 2 - popupLeft, popupW - 16));
|
||||
return { x, y, arrowLeft, below };
|
||||
};
|
||||
|
||||
// ─── Slideshow advance ───────────────────────────────────────────────
|
||||
// evSlideTo triggers a crossfade to a new slide index.
|
||||
const evSlideTo = useCallback((nextIndex) => {
|
||||
setEvTransition('exiting');
|
||||
// After the exit animation (~400 ms) swap content and fade in
|
||||
setTimeout(() => {
|
||||
prevSlideIndexRef.current = nextIndex;
|
||||
setEvSlideIndex(nextIndex);
|
||||
setEvTransition('entering');
|
||||
// Clear the entering class once the animation finishes
|
||||
setTimeout(() => setEvTransition(null), 420);
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
// Auto-advance slideshow when popup is open with multiple events.
|
||||
// We store a ref to evSlideIndex so the interval closure always reads
|
||||
// the latest value without needing to be recreated on every slide change.
|
||||
const evSlideIndexRef = useRef(0);
|
||||
useEffect(() => { evSlideIndexRef.current = evSlideIndex; }, [evSlideIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventTagPopup || eventTagPopup.events.length <= 1) {
|
||||
clearInterval(evSlideTimerRef.current);
|
||||
return;
|
||||
}
|
||||
evSlideTimerRef.current = setInterval(() => {
|
||||
const next = (evSlideIndexRef.current + 1) % eventTagPopup.events.length;
|
||||
evSlideTo(next);
|
||||
}, 4000);
|
||||
return () => clearInterval(evSlideTimerRef.current);
|
||||
}, [eventTagPopup, evSlideTo]);
|
||||
|
||||
// ─── Event tag popup handlers ────────────────────────────────────────
|
||||
const openEventTagPopup = (events, spanEl) => {
|
||||
clearInterval(evSlideTimerRef.current);
|
||||
setEvSlideIndex(0);
|
||||
setEvTransition(null);
|
||||
setEventTagPopup({ events, ...calcEventPopupPos(spanEl) });
|
||||
};
|
||||
|
||||
const closeEventTagPopup = () => {
|
||||
setEventTagPopup(null);
|
||||
setEvSlideIndex(0);
|
||||
setEvTransition(null);
|
||||
clearTimeout(evHoverTimerRef.current);
|
||||
clearTimeout(evCloseTimerRef.current);
|
||||
clearInterval(evSlideTimerRef.current);
|
||||
};
|
||||
|
||||
// rowEvents = all events for that row (passed in from app.js)
|
||||
const handleEventTagClick = (rowEvents, e) => {
|
||||
e.stopPropagation();
|
||||
clearTimeout(evHoverTimerRef.current);
|
||||
clearTimeout(evCloseTimerRef.current);
|
||||
// Toggle off if same set already open
|
||||
if (eventTagPopup && JSON.stringify(eventTagPopup.events.map(ev => ev.id)) === JSON.stringify(rowEvents.map(ev => ev.id))) {
|
||||
closeEventTagPopup(); return;
|
||||
}
|
||||
openEventTagPopup(rowEvents, e.currentTarget);
|
||||
};
|
||||
|
||||
const handleEventTagEnter = (rowEvents, e) => {
|
||||
clearTimeout(evHoverTimerRef.current);
|
||||
clearTimeout(evCloseTimerRef.current);
|
||||
const spanEl = e.currentTarget;
|
||||
evHoverTimerRef.current = setTimeout(() => openEventTagPopup(rowEvents, spanEl), 700);
|
||||
};
|
||||
|
||||
const handleEventTagLeave = () => clearTimeout(evHoverTimerRef.current);
|
||||
|
||||
const handleEventTagPopupEnter = () => clearTimeout(evCloseTimerRef.current);
|
||||
const handleEventTagPopupLeave = () => { evCloseTimerRef.current = setTimeout(closeEventTagPopup, 200); };
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventTagPopup) return;
|
||||
const onClickOutside = (e) => {
|
||||
if (eventTagPopupRef.current && !eventTagPopupRef.current.contains(e.target)) closeEventTagPopup();
|
||||
};
|
||||
const onScrollOrResize = () => closeEventTagPopup();
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
window.addEventListener('scroll', onScrollOrResize, { passive: true, capture: true });
|
||||
window.addEventListener('resize', onScrollOrResize, { passive: true });
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
window.removeEventListener('scroll', onScrollOrResize, { capture: true });
|
||||
window.removeEventListener('resize', onScrollOrResize);
|
||||
};
|
||||
}, [eventTagPopup]);
|
||||
|
||||
return {
|
||||
// column-header popup
|
||||
colPopup, colPopupRef,
|
||||
handleThClick, handleThEnter, handleThLeave,
|
||||
handlePopupEnter, handlePopupLeave,
|
||||
closePopup,
|
||||
// event-tag popup
|
||||
eventTagPopup, eventTagPopupRef,
|
||||
evSlideIndex, evTransition, evSlideTo,
|
||||
handleEventTagClick, handleEventTagEnter, handleEventTagLeave,
|
||||
handleEventTagPopupEnter, handleEventTagPopupLeave,
|
||||
closeEventTagPopup,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user