feat: anade app playground con catalogo de componentes
This commit is contained in:
62
apps/playground/src/app/app.config.ts
Normal file
62
apps/playground/src/app/app.config.ts
Normal 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 }),
|
||||
],
|
||||
};
|
||||
134
apps/playground/src/app/app.html
Normal file
134
apps/playground/src/app/app.html
Normal 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>
|
||||
82
apps/playground/src/app/app.scss
Normal file
82
apps/playground/src/app/app.scss
Normal 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;
|
||||
}
|
||||
142
apps/playground/src/app/app.ts
Normal file
142
apps/playground/src/app/app.ts
Normal 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')}`);
|
||||
}
|
||||
}
|
||||
55
apps/playground/src/app/showcase-section.ts
Normal file
55
apps/playground/src/app/showcase-section.ts
Normal 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>();
|
||||
}
|
||||
13
apps/playground/src/index.html
Normal file
13
apps/playground/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>playground</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
5
apps/playground/src/main.ts
Normal file
5
apps/playground/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
|
||||
41
apps/playground/src/styles.scss
Normal file
41
apps/playground/src/styles.scss
Normal file
@@ -0,0 +1,41 @@
|
||||
// Estilos globales del playground.
|
||||
//
|
||||
// No se usa `libs/ui/src/lib/styles/common/index.scss` porque ese fichero monta
|
||||
// el tema con la API antigua de Angular Material (`mat.define-palette`,
|
||||
// `mat.define-light-theme`, `mat.all-component-themes`), retirada en Material
|
||||
// 19+, y además importa CSS de `@typeform/embed`, que no es dependencia de la
|
||||
// librería. Aquí se cargan los mismos partials (que sí son SCSS puro) y el tema
|
||||
// se define con la API actual.
|
||||
|
||||
@use '@angular/material' as mat;
|
||||
@use '../../../libs/ui/src/lib/styles/common/abstracts' as *;
|
||||
@use '../../../libs/ui/src/lib/styles/common/base' as *;
|
||||
@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 *;
|
||||
|
||||
html {
|
||||
@include mat.theme(
|
||||
(
|
||||
color: (
|
||||
primary: mat.$azure-palette,
|
||||
tertiary: mat.$blue-palette,
|
||||
),
|
||||
typography: Roboto,
|
||||
density: 0,
|
||||
)
|
||||
);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
Soehne,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background: #f7f8fa;
|
||||
color: #1c1c1e;
|
||||
}
|
||||
Reference in New Issue
Block a user