new components

This commit is contained in:
2026-08-06 18:10:55 +02:00
parent b02c885b83
commit f86db1556c
149 changed files with 7038 additions and 395 deletions

View File

@@ -43,7 +43,7 @@
align-items: stretch;
justify-content: center;
padding: 0;
border-radius: var(--pi-border-radius-medium);
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-medium));
box-shadow: unset;
overflow: hidden;
overflow-y: auto;
@@ -129,7 +129,7 @@
width: 90vw;
height: 90vh;
.mat-mdc-dialog-container {
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-large));
}
}
}

View File

@@ -0,0 +1,27 @@
// El panel de `pi-hub` es un `MatDialogConfig.panelClass`: el overlay lo
// cuelga fuera de `pi-hub.component`, así que estos estilos no pueden ir en
// el scss del componente (view encapsulation no llegaría hasta ahí).
.cdk-overlay-container,
.pi-shell {
.pi-hub__panel {
margin: 0;
.mat-mdc-dialog-container,
.mdc-dialog__surface {
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-medium))
var(--pi-theme-modal-radius, var(--pi-border-radius-medium)) 0 0;
height: 100%;
}
animation: pi-hub-slide-up 0.25s ease-out;
}
}
@keyframes pi-hub-slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}

View File

@@ -21,8 +21,8 @@
margin: var(--pi-spacing-x-large);
&.pi-snackbar {
padding: var(--pi-spacing-large) var(--pi-spacing-x-large);
border-radius: var(--pi-border-radius-large);
box-shadow: var(--pi-box-shadow);
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
box-shadow: var(--pi-theme-notification-shadow, var(--pi-box-shadow));
border: 1px solid;
&.pi-snackbar__info {
color: var(--pi-color-primary);
@@ -60,7 +60,7 @@
.pi-layout {
.pi-snackbar {
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large);
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
border: 1px solid;
}
}

View File

@@ -2,8 +2,8 @@
.mat-mdc-tooltip {
margin: var(--pi-spacing-xxx-small);
padding: var(--pi-spacing-xx-small) var(--pi-spacing-small);
box-shadow: var(--pi-box-shadow-panel-double);
border-radius: var(--pi-border-radius-medium);
box-shadow: var(--pi-theme-tooltip-shadow, var(--pi-box-shadow-panel-double));
border-radius: var(--pi-theme-tooltip-radius, var(--pi-border-radius-medium));
.mdc-tooltip__surface {
text-align: center;
}

View File

@@ -5,6 +5,7 @@
@forward 'mat-dialog';
@forward 'mat-drawer';
@forward 'mat-expansion-panel';
@forward 'mat-hub';
@forward 'mat-form-field';
@forward 'mat-menu';
@forward 'mat-progress-bar';

View File

@@ -43,6 +43,14 @@
--pi-theme-card-shadow: none;
--pi-theme-panel-radius: var(--pi-border-radius-xx-small);
--pi-theme-chip-radius: 0;
// --- Overlays: mismo criterio, mismas esquinas rectas -----------------
--pi-theme-modal-radius: var(--pi-border-radius-xx-small);
--pi-theme-notification-radius: var(--pi-border-radius-xx-small);
--pi-theme-notification-shadow: none;
--pi-theme-tooltip-radius: var(--pi-border-radius-xx-small);
--pi-theme-tooltip-shadow: none;
--pi-theme-accordion-radius: var(--pi-border-radius-xx-small);
}
.pi-shell__minimal {

View File

@@ -9,6 +9,17 @@ import {
} from '@angular/core';
import { status } from '../../../util/status';
/**
* Contexto de `ngTemplateOutletContext` para el header y el summary de un
* item: `item` es el propio `PiAccordionItemDirective` (de ahí `$implicit`),
* más su índice y si está expandido en ese momento.
*/
export interface AccordionItemTemplateContext {
$implicit: PiAccordionItemDirective;
index: number;
isExpanded: boolean;
}
/**
* The accordion header is a separated content of the accordion items group.
* If it is needed it must be used as a Header for the whole accordion.
@@ -26,18 +37,21 @@ export class PiAccordionHeaderDirective {
// Accordion Item Header Content
@Directive({ selector: '[pi-accordion-item-header-content]' })
export class PiAccordionItemHeaderDirective {
public readonly templateRef = inject<any>(TemplateRef);
public readonly templateRef = inject<
TemplateRef<AccordionItemTemplateContext>
>(TemplateRef);
}
// Accordion Item Summary Content
@Directive({ selector: '[pi-accordion-item-summary-content]' })
export class PiAccordionSummaryContentDirective {
readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
readonly templateRef =
inject<TemplateRef<AccordionItemTemplateContext>>(TemplateRef);
}
// Accordion Item Expandable Content
@Directive({ selector: '[pi-accordion-item-expandable-content]' })
export class PiAccordionItemContentDirective {
readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
}
/**

View File

@@ -4,7 +4,10 @@
& > header {
border: 1px solid;
border-color: var(--pi-color-#{$color}-75);
background-color: var(--pi-color-#{$color}-25);
background-color: var(
--pi-theme-accordion-surface-#{$color},
var(--pi-color-#{$color}-25)
);
&.is-expanded {
background-color: var(--pi-color-grey-10);
border-color: var(--pi-color-grey-25);
@@ -21,7 +24,7 @@
gap: var(--pi-spacing-large);
padding: var(--pi-spacing-x-small) var(--pi-spacing-small)
var(--pi-spacing-x-small);
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
&.is-expanded {
border: 1px solid;
}

View File

@@ -39,16 +39,16 @@
& > .pi-accordion-item-content__header {
&.is-expanded {
border-bottom: 1px solid;
border-top-left-radius: var(--pi-border-radius-large);
border-top-right-radius: var(--pi-border-radius-large);
border-top-left-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
border-top-right-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
&:not(.is-expanded) {
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
}
& > .pi-accordion-item-content__expandable {
border-bottom-left-radius: var(--pi-border-radius-large);
border-bottom-right-radius: var(--pi-border-radius-large);
border-bottom-left-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
border-bottom-right-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
}
}
@@ -113,14 +113,20 @@
& > .pi-accordion-item {
& > .pi-accordion-item-content__header {
&.is-expanded {
background-color: var(--pi-color-#{$color}-50);
background-color: var(
--pi-theme-accordion-surface-#{$color}-expanded,
var(--pi-color-#{$color}-50)
);
border-color: var(--pi-color-#{$color}-100);
}
}
&:hover {
& > .pi-accordion-item-content__header {
&:not(.is-expanded) {
background-color: var(--pi-color-#{$color}-50);
background-color: var(
--pi-theme-accordion-surface-#{$color}-expanded,
var(--pi-color-#{$color}-50)
);
border-color: var(--pi-color-#{$color}-100);
}
}
@@ -138,8 +144,12 @@
justify-content: center;
align-items: center;
height: var(--pi-spacing-xx-large);
border-top-left-radius: calc(var(--pi-border-radius-large) - 0.0625rem);
border-top-right-radius: calc(var(--pi-border-radius-large) - 0.0625rem);
border-top-left-radius: calc(
var(--pi-theme-accordion-radius, var(--pi-border-radius-large)) - 0.0625rem
);
border-top-right-radius: calc(
var(--pi-theme-accordion-radius, var(--pi-border-radius-large)) - 0.0625rem
);
transition: var(--pi-animation);
&.pi-color__primary {
background-color: var(--pi-color-primary-50);

View File

@@ -70,7 +70,7 @@
&.pi-accordion__appearance--fill {
gap: var(--pi-spacing-large);
& > section {
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
header {
aside {
@@ -88,7 +88,10 @@
// Appearance Fill Color
@mixin accordionAppearanceFilleColor($color) {
& > section {
background-color: var(--pi-color-#{$color}-25);
background-color: var(
--pi-theme-accordion-surface-#{$color},
var(--pi-color-#{$color}-25)
);
}
}
@@ -97,7 +100,10 @@
& > section {
border: 1px solid;
border-color: var(--pi-color-#{$color}-75);
background-color: var(--pi-color-#{$color}-25);
background-color: var(
--pi-theme-accordion-surface-#{$color},
var(--pi-color-#{$color}-25)
);
}
}

View File

@@ -0,0 +1,13 @@
@for (item of visibleItems(); track $index; let i = $index) {
<pi-avatar
[image]="item.image"
[name]="item.name"
[alt]="item.alt"
[color]="item.color ?? 'primary'"
[size]="size()"
[style.z-index]="zIndexFor(i)"
/>
}
@if (overflowCount() > 0) {
<span class="pi-avatar-group__overflow">+{{ overflowCount() }}</span>
}

View File

@@ -0,0 +1,54 @@
@mixin overflowSize($dimension, $font-size) {
height: $dimension;
width: $dimension;
min-width: $dimension;
font-size: $font-size;
}
:host {
display: flex;
flex-direction: row;
// El anillo (box-shadow, no border: no cambia la caja) separa avatares
// solapados; se aplica desde aquí porque `pi-avatar` no sabe que puede
// vivir en un grupo.
pi-avatar {
position: relative;
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
border-radius: 50%;
&:not(:first-child) {
margin-left: calc(var(--pi-spacing-xx-small) * -1);
}
}
.pi-avatar-group__overflow {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 50%;
margin-left: calc(var(--pi-spacing-xx-small) * -1);
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
background-color: var(--pi-color-grey-100);
color: var(--pi-color-grey);
font-weight: 700;
}
// Size (mismos escalones que pi-avatar, para que el "+N" encaje)
&.pi-avatar-group__size--x-small .pi-avatar-group__overflow {
@include overflowSize(1.5rem, var(--pi-font-size-xxx-small));
}
&.pi-avatar-group__size--small .pi-avatar-group__overflow {
@include overflowSize(2rem, var(--pi-font-size-xx-small));
}
&.pi-avatar-group__size--medium .pi-avatar-group__overflow {
@include overflowSize(2.5rem, var(--pi-font-size-x-small));
}
&.pi-avatar-group__size--large .pi-avatar-group__overflow {
@include overflowSize(3.25rem, var(--pi-font-size-small));
}
&.pi-avatar-group__size--x-large .pi-avatar-group__overflow {
@include overflowSize(4rem, var(--pi-font-size-medium));
}
}

View File

@@ -0,0 +1,75 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { AvatarGroupItem, PiAvatarGroupComponent } from './avatar-group.component';
describe('PiAvatarGroupComponent', () => {
let fixture: ComponentFixture<PiAvatarGroupComponent>;
const items: AvatarGroupItem[] = [
{ name: 'Ada Lovelace' },
{ name: 'Grace Hopper' },
{ name: 'Margaret Hamilton' },
{ name: 'Katherine Johnson' },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiAvatarGroupComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(PiAvatarGroupComponent);
fixture.componentRef.setInput('items', items);
});
function avatars(): HTMLElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('pi-avatar'));
}
function overflowText(): string | null {
return (
fixture.nativeElement
.querySelector('.pi-avatar-group__overflow')
?.textContent?.trim() ?? null
);
}
it('renders one pi-avatar per item when there is no max', () => {
fixture.detectChanges();
expect(avatars().length).toBe(items.length);
expect(overflowText()).toBeNull();
});
it('caps visible avatars at max and shows the overflow count', () => {
fixture.componentRef.setInput('max', 3);
fixture.detectChanges();
expect(avatars().length).toBe(3);
expect(overflowText()).toBe('+1');
});
it('does not show an overflow badge when max is at least the item count', () => {
fixture.componentRef.setInput('max', items.length);
fixture.detectChanges();
expect(avatars().length).toBe(items.length);
expect(overflowText()).toBeNull();
});
it('gives earlier avatars a higher z-index so they stack on top', () => {
fixture.detectChanges();
const zIndexes = avatars().map((el) => Number(el.style.zIndex));
expect(zIndexes).toEqual([4, 3, 2, 1]);
});
it('reflects size as a host class', () => {
fixture.componentRef.setInput('size', 'large');
fixture.detectChanges();
expect(fixture.nativeElement.className).toContain(
'pi-avatar-group__size--large',
);
});
});

View File

@@ -0,0 +1,55 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
computed,
input,
} from '@angular/core';
// Shared
import { iconSize, theming } from '../../../styles';
import { PiAvatarComponent } from '../avatar/avatar.component';
export interface AvatarGroupItem {
image?: string;
name?: string;
alt?: string;
color?: theming;
}
@Component({
selector: 'pi-avatar-group',
templateUrl: './avatar-group.component.html',
styleUrls: ['./avatar-group.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiAvatarComponent],
})
export class PiAvatarGroupComponent {
@HostBinding('class') get classes(): string {
return ['pi-avatar-group', `pi-avatar-group__size--${this.size()}`].join(
' ',
);
}
readonly items = input.required<AvatarGroupItem[]>();
/** Por encima de este número, el resto se resume en un "+N". */
readonly max = input<number>();
readonly size = input<iconSize>('medium');
protected readonly visibleItems = computed(() => {
const max = this.max();
const items = this.items();
return max ? items.slice(0, max) : items;
});
protected readonly overflowCount = computed(() => {
const max = this.max();
const items = this.items();
return max && items.length > max ? items.length - max : 0;
});
/** El primero debe quedar por encima: z-index descendente por índice. */
protected zIndexFor(index: number): number {
return this.visibleItems().length - index;
}
}

View File

@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { PiAvatarComponent } from './avatar.component';
describe('PiAvatarComponent', () => {
let fixture: ComponentFixture<PiAvatarComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiAvatarComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(PiAvatarComponent);
});
function initialsText(): string | null {
return (
fixture.nativeElement
.querySelector('.pi-avatar__initials')
?.textContent?.trim() ?? null
);
}
it('falls back to the icon when neither image nor name are set', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('pi-icon')).toBeTruthy();
expect(fixture.nativeElement.querySelector('img')).toBeNull();
expect(initialsText()).toBeNull();
});
it('falls back to initials when name is set but no image', () => {
fixture.componentRef.setInput('name', 'Ada Lovelace');
fixture.detectChanges();
expect(initialsText()).toBe('AL');
expect(fixture.nativeElement.querySelector('pi-icon')).toBeNull();
});
it('computes a single-letter initial from a single-word name', () => {
fixture.componentRef.setInput('name', 'Madonna');
fixture.detectChanges();
expect(initialsText()).toBe('M');
});
it('prefers the image over name/icon when all are provided', () => {
fixture.componentRef.setInput('image', 'https://example.com/a.png');
fixture.componentRef.setInput('name', 'Ada Lovelace');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img')).toBeTruthy();
expect(initialsText()).toBeNull();
});
it('falls back to initials when the image fails to load', () => {
fixture.componentRef.setInput('image', 'https://example.com/broken.png');
fixture.componentRef.setInput('name', 'Ada Lovelace');
fixture.detectChanges();
const img: HTMLImageElement = fixture.nativeElement.querySelector('img');
img.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img')).toBeNull();
expect(initialsText()).toBe('AL');
});
it('reflects size, shape and color as host classes', () => {
fixture.componentRef.setInput('size', 'large');
fixture.componentRef.setInput('shape', 'square');
fixture.componentRef.setInput('color', 'accent');
fixture.detectChanges();
const classes: string = fixture.nativeElement.className;
expect(classes).toContain('pi-avatar__size--large');
expect(classes).toContain('pi-avatar__shape--square');
expect(classes).toContain('pi-color__accent');
});
});

