new components
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 -------------------------------------------------- */
|
||||||
|
|||||||
@@ -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,15 +733,62 @@
|
|||||||
</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"
|
>
|
||||||
|
<div class="chart-grid">
|
||||||
|
<div class="chart-grid__item">
|
||||||
|
<span class="chart-grid__label">pi-line-chart</span>
|
||||||
|
<pi-line-chart
|
||||||
[device]="device"
|
[device]="device"
|
||||||
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
|
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
|
||||||
[datasets]="chartDatasets"
|
[datasets]="chartDatasets"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</app-section>
|
</app-section>
|
||||||
|
|
||||||
<app-section title="Carousel">
|
<app-section title="Carousel">
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
apps/playground/src/app/pages/examples/landing/landing.page.html
Normal file
242
apps/playground/src/app/pages/examples/landing/landing.page.html
Normal 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>
|
||||||
409
apps/playground/src/app/pages/examples/landing/landing.page.scss
Normal file
409
apps/playground/src/app/pages/examples/landing/landing.page.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
apps/playground/src/app/pages/examples/landing/landing.page.ts
Normal file
220
apps/playground/src/app/pages/examples/landing/landing.page.ts
Normal 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);
|
||||||
|
}
|
||||||
92
apps/playground/src/app/pages/examples/login/login.page.html
Normal file
92
apps/playground/src/app/pages/examples/login/login.page.html
Normal 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>
|
||||||
179
apps/playground/src/app/pages/examples/login/login.page.scss
Normal file
179
apps/playground/src/app/pages/examples/login/login.page.scss
Normal 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);
|
||||||
|
}
|
||||||
68
apps/playground/src/app/pages/examples/login/login.page.ts
Normal file
68
apps/playground/src/app/pages/examples/login/login.page.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
247
libs/ui/TOKENS.md
Normal 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
BIN
libs/ui/TOKENS.pdf
Normal file
Binary file not shown.
706
libs/ui/docs/theming.html
Normal file
706
libs/ui/docs/theming.html
Normal 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><body></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"><body class="<span class="t">pi-shell pi-shell__<nombre-del-tema></span>"></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__<nombre></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, <valor-original>)</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><mat-form-field appearance="outline"></code>
|
||||||
|
no es un <code>border</code> normal — es tres <code><div></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><html></code> (no al <code><body></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__<nombre></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.
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal file
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// El panel de `pi-hub` es un `MatDialogConfig.panelClass`: el overlay lo
|
||||||
|
// cuelga fuera de `pi-hub.component`, así que estos estilos no pueden ir en
|
||||||
|
// el scss del componente (view encapsulation no llegaría hasta ahí).
|
||||||
|
.cdk-overlay-container,
|
||||||
|
.pi-shell {
|
||||||
|
.pi-hub__panel {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
.mat-mdc-dialog-container,
|
||||||
|
.mdc-dialog__surface {
|
||||||
|
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-medium))
|
||||||
|
var(--pi-theme-modal-radius, var(--pi-border-radius-medium)) 0 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
animation: pi-hub-slide-up 0.25s ease-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pi-hub-slide-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
margin: var(--pi-spacing-x-large);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@for (item of visibleItems(); track $index; let i = $index) {
|
||||||
|
<pi-avatar
|
||||||
|
[image]="item.image"
|
||||||
|
[name]="item.name"
|
||||||
|
[alt]="item.alt"
|
||||||
|
[color]="item.color ?? 'primary'"
|
||||||
|
[size]="size()"
|
||||||
|
[style.z-index]="zIndexFor(i)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
@if (overflowCount() > 0) {
|
||||||
|
<span class="pi-avatar-group__overflow">+{{ overflowCount() }}</span>
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@mixin overflowSize($dimension, $font-size) {
|
||||||
|
height: $dimension;
|
||||||
|
width: $dimension;
|
||||||
|
min-width: $dimension;
|
||||||
|
font-size: $font-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
// El anillo (box-shadow, no border: no cambia la caja) separa avatares
|
||||||
|
// solapados; se aplica desde aquí porque `pi-avatar` no sabe que puede
|
||||||
|
// vivir en un grupo.
|
||||||
|
pi-avatar {
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-left: calc(var(--pi-spacing-xx-small) * -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pi-avatar-group__overflow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: calc(var(--pi-spacing-xx-small) * -1);
|
||||||
|
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
|
||||||
|
background-color: var(--pi-color-grey-100);
|
||||||
|
color: var(--pi-color-grey);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size (mismos escalones que pi-avatar, para que el "+N" encaje)
|
||||||
|
&.pi-avatar-group__size--x-small .pi-avatar-group__overflow {
|
||||||
|
@include overflowSize(1.5rem, var(--pi-font-size-xxx-small));
|
||||||
|
}
|
||||||
|
&.pi-avatar-group__size--small .pi-avatar-group__overflow {
|
||||||
|
@include overflowSize(2rem, var(--pi-font-size-xx-small));
|
||||||
|
}
|
||||||
|
&.pi-avatar-group__size--medium .pi-avatar-group__overflow {
|
||||||
|
@include overflowSize(2.5rem, var(--pi-font-size-x-small));
|
||||||
|
}
|
||||||
|
&.pi-avatar-group__size--large .pi-avatar-group__overflow {
|
||||||
|
@include overflowSize(3.25rem, var(--pi-font-size-small));
|
||||||
|
}
|
||||||
|
&.pi-avatar-group__size--x-large .pi-avatar-group__overflow {
|
||||||
|
@include overflowSize(4rem, var(--pi-font-size-medium));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||||
|
|
||||||
|
import { AvatarGroupItem, PiAvatarGroupComponent } from './avatar-group.component';
|
||||||
|
|
||||||
|
describe('PiAvatarGroupComponent', () => {
|
||||||
|
let fixture: ComponentFixture<PiAvatarGroupComponent>;
|
||||||
|
|
||||||
|
const items: AvatarGroupItem[] = [
|
||||||
|
{ name: 'Ada Lovelace' },
|
||||||
|
{ name: 'Grace Hopper' },
|
||||||
|
{ name: 'Margaret Hamilton' },
|
||||||
|
{ name: 'Katherine Johnson' },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PiAvatarGroupComponent, MatIconTestingModule],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(PiAvatarGroupComponent);
|
||||||
|
fixture.componentRef.setInput('items', items);
|
||||||
|
});
|
||||||
|
|
||||||
|
function avatars(): HTMLElement[] {
|
||||||
|
return Array.from(fixture.nativeElement.querySelectorAll('pi-avatar'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function overflowText(): string | null {
|
||||||
|
return (
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('.pi-avatar-group__overflow')
|
||||||
|
?.textContent?.trim() ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders one pi-avatar per item when there is no max', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(avatars().length).toBe(items.length);
|
||||||
|
expect(overflowText()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps visible avatars at max and shows the overflow count', () => {
|
||||||
|
fixture.componentRef.setInput('max', 3);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(avatars().length).toBe(3);
|
||||||
|
expect(overflowText()).toBe('+1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show an overflow badge when max is at least the item count', () => {
|
||||||
|
fixture.componentRef.setInput('max', items.length);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(avatars().length).toBe(items.length);
|
||||||
|
expect(overflowText()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives earlier avatars a higher z-index so they stack on top', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const zIndexes = avatars().map((el) => Number(el.style.zIndex));
|
||||||
|
expect(zIndexes).toEqual([4, 3, 2, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects size as a host class', () => {
|
||||||
|
fixture.componentRef.setInput('size', 'large');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.className).toContain(
|
||||||
|
'pi-avatar-group__size--large',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
HostBinding,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
import { iconSize, theming } from '../../../styles';
|
||||||
|
import { PiAvatarComponent } from '../avatar/avatar.component';
|
||||||
|
|
||||||
|
export interface AvatarGroupItem {
|
||||||
|
image?: string;
|
||||||
|
name?: string;
|
||||||
|
alt?: string;
|
||||||
|
color?: theming;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-avatar-group',
|
||||||
|
templateUrl: './avatar-group.component.html',
|
||||||
|
styleUrls: ['./avatar-group.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiAvatarComponent],
|
||||||
|
})
|
||||||
|
export class PiAvatarGroupComponent {
|
||||||
|
@HostBinding('class') get classes(): string {
|
||||||
|
return ['pi-avatar-group', `pi-avatar-group__size--${this.size()}`].join(
|
||||||
|
' ',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly items = input.required<AvatarGroupItem[]>();
|
||||||
|
/** Por encima de este número, el resto se resume en un "+N". */
|
||||||
|
readonly max = input<number>();
|
||||||
|
readonly size = input<iconSize>('medium');
|
||||||
|
|
||||||
|
protected readonly visibleItems = computed(() => {
|
||||||
|
const max = this.max();
|
||||||
|
const items = this.items();
|
||||||
|
return max ? items.slice(0, max) : items;
|
||||||
|
});
|
||||||
|
|
||||||
|
protected readonly overflowCount = computed(() => {
|
||||||
|
const max = this.max();
|
||||||
|
const items = this.items();
|
||||||
|
return max && items.length > max ? items.length - max : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** El primero debe quedar por encima: z-index descendente por índice. */
|
||||||
|
protected zIndexFor(index: number): number {
|
||||||
|
return this.visibleItems().length - index;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal file
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||||
|
|
||||||
|
import { PiAvatarComponent } from './avatar.component';
|
||||||
|
|
||||||
|
describe('PiAvatarComponent', () => {
|
||||||
|
let fixture: ComponentFixture<PiAvatarComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PiAvatarComponent, MatIconTestingModule],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(PiAvatarComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function initialsText(): string | null {
|
||||||
|
return (
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('.pi-avatar__initials')
|
||||||
|
?.textContent?.trim() ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('falls back to the icon when neither image nor name are set', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('pi-icon')).toBeTruthy();
|
||||||
|
expect(fixture.nativeElement.querySelector('img')).toBeNull();
|
||||||
|
expect(initialsText()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to initials when name is set but no image', () => {
|
||||||
|
fixture.componentRef.setInput('name', 'Ada Lovelace');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(initialsText()).toBe('AL');
|
||||||
|
expect(fixture.nativeElement.querySelector('pi-icon')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a single-letter initial from a single-word name', () => {
|
||||||
|
fixture.componentRef.setInput('name', 'Madonna');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(initialsText()).toBe('M');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the image over name/icon when all are provided', () => {
|
||||||
|
fixture.componentRef.setInput('image', 'https://example.com/a.png');
|
||||||
|
fixture.componentRef.setInput('name', 'Ada Lovelace');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('img')).toBeTruthy();
|
||||||
|
expect(initialsText()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to initials when the image fails to load', () => {
|
||||||
|
fixture.componentRef.setInput('image', 'https://example.com/broken.png');
|
||||||
|
fixture.componentRef.setInput('name', 'Ada Lovelace');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const img: HTMLImageElement = fixture.nativeElement.querySelector('img');
|
||||||
|
img.dispatchEvent(new Event('error'));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('img')).toBeNull();
|
||||||
|
expect(initialsText()).toBe('AL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects size, shape and color as host classes', () => {
|
||||||
|
fixture.componentRef.setInput('size', 'large');
|
||||||
|
fixture.componentRef.setInput('shape', 'square');
|
||||||
|
fixture.componentRef.setInput('color', 'accent');
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const classes: string = fixture.nativeElement.className;
|
||||||
|
expect(classes).toContain('pi-avatar__size--large');
|
||||||
|
expect(classes).toContain('pi-avatar__shape--square');
|
||||||
|
expect(classes).toContain('pi-color__accent');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from './avatar/avatar.component';
|
export * from './avatar/avatar.component';
|
||||||
|
export * from './avatar-group/avatar-group.component';
|
||||||
|
|||||||
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal file
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PiBadgeComponent } from './badge.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<pi-badge [value]="value" [max]="max" [isHidden]="isHidden" color="warn">
|
||||||
|
<span class="projected-content">child</span>
|
||||||
|
</pi-badge>
|
||||||
|
`,
|
||||||
|
imports: [PiBadgeComponent],
|
||||||
|
})
|
||||||
|
class HostComponent {
|
||||||
|
value: string | number | undefined = undefined;
|
||||||
|
max = 99;
|
||||||
|
isHidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PiBadgeComponent', () => {
|
||||||
|
let fixture: ComponentFixture<HostComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [HostComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(HostComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
function indicator(): HTMLElement | null {
|
||||||
|
return fixture.nativeElement.querySelector('.pi-badge__indicator');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('always renders the projected content', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.projected-content'),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a dot with no text when value is not set', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(indicator()?.textContent?.trim()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the numeric value when under max', () => {
|
||||||
|
fixture.componentInstance.value = 3;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(indicator()?.textContent?.trim()).toBe('3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the numeric value at max with a "+" suffix', () => {
|
||||||
|
fixture.componentInstance.value = 150;
|
||||||
|
fixture.componentInstance.max = 99;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(indicator()?.textContent?.trim()).toBe('99+');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a string value as-is, ignoring max', () => {
|
||||||
|
fixture.componentInstance.value = 'new';
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(indicator()?.textContent?.trim()).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the indicator without unmounting the projected content', () => {
|
||||||
|
fixture.componentInstance.value = 3;
|
||||||
|
fixture.componentInstance.isHidden = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(indicator()).toBeNull();
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.projected-content'),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<ol>
|
||||||
|
@for (item of items(); track item.label; let last = $last) {
|
||||||
|
<li>
|
||||||
|
@if (item.link && !last) {
|
||||||
|
<a [routerLink]="item.link">{{ item.label }}</a>
|
||||||
|
} @else {
|
||||||
|
<span [attr.aria-current]="last ? 'page' : null">{{
|
||||||
|
item.label
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
@if (!last) {
|
||||||
|
<pi-icon [icon]="separatorIcon()" size="x-small" color="grey" />
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
ol {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--pi-spacing-xxx-small);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--pi-spacing-xxx-small);
|
||||||
|
// El reset global aplica `li + li { margin-top: ... }` pensado para listas
|
||||||
|
// de prosa (ver _pi-reset.scss); aquí el <ol> es una fila flex, así que
|
||||||
|
// ese margen solo desalinea verticalmente los elementos a partir del 2º.
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--pi-color-grey);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--pi-font-size-x-small);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--pi-color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: var(--pi-font-size-x-small);
|
||||||
|
color: var(--pi-color-black);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||||
|
|
||||||
|
import { BreadcrumbItem, PiBreadcrumbComponent } from './breadcrumb.component';
|
||||||
|
|
||||||
|
describe('PiBreadcrumbComponent', () => {
|
||||||
|
let fixture: ComponentFixture<PiBreadcrumbComponent>;
|
||||||
|
|
||||||
|
const items: BreadcrumbItem[] = [
|
||||||
|
{ label: 'Inicio', link: ['/'] },
|
||||||
|
{ label: 'Componentes', link: ['/componentes'] },
|
||||||
|
{ label: 'Avatar' },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PiBreadcrumbComponent, MatIconTestingModule],
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(PiBreadcrumbComponent);
|
||||||
|
fixture.componentRef.setInput('items', items);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a routerLink anchor for every item except the last', () => {
|
||||||
|
const links: HTMLAnchorElement[] =
|
||||||
|
fixture.nativeElement.querySelectorAll('a');
|
||||||
|
|
||||||
|
expect(links.length).toBe(2);
|
||||||
|
expect(links[0].textContent?.trim()).toBe('Inicio');
|
||||||
|
expect(links[1].textContent?.trim()).toBe('Componentes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the last item as plain text marked as the current page', () => {
|
||||||
|
const spans: HTMLSpanElement[] =
|
||||||
|
fixture.nativeElement.querySelectorAll('span');
|
||||||
|
const current = Array.from(spans).find(
|
||||||
|
(span) => span.textContent?.trim() === 'Avatar',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(current).toBeTruthy();
|
||||||
|
expect(current?.getAttribute('aria-current')).toBe('page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders one separator icon fewer than the number of items', () => {
|
||||||
|
const separators = fixture.nativeElement.querySelectorAll('pi-icon');
|
||||||
|
|
||||||
|
expect(separators.length).toBe(items.length - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an item without a link as plain text even mid-list', () => {
|
||||||
|
fixture.componentRef.setInput('items', [
|
||||||
|
{ label: 'Sin enlace' },
|
||||||
|
{ label: 'Actual' },
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('a').length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
HostBinding,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { PiIconComponent } from '../../icon/icon/icon.component';
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
/** Comandos de `routerLink`. Si se omite, se pinta como texto plano
|
||||||
|
* (típicamente el último elemento, la página actual). */
|
||||||
|
link?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-breadcrumb',
|
||||||
|
templateUrl: './breadcrumb.component.html',
|
||||||
|
styleUrls: ['./breadcrumb.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [RouterLink, PiIconComponent],
|
||||||
|
})
|
||||||
|
export class PiBreadcrumbComponent {
|
||||||
|
@HostBinding('class') className = 'pi-breadcrumb';
|
||||||
|
@HostBinding('attr.role') role = 'navigation';
|
||||||
|
@HostBinding('attr.aria-label') ariaLabel = 'Breadcrumb';
|
||||||
|
|
||||||
|
readonly items = input.required<BreadcrumbItem[]>();
|
||||||
|
readonly separatorIcon = input('chevron-forward');
|
||||||
|
}
|
||||||
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './breadcrumb/breadcrumb.component';
|
||||||
@@ -39,7 +39,7 @@ export abstract class PiBaseButtonComponent {
|
|||||||
return 'is-disabled';
|
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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@use '../styles/chart-canvas' as canvas;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include canvas.chartCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include canvas.chartCanvasResponsive;
|
||||||
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal file
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import {
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
Chart,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { barDatasetConfig } from '../chart.configs';
|
||||||
|
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||||
|
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||||
|
|
||||||
|
Chart.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-bar-chart',
|
||||||
|
templateUrl: './bar-chart.component.html',
|
||||||
|
styleUrls: ['./bar-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiChartLegendComponent],
|
||||||
|
})
|
||||||
|
export class PiBarChartComponent extends PiBaseCartesianChartComponent {
|
||||||
|
get completedDatasets() {
|
||||||
|
return this.translatedDatasets().map((dataset) => ({
|
||||||
|
...dataset,
|
||||||
|
...barDatasetConfig,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildChart(): Chart {
|
||||||
|
return new Chart(this.chartRef().nativeElement, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: this.xAxisMarks(),
|
||||||
|
datasets: this.completedDatasets,
|
||||||
|
},
|
||||||
|
options: this.getCartesianOptions('bar'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal file
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
import { ChartData, ChartOptions } from 'chart.js';
|
||||||
|
import { ChartSlice } from '../../util/models';
|
||||||
|
import { arcDatasetConfig, tooltipConfig } from './chart.configs';
|
||||||
|
import { PiBaseChartComponent } from './base-chart.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base de `pi-pie-chart` y `pi-doughnut-chart`: a diferencia de
|
||||||
|
* `PiBaseCartesianChartComponent` (varias series a lo largo de un eje), aquí
|
||||||
|
* hay una sola magnitud repartida en porciones — cada una con su propio
|
||||||
|
* color — así que usan `slices`, no `datasets`, y la leyenda nativa de
|
||||||
|
* Chart.js en vez de `pi-chart-legend`.
|
||||||
|
*/
|
||||||
|
@Component({ template: '' })
|
||||||
|
export abstract class PiBaseArcChartComponent extends PiBaseChartComponent {
|
||||||
|
readonly slices = input.required<Array<ChartSlice>>();
|
||||||
|
|
||||||
|
protected getArcData<T extends 'pie' | 'doughnut'>(): ChartData<T> {
|
||||||
|
const slices = this.slices();
|
||||||
|
return {
|
||||||
|
labels: slices.map((slice) => this.translate.instant(slice.label)),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: slices.map((slice) => slice.value),
|
||||||
|
backgroundColor: slices.map((slice) => slice.color),
|
||||||
|
...arcDatasetConfig,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as ChartData<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getArcOptions<T extends 'pie' | 'doughnut'>(): ChartOptions<T> {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 20,
|
||||||
|
font: {
|
||||||
|
family: 'Soehne',
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...tooltipConfig,
|
||||||
|
callbacks: {
|
||||||
|
label: (context): string => {
|
||||||
|
const label = context.label ?? '';
|
||||||
|
const value = context.parsed as number;
|
||||||
|
const dataset = context.dataset as unknown as {
|
||||||
|
data: number[];
|
||||||
|
};
|
||||||
|
const total = dataset.data.reduce(
|
||||||
|
(sum, current) => sum + current,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const percentage = total
|
||||||
|
? Math.round((value / total) * 100)
|
||||||
|
: 0;
|
||||||
|
return `${label}: ${context.formattedValue} (${percentage}%)`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ChartOptions<T>;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal file
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { Component, input, signal } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ActiveElement,
|
||||||
|
Chart,
|
||||||
|
ChartEvent,
|
||||||
|
ChartOptions,
|
||||||
|
ChartType,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Dataset } from '../../util/models';
|
||||||
|
import { scalesConfig, tooltipConfig } from './chart.configs';
|
||||||
|
import { PiBaseChartComponent } from './base-chart.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base de `pi-line-chart` y `pi-bar-chart`: ambos comparan una o varias
|
||||||
|
* `Dataset` (series) a lo largo de `xAxisMarks`, con la misma leyenda propia
|
||||||
|
* (`pi-chart-legend`, no la nativa de Chart.js) y el mismo tooltip. Lo único
|
||||||
|
* que cambia entre los dos es el `type` de Chart.js y el aspecto del propio
|
||||||
|
* dataset (con o sin puntos) — eso lo aporta cada subclase.
|
||||||
|
*/
|
||||||
|
@Component({ template: '' })
|
||||||
|
export abstract class PiBaseCartesianChartComponent extends PiBaseChartComponent {
|
||||||
|
readonly datasets = input.required<Array<Dataset>>();
|
||||||
|
readonly xAxisMarks = input<Array<string>>();
|
||||||
|
readonly isComparativeChart = input<boolean>(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signal en vez de campo llano: `onHover` lo actualiza desde un callback de
|
||||||
|
* Chart.js (fuera de la zona de Angular), y escribir un signal ya reprograma
|
||||||
|
* la detección de cambios sola, sin `ChangeDetectorRef.detectChanges()`.
|
||||||
|
*/
|
||||||
|
readonly hoveredDatasetIndex = signal(-1);
|
||||||
|
|
||||||
|
/** Etiquetas traducidas; el resto del aspecto lo añade cada subclase. */
|
||||||
|
protected translatedDatasets(): Array<Dataset> {
|
||||||
|
return this.datasets().map((dataset) => ({
|
||||||
|
...dataset,
|
||||||
|
label: this.translate.instant(dataset.label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerHover(datasetIndex: number): void {
|
||||||
|
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
|
||||||
|
this.chart.setActiveElements([]);
|
||||||
|
} else {
|
||||||
|
this.chart.setActiveElements([{ datasetIndex, index: 0 }]);
|
||||||
|
}
|
||||||
|
this.chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
datasetHoveredInLegend(index: number): void {
|
||||||
|
this.hoveredDatasetIndex.set(index);
|
||||||
|
this.triggerHover(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getCartesianOptions(chartType: ChartType): ChartOptions {
|
||||||
|
const isComparativeChart = this.isComparativeChart();
|
||||||
|
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||||
|
hover: {
|
||||||
|
mode: 'dataset',
|
||||||
|
},
|
||||||
|
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
|
||||||
|
this.hoveredDatasetIndex.set(
|
||||||
|
activeElements.length ? activeElements[0].datasetIndex : -1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...tooltipConfig,
|
||||||
|
filter: (tooltipItem): boolean => {
|
||||||
|
if (isComparativeChart) {
|
||||||
|
return (
|
||||||
|
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tooltipItem.element.active;
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
labelTextColor: (context): string =>
|
||||||
|
context.dataset.borderColor as string,
|
||||||
|
label: (context): string => {
|
||||||
|
const datasetLabel = context.dataset.label ?? '';
|
||||||
|
const chartLabel =
|
||||||
|
datasetLabel.length > 12
|
||||||
|
? datasetLabel.slice(0, 12) + '...'
|
||||||
|
: datasetLabel;
|
||||||
|
const dataset = context.dataset as Dataset;
|
||||||
|
if (dataset.unitPosition === 'before') {
|
||||||
|
return (
|
||||||
|
chartLabel + ': ' + dataset.unit + context.formattedValue
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
chartLabel + ': ' + context.formattedValue + dataset.unit
|
||||||
|
);
|
||||||
|
},
|
||||||
|
title: (): string => '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index',
|
||||||
|
},
|
||||||
|
scales: scalesConfig,
|
||||||
|
} as ChartOptions<typeof chartType>;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal file
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Chart } from 'chart.js';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { Device } from '../../util/device-configs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base común a los 4 gráficos (`pi-line-chart`, `pi-bar-chart`,
|
||||||
|
* `pi-pie-chart`, `pi-doughnut-chart`): el `<canvas>`, el ciclo de vida que
|
||||||
|
* construye el `Chart` una vez la vista está lista, y el `TranslateService`
|
||||||
|
* que todos usan para las etiquetas. Cada tipo registra solo los
|
||||||
|
* controladores/elementos de Chart.js que necesita — no hay un registro
|
||||||
|
* global compartido, así que un consumidor que solo importe `pi-pie-chart`
|
||||||
|
* no arrastra el código de líneas/barras.
|
||||||
|
*/
|
||||||
|
@Component({ template: '' })
|
||||||
|
export abstract class PiBaseChartComponent implements AfterViewInit {
|
||||||
|
readonly device = input.required<Device>();
|
||||||
|
|
||||||
|
readonly chartRef = viewChild.required('chart', {
|
||||||
|
read: ElementRef<HTMLCanvasElement>,
|
||||||
|
});
|
||||||
|
|
||||||
|
chart!: Chart;
|
||||||
|
|
||||||
|
protected readonly translate = inject(TranslateService);
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
this.chart = this.buildChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract buildChart(): Chart;
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ export const scalesConfig: ScalesConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const datasetConfig = {
|
export const lineDatasetConfig = {
|
||||||
borderWidth: 1.3,
|
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})`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 100%;
|
|
||||||
max-width: 100%;
|
|
||||||
|
|
||||||
& > div {
|
|
||||||
position: relative;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
&:not(.is-desktop) {
|
|
||||||
:host {
|
|
||||||
& > div {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 40vh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.is-desktop {
|
|
||||||
:host {
|
|
||||||
& > div {
|
|
||||||
& > canvas {
|
|
||||||
aspect-ratio: 1;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import {
|
|
||||||
AfterViewInit,
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
input,
|
|
||||||
viewChild,
|
|
||||||
inject,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { Device } from '../../../util/device-configs';
|
|
||||||
import { Dataset } from '../../../util/models';
|
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
|
||||||
import {
|
|
||||||
BarController,
|
|
||||||
BarElement,
|
|
||||||
CategoryScale,
|
|
||||||
Chart,
|
|
||||||
Legend,
|
|
||||||
LineController,
|
|
||||||
LineElement,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
} from 'chart.js';
|
|
||||||
import { datasetConfig, scalesConfig, tooltipConfig } from '../chart.configs';
|
|
||||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
|
||||||
|
|
||||||
function registerChartComponents() {
|
|
||||||
Chart.register(CategoryScale);
|
|
||||||
Chart.register(LinearScale);
|
|
||||||
Chart.register(BarController);
|
|
||||||
Chart.register(BarElement);
|
|
||||||
Chart.register(LineController);
|
|
||||||
Chart.register(LineElement);
|
|
||||||
Chart.register(PointElement);
|
|
||||||
Chart.register(Tooltip);
|
|
||||||
Chart.register(Legend);
|
|
||||||
Chart.register(Title);
|
|
||||||
}
|
|
||||||
|
|
||||||
registerChartComponents();
|
|
||||||
@Component({
|
|
||||||
selector: 'pi-chart',
|
|
||||||
templateUrl: './chart.component.html',
|
|
||||||
styleUrls: ['./chart.component.scss'],
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [PiChartLegendComponent],
|
|
||||||
})
|
|
||||||
export class PiChartComponent implements AfterViewInit {
|
|
||||||
readonly type = input<'line' | 'bar'>('line');
|
|
||||||
readonly datasets = input.required<Array<Dataset>>();
|
|
||||||
readonly device = input.required<Device>();
|
|
||||||
readonly xAxisMarks = input<Array<string>>();
|
|
||||||
readonly isComparativeChart = input<boolean>(true);
|
|
||||||
|
|
||||||
private _hoveredDatasetIndex = -1;
|
|
||||||
set hoveredDatasetIndex(index: number) {
|
|
||||||
this._hoveredDatasetIndex = index;
|
|
||||||
}
|
|
||||||
get hoveredDatasetIndex(): number {
|
|
||||||
return this._hoveredDatasetIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
readonly chartRef = viewChild.required('chart', {
|
|
||||||
read: ElementRef<HTMLCanvasElement>,
|
|
||||||
});
|
|
||||||
|
|
||||||
chart!: Chart;
|
|
||||||
|
|
||||||
private readonly translate = inject(TranslateService);
|
|
||||||
private readonly cdr = inject(ChangeDetectorRef);
|
|
||||||
|
|
||||||
get completedDatasets() {
|
|
||||||
return this.datasets().map((dataset) => {
|
|
||||||
const datasetWithTranslatedLabels = {
|
|
||||||
...dataset,
|
|
||||||
label: this.translate.instant(dataset.label),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...datasetWithTranslatedLabels,
|
|
||||||
...datasetConfig,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
this.getLineChartConfigs();
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerHover(datasetIndex: number) {
|
|
||||||
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
|
|
||||||
this.chart.setActiveElements([]);
|
|
||||||
} else {
|
|
||||||
this.chart.setActiveElements([
|
|
||||||
{
|
|
||||||
datasetIndex,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
this.chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
getLineChartConfigs(): void {
|
|
||||||
const isComparativeChart = this.isComparativeChart();
|
|
||||||
|
|
||||||
this.chart = new Chart(this.chartRef().nativeElement, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: this.xAxisMarks(),
|
|
||||||
datasets: this.completedDatasets,
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: this.device() === 'DESKTOP' ? true : false,
|
|
||||||
hover: {
|
|
||||||
mode: 'dataset',
|
|
||||||
},
|
|
||||||
onHover: (e: any, activeElements: any) => {
|
|
||||||
this.hoveredDatasetIndex =
|
|
||||||
activeElements.length && activeElements[0].datasetIndex;
|
|
||||||
this.cdr.detectChanges();
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
...tooltipConfig,
|
|
||||||
filter: function (tooltipItem): boolean {
|
|
||||||
if (isComparativeChart) {
|
|
||||||
return (
|
|
||||||
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return tooltipItem.element.active;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
callbacks: {
|
|
||||||
labelTextColor: function (context): string {
|
|
||||||
return context.dataset.borderColor as string;
|
|
||||||
},
|
|
||||||
label: function (context) {
|
|
||||||
const datasetLabel = context.dataset.label ?? '';
|
|
||||||
const chartLabel =
|
|
||||||
datasetLabel.length > 12
|
|
||||||
? datasetLabel.slice(0, 12) + '...'
|
|
||||||
: datasetLabel;
|
|
||||||
let label: string;
|
|
||||||
if ((context.dataset as any).unitPosition === 'before') {
|
|
||||||
label =
|
|
||||||
chartLabel +
|
|
||||||
': ' +
|
|
||||||
(context.dataset as any).unit +
|
|
||||||
context.formattedValue;
|
|
||||||
} else {
|
|
||||||
label =
|
|
||||||
chartLabel +
|
|
||||||
': ' +
|
|
||||||
context.formattedValue +
|
|
||||||
(context.dataset as any).unit;
|
|
||||||
}
|
|
||||||
return label;
|
|
||||||
},
|
|
||||||
title: function () {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
intersect: false,
|
|
||||||
mode: 'index',
|
|
||||||
},
|
|
||||||
scales: scalesConfig,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
datasetHoveredInLegend(index: number): void {
|
|
||||||
this.hoveredDatasetIndex = index;
|
|
||||||
this.triggerHover(this.hoveredDatasetIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div>
|
||||||
|
<canvas #chart aria-label="Chart"></canvas>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@use '../styles/chart-canvas' as canvas;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include canvas.chartCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include canvas.chartCanvasResponsive;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ArcElement,
|
||||||
|
Chart,
|
||||||
|
DoughnutController,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
|
||||||
|
|
||||||
|
Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-doughnut-chart',
|
||||||
|
templateUrl: './doughnut-chart.component.html',
|
||||||
|
styleUrls: ['./doughnut-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PiDoughnutChartComponent extends PiBaseArcChartComponent {
|
||||||
|
protected buildChart(): Chart {
|
||||||
|
return new Chart(this.chartRef().nativeElement, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: this.getArcData<'doughnut'>(),
|
||||||
|
options: this.getArcOptions<'doughnut'>(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<div>
|
||||||
|
<canvas #chart aria-label="Gauge"></canvas>
|
||||||
|
<div class="pi-gauge-chart__value">
|
||||||
|
<span>
|
||||||
|
<strong>{{ clampedValue() }}</strong><small>/{{ max() }}</small>
|
||||||
|
</span>
|
||||||
|
@if (label()) {
|
||||||
|
<span class="pi-gauge-chart__label">{{ label() }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@use '../styles/chart-canvas' as canvas;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include canvas.chartCanvas;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pi-gauge-chart__value {
|
||||||
|
position: absolute;
|
||||||
|
top: 68%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-size: var(--pi-font-size-xx-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: var(--pi-font-size-small);
|
||||||
|
color: var(--pi-color-grey-300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pi-gauge-chart__label {
|
||||||
|
margin-top: var(--pi-spacing-xxxx-small);
|
||||||
|
font-size: var(--pi-font-size-x-small);
|
||||||
|
color: var(--pi-color-grey-300);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ArcElement, Chart, DoughnutController } from 'chart.js';
|
||||||
|
import { DatasetColor } from '../../../util/models';
|
||||||
|
import { PiBaseChartComponent } from '../base-chart.component';
|
||||||
|
|
||||||
|
Chart.register(ArcElement, DoughnutController);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un único valor sobre `max`, no una serie ni porciones — por eso extiende
|
||||||
|
* directamente `PiBaseChartComponent` (la base raíz) en vez de
|
||||||
|
* `PiBaseCartesianChartComponent`/`PiBaseArcChartComponent`, pensadas para
|
||||||
|
* varios datasets o varias porciones. El texto central es HTML superpuesto,
|
||||||
|
* no un plugin de Chart.js: mismo criterio que `pi-line-chart` con
|
||||||
|
* `hexToRgba` — evitar un plugin nuevo cuando una superposición CSS basta.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-gauge-chart',
|
||||||
|
templateUrl: './gauge-chart.component.html',
|
||||||
|
styleUrls: ['./gauge-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PiGaugeChartComponent extends PiBaseChartComponent {
|
||||||
|
readonly value = input.required<number>();
|
||||||
|
readonly max = input(100);
|
||||||
|
readonly color = input<DatasetColor>(DatasetColor.primary);
|
||||||
|
/** Texto bajo el valor central (p. ej. "de satisfacción"). */
|
||||||
|
readonly label = input<string>();
|
||||||
|
|
||||||
|
protected readonly clampedValue = computed(() =>
|
||||||
|
Math.min(Math.max(this.value(), 0), this.max()),
|
||||||
|
);
|
||||||
|
|
||||||
|
protected buildChart(): Chart {
|
||||||
|
const clamped = this.clampedValue();
|
||||||
|
return new Chart(this.chartRef().nativeElement, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [clamped, this.max() - clamped],
|
||||||
|
backgroundColor: [this.color(), DatasetColor.grey75],
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||||
|
circumference: 180,
|
||||||
|
rotation: -90,
|
||||||
|
cutout: '75%',
|
||||||
|
events: [],
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
export * from './chart/chart.component';
|
export * from './base-arc-chart.component';
|
||||||
|
export * from './base-cartesian-chart.component';
|
||||||
|
export * from './base-chart.component';
|
||||||
|
|
||||||
export * from './chart.configs';
|
export * from './chart.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';
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<div>
|
||||||
|
<canvas #chart aria-label="Chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<pi-chart-legend
|
||||||
|
[datasets]="datasets()"
|
||||||
|
[isComparativeChart]="isComparativeChart()"
|
||||||
|
[hoveredDatasetIndex]="hoveredDatasetIndex()"
|
||||||
|
(triggerDatasetHover)="datasetHoveredInLegend($event)"
|
||||||
|
></pi-chart-legend>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@use '../styles/chart-canvas' as canvas;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include canvas.chartCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include canvas.chartCanvasResponsive;
|
||||||
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal file
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||||
|
import {
|
||||||
|
CategoryScale,
|
||||||
|
Chart,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { hexToRgba, lineDatasetConfig } from '../chart.configs';
|
||||||
|
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||||
|
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||||
|
|
||||||
|
Chart.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-line-chart',
|
||||||
|
templateUrl: './line-chart.component.html',
|
||||||
|
styleUrls: ['./line-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiChartLegendComponent],
|
||||||
|
})
|
||||||
|
export class PiLineChartComponent extends PiBaseCartesianChartComponent {
|
||||||
|
/** Rellena el área bajo la línea (gráfico de área) en vez de solo trazarla. */
|
||||||
|
readonly filled = input(false);
|
||||||
|
|
||||||
|
get completedDatasets() {
|
||||||
|
const datasets = this.translatedDatasets().map((dataset) => ({
|
||||||
|
...dataset,
|
||||||
|
...lineDatasetConfig,
|
||||||
|
}));
|
||||||
|
if (!this.filled()) {
|
||||||
|
return datasets;
|
||||||
|
}
|
||||||
|
// Área suave en vez de un bloque sólido: la misma marca del dataset,
|
||||||
|
// pero translúcida. `pointBackgroundColor` conserva el color opaco
|
||||||
|
// original para que los puntos no salgan huecos por reusar
|
||||||
|
// `backgroundColor` para el área.
|
||||||
|
return datasets.map((dataset) => {
|
||||||
|
const solidColor = (dataset.backgroundColor ??
|
||||||
|
dataset.borderColor) as string;
|
||||||
|
return {
|
||||||
|
...dataset,
|
||||||
|
fill: true,
|
||||||
|
backgroundColor: hexToRgba(solidColor, 0.15),
|
||||||
|
pointBackgroundColor: solidColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildChart(): Chart {
|
||||||
|
return new Chart(this.chartRef().nativeElement, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: this.xAxisMarks(),
|
||||||
|
datasets: this.completedDatasets,
|
||||||
|
},
|
||||||
|
options: this.getCartesianOptions('line'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<div>
|
||||||
|
<canvas #chart aria-label="Chart"></canvas>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@use '../styles/chart-canvas' as canvas;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include canvas.chartCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include canvas.chartCanvasResponsive;
|
||||||
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal file
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import { ArcElement, Chart, Legend, PieController, Tooltip } from 'chart.js';
|
||||||
|
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
|
||||||
|
|
||||||
|
Chart.register(ArcElement, PieController, Tooltip, Legend);
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-pie-chart',
|
||||||
|
templateUrl: './pie-chart.component.html',
|
||||||
|
styleUrls: ['./pie-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PiPieChartComponent extends PiBaseArcChartComponent {
|
||||||
|
protected buildChart(): Chart {
|
||||||
|
return new Chart(this.chartRef().nativeElement, {
|
||||||
|
type: 'pie',
|
||||||
|
data: this.getArcData<'pie'>(),
|
||||||
|
options: this.getArcOptions<'pie'>(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<div>
|
||||||
|
<canvas #chart aria-label="Chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<pi-chart-legend
|
||||||
|
[datasets]="datasets()"
|
||||||
|
[isComparativeChart]="isComparativeChart()"
|
||||||
|
[hoveredDatasetIndex]="hoveredDatasetIndex()"
|
||||||
|
(triggerDatasetHover)="datasetHoveredInLegend($event)"
|
||||||
|
></pi-chart-legend>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@use '../styles/chart-canvas' as canvas;
|
||||||
|
|
||||||
|
:host {
|
||||||
|
@include canvas.chartCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include canvas.chartCanvasResponsive;
|
||||||
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal file
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
|
import {
|
||||||
|
ActiveElement,
|
||||||
|
Chart,
|
||||||
|
ChartEvent,
|
||||||
|
ChartOptions,
|
||||||
|
Filler,
|
||||||
|
Legend,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
RadarController,
|
||||||
|
RadialLinearScale,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Dataset, DatasetColor } from '../../../util/models';
|
||||||
|
import { hexToRgba, lineDatasetConfig, tooltipConfig } from '../chart.configs';
|
||||||
|
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||||
|
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||||
|
|
||||||
|
Chart.register(
|
||||||
|
RadarController,
|
||||||
|
RadialLinearScale,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Filler,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A diferencia de `pi-line-chart`/`pi-bar-chart`, la escala radial (`r`) no
|
||||||
|
* encaja en `scalesConfig` (pensado para `x`/`y`), así que no reutiliza
|
||||||
|
* `getCartesianOptions` de `PiBaseCartesianChartComponent` — sí reutiliza el
|
||||||
|
* resto (hover por dataset, leyenda propia, tooltip).
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-radar-chart',
|
||||||
|
templateUrl: './radar-chart.component.html',
|
||||||
|
styleUrls: ['./radar-chart.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiChartLegendComponent],
|
||||||
|
})
|
||||||
|
export class PiRadarChartComponent extends PiBaseCartesianChartComponent {
|
||||||
|
/** Igual que `pi-line-chart[filled]`: relleno translúcido bajo cada polígono. */
|
||||||
|
protected get completedDatasets() {
|
||||||
|
return this.translatedDatasets().map((dataset) => {
|
||||||
|
const solidColor = (dataset.backgroundColor ??
|
||||||
|
dataset.borderColor) as string;
|
||||||
|
return {
|
||||||
|
...dataset,
|
||||||
|
...lineDatasetConfig,
|
||||||
|
fill: true,
|
||||||
|
backgroundColor: hexToRgba(solidColor, 0.15),
|
||||||
|
pointBackgroundColor: solidColor,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRadarOptions(): ChartOptions<'radar'> {
|
||||||
|
return {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||||
|
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
|
||||||
|
this.hoveredDatasetIndex.set(
|
||||||
|
activeElements.length ? activeElements[0].datasetIndex : -1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
...tooltipConfig,
|
||||||
|
callbacks: {
|
||||||
|
labelTextColor: (context): string =>
|
||||||
|
context.dataset.borderColor as string,
|
||||||
|
label: (context): string => {
|
||||||
|
const dataset = context.dataset as Dataset;
|
||||||
|
return `${dataset.label}: ${context.formattedValue}${dataset.unit ?? ''}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
r: {
|
||||||
|
grid: { color: DatasetColor.grey50 },
|
||||||
|
angleLines: { color: DatasetColor.grey75 },
|
||||||
|
pointLabels: {
|
||||||
|
font: { family: 'Soehne', size: 12 },
|
||||||
|
color: DatasetColor.grey300,
|
||||||
|
},
|
||||||
|
ticks: { display: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as ChartOptions<'radar'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected buildChart(): Chart {
|
||||||
|
return new Chart(this.chartRef().nativeElement, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: this.xAxisMarks(),
|
||||||
|
datasets: this.completedDatasets,
|
||||||
|
},
|
||||||
|
options: this.getRadarOptions(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal file
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Tamaño del lienzo, compartido por los 4 gráficos (line/bar/pie/doughnut).
|
||||||
|
* Cada componente lo incluye con `@include chartCanvas;` en su propio
|
||||||
|
* `:host` — así siguen siendo ficheros independientes sin repetir el CSS.
|
||||||
|
*/
|
||||||
|
@mixin chartCanvas {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin chartCanvasResponsive {
|
||||||
|
html {
|
||||||
|
&:not(.is-desktop) {
|
||||||
|
:host {
|
||||||
|
& > div {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-desktop {
|
||||||
|
:host {
|
||||||
|
& > div {
|
||||||
|
& > canvas {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export class PiDataTableItemComponent implements AfterViewInit {
|
|||||||
readonly justify = input<justify>('flex-start');
|
readonly 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 {
|
||||||
|
|||||||
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Component, TemplateRef, ViewChild } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { DrawerConfigKeys } from '../entities/drawer-configs';
|
||||||
|
import { getDrawerConfigs } from '../utils';
|
||||||
|
import { PiDrawerComponent } from './drawer.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `<ng-template #content><p class="drawer-content">Hola</p></ng-template>`,
|
||||||
|
})
|
||||||
|
class HostComponent {
|
||||||
|
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PiDrawerComponent', () => {
|
||||||
|
let hostFixture: ComponentFixture<HostComponent>;
|
||||||
|
let dialog: MatDialog;
|
||||||
|
let overlayContainerElement: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [HostComponent],
|
||||||
|
providers: [provideTranslateService()],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
hostFixture = TestBed.createComponent(HostComponent);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
dialog = TestBed.inject(MatDialog);
|
||||||
|
overlayContainerElement = TestBed.inject(
|
||||||
|
OverlayContainer,
|
||||||
|
).getContainerElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dialog.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the title and the projected template content', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiDrawerComponent,
|
||||||
|
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||||
|
title: 'Detalle',
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
expect(overlayContainerElement.textContent).toContain('Detalle');
|
||||||
|
expect(overlayContainerElement.querySelector('.drawer-content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the right-side panel class for DrawerConfigKeys.Right', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiDrawerComponent,
|
||||||
|
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||||
|
expect(pane?.classList.contains('pi-drawer__panel--right')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the left-side panel class for DrawerConfigKeys.Left', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiDrawerComponent,
|
||||||
|
getDrawerConfigs(DrawerConfigKeys.Left, {
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||||
|
expect(pane?.classList.contains('pi-drawer__panel--left')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the title header when none is provided', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiDrawerComponent,
|
||||||
|
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
expect(overlayContainerElement.querySelector('header span')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the dialog when the close button is clicked', fakeAsync(() => {
|
||||||
|
const dialogRef = dialog.open(
|
||||||
|
PiDrawerComponent,
|
||||||
|
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||||
|
title: 'Detalle',
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
const closeSpy = jest.fn();
|
||||||
|
dialogRef.afterClosed().subscribe(closeSpy);
|
||||||
|
|
||||||
|
const closeButton: HTMLElement | null =
|
||||||
|
overlayContainerElement.querySelector('pi-close-button label');
|
||||||
|
closeButton?.click();
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
tick(500);
|
||||||
|
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
});
|
||||||
@@ -2,5 +2,5 @@ import { TemplateRef } from '@angular/core';
|
|||||||
|
|
||||||
export interface DrawerData {
|
export interface DrawerData {
|
||||||
title?: string;
|
title?: string;
|
||||||
template: TemplateRef<any>;
|
template: TemplateRef<unknown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<pi-icon [icon]="icon()" size="x-large" color="grey" />
|
||||||
|
<h4>{{ title() }}</h4>
|
||||||
|
@if (description()) {
|
||||||
|
<p>{{ description() }}</p>
|
||||||
|
}
|
||||||
|
<ng-content />
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: var(--pi-spacing-xx-small);
|
||||||
|
padding: var(--pi-spacing-xx-large) var(--pi-spacing-large);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: var(--pi-spacing-xx-small) 0 0;
|
||||||
|
color: var(--pi-color-black);
|
||||||
|
font-size: var(--pi-font-size-small);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--pi-color-grey);
|
||||||
|
font-size: var(--pi-font-size-x-small);
|
||||||
|
max-width: 30ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||||
|
|
||||||
|
import { PiEmptyStateComponent } from './empty-state.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<pi-empty-state [title]="title" [description]="description">
|
||||||
|
<button class="projected-action">Limpiar filtros</button>
|
||||||
|
</pi-empty-state>
|
||||||
|
`,
|
||||||
|
imports: [PiEmptyStateComponent],
|
||||||
|
})
|
||||||
|
class HostComponent {
|
||||||
|
title = 'Sin resultados';
|
||||||
|
description?: string = 'No hay elementos que coincidan con el filtro.';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PiEmptyStateComponent', () => {
|
||||||
|
let fixture: ComponentFixture<HostComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [HostComponent, MatIconTestingModule],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(HostComponent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the title and description', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('h4')?.textContent?.trim()).toBe(
|
||||||
|
'Sin resultados',
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.querySelector('p')?.textContent?.trim()).toBe(
|
||||||
|
'No hay elementos que coincidan con el filtro.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the description paragraph when none is provided', () => {
|
||||||
|
fixture.componentInstance.description = undefined;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('p')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the projected action', () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.projected-action')?.textContent?.trim(),
|
||||||
|
).toBe('Limpiar filtros');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
HostBinding,
|
||||||
|
input,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { PiIconComponent } from '../../icon/icon/icon.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder para listas/tablas/data-grid sin resultados. El icono, título y
|
||||||
|
* descripción son fijos; la acción (p. ej. "Limpiar filtros") se proyecta.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-empty-state',
|
||||||
|
templateUrl: './empty-state.component.html',
|
||||||
|
styleUrls: ['./empty-state.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [PiIconComponent],
|
||||||
|
})
|
||||||
|
export class PiEmptyStateComponent {
|
||||||
|
@HostBinding('class') className = 'pi-empty-state';
|
||||||
|
|
||||||
|
readonly icon = input('search');
|
||||||
|
readonly title = input.required<string>();
|
||||||
|
readonly description = input<string>();
|
||||||
|
}
|
||||||
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './empty-state/empty-state.component';
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<mat-form-field [appearance]="appearance()" floatLabel="always">
|
||||||
|
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
[attr.aria-expanded]="isOpen()"
|
||||||
|
[attr.aria-controls]="listboxId"
|
||||||
|
[attr.aria-activedescendant]="
|
||||||
|
activeIndex() > -1 ? listboxId + '-option-' + activeIndex() : null
|
||||||
|
"
|
||||||
|
[value]="value()"
|
||||||
|
(input)="onInput($any($event.target).value)"
|
||||||
|
(keydown)="onKeydown($event)"
|
||||||
|
(focus)="onFocus()"
|
||||||
|
(blur)="onBlur()"
|
||||||
|
[placeholder]="placeholder()!"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (enableErrorMessage() && hasError()) {
|
||||||
|
<mat-error>
|
||||||
|
@for (message of errorMessages(); track message) {
|
||||||
|
{{ message | translate }}
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (isOpen()) {
|
||||||
|
<ul class="pi-autocomplete-field__panel" [id]="listboxId" role="listbox">
|
||||||
|
@for (option of filteredOptions(); track option; let i = $index) {
|
||||||
|
<li
|
||||||
|
[id]="listboxId + '-option-' + i"
|
||||||
|
role="option"
|
||||||
|
tabindex="-1"
|
||||||
|
[attr.aria-selected]="i === activeIndex()"
|
||||||
|
[class.is-active]="i === activeIndex()"
|
||||||
|
(mousedown)="$event.preventDefault()"
|
||||||
|
(click)="selectOption(option)"
|
||||||
|
(keydown.enter)="selectOption(option)"
|
||||||
|
>
|
||||||
|
{{ option }}
|
||||||
|
</li>
|
||||||
|
} @empty {
|
||||||
|
<li class="pi-autocomplete-field__empty">{{ noResultsLabel() | translate }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pi-autocomplete-field__panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
margin: var(--pi-spacing-xxxx-small) 0 0;
|
||||||
|
padding: var(--pi-spacing-xxxx-small) 0;
|
||||||
|
max-height: 14rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--pi-color-white);
|
||||||
|
border-radius: var(--pi-border-radius-medium);
|
||||||
|
box-shadow: var(--pi-box-shadow-panel);
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: var(--pi-spacing-small) var(--pi-spacing-medium);
|
||||||
|
font-size: var(--pi-font-size-small);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.is-active {
|
||||||
|
background: var(--pi-color-primary-25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pi-autocomplete-field__empty {
|
||||||
|
color: var(--pi-color-grey-300);
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
HostBinding,
|
||||||
|
HostListener,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatFormField,
|
||||||
|
MatLabel,
|
||||||
|
MatError,
|
||||||
|
} from '@angular/material/form-field';
|
||||||
|
import { MatInput } from '@angular/material/input';
|
||||||
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { PiBaseFormFieldComponent } from '../base-form-field.component';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Campo de texto con sugerencias filtradas mientras se escribe. El menú es
|
||||||
|
* un `<ul>` propio con `position: absolute` (mismo criterio que el menú de
|
||||||
|
* `pi-select`) en vez de `mat-autocomplete`/CDK Overlay: no hace falta
|
||||||
|
* escapar contenedores con `overflow: hidden` para este caso de uso.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-autocomplete-field',
|
||||||
|
templateUrl: './autocomplete-field.component.html',
|
||||||
|
styleUrls: ['./autocomplete-field.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [MatFormField, MatLabel, MatInput, MatError, TranslatePipe],
|
||||||
|
})
|
||||||
|
export class PiAutocompleteFieldComponent extends PiBaseFormFieldComponent<string> {
|
||||||
|
@HostBinding('class') className = 'pi-form-field__autocomplete';
|
||||||
|
|
||||||
|
private static nextId = 0;
|
||||||
|
|
||||||
|
readonly options = input.required<readonly string[]>();
|
||||||
|
readonly noResultsLabel = input('Sin resultados');
|
||||||
|
|
||||||
|
protected readonly listboxId = `pi-autocomplete-listbox-${PiAutocompleteFieldComponent.nextId++}`;
|
||||||
|
|
||||||
|
protected readonly isOpen = signal(false);
|
||||||
|
protected readonly activeIndex = signal(-1);
|
||||||
|
|
||||||
|
private readonly el = inject(ElementRef);
|
||||||
|
|
||||||
|
protected readonly filteredOptions = computed(() => {
|
||||||
|
const query = this.value().trim().toLowerCase();
|
||||||
|
if (!query) return this.options();
|
||||||
|
return this.options().filter((option) =>
|
||||||
|
option.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocumentClick(event: MouseEvent): void {
|
||||||
|
if (!this.el.nativeElement.contains(event.target)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onInput(text: string): void {
|
||||||
|
this.value.set(text);
|
||||||
|
this.activeIndex.set(-1);
|
||||||
|
this.isOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (
|
||||||
|
!this.isOpen() &&
|
||||||
|
(event.key === 'ArrowDown' || event.key === 'ArrowUp')
|
||||||
|
) {
|
||||||
|
this.isOpen.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = this.filteredOptions();
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
this.activeIndex.update((index) =>
|
||||||
|
Math.min(index + 1, options.length - 1),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
this.activeIndex.update((index) => Math.max(index - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter': {
|
||||||
|
const active = options[this.activeIndex()];
|
||||||
|
if (active) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.selectOption(active);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Escape':
|
||||||
|
this.close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected selectOption(option: string): void {
|
||||||
|
this.value.set(option);
|
||||||
|
this.touch.emit();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
override onFocus(): void {
|
||||||
|
super.onFocus();
|
||||||
|
this.isOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
override onBlur(): void {
|
||||||
|
super.onBlur();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private close(): void {
|
||||||
|
this.isOpen.set(false);
|
||||||
|
this.activeIndex.set(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<mat-form-field [appearance]="appearance()" floatLabel="always">
|
||||||
|
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
|
||||||
|
|
||||||
|
<mat-date-range-input
|
||||||
|
[rangePicker]="picker"
|
||||||
|
[min]="min() ?? null"
|
||||||
|
[max]="max() ?? null"
|
||||||
|
[disabled]="disabled()"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
matStartDate
|
||||||
|
[value]="value().start"
|
||||||
|
[placeholder]="startPlaceholder() | translate"
|
||||||
|
(dateChange)="onStartChange($any($event).value)"
|
||||||
|
(focus)="onFocus()"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
matEndDate
|
||||||
|
[value]="value().end"
|
||||||
|
[placeholder]="endPlaceholder() | translate"
|
||||||
|
(dateChange)="onEndChange($any($event).value)"
|
||||||
|
(blur)="onBlur()"
|
||||||
|
/>
|
||||||
|
</mat-date-range-input>
|
||||||
|
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="picker" />
|
||||||
|
<mat-date-range-picker #picker />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (enableErrorMessage() && hasError()) {
|
||||||
|
<mat-error>
|
||||||
|
@for (message of errorMessages(); track message) {
|
||||||
|
{{ message | translate }}
|
||||||
|
}
|
||||||
|
</mat-error>
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, HostBinding, input } from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatFormField,
|
||||||
|
MatLabel,
|
||||||
|
MatSuffix,
|
||||||
|
MatError,
|
||||||
|
} from '@angular/material/form-field';
|
||||||
|
import {
|
||||||
|
MatDateRangeInput,
|
||||||
|
MatStartDate,
|
||||||
|
MatEndDate,
|
||||||
|
MatDateRangePicker,
|
||||||
|
MatDatepickerToggle,
|
||||||
|
} from '@angular/material/datepicker';
|
||||||
|
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||||
|
import { TranslatePipe } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { PiBaseFormFieldComponent } from '../base-form-field.component';
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
start: Date | null;
|
||||||
|
end: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rango de fechas, hermano de `pi-date-field`: mismo `provideNativeDateAdapter`
|
||||||
|
* (el proyecto no tiene adaptador de luxon/moment) y misma herencia de
|
||||||
|
* `PiBaseFormFieldComponent`, pero con `value` como `{ start, end }` en vez de
|
||||||
|
* `Date | null` — `mat-date-range-input` necesita dos `<input>` (`matStartDate`
|
||||||
|
* / `matEndDate`), no uno.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-date-range-field',
|
||||||
|
templateUrl: './date-range-field.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
providers: [provideNativeDateAdapter()],
|
||||||
|
imports: [
|
||||||
|
MatFormField,
|
||||||
|
MatLabel,
|
||||||
|
MatSuffix,
|
||||||
|
MatDateRangeInput,
|
||||||
|
MatStartDate,
|
||||||
|
MatEndDate,
|
||||||
|
MatDateRangePicker,
|
||||||
|
MatDatepickerToggle,
|
||||||
|
MatError,
|
||||||
|
TranslatePipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PiDateRangeFieldComponent extends PiBaseFormFieldComponent<DateRange> {
|
||||||
|
@HostBinding('class') className = 'pi-form-field__date-range';
|
||||||
|
|
||||||
|
readonly min = input<Date | undefined>(undefined);
|
||||||
|
readonly max = input<Date | undefined>(undefined);
|
||||||
|
|
||||||
|
readonly startPlaceholder = input('Desde');
|
||||||
|
readonly endPlaceholder = input('Hasta');
|
||||||
|
|
||||||
|
protected onStartChange(start: Date | null): void {
|
||||||
|
this.value.set({ ...this.value(), start });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onEndChange(end: Date | null): void {
|
||||||
|
this.value.set({ ...this.value(), end });
|
||||||
|
this.touch.emit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './date-field/date-field.component';
|
export * from './date-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';
|
||||||
|
|||||||
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal file
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { MatDialogConfig } from '@angular/material/dialog';
|
||||||
|
|
||||||
|
export enum HubConfigKeys {
|
||||||
|
Default = 'default',
|
||||||
|
Full = 'full',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HubConfigs = {
|
||||||
|
[key in HubConfigKeys]: MatDialogConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `enterAnimationDuration`/`exitAnimationDuration` a `0ms`: igual que
|
||||||
|
* `pi-drawer`, la animación de `MatDialog` (fade + scale desde el centro) no
|
||||||
|
* encaja con un panel anclado a un borde. El deslizamiento real lo hace
|
||||||
|
* `_mat-hub.scss` sobre `panelClass`, fuera del ciclo de animación de
|
||||||
|
* Material. Solo se fija `position.bottom`: al dejar `left`/`right` sin
|
||||||
|
* definir, el propio `cdk-global-overlay-wrapper` centra el panel en
|
||||||
|
* horizontal por defecto.
|
||||||
|
*/
|
||||||
|
export const hubConfigs: HubConfigs = {
|
||||||
|
[HubConfigKeys.Default]: {
|
||||||
|
width: '100vw',
|
||||||
|
maxWidth: '34rem',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
position: { bottom: '0' },
|
||||||
|
backdropClass: 'pi-backdrop',
|
||||||
|
panelClass: ['pi-hub__panel'],
|
||||||
|
enterAnimationDuration: '0ms',
|
||||||
|
exitAnimationDuration: '0ms',
|
||||||
|
},
|
||||||
|
[HubConfigKeys.Full]: {
|
||||||
|
width: '100vw',
|
||||||
|
maxWidth: '34rem',
|
||||||
|
height: '92vh',
|
||||||
|
maxHeight: '92vh',
|
||||||
|
position: { bottom: '0' },
|
||||||
|
backdropClass: 'pi-backdrop',
|
||||||
|
panelClass: ['pi-hub__panel', 'pi-hub__panel--full'],
|
||||||
|
enterAnimationDuration: '0ms',
|
||||||
|
exitAnimationDuration: '0ms',
|
||||||
|
},
|
||||||
|
};
|
||||||
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { TemplateRef } from '@angular/core';
|
||||||
|
|
||||||
|
export interface HubData {
|
||||||
|
title?: string;
|
||||||
|
template: TemplateRef<unknown>;
|
||||||
|
}
|
||||||
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal file
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="pi-hub__grabber" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
@if (data.title) {
|
||||||
|
<span bold l>{{ data.title }}</span>
|
||||||
|
}
|
||||||
|
<pi-close-button size="small" [hasLabel]="false" (click)="close()" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<ng-container [ngTemplateOutlet]="data.template" />
|
||||||
|
</main>
|
||||||
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal file
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.pi-hub__grabber {
|
||||||
|
flex: none;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 0.25rem;
|
||||||
|
margin: var(--pi-spacing-small) auto 0;
|
||||||
|
border-radius: var(--pi-border-radius-x-small);
|
||||||
|
background-color: var(--pi-color-grey-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--pi-spacing-medium);
|
||||||
|
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-large);
|
||||||
|
border-bottom: 0.0625rem solid var(--pi-color-grey-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--pi-spacing-x-large);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Component, TemplateRef, ViewChild } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { HubConfigKeys } from '../entities/hub-configs';
|
||||||
|
import { getHubConfigs } from '../utils';
|
||||||
|
import { PiHubComponent } from './hub.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `<ng-template #content><p class="hub-content">Hola</p></ng-template>`,
|
||||||
|
})
|
||||||
|
class HostComponent {
|
||||||
|
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PiHubComponent', () => {
|
||||||
|
let hostFixture: ComponentFixture<HostComponent>;
|
||||||
|
let dialog: MatDialog;
|
||||||
|
let overlayContainerElement: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [HostComponent],
|
||||||
|
providers: [provideTranslateService()],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
hostFixture = TestBed.createComponent(HostComponent);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
dialog = TestBed.inject(MatDialog);
|
||||||
|
overlayContainerElement = TestBed.inject(
|
||||||
|
OverlayContainer,
|
||||||
|
).getContainerElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dialog.closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the title and the projected template content', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiHubComponent,
|
||||||
|
getHubConfigs(HubConfigKeys.Default, {
|
||||||
|
title: 'Detalle',
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
expect(overlayContainerElement.textContent).toContain('Detalle');
|
||||||
|
expect(overlayContainerElement.querySelector('.hub-content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the panel class for HubConfigKeys.Default', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiHubComponent,
|
||||||
|
getHubConfigs(HubConfigKeys.Default, {
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||||
|
expect(pane?.classList.contains('pi-hub__panel')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the full panel class for HubConfigKeys.Full', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiHubComponent,
|
||||||
|
getHubConfigs(HubConfigKeys.Full, {
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||||
|
expect(pane?.classList.contains('pi-hub__panel--full')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the title header when none is provided', () => {
|
||||||
|
dialog.open(
|
||||||
|
PiHubComponent,
|
||||||
|
getHubConfigs(HubConfigKeys.Default, {
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
expect(overlayContainerElement.querySelector('header span')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the dialog when the close button is clicked', fakeAsync(() => {
|
||||||
|
const dialogRef = dialog.open(
|
||||||
|
PiHubComponent,
|
||||||
|
getHubConfigs(HubConfigKeys.Default, {
|
||||||
|
title: 'Detalle',
|
||||||
|
template: hostFixture.componentInstance.content,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
|
||||||
|
const closeSpy = jest.fn();
|
||||||
|
dialogRef.afterClosed().subscribe(closeSpy);
|
||||||
|
|
||||||
|
const closeButton: HTMLElement | null =
|
||||||
|
overlayContainerElement.querySelector('pi-close-button label');
|
||||||
|
closeButton?.click();
|
||||||
|
hostFixture.detectChanges();
|
||||||
|
tick(500);
|
||||||
|
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
});
|
||||||
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal file
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
|
|
||||||
|
import { HubData } from '../entities/hub.model';
|
||||||
|
import { PiCloseButtonComponent } from '../../button/close-button/close-button.component';
|
||||||
|
import { PiTextWeightBoldDirective } from '../../text/directives/text-weights.directive';
|
||||||
|
import { PiTextLargeDirective } from '../../text/directives/text-sizes.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pi-hub',
|
||||||
|
templateUrl: './hub.component.html',
|
||||||
|
styleUrls: ['./hub.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
NgTemplateOutlet,
|
||||||
|
PiCloseButtonComponent,
|
||||||
|
PiTextWeightBoldDirective,
|
||||||
|
PiTextLargeDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class PiHubComponent {
|
||||||
|
@HostBinding('class') className = 'pi-hub';
|
||||||
|
|
||||||
|
readonly dialogRef = inject<MatDialogRef<PiHubComponent>>(MatDialogRef);
|
||||||
|
readonly data = inject<HubData>(MAT_DIALOG_DATA);
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
libs/ui/src/lib/ui/hub/index.ts
Normal file
9
libs/ui/src/lib/ui/hub/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Component
|
||||||
|
export * from './hub/hub.component';
|
||||||
|
|
||||||
|
// Entities
|
||||||
|
export * from './entities/hub.model';
|
||||||
|
export * from './entities/hub-configs';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export * from './utils';
|
||||||
16
libs/ui/src/lib/ui/hub/utils.ts
Normal file
16
libs/ui/src/lib/ui/hub/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { MatDialogConfig } from '@angular/material/dialog';
|
||||||
|
import { HubConfigKeys, hubConfigs } from './entities/hub-configs';
|
||||||
|
import { HubData } from './entities/hub.model';
|
||||||
|
|
||||||
|
// Hub config
|
||||||
|
export function getHubConfigs(
|
||||||
|
key: HubConfigKeys,
|
||||||
|
data: HubData,
|
||||||
|
disableClose?: boolean,
|
||||||
|
): MatDialogConfig<HubData> {
|
||||||
|
return {
|
||||||
|
...(disableClose && { disableClose }),
|
||||||
|
...hubConfigs[key],
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
@if (type() === 'custom') {
|
@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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user