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,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']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user