View File

@@ -1 +1,2 @@
export * from './avatar/avatar.component';
export * from './avatar-group/avatar-group.component';

View File

@@ -0,0 +1,87 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PiBadgeComponent } from './badge.component';
@Component({
template: `
<pi-badge [value]="value" [max]="max" [isHidden]="isHidden" color="warn">
<span class="projected-content">child</span>
</pi-badge>
`,
imports: [PiBadgeComponent],
})
class HostComponent {
value: string | number | undefined = undefined;
max = 99;
isHidden = false;
}
describe('PiBadgeComponent', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
});
function indicator(): HTMLElement | null {
return fixture.nativeElement.querySelector('.pi-badge__indicator');
}
it('always renders the projected content', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.projected-content'),
).toBeTruthy();
});
it('shows a dot with no text when value is not set', () => {
fixture.detectChanges();
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
true,
);
expect(indicator()?.textContent?.trim()).toBe('');
});
it('shows the numeric value when under max', () => {
fixture.componentInstance.value = 3;
fixture.detectChanges();
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
false,
);
expect(indicator()?.textContent?.trim()).toBe('3');
});
it('caps the numeric value at max with a "+" suffix', () => {
fixture.componentInstance.value = 150;
fixture.componentInstance.max = 99;
fixture.detectChanges();
expect(indicator()?.textContent?.trim()).toBe('99+');
});
it('shows a string value as-is, ignoring max', () => {
fixture.componentInstance.value = 'new';
fixture.detectChanges();
expect(indicator()?.textContent?.trim()).toBe('new');
});
it('hides the indicator without unmounting the projected content', () => {
fixture.componentInstance.value = 3;
fixture.componentInstance.isHidden = true;
fixture.detectChanges();
expect(indicator()).toBeNull();
expect(
fixture.nativeElement.querySelector('.projected-content'),
).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
<ol>
@for (item of items(); track item.label; let last = $last) {
<li>
@if (item.link && !last) {
<a [routerLink]="item.link">{{ item.label }}</a>
} @else {
<span [attr.aria-current]="last ? 'page' : null">{{
item.label
}}</span>
}
@if (!last) {
<pi-icon [icon]="separatorIcon()" size="x-small" color="grey" />
}
</li>
}
</ol>

View File

@@ -0,0 +1,40 @@
:host {
display: block;
ol {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--pi-spacing-xxx-small);
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: var(--pi-spacing-xxx-small);
// El reset global aplica `li + li { margin-top: ... }` pensado para listas
// de prosa (ver _pi-reset.scss); aquí el <ol> es una fila flex, así que
// ese margen solo desalinea verticalmente los elementos a partir del 2º.
margin-top: 0;
}
a {
color: var(--pi-color-grey);
text-decoration: none;
font-size: var(--pi-font-size-x-small);
&:hover {
color: var(--pi-color-primary);
text-decoration: underline;
}
}
span {
font-size: var(--pi-font-size-x-small);
color: var(--pi-color-black);
font-weight: 600;
}
}

View File

@@ -0,0 +1,62 @@
import { provideRouter } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { BreadcrumbItem, PiBreadcrumbComponent } from './breadcrumb.component';
describe('PiBreadcrumbComponent', () => {
let fixture: ComponentFixture<PiBreadcrumbComponent>;
const items: BreadcrumbItem[] = [
{ label: 'Inicio', link: ['/'] },
{ label: 'Componentes', link: ['/componentes'] },
{ label: 'Avatar' },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiBreadcrumbComponent, MatIconTestingModule],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(PiBreadcrumbComponent);
fixture.componentRef.setInput('items', items);
fixture.detectChanges();
});
it('renders a routerLink anchor for every item except the last', () => {
const links: HTMLAnchorElement[] =
fixture.nativeElement.querySelectorAll('a');
expect(links.length).toBe(2);
expect(links[0].textContent?.trim()).toBe('Inicio');
expect(links[1].textContent?.trim()).toBe('Componentes');
});
it('renders the last item as plain text marked as the current page', () => {
const spans: HTMLSpanElement[] =
fixture.nativeElement.querySelectorAll('span');
const current = Array.from(spans).find(
(span) => span.textContent?.trim() === 'Avatar',
);
expect(current).toBeTruthy();
expect(current?.getAttribute('aria-current')).toBe('page');
});
it('renders one separator icon fewer than the number of items', () => {
const separators = fixture.nativeElement.querySelectorAll('pi-icon');
expect(separators.length).toBe(items.length - 1);
});
it('treats an item without a link as plain text even mid-list', () => {
fixture.componentRef.setInput('items', [
{ label: 'Sin enlace' },
{ label: 'Actual' },
]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('a').length).toBe(0);
});
});

View File

@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '../../icon/icon/icon.component';
export interface BreadcrumbItem {
label: string;
/** Comandos de `routerLink`. Si se omite, se pinta como texto plano
* (típicamente el último elemento, la página actual). */
link?: unknown[];
}
@Component({
selector: 'pi-breadcrumb',
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent],
})
export class PiBreadcrumbComponent {
@HostBinding('class') className = 'pi-breadcrumb';
@HostBinding('attr.role') role = 'navigation';
@HostBinding('attr.aria-label') ariaLabel = 'Breadcrumb';
readonly items = input.required<BreadcrumbItem[]>();
readonly separatorIcon = input('chevron-forward');
}

View File

@@ -0,0 +1 @@
export * from './breadcrumb/breadcrumb.component';

View File

