diff --git a/resources/js/hooks/use-scroll-progress.test.ts b/resources/js/hooks/use-scroll-progress.test.ts new file mode 100644 index 00000000..fae2642c --- /dev/null +++ b/resources/js/hooks/use-scroll-progress.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useScrollProgress } from './use-scroll-progress'; + +/** + * Place the element's box so that the hook sees it at a known position, then + * fire the scroll listener the hook attached. + */ +function positionElement( + element: HTMLElement | null, + { top, height }: { top: number; height: number }, +) { + vi.spyOn(element as HTMLElement, 'getBoundingClientRect').mockReturnValue({ + top, + height, + } as DOMRect); + + act(() => { + window.dispatchEvent(new Event('scroll')); + }); +} + +describe('useScrollProgress', () => { + beforeEach(() => { + window.innerHeight = 800; + }); + + it('stays at 0 until the element enters from the bottom', () => { + const { result } = renderHook(() => + useScrollProgress(), + ); + result.current.ref.current = document.createElement('div'); + + // Element still below the fold: top beyond the viewport height. + positionElement(result.current.ref.current, { top: 900, height: 200 }); + + expect(result.current.progress).toBe(0); + }); + + it('reaches 1 once the element has passed above the viewport', () => { + const { result } = renderHook(() => + useScrollProgress(), + ); + result.current.ref.current = document.createElement('div'); + + positionElement(result.current.ref.current, { top: -400, height: 200 }); + + expect(result.current.progress).toBe(1); + }); + + it('reports how far through the viewport the element has travelled', () => { + const { result } = renderHook(() => + useScrollProgress(), + ); + result.current.ref.current = document.createElement('div'); + + // (800 - 400) / (800 + 200) = 0.4 + positionElement(result.current.ref.current, { top: 400, height: 200 }); + + expect(result.current.progress).toBeCloseTo(0.4); + }); +}); diff --git a/resources/js/hooks/use-scroll-progress.ts b/resources/js/hooks/use-scroll-progress.ts new file mode 100644 index 00000000..72a7fc5e --- /dev/null +++ b/resources/js/hooks/use-scroll-progress.ts @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * How far an element has travelled through the viewport: 0 before it enters from + * the bottom, 1 once it has left through the top. Drives the scroll-linked + * animations in the landing page previews. + */ +export function useScrollProgress() { + const [progress, setProgress] = useState(0); + const ref = useRef(null); + + useEffect(() => { + const updateProgress = () => { + const element = ref.current; + if (!element) { + return; + } + + const rect = element.getBoundingClientRect(); + const viewportHeight = window.innerHeight || 1; + const next = + (viewportHeight - rect.top) / (viewportHeight + rect.height); + setProgress(Math.min(1, Math.max(0, next))); + }; + + updateProgress(); + window.addEventListener('scroll', updateProgress, { passive: true }); + window.addEventListener('resize', updateProgress); + + return () => { + window.removeEventListener('scroll', updateProgress); + window.removeEventListener('resize', updateProgress); + }; + }, []); + + return { ref, progress }; +} + +interface UseScrollTranslateOptions { + /** Fraction of the scrollable distance the list moves per unit of progress. */ + speed: number; + /** Extra travel past the overflow so the last row clears the container. */ + padding: number; +} + +/** + * A list that scrolls itself as the page scrolls past it: returns the refs to + * wire up plus how far the list should be translated. + */ +export function useScrollTranslate< + C extends HTMLElement, + L extends HTMLElement, +>({ speed, padding }: UseScrollTranslateOptions) { + const { ref: containerRef, progress } = useScrollProgress(); + const listRef = useRef(null); + const [maxTranslate, setMaxTranslate] = useState(0); + + useEffect(() => { + const updateMaxTranslate = () => { + const container = containerRef.current; + const list = listRef.current; + if (!container || !list) { + return; + } + + setMaxTranslate( + Math.max( + 0, + list.scrollHeight - container.clientHeight + padding, + ), + ); + }; + + updateMaxTranslate(); + window.addEventListener('resize', updateMaxTranslate); + + return () => { + window.removeEventListener('resize', updateMaxTranslate); + }; + }, [containerRef, padding]); + + return { + containerRef, + listRef, + progress, + translateY: progress * maxTranslate * speed, + }; +} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index 94f931ec..33b4ba79 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -7,6 +7,10 @@ import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { tailwindColorClasses } from '@/components/user-info'; import { usePwaInstall } from '@/hooks/use-pwa-install'; +import { + useScrollProgress, + useScrollTranslate, +} from '@/hooks/use-scroll-progress'; import { readStoredValue, writeStoredValue } from '@/lib/safe-storage'; import { cn } from '@/lib/utils'; import { type SharedData } from '@/types'; @@ -37,7 +41,7 @@ import { WrenchIcon, XIcon, } from 'lucide-react'; -import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; const LANDING_IMAGES = [ { @@ -280,66 +284,16 @@ function BankConnectionsPreview({ prependFromBottomCount?: number; className?: string; }) { - const [translateY, setTranslateY] = useState(0); - const [maxTranslate, setMaxTranslate] = useState(0); - const containerRef = useRef(null); - const listRef = useRef(null); - const scrollSpeed = 0.15; + const { containerRef, listRef, translateY } = useScrollTranslate< + HTMLDivElement, + HTMLUListElement + >({ speed: 0.15, padding: 24 }); const prependedBanksCount = Math.min(prependFromBottomCount, banks.length); const previewBanks = useMemo( () => [...banks.slice(-prependedBanksCount), ...banks], [banks, prependedBanksCount], ); - useEffect(() => { - const updateMaxTranslate = () => { - const container = containerRef.current; - const list = listRef.current; - if (!container || !list) { - return; - } - - const travelDistance = Math.max( - 0, - list.scrollHeight - container.clientHeight + 24, - ); - setMaxTranslate(travelDistance); - }; - - updateMaxTranslate(); - window.addEventListener('resize', updateMaxTranslate); - - return () => { - window.removeEventListener('resize', updateMaxTranslate); - }; - }, []); - - useEffect(() => { - const updateTranslate = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - const clampedProgress = Math.min(1, Math.max(0, progress)); - - setTranslateY(clampedProgress * maxTranslate * scrollSpeed); - }; - - updateTranslate(); - window.addEventListener('scroll', updateTranslate, { passive: true }); - window.addEventListener('resize', updateTranslate); - - return () => { - window.removeEventListener('scroll', updateTranslate); - window.removeEventListener('resize', updateTranslate); - }; - }, [maxTranslate, scrollSpeed]); - return (
(null); - const listRef = useRef(null); + const { + containerRef, + listRef, + translateY, + progress: scrollProgress, + } = useScrollTranslate({ + speed: 0.28, + padding: 56, + }); const prependedRowsCount = Math.min(10, TRANSACTION_PREVIEW_ROWS.length); - const scrollSpeed = 0.28; const previewRows = useMemo( () => [ ...TRANSACTION_PREVIEW_ROWS.slice(-prependedRowsCount), @@ -412,56 +369,6 @@ function TransactionRowsPreview({ [prependedRowsCount], ); - useEffect(() => { - const updateMaxTranslate = () => { - const container = containerRef.current; - const list = listRef.current; - if (!container || !list) { - return; - } - - const travelDistance = Math.max( - 0, - list.scrollHeight - container.clientHeight + 56, - ); - setMaxTranslate(travelDistance); - }; - - updateMaxTranslate(); - window.addEventListener('resize', updateMaxTranslate); - - return () => { - window.removeEventListener('resize', updateMaxTranslate); - }; - }, []); - - useEffect(() => { - const updateTranslate = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - const clampedProgress = Math.min(1, Math.max(0, progress)); - - setScrollProgress(clampedProgress); - setTranslateY(clampedProgress * maxTranslate * scrollSpeed); - }; - - updateTranslate(); - window.addEventListener('scroll', updateTranslate, { passive: true }); - window.addEventListener('resize', updateTranslate); - - return () => { - window.removeEventListener('scroll', updateTranslate); - window.removeEventListener('resize', updateTranslate); - }; - }, [maxTranslate, scrollSpeed]); - return (
(null); + const { ref: containerRef, progress: scrollProgress } = + useScrollProgress(); const cardCount = ACCOUNT_PREVIEW_ROWS.length; const staggerDelay = 0.12; const staggerRange = 1 - (cardCount - 1) * staggerDelay; - useEffect(() => { - const updateProgress = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - setScrollProgress(Math.min(1, Math.max(0, progress))); - }; - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); - return (
(null); + const { ref: containerRef, progress: scrollProgress } = + useScrollProgress(); // How many transaction rows to show below the file card const previewRows = TRANSACTION_PREVIEW_ROWS.slice(0, 5); @@ -720,30 +603,6 @@ function ImportPreview({ // Row i starts appearing at this scroll progress const rowStart = (i: number) => 0.3 + i * 0.1; - useEffect(() => { - const updateProgress = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - setScrollProgress(Math.min(1, Math.max(0, progress))); - }; - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); - // File card animation: drops from above, scales and fades in const fileProgress = Math.min(1, scrollProgress / FILE_DROP_END); const fileY = -80 + 92 * fileProgress; @@ -861,38 +720,14 @@ function PrivacyRedactedPreview() { { label: 'Statement', value: 'Q4 2024 · ' + __('Quartely') }, ] as const; - const [scrollProgress, setScrollProgress] = useState(0); - const containerRef = useRef(null); + const { ref: containerRef, progress: scrollProgress } = + useScrollProgress(); // Each bar slides in over this span of scroll progress const BAR_SPAN = 0.15; // Row i's bar starts sliding at this scroll progress const barStart = (i: number) => 0.1 + i * 0.12; - useEffect(() => { - const updateProgress = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - setScrollProgress(Math.min(1, Math.max(0, progress))); - }; - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); - return (
(null); - - useEffect(() => { - const updateProgress = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - setScrollProgress(Math.min(1, Math.max(0, progress))); - }; - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); + const { ref: containerRef, progress: scrollProgress } = + useScrollProgress(); const maxValue = Math.max( ...CASHFLOW_PREVIEW_DATA.map((d) => @@ -1131,38 +942,14 @@ function BudgetsListPreview({ currency: string; locale: string; }) { - const [scrollProgress, setScrollProgress] = useState(0); - const containerRef = useRef(null); + const { ref: containerRef, progress: scrollProgress } = + useScrollProgress(); const ROW_SLIDE_SPAN = 0.15; const BAR_FILL_SPAN = 0.2; const rowSlideStart = (i: number) => 0.05 + i * 0.1; const barFillStart = (i: number) => rowSlideStart(i) + 0.12; - useEffect(() => { - const updateProgress = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - setScrollProgress(Math.min(1, Math.max(0, progress))); - }; - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); - return (
(null); + const { ref: containerRef, progress: scrollProgress } = + useScrollProgress(); // Grocery budget detail — 68% spent const budget = BUDGETS_PREVIEW_ROWS[0]; @@ -1287,30 +1074,6 @@ function BudgetDetailPreview({ const ROW_STEP = 0.1; const ROW_SPAN = 0.18; - useEffect(() => { - const updateProgress = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - setScrollProgress(Math.min(1, Math.max(0, progress))); - }; - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); - const barFill = Math.min(1, Math.max(0, scrollProgress / BAR_SPAN)) * spentPct * 100; const statsProgress = Math.min( @@ -1448,8 +1211,8 @@ function BudgetDetailPreview({ } function BudgetEditPreview() { - const [scrollProgress, setScrollProgress] = useState(0); - const containerRef = useRef(null); + const { ref: containerRef, progress: scrollProgress } = + useScrollProgress(); // "Dining Out" budget — 90% spent (high alert) const budget = BUDGETS_PREVIEW_ROWS[2]; // Entertainment, 90% @@ -1462,30 +1225,6 @@ function BudgetEditPreview() { const ACTION_START = 0.35; const ACTION_SPAN = 0.18; - useEffect(() => { - const updateProgress = () => { - const container = containerRef.current; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const viewportHeight = window.innerHeight || 1; - const progress = - (viewportHeight - rect.top) / (viewportHeight + rect.height); - setScrollProgress(Math.min(1, Math.max(0, progress))); - }; - - updateProgress(); - window.addEventListener('scroll', updateProgress, { passive: true }); - window.addEventListener('resize', updateProgress); - - return () => { - window.removeEventListener('scroll', updateProgress); - window.removeEventListener('resize', updateProgress); - }; - }, []); - const rowProgress = Math.min(1, Math.max(0, scrollProgress / ROW_SPAN)); const alertProgress = Math.min( 1,