Bienvenido al código fuente del Design System Stack-and-Flow. Seguimos estrictamente Atomic Design combinado con el patrón Container/Presentational. Respetar estas guías es OBLIGATORIO para cualquier PR.
La UI se divide en tres niveles de complejidad:
- Atoms: Los bloques básicos de construcción (ej:
Button,Badge,Input). No dependen de otros componentes del sistema, salvo funciones utilitarias. - Molecules: Grupos de átomos combinados para formar una unidad funcional (ej:
Modalgeneralmente usa botones, tipografía, etc.). - Organisms: Componentes de UI complejos que forman secciones diferenciadas de una interfaz (ej: un
Headercompuesto por logo, molécula de búsqueda y átomos de navegación).
Cada componente DEBE separarse en lógica (Container) y renderizado (Presentational). Lo logramos mediante custom hooks y archivos .tsx.
Cada componente DEBE vivir dentro de un directorio en kebab-case (src/components/atoms/button/) y contener EXACTAMENTE estos seis archivos:
| Archivo | Propósito | Regla |
|---|---|---|
types.ts |
Tipos y Variantes | Define props con type, JSDoc controls y variantes cva. |
useButton.ts |
Hook Container | Contiene TODA la lógica, estado, refs, handlers y consumo de clases cva. |
Button.tsx |
Componente Presentacional | SOLO JSX. Consume el hook. Sin lógica, estado ni CVA. |
Button.test.tsx |
Tests | Cubre hook y comportamiento del componente. |
Button.stories.tsx |
Documentación | Contiene Storybook autodocs, args, JSDoc encima de const meta y JSDoc encima de cada story. |
index.ts |
API Pública | Re-exporta el componente y los tipos. |
Todas las variantes cva van aquí, nunca en el hook ni en el componente.
Usa comentarios JSDoc para generar automáticamente los controles de Storybook.
import { cva, type VariantProps } from "class-variance-authority";
export const buttonVariants = cva(
["flex items-center justify-center font-secondary-bold"],
{
variants: {
variant: {
primary: "bg-secondary text-text-dark",
ghost: "bg-transparent text-text-light",
},
},
defaultVariants: {
variant: "primary",
},
},
);
export type ButtonProps = {
/**
* @control select
* @default primary
*/
variant?: VariantProps<typeof buttonVariants>["variant"];
className?: string;
disabled?: boolean;
};El hook retorna todo lo que el elemento necesita: clases CSS, manejadores de eventos, props mapeadas y atributos aria.
import { buttonVariants, type ButtonProps } from "./types";
export const useButton = ({
variant = "primary",
className,
disabled = false,
...props
}: ButtonProps) => {
// Logic here (refs, state, effects)
const buttonClass = buttonVariants({ variant, className });
return {
buttonClass,
disabled,
...props,
};
};Solo desestructura lo que necesita del hook y lo renderiza.
import type { FC, ComponentProps } from "react";
import type { ButtonProps } from "./types";
import { useButton } from "./useButton";
const Button: FC<ButtonProps & ComponentProps<"button">> = ({ ...props }) => {
const { buttonClass, disabled, children } = useButton(props);
return (
<button className={buttonClass} disabled={disabled} {...props}>
{children}
</button>
);
};
export { Button };export { Button } from "./Button";
export type * from "./types";typesobreinterface: USA SIEMPREexport type ComponentProps = {}. NO usesinterface.- Sin
any: El uso explícito deanyestá estrictamente prohibido. Si no conoces el tipo, usaunknowno reduce el tipo correctamente. - Props explícitas: Nunca tipifiques props implícitamente. Todo DEBE estar definido explícitamente en
types.ts. - Definición de componente: Usa
FC<ComponentProps>y exports nombrados — nunca default exports para componentes.
- Solo en inglés: Toda la documentación de Storybook debe escribirse en inglés, incluidos headings, descripciones, comentarios, nombres de stories y labels.
- Controles obligatorios: Usa comentarios JSDoc (
/** @control text */) entypes.tspara activar los controles. - Descripción obligatoria: Cada story de componente DEBE incluir un bloque JSDoc encima de
const metacon headings en inglés:/** * ## Description * A versatile button component used to trigger actions. * * ## Dependencies * Uses `Icon` when an icon slot is provided. * * ## Usage Guide * Use this component for primary actions. Avoid using it for navigation. */ const meta: Meta<typeof Button> = { title: "Atoms/Button", component: Button, parameters: { docs: { autodocs: true, }, }, tags: ["autodocs"], };
- Sin
parameters.docs.description.component: la documentación del componente vive en el bloque JSDoc encima deconst meta. - Stories documentadas: añade un bloque JSDoc útil encima de cada
export const StoryName; debe explicar el escenario y por qué importa, no solo repetir el nombre de la story. - Sin stories redundantes: cada story debe demostrar un estado, eje de variante, restricción de composición, comportamiento de accesibilidad o contexto de integración distinto.
- Sin story genérica
DarkMode: usa el toolbar dark-mode de Storybook para cobertura normal de tema; las stories dark-mode dedicadas quedan solo para scope local, herencia en portales o regresiones de tema que el toolbar no pueda expresar. - Args: Define
argspor defecto para la story base sin pisardefaultVariants.
Usamos Tailwind v4 con configuraciones @theme definidas en src/styles/theme.css.
-
Fuente de verdad: usa únicamente prefijos nativos de Tailwind:
sm,md,lg,xl,2xl. -
Prohibido introducir o reutilizar aliases custom de breakpoints (por ejemplo
tablet,desktop). -
Si un ajuste responsive requiere un corte intermedio y no hay evidencia fuerte en contra, usa
mdcomo punto de partida conservador. -
OBLIGATORIO: DEBES usar las propiedades CSS personalizadas del design system (tokens) mediante clases de Tailwind.
-
SIN HARDCODING: Nunca escribas colores en duro (ej:
#FF0000) ni valores arbitrarios en estilos inline. Las clases Tailwind arbitrarias de tamaño/typography se permiten solo dentro de CVA entypes.tspara variantes compactas/densas explícitamente aprobadas; colores arbitrarios siguen prohibidos. -
Usa las clases predefinidas:
text-text-dark,bg-secondary,gap-sm,fs-h1, etc.
La accesibilidad es una funcionalidad principal, no un agregado posterior.
- Atributos ARIA: Los elementos interactivos DEBEN tener los atributos ARIA apropiados (
aria-expanded,aria-pressed,aria-hidden, etc.). aria-labeldinámico: No escribasaria-labelen duro. Exponlo como prop para que los consumidores puedan personalizarlo para traducciones o contexto.- Roles: Define
roleexplícitamente cuando la semántica lo requiera (ej:role="status"en Badge,role="switch"en botones con toggle). - Navegación por teclado: Asegúrate de que los elementos sean enfocables y muestren el foco visualmente (
focus-visible). Nuestros estilos globales gestionan los anillos de foco de forma nativa.
- Directorios:
kebab-case(ej:src/components/atoms/date-picker/). - Componentes y archivos:
PascalCase(ej:DatePicker.tsx,DatePicker.stories.tsx). - Hooks:
camelCasecon prefijouse(ej:useDatePicker.ts). - Tipos:
PascalCase(ej:DatePickerProps). - Variantes CVA:
camelCasecon sufijoVariants(ej:datePickerVariants).
Cada componente del design system DEBE tener un archivo de test correspondiente que siga estas convenciones.
Seguimos la separación Container/Presentational también en los tests:
| Capa | Herramienta | Qué testear |
|---|---|---|
useComponentName.ts (Hook) |
renderHook |
Lógica pura: valores por defecto, props computadas, manejadores de eventos, forma del retorno |
ComponentName.tsx (Componente) |
render + screen + userEvent |
Comportamiento observable: accesibilidad, estado deshabilitado, loading, manejo de clicks |
Nunca testees detalles de implementación: NO hagas assertions sobre cadenas de clases CSS, valores internos de refs, ni qué string de variante se aplica al DOM. Testea lo que un usuario real (o un lector de pantalla) observaría.
Coloca el archivo de test junto al componente — no en un directorio __tests__ separado:
src/components/atoms/button/
Button.tsx
useButton.ts
types.ts
index.ts
Button.stories.tsx
Button.test.tsx ← aquí
Cada archivo de test de componente DEBE cubrir:
- Estado por defecto — el componente renderiza sin props requeridas
- Prop
disabled— el elemento está deshabilitado cuandodisabled={true} - Prop
isLoading— el elemento está deshabilitado cuandoisLoading={true}(aunquedisabled={false}) - Manejador
onClick— se llama cuando es interactivo y no está cargando; NO se llama cuando está cargando aria-label— el nombre accesible se aplica correctamente- Valores por defecto del hook —
disabled: falseeisLoading: falseson retornados por defecto
Mockeá solo los paquetes que el componente importa. lucide-react/dynamic.js y spinners-react pueden romper jsdom por módulos dinámicos o animaciones CSS, pero el mock debe existir únicamente cuando el componente usa ese paquete:
vi.mock("lucide-react/dynamic.js", () => ({
DynamicIcon: () => null,
}));
// Solo si el componente importa spinners-react
vi.mock("spinners-react", () => ({
SpinnerCircular: () => null,
}));Si un componente renderiza un spinner de loading, debe usar SpinnerCircular de spinners-react, siguiendo Button como referencia. No implementes spinners CSS locales por componente.
También mockea cualquier archivo CSS importado directamente desde el componente:
vi.mock("@/components/utils/styles/index.css", () => ({}));Test de hook — usa renderHook:
import { renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useButton } from "./useButton";
describe("useButton — logic", () => {
it("returns disabled: false by default", () => {
const { result } = renderHook(() => useButton({}));
expect(result.current.disabled).toBe(false);
});
it("returns the correct variant when variant: ghost is passed", () => {
const { result } = renderHook(() => useButton({ variant: "ghost" }));
expect(result.current.variant).toBe("ghost");
});
});Test de componente — usa render + screen + userEvent:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Button } from './Button';
describe('Button — component behavior', () => {
it('is disabled when isLoading is true', () => {
render(<Button text="Loading" isLoading disabled={false} />);
expect(screen.getByRole('button', { name: 'Loading' })).toBeDisabled();
});
it('does NOT call onClick when isLoading is true', async () => {
const handleClick = vi.fn();
render(<Button text="Saving" isLoading onClick={handleClick} />);
await userEvent.click(screen.getByRole('button', { name: 'Saving' }));
expect(handleClick).not.toHaveBeenCalled();
});
});pnpm run test # ejecución única
pnpm run test:watch # modo watch
pnpm run test:coverage # con reporte de coberturaLas siguientes prácticas resultan en rechazo del PR:
- NO combinar lógica Presentacional y Container en el mismo archivo
.tsx. - NO poner
cvadentro del archivo.tsxouseHook.ts(pertenece atypes.ts). - NO usar
export interfaceen TypeScript. - NO valores arbitrarios de color en Tailwind (
text-[#000]). Tamaños/typography arbitrarios solo se permiten en CVA (types.ts) para variantes compactas/densas explícitamente aprobadas. - NO código en español (variables, comentarios y stories deben estar en inglés).
- NO tipos
any. - NO omitir accesibilidad (
aria-*o foco por teclado ausente). - NO exportar múltiples componentes desde un único archivo. Un componente = un directorio.