new components

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

View File

@@ -32,6 +32,7 @@ const TRANSLATIONS: TranslationObject = {
reset: 'Borrar', reset: 'Borrar',
search: 'Buscar…', search: 'Buscar…',
noResults: 'Sin resultados', noResults: 'Sin resultados',
legend: 'Leyenda',
validator: { validator: {
requiredField: 'Este campo es obligatorio', requiredField: 'Este campo es obligatorio',
onlyNumbers: 'Sólo se admiten números', onlyNumbers: 'Sólo se admiten números',

View File

@@ -1,3 +1,5 @@
<pi-loader-bar />
<header class="page-header"> <header class="page-header">
<div class="page-header__top"> <div class="page-header__top">
<div> <div>

View File

@@ -14,5 +14,27 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('./pages/layouts/layouts.page').then((m) => m.LayoutsPage), import('./pages/layouts/layouts.page').then((m) => m.LayoutsPage),
}, },
{
path: 'ejemplos/login',
title: 'Login · @pi/ui',
loadComponent: () =>
import('./pages/examples/login/login.page').then((m) => m.LoginPage),
},
{
path: 'ejemplos/landing',
title: 'Landing · @pi/ui',
loadComponent: () =>
import('./pages/examples/landing/landing.page').then(
(m) => m.LandingPage,
),
},
{
path: 'ejemplos/dashboard',
title: 'Dashboard · @pi/ui',
loadComponent: () =>
import('./pages/examples/dashboard/dashboard.page').then(
(m) => m.DashboardPage,
),
},
{ path: '**', redirectTo: 'componentes' }, { path: '**', redirectTo: 'componentes' },
]; ];

View File

@@ -8,7 +8,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { PiSlideToggleComponent, ShellClass } from '@pi/ui'; import { PiLoaderBarComponent, PiSlideToggleComponent, ShellClass } from '@pi/ui';
/** /**
* Shell del playground: cabecera, navegación y estado de tema. El contenido de * Shell del playground: cabecera, navegación y estado de tema. El contenido de
@@ -19,12 +19,21 @@ import { PiSlideToggleComponent, ShellClass } from '@pi/ui';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
imports: [RouterOutlet, RouterLink, RouterLinkActive, PiSlideToggleComponent], imports: [
RouterOutlet,
RouterLink,
RouterLinkActive,
PiSlideToggleComponent,
PiLoaderBarComponent,
],
}) })
export class App { export class App {
protected readonly navLinks = [ protected readonly navLinks = [
{ path: '/componentes', label: 'Componentes' }, { path: '/componentes', label: 'Componentes' },
{ path: '/layouts', label: 'Layouts' }, { path: '/layouts', label: 'Layouts' },
{ path: '/ejemplos/login', label: 'Ejemplo: Login' },
{ path: '/ejemplos/landing', label: 'Ejemplo: Landing' },
{ path: '/ejemplos/dashboard', label: 'Ejemplo: Dashboard' },
]; ];
/* --- Tema y modo oscuro -------------------------------------------------- */ /* --- Tema y modo oscuro -------------------------------------------------- */

View File

@@ -58,6 +58,14 @@
<pi-slide-toggle [formField]="f.newsletter" /> <pi-slide-toggle [formField]="f.newsletter" />
</label> </label>
<label class="rating-field">
<span>Satisfacción</span>
<pi-rating [formField]="f.satisfaction" />
</label>
@if (f.satisfaction().errors().length > 0) {
<span class="field-error">{{ f.satisfaction().errors()[0].message }}</span>
}
<pi-slider <pi-slider
[formField]="f.volume" [formField]="f.volume"
unit="%" unit="%"
@@ -215,6 +223,82 @@
</pi-badge> </pi-badge>
</app-section> </app-section>
<app-section
title="Avatar group"
hint="pi-avatar-group pinta los pi-avatar internamente (a partir de items), por eso puede solaparlos con box-shadow como anillo sin tocar pi-avatar."
>
<pi-avatar-group [items]="avatarGroupItems" [max]="3" />
<pi-avatar-group [items]="avatarGroupItems" size="small" />
</app-section>
<app-section
title="Breadcrumb, empty state y segmented control"
hint="pi-breadcrumb usa routerLink en los elementos intermedios; pi-empty-state proyecta la acción; pi-segmented-control es genérico ([(value)])."
>
<div class="stack full">
<pi-breadcrumb [items]="breadcrumbItems" />
<pi-segmented-control
[options]="densityOptions"
[(value)]="density"
ariaLabel="Densidad"
/>
<pi-empty-state
icon="search"
title="Sin resultados"
description="No hay elementos que coincidan con el filtro aplicado."
>
<button pi-outline (click)="log('empty-state: limpiar filtros')">
Limpiar filtros
</button>
</pi-empty-state>
</div>
</app-section>
<app-section
title="Rating y Timeline"
hint="pi-rating es interactivo ([(value)], con previsualización en hover); readonly lo deja solo en lectura. starTemplate sustituye el icono por defecto (aquí, corazones sin pi-icon). pi-timeline es de solo lectura, a partir de items."
>
<div class="stack full">
<pi-rating [(value)]="rating" />
<pi-rating [value]="4" [readonly]="true" color="primary" />
<pi-rating [(value)]="rating" [starTemplate]="heart" />
<ng-template #heart let-filled="filled">
<span class="heart-star" [class.is-filled]="filled">♥</span>
</ng-template>
<pi-timeline [items]="timelineItems" />
</div>
</app-section>
<app-section
title="Autocomplete y rango de fechas"
hint="pi-autocomplete-field es un pi-text-field con sugerencias filtradas (menú propio, sin CDK Overlay); pi-date-range-field envuelve mat-date-range-input con dos fechas ([start, end]) en vez de una."
>
<div class="stack full">
<pi-autocomplete-field
[(value)]="autocompleteValue"
[options]="cityOptions"
placeholder="Escribe una ciudad…"
>
<pi-label>Ciudad</pi-label>
</pi-autocomplete-field>
<pi-date-range-field [(value)]="dateRange">
<pi-label>Periodo</pi-label>
</pi-date-range-field>
</div>
</app-section>
<app-section
title="Tree"
hint="pi-tree es recursivo (pi-tree-node se auto-referencia, como ya hace pi-paginator): expande/colapsa por nodo y la selección burbujea hasta la raíz."
>
<pi-tree [nodes]="treeNodes" [(selectedId)]="treeSelectedId" />
</app-section>
<app-section <app-section
title="Cabecera y listado" title="Cabecera y listado"
hint="pi-headline para portadas de sección, pi-list-item para filas de listado. pi-headline resuelve pathImg contra un CDN externo fijo, así que la ilustración solo carga con red." hint="pi-headline para portadas de sección, pi-list-item para filas de listado. pi-headline resuelve pathImg contra un CDN externo fijo, así que la ilustración solo carga con red."
@@ -293,6 +377,50 @@
</pi-card> </pi-card>
</app-section> </app-section>
<app-section
title="Stat card"
hint="pi-stat-card extrae el patrón (icono + label + valor + tendencia) repetido a mano en login/landing/dashboard. No envuelve pi-card: aquí se anida dentro a propósito, igual que en el dashboard."
>
<div class="stat-card-grid">
<pi-card
alignment="flex-start"
gap="s"
innerPadding="m"
context="private"
color="white"
>
<pi-card-center-content>
<pi-stat-card
icon="coins"
label="Ingresos este mes"
value="12.4K €"
trend="+18%"
[trendUp]="true"
color="primary"
/>
</pi-card-center-content>
</pi-card>
<pi-card
alignment="flex-start"
gap="s"
innerPadding="m"
context="private"
color="white"
>
<pi-card-center-content>
<pi-stat-card
icon="document-euro"
label="Facturas pendientes"
value="7"
trend="-2"
[trendUp]="false"
color="accent"
/>
</pi-card-center-content>
</pi-card>
</div>
</app-section>
<app-section <app-section
title="Panel" title="Panel"
hint="pi-expansion-panel[type=form] usa Reactive Forms clásico, no Signal Forms." hint="pi-expansion-panel[type=form] usa Reactive Forms clásico, no Signal Forms."
@@ -517,6 +645,20 @@
</ng-template> </ng-template>
</app-section> </app-section>
<app-section
title="Hub"
hint="pi-hub es como pi-drawer pero anclado abajo: se abre vía MatDialog con getHubConfigs y entra deslizándose desde el borde inferior."
>
<button pi-primary (click)="openDemoHub('default')">Abrir hub</button>
<button pi-outline (click)="openDemoHub('full')">
Abrir hub (pantalla completa)
</button>
<ng-template #hubTemplate>
<p>Contenido de ejemplo dentro del hub.</p>
<p>Puede llevar cualquier plantilla: formularios, listas, filtros…</p>
</ng-template>
</app-section>
<app-section <app-section
title="Popover" title="Popover"
hint="pi-popover es una directiva ([pi-popover]) sobre @angular/cdk/overlay: no hay componente Material de contenido enriquecido que envolver." hint="pi-popover es una directiva ([pi-popover]) sobre @angular/cdk/overlay: no hay componente Material de contenido enriquecido que envolver."
@@ -591,14 +733,61 @@
</div> </div>
</app-section> </app-section>
<app-section title="Chart"> <app-section
<div class="stack full"> title="Chart"
<pi-chart hint="pi-line-chart, pi-bar-chart, pi-pie-chart, pi-doughnut-chart, pi-radar-chart y pi-gauge-chart son componentes independientes (cada uno registra solo lo suyo en Chart.js). line/bar/radar comparten datasets + pi-chart-legend; pie/doughnut usan slices y la leyenda nativa; gauge es un solo valor sobre un máximo."
type="line" >
[device]="device" <div class="chart-grid">
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']" <div class="chart-grid__item">
[datasets]="chartDatasets" <span class="chart-grid__label">pi-line-chart</span>
/> <pi-line-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="chartDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-line-chart [filled]="true" (área)</span>
<pi-line-chart
[filled]="true"
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="chartDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-bar-chart</span>
<pi-bar-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="barChartDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-pie-chart</span>
<pi-pie-chart [device]="device" [slices]="chartSlices" />
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-doughnut-chart</span>
<pi-doughnut-chart [device]="device" [slices]="chartSlices" />
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-radar-chart</span>
<pi-radar-chart
[device]="device"
[xAxisMarks]="['Velocidad', 'Fiabilidad', 'Precio', 'Soporte', 'Diseño']"
[datasets]="radarDatasets"
/>
</div>
<div class="chart-grid__item">
<span class="chart-grid__label">pi-gauge-chart</span>
<pi-gauge-chart
[device]="device"
[value]="72"
[max]="100"
label="satisfacción"
/>
</div>
</div> </div>
</app-section> </app-section>
@@ -666,10 +855,13 @@
<app-section <app-section
title="Feedback y navegación" title="Feedback y navegación"
hint="pi-spinner se omite a propósito: es un overlay position:fixed de página completa, no un indicador en línea." hint="pi-spinner se omite a propósito: es un overlay position:fixed de página completa, no un indicador en línea. pi-loader-bar tampoco se usa como etiqueta con inputs: vive arriba del todo (ver app.html) y se muestra sola siguiendo LoaderFacade."
> >
<pi-progress-spinner label="Progreso" [value]="65" [diameter]="72" /> <pi-progress-spinner label="Progreso" [value]="65" [diameter]="72" />
<pi-progress-bar label="Carga" [value]="65" /> <pi-progress-bar label="Carga" [value]="65" />
<button pi-outline (click)="triggerLoaderBar()">
Simular carga (loader-bar arriba)
</button>
<pi-paginator <pi-paginator
[(currentPage)]="currentPage" [(currentPage)]="currentPage"
[pageSize]="10" [pageSize]="10"

View File

@@ -2,6 +2,17 @@
display: block; display: block;
} }
.heart-star {
font-size: 1.25rem;
line-height: 1;
color: var(--pg-card-border);
transition: color 0.1s ease-out;
&.is-filled {
color: #e0245e;
}
}
.event { .event {
display: inline-block; display: inline-block;
margin-top: 0.75rem; margin-top: 0.75rem;
@@ -44,6 +55,23 @@ main {
gap: 1rem; gap: 1rem;
} }
.field-error {
margin-top: -0.5rem;
font-size: 0.75rem;
color: var(--pi-color-warn);
}
.rating-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
> span {
font-size: 0.75rem;
color: var(--pg-text-muted);
}
}
.state { .state {
margin: 0; margin: 0;
font-size: 0.85rem; font-size: 0.85rem;
@@ -91,3 +119,29 @@ pre {
border: 1px dashed var(--pg-card-border); border: 1px dashed var(--pg-card-border);
border-radius: 8px; border-radius: 8px;
} }
.chart-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
width: 100%;
}
.chart-grid__item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chart-grid__label {
font-size: 0.78rem;
font-family: monospace;
color: var(--pg-text-muted);
}
.stat-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.5rem;
width: 100%;
}

View File