@@ -39,7 +39,7 @@ export abstract class PiBaseButtonComponent {
return 'is-disabled';
}
onClickHandler(event: any): void {
onClickHandler(event: MouseEvent): void {
if (!this.disabled()) {
if (!this.allowPropagation()) {
event.preventDefault();

View File

@@ -25,12 +25,12 @@ export abstract class PiBaseCardComponent {
readonly execute = output<void>();
protected get hasShadowClass(): string | boolean | undefined {
return this.hasShadow() && 'has-shadow';
protected get hasShadowClass(): string {
return this.hasShadow() ? 'has-shadow' : '';
}
protected get isClickableClass(): string | false | undefined {
return this.isClickable() && 'is-clickable';
protected get isClickableClass(): string {
return this.isClickable() ? 'is-clickable' : '';
}
protected get colorClass(): string {

View File

@@ -10,6 +10,6 @@ import {
host: { class: 'pi-carousel pi-carousel__item' },
})
export class PiCarouselItemDirective {
public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
public readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
public readonly viewContainerRef = inject(ViewContainerRef);
}

View File

@@ -27,7 +27,7 @@
<!-- Paginator -->
@if (hasOverflow && !hidePaginator()) {
<footer>
@for (i of pages; track i; let index = $index) {
@for (i of pages(); track i; let index = $index) {
<div [class]="index === currentPageIndex ? 'is-active' : ''"></div>
}
</footer>

View File

@@ -3,7 +3,6 @@ import {
AfterViewChecked,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
@@ -11,6 +10,7 @@ import {
Renderer2,
input,
output,
signal,
viewChild,
contentChildren,
inject,
@@ -142,17 +142,22 @@ export class PiCarouselComponent
private destroyed$: Subject<void> = new Subject<void>();
private _currentItemIndex = 0;
set currentItem(index: number) {
this._currentItemIndex = index;
/**
* Signals en vez de campos llanos: se mutan desde listeners nativos
* (`Renderer2.listen` en `document.documentElement`, la suscripción a
* `device$`/`size$`) que no pasan por un binding de evento de plantilla, así
* que antes hacía falta `ChangeDetectorRef.detectChanges()`/`markForCheck()`
* a mano para que la plantilla se enterase. Escribir un signal ya
* reprograma la detección de cambios sola.
*/
readonly currentItem = signal(0);
readonly itemsPerPage = signal(0);
readonly pages = signal<number[]>([]);
private setCurrentItem(index: number): void {
this.currentItem.set(index);
this.itemChanged.emit(index);
}
get currentItem(): number {
return this._currentItemIndex;
}
itemsPerPage!: number;
pages!: Array<number>;
// Drag variables
isDragging = false;
@@ -168,7 +173,6 @@ export class PiCarouselComponent
private touchmoveListener!: () => void;
private touchendListener!: () => void;
private readonly cdr = inject(ChangeDetectorRef);
private readonly el = inject(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly deviceConfig = inject(DeviceConfigsFacade);
@@ -193,7 +197,7 @@ export class PiCarouselComponent
* Used for symmetric carousel to know how many items to show on each side of the current item.
*/
get numberOfSideItems(): number {
return Math.floor(this.itemsPerPage / 2);
return Math.floor(this.itemsPerPage() / 2);
}
/**
@@ -231,26 +235,26 @@ export class PiCarouselComponent
* Returns true if the number of items is greater than the number of items per page.
*/
get hasOverflow(): boolean {
return this.carouselItems().length > this.itemsPerPage;
return this.carouselItems().length > this.itemsPerPage();
}
get canSwipeNext(): boolean {
return this.movePerPage()
? this.currentItem + this.itemsPerPage < this.carouselItems().length
: this.currentItem < this.carouselItems().length - 1;
? this.currentItem() + this.itemsPerPage() < this.carouselItems().length
: this.currentItem() < this.carouselItems().length - 1;
}
get canSwipePrev(): boolean {
return this.movePerPage()
? this.currentItem - this.itemsPerPage >= 0
: this.currentItem > 0;
? this.currentItem() - this.itemsPerPage() >= 0
: this.currentItem() > 0;
}
/**
* Returns the index of the current page.
*/
get currentPageIndex(): number {
return Math.trunc(this.currentItem / this.itemsPerPage);
return Math.trunc(this.currentItem() / this.itemsPerPage());
}
ngAfterViewInit(): void {
@@ -278,58 +282,58 @@ export class PiCarouselComponent
reloadComponent(): void {
this.ngOnDestroy();
this.currentItem = 0;
this.setCurrentItem(0);
this.ngAfterViewInit();
this.ngAfterViewChecked();
this.cdr.markForCheck();
}
next(): void {
if (this.movePerPage()) {
this.currentPageIndex < this.pages.length - 1
? (this.currentItem = (this.currentPageIndex + 1) * this.itemsPerPage)
this.currentPageIndex < this.pages().length - 1
? this.setCurrentItem((this.currentPageIndex + 1) * this.itemsPerPage())
: null;
} else {
this.currentItem = this.currentItem + 1;
this.setCurrentItem(this.currentItem() + 1);
}
this.carouselTranslation(this.currentItem);
this.carouselTranslation(this.currentItem());
}
prev(): void {
if (this.movePerPage()) {
this.currentPageIndex > 0
? (this.currentItem = (this.currentPageIndex - 1) * this.itemsPerPage)
? this.setCurrentItem((this.currentPageIndex - 1) * this.itemsPerPage())
: null;
} else {
this.currentItem = this.currentItem - 1;
this.setCurrentItem(this.currentItem() - 1);
}
this.carouselTranslation(this.currentItem);
this.carouselTranslation(this.currentItem());
}
setItemsPerPage(): void {
const itemWidth = this.itemWidth();
if (!!itemWidth) {
this.itemsPerPage =
Math.floor(this.carouselContentElement.clientWidth / itemWidth) || 1;
this.itemsPerPage.set(
Math.floor(this.carouselContentElement.clientWidth / itemWidth) || 1,
);
} else {
const itemsDesktop = this.itemsDesktop();
const itemsTablet = this.itemsTablet();
const itemsMobile = this.itemsMobile();
switch (this.device()) {
case 'DESKTOP':
this.itemsPerPage = itemsDesktop
? itemsDesktop
: this.carouselItems().length;
this.itemsPerPage.set(
itemsDesktop ? itemsDesktop : this.carouselItems().length,
);
break;
case 'TABLET':
this.itemsPerPage = itemsTablet
? itemsTablet
: this.carouselItems().length;
this.itemsPerPage.set(
itemsTablet ? itemsTablet : this.carouselItems().length,
);
break;
case 'MOBILE':
this.itemsPerPage = itemsMobile
? itemsMobile
: this.carouselItems().length;
this.itemsPerPage.set(
itemsMobile ? itemsMobile : this.carouselItems().length,
);
break;
}
}
@@ -341,12 +345,12 @@ export class PiCarouselComponent
getItemClass(index: number): string {
let itemClass: string = 'pi-carousel__item pi-carousel__item--' + index;
if (index === this.currentItem) itemClass += ' ' + 'is-active';
if (index === this.currentItem()) itemClass += ' ' + 'is-active';
return itemClass;
}
selectItem(index: number): void {
this.currentItem = index;
this.setCurrentItem(index);
if (this.hasOverflow && !this.movePerPage()) {
this.carouselTranslation(index);
}
@@ -355,12 +359,11 @@ export class PiCarouselComponent
setPages(): void {
let totalPages: number;
let pages: Array<number> = [];
totalPages = Math.ceil(this.carouselItems().length / this.itemsPerPage);
totalPages = Math.ceil(this.carouselItems().length / this.itemsPerPage());
for (let i = 0; i < totalPages; i++) {
pages.push(i);
}
this.pages = pages;
this.cdr.detectChanges();
this.pages.set(pages);
}
private stopTouchDragEventListeners(): void {
@@ -456,7 +459,7 @@ export class PiCarouselComponent
const itemWidth = this.itemWidth();
const minDraggingWidth = itemWidth
? 0.8 * itemWidth
: 0.8 * (100 / this.itemsPerPage);
: 0.8 * (100 / this.itemsPerPage());
this.isDragging = true;
@@ -468,8 +471,8 @@ export class PiCarouselComponent
this.dragInitPosition - this.dragFinalPosition > minDraggingWidth &&
this.canSwipeNext
) {
this.currentItem++;
this.carouselTranslation(this.currentItem);
this.setCurrentItem(this.currentItem() + 1);
this.carouselTranslation(this.currentItem());
this.dragInitPosition = this.dragFinalPosition;
}
@@ -477,8 +480,8 @@ export class PiCarouselComponent
this.dragInitPosition - this.dragFinalPosition < -minDraggingWidth &&
this.canSwipePrev
) {
this.currentItem--;
this.carouselTranslation(this.currentItem);
this.setCurrentItem(this.currentItem() - 1);
this.carouselTranslation(this.currentItem());
this.dragInitPosition = this.dragFinalPosition;
}
}
@@ -486,7 +489,6 @@ export class PiCarouselComponent
private dragEnd(): void {
this.isDragging = false;
this.isMouseDown = false;
this.cdr.detectChanges();
}
private getCarouselItemsNativeElements(): void {
@@ -499,7 +501,7 @@ export class PiCarouselComponent
const type = this.type();
if (
type === 'linear' &&
currentItem <= this.carouselItems().length - this.itemsPerPage
currentItem <= this.carouselItems().length - this.itemsPerPage()
) {
this.carouselItemsTranslation(
currentItem,
@@ -524,7 +526,7 @@ export class PiCarouselComponent
// If we are at the end of the carousel
this.carouselItemsTranslation(
currentItem,
`-${(this.carouselItems().length - this.itemsPerPage) * 100}%`,
`-${(this.carouselItems().length - this.itemsPerPage()) * 100}%`,
this.direction(),
);
}
@@ -564,14 +566,14 @@ export class PiCarouselComponent
this.renderer.setStyle(
item,
'min-width',
`${100 / this.itemsPerPage}%`,
`${100 / this.itemsPerPage()}%`,
);
this.renderer.setStyle(
item,
'max-width',
`${100 / this.itemsPerPage}%`,
`${100 / this.itemsPerPage()}%`,
);
this.renderer.setStyle(item, 'width', `${100 / this.itemsPerPage}%`);
this.renderer.setStyle(item, 'width', `${100 / this.itemsPerPage()}%`);
}
});
}
@@ -580,7 +582,7 @@ export class PiCarouselComponent
this.renderer.setStyle(
this.carouselContentElement,
'min-height',
`${this.itemsPerPage * this.tallestElementHeightPx}px`,
`${this.itemsPerPage() * this.tallestElementHeightPx}px`,
);
this.carouselItemsNativeElements.forEach((item) => {
this.renderer.setStyle(

View File

@@ -4,6 +4,6 @@
<pi-chart-legend
[datasets]="datasets()"
[isComparativeChart]="isComparativeChart()"
[hoveredDatasetIndex]="hoveredDatasetIndex"
[hoveredDatasetIndex]="hoveredDatasetIndex()"
(triggerDatasetHover)="datasetHoveredInLegend($event)"
></pi-chart-legend>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Tooltip,
} from 'chart.js';
import { barDatasetConfig } from '../chart.configs';
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
Chart.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend);
@Component({
selector: 'pi-bar-chart',
templateUrl: './bar-chart.component.html',
styleUrls: ['./bar-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
export class PiBarChartComponent extends PiBaseCartesianChartComponent {
get completedDatasets() {
return this.translatedDatasets().map((dataset) => ({
...dataset,
...barDatasetConfig,
}));
}
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'bar',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: this.getCartesianOptions('bar'),
});
}
}

View File

@@ -0,0 +1,72 @@
import { Component, input } from '@angular/core';
import { ChartData, ChartOptions } from 'chart.js';
import { ChartSlice } from '../../util/models';
import { arcDatasetConfig, tooltipConfig } from './chart.configs';
import { PiBaseChartComponent } from './base-chart.component';
/**
* Base de `pi-pie-chart` y `pi-doughnut-chart`: a diferencia de
* `PiBaseCartesianChartComponent` (varias series a lo largo de un eje), aquí
* hay una sola magnitud repartida en porciones — cada una con su propio
* color — así que usan `slices`, no `datasets`, y la leyenda nativa de
* Chart.js en vez de `pi-chart-legend`.
*/
@Component({ template: '' })
export abstract class PiBaseArcChartComponent extends PiBaseChartComponent {
readonly slices = input.required<Array<ChartSlice>>();
protected getArcData<T extends 'pie' | 'doughnut'>(): ChartData<T> {
const slices = this.slices();
return {
labels: slices.map((slice) => this.translate.instant(slice.label)),
datasets: [
{
data: slices.map((slice) => slice.value),
backgroundColor: slices.map((slice) => slice.color),
...arcDatasetConfig,
},
],
} as unknown as ChartData<T>;
}
protected getArcOptions<T extends 'pie' | 'doughnut'>(): ChartOptions<T> {
return {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
padding: 20,
font: {
family: 'Soehne',
size: 12,
},
},
},
tooltip: {
...tooltipConfig,
callbacks: {
label: (context): string => {
const label = context.label ?? '';
const value = context.parsed as number;
const dataset = context.dataset as unknown as {
data: number[];
};
const total = dataset.data.reduce(
(sum, current) => sum + current,
0,
);
const percentage = total
? Math.round((value / total) * 100)
: 0;
return `${label}: ${context.formattedValue} (${percentage}%)`;
},
},
},
},
} as ChartOptions<T>;
}
}

View File

@@ -0,0 +1,113 @@
import { Component, input, signal } from '@angular/core';
import {
ActiveElement,
Chart,
ChartEvent,
ChartOptions,
ChartType,
} from 'chart.js';
import { Dataset } from '../../util/models';
import { scalesConfig, tooltipConfig } from './chart.configs';
import { PiBaseChartComponent } from './base-chart.component';
/**
* Base de `pi-line-chart` y `pi-bar-chart`: ambos comparan una o varias
* `Dataset` (series) a lo largo de `xAxisMarks`, con la misma leyenda propia
* (`pi-chart-legend`, no la nativa de Chart.js) y el mismo tooltip. Lo único
* que cambia entre los dos es el `type` de Chart.js y el aspecto del propio
* dataset (con o sin puntos) — eso lo aporta cada subclase.
*/
@Component({ template: '' })
export abstract class PiBaseCartesianChartComponent extends PiBaseChartComponent {
readonly datasets = input.required<Array<Dataset>>();
readonly xAxisMarks = input<Array<string>>();
readonly isComparativeChart = input<boolean>(true);
/**
* Signal en vez de campo llano: `onHover` lo actualiza desde un callback de
* Chart.js (fuera de la zona de Angular), y escribir un signal ya reprograma
* la detección de cambios sola, sin `ChangeDetectorRef.detectChanges()`.
*/
readonly hoveredDatasetIndex = signal(-1);
/** Etiquetas traducidas; el resto del aspecto lo añade cada subclase. */
protected translatedDatasets(): Array<Dataset> {
return this.datasets().map((dataset) => ({
...dataset,
label: this.translate.instant(dataset.label),
}));
}
triggerHover(datasetIndex: number): void {
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
this.chart.setActiveElements([]);
} else {
this.chart.setActiveElements([{ datasetIndex, index: 0 }]);
}
this.chart.update();
}
datasetHoveredInLegend(index: number): void {
this.hoveredDatasetIndex.set(index);
this.triggerHover(index);
}
protected getCartesianOptions(chartType: ChartType): ChartOptions {
const isComparativeChart = this.isComparativeChart();
return {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
hover: {
mode: 'dataset',
},
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
this.hoveredDatasetIndex.set(
activeElements.length ? activeElements[0].datasetIndex : -1,
);
},
plugins: {
legend: {
display: false,
},
tooltip: {
...tooltipConfig,
filter: (tooltipItem): boolean => {
if (isComparativeChart) {
return (
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
);
}
return tooltipItem.element.active;
},
callbacks: {
labelTextColor: (context): string =>
context.dataset.borderColor as string,
label: (context): string => {
const datasetLabel = context.dataset.label ?? '';
const chartLabel =
datasetLabel.length > 12
? datasetLabel.slice(0, 12) + '...'
: datasetLabel;
const dataset = context.dataset as Dataset;
if (dataset.unitPosition === 'before') {
return (
chartLabel + ': ' + dataset.unit + context.formattedValue
);
}
return (
chartLabel + ': ' + context.formattedValue + dataset.unit
);
},
title: (): string => '',
},
},
},
interaction: {
intersect: false,
mode: 'index',
},
scales: scalesConfig,
} as ChartOptions<typeof chartType>;
}
}

View File

@@ -0,0 +1,39 @@
import {
AfterViewInit,
Component,
ElementRef,
inject,
input,
viewChild,
} from '@angular/core';
import { Chart } from 'chart.js';
import { TranslateService } from '@ngx-translate/core';
import { Device } from '../../util/device-configs';
/**
* Base común a los 4 gráficos (`pi-line-chart`, `pi-bar-chart`,
* `pi-pie-chart`, `pi-doughnut-chart`): el `<canvas>`, el ciclo de vida que
* construye el `Chart` una vez la vista está lista, y el `TranslateService`
* que todos usan para las etiquetas. Cada tipo registra solo los
* controladores/elementos de Chart.js que necesita — no hay un registro
* global compartido, así que un consumidor que solo importe `pi-pie-chart`
* no arrastra el código de líneas/barras.
*/
@Component({ template: '' })
export abstract class PiBaseChartComponent implements AfterViewInit {
readonly device = input.required<Device>();
readonly chartRef = viewChild.required('chart', {
read: ElementRef<HTMLCanvasElement>,
});
chart!: Chart;
protected readonly translate = inject(TranslateService);
ngAfterViewInit(): void {
this.chart = this.buildChart();
}
protected abstract buildChart(): Chart;
}

View File

@@ -81,7 +81,7 @@ export const scalesConfig: ScalesConfig = {
},
};
export const datasetConfig = {
export const lineDatasetConfig = {
borderWidth: 1.3,
hoverBorderWidth: 3.5,
pointRadius: 8,
@@ -89,3 +89,27 @@ export const datasetConfig = {
pointBorderWidth: 2,
pointHoverBorderWidth: 2,
};
/** `type="bar"`: sin puntos (no aplican a barras), esquinas redondeadas. */
export const barDatasetConfig = {
borderWidth: 0,
borderRadius: 6,
maxBarThickness: 32,
};
/** `pi-pie-chart`/`pi-doughnut-chart`: separación fina entre porciones. */
export const arcDatasetConfig = {
borderWidth: 2,
borderColor: DatasetColor.white,
hoverOffset: 12,
};
/** `#rrggbb` -> `rgba(r, g, b, alpha)`; usado por `pi-line-chart` para el área rellena. */
export function hexToRgba(hex: string, alpha: number): string {
const clean = hex.replace('#', '');
const value = parseInt(clean, 16);
const r = (value >> 16) & 255;
const g = (value >> 8) & 255;
const b = value & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@@ -1,34 +0,0 @@
:host {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
max-width: 100%;
& > div {
position: relative;
max-width: 100%;
}
}
html {
&:not(.is-desktop) {
:host {
& > div {
aspect-ratio: 1;
height: 100%;
max-height: 40vh;
}
}
}
&.is-desktop {
:host {
& > div {
& > canvas {
aspect-ratio: 1;
height: 100%;
}
}
}
}
}

View File

@@ -1,184 +0,0 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
input,
viewChild,
inject,
} from '@angular/core';
import { Device } from '../../../util/device-configs';
import { Dataset } from '../../../util/models';
import { TranslateService } from '@ngx-translate/core';
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LineController,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
} from 'chart.js';
import { datasetConfig, scalesConfig, tooltipConfig } from '../chart.configs';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
function registerChartComponents() {
Chart.register(CategoryScale);
Chart.register(LinearScale);
Chart.register(BarController);
Chart.register(BarElement);
Chart.register(LineController);
Chart.register(LineElement);
Chart.register(PointElement);
Chart.register(Tooltip);
Chart.register(Legend);
Chart.register(Title);
}
registerChartComponents();
@Component({
selector: 'pi-chart',
templateUrl: './chart.component.html',
styleUrls: ['./chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
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 = -1;
set hoveredDatasetIndex(index: number) {
this._hoveredDatasetIndex = index;
}
get hoveredDatasetIndex(): number {
return this._hoveredDatasetIndex;
}
readonly chartRef = viewChild.required('chart', {
read: ElementRef<HTMLCanvasElement>,
});
chart!: Chart;
private readonly translate = inject(TranslateService);
private readonly cdr = inject(ChangeDetectorRef);
get completedDatasets() {
return this.datasets().map((dataset) => {
const datasetWithTranslatedLabels = {
...dataset,
label: this.translate.instant(dataset.label),
};
return {
...datasetWithTranslatedLabels,
...datasetConfig,
};
});
}
ngAfterViewInit(): void {
this.getLineChartConfigs();
}
triggerHover(datasetIndex: number) {
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
this.chart.setActiveElements([]);
} else {
this.chart.setActiveElements([
{
datasetIndex,
index: 0,
},
]);
}
this.chart.update();
}
getLineChartConfigs(): void {
const isComparativeChart = this.isComparativeChart();
this.chart = new Chart(this.chartRef().nativeElement, {
type: 'line',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP' ? true : false,
hover: {
mode: 'dataset',
},
onHover: (e: any, activeElements: any) => {
this.hoveredDatasetIndex =
activeElements.length && activeElements[0].datasetIndex;
this.cdr.detectChanges();
},
plugins: {
legend: {
display: false,
},
tooltip: {
...tooltipConfig,
filter: function (tooltipItem): boolean {
if (isComparativeChart) {
return (
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
);
} else {
return tooltipItem.element.active;
}
},
callbacks: {
labelTextColor: function (context): string {
return context.dataset.borderColor as string;
},
label: function (context) {
const datasetLabel = context.dataset.label ?? '';
const chartLabel =
datasetLabel.length > 12
? datasetLabel.slice(0, 12) + '...'
: datasetLabel;
let label: string;
if ((context.dataset as any).unitPosition === 'before') {
label =
chartLabel +
': ' +
(context.dataset as any).unit +
context.formattedValue;
} else {
label =
chartLabel +
': ' +
context.formattedValue +
(context.dataset as any).unit;
}
return label;
},
title: function () {},
},
},
},
interaction: {
intersect: false,
mode: 'index',
},
scales: scalesConfig,
},
});
}
datasetHoveredInLegend(index: number): void {
this.hoveredDatasetIndex = index;
this.triggerHover(this.hoveredDatasetIndex);
}
}

View File

@@ -0,0 +1,3 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ArcElement,
Chart,
DoughnutController,
Legend,
Tooltip,
} from 'chart.js';
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
@Component({
selector: 'pi-doughnut-chart',
templateUrl: './doughnut-chart.component.html',
styleUrls: ['./doughnut-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiDoughnutChartComponent extends PiBaseArcChartComponent {
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'doughnut',
data: this.getArcData<'doughnut'>(),
options: this.getArcOptions<'doughnut'>(),
});
}
}

View File

@@ -0,0 +1,11 @@
<div>
<canvas #chart aria-label="Gauge"></canvas>
<div class="pi-gauge-chart__value">
<span>
<strong>{{ clampedValue() }}</strong><small>/{{ max() }}</small>
</span>
@if (label()) {
<span class="pi-gauge-chart__label">{{ label() }}</span>
}
</div>
</div>

View File

@@ -0,0 +1,35 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
& > div {
position: relative;
}
}
.pi-gauge-chart__value {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
strong {
font-size: var(--pi-font-size-xx-large);
}
small {
font-size: var(--pi-font-size-small);
color: var(--pi-color-grey-300);
}
}
.pi-gauge-chart__label {
margin-top: var(--pi-spacing-xxxx-small);
font-size: var(--pi-font-size-x-small);
color: var(--pi-color-grey-300);
}

View File

@@ -0,0 +1,65 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { ArcElement, Chart, DoughnutController } from 'chart.js';
import { DatasetColor } from '../../../util/models';
import { PiBaseChartComponent } from '../base-chart.component';
Chart.register(ArcElement, DoughnutController);
/**
* Un único valor sobre `max`, no una serie ni porciones — por eso extiende
* directamente `PiBaseChartComponent` (la base raíz) en vez de
* `PiBaseCartesianChartComponent`/`PiBaseArcChartComponent`, pensadas para
* varios datasets o varias porciones. El texto central es HTML superpuesto,
* no un plugin de Chart.js: mismo criterio que `pi-line-chart` con
* `hexToRgba` — evitar un plugin nuevo cuando una superposición CSS basta.
*/
@Component({
selector: 'pi-gauge-chart',
templateUrl: './gauge-chart.component.html',
styleUrls: ['./gauge-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiGaugeChartComponent extends PiBaseChartComponent {
readonly value = input.required<number>();
readonly max = input(100);
readonly color = input<DatasetColor>(DatasetColor.primary);
/** Texto bajo el valor central (p. ej. "de satisfacción"). */
readonly label = input<string>();
protected readonly clampedValue = computed(() =>
Math.min(Math.max(this.value(), 0), this.max()),
);
protected buildChart(): Chart {
const clamped = this.clampedValue();
return new Chart(this.chartRef().nativeElement, {
type: 'doughnut',
data: {
datasets: [
{
data: [clamped, this.max() - clamped],
backgroundColor: [this.color(), DatasetColor.grey75],
borderWidth: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
circumference: 180,
rotation: -90,
cutout: '75%',
events: [],
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
},
});
}
}

View File

@@ -1,5 +1,14 @@
export * from './chart/chart.component';
export * from './base-arc-chart.component';
export * from './base-cartesian-chart.component';
export * from './base-chart.component';
export * from './chart.configs';
export * from './chart-legend/chart-legend.component';
export * from './line-chart/line-chart.component';
export * from './bar-chart/bar-chart.component';
export * from './pie-chart/pie-chart.component';
export * from './doughnut-chart/doughnut-chart.component';
export * from './radar-chart/radar-chart.component';
export * from './gauge-chart/gauge-chart.component';

View File

@@ -0,0 +1,9 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>
<pi-chart-legend
[datasets]="datasets()"
[isComparativeChart]="isComparativeChart()"
[hoveredDatasetIndex]="hoveredDatasetIndex()"
(triggerDatasetHover)="datasetHoveredInLegend($event)"
></pi-chart-legend>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
import { hexToRgba, lineDatasetConfig } from '../chart.configs';
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
Chart.register(
CategoryScale,
LinearScale,
LineController,
LineElement,
PointElement,
Filler,
Tooltip,
Legend,
);
@Component({
selector: 'pi-line-chart',
templateUrl: './line-chart.component.html',
styleUrls: ['./line-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
export class PiLineChartComponent extends PiBaseCartesianChartComponent {
/** Rellena el área bajo la línea (gráfico de área) en vez de solo trazarla. */
readonly filled = input(false);
get completedDatasets() {
const datasets = this.translatedDatasets().map((dataset) => ({
...dataset,
...lineDatasetConfig,
}));
if (!this.filled()) {
return datasets;
}
// Área suave en vez de un bloque sólido: la misma marca del dataset,
// pero translúcida. `pointBackgroundColor` conserva el color opaco
// original para que los puntos no salgan huecos por reusar
// `backgroundColor` para el área.
return datasets.map((dataset) => {
const solidColor = (dataset.backgroundColor ??
dataset.borderColor) as string;
return {
...dataset,
fill: true,
backgroundColor: hexToRgba(solidColor, 0.15),
pointBackgroundColor: solidColor,
};
});
}
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'line',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: this.getCartesianOptions('line'),
});
}
}

View File

@@ -0,0 +1,3 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ArcElement, Chart, Legend, PieController, Tooltip } from 'chart.js';
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
Chart.register(ArcElement, PieController, Tooltip, Legend);
@Component({
selector: 'pi-pie-chart',
templateUrl: './pie-chart.component.html',
styleUrls: ['./pie-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiPieChartComponent extends PiBaseArcChartComponent {
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'pie',
data: this.getArcData<'pie'>(),
options: this.getArcOptions<'pie'>(),
});
}
}

View File

@@ -0,0 +1,9 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>
<pi-chart-legend
[datasets]="datasets()"
[isComparativeChart]="isComparativeChart()"
[hoveredDatasetIndex]="hoveredDatasetIndex()"
(triggerDatasetHover)="datasetHoveredInLegend($event)"
></pi-chart-legend>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,106 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ActiveElement,
Chart,
ChartEvent,
ChartOptions,
Filler,
Legend,
LineElement,
PointElement,
RadarController,
RadialLinearScale,
Tooltip,
} from 'chart.js';
import { Dataset, DatasetColor } from '../../../util/models';
import { hexToRgba, lineDatasetConfig, tooltipConfig } from '../chart.configs';
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
Chart.register(
RadarController,
RadialLinearScale,
LineElement,
PointElement,
Filler,
Tooltip,
Legend,
);
/**
* A diferencia de `pi-line-chart`/`pi-bar-chart`, la escala radial (`r`) no
* encaja en `scalesConfig` (pensado para `x`/`y`), así que no reutiliza
* `getCartesianOptions` de `PiBaseCartesianChartComponent` — sí reutiliza el
* resto (hover por dataset, leyenda propia, tooltip).
*/
@Component({
selector: 'pi-radar-chart',
templateUrl: './radar-chart.component.html',
styleUrls: ['./radar-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
export class PiRadarChartComponent extends PiBaseCartesianChartComponent {
/** Igual que `pi-line-chart[filled]`: relleno translúcido bajo cada polígono. */
protected get completedDatasets() {
return this.translatedDatasets().map((dataset) => {
const solidColor = (dataset.backgroundColor ??
dataset.borderColor) as string;
return {
...dataset,
...lineDatasetConfig,
fill: true,
backgroundColor: hexToRgba(solidColor, 0.15),
pointBackgroundColor: solidColor,
};
});
}
private getRadarOptions(): ChartOptions<'radar'> {
return {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
this.hoveredDatasetIndex.set(
activeElements.length ? activeElements[0].datasetIndex : -1,
);
},
plugins: {
legend: { display: false },
tooltip: {
...tooltipConfig,
callbacks: {
labelTextColor: (context): string =>
context.dataset.borderColor as string,
label: (context): string => {
const dataset = context.dataset as Dataset;
return `${dataset.label}: ${context.formattedValue}${dataset.unit ?? ''}`;
},
},
},
},
scales: {
r: {
grid: { color: DatasetColor.grey50 },
angleLines: { color: DatasetColor.grey75 },
pointLabels: {
font: { family: 'Soehne', size: 12 },
color: DatasetColor.grey300,
},
ticks: { display: false },
},
},
} as ChartOptions<'radar'>;
}
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'radar',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: this.getRadarOptions(),
});
}
}

View File

@@ -0,0 +1,41 @@
/**
* Tamaño del lienzo, compartido por los 4 gráficos (line/bar/pie/doughnut).
* Cada componente lo incluye con `@include chartCanvas;` en su propio
* `:host` — así siguen siendo ficheros independientes sin repetir el CSS.
*/
@mixin chartCanvas {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
max-width: 100%;
& > div {
position: relative;
max-width: 100%;
}
}
@mixin chartCanvasResponsive {
html {
&:not(.is-desktop) {
:host {
& > div {
aspect-ratio: 1;
height: 100%;
max-height: 40vh;
}
}
}
&.is-desktop {
:host {
& > div {
& > canvas {
aspect-ratio: 1;
height: 100%;
}
}
}
}
}
}

View File

@@ -31,7 +31,7 @@ export class PiDataTableItemComponent implements AfterViewInit {
readonly justify = input<justify>('flex-start');
readonly borderType = input<borderType>();
readonly item = contentChild<TemplateRef<any>>('item');
readonly item = contentChild<TemplateRef<unknown>>('item');
readonly componentItem = viewChild<ElementRef>('componentItem');
ngAfterViewInit(): void {

View File

@@ -0,0 +1,115 @@
import { Component, TemplateRef, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core';
import { DrawerConfigKeys } from '../entities/drawer-configs';
import { getDrawerConfigs } from '../utils';
import { PiDrawerComponent } from './drawer.component';
@Component({
template: `<ng-template #content><p class="drawer-content">Hola</p></ng-template>`,
})
class HostComponent {
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
}
describe('PiDrawerComponent', () => {
let hostFixture: ComponentFixture<HostComponent>;
let dialog: MatDialog;
let overlayContainerElement: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
providers: [provideTranslateService()],
}).compileComponents();
hostFixture = TestBed.createComponent(HostComponent);
hostFixture.detectChanges();
dialog = TestBed.inject(MatDialog);
overlayContainerElement = TestBed.inject(
OverlayContainer,
).getContainerElement();
});
afterEach(() => {
dialog.closeAll();
});
it('renders the title and the projected template content', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.textContent).toContain('Detalle');
expect(overlayContainerElement.querySelector('.drawer-content')).toBeTruthy();
});
it('applies the right-side panel class for DrawerConfigKeys.Right', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-drawer__panel--right')).toBe(true);
});
it('applies the left-side panel class for DrawerConfigKeys.Left', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Left, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-drawer__panel--left')).toBe(true);
});
it('omits the title header when none is provided', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.querySelector('header span')).toBeNull();
});
it('closes the dialog when the close button is clicked', fakeAsync(() => {
const dialogRef = dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const closeSpy = jest.fn();
dialogRef.afterClosed().subscribe(closeSpy);
const closeButton: HTMLElement | null =
overlayContainerElement.querySelector('pi-close-button label');
closeButton?.click();
hostFixture.detectChanges();
tick(500);
expect(closeSpy).toHaveBeenCalled();
}));
});

