fix components

This commit is contained in:
2026-07-30 11:56:57 +02:00
parent c446aa2b04
commit 85886a094a
156 changed files with 4180 additions and 7466 deletions

View File

@@ -5,7 +5,10 @@ import {
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { routes } from './app.routes';
import {
TranslateLoader,
TranslationObject,
@@ -48,6 +51,12 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(),
provideAnimationsAsync(),
provideRouter(
routes,
// Al cambiar de página se vuelve arriba; el catálogo es muy largo.
withInMemoryScrolling({ scrollPositionRestoration: 'top' }),
),
// Los iconos son SVG registrados en el MatIconRegistry (assets/icons).
provideAppInitializer(() => inject(IconRegistryService).init()),

View File

@@ -0,0 +1,48 @@
<header class="page-header">
<div class="page-header__top">
<div>
<h1>@pi/ui</h1>
<p>
Catálogo de la librería migrada a Angular 22: componentes standalone,
señales, OnPush y Signal Forms.
</p>
</div>
<div class="theme-toggles">
<label class="theme-toggle">
<span>Tema alternativo</span>
<pi-slide-toggle [(checked)]="alternativeTheme" />
</label>
<label class="theme-toggle">
<span>Modo oscuro</span>
<pi-slide-toggle [(checked)]="darkMode" />
</label>
</div>
</div>
<nav class="page-nav">
@for (link of navLinks; track link.path) {
<a
[routerLink]="link.path"
routerLinkActive="is-active"
[routerLinkActiveOptions]="{ exact: true }"
>{{ link.label }}</a
>
}
</nav>
<p class="theme-hint">
Tema:
<strong>{{ alternativeTheme() ? 'alternative' : 'default' }}</strong> ·
<code
>{{ alternativeTheme() ? 'pi-shell__alternative' : 'pi-shell__default'
}}</code
>
en el <code>&lt;body&gt;</code>. Los dos temas comparten exactamente los
mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y
<code>--pi-theme-*</code> que definen <code>themes/_default.scss</code> y
<code>themes/_alternative.scss</code>.
</p>
</header>
<router-outlet />

View File

@@ -0,0 +1,18 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'componentes' },
{
path: 'componentes',
title: 'Componentes · @pi/ui',
loadComponent: () =>
import('./pages/catalog/catalog.page').then((m) => m.CatalogPage),
},
{
path: 'layouts',
title: 'Layouts · @pi/ui',
loadComponent: () =>
import('./pages/layouts/layouts.page').then((m) => m.LayoutsPage),
},
{ path: '**', redirectTo: 'componentes' },
];

View File

@@ -0,0 +1,85 @@
:host {
display: block;
max-width: 1400px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.page-header {
margin-bottom: 2rem;
&__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
}
h1 {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: var(--pg-heading);
}
p {
margin: 0.5rem 0 0;
color: var(--pg-text-muted);
max-width: 60ch;
}
}
.theme-toggles {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
flex-shrink: 0;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
font-size: 0.85rem;
color: var(--pg-text-muted);
white-space: nowrap;
}
.theme-hint {
margin: 0.75rem 0 0;
font-size: 0.8rem;
color: var(--pg-text-muted);
max-width: 70ch;
code {
padding: 0.05rem 0.3rem;
border-radius: 4px;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-size: 0.95em;
}
}
.page-nav {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
a {
padding: 0.35rem 0.9rem;
border-radius: 999px;
border: 1px solid var(--pg-card-border);
color: var(--pg-text-muted);
font-size: 0.85rem;
text-decoration: none;
&.is-active {
border-color: transparent;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-weight: 600;
}
}
}

View File

@@ -0,0 +1,59 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
} from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { PiSlideToggleComponent, ShellClass } from '@pi/ui';
/**
* Shell del playground: cabecera, navegación y estado de tema. El contenido de
* cada página vive en `pages/` y entra por el `<router-outlet>`.
*/
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html',
styleUrl: './app.scss',
imports: [RouterOutlet, RouterLink, RouterLinkActive, PiSlideToggleComponent],
})
export class App {
protected readonly navLinks = [
{ path: '/componentes', label: 'Componentes' },
{ path: '/layouts', label: 'Layouts' },
];
/* --- Tema y modo oscuro -------------------------------------------------- */
private readonly document = inject(DOCUMENT);
protected readonly darkMode = signal(false);
/**
* Tema visual de la librería. `ShellClass` devuelve las dos clases que hay
* que poner juntas (`pi-shell pi-shell__<tema>`); van en el `<body>` porque
* los overlays de Material (diálogos, snackbars) se montan fuera de
* `<app-root>` y también deben heredarlas.
*/
protected readonly alternativeTheme = signal(false);
constructor() {
effect(() => {
this.document.documentElement.classList.toggle(
'dark-mode',
this.darkMode(),
);
});
effect(() => {
const shell = this.alternativeTheme()
? ShellClass.Alternative
: ShellClass.Default;
this.document.body.className = shell;
});
}
}

View File

