Files
pireon-ui/libs/ui/README.md
Josep Piguillem 499f8ac075 Keep Chart.js and the whole of lodash out of apps' initial bundle
Each chart registers what it needs when it is built instead of on
import, and lodash is imported one function at a time. In TaskStream
the initial bundle drops from 2.13 MB to 1.90 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:40 +02:00

298 lines
17 KiB
Markdown

# @pi/ui
Librería de componentes Angular migrada desde `monorepo-nx-ddd-demo/libs/shared`
(Angular 14, NgModules, decoradores) a Angular 22 con componentes standalone y
señales.
```
libs/ui/src/
index.ts barrel público de @pi/ui
lib/styles/ antes shared/styles
lib/util/<n>/ antes shared/util/<n> (24 libs)
lib/ui/<n>/ antes shared/ui/<n> (29 libs)
```
```bash
npm start # catálogo de componentes en http://localhost:4200
npm run build # construye la librería
npm run build:playground
npm run build:all # librería + playground
npm run lint
npm run format
```
## Qué se hizo
| | |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prefijo | `Dl`→`Pi`, `dl-`→`pi-` (selectores, clases CSS, nombres de fichero), `@deale-workspace/…`→rutas relativas |
| NgModules | 41 → 4 (sólo quedan los que aportan providers: `icon-registry`, `loaders`, `router-store`, `scroll-to`) |
| Componentes | 70, **todos** con `ChangeDetectionStrategy.OnPush` |
| Decoradores | 0 restantes: 460 `input()`, 6 `model()`, 20 `output()`, 8 `viewChild()`, 2 `viewChildren()`, 17 `contentChild()`, 6 `contentChildren()` |
| Control flow | `*ngIf`/`*ngFor`/`*ngSwitch` → `@if`/`@for`/`@switch` (110 `@if`, 26 `@for`, 3 `@switch`). Ya no se importa ninguna directiva de `CommonModule` |
| Formularios | Los 13 campos de `ui/form-field` y los 2 de `ui/slider` pasan de `ControlValueAccessor` + `formControlName` a **Signal Forms** (`FormValueControl` / `FormCheckboxControl`) |
Se usaron las migraciones oficiales de Angular (`@angular/core:standalone`,
`signal-input-migration`, `output-migration`, `signal-queries-migration`,
`control-flow-migration`, `cleanup-unused-imports`) y se resolvió a mano lo que
no cubren.
## Formularios: Signal Forms
Los campos ya no son `ControlValueAccessor` ni reciben `formControlName`.
Implementan los contratos de Signal Forms, así que basta con enlazar
`[formField]` — ése es el selector de la directiva `FormField`, no `[field]`:
```ts
import { form, required, min, FormField } from '@angular/forms/signals';
@Component({
imports: [FormField, PiTextFieldComponent, PiNumberFieldComponent, PiSlideToggleComponent],
template: `
<pi-text-field [formField]="f.name" placeholder="Nombre" />
<pi-number-field [formField]="f.age" />
<pi-slide-toggle [formField]="f.active" />
`,
})
export class Ejemplo {
private readonly model = signal({ name: '', age: null, active: false });
protected readonly f = form(this.model, (m) => {
required(m.name);
min(m.age, 18);
});
}
```
La directiva `FormField` sincroniza sola `value`, `disabled`, `errors`,
`touched`, `required`, `min`/`max`, `maxLength`… con el campo enlazado. De
hecho **no se pueden enlazar a mano** esas entradas en un elemento que ya use
`[formField]`: Angular lo rechaza con `NG8022`. Los validadores van en el
`form()`, no en la plantilla.
Se puede ver funcionando en la app `playground` (`nx serve playground`).
## Theming: tokens CSS
El aspecto visual (color, forma, sombra, tipografía de controles) se resuelve
en custom properties (`--pi-color-*`, `--pi-theme-*`) aplicadas vía `ShellClass`
(`pi-shell pi-shell__<tema>`). Documentación completa, tabla de valores por
tema (Default/Alternative/Minimal/Gradient) y guía para añadir tokens o temas
nuevos: [`TOKENS.md`](./TOKENS.md).
Tres piezas pensadas para que reskinear la librería en otro proyecto sea
barato:
- **`PiThemeService`** (`util/theme`) — inyectable, con `theme`/`darkMode`
como señales normales (mutables con setters o con `[(value)]`/`[(checked)]`
directo), persistencia en `localStorage` y aplicación de clases vía
`effect()`. Sustituye al cableado manual que antes vivía en cada app
consumidora (ver el `app.ts` del playground antes/después de esta
extracción).
- **`nx g @pi/ui:theme <nombre>`** — genera `_<nombre>.scss`, lo registra en
`themes/index.scss`, añade la entrada a `ShellClass` y extiende
`PiThemeService`; el tema queda seleccionable sin pasos manuales.
- **`nx run ui:lint-styles`** — regla de stylelint (`pi/no-raw-color-token`)
que detecta un `--pi-color-*` usado fuera del fallback de un
`--pi-theme-*`, la misma clase de bug que dejó colores de marca fijos en
modo oscuro (accordion/tab/select-option, antes de tokenizarlos). Target
aparte por ahora — ver TOKENS.md para el porqué y el estado de adopción.
| Componente | Contrato | Tipo del valor |
| ---------------------------------------------------- | --------------------- | ------------------------------------- |
| text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` |
| number, currency, incremental-number | `FormValueControl` | `number \| null` |
| slide-toggle | `FormCheckboxControl` | `boolean` (usa `checked`, no `value`) |
| incremental-range, range-slider | `FormValueControl` | `PiRange` (`{from, to}`) |
| slider | `FormValueControl` | `number` |
Detalles a tener en cuenta:
- **`formControlName` y `formGroupName` desaparecen.** `incremental-range-field`
y `range-slider` ya no leen un `FormGroup` anidado: reciben el rango completo
por `value` (tipo `PiRange`, exportado por la librería).
- **`isDisabled` → `disabled`**, que es el nombre del contrato y ahora es una
señal (`disabled()`).
- **Los mensajes de error** venían de `control.errors['message']` (un array de
claves i18n). Ahora se leen del input `errors`, usando `error.message` y
cayendo a `error.kind` si no lo hay. La clase base expone `errorMessages()`.
- **Los campos numéricos usan `transformedValue`**, el mecanismo de Signal Forms
para parsear texto→número reportando errores de parseo al campo.
- **`ngx-currency` se ha eliminado de `currency-field`.** Su directiva
`currencyMask` es un ControlValueAccessor y no funciona sin reactive forms.
El formateo (decimal `,`, miles `.`, 2 decimales) se replica con
`transformedValue` + `toLocaleString('es-ES')`. **Se pierde el enmascarado en
vivo mientras se escribe**: ahora se formatea al salir del campo.
- **`slide-toggle` ya no extiende `PiBaseFormFieldComponent`**: el contrato de
casilla exige `checked` y prohíbe declarar `value`. También desaparece el
campo `isChecked` que se sincronizaba por suscripción.
- **`touch`**: los controles emiten esta salida en el `blur` para que el campo
se marque como tocado.
Los selects (`ui/select`) siguen con `ControlValueAccessor`; quedaron fuera del
alcance de esta migración.
## Charts
`pi-chart` (un componente, un input `type`) se dividió en **4 componentes
independientes** — `pi-line-chart`, `pi-bar-chart`, `pi-pie-chart`,
`pi-doughnut-chart` —, cada uno en su propia carpeta, con su propio selector y
su propio registro de Chart.js. Ningún consumidor arrastra código de un tipo
de gráfico que no usa: `pi-pie-chart` no registra `LineController`/`BarController`.
Comparten dos bases abstractas (no un único componente con `@switch`):
- `PiBaseChartComponent` — el `<canvas>`, `device`, y el ciclo de vida que
llama a `buildChart()`.
- `PiBaseCartesianChartComponent extends PiBaseChartComponent` — de
`pi-line-chart`/`pi-bar-chart`: reciben `datasets` (una serie por
elemento, comparadas a lo largo de `xAxisMarks`), usan `pi-chart-legend`
(leyenda propia, no la de Chart.js) y comparten el tooltip/hover.
- `PiBaseArcChartComponent extends PiBaseChartComponent` — de
`pi-pie-chart`/`pi-doughnut-chart`: reciben `slices`
(`{ label, value, color }[]`, tipo `ChartSlice`), no `datasets` — la forma
de los datos es otra (una magnitud repartida en porciones, cada una con su
propio color, no varias series en el tiempo) — y usan la leyenda **nativa**
de Chart.js en vez de `pi-chart-legend`.
```html
<pi-line-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" />
<pi-line-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" [filled]="true" /> <!-- área -->
<pi-bar-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" />
<pi-pie-chart [device]="device" [slices]="slices" />
<pi-doughnut-chart [device]="device" [slices]="slices" />
```
Detalles a tener en cuenta:
- **`[filled]="true"` en `pi-line-chart` exige el plugin `Filler` de
Chart.js.** Sin registrarlo, `fill: true` en el dataset no falla — se
ignora en silencio y el gráfico sale sin rellenar. Ya está registrado en
`line-chart.component.ts`; si se añade otro tipo relleno, hay que
registrarlo ahí también.
- **El área usa un color traducido a `rgba(…, 0.15)`, no el `backgroundColor`
del dataset tal cual.** Ese campo también pinta el relleno de los puntos;
si se reutilizase el color opaco para el área saldría un bloque sólido en
vez de una zona translúcida, así que `pointBackgroundColor` conserva el
color original y `backgroundColor` pasa a translúcido solo cuando `filled`
está activo (ver `hexToRgba` en `chart.configs.ts`).
- **`DatasetColor` no tenía `success`/`warn`** (solo `primary`/`accent`/
`highlighted`/`grey`), así que un `pie`/`doughnut` no podía representar
estados (pagado/pendiente/rechazado, etc.) con los mismos colores que
`pi-chip-status`. Se añadieron con los mismos hex que `--pi-color-success`/
`--pi-color-warn`.
- **`Dataset.cubicInterpolationMode` pasa a opcional.** Solo tiene sentido en
`line`; forzarlo en `bar`/`pie`/`doughnut` no aportaba nada y esos tipos ni
siquiera usan `Dataset` (los dos últimos usan `ChartSlice`).
- **Cada gráfico registra lo suyo en `buildChart()`, no al importar.** El
paquete es un único `fesm`, así que un `Chart.register(...)` a nivel de
módulo se ejecutaba en cualquier app que importase `@pi/ui` y metía Chart.js
entero en su bundle inicial, pintase gráficos o no (en TaskStream, unos
230 kB). Registrar dos veces lo mismo no hace nada. Si se añade un tipo de
gráfico, su registro va ahí también.
- **El `type="bar"` del `pi-chart` original nunca funcionó**: el input
existía pero `getLineChartConfigs()` construía siempre un chart `line`
ignorando su valor. Se descubrió al dividir los componentes; `pi-bar-chart`
es la primera versión que realmente renderiza barras.
**`lodash` se importa por función** (`lodash/isEqual`, `lodash/some`,
`lodash/capitalize`), nunca `import _ from 'lodash'`: lodash es CommonJS y el
import completo mete la librería entera en el bundle de quien use un select.
**El loader de servidor de traducciones ya no está en el barrel.**
`util/translate/loaders/translate-server.loader` usa `fs`, `path` y `process`;
exportarlo desde `@pi/ui` rompía el bundle de navegador de cualquier app que
importase la librería. Se importa por su ruta directa desde el arranque de SSR.
## Decisiones que cambian comportamiento
Revisar estos puntos antes de usar la librería en producción:
**1. `input.required` no significa lo mismo que el antiguo `!`.**
La migración convierte `@Input() x!: T` en `input.required<T>()`, pero en
Angular 14 no existían las entradas obligatorias: el `!` sólo era una aserción
de asignación definida. Se devolvieron a opcionales las que las propias
plantillas de la librería demostraban que no eran obligatorias (`allowPropagation`,
`flexDirection`, `value` de text-viewer, `gap`/`icon`/`label` de divider,
`link`/`gap` de headline, `device`/`justify`/`itemWidth` de carousel, las de
grid-item…). **Las `input.required` que quedan endurecen la API respecto al
original**; conviene repasarlas contra el uso real en las aplicaciones.
**2. Entradas con accessor → `input()`/`model()` + `effect()`.**
Seis entradas tenían getter/setter con efectos secundarios. El efecto se ejecuta
durante la detección de cambios, no de forma síncrona en la asignación:
- `PiTabComponent.defaultSelectedTab` — el `onTabChange()` del setter pasa a un `effect()`.
- `PiBaseFormFieldComponent.isDisabled` — pasó a `input()` y después, con la migración a Signal Forms, se renombró a `disabled` (ver la sección de formularios).
- `PiSearchFieldComponent.isExpanded` — `model()`; la emisión de `isActive` pasa a un `effect()`.
- `PiSelectOptionComponent.isSelected` — `model()` (los selects padre lo asignan sobre las instancias hijas); su `markForCheck()` manual ya no hace falta.
- `PiBaseMenuComponent.menuItems` — el setter era un pass-through puro.
- `PiSelectMultipleComponent.selectedOptions` — sólo tenía getter, así que el `@Input()` nunca pudo asignarse desde fuera: era un valor derivado.
**3. `PiAccordionComponent.expandedItemIndex` pasa a `model()`.**
Tenía un two-way binding hecho a mano (`expandedItemIndex` + `expandedItemIndexChange`).
`model()` genera el evento automáticamente. **Ojo:** el código original emitía el
valor _anterior_ al plegar/desplegar (`emit(expandedItemIndex)` antes de reasignar);
`model()` emite el nuevo. Parece que el original tenía un bug. Su tipo pasa a
`number | null` porque `toggleState` asigna `null` al colapsar.
**4. Suscripciones a `.changes` eliminadas.**
Las signal queries no exponen `QueryList.changes`. Donde sólo se usaba para
`markForCheck()` (accordion, tab, carousel) se eliminó: Angular ya reevalúa la
plantilla al cambiar la señal. En los 4 selects se sustituyó por `toObservable()`.
**5. Salidas que emitían `any`.**
`changePage` y `toggledTo` eran `new EventEmitter()` sin genérico. Ahora son
`output<number>()` y `output<'on' | 'off'>()`.
**6. Aserciones `!` en plantillas.**
`*ngIf="icon()"` no estrecha el tipo (TypeScript no estrecha resultados de
llamada entre expresiones), así que hay `[svgIcon]="icon()!"` en ~30 sitios.
Mantiene el runtime idéntico al original. Se pueden eliminar migrando a
`@if (icon(); as icon)`.
**7. Precedencia de pipes en select-multiple.**
`x | truncate : 33 || (placeholder() | translate)` se parseaba como
`truncate : (33 || …)`, así que el fallback nunca se aplicaba. Ahora está
parentizado como se pretendía.
**8. Los 3 selects se convirtieron a control flow a mano.**
La migración automática se rindió con ellos: tenían un `*ngIf ... else` que
apuntaba a un `<ng-template>` con un `[ngSwitch]` dentro. Al reescribirlos hubo
que renombrar plantillas duplicadas (`#defaultSelected` aparecía dos veces en
ramas distintas del switch) a `#defaultSelectedFill`/`#defaultSelectedOutline`,
y en select-period `#defaultOption` → `#defaultMonthOption`/`#defaultYearOption`.
Los `*ngFor` sin `trackBy` pasaron a `track <item>`, que es lo que hacía `ngFor`
por defecto. En data-grid y select-carousel la migración generó
`track trackByFn(i, item)` pero esos `trackByFn` sólo declaran `index`: se
corrigió la aridad a `track trackByFn(i)`.
## Colisiones de nombres en el barrel
El origen tiene símbolos duplicados. En `index.ts` gana el primero y el otro se
omite (queda anotado en el fichero). Siguen siendo accesibles por ruta directa:
| Símbolo | Se exporta desde | Se omite de |
| ------------------------- | ---------------- | ------------------ |
| `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` |
| `PiDisabledDirective` | `util/status` | `ui/text` |
## APIs de terceros actualizadas
- **ngx-translate 18** — no hay `TranslateModule`. `PiSharedUtilTranslateModule` → `providePiTranslate()`; en plantillas se importa `TranslatePipe`/`TranslateDirective`.
- **apollo-angular 14** — no hay `ApolloModule`. `PiSharedUtilDatoCmsModule` → `providePiDatoCms()`.
- **ngx-currency 19** — `CurrencyMaskInputMode`→`NgxCurrencyInputMode`, `NgxCurrencyModule`→`NgxCurrencyDirective`.
- **chart.js 4** — `chart.js/dist/types/utils` no es importable; los tipos se derivan de `ChartOptions`.
- **Angular** — `TransferState`/`StateKey`/`makeStateKey` vienen de `@angular/core`.
## Pendiente
- **Tests**: no se copiaron los `*.spec.ts` del origen.
- **`Sector`** vivía en `deale-saas/util/models` (lib de aplicación, fuera de alcance); se trajo la interfaz a `util/models`.
- **Lint**: `nx lint ui` falla con avisos heredados del código original —
`prefer-inject` (51), `directive-selector` (46), `no-explicit-any` (35),
`no-unused-expressions` (33), a11y (`click-events-have-key-events`,
`interactive-supports-focus`, `label-has-associated-control`) y
`no-unused-vars`. Nada bloquea el build. Para el primer grupo hay migración
oficial: `nx g @angular/core:inject-migration`.