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>
17 KiB
@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)
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]:
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.
Tres piezas pensadas para que reskinear la librería en otro proyecto sea barato:
PiThemeService(util/theme) — inyectable, contheme/darkModecomo señales normales (mutables con setters o con[(value)]/[(checked)]directo), persistencia enlocalStoragey aplicación de clases víaeffect(). Sustituye al cableado manual que antes vivía en cada app consumidora (ver elapp.tsdel playground antes/después de esta extracción).nx g @pi/ui:theme <nombre>— genera_<nombre>.scss, lo registra enthemes/index.scss, añade la entrada aShellClassy extiendePiThemeService; 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:
formControlNameyformGroupNamedesaparecen.incremental-range-fieldyrange-sliderya no leen unFormGroupanidado: reciben el rango completo porvalue(tipoPiRange, 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 inputerrors, usandoerror.messagey cayendo aerror.kindsi no lo hay. La clase base exponeerrorMessages(). - Los campos numéricos usan
transformedValue, el mecanismo de Signal Forms para parsear texto→número reportando errores de parseo al campo. ngx-currencyse ha eliminado decurrency-field. Su directivacurrencyMaskes un ControlValueAccessor y no funciona sin reactive forms. El formateo (decimal,, miles., 2 decimales) se replica contransformedValue+toLocaleString('es-ES'). Se pierde el enmascarado en vivo mientras se escribe: ahora se formatea al salir del campo.slide-toggleya no extiendePiBaseFormFieldComponent: el contrato de casilla exigecheckedy prohíbe declararvalue. También desaparece el campoisCheckedque se sincronizaba por suscripción.touch: los controles emiten esta salida en elblurpara 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 abuildChart().PiBaseCartesianChartComponent extends PiBaseChartComponent— depi-line-chart/pi-bar-chart: recibendatasets(una serie por elemento, comparadas a lo largo dexAxisMarks), usanpi-chart-legend(leyenda propia, no la de Chart.js) y comparten el tooltip/hover.PiBaseArcChartComponent extends PiBaseChartComponent— depi-pie-chart/pi-doughnut-chart: recibenslices({ label, value, color }[], tipoChartSlice), nodatasets— 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 depi-chart-legend.
<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"enpi-line-chartexige el pluginFillerde Chart.js. Sin registrarlo,fill: trueen el dataset no falla — se ignora en silencio y el gráfico sale sin rellenar. Ya está registrado enline-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 elbackgroundColordel 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í quepointBackgroundColorconserva el color original ybackgroundColorpasa a translúcido solo cuandofilledestá activo (verhexToRgbaenchart.configs.ts). DatasetColorno teníasuccess/warn(soloprimary/accent/highlighted/grey), así que unpie/doughnutno podía representar estados (pagado/pendiente/rechazado, etc.) con los mismos colores quepi-chip-status. Se añadieron con los mismos hex que--pi-color-success/--pi-color-warn.Dataset.cubicInterpolationModepasa a opcional. Solo tiene sentido enline; forzarlo enbar/pie/doughnutno aportaba nada y esos tipos ni siquiera usanDataset(los dos últimos usanChartSlice).- Cada gráfico registra lo suyo en
buildChart(), no al importar. El paquete es un únicofesm, así que unChart.register(...)a nivel de módulo se ejecutaba en cualquier app que importase@pi/uiy 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"delpi-chartoriginal nunca funcionó: el input existía perogetLineChartConfigs()construía siempre un chartlineignorando su valor. Se descubrió al dividir los componentes;pi-bar-chartes 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— elonTabChange()del setter pasa a uneffect().PiBaseFormFieldComponent.isDisabled— pasó ainput()y después, con la migración a Signal Forms, se renombró adisabled(ver la sección de formularios).PiSearchFieldComponent.isExpanded—model(); la emisión deisActivepasa a uneffect().PiSelectOptionComponent.isSelected—model()(los selects padre lo asignan sobre las instancias hijas); sumarkForCheck()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 importaTranslatePipe/TranslateDirective. - apollo-angular 14 — no hay
ApolloModule.PiSharedUtilDatoCmsModule→providePiDatoCms(). - ngx-currency 19 —
CurrencyMaskInputMode→NgxCurrencyInputMode,NgxCurrencyModule→NgxCurrencyDirective. - chart.js 4 —
chart.js/dist/types/utilsno es importable; los tipos se derivan deChartOptions. - Angular —
TransferState/StateKey/makeStateKeyvienen de@angular/core.
Pendiente
- Tests: no se copiaron los
*.spec.tsdel origen. Sectorvivía endeale-saas/util/models(lib de aplicación, fuera de alcance); se trajo la interfaz autil/models.- Lint:
nx lint uifalla 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) yno-unused-vars. Nada bloquea el build. Para el primer grupo hay migración oficial:nx g @angular/core:inject-migration.