View File

@@ -2,5 +2,5 @@ import { TemplateRef } from '@angular/core';
export interface DrawerData {
title?: string;
template: TemplateRef<any>;
template: TemplateRef<unknown>;
}

View File

@@ -0,0 +1,6 @@
<pi-icon [icon]="icon()" size="x-large" color="grey" />
<h4>{{ title() }}</h4>
@if (description()) {
<p>{{ description() }}</p>
}
<ng-content />

View File

@@ -0,0 +1,23 @@
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--pi-spacing-xx-small);
padding: var(--pi-spacing-xx-large) var(--pi-spacing-large);
h4 {
margin: var(--pi-spacing-xx-small) 0 0;
color: var(--pi-color-black);
font-size: var(--pi-font-size-small);
font-weight: 600;
}
p {
margin: 0;
color: var(--pi-color-grey);
font-size: var(--pi-font-size-x-small);
max-width: 30ch;
}
}

View File

@@ -0,0 +1,56 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { PiEmptyStateComponent } from './empty-state.component';
@Component({
template: `
<pi-empty-state [title]="title" [description]="description">
<button class="projected-action">Limpiar filtros</button>
</pi-empty-state>
`,
imports: [PiEmptyStateComponent],
})
class HostComponent {
title = 'Sin resultados';
description?: string = 'No hay elementos que coincidan con el filtro.';
}
describe('PiEmptyStateComponent', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
});
it('renders the title and description', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h4')?.textContent?.trim()).toBe(
'Sin resultados',
);
expect(fixture.nativeElement.querySelector('p')?.textContent?.trim()).toBe(
'No hay elementos que coincidan con el filtro.',
);
});
it('omits the description paragraph when none is provided', () => {
fixture.componentInstance.description = undefined;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p')).toBeNull();
});
it('renders the projected action', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.projected-action')?.textContent?.trim(),
).toBe('Limpiar filtros');
});
});

