This commit is contained in:
2026-07-30 21:20:00 +02:00
parent 85886a094a
commit a0908e1b5a
85 changed files with 1396 additions and 557 deletions

View File

@@ -31,6 +31,8 @@ const TRANSLATIONS: TranslationObject = {
max: 'Máx', max: 'Máx',
save: 'Guardar', save: 'Guardar',
reset: 'Borrar', reset: 'Borrar',
search: 'Buscar…',
noResults: 'Sin resultados',
validator: { validator: {
requiredField: 'Este campo es obligatorio', requiredField: 'Este campo es obligatorio',
onlyNumbers: 'Sólo se admiten números', onlyNumbers: 'Sólo se admiten números',

View File

@@ -81,6 +81,28 @@
<pi-label>Fecha de nacimiento</pi-label> <pi-label>Fecha de nacimiento</pi-label>
</pi-date-field> </pi-date-field>
<pi-select-field [formField]="f.language" [options]="languages">
<pi-label>Idioma</pi-label>
</pi-select-field>
<pi-searchable-select-field
[formField]="f.country2"
[options]="countryOptions"
>
<pi-label>País</pi-label>
</pi-searchable-select-field>
<pi-searchable-multi-select-field
[formField]="f.visited"
[options]="countryOptions"
>
<pi-label>Países visitados</pi-label>
</pi-searchable-multi-select-field>
<pi-multi-select-field [formField]="f.skills" [options]="skillOptions">
<pi-label>Conocimientos</pi-label>
</pi-multi-select-field>
<pi-radio-group <pi-radio-group
[formField]="f.shipping" [formField]="f.shipping"
[options]="shippingOptions" [options]="shippingOptions"

View File

@@ -91,6 +91,11 @@ import {
PiSelectCarouselComponent, PiSelectCarouselComponent,
PiSelectComponent, PiSelectComponent,
PiSelectMultipleComponent, PiSelectMultipleComponent,
PiSearchableMultiSelectFieldComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
PiSelectFieldOption,
PiMultiSelectFieldComponent,
PiSelectOptionComponent, PiSelectOptionComponent,
PiSelectPeriodComponent, PiSelectPeriodComponent,
PiSlideToggleComponent, PiSlideToggleComponent,
@@ -148,6 +153,10 @@ interface DemoModel {
plan: SelectOptionValue | null; plan: SelectOptionValue | null;
terms: boolean; terms: boolean;
shipping: string | null; shipping: string | null;
language: string | null;
country2: string | null;
visited: string[];
skills: string[];
birthDate: Date | null; birthDate: Date | null;
attachments: File[]; attachments: File[];
} }
@@ -240,6 +249,10 @@ interface DemoModel {
PiSelectPeriodComponent, PiSelectPeriodComponent,
PiSelectCarouselComponent, PiSelectCarouselComponent,
PiSelectOptionComponent, PiSelectOptionComponent,
PiSelectFieldComponent,
PiMultiSelectFieldComponent,
PiSearchableSelectFieldComponent,
PiSearchableMultiSelectFieldComponent,
// Datos // Datos
PiDataGridComponent, PiDataGridComponent,
PiCellDirective, PiCellDirective,
@@ -285,6 +298,10 @@ export class CatalogPage {
plan: null, plan: null,
terms: false, terms: false,
shipping: null, shipping: null,
language: null,
country2: null,
visited: [],
skills: [],
birthDate: null, birthDate: null,
attachments: [], attachments: [],
}); });
@@ -312,6 +329,71 @@ export class CatalogPage {
{ id: 4, name: 'Ventas' }, { id: 4, name: 'Ventas' },
]; ];
protected readonly languages: PiSelectFieldOption<string>[] = [
{ value: 'es', label: 'Español' },
{ value: 'en', label: 'Inglés' },
{ value: 'fr', label: 'Francés' },
{ value: 'de', label: 'Alemán', disabled: true },
];
/** Lista larga a propósito: es donde el buscador aporta algo. */
protected readonly countryOptions: PiSelectFieldOption<string>[] = [
'Alemania',
'Andorra',
'Argentina',
'Australia',
'Austria',
'Bélgica',
'Bolivia',
'Brasil',
'Canadá',
'Chile',
'China',
'Colombia',
'Corea del Sur',
'Costa Rica',
'Cuba',
'Dinamarca',
'Ecuador',
'Egipto',
'España',
'Estados Unidos',
'Finlandia',
'Francia',
'Grecia',
'Guatemala',
'Honduras',
'Hungría',
'India',
'Irlanda',
'Islandia',
'Italia',
'Japón',
'Marruecos',
'México',
'Noruega',
'Países Bajos',
'Panamá',
'Paraguay',
'Perú',
'Polonia',
'Portugal',
'Reino Unido',
'República Dominicana',
'Rumanía',
'Suecia',
'Suiza',
'Uruguay',
'Venezuela',
].map((name) => ({ value: name.toLowerCase(), label: name }));
protected readonly skillOptions: PiSelectFieldOption<string>[] = [
{ value: 'angular', label: 'Angular' },
{ value: 'rxjs', label: 'RxJS' },
{ value: 'scss', label: 'SCSS' },
{ value: 'a11y', label: 'Accesibilidad' },
];
protected readonly plans = [ protected readonly plans = [
{ id: 1, name: 'Básico', description: '9 €/mes' }, { id: 1, name: 'Básico', description: '9 €/mes' },
{ id: 2, name: 'Pro', description: '29 €/mes' }, { id: 2, name: 'Pro', description: '29 €/mes' },

View File

@@ -47,3 +47,35 @@
display: none; display: none;
} }
} }
// --- Superficie del panel -----------------------------------------------
//
// El calendario venía con el gris y el radio de Material de serie. Se
// reconstruye con los mismos tokens que el panel del select y el resto de
// superficies de la librería.
.cdk-overlay-pane .mat-datepicker-content,
.mat-datepicker-content {
border-radius: var(--pi-theme-panel-radius);
border: 1px solid var(--pi-theme-field-border-color);
background-color: var(--pi-theme-field-surface);
box-shadow: var(--pi-box-shadow-panel);
.mat-calendar-body-label,
.mat-calendar-table-header th {
color: var(--pi-color-grey-300);
}
// Día de hoy y día seleccionado, con los colores de marca.
.mat-calendar-body-today:not(.mat-calendar-body-selected) {
border-color: var(--pi-color-primary);
}
.mat-calendar-body-selected {
background-color: var(--pi-color-primary);
color: var(--pi-color-white);
}
.mat-calendar-body-cell-content:hover:not(.mat-calendar-body-selected) {
background-color: var(--pi-color-primary-50);
}
}

View File

@@ -0,0 +1,163 @@
// Select
//
// Cubre lo que usan `pi-select-field` y `pi-multi-select-field`. Son dos cosas
// distintas y por eso están separadas:
//
// 1. El *trigger*, dentro del campo. `_mat-form-field.scss` estira el `<input>`
// para que llene el alto de la caja, pero no hay equivalente para el select:
// su trigger mide lo que su contenido (24px) y quedaba pegado arriba, con el
// texto y la flecha ~10px por encima del centro.
//
// 2. El *panel*, que se monta en el overlay y por tanto fuera del campo. Venía
// con el gris y el radio de 4px de Material de serie, sin relación con la
// librería. Se reconstruye con los mismos tokens que el resto de superficies.
// --- 1. Trigger: centrado vertical dentro del campo ----------------------
.pi-field {
// El `<mat-select>` es `inline-block` y mide lo que su contenido (24px)
// dentro de un infix de 48px. Estirarlo es lo que de verdad centra: poner
// `height: 100%` solo en el trigger no servía, porque ese 100% se resolvía
// contra los 24px del propio select.
.mat-mdc-select {
height: 100%;
}
.mat-mdc-select-trigger {
display: flex;
align-items: center;
height: 100%;
}
// El valor deja de ser un bloque suelto arriba y se centra con el trigger.
.mat-mdc-select-value {
display: flex;
align-items: center;
}
// La flecha va dentro del infix, mientras que los iconos sufijo del resto de
// campos van fuera de él. Entre ambos anclajes hay 28px: los 12 del
// `translateX(-0.75rem)` que `_mat-form-field.scss` aplica al infix, más la
// diferencia de padding entre el flex y el wrapper. Se posiciona respecto al
// infix (que ya es `position: relative`) sacándola esos 28px, para que quede
// al mismo inset que los demás iconos.
.mat-mdc-select-arrow-wrapper {
position: absolute;
right: -1.75rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
}
// Material dimensiona `.mat-mdc-select-arrow` para el triángulo CSS antiguo
// (10x5) pero dentro pinta un SVG de 24x24, que se salía 7px por cada lado.
// Igualando la caja al glifo deja de desbordar y queda a la misma altura y
// color que los iconos sufijo del resto de campos.
.mat-mdc-select-arrow {
display: flex;
align-items: center;
justify-content: center;
width: var(--pi-spacing-large);
height: var(--pi-spacing-large);
color: var(--pi-color-grey-300);
svg {
position: static;
transform: none;
width: var(--pi-spacing-large);
height: var(--pi-spacing-large);
}
}
// Mismos estados que los afijos de icono en `_mat-form-field.scss`.
&.is-focused .mat-mdc-select-arrow {
color: var(--pi-color-primary);
}
// Estados del propio campo, en línea con `_mat-form-field.scss`.
.mat-mdc-select-placeholder {
color: var(--pi-color-grey-300);
}
&.has-error .mat-mdc-select-arrow {
color: var(--pi-color-warn);
}
}
// --- 2. Panel desplegable ------------------------------------------------
//
// Se ancla a `.cdk-overlay-pane`, no a `.cdk-overlay-container`: esta versión
// de Material monta el panel con la API de popover *dentro* del propio
// `<mat-select>`, así que el contenedor global no está en su ascendencia.
.cdk-overlay-pane {
.mat-mdc-select-panel {
// Radio de superficie, no el del campo: en el tema alternativo el campo es
// una pastilla (999px) y un panel con ese radio quedaría deforme.
border-radius: var(--pi-theme-panel-radius);
border: 1px solid var(--pi-theme-field-border-color);
background-color: var(--pi-theme-field-surface);
box-shadow: var(--pi-box-shadow-panel);
padding: var(--pi-spacing-xx-small);
}
// Buscador de `pi-searchable-select-field`. Va pegado arriba para que siga
// visible al desplazar una lista larga.
.pi-select-search {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
padding: var(--pi-spacing-xx-small) var(--pi-spacing-small);
margin-bottom: var(--pi-spacing-xx-small);
border-bottom: 1px solid var(--pi-theme-field-border-color);
background-color: var(--pi-theme-field-surface);
input {
flex: 1 1 auto;
border: none;
outline: none;
background: transparent;
color: var(--pi-color-black);
font-family: inherit;
font-size: var(--pi-font-size-small);
&::placeholder {
color: var(--pi-color-grey-300);
}
}
&__empty {
margin: 0;
padding: var(--pi-spacing-small);
color: var(--pi-color-grey-300);
font-size: var(--pi-font-size-small);
text-align: center;
}
}
.mat-mdc-option {
border-radius: var(--pi-border-radius-small);
min-height: var(--pi-spacing-xxx-large);
// Material pinta hover/foco/selección con una capa de estado propia, no
// con `background-color`; por eso se ajustan sus tokens en vez de
// sobreescribir el fondo, que quedaba tapado por esa capa.
--mat-option-hover-state-layer-color: var(--pi-color-primary-50);
--mat-option-focus-state-layer-color: var(--pi-color-primary-50);
--mat-option-selected-state-layer-color: var(--pi-color-primary-50);
--mat-option-label-text-color: var(--pi-color-black);
--mat-option-selected-state-label-text-color: var(--pi-color-primary-900);
// Opción resaltada por teclado en `pi-searchable-select-field`, que lleva
// su propio índice activo (ver el componente).
&.is-active {
background-color: var(--pi-color-primary-50);
}
&.mdc-list-item--disabled {
color: var(--pi-color-grey-300);
}
}
}

