feat: anade app playground con catalogo de componentes

This commit is contained in:
2026-07-28 19:19:02 +02:00
parent d8c38f434d
commit deb19dd744
192 changed files with 9632 additions and 42 deletions

View File

@@ -0,0 +1,62 @@
import {
ApplicationConfig,
inject,
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideStore } from '@ngrx/store';
import {
TranslateLoader,
TranslationObject,
provideTranslateService,
} from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { IconRegistryService, deviceConfigsReducer, featureKey } from '@pi/ui';
/**
* Diccionario en línea: la librería resuelve los textos con `| translate`, y
* montar el loader HTTP real exigiría ficheros de i18n que no forman parte de
* la librería. Con las claves que faltan ngx-translate devuelve la propia
* clave, que para un catálogo de componentes es suficiente.
*/
const TRANSLATIONS: TranslationObject = {
common: {
close: 'Cerrar',
min: 'Mín',
max: 'Máx',
save: 'Guardar',
reset: 'Borrar',
validator: {
requiredField: 'Este campo es obligatorio',
onlyNumbers: 'Sólo se admiten números',
enterValidEmail: 'Introduce un email válido',
minimumPasswordCharacters: 'La contraseña es demasiado corta',
},
},
};
class PlaygroundTranslateLoader implements TranslateLoader {
getTranslation(): Observable<TranslationObject> {
return of(TRANSLATIONS);
}
}
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideAnimationsAsync(),
// Los iconos son SVG registrados en el MatIconRegistry (assets/icons).
provideAppInitializer(() => inject(IconRegistryService).init()),
provideTranslateService({
fallbackLang: 'es',
loader: { provide: TranslateLoader, useClass: PlaygroundTranslateLoader },
}),
// data-table y background-image leen el tipo de dispositivo del store.
provideStore({ [featureKey]: deviceConfigsReducer }),
],
};

View File

@@ -0,0 +1,134 @@
<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>
<main>
<div class="col">
<app-section
title="Formularios (Signal Forms)"
hint="Cada campo se enlaza con [formField]; la validación vive en el form()."
>
<div class="stack">
<pi-text-field [formField]="f.name">
<pi-label>Nombre</pi-label>
</pi-text-field>
<pi-email-field [formField]="f.email">
<pi-label>Email</pi-label>
</pi-email-field>
<pi-password-field [formField]="f.password">
<pi-label>Contraseña</pi-label>
</pi-password-field>
<pi-textarea [formField]="f.bio">
<pi-label>Biografía</pi-label>
</pi-textarea>
<pi-incremental-number-field [formField]="f.age">
<pi-label>Edad</pi-label>
</pi-incremental-number-field>
<pi-currency-field [formField]="f.price">
<pi-label>Precio</pi-label>
</pi-currency-field>
<pi-incremental-range-field
[formField]="f.range"
[minValue]="0"
[maxValue]="100"
unit=""
[step]="1"
/>
<pi-search-field [formField]="f.search" placeholder="Buscar…" />
<label class="inline">
<span>Newsletter</span>
<pi-slide-toggle [formField]="f.newsletter" />
</label>
<pi-slider
[formField]="f.volume"
unit="%"
label="Volumen"
[maxValue]="100"
/>
</div>
</app-section>
</div>
<div class="col">
<app-section title="Estado del formulario" hint="Modelo y validez en vivo.">
<div class="stack full">
<p class="state">
<strong>válido:</strong> {{ f().valid() }} ·
<strong>tocado:</strong> {{ f().touched() }} ·
<strong>modificado:</strong> {{ f().dirty() }}
</p>
<pre>{{ modelJson() }}</pre>
</div>
</app-section>
<app-section title="Botones">
<button pi-primary large (click)="log('botón grande')">Primario</button>
<button pi-ghost small (click)="log('botón ghost')">Ghost</button>
<button pi-outline (click)="log('botón outline')">Outline</button>
<button pi-primary w100 (click)="log('botón w100')">
Ancho completo
</button>
<pi-action-button text="Acción" (execute)="log('action-button')" />
<pi-icon-button icon="add" text="Añadir" (execute)="log('icon-button')" />
<pi-chevron-button (toggle)="log('chevron')" />
<pi-close-button (toggle)="log('close')" />
<pi-hamburger (toggle)="log('hamburger')" />
</app-section>
<app-section title="Iconos y texto">
<pi-icon icon="add" color="primary" size="large" />
<pi-icon icon="search" color="accent" size="large" />
<pi-icon icon="close" color="warn" size="large" />
<pi-icon
icon="check"
color="primary"
appearance="fill"
[isStroked]="true"
/>
<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="Chips">
<pi-chip label="Etiqueta" color="primary" />
<pi-chip-status status="success" label="Correcto" />
<pi-chip-status status="error" label="Error" />
<pi-chip-status status="pending" label="Pendiente" />
<pi-chip-value [value]="42" unit="%" label="Progreso" />
<pi-chip-action
label="Acción"
[isActive]="true"
(execute)="log('chip-action')"
/>
</app-section>
<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."
>
<pi-progress-spinner label="Progreso" [value]="65" [diameter]="72" />
<pi-progress-bar label="Carga" [value]="65" />
<pi-paginator
[(currentPage)]="currentPage"
[pageSize]="10"
[totalPages]="5"
[isDisabled]="false"
(changePage)="log('página ' + $event)"
/>
</app-section>
</div>
</main>

