new components
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal file
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal file
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './avatar/avatar.component';
|
||||
export * from './avatar-group/avatar-group.component';
|
||||
|
||||
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal file
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './breadcrumb/breadcrumb.component';
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
<pi-chart-legend
|
||||
[datasets]="datasets()"
|
||||
[isComparativeChart]="isComparativeChart()"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex()"
|
||||
(triggerDatasetHover)="datasetHoveredInLegend($event)"
|
||||
></pi-chart-legend>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal file
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal file
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal file
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal file
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal 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;
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
@@ -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'>(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal file
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal file
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal 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'>(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal file
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal file
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
@@ -2,5 +2,5 @@ import { TemplateRef } from '@angular/core';
|
||||
|
||||
export interface DrawerData {
|
||||
title?: string;
|
||||
template: TemplateRef<any>;
|
||||
template: TemplateRef<unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<pi-icon [icon]="icon()" size="x-large" color="grey" />
|
||||
<h4>{{ title() }}</h4>
|
||||
@if (description()) {
|
||||
<p>{{ description() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
}
|
||||
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './empty-state/empty-state.component';
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal file
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TemplateRef } from '@angular/core';
|
||||
|
||||
export interface HubData {
|
||||
title?: string;
|
||||
template: TemplateRef<unknown>;
|
||||
}
|
||||
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal file
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal 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>
|
||||
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal file
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal file
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
libs/ui/src/lib/ui/hub/index.ts
Normal file
9
libs/ui/src/lib/ui/hub/index.ts
Normal 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';
|
||||
16
libs/ui/src/lib/ui/hub/utils.ts
Normal file
16
libs/ui/src/lib/ui/hub/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
233
libs/ui/src/lib/ui/popover/popover.directive.spec.ts
Normal file
233
libs/ui/src/lib/ui/popover/popover.directive.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@if (isLoading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
1
libs/ui/src/lib/ui/rating/index.ts
Normal file
1
libs/ui/src/lib/ui/rating/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './rating/rating.component';
|
||||
25
libs/ui/src/lib/ui/rating/rating/rating.component.html
Normal file
25
libs/ui/src/lib/ui/rating/rating/rating.component.html
Normal 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>
|
||||
}
|
||||
35
libs/ui/src/lib/ui/rating/rating/rating.component.scss
Normal file
35
libs/ui/src/lib/ui/rating/rating/rating.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
175
libs/ui/src/lib/ui/rating/rating/rating.component.spec.ts
Normal file
175
libs/ui/src/lib/ui/rating/rating/rating.component.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
110
libs/ui/src/lib/ui/rating/rating/rating.component.ts
Normal file
110
libs/ui/src/lib/ui/rating/rating/rating.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
1
libs/ui/src/lib/ui/segmented-control/index.ts
Normal file
1
libs/ui/src/lib/ui/segmented-control/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './segmented-control/segmented-control.component';
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user