feat(header): progreso de lectura y compactado al bajar
This commit is contained in:
1 parent
a2fa730b7b
commit
d447239542
4 files changed
+78
-4
No files matched your search
@@ -1,19 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useScroll, useMotionValueEvent } from 'framer-motion';
|
||||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||||
import { CTA } from '@/components/primitives/CTA';
|
import { CTA } from '@/components/primitives/CTA';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
|
import { ProgresoLectura } from '@/components/primitives/ProgresoLectura';
|
||||||
import { header } from '@/lib/content';
|
import { header } from '@/lib/content';
|
||||||
|
|
||||||
|
// The header tightens once the reader leaves the hero: less chrome, more page.
|
||||||
|
// Client component because the compaction needs scroll state on the <header>
|
||||||
|
// itself; there is no server-rendered data here to lose.
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
const [compacto, setCompacto] = useState(false);
|
||||||
|
const { scrollY } = useScroll();
|
||||||
|
|
||||||
|
useMotionValueEvent(scrollY, 'change', (y) => {
|
||||||
|
// Hysteresis: compact past 120px, expand back below 60px, so a header
|
||||||
|
// sitting exactly on the threshold doesn't flicker.
|
||||||
|
setCompacto((actual) => (actual ? y > 60 : y > 120));
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-b border-azul-profundo/10 bg-white/80 backdrop-blur">
|
<header
|
||||||
<Container className="flex items-center justify-between py-3">
|
className={`sticky top-0 z-50 relative border-b border-azul-profundo/10 bg-white/80 backdrop-blur transition-[padding] duration-300 motion-reduce:transition-none ${
|
||||||
<a href="#top" aria-label="creeer — inicio">
|
compacto ? 'py-0' : 'py-3'
|
||||||
<Wordmark size="md" />
|
}`}
|
||||||
|
>
|
||||||
|
<Container className="flex items-center justify-between">
|
||||||
|
<a href="#top" aria-label="creeer — inicio" className="block transition-transform duration-300 origin-left motion-reduce:transition-none">
|
||||||
|
<Wordmark size={compacto ? 'sm' : 'md'} />
|
||||||
</a>
|
</a>
|
||||||
<nav aria-label="Principal">
|
<nav aria-label="Principal">
|
||||||
<CTA href="#contacto" label={header.cta} variant="light" />
|
<CTA href="#contacto" label={header.cta} variant="light" />
|
||||||
</nav>
|
</nav>
|
||||||
</Container>
|
</Container>
|
||||||
|
<ProgresoLectura />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,4 +14,15 @@ describe('<Header />', () => {
|
|||||||
const cta = screen.getByRole('link', { name: /Hablemos/i });
|
const cta = screen.getByRole('link', { name: /Hablemos/i });
|
||||||
expect(cta).toHaveAttribute('href', '#contacto');
|
expect(cta).toHaveAttribute('href', '#contacto');
|
||||||
});
|
});
|
||||||
|
it('shows reading progress', () => {
|
||||||
|
const { container } = render(<Header />);
|
||||||
|
expect(container.querySelector('.bg-dorado')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts roomy, before any scrolling has happened', () => {
|
||||||
|
const { container } = render(<Header />);
|
||||||
|
// The header compacts on scroll; at rest it must be in its tall state, or
|
||||||
|
// the wordmark would land already shrunken on first paint.
|
||||||
|
expect(container.querySelector('header')?.className).toContain('py-3');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, useScroll, useSpring } from 'framer-motion';
|
||||||
|
import { usePrefersReducedMotion } from '@/lib/motion';
|
||||||
|
|
||||||
|
// Reading progress along the bottom edge of the header. Pure motion feedback:
|
||||||
|
// under reduced motion it renders nothing at all, because a static progress
|
||||||
|
// bar communicates nothing — unlike an illustration, there is no meaningful
|
||||||
|
// still frame of it.
|
||||||
|
export function ProgresoLectura({ className = '' }: { className?: string }) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const { scrollYProgress } = useScroll();
|
||||||
|
const avance = useSpring(scrollYProgress, { stiffness: 120, damping: 30, restDelta: 0.001 });
|
||||||
|
|
||||||
|
if (reduced) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`absolute bottom-0 left-0 right-0 h-0.5 origin-left bg-dorado ${className}`}
|
||||||
|
style={{ scaleX: avance }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ProgresoLectura } from '../ProgresoLectura';
|
||||||
|
|
||||||
|
describe('<ProgresoLectura />', () => {
|
||||||
|
it('is decorative: hidden from assistive tech', () => {
|
||||||
|
const { container } = render(<ProgresoLectura />);
|
||||||
|
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders as a gold rule anchored to the bottom edge', () => {
|
||||||
|
const { container } = render(<ProgresoLectura />);
|
||||||
|
const cls = container.firstElementChild?.className ?? '';
|
||||||
|
expect(cls).toContain('bg-dorado');
|
||||||
|
expect(cls).toContain('absolute');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user