@@ -21,8 +21,15 @@ import {
PiAccordionItemContentDirective, PiAccordionItemContentDirective,
PiAccordionItemDirective, PiAccordionItemDirective,
PiActionButtonComponent, PiActionButtonComponent,
AvatarGroupItem,
PiAvatarComponent, PiAvatarComponent,
PiAvatarGroupComponent,
PiBadgeComponent, PiBadgeComponent,
BreadcrumbItem,
PiBreadcrumbComponent,
PiEmptyStateComponent,
SegmentedControlOption,
PiSegmentedControlComponent,
PiBannerComponent, PiBannerComponent,
PiBaseDataTableComponent, PiBaseDataTableComponent,
PiCardBottomContentDirective, PiCardBottomContentDirective,
@@ -33,7 +40,10 @@ import {
PiCarouselItemDirective, PiCarouselItemDirective,
PiCellDirective, PiCellDirective,
PiCellTypeComponent, PiCellTypeComponent,
PiChartComponent, PiBarChartComponent,
PiDoughnutChartComponent,
PiLineChartComponent,
PiPieChartComponent,
PiChevronButtonComponent, PiChevronButtonComponent,
PiChipActionComponent, PiChipActionComponent,
PiChipComponent, PiChipComponent,
@@ -46,6 +56,7 @@ import {
PiCurrencyFieldComponent, PiCurrencyFieldComponent,
PiDataGridComponent, PiDataGridComponent,
PiDataTableItemComponent, PiDataTableItemComponent,
ChartSlice,
Dataset, Dataset,
DatasetColor, DatasetColor,
Device, Device,
@@ -54,6 +65,9 @@ import {
PiDrawerComponent, PiDrawerComponent,
getDrawerConfigs, getDrawerConfigs,
PiEmailFieldComponent, PiEmailFieldComponent,
HubConfigKeys,
PiHubComponent,
getHubConfigs,
PiExpansionPanelComponent, PiExpansionPanelComponent,
PiGhostButtonDirective, PiGhostButtonDirective,
PiHamburgerComponent, PiHamburgerComponent,
@@ -72,6 +86,7 @@ import {
PiListItemLeftIconDirective, PiListItemLeftIconDirective,
PiListItemRightContentDirective, PiListItemRightContentDirective,
PiListItemRightIconDirective, PiListItemRightIconDirective,
LoaderFacade,
ModalConfigKeys, ModalConfigKeys,
ModalStyleType, ModalStyleType,
ModalType, ModalType,
@@ -95,6 +110,7 @@ import {
PiRadioButtonComponent, PiRadioButtonComponent,
PiRange, PiRange,
PiRangeSliderComponent, PiRangeSliderComponent,
PiRatingComponent,
PiSearchFieldComponent, PiSearchFieldComponent,
PiSelectCarouselComponent, PiSelectCarouselComponent,
PiSelectComponent, PiSelectComponent,
@@ -128,6 +144,16 @@ import {
PiDynamicMenuComponent, PiDynamicMenuComponent,
PiSkeletonComponent, PiSkeletonComponent,
PiStepperComponent, PiStepperComponent,
PiTimelineComponent,
TimelineItem,
PiStatCardComponent,
PiRadarChartComponent,
PiGaugeChartComponent,
PiAutocompleteFieldComponent,
PiDateRangeFieldComponent,
DateRange,
PiTreeComponent,
TreeNode,
} from '@pi/ui'; } from '@pi/ui';
import { ShowcaseSection } from '../showcase-section'; import { ShowcaseSection } from '../showcase-section';
@@ -149,6 +175,7 @@ interface DemoModel {
range: PiRange; range: PiRange;
volume: number; volume: number;
newsletter: boolean; newsletter: boolean;
satisfaction: number;
search: string; search: string;
website: string; website: string;
phone: string; phone: string;
@@ -198,7 +225,19 @@ interface DemoModel {
PiTextViewerComponent, PiTextViewerComponent,
PiDividerComponent, PiDividerComponent,
PiAvatarComponent, PiAvatarComponent,
PiAvatarGroupComponent,
PiBadgeComponent, PiBadgeComponent,
PiBreadcrumbComponent,
PiEmptyStateComponent,
PiSegmentedControlComponent,
PiRatingComponent,
PiTimelineComponent,
PiStatCardComponent,
PiRadarChartComponent,
PiGaugeChartComponent,
PiAutocompleteFieldComponent,
PiDateRangeFieldComponent,
PiTreeComponent,
PiChipComponent, PiChipComponent,
PiChipStatusComponent, PiChipStatusComponent,
PiChipValueComponent, PiChipValueComponent,
@@ -270,7 +309,10 @@ interface DemoModel {
PiBaseDataTableComponent, PiBaseDataTableComponent,
PiDataTableItemComponent, PiDataTableItemComponent,
PiCellTypeComponent, PiCellTypeComponent,
PiChartComponent, PiBarChartComponent,
PiDoughnutChartComponent,
PiLineChartComponent,
PiPieChartComponent,
PiCarouselComponent, PiCarouselComponent,
PiCarouselItemDirective, PiCarouselItemDirective,
// Componentes nuevos // Componentes nuevos
@@ -297,6 +339,7 @@ export class CatalogPage {
range: { from: 0, to: 10 }, range: { from: 0, to: 10 },
volume: 40, volume: 40,
newsletter: false, newsletter: false,
satisfaction: 0,
search: '', search: '',
website: '', website: '',
phone: '', phone: '',
@@ -322,6 +365,7 @@ export class CatalogPage {
maxLength(m.bio, 140, { message: 'Máximo 140 caracteres' }); maxLength(m.bio, 140, { message: 'Máximo 140 caracteres' });
email(m.email, { message: 'Introduce un email válido' }); email(m.email, { message: 'Introduce un email válido' });
min(m.age, 18, { message: 'Debes ser mayor de edad' }); min(m.age, 18, { message: 'Debes ser mayor de edad' });
min(m.satisfaction, 1, { message: 'Valora con al menos una estrella' });
}); });
/** Se muestra en el panel lateral para ver el modelo en vivo. */ /** Se muestra en el panel lateral para ver el modelo en vivo. */
@@ -508,6 +552,14 @@ export class CatalogPage {
); );
} }
/** `pi-loader-bar` no tiene inputs: lee este estado compartido solo. */
private readonly loader = inject(LoaderFacade);
protected triggerLoaderBar(): void {
this.loader.showLoader();
setTimeout(() => this.loader.hideLoader(), 2000);
}
/* --- Drawer -------------------------------------------------------------- */ /* --- Drawer -------------------------------------------------------------- */
/** Igual que `pi-modal`: se abre desde TypeScript vía `MatDialog`. */ /** Igual que `pi-modal`: se abre desde TypeScript vía `MatDialog`. */
@@ -525,6 +577,118 @@ export class CatalogPage {
this.log(`drawer abierto (${side})`); this.log(`drawer abierto (${side})`);
} }
/* --- Hub ------------------------------------------------------------------ */
/** Igual que `pi-drawer`, pero anclado abajo con un deslizamiento vertical. */
private readonly hubTemplate =
viewChild.required<TemplateRef<unknown>>('hubTemplate');
protected openDemoHub(size: 'default' | 'full'): void {
this.dialog.open(
PiHubComponent,
getHubConfigs(size === 'full' ? HubConfigKeys.Full : HubConfigKeys.Default, {
title: 'Detalle',
template: this.hubTemplate(),
}),
);
this.log(`hub abierto (${size})`);
}
/* --- Avatar group, breadcrumb, segmented control ------------------------ */
protected readonly avatarGroupItems: AvatarGroupItem[] = [
{ name: 'Ada Lovelace', color: 'primary' },
{ name: 'Grace Hopper', color: 'accent' },
{ image: 'https://i.pravatar.cc/128?img=12' },
{ name: 'Margaret Hamilton', color: 'highlighted' },
{ name: 'Katherine Johnson', color: 'grey' },
];
protected readonly breadcrumbItems: BreadcrumbItem[] = [
{ label: 'Inicio', link: ['/'] },
{ label: 'Componentes', link: ['/componentes'] },
{ label: 'Avatar group' },
];
protected readonly densityOptions: SegmentedControlOption<string>[] = [
{ value: 'compact', label: 'Compacta' },
{ value: 'comfortable', label: 'Cómoda' },
];
protected readonly density = signal('comfortable');
protected readonly rating = signal(3);
protected readonly autocompleteValue = signal('');
protected readonly cityOptions: string[] = [
'Barcelona',
'Bilbao',
'Madrid',
'Málaga',
'Sevilla',
'Valencia',
'Zaragoza',
];
protected readonly dateRange = signal<DateRange>({ start: null, end: null });
protected readonly treeSelectedId = signal<string | undefined>(undefined);
protected readonly treeNodes: TreeNode[] = [
{
id: 'docs',
label: 'Documentación',
icon: 'document-euro',
children: [
{ id: 'docs-getting-started', label: 'Primeros pasos' },
{
id: 'docs-components',
label: 'Componentes',
children: [
{ id: 'docs-components-form', label: 'Formularios' },
{ id: 'docs-components-chart', label: 'Charts' },
],
},
],
},
{
id: 'settings',
label: 'Ajustes',
icon: 'user',
children: [
{ id: 'settings-profile', label: 'Perfil' },
{ id: 'settings-billing', label: 'Facturación' },
],
},
];
protected readonly timelineItems: TimelineItem[] = [
{
title: 'Pedido creado',
description: 'Se registró el pedido #4521.',
date: '28 jul, 09:12',
color: 'grey',
},
{
title: 'Pago confirmado',
description: 'Cargo aceptado por la pasarela de pago.',
date: '28 jul, 09:14',
color: 'primary',
},
{
title: 'Enviado',
description: 'Paquete entregado al transportista.',
date: '29 jul, 16:40',
color: 'accent',
},
{
title: 'Entregado',
date: '31 jul, 11:05',
color: 'success',
},
];
/* --- Datos: grid, tabla, gráfico, carrusel ------------------------------ */ /* --- Datos: grid, tabla, gráfico, carrusel ------------------------------ */
protected readonly employees = [ protected readonly employees = [
@@ -552,6 +716,42 @@ export class CatalogPage {
}, },
]; ];
protected readonly barChartDatasets: Dataset[] = [
{
label: 'Ventas',
data: [65, 82, 74, 96],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
unit: ' uds.',
unitPosition: 'after',
},
];
protected readonly chartSlices: ChartSlice[] = [
{ label: 'Básico', value: 38, color: DatasetColor.primary },
{ label: 'Pro', value: 45, color: DatasetColor.accent },
{ label: 'Empresas', value: 17, color: DatasetColor.highlighted },
];
protected readonly radarDatasets: Dataset[] = [
{
label: 'Nuestro producto',
data: [8, 7, 6, 9, 8],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
unit: '/10',
unitPosition: 'after',
},
{
label: 'Competencia',
data: [6, 6, 8, 5, 6],
borderColor: DatasetColor.grey,
backgroundColor: DatasetColor.grey,
unit: '/10',
unitPosition: 'after',
},
];
protected readonly carouselItems = [ protected readonly carouselItems = [
{ id: 1, label: 'Elemento 1' }, { id: 1, label: 'Elemento 1' },
{ id: 2, label: 'Elemento 2' }, { id: 2, label: 'Elemento 2' },

View File

@@ -0,0 +1,193 @@
<div class="dashboard">
<aside class="dashboard__sidebar">
<div class="dashboard__brand">
<pi-icon icon="dashboard" color="primary" size="large" />
<span>Nimbus</span>
</div>
<pi-dynamic-menu
class="dashboard__menu"
[menuItems]="menuEntries()"
(selectEntry)="onSelectMenuEntry($any($event))"
/>
<div class="dashboard__sidebar-footer">
<pi-avatar name="Marta Ruiz" color="primary" size="small" />
<div>
<strong>Marta Ruiz</strong>
<span>COO, Nimbus</span>
</div>
</div>
</aside>
<div class="dashboard__main">
<header class="dashboard__topbar">
<pi-breadcrumb [items]="breadcrumbItems" />
<div class="dashboard__topbar-actions">
<pi-search-field
[formField]="f.query"
placeholder="Buscar clientes o facturas…"
/>
<pi-badge [value]="3" color="warn">
<pi-icon-button
icon="notification-on"
aria-label="Notificaciones"
[hasPadding]="false"
/>
</pi-badge>
<pi-avatar name="Marta Ruiz" color="primary" size="small" />
</div>
</header>
<section class="dashboard__stats">
@for (stat of stats; track stat.label) {
<pi-card
alignment="flex-start"
gap="s"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__stat-icon">
<pi-icon [icon]="stat.icon" color="primary" size="medium" />
</div>
</pi-card-top-content>
<pi-card-center-content>
<span class="dashboard__stat-label">{{ stat.label }}</span>
<strong class="dashboard__stat-value">{{ stat.value }}</strong>
</pi-card-center-content>
<pi-card-bottom-content>
<span
class="dashboard__stat-trend"
[class.is-down]="!stat.trendUp"
>
<pi-icon
[icon]="stat.trendUp ? 'metric-up-arrow' : 'metric-down-arrow'"
[color]="stat.trendUp ? 'success' : 'warn'"
size="x-small"
/>
{{ stat.trend }}
</span>
</pi-card-bottom-content>
</pi-card>
}
</section>
<section class="dashboard__content">
<div class="dashboard__content-main">
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__card-header">
<h3>Ingresos vs. gastos</h3>
<button pi-ghost small>Ver informe</button>
</div>
</pi-card-top-content>
<pi-card-center-content>
<pi-line-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun']"
[datasets]="chartDatasets"
[isComparativeChart]="true"
/>
</pi-card-center-content>
</pi-card>
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__card-header">
<h3>Últimas facturas</h3>
<button pi-ghost small>Ver todas</button>
</div>
</pi-card-top-content>
<pi-card-center-content>
<div class="dashboard__table">
<div class="dashboard__table-row dashboard__table-row--head">
<span>Cliente</span>
<span>Importe</span>
<span>Fecha</span>
<span>Estado</span>
</div>
@for (row of invoices; track row.client) {
<div class="dashboard__table-row">
<span>{{ row.client }}</span>
<span>{{ row.amount }}</span>
<span>{{ row.date }}</span>
<pi-chip-status [status]="row.status" [label]="row.statusLabel" />
</div>
}
</div>
</pi-card-center-content>
</pi-card>
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<div class="dashboard__card-header">
<h3>Nuevos clientes por mes</h3>
<button pi-ghost small>Ver informe</button>
</div>
</pi-card-top-content>
<pi-card-center-content>
<pi-bar-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun']"
[datasets]="newClientsDatasets"
[isComparativeChart]="false"
/>
</pi-card-center-content>
</pi-card>
</div>
<div class="dashboard__content-side">
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<h3>Facturas por estado</h3>
</pi-card-top-content>
<pi-card-center-content>
<pi-doughnut-chart [device]="device" [slices]="invoiceStatusSlices" />
</pi-card-center-content>
</pi-card>
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="private"
color="white"
>
<pi-card-top-content>
<h3>Actividad reciente</h3>
</pi-card-top-content>
<pi-card-center-content>
<pi-timeline [items]="timelineItems" />
</pi-card-center-content>
</pi-card>
</div>
</section>
</div>
</div>

View File

@@ -0,0 +1,222 @@
:host {
display: block;
}
.dashboard {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
gap: var(--pi-spacing-x-large);
min-height: 80vh;
}
@media (max-width: 960px) {
.dashboard {
grid-template-columns: 1fr;
}
}
/* ---------- Sidebar ---------- */
.dashboard__sidebar {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-x-large);
padding: var(--pi-spacing-large);
border-radius: var(--pi-border-radius-large);
background: var(--pg-card-bg);
border: 1px solid var(--pg-card-border);
}
.dashboard__brand {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
font-weight: 700;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
padding: 0 var(--pi-spacing-xx-small);
}
.dashboard__menu {
flex: 1;
}
.dashboard__sidebar-footer {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
padding: var(--pi-spacing-small) var(--pi-spacing-xx-small) 0;
border-top: 1px solid var(--pg-card-border);
strong {
display: block;
font-size: var(--pi-font-size-x-small);
color: var(--pg-page-text);
}
span {
display: block;
font-size: var(--pi-font-size-xxx-small);
color: var(--pg-text-muted);
}
}
/* ---------- Main column ---------- */
.dashboard__main {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-x-large);
min-width: 0;
}
.dashboard__topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-large);
flex-wrap: wrap;
}
.dashboard__topbar-actions {
display: flex;
align-items: center;
gap: var(--pi-spacing-medium);
}
/* ---------- Stats ---------- */
.dashboard__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--pi-spacing-large);
}
@media (max-width: 1200px) {
.dashboard__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.dashboard__stats {
grid-template-columns: 1fr;
}
}
.dashboard__stat-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: var(--pi-border-radius-medium);
background: var(--pi-color-primary-25);
}
.dashboard__stat-label {
display: block;
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
.dashboard__stat-value {
display: block;
margin-top: var(--pi-spacing-xxxx-small);
font-size: var(--pi-font-size-xx-large);
color: var(--pg-page-text);
}
.dashboard__stat-trend {
display: inline-flex;
align-items: center;
gap: var(--pi-spacing-xxxx-small);
font-size: var(--pi-font-size-x-small);
font-weight: 600;
color: var(--pi-color-success);
&.is-down {
color: var(--pi-color-warn);
}
}
/* ---------- Contenido: gráfico + tabla / actividad ---------- */
.dashboard__content {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--pi-spacing-large);
align-items: start;
}
@media (max-width: 1100px) {
.dashboard__content {
grid-template-columns: 1fr;
}
}
.dashboard__content-main {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-large);
min-width: 0;
}
.dashboard__content-side {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-large);
min-width: 0;
}
.dashboard__card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-small);
h3 {
margin: 0;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
}
}
.dashboard__content-side pi-card h3 {
margin: 0;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
}
/* ---------- Tabla de facturas ---------- */
.dashboard__table {
display: flex;
flex-direction: column;
}
.dashboard__table-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) minmax(
0,
0.8fr
);
align-items: center;
gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-small) var(--pi-spacing-xx-small);
border-bottom: 1px solid var(--pg-card-border);
font-size: var(--pi-font-size-small);
color: var(--pg-page-text);
&:last-child {
border-bottom: none;
}
}
.dashboard__table-row--head {
font-size: var(--pi-font-size-xxx-small);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--pg-text-muted);
font-weight: 600;
}

