lodash declares no exports, so `lodash/isEqual` resolves in a bundler but not in Node's ESM loader: in 0.0.3 apps could not load @pi/ui in tests or when rendering on the server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
17 KiB
Markdown
301 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 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<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`.
|