feat(primitivas): capa gráfica y de movimiento reutilizable
Diez primitivas que dan a la landing el "gráfico y movimiento" que pidió la dueña, todas con guarda de prefers-reduced-motion: - FondoPuntos / PatronPuntos — el patrón de puntos del manual - TarjetaSpotlight, TextoDesenfoque, ContadorNumero — realce y reveal - Carrusel — deslizable, con contador, autoplay pausable - Icono, SimboloTresLineas, IlustracionInflexion — línea, sin relleno - FondoFoto — duotono azul/dorado sobre foto useAutoStep en lib/motion.ts comparte la forma del autoplay del carrusel para indicadores de paso que no son carrusel. IlustracionInflexion documenta un detalle que muerde: motion.path aplica su propio andamiaje de dasharray aunque no reciba initial/animate, así que la rama de reduced motion renderiza <path> planos en vez de intentar neutralizar los animados. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9758a92f9
commit
5b400cb161
19 files changed
+881
No files matched your search
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import useEmblaCarousel from 'embla-carousel-react';
|
||||||
|
import Autoplay from 'embla-carousel-autoplay';
|
||||||
|
import { usePrefersReducedMotion } from '@/lib/motion';
|
||||||
|
|
||||||
|
// Thin Embla wrapper shared by the hero's pain-point rotator and the mobile
|
||||||
|
// row of inflection points. Autoplay pauses on hover/focus/interaction, and
|
||||||
|
// never runs at all for users who asked for less motion — it's still a fully
|
||||||
|
// usable carousel (drag, dots, keyboard) with it off, just static.
|
||||||
|
export function Carrusel({
|
||||||
|
slides,
|
||||||
|
autoplayMs,
|
||||||
|
className = '',
|
||||||
|
slideClassName = '',
|
||||||
|
ariaLabel,
|
||||||
|
mostrarContador = false,
|
||||||
|
}: {
|
||||||
|
slides: ReactNode[];
|
||||||
|
autoplayMs?: number;
|
||||||
|
className?: string;
|
||||||
|
slideClassName?: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
// "01 / 06" + flechas prev/next, en vez de puntos — el patrón de
|
||||||
|
// en-ti.com ("desliza para explorar") para carruseles con más peso.
|
||||||
|
mostrarContador?: boolean;
|
||||||
|
}) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const plugins = autoplayMs && !reduced ? [Autoplay({ delay: autoplayMs, stopOnInteraction: false, stopOnMouseEnter: true })] : [];
|
||||||
|
const [emblaRef, embla] = useEmblaCarousel({ loop: true }, plugins);
|
||||||
|
const [selected, setSelected] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embla) return;
|
||||||
|
const onSelect = () => setSelected(embla.selectedScrollSnap());
|
||||||
|
embla.on('select', onSelect);
|
||||||
|
onSelect();
|
||||||
|
return () => {
|
||||||
|
embla.off('select', onSelect);
|
||||||
|
};
|
||||||
|
}, [embla]);
|
||||||
|
|
||||||
|
const scrollTo = useCallback((i: number) => embla?.scrollTo(i), [embla]);
|
||||||
|
const scrollPrev = useCallback(() => embla?.scrollPrev(), [embla]);
|
||||||
|
const scrollNext = useCallback(() => embla?.scrollNext(), [embla]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-w-0 ${className}`}>
|
||||||
|
<div className="overflow-hidden" ref={emblaRef} role="group" aria-label={ariaLabel}>
|
||||||
|
<div className="flex">
|
||||||
|
{slides.map((slide, i) => (
|
||||||
|
<div key={i} className={`min-w-0 shrink-0 grow-0 basis-full ${slideClassName}`}>
|
||||||
|
{slide}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{slides.length > 1 && mostrarContador && (
|
||||||
|
<div className="mt-6 flex items-center justify-center gap-5">
|
||||||
|
<button type="button" onClick={scrollPrev} aria-label="Anterior"
|
||||||
|
className="grid place-items-center w-9 h-9 rounded-full border border-azul-profundo/20 text-azul-profundo transition-colors hover:border-dorado hover:text-dorado">
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<p className="font-sans font-bold text-sm tabular-nums text-azul-profundo tracking-wider">
|
||||||
|
{String(selected + 1).padStart(2, '0')} <span className="text-gris-medio font-normal">/ {String(slides.length).padStart(2, '0')}</span>
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={scrollNext} aria-label="Siguiente"
|
||||||
|
className="grid place-items-center w-9 h-9 rounded-full border border-azul-profundo/20 text-azul-profundo transition-colors hover:border-dorado hover:text-dorado">
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slides.length > 1 && !mostrarContador && (
|
||||||
|
<div className="mt-5 flex justify-center gap-2">
|
||||||
|
{slides.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollTo(i)}
|
||||||
|
aria-label={`Ir a la tarjeta ${i + 1} de ${slides.length}`}
|
||||||
|
aria-current={selected === i}
|
||||||
|
className={`h-2 rounded-full transition-all duration-300 ${selected === i ? 'w-6 bg-dorado' : 'w-2 bg-gris-medio/50 hover:bg-gris-medio'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { animate } from 'framer-motion';
|
||||||
|
import { usePrefersReducedMotion } from '@/lib/motion';
|
||||||
|
|
||||||
|
// Counts up to `hasta` once scrolled into view. Only ever used with a number
|
||||||
|
// that's already written in the approved copy (e.g. "30+ años") — a visual
|
||||||
|
// echo of real content, never a stand-in for real metrics we don't have yet.
|
||||||
|
export function ContadorNumero({ hasta, sufijo = '', className = '' }: { hasta: number; sufijo?: string; className?: string }) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [display, setDisplay] = useState(reduced ? hasta : 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || reduced) return;
|
||||||
|
let controls: { stop: () => void } | undefined;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
observer.unobserve(el);
|
||||||
|
controls = animate(0, hasta, {
|
||||||
|
duration: 1.4,
|
||||||
|
ease: [0.22, 1, 0.36, 1],
|
||||||
|
onUpdate: (v) => setDisplay(Math.round(v)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
controls?.stop();
|
||||||
|
};
|
||||||
|
}, [reduced, hasta]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className={className}>
|
||||||
|
{display}
|
||||||
|
{sufijo}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Full-bleed photo background, tinted into the brand's navy/gold duotone via
|
||||||
|
// `mix-blend-mode: luminosity` — the gradient behind supplies the colour, the
|
||||||
|
// photo only contributes its tonal range. Keeps "blanco con azul" even when a
|
||||||
|
// photograph is on screen. Decorative only: aria-hidden, lazy-loaded.
|
||||||
|
export function FondoFoto({
|
||||||
|
src,
|
||||||
|
className = '',
|
||||||
|
imgClassName = '',
|
||||||
|
wash = 'bg-azul-profundo/70',
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
className?: string;
|
||||||
|
imgClassName?: string;
|
||||||
|
wash?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className={`absolute overflow-hidden pointer-events-none select-none ${className}`}>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-azul-profundo to-dorado" />
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- decorative background on a `output: export` site with images.unoptimized */}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover mix-blend-luminosity ${imgClassName}`}
|
||||||
|
/>
|
||||||
|
<div className={`absolute inset-0 ${wash}`} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { motion, useMotionValue, useScroll, useSpring, useTransform } from 'framer-motion';
|
||||||
|
import { PatronPuntos } from './PatronPuntos';
|
||||||
|
import { usePrefersReducedMotion } from '@/lib/motion';
|
||||||
|
|
||||||
|
// Decorative background: the manual's dot pattern, drifting slightly against
|
||||||
|
// the scroll. Purely presentational — hidden from assistive tech, and it holds
|
||||||
|
// still for anyone who asked for less movement.
|
||||||
|
export function FondoPuntos({
|
||||||
|
className = '',
|
||||||
|
distance = 40,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
interactivo = false,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
distance?: number;
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
|
// Además de derivar con el scroll, sigue el cursor levemente — reservado
|
||||||
|
// para los patrones más prominentes (el hero), no para cada instancia.
|
||||||
|
interactivo?: boolean;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'end start'] });
|
||||||
|
const scrollY = useTransform(scrollYProgress, [0, 1], [distance, -distance]);
|
||||||
|
|
||||||
|
const mouseXRaw = useMotionValue(0);
|
||||||
|
const mouseYRaw = useMotionValue(0);
|
||||||
|
const mouseX = useSpring(mouseXRaw, { stiffness: 40, damping: 15 });
|
||||||
|
const mouseY = useSpring(mouseYRaw, { stiffness: 40, damping: 15 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interactivo || reduced) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
mouseXRaw.set((e.clientX / window.innerWidth - 0.5) * 16);
|
||||||
|
mouseYRaw.set((e.clientY / window.innerHeight - 0.5) * 16);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', handler);
|
||||||
|
return () => window.removeEventListener('mousemove', handler);
|
||||||
|
}, [interactivo, reduced, mouseXRaw, mouseYRaw]);
|
||||||
|
|
||||||
|
const y = useTransform([scrollY, mouseY], ([a, b]) => (a as number) + (b as number));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} aria-hidden="true" className={`pointer-events-none absolute select-none ${className}`}>
|
||||||
|
<motion.div style={reduced ? undefined : { y, x: interactivo ? mouseX : undefined }}>
|
||||||
|
<PatronPuntos rows={rows} cols={cols} className="w-full h-auto" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// Set of simple line icons — one per concept used across Mecanismo,
|
||||||
|
// ComoEmpezar and Disciplinas. Same stroke weight and geometric language as
|
||||||
|
// the manual's own iconography guidance ("líneas simples, geométricas,
|
||||||
|
// consistentes en grosor"), hand-drawn to match each step's specific idea
|
||||||
|
// rather than generic stock glyphs.
|
||||||
|
|
||||||
|
export type IconoNombre =
|
||||||
|
| 'inmersion'
|
||||||
|
| 'desafiar'
|
||||||
|
| 'estrategia'
|
||||||
|
| 'escribeme'
|
||||||
|
| 'diagnostico'
|
||||||
|
| 'plan'
|
||||||
|
| 'comunicacion'
|
||||||
|
| 'digital'
|
||||||
|
| 'experiencias';
|
||||||
|
|
||||||
|
const PATHS: Record<IconoNombre, ReactNode> = {
|
||||||
|
// A line dives through two layers down to a core point — "me sumerjo
|
||||||
|
// hasta comprenderla desde dentro".
|
||||||
|
inmersion: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3v11" />
|
||||||
|
<path d="M7 8h10" />
|
||||||
|
<path d="M9 12h6" />
|
||||||
|
<path d="m8 14 4 4 4-4" />
|
||||||
|
<circle cx="12" cy="20.5" r="0.75" fill="currentColor" stroke="none" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// A circle (the assumption) cracked by a bolt — "cuestiono lo que todos
|
||||||
|
// dan por hecho".
|
||||||
|
desafiar: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="8.5" />
|
||||||
|
<path d="m13 6-4 6h4l-2 6 5-7.5h-4z" fill="currentColor" stroke="none" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// A path rising to a flag — "una estrategia que puedas ejecutar".
|
||||||
|
estrategia: (
|
||||||
|
<>
|
||||||
|
<path d="M4 20 10 12 14 15 20 6" strokeDasharray="1.5 3" />
|
||||||
|
<path d="M20 6v6" />
|
||||||
|
<path d="M20 6h-5l2.5-3.5L20 6" fill="currentColor" stroke="none" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// A speech bubble mid-thought — "cuéntame dónde está tu negocio hoy".
|
||||||
|
escribeme: (
|
||||||
|
<>
|
||||||
|
<path d="M4 5.5h16v10H10l-4 3.5v-3.5H4z" />
|
||||||
|
<path d="M8 9.5h8" />
|
||||||
|
<path d="M8 12.5h5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// A magnifying glass over a short list — "una conversación para ubicar tu
|
||||||
|
// punto de inflexión".
|
||||||
|
diagnostico: (
|
||||||
|
<>
|
||||||
|
<path d="M5 4h9v9.5" />
|
||||||
|
<path d="M8 8h5" />
|
||||||
|
<path d="M8 11h2.5" />
|
||||||
|
<circle cx="15.5" cy="15.5" r="4" />
|
||||||
|
<path d="m18.6 18.6 2.4 2.4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// A short checklist — "sales con claridad y tu siguiente movimiento
|
||||||
|
// definido".
|
||||||
|
plan: (
|
||||||
|
<>
|
||||||
|
<rect x="5" y="3.5" width="14" height="17" rx="1.5" />
|
||||||
|
<path d="m8.3 8.5 1.2 1.2L11.7 7.5" />
|
||||||
|
<path d="M14 8.5h5" />
|
||||||
|
<path d="m8.3 14 1.2 1.2 2.2-2.2" />
|
||||||
|
<path d="M14 14h5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// Two overlapping bubbles — mensaje que alinea a tu equipo y tu mercado.
|
||||||
|
comunicacion: (
|
||||||
|
<>
|
||||||
|
<path d="M3 6.5h11v7.5H9l-3 2.5v-2.5H3z" />
|
||||||
|
<path d="M12 6.5A2.5 2.5 0 0 1 14.5 4H20v6.5h-3v2.5l-2.7-2.5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// A screen with a signal — presencia y procesos que escalan.
|
||||||
|
digital: (
|
||||||
|
<>
|
||||||
|
<rect x="3.5" y="5" width="17" height="12" rx="1.2" />
|
||||||
|
<path d="M9 20.5h6" />
|
||||||
|
<path d="M12 17v3.5" />
|
||||||
|
<path d="M8.5 12.5a3.6 3.6 0 0 1 7 0" strokeDasharray="0.1 3" />
|
||||||
|
<circle cx="12" cy="12.5" r="0.9" fill="currentColor" stroke="none" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
// A ripple from a single touch — cada toque con tu marca construye
|
||||||
|
// confianza.
|
||||||
|
experiencias: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<circle cx="12" cy="12" r="9" strokeOpacity={0.55} />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Icono({ nombre, className = '' }: { nombre: IconoNombre; className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.6}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{PATHS[nombre]}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { usePrefersReducedMotion } from '@/lib/motion';
|
||||||
|
|
||||||
|
// A literal small illustration of the section's own idea: a business grows,
|
||||||
|
// hits its inflection point (the dot), and from there either drifts flat
|
||||||
|
// without direction (grey, dashed) or keeps climbing with a clear strategy
|
||||||
|
// (dorado). Draws itself in once scrolled into view; a decorative aside, not
|
||||||
|
// data, so it stays purely presentational.
|
||||||
|
export function IlustracionInflexion({ className = '' }: { className?: string }) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const ref = useRef<SVGSVGElement>(null);
|
||||||
|
const [inView, setInView] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || reduced) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setInView(true);
|
||||||
|
observer.unobserve(el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.4 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [reduced]);
|
||||||
|
|
||||||
|
// `motion.path` applies its own dash-based pathLength scaffolding even with
|
||||||
|
// no initial/animate props, which left the lines invisible for
|
||||||
|
// reduced-motion users — so that branch renders plain, non-motion <path>s
|
||||||
|
// instead of trying to neutralise the animated ones.
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 320 180" className={className} aria-hidden="true" focusable="false">
|
||||||
|
<path d="M20 140 C 70 150, 110 40, 165 60" fill="none" stroke="currentColor" className="text-azul-profundo" strokeWidth={3} strokeLinecap="round" />
|
||||||
|
<path d="M165 60 C 210 76, 250 92, 300 88" fill="none" stroke="currentColor" className="text-gris-medio" strokeWidth={2.5} strokeLinecap="round" strokeDasharray="1 9" />
|
||||||
|
<path d="M165 60 C 205 48, 245 8, 300 14" fill="none" stroke="currentColor" className="text-dorado" strokeWidth={3} strokeLinecap="round" />
|
||||||
|
<circle cx="165" cy="60" r="6" fill="currentColor" className="text-dorado" />
|
||||||
|
<circle cx="165" cy="60" r="10" fill="none" stroke="currentColor" className="text-dorado" strokeWidth={1} strokeOpacity={0.4} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = (delay: number) => ({
|
||||||
|
initial: { pathLength: 0 },
|
||||||
|
animate: inView ? { pathLength: 1 } : { pathLength: 0 },
|
||||||
|
transition: { duration: 1.1, delay, ease: [0.22, 1, 0.36, 1] as const },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg ref={ref} viewBox="0 0 320 180" className={className} aria-hidden="true" focusable="false">
|
||||||
|
<motion.path
|
||||||
|
d="M20 140 C 70 150, 110 40, 165 60"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-azul-profundo"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
{...draw(0)}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M165 60 C 210 76, 250 92, 300 88"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-gris-medio"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray="1 9"
|
||||||
|
{...draw(0.5)}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M165 60 C 205 48, 245 8, 300 14"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="text-dorado"
|
||||||
|
strokeWidth={3}
|
||||||
|
strokeLinecap="round"
|
||||||
|
{...draw(0.5)}
|
||||||
|
/>
|
||||||
|
<circle cx="165" cy="60" r="6" fill="currentColor" className="text-dorado" />
|
||||||
|
<circle cx="165" cy="60" r="10" fill="none" stroke="currentColor" className="text-dorado" strokeWidth={1} strokeOpacity={0.4} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// "Patrón de puntos" from the Manual de Identidad Visual (recursos visuales).
|
||||||
|
// Geometry measured off the manual: the dots shrink and tighten from left to
|
||||||
|
// right, resolving complexity into focus — the same idea as the ≡ symbol.
|
||||||
|
// Colour comes from `currentColor`, so callers set it with a text-* class.
|
||||||
|
|
||||||
|
const R0 = 5.5; // radius of the leftmost dot
|
||||||
|
const R_DECAY = 0.847; // per column
|
||||||
|
const S0 = 29; // gap between the first two columns
|
||||||
|
const S_DECAY = 0.97; // per column
|
||||||
|
const ROW_GAP = 24.5;
|
||||||
|
|
||||||
|
export function PatronPuntos({
|
||||||
|
rows = 3,
|
||||||
|
cols = 8,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
rows?: number;
|
||||||
|
cols?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const columns: { cx: number; r: number }[] = [];
|
||||||
|
let cx = R0;
|
||||||
|
for (let i = 0; i < cols; i++) {
|
||||||
|
const r = R0 * R_DECAY ** i;
|
||||||
|
columns.push({ cx, r });
|
||||||
|
cx += S0 * S_DECAY ** i;
|
||||||
|
}
|
||||||
|
const last = columns[columns.length - 1];
|
||||||
|
const width = last.cx + last.r;
|
||||||
|
const height = (rows - 1) * ROW_GAP + 2 * R0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width.toFixed(1)} ${height.toFixed(1)}`}
|
||||||
|
className={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
{Array.from({ length: rows }, (_, row) =>
|
||||||
|
columns.map(({ cx: x, r }, col) => (
|
||||||
|
<circle key={`${row}-${col}`} cx={x.toFixed(2)} cy={(R0 + row * ROW_GAP).toFixed(2)} r={r.toFixed(2)} fill="currentColor" />
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
// Adapted from React Bits' SpotlightCard (reactbits.dev, MIT) — a soft glow
|
||||||
|
// that follows the cursor, recoloured to the brand's dorado instead of the
|
||||||
|
// original's plain white. Pure CSS custom properties + a `before:` pseudo
|
||||||
|
// element, so no separate stylesheet is needed.
|
||||||
|
export function TarjetaSpotlight({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
spotlightColor = 'rgba(196, 148, 46, 0.16)', // dorado, low alpha
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
spotlightColor?: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
el.style.setProperty('--spot-x', `${e.clientX - rect.left}px`);
|
||||||
|
el.style.setProperty('--spot-y', `${e.clientY - rect.top}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
style={{ ['--spot-color' as string]: spotlightColor }}
|
||||||
|
className={`group relative overflow-hidden
|
||||||
|
before:absolute before:inset-0 before:pointer-events-none before:opacity-0 before:transition-opacity before:duration-500
|
||||||
|
before:[background:radial-gradient(circle_at_var(--spot-x,50%)_var(--spot-y,50%),var(--spot-color),transparent_70%)]
|
||||||
|
hover:before:opacity-100 focus-within:before:opacity-100 motion-reduce:before:hidden
|
||||||
|
${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useEffect, useMemo, useRef, useState, type ElementType } from 'react';
|
||||||
|
import { usePrefersReducedMotion } from '@/lib/motion';
|
||||||
|
|
||||||
|
// Adapted from React Bits' BlurText (reactbits.dev, MIT) — generalised to take
|
||||||
|
// several independently-styled segments instead of one plain string, so a
|
||||||
|
// gold/underlined word can blur in beside plain navy text in the same headline.
|
||||||
|
type Segmento = { text: string; className?: string };
|
||||||
|
|
||||||
|
const FROM = { filter: 'blur(10px)', opacity: 0, y: 14 };
|
||||||
|
const KEYFRAMES = {
|
||||||
|
filter: ['blur(10px)', 'blur(4px)', 'blur(0px)'],
|
||||||
|
opacity: [0, 0.6, 1],
|
||||||
|
y: [14, 4, 0],
|
||||||
|
};
|
||||||
|
const TIMES = [0, 0.5, 1];
|
||||||
|
|
||||||
|
export function TextoDesenfoque({
|
||||||
|
segments,
|
||||||
|
delayMs = 45,
|
||||||
|
stepDuration = 0.35,
|
||||||
|
className = '',
|
||||||
|
as: Tag = 'span',
|
||||||
|
}: {
|
||||||
|
segments: Segmento[];
|
||||||
|
delayMs?: number;
|
||||||
|
stepDuration?: number;
|
||||||
|
className?: string;
|
||||||
|
as?: ElementType;
|
||||||
|
}) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const [inView, setInView] = useState(false);
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || reduced) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setInView(true);
|
||||||
|
observer.unobserve(el);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.4 },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [reduced]);
|
||||||
|
|
||||||
|
const words = useMemo(
|
||||||
|
() => segments.flatMap((seg, si) => seg.text.split(' ').map((w, wi) => ({ w, si, wi, className: seg.className }))),
|
||||||
|
[segments],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reduced) {
|
||||||
|
return (
|
||||||
|
<Tag ref={ref} className={className}>
|
||||||
|
{segments.map((seg, i) => (
|
||||||
|
<span key={i} className={seg.className}>
|
||||||
|
{seg.text}
|
||||||
|
{i < segments.length - 1 ? ' ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag ref={ref} className={className} style={{ display: 'inline' }}>
|
||||||
|
{words.map(({ w, si, wi, className: segClass }, i) => (
|
||||||
|
// The joining space is a sibling text node, not the span's own trailing
|
||||||
|
// character — a space at the very end of an inline-block's own content
|
||||||
|
// collapses to zero width, which silently glued every word together.
|
||||||
|
<span key={`${si}-${wi}`}>
|
||||||
|
<motion.span
|
||||||
|
className={`inline-block will-change-[transform,filter,opacity] ${segClass ?? ''}`}
|
||||||
|
initial={FROM}
|
||||||
|
animate={inView ? KEYFRAMES : FROM}
|
||||||
|
transition={{ duration: stepDuration * 2, times: TIMES, delay: (i * delayMs) / 1000 }}
|
||||||
|
>
|
||||||
|
{w}
|
||||||
|
</motion.span>
|
||||||
|
{i < words.length - 1 ? ' ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Carrusel } from '../Carrusel';
|
||||||
|
|
||||||
|
describe('<Carrusel />', () => {
|
||||||
|
it('renders every slide', () => {
|
||||||
|
render(<Carrusel ariaLabel="test" slides={[<p key="a">Uno</p>, <p key="b">Dos</p>, <p key="c">Tres</p>]} />);
|
||||||
|
expect(screen.getByText('Uno')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Dos')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tres')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('renders one position dot per slide, and marks the current one', () => {
|
||||||
|
render(<Carrusel ariaLabel="test" slides={[<p key="a">Uno</p>, <p key="b">Dos</p>]} />);
|
||||||
|
const dots = screen.getAllByRole('button', { name: /ir a la tarjeta/i });
|
||||||
|
expect(dots).toHaveLength(2);
|
||||||
|
expect(dots[0]).toHaveAttribute('aria-current', 'true');
|
||||||
|
});
|
||||||
|
it('moves to a slide when its dot is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Carrusel ariaLabel="test" slides={[<p key="a">Uno</p>, <p key="b">Dos</p>]} />);
|
||||||
|
const dots = screen.getAllByRole('button', { name: /ir a la tarjeta/i });
|
||||||
|
await user.click(dots[1]);
|
||||||
|
expect(dots[1]).toHaveAttribute('aria-current', 'true');
|
||||||
|
});
|
||||||
|
it('hides the dots entirely for a single slide', () => {
|
||||||
|
render(<Carrusel ariaLabel="test" slides={[<p key="a">Solo una</p>]} />);
|
||||||
|
expect(screen.queryByRole('button', { name: /ir a la tarjeta/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('exposes the slide group with the given accessible name', () => {
|
||||||
|
render(<Carrusel ariaLabel="Frases de ejemplo" slides={[<p key="a">Uno</p>]} />);
|
||||||
|
expect(screen.getByRole('group', { name: 'Frases de ejemplo' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('shows a numbered counter with prev/next arrows instead of dots when mostrarContador is set', () => {
|
||||||
|
render(<Carrusel ariaLabel="test" mostrarContador slides={[<p key="a">Uno</p>, <p key="b">Dos</p>, <p key="c">Tres</p>]} />);
|
||||||
|
expect(screen.getByText('01')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/\/ 03/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Siguiente' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Anterior' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /ir a la tarjeta/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('advances the counter when the next arrow is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<Carrusel ariaLabel="test" mostrarContador slides={[<p key="a">Uno</p>, <p key="b">Dos</p>]} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Siguiente' }));
|
||||||
|
expect(screen.getByText('02')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ContadorNumero } from '../ContadorNumero';
|
||||||
|
|
||||||
|
describe('<ContadorNumero />', () => {
|
||||||
|
it('starts at 0 and only counts up once scrolled into view', () => {
|
||||||
|
const { container } = render(<ContadorNumero hasta={30} />);
|
||||||
|
expect(container.textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends the given suffix', () => {
|
||||||
|
const { container } = render(<ContadorNumero hasta={30} sufijo="+" />);
|
||||||
|
expect(container.textContent).toBe('0+');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the given className to the counter span', () => {
|
||||||
|
const { container } = render(<ContadorNumero hasta={30} className="text-dorado" />);
|
||||||
|
expect(container.querySelector('span')).toHaveClass('text-dorado');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { FondoFoto } from '../FondoFoto';
|
||||||
|
|
||||||
|
describe('<FondoFoto />', () => {
|
||||||
|
it('is decorative: hidden from assistive tech, empty alt text', () => {
|
||||||
|
const { container } = render(<FondoFoto src="/images/test.jpg" />);
|
||||||
|
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
expect(container.querySelector('img')).toHaveAttribute('alt', '');
|
||||||
|
});
|
||||||
|
it('lazy-loads the image', () => {
|
||||||
|
const { container } = render(<FondoFoto src="/images/test.jpg" />);
|
||||||
|
expect(container.querySelector('img')).toHaveAttribute('loading', 'lazy');
|
||||||
|
});
|
||||||
|
it('tints the photo into the brand duotone via mix-blend-mode, not raw colour', () => {
|
||||||
|
const { container } = render(<FondoFoto src="/images/test.jpg" />);
|
||||||
|
expect(container.querySelector('img')?.className).toContain('mix-blend-luminosity');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { FondoPuntos } from '../FondoPuntos';
|
||||||
|
|
||||||
|
describe('<FondoPuntos />', () => {
|
||||||
|
it('is decorative: hidden from assistive tech', () => {
|
||||||
|
const { container } = render(<FondoPuntos />);
|
||||||
|
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the dot pattern', () => {
|
||||||
|
const { container } = render(<FondoPuntos />);
|
||||||
|
expect(container.querySelectorAll('circle').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the cursor moves, with or without interactivo', () => {
|
||||||
|
const { container } = render(<FondoPuntos interactivo />);
|
||||||
|
expect(() => fireEvent.mouseMove(window, { clientX: 100, clientY: 100 })).not.toThrow();
|
||||||
|
expect(container.querySelectorAll('circle').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Icono, type IconoNombre } from '../Icono';
|
||||||
|
|
||||||
|
const NOMBRES: IconoNombre[] = [
|
||||||
|
'inmersion',
|
||||||
|
'desafiar',
|
||||||
|
'estrategia',
|
||||||
|
'escribeme',
|
||||||
|
'diagnostico',
|
||||||
|
'plan',
|
||||||
|
'comunicacion',
|
||||||
|
'digital',
|
||||||
|
'experiencias',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('<Icono />', () => {
|
||||||
|
it('is decorative: hidden from assistive tech', () => {
|
||||||
|
const { container } = render(<Icono nombre="inmersion" />);
|
||||||
|
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(NOMBRES)('renders a non-empty svg for "%s"', (nombre) => {
|
||||||
|
const { container } = render(<Icono nombre={nombre} />);
|
||||||
|
const svg = container.querySelector('svg');
|
||||||
|
expect(svg?.children.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes its colour from currentColor via className, not a hardcoded fill', () => {
|
||||||
|
const { container } = render(<Icono nombre="plan" className="text-dorado" />);
|
||||||
|
expect(container.querySelector('svg')).toHaveAttribute('stroke', 'currentColor');
|
||||||
|
expect(container.querySelector('svg')).toHaveClass('text-dorado');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { IlustracionInflexion } from '../IlustracionInflexion';
|
||||||
|
|
||||||
|
describe('<IlustracionInflexion />', () => {
|
||||||
|
it('is decorative: hidden from assistive tech', () => {
|
||||||
|
const { container } = render(<IlustracionInflexion />);
|
||||||
|
expect(container.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the growth line, the drift-away line and the strategy line', () => {
|
||||||
|
const { container } = render(<IlustracionInflexion />);
|
||||||
|
expect(container.querySelectorAll('path')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the inflection point itself', () => {
|
||||||
|
const { container } = render(<IlustracionInflexion />);
|
||||||
|
expect(container.querySelectorAll('circle').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { PatronPuntos } from '../PatronPuntos';
|
||||||
|
|
||||||
|
describe('<PatronPuntos />', () => {
|
||||||
|
it('draws rows x cols dots', () => {
|
||||||
|
const { container } = render(<PatronPuntos rows={3} cols={8} />);
|
||||||
|
expect(container.querySelectorAll('circle')).toHaveLength(24);
|
||||||
|
});
|
||||||
|
it('shrinks the dots left to right, as the manual does', () => {
|
||||||
|
const { container } = render(<PatronPuntos rows={1} cols={6} />);
|
||||||
|
const radii = Array.from(container.querySelectorAll('circle')).map((c) => Number(c.getAttribute('r')));
|
||||||
|
for (let i = 1; i < radii.length; i++) expect(radii[i]).toBeLessThan(radii[i - 1]);
|
||||||
|
});
|
||||||
|
it('is decorative, so it stays out of the accessibility tree', () => {
|
||||||
|
const { container } = render(<PatronPuntos />);
|
||||||
|
const svg = container.querySelector('svg');
|
||||||
|
expect(svg?.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
});
|
||||||
|
it('takes its colour from currentColor so it works on light and dark', () => {
|
||||||
|
const { container } = render(<PatronPuntos rows={1} cols={2} />);
|
||||||
|
for (const c of Array.from(container.querySelectorAll('circle'))) {
|
||||||
|
expect(c.getAttribute('fill')).toBe('currentColor');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TarjetaSpotlight } from '../TarjetaSpotlight';
|
||||||
|
|
||||||
|
describe('<TarjetaSpotlight />', () => {
|
||||||
|
it('renders its children', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<TarjetaSpotlight>
|
||||||
|
<p>Contenido de la tarjeta</p>
|
||||||
|
</TarjetaSpotlight>,
|
||||||
|
);
|
||||||
|
expect(getByText('Contenido de la tarjeta')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the glow for reduced-motion users via a motion-reduce guard', () => {
|
||||||
|
const { container } = render(<TarjetaSpotlight>hola</TarjetaSpotlight>);
|
||||||
|
expect(container.firstElementChild?.className).toContain('motion-reduce:before:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks the cursor position as CSS custom properties on mouse move', () => {
|
||||||
|
const { container } = render(<TarjetaSpotlight>hola</TarjetaSpotlight>);
|
||||||
|
const card = container.firstElementChild as HTMLDivElement;
|
||||||
|
fireEvent.mouseMove(card, { clientX: 40, clientY: 20 });
|
||||||
|
expect(card.style.getPropertyValue('--spot-x')).toMatch(/px$/);
|
||||||
|
expect(card.style.getPropertyValue('--spot-y')).toMatch(/px$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a custom spotlight colour', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TarjetaSpotlight spotlightColor="rgba(0,0,0,0.5)">hola</TarjetaSpotlight>,
|
||||||
|
);
|
||||||
|
const card = container.firstElementChild as HTMLDivElement;
|
||||||
|
expect(card.style.getPropertyValue('--spot-color')).toBe('rgba(0,0,0,0.5)');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { TextoDesenfoque } from '../TextoDesenfoque';
|
||||||
|
|
||||||
|
describe('<TextoDesenfoque />', () => {
|
||||||
|
it('renders the full phrase across all segments', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextoDesenfoque
|
||||||
|
segments={[{ text: 'Tu negocio no necesita' }, { text: 'más esfuerzo.', className: 'text-dorado' }]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.textContent).toBe('Tu negocio no necesita más esfuerzo.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins words with a plain space, not a stray non-breaking space', () => {
|
||||||
|
const { container } = render(<TextoDesenfoque segments={[{ text: 'Uno dos tres' }]} />);
|
||||||
|
expect(container.textContent).toBe('Uno dos tres');
|
||||||
|
expect(container.textContent).not.toMatch(/ /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies each segment\'s className to its own words only', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextoDesenfoque segments={[{ text: 'texto plano' }, { text: 'texto dorado', className: 'text-dorado' }]} />,
|
||||||
|
);
|
||||||
|
const goldWords = Array.from(container.querySelectorAll('.text-dorado')).map((el) => el.textContent?.trim());
|
||||||
|
expect(goldWords).toEqual(['texto', 'dorado']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,3 +35,25 @@ export const motionProps = (reduced: boolean) =>
|
|||||||
reduced
|
reduced
|
||||||
? { initial: 'visible' as const, animate: 'visible' as const, whileInView: undefined, viewport: undefined }
|
? { initial: 'visible' as const, animate: 'visible' as const, whileInView: undefined, viewport: undefined }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
// Cycles through `count` steps on its own (Mecanismo, ComoEmpezar): each
|
||||||
|
// manual change restarts the dwell timer, hovering pauses it, and it never
|
||||||
|
// advances at all for reduced-motion users — same shape as the Carrusel
|
||||||
|
// autoplay, for step indicators that aren't a carousel.
|
||||||
|
export function useAutoStep(count: number, intervalMs = 3500) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reduced || paused || count <= 1) return;
|
||||||
|
const id = setTimeout(() => setActive((a) => (a + 1) % count), intervalMs);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [active, reduced, paused, count, intervalMs]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
setActive,
|
||||||
|
hoverProps: { onMouseEnter: () => setPaused(true), onMouseLeave: () => setPaused(false) },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user