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

@@ -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()"
/>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

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()"
/>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

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

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()"
/>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

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()"
/>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

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

View File

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

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()"
/>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -17,14 +17,14 @@
pi-autofocus
[isAutofocusSelected]="isAutofocus()"
></textarea>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
@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);
}
ngOnChanges({ status }: SimpleChanges): void {
if (status?.currentValue === '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));
}
});
}
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 {
this.tooltip.tooltipClass = [
'pi-tooltip',
this.typeClass,
this.colorClass,
].join(' ');
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) => {