@kucho77/design-system
v0.1.1
Published
React + TypeScript + Tailwind component library for building consistent, accessible application UIs.
Readme
@kucho77/design-system
Kit de UI en React + TypeScript, construido de forma incremental como reemplazo standalone de una
librería de componentes Angular equivalente. Nació como el subconjunto mínimo que necesitaba
la pantalla de Inicio (Icon, Label, Loader,
Layout/LayoutMain, Hint), sumó componentes de propósito general en el Batch 1
(Button, Badge, Ribbon, Copy, Title, Grid, Wrapper, Card, Detail), estructurales/
interactivos en el Batch 2 (Dropdown, Accordion/Panel, Tabs/Tab, Group/GroupItem,
Heading/Item), campos de formulario en el Batch 3 (FormField, ValidationMessages,
validators, Bool, Int, Float, Phone, Text, Options, Radio, Slider, Select,
Datetime), overlays/tabla en el Batch 4 (Modal, Tooltip, Table, List, Visualizador,
Help), Wizard/Toast/directivas menores en el Batch 5 (Toast + toast sobre sonner,
Wizard con modo fullScreen propio y modo spotlight sobre react-joyride, alignClasses/
caseClasses/growClasses/spanClasses, useIsMobile, useRipple), y el port de íconos de
webfont a SVG en el Batch 6 (Icon ahora renderiza SVG inline en vez de una fuente de íconos —
ver más abajo). Pensado para crecer sin romper nada de lo que ya usan sus consumidores — ver
/design-system-preview en la app para una muestra visual en vivo de todo lo que hay disponible.
No queda backlog pendiente de la librería original salvo mejoras puntuales ya anotadas por componente (ver Evaluar aparte en Batch 5 más abajo).
Instalación
Hoy vive como package de un npm workspace (menu-react/packages/design-system), consumido
localmente por la app vecina vía "@kucho77/design-system": "*". Para usarlo en otro proyecto:
- Mismo workspace / monorepo: agregá
packages/design-systema losworkspacesde ese proyecto y declará la dependencia igual que hacemenu-react("@kucho77/design-system": "*"). - Proyecto separado: copiá la carpeta
packages/design-systemy publicala a tu registry interno (npm publish, sacando antes el"private": truedelpackage.json), o instalala directo desde una ruta/git (npm install file:../ruta/al/paquete).
El paquete es standalone: no depende de nada de menu-react salvo react/react-dom
(peer dependencies), clsx, sonner y react-joyride.
Setup
// en tu entry point (main.tsx), una sola vez:
import '@kucho77/design-system/style.css';style.css trae los estilos de todos los componentes, íconos incluidos — no hace falta ningún
icons.css aparte (ver Íconos más abajo, Batch 6).
Custom properties overrideables
Icon (tipo info/dark), Loader y Hint usan dos variables de color con fallback, para que
el paquete funcione standalone sin configuración:
| Variable | Default | Uso |
|---|---|---|
| --color-nav-bar | #00a8e0 | Ícono type="info", barra del Loader (nombre heredado de la app original) |
| --color-info | --color-nav-bar (#00a8e0) | Mismo azul, token con nombre semántico — lo usan los componentes del Batch 1 (Button, Badge, Card, Ribbon, Title, Detail). Si sólo definís --color-nav-bar, estos componentes lo heredan igual (fallback encadenado). |
| --color-brand-dark | #002838 | Ícono type="dark", fondo del tooltip de Hint, type="dark" en Button/Badge/Ribbon/Title/Card |
| --color-default | #999999 | type="default" en Button/Badge/Ribbon/Title |
| --color-success | #8cc63f | type="success" |
| --color-warning | #ff8d22 | type="warning" |
| --color-danger | #dd4b39 | type="danger" |
Valores tomados de la paleta real de plex (plex-master/src/lib/css/variables.scss). Para
re-brandear, definilas en tu CSS raíz (:root { --color-info: ...; }) — no hace falta redefinir
las seis si sólo querés cambiar una.
Componentes
<Icon />
<Icon name="bell" size="lg" type="info" />| Prop | Tipo | Default | Notas |
|---|---|---|---|
| name | string | — | nombre del glifo, ej. "bell", "chevron-right" — clave en el set de ~688 íconos incluidos (ver Íconos abajo) |
| prefix | string | 'adi' | sólo 'adi' tiene datos reales hoy; otros prefixes quedan reservados para un set futuro y no renderizan nada |
| size | '18'\|'24'\|'36'\|'48'\|'xs'\|'sm'\|'md'\|'lg'\|'xl'\|'xxl' | 'sm' | los numéricos quedan reservados para un set px-based futuro; con prefix='adi' usá los UiSize (xs..xxl → 12/14/16/24/28/36px) |
| type | UiType | — | agrega color semántico (ds-icon-{type}) |
| title | string | — | si se pasa, hace el ícono accesible (role="img") |
Íconos: de webfont a SVG (Batch 6)
Icon renderiza un <svg><path d="..." fill="currentColor" /></svg> inline por cada glifo, en vez
de depender de una fuente de íconos. Los
~688 glifos se extrajeron una sola vez de un proyecto IcoMoon fuente
(export config.json, que trae el path SVG de cada glifo) hacia
src/Icon/glyphs.generated.ts — un Record<string, { path, width }> generado, no se edita a mano.
Dos glifos (video-chat, videocam) no traían datos en ese config.json; se recuperaron
parseando la fuente SVG compilada del mismo proyecto y aplicándoles el flip Y +
translate que corresponde entre el espacio de coordenadas de una fuente (origen en la línea base,
Y hacia arriba) y el de un viewBox normal (origen arriba-izquierda, Y hacia abajo) — 688/688
íconos con cobertura completa, ninguno faltante.
Por qué: la fuente webfont pesaba ~700KB repartidos en 5 formatos (woff2/woff/ttf/eot/svg,
sólo uno se cargaba en runtime pero los 5 viajaban en el paquete), tenía flash de ícono
sin-estilo mientras cargaba, y atarse a una fuente para íconos es una dependencia más frágil que
datos vectoriales inline. El costo es un dist/index.js más pesado (~200KB gzip, todo el set de
íconos va bundleado porque Icon es data-driven por name — no hay forma de tree-shakear sin
cambiar la API pública), pero sin request de red aparte ni parpadeo de carga.
La API pública de Icon no cambió — name/size/type/className/title funcionan igual
que antes; ningún consumidor (ni dentro del paquete ni en la app) necesitó tocarse.
<Label />
<Label icon="llave" type="warning" size="xl" direction="column" titulo="..." subtitulo="..." />titulo, subtitulo, tituloBold (default true), size (sm|md|lg|xl), icon, direction
(row|column), type, color (setea --label-color inline).
<Loader />
<Loader type="linear" />Sólo la variante "linear" está implementada hoy; ball-pulse/ball-pulse-sync/ball-beat
están tipadas pero pendientes.
<Layout /> / <LayoutMain />
<Layout>
<LayoutMain>{children}</LayoutMain>
</Layout>Versión simplificada (sin sidebar todavía) del layout de 12 columnas de plex.
<Hint />
<Hint content="Novedades del módulo X" icon="bell" type="info" detach="both" onClick={...} />Badge clickeable con tooltip CSS on-hover/focus. detach controla el margen de separación
(''|'both'|'top').
useContainerBreakpoint()
Hook con ResizeObserver que mide el propio contenedor (no el viewport) y devuelve
'sm'|'md'|'lg'|'xl' en los cortes 576/768/1024px — para cuando necesitás ramificar JSX según
el tamaño del contenedor, no sólo CSS.
justifyClasses(value)
'start'|'end'|'center'|'between'|'around' → clases Tailwind de flexbox.
<Button />
<Button type="info" label="Guardar" icon="chevron-down" size="lg" onClick={...} />label, icon, type (UiType), size (sm|md|lg|block, default md), disabled,
tabIndex, ariaLabel, onClick, children (se usa como contenido si no hay icon ni label).
No incluye la integración con NgForm del original (disparar validación de formulario al hacer
click) ni la directiva de ripple — quedan para el batch de directivas.
<Badge />
<Badge type="success" icon="bell" color="#853188">3 nuevas</Badge>type (UiType), size (ComponentSize), color (override vía --badge-color), icon,
children (contenido principal), action (slot secundario, ej. un Button embebido).
<Ribbon />
<Ribbon type="danger" text="Beta" position="top-right" />Banner de esquina — colocalo dentro de un contenedor position: relative. type, text,
position (top-left|top-right).
<Copy />
<Copy value="texto-a-copiar"><code>texto-a-copiar</code></Copy>Copia value al portapapeles (navigator.clipboard, no el document.execCommand deprecado del
original) y muestra un ícono de check por 2 segundos. value, size (UiSize), children.
<Title />
<Title titulo="Pacientes" type="info" onBack={() => navigate(-1)}>
<Button size="sm" label="Nuevo" />
</Title>titulo, size (sm|md|lg|xl), type (UiType), onBack (si se pasa, muestra la flecha de
volver — igual que el original, que detecta si hay algún listener en el output back), children
(acciones a la derecha).
<Grid />
<Grid type="auto" size="md" cols={3}>{items}</Grid>CSS Grid responsivo. type (full|auto → auto-fit/auto-fill), size (xs|sm|md|lg|xl, define
el tamaño mínimo de columna), cols/colsSm/colsMd/colsLg (columnas fijas, los breakpoints
Sm/Md/Lg son viewport-based — el original usaba container queries vía la directiva
responsive de plex, no portada todavía), noGap. No incluye el auto-expand de Label con
subtítulo largo a 2 columnas (reflection sobre hijos, no idiomático en React).
<Wrapper />
<Wrapper activeFilters collapse={<MasFiltros />}>
<FiltroSiempreVisible />
</Wrapper>Barra colapsable de filtros. children (siempre visible), collapse (sólo visible expandido —
si no se pasa, no se muestra el botón toggle), activeFilters (badge Hint parpadeante mientras
está colapsado), onChange(desplegado).
<Card />
<Card type="success" mode="outlined" selectable label={<Label titulo="..." />} actions={<Button .../>} />selected, selectable, aligned (start|end|center), size (ComponentSize), type
(info|success|warning|danger|dark|custom|default), mode (filled|outlined), color (con
type="custom"), slots: media, badges, label, actions, children. El sistema de color
usa la paleta compartida + color-mix() para el tinte de outlined, en vez de replicar los mixins
SCSS de lighten/darken por HSL del original.
<Detail />
<Detail media={<Icon name="account" size="xxl" />} title="Juan Pérez" subtitle="Médico"
items={[{ label: 'DNI', valor: '30123456' }]} />direction (row|column), size (xs|md|lg), media, badges, actions, title, subtitle,
items (array {label, valor} renderizado como Labels en un Grid), children (labels extra).
<Box />
Alias de LayoutMain — en plex, plex-box y plex-layout-main son el mismo template
(.plex-box/.plex-box-content), así que no hay un componente separado.
useScrollEnd(ref, onEnd)
const ref = useRef<HTMLDivElement>(null);
useScrollEnd(ref, () => cargarMasItems());
<div ref={ref} style={{ overflowY: 'auto' }}>...</div>Port de plex-scroll (un componente sin template que sólo emitía al llegar al final del scroll
del padre) — en React eso es un hook, no un componente.
<Dropdown />
<Dropdown label="Opciones" icon="chevron-down" type="info" items={[
{ label: 'Editar', icon: 'documento-lapiz', onSelect: () => ... },
{ divider: true },
{ label: 'Eliminar', onSelect: () => ... },
]} />label, icon, items (DropdownItemDef[] — label/icon/divider/href/onSelect), type
(UiType), size (ComponentSize), right (alinea el menú a la derecha), disabled, onOpen,
children (menú de contenido libre, usado cuando no se pasa items). Se cierra solo al clickear
afuera. Adelantado desde el batch de inputs porque Group lo necesita para su menú de overflow.
<Accordion /> / <Panel />
<Accordion activeLast>
<Panel tituloPrincipal="Datos personales" icon="account">...</Panel>
<Panel tituloPrincipal="Domicilio" icon="calendario" tituloSecundario="Opcional">...</Panel>
</Accordion>Accordion: activeLast (default false — si es true, abrir un panel cierra los demás; si es
false, cada Panel es independiente). Panel: tituloPrincipal, tituloSecundario, icon
(default chevron-down), header (contenido custom en vez de título+ícono), defaultActive
(estado inicial cuando Panel se usa standalone, sin Accordion padre — también funciona solo),
onToggle(open). La coordinación "sólo uno abierto" usa Context en vez del patrón de Angular de
inyectar el padre por constructor y mutar sus hijos directamente.
<Tabs /> / <Tab />
<Tabs onChange={(i) => ...}>
<Tab label="Resumen" icon="documento-lapiz" color="info">...</Tab>
<Tab label="Historial" allowClose onClose={...}>...</Tab>
</Tabs>Tabs: activeIndex/defaultActiveIndex (controlado/no controlado), size ('full' para que
las tabs ocupen todo el ancho), onChange(index), onClose(index), actions (badges/botones a la
derecha de la barra). Tab: label, icon, color ('default'|'info'|'success'|'warning'|
'danger'), allowClose. A diferencia de Angular (que usa ContentChildren para inspeccionar los
tabs hijos), acá Tabs lee label/icon/color/allowClose directo de los props de cada
<Tab> — no hace falta ningún mecanismo de registro. Navegación con flechas ←/→ y Enter.
<Group /> / <GroupItem />
<Group overflow={<><GroupItem label="Exportar" icon="clipboard-plus" onClick={...} /></>}>
<Badge size="sm" type="success">activo</Badge>
<Button size="sm" type="info" label="Ver" />
</Group>Group: children (fila inline, pensada para Badge/Button size="sm"), overflow
(GroupItems mostrados en un menú "⋮", usa Dropdown por debajo), spacing (1|2|3|4), right.
GroupItem: label, icon, href, onClick.
<Heading /> / <Item />
<Heading><div>Nombre</div><div>Estado</div></Heading>
<Item media={<Icon name="account" />} actions={<Badge size="sm">activo</Badge>} selected>
<div>Juan Pérez</div><div>Habilitado</div>
</Item>Bloques simples para armar una lista de filas — Heading (columnas de título, sticky), Item
(selectable, selected, media slot izquierdo, actions slot derecho — la "botonera" de
badges/botones, colors para overrides puntuales de color vía custom properties). No incluye
el plex-list completo (paginado, columnas ordenables, filtros) — eso está fuertemente acoplado a
Table y queda para ese batch.
Campos de formulario (Batch 3)
Ninguno de estos usa Angular Forms/ControlValueAccessor (no existe en React) — todos siguen el
mismo patrón simple: value + onChange(value) + error (string) + required (boolean). Vos
decidís cómo validar (React Hook Form, Formik, useState a mano) y le pasás el mensaje ya resuelto
por error; el campo no refleja sobre ningún AbstractControl como hacía el original.
<FormField />
Wrapper compartido (label + marca de requerido + children + mensaje de error) que usan todos los
campos de abajo — expuesto por si querés armar un campo custom con la misma pinta.
<FormField label="Nombre" required error={miError}>
<input className="ds-form-control" />
</FormField><ValidationMessages />
<ValidationMessages message={miError} />Sólo renderiza el mensaje ya resuelto — a diferencia del original, no interpreta claves de error de
un AbstractControl (required/format/min/max/...). FormField ya lo renderiza solo vía su
prop error; usalo directo sólo en layouts custom que no pasan por FormField.
validators (helpers puros)
import { validators } from '@kucho77/design-system';
error={validators.required(value) ?? validators.numberRange(value, 0, 100)}required(value, mensaje?), numberRange(value, min?, max?), dateRange(value, min?, max?),
pattern(value, regex, mensaje?) — equivalentes standalone a validator.functions.ts del
original, sin depender de AbstractControl.
<Bool />
<Bool label="Activo" type="slide" value={activo} onChange={setActivo} />label, type (checkbox|slide), value, onChange, readonly. <input> nativo — sin Angular
Material.
<Int /> / <Float /> / <Phone />
<Int label="Edad" value={edad} onChange={setEdad} />
<Float label="Peso" value={peso} onChange={setPeso} suffix="kg" />
<Phone label="Teléfono" value={tel} onChange={setTel} icon="phone" />Campos de texto que rechazan caracteres inválidos mientras escribís (igual que el original):
Int sólo enteros, Float decimales con coma (estilo AR), Phone sólo dígitos. label, prefix/
suffix, placeholder, disabled, readonly, required, error, autoFocus. No validan
min/max internamente — usá validators.numberRange vía error. Phone exporta también
MOBILE_PHONE_REGEX (el patrón de celular AR del original) para usar con validators.pattern.
<Text />
<Text label="Comentario" multiline rows={3} value={texto} onChange={setTexto} />type (text|password|email), multiline+rows, prefix/suffix/left/right, botón de
limpiar (ícono close-circle) cuando hay contenido, debounce (ms). No incluye el modo html
(editor de texto enriquecido) del original — es un componente grande en sí mismo, se evaluará por
separado si hace falta.
<Options />
<Options items={[{key:'d',label:'Día'},{key:'s',label:'Semana'}]} value={activo} onChange={setActivo} />Grupo de botones de selección única. items ({key,label}[]), value/onChange (controlado —
sin controlar, arranca en el primer item, igual que el original).
<Radio />
<Radio data={opciones} keyField="id" labelField="label" value={seleccionado} onChange={setSeleccionado} />
<Radio data={opciones} multiple value={seleccionados} onChange={setSeleccionados} type="horizontal" />data (array genérico), keyField/labelField (default id/label), multiple (checkboxes en
vez de radio buttons — value/onChange pasan a ser el array de items seleccionados), type
(vertical|horizontal), readonly. <input> nativo, sin Angular Material.
<Slider />
<Slider>{cards.map(c => <Card key={c.id} .../>)}</Slider>Carrusel horizontal con botones prev/next. Simplificado vs. el original: en vez de medir hijos
plex-card para calcular un ancho de item fijo, scrollea ~90% del ancho visible por click — funciona
con cualquier contenido, no sólo Card.
<Select />
<Select label="País" data={paises} value={pais} onChange={setPais} placeholder="Elegí..." />
<Select data={paises} multiple value={seleccionados} onChange={setSeleccionados} />Combobox armado a mano (sin jQuery/selectize): data ({id,label,...}[]), value/onChange
(objeto en modo single, array en modo multiple — con tags removibles), búsqueda local por
label, navegación con flechas ↑/↓ + Enter, cierre al clickear afuera o Escape. No incluye
carga asíncrona (load/getData) ni templates de render custom del original — sólo datos locales.
<Datetime />
<Datetime type="datetime" label="Turno" value={fecha} onChange={setFecha} min={hoy} max={limite} />Calendario armado a mano (sin Angular Material Datepicker ni moment.js): type
(date|time|datetime), value/onChange (Date), min/max, grilla de mes con navegación
prev/next. Para la hora (time/datetime) usa un <input type="time"> nativo dentro del popover
en vez de replicar los selectores de horas/minutos con scroll del original — la grilla del
calendario es la parte que realmente pedía UI custom. No incluye la navegación rápida
prev/siguiente por día/mes/año (skipBy) del original.
Overlays y tabla (Batch 4)
<Modal /> / <ModalTitle /> / <ModalSubtitle />
<Modal
open={open}
onClose={() => setOpen(false)}
size="md"
allowClose
icon={<Icon name="alert-circle" type="warning" />}
title={<ModalTitle type="warning">Confirmar acción</ModalTitle>}
subtitle={<ModalSubtitle>Esta operación no se puede deshacer</ModalSubtitle>}
footerLeft={<Button type="default" label="Cancelar" onClick={() => setOpen(false)} />}
footerRight={<Button type="danger" label="Eliminar" onClick={confirmar} />}
>
<p>Contenido del modal.</p>
</Modal>open/onClose (controlado — reemplaza el show()/close() imperativo del original), size
(sm|md|lg), allowClose (cruz para cerrar), allowBackdropClose, allowEscClose, icon,
title/subtitle (slots libres — ModalTitle/ModalSubtitle son componentes opcionales que sólo
aportan el color por type), footerLeft/footerCenter/footerRight (slots del footer,
normalmente Buttons).
<Tooltip />
<Tooltip content="Ayuda contextual" placement="top">
<Button type="info" label="?" />
</Tooltip>content, placement (top|bottom|left|right), disabled, children (elemento que dispara el
tooltip al hover/focus). Simplificación grande respecto al original: tooltip-content medía el host
con getBoundingClientRect y hacía polling de :hover cada 100ms con setInterval para
reposicionarse; acá el posicionamiento es CSS puro (el wrapper es position: relative, el tooltip
position: absolute centrado por placement) y la visibilidad es estado de React on hover/focus —
sin mediciones ni intervalos.
<Table />
<Table
columns={[
{ key: 'nombre', label: 'Nombre', sortable: true, sort: (a, b) => a.nombre.localeCompare(b.nombre) },
{ key: 'rol', label: 'Rol', optional: true, filterBy: (r) => r.rol },
{ key: 'edad', label: 'Edad', right: true, sortable: true, sort: (a, b) => a.edad - b.edad },
]}
data={personas}
rowKey={(p) => p.id}
selectable
selectedRowKey={seleccionado}
onRowClick={(p) => setSeleccionado(p.id)}
/>columns (TableColumn<T>[] — key, label, sortable+sort, optional (hideable por el
dropdown de columnas), right, filterBy (agrega un dropdown de filtro por checkbox), render
(celda custom, default String(row[key]))), data, rowKey, selectable+selectedRowKey+
onRowClick, size (sm|md), height/offset (altura fija con scroll), showColumnToggle
(default: true si hay alguna columna optional), onScrollEnd (scroll infinito, vía
useScrollEnd), headOpacity, title.
Cambio de arquitectura más grande de todo el port: el original no renderiza filas — plex-table
sólo genera el <thead> a partir de columns y deja el <tbody> completo a cargo del consumidor
vía ng-content select="tr" (cada <tr> hecho a mano, con *plTableCol="'key'" por celda para
mostrar/ocultar según columnas visibles). Acá Table es data-driven de punta a punta: las filas
salen de columns[].render, no de JSX escrito a mano por fila — es el patrón habitual en tablas
React (más parecido a TanStack Table que a la composición de Angular) y es lo que hace posible que
"full-featured" (sort + columnas + filtros) sea una sola prop columns, no una plantilla por fila.
Por debajo, Table comparte el hook interno useTableColumns con List — el equivalente a que el
original comparta PlexColumnDirective entre plex-table y plex-list vía @Optional() @Self().
<List />
<List
columns={columnasDePersona}
data={personas}
rowKey={(p) => p.id}
media={(p) => <Icon name="account" type="info" />}
actions={(p) => <Button size="sm" type="info" label="Ver" onClick={() => verPerfil(p)} />}
/>Mismas columns/data/rowKey/sort/filtros/showColumnToggle/scroll que Table (comparten
useTableColumns), pero las filas se renderizan como Heading/Item (grid) en vez de
<table>/<tr> — es el port de plex-list, que en el original literalmente comparte la misma
PlexColumnDirective que plex-table. Además: striped, inverted, size (sm|md|lg), media
(slot izquierdo por fila), actions (la "botonera" por fila).
<Visualizador />
<Visualizador
open={open}
files={[fotoUrl, { url: adjuntoUrl, ext: 'pdf' }]}
index={index}
onChange={setIndex}
onClose={() => setOpen(false)}
/>Lightbox controlado. files ((string | {url, ext})[] — un string se asume imagen), index,
onChange (flechas ←/→ o los íconos prev/next), onClose (backdrop, ícono, o Escape),
onImageClick. Archivos con extensión no-imagen (pdf, doc, xls, ...) se muestran como link de
descarga en vez de <img>, igual que el original.
<Help />
<Help titulo="Ayuda" size="sm" icon="help">
<p>Contenido de ayuda.</p>
</Help>titulo, size (sm|md|lg|auto|full — controla el ancho del panel), btnSize/btnType/label/
icon (el botón trigger), inverted, scroll+maxHeight, onOpen/onClose, children.
Simplificación respecto al original: plex-help envuelve mat-menu de Angular Material y coordina
con un HelpService para que abrir una ayuda cierre cualquier otra que estuviera abierta. Acá es un
popover autocontenido con el mismo patrón que Dropdown/Datetime (cierra solo al clickear afuera)
— cada instancia maneja su propio estado, sin el "cerrar la anterior" del servicio compartido.
Wizard, Toast y directivas menores (Batch 5)
Los dos únicos componentes de plex que dependían de librerías de terceros (SweetAlert2/Intro.js para Wizard, angular2-notifications para Toast). Después de calibrar con el usuario, se resolvieron así:
- Toast: se adoptó
sonnercomo dependencia — ya resuelve stacking, timers, pausa-en-hover, posición y animaciones; reimplementar todo eso a mano no aportaba nada sobre una librería madura y muy chica (~5kb). - Wizard: el modo
fullScreense construyó a mano sobre el propioModal(no hacía falta SweetAlert2 para una secuencia de modales). El modo spotlight/tour sí se resolvió con una librería —react-joyride, el equivalente directo de Intro.js en React — en vez de replicar a mano el resaltado de elementos + posicionamiento del tooltip + auto-scroll, que si valía la pena delegar en una librería especializada. - Ambas librerías son
dependenciesnormales del paquete (nopeerDependencies) — tsup las dejaexternalautomáticamente (igual que hace conclsx), así que no se bundlean: quedan resueltas víanode_modulescuando alguien instala@kucho77/design-system, sin duplicar código.
<Toast /> + toast
// una vez, cerca de la raíz de la app:
import { Toast } from '@kucho77/design-system';
<Toast position="top-right" richColors />
// en cualquier lado:
import { toast } from '@kucho77/design-system';
toast.success('Guardado', 'Los cambios se guardaron correctamente');
toast.error('Error', 'No se pudo completar la operación');
toast.info('Info');
toast.warn('Atención', 'Revisá este dato');
toast.message('Mensaje simple');
toast.dismiss(); // cierra todosToast: position, richColors, visibleToasts, closeButton — pasa directo a sonner's
<Toaster />. toast: mismo shape que NotificationsService del original (title + content?
opcional) — internamente arma { description: content } para sonner. warn (no warning) se
mantuvo para matchear el nombre del método original.
<Wizard />
// fullScreen: modales secuenciales (SweetAlert2 en el original)
<Wizard
id="alta-paciente" updatedOn={new Date('2026-01-01')}
steps={[{ title: 'Bienvenido', content: '...' }, { title: 'Listo', content: '...' }]}
open={open} fullScreen showNumbers
onFinish={(completado) => setOpen(false)}
/>
// spotlight/tour: resalta elementos del DOM (Intro.js en el original)
<Wizard
id="tour-inicio" updatedOn={new Date('2026-01-01')}
steps={[{ title: '...', content: '...', target: '#mi-boton', position: 'bottom' }]}
open={open} showNumbers
onFinish={(completado) => setOpen(false)}
/>id+updatedOn: misma clave que el original para "no volver a mostrar"
(localStorage['wizard-{id}-{updatedOn}-hide']), seteada sólo cuando el wizard se completa (no al
cancelar/saltear — igual que el original). forceShow bypassea el check. fullScreen (default
false) elige el modo. steps: title, content, imageClass? (sólo fullScreen), target?+
position? (sólo spotlight, target es un selector CSS). showNumbers muestra el progreso
(1 / 3 en fullScreen, Siguiente (1 de 3) en spotlight). open/onFinish(completado)
controlado, igual que Modal.
Nota sobre spotlight: react-joyride por defecto exige un click extra sobre un "beacon" (un punto
pulsante) antes de mostrar el tooltip de cada paso — acá se desactiva (skipBeacon: true) para que
cada paso aparezca directo, igual que hacía Intro.js.
Directivas menores → utils y hooks
import { alignClasses, caseClasses, growClasses, spanClasses, useIsMobile, useRipple } from '@kucho77/design-system';
<div className={alignClasses('center')}>...</div>
<span className={caseClasses('capitalize-first')}>...</span>
<div className={growClasses('2')}>...</div> {/* dentro de un flex container */}
<div className={spanClasses('3')}>...</div> {/* dentro de <Grid> */}
const isMobile = useIsMobile(); // max-width: 599px, mismo corte que el original
const ripple = useRipple<HTMLButtonElement>();
<button ref={ripple.ref} className={ripple.className} onPointerDown={ripple.onPointerDown}>...</button>Puertos de [align], [case], [grow], [span], [mobile] y [plexRipples] — directivas de
Angular que sólo agregaban clases CSS u ocultaban contenido, sin equivalente natural a componente.
align/case/grow/span devuelven className (clases propias ds-*, con su propio CSS —no
Tailwind, para no depender de que el content-scanning de la app detecte clases generadas en
runtime). useIsMobile reemplaza el *mobile/*mobile="false" estructural por un booleano para
ramificar en JSX. useRipple es el único explícitamente "opcional" (no se aplicó a Button ni a
ningún componente existente — el usuario lo pidió como efecto CSS opt-in, no retroactivo).
[responsive] y [justify] ya estaban cubiertos por useContainerBreakpoint (Batch 1) y
justifyClasses (Batch 1) respectivamente — no se tocaron en este batch.
Build
npm run build # tsup — genera dist/index.js, dist/index.d.ts, dist/index.css
npm run dev # tsup --watch