View File

@@ -9,6 +9,7 @@
@forward 'mat-progress-bar'; @forward 'mat-progress-bar';
@forward 'mat-progress-spinner'; @forward 'mat-progress-spinner';
@forward 'mat-radio-button'; @forward 'mat-radio-button';
@forward 'mat-select';
@forward 'mat-slider'; @forward 'mat-slider';
@forward 'mat-snackbar'; @forward 'mat-snackbar';
@forward 'mat-tooltip'; @forward 'mat-tooltip';

View File

@@ -95,9 +95,3 @@
.pi-shell__alternative { .pi-shell__alternative {
@include alternativeThemeTokens; @include alternativeThemeTokens;
} }
// Los overlays de Material se montan fuera del shell: se les propaga el tema
// marcando el contenedor (lo hace la app al cambiar de tema).
.cdk-overlay-container.pi-shell__alternative {
@include alternativeThemeTokens;
}

View File

@@ -6,9 +6,13 @@
// tokens (ver `_alternative.scss`). // tokens (ver `_alternative.scss`).
// //
// Los valores viven en `.pi-shell` (no en `:root`) para que puedan convivir // Los valores viven en `.pi-shell` (no en `:root`) para que puedan convivir
// varios temas en la misma página, y se repiten en `.cdk-overlay-container` // varios temas en la misma página.
// porque los overlays de Material (diálogos, snackbars, menús) se montan fuera //
// del árbol del shell y no heredarían nada. // No hace falta repetirlos en `.cdk-overlay-container`: ese contenedor lo
// cuelga el CDK del propio `<body>`, que es donde va la clase de tema, así que
// hereda los tokens. Declararlos ahí además sería contraproducente, porque el
// contenedor no lleva la clase del tema y sus valores por defecto pisarían por
// proximidad a los que hereda.
@mixin defaultThemeTokens { @mixin defaultThemeTokens {
// --- Botones --------------------------------------------------------- // --- Botones ---------------------------------------------------------
@@ -69,7 +73,6 @@
// Base para *todos* los shells: así un tema solo tiene que declarar lo que // Base para *todos* los shells: así un tema solo tiene que declarar lo que
// cambia, y lo que no redefina sigue teniendo valor (incluido el puente con // cambia, y lo que no redefina sigue teniendo valor (incluido el puente con
// Material). Los temas concretos se aplican encima; ver `_alternative.scss`. // Material). Los temas concretos se aplican encima; ver `_alternative.scss`.
.pi-shell, .pi-shell {
.cdk-overlay-container {
@include defaultThemeTokens; @include defaultThemeTokens;
} }

View File

@@ -5,6 +5,7 @@ import {
TemplateRef, TemplateRef,
input, input,
contentChild, contentChild,
inject,
} from '@angular/core'; } from '@angular/core';
import { status } from '../../../util/status'; import { status } from '../../../util/status';
@@ -25,18 +26,18 @@ export class PiAccordionHeaderDirective {
// Accordion Item Header Content // Accordion Item Header Content
@Directive({ selector: '[pi-accordion-item-header-content]' }) @Directive({ selector: '[pi-accordion-item-header-content]' })
export class PiAccordionItemHeaderDirective { export class PiAccordionItemHeaderDirective {
constructor(public templateRef: TemplateRef<any>) {} public readonly templateRef = inject<any>(TemplateRef);
} }
// Accordion Item Summary Content // Accordion Item Summary Content
@Directive({ selector: '[pi-accordion-item-summary-content]' }) @Directive({ selector: '[pi-accordion-item-summary-content]' })
export class PiAccordionSummaryContentDirective { export class PiAccordionSummaryContentDirective {
constructor(public templateRef: TemplateRef<any>) {} readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
} }
// Accordion Item Expandable Content // Accordion Item Expandable Content
@Directive({ selector: '[pi-accordion-item-expandable-content]' }) @Directive({ selector: '[pi-accordion-item-expandable-content]' })
export class PiAccordionItemContentDirective { export class PiAccordionItemContentDirective {
constructor(public templateRef: TemplateRef<any>) {} readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
} }
/** /**

View File

@@ -1,5 +1,5 @@
// Angular // Angular
import { Directive, OnInit, input, output } from '@angular/core'; import { Directive, input, output } from '@angular/core';
import { import {
buttonSize, buttonSize,
styleType, styleType,
@@ -9,7 +9,7 @@ import {
// Style definitions // Style definitions
@Directive() @Directive()
export abstract class PiBaseButtonComponent implements OnInit { export abstract class PiBaseButtonComponent {
readonly size = input<buttonSize>('medium'); readonly size = input<buttonSize>('medium');
readonly color = input<styleType>('primary'); readonly color = input<styleType>('primary');
readonly appearance = input<buttonStyle>('fill'); readonly appearance = input<buttonStyle>('fill');
@@ -19,11 +19,6 @@ export abstract class PiBaseButtonComponent implements OnInit {
readonly execute = output<MouseEvent>(); readonly execute = output<MouseEvent>();
ngOnInit(): void {
this.sizeClass;
this.flexDirectionClass;
}
protected get colorClass(): string { protected get colorClass(): string {
return `pi-color__${this.color()}`; return `pi-color__${this.color()}`;
} }

View File

@@ -1,11 +1,11 @@
// Angular // Angular
import { Directive, HostBinding, OnInit, input, output } from '@angular/core'; import { Directive, input, output } from '@angular/core';
// Style definitions // Style definitions
import { alignment, context, gap, mode, opacity, padding } from '../../styles'; import { alignment, context, gap, mode, opacity, padding } from '../../styles';
@Directive() @Directive()
export abstract class PiBaseCardComponent implements OnInit { export abstract class PiBaseCardComponent {
readonly hasShadow = input(true); readonly hasShadow = input(true);
readonly isClickable = input(false); readonly isClickable = input(false);
readonly isStroked = input(false); readonly isStroked = input(false);
@@ -25,19 +25,6 @@ export abstract class PiBaseCardComponent implements OnInit {
readonly execute = output<void>(); readonly execute = output<void>();
ngOnInit(): void {
this.colorClass;
this.contextClass;
this.gapClass;
this.hasEqualPaddingClass;
this.hasLinkLabelClass;
this.hasShadowClass;
this.isClickableClass;
this.modeClass;
this.opacityClass;
this.isStrokedClass;
}
protected get hasShadowClass(): string | boolean | undefined { protected get hasShadowClass(): string | boolean | undefined {
return this.hasShadow() && 'has-shadow'; return this.hasShadow() && 'has-shadow';
} }

View File

@@ -1,12 +1,15 @@
import { Directive, TemplateRef, ViewContainerRef } from '@angular/core'; import {
Directive,
TemplateRef,
ViewContainerRef,
inject,
} from '@angular/core';
@Directive({ @Directive({
selector: '[pi-carousel-item]', selector: '[pi-carousel-item]',
host: { class: 'pi-carousel pi-carousel__item' }, host: { class: 'pi-carousel pi-carousel__item' },
}) })
export class PiCarouselItemDirective { export class PiCarouselItemDirective {
constructor( public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
public templateRef: TemplateRef<any>, public readonly viewContainerRef = inject(ViewContainerRef);
public viewContainerRef: ViewContainerRef,
) {}
} }

View File

@@ -7,15 +7,15 @@ import {
Component, Component,
ElementRef, ElementRef,
HostBinding, HostBinding,
OnChanges,
OnDestroy, OnDestroy,
OnInit,
Renderer2, Renderer2,
SimpleChanges,
input, input,
output, output,
viewChild, viewChild,
contentChildren, contentChildren,
inject,
effect,
untracked,
} from '@angular/core'; } from '@angular/core';
// Shared // Shared
@@ -34,7 +34,7 @@ import { PiIconButtonComponent } from '../../button/icon-button/icon-button.comp
imports: [PiIconButtonComponent, NgTemplateOutlet], imports: [PiIconButtonComponent, NgTemplateOutlet],
}) })
export class PiCarouselComponent export class PiCarouselComponent
implements OnInit, OnChanges, AfterViewInit, AfterViewChecked, OnDestroy implements AfterViewInit, AfterViewChecked, OnDestroy
{ {
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
const carouselItems = this.carouselItems(); const carouselItems = this.carouselItems();
@@ -168,12 +168,26 @@ export class PiCarouselComponent
private touchmoveListener!: () => void; private touchmoveListener!: () => void;
private touchendListener!: () => void; private touchendListener!: () => void;
constructor( private readonly cdr = inject(ChangeDetectorRef);
private cdr: ChangeDetectorRef, private readonly el = inject(ElementRef);
private el: ElementRef, private readonly renderer = inject(Renderer2);
private renderer: Renderer2, private readonly deviceConfig = inject(DeviceConfigsFacade);
private deviceConfig: DeviceConfigsFacade,
) {} constructor() {
// No depende de inputs: se puede suscribir ya en la construcción.
// Sustituye al `ngOnChanges`: recalcula al cambiar `itemWidth`, siempre
// que la vista ya esté montada.
effect(() => {
this.itemWidth();
if (this.carouselContentWrapper()) {
untracked(() => this.setItemsPerPage());
}
});
combineLatest([this.deviceConfig.device$, this.deviceConfig.size$])
.pipe(skip(1), takeUntil(this.destroyed$))
.subscribe(() => this.reloadComponent());
}
/** /**
* Used for symmetric carousel to know how many items to show on each side of the current item. * Used for symmetric carousel to know how many items to show on each side of the current item.
@@ -239,20 +253,6 @@ export class PiCarouselComponent
return Math.trunc(this.currentItem / this.itemsPerPage); return Math.trunc(this.currentItem / this.itemsPerPage);
} }
ngOnInit(): void {
combineLatest([this.deviceConfig.device$, this.deviceConfig.size$])
.pipe(skip(1), takeUntil(this.destroyed$))
.subscribe(() => {
this.reloadComponent();
});
}
ngOnChanges(simpleChanges: SimpleChanges): void {
if (simpleChanges['itemWidth'] && !!this.carouselContentWrapper()) {
this.setItemsPerPage();
}
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.setItemsPerPage(); this.setItemsPerPage();
this.setPages(); this.setPages();

View File

@@ -4,9 +4,9 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
ElementRef, ElementRef,
OnInit,
input, input,
viewChild, viewChild,
inject,
} from '@angular/core'; } from '@angular/core';
import { Device } from '../../../util/device-configs'; import { Device } from '../../../util/device-configs';
import { Dataset } from '../../../util/models'; import { Dataset } from '../../../util/models';
@@ -48,14 +48,14 @@ registerChartComponents();
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent], imports: [PiChartLegendComponent],
}) })
export class PiChartComponent implements OnInit, AfterViewInit { export class PiChartComponent implements AfterViewInit {
readonly type = input<'line' | 'bar'>('line'); readonly type = input<'line' | 'bar'>('line');
readonly datasets = input.required<Array<Dataset>>(); readonly datasets = input.required<Array<Dataset>>();
readonly device = input.required<Device>(); readonly device = input.required<Device>();
readonly xAxisMarks = input<Array<string>>(); readonly xAxisMarks = input<Array<string>>();
readonly isComparativeChart = input<boolean>(true); readonly isComparativeChart = input<boolean>(true);
private _hoveredDatasetIndex!: number; private _hoveredDatasetIndex = -1;
set hoveredDatasetIndex(index: number) { set hoveredDatasetIndex(index: number) {
this._hoveredDatasetIndex = index; this._hoveredDatasetIndex = index;
} }
@@ -69,10 +69,8 @@ export class PiChartComponent implements OnInit, AfterViewInit {
chart!: Chart; chart!: Chart;
constructor( private readonly translate = inject(TranslateService);
private translate: TranslateService, private readonly cdr = inject(ChangeDetectorRef);
private cdr: ChangeDetectorRef,
) {}
get completedDatasets() { get completedDatasets() {
return this.datasets().map((dataset) => { return this.datasets().map((dataset) => {
@@ -87,10 +85,6 @@ export class PiChartComponent implements OnInit, AfterViewInit {
}); });
} }
ngOnInit(): void {
this.hoveredDatasetIndex = -1;
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.getLineChartConfigs(); this.getLineChartConfigs();
} }

View File

@@ -3,7 +3,6 @@ import {
Component, Component,
ElementRef, ElementRef,
HostBinding, HostBinding,
OnInit,
TemplateRef, TemplateRef,
input, input,
output, output,

View File

@@ -4,6 +4,7 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
input, input,
inject,
} from '@angular/core'; } from '@angular/core';
import { itemType } from '../entities/data-grid.model'; import { itemType } from '../entities/data-grid.model';
@@ -24,7 +25,7 @@ export class PiCellDirective implements AfterViewInit {
readonly index = input.required<number>(); readonly index = input.required<number>();
readonly textAlign = input<'left' | 'center' | 'right'>('left'); readonly textAlign = input<'left' | 'center' | 'right'>('left');
constructor(private elRef: ElementRef) {} private readonly elRef = inject(ElementRef);
ngAfterViewInit(): void { ngAfterViewInit(): void {
const index = this.index(); const index = this.index();

View File

@@ -5,6 +5,7 @@ import {
HostListener, HostListener,
Renderer2, Renderer2,
input, input,
inject,
} from '@angular/core'; } from '@angular/core';
import { columnsNumber } from '../entities/data-grid.model'; import { columnsNumber } from '../entities/data-grid.model';
@@ -32,10 +33,8 @@ export class PiGridItemDirective {
readonly isVisited = input(false); readonly isVisited = input(false);
constructor( private readonly elRef = inject(ElementRef);
private elRef: ElementRef, private readonly renderer = inject(Renderer2);
private renderer: Renderer2,
) {}
@HostListener('click') selectItem(): void { @HostListener('click') selectItem(): void {
const gridItem = this.elRef.nativeElement; const gridItem = this.elRef.nativeElement;

View File

@@ -2,8 +2,9 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostBinding, HostBinding,
OnInit,
input, input,
effect,
inject,
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -22,7 +23,7 @@ import { take } from 'rxjs/operators';
styleUrls: ['./base-data-table.component.scss'], styleUrls: ['./base-data-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PiBaseDataTableComponent implements OnInit { export class PiBaseDataTableComponent {
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
return [ return [
'pi-base-data-table', 'pi-base-data-table',
@@ -36,12 +37,12 @@ export class PiBaseDataTableComponent implements OnInit {
readonly type = input<'header' | 'data'>('data'); readonly type = input<'header' | 'data'>('data');
readonly hideValues = input(false); readonly hideValues = input(false);
constructor(private store: Store<DeviceConfigsState>) {} private readonly store = inject<Store<DeviceConfigsState>>(Store);
ngOnInit(): void { constructor() {
this.dataDirectionClass; // `hideHeader` lee inputs; las otras dos líneas del antiguo `ngOnInit` solo
this.typeClass; // leían getters y no hacían nada.
this.hideHeader(); effect(() => this.hideHeader());
} }
get dataDirectionClass(): string { get dataDirectionClass(): string {

View File

@@ -8,15 +8,13 @@ import {
input, input,
contentChild, contentChild,
viewChild, viewChild,
inject,
} from '@angular/core'; } from '@angular/core';
import { textStyle, justify, borderType } from '../../../styles'; import { textStyle, justify, borderType } from '../../../styles';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
// Store // Store
import { import { DeviceConfigsState } from '../../../util/device-configs';
DeviceConfigsState,
DeviceTypeSelectors,
} from '../../../util/device-configs';
// RxJS // RxJS
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
@@ -44,7 +42,7 @@ export class PiDataTableItemComponent implements AfterViewInit {
readonly item = contentChild<TemplateRef<any>>('item'); readonly item = contentChild<TemplateRef<any>>('item');
readonly componentItem = viewChild<ElementRef>('componentItem'); readonly componentItem = viewChild<ElementRef>('componentItem');
constructor(private store: Store<DeviceConfigsState>) {} private readonly store = inject<Store<DeviceConfigsState>>(Store);
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.setCellClassByIndex(); this.setCellClassByIndex();

View File

@@ -3,6 +3,7 @@ import {
Component, Component,
HostBinding, HostBinding,
forwardRef, forwardRef,
inject,
} from '@angular/core'; } from '@angular/core';
import { Location } from '@angular/common'; import { Location } from '@angular/common';
import { LayoutTypeClass } from '../../../styles'; import { LayoutTypeClass } from '../../../styles';
@@ -39,7 +40,7 @@ import { TranslatePipe } from '@ngx-translate/core';
export class PiNotFoundComponent { export class PiNotFoundComponent {
@HostBinding('class') className = LayoutTypeClass.VerticallyCentered; @HostBinding('class') className = LayoutTypeClass.VerticallyCentered;
constructor(private location: Location) {} private readonly location = inject(Location);
previous(): void { previous(): void {
this.location.back(); this.location.back();

View File

@@ -166,7 +166,9 @@ export class PiFileUploadComponent implements FormValueControl<File[]> {
} }
// En modo simple el último sustituye al anterior. // En modo simple el último sustituye al anterior.
let next = this.multiple() ? [...this.value(), ...accepted] : accepted.slice(-1); let next = this.multiple()
? [...this.value(), ...accepted]
: accepted.slice(-1);
const maxFiles = this.maxFiles(); const maxFiles = this.maxFiles();
if (this.multiple() && maxFiles !== undefined && next.length > maxFiles) { if (this.multiple() && maxFiles !== undefined && next.length > maxFiles) {

View File

@@ -20,8 +20,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -29,3 +27,4 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, HostBinding, input } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
HostBinding,
input,
} from '@angular/core';
import { import {
MatFormField, MatFormField,
MatLabel, MatLabel,

View File

@@ -1,27 +1,37 @@
import { import {
Directive, Directive,
OnChanges,
OnInit,
SimpleChanges,
input, input,
afterNextRender,
inject,
effect,
untracked,
} from '@angular/core'; } from '@angular/core';
import { MatInput } from '@angular/material/input'; import { MatInput } from '@angular/material/input';
@Directive({ selector: '[pi-autofocus]' }) @Directive({ selector: '[pi-autofocus]' })
export class PiAutofocusDirective implements OnInit, OnChanges { export class PiAutofocusDirective {
readonly isAutofocusSelected = input<boolean>(); readonly isAutofocusSelected = input<boolean>();
constructor(private matInput: MatInput) {} private readonly matInput = inject(MatInput);
ngOnInit() { constructor() {
if (this.isAutofocusSelected()) { // `afterNextRender` sustituye al `ngOnInit` + `setTimeout`: ya corre con la
setTimeout(() => this.matInput.focus()); // vista pintada, así que el `focus()` no necesita aplazarse a mano.
} // Cubre lo que hacían el `ngOnInit` (primer render) y el `ngOnChanges`
} // (cada cambio de la entrada) en una sola pieza.
afterNextRender(() => {
ngOnChanges(changes: SimpleChanges): void {
if (changes) {
if (this.isAutofocusSelected()) this.matInput.focus(); if (this.isAutofocusSelected()) this.matInput.focus();
} });
let first = true;
effect(() => {
const shouldFocus = this.isAutofocusSelected();
// El primer paso lo cubre `afterNextRender`, con la vista ya pintada.
if (first) {
first = false;
return;
}
if (shouldFocus) untracked(() => this.matInput.focus());
});
} }
} }

View File

@@ -2,13 +2,14 @@ import {
Directive, Directive,
ElementRef, ElementRef,
HostBinding, HostBinding,
OnInit,
Renderer2, Renderer2,
input, input,
afterNextRender,
inject,
} from '@angular/core'; } from '@angular/core';
@Directive({ selector: 'pi-label' }) @Directive({ selector: 'pi-label' })
export class PiLabelDirective implements OnInit { export class PiLabelDirective {
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
const validationClass = this.isRequired() ? 'is-required' : ''; const validationClass = this.isRequired() ? 'is-required' : '';
return ['pi-label', validationClass].join(' '); return ['pi-label', validationClass].join(' ');
@@ -17,12 +18,15 @@ export class PiLabelDirective implements OnInit {
readonly isRequired = input<boolean>(false); readonly isRequired = input<boolean>(false);
constructor( private readonly renderer = inject(Renderer2);
private renderer: Renderer2, private readonly el = inject(ElementRef);
private el: ElementRef,
) {}
ngOnInit() { constructor() {
// Igual que en `pi-affix`: depende de un input y toca el DOM una vez.
afterNextRender(() => this.renderRequiredMark());
}
private renderRequiredMark(): void {
if (this.isRequired()) { if (this.isRequired()) {
const span = this.renderer.createElement('span'); const span = this.renderer.createElement('span');
const text = this.renderer.createText('*'); const text = this.renderer.createText('*');

View File

@@ -17,8 +17,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -26,3 +24,4 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>

View File

@@ -18,8 +18,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
</section>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -27,3 +25,5 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>
</section>

View File

@@ -11,7 +11,4 @@
gap: var(--pi-spacing-medium); gap: var(--pi-spacing-medium);
width: 100%; width: 100%;
} }
.mat-mdc-form-field-error {
margin-left: 3.8rem;
}
} }

View File

@@ -38,8 +38,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -47,3 +45,4 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>

View File

@@ -28,3 +28,9 @@ export * from './base-form-field.component';
export * from './incremental-range-field/incremental-range-field.component'; export * from './incremental-range-field/incremental-range-field.component';
export * from './slide-toggle/slide-toggle.component'; export * from './slide-toggle/slide-toggle.component';
export * from './select-field/select-field.model';
export * from './select-field/select-field.component';
export * from './select-field/multi-select-field.component';
export * from './select-field/searchable-select-field.component';
export * from './select-field/base-searchable-select-field.component';
export * from './select-field/searchable-multi-select-field.component';

View File

@@ -20,8 +20,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -29,3 +27,4 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>

View File

@@ -24,8 +24,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -33,3 +31,4 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>

View File

@@ -16,8 +16,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -25,3 +23,4 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>

View File

@@ -39,9 +39,9 @@ export interface PiRadioOption<TValue = unknown> {
imports: [PiRadioButtonComponent, TranslatePipe], imports: [PiRadioButtonComponent, TranslatePipe],
host: { role: 'radiogroup' }, host: { role: 'radiogroup' },
}) })
export class PiRadioGroupComponent<TValue = unknown> export class PiRadioGroupComponent<
implements FormValueControl<TValue | null> TValue = unknown,
{ > implements FormValueControl<TValue | null> {
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
return [ return [
ComponentFamilyClass.Field, ComponentFamilyClass.Field,

View File

@@ -0,0 +1,146 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
computed,
effect,
input,
signal,
viewChild,
} from '@angular/core';
import { MatSelect } from '@angular/material/select';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { PiSelectFieldOption } from './select-field.model';
/** Quita acentos y pasa a minúsculas, para que «Peru» encuentre «Perú». */
function normalize(text: string): string {
return text.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '');
}
/**
* Parte común de los selects con buscador (simple y múltiple).
*
* Reúne el filtro, la navegación por teclado y el enganche del estado de
* error. Lo único que cambia entre ambos es qué hace `Enter` sobre la opción
* resaltada y cómo se resume el valor en el campo cerrado, así que eso queda
* abstracto.
*
* @template TValue Tipo de cada opción.
* @template TModel Lo que guarda el formulario: una opción o un array de ellas.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: '',
})
export abstract class PiBaseSearchableSelectFieldComponent<
TValue,
TModel,
> extends PiBaseFormFieldComponent<TModel> {
readonly options = input.required<readonly PiSelectFieldOption<TValue>[]>();
/** Texto de la caja de búsqueda. */
readonly searchPlaceholder = input('common.search');
/** Se muestra cuando el filtro no deja ninguna opción. */
readonly emptyLabel = input('common.noResults');
protected readonly search = signal('');
/**
* Índice resaltado dentro de la lista filtrada. Se gestiona aquí en vez de
* delegar en `mat-select`: con el foco en el buscador su gestor de teclas no
* llega a activarse, y enfocar la opción a mano no lo sincroniza (Enter
* acababa confirmando una opción distinta de la resaltada).
*/
protected readonly activeIndex = signal(0);
protected readonly filteredOptions = computed(() => {
const term = normalize(this.search().trim());
if (!term) return this.options();
return this.options().filter((o) => normalize(o.label).includes(term));
});
protected readonly matSelectRef = viewChild(MatSelect);
private readonly searchInputRef =
viewChild<ElementRef<HTMLInputElement>>('searchInput');
constructor() {
super();
// La clase base engancha el `errorStateMatcher` a `MatInput`, que aquí no
// hay; `MatSelect` expone la misma API.
effect(() => {
this.hasError();
const select = this.matSelectRef();
if (!select) return;
select.errorStateMatcher = this.errorStateMatcher;
select.updateErrorState();
});
}
/** Qué hacer con la opción resaltada al pulsar Enter. */
protected abstract commitActiveOption(
option: PiSelectFieldOption<TValue>,
): void;
protected onSearchInput(term: string): void {
this.search.set(term);
this.activeIndex.set(0);
}
protected isOptionActive(index: number): boolean {
return this.activeIndex() === index;
}
protected onOpenedChange(opened: boolean): void {
if (opened) {
this.onFocus();
// El panel se monta al abrir, así que hay que esperar al siguiente frame
// para que el input exista y se pueda enfocar.
requestAnimationFrame(() => this.searchInputRef()?.nativeElement.focus());
} else {
// El filtro no se conserva entre aperturas: al reabrir se ve la lista
// completa, que es lo esperable.
this.search.set('');
this.activeIndex.set(0);
this.onBlur();
}
}
/**
* `mat-select` salta a la opción que empieza por la letra pulsada, así que
* lo escrito en el buscador no debe llegarle. Las flechas y el Enter se
* atienden aquí, sobre la lista ya filtrada.
*/
protected onSearchKeydown(event: KeyboardEvent): void {
const options = this.filteredOptions();
const last = options.length - 1;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
event.stopPropagation();
this.activeIndex.set(Math.min(this.activeIndex() + 1, last));
return;
case 'ArrowUp':
event.preventDefault();
event.stopPropagation();
this.activeIndex.set(Math.max(this.activeIndex() - 1, 0));
return;
case 'Enter': {
const option = options[this.activeIndex()];
event.preventDefault();
event.stopPropagation();
if (option && !option.disabled) this.commitActiveOption(option);
return;
}
case 'Escape':
case 'Tab':
// Las gestiona `mat-select` (cerrar el panel, salir del campo).
return;
default:
event.stopPropagation();
}
}
}