View File

@@ -0,0 +1,27 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
input,
} from '@angular/core';
import { PiIconComponent } from '../../icon/icon/icon.component';
/**
* Placeholder para listas/tablas/data-grid sin resultados. El icono, título y
* descripción son fijos; la acción (p. ej. "Limpiar filtros") se proyecta.
*/
@Component({
selector: 'pi-empty-state',
templateUrl: './empty-state.component.html',
styleUrls: ['./empty-state.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent],
})
export class PiEmptyStateComponent {
@HostBinding('class') className = 'pi-empty-state';
readonly icon = input('search');
readonly title = input.required<string>();
readonly description = input<string>();
}

View File

@@ -0,0 +1 @@
export * from './empty-state/empty-state.component';

View File

@@ -0,0 +1,50 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<input
matInput
role="combobox"
aria-autocomplete="list"
[attr.aria-expanded]="isOpen()"
[attr.aria-controls]="listboxId"
[attr.aria-activedescendant]="
activeIndex() > -1 ? listboxId + '-option-' + activeIndex() : null
"
[value]="value()"
(input)="onInput($any($event.target).value)"
(keydown)="onKeydown($event)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
[disabled]="disabled()"
/>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (isOpen()) {
<ul class="pi-autocomplete-field__panel" [id]="listboxId" role="listbox">
@for (option of filteredOptions(); track option; let i = $index) {
<li
[id]="listboxId + '-option-' + i"
role="option"
tabindex="-1"
[attr.aria-selected]="i === activeIndex()"
[class.is-active]="i === activeIndex()"
(mousedown)="$event.preventDefault()"
(click)="selectOption(option)"
(keydown.enter)="selectOption(option)"
>
{{ option }}
</li>
} @empty {
<li class="pi-autocomplete-field__empty">{{ noResultsLabel() | translate }}</li>
}
</ul>
}

View File

@@ -0,0 +1,40 @@
:host {
position: relative;
display: block;
}
.pi-autocomplete-field__panel {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
margin: var(--pi-spacing-xxxx-small) 0 0;
padding: var(--pi-spacing-xxxx-small) 0;
max-height: 14rem;
overflow-y: auto;
list-style: none;
background: var(--pi-color-white);
border-radius: var(--pi-border-radius-medium);
box-shadow: var(--pi-box-shadow-panel);
li {
padding: var(--pi-spacing-small) var(--pi-spacing-medium);
font-size: var(--pi-font-size-small);
cursor: pointer;
&:hover,
&.is-active {
background: var(--pi-color-primary-25);
}
}
}
.pi-autocomplete-field__empty {
color: var(--pi-color-grey-300);
cursor: default;
&:hover {
background: transparent !important;
}
}

View File

@@ -0,0 +1,126 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostBinding,
HostListener,
computed,
inject,
input,
signal,
} from '@angular/core';
import {
MatFormField,
MatLabel,
MatError,
} from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
/**
* Campo de texto con sugerencias filtradas mientras se escribe. El menú es
* un `<ul>` propio con `position: absolute` (mismo criterio que el menú de
* `pi-select`) en vez de `mat-autocomplete`/CDK Overlay: no hace falta
* escapar contenedores con `overflow: hidden` para este caso de uso.
*/
@Component({
selector: 'pi-autocomplete-field',
templateUrl: './autocomplete-field.component.html',
styleUrls: ['./autocomplete-field.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatFormField, MatLabel, MatInput, MatError, TranslatePipe],
})
export class PiAutocompleteFieldComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = 'pi-form-field__autocomplete';
private static nextId = 0;
readonly options = input.required<readonly string[]>();
readonly noResultsLabel = input('Sin resultados');
protected readonly listboxId = `pi-autocomplete-listbox-${PiAutocompleteFieldComponent.nextId++}`;
protected readonly isOpen = signal(false);
protected readonly activeIndex = signal(-1);
private readonly el = inject(ElementRef);
protected readonly filteredOptions = computed(() => {
const query = this.value().trim().toLowerCase();
if (!query) return this.options();
return this.options().filter((option) =>
option.toLowerCase().includes(query),
);
});
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.el.nativeElement.contains(event.target)) {
this.close();
}
}
protected onInput(text: string): void {
this.value.set(text);
this.activeIndex.set(-1);
this.isOpen.set(true);
}
protected onKeydown(event: KeyboardEvent): void {
if (
!this.isOpen() &&
(event.key === 'ArrowDown' || event.key === 'ArrowUp')
) {
this.isOpen.set(true);
return;
}
const options = this.filteredOptions();
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.activeIndex.update((index) =>
Math.min(index + 1, options.length - 1),
);
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex.update((index) => Math.max(index - 1, 0));
break;
case 'Enter': {
const active = options[this.activeIndex()];
if (active) {
event.preventDefault();
this.selectOption(active);
}
break;
}
case 'Escape':
this.close();
break;
}
}
protected selectOption(option: string): void {
this.value.set(option);
this.touch.emit();
this.close();
}
override onFocus(): void {
super.onFocus();
this.isOpen.set(true);
}
override onBlur(): void {
super.onBlur();
this.close();
}
private close(): void {
this.isOpen.set(false);
this.activeIndex.set(-1);
}
}