View File

@@ -0,0 +1,239 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, form } from '@angular/forms/signals';
import {
PiAvatarComponent,
PiBadgeComponent,
PiBarChartComponent,
BreadcrumbItem,
PiBreadcrumbComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
ChartSlice,
PiDoughnutChartComponent,
PiLineChartComponent,
PiChipStatusComponent,
Dataset,
DatasetColor,
Device,
PiDynamicMenuComponent,
PiGhostButtonDirective,
PiIconButtonComponent,
PiIconComponent,
PiSearchFieldComponent,
PiSmallButtonDirective,
PiTimelineComponent,
TimelineItem,
MenuEntry,
} from '@pi/ui';
interface SearchModel {
query: string;
}
interface Stat {
icon: string;
label: string;
value: string;
trend: string;
trendUp: boolean;
}
interface InvoiceRow {
client: string;
amount: string;
date: string;
status: 'success' | 'pending' | 'error';
statusLabel: string;
}
@Component({
selector: 'app-dashboard',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dashboard.page.html',
styleUrl: './dashboard.page.scss',
imports: [
FormField,
PiAvatarComponent,
PiBadgeComponent,
PiBreadcrumbComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiBarChartComponent,
PiDoughnutChartComponent,
PiLineChartComponent,
PiChipStatusComponent,
PiDynamicMenuComponent,
PiGhostButtonDirective,
PiIconButtonComponent,
PiIconComponent,
PiSearchFieldComponent,
PiSmallButtonDirective,
PiTimelineComponent,
],
})
export class DashboardPage {
protected readonly device: Device = 'DESKTOP';
protected readonly menuEntries = signal<MenuEntry[]>([
{ id: 1, label: 'Panel', icon: 'dashboard', isActive: true },
{ id: 2, label: 'Clientes', icon: 'briefcase' },
{ id: 3, label: 'Facturas', icon: 'document-euro' },
{ id: 4, label: 'Pagos', icon: 'coins' },
{ id: 5, label: 'Ajustes', icon: 'user' },
]);
protected readonly breadcrumbItems: BreadcrumbItem[] = [
{ label: 'Nimbus', link: ['/ejemplos/dashboard'] },
{ label: 'Panel' },
];
private readonly searchModel = signal<SearchModel>({ query: '' });
protected readonly f = form(this.searchModel, () => {});
protected readonly stats: Stat[] = [
{
icon: 'coins',
label: 'Ingresos este mes',
value: '12.4K €',
trend: '+18%',
trendUp: true,
},
{
icon: 'document-euro',
label: 'Facturas pendientes',
value: '7',
trend: '-2',
trendUp: false,
},
{
icon: 'briefcase',
label: 'Clientes activos',
value: '184',
trend: '+5',
trendUp: true,
},
{
icon: 'time-detail',
label: 'Tiempo medio de cobro',
value: '9 días',
trend: '-1,2',
trendUp: true,
},
];
protected readonly chartDatasets: Dataset[] = [
{
label: 'Ingresos',
data: [38, 52, 47, 63, 59, 74],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
cubicInterpolationMode: 'monotone',
unit: 'K €',
unitPosition: 'after',
},
{
label: 'Gastos',
data: [22, 26, 24, 30, 28, 31],
borderColor: DatasetColor.grey,
backgroundColor: DatasetColor.grey,
cubicInterpolationMode: 'monotone',
unit: 'K €',
unitPosition: 'after',
},
];
protected readonly newClientsDatasets: Dataset[] = [
{
label: 'Nuevos clientes',
data: [8, 12, 9, 14, 11, 17],
borderColor: DatasetColor.highlighted,
backgroundColor: DatasetColor.highlighted,
unit: '',
unitPosition: 'after',
},
];
/** Coincide con el desglose real de `invoices`: 2 pagadas, 2 pendientes, 1 rechazada. */
protected readonly invoiceStatusSlices: ChartSlice[] = [
{ label: 'Pagadas', value: 2, color: DatasetColor.success },
{ label: 'Pendientes', value: 2, color: DatasetColor.accent },
{ label: 'Rechazadas', value: 1, color: DatasetColor.warn },
];
protected readonly invoices: InvoiceRow[] = [
{
client: 'Cocina Litoral',
amount: '1.250,00 €',
date: '28/07/2026',
status: 'success',
statusLabel: 'Pagada',
},
{
client: 'Bloom Studio',
amount: '890,50 €',
date: '26/07/2026',
status: 'pending',
statusLabel: 'Pendiente',
},
{
client: 'Vela Textil',
amount: '2.140,00 €',
date: '22/07/2026',
status: 'success',
statusLabel: 'Pagada',
},
{
client: 'Norte Legal',
amount: '430,00 €',
date: '18/07/2026',
status: 'error',
statusLabel: 'Rechazada',
},
{
client: 'Rueda Logística',
amount: '675,25 €',
date: '15/07/2026',
status: 'pending',
statusLabel: 'Pendiente',
},
];
protected readonly timelineItems: TimelineItem[] = [
{
title: 'Factura #4521 pagada',
description: 'Cocina Litoral · 1.250,00 €',
date: 'Hoy, 09:12',
color: 'primary',
},
{
title: 'Nuevo cliente',
description: 'Rueda Logística se ha dado de alta.',
date: 'Ayer, 17:40',
color: 'accent',
},
{
title: 'Factura #4498 rechazada',
description: 'Norte Legal · pago rechazado por el banco.',
date: 'Ayer, 11:05',
color: 'grey',
},
{
title: 'Recordatorio enviado',
description: 'Bloom Studio · factura #4510 pendiente.',
date: '26 jul, 16:20',
color: 'success',
},
];
protected onSelectMenuEntry(entry: MenuEntry): void {
this.menuEntries.update((entries) =>
entries.map((item) => ({ ...item, isActive: item.id === entry.id })),
);
}
}

View File

@@ -0,0 +1,242 @@
<div class="landing">
<!-- Nav -->
<nav class="landing__nav">
<div class="landing__logo">
<pi-icon icon="dashboard" color="primary" size="large" />
<span>Nimbus</span>
</div>
<div class="landing__nav-links">
<a href="javascript:void(0)">Producto</a>
<a href="javascript:void(0)">Precios</a>
<a href="javascript:void(0)">Clientes</a>
</div>
<div class="landing__nav-actions">
<button pi-outline small>Iniciar sesión</button>
<button pi-primary small>Empezar gratis</button>
</div>
</nav>
<!-- Hero -->
<header class="landing__hero">
<div class="landing__hero-copy">
<pi-chip label="Nuevo: previsión de caja con IA" color="primary" />
<h1>Todo el control de tu negocio, en un solo panel.</h1>
<p>
Nimbus conecta tu facturación, tus clientes y tu banco para que dejes
de perseguir números en hojas de cálculo y empieces a tomar
decisiones con datos en tiempo real.
</p>
<div class="landing__hero-actions">
<button pi-primary large>Empieza gratis 14 días</button>
<button pi-outline large>Ver una demo</button>
</div>
<p class="landing__hero-note">
Sin tarjeta de crédito · Cancela cuando quieras
</p>
</div>
<div class="landing__hero-preview">
<pi-card
alignment="stretch"
gap="m"
innerPadding="l"
context="public"
color="white"
class="landing__preview-card"
>
<pi-card-top-content>
<div class="landing__preview-header">
<span>Ingresos</span>
<pi-chip-status status="success" label="+18% vs. mes anterior" />
</div>
</pi-card-top-content>
<pi-card-center-content>
<pi-line-chart
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun']"
[datasets]="heroChartData"
/>
</pi-card-center-content>
</pi-card>
</div>
</header>
<!-- Prueba social -->
<section class="landing__logos">
<span>Con la confianza de equipos en</span>
<div class="landing__logos-row">
<span>Cocina Litoral</span>
<span>Bloom Studio</span>
<span>Vela Textil</span>
<span>Norte Legal</span>
<span>Rueda Logística</span>
</div>
</section>
<!-- Features -->
<section class="landing__section">
<div class="landing__section-heading">
<h2>Todo lo que necesitas para llevar las cuentas</h2>
<p>Sin implementaciones eternas ni consultores. Listo en un día.</p>
</div>
<div class="landing__features">
@for (feature of features; track feature.title) {
<pi-card
alignment="flex-start"
gap="s"
innerPadding="l"
context="public"
color="white"
>
<pi-card-top-content>
<div class="landing__feature-icon">
<pi-icon [icon]="feature.icon" color="primary" size="medium" />
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3>{{ feature.title }}</h3>
<p>{{ feature.description }}</p>
</pi-card-center-content>
</pi-card>
}
</div>
</section>
<!-- Stats -->
<section class="landing__stats">
@for (stat of stats; track stat.label) {
<div class="landing__stat">
<strong>{{ stat.value }}</strong>
<span>{{ stat.label }}</span>
</div>
}
</section>
<!-- Testimonios -->
<section class="landing__section">
<div class="landing__section-heading">
<h2>Lo que dicen los equipos que ya usan Nimbus</h2>
</div>
<div class="landing__testimonials">
@for (testimonial of testimonials; track testimonial.name) {
<pi-card
alignment="flex-start"
gap="m"
innerPadding="l"
context="public"
color="white"
>
<pi-card-center-content>
<p class="landing__testimonial-quote">«{{ testimonial.quote }}»</p>
</pi-card-center-content>
<pi-card-bottom-content>
<div class="landing__testimonial-author">
<pi-avatar [name]="testimonial.name" [color]="testimonial.color" />
<div>
<strong>{{ testimonial.name }}</strong>
<span>{{ testimonial.role }}</span>
</div>
</div>
</pi-card-bottom-content>
</pi-card>
}
</div>
</section>
<!-- Precios -->
<section class="landing__section" id="precios">
<div class="landing__section-heading">
<h2>Un plan para cada etapa</h2>
<p>Cambia de plan cuando quieras, sin penalizaciones.</p>
</div>
<div class="landing__plans">
@for (plan of plans; track plan.name) {
<pi-card
alignment="stretch"
gap="m"
innerPadding="xl"
context="public"
[color]="plan.isFeatured ? 'primary' : 'white'"
[class.landing__plan--featured]="plan.isFeatured"
>
<pi-card-top-content>
@if (plan.isFeatured) {
<pi-chip label="Más popular" color="primary-white" />
}
<h3>{{ plan.name }}</h3>
<p class="landing__plan-description">{{ plan.description }}</p>
<p class="landing__plan-price">
<strong>{{ plan.price }} €</strong>
<span>{{ plan.period }}</span>
</p>
</pi-card-top-content>
<pi-card-center-content>
<ul class="landing__plan-features">
@for (item of plan.features; track item) {
<li>
<pi-icon
icon="check-rounded"
[color]="plan.isFeatured ? 'white' : 'primary'"
size="small"
/>
<span>{{ item }}</span>
</li>
}
</ul>
</pi-card-center-content>
<pi-card-bottom-content>
@if (plan.isFeatured) {
<button pi-white w100>{{ plan.cta }}</button>
} @else {
<button pi-outline w100>{{ plan.cta }}</button>
}
</pi-card-bottom-content>
</pi-card>
}
</div>
</section>
<!-- FAQ -->
<section class="landing__section landing__section--narrow">
<div class="landing__section-heading">
<h2>Preguntas frecuentes</h2>
</div>
<pi-accordion
color="primary"
appearance="outline"
[(expandedItemIndex)]="expandedFaqIndex"
>
@for (faq of faqs; track faq.question) {
<pi-accordion-item
[title]="faq.question"
[status]="faq.tagStatus"
[statusLabel]="faq.tag"
>
<ng-template pi-accordion-item-expandable-content>
<p>{{ faq.answer }}</p>
</ng-template>
</pi-accordion-item>
}
</pi-accordion>
</section>
<!-- CTA final -->
<section class="landing__cta">
<h2>Deja de perseguir números. Empieza a decidir con datos.</h2>
<p>Prueba Nimbus gratis durante 14 días. Sin tarjeta de crédito.</p>
<button pi-white large>Crear cuenta gratis</button>
</section>
<!-- Footer -->
<footer class="landing__footer">
<div class="landing__logo">
<pi-icon icon="dashboard" color="grey" size="medium" />
<span>Nimbus</span>
</div>
<p>© 2026 Nimbus. Pantalla de ejemplo generada con @pi/ui.</p>
</footer>
</div>

View File