View File

@@ -0,0 +1,32 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<mat-select
multiple
[value]="value()"
(valueChange)="onSelectionChange($event)"
(openedChange)="$event ? onFocus() : onBlur()"
[disabled]="disabled()"
[required]="required()"
[placeholder]="placeholder()!"
>
<!--
`mat-select-trigger` sustituye al texto que pinta Material por defecto,
que concatenaría los valores en crudo (objetos, en el caso general).
-->
<mat-select-trigger>{{ displayText() }}</mat-select-trigger>
@for (option of options(); track option.value) {
<mat-option [value]="option.value" [disabled]="!!option.disabled">
{{ option.label | translate }}
</mat-option>
}
</mat-select>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>

View File

@@ -0,0 +1,89 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
computed,
effect,
input,
viewChild,
} from '@angular/core';
import { MatError, MatFormField, MatLabel } from '@angular/material/form-field';
import { MatOption } from '@angular/material/core';
import { MatSelect, MatSelectTrigger } from '@angular/material/select';
import { TranslatePipe } from '@ngx-translate/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { PiSelectFieldOption } from './select-field.model';
/**
* Campo con desplegable de selección múltiple.
*
* Mismo planteamiento que `pi-select-field`; `mat-select` en modo `multiple`
* ya pinta las casillas dentro del panel, así que no hace falta componer nada
* a mano.
*/
@Component({
selector: 'pi-multi-select-field',
templateUrl: './multi-select-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatFormField,
MatLabel,
MatSelect,
MatSelectTrigger,
MatOption,
MatError,
TranslatePipe,
],
})
export class PiMultiSelectFieldComponent<
TValue = unknown,
> extends PiBaseFormFieldComponent<TValue[]> {
@HostBinding('class') className = 'pi-form-field__select';
readonly options = input.required<readonly PiSelectFieldOption<TValue>[]>();
/**
* A partir de este número de opciones marcadas se resume como «N
* seleccionados» en vez de listarlas. Con `0` nunca resume.
*/
readonly summaryThreshold = input(3);
/** Texto del resumen; recibe el número por el pipe `translate`. */
readonly summaryLabel = input('seleccionados');
/**
* Lo que se ve en el campo cuando está cerrado. `mat-select` por defecto
* concatena los valores en crudo, que aquí serían objetos.
*/
protected readonly displayText = computed(() => {
const selected = this.value() ?? [];
if (!selected.length) return '';
const threshold = this.summaryThreshold();
if (threshold > 0 && selected.length >= threshold) {
return `${selected.length} ${this.summaryLabel()}`;
}
const byValue = new Map(this.options().map((o) => [o.value, o.label]));
return selected.map((v) => byValue.get(v) ?? String(v)).join(', ');
});
/** Mismo enganche de estado de error que en `pi-select-field`. */
private readonly matSelectRef = viewChild(MatSelect);
constructor() {
super();
effect(() => {
this.hasError();
const select = this.matSelectRef();
if (!select) return;
select.errorStateMatcher = this.errorStateMatcher;
select.updateErrorState();
});
}
protected onSelectionChange(next: TValue[] | null): void {
this.value.set(next ?? []);
}
}

