refactor(landing): extract the scroll-progress hook the previews all copied
Nine preview components on the landing page each carried their own copy of the same effect: measure the container against the viewport, clamp the ratio to 0..1, keep it in state, and attach passive scroll + resize listeners. Two of them also duplicated the effect that measures how far a list can travel. useScrollProgress() and useScrollTranslate() now own that, and the components just read the progress. welcome.tsx loses 265 lines. Verified in the browser: all nine previews still animate as the page scrolls (the eight transform/opacity ones and the cashflow chart, which animates bar heights). Adds unit coverage for the clamped progress, which nothing tested.
This commit is contained in:
parent
026e61cd6e
commit
d41336c710
|
|
@ -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<HTMLDivElement>(),
|
||||
);
|
||||
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<HTMLDivElement>(),
|
||||
);
|
||||
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<HTMLDivElement>(),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<T extends HTMLElement>() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const ref = useRef<T | null>(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<C>();
|
||||
const listRef = useRef<L | null>(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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const listRef = useRef<HTMLUListElement | null>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -397,13 +351,16 @@ function TransactionRowsPreview({
|
|||
currency: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const [translateY, setTranslateY] = useState(0);
|
||||
const [maxTranslate, setMaxTranslate] = useState(0);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const listRef = useRef<HTMLUListElement | null>(null);
|
||||
const {
|
||||
containerRef,
|
||||
listRef,
|
||||
translateY,
|
||||
progress: scrollProgress,
|
||||
} = useScrollTranslate<HTMLDivElement, HTMLUListElement>({
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -569,36 +476,12 @@ function AccountsBalancePreview({
|
|||
currency: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { ref: containerRef, progress: scrollProgress } =
|
||||
useScrollProgress<HTMLDivElement>();
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -707,8 +590,8 @@ function ImportPreview({
|
|||
currency: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { ref: containerRef, progress: scrollProgress } =
|
||||
useScrollProgress<HTMLDivElement>();
|
||||
|
||||
// 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<HTMLDivElement | null>(null);
|
||||
const { ref: containerRef, progress: scrollProgress } =
|
||||
useScrollProgress<HTMLDivElement>();
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -962,32 +797,8 @@ const CASHFLOW_PREVIEW_DATA = [
|
|||
] as const;
|
||||
|
||||
function CashflowChartPreview() {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>();
|
||||
|
||||
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<HTMLDivElement | null>(null);
|
||||
const { ref: containerRef, progress: scrollProgress } =
|
||||
useScrollProgress<HTMLDivElement>();
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -1272,8 +1059,8 @@ function BudgetDetailPreview({
|
|||
currency: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { ref: containerRef, progress: scrollProgress } =
|
||||
useScrollProgress<HTMLDivElement>();
|
||||
|
||||
// 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<HTMLDivElement | null>(null);
|
||||
const { ref: containerRef, progress: scrollProgress } =
|
||||
useScrollProgress<HTMLDivElement>();
|
||||
|
||||
// "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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue