An untouched field with errors was painted red from the start, so a form with required fields opened full of «Campo obligatorio». Text fields, selects, checkboxes, radio groups and file uploads now wait until the field is touched (blur, or the form's submit()). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
17 KiB
Markdown
306 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.
|
|
|
|
Los errores se ven cuando el campo está **tocado**: al salir de él, o al
|
|
enviar con `submit()` de Signal Forms, que marca todo el formulario. Un campo
|
|
obligatorio vacío no sale en rojo nada más abrir el formulario. Para volver a
|
|
abrir un formulario como nuevo, `f().reset(valor)` pone los valores y quita el
|
|
estado de tocado.
|
|
|
|
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.
|
|
|
|
**Sin lodash.** Las tres funciones que se usaban (`isEqual`, `some` con un
|
|
objeto como criterio y `capitalize`) están en `util/common/compare.ts`, con la
|
|
misma semántica. lodash es CommonJS y sin mapa de `exports`: entero pesaba en el
|
|
bundle de quien usara un select, y por funciones (`lodash/isEqual`) Node en ESM
|
|
(Vitest, SSR) no lo resolvía.
|
|
|
|
**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`.
|