View File

@@ -0,0 +1,51 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<mat-select
multiple
[value]="value()"
(valueChange)="onSelectionChange($event)"
(openedChange)="onOpenedChange($event)"
[disabled]="disabled()"
[required]="required()"
[placeholder]="placeholder()!"
>
<!--
El texto del campo cerrado sale del valor y no de la opción pintada:
con el filtro activo esa opción puede no estar en la lista.
-->
<mat-select-trigger>{{ displayText() }}</mat-select-trigger>
<div class="pi-select-search">
<pi-icon icon="search" size="small" color="grey" />
<input
#searchInput
type="text"
[value]="search()"
(input)="onSearchInput($any($event.target).value)"
(keydown)="onSearchKeydown($event)"
[placeholder]="searchPlaceholder() | translate"
/>
</div>
@for (option of filteredOptions(); track option.value; let i = $index) {
<mat-option
[value]="option.value"
[disabled]="!!option.disabled"
[class.is-active]="isOptionActive(i)"
>
{{ option.label | translate }}
</mat-option>
} @empty {
<p class="pi-select-search__empty">{{ emptyLabel() | translate }}</p>
}
</mat-select>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>

View File

@@ -0,0 +1,92 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
computed,
input,
} from '@angular/core';
import { MatError, MatFormField, MatLabel } from '@angular/material/form-field';
import { MatOption } from '@angular/material/core';
import { MatSelect, MatSelectTrigger } from '@angular/material/select';
import { TranslatePipe } from '@ngx-translate/core';
import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiBaseSearchableSelectFieldComponent } from './base-searchable-select-field.component';
import { PiSelectFieldOption } from './select-field.model';
/**
* Campo con desplegable, buscador y selección múltiple.
*
* Combina `pi-multi-select-field` y `pi-searchable-select-field`: casillas en
* el panel más caja de filtro, para listas largas de las que hay que elegir
* varias.
*/
@Component({
selector: 'pi-searchable-multi-select-field',
templateUrl: './searchable-multi-select-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatFormField,
MatLabel,
MatSelect,
MatSelectTrigger,
MatOption,
MatError,
PiIconComponent,
TranslatePipe,
],
})
export class PiSearchableMultiSelectFieldComponent<
TValue = unknown,
> extends PiBaseSearchableSelectFieldComponent<TValue, TValue[]> {
@HostBinding('class') className = 'pi-form-field__select';
/**
* A partir de este número de opciones marcadas se resume como «N
* seleccionados» en vez de listarlas. Con `0` nunca resume.
*/
readonly summaryThreshold = input(3);
/** Texto del resumen; recibe el número por el pipe `translate`. */
readonly summaryLabel = input('seleccionados');
/**
* Igual que en el simple, el texto sale del valor y no de las opciones
* pintadas: con el filtro activo puede haber selecciones fuera de la lista.
*/
protected readonly displayText = computed(() => {
const selected = this.value() ?? [];
if (!selected.length) return '';
const threshold = this.summaryThreshold();
if (threshold > 0 && selected.length >= threshold) {
return `${selected.length} ${this.summaryLabel()}`;
}
const byValue = new Map(this.options().map((o) => [o.value, o.label]));
return selected.map((v) => byValue.get(v) ?? String(v)).join(', ');
});
protected isSelected(option: PiSelectFieldOption<TValue>): boolean {
return (this.value() ?? []).includes(option.value);
}
/**
* En selección múltiple, Enter marca o desmarca la opción resaltada y deja
* el panel abierto, para poder seguir eligiendo sin reabrirlo.
*/
protected override commitActiveOption(
option: PiSelectFieldOption<TValue>,
): void {
const current = this.value() ?? [];
this.value.set(
current.includes(option.value)
? current.filter((v) => v !== option.value)
: [...current, option.value],
);
}
protected onSelectionChange(next: TValue[] | null): void {
this.value.set(next ?? []);
}
}

