new components
This commit is contained in:
@@ -32,6 +32,7 @@ const TRANSLATIONS: TranslationObject = {
|
||||
reset: 'Borrar',
|
||||
search: 'Buscar…',
|
||||
noResults: 'Sin resultados',
|
||||
legend: 'Leyenda',
|
||||
validator: {
|
||||
requiredField: 'Este campo es obligatorio',
|
||||
onlyNumbers: 'Sólo se admiten números',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<pi-loader-bar />
|
||||
|
||||
<header class="page-header">
|
||||
<div class="page-header__top">
|
||||
<div>
|
||||
|
||||
@@ -14,5 +14,27 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
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' },
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@angular/core';
|
||||
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
|
||||
@@ -19,12 +19,21 @@ import { PiSlideToggleComponent, ShellClass } from '@pi/ui';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, PiSlideToggleComponent],
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
PiSlideToggleComponent,
|
||||
PiLoaderBarComponent,
|
||||
],
|
||||
})
|
||||
export class App {
|
||||
protected readonly navLinks = [
|
||||
{ path: '/componentes', label: 'Componentes' },
|
||||
{ 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 -------------------------------------------------- */
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
<pi-slide-toggle [formField]="f.newsletter" />
|
||||
</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
|
||||
[formField]="f.volume"
|
||||
unit="%"
|
||||
@@ -215,6 +223,82 @@
|
||||
</pi-badge>
|
||||
</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
|
||||
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."
|
||||
@@ -293,6 +377,50 @@
|
||||
</pi-card>
|
||||
</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
|
||||
title="Panel"
|
||||
hint="pi-expansion-panel[type=form] usa Reactive Forms clásico, no Signal Forms."
|
||||
@@ -517,6 +645,20 @@
|
||||
</ng-template>
|
||||
</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
|
||||
title="Popover"
|
||||
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>
|
||||
</app-section>
|
||||
|
||||
<app-section title="Chart">
|
||||
<div class="stack full">
|
||||
<pi-chart
|
||||
type="line"
|
||||
<app-section
|
||||
title="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."
|
||||
>
|
||||
<div class="chart-grid">
|
||||
<div class="chart-grid__item">
|
||||
<span class="chart-grid__label">pi-line-chart</span>
|
||||
<pi-line-chart
|
||||
[device]="device"
|
||||
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
|
||||
[datasets]="chartDatasets"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-grid__item">
|
||||
<span class="chart-grid__label">pi-line-chart [filled]="true" (área)</span>
|
||||
<pi-line-chart
|
||||
[filled]="true"
|
||||
[device]="device"
|
||||
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
|
||||
[datasets]="chartDatasets"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-grid__item">
|
||||
<span class="chart-grid__label">pi-bar-chart</span>
|
||||
<pi-bar-chart
|
||||
[device]="device"
|
||||
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
|
||||
[datasets]="barChartDatasets"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-grid__item">
|
||||
<span class="chart-grid__label">pi-pie-chart</span>
|
||||
<pi-pie-chart [device]="device" [slices]="chartSlices" />
|
||||
</div>
|
||||
<div class="chart-grid__item">
|
||||
<span class="chart-grid__label">pi-doughnut-chart</span>
|
||||
<pi-doughnut-chart [device]="device" [slices]="chartSlices" />
|
||||
</div>
|
||||
<div class="chart-grid__item">
|
||||
<span class="chart-grid__label">pi-radar-chart</span>
|
||||
<pi-radar-chart
|
||||
[device]="device"
|
||||
[xAxisMarks]="['Velocidad', 'Fiabilidad', 'Precio', 'Soporte', 'Diseño']"
|
||||
[datasets]="radarDatasets"
|
||||
/>
|
||||
</div>
|
||||
<div class="chart-grid__item">
|
||||
<span class="chart-grid__label">pi-gauge-chart</span>
|
||||
<pi-gauge-chart
|
||||
[device]="device"
|
||||
[value]="72"
|
||||
[max]="100"
|
||||
label="satisfacción"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</app-section>
|
||||
|
||||
<app-section title="Carousel">
|
||||
@@ -666,10 +855,13 @@
|
||||
|
||||
<app-section
|
||||
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-bar label="Carga" [value]="65" />
|
||||
<button pi-outline (click)="triggerLoaderBar()">
|
||||
Simular carga (loader-bar arriba)
|
||||
</button>
|
||||
<pi-paginator
|
||||
[(currentPage)]="currentPage"
|
||||
[pageSize]="10"
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
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 {
|
||||
display: inline-block;
|
||||
margin-top: 0.75rem;
|
||||
@@ -44,6 +55,23 @@ main {
|
||||
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 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -91,3 +119,29 @@ pre {
|
||||
border: 1px dashed var(--pg-card-border);
|
||||
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,
|
||||
PiAccordionItemDirective,
|
||||
PiActionButtonComponent,
|
||||
AvatarGroupItem,
|
||||
PiAvatarComponent,
|
||||
PiAvatarGroupComponent,
|
||||
PiBadgeComponent,
|
||||
BreadcrumbItem,
|
||||
PiBreadcrumbComponent,
|
||||
PiEmptyStateComponent,
|
||||
SegmentedControlOption,
|
||||
PiSegmentedControlComponent,
|
||||
PiBannerComponent,
|
||||
PiBaseDataTableComponent,
|
||||
PiCardBottomContentDirective,
|
||||
@@ -33,7 +40,10 @@ import {
|
||||
PiCarouselItemDirective,
|
||||
PiCellDirective,
|
||||
PiCellTypeComponent,
|
||||
PiChartComponent,
|
||||
PiBarChartComponent,
|
||||
PiDoughnutChartComponent,
|
||||
PiLineChartComponent,
|
||||
PiPieChartComponent,
|
||||
PiChevronButtonComponent,
|
||||
PiChipActionComponent,
|
||||
PiChipComponent,
|
||||
@@ -46,6 +56,7 @@ import {
|
||||
PiCurrencyFieldComponent,
|
||||
PiDataGridComponent,
|
||||
PiDataTableItemComponent,
|
||||
ChartSlice,
|
||||
Dataset,
|
||||
DatasetColor,
|
||||
Device,
|
||||
@@ -54,6 +65,9 @@ import {
|
||||
PiDrawerComponent,
|
||||
getDrawerConfigs,
|
||||
PiEmailFieldComponent,
|
||||
HubConfigKeys,
|
||||
PiHubComponent,
|
||||
getHubConfigs,
|
||||
PiExpansionPanelComponent,
|
||||
PiGhostButtonDirective,
|
||||
PiHamburgerComponent,
|
||||
@@ -72,6 +86,7 @@ import {
|
||||
PiListItemLeftIconDirective,
|
||||
PiListItemRightContentDirective,
|
||||
PiListItemRightIconDirective,
|
||||
LoaderFacade,
|
||||
ModalConfigKeys,
|
||||
ModalStyleType,
|
||||
ModalType,
|
||||
@@ -95,6 +110,7 @@ import {
|
||||
PiRadioButtonComponent,
|
||||
PiRange,
|
||||
PiRangeSliderComponent,
|
||||
PiRatingComponent,
|
||||
PiSearchFieldComponent,
|
||||
PiSelectCarouselComponent,
|
||||
PiSelectComponent,
|
||||
@@ -128,6 +144,16 @@ import {
|
||||
PiDynamicMenuComponent,
|
||||
PiSkeletonComponent,
|
||||
PiStepperComponent,
|
||||
PiTimelineComponent,
|
||||
TimelineItem,
|
||||
PiStatCardComponent,
|
||||
PiRadarChartComponent,
|
||||
PiGaugeChartComponent,
|
||||
PiAutocompleteFieldComponent,
|
||||
PiDateRangeFieldComponent,
|
||||
DateRange,
|
||||
PiTreeComponent,
|
||||
TreeNode,
|
||||
} from '@pi/ui';
|
||||
|
||||
import { ShowcaseSection } from '../showcase-section';
|
||||
@@ -149,6 +175,7 @@ interface DemoModel {
|
||||
range: PiRange;
|
||||
volume: number;
|
||||
newsletter: boolean;
|
||||
satisfaction: number;
|
||||
search: string;
|
||||
website: string;
|
||||
phone: string;
|
||||
@@ -198,7 +225,19 @@ interface DemoModel {
|
||||
PiTextViewerComponent,
|
||||
PiDividerComponent,
|
||||
PiAvatarComponent,
|
||||
PiAvatarGroupComponent,
|
||||
PiBadgeComponent,
|
||||
PiBreadcrumbComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiRatingComponent,
|
||||
PiTimelineComponent,
|
||||
PiStatCardComponent,
|
||||
PiRadarChartComponent,
|
||||
PiGaugeChartComponent,
|
||||
PiAutocompleteFieldComponent,
|
||||
PiDateRangeFieldComponent,
|
||||
PiTreeComponent,
|
||||
PiChipComponent,
|
||||
PiChipStatusComponent,
|
||||
PiChipValueComponent,
|
||||
@@ -270,7 +309,10 @@ interface DemoModel {
|
||||
PiBaseDataTableComponent,
|
||||
PiDataTableItemComponent,
|
||||
PiCellTypeComponent,
|
||||
PiChartComponent,
|
||||
PiBarChartComponent,
|
||||
PiDoughnutChartComponent,
|
||||
PiLineChartComponent,
|
||||
PiPieChartComponent,
|
||||
PiCarouselComponent,
|
||||
PiCarouselItemDirective,
|
||||
// Componentes nuevos
|
||||
@@ -297,6 +339,7 @@ export class CatalogPage {
|
||||
range: { from: 0, to: 10 },
|
||||
volume: 40,
|
||||
newsletter: false,
|
||||
satisfaction: 0,
|
||||
search: '',
|
||||
website: '',
|
||||
phone: '',
|
||||
@@ -322,6 +365,7 @@ export class CatalogPage {
|
||||
maxLength(m.bio, 140, { message: 'Máximo 140 caracteres' });
|
||||
email(m.email, { message: 'Introduce un email válido' });
|
||||
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. */
|
||||
@@ -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 -------------------------------------------------------------- */
|
||||
|
||||
/** Igual que `pi-modal`: se abre desde TypeScript vía `MatDialog`. */
|
||||
@@ -525,6 +577,118 @@ export class CatalogPage {
|
||||
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 ------------------------------ */
|
||||
|
||||
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 = [
|
||||
{ id: 1, label: 'Elemento 1' },
|
||||
{ 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-400: #1c1f26;
|
||||
--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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
## 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 |
|
||||
| ---------------------------------------------------- | --------------------- | ------------------------------------- |
|
||||
| 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
|
||||
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.**
|
||||
`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
|
||||
|
||||
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/badge';
|
||||
export * from './lib/ui/banner';
|
||||
export * from './lib/ui/breadcrumb';
|
||||
export * from './lib/ui/button';
|
||||
export * from './lib/ui/card';
|
||||
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/divider';
|
||||
export * from './lib/ui/drawer';
|
||||
export * from './lib/ui/empty-state';
|
||||
export * from './lib/ui/error-status-code';
|
||||
export * from './lib/ui/file-upload';
|
||||
export * from './lib/ui/form-field';
|
||||
export * from './lib/ui/hub';
|
||||
export * from './lib/ui/icon';
|
||||
export * from './lib/ui/image';
|
||||
export * from './lib/ui/list-item';
|
||||
@@ -53,11 +56,16 @@ export * from './lib/ui/paginator';
|
||||
export * from './lib/ui/panel';
|
||||
export * from './lib/ui/popover';
|
||||
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/slider';
|
||||
export * from './lib/ui/spinner';
|
||||
export * from './lib/ui/stat-card';
|
||||
export * from './lib/ui/stepper';
|
||||
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
|
||||
export {
|
||||
PiBaseTextViewerComponent,
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: var(--pi-border-radius-medium);
|
||||
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-medium));
|
||||
box-shadow: unset;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
@@ -129,7 +129,7 @@
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
.mat-mdc-dialog-container {
|
||||
border-radius: var(--pi-border-radius-large);
|
||||
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-large));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal file
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
// El panel de `pi-hub` es un `MatDialogConfig.panelClass`: el overlay lo
|
||||
// cuelga fuera de `pi-hub.component`, así que estos estilos no pueden ir en
|
||||
// el scss del componente (view encapsulation no llegaría hasta ahí).
|
||||
.cdk-overlay-container,
|
||||
.pi-shell {
|
||||
.pi-hub__panel {
|
||||
margin: 0;
|
||||
|
||||
.mat-mdc-dialog-container,
|
||||
.mdc-dialog__surface {
|
||||
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-medium))
|
||||
var(--pi-theme-modal-radius, var(--pi-border-radius-medium)) 0 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
animation: pi-hub-slide-up 0.25s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pi-hub-slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -21,8 +21,8 @@
|
||||
margin: var(--pi-spacing-x-large);
|
||||
&.pi-snackbar {
|
||||
padding: var(--pi-spacing-large) var(--pi-spacing-x-large);
|
||||
border-radius: var(--pi-border-radius-large);
|
||||
box-shadow: var(--pi-box-shadow);
|
||||
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
|
||||
box-shadow: var(--pi-theme-notification-shadow, var(--pi-box-shadow));
|
||||
border: 1px solid;
|
||||
&.pi-snackbar__info {
|
||||
color: var(--pi-color-primary);
|
||||
@@ -60,7 +60,7 @@
|
||||
.pi-layout {
|
||||
.pi-snackbar {
|
||||
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large);
|
||||
border-radius: var(--pi-border-radius-large);
|
||||
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
.mat-mdc-tooltip {
|
||||
margin: var(--pi-spacing-xxx-small);
|
||||
padding: var(--pi-spacing-xx-small) var(--pi-spacing-small);
|
||||
box-shadow: var(--pi-box-shadow-panel-double);
|
||||
border-radius: var(--pi-border-radius-medium);
|
||||
box-shadow: var(--pi-theme-tooltip-shadow, var(--pi-box-shadow-panel-double));
|
||||
border-radius: var(--pi-theme-tooltip-radius, var(--pi-border-radius-medium));
|
||||
.mdc-tooltip__surface {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@forward 'mat-dialog';
|
||||
@forward 'mat-drawer';
|
||||
@forward 'mat-expansion-panel';
|
||||
@forward 'mat-hub';
|
||||
@forward 'mat-form-field';
|
||||
@forward 'mat-menu';
|
||||
@forward 'mat-progress-bar';
|
||||
|
||||
@@ -43,6 +43,14 @@
|
||||
--pi-theme-card-shadow: none;
|
||||
--pi-theme-panel-radius: var(--pi-border-radius-xx-small);
|
||||
--pi-theme-chip-radius: 0;
|
||||
|
||||
// --- Overlays: mismo criterio, mismas esquinas rectas -----------------
|
||||
--pi-theme-modal-radius: var(--pi-border-radius-xx-small);
|
||||
--pi-theme-notification-radius: var(--pi-border-radius-xx-small);
|
||||
--pi-theme-notification-shadow: none;
|
||||
--pi-theme-tooltip-radius: var(--pi-border-radius-xx-small);
|
||||
--pi-theme-tooltip-shadow: none;
|
||||
--pi-theme-accordion-radius: var(--pi-border-radius-xx-small);
|
||||
}
|
||||
|
||||
.pi-shell__minimal {
|
||||
|
||||
@@ -9,6 +9,17 @@ import {
|
||||
} from '@angular/core';
|
||||
import { status } from '../../../util/status';
|
||||
|
||||
/**
|
||||
* Contexto de `ngTemplateOutletContext` para el header y el summary de un
|
||||
* item: `item` es el propio `PiAccordionItemDirective` (de ahí `$implicit`),
|
||||
* más su índice y si está expandido en ese momento.
|
||||
*/
|
||||
export interface AccordionItemTemplateContext {
|
||||
$implicit: PiAccordionItemDirective;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The accordion header is a separated content of the accordion items group.
|
||||
* If it is needed it must be used as a Header for the whole accordion.
|
||||
@@ -26,18 +37,21 @@ export class PiAccordionHeaderDirective {
|
||||
// Accordion Item Header Content
|
||||
@Directive({ selector: '[pi-accordion-item-header-content]' })
|
||||
export class PiAccordionItemHeaderDirective {
|
||||
public readonly templateRef = inject<any>(TemplateRef);
|
||||
public readonly templateRef = inject<
|
||||
TemplateRef<AccordionItemTemplateContext>
|
||||
>(TemplateRef);
|
||||
}
|
||||
// Accordion Item Summary Content
|
||||
@Directive({ selector: '[pi-accordion-item-summary-content]' })
|
||||
export class PiAccordionSummaryContentDirective {
|
||||
readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
|
||||
readonly templateRef =
|
||||
inject<TemplateRef<AccordionItemTemplateContext>>(TemplateRef);
|
||||
}
|
||||
|
||||
// Accordion Item Expandable Content
|
||||
@Directive({ selector: '[pi-accordion-item-expandable-content]' })
|
||||
export class PiAccordionItemContentDirective {
|
||||
readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
|
||||
readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
& > header {
|
||||
border: 1px solid;
|
||||
border-color: var(--pi-color-#{$color}-75);
|
||||
background-color: var(--pi-color-#{$color}-25);
|
||||
background-color: var(
|
||||
--pi-theme-accordion-surface-#{$color},
|
||||
var(--pi-color-#{$color}-25)
|
||||
);
|
||||
&.is-expanded {
|
||||
background-color: var(--pi-color-grey-10);
|
||||
border-color: var(--pi-color-grey-25);
|
||||
@@ -21,7 +24,7 @@
|
||||
gap: var(--pi-spacing-large);
|
||||
padding: var(--pi-spacing-x-small) var(--pi-spacing-small)
|
||||
var(--pi-spacing-x-small);
|
||||
border-radius: var(--pi-border-radius-large);
|
||||
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
|
||||
&.is-expanded {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
@@ -39,16 +39,16 @@
|
||||
& > .pi-accordion-item-content__header {
|
||||
&.is-expanded {
|
||||
border-bottom: 1px solid;
|
||||
border-top-left-radius: var(--pi-border-radius-large);
|
||||
border-top-right-radius: var(--pi-border-radius-large);
|
||||
border-top-left-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
|
||||
border-top-right-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
|
||||
}
|
||||
&:not(.is-expanded) {
|
||||
border-radius: var(--pi-border-radius-large);
|
||||
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
|
||||
}
|
||||
}
|
||||
& > .pi-accordion-item-content__expandable {
|
||||
border-bottom-left-radius: var(--pi-border-radius-large);
|
||||
border-bottom-right-radius: var(--pi-border-radius-large);
|
||||
border-bottom-left-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
|
||||
border-bottom-right-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,14 +113,20 @@
|
||||
& > .pi-accordion-item {
|
||||
& > .pi-accordion-item-content__header {
|
||||
&.is-expanded {
|
||||
background-color: var(--pi-color-#{$color}-50);
|
||||
background-color: var(
|
||||
--pi-theme-accordion-surface-#{$color}-expanded,
|
||||
var(--pi-color-#{$color}-50)
|
||||
);
|
||||
border-color: var(--pi-color-#{$color}-100);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
& > .pi-accordion-item-content__header {
|
||||
&:not(.is-expanded) {
|
||||
background-color: var(--pi-color-#{$color}-50);
|
||||
background-color: var(
|
||||
--pi-theme-accordion-surface-#{$color}-expanded,
|
||||
var(--pi-color-#{$color}-50)
|
||||
);
|
||||
border-color: var(--pi-color-#{$color}-100);
|
||||
}
|
||||
}
|
||||
@@ -138,8 +144,12 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: var(--pi-spacing-xx-large);
|
||||
border-top-left-radius: calc(var(--pi-border-radius-large) - 0.0625rem);
|
||||
border-top-right-radius: calc(var(--pi-border-radius-large) - 0.0625rem);
|
||||
border-top-left-radius: calc(
|
||||
var(--pi-theme-accordion-radius, var(--pi-border-radius-large)) - 0.0625rem
|
||||
);
|
||||
border-top-right-radius: calc(
|
||||
var(--pi-theme-accordion-radius, var(--pi-border-radius-large)) - 0.0625rem
|
||||
);
|
||||
transition: var(--pi-animation);
|
||||
&.pi-color__primary {
|
||||
background-color: var(--pi-color-primary-50);
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
&.pi-accordion__appearance--fill {
|
||||
gap: var(--pi-spacing-large);
|
||||
& > section {
|
||||
border-radius: var(--pi-border-radius-large);
|
||||
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
|
||||
}
|
||||
header {
|
||||
aside {
|
||||
@@ -88,7 +88,10 @@
|
||||
// Appearance Fill Color
|
||||
@mixin accordionAppearanceFilleColor($color) {
|
||||
& > section {
|
||||
background-color: var(--pi-color-#{$color}-25);
|
||||
background-color: var(
|
||||
--pi-theme-accordion-surface-#{$color},
|
||||
var(--pi-color-#{$color}-25)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +100,10 @@
|
||||
& > section {
|
||||
border: 1px solid;
|
||||
border-color: var(--pi-color-#{$color}-75);
|
||||
background-color: var(--pi-color-#{$color}-25);
|
||||
background-color: var(
|
||||
--pi-theme-accordion-surface-#{$color},
|
||||
var(--pi-color-#{$color}-25)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@for (item of visibleItems(); track $index; let i = $index) {
|
||||
<pi-avatar
|
||||
[image]="item.image"
|
||||
[name]="item.name"
|
||||
[alt]="item.alt"
|
||||
[color]="item.color ?? 'primary'"
|
||||
[size]="size()"
|
||||
[style.z-index]="zIndexFor(i)"
|
||||
/>
|
||||
}
|
||||
@if (overflowCount() > 0) {
|
||||
<span class="pi-avatar-group__overflow">+{{ overflowCount() }}</span>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@mixin overflowSize($dimension, $font-size) {
|
||||
height: $dimension;
|
||||
width: $dimension;
|
||||
min-width: $dimension;
|
||||
font-size: $font-size;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
// El anillo (box-shadow, no border: no cambia la caja) separa avatares
|
||||
// solapados; se aplica desde aquí porque `pi-avatar` no sabe que puede
|
||||
// vivir en un grupo.
|
||||
pi-avatar {
|
||||
position: relative;
|
||||
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
|
||||
border-radius: 50%;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: calc(var(--pi-spacing-xx-small) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
.pi-avatar-group__overflow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
margin-left: calc(var(--pi-spacing-xx-small) * -1);
|
||||
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
|
||||
background-color: var(--pi-color-grey-100);
|
||||
color: var(--pi-color-grey);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// Size (mismos escalones que pi-avatar, para que el "+N" encaje)
|
||||
&.pi-avatar-group__size--x-small .pi-avatar-group__overflow {
|
||||
@include overflowSize(1.5rem, var(--pi-font-size-xxx-small));
|
||||
}
|
||||
&.pi-avatar-group__size--small .pi-avatar-group__overflow {
|
||||
@include overflowSize(2rem, var(--pi-font-size-xx-small));
|
||||
}
|
||||
&.pi-avatar-group__size--medium .pi-avatar-group__overflow {
|
||||
@include overflowSize(2.5rem, var(--pi-font-size-x-small));
|
||||
}
|
||||
&.pi-avatar-group__size--large .pi-avatar-group__overflow {
|
||||
@include overflowSize(3.25rem, var(--pi-font-size-small));
|
||||
}
|
||||
&.pi-avatar-group__size--x-large .pi-avatar-group__overflow {
|
||||
@include overflowSize(4rem, var(--pi-font-size-medium));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||
|
||||
import { AvatarGroupItem, PiAvatarGroupComponent } from './avatar-group.component';
|
||||
|
||||
describe('PiAvatarGroupComponent', () => {
|
||||
let fixture: ComponentFixture<PiAvatarGroupComponent>;
|
||||
|
||||
const items: AvatarGroupItem[] = [
|
||||
{ name: 'Ada Lovelace' },
|
||||
{ name: 'Grace Hopper' },
|
||||
{ name: 'Margaret Hamilton' },
|
||||
{ name: 'Katherine Johnson' },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PiAvatarGroupComponent, MatIconTestingModule],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PiAvatarGroupComponent);
|
||||
fixture.componentRef.setInput('items', items);
|
||||
});
|
||||
|
||||
function avatars(): HTMLElement[] {
|
||||
return Array.from(fixture.nativeElement.querySelectorAll('pi-avatar'));
|
||||
}
|
||||
|
||||
function overflowText(): string | null {
|
||||
return (
|
||||
fixture.nativeElement
|
||||
.querySelector('.pi-avatar-group__overflow')
|
||||
?.textContent?.trim() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
it('renders one pi-avatar per item when there is no max', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(avatars().length).toBe(items.length);
|
||||
expect(overflowText()).toBeNull();
|
||||
});
|
||||
|
||||
it('caps visible avatars at max and shows the overflow count', () => {
|
||||
fixture.componentRef.setInput('max', 3);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(avatars().length).toBe(3);
|
||||
expect(overflowText()).toBe('+1');
|
||||
});
|
||||
|
||||
it('does not show an overflow badge when max is at least the item count', () => {
|
||||
fixture.componentRef.setInput('max', items.length);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(avatars().length).toBe(items.length);
|
||||
expect(overflowText()).toBeNull();
|
||||
});
|
||||
|
||||
it('gives earlier avatars a higher z-index so they stack on top', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const zIndexes = avatars().map((el) => Number(el.style.zIndex));
|
||||
expect(zIndexes).toEqual([4, 3, 2, 1]);
|
||||
});
|
||||
|
||||
it('reflects size as a host class', () => {
|
||||
fixture.componentRef.setInput('size', 'large');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.className).toContain(
|
||||
'pi-avatar-group__size--large',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostBinding,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
// Shared
|
||||
import { iconSize, theming } from '../../../styles';
|
||||
import { PiAvatarComponent } from '../avatar/avatar.component';
|
||||
|
||||
export interface AvatarGroupItem {
|
||||
image?: string;
|
||||
name?: string;
|
||||
alt?: string;
|
||||
color?: theming;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pi-avatar-group',
|
||||
templateUrl: './avatar-group.component.html',
|
||||
styleUrls: ['./avatar-group.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiAvatarComponent],
|
||||
})
|
||||
export class PiAvatarGroupComponent {
|
||||
@HostBinding('class') get classes(): string {
|
||||
return ['pi-avatar-group', `pi-avatar-group__size--${this.size()}`].join(
|
||||
' ',
|
||||
);
|
||||
}
|
||||
|
||||
readonly items = input.required<AvatarGroupItem[]>();
|
||||
/** Por encima de este número, el resto se resume en un "+N". */
|
||||
readonly max = input<number>();
|
||||
readonly size = input<iconSize>('medium');
|
||||
|
||||
protected readonly visibleItems = computed(() => {
|
||||
const max = this.max();
|
||||
const items = this.items();
|
||||
return max ? items.slice(0, max) : items;
|
||||
});
|
||||
|
||||
protected readonly overflowCount = computed(() => {
|
||||
const max = this.max();
|
||||
const items = this.items();
|
||||
return max && items.length > max ? items.length - max : 0;
|
||||
});
|
||||
|
||||
/** El primero debe quedar por encima: z-index descendente por índice. */
|
||||
protected zIndexFor(index: number): number {
|
||||
return this.visibleItems().length - index;
|
||||
}
|
||||
}
|
||||
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal file
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||
|
||||
import { PiAvatarComponent } from './avatar.component';
|
||||
|
||||
describe('PiAvatarComponent', () => {
|
||||
let fixture: ComponentFixture<PiAvatarComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PiAvatarComponent, MatIconTestingModule],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PiAvatarComponent);
|
||||
});
|
||||
|
||||
function initialsText(): string | null {
|
||||
return (
|
||||
fixture.nativeElement
|
||||
.querySelector('.pi-avatar__initials')
|
||||
?.textContent?.trim() ?? null
|
||||
);
|
||||
}
|
||||
|
||||
it('falls back to the icon when neither image nor name are set', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('pi-icon')).toBeTruthy();
|
||||
expect(fixture.nativeElement.querySelector('img')).toBeNull();
|
||||
expect(initialsText()).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to initials when name is set but no image', () => {
|
||||
fixture.componentRef.setInput('name', 'Ada Lovelace');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(initialsText()).toBe('AL');
|
||||
expect(fixture.nativeElement.querySelector('pi-icon')).toBeNull();
|
||||
});
|
||||
|
||||
it('computes a single-letter initial from a single-word name', () => {
|
||||
fixture.componentRef.setInput('name', 'Madonna');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(initialsText()).toBe('M');
|
||||
});
|
||||
|
||||
it('prefers the image over name/icon when all are provided', () => {
|
||||
fixture.componentRef.setInput('image', 'https://example.com/a.png');
|
||||
fixture.componentRef.setInput('name', 'Ada Lovelace');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('img')).toBeTruthy();
|
||||
expect(initialsText()).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to initials when the image fails to load', () => {
|
||||
fixture.componentRef.setInput('image', 'https://example.com/broken.png');
|
||||
fixture.componentRef.setInput('name', 'Ada Lovelace');
|
||||
fixture.detectChanges();
|
||||
|
||||
const img: HTMLImageElement = fixture.nativeElement.querySelector('img');
|
||||
img.dispatchEvent(new Event('error'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('img')).toBeNull();
|
||||
expect(initialsText()).toBe('AL');
|
||||
});
|
||||
|
||||
it('reflects size, shape and color as host classes', () => {
|
||||
fixture.componentRef.setInput('size', 'large');
|
||||
fixture.componentRef.setInput('shape', 'square');
|
||||
fixture.componentRef.setInput('color', 'accent');
|
||||
fixture.detectChanges();
|
||||
|
||||
const classes: string = fixture.nativeElement.className;
|
||||
expect(classes).toContain('pi-avatar__size--large');
|
||||
expect(classes).toContain('pi-avatar__shape--square');
|
||||
expect(classes).toContain('pi-color__accent');
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './avatar/avatar.component';
|
||||
export * from './avatar-group/avatar-group.component';
|
||||
|
||||
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal file
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PiBadgeComponent } from './badge.component';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<pi-badge [value]="value" [max]="max" [isHidden]="isHidden" color="warn">
|
||||
<span class="projected-content">child</span>
|
||||
</pi-badge>
|
||||
`,
|
||||
imports: [PiBadgeComponent],
|
||||
})
|
||||
class HostComponent {
|
||||
value: string | number | undefined = undefined;
|
||||
max = 99;
|
||||
isHidden = false;
|
||||
}
|
||||
|
||||
describe('PiBadgeComponent', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
});
|
||||
|
||||
function indicator(): HTMLElement | null {
|
||||
return fixture.nativeElement.querySelector('.pi-badge__indicator');
|
||||
}
|
||||
|
||||
it('always renders the projected content', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.projected-content'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows a dot with no text when value is not set', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(indicator()?.textContent?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('shows the numeric value when under max', () => {
|
||||
fixture.componentInstance.value = 3;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(indicator()?.textContent?.trim()).toBe('3');
|
||||
});
|
||||
|
||||
it('caps the numeric value at max with a "+" suffix', () => {
|
||||
fixture.componentInstance.value = 150;
|
||||
fixture.componentInstance.max = 99;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(indicator()?.textContent?.trim()).toBe('99+');
|
||||
});
|
||||
|
||||
it('shows a string value as-is, ignoring max', () => {
|
||||
fixture.componentInstance.value = 'new';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(indicator()?.textContent?.trim()).toBe('new');
|
||||
});
|
||||
|
||||
it('hides the indicator without unmounting the projected content', () => {
|
||||
fixture.componentInstance.value = 3;
|
||||
fixture.componentInstance.isHidden = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(indicator()).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.projected-content'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<ol>
|
||||
@for (item of items(); track item.label; let last = $last) {
|
||||
<li>
|
||||
@if (item.link && !last) {
|
||||
<a [routerLink]="item.link">{{ item.label }}</a>
|
||||
} @else {
|
||||
<span [attr.aria-current]="last ? 'page' : null">{{
|
||||
item.label
|
||||
}}</span>
|
||||
}
|
||||
@if (!last) {
|
||||
<pi-icon [icon]="separatorIcon()" size="x-small" color="grey" />
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
@@ -0,0 +1,40 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
ol {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--pi-spacing-xxx-small);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--pi-spacing-xxx-small);
|
||||
// El reset global aplica `li + li { margin-top: ... }` pensado para listas
|
||||
// de prosa (ver _pi-reset.scss); aquí el <ol> es una fila flex, así que
|
||||
// ese margen solo desalinea verticalmente los elementos a partir del 2º.
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--pi-color-grey);
|
||||
text-decoration: none;
|
||||
font-size: var(--pi-font-size-x-small);
|
||||
|
||||
&:hover {
|
||||
color: var(--pi-color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: var(--pi-font-size-x-small);
|
||||
color: var(--pi-color-black);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||
|
||||
import { BreadcrumbItem, PiBreadcrumbComponent } from './breadcrumb.component';
|
||||
|
||||
describe('PiBreadcrumbComponent', () => {
|
||||
let fixture: ComponentFixture<PiBreadcrumbComponent>;
|
||||
|
||||
const items: BreadcrumbItem[] = [
|
||||
{ label: 'Inicio', link: ['/'] },
|
||||
{ label: 'Componentes', link: ['/componentes'] },
|
||||
{ label: 'Avatar' },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PiBreadcrumbComponent, MatIconTestingModule],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PiBreadcrumbComponent);
|
||||
fixture.componentRef.setInput('items', items);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders a routerLink anchor for every item except the last', () => {
|
||||
const links: HTMLAnchorElement[] =
|
||||
fixture.nativeElement.querySelectorAll('a');
|
||||
|
||||
expect(links.length).toBe(2);
|
||||
expect(links[0].textContent?.trim()).toBe('Inicio');
|
||||
expect(links[1].textContent?.trim()).toBe('Componentes');
|
||||
});
|
||||
|
||||
it('renders the last item as plain text marked as the current page', () => {
|
||||
const spans: HTMLSpanElement[] =
|
||||
fixture.nativeElement.querySelectorAll('span');
|
||||
const current = Array.from(spans).find(
|
||||
(span) => span.textContent?.trim() === 'Avatar',
|
||||
);
|
||||
|
||||
expect(current).toBeTruthy();
|
||||
expect(current?.getAttribute('aria-current')).toBe('page');
|
||||
});
|
||||
|
||||
it('renders one separator icon fewer than the number of items', () => {
|
||||
const separators = fixture.nativeElement.querySelectorAll('pi-icon');
|
||||
|
||||
expect(separators.length).toBe(items.length - 1);
|
||||
});
|
||||
|
||||
it('treats an item without a link as plain text even mid-list', () => {
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ label: 'Sin enlace' },
|
||||
{ label: 'Actual' },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelectorAll('a').length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostBinding,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { PiIconComponent } from '../../icon/icon/icon.component';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
/** Comandos de `routerLink`. Si se omite, se pinta como texto plano
|
||||
* (típicamente el último elemento, la página actual). */
|
||||
link?: unknown[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pi-breadcrumb',
|
||||
templateUrl: './breadcrumb.component.html',
|
||||
styleUrls: ['./breadcrumb.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiIconComponent],
|
||||
})
|
||||
export class PiBreadcrumbComponent {
|
||||
@HostBinding('class') className = 'pi-breadcrumb';
|
||||
@HostBinding('attr.role') role = 'navigation';
|
||||
@HostBinding('attr.aria-label') ariaLabel = 'Breadcrumb';
|
||||
|
||||
readonly items = input.required<BreadcrumbItem[]>();
|
||||
readonly separatorIcon = input('chevron-forward');
|
||||
}
|
||||
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './breadcrumb/breadcrumb.component';
|
||||
@@ -39,7 +39,7 @@ export abstract class PiBaseButtonComponent {
|
||||
return 'is-disabled';
|
||||
}
|
||||
|
||||
onClickHandler(event: any): void {
|
||||
onClickHandler(event: MouseEvent): void {
|
||||
if (!this.disabled()) {
|
||||
if (!this.allowPropagation()) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -25,12 +25,12 @@ export abstract class PiBaseCardComponent {
|
||||
|
||||
readonly execute = output<void>();
|
||||
|
||||
protected get hasShadowClass(): string | boolean | undefined {
|
||||
return this.hasShadow() && 'has-shadow';
|
||||
protected get hasShadowClass(): string {
|
||||
return this.hasShadow() ? 'has-shadow' : '';
|
||||
}
|
||||
|
||||
protected get isClickableClass(): string | false | undefined {
|
||||
return this.isClickable() && 'is-clickable';
|
||||
protected get isClickableClass(): string {
|
||||
return this.isClickable() ? 'is-clickable' : '';
|
||||
}
|
||||
|
||||
protected get colorClass(): string {
|
||||
|
||||
@@ -10,6 +10,6 @@ import {
|
||||
host: { class: 'pi-carousel pi-carousel__item' },
|
||||
})
|
||||
export class PiCarouselItemDirective {
|
||||
public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
|
||||
public readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
|
||||
public readonly viewContainerRef = inject(ViewContainerRef);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- Paginator -->
|
||||
@if (hasOverflow && !hidePaginator()) {
|
||||
<footer>
|
||||
@for (i of pages; track i; let index = $index) {
|
||||
@for (i of pages(); track i; let index = $index) {
|
||||
<div [class]="index === currentPageIndex ? 'is-active' : ''"></div>
|
||||
}
|
||||
</footer>
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AfterViewChecked,
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
Renderer2,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
contentChildren,
|
||||
inject,
|
||||
@@ -142,17 +142,22 @@ export class PiCarouselComponent
|
||||
|
||||
private destroyed$: Subject<void> = new Subject<void>();
|
||||
|
||||
private _currentItemIndex = 0;
|
||||
set currentItem(index: number) {
|
||||
this._currentItemIndex = index;
|
||||
/**
|
||||
* Signals en vez de campos llanos: se mutan desde listeners nativos
|
||||
* (`Renderer2.listen` en `document.documentElement`, la suscripción a
|
||||
* `device$`/`size$`) que no pasan por un binding de evento de plantilla, así
|
||||
* que antes hacía falta `ChangeDetectorRef.detectChanges()`/`markForCheck()`
|
||||
* a mano para que la plantilla se enterase. Escribir un signal ya
|
||||
* reprograma la detección de cambios sola.
|
||||
*/
|
||||
readonly currentItem = signal(0);
|
||||
readonly itemsPerPage = signal(0);
|
||||
readonly pages = signal<number[]>([]);
|
||||
|
||||
private setCurrentItem(index: number): void {
|
||||
this.currentItem.set(index);
|
||||
this.itemChanged.emit(index);
|
||||
}
|
||||
get currentItem(): number {
|
||||
return this._currentItemIndex;
|
||||
}
|
||||
|
||||
itemsPerPage!: number;
|
||||
pages!: Array<number>;
|
||||
|
||||
// Drag variables
|
||||
isDragging = false;
|
||||
@@ -168,7 +173,6 @@ export class PiCarouselComponent
|
||||
private touchmoveListener!: () => void;
|
||||
private touchendListener!: () => void;
|
||||
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
private readonly el = inject(ElementRef);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
private readonly deviceConfig = inject(DeviceConfigsFacade);
|
||||
@@ -193,7 +197,7 @@ export class PiCarouselComponent
|
||||
* Used for symmetric carousel to know how many items to show on each side of the current item.
|
||||
*/
|
||||
get numberOfSideItems(): number {
|
||||
return Math.floor(this.itemsPerPage / 2);
|
||||
return Math.floor(this.itemsPerPage() / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,26 +235,26 @@ export class PiCarouselComponent
|
||||
* Returns true if the number of items is greater than the number of items per page.
|
||||
*/
|
||||
get hasOverflow(): boolean {
|
||||
return this.carouselItems().length > this.itemsPerPage;
|
||||
return this.carouselItems().length > this.itemsPerPage();
|
||||
}
|
||||
|
||||
get canSwipeNext(): boolean {
|
||||
return this.movePerPage()
|
||||
? this.currentItem + this.itemsPerPage < this.carouselItems().length
|
||||
: this.currentItem < this.carouselItems().length - 1;
|
||||
? this.currentItem() + this.itemsPerPage() < this.carouselItems().length
|
||||
: this.currentItem() < this.carouselItems().length - 1;
|
||||
}
|
||||
|
||||
get canSwipePrev(): boolean {
|
||||
return this.movePerPage()
|
||||
? this.currentItem - this.itemsPerPage >= 0
|
||||
: this.currentItem > 0;
|
||||
? this.currentItem() - this.itemsPerPage() >= 0
|
||||
: this.currentItem() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the current page.
|
||||
*/
|
||||
get currentPageIndex(): number {
|
||||
return Math.trunc(this.currentItem / this.itemsPerPage);
|
||||
return Math.trunc(this.currentItem() / this.itemsPerPage());
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -278,58 +282,58 @@ export class PiCarouselComponent
|
||||
|
||||
reloadComponent(): void {
|
||||
this.ngOnDestroy();
|
||||
this.currentItem = 0;
|
||||
this.setCurrentItem(0);
|
||||
this.ngAfterViewInit();
|
||||
this.ngAfterViewChecked();
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
next(): void {
|
||||
if (this.movePerPage()) {
|
||||
this.currentPageIndex < this.pages.length - 1
|
||||
? (this.currentItem = (this.currentPageIndex + 1) * this.itemsPerPage)
|
||||
this.currentPageIndex < this.pages().length - 1
|
||||
? this.setCurrentItem((this.currentPageIndex + 1) * this.itemsPerPage())
|
||||
: null;
|
||||
} else {
|
||||
this.currentItem = this.currentItem + 1;
|
||||
this.setCurrentItem(this.currentItem() + 1);
|
||||
}
|
||||
this.carouselTranslation(this.currentItem);
|
||||
this.carouselTranslation(this.currentItem());
|
||||
}
|
||||
|
||||
prev(): void {
|
||||
if (this.movePerPage()) {
|
||||
this.currentPageIndex > 0
|
||||
? (this.currentItem = (this.currentPageIndex - 1) * this.itemsPerPage)
|
||||
? this.setCurrentItem((this.currentPageIndex - 1) * this.itemsPerPage())
|
||||
: null;
|
||||
} else {
|
||||
this.currentItem = this.currentItem - 1;
|
||||
this.setCurrentItem(this.currentItem() - 1);
|
||||
}
|
||||
this.carouselTranslation(this.currentItem);
|
||||
this.carouselTranslation(this.currentItem());
|
||||
}
|
||||
|
||||
setItemsPerPage(): void {
|
||||
const itemWidth = this.itemWidth();
|
||||
if (!!itemWidth) {
|
||||
this.itemsPerPage =
|
||||
Math.floor(this.carouselContentElement.clientWidth / itemWidth) || 1;
|
||||
this.itemsPerPage.set(
|
||||
Math.floor(this.carouselContentElement.clientWidth / itemWidth) || 1,
|
||||
);
|
||||
} else {
|
||||
const itemsDesktop = this.itemsDesktop();
|
||||
const itemsTablet = this.itemsTablet();
|
||||
const itemsMobile = this.itemsMobile();
|
||||
switch (this.device()) {
|
||||
case 'DESKTOP':
|
||||
this.itemsPerPage = itemsDesktop
|
||||
? itemsDesktop
|
||||
: this.carouselItems().length;
|
||||
this.itemsPerPage.set(
|
||||
itemsDesktop ? itemsDesktop : this.carouselItems().length,
|
||||
);
|
||||
break;
|
||||
case 'TABLET':
|
||||
this.itemsPerPage = itemsTablet
|
||||
? itemsTablet
|
||||
: this.carouselItems().length;
|
||||
this.itemsPerPage.set(
|
||||
itemsTablet ? itemsTablet : this.carouselItems().length,
|
||||
);
|
||||
break;
|
||||
case 'MOBILE':
|
||||
this.itemsPerPage = itemsMobile
|
||||
? itemsMobile
|
||||
: this.carouselItems().length;
|
||||
this.itemsPerPage.set(
|
||||
itemsMobile ? itemsMobile : this.carouselItems().length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -341,12 +345,12 @@ export class PiCarouselComponent
|
||||
|
||||
getItemClass(index: number): string {
|
||||
let itemClass: string = 'pi-carousel__item pi-carousel__item--' + index;
|
||||
if (index === this.currentItem) itemClass += ' ' + 'is-active';
|
||||
if (index === this.currentItem()) itemClass += ' ' + 'is-active';
|
||||
return itemClass;
|
||||
}
|
||||
|
||||
selectItem(index: number): void {
|
||||
this.currentItem = index;
|
||||
this.setCurrentItem(index);
|
||||
if (this.hasOverflow && !this.movePerPage()) {
|
||||
this.carouselTranslation(index);
|
||||
}
|
||||
@@ -355,12 +359,11 @@ export class PiCarouselComponent
|
||||
setPages(): void {
|
||||
let totalPages: number;
|
||||
let pages: Array<number> = [];
|
||||
totalPages = Math.ceil(this.carouselItems().length / this.itemsPerPage);
|
||||
totalPages = Math.ceil(this.carouselItems().length / this.itemsPerPage());
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
this.pages = pages;
|
||||
this.cdr.detectChanges();
|
||||
this.pages.set(pages);
|
||||
}
|
||||
|
||||
private stopTouchDragEventListeners(): void {
|
||||
@@ -456,7 +459,7 @@ export class PiCarouselComponent
|
||||
const itemWidth = this.itemWidth();
|
||||
const minDraggingWidth = itemWidth
|
||||
? 0.8 * itemWidth
|
||||
: 0.8 * (100 / this.itemsPerPage);
|
||||
: 0.8 * (100 / this.itemsPerPage());
|
||||
|
||||
this.isDragging = true;
|
||||
|
||||
@@ -468,8 +471,8 @@ export class PiCarouselComponent
|
||||
this.dragInitPosition - this.dragFinalPosition > minDraggingWidth &&
|
||||
this.canSwipeNext
|
||||
) {
|
||||
this.currentItem++;
|
||||
this.carouselTranslation(this.currentItem);
|
||||
this.setCurrentItem(this.currentItem() + 1);
|
||||
this.carouselTranslation(this.currentItem());
|
||||
this.dragInitPosition = this.dragFinalPosition;
|
||||
}
|
||||
|
||||
@@ -477,8 +480,8 @@ export class PiCarouselComponent
|
||||
this.dragInitPosition - this.dragFinalPosition < -minDraggingWidth &&
|
||||
this.canSwipePrev
|
||||
) {
|
||||
this.currentItem--;
|
||||
this.carouselTranslation(this.currentItem);
|
||||
this.setCurrentItem(this.currentItem() - 1);
|
||||
this.carouselTranslation(this.currentItem());
|
||||
this.dragInitPosition = this.dragFinalPosition;
|
||||
}
|
||||
}
|
||||
@@ -486,7 +489,6 @@ export class PiCarouselComponent
|
||||
private dragEnd(): void {
|
||||
this.isDragging = false;
|
||||
this.isMouseDown = false;
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
|
||||
private getCarouselItemsNativeElements(): void {
|
||||
@@ -499,7 +501,7 @@ export class PiCarouselComponent
|
||||
const type = this.type();
|
||||
if (
|
||||
type === 'linear' &&
|
||||
currentItem <= this.carouselItems().length - this.itemsPerPage
|
||||
currentItem <= this.carouselItems().length - this.itemsPerPage()
|
||||
) {
|
||||
this.carouselItemsTranslation(
|
||||
currentItem,
|
||||
@@ -524,7 +526,7 @@ export class PiCarouselComponent
|
||||
// If we are at the end of the carousel
|
||||
this.carouselItemsTranslation(
|
||||
currentItem,
|
||||
`-${(this.carouselItems().length - this.itemsPerPage) * 100}%`,
|
||||
`-${(this.carouselItems().length - this.itemsPerPage()) * 100}%`,
|
||||
this.direction(),
|
||||
);
|
||||
}
|
||||
@@ -564,14 +566,14 @@ export class PiCarouselComponent
|
||||
this.renderer.setStyle(
|
||||
item,
|
||||
'min-width',
|
||||
`${100 / this.itemsPerPage}%`,
|
||||
`${100 / this.itemsPerPage()}%`,
|
||||
);
|
||||
this.renderer.setStyle(
|
||||
item,
|
||||
'max-width',
|
||||
`${100 / this.itemsPerPage}%`,
|
||||
`${100 / this.itemsPerPage()}%`,
|
||||
);
|
||||
this.renderer.setStyle(item, 'width', `${100 / this.itemsPerPage}%`);
|
||||
this.renderer.setStyle(item, 'width', `${100 / this.itemsPerPage()}%`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -580,7 +582,7 @@ export class PiCarouselComponent
|
||||
this.renderer.setStyle(
|
||||
this.carouselContentElement,
|
||||
'min-height',
|
||||
`${this.itemsPerPage * this.tallestElementHeightPx}px`,
|
||||
`${this.itemsPerPage() * this.tallestElementHeightPx}px`,
|
||||
);
|
||||
this.carouselItemsNativeElements.forEach((item) => {
|
||||
this.renderer.setStyle(
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
<pi-chart-legend
|
||||
[datasets]="datasets()"
|
||||
[isComparativeChart]="isComparativeChart()"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex()"
|
||||
(triggerDatasetHover)="datasetHoveredInLegend($event)"
|
||||
></pi-chart-legend>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal file
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Legend,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import { barDatasetConfig } from '../chart.configs';
|
||||
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||
|
||||
Chart.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-bar-chart',
|
||||
templateUrl: './bar-chart.component.html',
|
||||
styleUrls: ['./bar-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiChartLegendComponent],
|
||||
})
|
||||
export class PiBarChartComponent extends PiBaseCartesianChartComponent {
|
||||
get completedDatasets() {
|
||||
return this.translatedDatasets().map((dataset) => ({
|
||||
...dataset,
|
||||
...barDatasetConfig,
|
||||
}));
|
||||
}
|
||||
|
||||
protected buildChart(): Chart {
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: this.xAxisMarks(),
|
||||
datasets: this.completedDatasets,
|
||||
},
|
||||
options: this.getCartesianOptions('bar'),
|
||||
});
|
||||
}
|
||||
}
|
||||
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal file
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ChartData, ChartOptions } from 'chart.js';
|
||||
import { ChartSlice } from '../../util/models';
|
||||
import { arcDatasetConfig, tooltipConfig } from './chart.configs';
|
||||
import { PiBaseChartComponent } from './base-chart.component';
|
||||
|
||||
/**
|
||||
* Base de `pi-pie-chart` y `pi-doughnut-chart`: a diferencia de
|
||||
* `PiBaseCartesianChartComponent` (varias series a lo largo de un eje), aquí
|
||||
* hay una sola magnitud repartida en porciones — cada una con su propio
|
||||
* color — así que usan `slices`, no `datasets`, y la leyenda nativa de
|
||||
* Chart.js en vez de `pi-chart-legend`.
|
||||
*/
|
||||
@Component({ template: '' })
|
||||
export abstract class PiBaseArcChartComponent extends PiBaseChartComponent {
|
||||
readonly slices = input.required<Array<ChartSlice>>();
|
||||
|
||||
protected getArcData<T extends 'pie' | 'doughnut'>(): ChartData<T> {
|
||||
const slices = this.slices();
|
||||
return {
|
||||
labels: slices.map((slice) => this.translate.instant(slice.label)),
|
||||
datasets: [
|
||||
{
|
||||
data: slices.map((slice) => slice.value),
|
||||
backgroundColor: slices.map((slice) => slice.color),
|
||||
...arcDatasetConfig,
|
||||
},
|
||||
],
|
||||
} as unknown as ChartData<T>;
|
||||
}
|
||||
|
||||
protected getArcOptions<T extends 'pie' | 'doughnut'>(): ChartOptions<T> {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
font: {
|
||||
family: 'Soehne',
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipConfig,
|
||||
callbacks: {
|
||||
label: (context): string => {
|
||||
const label = context.label ?? '';
|
||||
const value = context.parsed as number;
|
||||
const dataset = context.dataset as unknown as {
|
||||
data: number[];
|
||||
};
|
||||
const total = dataset.data.reduce(
|
||||
(sum, current) => sum + current,
|
||||
0,
|
||||
);
|
||||
const percentage = total
|
||||
? Math.round((value / total) * 100)
|
||||
: 0;
|
||||
return `${label}: ${context.formattedValue} (${percentage}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ChartOptions<T>;
|
||||
}
|
||||
}
|
||||
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal file
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Component, input, signal } from '@angular/core';
|
||||
import {
|
||||
ActiveElement,
|
||||
Chart,
|
||||
ChartEvent,
|
||||
ChartOptions,
|
||||
ChartType,
|
||||
} from 'chart.js';
|
||||
import { Dataset } from '../../util/models';
|
||||
import { scalesConfig, tooltipConfig } from './chart.configs';
|
||||
import { PiBaseChartComponent } from './base-chart.component';
|
||||
|
||||
/**
|
||||
* Base de `pi-line-chart` y `pi-bar-chart`: ambos comparan una o varias
|
||||
* `Dataset` (series) a lo largo de `xAxisMarks`, con la misma leyenda propia
|
||||
* (`pi-chart-legend`, no la nativa de Chart.js) y el mismo tooltip. Lo único
|
||||
* que cambia entre los dos es el `type` de Chart.js y el aspecto del propio
|
||||
* dataset (con o sin puntos) — eso lo aporta cada subclase.
|
||||
*/
|
||||
@Component({ template: '' })
|
||||
export abstract class PiBaseCartesianChartComponent extends PiBaseChartComponent {
|
||||
readonly datasets = input.required<Array<Dataset>>();
|
||||
readonly xAxisMarks = input<Array<string>>();
|
||||
readonly isComparativeChart = input<boolean>(true);
|
||||
|
||||
/**
|
||||
* Signal en vez de campo llano: `onHover` lo actualiza desde un callback de
|
||||
* Chart.js (fuera de la zona de Angular), y escribir un signal ya reprograma
|
||||
* la detección de cambios sola, sin `ChangeDetectorRef.detectChanges()`.
|
||||
*/
|
||||
readonly hoveredDatasetIndex = signal(-1);
|
||||
|
||||
/** Etiquetas traducidas; el resto del aspecto lo añade cada subclase. */
|
||||
protected translatedDatasets(): Array<Dataset> {
|
||||
return this.datasets().map((dataset) => ({
|
||||
...dataset,
|
||||
label: this.translate.instant(dataset.label),
|
||||
}));
|
||||
}
|
||||
|
||||
triggerHover(datasetIndex: number): void {
|
||||
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
|
||||
this.chart.setActiveElements([]);
|
||||
} else {
|
||||
this.chart.setActiveElements([{ datasetIndex, index: 0 }]);
|
||||
}
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
datasetHoveredInLegend(index: number): void {
|
||||
this.hoveredDatasetIndex.set(index);
|
||||
this.triggerHover(index);
|
||||
}
|
||||
|
||||
protected getCartesianOptions(chartType: ChartType): ChartOptions {
|
||||
const isComparativeChart = this.isComparativeChart();
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||
hover: {
|
||||
mode: 'dataset',
|
||||
},
|
||||
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
|
||||
this.hoveredDatasetIndex.set(
|
||||
activeElements.length ? activeElements[0].datasetIndex : -1,
|
||||
);
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipConfig,
|
||||
filter: (tooltipItem): boolean => {
|
||||
if (isComparativeChart) {
|
||||
return (
|
||||
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
|
||||
);
|
||||
}
|
||||
return tooltipItem.element.active;
|
||||
},
|
||||
callbacks: {
|
||||
labelTextColor: (context): string =>
|
||||
context.dataset.borderColor as string,
|
||||
label: (context): string => {
|
||||
const datasetLabel = context.dataset.label ?? '';
|
||||
const chartLabel =
|
||||
datasetLabel.length > 12
|
||||
? datasetLabel.slice(0, 12) + '...'
|
||||
: datasetLabel;
|
||||
const dataset = context.dataset as Dataset;
|
||||
if (dataset.unitPosition === 'before') {
|
||||
return (
|
||||
chartLabel + ': ' + dataset.unit + context.formattedValue
|
||||
);
|
||||
}
|
||||
return (
|
||||
chartLabel + ': ' + context.formattedValue + dataset.unit
|
||||
);
|
||||
},
|
||||
title: (): string => '',
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
scales: scalesConfig,
|
||||
} as ChartOptions<typeof chartType>;
|
||||
}
|
||||
}
|
||||
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal file
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { Chart } from 'chart.js';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Device } from '../../util/device-configs';
|
||||
|
||||
/**
|
||||
* Base común a los 4 gráficos (`pi-line-chart`, `pi-bar-chart`,
|
||||
* `pi-pie-chart`, `pi-doughnut-chart`): el `<canvas>`, el ciclo de vida que
|
||||
* construye el `Chart` una vez la vista está lista, y el `TranslateService`
|
||||
* que todos usan para las etiquetas. Cada tipo registra solo los
|
||||
* controladores/elementos de Chart.js que necesita — no hay un registro
|
||||
* global compartido, así que un consumidor que solo importe `pi-pie-chart`
|
||||
* no arrastra el código de líneas/barras.
|
||||
*/
|
||||
@Component({ template: '' })
|
||||
export abstract class PiBaseChartComponent implements AfterViewInit {
|
||||
readonly device = input.required<Device>();
|
||||
|
||||
readonly chartRef = viewChild.required('chart', {
|
||||
read: ElementRef<HTMLCanvasElement>,
|
||||
});
|
||||
|
||||
chart!: Chart;
|
||||
|
||||
protected readonly translate = inject(TranslateService);
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.chart = this.buildChart();
|
||||
}
|
||||
|
||||
protected abstract buildChart(): Chart;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export const scalesConfig: ScalesConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export const datasetConfig = {
|
||||
export const lineDatasetConfig = {
|
||||
borderWidth: 1.3,
|
||||
hoverBorderWidth: 3.5,
|
||||
pointRadius: 8,
|
||||
@@ -89,3 +89,27 @@ export const datasetConfig = {
|
||||
pointBorderWidth: 2,
|
||||
pointHoverBorderWidth: 2,
|
||||
};
|
||||
|
||||
/** `type="bar"`: sin puntos (no aplican a barras), esquinas redondeadas. */
|
||||
export const barDatasetConfig = {
|
||||
borderWidth: 0,
|
||||
borderRadius: 6,
|
||||
maxBarThickness: 32,
|
||||
};
|
||||
|
||||
/** `pi-pie-chart`/`pi-doughnut-chart`: separación fina entre porciones. */
|
||||
export const arcDatasetConfig = {
|
||||
borderWidth: 2,
|
||||
borderColor: DatasetColor.white,
|
||||
hoverOffset: 12,
|
||||
};
|
||||
|
||||
/** `#rrggbb` -> `rgba(r, g, b, alpha)`; usado por `pi-line-chart` para el área rellena. */
|
||||
export function hexToRgba(hex: string, alpha: number): string {
|
||||
const clean = hex.replace('#', '');
|
||||
const value = parseInt(clean, 16);
|
||||
const r = (value >> 16) & 255;
|
||||
const g = (value >> 8) & 255;
|
||||
const b = value & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
&:not(.is-desktop) {
|
||||
:host {
|
||||
& > div {
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.is-desktop {
|
||||
:host {
|
||||
& > div {
|
||||
& > canvas {
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
viewChild,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Device } from '../../../util/device-configs';
|
||||
import { Dataset } from '../../../util/models';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Legend,
|
||||
LineController,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import { datasetConfig, scalesConfig, tooltipConfig } from '../chart.configs';
|
||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||
|
||||
function registerChartComponents() {
|
||||
Chart.register(CategoryScale);
|
||||
Chart.register(LinearScale);
|
||||
Chart.register(BarController);
|
||||
Chart.register(BarElement);
|
||||
Chart.register(LineController);
|
||||
Chart.register(LineElement);
|
||||
Chart.register(PointElement);
|
||||
Chart.register(Tooltip);
|
||||
Chart.register(Legend);
|
||||
Chart.register(Title);
|
||||
}
|
||||
|
||||
registerChartComponents();
|
||||
@Component({
|
||||
selector: 'pi-chart',
|
||||
templateUrl: './chart.component.html',
|
||||
styleUrls: ['./chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiChartLegendComponent],
|
||||
})
|
||||
export class PiChartComponent implements AfterViewInit {
|
||||
readonly type = input<'line' | 'bar'>('line');
|
||||
readonly datasets = input.required<Array<Dataset>>();
|
||||
readonly device = input.required<Device>();
|
||||
readonly xAxisMarks = input<Array<string>>();
|
||||
readonly isComparativeChart = input<boolean>(true);
|
||||
|
||||
private _hoveredDatasetIndex = -1;
|
||||
set hoveredDatasetIndex(index: number) {
|
||||
this._hoveredDatasetIndex = index;
|
||||
}
|
||||
get hoveredDatasetIndex(): number {
|
||||
return this._hoveredDatasetIndex;
|
||||
}
|
||||
|
||||
readonly chartRef = viewChild.required('chart', {
|
||||
read: ElementRef<HTMLCanvasElement>,
|
||||
});
|
||||
|
||||
chart!: Chart;
|
||||
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
|
||||
get completedDatasets() {
|
||||
return this.datasets().map((dataset) => {
|
||||
const datasetWithTranslatedLabels = {
|
||||
...dataset,
|
||||
label: this.translate.instant(dataset.label),
|
||||
};
|
||||
return {
|
||||
...datasetWithTranslatedLabels,
|
||||
...datasetConfig,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.getLineChartConfigs();
|
||||
}
|
||||
|
||||
triggerHover(datasetIndex: number) {
|
||||
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
|
||||
this.chart.setActiveElements([]);
|
||||
} else {
|
||||
this.chart.setActiveElements([
|
||||
{
|
||||
datasetIndex,
|
||||
index: 0,
|
||||
},
|
||||
]);
|
||||
}
|
||||
this.chart.update();
|
||||
}
|
||||
|
||||
getLineChartConfigs(): void {
|
||||
const isComparativeChart = this.isComparativeChart();
|
||||
|
||||
this.chart = new Chart(this.chartRef().nativeElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: this.xAxisMarks(),
|
||||
datasets: this.completedDatasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: this.device() === 'DESKTOP' ? true : false,
|
||||
hover: {
|
||||
mode: 'dataset',
|
||||
},
|
||||
onHover: (e: any, activeElements: any) => {
|
||||
this.hoveredDatasetIndex =
|
||||
activeElements.length && activeElements[0].datasetIndex;
|
||||
this.cdr.detectChanges();
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
...tooltipConfig,
|
||||
filter: function (tooltipItem): boolean {
|
||||
if (isComparativeChart) {
|
||||
return (
|
||||
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
|
||||
);
|
||||
} else {
|
||||
return tooltipItem.element.active;
|
||||
}
|
||||
},
|
||||
callbacks: {
|
||||
labelTextColor: function (context): string {
|
||||
return context.dataset.borderColor as string;
|
||||
},
|
||||
label: function (context) {
|
||||
const datasetLabel = context.dataset.label ?? '';
|
||||
const chartLabel =
|
||||
datasetLabel.length > 12
|
||||
? datasetLabel.slice(0, 12) + '...'
|
||||
: datasetLabel;
|
||||
let label: string;
|
||||
if ((context.dataset as any).unitPosition === 'before') {
|
||||
label =
|
||||
chartLabel +
|
||||
': ' +
|
||||
(context.dataset as any).unit +
|
||||
context.formattedValue;
|
||||
} else {
|
||||
label =
|
||||
chartLabel +
|
||||
': ' +
|
||||
context.formattedValue +
|
||||
(context.dataset as any).unit;
|
||||
}
|
||||
return label;
|
||||
},
|
||||
title: function () {},
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
scales: scalesConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
datasetHoveredInLegend(index: number): void {
|
||||
this.hoveredDatasetIndex = index;
|
||||
this.triggerHover(this.hoveredDatasetIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import {
|
||||
ArcElement,
|
||||
Chart,
|
||||
DoughnutController,
|
||||
Legend,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
|
||||
|
||||
Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-doughnut-chart',
|
||||
templateUrl: './doughnut-chart.component.html',
|
||||
styleUrls: ['./doughnut-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PiDoughnutChartComponent extends PiBaseArcChartComponent {
|
||||
protected buildChart(): Chart {
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'doughnut',
|
||||
data: this.getArcData<'doughnut'>(),
|
||||
options: this.getArcOptions<'doughnut'>(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Gauge"></canvas>
|
||||
<div class="pi-gauge-chart__value">
|
||||
<span>
|
||||
<strong>{{ clampedValue() }}</strong><small>/{{ max() }}</small>
|
||||
</span>
|
||||
@if (label()) {
|
||||
<span class="pi-gauge-chart__label">{{ label() }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.pi-gauge-chart__value {
|
||||
position: absolute;
|
||||
top: 68%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
|
||||
strong {
|
||||
font-size: var(--pi-font-size-xx-large);
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: var(--pi-font-size-small);
|
||||
color: var(--pi-color-grey-300);
|
||||
}
|
||||
}
|
||||
|
||||
.pi-gauge-chart__label {
|
||||
margin-top: var(--pi-spacing-xxxx-small);
|
||||
font-size: var(--pi-font-size-x-small);
|
||||
color: var(--pi-color-grey-300);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { ArcElement, Chart, DoughnutController } from 'chart.js';
|
||||
import { DatasetColor } from '../../../util/models';
|
||||
import { PiBaseChartComponent } from '../base-chart.component';
|
||||
|
||||
Chart.register(ArcElement, DoughnutController);
|
||||
|
||||
/**
|
||||
* Un único valor sobre `max`, no una serie ni porciones — por eso extiende
|
||||
* directamente `PiBaseChartComponent` (la base raíz) en vez de
|
||||
* `PiBaseCartesianChartComponent`/`PiBaseArcChartComponent`, pensadas para
|
||||
* varios datasets o varias porciones. El texto central es HTML superpuesto,
|
||||
* no un plugin de Chart.js: mismo criterio que `pi-line-chart` con
|
||||
* `hexToRgba` — evitar un plugin nuevo cuando una superposición CSS basta.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'pi-gauge-chart',
|
||||
templateUrl: './gauge-chart.component.html',
|
||||
styleUrls: ['./gauge-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PiGaugeChartComponent extends PiBaseChartComponent {
|
||||
readonly value = input.required<number>();
|
||||
readonly max = input(100);
|
||||
readonly color = input<DatasetColor>(DatasetColor.primary);
|
||||
/** Texto bajo el valor central (p. ej. "de satisfacción"). */
|
||||
readonly label = input<string>();
|
||||
|
||||
protected readonly clampedValue = computed(() =>
|
||||
Math.min(Math.max(this.value(), 0), this.max()),
|
||||
);
|
||||
|
||||
protected buildChart(): Chart {
|
||||
const clamped = this.clampedValue();
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
data: [clamped, this.max() - clamped],
|
||||
backgroundColor: [this.color(), DatasetColor.grey75],
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||
circumference: 180,
|
||||
rotation: -90,
|
||||
cutout: '75%',
|
||||
events: [],
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { enabled: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
export * from './chart/chart.component';
|
||||
export * from './base-arc-chart.component';
|
||||
export * from './base-cartesian-chart.component';
|
||||
export * from './base-chart.component';
|
||||
|
||||
export * from './chart.configs';
|
||||
|
||||
export * from './chart-legend/chart-legend.component';
|
||||
|
||||
export * from './line-chart/line-chart.component';
|
||||
export * from './bar-chart/bar-chart.component';
|
||||
export * from './pie-chart/pie-chart.component';
|
||||
export * from './doughnut-chart/doughnut-chart.component';
|
||||
export * from './radar-chart/radar-chart.component';
|
||||
export * from './gauge-chart/gauge-chart.component';
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
<pi-chart-legend
|
||||
[datasets]="datasets()"
|
||||
[isComparativeChart]="isComparativeChart()"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex()"
|
||||
(triggerDatasetHover)="datasetHoveredInLegend($event)"
|
||||
></pi-chart-legend>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal file
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart,
|
||||
Filler,
|
||||
Legend,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import { hexToRgba, lineDatasetConfig } from '../chart.configs';
|
||||
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-line-chart',
|
||||
templateUrl: './line-chart.component.html',
|
||||
styleUrls: ['./line-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiChartLegendComponent],
|
||||
})
|
||||
export class PiLineChartComponent extends PiBaseCartesianChartComponent {
|
||||
/** Rellena el área bajo la línea (gráfico de área) en vez de solo trazarla. */
|
||||
readonly filled = input(false);
|
||||
|
||||
get completedDatasets() {
|
||||
const datasets = this.translatedDatasets().map((dataset) => ({
|
||||
...dataset,
|
||||
...lineDatasetConfig,
|
||||
}));
|
||||
if (!this.filled()) {
|
||||
return datasets;
|
||||
}
|
||||
// Área suave en vez de un bloque sólido: la misma marca del dataset,
|
||||
// pero translúcida. `pointBackgroundColor` conserva el color opaco
|
||||
// original para que los puntos no salgan huecos por reusar
|
||||
// `backgroundColor` para el área.
|
||||
return datasets.map((dataset) => {
|
||||
const solidColor = (dataset.backgroundColor ??
|
||||
dataset.borderColor) as string;
|
||||
return {
|
||||
...dataset,
|
||||
fill: true,
|
||||
backgroundColor: hexToRgba(solidColor, 0.15),
|
||||
pointBackgroundColor: solidColor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
protected buildChart(): Chart {
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: this.xAxisMarks(),
|
||||
datasets: this.completedDatasets,
|
||||
},
|
||||
options: this.getCartesianOptions('line'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal file
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ArcElement, Chart, Legend, PieController, Tooltip } from 'chart.js';
|
||||
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
|
||||
|
||||
Chart.register(ArcElement, PieController, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-pie-chart',
|
||||
templateUrl: './pie-chart.component.html',
|
||||
styleUrls: ['./pie-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PiPieChartComponent extends PiBaseArcChartComponent {
|
||||
protected buildChart(): Chart {
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'pie',
|
||||
data: this.getArcData<'pie'>(),
|
||||
options: this.getArcOptions<'pie'>(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
<pi-chart-legend
|
||||
[datasets]="datasets()"
|
||||
[isComparativeChart]="isComparativeChart()"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex()"
|
||||
(triggerDatasetHover)="datasetHoveredInLegend($event)"
|
||||
></pi-chart-legend>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal file
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import {
|
||||
ActiveElement,
|
||||
Chart,
|
||||
ChartEvent,
|
||||
ChartOptions,
|
||||
Filler,
|
||||
Legend,
|
||||
LineElement,
|
||||
PointElement,
|
||||
RadarController,
|
||||
RadialLinearScale,
|
||||
Tooltip,
|
||||
} from 'chart.js';
|
||||
import { Dataset, DatasetColor } from '../../../util/models';
|
||||
import { hexToRgba, lineDatasetConfig, tooltipConfig } from '../chart.configs';
|
||||
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||
|
||||
Chart.register(
|
||||
RadarController,
|
||||
RadialLinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
/**
|
||||
* A diferencia de `pi-line-chart`/`pi-bar-chart`, la escala radial (`r`) no
|
||||
* encaja en `scalesConfig` (pensado para `x`/`y`), así que no reutiliza
|
||||
* `getCartesianOptions` de `PiBaseCartesianChartComponent` — sí reutiliza el
|
||||
* resto (hover por dataset, leyenda propia, tooltip).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'pi-radar-chart',
|
||||
templateUrl: './radar-chart.component.html',
|
||||
styleUrls: ['./radar-chart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiChartLegendComponent],
|
||||
})
|
||||
export class PiRadarChartComponent extends PiBaseCartesianChartComponent {
|
||||
/** Igual que `pi-line-chart[filled]`: relleno translúcido bajo cada polígono. */
|
||||
protected get completedDatasets() {
|
||||
return this.translatedDatasets().map((dataset) => {
|
||||
const solidColor = (dataset.backgroundColor ??
|
||||
dataset.borderColor) as string;
|
||||
return {
|
||||
...dataset,
|
||||
...lineDatasetConfig,
|
||||
fill: true,
|
||||
backgroundColor: hexToRgba(solidColor, 0.15),
|
||||
pointBackgroundColor: solidColor,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getRadarOptions(): ChartOptions<'radar'> {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: this.device() === 'DESKTOP',
|
||||
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
|
||||
this.hoveredDatasetIndex.set(
|
||||
activeElements.length ? activeElements[0].datasetIndex : -1,
|
||||
);
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
...tooltipConfig,
|
||||
callbacks: {
|
||||
labelTextColor: (context): string =>
|
||||
context.dataset.borderColor as string,
|
||||
label: (context): string => {
|
||||
const dataset = context.dataset as Dataset;
|
||||
return `${dataset.label}: ${context.formattedValue}${dataset.unit ?? ''}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
grid: { color: DatasetColor.grey50 },
|
||||
angleLines: { color: DatasetColor.grey75 },
|
||||
pointLabels: {
|
||||
font: { family: 'Soehne', size: 12 },
|
||||
color: DatasetColor.grey300,
|
||||
},
|
||||
ticks: { display: false },
|
||||
},
|
||||
},
|
||||
} as ChartOptions<'radar'>;
|
||||
}
|
||||
|
||||
protected buildChart(): Chart {
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels: this.xAxisMarks(),
|
||||
datasets: this.completedDatasets,
|
||||
},
|
||||
options: this.getRadarOptions(),
|
||||
});
|
||||
}
|
||||
}
|
||||
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal file
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Tamaño del lienzo, compartido por los 4 gráficos (line/bar/pie/doughnut).
|
||||
* Cada componente lo incluye con `@include chartCanvas;` en su propio
|
||||
* `:host` — así siguen siendo ficheros independientes sin repetir el CSS.
|
||||
*/
|
||||
@mixin chartCanvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin chartCanvasResponsive {
|
||||
html {
|
||||
&:not(.is-desktop) {
|
||||
:host {
|
||||
& > div {
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
max-height: 40vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.is-desktop {
|
||||
:host {
|
||||
& > div {
|
||||
& > canvas {
|
||||
aspect-ratio: 1;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export class PiDataTableItemComponent implements AfterViewInit {
|
||||
readonly justify = input<justify>('flex-start');
|
||||
readonly borderType = input<borderType>();
|
||||
|
||||
readonly item = contentChild<TemplateRef<any>>('item');
|
||||
readonly item = contentChild<TemplateRef<unknown>>('item');
|
||||
readonly componentItem = viewChild<ElementRef>('componentItem');
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
|
||||
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Component, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { DrawerConfigKeys } from '../entities/drawer-configs';
|
||||
import { getDrawerConfigs } from '../utils';
|
||||
import { PiDrawerComponent } from './drawer.component';
|
||||
|
||||
@Component({
|
||||
template: `<ng-template #content><p class="drawer-content">Hola</p></ng-template>`,
|
||||
})
|
||||
class HostComponent {
|
||||
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
|
||||
}
|
||||
|
||||
describe('PiDrawerComponent', () => {
|
||||
let hostFixture: ComponentFixture<HostComponent>;
|
||||
let dialog: MatDialog;
|
||||
let overlayContainerElement: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent],
|
||||
providers: [provideTranslateService()],
|
||||
}).compileComponents();
|
||||
|
||||
hostFixture = TestBed.createComponent(HostComponent);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
overlayContainerElement = TestBed.inject(
|
||||
OverlayContainer,
|
||||
).getContainerElement();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dialog.closeAll();
|
||||
});
|
||||
|
||||
it('renders the title and the projected template content', () => {
|
||||
dialog.open(
|
||||
PiDrawerComponent,
|
||||
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||
title: 'Detalle',
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(overlayContainerElement.textContent).toContain('Detalle');
|
||||
expect(overlayContainerElement.querySelector('.drawer-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies the right-side panel class for DrawerConfigKeys.Right', () => {
|
||||
dialog.open(
|
||||
PiDrawerComponent,
|
||||
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||
expect(pane?.classList.contains('pi-drawer__panel--right')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the left-side panel class for DrawerConfigKeys.Left', () => {
|
||||
dialog.open(
|
||||
PiDrawerComponent,
|
||||
getDrawerConfigs(DrawerConfigKeys.Left, {
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||
expect(pane?.classList.contains('pi-drawer__panel--left')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the title header when none is provided', () => {
|
||||
dialog.open(
|
||||
PiDrawerComponent,
|
||||
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(overlayContainerElement.querySelector('header span')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the dialog when the close button is clicked', fakeAsync(() => {
|
||||
const dialogRef = dialog.open(
|
||||
PiDrawerComponent,
|
||||
getDrawerConfigs(DrawerConfigKeys.Right, {
|
||||
title: 'Detalle',
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const closeSpy = jest.fn();
|
||||
dialogRef.afterClosed().subscribe(closeSpy);
|
||||
|
||||
const closeButton: HTMLElement | null =
|
||||
overlayContainerElement.querySelector('pi-close-button label');
|
||||
closeButton?.click();
|
||||
hostFixture.detectChanges();
|
||||
tick(500);
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
@@ -2,5 +2,5 @@ import { TemplateRef } from '@angular/core';
|
||||
|
||||
export interface DrawerData {
|
||||
title?: string;
|
||||
template: TemplateRef<any>;
|
||||
template: TemplateRef<unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<pi-icon [icon]="icon()" size="x-large" color="grey" />
|
||||
<h4>{{ title() }}</h4>
|
||||
@if (description()) {
|
||||
<p>{{ description() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
@@ -0,0 +1,23 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: var(--pi-spacing-xx-small);
|
||||
padding: var(--pi-spacing-xx-large) var(--pi-spacing-large);
|
||||
|
||||
h4 {
|
||||
margin: var(--pi-spacing-xx-small) 0 0;
|
||||
color: var(--pi-color-black);
|
||||
font-size: var(--pi-font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--pi-color-grey);
|
||||
font-size: var(--pi-font-size-x-small);
|
||||
max-width: 30ch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatIconTestingModule } from '@angular/material/icon/testing';
|
||||
|
||||
import { PiEmptyStateComponent } from './empty-state.component';
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<pi-empty-state [title]="title" [description]="description">
|
||||
<button class="projected-action">Limpiar filtros</button>
|
||||
</pi-empty-state>
|
||||
`,
|
||||
imports: [PiEmptyStateComponent],
|
||||
})
|
||||
class HostComponent {
|
||||
title = 'Sin resultados';
|
||||
description?: string = 'No hay elementos que coincidan con el filtro.';
|
||||
}
|
||||
|
||||
describe('PiEmptyStateComponent', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent, MatIconTestingModule],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
});
|
||||
|
||||
it('renders the title and description', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('h4')?.textContent?.trim()).toBe(
|
||||
'Sin resultados',
|
||||
);
|
||||
expect(fixture.nativeElement.querySelector('p')?.textContent?.trim()).toBe(
|
||||
'No hay elementos que coincidan con el filtro.',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the description paragraph when none is provided', () => {
|
||||
fixture.componentInstance.description = undefined;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('p')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the projected action', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.projected-action')?.textContent?.trim(),
|
||||
).toBe('Limpiar filtros');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostBinding,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
import { PiIconComponent } from '../../icon/icon/icon.component';
|
||||
|
||||
/**
|
||||
* Placeholder para listas/tablas/data-grid sin resultados. El icono, título y
|
||||
* descripción son fijos; la acción (p. ej. "Limpiar filtros") se proyecta.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'pi-empty-state',
|
||||
templateUrl: './empty-state.component.html',
|
||||
styleUrls: ['./empty-state.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiIconComponent],
|
||||
})
|
||||
export class PiEmptyStateComponent {
|
||||
@HostBinding('class') className = 'pi-empty-state';
|
||||
|
||||
readonly icon = input('search');
|
||||
readonly title = input.required<string>();
|
||||
readonly description = input<string>();
|
||||
}
|
||||
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './empty-state/empty-state.component';
|
||||
@@ -0,0 +1,50 @@
|
||||
<mat-form-field [appearance]="appearance()" floatLabel="always">
|
||||
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
|
||||
|
||||
<input
|
||||
matInput
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
[attr.aria-expanded]="isOpen()"
|
||||
[attr.aria-controls]="listboxId"
|
||||
[attr.aria-activedescendant]="
|
||||
activeIndex() > -1 ? listboxId + '-option-' + activeIndex() : null
|
||||
"
|
||||
[value]="value()"
|
||||
(input)="onInput($any($event.target).value)"
|
||||
(keydown)="onKeydown($event)"
|
||||
(focus)="onFocus()"
|
||||
(blur)="onBlur()"
|
||||
[placeholder]="placeholder()!"
|
||||
[disabled]="disabled()"
|
||||
/>
|
||||
|
||||
@if (enableErrorMessage() && hasError()) {
|
||||
<mat-error>
|
||||
@for (message of errorMessages(); track message) {
|
||||
{{ message | translate }}
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (isOpen()) {
|
||||
<ul class="pi-autocomplete-field__panel" [id]="listboxId" role="listbox">
|
||||
@for (option of filteredOptions(); track option; let i = $index) {
|
||||
<li
|
||||
[id]="listboxId + '-option-' + i"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
[attr.aria-selected]="i === activeIndex()"
|
||||
[class.is-active]="i === activeIndex()"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="selectOption(option)"
|
||||
(keydown.enter)="selectOption(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="pi-autocomplete-field__empty">{{ noResultsLabel() | translate }}</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pi-autocomplete-field__panel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
margin: var(--pi-spacing-xxxx-small) 0 0;
|
||||
padding: var(--pi-spacing-xxxx-small) 0;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
background: var(--pi-color-white);
|
||||
border-radius: var(--pi-border-radius-medium);
|
||||
box-shadow: var(--pi-box-shadow-panel);
|
||||
|
||||
li {
|
||||
padding: var(--pi-spacing-small) var(--pi-spacing-medium);
|
||||
font-size: var(--pi-font-size-small);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
background: var(--pi-color-primary-25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pi-autocomplete-field__empty {
|
||||
color: var(--pi-color-grey-300);
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatError,
|
||||
} from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
import { PiBaseFormFieldComponent } from '../base-form-field.component';
|
||||
|
||||
/**
|
||||
* Campo de texto con sugerencias filtradas mientras se escribe. El menú es
|
||||
* un `<ul>` propio con `position: absolute` (mismo criterio que el menú de
|
||||
* `pi-select`) en vez de `mat-autocomplete`/CDK Overlay: no hace falta
|
||||
* escapar contenedores con `overflow: hidden` para este caso de uso.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'pi-autocomplete-field',
|
||||
templateUrl: './autocomplete-field.component.html',
|
||||
styleUrls: ['./autocomplete-field.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatFormField, MatLabel, MatInput, MatError, TranslatePipe],
|
||||
})
|
||||
export class PiAutocompleteFieldComponent extends PiBaseFormFieldComponent<string> {
|
||||
@HostBinding('class') className = 'pi-form-field__autocomplete';
|
||||
|
||||
private static nextId = 0;
|
||||
|
||||
readonly options = input.required<readonly string[]>();
|
||||
readonly noResultsLabel = input('Sin resultados');
|
||||
|
||||
protected readonly listboxId = `pi-autocomplete-listbox-${PiAutocompleteFieldComponent.nextId++}`;
|
||||
|
||||
protected readonly isOpen = signal(false);
|
||||
protected readonly activeIndex = signal(-1);
|
||||
|
||||
private readonly el = inject(ElementRef);
|
||||
|
||||
protected readonly filteredOptions = computed(() => {
|
||||
const query = this.value().trim().toLowerCase();
|
||||
if (!query) return this.options();
|
||||
return this.options().filter((option) =>
|
||||
option.toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onDocumentClick(event: MouseEvent): void {
|
||||
if (!this.el.nativeElement.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
protected onInput(text: string): void {
|
||||
this.value.set(text);
|
||||
this.activeIndex.set(-1);
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
protected onKeydown(event: KeyboardEvent): void {
|
||||
if (
|
||||
!this.isOpen() &&
|
||||
(event.key === 'ArrowDown' || event.key === 'ArrowUp')
|
||||
) {
|
||||
this.isOpen.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = this.filteredOptions();
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
this.activeIndex.update((index) =>
|
||||
Math.min(index + 1, options.length - 1),
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
this.activeIndex.update((index) => Math.max(index - 1, 0));
|
||||
break;
|
||||
case 'Enter': {
|
||||
const active = options[this.activeIndex()];
|
||||
if (active) {
|
||||
event.preventDefault();
|
||||
this.selectOption(active);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected selectOption(option: string): void {
|
||||
this.value.set(option);
|
||||
this.touch.emit();
|
||||
this.close();
|
||||
}
|
||||
|
||||
override onFocus(): void {
|
||||
super.onFocus();
|
||||
this.isOpen.set(true);
|
||||
}
|
||||
|
||||
override onBlur(): void {
|
||||
super.onBlur();
|
||||
this.close();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.isOpen.set(false);
|
||||
this.activeIndex.set(-1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<mat-form-field [appearance]="appearance()" floatLabel="always">
|
||||
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
|
||||
|
||||
<mat-date-range-input
|
||||
[rangePicker]="picker"
|
||||
[min]="min() ?? null"
|
||||
[max]="max() ?? null"
|
||||
[disabled]="disabled()"
|
||||
>
|
||||
<input
|
||||
matStartDate
|
||||
[value]="value().start"
|
||||
[placeholder]="startPlaceholder() | translate"
|
||||
(dateChange)="onStartChange($any($event).value)"
|
||||
(focus)="onFocus()"
|
||||
/>
|
||||
<input
|
||||
matEndDate
|
||||
[value]="value().end"
|
||||
[placeholder]="endPlaceholder() | translate"
|
||||
(dateChange)="onEndChange($any($event).value)"
|
||||
(blur)="onBlur()"
|
||||
/>
|
||||
</mat-date-range-input>
|
||||
|
||||
<mat-datepicker-toggle matSuffix [for]="picker" />
|
||||
<mat-date-range-picker #picker />
|
||||
</mat-form-field>
|
||||
|
||||
@if (enableErrorMessage() && hasError()) {
|
||||
<mat-error>
|
||||
@for (message of errorMessages(); track message) {
|
||||
{{ message | translate }}
|
||||
}
|
||||
</mat-error>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ChangeDetectionStrategy, Component, HostBinding, input } from '@angular/core';
|
||||
import {
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatSuffix,
|
||||
MatError,
|
||||
} from '@angular/material/form-field';
|
||||
import {
|
||||
MatDateRangeInput,
|
||||
MatStartDate,
|
||||
MatEndDate,
|
||||
MatDateRangePicker,
|
||||
MatDatepickerToggle,
|
||||
} from '@angular/material/datepicker';
|
||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
import { PiBaseFormFieldComponent } from '../base-form-field.component';
|
||||
|
||||
export interface DateRange {
|
||||
start: Date | null;
|
||||
end: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rango de fechas, hermano de `pi-date-field`: mismo `provideNativeDateAdapter`
|
||||
* (el proyecto no tiene adaptador de luxon/moment) y misma herencia de
|
||||
* `PiBaseFormFieldComponent`, pero con `value` como `{ start, end }` en vez de
|
||||
* `Date | null` — `mat-date-range-input` necesita dos `<input>` (`matStartDate`
|
||||
* / `matEndDate`), no uno.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'pi-date-range-field',
|
||||
templateUrl: './date-range-field.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [provideNativeDateAdapter()],
|
||||
imports: [
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatSuffix,
|
||||
MatDateRangeInput,
|
||||
MatStartDate,
|
||||
MatEndDate,
|
||||
MatDateRangePicker,
|
||||
MatDatepickerToggle,
|
||||
MatError,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class PiDateRangeFieldComponent extends PiBaseFormFieldComponent<DateRange> {
|
||||
@HostBinding('class') className = 'pi-form-field__date-range';
|
||||
|
||||
readonly min = input<Date | undefined>(undefined);
|
||||
readonly max = input<Date | undefined>(undefined);
|
||||
|
||||
readonly startPlaceholder = input('Desde');
|
||||
readonly endPlaceholder = input('Hasta');
|
||||
|
||||
protected onStartChange(start: Date | null): void {
|
||||
this.value.set({ ...this.value(), start });
|
||||
}
|
||||
|
||||
protected onEndChange(end: Date | null): void {
|
||||
this.value.set({ ...this.value(), end });
|
||||
this.touch.emit();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from './date-field/date-field.component';
|
||||
export * from './date-range-field/date-range-field.component';
|
||||
export * from './autocomplete-field/autocomplete-field.component';
|
||||
export * from './checkbox/checkbox.component';
|
||||
export * from './radio-group/radio-group.component';
|
||||
export * from './directives/label.directive';
|
||||
|
||||
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal file
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { MatDialogConfig } from '@angular/material/dialog';
|
||||
|
||||
export enum HubConfigKeys {
|
||||
Default = 'default',
|
||||
Full = 'full',
|
||||
}
|
||||
|
||||
export type HubConfigs = {
|
||||
[key in HubConfigKeys]: MatDialogConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* `enterAnimationDuration`/`exitAnimationDuration` a `0ms`: igual que
|
||||
* `pi-drawer`, la animación de `MatDialog` (fade + scale desde el centro) no
|
||||
* encaja con un panel anclado a un borde. El deslizamiento real lo hace
|
||||
* `_mat-hub.scss` sobre `panelClass`, fuera del ciclo de animación de
|
||||
* Material. Solo se fija `position.bottom`: al dejar `left`/`right` sin
|
||||
* definir, el propio `cdk-global-overlay-wrapper` centra el panel en
|
||||
* horizontal por defecto.
|
||||
*/
|
||||
export const hubConfigs: HubConfigs = {
|
||||
[HubConfigKeys.Default]: {
|
||||
width: '100vw',
|
||||
maxWidth: '34rem',
|
||||
maxHeight: '80vh',
|
||||
position: { bottom: '0' },
|
||||
backdropClass: 'pi-backdrop',
|
||||
panelClass: ['pi-hub__panel'],
|
||||
enterAnimationDuration: '0ms',
|
||||
exitAnimationDuration: '0ms',
|
||||
},
|
||||
[HubConfigKeys.Full]: {
|
||||
width: '100vw',
|
||||
maxWidth: '34rem',
|
||||
height: '92vh',
|
||||
maxHeight: '92vh',
|
||||
position: { bottom: '0' },
|
||||
backdropClass: 'pi-backdrop',
|
||||
panelClass: ['pi-hub__panel', 'pi-hub__panel--full'],
|
||||
enterAnimationDuration: '0ms',
|
||||
exitAnimationDuration: '0ms',
|
||||
},
|
||||
};
|
||||
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TemplateRef } from '@angular/core';
|
||||
|
||||
export interface HubData {
|
||||
title?: string;
|
||||
template: TemplateRef<unknown>;
|
||||
}
|
||||
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal file
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="pi-hub__grabber" aria-hidden="true"></div>
|
||||
|
||||
<header>
|
||||
@if (data.title) {
|
||||
<span bold l>{{ data.title }}</span>
|
||||
}
|
||||
<pi-close-button size="small" [hasLabel]="false" (click)="close()" />
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<ng-container [ngTemplateOutlet]="data.template" />
|
||||
</main>
|
||||
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal file
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.pi-hub__grabber {
|
||||
flex: none;
|
||||
width: 2.5rem;
|
||||
height: 0.25rem;
|
||||
margin: var(--pi-spacing-small) auto 0;
|
||||
border-radius: var(--pi-border-radius-x-small);
|
||||
background-color: var(--pi-color-grey-100);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--pi-spacing-medium);
|
||||
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-large);
|
||||
border-bottom: 0.0625rem solid var(--pi-color-grey-100);
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--pi-spacing-x-large);
|
||||
}
|
||||
}
|
||||
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Component, TemplateRef, ViewChild } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { OverlayContainer } from '@angular/cdk/overlay';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { provideTranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { HubConfigKeys } from '../entities/hub-configs';
|
||||
import { getHubConfigs } from '../utils';
|
||||
import { PiHubComponent } from './hub.component';
|
||||
|
||||
@Component({
|
||||
template: `<ng-template #content><p class="hub-content">Hola</p></ng-template>`,
|
||||
})
|
||||
class HostComponent {
|
||||
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
|
||||
}
|
||||
|
||||
describe('PiHubComponent', () => {
|
||||
let hostFixture: ComponentFixture<HostComponent>;
|
||||
let dialog: MatDialog;
|
||||
let overlayContainerElement: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent],
|
||||
providers: [provideTranslateService()],
|
||||
}).compileComponents();
|
||||
|
||||
hostFixture = TestBed.createComponent(HostComponent);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
dialog = TestBed.inject(MatDialog);
|
||||
overlayContainerElement = TestBed.inject(
|
||||
OverlayContainer,
|
||||
).getContainerElement();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dialog.closeAll();
|
||||
});
|
||||
|
||||
it('renders the title and the projected template content', () => {
|
||||
dialog.open(
|
||||
PiHubComponent,
|
||||
getHubConfigs(HubConfigKeys.Default, {
|
||||
title: 'Detalle',
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(overlayContainerElement.textContent).toContain('Detalle');
|
||||
expect(overlayContainerElement.querySelector('.hub-content')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('applies the panel class for HubConfigKeys.Default', () => {
|
||||
dialog.open(
|
||||
PiHubComponent,
|
||||
getHubConfigs(HubConfigKeys.Default, {
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||
expect(pane?.classList.contains('pi-hub__panel')).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the full panel class for HubConfigKeys.Full', () => {
|
||||
dialog.open(
|
||||
PiHubComponent,
|
||||
getHubConfigs(HubConfigKeys.Full, {
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
|
||||
expect(pane?.classList.contains('pi-hub__panel--full')).toBe(true);
|
||||
});
|
||||
|
||||
it('omits the title header when none is provided', () => {
|
||||
dialog.open(
|
||||
PiHubComponent,
|
||||
getHubConfigs(HubConfigKeys.Default, {
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
expect(overlayContainerElement.querySelector('header span')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the dialog when the close button is clicked', fakeAsync(() => {
|
||||
const dialogRef = dialog.open(
|
||||
PiHubComponent,
|
||||
getHubConfigs(HubConfigKeys.Default, {
|
||||
title: 'Detalle',
|
||||
template: hostFixture.componentInstance.content,
|
||||
}),
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
|
||||
const closeSpy = jest.fn();
|
||||
dialogRef.afterClosed().subscribe(closeSpy);
|
||||
|
||||
const closeButton: HTMLElement | null =
|
||||
overlayContainerElement.querySelector('pi-close-button label');
|
||||
closeButton?.click();
|
||||
hostFixture.detectChanges();
|
||||
tick(500);
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal file
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
|
||||
import { HubData } from '../entities/hub.model';
|
||||
import { PiCloseButtonComponent } from '../../button/close-button/close-button.component';
|
||||
import { PiTextWeightBoldDirective } from '../../text/directives/text-weights.directive';
|
||||
import { PiTextLargeDirective } from '../../text/directives/text-sizes.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'pi-hub',
|
||||
templateUrl: './hub.component.html',
|
||||
styleUrls: ['./hub.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
PiCloseButtonComponent,
|
||||
PiTextWeightBoldDirective,
|
||||
PiTextLargeDirective,
|
||||
],
|
||||
})
|
||||
export class PiHubComponent {
|
||||
@HostBinding('class') className = 'pi-hub';
|
||||
|
||||
readonly dialogRef = inject<MatDialogRef<PiHubComponent>>(MatDialogRef);
|
||||
readonly data = inject<HubData>(MAT_DIALOG_DATA);
|
||||
|
||||
close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
9
libs/ui/src/lib/ui/hub/index.ts
Normal file
9
libs/ui/src/lib/ui/hub/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Component
|
||||
export * from './hub/hub.component';
|
||||
|
||||
// Entities
|
||||
export * from './entities/hub.model';
|
||||
export * from './entities/hub-configs';
|
||||
|
||||
// Utils
|
||||
export * from './utils';
|
||||
16
libs/ui/src/lib/ui/hub/utils.ts
Normal file
16
libs/ui/src/lib/ui/hub/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { MatDialogConfig } from '@angular/material/dialog';
|
||||
import { HubConfigKeys, hubConfigs } from './entities/hub-configs';
|
||||
import { HubData } from './entities/hub.model';
|
||||
|
||||
// Hub config
|
||||
export function getHubConfigs(
|
||||
key: HubConfigKeys,
|
||||
data: HubData,
|
||||
disableClose?: boolean,
|
||||
): MatDialogConfig<HubData> {
|
||||
return {
|
||||
...(disableClose && { disableClose }),
|
||||
...hubConfigs[key],
|
||||
data,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
@if (type() === 'custom') {
|
||||
<img src="/assets/icons/{{ icon() }}.svg" />
|
||||
} @else {
|
||||
<mat-icon size="size" [svgIcon]="icon()!" color="color"></mat-icon>
|
||||
<mat-icon [svgIcon]="icon()!"></mat-icon>
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin defaultIconColor {
|
||||
@mixin defaultIconColor($color) {
|
||||
.mat-icon {
|
||||
color: var(--pi-color-primary);
|
||||
color: $color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,26 @@
|
||||
&.pi-icon__x-large {
|
||||
@include defaultIconSize(var(--pi-spacing-xx-large));
|
||||
}
|
||||
|
||||
// Color (la apariencia "fill" tiene la suya, más abajo)
|
||||
&.pi-color__primary {
|
||||
@include defaultIconColor(var(--pi-color-primary));
|
||||
}
|
||||
&.pi-color__accent {
|
||||
@include defaultIconColor(var(--pi-color-accent));
|
||||
}
|
||||
&.pi-color__warn {
|
||||
@include defaultIconColor(var(--pi-color-warn));
|
||||
}
|
||||
&.pi-color__highlighted {
|
||||
@include defaultIconColor(var(--pi-color-highlighted));
|
||||
}
|
||||
&.pi-color__white {
|
||||
@include defaultIconColor(var(--pi-color-white));
|
||||
}
|
||||
&.pi-color__grey {
|
||||
@include defaultIconColor(var(--pi-color-grey));
|
||||
}
|
||||
}
|
||||
|
||||
&.pi-icon__appearance--fill {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user