"use client"; import gsap from "gsap"; import { useCallback, useContext, useLayoutEffect, useRef } from "react"; import { SortDirection, TableSortContext } from "./tableSortContext"; const prefersReducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches; /** * Presenter for a single sortable column header. * * Owns the interaction logic (which direction to request next) and the GSAP * choreography for the animated sort indicator, keeping the `table.tsx` view * free of side effects per the architecture guidelines. */ export const useSortableHeadPresenter = ( sortKey: string, defaultSortOrder: SortDirection = "desc", ) => { const { sortKey: activeKey, sortOrder, onSortChange, } = useContext(TableSortContext); const isActive = activeKey === sortKey; const currentOrder: SortDirection = isActive ? sortOrder ?? defaultSortOrder : defaultSortOrder; const rootRef = useRef(null); const pillRef = useRef(null); const upRef = useRef(null); const downRef = useRef(null); const sweepRef = useRef(null); const didMount = useRef(false); const handleClick = useCallback(() => { if (!onSortChange) return; const nextOrder: SortDirection = isActive ? currentOrder === "asc" ? "desc" : "asc" : defaultSortOrder; onSortChange(sortKey, nextOrder); }, [onSortChange, isActive, currentOrder, defaultSortOrder, sortKey]); useLayoutEffect(() => { const reduced = prefersReducedMotion(); const ctx = gsap.context(() => { const up = upRef.current; const down = downRef.current; const pill = pillRef.current; const sweep = sweepRef.current; if (!up || !down || !pill) return; const activeArrow = currentOrder === "asc" ? up : down; const idleArrow = currentOrder === "asc" ? down : up; const activeOpacity = isActive ? 1 : 0.45; const idleOpacity = isActive ? 0.2 : 0.45; if (reduced) { gsap.set(idleArrow, { opacity: idleOpacity, y: 0, scale: 1 }); gsap.set(activeArrow, { opacity: activeOpacity, y: 0, scale: 1 }); return; } // Resting state for whichever chevron is not the requested direction. gsap.to(idleArrow, { opacity: idleOpacity, scale: 0.85, y: 0, duration: 0.3, ease: "power2.out", }); // First paint: settle without the celebratory burst. if (!didMount.current) { didMount.current = true; gsap.set(activeArrow, { opacity: activeOpacity, scale: isActive ? 1 : 0.85, y: 0, }); return; } gsap.to(activeArrow, { opacity: activeOpacity, scale: isActive ? 1 : 0.85, y: 0, duration: 0.35, ease: "power2.out", }); if (!isActive) return; // Pop the pill and slide the chosen chevron in from its direction. gsap.fromTo( pill, { scale: 0.78 }, { scale: 1, duration: 0.55, ease: "back.out(3.2)" }, ); gsap.fromTo( activeArrow, { y: currentOrder === "asc" ? 7 : -7, scale: 0.4, opacity: 0 }, { y: 0, scale: 1, opacity: 1, duration: 0.55, ease: "back.out(2.6)", }, ); // A light sweep races across the indicator on every change. if (sweep) { gsap.fromTo( sweep, { xPercent: -160, opacity: 0.9 }, { xPercent: 160, opacity: 0, duration: 0.7, ease: "power2.inOut" }, ); } }, rootRef); return () => ctx.revert(); }, [isActive, currentOrder]); return { rootRef, pillRef, upRef, downRef, sweepRef, isActive, currentOrder, handleClick, }; };