View File

@@ -0,0 +1,50 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<mat-select
[value]="value()"
(valueChange)="onSelectionChange($event)"
(openedChange)="onOpenedChange($event)"
[disabled]="disabled()"
[required]="required()"
[placeholder]="placeholder()!"
>
<!--
El texto del campo cerrado sale del valor y no de la opción pintada:
con el filtro activo esa opción puede no estar en la lista.
-->
<mat-select-trigger>{{ displayText() }}</mat-select-trigger>
<div class="pi-select-search">
<pi-icon icon="search" size="small" color="grey" />
<input
#searchInput
type="text"
[value]="search()"
(input)="onSearchInput($any($event.target).value)"
(keydown)="onSearchKeydown($event)"
[placeholder]="searchPlaceholder() | translate"
/>
</div>
@for (option of filteredOptions(); track option.value; let i = $index) {
<mat-option
[value]="option.value"
[disabled]="!!option.disabled"
[class.is-active]="isOptionActive(i)"
>
{{ option.label | translate }}
</mat-option>
} @empty {
<p class="pi-select-search__empty">{{ emptyLabel() | translate }}</p>
}
</mat-select>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>

View File

@@ -0,0 +1,66 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
computed,
} from '@angular/core';
import { MatError, MatFormField, MatLabel } from '@angular/material/form-field';
import { MatOption } from '@angular/material/core';
import { MatSelect, MatSelectTrigger } from '@angular/material/select';
import { TranslatePipe } from '@ngx-translate/core';
import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiBaseSearchableSelectFieldComponent } from './base-searchable-select-field.component';
import { PiSelectFieldOption } from './select-field.model';
/**
* Campo con desplegable y buscador (selección simple).
*
* Igual que `pi-select-field` pero con una caja de filtro dentro del panel,
* pensado para listas largas donde recorrer las opciones a mano no es viable.
*/
@Component({
selector: 'pi-searchable-select-field',
templateUrl: './searchable-select-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatFormField,
MatLabel,
MatSelect,
MatSelectTrigger,
MatOption,
MatError,
PiIconComponent,
TranslatePipe,
],
})
export class PiSearchableSelectFieldComponent<
TValue = unknown,
> extends PiBaseSearchableSelectFieldComponent<TValue, TValue | null> {
@HostBinding('class') className = 'pi-form-field__select';
/**
* El texto del campo cerrado se calcula del valor, no de la opción
* renderizada: al filtrar, la opción seleccionada puede no estar en la lista
* y `mat-select` se quedaría sin nada que mostrar.
*/
protected readonly displayText = computed(() => {
const current = this.value();
if (current === null || current === undefined) return '';
return (
this.options().find((o) => o.value === current)?.label ?? String(current)
);
});
/** En selección simple, Enter confirma y cierra. */
protected override commitActiveOption(
option: PiSelectFieldOption<TValue>,
): void {
this.value.set(option.value);
this.matSelectRef()?.close();
}
protected onSelectionChange(next: TValue | null): void {
this.value.set(next);
}
}

View File

@@ -0,0 +1,25 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<mat-select
[value]="value()"
(valueChange)="onSelectionChange($event)"
(openedChange)="$event ? onFocus() : onBlur()"
[disabled]="disabled()"
[required]="required()"
[placeholder]="placeholder()!"
>
@for (option of options(); track option.value) {
<mat-option [value]="option.value" [disabled]="!!option.disabled">
{{ option.label | translate }}
</mat-option>
}
</mat-select>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>

View File

@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
effect,
input,
viewChild,
} from '@angular/core';
import { MatError, MatFormField, MatLabel } from '@angular/material/form-field';
import { MatOption } from '@angular/material/core';
import { MatSelect } from '@angular/material/select';
import { TranslatePipe } from '@ngx-translate/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
import { PiSelectFieldOption } from './select-field.model';
/**
* Campo con desplegable (selección simple).
*
* Es un *campo de formulario*, no la familia `pi-select`: aquélla pinta una
* lista siempre visible y se usa para filtros y menús; ésta es un input con su
* panel desplegable, y encaja con el resto de campos (`pi-text-field`,
* `pi-email-field`...).
*
* Extiende `PiBaseFormFieldComponent` y envuelve `mat-form-field`, así hereda
* los tokens de tema (radio, borde, superficie, etiqueta) y el tratamiento de
* errores del resto de campos.
*/
@Component({
selector: 'pi-select-field',
templateUrl: './select-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatFormField,
MatLabel,
MatSelect,
MatOption,
MatError,
TranslatePipe,
],
})
export class PiSelectFieldComponent<
TValue = unknown,
> extends PiBaseFormFieldComponent<TValue | null> {
@HostBinding('class') className = 'pi-form-field__select';
readonly options = input.required<readonly PiSelectFieldOption<TValue>[]>();
/**
* La clase base engancha el `errorStateMatcher` a `MatInput`, que aquí no
* hay. `MatSelect` expone la misma API, así que se repite el enganche para
* que el borde rojo y el mensaje también aparezcan en el desplegable.
*/
private readonly matSelectRef = viewChild(MatSelect);
constructor() {
super();
effect(() => {
this.hasError();
const select = this.matSelectRef();
if (!select) return;
select.errorStateMatcher = this.errorStateMatcher;
select.updateErrorState();
});
}
protected onSelectionChange(next: TValue | null): void {
this.value.set(next);
}
}

View File

@@ -0,0 +1,7 @@
/** Opción de `pi-select-field` y `pi-multi-select-field`. */
export interface PiSelectFieldOption<TValue = unknown> {
value: TValue;
/** Se pasa por el pipe `translate`, así que admite clave de i18n. */
label: string;
disabled?: boolean;
}

View File

@@ -15,8 +15,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
/> />
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -24,3 +22,4 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>

View File

@@ -17,7 +17,6 @@
pi-autofocus pi-autofocus
[isAutofocusSelected]="isAutofocus()" [isAutofocusSelected]="isAutofocus()"
></textarea> ></textarea>
</mat-form-field>
@if (enableErrorMessage() && hasError()) { @if (enableErrorMessage() && hasError()) {
<mat-error> <mat-error>
@for (message of errorMessages(); track message) { @for (message of errorMessages(); track message) {
@@ -25,6 +24,7 @@
} }
</mat-error> </mat-error>
} }
</mat-form-field>
@if (maxLength() !== 1e6) { @if (maxLength() !== 1e6) {
<span>{{ value().length }} / {{ maxLength() }}</span> <span>{{ value().length }} / {{ maxLength() }}</span>
} }

View File