View File

@@ -0,0 +1,36 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<mat-date-range-input
[rangePicker]="picker"
[min]="min() ?? null"
[max]="max() ?? null"
[disabled]="disabled()"
>
<input
matStartDate
[value]="value().start"
[placeholder]="startPlaceholder() | translate"
(dateChange)="onStartChange($any($event).value)"
(focus)="onFocus()"
/>
<input
matEndDate
[value]="value().end"
[placeholder]="endPlaceholder() | translate"
(dateChange)="onEndChange($any($event).value)"
(blur)="onBlur()"
/>
</mat-date-range-input>
<mat-datepicker-toggle matSuffix [for]="picker" />
<mat-date-range-picker #picker />
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -0,0 +1,67 @@
import { ChangeDetectionStrategy, Component, HostBinding, input } from '@angular/core';
import {
MatFormField,
MatLabel,
MatSuffix,
MatError,
} from '@angular/material/form-field';
import {
MatDateRangeInput,
MatStartDate,
MatEndDate,
MatDateRangePicker,
MatDatepickerToggle,
} from '@angular/material/datepicker';
import { provideNativeDateAdapter } from '@angular/material/core';
import { TranslatePipe } from '@ngx-translate/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
export interface DateRange {
start: Date | null;
end: Date | null;
}
/**
* Rango de fechas, hermano de `pi-date-field`: mismo `provideNativeDateAdapter`
* (el proyecto no tiene adaptador de luxon/moment) y misma herencia de
* `PiBaseFormFieldComponent`, pero con `value` como `{ start, end }` en vez de
* `Date | null` — `mat-date-range-input` necesita dos `<input>` (`matStartDate`
* / `matEndDate`), no uno.
*/
@Component({
selector: 'pi-date-range-field',
templateUrl: './date-range-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideNativeDateAdapter()],
imports: [
MatFormField,
MatLabel,
MatSuffix,
MatDateRangeInput,
MatStartDate,
MatEndDate,
MatDateRangePicker,
MatDatepickerToggle,
MatError,
TranslatePipe,
],
})
export class PiDateRangeFieldComponent extends PiBaseFormFieldComponent<DateRange> {
@HostBinding('class') className = 'pi-form-field__date-range';
readonly min = input<Date | undefined>(undefined);
readonly max = input<Date | undefined>(undefined);
readonly startPlaceholder = input('Desde');
readonly endPlaceholder = input('Hasta');
protected onStartChange(start: Date | null): void {
this.value.set({ ...this.value(), start });
}
protected onEndChange(end: Date | null): void {
this.value.set({ ...this.value(), end });
this.touch.emit();
}
}

View File

@@ -1,4 +1,6 @@
export * from './date-field/date-field.component';
export * from './date-range-field/date-range-field.component';
export * from './autocomplete-field/autocomplete-field.component';
export * from './checkbox/checkbox.component';
export * from './radio-group/radio-group.component';
export * from './directives/label.directive';

View File

@@ -0,0 +1,43 @@
import { MatDialogConfig } from '@angular/material/dialog';
export enum HubConfigKeys {
Default = 'default',
Full = 'full',
}
export type HubConfigs = {
[key in HubConfigKeys]: MatDialogConfig;
};
/**
* `enterAnimationDuration`/`exitAnimationDuration` a `0ms`: igual que
* `pi-drawer`, la animación de `MatDialog` (fade + scale desde el centro) no
* encaja con un panel anclado a un borde. El deslizamiento real lo hace
* `_mat-hub.scss` sobre `panelClass`, fuera del ciclo de animación de
* Material. Solo se fija `position.bottom`: al dejar `left`/`right` sin
* definir, el propio `cdk-global-overlay-wrapper` centra el panel en
* horizontal por defecto.
*/
export const hubConfigs: HubConfigs = {
[HubConfigKeys.Default]: {
width: '100vw',
maxWidth: '34rem',
maxHeight: '80vh',
position: { bottom: '0' },
backdropClass: 'pi-backdrop',
panelClass: ['pi-hub__panel'],
enterAnimationDuration: '0ms',
exitAnimationDuration: '0ms',
},
[HubConfigKeys.Full]: {
width: '100vw',
maxWidth: '34rem',
height: '92vh',
maxHeight: '92vh',
position: { bottom: '0' },
backdropClass: 'pi-backdrop',
panelClass: ['pi-hub__panel', 'pi-hub__panel--full'],
enterAnimationDuration: '0ms',
exitAnimationDuration: '0ms',
},
};

View File

@@ -0,0 +1,6 @@
import { TemplateRef } from '@angular/core';
export interface HubData {
title?: string;
template: TemplateRef<unknown>;
}

View File

@@ -0,0 +1,12 @@
<div class="pi-hub__grabber" aria-hidden="true"></div>
<header>
@if (data.title) {
<span bold l>{{ data.title }}</span>
}
<pi-close-button size="small" [hasLabel]="false" (click)="close()" />
</header>
<main>
<ng-container [ngTemplateOutlet]="data.template" />
</main>

View File

@@ -0,0 +1,30 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
.pi-hub__grabber {
flex: none;
width: 2.5rem;
height: 0.25rem;
margin: var(--pi-spacing-small) auto 0;
border-radius: var(--pi-border-radius-x-small);
background-color: var(--pi-color-grey-100);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-large);
border-bottom: 0.0625rem solid var(--pi-color-grey-100);
}
main {
flex-grow: 1;
overflow-y: auto;
padding: var(--pi-spacing-x-large);
}
}

View File

@@ -0,0 +1,115 @@
import { Component, TemplateRef, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core';
import { HubConfigKeys } from '../entities/hub-configs';
import { getHubConfigs } from '../utils';
import { PiHubComponent } from './hub.component';
@Component({
template: `<ng-template #content><p class="hub-content">Hola</p></ng-template>`,
})
class HostComponent {
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
}
describe('PiHubComponent', () => {
let hostFixture: ComponentFixture<HostComponent>;
let dialog: MatDialog;
let overlayContainerElement: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
providers: [provideTranslateService()],
}).compileComponents();
hostFixture = TestBed.createComponent(HostComponent);
hostFixture.detectChanges();
dialog = TestBed.inject(MatDialog);
overlayContainerElement = TestBed.inject(
OverlayContainer,
).getContainerElement();
});
afterEach(() => {
dialog.closeAll();
});
it('renders the title and the projected template content', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.textContent).toContain('Detalle');
expect(overlayContainerElement.querySelector('.hub-content')).toBeTruthy();
});
it('applies the panel class for HubConfigKeys.Default', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-hub__panel')).toBe(true);
});
it('applies the full panel class for HubConfigKeys.Full', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Full, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-hub__panel--full')).toBe(true);
});
it('omits the title header when none is provided', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.querySelector('header span')).toBeNull();
});
it('closes the dialog when the close button is clicked', fakeAsync(() => {
const dialogRef = dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const closeSpy = jest.fn();
dialogRef.afterClosed().subscribe(closeSpy);
const closeButton: HTMLElement | null =
overlayContainerElement.querySelector('pi-close-button label');
closeButton?.click();
hostFixture.detectChanges();
tick(500);
expect(closeSpy).toHaveBeenCalled();
}));
});

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NgTemplateOutlet } from '@angular/common';
import { HubData } from '../entities/hub.model';
import { PiCloseButtonComponent } from '../../button/close-button/close-button.component';
import { PiTextWeightBoldDirective } from '../../text/directives/text-weights.directive';
import { PiTextLargeDirective } from '../../text/directives/text-sizes.directive';
@Component({
selector: 'pi-hub',
templateUrl: './hub.component.html',
styleUrls: ['./hub.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NgTemplateOutlet,
PiCloseButtonComponent,
PiTextWeightBoldDirective,
PiTextLargeDirective,
],
})
export class PiHubComponent {
@HostBinding('class') className = 'pi-hub';
readonly dialogRef = inject<MatDialogRef<PiHubComponent>>(MatDialogRef);
readonly data = inject<HubData>(MAT_DIALOG_DATA);
close(): void {
this.dialogRef.close();
}
}

View File

@@ -0,0 +1,9 @@
// Component
export * from './hub/hub.component';
// Entities
export * from './entities/hub.model';
export * from './entities/hub-configs';
// Utils
export * from './utils';

View File

@@ -0,0 +1,16 @@
import { MatDialogConfig } from '@angular/material/dialog';
import { HubConfigKeys, hubConfigs } from './entities/hub-configs';
import { HubData } from './entities/hub.model';
// Hub config
export function getHubConfigs(
key: HubConfigKeys,
data: HubData,
disableClose?: boolean,
): MatDialogConfig<HubData> {
return {
...(disableClose && { disableClose }),
...hubConfigs[key],
data,
};
}

View File

@@ -1,5 +1,5 @@
@if (type() === 'custom') {
<img src="/assets/icons/{{ icon() }}.svg" />
} @else {
<mat-icon size="size" [svgIcon]="icon()!" color="color"></mat-icon>
<mat-icon [svgIcon]="icon()!"></mat-icon>
}

View File

