kaizen-lib
v0.2.22
Published
Tokens y componentes compartidos entre los fronts de Kaizen.
Readme
kaizen-lib
Tokens y componentes compartidos entre los fronts de Kaizen. Un solo paquete, con subpath exports: cada parte se importa por separado y el resto no entra al bundle.
Se publica en TypeScript crudo, sin build. Los consumidores son Next + React 19 +
styled-components con moduleResolution: "bundler", así que lo transpilan ellos.
Estado
| Subpath | Qué es | Estado |
|---|---|---|
| kaizen-lib/tokens | Referencias var(--kz-*) tipadas | listo |
| kaizen-lib/tokens.css | Valores por defecto, tema oscuro y claro | listo |
| kaizen-lib/ui | Componentes (IconButton, ReactionButton, Medidor, Relieve, LangSelector, Modal, Sheet, Drawer, Toast, Tabs, Tooltip, Popover, Menu, Select, Combobox, Badge, BottomNav, PanelExpandible, Stepper, DiscoHaxball, Skeleton) | en curso |
| kaizen-lib/ui | Componentes (IconButton, ReactionButton, Medidor, Relieve, LangSelector, Modal, Badge, BottomNav, PanelExpandible, Stepper, DiscoHaxball, Skeleton) | en curso |
| kaizen-lib/efectos | Efectos de landing: Spotlight, Card3D, Beams, StarField, HeroText | en curso |
¿Qué uso para qué?
Antes de escribir un styled.button a mano o un ciclador de idioma propio, revisá esta
tabla. Si tu caso está acá, es un defecto reconstruirlo local — usá esto, y si no
alcanza, extendé el componente en vez de bypassearlo (ver implement.md del harness:
"un duplicado local de algo que ya existe compartido se reemplaza por el compartido").
| Necesito... | Uso | No es... |
|---|---|---|
| Un botón redondo de sólo ícono en una topbar (notificaciones, perfil, cerrar) | IconButton | — |
| Que ese botón tenga fondo teñido cuando está seleccionado (la página actual, un filtro aplicado) | IconButton con active | ReactionButton (ver abajo, distinción a propósito) |
| Un ícono que cambia de color al reaccionar (me gusta, guardar, favorito) — sin fondo teñido | ReactionButton | IconButton con active |
| El relieve neumórfico (círculo con sombra, sin relleno) alrededor de un IconButton | Relieve envolviendo el IconButton | una prop de IconButton — ver por qué en su docblock |
| Un ciclador de idioma con banderas en la topbar | LangSelector | reconstruir el ciclado a mano — ya lo resuelve, con Relieve incluido |
| Un panel flotante que bloquea el resto de la página hasta que se resuelve (formulario, confirmación, contenido a pantalla completa) | Modal | un overlay propio — portal, foco atrapado, Escape, scroll bloqueado y anidamiento ya están resueltos ahí |
| Un panel que sube desde abajo en el teléfono (elegir de una lista, formulario corto, acciones) | Sheet (alturas auto/media/completa); animado y con cierre por arrastre, AnimatedSheet de kaizen-lib/motion | un overlay propio con keyframes — es el Modal con posicion="abajo" |
| Un panel lateral a toda la altura (ficha de detalle, filtros) | Drawer con lado; animado, AnimatedDrawer | un panel deslizante casero sin role ni foco atrapado |
| Un arco/medidor que muestra cuánto de un total está ocupado | Medidor | un <progress> o una barra propia |
| Una píldora de estado (activo, vencido, un rol, una etiqueta) con fondo lavado y texto del mismo tono | Badge | un styled.span propio por producto — es exactamente lo que ya había triplicado |
| La navegación principal fija abajo en el teléfono (ícono + rótulo por destino) | BottomNav, y bottomNavOffset de padding inferior en lo que no debe quedar tapado | una barra propia — ya estaba cuadruplicada |
| Un panel de pantalla con título, un número que resume el contenido y un cuerpo que se pliega (sin desmontarse) | PanelExpandible | un <details> o un acordeón propio — el indicador queda a la vista con el panel cerrado, que es lo que justifica plegarlo |
| Los pasos de un recorrido (un formulario en etapas, un trámite), con el actual marcado | Stepper; la paleta se pisa con --kz-stepper-* (ver su docblock) | una barra de progreso propia — las líneas se llenan en orden y respeta prefers-reduced-motion |
| Pestañas que cambian el contenido de una zona (subrayado o pastilla), con teclado y roles WAI-ARIA | Tabs; con indicador animado, AnimatedTabs de kaizen-lib/motion | una fila de botones propia — aria-controls, flechas, Home/End, roving tabindex y activación manual ya están |
| Espaciado, radios, color, tipografía, sombra | kaizen-lib/tokens (space, radius, fg, accent, font, shadow, relief...) | un valor a mano — cada uno es una referencia var(--kz-*) |
| Que algo entre y salga animado (un modal, un toast, un panel) | kaizen-lib/motion: AnimatedModal, o un preset (fade, scale, slideUp, slideSide) sobre m.div | importar motion directo o escribir duraciones a mano — los presets leen los tokens de entrada/salida |
| Un aviso efímero (guardado, error con reintentar, deshacer) | ToastProvider en la raíz + useToast() (kaizen-lib/ui); animado, AnimatedToastProvider de kaizen-lib/motion | un toast o un contexto propio — las regiones aria-live, la pausa y el teclado ya están |
| El lugar de un dato que todavía no llegó (renglones, card, avatar) | Skeleton (variant line/block/circle, lines) | un bloque gris propio — ya anuncia la carga (role="status", aria-busy) y respeta reduced motion |
| Un número que cuenta hasta su valor (total, saldo, indicador) | kaizen-lib/motion: AnimatedNumber (formato de Intl.NumberFormat) | un contador con requestAnimationFrame — el lector lee sólo el valor final |
| Que algo aparezca al entrar en pantalla, solo o escalonado | kaizen-lib/motion: Reveal (preset, escalonado) | un IntersectionObserver a mano |
| Respuesta al apretar/pasar por encima de un botón o card | kaizen-lib/motion: Pressable (as, tap, hover) | un keyframe de scale propio |
| Un efecto de landing (halo que sigue al mouse, card que se inclina, fondo de haces o de estrellas, título que aparece por palabras) | kaizen-lib/efectos | copiar efectos de Aceternity o el StarField de taboo/nsandev — los de acá se pausan fuera de pantalla, degradan en táctil y respetan reduced-motion |
| Mostrar la ficha/disco de Haxball de un equipo (para elegir un equipo representativo, o para lucir el catálogo de camisetas) | DiscoHaxball + CAMISETAS_HAXBALL; el avatar (texto) se valida al guardarlo con recortarAvatarHaxball | una imagen propia — es una aproximación visual generada por SVG, no requiere ningún asset |
| Un globo de ayuda al pasar el puntero o enfocar un control | Tooltip | un title o un tooltip a mano — no se reposiciona en los bordes ni abre con teclado |
| Un panel flotante no modal anclado a un botón (filtros, detalle, formulario corto) | Popover | Modal (bloquea la página) |
| Un menú de acciones (editar, duplicar, borrar) detrás de un botón, por defecto los tres puntos | Menu | un Popover con botones — no tendría flechas, typeahead ni roles de menú |
| Elegir un valor de una lista corta | Select | un <select> estilado a mano |
| Elegir un valor de una lista larga tipeando para filtrar | Combobox | un input + lista propios — el activedescendant y el teclado ya están resueltos |
Ningún componente trae íconos ni banderas: los recibe como children/icono, y el
producto elige de dónde salen (react-icons, react-country-flag, SVG propio).
Instalación
npm install kaizen-libreact y styled-components son peerDependencies y las pone el consumidor. Si
alguna vez quedan como dependencies, el consumidor termina con dos instancias de
styled-components y se rompen el theming y el SSR de un modo difícil de diagnosticar.
En next.config:
const nextConfig = { transpilePackages: ['kaizen-lib'] };Movimiento (kaizen-lib/motion)
Subpath opcional sobre motion v12. motion es
peerDependency opcional: sólo la instala quien importa kaizen-lib/motion;
kaizen-lib/ui y kaizen-lib/tokens no la necesitan.
npm install motionUna vez, cerca de la raíz (en Next, en un layout 'use client'):
import { MotionProvider } from 'kaizen-lib/motion';
<MotionProvider>{children}</MotionProvider>Carga sólo domAnimation en modo strict (hay que usar m.*, no motion.*) y
respeta prefers-reduced-motion: con la preferencia activa quedan los fades y se
apagan los desplazamientos y escalas. Layout animation (layout, layoutId) necesita
domMax: la pieza que la use la carga en diferido con su propio
<LazyMotion features={() => import('motion/react').then((m) => m.domMax)}> (ver el
docblock de MotionProvider).
Presets. fade(), scale(), slideUp(), slideSide('left' | 'right'). Cada uno
devuelve { initial, animate, exit } para esparcir sobre un m.* dentro de
AnimatePresence; ambos se reexportan desde el subpath:
import { AnimatePresence, m, slideSide } from 'kaizen-lib/motion';
<AnimatePresence>
{visible ? <m.div key="aviso" {...slideSide('right')}>Guardado</m.div> : null}
</AnimatePresence>Duraciones y curvas salen de --kz-duration-enter/-exit y --kz-ease-enter/-exit,
leídas del CSS en ejecución: si el producto pisa el token, la animación lo sigue. Para
un caso puntual, fade({ tiempos: { enter: 0.4 } }).
AnimatedModal. Las mismas props que Modal más entrada?: 'scale' | 'slideUp'
(default scale). Fondo en fade, panel según entrada, y el desmontaje espera a que
termine la salida. Foco, Escape, pila y disableClose son los de Modal. El
Modal de kaizen-lib/ui sigue igual sin motion; la animación entra por su prop
animacion (ModalAnimacion), que arma AnimatedModal.
AnimatedToastProvider. Las mismas props que ToastProvider (position, max,
closeLabel). Cada toast entra y sale con slideSide('right') (o slideUp en
bottom-center) y la pila se reacomoda con layout; domMax se carga en diferido sólo
ahí. useToast() es el mismo hook. Sin motion, ToastProvider de kaizen-lib/ui hace
lo mismo sin animar (adaptador ToastAnimacion, como ModalAnimacion).
Tonos: info, success, warning (toast.warning('Tu sesión expiró'), región polite)
y error (región assertive). Para no tapar barras fijas, offsetTop y offsetBottom
aceptan cualquier largo CSS (offsetBottom="var(--bottom-nav-height)"); se suman al
margen de la pila. Default: env(safe-area-inset-top|bottom, 0px); al pasarlos se
reemplaza, así que si tu barra no incluye el safe-area, sumalo con calc.
AnimatedSheet y AnimatedDrawer. Las props de Sheet/Drawer. El sheet entra
desde abajo (desdeBorde('bottom')) y trae un tirador: arrastrarlo hacia abajo más de
un cuarto del alto (tope 160px) o con un tirón rápido cierra (cierraPorArrastre).
arrastrable={false} lo saca; con disableClose no cierra. El drawer entra por su
costado (desdeBorde('left' | 'right')).
Efectos de landing (kaizen-lib/efectos)
Subpath propio para que su peso no caiga en ui ni en motion. No necesita motion
ni MotionProvider: todo es CSS (transform, gradientes, SVG) y unas pocas líneas de JS
que escriben variables CSS sin re-render.
import { Spotlight, Card3D, Beams, StarField, HeroText } from 'kaizen-lib/efectos';
<Spotlight style={{ borderRadius: 16 }}>…sección…</Spotlight>
<Card3D brillo max={6}>…card…</Card3D>
<section style={{ position: 'relative' }}><Beams cantidad={6} />…</section>
<StarField modo="fijo" color="#bb7fff" />
<HeroText texto={t('hero.title')} por="palabra" />
<HeroText texto={t('hero.title')} gradiente="linear-gradient(90deg, var(--a), var(--b))" />HeroText con texto en gradiente: no pongas background-clip: text en el padre (las
piezas inline-block animadas lo rompen); pasá gradiente. Cada pieza pinta su tramo
del gradiente del título entero, medido sin re-render y re-medido al cambiar de tamaño,
así corre continuo y no se repite por palabra.
| Efecto | Táctil (sin hover) | Reduced-motion | Fuera de pantalla / pestaña oculta | Peso (min / gzip) |
|---|---|---|---|---|
| Spotlight | halo quieto en reposo (o apagado con reposo={null}) | igual que táctil | sin trabajo: sólo reacciona a pointermove, una escritura por frame | 2.2 KB / 1.1 KB |
| Card3D | plana, sin listeners | plana, sin brillo | sin trabajo | 2.7 KB / 1.2 KB |
| Beams | igual | sólo las guías, sin pulso | animación CSS en pausa | 2.4 KB / 1.2 KB |
| StarField | igual | estrellas quietas | animación CSS en pausa | 2.4 KB / 1.2 KB |
| HeroText | igual | texto final, sin animar | anima una vez, al entrar en pantalla | 2.2 KB / 1.1 KB |
Pesos medidos con rolldown (minificado, react y styled-components externos); cada
fila incluye el helper compartido. El subpath entero: 10.3 KB / 3.7 KB.
StarField reemplaza al componente copiado en taboo y nsandev: misma distribución
gaussiana, pero en un solo SVG con tres grupos que titilan (no una animación por
estrella) y posiciones por semilla, sin render vacío antes de montar.
Camisetas (kaizen-lib/camisetas)
Módulo puro, sin React: el catálogo CAMISETAS_HAXBALL (1 a 3 franjas, ángulo), distanciaCamisetas (el par de colores más parecido, portado de kaizen-bot), rivalQueContrasta(nombre, { semilla, excluir }) (determinista) y esCamiseta. Es el único subpath que además se publica compilado a CommonJS en dist/camisetas (npm run build, corre solo en prepublishOnly), para servidores Node que no transpilan el paquete. kaizen-lib/ui sigue reexportando el catálogo.
Tokens
El contrato es uno solo: los nombres. Los valores los define cada producto.
import styled from 'styled-components';
import { surface, fg, space, radius } from 'kaizen-lib/tokens';
const Card = styled.div`
background: ${surface[2]};
color: ${fg.DEFAULT};
padding: ${space[4]};
border-radius: ${radius.md};
`;/* globals.css — opcional: valores por defecto, después los pisás con los tuyos */
@import 'kaizen-lib/tokens.css';
:root {
--kz-accent: #e8318f; /* la marca del producto */
}Adopción parcial. Si un producto redefine sólo algunos tokens, el resto cae al
valor por defecto. Si no importa tokens.css y no define un token que un componente
usa, el estilo simplemente no se aplica — no hay error. Por eso los tests exigen que
todo token de la API esté definido en defaults.css.
Tema. Oscuro en :root; claro cuando <html> lleva la clase light. Es el
mecanismo de tutipoker-school-front, el único consumidor con toggle real.
Elevación. Cuatro niveles de uso fijo: 0 página, 1 panel, 2 card, 3 overlay.
sunken es el pozo (inputs, tracks) y va más oscuro que su contenedor.
Componentes
import { IconButton } from 'kaizen-lib/ui';
<IconButton label="Cambiar tema" size="lg" onClick={alternar}>
<IconoLuna />
</IconButton>
<IconButton as={Link} href="/notificaciones" label="Notificaciones, 3 sin leer" badge={3}>
<IconoCampana />
</IconButton>IconButton es el botón redondo de sólo ícono de una topbar. label es obligatorio
(es el nombre accesible). Con badge dibuja un contador, y ese número es decorativo: la
cuenta va dentro del label, porque el idioma no es asunto de la librería. Con as toma
otro elemento —un <Link> de Next— sin que la librería dependa de Next. size="lg" es el
área táctil de 44px. Con motivo queda bloqueado pero enfocable, para poder mostrar por
qué.
La librería no trae íconos: cada producto usa los suyos.
import { ReactionButton } from 'kaizen-lib/ui';
<ReactionButton tone="danger" active={likeado} glow count={likes} label={`Me gusta, ${likes}`}>
<IconoCorazon />
</ReactionButton>ReactionButton es un ícono que cambia de color al reaccionar (tone: neutral |
accent | success | danger | warning | info), con resplandor opcional (glow,
del mismo color vía currentColor) y una cuenta al lado (count; 0 se muestra, no es
una ausencia). No es IconButton con más props: nunca tiñe el fondo. IconButton es
"esto está seleccionado" (la página actual, un filtro aplicado) y por eso tiñe el círculo
entero; ReactionButton es "reaccioné con esto" (un me gusta, un guardado), y ahí sólo el
ícono cambia — un corazón marcado no necesita, además, un círculo rosa detrás.
Un color que los seis tonos no cubren —la marca de un producto, no una categoría
semántica— se pisa con style, porque el color activo sale de la variable CSS
--kz-icon-on y un estilo en línea le gana a la regla de la librería:
<ReactionButton
active={guardado}
style={{ '--kz-icon-on': 'var(--mi-color-de-marca)' } as React.CSSProperties}
label="Guardar"
>
<IconoMarcador />
</ReactionButton>import { Medidor } from 'kaizen-lib/ui';
<Medidor valor={8} maximo={10} sentido="menos-es-mejor" label="Anotados: 8 de 10" />Medidor es un arco que dice cuánto de un total está ocupado, pintado por escalón. No es
un control: es un role="img", así que si el dato tiene que llevar a algún lado lo envuelve
un link o un botón del producto. label es obligatorio y va en palabras — el arco y el
número del centro son decorativos, y el color nunca es el único portador del significado.
Los cuatro escalones salen de la proporción valor / maximo, no del valor (8 no significa
lo mismo sobre 10 que sobre 22). Sobre una escala de 0 a 10: 10 tope, 8 a 9,99 alto,
4 a 7,99 medio, menos de 4 bajo. Cada corte es inclusivo abajo.
sentido es obligatorio y decide qué extremo es el bueno, porque eso lo define lo que el
número significa en el producto y no el componente:
| Escalón | menos-es-mejor | mas-es-mejor |
|---|---|---|
| bajo (< 40%) | verde | rojo |
| medio (40–79%) | azul | naranja |
| alto (80–99%) | naranja | azul |
| tope (100%) | rojo | verde |
menos-es-mejor es "cuánto lugar queda": vacío es una oportunidad, lleno una puerta
cerrada. mas-es-mejor es un progreso o una meta. Usa los tonos success, info,
warning y danger, así que un producto que ya los definió no declara nada nuevo. El
centro muestra valor/maximo y se reemplaza con children cuando el formato es del producto.
import { IconButton, Relieve } from 'kaizen-lib/ui';
import Link from 'next/link';
<Relieve>
<IconButton label="Notificaciones, 3 sin leer" badge={3}><IconoCampana /></IconButton>
</Relieve>
<Relieve $prendido={enPerfil}>
<IconButton as={Link} href="/perfil" active={enPerfil} label="Perfil"><IconoPerfil /></IconButton>
</Relieve>Relieve envuelve a IconButton con el relieve neumórfico: un círculo sin relleno ni
borde, dibujado por un par de sombras opuestas (--kz-relief-out) que se invierten al
presionar (--kz-relief-in) o con $prendido (el equivalente sostenido de "esto está
activo" o "vos estás acá"). Es un <span> alrededor del control y no un
styled(IconButton): IconButton es polimórfico (as), y styled-components
intercepta el as de cualquier componente que envuelve, así que envolverlo así lo haría
saltear toda su lógica. El relieve es neutro por defecto (negro/blanco) — un producto con
marca lo tiñe redefiniendo esos tres tokens, igual que con cualquier otro.
import { LangSelector } from 'kaizen-lib/ui';
<LangSelector
actual={i18n.language}
onChange={(codigo) => i18n.changeLanguage(codigo)}
etiqueta={(siguiente) => `Cambiar idioma a ${siguiente.nombre}`}
opciones={[
{ codigo: 'es', nombre: 'Español', icono: <ReactCountryFlag countryCode="AR" /> },
{ codigo: 'en', nombre: 'English', icono: <ReactCountryFlag countryCode="US" /> },
]}
/>LangSelector es el botón de idioma de una topbar: cicla a la siguiente opción en
cada tap y muestra el ícono del idioma actual, con el relieve de Relieve ya incluido
(a diferencia de IconButton, acá no hace falta envolverlo aparte — todo consumidor lo
quería). No trae banderas — mismo criterio que IconButton no trae íconos —, así que
icono es cosa del producto; react-country-flag (ya usado en tuxon-front) es una
opción probada. etiqueta arma el nombre accesible a partir de la opción siguiente y la
actual — es obligatoria y sin default: la frase y el idioma son del producto (dos ya
difieren: uno anuncia el destino, "Cambiar idioma a English"; otro el estado, "Idioma:
English. Cambiar idioma"). onChange recibe el código de la opción siguiente; qué hacer
con eso (i18n.changeLanguage, next-intl, lo que sea) es del producto — la librería no
sabe qué mecanismo de traducción hay atrás.
import { Modal } from 'kaizen-lib/ui';
<Modal
open={abierto}
onClose={cerrar}
title="Eliminar cepa"
description="Esta acción no se puede deshacer."
footer={<Acciones><Boton onClick={cerrar}>Cancelar</Boton><Boton onClick={eliminar}>Eliminar</Boton></Acciones>}
>
<p>Se van a borrar también sus lotes asociados.</p>
</Modal>Modal es el shell único de un panel flotante: portalea a document.body, atrapa el
foco y lo devuelve a quien lo abrió al cerrar, cierra con Escape/click afuera/la X, y
bloquea el scroll del fondo. Lleva una pila interna para el anidamiento real (un modal de
confirmación abierto encima de un formulario): sólo el de más arriba atiende Escape, y el
scroll se libera recién cuando se cierra el último.
Sin title no hay nombre accesible propio — pasá ariaLabel. size (sm|md|lg|xl)
fija el ancho máximo del panel; maxWidth lo pisa con un valor explícito. disableClose
bloquea la X, el overlay y Escape a la vez — para una operación en curso, no para "no
quiero que cierre nunca". dismissOnOverlay={false} sólo desactiva el click afuera, para
un formulario a medio llenar que sigue queriendo cerrar con la X o Escape. closeLabel
(default 'Cerrar') es el nombre accesible de la X — mismo criterio que etiqueta de
LangSelector: un consumidor con más de un idioma lo pisa, el resto no lo toca.
Lo que no trae: un placement lateral (drawer) y un padding compacto de cuerpo son
casos de un solo consumidor cada uno (components/ui/Dialog de valle-verde) — se resuelven
ahí con un wrapper local hasta que un segundo consumidor los pida acá.
import { Badge } from 'kaizen-lib/ui';
<Badge tone="success">Activo</Badge>
<Badge tone="danger" bordered>Vencido</Badge>Badge es una píldora de estado: fondo lavado + texto del mismo tono (neutral |
accent | success | danger | warning | info, default neutral), mayúsculas
apretadas. Elicitado de tres implementaciones ya duplicadas (taboo-next, valle-verde,
kaizen-next) — el look es el de valle-verde. bordered agrega un borde de 1px del color
pleno del tono, para cuando el fondo lavado solo no alcanza de contraste contra la
superficie de atrás; no es una segunda estética, es el mismo tono con más peso.
import Link from 'next/link';
import { BottomNav, bottomNavOffset } from 'kaizen-lib/ui';
<BottomNav
items={[{ id: 'inicio', label: 'Inicio', icon: <IoHomeOutline />, href: '/' }, ...]}
activeId={activo}
maxWidth={640}
ariaLabel="Secciones"
linkAs={Link}
/>
const Contenido = styled.main`padding-bottom: ${bottomNavOffset};`;BottomNav es la barra de navegación fija abajo del teléfono: ícono + rótulo por
destino, el activo con color de acento y una franja arriba (no sólo color). Elicitada de
cuatro barras ya duplicadas (mixbol, valle-verde, tuxon, platenzen). Existe hasta
maxWidth px y arriba se apaga por CSS, sin desmontarse, para no parpadear al hidratar.
Qué decide el producto: activeId (si un destino matchea exacto o por prefijo es regla de
sus rutas), linkAs (su router; sin él, <a>), y ariaLabel (su idioma, sin default).
onSelect(item, event) se llama antes de navegar; con event.preventDefault() el
destino hace otra cosa sin dejar de ser un link.
Mientras se ve, publica --kz-bottom-nav-offset (su alto más el inset inferior del
dispositivo). La barra es fija, así que el contenido lleva bottomNavOffset de padding
inferior y lo que flota (FAB, avisos) lo suma a su bottom; donde la barra no está, vale
0px.
import { PanelExpandible } from 'kaizen-lib/ui';
<PanelExpandible titulo="Comprometido" indicador="30 g" leyenda="faltan entregar">
<Tabla />
</PanelExpandible>PanelExpandible es un panel de pantalla: cabecera con el título y un indicador
(indicador + leyenda, o varios con indicadores), y un cuerpo que se pliega. Plegado
no desmonta: el contenido conserva filtros y selección. El control accesible es el
chevron (aria-expanded); toda la cabecera responde al click. Controlado (abierto +
onCambiar) o no (abiertoInicial). Con plegable={false} conserva el formato sin
chevron, para una pantalla de un solo panel. duracionMs (default 300) fija la animación
y el momento en que el cuerpo se oculta. etiquetaPlegar/etiquetaDesplegar traducen el
nombre del chevron. La columna del plegable es minmax(0, 1fr): una tabla ancha adentro
se compacta o scrollea, no empuja el panel fuera de la pantalla.
Flotantes: Tooltip, Popover, Menu, Select, Combobox
Construidos sobre Base UI (@base-ui/react, dependencia de la
lib: el consumidor no la instala). Base UI pone el comportamiento —posición con volteo,
portal, foco, teclado, roles ARIA— y la lib sólo el estilo con tokens --kz-*. La
entrada y salida son una transición CSS de opacidad y escala, apagada con
prefers-reduced-motion: reduce. Los cinco quedan por encima de Modal, así que se
pueden abrir dentro de uno.
import { Combobox, Menu, Popover, Select, Tooltip } from 'kaizen-lib/ui';
<Tooltip content="Copiar enlace"><IconButton aria-label="Copiar enlace">…</IconButton></Tooltip>
<Popover trigger={<button>Filtros</button>} title="Filtrar">
{({ close }) => <Filtros onAplicar={close} />}
</Popover>
<Menu label="Acciones" items={[{ label: 'Editar', onSelect: editar }, { label: 'Borrar', onSelect: borrar, danger: true }]} />
<Select label="Orden" value={orden} onChange={setOrden} options={[{ value: 'nombre', label: 'Nombre' }]} />
<Combobox label="Paciente" placeholder="Por asignar" value={id} onChange={setId}
options={pacientes.map((p) => ({ value: p.id, label: p.nombre, searchText: p.email }))} />- Todo disparador (
childrendeTooltip,triggerdePopoveryMenu) es un elemento que acepterefy props. El tooltip es visual: el disparador lleva su propio nombre accesible. SelectyComboboxcompartenSelectOption(value,label,disabled;searchTextsólo lo usaCombobox).labeles el nombre accesible.Comboboxfiltra sin acentos ni mayúsculas sobrelabel+searchText, resalta la primera coincidencia y se limpia con su botón (onChange(null)).- Peso: importar uno solo suma entre ~35 kB (
Tooltip) y ~63 kB (Menu) gzip al bundle del cliente; los cinco juntos, ~97 kB, porque comparten el núcleo de Base UI. Quien no los importa no paga nada:kaizen-lib/uino tiene efectos secundarios y el bundler los descarta.
Desarrollo
npm run check # typecheck + testsLos tests verifican dos cosas:
- Paridad (
tokens.test.ts): todo token de la API está definido en el CSS y todo token del CSS está en la API. El tema claro no inventa tokens ni deja colores sin redefinir. - Contraste (
defaults.test.ts): los pares texto/fondo de la paleta por defecto cumplen WCAG 2.1 (4.5:1 texto, 3:1 no textual), en los dos temas.
Si cambiás un color, el número lo da la suite, no el ojo.
Licencia
MIT