@@ -1,11 +1,4 @@
<header class="page-header">
<h1>@pi/ui</h1>
<p>
Catálogo de la librería migrada a Angular 22: componentes standalone,
señales, OnPush y Signal Forms.
</p>
<span class="event">Último evento: {{ lastEvent() }}</span>
</header>
<span class="event">Último evento: {{ lastEvent() }}</span>
<main>
<div class="col">
@@ -48,6 +41,18 @@
<pi-search-field [formField]="f.search" placeholder="Buscar…" />
<pi-icon-field [formField]="f.website" icon="globe">
<pi-label>Web</pi-label>
</pi-icon-field>
<pi-phone-field [formField]="f.phone">
<pi-label>Teléfono</pi-label>
</pi-phone-field>
<pi-number-field [formField]="f.weight">
<pi-label>Peso (kg)</pi-label>
</pi-number-field>
<label class="inline">
<span>Newsletter</span>
<pi-slide-toggle [formField]="f.newsletter" />
@@ -59,6 +64,45 @@
label="Volumen"
[maxValue]="100"
/>
<div class="range-slider-caption">
<span
>Rango de precio (pi-range-slider no pinta su propia etiqueta)</span
>
<pi-range-slider
[formField]="f.priceRange"
unit="€"
[minValue]="0"
[maxValue]="100"
/>
</div>
<pi-date-field [formField]="f.birthDate">
<pi-label>Fecha de nacimiento</pi-label>
</pi-date-field>
<pi-radio-group
[formField]="f.shipping"
[options]="shippingOptions"
[columns]="1"
/>
<pi-checkbox [formField]="f.terms" label="Acepto los términos" />
<pi-file-upload
[formField]="f.attachments"
[multiple]="true"
accept="image/*,.pdf"
[maxSizeBytes]="2097152"
[maxFiles]="3"
label="Arrastra ficheros o pulsa para elegir"
hint="Imágenes o PDF · máx. 2 MB · hasta 3"
(rejected)="log('rechazados: ' + $event.length)"
/>
<button pi-primary w100 (click)="submitForm()">
Enviar (muestra los errores)
</button>
</div>
</app-section>
</div>
@@ -87,6 +131,24 @@
<pi-chevron-button (toggle)="log('chevron')" />
<pi-close-button (toggle)="log('close')" />
<pi-hamburger (toggle)="log('hamburger')" />
<pi-radio-button
[isActive]="radioActive()"
[isDisabled]="false"
(execute)="radioActive.set(!radioActive())"
/>
<pi-switch-button
[content]="['Mensual', 'Anual']"
contentType="text"
[(isActive)]="switchActive"
(toggledTo)="log('switch ' + $event)"
/>
<pi-toggle-button
text="Favorito"
icon="star-fill"
[isActive]="toggleActive()"
[isDisabled]="false"
(execute)="toggleActive.set(!toggleActive())"
/>
</app-section>
<app-section title="Iconos y texto">
@@ -99,10 +161,58 @@
appearance="fill"
[isStroked]="true"
/>
<pi-icon
icon="info"
color="primary"
size="large"
pi-tooltip
tooltipText="Esto usa PiTooltipDirective"
tooltipPosition="above"
/>
<pi-text-viewer value="Texto con el visor" fontSize="m" color="black" />
<pi-divider color="primary" width="large" label="Separador" />
</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."
>
<div class="stack full">
<pi-headline pathImg="404.svg" alt="Ilustración">
<pi-headline-label-content>
<h4>Bienvenido</h4>
</pi-headline-label-content>
<pi-headline-description-content>
<p>Gestiona tu cuenta desde aquí.</p>
</pi-headline-description-content>
</pi-headline>
<pi-list-item borderType="bottom" color="white" [isClickable]="true">
<pi-list-item-left-icon>
<pi-icon icon="user" color="primary" />
</pi-list-item-left-icon>
<pi-list-item-left-content>
<span>Perfil</span>
</pi-list-item-left-content>
<pi-list-item-right-icon>
<pi-icon icon="chevron-forward" color="grey" />
</pi-list-item-right-icon>
</pi-list-item>
<pi-list-item borderType="bottom" color="white" [isClickable]="true">
<pi-list-item-left-icon>
<pi-icon icon="notification-on" color="primary" />
</pi-list-item-left-icon>
<pi-list-item-left-content>
<span>Notificaciones</span>
</pi-list-item-left-content>
<pi-list-item-right-content>
<pi-chip-status status="pending" label="3" />
</pi-list-item-right-content>
</pi-list-item>
</div>
</app-section>
<app-section title="Chips">
<pi-chip label="Etiqueta" color="primary" />
<pi-chip-status status="success" label="Correcto" />
@@ -114,6 +224,359 @@
[isActive]="true"
(execute)="log('chip-action')"
/>
<pi-chip-time
date="2026-07-28"
time="2026-07-28T09:30:00Z"
[isActive]="false"
/>
<pi-chip-date date="2026-07-28" [isActive]="false" />
<pi-tile label="Ingresos" value="12.4K €" icon="coin" />
</app-section>
<app-section title="Card">
<pi-card
alignment="flex-start"
gap="m"
innerPadding="l"
context="public"
[isClickable]="true"
(execute)="log('card')"
>
<pi-card-top-content>
<h4>Título de la tarjeta</h4>
</pi-card-top-content>
<pi-card-center-content>
<p>Contenido de ejemplo para pi-card.</p>
</pi-card-center-content>
</pi-card>
</app-section>
<app-section
title="Panel"
hint="pi-expansion-panel[type=form] usa Reactive Forms clásico, no Signal Forms."
>
<div class="stack full">
<pi-panel
title="Resumen"
subtitle="Últimos 30 días"
height="medium"
outline="grey"
status=""
innerPadding="l"
>
<pi-panel-center-content>
<p>Contenido central del panel.</p>
</pi-panel-center-content>
<pi-panel-bottom-content>
<button pi-ghost (click)="log('panel bottom action')">
Ver más
</button>
</pi-panel-bottom-content>
</pi-panel>
<pi-expansion-panel
type="form"
title="Datos de contacto"
height="small"
outline="grey"
status=""
innerPadding="l"
[(isCollapsed)]="isPanelCollapsed"
(edit)="log('expansion-panel edit')"
>
<pi-panel-center-content>
<p>{{ f.contactEmail().value() || 'Sin email' }}</p>
</pi-panel-center-content>
<pi-panel-expandable-content>
<input
[formField]="f.contactEmail"
placeholder="email@ejemplo.com"
/>
</pi-panel-expandable-content>
</pi-expansion-panel>
</div>
</app-section>
<app-section title="Accordion">
<div class="stack full">
<pi-accordion
color="primary"
appearance="outline"
[(expandedItemIndex)]="expandedAccordionIndex"
>
<pi-accordion-item
title="Facturación"
subtitle="Datos de pago"
status="info"
statusLabel="Pendiente"
>
<ng-template pi-accordion-item-expandable-content>
<p>Contenido expandible del ítem 1.</p>
</ng-template>
</pi-accordion-item>
<pi-accordion-item
title="Envío"
status="success"
statusLabel="Completo"
>
<ng-template pi-accordion-item-expandable-content>
<p>Detalles de envío.</p>
</ng-template>
</pi-accordion-item>
</pi-accordion>
</div>
</app-section>
<app-section title="Tab">
<div class="stack full">
<pi-tab
[tabButtons]="tabs"
[isSmallDevice]="false"
(tabChange)="log('tab ' + $event)"
>
<ng-template pi-tab-content
>Contenido de la pestaña General.</ng-template
>
<ng-template pi-tab-content
>Contenido de la pestaña Facturación.</ng-template
>
<ng-template pi-tab-content
>Contenido de la pestaña Avanzado.</ng-template
>
</pi-tab>
</div>
</app-section>
<app-section title="Banner">
<div class="stack full">
<pi-banner
mode="dark"
title="Nueva funcionalidad"
message="Descubre el nuevo panel de control."
buttonText="Ver más"
pathImg="404.svg"
extension="svg"
(execute)="log('banner')"
/>
</div>
</app-section>
<app-section title="Note">
<div class="stack full">
<pi-note
title="Aviso"
text="Tu suscripción caduca pronto."
status="pending"
gap="s"
>
<pi-note-action-content>
<button pi-ghost (click)="log('note action')">Renovar</button>
</pi-note-action-content>
</pi-note>
</div>
</app-section>
<app-section
title="Select"
hint="pi-select* usa Signal Forms como el resto: [formField] enlaza el valor completo de la opción elegida. #optionTemplate/#selectedTemplate son content children obligatorios de la librería; sin ellos, Angular lanza NG0951."
>
<div class="stack full">
<pi-select
[formField]="f.country"
keyIdProperty="id"
label="País"
placeholder="Selecciona un país"
[options]="countries"
>
<ng-template #optionTemplate let-option>
<pi-select-option [id]="option.id" [label]="option.name" />
</ng-template>
<ng-template #selectedTemplate></ng-template>
</pi-select>
<pi-select-multiple
[formField]="f.tags"
keyIdProperty="id"
label="Etiquetas"
placeholder="Selecciona etiquetas"
[options]="tags"
[hasSelectAllOptions]="true"
selectAllLabel="Todas"
>
<ng-template #optionTemplate let-option>
<pi-select-option
type="multiple"
[id]="option.id"
[label]="option.name"
/>
</ng-template>
<ng-template #selectedTemplate></ng-template>
</pi-select-multiple>
<pi-select-period
[formField]="f.period"
keyIdProperty="id"
label="Periodo"
placeholder="Selecciona un periodo"
[options]="countries"
>
<ng-template #optionTemplate let-option>
<pi-select-option [id]="option.id" [label]="option.name" />
</ng-template>
<ng-template #selectedTemplate></ng-template>
</pi-select-period>
<pi-select-carousel
[formField]="f.plan"
keyIdProperty="id"
[options]="plans"
[itemsDesktop]="3"
[device]="device"
>
<ng-template #optionTemplate let-option>
<pi-select-option
[id]="option.id"
[label]="option.name"
[description]="option.description"
flexDirection="column"
/>
</ng-template>
<ng-template #selectedTemplate></ng-template>
</pi-select-carousel>
</div>
</app-section>
<app-section
title="Modal y notificaciones"
hint="pi-modal y pi-snack-bar no se usan como etiquetas: se abren desde TypeScript vía MatDialog y NotificationService."
>
<button pi-primary (click)="openDemoModal()">Abrir modal</button>
<button pi-outline (click)="showSuccessNotification()">
Notificación de éxito
</button>
<button pi-outline (click)="showErrorNotification()">
Notificación de error
</button>
</app-section>
<app-section title="Data grid">
<div class="stack full">
<pi-data-grid
[data]="employees"
[columns]="3"
[rows]="1"
[index]="0"
[isActive]="false"
mode="list"
dataDirection="column"
(clickItem)="log('data-grid item')"
>
<ng-template #valuesTemplate let-value let-i="index">
<span pi-cell [index]="i">{{ value.name }} — {{ value.role }}</span>
</ng-template>
</pi-data-grid>
</div>
</app-section>
<app-section title="Data table">
<div class="stack full">
<pi-base-data-table dataDirection="row" type="header">
<pi-data-table-item
heading="Concepto"
[values]="['Cliente', 'Importe', 'Fecha']"
>
<ng-template #item let-value="value">
<pi-cell-type type="text" [display]="value" />
</ng-template>
</pi-data-table-item>
</pi-base-data-table>
@for (row of invoiceRows; track row) {
<pi-base-data-table dataDirection="row" type="data">
<pi-data-table-item [values]="row">
<ng-template #item let-value="value" let-i="index">
<pi-cell-type
[type]="i === 1 ? 'amount' : 'text'"
[display]="value"
/>
</ng-template>
</pi-data-table-item>
</pi-base-data-table>
}
</div>
</app-section>
<app-section title="Chart">
<div class="stack full">
<pi-chart
type="line"
[device]="device"
[xAxisMarks]="['Ene', 'Feb', 'Mar', 'Abr']"
[datasets]="chartDatasets"
/>
</div>
</app-section>
<app-section title="Carousel">
<div class="stack full">
<pi-carousel
[itemsDesktop]="3"
[itemsTablet]="2"
[itemsMobile]="1"
[hideSwipeButtons]="false"
[device]="device"
color="primary"
(itemChanged)="log('carousel ' + $event)"
>
@for (item of carouselItems; track item.id) {
<ng-template pi-carousel-item>
<div class="demo-card">{{ item.label }}</div>
</ng-template>
}
</pi-carousel>
</div>
</app-section>
<app-section
title="Stepper"
hint="Cubre WrapperTypeClass.Steps, que estaba declarado sin componente ni CSS."
>
<div class="stack full">
<pi-stepper
#stepper
[steps]="steps"
[(selectedIndex)]="stepperIndex"
(stepChange)="log('paso ' + $event)"
/>
<div class="inline">
<button pi-ghost small (click)="stepper.previous()">Anterior</button>
<button pi-ghost small (click)="stepper.next()">Siguiente</button>
</div>
</div>
</app-section>
<app-section
title="Dynamic menu"
hint="ComponentFamilyClass ya declaraba la familia dynamic-menu y util/menu traía la clase base; faltaba el componente."
>
<div class="stack full">
<pi-dynamic-menu
label="Navegación"
[menuItems]="menuEntries"
(selectEntry)="log('menú: ' + $any($event).label)"
/>
</div>
</app-section>
<app-section
title="Skeleton"
hint="Resuelve el TODO que quedaba en pi-data-grid; también es el respaldo cuando no se pasa plantilla."
>
<div class="stack full">
<pi-skeleton [lines]="3" />
<pi-skeleton shape="block" width="100%" height="4rem" />
<pi-skeleton shape="circle" width="3rem" />
</div>
</app-section>
<app-section
@@ -130,5 +593,14 @@
(changePage)="log('página ' + $event)"
/>
</app-section>
<app-section
title="Página no encontrada"
hint="pi-not-found: página completa (pi-headline + botón volver), no un widget en línea."
>
<div class="not-found-frame">
<pi-not-found />
</div>
</app-section>
</div>
</main>