View File

@@ -0,0 +1,82 @@
: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;
}
}
main {
display: grid;
grid-template-columns: minmax(320px, 420px) 1fr;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 900px) {
main {
grid-template-columns: 1fr;
}
}
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
&.full > * {
width: 100%;
}
}
.inline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.state {
margin: 0;
font-size: 0.85rem;
color: #374151;
}
pre {
margin: 0;
padding: 0.9rem;
border-radius: 8px;
background: #0f172a;
color: #cbd5f5;
font-size: 0.78rem;
line-height: 1.5;
overflow-x: auto;
}

View File

@@ -0,0 +1,142 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
FormField,
email,
form,
maxLength,
min,
required,
} from '@angular/forms/signals';
import {
PiActionButtonComponent,
PiChevronButtonComponent,
PiChipActionComponent,
PiChipComponent,
PiChipStatusComponent,
PiChipValueComponent,
PiCloseButtonComponent,
PiCurrencyFieldComponent,
PiDividerComponent,
PiEmailFieldComponent,
PiGhostButtonDirective,
PiHamburgerComponent,
PiIconButtonComponent,
PiIconComponent,
PiIncrementalNumberFieldComponent,
PiIncrementalRangeFieldComponent,
PiLabelDirective,
PiLargeButtonDirective,
PiOutlineButtonDirective,
PiPaginatorComponent,
PiPasswordFieldComponent,
PiProgressBarComponent,
PiRange,
PiSearchFieldComponent,
PiSlideToggleComponent,
PiSliderComponent,
PiSmallButtonDirective,
PiProgressSpinnerComponent,
PiTextFieldComponent,
PiTextViewerComponent,
PiTextareaComponent,
PiW100ButtonDirective,
} from '@pi/ui';
import { ShowcaseSection } from './showcase-section';
interface DemoModel {
name: string;
email: string;
password: string;
bio: string;
age: number | null;
price: number | null;
quantity: number | null;
range: PiRange;
volume: number;
newsletter: boolean;
search: string;
}
@Component({
selector: 'app-root',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './app.html',
styleUrl: './app.scss',
imports: [
FormField,
ShowcaseSection,
// Botones
PiActionButtonComponent,
PiChevronButtonComponent,
PiCloseButtonComponent,
PiHamburgerComponent,
PiIconButtonComponent,
PiGhostButtonDirective,
PiOutlineButtonDirective,
PiLabelDirective,
PiLargeButtonDirective,
PiSmallButtonDirective,
PiW100ButtonDirective,
// Contenido
PiIconComponent,
PiTextViewerComponent,
PiDividerComponent,
PiChipComponent,
PiChipStatusComponent,
PiChipValueComponent,
PiChipActionComponent,
// Feedback
PiProgressSpinnerComponent,
PiProgressBarComponent,
PiPaginatorComponent,
// Formularios
PiTextFieldComponent,
PiEmailFieldComponent,
PiPasswordFieldComponent,
PiTextareaComponent,
PiIncrementalNumberFieldComponent,
PiCurrencyFieldComponent,
PiIncrementalRangeFieldComponent,
PiSearchFieldComponent,
PiSlideToggleComponent,
PiSliderComponent,
],
})
export class App {
/* --- Signal Forms ----------------------------------------------------- */
private readonly model = signal<DemoModel>({
name: '',
email: '',
password: '',
bio: '',
age: null,
price: null,
quantity: 1,
range: { from: 0, to: 10 },
volume: 40,
newsletter: false,
search: '',
});
protected readonly f = form(this.model, (m) => {
required(m.name, { message: 'El nombre es obligatorio' });
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' });
});
/** Se muestra en el panel lateral para ver el modelo en vivo. */
protected readonly modelJson = () => JSON.stringify(this.model(), null, 2);
/* --- Estado de los demás componentes ---------------------------------- */
protected readonly currentPage = signal(1);
protected readonly lastEvent = signal('—');
protected log(what: string): void {
this.lastEvent.set(`${what} · ${new Date().toLocaleTimeString('es-ES')}`);
}
}

View File

@@ -0,0 +1,55 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
/** Marco común de cada apartado del catálogo. */
@Component({
selector: 'app-section',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section>
<header>
<h2>{{ title() }}</h2>
@if (hint()) {
<p>{{ hint() }}</p>
}
</header>
<div class="content"><ng-content /></div>
</section>
`,
styles: `
section {
background: #fff;
border: 1px solid #e7e9ee;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
header {
border-bottom: 1px solid #eef0f4;
margin-bottom: 1.25rem;
padding-bottom: 0.75rem;
}
h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #5c82e9;
}
p {
margin: 0.35rem 0 0;
font-size: 0.85rem;
color: #6b7280;
}
.content {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
align-items: flex-start;
}
`,
})
export class ShowcaseSection {
readonly title = input.required<string>();
readonly hint = input<string>();
}