# @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// antes shared/util/ (24 libs) lib/ui// antes shared/ui/ (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: ` `, }) 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__`). 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 `** — genera `_.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 ``, `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 ``` 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 y con extensión** (`lodash/isEqual.js`, `lodash/some.js`, `lodash/capitalize.js`), nunca `import _ from 'lodash'`: lodash es CommonJS y el import completo mete la librería entera en el bundle de quien use un select. La extensión hace falta porque lodash no declara `exports`: un empaquetador resuelve `lodash/isEqual`, pero Node en ESM (Vitest, SSR) no, y la app falla al cargar `@pi/ui`. **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()`, 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()` 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 `` 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 `, 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`.