npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-system a los workspaces de ese proyecto y declará la dependencia igual que hace menu-react ("@kucho77/design-system": "*").
  • Proyecto separado: copiá la carpeta packages/design-system y publicala a tu registry interno (npm publish, sacando antes el "private": true del package.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|autoauto-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ó sonner como 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 fullScreen se construyó a mano sobre el propio Modal (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 dependencies normales del paquete (no peerDependencies) — tsup las deja external automáticamente (igual que hace con clsx), así que no se bundlean: quedan resueltas vía node_modules cuando 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 todos

Toast: 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