@@ -27,9 +27,9 @@
}
}
@mixin defaultIconColor {
@mixin defaultIconColor($color) {
.mat-icon {
color: var(--pi-color-primary);
color: $color;
}
}
@@ -64,6 +64,26 @@
&.pi-icon__x-large {
@include defaultIconSize(var(--pi-spacing-xx-large));
}
// Color (la apariencia "fill" tiene la suya, más abajo)
&.pi-color__primary {
@include defaultIconColor(var(--pi-color-primary));
}
&.pi-color__accent {
@include defaultIconColor(var(--pi-color-accent));
}
&.pi-color__warn {
@include defaultIconColor(var(--pi-color-warn));
}
&.pi-color__highlighted {
@include defaultIconColor(var(--pi-color-highlighted));
}
&.pi-color__white {
@include defaultIconColor(var(--pi-color-white));
}
&.pi-color__grey {
@include defaultIconColor(var(--pi-color-grey));
}
}
&.pi-icon__appearance--fill {

View File

@@ -40,7 +40,7 @@ export class PiIconComponent {
const borderRadiusValue = this.borderRadius();
const borderRadiusClass =
borderRadiusValue && `pi-icon__border-radius--${borderRadiusValue}`;
const strokeClass = this.isStroked() === true && 'is-stroked';
const strokeClass = this.isStroked() ? 'is-stroked' : '';
return [
'pi-icon',
ComponentFamilyClass.Text,

View File

@@ -19,7 +19,7 @@ interface ModalData {
export interface DialogModalData extends ModalData {
type: ModalType.DIALOG;
pathImg: string;
template?: TemplateRef<any>;
template?: TemplateRef<unknown>;
description?: string;
label?: string;
buttonText: {
@@ -31,5 +31,5 @@ export interface DialogModalData extends ModalData {
export interface CustomModalData extends ModalData {
type: ModalType.CUSTOM;
template: TemplateRef<any>;
template: TemplateRef<unknown>;
}

View File

@@ -17,7 +17,7 @@
align-items: center;
gap: var(--pi-spacing-x-small);
margin: auto;
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
text-align: center;
div {
display: flex;

View File

@@ -69,8 +69,8 @@ export abstract class PiBasePanelComponent {
return `pi-color__${this.color()}`;
}
protected get hasShadowClass(): string | boolean {
return this.hasShadow() && 'has-shadow';
protected get hasShadowClass(): string {
return this.hasShadow() ? 'has-shadow' : '';
}
protected get heightClass(): string | undefined {

View File

@@ -0,0 +1,233 @@
import { Component } from '@angular/core';
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
import {
PiPopoverDirective,
PopoverPosition,
PopoverTrigger,
} from './popover.directive';
@Component({
template: `
<button
pi-popover
[popoverContent]="content"
[popoverPosition]="position"
[popoverTrigger]="trigger"
(opened)="opened()"
(closed)="closed()"
>
Trigger
</button>
<ng-template #content let-close="close">
<p class="popover-content">Hola</p>
<button class="popover-close" (click)="close()">Cerrar</button>
</ng-template>
`,
imports: [PiPopoverDirective],
})
class HostComponent {
position: PopoverPosition = 'below';
trigger: PopoverTrigger = 'click';
opened = jest.fn();
closed = jest.fn();
}
describe('PiPopoverDirective', () => {
let overlayContainerElement: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
overlayContainerElement = TestBed.inject(
OverlayContainer,
).getContainerElement();
});
afterEach(() => {
TestBed.inject(OverlayContainer).ngOnDestroy();
});
/**
* El `trigger` se fija ANTES del primer `detectChanges()`, nunca
* reasignado después: en este entorno de test, mutar un campo del host y
* volver a llamar a `detectChanges()` una segunda vez no propaga de forma
* fiable un signal input (comprobado con un repro mínimo ajeno a este
* directive). Cada configuración distinta usa su propio fixture desde el
* principio.
*/
function createFixture(
overrides: Partial<Pick<HostComponent, 'trigger' | 'position'>> = {},
): ComponentFixture<HostComponent> {
const fixture = TestBed.createComponent(HostComponent);
Object.assign(fixture.componentInstance, overrides);
fixture.detectChanges();
return fixture;
}
function triggerEl(fixture: ComponentFixture<HostComponent>): HTMLButtonElement {
return fixture.nativeElement.querySelector('button');
}
function popoverContent(): HTMLElement | null {
return overlayContainerElement.querySelector('.popover-content');
}
describe('click trigger (default)', () => {
it('is closed initially', () => {
createFixture();
expect(popoverContent()).toBeNull();
});
it('opens on click and emits `opened`', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
expect(fixture.componentInstance.opened).toHaveBeenCalledTimes(1);
});
it('toggles closed on a second click and emits `closed`', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
triggerEl(fixture).click();
fixture.detectChanges();
expect(popoverContent()).toBeNull();
expect(fixture.componentInstance.closed).toHaveBeenCalledTimes(1);
});
it('closes when the backdrop is clicked', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
const backdrop: HTMLElement | null =
overlayContainerElement.parentElement?.querySelector(
'.cdk-overlay-backdrop',
) ?? null;
backdrop?.click();
fixture.detectChanges();
expect(popoverContent()).toBeNull();
});
it('closes when Escape is pressed', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
pane?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
fixture.detectChanges();
expect(popoverContent()).toBeNull();
});
it('closes via the `close` function exposed in the template context', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
const closeButton: HTMLElement | null =
overlayContainerElement.querySelector('.popover-close');
closeButton?.click();
fixture.detectChanges();
expect(popoverContent()).toBeNull();
});
it('disposes the overlay when the host is destroyed', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
fixture.destroy();
expect(popoverContent()).toBeNull();
});
});
describe('hover trigger', () => {
it('opens on mouseenter without a backdrop', () => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
expect(
overlayContainerElement.parentElement?.querySelector(
'.cdk-overlay-backdrop',
),
).toBeNull();
});
it('does not close immediately on mouseleave, only after the debounce', fakeAsync(() => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
triggerEl(fixture).dispatchEvent(new Event('mouseleave'));
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
tick(100);
fixture.detectChanges();
expect(popoverContent()).toBeNull();
}));
it('cancels the scheduled close if the mouse re-enters before the debounce elapses', fakeAsync(() => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
triggerEl(fixture).dispatchEvent(new Event('mouseleave'));
tick(50);
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
tick(100);
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
}));
it('stays open while the mouse is over the panel itself', fakeAsync(() => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
triggerEl(fixture).dispatchEvent(new Event('mouseleave'));
const pane = overlayContainerElement.querySelector(
'.cdk-overlay-pane',
) as HTMLElement;
pane.dispatchEvent(new Event('mouseenter'));
tick(100);
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
}));
});
});

View File

@@ -1,3 +1,5 @@
export * from './loader-bar/loader-bar.component';
export * from './progress-bar/progress-bar.component';
export * from './progress-spinner/progress-spinner.component';

View File

@@ -0,0 +1,3 @@
@if (isLoading()) {
<mat-progress-bar mode="indeterminate" />
}

View File

@@ -0,0 +1,12 @@
:host {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1100; // por encima de mat-dialog/mat-menu (1000, ver overlay-prebuilt.css)
pointer-events: none;
mat-progress-bar {
height: 0.1875rem;
}
}

View File

@@ -0,0 +1,57 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoaderFacade } from '../../../util/loaders';
import { PiLoaderBarComponent } from './loader-bar.component';
describe('PiLoaderBarComponent', () => {
let fixture: ComponentFixture<PiLoaderBarComponent>;
let loaderFacade: LoaderFacade;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiLoaderBarComponent],
}).compileComponents();
fixture = TestBed.createComponent(PiLoaderBarComponent);
loaderFacade = TestBed.inject(LoaderFacade);
});
function progressBar(): HTMLElement | null {
return fixture.nativeElement.querySelector('mat-progress-bar');
}
it('does not render the bar while the loader is idle', () => {
fixture.detectChanges();
expect(progressBar()).toBeNull();
});
it('renders the bar as soon as LoaderFacade.showLoader() is called', () => {
fixture.detectChanges();
loaderFacade.showLoader();
fixture.detectChanges();
expect(progressBar()).toBeTruthy();
expect(progressBar()?.getAttribute('mode')).toBe('indeterminate');
});
it('hides the bar again once LoaderFacade.hideLoader() is called', () => {
fixture.detectChanges();
loaderFacade.showLoader();
fixture.detectChanges();
loaderFacade.hideLoader();
fixture.detectChanges();
expect(progressBar()).toBeNull();
});
it('reflects the loader state even for instances created after showLoader()', () => {
loaderFacade.showLoader();
fixture.detectChanges();
expect(progressBar()).toBeTruthy();
});
});

View File

@@ -0,0 +1,33 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
inject,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatProgressBar } from '@angular/material/progress-bar';
import { LoaderFacade } from '../../../util/loaders';
/**
* Barra de carga global: se muestra/oculta sola siguiendo `LoaderFacade`, sin
* inputs. El host la ancla arriba del todo (`position: fixed`); la app solo
* tiene que ponerla una vez en el shell y llamar a `showLoader()`/
* `hideLoader()` donde corresponda (p. ej. un interceptor HTTP).
*/
@Component({
selector: 'pi-loader-bar',
templateUrl: './loader-bar.component.html',
styleUrls: ['./loader-bar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatProgressBar],
})
export class PiLoaderBarComponent {
@HostBinding('class') className = 'pi-loader-bar';
private readonly loaderFacade = inject(LoaderFacade);
protected readonly isLoading = toSignal(this.loaderFacade.loader$, {
initialValue: false,
});
}

View File

@@ -0,0 +1 @@
export * from './rating/rating.component';

View File

@@ -0,0 +1,25 @@
@for (star of stars(); track star) {
<button
type="button"
class="pi-rating__star pi-button__trimmed"
[attr.aria-label]="'Valorar ' + star + ' de ' + stars().length"
[attr.aria-pressed]="star <= value()"
[disabled]="!isInteractive()"
(mouseenter)="onEnter(star)"
(mouseleave)="onLeave()"
(click)="onClick(star)"
>
@if (starTemplate(); as template) {
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="starContext(star)"
/>
} @else {
<pi-icon
[icon]="isFilled(star) ? 'star-fill' : 'star-outline'"
[size]="size()"
[color]="color()"
/>
}
</button>
}

View File

@@ -0,0 +1,35 @@
:host {
display: inline-flex;
gap: var(--pi-spacing-xxx-small);
.pi-rating__star {
display: flex;
// El reset global de botones (`_pi-button.scss`) pone `min-width: 5.5rem`
// y `height: var(--pi-theme-button-height)` en cualquier `<button>` sin
// clase `mat-*`; sin este override cada estrella se infla a 88px.
min-width: 0;
height: auto;
padding: 0;
border: none;
background: none;
line-height: 0;
cursor: pointer;
&:disabled {
cursor: default;
}
}
&.pi-rating__readonly .pi-rating__star {
pointer-events: none;
}
&.is-disabled {
opacity: 0.5;
.pi-rating__star {
cursor: not-allowed;
pointer-events: none;
}
}
}

View File

@@ -0,0 +1,175 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { By } from '@angular/platform-browser';
import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiRatingComponent } from './rating.component';
describe('PiRatingComponent', () => {
let fixture: ComponentFixture<PiRatingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiRatingComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(PiRatingComponent);
});
function starButtons(): HTMLButtonElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('button'));
}
function iconNames(): string[] {
return fixture.debugElement
.queryAll(By.directive(PiIconComponent))
.map((debugEl) => debugEl.componentInstance.icon());
}
it('renders `max` stars (5 by default)', () => {
fixture.detectChanges();
expect(starButtons().length).toBe(5);
});
it('renders a custom number of stars via `max`', () => {
fixture.componentRef.setInput('max', 3);
fixture.detectChanges();
expect(starButtons().length).toBe(3);
});
it('fills stars up to the current value', () => {
fixture.componentRef.setInput('value', 3);
fixture.detectChanges();
expect(iconNames()).toEqual([
'star-fill',
'star-fill',
'star-fill',
'star-outline',
'star-outline',
]);
});
it('sets the value on click', () => {
fixture.detectChanges();
starButtons()[3].click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe(4);
expect(iconNames()).toEqual([
'star-fill',
'star-fill',
'star-fill',
'star-fill',
'star-outline',
]);
});
it('previews the hovered value without committing it until clicked', () => {
fixture.componentRef.setInput('value', 1);
fixture.detectChanges();
starButtons()[3].dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(iconNames().filter((n) => n === 'star-fill').length).toBe(4);
expect(fixture.componentInstance.value()).toBe(1);
starButtons()[3].dispatchEvent(new Event('mouseleave'));
fixture.detectChanges();
expect(iconNames().filter((n) => n === 'star-fill').length).toBe(1);
});
it('ignores clicks and hover when readonly is true', () => {
fixture.componentRef.setInput('value', 2);
fixture.componentRef.setInput('readonly', true);
fixture.detectChanges();
const buttons = starButtons();
expect(buttons.every((button) => button.disabled)).toBe(true);
buttons[4].dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(iconNames().filter((n) => n === 'star-fill').length).toBe(2);
buttons[4].click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe(2);
});
it('ignores clicks and hover when disabled is true', () => {
fixture.componentRef.setInput('value', 1);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const buttons = starButtons();
expect(buttons.every((button) => button.disabled)).toBe(true);
buttons[4].click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe(1);
});
it('emits touch when a click changes the value', () => {
const touchSpy = jest.fn();
fixture.componentInstance.touch.subscribe(touchSpy);
fixture.detectChanges();
starButtons()[2].click();
fixture.detectChanges();
expect(touchSpy).toHaveBeenCalledTimes(1);
});
it('does not emit touch when readonly blocks the click', () => {
const touchSpy = jest.fn();
fixture.componentInstance.touch.subscribe(touchSpy);
fixture.componentRef.setInput('readonly', true);
fixture.detectChanges();
starButtons()[2].click();
fixture.detectChanges();
expect(touchSpy).not.toHaveBeenCalled();
});
});
describe('PiRatingComponent with a custom starTemplate', () => {
@Component({
template: `
<pi-rating [value]="3" [starTemplate]="star" />
<ng-template #star let-index let-filled="filled">
<span class="custom-star">{{ index }}:{{ filled }}</span>
</ng-template>
`,
imports: [PiRatingComponent],
})
class HostComponent {}
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
function customStars(): HTMLElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('.custom-star'));
}
it('renders the custom template instead of pi-icon', () => {
expect(fixture.nativeElement.querySelector('pi-icon')).toBeNull();
expect(customStars().length).toBe(5);
});
it('passes the star index and filled state to the template context', () => {
const texts = customStars().map((el) => el.textContent?.trim());
expect(texts).toEqual(['1:true', '2:true', '3:true', '4:false', '5:false']);
});
});