View File

@@ -1,36 +1,16 @@
:host {
display: block;
max-width: 1400px;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.page-header {
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 2rem;
font-weight: 700;
color: #5c82e9;
}
p {
margin: 0.5rem 0 0;
color: #6b7280;
max-width: 60ch;
}
.event {
display: inline-block;
margin-top: 0.75rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: #eef2ff;
color: #4c5fd7;
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.event {
display: inline-block;
margin-top: 0.75rem;
padding: 0.25rem 0.6rem;
border-radius: 999px;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
main {
@@ -67,16 +47,47 @@ main {
.state {
margin: 0;
font-size: 0.85rem;
color: #374151;
color: var(--pg-state-text);
}
pre {
margin: 0;
padding: 0.9rem;
border-radius: 8px;
background: #0f172a;
color: #cbd5f5;
background: var(--pg-code-bg);
color: var(--pg-code-text);
font-size: 0.78rem;
line-height: 1.5;
overflow-x: auto;
}
.range-slider-caption {
display: flex;
flex-direction: column;
gap: 0.35rem;
width: 100%;
> span {
font-size: 0.75rem;
color: var(--pg-text-muted);
}
}
.demo-card {
display: flex;
align-items: center;
justify-content: center;
height: 4rem;
border-radius: 8px;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-size: 0.85rem;
}
.not-found-frame {
width: 100%;
max-height: 22rem;
overflow: auto;
border: 1px dashed var(--pg-card-border);
border-radius: 8px;
}

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core';
import {
FormField,
email,
@@ -7,43 +12,118 @@ import {
min,
required,
} from '@angular/forms/signals';
import { MatDialog } from '@angular/material/dialog';
import {
PiAccordionComponent,
PiAccordionItemContentDirective,
PiAccordionItemDirective,
PiActionButtonComponent,
PiBannerComponent,
PiBaseDataTableComponent,
PiCardBottomContentDirective,
PiCardCenterContentDirective,
PiCardComponent,
PiCardTopContentDirective,
PiCarouselComponent,
PiCarouselItemDirective,
PiCellDirective,
PiCellTypeComponent,
PiChartComponent,
PiChevronButtonComponent,
PiChipActionComponent,
PiChipComponent,
PiChipDateComponent,
PiChipStatusComponent,
PiChipTimeComponent,
PiChipValueComponent,
PiCloseButtonComponent,
PiColorPrimaryDirective,
PiCurrencyFieldComponent,
PiDataGridComponent,
PiDataTableItemComponent,
Dataset,
DatasetColor,
Device,
PiDividerComponent,
PiEmailFieldComponent,
PiExpansionPanelComponent,
PiGhostButtonDirective,
PiHamburgerComponent,
PiHeadlineComponent,
PiHeadlineDescriptionContentDirective,
PiHeadlineLabelContentDirective,
PiIconButtonComponent,
PiIconComponent,
PiIconFieldComponent,
PiIncrementalNumberFieldComponent,
PiIncrementalRangeFieldComponent,
PiLabelDirective,
PiLargeButtonDirective,
PiListItemComponent,
PiListItemLeftContentDirective,
PiListItemLeftIconDirective,
PiListItemRightContentDirective,
PiListItemRightIconDirective,
ModalConfigKeys,
ModalStyleType,
ModalType,
NotificationService,
PiModalComponent,
PiNoteActionContentDirective,
PiNoteComponent,
PiNotFoundComponent,
getModalConfigs,
PiNumberFieldComponent,
PiOutlineButtonDirective,
PiPaginatorComponent,
PiPanelBottomContentDirective,
PiPanelCenterContentDirective,
PiPanelComponent,
PiPanelExpandableContentDirective,
PiPasswordFieldComponent,
PiPhoneFieldComponent,
PiProgressBarComponent,
PiRadioButtonComponent,
PiRange,
PiRangeSliderComponent,
PiSearchFieldComponent,
PiSelectCarouselComponent,
PiSelectComponent,
PiSelectMultipleComponent,
PiSelectOptionComponent,
PiSelectPeriodComponent,
PiSlideToggleComponent,
PiSliderComponent,
PiSmallButtonDirective,
PiProgressSpinnerComponent,
PiSwitchButtonComponent,
PiTabComponent,
PiTabContentDirective,
TabButton,
PiTextFieldComponent,
PiTextViewerComponent,
PiTextareaComponent,
PiTileComponent,
PiToggleButtonComponent,
PiTooltipDirective,
PiW100ButtonDirective,
PiCheckboxComponent,
PiRadioGroupComponent,
PiDateFieldComponent,
PiFileUploadComponent,
PiDynamicMenuComponent,
PiSkeletonComponent,
PiStepperComponent,
} from '@pi/ui';
import { ShowcaseSection } from './showcase-section';
import { ShowcaseSection } from '../showcase-section';
interface SelectOptionValue {
id: number;
name: string;
description?: string;
}
interface DemoModel {
name: string;
@@ -57,13 +137,26 @@ interface DemoModel {
volume: number;
newsletter: boolean;
search: string;
website: string;
phone: string;
weight: number | null;
priceRange: PiRange;
contactEmail: string;
country: SelectOptionValue | null;
tags: SelectOptionValue[];
period: SelectOptionValue | null;
plan: SelectOptionValue | null;
terms: boolean;
shipping: string | null;
birthDate: Date | null;
attachments: File[];
}
@Component({
selector: 'app-root',
selector: 'app-catalog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html',
styleUrl: './app.scss',
templateUrl: './catalog.page.html',
styleUrl: './catalog.page.scss',
imports: [
FormField,
ShowcaseSection,
@@ -75,10 +168,14 @@ interface DemoModel {
PiIconButtonComponent,
PiGhostButtonDirective,
PiOutlineButtonDirective,
PiColorPrimaryDirective,
PiLabelDirective,
PiLargeButtonDirective,
PiSmallButtonDirective,
PiW100ButtonDirective,
PiRadioButtonComponent,
PiSwitchButtonComponent,
PiToggleButtonComponent,
// Contenido
PiIconComponent,
PiTextViewerComponent,
@@ -87,6 +184,19 @@ interface DemoModel {
PiChipStatusComponent,
PiChipValueComponent,
PiChipActionComponent,
PiChipTimeComponent,
PiChipDateComponent,
PiTileComponent,
PiHeadlineComponent,
PiHeadlineLabelContentDirective,
PiHeadlineDescriptionContentDirective,
PiListItemComponent,
PiListItemLeftIconDirective,
PiListItemLeftContentDirective,
PiListItemRightContentDirective,
PiListItemRightIconDirective,
PiTooltipDirective,
PiNotFoundComponent,
// Feedback
PiProgressSpinnerComponent,
PiProgressBarComponent,
@@ -102,9 +212,54 @@ interface DemoModel {
PiSearchFieldComponent,
PiSlideToggleComponent,
PiSliderComponent,
PiRangeSliderComponent,
PiIconFieldComponent,
PiPhoneFieldComponent,
PiNumberFieldComponent,
// Contenedores
PiCardComponent,
PiCardTopContentDirective,
PiCardCenterContentDirective,
PiCardBottomContentDirective,
PiPanelComponent,
PiPanelCenterContentDirective,
PiPanelBottomContentDirective,
PiExpansionPanelComponent,
PiPanelExpandableContentDirective,
PiAccordionComponent,
PiAccordionItemDirective,
PiAccordionItemContentDirective,
PiTabComponent,
PiTabContentDirective,
PiBannerComponent,
PiNoteComponent,
PiNoteActionContentDirective,
// Selects
PiSelectComponent,
PiSelectMultipleComponent,
PiSelectPeriodComponent,
PiSelectCarouselComponent,
PiSelectOptionComponent,
// Datos
PiDataGridComponent,
PiCellDirective,
PiBaseDataTableComponent,
PiDataTableItemComponent,
PiCellTypeComponent,
PiChartComponent,
PiCarouselComponent,
PiCarouselItemDirective,
// Componentes nuevos
PiCheckboxComponent,
PiRadioGroupComponent,
PiDateFieldComponent,
PiFileUploadComponent,
PiDynamicMenuComponent,
PiSkeletonComponent,
PiStepperComponent,
],
})
export class App {
export class CatalogPage {
/* --- Signal Forms ----------------------------------------------------- */
private readonly model = signal<DemoModel>({
@@ -119,6 +274,19 @@ export class App {
volume: 40,
newsletter: false,
search: '',
website: '',
phone: '',
weight: null,
priceRange: { from: 20, to: 80 },
contactEmail: '',
country: null,
tags: [],
period: null,
plan: null,
terms: false,
shipping: null,
birthDate: null,
attachments: [],
});
protected readonly f = form(this.model, (m) => {
@@ -131,12 +299,155 @@ export class App {
/** Se muestra en el panel lateral para ver el modelo en vivo. */
protected readonly modelJson = () => JSON.stringify(this.model(), null, 2);
protected readonly countries = [
{ id: 1, name: 'España' },
{ id: 2, name: 'Portugal' },
{ id: 3, name: 'Francia' },
];
protected readonly tags = [
{ id: 1, name: 'Urgente' },
{ id: 2, name: 'Facturación' },
{ id: 3, name: 'Soporte' },
{ id: 4, name: 'Ventas' },
];
protected readonly plans = [
{ id: 1, name: 'Básico', description: '9 €/mes' },
{ id: 2, name: 'Pro', description: '29 €/mes' },
{ id: 3, name: 'Empresas', description: '99 €/mes' },
];
/**
* Los campos ya calculan sus errores en cuanto se marcan como `touched`
* (así lo exige `ErrorStateMatcher` de Material); antes de eso el usuario
* no ha interactuado y no tiene sentido mostrarlos. Este botón simula el
* envío del formulario para revelarlos todos de golpe.
*/
protected submitForm(): void {
this.f().markAsTouched();
this.log('formulario enviado');
}
/* --- Estado de los demás componentes ---------------------------------- */
protected readonly currentPage = signal(1);
protected readonly lastEvent = signal('—');
protected readonly radioActive = signal(false);
protected readonly switchActive = signal(true);
protected readonly toggleActive = signal(false);
protected readonly isPanelCollapsed = signal(false);
protected readonly expandedAccordionIndex = signal<number | null>(0);
protected readonly tabs: TabButton[] = [
{ label: 'General' },
{ label: 'Facturación', icon: 'euro' },
{ label: 'Avanzado', isDisabled: true },
];
protected readonly shippingOptions = [
{ value: 'standard', label: 'Estándar (3-5 días)' },
{ value: 'express', label: 'Exprés (24 h)' },
{ value: 'pickup', label: 'Recoger en tienda', disabled: true },
];
protected readonly stepperIndex = signal(1);
protected readonly steps = [
{ label: 'Cuenta', description: 'Datos de acceso' },
{ label: 'Perfil', description: 'Información personal' },
{ label: 'Pago', description: 'Método de cobro' },
];
protected readonly menuEntries = [
{ label: 'Panel', icon: 'dashboard', isActive: true },
{
label: 'Operaciones',
icon: 'briefcase',
entries: [
{ label: 'En curso', icon: 'time-detail' },
{ label: 'Cerradas', icon: 'check' },
],
},
{ label: 'Ajustes', icon: 'user', isDisabled: true },
];
protected log(what: string): void {
this.lastEvent.set(`${what} · ${new Date().toLocaleTimeString('es-ES')}`);
}
/* --- Modal y notificaciones --------------------------------------------- */
/**
* `pi-modal` y `pi-snack-bar` no se usan como etiquetas en una plantilla:
* viven detrás de `MatDialog`/`MatSnackBar` y se abren desde TypeScript.
*/
private readonly dialog = inject(MatDialog);
private readonly notification = inject(NotificationService);
protected openDemoModal(): void {
this.dialog.open(
PiModalComponent,
getModalConfigs(ModalConfigKeys.Small, {
type: ModalType.DIALOG,
pathImg: '404.svg',
label: 'Confirmar acción',
description: '¿Seguro que quieres continuar?',
style: ModalStyleType.PRIMARY,
buttonText: { primaryAction: 'Aceptar', secondaryAction: 'Cancelar' },
}),
);
this.log('modal abierto');
}
protected showSuccessNotification(): void {
this.notification.success(
'Guardado correctamente',
'Los cambios ya están disponibles',
);
}
protected showErrorNotification(): void {
this.notification.error(
'Ha ocurrido un error',
'Inténtalo de nuevo más tarde',
);
}
/* --- Datos: grid, tabla, gráfico, carrusel ------------------------------ */
protected readonly employees = [
{ name: 'Ana López', role: 'Diseño' },
{ name: 'Luis Marín', role: 'Backend' },
{ name: 'Marta Ruiz', role: 'Ventas' },
];
protected readonly invoiceRows = [
['Ana López', '150,00 €', '12/07/2026'],
['Luis Marín', '89,50 €', '15/07/2026'],
];
protected readonly device: Device = 'DESKTOP';
protected readonly chartDatasets: Dataset[] = [
{
label: 'Ingresos',
data: [120, 190, 150, 220],
borderColor: DatasetColor.primary,
backgroundColor: DatasetColor.primary,
cubicInterpolationMode: 'monotone',
unit: ' €',
unitPosition: 'after',
},
];
protected readonly carouselItems = [
{ id: 1, label: 'Elemento 1' },
{ id: 2, label: 'Elemento 2' },
{ id: 3, label: 'Elemento 3' },
{ id: 4, label: 'Elemento 4' },
{ id: 5, label: 'Elemento 5' },
{ id: 6, label: 'Elemento 6' },
];
}

View File

@@ -0,0 +1,82 @@
<app-section
title="Cómo se usan"
hint="El layout es una clase en el contenedor de la vista, normalmente vía @HostBinding('class') con una constante de LayoutTypeClass. Casi todos esperan un .pi-container-layout como padre."
>
<pre class="snippet">
&#64;Component({{ '{' }} ... {{ '}' }})
export class MiVista {{ '{' }}
&#64;HostBinding('class') cls = LayoutTypeClass.VerticallyCentered;
{{ '}' }}</pre>
</app-section>
<app-section
title="Autónomos"
hint="Su CSS actúa sobre el propio contenedor, así que se ven con bloques sueltos. Las vistas van recortadas porque algunos se dimensionan en vh."
>
<label class="clip-toggle">
<input
type="checkbox"
[checked]="clipped()"
(change)="clipped.set($any($event.target).checked)"
/>
<span>Recortar vistas previas</span>
</label>
</app-section>
@for (layout of standalone; track layout.classes) {
<app-section [title]="layout.name" [hint]="layout.description">
<div class="demo">
<code class="demo__classes">{{ layout.classes }}</code>
<div class="demo__frame" [class.is-clipped]="clipped()">
<div class="pi-container-layout">
<div [class]="layout.classes">
@for (block of layout.blocks; track block) {
<div class="demo__block">{{ block }}</div>
}
</div>
</div>
</div>
</div>
</app-section>
}
<app-section
title="Dependen de composición"
hint="Estos no cambian nada con bloques sueltos: su CSS apunta a los componentes de wrapper o a estados del shell. Se listan con lo que necesitan alrededor para hacer algo."
>
<div class="needs">
@for (layout of compositional; track layout.classes) {
<div class="needs__item">
<strong>{{ layout.name }}</strong>
<code>{{ layout.classes }}</code>
<p>{{ layout.needs }}</p>
</div>
}
</div>
</app-section>
<app-section
title="Alturas"
hint="Se combinan con cualquier layout para fijar una altura mínima. Hay variantes por dispositivo con heights(mobile, tablet, desktop)."
>
<div class="heights">
@for (h of heights; track h.cls) {
<div class="heights__item">
<code>{{ h.cls }}</code>
<span>{{ h.hint }}</span>
</div>
}
</div>
</app-section>
<app-section
title="Sin CSS en la capa genérica"
hint="Siguen expuestos en LayoutTypeClass, pero su CSS eran composiciones de app (estados del aside, tipos de usuario) que vivían en la carpeta de marca y no se han traído a common/."
>
<div class="missing">
<code>pi-layout__00-authentication</code>
<code>pi-layout__01-dashboard</code>
<code>pi-layout__05a-splitted-paywall</code>
</div>
</app-section>

View File

@@ -0,0 +1,125 @@
:host {
display: block;
}
.snippet {
margin: 0;
padding: 0.9rem;
border-radius: 8px;
background: var(--pg-code-bg);
color: var(--pg-code-text);
font-size: 0.78rem;
line-height: 1.5;
overflow-x: auto;
}
.clip-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--pg-text-muted);
cursor: pointer;
}
.demo {
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
&__classes {
align-self: flex-start;
padding: 0.15rem 0.5rem;
border-radius: 4px;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-size: 0.75rem;
}
// Marco que aísla la vista previa: el layout real se dimensiona dentro.
&__frame {
width: 100%;
border: 1px dashed var(--pg-card-border);
border-radius: 8px;
overflow: auto;
resize: vertical;
&.is-clipped {
max-height: 16rem;
}
}
&__block {
display: flex;
align-items: center;
justify-content: center;
min-height: 3rem;
padding: 0.75rem;
border-radius: 6px;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-size: 0.8rem;
// Marca los huecos que reparte el layout.
outline: 1px solid color-mix(in srgb, currentColor 25%, transparent);
}
}
.heights,
.missing {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
code {
padding: 0.2rem 0.55rem;
border-radius: 4px;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-size: 0.78rem;
}
}
.heights__item {
display: flex;
align-items: center;
gap: 0.5rem;
span {
font-size: 0.75rem;
color: var(--pg-text-muted);
}
}
.needs {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
&__item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3rem;
padding-left: 0.75rem;
border-left: 2px solid var(--pg-card-border);
strong {
font-size: 0.9rem;
}
code {
padding: 0.15rem 0.5rem;
border-radius: 4px;
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-size: 0.75rem;
}
p {
margin: 0;
font-size: 0.82rem;
color: var(--pg-text-muted);
max-width: 70ch;
}
}
}

View File

@@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { LayoutHeight, LayoutTypeClass } from '@pi/ui';
import { ShowcaseSection } from '../showcase-section';
interface LayoutDemo {
/** Nombre corto para el título del apartado. */
name: string;
/** Constante de `LayoutTypeClass`, tal cual se aplica en el HostBinding. */
classes: string;
/** Qué hace realmente el CSS de este layout. */
description: string;
/** Bloques de relleno que se pintan dentro de la vista previa. */
blocks: string[];
}
@Component({
selector: 'app-layouts',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './layouts.page.html',
styleUrl: './layouts.page.scss',
imports: [ShowcaseSection],
})
export class LayoutsPage {
/**
* Layouts que se sostienen solos: su CSS actúa sobre el propio contenedor,
* así que la vista previa con bloques sueltos ya enseña lo que hacen.
*/
protected readonly standalone: LayoutDemo[] = [
{
name: 'Vertically centered',
classes: LayoutTypeClass.VerticallyCentered,
description:
'Columna flex centrada verticalmente (justify-content: center). Típico de login, errores o estados vacíos.',
blocks: ['Bloque centrado'],
},
{
name: 'Space between',
classes: LayoutTypeClass.SpaceBetween,
description:
'Columna flex con justify-content: space-between: empuja el primer bloque arriba y el último abajo.',
blocks: ['Cabecera', 'Pie'],
},
{
name: 'Full width auto',
classes: LayoutTypeClass.FullWidthAuto,
description:
'Añade margin: auto sobre el full width, para centrar el bloque cuando no ocupa todo el ancho.',
blocks: ['Contenido centrado'],
},
{
name: 'Pricing',
classes: LayoutTypeClass.Pricing,
description:
'Rejilla de 3 columnas (repeat(3, 1fr)) para tarifas; tiene variante 1fr 3fr con tarjeta destacada.',
blocks: ['Básico', 'Pro', 'Empresas'],
},
];
/**
* Estos no se pueden enseñar con bloques sueltos: su CSS apunta a los
* componentes de wrapper o a estados del shell de la aplicación, así que
* fuera de esa composición no cambian nada visible.
*/
protected readonly compositional = [
{
name: 'Full width',
classes: LayoutTypeClass.FullWidth,
needs:
'Un ancestro con `aside-is-open`: los anchos máximos se calculan a partir del aside y el navbar.',
},
{
name: 'Splitted',
classes: LayoutTypeClass.Splitted,
needs:
'No parte en dos por sí solo: es una columna a 100vh y las mitades las aportan los slots izquierdo/derecho de `pi-main-wrapper`.',
},
{
name: 'Edge to edge',
classes: LayoutTypeClass.EdgeToEdge,
needs:
'Un `pi-wrapper__header` como hijo dentro de `.pi-container-layout`: lo que ajusta es su espaciado superior.',
},
{
name: 'List',
classes: LayoutTypeClass.List,
needs:
'Wrappers (`pi-wrapper`, `pi-wrapper__list`) y un `pi-data-grid`: limita sus anchos y centra la rejilla.',
},
{
name: 'List auto',
classes: LayoutTypeClass.ListAuto,
needs: 'Igual que List, para listados con scroll infinito.',
},
{
name: 'Form',
classes: LayoutTypeClass.Form,
needs:
'Ir anidado dentro de un `pi-layout__05-splitted`: solo ahí recibe padding y scrollbar propios.',
},
];
/** Alturas mínimas, aplicables sobre cualquier layout. */
protected readonly heights = [
{ label: 'small', cls: LayoutHeight.Small, hint: '33.33vh' },
{ label: 'medium', cls: LayoutHeight.Medium, hint: '40vh' },
{ label: 'large', cls: LayoutHeight.Large, hint: '75vh' },
{ label: 'full', cls: LayoutHeight.Full, hint: '100vh' },
];
/**
* Las vistas previas van recortadas: varios layouts piden `vh` y ocuparían
* pantallas enteras. Se puede soltar el recorte para verlos a tamaño real.
*/
protected readonly clipped = signal(true);
}

View File

@@ -17,14 +17,14 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
`,
styles: `
section {
background: #fff;
border: 1px solid #e7e9ee;
background: var(--pg-card-bg);
border: 1px solid var(--pg-card-border);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
header {
border-bottom: 1px solid #eef0f4;
border-bottom: 1px solid var(--pg-card-header-border);
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
}
@@ -34,12 +34,12 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core';
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #5c82e9;
color: var(--pg-heading);
}
p {
margin: 0.35rem 0 0;
font-size: 0.85rem;
color: #6b7280;
color: var(--pg-text-muted);
}
.content {
display: flex;

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="is-desktop">
<head>
<meta charset="utf-8" />
<title>playground</title>
@@ -7,7 +7,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<!-- Valor inicial; App lo reescribe según el tema elegido (ver ShellClass). -->
<body class="pi-shell pi-shell__default">
<app-root></app-root>
</body>
</html>

View File

@@ -13,6 +13,14 @@
@use '../../../libs/ui/src/lib/styles/common/components' as *;
@use '../../../libs/ui/src/lib/styles/common/layouts' as *;
@use '../../../libs/ui/src/lib/styles/common/wrappers' as *;
// Overrides de Material (campos, checkbox, diálogos, sliders, menús...). Es lo
// que hace que los componentes se vean «pi» y no Material de serie; el
// `@import` de `@typeform/embed` que estorbaba está en `common/index.scss`, no
// aquí, así que este partial sí se puede cargar.
@use '../../../libs/ui/src/lib/styles/common/vendors' as *;
// Capa de temas: declara los tokens --pi-theme-* que consume `common/`.
// Va después de `common` para que sus overrides ganen.
@use '../../../libs/ui/src/lib/styles/themes' as *;
html {
@include mat.theme(
@@ -27,6 +35,90 @@ html {
);
color-scheme: light;
// Chrome propio del playground (cabecera, tarjetas de sección, bloque de
// estado). Los componentes de @pi/ui usan --pi-color-*, sobrescritas más
// abajo para el modo oscuro.
--pg-page-bg: #f7f8fa;
--pg-page-text: #1c1c1e;
--pg-heading: #5c82e9;
--pg-text-muted: #6b7280;
--pg-badge-bg: #eef2ff;
--pg-badge-text: #4c5fd7;
--pg-card-bg: #fff;
--pg-card-border: #e7e9ee;
--pg-card-header-border: #eef0f4;
--pg-state-text: #374151;
--pg-code-bg: #0f172a;
--pg-code-text: #cbd5f5;
}
// Modo oscuro: se activa añadiendo la clase `dark-mode` al <html> (ver
// App.darkMode en apps/playground/src/app/app.ts). @pi/ui no está diseñada
// con un tema oscuro propio, así que esto es una reasignación best-effort de
// sus variables --pi-color-* neutras (blancos/negros/grises/fondos); las
// paletas de marca (primary/accent/warn/success/highlighted) se mantienen
// igual y pueden perder algo de contraste en algunos componentes.
html.dark-mode {
@include mat.theme(
(
color: (
theme-type: dark,
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
)
);
color-scheme: dark;
--pg-page-bg: #14161a;
--pg-page-text: #e5e7eb;
--pg-heading: #8cacf2;
--pg-text-muted: #9aa1ac;
--pg-badge-bg: #1f2a4d;
--pg-badge-text: #b9c4ff;
--pg-card-bg: #1c1f26;
--pg-card-border: #2a2f3a;
--pg-card-header-border: #262b35;
--pg-state-text: #cbd2db;
--pg-code-bg: #0a0d16;
--pg-code-text: #cbd5f5;
// --- @pi/ui: neutros -------------------------------------------------
--pi-color-white: #1c1f26;
--pi-color-white-900: #252932;
--pi-color-black: #f1f2f4;
--pi-color-black-200: #c7cbd4;
--pi-color-grey-10: #111318;
--pi-color-grey-25: #161920;
--pi-color-grey-50: #1b1e26;
--pi-color-grey-75: #20232c;
--pi-color-grey-100: #2a2e38;
--pi-color-grey-200: #3a3f4b;
--pi-color-grey-300: #565c6b;
--pi-color-grey-400: #7a8190;
--pi-color-grey: #9aa1ac;
--pi-color-grey-900: #e4e6ea;
--pi-color-background-300: #4b505c;
--pi-color-background-400: #1c1f26;
--pi-color-background-500: #181b22;
}
// El tema alternativo usa escalones claros de su marca como superficie de los
// campos (`--pi-theme-field-surface`). Como el bloque de arriba solo reasigna
// neutros, en oscuro esos campos se quedaban en claro: aquí se oscurecen los
// pocos escalones que actúan de superficie, manteniendo el tinte violeta.
html.dark-mode .pi-shell__alternative {
--pi-color-primary-10: #16131f;
--pi-color-primary-25: #1a1626;
--pi-color-primary-50: #201a30;
--pi-color-primary-75: #2a2140;
--pi-color-primary-100: #3a2d5c;
}
body {
@@ -36,6 +128,6 @@ body {
system-ui,
-apple-system,
sans-serif;
background: #f7f8fa;
color: #1c1c1e;
background: var(--pg-page-bg);
color: var(--pg-page-text);
}