@@ -0,0 +1,409 @@
:host {
display: block;
}
.landing {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-xxxx-large);
max-width: 72rem;
margin: 0 auto;
padding-bottom: var(--pi-spacing-xxxx-large);
}
h1,
h2,
h3 {
color: var(--pg-page-text);
}
/* ---------- Nav ---------- */
.landing__nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-large);
padding: var(--pi-spacing-medium) 0;
}
.landing__nav-links {
display: flex;
gap: var(--pi-spacing-x-large);
a {
color: var(--pg-text-muted);
text-decoration: none;
font-size: var(--pi-font-size-small);
font-weight: 600;
&:hover {
color: var(--pi-color-primary);
}
}
}
.landing__nav-actions {
display: flex;
gap: var(--pi-spacing-small);
}
.landing__logo {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
font-weight: 700;
font-size: var(--pi-font-size-medium);
color: var(--pg-page-text);
}
/* ---------- Hero ---------- */
.landing__hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--pi-spacing-xxx-large);
align-items: center;
padding: var(--pi-spacing-x-large) 0 var(--pi-spacing-xxxx-large);
}
@media (max-width: 900px) {
.landing__hero {
grid-template-columns: 1fr;
}
}
.landing__hero-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--pi-spacing-large);
h1 {
margin: 0;
font-size: var(--pi-font-size-xxx-large);
line-height: 1.1;
}
> p {
margin: 0;
color: var(--pg-text-muted);
font-size: var(--pi-font-size-medium);
line-height: 1.6;
max-width: 32rem;
}
}
.landing__hero-actions {
display: flex;
gap: var(--pi-spacing-small);
}
.landing__hero-note {
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
.landing__preview-card {
box-shadow: 0 40px 80px -40px rgba(20, 30, 70, 0.35) !important;
}
.landing__preview-header {
display: flex;
align-items: center;
justify-content: space-between;
span {
font-weight: 700;
color: var(--pg-page-text);
}
}
/* ---------- Logos ---------- */
.landing__logos {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-large) 0;
border-top: 1px solid var(--pg-card-border);
border-bottom: 1px solid var(--pg-card-border);
> span {
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
}
.landing__logos-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--pi-spacing-xx-large);
span {
font-weight: 700;
color: var(--pg-text-muted);
opacity: 0.7;
}
}
/* ---------- Secciones genéricas ---------- */
.landing__section {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-xx-large);
}
.landing__section--narrow {
max-width: 42rem;
margin: 0 auto;
width: 100%;
}
.landing__section-heading {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-xxx-small);
text-align: center;
h2 {
margin: 0;
font-size: var(--pi-font-size-xx-large);
}
p {
margin: 0;
color: var(--pg-text-muted);
}
}
/* ---------- Features ---------- */
.landing__features {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--pi-spacing-large);
}
@media (max-width: 900px) {
.landing__features {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.landing__features {
grid-template-columns: 1fr;
}
}
.landing__feature-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: var(--pi-border-radius-medium);
background: var(--pi-color-primary-25);
}
.landing__features h3 {
margin: 0 0 var(--pi-spacing-xxx-small);
font-size: var(--pi-font-size-medium);
}
.landing__features p {
margin: 0;
color: var(--pg-text-muted);
font-size: var(--pi-font-size-small);
line-height: 1.5;
}
/* ---------- Stats ---------- */
.landing__stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--pi-spacing-large);
padding: var(--pi-spacing-xx-large);
border-radius: var(--pi-border-radius-large);
background: var(--pg-card-bg);
border: 1px solid var(--pg-card-border);
}
@media (max-width: 700px) {
.landing__stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.landing__stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-xxxx-small);
text-align: center;
strong {
font-size: var(--pi-font-size-xx-large);
color: var(--pi-color-primary);
}
span {
font-size: var(--pi-font-size-x-small);
color: var(--pg-text-muted);
}
}
/* ---------- Testimonios ---------- */
.landing__testimonials {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--pi-spacing-large);
}
@media (max-width: 900px) {
.landing__testimonials {
grid-template-columns: 1fr;
}
}
.landing__testimonial-quote {
margin: 0;
font-style: italic;
color: var(--pg-page-text);
line-height: 1.6;
}
.landing__testimonial-author {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
strong {
display: block;
font-size: var(--pi-font-size-small);
color: var(--pg-page-text);
}
span {
display: block;
font-size: var(--pi-font-size-xxx-small);
color: var(--pg-text-muted);
}
}
/* ---------- Precios ---------- */
.landing__plans {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--pi-spacing-large);
align-items: stretch;
}
@media (max-width: 900px) {
.landing__plans {
grid-template-columns: 1fr;
}
}
.landing__plan--featured {
transform: scale(1.03);
border: 2px solid var(--pi-color-primary);
box-shadow: 0 24px 48px -24px rgba(87, 114, 212, 0.45) !important;
}
.landing__plans h3 {
margin: var(--pi-spacing-small) 0 0;
font-size: var(--pi-font-size-large);
}
.landing__plan-description {
margin: var(--pi-spacing-xxxx-small) 0 0;
font-size: var(--pi-font-size-x-small);
opacity: 0.85;
}
.landing__plan-price {
margin: var(--pi-spacing-medium) 0 0;
display: flex;
align-items: baseline;
gap: var(--pi-spacing-xxxx-small);
strong {
font-size: var(--pi-font-size-xxx-large);
}
span {
font-size: var(--pi-font-size-x-small);
opacity: 0.75;
}
}
.landing__plan-features {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--pi-spacing-small);
li {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
font-size: var(--pi-font-size-small);
}
}
/* ---------- CTA final ---------- */
.landing__cta {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-medium);
text-align: center;
padding: var(--pi-spacing-xxxx-large);
border-radius: var(--pi-border-radius-large);
background: linear-gradient(
135deg,
var(--pi-color-primary-900),
var(--pi-color-highlighted)
);
color: var(--pi-color-white);
h2 {
margin: 0;
color: var(--pi-color-white);
font-size: var(--pi-font-size-xx-large);
max-width: 34rem;
}
p {
margin: 0;
color: var(--pi-color-primary-100);
}
}
/* ---------- Footer ---------- */
.landing__footer {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--pi-spacing-small);
padding-top: var(--pi-spacing-x-large);
border-top: 1px solid var(--pg-card-border);
p {
margin: 0;
font-size: var(--pi-font-size-xxx-small);
color: var(--pg-text-muted);
}
}

View File

@@ -0,0 +1,220 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
PiAccordionComponent,
PiAccordionItemContentDirective,
PiAccordionItemDirective,
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiLineChartComponent,
PiChipComponent,
PiChipStatusComponent,
PiColorPrimaryDirective,
PiColorWhiteDirective,
Dataset,
DatasetColor,
Device,
PiIconComponent,
PiLargeButtonDirective,
PiOutlineButtonDirective,
PiSmallButtonDirective,
PiW100ButtonDirective,
} from '@pi/ui';
interface Feature {
icon: string;
title: string;
description: string;
}
interface Plan {
name: string;
price: string;
period: string;
description: string;
isFeatured: boolean;
features: string[];
cta: string;
}
interface Faq {
question: string;
answer: string;
tagStatus: 'success' | 'info' | 'pending';
tag: string;
}
@Component({
selector: 'app-landing',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './landing.page.html',
styleUrl: './landing.page.scss',
imports: [
PiAccordionComponent,
PiAccordionItemContentDirective,
PiAccordionItemDirective,
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiLineChartComponent,
PiChipComponent,
PiChipStatusComponent,
PiColorPrimaryDirective,
PiColorWhiteDirective,
PiIconComponent,
PiLargeButtonDirective,
PiOutlineButtonDirective,
PiSmallButtonDirective,
PiW100ButtonDirective,
],
})
export class LandingPage {
protected readonly device: Device = 'DESKTOP';
protected readonly heroChartData: Dataset[] = [
{
label: 'Ingresos',
data: [38, 52, 47, 63, 59, 74],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
cubicInterpolationMode: 'monotone',
unit: 'K €',
unitPosition: 'after',
},
];
protected readonly features: Feature[] = [
{
icon: 'dashboard',
title: 'Panel unificado',
description:
'Ventas, gastos y caja en una sola vista, actualizada al segundo.',
},
{
icon: 'document-euro',
title: 'Facturación automática',
description:
'Genera y envía facturas recurrentes sin tocar una hoja de cálculo.',
},
{
icon: 'briefcase',
title: 'Gestión de clientes',
description: 'Historial de pedidos, notas y contratos en una ficha.',
},
{
icon: 'connect',
title: 'Se integra con tu stack',
description:
'Conecta tu banco, tu ERP y tu CRM en minutos, sin escribir código.',
},
];
protected readonly stats = [
{ value: '2.400+', label: 'Empresas activas' },
{ value: '98%', label: 'Retención anual' },
{ value: '12M €', label: 'Facturados cada mes' },
{ value: '4.9/5', label: 'Valoración media' },
];
protected readonly testimonials = [
{
quote:
'Migramos toda la contabilidad a Nimbus en una tarde. El equipo de soporte respondió en minutos.',
name: 'Marta Ruiz',
role: 'COO, Cocina Litoral',
color: 'primary' as const,
},
{
quote:
'Por fin dejamos de perseguir números en spreadsheets. El panel en tiempo real cambió cómo tomamos decisiones.',
name: 'Diego Ortiz',
role: 'CEO, Bloom Studio',
color: 'accent' as const,
},
{
quote:
'La facturación automática nos ahorra unas 6 horas a la semana solo en el equipo de administración.',
name: 'Laura Campos',
role: 'Finanzas, Vela Textil',
color: 'highlighted' as const,
},
];
protected readonly plans: Plan[] = [
{
name: 'Básico',
price: '9',
period: '/mes',
description: 'Para autónomos que empiezan a facturar.',
isFeatured: false,
features: ['Hasta 20 facturas/mes', '1 usuario', 'Soporte por email'],
cta: 'Empezar gratis',
},
{
name: 'Pro',
price: '29',
period: '/mes',
description: 'Para equipos que necesitan más control.',
isFeatured: true,
features: [
'Facturas ilimitadas',
'Hasta 10 usuarios',
'Panel de métricas en vivo',
'Soporte prioritario',
],
cta: 'Empezar prueba de 14 días',
},
{
name: 'Empresas',
price: '99',
period: '/mes',
description: 'Para organizaciones con necesidades a medida.',
isFeatured: false,
features: [
'Todo lo de Pro',
'Usuarios ilimitados',
'Integraciones a medida',
'Gestor de cuenta dedicado',
],
cta: 'Hablar con ventas',
},
];
protected readonly faqs: Faq[] = [
{
question: '¿Puedo cancelar cuando quiera?',
answer:
'Sí. No hay permanencia: puedes cancelar tu suscripción desde el panel en cualquier momento y seguirás teniendo acceso hasta el final del periodo pagado.',
tagStatus: 'success',
tag: 'Planes',
},
{
question: '¿Cómo migro mis datos actuales?',
answer:
'Importamos clientes, facturas y catálogo desde una hoja de cálculo o desde tu herramienta anterior. El equipo de soporte te acompaña durante todo el proceso.',
tagStatus: 'pending',
tag: 'Migración',
},
{
question: '¿Mis datos están seguros?',
answer:
'Toda la información viaja cifrada y se aloja en servidores dentro de la UE, con copias de seguridad diarias.',
tagStatus: 'info',
tag: 'Seguridad',
},
{
question: '¿Ofrecéis soporte en español?',
answer:
'Sí, todo nuestro equipo de soporte atiende en español por chat y email, con un tiempo de respuesta medio de menos de una hora en horario laboral.',
tagStatus: 'info',
tag: 'Soporte',
},
];
protected readonly expandedFaqIndex = signal<number | null>(0);
}

View File

@@ -0,0 +1,92 @@
<div class="login">
<section class="login__brand">
<div class="login__brand-content">
<div class="login__logo">
<pi-icon icon="dashboard" color="white" size="large" />
<span>Nimbus</span>
</div>
<h1>Todo el control de tu negocio, en un solo panel.</h1>
<p>
Facturación, clientes y métricas en tiempo real. Nimbus conecta tus
herramientas para que dejes de perseguir números en hojas de cálculo.
</p>
<ul class="login__highlights">
@for (item of highlights; track item.text) {
<li>
<pi-icon [icon]="item.icon" color="white" size="small" />
<span>{{ item.text }}</span>
</li>
}
</ul>
<pi-card
alignment="flex-start"
gap="s"
innerPadding="m"
context="public"
color="white"
[hasShadow]="true"
class="login__quote"
>
<pi-card-center-content>
<p>
«Migramos toda la contabilidad a Nimbus en una tarde. El equipo de
soporte respondió en minutos.»
</p>
</pi-card-center-content>
<pi-card-bottom-content>
<div class="login__quote-author">
<pi-avatar name="Marta Ruiz" color="primary" size="small" />
<div>
<strong>Marta Ruiz</strong>
<span>COO, Cocina Litoral</span>
</div>
</div>
</pi-card-bottom-content>
</pi-card>
</div>
</section>
<section class="login__form-side">
<pi-card
alignment="stretch"
gap="l"
innerPadding="xl"
context="public"
color="white"
class="login__form-card"
>
<pi-card-top-content>
<h2>Inicia sesión</h2>
<p class="login__form-hint">
¿No tienes cuenta todavía?
<a href="javascript:void(0)">Prueba Nimbus gratis</a>
</p>
</pi-card-top-content>
<pi-card-center-content>
<form class="login__form" (submit)="$event.preventDefault(); submit()">
<pi-email-field [formField]="f.email" placeholder="tu@empresa.com">
<pi-label>Email</pi-label>
</pi-email-field>
<pi-password-field [formField]="f.password" placeholder="••••••••">
<pi-label>Contraseña</pi-label>
</pi-password-field>
<div class="login__form-row">
<label class="login__remember">
<pi-slide-toggle [formField]="f.remember" />
<span>Recuérdame</span>
</label>
<a href="javascript:void(0)">¿Olvidaste tu contraseña?</a>
</div>
<button pi-primary w100 large type="submit">Entrar</button>
</form>
</pi-card-center-content>
</pi-card>
</section>
</div>

View File