@@ -7,6 +7,7 @@ import {
Renderer2, Renderer2,
OnDestroy, OnDestroy,
input, input,
inject,
} from '@angular/core'; } from '@angular/core';
// RxJS // RxJS
@@ -45,11 +46,9 @@ export class PiBackgroundImageDirective implements AfterViewInit, OnDestroy {
private destroy$: Subject<void> = new Subject<void>(); private destroy$: Subject<void> = new Subject<void>();
constructor( private readonly deviceConfigsFacade = inject(DeviceConfigsFacade);
private deviceConfigsFacade: DeviceConfigsFacade, private readonly element = inject(ElementRef);
private element: ElementRef, private readonly renderer = inject(Renderer2);
private renderer: Renderer2,
) {}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.deviceConfigsFacade.device$ this.deviceConfigsFacade.device$

View File

@@ -2,7 +2,7 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostBinding, HostBinding,
Inject, inject,
} from '@angular/core'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { import {
@@ -58,10 +58,11 @@ export class PiModalComponent {
title: string | undefined; title: string | undefined;
subtitle: string | undefined; subtitle: string | undefined;
constructor( readonly dialogRef = inject<MatDialogRef<PiModalComponent>>(MatDialogRef);
public dialogRef: MatDialogRef<PiModalComponent>, readonly data = inject<CustomModalData | DialogModalData>(MAT_DIALOG_DATA);
@Inject(MAT_DIALOG_DATA) public data: CustomModalData | DialogModalData,
) { constructor() {
const data = this.data;
this.title = data.title; this.title = data.title;
this.subtitle = data.subtitle; this.subtitle = data.subtitle;
if (data.type === ModalType.DIALOG) { if (data.type === ModalType.DIALOG) {

View File

@@ -1,5 +1,5 @@
<div> <div>
<mat-icon [svgIcon]="noteStatus?.icon!"></mat-icon> <mat-icon [svgIcon]="noteStatus()?.icon!"></mat-icon>
@if (title()) { @if (title()) {
<h5 s>{{ title() }}</h5> <h5 s>{{ title() }}</h5>
} }

View File

@@ -3,8 +3,9 @@ import {
Component, Component,
Directive, Directive,
HostBinding, HostBinding,
OnInit,
input, input,
computed,
inject,
} from '@angular/core'; } from '@angular/core';
import { NoteDataService } from '../infrastructure/note-data.service'; import { NoteDataService } from '../infrastructure/note-data.service';
import { NoteStatus } from '../entities/note.model'; import { NoteStatus } from '../entities/note.model';
@@ -37,7 +38,7 @@ export class PiNoteActionContentDirective {}
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon, PiTextSmallDirective, PiTextXSmallDirective], imports: [MatIcon, PiTextSmallDirective, PiTextXSmallDirective],
}) })
export class PiNoteComponent implements OnInit { export class PiNoteComponent {
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
const alignmentValue = this.alignment(); const alignmentValue = this.alignment();
const alignmentClass = const alignmentClass =
@@ -97,18 +98,16 @@ export class PiNoteComponent implements OnInit {
readonly appearance = input<'fill' | 'ghost'>('fill'); readonly appearance = input<'fill' | 'ghost'>('fill');
readonly type = input<'status' | 'simple'>('status'); readonly type = input<'status' | 'simple'>('status');
noteStatus?: NoteStatus; private readonly noteDataService = inject(NoteDataService);
constructor(private noteDataService: NoteDataService) {} /**
* Antes se calculaba en `ngOnInit`, así que no seguía a `status` si cambiaba
ngOnInit(): void { * después. Como `computed`, se recalcula solo.
this.noteStatus = this.getNoteStatus(); */
} readonly noteStatus = computed<NoteStatus | undefined>(() => {
getNoteStatus(): NoteStatus | undefined {
const noteStatusOptions = this.noteDataService.getNoteStatusData(); const noteStatusOptions = this.noteDataService.getNoteStatusData();
return noteStatusOptions.find( return noteStatusOptions.find(
(noteStatus) => this.status() === noteStatus.statusType, (noteStatus) => this.status() === noteStatus.statusType,
); );
} });
} }

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
// Material // Material
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
@@ -10,7 +10,7 @@ import { PiSnackBarComponent } from '../snack-bar/snack-bar.component';
providedIn: 'root', providedIn: 'root',
}) })
export class NotificationService { export class NotificationService {
constructor(private _snackBar: MatSnackBar) {} private readonly _snackBar = inject(MatSnackBar);
success(message: string, description?: string): void { success(message: string, description?: string): void {
this._snackBar.openFromComponent(PiSnackBarComponent, { this._snackBar.openFromComponent(PiSnackBarComponent, {

View File

@@ -2,10 +2,9 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostBinding, HostBinding,
Inject,
Optional,
forwardRef, forwardRef,
signal, signal,
inject,
} from '@angular/core'; } from '@angular/core';
import { import {
MatSnackBarRef, MatSnackBarRef,
@@ -73,7 +72,10 @@ export class PiSnackBarComponent {
/** The instance of the component making up the content of the snack bar. */ /** The instance of the component making up the content of the snack bar. */
private readonly snackBarRef!: MatSnackBarRef<PiSnackBarComponent>; private readonly snackBarRef!: MatSnackBarRef<PiSnackBarComponent>;
constructor(@Optional() @Inject(MAT_SNACK_BAR_DATA) public data: any) { readonly data = inject(MAT_SNACK_BAR_DATA, { optional: true });
constructor() {
const data = this.data;
if (data) { if (data) {
this.message.set(data.message); this.message.set(data.message);
this.icon.set(data.icon); this.icon.set(data.icon);

View File

@@ -1,12 +1,13 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
OnInit,
HostBinding, HostBinding,
forwardRef, forwardRef,
input, input,
model, model,
output, output,
effect,
untracked,
} from '@angular/core'; } from '@angular/core';
import { PiIconButtonComponent } from '../../button/icon-button/icon-button.component'; import { PiIconButtonComponent } from '../../button/icon-button/icon-button.component';
@@ -23,7 +24,7 @@ import { PiIconComponent } from '../../icon/icon/icon.component';
forwardRef(() => PiPaginatorComponent), forwardRef(() => PiPaginatorComponent),
], ],
}) })
export class PiPaginatorComponent implements OnInit { export class PiPaginatorComponent {
@HostBinding('class') className = 'pi-paginator'; @HostBinding('class') className = 'pi-paginator';
readonly currentPage = model(1); readonly currentPage = model(1);
readonly pageSize = input.required<number>(); readonly pageSize = input.required<number>();
@@ -36,8 +37,13 @@ export class PiPaginatorComponent implements OnInit {
firstPagesRange = 0; firstPagesRange = 0;
ngOnInit(): void { constructor() {
this.initFirstPages(); // `initFirstPages` lee el input `totalPages`, que no está disponible en el
// constructor; con un efecto además se recalcula si cambia.
effect(() => {
this.totalPages();
untracked(() => this.initFirstPages());
});
} }
get pages() { get pages() {

View File

@@ -1,5 +1,5 @@
// Angular // Angular
import { Directive, OnInit, input } from '@angular/core'; import { Directive, input } from '@angular/core';
import { import {
alignment, alignment,
borderRadius, borderRadius,
@@ -13,7 +13,7 @@ import {
import { status } from '../../util/status'; import { status } from '../../util/status';
@Directive() @Directive()
export abstract class PiBasePanelComponent implements OnInit { export abstract class PiBasePanelComponent {
readonly alignment = input<alignment>('center'); readonly alignment = input<alignment>('center');
readonly borderRadius = input<borderRadius>('large'); readonly borderRadius = input<borderRadius>('large');
readonly color = input<styleType>('white'); readonly color = input<styleType>('white');
@@ -38,22 +38,6 @@ export abstract class PiBasePanelComponent implements OnInit {
readonly status = input.required<status | ''>(); readonly status = input.required<status | ''>();
ngOnInit(): void {
this.alignmentClass;
this.borderRadiusClass;
this.colorClass;
this.flexDirectionClass;
this.justifyClass;
this.widthClass;
this.outlineClass;
this.paddingClass;
this.gapClass;
this.statusClass;
this.isStrokedClass;
this.modeClass;
this.opacityClass;
}
protected get widthClass(): string | undefined { protected get widthClass(): string | undefined {
const width = this.width(); const width = this.width();
return width && `pi-panel__width--${width}`; return width && `pi-panel__width--${width}`;

View File

@@ -2,14 +2,13 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
HostBinding, HostBinding,
OnChanges,
OnInit,
SimpleChanges,
TemplateRef, TemplateRef,
input, input,
model, model,
output, output,
contentChild, contentChild,
effect,
untracked,
} from '@angular/core'; } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ComponentFamilyClass } from '../../../styles'; import { ComponentFamilyClass } from '../../../styles';
@@ -50,10 +49,7 @@ import { TranslatePipe } from '@ngx-translate/core';
TranslatePipe, TranslatePipe,
], ],
}) })
export class PiExpansionPanelComponent export class PiExpansionPanelComponent extends PiBasePanelComponent {
extends PiBasePanelComponent
implements OnInit, OnChanges
{
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
const type = this.type(); const type = this.type();
const typeClass = type && `pi-panel__expansion--type-${type}`; const typeClass = type && `pi-panel__expansion--type-${type}`;
@@ -90,12 +86,16 @@ export class PiExpansionPanelComponent
readonly expandableContentRef = readonly expandableContentRef =
contentChild<TemplateRef<unknown>>('expandableContent'); contentChild<TemplateRef<unknown>>('expandableContent');
override ngOnInit(): void { constructor() {
if (this.status() === 'disabled') this.isCollapsed.set(true); super();
// Antes eran un `ngOnInit` y un `ngOnChanges` que hacían lo mismo (uno para
// el valor inicial y otro para los cambios). Con `status` siendo un input
// de señal, un solo efecto cubre ambos casos.
effect(() => {
if (this.status() === 'disabled') {
untracked(() => this.isCollapsed.set(true));
} }
});
ngOnChanges({ status }: SimpleChanges): void {
if (status?.currentValue === 'disabled') this.isCollapsed.set(true);
} }
toggle(): void { toggle(): void {

View File

@@ -1,18 +1,14 @@
import { Directive, HostBinding, OnInit, input } from '@angular/core'; import { Directive, HostBinding, input } from '@angular/core';
import { padding } from '../../styles'; import { padding } from '../../styles';
@Directive() @Directive()
export abstract class PiBasePanelContentDirective implements OnInit { export abstract class PiBasePanelContentDirective {
@HostBinding('class') get commonClasses(): string { @HostBinding('class') get commonClasses(): string {
return ['pi-panel-content', this.innerPaddingClass].join(' '); return ['pi-panel-content', this.innerPaddingClass].join(' ');
} }
readonly innerPadding = input<padding>(); readonly innerPadding = input<padding>();
ngOnInit(): void {
this.innerPaddingClass;
}
protected get innerPaddingClass(): string { protected get innerPaddingClass(): string {
const innerPadding = this.innerPadding(); const innerPadding = this.innerPadding();
return innerPadding return innerPadding

View File

@@ -11,6 +11,7 @@ import {
contentChildren, contentChildren,
viewChildren, viewChildren,
ChangeDetectionStrategy, ChangeDetectionStrategy,
inject,
} from '@angular/core'; } from '@angular/core';
import { FormValueControl, ValidationError } from '@angular/forms/signals'; import { FormValueControl, ValidationError } from '@angular/forms/signals';
import { PiSelectOptionComponent } from './select-option/select-option.component'; import { PiSelectOptionComponent } from './select-option/select-option.component';
@@ -197,10 +198,8 @@ export abstract class PiBaseSelectComponent<
read: ElementRef, read: ElementRef,
}); });
constructor( protected readonly el = inject(ElementRef);
protected el: ElementRef, protected readonly _renderer = inject(Renderer2);
protected readonly _renderer: Renderer2,
) {}
trackByFn(index: number): number | undefined { trackByFn(index: number): number | undefined {
return index; return index;

View File

@@ -5,9 +5,9 @@ import {
Component, Component,
ElementRef, ElementRef,
HostBinding, HostBinding,
Renderer2,
effect, effect,
input, input,
inject,
} from '@angular/core'; } from '@angular/core';
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import _ from 'lodash'; import _ from 'lodash';
@@ -78,13 +78,11 @@ export class PiSelectCarouselComponent
* @param el - Element reference of the component. * @param el - Element reference of the component.
* @param _renderer - Renderer to modify native elements. * @param _renderer - Renderer to modify native elements.
*/ */
constructor( private readonly translateService = inject(TranslateService);
private readonly translateService: TranslateService, private readonly _cdr = inject(ChangeDetectorRef);
protected override el: ElementRef,
protected override readonly _renderer: Renderer2, constructor() {
private _cdr: ChangeDetectorRef, super();
) {
super(el, _renderer);
effect(() => { effect(() => {
this.childrenOptionsComponents(); this.childrenOptionsComponents();

View File

@@ -5,9 +5,9 @@ import {
Component, Component,
ElementRef, ElementRef,
HostBinding, HostBinding,
Renderer2,
effect, effect,
input, input,
inject,
} from '@angular/core'; } from '@angular/core';
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component'; import { PiBaseSelectComponent } from '../base-select.component';
@@ -88,13 +88,11 @@ export class PiSelectMultipleComponent
*/ */
readonly selectAllLabel = input<string>(); readonly selectAllLabel = input<string>();
constructor( private readonly translateService = inject(TranslateService);
private readonly translateService: TranslateService, private readonly _cdr = inject(ChangeDetectorRef);
protected override el: ElementRef,
protected override readonly _renderer: Renderer2, constructor() {
private _cdr: ChangeDetectorRef, super();
) {
super(el, _renderer);
/** /**
* Sustituye al `merge(...).subscribe(...)` sobre `control.valueChanges` + * Sustituye al `merge(...).subscribe(...)` sobre `control.valueChanges` +

View File

@@ -6,8 +6,8 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
HostListener, HostListener,
Renderer2,
effect, effect,
inject,
} from '@angular/core'; } from '@angular/core';
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component'; import { PiBaseSelectComponent } from '../base-select.component';
@@ -84,13 +84,11 @@ export class PiSelectPeriodComponent
* @param el - Element reference of the component. * @param el - Element reference of the component.
* @param _renderer - Renderer to modify native elements. * @param _renderer - Renderer to modify native elements.
*/ */
constructor( private readonly translateService = inject(TranslateService);
private readonly translateService: TranslateService, private readonly _cdr = inject(ChangeDetectorRef);
protected override el: ElementRef,
protected override readonly _renderer: Renderer2, constructor() {
private _cdr: ChangeDetectorRef, super();
) {
super(el, _renderer);
effect(() => { effect(() => {
this.childrenOptionsComponents(); this.childrenOptionsComponents();

View File

@@ -6,10 +6,10 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
HostListener, HostListener,
Renderer2,
effect, effect,
forwardRef, forwardRef,
input, input,
inject,
} from '@angular/core'; } from '@angular/core';
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component'; import { PiBaseSelectComponent } from '../base-select.component';
@@ -92,13 +92,11 @@ export class PiSelectComponent
* @param el - Element reference of the component. * @param el - Element reference of the component.
* @param _renderer - Renderer to modify native elements. * @param _renderer - Renderer to modify native elements.
*/ */
constructor( private readonly translateService = inject(TranslateService);
private readonly translateService: TranslateService, private readonly _cdr = inject(ChangeDetectorRef);
protected override el: ElementRef,
protected override readonly _renderer: Renderer2, constructor() {
private _cdr: ChangeDetectorRef, super();
) {
super(el, _renderer);
/** /**
* Sustituye al `merge(...).subscribe(...)` sobre `control.valueChanges` + * Sustituye al `merge(...).subscribe(...)` sobre `control.valueChanges` +

View File

@@ -1,11 +1,11 @@
import { Directive, TemplateRef } from '@angular/core'; import { Directive, TemplateRef, inject } from '@angular/core';
@Directive({ selector: '[pi-tab-content]' }) @Directive({ selector: '[pi-tab-content]' })
export class PiTabContentDirective { export class PiTabContentDirective {
constructor(public templateRef: TemplateRef<any>) {} public readonly templateRef = inject<any>(TemplateRef);
} }
@Directive({ selector: '[pi-tab-element]' }) @Directive({ selector: '[pi-tab-element]' })
export class PiTabElementDirective { export class PiTabElementDirective {
constructor(public templateRef: TemplateRef<any>) {} readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
} }

View File

@@ -1,5 +1,5 @@
// Angular // Angular
import { Directive, HostBinding, OnInit, input } from '@angular/core'; import { Directive, HostBinding, input } from '@angular/core';
// Style definitions // Style definitions
import { import {
@@ -17,7 +17,7 @@ import {
} from '../../styles'; } from '../../styles';
@Directive() @Directive()
export abstract class PiBaseTextViewerComponent implements OnInit { export abstract class PiBaseTextViewerComponent {
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
return [ return [
ComponentFamilyClass.Text, ComponentFamilyClass.Text,
@@ -58,23 +58,6 @@ export abstract class PiBaseTextViewerComponent implements OnInit {
readonly label = input<string | unknown>(); readonly label = input<string | unknown>();
readonly value = input<string>(); readonly value = input<string>();
ngOnInit(): void {
this.alignmentClass;
this.borderTypeClass;
this.colorClass;
this.flexDirectionClass;
this.fontSizeClass;
this.fontStyleClass;
this.fontWeightClass;
this.justifyClass;
this.iconSizeClass;
this.iconColorClass;
this.isDisabledClass;
this.hasLabelClass;
this.hasValueClass;
this.iconAppearanceClass;
}
protected get alignmentClass(): string { protected get alignmentClass(): string {
const alignmentValue = this.alignment(); const alignmentValue = this.alignment();
return alignmentValue && `pi-text-viewer__align--${alignmentValue}`; return alignmentValue && `pi-text-viewer__align--${alignmentValue}`;

View File

@@ -3,9 +3,10 @@ import {
ElementRef, ElementRef,
HostBinding, HostBinding,
OnDestroy, OnDestroy,
OnInit,
Renderer2, Renderer2,
input, input,
effect,
inject,
} from '@angular/core'; } from '@angular/core';
import { MatTooltip, TooltipPosition } from '@angular/material/tooltip'; import { MatTooltip, TooltipPosition } from '@angular/material/tooltip';
@@ -13,7 +14,7 @@ import { MatTooltip, TooltipPosition } from '@angular/material/tooltip';
selector: '[pi-tooltip]', selector: '[pi-tooltip]',
providers: [MatTooltip], providers: [MatTooltip],
}) })
export class PiTooltipDirective implements OnInit, OnDestroy { export class PiTooltipDirective implements OnDestroy {
@HostBinding('class') className = 'pi-tooltip-dispatcher'; @HostBinding('class') className = 'pi-tooltip-dispatcher';
readonly tooltipText = input<string>(); readonly tooltipText = input<string>();
@@ -25,19 +26,24 @@ export class PiTooltipDirective implements OnInit, OnDestroy {
private mouseoutListener!: () => void; private mouseoutListener!: () => void;
private clickListener!: () => void; private clickListener!: () => void;
constructor( private readonly tooltip = inject(MatTooltip);
private tooltip: MatTooltip, private readonly el = inject(ElementRef);
private el: ElementRef, private readonly renderer = inject(Renderer2);
private renderer: Renderer2,
) {}
ngOnInit(): void { constructor() {
// La clase depende de inputs, así que va en un efecto; los listeners no
// dependen de nada y se registran una sola vez.
effect(() => {
this.tooltip.tooltipClass = [ this.tooltip.tooltipClass = [
'pi-tooltip', 'pi-tooltip',
this.typeClass, this.typeClass,
this.colorClass, this.colorClass,
].join(' '); ].join(' ');
});
this.registerListeners();
}
private registerListeners(): void {
this.mouseoverListener = this.renderer.listen( this.mouseoverListener = this.renderer.listen(
this.el.nativeElement, this.el.nativeElement,
'mouseover', 'mouseover',

View File

@@ -1,5 +1,5 @@
// Angular // Angular
import { Directive, OnInit, input } from '@angular/core'; import { Directive, input } from '@angular/core';
import { itemsWidth } from '../../styles'; import { itemsWidth } from '../../styles';
import { import {
@@ -12,7 +12,7 @@ import {
} from '../../styles'; } from '../../styles';
@Directive() @Directive()
export abstract class PiBaseWrapper implements OnInit { export abstract class PiBaseWrapper {
readonly alignment = input<alignment>('center'); readonly alignment = input<alignment>('center');
readonly flexDirection = input<flexDirection>('row'); readonly flexDirection = input<flexDirection>('row');
readonly gap = input.required<gap>(); readonly gap = input.required<gap>();
@@ -23,17 +23,6 @@ export abstract class PiBaseWrapper implements OnInit {
readonly maxWidth = input<maxWidth>('edge-to-edge'); readonly maxWidth = input<maxWidth>('edge-to-edge');
readonly itemsWidth = input<itemsWidth>(); readonly itemsWidth = input<itemsWidth>();
ngOnInit(): void {
this.alignmentClass;
this.flexDirectionClass;
this.gridClass;
this.hasBackgroundClass;
this.hasPaddingClass;
this.justifyClass;
this.itemsWidthClass;
this.maxWidthClass;
}
protected get maxWidthClass(): string | undefined { protected get maxWidthClass(): string | undefined {
const maxWidthValue = this.maxWidth(); const maxWidthValue = this.maxWidth();
return maxWidthValue && `pi-wrapper__${maxWidthValue}`; return maxWidthValue && `pi-wrapper__${maxWidthValue}`;

View File

@@ -2,14 +2,15 @@ import {
Directive, Directive,
ElementRef, ElementRef,
HostBinding, HostBinding,
OnInit,
Renderer2, Renderer2,
input, input,
afterNextRender,
inject,
} from '@angular/core'; } from '@angular/core';
import { unit } from '../models'; import { unit } from '../models';
@Directive() @Directive()
export abstract class PiBaseAffixDirective implements OnInit { export abstract class PiBaseAffixDirective {
@HostBinding('class') get classes(): string { @HostBinding('class') get classes(): string {
return ['pi-affix', `pi-affix__appearance--${this.appearance()}`].join(' '); return ['pi-affix', `pi-affix__appearance--${this.appearance()}`].join(' ');
} }
@@ -17,12 +18,17 @@ export abstract class PiBaseAffixDirective implements OnInit {
readonly unit = input<unit | string>(); readonly unit = input<unit | string>();
readonly appearance = input<'ghost' | 'fill'>('ghost'); readonly appearance = input<'ghost' | 'fill'>('ghost');
constructor( private readonly renderer = inject(Renderer2);
private renderer: Renderer2, private readonly el = inject(ElementRef);
private el: ElementRef,
) {}
ngOnInit() { constructor() {
// Pinta DOM a partir de un input, así que no puede ir en el cuerpo del
// constructor (los inputs aún no tienen valor). `afterNextRender` corre una
// sola vez tras el primer render, que es lo que hacía el `ngOnInit`.
afterNextRender(() => this.renderUnit());
}
private renderUnit(): void {
const unitValue = this.unit(); const unitValue = this.unit();
if (unitValue) { if (unitValue) {
const span = this.renderer.createElement('span'); const span = this.renderer.createElement('span');

View File

@@ -1,5 +1,5 @@
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; import { Inject, Injectable, PLATFORM_ID, inject } from '@angular/core';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -8,7 +8,9 @@ export class AmplitudeService {
private amp: any; private amp: any;
private ampEnabled = false; private ampEnabled = false;
constructor(@Inject(PLATFORM_ID) public platformId: object) { readonly platformId = inject<object>(PLATFORM_ID);
constructor() {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
this.amp = (window as any).amplitude; this.amp = (window as any).amplitude;
if (this.amp) this.ampEnabled = true; if (this.amp) this.ampEnabled = true;

View File

@@ -1,8 +1,8 @@
import { Directive, HostBinding, OnInit, input } from '@angular/core'; import { Directive, HostBinding, input } from '@angular/core';
import { density, mode, opacity, styleType } from '../../../styles'; import { density, mode, opacity, styleType } from '../../../styles';
@Directive() @Directive()
export abstract class PiBaseColor implements OnInit { export abstract class PiBaseColor {
@HostBinding('class') get commonClasses(): string { @HostBinding('class') get commonClasses(): string {
return [this.densityClass, this.modeClass].join(' '); return [this.densityClass, this.modeClass].join(' ');
} }
@@ -10,9 +10,6 @@ export abstract class PiBaseColor implements OnInit {
readonly opacity = input<opacity>('100'); readonly opacity = input<opacity>('100');
readonly mode = input<mode>('dark'); readonly mode = input<mode>('dark');
ngOnInit(): void {
this.densityClass;
}
protected get densityClass(): string { protected get densityClass(): string {
const densityValue = this.density(); const densityValue = this.density();
return !!densityValue ? `pi-color__density--${densityValue}` : ''; return !!densityValue ? `pi-color__density--${densityValue}` : '';

View File

@@ -1,12 +1,12 @@
// Angular // Angular
import { HttpParams } from '@angular/common/http'; import { HttpParams } from '@angular/common/http';
import { Inject, PLATFORM_ID } from '@angular/core'; import { Inject, PLATFORM_ID, inject } from '@angular/core';
// Shared // Shared
import { Params } from '../models'; import { Params } from '../models';
import { timer } from 'rxjs'; import { timer } from 'rxjs';
export class Commons { export class Commons {
constructor(@Inject(PLATFORM_ID) public platformId: object) {} readonly platformId = inject<object>(PLATFORM_ID);
static followUrl($event: MouseEvent, item: { url: string }): void { static followUrl($event: MouseEvent, item: { url: string }): void {
Commons.followExternalUrl($event, item); Commons.followExternalUrl($event, item);

View File

@@ -1,10 +1,10 @@
// Angular // Angular
import { Inject, PLATFORM_ID } from '@angular/core'; import { Inject, PLATFORM_ID, inject } from '@angular/core';
import { Sector } from '../models'; import { Sector } from '../models';
import { SectorType, SectorUidMatchLocale } from '../models'; import { SectorType, SectorUidMatchLocale } from '../models';
export class SectorCommons { export class SectorCommons {
constructor(@Inject(PLATFORM_ID) public platformId: object) {} readonly platformId = inject<object>(PLATFORM_ID);
static getSectorLocaleFromUid(uid: string): string { static getSectorLocaleFromUid(uid: string): string {
const sectorLocale: string = SectorUidMatchLocale[uid as SectorType]; const sectorLocale: string = SectorUidMatchLocale[uid as SectorType];

View File

@@ -1,8 +1,8 @@
import { Inject, PLATFORM_ID } from '@angular/core'; import { Inject, PLATFORM_ID, inject } from '@angular/core';
import { SelectOption } from '../../ui/select'; import { SelectOption } from '../../ui/select';
export class SelectOptionsCommons { export class SelectOptionsCommons {
constructor(@Inject(PLATFORM_ID) public platformId: object) {} readonly platformId = inject<object>(PLATFORM_ID);
static convertStringstoSelectOptions( static convertStringstoSelectOptions(
values: Array<any>, values: Array<any>,

View File

@@ -1,6 +1,6 @@
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; import { Inject, Injectable, PLATFORM_ID, inject } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@@ -32,12 +32,12 @@ import { DeviceConfigsState } from '../state/device-configs.reducer';
export class DeviceConfigsService { export class DeviceConfigsService {
private htmlTag!: HTMLElement; private htmlTag!: HTMLElement;
constructor( private readonly store = inject<Store<DeviceConfigsState>>(Store);
private store: Store<DeviceConfigsState>, private readonly breakpointObserver = inject(BreakpointObserver);
private breakpointObserver: BreakpointObserver, private readonly document = inject<Document>(DOCUMENT);
@Inject(DOCUMENT) private document: Document, readonly platformId = inject<Object>(PLATFORM_ID);
@Inject(PLATFORM_ID) public platformId: Object,
) { constructor() {
this.deviceConfigsHandler(); this.deviceConfigsHandler();
} }

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
// Rxjs // Rxjs
import { of } from 'rxjs'; import { of } from 'rxjs';
@@ -15,10 +15,8 @@ import { DeviceConfigsService } from '../services/device-configs.service';
@Injectable() @Injectable()
export class DeviceConfigsEffects { export class DeviceConfigsEffects {
constructor( private readonly deviceConfigsService = inject(DeviceConfigsService);
private deviceConfigsService: DeviceConfigsService, private readonly actions = inject(Actions);
private actions: Actions,
) {}
getBrowser$ = createEffect(() => { getBrowser$ = createEffect(() => {
return this.actions.pipe( return this.actions.pipe(

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { MatIconRegistry } from '@angular/material/icon'; import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser'; import { DomSanitizer } from '@angular/platform-browser';
import { customIcons } from './icons.data'; import { customIcons } from './icons.data';
@@ -7,10 +7,8 @@ import { customIcons } from './icons.data';
providedIn: 'root', providedIn: 'root',
}) })
export class IconRegistryService { export class IconRegistryService {
constructor( private readonly matIconRegistry = inject(MatIconRegistry);
private matIconRegistry: MatIconRegistry, private readonly domSanitizer = inject(DomSanitizer);
private domSanitizer: DomSanitizer,
) {}
init(): void { init(): void {
this.registerIcons(customIcons, 'assets/icons/'); this.registerIcons(customIcons, 'assets/icons/');

View File

@@ -1,10 +1,10 @@
// Angular // Angular
import { isPlatformBrowser } from '@angular/common'; import { isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; import { Inject, Injectable, PLATFORM_ID, inject } from '@angular/core';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class LocalStorageService { export class LocalStorageService {
constructor(@Inject(PLATFORM_ID) public platformId: Object) {} readonly platformId = inject<Object>(PLATFORM_ID);
getItem(tag: string) { getItem(tag: string) {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {

View File

@@ -4,16 +4,15 @@ import {
HostBinding, HostBinding,
HostListener, HostListener,
Renderer2, Renderer2,
inject,
} from '@angular/core'; } from '@angular/core';
@Directive() @Directive()
export abstract class PiBaseMenuEventItem { export abstract class PiBaseMenuEventItem {
@HostBinding('class') className = 'pi-menu-event-item'; @HostBinding('class') className = 'pi-menu-event-item';
constructor( protected readonly el = inject(ElementRef);
protected el: ElementRef, protected readonly renderer = inject(Renderer2);
protected renderer: Renderer2,
) {}
@HostListener('click') @HostListener('click')
onClick() { onClick() {

View File

@@ -4,15 +4,14 @@ import {
HostBinding, HostBinding,
HostListener, HostListener,
Renderer2, Renderer2,
inject,
} from '@angular/core'; } from '@angular/core';
@Directive({ selector: '[pi-menu-event-children]' }) @Directive({ selector: '[pi-menu-event-children]' })
export class PiMenuEventChildrenDirective { export class PiMenuEventChildrenDirective {
@HostBinding('class') className = 'pi-menu pi-menu__children'; @HostBinding('class') className = 'pi-menu pi-menu__children';
constructor( private readonly el = inject(ElementRef);
private el: ElementRef, private readonly renderer = inject(Renderer2);
private renderer: Renderer2,
) {}
@HostListener('click') onClickMenuItem() { @HostListener('click') onClickMenuItem() {
const menuChildren = this.el.nativeElement; const menuChildren = this.el.nativeElement;

View File

@@ -1,9 +1,9 @@
import { ErrorHandler, Injectable, Injector } from '@angular/core'; import { ErrorHandler, Injectable, Injector, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@Injectable() @Injectable()
export class GlobalErrorHandler implements ErrorHandler { export class GlobalErrorHandler implements ErrorHandler {
constructor(private injector: Injector) {} private readonly injector = inject(Injector);
handleError(error: any) { handleError(error: any) {
const router = this.injector.get(Router); const router = this.injector.get(Router);
console.error(error.stack.toString()); console.error(error.stack.toString());

View File

@@ -1,6 +1,6 @@
// Angular // Angular
import { DOCUMENT } from '@angular/common'; import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2 } from '@angular/core'; import { Inject, Injectable, Renderer2, inject } from '@angular/core';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -8,7 +8,7 @@ import { Inject, Injectable, Renderer2 } from '@angular/core';
export class SchemaService { export class SchemaService {
script!: HTMLScriptElement; script!: HTMLScriptElement;
constructor(@Inject(DOCUMENT) private doc: Document) {} private readonly doc = inject<Document>(DOCUMENT);
loadJsScript(renderer: Renderer2, data: any): HTMLScriptElement { loadJsScript(renderer: Renderer2, data: any): HTMLScriptElement {
const script = renderer.createElement('script'); const script = renderer.createElement('script');

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -6,7 +6,7 @@ import { Observable } from 'rxjs';
providedIn: 'root', providedIn: 'root',
}) })
export class I18nService { export class I18nService {
constructor(private translateService: TranslateService) {} private readonly translateService = inject(TranslateService);
setLanguage(lang: string): Observable<any> { setLanguage(lang: string): Observable<any> {
return new Observable((observer) => { return new Observable((observer) => {

View File

@@ -1,15 +1,13 @@
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { TranslateLoader } from '@ngx-translate/core'; import { TranslateLoader } from '@ngx-translate/core';
import { makeStateKey, StateKey, TransferState } from '@angular/core'; import { makeStateKey, StateKey, TransferState, inject } from '@angular/core';
import { join } from 'path'; import { join } from 'path';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
export class TranslateServerLoader implements TranslateLoader { export class TranslateServerLoader implements TranslateLoader {
constructor( private readonly transferState = inject(TransferState);
private transferState: TransferState, private readonly prefix = inject((string = 'i18n'));
private prefix: string = 'i18n', private readonly suffix = inject((string = '.json'));
private suffix: string = '.json',
) {}
public getTranslation(lang: string): Observable<any> { public getTranslation(lang: string): Observable<any> {
return new Observable((observer) => { return new Observable((observer) => {

0
nx/core/package-json Normal file
View File

0
nx/core/project-json Normal file
View File

View File

265
package-lock.json generated

File diff suppressed because it is too large Load Diff