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',
save: 'Guardar',
reset: 'Borrar',
search: 'Buscar…',
noResults: 'Sin resultados',
validator: {
requiredField: 'Este campo es obligatorio',
onlyNumbers: 'Sólo se admiten números',

View File

@@ -81,6 +81,28 @@
<pi-label>Fecha de nacimiento</pi-label>
</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
[formField]="f.shipping"
[options]="shippingOptions"

View File

@@ -91,6 +91,11 @@ import {
PiSelectCarouselComponent,
PiSelectComponent,
PiSelectMultipleComponent,
PiSearchableMultiSelectFieldComponent,
PiSearchableSelectFieldComponent,
PiSelectFieldComponent,
PiSelectFieldOption,
PiMultiSelectFieldComponent,
PiSelectOptionComponent,
PiSelectPeriodComponent,
PiSlideToggleComponent,
@@ -148,6 +153,10 @@ interface DemoModel {
plan: SelectOptionValue | null;
terms: boolean;
shipping: string | null;
language: string | null;
country2: string | null;
visited: string[];
skills: string[];
birthDate: Date | null;
attachments: File[];
}
@@ -240,6 +249,10 @@ interface DemoModel {
PiSelectPeriodComponent,
PiSelectCarouselComponent,
PiSelectOptionComponent,
PiSelectFieldComponent,
PiMultiSelectFieldComponent,
PiSearchableSelectFieldComponent,
PiSearchableMultiSelectFieldComponent,
// Datos
PiDataGridComponent,
PiCellDirective,
@@ -285,6 +298,10 @@ export class CatalogPage {
plan: null,
terms: false,
shipping: null,
language: null,
country2: null,
visited: [],
skills: [],
birthDate: null,
attachments: [],
});
@@ -312,6 +329,71 @@ export class CatalogPage {
{ 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 = [
{ id: 1, name: 'Básico', description: '9 €/mes' },
{ id: 2, name: 'Pro', description: '29 €/mes' },

View File

@@ -47,3 +47,35 @@
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-spinner';
@forward 'mat-radio-button';
@forward 'mat-select';
@forward 'mat-slider';
@forward 'mat-snackbar';
@forward 'mat-tooltip';

View File

@@ -95,9 +95,3 @@
.pi-shell__alternative {
@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`).
//
// 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`
// porque los overlays de Material (diálogos, snackbars, menús) se montan fuera
// del árbol del shell y no heredarían nada.
// varios temas en la misma página.
//
// 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 {
// --- Botones ---------------------------------------------------------
@@ -69,7 +73,6 @@
// 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
// Material). Los temas concretos se aplican encima; ver `_alternative.scss`.
.pi-shell,
.cdk-overlay-container {
.pi-shell {
@include defaultThemeTokens;
}

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
// Angular
import { Directive, HostBinding, OnInit, input, output } from '@angular/core';
import { Directive, input, output } from '@angular/core';
// Style definitions
import { alignment, context, gap, mode, opacity, padding } from '../../styles';
@Directive()
export abstract class PiBaseCardComponent implements OnInit {
export abstract class PiBaseCardComponent {
readonly hasShadow = input(true);
readonly isClickable = input(false);
readonly isStroked = input(false);
@@ -25,19 +25,6 @@ export abstract class PiBaseCardComponent implements OnInit {
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 {
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({
selector: '[pi-carousel-item]',
host: { class: 'pi-carousel pi-carousel__item' },
})
export class PiCarouselItemDirective {
constructor(
public templateRef: TemplateRef<any>,
public viewContainerRef: ViewContainerRef,
) {}
public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
public readonly viewContainerRef = inject(ViewContainerRef);
}

View File

@@ -7,15 +7,15 @@ import {
Component,
ElementRef,
HostBinding,
OnChanges,
OnDestroy,
OnInit,
Renderer2,
SimpleChanges,
input,
output,
viewChild,
contentChildren,
inject,
effect,
untracked,
} from '@angular/core';
// Shared
@@ -34,7 +34,7 @@ import { PiIconButtonComponent } from '../../button/icon-button/icon-button.comp
imports: [PiIconButtonComponent, NgTemplateOutlet],
})
export class PiCarouselComponent
implements OnInit, OnChanges, AfterViewInit, AfterViewChecked, OnDestroy
implements AfterViewInit, AfterViewChecked, OnDestroy
{
@HostBinding('class') get classes(): string {
const carouselItems = this.carouselItems();
@@ -168,12 +168,26 @@ export class PiCarouselComponent
private touchmoveListener!: () => void;
private touchendListener!: () => void;
constructor(
private cdr: ChangeDetectorRef,
private el: ElementRef,
private renderer: Renderer2,
private deviceConfig: DeviceConfigsFacade,
) {}
private readonly cdr = inject(ChangeDetectorRef);
private readonly el = inject(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly deviceConfig = inject(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.
@@ -239,20 +253,6 @@ export class PiCarouselComponent
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 {
this.setItemsPerPage();
this.setPages();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -166,7 +166,9 @@ export class PiFileUploadComponent implements FormValueControl<File[]> {
}
// 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();
if (this.multiple() && maxFiles !== undefined && next.length > maxFiles) {

View File

@@ -20,12 +20,11 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</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 {
MatFormField,
MatLabel,

View File

@@ -1,27 +1,37 @@
import {
Directive,
OnChanges,
OnInit,
SimpleChanges,
input,
afterNextRender,
inject,
effect,
untracked,
} from '@angular/core';
import { MatInput } from '@angular/material/input';
@Directive({ selector: '[pi-autofocus]' })
export class PiAutofocusDirective implements OnInit, OnChanges {
export class PiAutofocusDirective {
readonly isAutofocusSelected = input<boolean>();
constructor(private matInput: MatInput) {}
private readonly matInput = inject(MatInput);
ngOnInit() {
if (this.isAutofocusSelected()) {
setTimeout(() => this.matInput.focus());
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes) {
constructor() {
// `afterNextRender` sustituye al `ngOnInit` + `setTimeout`: ya corre con la
// 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(() => {
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,
ElementRef,
HostBinding,
OnInit,
Renderer2,
input,
afterNextRender,
inject,
} from '@angular/core';
@Directive({ selector: 'pi-label' })
export class PiLabelDirective implements OnInit {
export class PiLabelDirective {
@HostBinding('class') get classes(): string {
const validationClass = this.isRequired() ? 'is-required' : '';
return ['pi-label', validationClass].join(' ');
@@ -17,12 +18,15 @@ export class PiLabelDirective implements OnInit {
readonly isRequired = input<boolean>(false);
constructor(
private renderer: Renderer2,
private el: ElementRef,
) {}
private readonly renderer = inject(Renderer2);
private readonly el = inject(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()) {
const span = this.renderer.createElement('span');
const text = this.renderer.createText('*');

View File

@@ -17,12 +17,11 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
}
</mat-form-field>

View File

@@ -18,12 +18,12 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
</section>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
}
</mat-form-field>
</section>

View File

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

View File

@@ -38,12 +38,11 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</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 './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,12 +20,11 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
}
</mat-form-field>

View File

@@ -24,12 +24,11 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
}
</mat-form-field>

View File

@@ -16,12 +16,11 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
}
</mat-form-field>

View File

@@ -39,9 +39,9 @@ export interface PiRadioOption<TValue = unknown> {
imports: [PiRadioButtonComponent, TranslatePipe],
host: { role: 'radiogroup' },
})
export class PiRadioGroupComponent<TValue = unknown>
implements FormValueControl<TValue | null>
{
export class PiRadioGroupComponent<
TValue = unknown,
> implements FormValueControl<TValue | null> {
@HostBinding('class') get classes(): string {
return [
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,12 +15,11 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
/>
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
}
</mat-form-field>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,10 +2,9 @@ import {
ChangeDetectionStrategy,
Component,
HostBinding,
Inject,
Optional,
forwardRef,
signal,
inject,
} from '@angular/core';
import {
MatSnackBarRef,
@@ -73,7 +72,10 @@ export class PiSnackBarComponent {
/** The instance of the component making up the content of the snack bar. */
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) {
this.message.set(data.message);
this.icon.set(data.icon);

View File

@@ -1,12 +1,13 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
HostBinding,
forwardRef,
input,
model,
output,
effect,
untracked,
} from '@angular/core';
import { PiIconButtonComponent } from '../../button/icon-button/icon-button.component';
@@ -23,7 +24,7 @@ import { PiIconComponent } from '../../icon/icon/icon.component';
forwardRef(() => PiPaginatorComponent),
],
})
export class PiPaginatorComponent implements OnInit {
export class PiPaginatorComponent {
@HostBinding('class') className = 'pi-paginator';
readonly currentPage = model(1);
readonly pageSize = input.required<number>();
@@ -36,8 +37,13 @@ export class PiPaginatorComponent implements OnInit {
firstPagesRange = 0;
ngOnInit(): void {
this.initFirstPages();
constructor() {
// `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() {

View File

@@ -1,5 +1,5 @@
// Angular
import { Directive, OnInit, input } from '@angular/core';
import { Directive, input } from '@angular/core';
import {
alignment,
borderRadius,
@@ -13,7 +13,7 @@ import {
import { status } from '../../util/status';
@Directive()
export abstract class PiBasePanelComponent implements OnInit {
export abstract class PiBasePanelComponent {
readonly alignment = input<alignment>('center');
readonly borderRadius = input<borderRadius>('large');
readonly color = input<styleType>('white');
@@ -38,22 +38,6 @@ export abstract class PiBasePanelComponent implements OnInit {
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 {
const width = this.width();
return width && `pi-panel__width--${width}`;

View File

@@ -2,14 +2,13 @@ import {
ChangeDetectionStrategy,
Component,
HostBinding,
OnChanges,
OnInit,
SimpleChanges,
TemplateRef,
input,
model,
output,
contentChild,
effect,
untracked,
} from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ComponentFamilyClass } from '../../../styles';
@@ -50,10 +49,7 @@ import { TranslatePipe } from '@ngx-translate/core';
TranslatePipe,
],
})
export class PiExpansionPanelComponent
extends PiBasePanelComponent
implements OnInit, OnChanges
{
export class PiExpansionPanelComponent extends PiBasePanelComponent {
@HostBinding('class') get classes(): string {
const type = this.type();
const typeClass = type && `pi-panel__expansion--type-${type}`;
@@ -90,12 +86,16 @@ export class PiExpansionPanelComponent
readonly expandableContentRef =
contentChild<TemplateRef<unknown>>('expandableContent');
override ngOnInit(): void {
if (this.status() === 'disabled') this.isCollapsed.set(true);
constructor() {
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 {

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';
@Directive()
export abstract class PiBasePanelContentDirective implements OnInit {
export abstract class PiBasePanelContentDirective {
@HostBinding('class') get commonClasses(): string {
return ['pi-panel-content', this.innerPaddingClass].join(' ');
}
readonly innerPadding = input<padding>();
ngOnInit(): void {
this.innerPaddingClass;
}
protected get innerPaddingClass(): string {
const innerPadding = this.innerPadding();
return innerPadding

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,10 @@ import {
ElementRef,
HostBinding,
HostListener,
Renderer2,
effect,
forwardRef,
input,
inject,
} from '@angular/core';
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component';
@@ -92,13 +92,11 @@ export class PiSelectComponent
* @param el - Element reference of the component.
* @param _renderer - Renderer to modify native elements.
*/
constructor(
private readonly translateService: TranslateService,
protected override el: ElementRef,
protected override readonly _renderer: Renderer2,
private _cdr: ChangeDetectorRef,
) {
super(el, _renderer);
private readonly translateService = inject(TranslateService);
private readonly _cdr = inject(ChangeDetectorRef);
constructor() {
super();
/**
* 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]' })
export class PiTabContentDirective {
constructor(public templateRef: TemplateRef<any>) {}
public readonly templateRef = inject<any>(TemplateRef);
}
@Directive({ selector: '[pi-tab-element]' })
export class PiTabElementDirective {
constructor(public templateRef: TemplateRef<any>) {}
readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
}

View File

@@ -1,5 +1,5 @@
// Angular
import { Directive, HostBinding, OnInit, input } from '@angular/core';
import { Directive, HostBinding, input } from '@angular/core';
// Style definitions
import {
@@ -17,7 +17,7 @@ import {
} from '../../styles';
@Directive()
export abstract class PiBaseTextViewerComponent implements OnInit {
export abstract class PiBaseTextViewerComponent {
@HostBinding('class') get classes(): string {
return [
ComponentFamilyClass.Text,
@@ -58,23 +58,6 @@ export abstract class PiBaseTextViewerComponent implements OnInit {
readonly label = input<string | unknown>();
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 {
const alignmentValue = this.alignment();
return alignmentValue && `pi-text-viewer__align--${alignmentValue}`;

View File

@@ -3,9 +3,10 @@ import {
ElementRef,
HostBinding,
OnDestroy,
OnInit,
Renderer2,
input,
effect,
inject,
} from '@angular/core';
import { MatTooltip, TooltipPosition } from '@angular/material/tooltip';
@@ -13,7 +14,7 @@ import { MatTooltip, TooltipPosition } from '@angular/material/tooltip';
selector: '[pi-tooltip]',
providers: [MatTooltip],
})
export class PiTooltipDirective implements OnInit, OnDestroy {
export class PiTooltipDirective implements OnDestroy {
@HostBinding('class') className = 'pi-tooltip-dispatcher';
readonly tooltipText = input<string>();
@@ -25,19 +26,24 @@ export class PiTooltipDirective implements OnInit, OnDestroy {
private mouseoutListener!: () => void;
private clickListener!: () => void;
constructor(
private tooltip: MatTooltip,
private el: ElementRef,
private renderer: Renderer2,
) {}
private readonly tooltip = inject(MatTooltip);
private readonly el = inject(ElementRef);
private readonly renderer = inject(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 = [
'pi-tooltip',
this.typeClass,
this.colorClass,
].join(' ');
});
this.registerListeners();
}
private registerListeners(): void {
this.mouseoverListener = this.renderer.listen(
this.el.nativeElement,
'mouseover',

View File

@@ -1,5 +1,5 @@
// Angular
import { Directive, OnInit, input } from '@angular/core';
import { Directive, input } from '@angular/core';
import { itemsWidth } from '../../styles';
import {
@@ -12,7 +12,7 @@ import {
} from '../../styles';
@Directive()
export abstract class PiBaseWrapper implements OnInit {
export abstract class PiBaseWrapper {
readonly alignment = input<alignment>('center');
readonly flexDirection = input<flexDirection>('row');
readonly gap = input.required<gap>();
@@ -23,17 +23,6 @@ export abstract class PiBaseWrapper implements OnInit {
readonly maxWidth = input<maxWidth>('edge-to-edge');
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 {
const maxWidthValue = this.maxWidth();
return maxWidthValue && `pi-wrapper__${maxWidthValue}`;

View File

@@ -2,14 +2,15 @@ import {
Directive,
ElementRef,
HostBinding,
OnInit,
Renderer2,
input,
afterNextRender,
inject,
} from '@angular/core';
import { unit } from '../models';
@Directive()
export abstract class PiBaseAffixDirective implements OnInit {
export abstract class PiBaseAffixDirective {
@HostBinding('class') get classes(): string {
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 appearance = input<'ghost' | 'fill'>('ghost');
constructor(
private renderer: Renderer2,
private el: ElementRef,
) {}
private readonly renderer = inject(Renderer2);
private readonly el = inject(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();
if (unitValue) {
const span = this.renderer.createElement('span');

View File

@@ -1,5 +1,5 @@
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',
@@ -8,7 +8,9 @@ export class AmplitudeService {
private amp: any;
private ampEnabled = false;
constructor(@Inject(PLATFORM_ID) public platformId: object) {
readonly platformId = inject<object>(PLATFORM_ID);
constructor() {
if (isPlatformBrowser(this.platformId)) {
this.amp = (window as any).amplitude;
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';
@Directive()
export abstract class PiBaseColor implements OnInit {
export abstract class PiBaseColor {
@HostBinding('class') get commonClasses(): string {
return [this.densityClass, this.modeClass].join(' ');
}
@@ -10,9 +10,6 @@ export abstract class PiBaseColor implements OnInit {
readonly opacity = input<opacity>('100');
readonly mode = input<mode>('dark');
ngOnInit(): void {
this.densityClass;
}
protected get densityClass(): string {
const densityValue = this.density();
return !!densityValue ? `pi-color__density--${densityValue}` : '';

View File

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

View File

@@ -1,10 +1,10 @@
// Angular
import { Inject, PLATFORM_ID } from '@angular/core';
import { Inject, PLATFORM_ID, inject } from '@angular/core';
import { Sector } from '../models';
import { SectorType, SectorUidMatchLocale } from '../models';
export class SectorCommons {
constructor(@Inject(PLATFORM_ID) public platformId: object) {}
readonly platformId = inject<object>(PLATFORM_ID);
static getSectorLocaleFromUid(uid: string): string {
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';
export class SelectOptionsCommons {
constructor(@Inject(PLATFORM_ID) public platformId: object) {}
readonly platformId = inject<object>(PLATFORM_ID);
static convertStringstoSelectOptions(
values: Array<any>,

View File

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

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
// Rxjs
import { of } from 'rxjs';
@@ -15,10 +15,8 @@ import { DeviceConfigsService } from '../services/device-configs.service';
@Injectable()
export class DeviceConfigsEffects {
constructor(
private deviceConfigsService: DeviceConfigsService,
private actions: Actions,
) {}
private readonly deviceConfigsService = inject(DeviceConfigsService);
private readonly actions = inject(Actions);
getBrowser$ = createEffect(() => {
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 { DomSanitizer } from '@angular/platform-browser';
import { customIcons } from './icons.data';
@@ -7,10 +7,8 @@ import { customIcons } from './icons.data';
providedIn: 'root',
})
export class IconRegistryService {
constructor(
private matIconRegistry: MatIconRegistry,
private domSanitizer: DomSanitizer,
) {}
private readonly matIconRegistry = inject(MatIconRegistry);
private readonly domSanitizer = inject(DomSanitizer);
init(): void {
this.registerIcons(customIcons, 'assets/icons/');

View File

@@ -1,10 +1,10 @@
// Angular
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' })
export class LocalStorageService {
constructor(@Inject(PLATFORM_ID) public platformId: Object) {}
readonly platformId = inject<Object>(PLATFORM_ID);
getItem(tag: string) {
if (isPlatformBrowser(this.platformId)) {

View File

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

View File

@@ -4,15 +4,14 @@ import {
HostBinding,
HostListener,
Renderer2,
inject,
} from '@angular/core';
@Directive({ selector: '[pi-menu-event-children]' })
export class PiMenuEventChildrenDirective {
@HostBinding('class') className = 'pi-menu pi-menu__children';
constructor(
private el: ElementRef,
private renderer: Renderer2,
) {}
private readonly el = inject(ElementRef);
private readonly renderer = inject(Renderer2);
@HostListener('click') onClickMenuItem() {
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';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
constructor(private injector: Injector) {}
private readonly injector = inject(Injector);
handleError(error: any) {
const router = this.injector.get(Router);
console.error(error.stack.toString());

View File

@@ -1,6 +1,6 @@
// Angular
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, Renderer2 } from '@angular/core';
import { Inject, Injectable, Renderer2, inject } from '@angular/core';
@Injectable({
providedIn: 'root',
@@ -8,7 +8,7 @@ import { Inject, Injectable, Renderer2 } from '@angular/core';
export class SchemaService {
script!: HTMLScriptElement;
constructor(@Inject(DOCUMENT) private doc: Document) {}
private readonly doc = inject<Document>(DOCUMENT);
loadJsScript(renderer: Renderer2, data: any): HTMLScriptElement {
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 { Observable } from 'rxjs';
@@ -6,7 +6,7 @@ import { Observable } from 'rxjs';
providedIn: 'root',
})
export class I18nService {
constructor(private translateService: TranslateService) {}
private readonly translateService = inject(TranslateService);
setLanguage(lang: string): Observable<any> {
return new Observable((observer) => {

View File

@@ -1,15 +1,13 @@
import { Observable } from 'rxjs';
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 { readFileSync } from 'fs';
export class TranslateServerLoader implements TranslateLoader {
constructor(
private transferState: TransferState,
private prefix: string = 'i18n',
private suffix: string = '.json',
) {}
private readonly transferState = inject(TransferState);
private readonly prefix = inject((string = 'i18n'));
private readonly suffix = inject((string = '.json'));
public getTranslation(lang: string): Observable<any> {
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