@@ -0,0 +1,179 @@
:host {
display: block;
}
.login {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
min-height: 80vh;
border-radius: 1.25rem;
overflow: hidden;
box-shadow: 0 30px 60px -30px rgba(20, 30, 70, 0.25);
}
@media (max-width: 860px) {
.login {
grid-template-columns: 1fr;
min-height: auto;
}
}
/* ---------- Panel de marca ---------- */
.login__brand {
display: flex;
align-items: center;
padding: var(--pi-spacing-xxxx-large);
background:
radial-gradient(
circle at 20% 20%,
rgba(255, 255, 255, 0.14),
transparent 45%
),
linear-gradient(
145deg,
var(--pi-color-primary-900),
var(--pi-color-highlighted)
);
color: var(--pi-color-white);
}
.login__brand-content {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-x-large);
max-width: 26rem;
}
.login__logo {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
font-size: var(--pi-font-size-large);
font-weight: 700;
}
.login__brand-content h1 {
margin: 0;
font-size: var(--pi-font-size-xx-large);
line-height: 1.15;
}
.login__brand-content > p {
margin: 0;
color: var(--pi-color-primary-100);
line-height: 1.6;
}
.login__highlights {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--pi-spacing-small);
li {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
font-size: var(--pi-font-size-small);
}
}
.login__quote {
margin-top: var(--pi-spacing-small);
background: rgba(255, 255, 255, 0.08) !important;
p {
margin: 0;
font-style: italic;
color: var(--pi-color-white);
line-height: 1.5;
}
}
.login__quote-author {
display: flex;
align-items: center;
gap: var(--pi-spacing-small);
strong {
display: block;
font-size: var(--pi-font-size-x-small);
}
span {
display: block;
font-size: var(--pi-font-size-xxx-small);
color: var(--pi-color-primary-100);
}
}
/* ---------- Panel de formulario ---------- */
.login__form-side {
display: flex;
align-items: center;
justify-content: center;
padding: var(--pi-spacing-xxxx-large) var(--pi-spacing-x-large);
background: var(--pi-color-white);
}
.login__form-card {
width: 100%;
max-width: 24rem;
box-shadow: none !important;
}
.login__form-card h2 {
margin: 0 0 var(--pi-spacing-xxx-small);
font-size: var(--pi-font-size-x-large);
color: var(--pg-page-text);
}
.login__form-hint {
margin: 0;
color: var(--pg-text-muted);
font-size: var(--pi-font-size-small);
a {
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.login__form {
display: flex;
flex-direction: column;
gap: var(--pi-spacing-large);
}
.login__form-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-small);
font-size: var(--pi-font-size-small);
a {
color: var(--pi-color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.login__remember {
display: flex;
align-items: center;
gap: var(--pi-spacing-xx-small);
color: var(--pg-text-muted);
}

View File

@@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { FormField, email, form } from '@angular/forms/signals';
import {
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiColorPrimaryDirective,
PiEmailFieldComponent,
PiIconComponent,
PiLabelDirective,
PiLargeButtonDirective,
PiPasswordFieldComponent,
PiSlideToggleComponent,
PiW100ButtonDirective,
} from '@pi/ui';
interface LoginModel {
email: string;
password: string;
remember: boolean;
}
@Component({
selector: 'app-login',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './login.page.html',
styleUrl: './login.page.scss',
imports: [
FormField,
PiAvatarComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiColorPrimaryDirective,
PiEmailFieldComponent,
PiIconComponent,
PiLabelDirective,
PiLargeButtonDirective,
PiPasswordFieldComponent,
PiSlideToggleComponent,
PiW100ButtonDirective,
],
})
export class LoginPage {
protected readonly highlights = [
{ icon: 'check-rounded', text: 'Reportes en tiempo real, sin esperas' },
{ icon: 'check-rounded', text: 'Integra tu stack en menos de 10 minutos' },
{ icon: 'check-rounded', text: 'Soporte humano, siempre en español' },
];
private readonly model = signal<LoginModel>({
email: '',
password: '',
remember: true,
});
protected readonly f = form(this.model, (m) => {
email(m.email, { message: 'Introduce un email válido' });
});
protected submit(): void {
this.f().markAsTouched();
}
}

View File

@@ -107,6 +107,47 @@ html.dark-mode {
--pi-color-background-300: #4b505c; --pi-color-background-300: #4b505c;
--pi-color-background-400: #1c1f26; --pi-color-background-400: #1c1f26;
--pi-color-background-500: #181b22; --pi-color-background-500: #181b22;
// --- @pi/ui: accordion/tab ---------------------------------------------
// Ambos usan un escalón claro de la paleta de marca (`--pi-color-{color}-25`
// / `-50`) como fondo de toda la superficie, no como un simple acento — así
// que, a diferencia de un badge o un chip, no puede quedarse claro en
// oscuro. Los tokens `--pi-theme-accordion-surface-*` / `--pi-theme-tab-*`
// (con fallback al valor de marca de siempre) permiten oscurecer solo estas
// dos superficies sin tocar `--pi-color-*` y sin afectar a nada que sí
// funciona bien en oscuro (chips, badges...).
--pi-theme-accordion-surface-primary: var(--pi-color-grey-50);
--pi-theme-accordion-surface-primary-expanded: var(--pi-color-grey-100);
--pi-theme-accordion-surface-warn: var(--pi-color-grey-50);
--pi-theme-accordion-surface-warn-expanded: var(--pi-color-grey-100);
--pi-theme-accordion-surface-accent: var(--pi-color-grey-50);
--pi-theme-accordion-surface-accent-expanded: var(--pi-color-grey-100);
--pi-theme-tab-header-surface: var(--pi-color-grey-50);
--pi-theme-tab-header-surface-fill: var(--pi-color-grey-50);
--pi-theme-tab-active-surface: var(--pi-color-grey-100);
// --- @pi/ui: select-option ---------------------------------------------
// Mismo caso: cada fila de opción usa un escalón claro de marca como fondo
// de toda la fila (no un acento), en 3 estados (reposo/seleccionada/hover).
// Un solo token oscuro por estado basta — todos los colores de marca
// (primary/accent/highlighted/success/warn) comparten el mismo criterio de
// superficie en oscuro, aunque conserven su matiz en claro.
--pi-theme-select-option-surface-primary: var(--pi-color-grey-50);
--pi-theme-select-option-surface-primary-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-primary-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-accent: var(--pi-color-grey-50);
--pi-theme-select-option-surface-accent-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-accent-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-highlighted: var(--pi-color-grey-50);
--pi-theme-select-option-surface-highlighted-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-highlighted-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-success: var(--pi-color-grey-50);
--pi-theme-select-option-surface-success-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-success-hover: var(--pi-color-grey-100);
--pi-theme-select-option-surface-warn: var(--pi-color-grey-50);
--pi-theme-select-option-surface-warn-selected: var(--pi-color-grey-100);
--pi-theme-select-option-surface-warn-hover: var(--pi-color-grey-100);
} }
// El tema alternativo usa escalones claros de su marca como superficie de los // El tema alternativo usa escalones claros de su marca como superficie de los

View File

@@ -72,6 +72,14 @@ hecho **no se pueden enlazar a mano** esas entradas en un elemento que ya use
Se puede ver funcionando en la app `playground` (`nx serve playground`). Se puede ver funcionando en la app `playground` (`nx serve playground`).
## Theming: tokens CSS
El aspecto visual (color, forma, sombra, tipografía de controles) se resuelve
en custom properties (`--pi-color-*`, `--pi-theme-*`) aplicadas vía `ShellClass`
(`pi-shell pi-shell__<tema>`). Documentación completa, tabla de valores por
tema (Default/Alternative/Minimal/Gradient) y guía para añadir tokens o temas
nuevos: [`TOKENS.md`](./TOKENS.md).
| Componente | Contrato | Tipo del valor | | Componente | Contrato | Tipo del valor |
| ---------------------------------------------------- | --------------------- | ------------------------------------- | | ---------------------------------------------------- | --------------------- | ------------------------------------- |
| text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` | | text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` |
@@ -106,6 +114,63 @@ Detalles a tener en cuenta:
Los selects (`ui/select`) siguen con `ControlValueAccessor`; quedaron fuera del Los selects (`ui/select`) siguen con `ControlValueAccessor`; quedaron fuera del
alcance de esta migración. alcance de esta migración.
## Charts
`pi-chart` (un componente, un input `type`) se dividió en **4 componentes
independientes** — `pi-line-chart`, `pi-bar-chart`, `pi-pie-chart`,
`pi-doughnut-chart` —, cada uno en su propia carpeta, con su propio selector y
su propio registro de Chart.js. Ningún consumidor arrastra código de un tipo
de gráfico que no usa: `pi-pie-chart` no registra `LineController`/`BarController`.
Comparten dos bases abstractas (no un único componente con `@switch`):
- `PiBaseChartComponent` — el `<canvas>`, `device`, y el ciclo de vida que
llama a `buildChart()`.
- `PiBaseCartesianChartComponent extends PiBaseChartComponent` — de
`pi-line-chart`/`pi-bar-chart`: reciben `datasets` (una serie por
elemento, comparadas a lo largo de `xAxisMarks`), usan `pi-chart-legend`
(leyenda propia, no la de Chart.js) y comparten el tooltip/hover.
- `PiBaseArcChartComponent extends PiBaseChartComponent` — de
`pi-pie-chart`/`pi-doughnut-chart`: reciben `slices`
(`{ label, value, color }[]`, tipo `ChartSlice`), no `datasets` — la forma
de los datos es otra (una magnitud repartida en porciones, cada una con su
propio color, no varias series en el tiempo) — y usan la leyenda **nativa**
de Chart.js en vez de `pi-chart-legend`.
```html
<pi-line-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" />
<pi-line-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" [filled]="true" /> <!-- área -->
<pi-bar-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" />
<pi-pie-chart [device]="device" [slices]="slices" />
<pi-doughnut-chart [device]="device" [slices]="slices" />
```
Detalles a tener en cuenta:
- **`[filled]="true"` en `pi-line-chart` exige el plugin `Filler` de
Chart.js.** Sin registrarlo, `fill: true` en el dataset no falla — se
ignora en silencio y el gráfico sale sin rellenar. Ya está registrado en
`line-chart.component.ts`; si se añade otro tipo relleno, hay que
registrarlo ahí también.
- **El área usa un color traducido a `rgba(…, 0.15)`, no el `backgroundColor`
del dataset tal cual.** Ese campo también pinta el relleno de los puntos;
si se reutilizase el color opaco para el área saldría un bloque sólido en
vez de una zona translúcida, así que `pointBackgroundColor` conserva el
color original y `backgroundColor` pasa a translúcido solo cuando `filled`
está activo (ver `hexToRgba` en `chart.configs.ts`).
- **`DatasetColor` no tenía `success`/`warn`** (solo `primary`/`accent`/
`highlighted`/`grey`), así que un `pie`/`doughnut` no podía representar
estados (pagado/pendiente/rechazado, etc.) con los mismos colores que
`pi-chip-status`. Se añadieron con los mismos hex que `--pi-color-success`/
`--pi-color-warn`.
- **`Dataset.cubicInterpolationMode` pasa a opcional.** Solo tiene sentido en
`line`; forzarlo en `bar`/`pie`/`doughnut` no aportaba nada y esos tipos ni
siquiera usan `Dataset` (los dos últimos usan `ChartSlice`).
- **El `type="bar"` del `pi-chart` original nunca funcionó**: el input
existía pero `getLineChartConfigs()` construía siempre un chart `line`
ignorando su valor. Se descubrió al dividir los componentes; `pi-bar-chart`
es la primera versión que realmente renderiza barras.
**El loader de servidor de traducciones ya no está en el barrel.** **El loader de servidor de traducciones ya no está en el barrel.**
`util/translate/loaders/translate-server.loader` usa `fs`, `path` y `process`; `util/translate/loaders/translate-server.loader` usa `fs`, `path` y `process`;
exportarlo desde `@pi/ui` rompía el bundle de navegador de cualquier app que exportarlo desde `@pi/ui` rompía el bundle de navegador de cualquier app que

247
libs/ui/TOKENS.md Normal file
View File

@@ -0,0 +1,247 @@
# Tokens de `@pi/ui`
Todo el aspecto visual de la librería (color, forma, sombra, tipografía de
controles) se resuelve en **custom properties de CSS**, nunca en valores
hardcodeados dentro de un componente. Hay dos familias con propósitos
distintos, más un puñado de primitivas base:
| Familia | Dónde vive | Qué representa | Cuántos temas la redefinen |
| --------------- | ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------- |
| `--pi-color-*` | `:root` (`_pi-color-variables.scss`) | Paleta de marca (primary/accent/highlighted/warn/success/grey/white/black/background) | Todos excepto Minimal |
| `--pi-theme-*` | `.pi-shell` (`themes/_*.scss`) | Forma y comportamiento por superficie: botón, campo, card, panel, chip, overlay | Todos |
| Primitivas base | `:root` (`_pi-global-variables.scss`, `_pi-font-sizes-variables.scss`, `_pi-public-variables.scss`) | Espaciado, radios, sombras, tamaños de fuente, animaciones — sin tematizar | Ninguno |
Los componentes de `common/` (botón, campo, card, panel, chip, modal,
snackbar, tooltip, accordion…) **no leen la paleta ni la forma directamente**:
consumen `--pi-theme-*`, y son los ficheros de `styles/themes/` los que deciden
qué valor tiene cada token. Así, un tema nuevo no toca un solo selector de
componente — solo redefine tokens (ver `_alternative.scss`, `_minimal.scss`,
`_gradient.scss` como ejemplo).
## Cómo se aplica un tema
```ts
import { ShellClass } from '@pi/ui';
// en el elemento raíz (normalmente <body> o el contenedor de la app)
document.body.className = ShellClass.Alternative; // 'pi-shell pi-shell__alternative'
```
`ShellClass` expone `Default`, `Alternative`, `Minimal` y `Gradient`
(`libs/ui/src/lib/styles/configs.ts`). Cada uno corresponde a un mixin en
`libs/ui/src/lib/styles/themes/_<nombre>.scss`, incluido desde
`themes/index.scss`. Dentro de un shell se puede forzar el aspecto contrario en
un subárbol con `.is-private`/`.is-public` (p. ej. el input `context` de
`pi-card`).
### El patrón de fallback
Un token puede consumirse de dos formas, según si el tema por defecto lo
declara o no:
```scss
// El tema Default declara este token → todos los temas lo tienen.
border-radius: var(--pi-theme-card-radius);
// El tema Default NO lo declara → el segundo argumento de var() reproduce
// el valor original hardcodeado. Solo Minimal (u otro tema futuro) lo pisa.
border-radius: var(--pi-theme-chip-radius, #{$chip-border-radius});
```
Esto es intencional: los tokens que **todos** los temas existentes necesitan
personalizar (radio/altura/tipografía de botón, forma de campo) se declaran en
`_default.scss`. Los que solo un tema concreto toca (radio de overlays en
Minimal, degradado de foco en Gradient) se dejan sin declarar en Default —así
su valor por defecto es exactamente el que tenía el componente antes de
tokenizar, byte a byte, y ningún tema existente cambia de aspecto sin querer.
## `--pi-theme-*`: tabla completa
**D** = Default · **A** = Alternative · **M** = Minimal · **G** = Gradient.
Una celda vacía significa "no lo redefine, hereda el valor de la fila D (o el
fallback de `var()` si D tampoco lo declara, marcado como *(fallback)*)".
### Botón (`pi-button/_pi-button.scss`, `_pi-button-theme.scss`, `menu/dynamic-menu`)
| Token | D | A | M | G |
| --- | --- | --- | --- | --- |
| `--pi-theme-button-height` | `var(--pi-spacing-xxx-large)` (2.5rem) | `2.875rem` | `2.5rem` | — |
| `--pi-theme-button-radius` | `var(--pi-border-radius-medium)` | `999px` (píldora) | `var(--pi-border-radius-xx-small)` | — |
| `--pi-theme-button-font-weight` | `600` | `700` | `500` | — |
| `--pi-theme-button-letter-spacing` | `normal` | `0.04em` | `normal` | — |
| `--pi-theme-button-text-transform` | `none` | `uppercase` | `none` | — |
| `--pi-theme-button-shadow` | `none` | `0 6px 16px -6px rgba(106,63,212,.55)` | `none` | — |
| `--pi-theme-button-transition` | `background-color .16s ease-in` | `all .2s ease-out` | `background-color .12s ease-out, border-color .12s ease-out` | — |
| `--pi-theme-button-fill-primary` | `none` (relleno plano) | degradado `primary-900→primary-400` | `none` | degradado `primary-400→primary-900` |
| `--pi-theme-button-fill-accent` | `none` | degradado `accent-900→accent-400` | `none` | degradado `accent-400→accent-900` |
| `--pi-theme-button-fill-warn` | `none` | — | `none` | — |
| `--pi-theme-button-fill-success` | `none` | — | `none` | — |
| `--pi-theme-button-fill-highlighted` | `none` | degradado `highlighted→highlighted-300` | `none` | degradado `highlighted-300→highlighted` |
`--pi-theme-button-fill-#{$color}` se interpola por nombre de color en
`_pi-button-theme.scss:47` (`background-image: var(--pi-theme-button-fill-#{$color}, none)`),
así que un tema nuevo que quiera degradado en un color que no sea
primary/accent/highlighted solo tiene que declarar
`--pi-theme-button-fill-<color>`.
### Campos de formulario (`_mat-form-field.scss`, `file-upload`, `_mat-datepicker.scss`, `_mat-select.scss`)
| Token | D | A | M | G |
| --- | --- | --- | --- | --- |
| `--pi-theme-field-radius` | `0.875rem` | `999px` | `var(--pi-border-radius-xx-small)` | — |
| `--pi-theme-field-border-width` | `1px` | `2px` | `1px` | — |
| `--pi-theme-field-border-color` | `var(--pi-color-grey-200)` | `var(--pi-color-primary-100)` | `var(--pi-color-grey-200)` | — |
| `--pi-theme-field-border-color-hover` | `var(--pi-color-grey-300)` | `var(--pi-color-primary-300)` | `var(--pi-color-grey-400)` | — |
| `--pi-theme-field-border-color-focus` | `var(--pi-color-primary)` | `var(--pi-color-primary)` | `var(--pi-color-black)` | `transparent` (deja ver el degradado) |
| `--pi-theme-field-surface` | `var(--pi-color-white)` | `var(--pi-color-primary-25)` | `var(--pi-color-white)` | — |
| `--pi-theme-field-label-color` | `var(--pi-color-grey-300)` | `var(--pi-color-primary-900)` | `var(--pi-color-grey-400)` | — |
| `--pi-theme-field-label-transform` *(fallback: `none`)* | — | `uppercase` | `none` | — |
| `--pi-theme-field-label-letter-spacing` *(fallback: `normal`)* | — | `0.08em` | `normal` | — |
| `--pi-theme-field-label-weight` *(fallback: `inherit`)* | — | `700` | `500` | — |
| `--pi-theme-field-focus-gradient` *(fallback: `none`)* | — | — | — | `linear-gradient(120deg, primary, accent, highlighted)` |
### Superficies: card / panel / chip
| Token | D | A | M | G | Consumidores |
| --- | --- | --- | --- | --- | --- |
| `--pi-theme-card-radius` | `var(--pi-border-radius-large)` | `1.5rem` | `var(--pi-border-radius-xx-small)` | — | `_pi-card.scss`, `skeleton.component.scss` |
| `--pi-theme-card-shadow` | `var(--pi-box-shadow-panel)` | `0 10px 30px -12px rgba(59,13,107,.28)` | `none` | — | `_pi-card.scss` |
| `--pi-theme-panel-radius` | `var(--pi-border-radius-large)` | `1.5rem` | `var(--pi-border-radius-xx-small)` | — | `panel.component.scss`, `_mat-datepicker.scss`, `_mat-select.scss`, `dynamic-menu` |
| `--pi-theme-chip-radius` *(fallback: radio original por tamaño)* | — | `0.25rem` | `0` | — | `chip/styles/_size.scss` (×3, por tamaño y por affix) |
### Overlays: modal / notification / tooltip / accordion
Ningún tema salvo Minimal los redefine — Default/Alternative/Gradient usan
siempre el *fallback* de `var()` (el valor que tenían antes de tokenizar).
| Token | Fallback (D/A/G) | M | Consumidores |
| --- | --- | --- | --- |
| `--pi-theme-modal-radius` | `var(--pi-border-radius-medium)` (panel completo) / `var(--pi-border-radius-large)` (`.pi-modal__panel--full`) | `var(--pi-border-radius-xx-small)` | `_mat-dialog.scss` (×2) |
| `--pi-theme-notification-radius` | `var(--pi-border-radius-large)` | `var(--pi-border-radius-xx-small)` | `snack-bar.component.scss`, `_mat-snackbar.scss` (×2) |
| `--pi-theme-notification-shadow` | `var(--pi-box-shadow)` | `none` | `_mat-snackbar.scss` |
| `--pi-theme-tooltip-radius` | `var(--pi-border-radius-medium)` | `var(--pi-border-radius-xx-small)` | `_mat-tooltip.scss` |
| `--pi-theme-tooltip-shadow` | `var(--pi-box-shadow-panel-double)` | `none` | `_mat-tooltip.scss` |
| `--pi-theme-accordion-radius` | `var(--pi-border-radius-large)` | `var(--pi-border-radius-xx-small)` | `accordion.component.scss`, `_horizontal-accordion.scss`, `_vertical-accordion.scss` (×6, incluye un `calc(... - 0.0625rem)`) |
### Puente con Angular Material (`--mat-sys-*`)
Los componentes de Material (slider, checkbox, radio, progress…) no leen
`--pi-color-*`: pintan con sus propios roles `--mat-sys-*`, generados por
`mat.theme()` a partir de una paleta separada. `_default.scss` reengancha una
cosa con otra **una sola vez**, por indirección:
```scss
--mat-sys-primary: var(--pi-color-primary);
--mat-sys-on-primary: var(--pi-color-white);
--mat-sys-primary-container: var(--pi-color-primary-100);
--mat-sys-on-primary-container: var(--pi-color-primary-900);
--mat-sys-secondary: var(--pi-color-accent);
--mat-sys-on-secondary: var(--pi-color-white);
--mat-sys-secondary-container: var(--pi-color-accent-100);
--mat-sys-on-secondary-container: var(--pi-color-accent-900);
--mat-sys-tertiary: var(--pi-color-highlighted);
--mat-sys-on-tertiary: var(--pi-color-white);
--mat-sys-error: var(--pi-color-warn);
--mat-sys-on-error: var(--pi-color-white);
```
Como es indirección (`var(--pi-color-*)`, no un valor literal), un tema que
redefina `--pi-color-primary` etc. (como Alternative) tiñe también los
controles de Material sin tener que repetir este bloque.
## `--pi-color-*`: paleta de marca
Declarada una vez en `:root` (`_pi-color-variables.scss`) con los valores
Default, y redeclarada completa dentro de `.pi-shell__alternative` (Minimal y
Gradient **no** tocan la paleta — ver comentario en `_minimal.scss`: "minimalista
aquí es una cuestión de forma, no de color"). Se consume **directamente por
nombre** (no hay indirección `--pi-theme-*` de por medio) en ~100 ficheros de
la librería: bordes, fondos, texto, iconos, estados de badge/chip/status, etc.
Cada paleta (`primary`, `accent`, `highlighted`, `warn`, `success`) sigue la
misma escala de 10 pasos + variante rgba del tono 200:
`10 → 25 → 50 → 75 → 100 → 200 → 200-rgba → 300 → 400 → (base) → 900`
| Escala | primary (D) | accent (D) | highlighted (D) | warn (D) | success (D) |
| --- | --- | --- | --- | --- | --- |
| 10 | `#fdfdff` | `#fffdf9` | `#f9fbff` | `#fff9fa` | `#f7fcf9` |
| 25 | `#fafbff` | `#fffaed` | `#f2f5ff` | `#ffedf0` | `#e6f6ed` |
| 50 | `#f0f4ff` | `#fdf5e5` | `#e9eeff` | `#fcebeb` | `#def5e8` |
| 75 | `#eaefff` | `#fae5bf` | `#dae2ff` | `#ffe7ea` | `#c7ebdc` |
| 100 | `#d7e2fb` | `#fce0ad` | `#bccdfa` | `#f7cdcd` | `#bbe8d5` |
| 200 | `#c3d5ff` | `#f6d495` | `#6482d7` | `#f2acac` | `#83dbb6` |
| 200-rgba | `rgba(196,213,255,.15)` | `rgba(252,224,173,.15)` | `rgba(168,193,255,.15)` | `rgba(237,129,129,.15)` | *(no tiene)* |
| 300 | `#a3c0ff` | `#f2c26a` | `#436ac1` | `#ec8b8b` | `#60c99d` |
| 400 | `#8cacf2` | `#f6bc63` | `#1d3f8a` | `#e87272` | `#3ebe88` |
| base | `#799ef0` | `#eda82a` | `#0d2a6b` | `#e45959` | `#1cb273` |
| 900 | `#5772d4` | `#d78f0f` | `#030f3c` | `#c13737` | `#009758` |
**Neutros y utilidad**, sin escala de tema (no cambian entre Default/Alternative):
| Token | Valor | Uso |
| --- | --- | --- |
| `--pi-color-white` | `#ffffff` | — |
| `--pi-color-white-900` | `#f4f4f4` | — |
| `--pi-color-white-rgba-03/05/06/09` | blanco a 33/50/66/88% opacidad | overlays, disabled |
| `--pi-color-black-200` | `#323748` | — |
| `--pi-color-black` | `#1a1a1a` | texto, foco (Minimal) |
| `--pi-color-grey-10…900` | escala de 10 pasos, `#fcfcfd → #3d3d3d` | bordes, texto secundario |
| `--pi-color-background-300/400/500` | `#aeaeb6` / `#fcfcfc` / `#f7f7f7` | fondos de página |
En `_alternative.scss` la paleta completa (`primary`, `accent`, `highlighted`)
se redeclara con una gama azul→violeta / turquesa / violeta oscuro,
respectivamente. `warn` y `success` se dejan intactos a propósito en todos los
temas: son colores semánticos y cambiarlos perjudicaría la lectura del estado.
> Nota: `_pi-color-variables.scss` declara `--pi-color-grey-200` dos veces
> (líneas 328-329) y `--pi-color-white`/`--pi-color-background-400` con el
> mismo patrón en otro punto del fichero — inofensivo (la segunda declaración
> gana y tiene el mismo valor) pero es limpieza pendiente si se retoca el
> fichero.
## Primitivas base (sin tematizar)
Estas variables no cambian entre temas — son la escala de espaciado/forma/sombra
de toda la librería, y los tokens `--pi-theme-*` casi siempre se expresan en
función de ellas (`var(--pi-border-radius-large)`, `var(--pi-spacing-xxx-large)`…).
| Familia | Tokens | Rango |
| --- | --- | --- |
| Espaciado | `--pi-spacing-xxxxx-small` … `--pi-spacing-xxxx-large` | `0.0625rem` (1px) … `3rem` (48px), 11 pasos |
| Radio | `--pi-border-radius-xx-small` … `--pi-border-radius-x-large` | `0.125rem` (2px) … `1.375rem` (22px), 5 pasos |
| Sombra | `--pi-box-shadow`, `-hover`, `-double`, `-panel`, `-panel-double`, `-panel-disabled`, `-top`, `-inset` | valores `rgba` fijos, ver `_pi-global-variables.scss` |
| Animación | `--pi-animation`, `-fast`, `-slow` | `all .16s/.25s/.4s ease-out` |
| Tamaño de fuente | `--pi-font-size-xxx-small` … `-xxx-large` | `0.5rem` (8px) … `2.875rem` (46px), 9 pasos |
| Layout (header/footer/navbar/aside/card) | `--pi-height-*`, `--pi-width-*` | ver `_pi-global-variables.scss` |
| Landing/marketing | `--pi-public-font-size-*`, `--pi-public-height-*`, `--pi-public-width-*` | ver `_pi-public-variables.scss` |
`_pi-gradient-variables.scss` (`$pi-gradient-primary-light`, etc.) son
variables **Sass**, no custom properties de CSS — se resuelven en tiempo de
compilación y no forman parte del contrato de theming en runtime.
## Cómo tokenizar una superficie nueva
1. En el consumidor, cambia el valor hardcodeado por
`var(--pi-theme-<superficie>-<propiedad>, <valor-original>)`. El fallback
garantiza que ningún tema existente cambia de aspecto hasta que decida
redefinir el token.
2. Si **todos** los temas van a necesitar tocarlo pronto, decláralo también en
`defaultThemeTokens` (`_default.scss`) con el valor original — así queda
documentado como parte del contrato en vez de vivir solo como fallback
disperso en el CSS.
3. Añade el override en el/los temas que lo necesiten
(`_alternative.scss`, `_minimal.scss`, `_gradient.scss`).
4. Actualiza este documento.
## Cómo añadir un tema nuevo
1. Crea `libs/ui/src/lib/styles/themes/_<nombre>.scss` con un mixin
`<nombre>ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si
aplica, `--pi-color-*`) que cambian respecto a Default.
2. Aplícalo a `.pi-shell__<nombre>`.
3. Impórtalo desde `themes/index.scss`.
4. Añade la entrada a `ShellClass` en `styles/configs.ts`.
No hace falta tocar ningún componente: si consumen los tokens en vez de
valores literales, el tema nuevo se propaga solo.

BIN
libs/ui/TOKENS.pdf Normal file

Binary file not shown.

706
libs/ui/docs/theming.html Normal file
View File

@@ -0,0 +1,706 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>@pi/ui — Guía de theming</title>
<style>
:root {
--primary: #799ef0;
--primary-900: #5772d4;
--accent: #eda82a;
--highlighted: #0d2a6b;
--success: #1cb273;
--warn: #e45959;
--ink: #1a1a1a;
--grey-700: #44475a;
--grey-500: #6b7280;
--grey-400: #9ca3af;
--grey-200: #e5e7eb;
--grey-100: #f3f4f6;
--callout-bg: #eef1fd;
--callout-border: #5772d4;
--warncallout-bg: #fdecec;
--warncallout-border: #e45959;
--code-bg: #12162a;
--code-text: #dbe3f5;
--code-token: #9db4f5;
--code-comment: #7c869e;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.55;
}
code, .mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
white-space: nowrap;
}
pre.code, pre.code code {
white-space: pre-wrap;
}
p { margin: 0 0 10px; color: var(--grey-700); }
a { color: var(--primary-900); }
.token {
color: var(--primary-900);
background: #eef1fd;
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
}
section.page {
page-break-after: always;
padding: 4px 4px 24px;
}
section.page:last-child { page-break-after: auto; }
/* ---------- Cover page ---------- */
.cover-kicker {
color: var(--primary-900);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.12em;
margin-bottom: 18px;
}
.cover-title {
font-size: 34px;
font-weight: 800;
line-height: 1.15;
margin: 0 0 16px;
color: var(--ink);
}
.cover-subtitle {
font-size: 14px;
color: var(--grey-500);
max-width: 30em;
margin-bottom: 26px;
}
.dots { display: flex; gap: 10px; margin-bottom: 26px; }
.dot {
width: 26px; height: 26px; border-radius: 50%;
display: inline-block;
}
.cover-rule { border: none; border-top: 1px solid var(--grey-200); margin: 0 0 20px; }
.cover-note {
font-size: 11px;
color: var(--grey-400);
max-width: 34em;
margin-bottom: 34px;
}
.toc-item {
display: flex;
align-items: baseline;
gap: 14px;
padding: 11px 0;
border-bottom: 1px dashed var(--grey-200);
font-size: 13px;
}
.toc-num { color: var(--primary-900); font-weight: 700; width: 1.4em; }
/* ---------- Section header ---------- */
h1.section-title {
font-size: 19px;
font-weight: 800;
margin: 0 0 6px;
padding-bottom: 10px;
border-bottom: 2px solid var(--ink);
}
h1.section-title .num { color: var(--primary-900); margin-right: 8px; }
.lede { color: var(--grey-500); margin-bottom: 18px; font-size: 13px; }
h2.sub {
font-size: 13.5px;
font-weight: 700;
margin: 22px 0 8px;
}
/* ---------- Tables ---------- */
table { width: 100%; border-collapse: collapse; margin-bottom: 14px; }
thead th {
text-align: left;
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--grey-500);
border-bottom: 1px solid var(--grey-200);
padding: 6px 10px 6px 0;
font-weight: 700;
}
tbody td {
padding: 8px 10px 8px 0;
border-bottom: 1px solid var(--grey-100);
vertical-align: top;
font-size: 12px;
color: var(--grey-700);
}
tbody tr:last-child td { border-bottom: none; }
td.tok { white-space: nowrap; }
small.note { color: var(--grey-400); font-size: 10.5px; }
/* ---------- Code blocks ---------- */
pre.code {
background: var(--code-bg);
color: var(--code-text);
border-radius: 8px;
padding: 16px 18px;
font-size: 11.5px;
line-height: 1.6;
overflow: hidden;
margin: 10px 0 16px;
white-space: pre-wrap;
word-break: break-word;
}
pre.code .t { color: var(--code-token); }
pre.code .c { color: var(--code-comment); font-style: italic; }
/* ---------- Callouts ---------- */
.callout {
background: var(--callout-bg);
border-left: 3px solid var(--callout-border);
border-radius: 4px;
padding: 12px 16px;
font-size: 11.5px;
color: #2c3550;
margin: 14px 0;
}
.callout.warn {
background: var(--warncallout-bg);
border-left-color: var(--warncallout-border);
color: #5a2a2a;
}
.callout b { color: inherit; }
/* ---------- Numbered steps ---------- */
.step { display: flex; gap: 14px; margin-bottom: 18px; align-items: flex-start; }
.step-num {
flex: none;
width: 24px; height: 24px; border-radius: 50%;
background: var(--primary-900); color: #fff;
font-size: 12px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
margin-top: 1px;
}
.step-body { flex: 1; }
.step-title { font-weight: 700; margin-bottom: 6px; font-size: 13px; }
/* ---------- Theme cards (§4) ---------- */
.theme-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 14px 0 18px;
}
.theme-card {
border: 1px solid var(--grey-200);
border-radius: 8px;
padding: 12px 14px;
}
.theme-card .name {
font-weight: 700;
font-size: 12.5px;
margin-bottom: 5px;
display: flex; align-items: center; gap: 7px;
}
.theme-card .name .swatch { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.theme-card p { margin: 0; font-size: 11.5px; color: var(--grey-500); }
/* ---------- Gradient demo (§5) ---------- */
.demo-row { display: flex; gap: 26px; margin: 16px 0 4px; align-items: flex-start; }
.demo-box {
width: 150px; height: 44px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
}
.demo-box.plain { border: 1px solid var(--grey-200); background: #fff; }
.demo-box.gradient {
border: 3px solid transparent;
background:
linear-gradient(#fff, #fff) padding-box,
linear-gradient(120deg, var(--primary), var(--accent), var(--highlighted)) border-box;
}
.demo-caption { font-size: 10.5px; color: var(--grey-500); text-align: center; margin-top: 6px; }
.demo-caption code { font-size: 10px; }
/* ---------- Checklist ---------- */
ul.check, ul.cross { list-style: none; padding: 0; margin: 0 0 20px; }
ul.check li, ul.cross li {
padding: 6px 0 6px 24px;
position: relative;
font-size: 12px;
color: var(--grey-700);
border-bottom: 1px solid var(--grey-100);
}
ul.check li:before {
content: "✓";
position: absolute; left: 0;
color: var(--success); font-weight: 700;
}
ul.cross li:before {
content: "✗";
position: absolute; left: 0;
color: var(--warn); font-weight: 700;
}
</style>
</head>
<body>
<!-- ================= PAGE 1: COVER ================= -->
<section class="page">
<div class="cover-kicker">@PI/UI — DESIGN SYSTEM</div>
<div class="cover-title">Cómo crear<br />temas</div>
<div class="cover-subtitle">
Guía práctica del sistema de theming: qué tokens existen, cómo se aplican,
y los pasos para dar de alta un tema nuevo sin tocar ni un selector de
componente.
</div>
<div class="dots">
<span class="dot" style="background:var(--primary)"></span>
<span class="dot" style="background:var(--accent)"></span>
<span class="dot" style="background:var(--highlighted)"></span>
<span class="dot" style="background:var(--success)"></span>
<span class="dot" style="background:var(--warn)"></span>
<span class="dot" style="background:var(--ink)"></span>
</div>
<hr class="cover-rule" />
<div class="cover-note">
Basada en el estado real de <code>libs/ui/src/lib/styles</code>: los cuatro
temas ya presentes en el repo (Default, Alternative, Minimal, Gradient)
sirven de ejemplo en todo el documento. Incluye los tokens de overlays
(modal, notificación, tooltip, accordion) añadidos tras la primera
versión de esta guía.
</div>
<div class="toc-item"><span class="toc-num">1</span> Arquitectura: shell + tokens</div>
<div class="toc-item"><span class="toc-num">2</span> El contrato de tokens</div>
<div class="toc-item"><span class="toc-num">3</span> Crear un tema, paso a paso</div>
<div class="toc-item"><span class="toc-num">4</span> Los cuatro temas del repo</div>
<div class="toc-item"><span class="toc-num">5</span> Caso de estudio: anillo de foco en degradado</div>
<div class="toc-item"><span class="toc-num">6</span> Modo oscuro y temas</div>
<div class="toc-item"><span class="toc-num">7</span> Checklist</div>
</section>
<!-- ================= PAGE 2: §1 ARQUITECTURA ================= -->
<section class="page">
<h1 class="section-title"><span class="num">1</span>Arquitectura: shell + tokens</h1>
<p class="lede">Dos piezas, ninguna es un selector de componente.</p>
<p>
Un tema en <code>@pi/ui</code> no es un solo fichero que reescriba
<code>.pi-button</code>, <code>.pi-card</code> o cualquier otro selector de
componente. Es, exclusivamente, un bloque que redefine
<b>variables CSS</b> (custom properties) bajo una clase contenedora. Los
componentes ya están escritos para leer esas variables — cambiarlas es
todo lo que hace falta.
</p>
<h2 class="sub">La clase contenedora: <code>.pi-shell</code></h2>
<p>
Todo tema se aplica añadiendo dos clases a un elemento raíz (normalmente
<code>&lt;body&gt;</code>, porque los overlays de Material — diálogos,
menús, snackbars — cuelgan de ahí y también deben heredar el tema):
</p>
<pre class="code">&lt;body class="<span class="t">pi-shell pi-shell__&lt;nombre-del-tema&gt;</span>"&gt;</pre>
<p>
<code>.pi-shell</code> por sí sola ya aplica el contrato completo de
tokens con sus valores por defecto (ver <code>themes/_default.scss</code>).
<code>.pi-shell__&lt;nombre&gt;</code> se aplica <i>a la vez</i>, no en su
lugar, y sobreescribe solo los tokens que ese tema quiere cambiar. Como
ambas clases tienen la misma especificidad, gana la que se declaró más
tarde en el CSS compilado — por eso el orden de <code>@forward</code> en
<code>themes/index.scss</code> importa (ver §3).
</p>
<div class="callout">
El helper <code>shellTheming()</code> (en
<code>libs/ui/src/lib/styles/configs.ts</code>) construye ese string por
ti: <code>shellTheming('minimal')</code> devuelve
<code>'pi-shell pi-shell__minimal'</code>. <code>ShellClass</code> ya
expone uno por tema — no hace falta escribir el string a mano.
</div>
<h2 class="sub">Las dos familias de variables</h2>
<table>
<thead>
<tr><th style="width:16%">Prefijo</th><th style="width:52%">Qué controla</th><th>Ejemplo</th></tr>
</thead>
<tbody>
<tr>
<td class="tok"><code class="token">--pi-color-*</code></td>
<td>Paleta de marca: <code>primary</code>, <code>accent</code>, <code>highlighted</code>, <code>warn</code>, <code>success</code>, <code>grey</code>, cada uno con escalones (<code>-10</code> a <code>-900</code>).</td>
<td><code>--pi-color-primary</code>, <code>--pi-color-primary-900</code></td>
</tr>
<tr>
<td class="tok"><code class="token">--pi-theme-*</code></td>
<td>Forma y comportamiento: radios, sombras, grosor de borde, peso/transformación de texto, rellenos de botón.</td>
<td><code>--pi-theme-button-radius</code>, <code>--pi-theme-card-shadow</code></td>
</tr>
</tbody>
</table>
<p>
Un tema puede tocar una familia, la otra, o ambas.
<code>_alternative.scss</code> cambia las dos (repaleta
primary/accent/highlighted <i>y</i> la forma). <code>_minimal.scss</code>
y <code>_gradient.scss</code> solo tocan <code>--pi-theme-*</code> — la
paleta de marca se queda igual a propósito.
</p>
<h2 class="sub">El puente con Angular Material</h2>
<p>
Los componentes nativos de Material (slider, checkbox, radio…) no leen
<code>--pi-color-*</code>: pintan con sus propios roles
<code>--mat-sys-*</code>, que genera <code>mat.theme()</code>. El puente se
declara <b>una sola vez</b>, en <code>_default.scss</code>, por
indirección:
</p>
<pre class="code"><span class="t">--mat-sys-primary</span>: var(--pi-color-primary);
<span class="t">--mat-sys-primary-container</span>: var(--pi-color-primary-100);
<span class="c">/* … */</span></pre>
<p>
Como es una indirección, cualquier tema que redefina
<code>--pi-color-primary</code> arrastra automáticamente a Material sin
tener que repetir el bloque del puente. Si tu tema no toca la paleta (como
Minimal o Gradient), no necesitas tocar esto en absoluto.
</p>
<div class="callout warn">
<b>Límite conocido:</b> el puente cubre color, no forma. El radio/sombra de
checkbox, radio, slider o mat-select vienen de tokens <code>--mdc-*</code>
que Angular Material fija en tiempo de compilación (no son variables de
runtime reasignables desde un tema). Aplanarlos también requeriría
overrides por componente — fuera del alcance de este contrato.
</div>
</section>
<!-- ================= PAGE 3-4: §2 CONTRATO DE TOKENS ================= -->
<section class="page">
<h1 class="section-title"><span class="num">2</span>El contrato de tokens</h1>
<p class="lede">Todo lo que un tema puede redefinir, con su valor en el tema por defecto y dónde se consume.</p>
<h2 class="sub">Botones</h2>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-button-height</code></td><td><code>var(--pi-spacing-xxx-large)</code> <small class="note">(2.5rem)</small></td><td><code>pi-button/_pi-button-theme.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-radius</code></td><td><code>var(--pi-border-radius-medium)</code></td><td><code>pi-button/_pi-button.scss</code>, <code>dynamic-menu.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-font-weight</code></td><td><code>600</code></td><td rowspan="4"><code>pi-button-theme.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-letter-spacing</code></td><td><code>normal</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-text-transform</code></td><td><code>none</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-shadow</code></td><td><code>none</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-transition</code></td><td><code>background-color .16s ease-in</code></td><td><code>pi-button-theme.scss</code>, <code>stepper.component.scss</code>, <code>file-upload.component.scss</code>, <code>dynamic-menu.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-fill-{primary,accent,warn,success,highlighted}</code></td><td><code>none</code> <small class="note">(manda el <code>background-color</code> sólido)</small></td><td><code>_pi-button-theme.scss</code></td></tr>
</tbody>
</table>
<h2 class="sub">Campos de formulario</h2>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-field-radius</code></td><td><code>0.875rem</code></td><td rowspan="7"><code>vendors/_mat-form-field.scss</code>, <code>file-upload.component.scss</code>, <code>_mat-datepicker.scss</code>, <code>_mat-select.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-width</code></td><td><code>1px</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-color</code></td><td><code>var(--pi-color-grey-200)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-color-hover</code></td><td><code>var(--pi-color-grey-300)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-color-focus</code></td><td><code>var(--pi-color-primary)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-surface</code></td><td><code>var(--pi-color-white)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-label-color</code></td><td><code>var(--pi-color-grey-300)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-label-transform</code> / <code>-letter-spacing</code> / <code>-weight</code></td><td><small class="note">sin declarar → hereda el valor original del componente</small></td><td><code>_mat-form-field.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-focus-gradient</code></td><td><small class="note">sin declarar → <code>none</code> (opt-in, ver §5)</small></td><td><code>_mat-form-field.scss</code></td></tr>
</tbody>
</table>
<h2 class="sub">Superficies</h2>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-card-radius</code></td><td><code>var(--pi-border-radius-large)</code></td><td><code>components/_pi-card.scss</code>, <code>skeleton.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-card-shadow</code></td><td><code>var(--pi-box-shadow-panel)</code></td><td><code>_pi-card.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-panel-radius</code></td><td><code>var(--pi-border-radius-large)</code></td><td><code>panel.component.scss</code>, <code>_mat-select.scss</code>, <code>_mat-datepicker.scss</code>, <code>dynamic-menu.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-chip-radius</code></td><td><small class="note">sin declarar → radio original del chip</small></td><td><code>chip/styles/_size.scss</code> (×3)</td></tr>
</tbody>
</table>
<div class="callout">
Fíjate en el patrón <code>var(--pi-theme-x, &lt;valor-original&gt;)</code>
que usan varios consumidores (chip, panel, card, overlays). Eso significa
que <b>no es obligatorio declarar todos los tokens</b> en un tema nuevo:
los que omitas caen al valor que ya tenía el componente antes de que
existiera el sistema de theming. Solo declara lo que quieras cambiar.
</div>
</section>
<section class="page">
<h2 class="sub" style="margin-top:0">Overlays <small class="note">(modal, notificación, tooltip, accordion)</small></h2>
<p>
Grupo añadido después de la primera versión de este contrato. Ningún tema
salvo <b>Minimal</b> los redefine todavía: Default, Alternative y Gradient
usan siempre el segundo argumento de <code>var()</code>, que reproduce
exactamente la forma que tenía cada overlay antes de tokenizar.
</p>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto (fallback)</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-modal-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-medium)</code> <small class="note">(<code>var(--pi-border-radius-large)</code> en <code>.pi-modal__panel--full</code>)</small></td><td><code>vendors/_mat-dialog.scss</code> (×2)</td></tr>
<tr><td class="tok"><code class="token">--pi-theme-notification-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-large)</code></td><td><code>snack-bar.component.scss</code>, <code>_mat-snackbar.scss</code> (×2)</td></tr>
<tr><td class="tok"><code class="token">--pi-theme-notification-shadow</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-box-shadow)</code></td><td><code>_mat-snackbar.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-tooltip-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-medium)</code></td><td><code>_mat-tooltip.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-tooltip-shadow</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-box-shadow-panel-double)</code></td><td><code>_mat-tooltip.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-accordion-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-large)</code></td><td><code>accordion.component.scss</code>, <code>_horizontal-accordion.scss</code>, <code>_vertical-accordion.scss</code> (×6, incluye un <code>calc()</code>)</td></tr>
</tbody>
</table>
<div class="callout">
Minimal declara los seis en su mixin (<code>_minimal.scss</code>): aplana
todos los radios a <code>--pi-border-radius-xx-small</code> y apaga las dos
sombras (<code>none</code>) — mismo criterio que ya aplicaba a botón, campo
y card, extendido al resto de superficies flotantes.
</div>
<!-- ================= §3 CREAR UN TEMA ================= -->
<h1 class="section-title" style="margin-top:26px"><span class="num">3</span>Crear un tema, paso a paso</h1>
<p class="lede">Cuatro ficheros, ninguno toca un componente. Ejemplo con un tema ficticio <code>brutalist</code>.</p>
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<div class="step-title">Crea <code>libs/ui/src/lib/styles/themes/_brutalist.scss</code></div>
<p>Un mixin con los tokens que cambian, más una clase que lo aplica:</p>
<pre class="code">@mixin <span class="t">brutalistThemeTokens</span> {
--pi-theme-button-radius: 0;
--pi-theme-button-shadow: 4px 4px 0 var(--pi-color-black);
--pi-theme-card-radius: 0;
--pi-theme-card-shadow: none;
}
.pi-shell__brutalist {
@include brutalistThemeTokens;
}</pre>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<div class="step-title">Regístralo en <code>themes/index.scss</code></div>
<p>Añade el <code>@forward</code>/<code>@use</code> al final de la lista — el orden es el orden de prioridad en el CSS compilado:</p>
<pre class="code">@forward 'default';
@forward 'alternative';
@forward 'minimal';
@forward 'gradient';
@forward 'brutalist'; <span class="c">// ← nuevo, al final</span>
@use 'default' as *;
@use 'alternative' as *;
@use 'minimal' as *;
@use 'gradient' as *;
@use 'brutalist' as *; <span class="c">// ← nuevo, al final</span></pre>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<div class="step-title">Añade la entrada en <code>ShellClass</code> <small class="note">(<code>libs/ui/src/lib/styles/configs.ts</code>)</small></div>
<pre class="code">export const ShellClass = {
Default: shellTheming('default'),
Alternative: shellTheming('alternative'),
Minimal: shellTheming('minimal'),
Gradient: shellTheming('gradient'),
<span class="c">/* Bordes duros, sombra dura, sin medias tintas. */</span>
Brutalist: shellTheming('brutalist'),
};</pre>
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-body">
<div class="step-title">Aplícalo en la app consumidora</div>
<p>En el playground esto ya está resuelto con un signal + effect (ver <code>apps/playground/src/app/app.ts</code>); en cualquier otra app basta con:</p>
<pre class="code">import { ShellClass } from '@pi/ui';
document.body.className = ShellClass.Brutalist;</pre>
</div>
</div>
<div class="callout">
No hay quinto paso. No se toca ningún <code>.component.scss</code>, ningún
selector, ningún componente TypeScript. Si necesitas cambiar algo que
<b>no</b> está en el contrato de tokens (§2), esa es la señal de que hace
falta añadir un token nuevo al contrato — y entonces sí tocas el
<code>.scss</code> del componente afectado, pero solo una vez, para que
quede disponible para todos los temas futuros (así se añadió
<code>--pi-theme-field-focus-gradient</code>, ver §5).
</div>
</section>
<!-- ================= §4 LOS CUATRO TEMAS ================= -->
<section class="page">
<h1 class="section-title"><span class="num">4</span>Los cuatro temas del repo</h1>
<p class="lede">Cuatro implementaciones reales del mismo contrato, en <code>libs/ui/src/lib/styles/themes/</code>.</p>
<div class="theme-grid">
<div class="theme-card">
<div class="name"><span class="swatch" style="background:var(--primary)"></span>Default</div>
<p>Rellenos planos y sólidos, controles compactos. Declara el contrato completo — el resto de temas parten de aquí.</p>
</div>
<div class="theme-card">
<div class="name"><span class="swatch" style="background:#8a63f0"></span>Alternative</div>
<p>Aspecto de marketing: repaleta primary→violeta, accent→turquesa, botones en pastilla con degradado y sombra, campos con superficie tintada.</p>
</div>
<div class="theme-card">
<div class="name"><span class="swatch" style="background:var(--ink)"></span>Minimal</div>
<p>Esquinas casi rectas, sin sombra (separa por borde fino en vez de elevación), sin degradados ni versalitas. Ahora también aplana los overlays — modal, notificación, tooltip y accordion comparten el mismo radio recto. Misma paleta que Default.</p>
</div>
<div class="theme-card">
<div class="name"><span class="swatch" style="background:var(--accent)"></span>Gradient</div>
<p>Igual que Default en forma, pero con degradado en el relleno de los botones y en el anillo de foco de los campos.</p>
</div>
</div>
<p>
Los cuatro comparten <i>exactamente</i> los mismos selectores de
componente — la prueba de que el contrato de tokens funciona es que
ninguno necesitó tocar un <code>.component.ts</code> ni un
<code>.component.html</code>.
</p>
</section>
<!-- ================= §5 CASO DE ESTUDIO ================= -->
<section class="page">
<h1 class="section-title"><span class="num">5</span>Caso de estudio: anillo de foco en degradado</h1>
<p class="lede">Qué hacer cuando el efecto que quieres <b>no</b> lo cubre ningún token existente.</p>
<p>
El tema <i>Gradient</i> quería un borde en degradado al enfocar un campo.
El problema: el borde de un <code>&lt;mat-form-field appearance="outline"&gt;</code>
no es un <code>border</code> normal — es tres <code>&lt;div&gt;</code>
(<code>leading</code>, <code>notch</code>, <code>trailing</code>) cada uno
con su propio borde parcial, para dejar hueco a la etiqueta flotante. Un
<code>background</code> en degradado ahí se vería cortado en cada unión.
</p>
<h2 class="sub">La solución: un anillo aparte, no el borde del notch</h2>
<p>
En vez de pelear con los tres fragmentos, se añade un
<code>::after</code> sobre <code>.mat-mdc-text-field-wrapper</code> (la
caja completa, sin partir) que pinta <i>solo el borde</i> gracias a
<code>mask-composite</code>: se enmascara la copia
<code>padding-box</code> (el interior) fuera de la copia
<code>border-box</code> (el rectángulo completo), dejando visible
únicamente el anillo:
</p>
<small class="note">libs/ui/src/lib/styles/common/vendors/_mat-form-field.scss</small>
<pre class="code">.mat-mdc-text-field-wrapper { position: relative; }
&.mat-focused .mat-mdc-text-field-wrapper::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: var(--pi-theme-field-radius);
padding: var(--pi-theme-field-border-width, 1px);
background: <span class="t">var(--pi-theme-field-focus-gradient, none)</span>;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}</pre>
<p>
El token nuevo, <code>--pi-theme-field-focus-gradient</code>, se añadió al
contrato compartido (consumido con fallback <code>none</code>) — así
cualquier tema puede optar por él sin que los demás se vean afectados.
<i>Gradient</i> además pone
<code>--pi-theme-field-border-color-focus: transparent</code> para apagar
el borde sólido del notch; si no, se vería el borde plano por debajo del
anillo.
</p>
<div class="demo-row">
<div>
<div class="demo-box plain"></div>
<div class="demo-caption">Sin degradado<br /><code>focus-gradient: none</code></div>
</div>
<div>
<div class="demo-box gradient"></div>
<div class="demo-caption">Con degradado<br />(anillo vía <code>mask-composite</code>)</div>
</div>
</div>
<div class="callout">
<b>Regla general:</b> cuando el efecto que buscas no cabe en una simple
reasignación de variable, la solución sigue siendo "un token opt-in con
fallback inerte" + "una técnica CSS aislada en el fichero compartido del
componente" — nunca un selector nuevo dentro del propio fichero del tema.
El fichero de tema solo declara <i>valores</i>.
</div>
</section>
<!-- ================= §6 MODO OSCURO ================= -->
<section class="page">
<h1 class="section-title"><span class="num">6</span>Modo oscuro y temas</h1>
<p class="lede">El modo oscuro del playground es ortogonal a los temas — conviven, no se solapan.</p>
<p>
<code>@pi/ui</code> no trae un tema oscuro propio. El playground activa
uno "best-effort" añadiendo la clase <code>dark-mode</code> al
<code>&lt;html&gt;</code> (no al <code>&lt;body&gt;</code>, que es donde
vive <code>.pi-shell</code>), y ahí reasigna solo los
<b>neutros</b> (<code>--pi-color-white</code>, <code>--pi-color-grey-*</code>,
<code>--pi-color-black</code>) — las paletas de marca
(<code>primary</code>, <code>accent</code>…) se dejan igual:
</p>
<small class="note">apps/playground/src/app/styles.scss</small>
<pre class="code">html.dark-mode {
--pi-color-white: #1c1f26;
--pi-color-grey-100: #2a2e38;
--pi-color-black: #f1f2f4;
<span class="c">/* … solo neutros, la marca no cambia … */</span>
}</pre>
<p>
Como es una clase distinta en un elemento distinto, cualquier combinación
(tema × modo oscuro) funciona sin código extra — son dos cascadas
independientes que solo coinciden en que ambas reasignan variables CSS. La
única excepción real es cuando un tema usa un escalón de marca
<i>como si fuera</i> superficie neutra (por ejemplo,
<code>--pi-color-primary-25</code> como fondo de campo en Alternative): ese
caso puntual sí necesita un ajuste en modo oscuro, ya presente en
<code>styles.scss</code> bajo <code>html.dark-mode .pi-shell__alternative</code>.
</p>
</section>
<!-- ================= §7 CHECKLIST ================= -->
<section class="page">
<h1 class="section-title"><span class="num">7</span>Checklist</h1>
<h2 class="sub" style="margin-top:6px">Antes de dar el tema por terminado</h2>
<ul class="check">
<li>El fichero nuevo vive en <code>themes/</code>, exporta un mixin y una clase <code>.pi-shell__&lt;nombre&gt;</code>.</li>
<li>Está en <code>themes/index.scss</code>, al final de ambos bloques (<code>@forward</code> y <code>@use</code>).</li>
<li>Tiene entrada en <code>ShellClass</code> (<code>configs.ts</code>), con un comentario de una línea que resuma el aspecto.</li>
<li>Solo declara los tokens que cambia — el resto hereda de <code>_default.scss</code> sin repetirlo.</li>
<li>Si toca <code>--pi-color-primary/accent/highlighted</code>, revisado que el puente <code>--mat-sys-*</code> (declarado una vez en default) sigue coherente.</li>
<li>Si toca overlays (modal/notificación/tooltip/accordion), revisado con las tres páginas de catálogo, no solo con un botón suelto.</li>
<li>Probado en el playground con las tres páginas de catálogo, no solo en un botón suelto.</li>
</ul>
<h2 class="sub">Evita</h2>
<ul class="cross">
<li>Añadir un selector de componente (<code>.pi-button.mi-tema</code>) dentro del fichero del tema — si hace falta, el token que falta va al contrato compartido, no aquí.</li>
<li>Redeclarar tokens que no cambian "por si acaso" — genera ruido y dificulta ver qué es realmente distinto en ese tema.</li>
<li>Tocar <code>warn</code> / <code>success</code> por estética — son colores semánticos; cambiarlos perjudica la lectura del estado (error/éxito).</li>
<li>Asumir que <code>--pi-theme-*</code> alcanza a los controles nativos de Material (checkbox, radio, slider): esos usan tokens <code>--mdc-*</code> fijados en compilación, fuera de este contrato (ver §1).</li>
</ul>
</section>
</body>
</html>

Binary file not shown.

View File

@@ -30,6 +30,7 @@ export * from './lib/ui/accordion';
export * from './lib/ui/avatar'; export * from './lib/ui/avatar';
export * from './lib/ui/badge'; export * from './lib/ui/badge';
export * from './lib/ui/banner'; export * from './lib/ui/banner';
export * from './lib/ui/breadcrumb';
export * from './lib/ui/button'; export * from './lib/ui/button';
export * from './lib/ui/card'; export * from './lib/ui/card';
export * from './lib/ui/carousel'; export * from './lib/ui/carousel';
@@ -39,9 +40,11 @@ export * from './lib/ui/data-grid';
export * from './lib/ui/data-table'; export * from './lib/ui/data-table';
export * from './lib/ui/divider'; export * from './lib/ui/divider';
export * from './lib/ui/drawer'; export * from './lib/ui/drawer';
export * from './lib/ui/empty-state';
export * from './lib/ui/error-status-code'; export * from './lib/ui/error-status-code';
export * from './lib/ui/file-upload'; export * from './lib/ui/file-upload';
export * from './lib/ui/form-field'; export * from './lib/ui/form-field';
export * from './lib/ui/hub';
export * from './lib/ui/icon'; export * from './lib/ui/icon';
export * from './lib/ui/image'; export * from './lib/ui/image';
export * from './lib/ui/list-item'; export * from './lib/ui/list-item';
@@ -53,11 +56,16 @@ export * from './lib/ui/paginator';
export * from './lib/ui/panel'; export * from './lib/ui/panel';
export * from './lib/ui/popover'; export * from './lib/ui/popover';
export * from './lib/ui/progress'; export * from './lib/ui/progress';
export * from './lib/ui/rating';
export * from './lib/ui/segmented-control';
export * from './lib/ui/select'; export * from './lib/ui/select';
export * from './lib/ui/slider'; export * from './lib/ui/slider';
export * from './lib/ui/spinner'; export * from './lib/ui/spinner';
export * from './lib/ui/stat-card';
export * from './lib/ui/stepper'; export * from './lib/ui/stepper';
export * from './lib/ui/tab'; export * from './lib/ui/tab';
export * from './lib/ui/timeline';
export * from './lib/ui/tree';
// ./lib/ui/text: omitido(s) por colisión con otro módulo -> PiDisabledDirective // ./lib/ui/text: omitido(s) por colisión con otro módulo -> PiDisabledDirective
export { export {
PiBaseTextViewerComponent, PiBaseTextViewerComponent,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,14 @@
--pi-theme-card-shadow: none; --pi-theme-card-shadow: none;
--pi-theme-panel-radius: var(--pi-border-radius-xx-small); --pi-theme-panel-radius: var(--pi-border-radius-xx-small);
--pi-theme-chip-radius: 0; --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 { .pi-shell__minimal {

View File

@@ -9,6 +9,17 @@ import {
} from '@angular/core'; } from '@angular/core';
import { status } from '../../../util/status'; 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. * 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. * 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 // Accordion Item Header Content
@Directive({ selector: '[pi-accordion-item-header-content]' }) @Directive({ selector: '[pi-accordion-item-header-content]' })
export class PiAccordionItemHeaderDirective { export class PiAccordionItemHeaderDirective {
public readonly templateRef = inject<any>(TemplateRef); public readonly templateRef = inject<
TemplateRef<AccordionItemTemplateContext>
>(TemplateRef);
} }
// Accordion Item Summary Content // Accordion Item Summary Content
@Directive({ selector: '[pi-accordion-item-summary-content]' }) @Directive({ selector: '[pi-accordion-item-summary-content]' })
export class PiAccordionSummaryContentDirective { export class PiAccordionSummaryContentDirective {
readonly templateRef = inject<TemplateRef<any>>(TemplateRef); readonly templateRef =
inject<TemplateRef<AccordionItemTemplateContext>>(TemplateRef);
} }
// Accordion Item Expandable Content // Accordion Item Expandable Content
@Directive({ selector: '[pi-accordion-item-expandable-content]' }) @Directive({ selector: '[pi-accordion-item-expandable-content]' })
export class PiAccordionItemContentDirective { export class PiAccordionItemContentDirective {
readonly templateRef = inject<TemplateRef<any>>(TemplateRef); readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
} }
/** /**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@
<!-- Paginator --> <!-- Paginator -->
@if (hasOverflow && !hidePaginator()) { @if (hasOverflow && !hidePaginator()) {
<footer> <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> <div [class]="index === currentPageIndex ? 'is-active' : ''"></div>
} }
</footer> </footer>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
export * from './date-field/date-field.component'; export * from './date-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 './checkbox/checkbox.component';
export * from './radio-group/radio-group.component'; export * from './radio-group/radio-group.component';
export * from './directives/label.directive'; export * from './directives/label.directive';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,9 +27,9 @@
} }
} }
@mixin defaultIconColor { @mixin defaultIconColor($color) {
.mat-icon { .mat-icon {
color: var(--pi-color-primary); color: $color;
} }
} }
@@ -64,6 +64,26 @@
&.pi-icon__x-large { &.pi-icon__x-large {
@include defaultIconSize(var(--pi-spacing-xx-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 { &.pi-icon__appearance--fill {

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