View File

@@ -0,0 +1,110 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
TemplateRef,
computed,
input,
model,
output,
signal,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
// Shared
import { iconSize, theming } from '../../../styles';
import { PiIconComponent } from '../../icon/icon/icon.component';
/** Contexto que recibe la plantilla pasada en `starTemplate`. */
export interface RatingStarContext {
/** Permite `let-star` además de `let-star="index"`. */
$implicit: number;
index: number;
filled: boolean;
}
/**
* Implementa `FormValueControl<number>`: basta con enlazar `[formField]` en
* el consumidor (como en `pi-slide-toggle`) y la directiva `FormField`
* mantiene `value` sincronizado con el `FieldTree`, además de rellenar
* `disabled`/`readonly`/`invalid`/`touched`/`required`/`name`/`errors`.
*/
@Component({
selector: 'pi-rating',
templateUrl: './rating.component.html',
styleUrls: ['./rating.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent, NgTemplateOutlet],
})
export class PiRatingComponent implements FormValueControl<number> {
@HostBinding('class') get classes(): string {
return [
'pi-rating',
this.readonly() ? 'pi-rating__readonly' : '',
this.disabled() ? 'is-disabled' : '',
].join(' ');
}
readonly size = input<iconSize>('medium');
readonly color = input<theming>('accent');
/** Sustituye el icono por defecto (p. ej. corazones en vez de estrellas). */
readonly starTemplate = input<TemplateRef<RatingStarContext>>();
/* --- Contrato FormValueControl ---------------------------------------- */
/** Valoración actual, entera (sin medias estrellas). */
readonly value = model(0);
readonly disabled = input(false);
readonly readonly = input(false);
readonly invalid = input(false);
readonly touched = input(false);
readonly required = input(false);
readonly name = input('');
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>(
[],
);
/**
* Número de estrellas (también el valor máximo válido): mismo campo que
* espera `FormUiControl.max` para enlazar un validador `max()`, no hace
* falta una segunda entrada.
*/
readonly max = input<number | undefined>(5);
/** Se emite tras cada clic que cambia el valor (no hay blur en un rating). */
readonly touch = output<void>();
/* --- Interacción -------------------------------------------------------- */
protected readonly isInteractive = computed(
() => !this.readonly() && !this.disabled(),
);
protected readonly stars = computed(() =>
Array.from({ length: this.max() ?? 5 }, (_, i) => i + 1),
);
private readonly hoverValue = signal<number | null>(null);
protected isFilled(star: number): boolean {
return star <= (this.hoverValue() ?? this.value());
}
protected starContext(star: number): RatingStarContext {
return { $implicit: star, index: star, filled: this.isFilled(star) };
}
protected onEnter(star: number): void {
if (this.isInteractive()) this.hoverValue.set(star);
}
protected onLeave(): void {
this.hoverValue.set(null);
}
protected onClick(star: number): void {
if (!this.isInteractive()) return;
this.value.set(star);
this.touch.emit();
}
}

View File

@@ -0,0 +1 @@
export * from './segmented-control/segmented-control.component';

View File

@@ -0,0 +1,11 @@
@for (option of options(); track option.value) {
<button
type="button"
role="radio"
[attr.aria-checked]="value() === option.value"
[class.is-active]="value() === option.value"
(click)="select(option)"
>
{{ option.label }}
</button>
}

View File

@@ -0,0 +1,22 @@
:host {
display: inline-flex;
gap: var(--pi-spacing-xxxx-small);
padding: var(--pi-spacing-xxxx-small);
border: 1px solid var(--pi-color-grey-200);
border-radius: var(--pi-border-radius-small);
button {
padding: var(--pi-spacing-xxxx-small) var(--pi-spacing-small);
border: none;
border-radius: var(--pi-border-radius-xx-small);
background: transparent;
color: var(--pi-color-grey);
font-size: var(--pi-font-size-x-small);
&.is-active {
background-color: var(--pi-color-primary-75);
color: var(--pi-color-primary);
font-weight: 600;
}
}
}

View File

@@ -0,0 +1,66 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PiSegmentedControlComponent } from './segmented-control.component';
describe('PiSegmentedControlComponent', () => {
let fixture: ComponentFixture<PiSegmentedControlComponent<string>>;
const options = [
{ value: 'compact', label: 'Compacta' },
{ value: 'comfortable', label: 'Cómoda' },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiSegmentedControlComponent],
}).compileComponents();
fixture = TestBed.createComponent(PiSegmentedControlComponent<string>);
fixture.componentRef.setInput('options', options);
});
function buttons(): HTMLButtonElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('button'));
}
it('renders one button per option, with its label', () => {
fixture.detectChanges();
const labels = buttons().map((button) => button.textContent?.trim());
expect(labels).toEqual(['Compacta', 'Cómoda']);
});
it('marks no option as active when value is unset', () => {
fixture.detectChanges();
expect(buttons().some((button) => button.classList.contains('is-active'))).toBe(
false,
);
});
it('marks the option matching `value` as active', () => {
fixture.componentRef.setInput('value', 'comfortable');
fixture.detectChanges();
expect(buttons()[0].classList.contains('is-active')).toBe(false);
expect(buttons()[1].classList.contains('is-active')).toBe(true);
expect(buttons()[1].getAttribute('aria-checked')).toBe('true');
});
it('updates the model when an option is clicked', () => {
fixture.detectChanges();
buttons()[0].click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe('compact');
expect(buttons()[0].classList.contains('is-active')).toBe(true);
});
it('reflects ariaLabel on the host', () => {
fixture.componentRef.setInput('ariaLabel', 'Densidad');
fixture.detectChanges();
expect(fixture.nativeElement.getAttribute('aria-label')).toBe('Densidad');
});
});

View File

@@ -0,0 +1,41 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
input,
model,
} from '@angular/core';
export interface SegmentedControlOption<T> {
value: T;
label: string;
}
/**
* Alternativa a un `mat-button-toggle-group` cuando se quiere el aspecto
* "pi" sin entrar en el sistema de Material: es el mismo control que se
* construyó ad-hoc para el selector de temas del playground, formalizado
* como componente reutilizable.
*/
@Component({
selector: 'pi-segmented-control',
templateUrl: './segmented-control.component.html',
styleUrls: ['./segmented-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiSegmentedControlComponent<T> {
@HostBinding('class') className = 'pi-segmented-control';
@HostBinding('attr.role') role = 'radiogroup';
@HostBinding('attr.aria-label') get ariaLabelAttr(): string | undefined {
return this.ariaLabel();
}
readonly options = input.required<SegmentedControlOption<T>[]>();
readonly ariaLabel = input<string>();
readonly value = model<T>();
protected select(option: SegmentedControlOption<T>): void {
this.value.set(option.value);
}
}

View File

@@ -27,7 +27,7 @@ import {
template: '',
})
export abstract class PiBaseSelectComponent<
TValue = any,
TValue = unknown,
> implements FormValueControl<TValue> {
@HostBinding('class') get commonClasses(): string {
const isDisabledClass = this.disabled() ? 'is-disabled' : '';
@@ -127,7 +127,12 @@ export abstract class PiBaseSelectComponent<
// ======================
/**
* List of selectable options.
* List of selectable options. Se deja en `any` a propósito: la forma real
* la decide cada consumidor (accedida dinámicamente vía `keyIdProperty()`
* y proyectada tal cual a `pi-select-option`/`optionTemplateRef`). Tipar
* esto como `Record<string, unknown>` obliga a acceso por índice en todas
* las plantillas (`noPropertyAccessFromIndexSignature`) y sigue sin encajar
* en los inputs `string` de `pi-select-option` sin casts adicionales.
*/
readonly options = input<Array<any>>([]);
@@ -159,13 +164,13 @@ export abstract class PiBaseSelectComponent<
* Provides a reference to the custom template for the options if provided.
*/
readonly optionTemplateRef =
contentChild.required<TemplateRef<any>>('optionTemplate');
contentChild.required<TemplateRef<unknown>>('optionTemplate');
/**
* Provides a reference to the custom template for the selected item if provided.
*/
readonly selectedTemplateRef =
contentChild.required<TemplateRef<any>>('selectedTemplate');
contentChild.required<TemplateRef<unknown>>('selectedTemplate');
/**
* A query list of all the child option components that are content projected from an external consumer.

View File

@@ -4,6 +4,7 @@ import { status } from '../../../util/status';
export interface SelectOption {
id: number;
name?: string;
/** Forma libre a propósito: la decide cada consumidor (ver `options` en `PiBaseSelectComponent`). */
value: any;
initials?: string;
description?: string;

View File

@@ -1,7 +1,6 @@
import {
AfterContentInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
@@ -79,7 +78,6 @@ export class PiSelectCarouselComponent
* @param _renderer - Renderer to modify native elements.
*/
private readonly translateService = inject(TranslateService);
private readonly _cdr = inject(ChangeDetectorRef);
constructor() {
super();
@@ -177,6 +175,5 @@ export class PiSelectCarouselComponent
childOption.isSelected.set(false);
}
});
this._cdr.markForCheck();
}
}

Some files were not shown because too many files have changed in this diff Show More