diff --git a/apps/playground/src/app/app.config.ts b/apps/playground/src/app/app.config.ts index ff05766..a423c2b 100644 --- a/apps/playground/src/app/app.config.ts +++ b/apps/playground/src/app/app.config.ts @@ -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()), diff --git a/apps/playground/src/app/app.html b/apps/playground/src/app/app.html new file mode 100644 index 0000000..8740f0d --- /dev/null +++ b/apps/playground/src/app/app.html @@ -0,0 +1,48 @@ + + + diff --git a/apps/playground/src/app/app.routes.ts b/apps/playground/src/app/app.routes.ts new file mode 100644 index 0000000..81c86d0 --- /dev/null +++ b/apps/playground/src/app/app.routes.ts @@ -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' }, +]; diff --git a/apps/playground/src/app/app.scss b/apps/playground/src/app/app.scss new file mode 100644 index 0000000..b9cfd15 --- /dev/null +++ b/apps/playground/src/app/app.scss @@ -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; + } + } +} diff --git a/apps/playground/src/app/app.ts b/apps/playground/src/app/app.ts new file mode 100644 index 0000000..7212053 --- /dev/null +++ b/apps/playground/src/app/app.ts @@ -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 ``. + */ +@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__`); van en el `` porque + * los overlays de Material (diálogos, snackbars) se montan fuera de + * `` 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; + }); + } +} diff --git a/apps/playground/src/app/pages/catalog/catalog.page.html b/apps/playground/src/app/pages/catalog/catalog.page.html index 3bf2800..ffc4afe 100644 --- a/apps/playground/src/app/pages/catalog/catalog.page.html +++ b/apps/playground/src/app/pages/catalog/catalog.page.html @@ -1,11 +1,4 @@ - +Último evento: {{ lastEvent() }}
@@ -48,6 +41,18 @@ + + Web + + + + Teléfono + + + + Peso (kg) + +
@@ -87,6 +131,24 @@ + + + @@ -99,10 +161,58 @@ appearance="fill" [isStroked]="true" /> + + +
+ + +

Bienvenido

+
+ +

Gestiona tu cuenta desde aquí.

+
+
+ + + + + + + Perfil + + + + + + + + + + + + Notificaciones + + + + + +
+
+ @@ -114,6 +224,359 @@ [isActive]="true" (execute)="log('chip-action')" /> + + + + + + + + +

Título de la tarjeta

+
+ +

Contenido de ejemplo para pi-card.

+
+
+
+ + +
+ + +

Contenido central del panel.

+
+ + + +
+ + + +

{{ f.contactEmail().value() || 'Sin email' }}

+
+ + + +
+
+
+ + +
+ + + +

Contenido expandible del ítem 1.

+
+
+ + +

Detalles de envío.

+
+
+
+
+
+ + +
+ + Contenido de la pestaña General. + Contenido de la pestaña Facturación. + Contenido de la pestaña Avanzado. + +
+
+ + +
+ +
+
+ + +
+ + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + +
+ + + {{ value.name }} — {{ value.role }} + + +
+
+ + +
+ + + + + + + + + @for (row of invoiceRows; track row) { + + + + + + + + } +
+
+ + +
+ +
+
+ + +
+ + @for (item of carouselItems; track item.id) { + +
{{ item.label }}
+
+ } +
+
+
+ + +
+ +
+ + +
+
+
+ + +
+ +
+
+ + +
+ + + +
+ + +
+ +
+
diff --git a/apps/playground/src/app/pages/catalog/catalog.page.scss b/apps/playground/src/app/pages/catalog/catalog.page.scss index 466f357..55dec44 100644 --- a/apps/playground/src/app/pages/catalog/catalog.page.scss +++ b/apps/playground/src/app/pages/catalog/catalog.page.scss @@ -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; +} diff --git a/apps/playground/src/app/pages/catalog/catalog.page.ts b/apps/playground/src/app/pages/catalog/catalog.page.ts index cb2d9cd..4597572 100644 --- a/apps/playground/src/app/pages/catalog/catalog.page.ts +++ b/apps/playground/src/app/pages/catalog/catalog.page.ts @@ -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({ @@ -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(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' }, + ]; } diff --git a/apps/playground/src/app/pages/layouts/layouts.page.html b/apps/playground/src/app/pages/layouts/layouts.page.html new file mode 100644 index 0000000..0fb2721 --- /dev/null +++ b/apps/playground/src/app/pages/layouts/layouts.page.html @@ -0,0 +1,82 @@ + +
+@Component({{ '{' }} ... {{ '}' }})
+export class MiVista {{ '{' }}
+  @HostBinding('class') cls = LayoutTypeClass.VerticallyCentered;
+{{ '}' }}
+
+ + + + + +@for (layout of standalone; track layout.classes) { + +
+ {{ layout.classes }} + +
+
+
+ @for (block of layout.blocks; track block) { +
{{ block }}
+ } +
+
+
+
+
+} + + +
+ @for (layout of compositional; track layout.classes) { +
+ {{ layout.name }} + {{ layout.classes }} +

{{ layout.needs }}

+
+ } +
+
+ + +
+ @for (h of heights; track h.cls) { +
+ {{ h.cls }} + {{ h.hint }} +
+ } +
+
+ + +
+ pi-layout__00-authentication + pi-layout__01-dashboard + pi-layout__05a-splitted-paywall +
+
diff --git a/apps/playground/src/app/pages/layouts/layouts.page.scss b/apps/playground/src/app/pages/layouts/layouts.page.scss new file mode 100644 index 0000000..3f7d74d --- /dev/null +++ b/apps/playground/src/app/pages/layouts/layouts.page.scss @@ -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; + } + } +} diff --git a/apps/playground/src/app/pages/layouts/layouts.page.ts b/apps/playground/src/app/pages/layouts/layouts.page.ts new file mode 100644 index 0000000..c53b916 --- /dev/null +++ b/apps/playground/src/app/pages/layouts/layouts.page.ts @@ -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); +} diff --git a/apps/playground/src/app/pages/showcase-section.ts b/apps/playground/src/app/pages/showcase-section.ts index a4b440b..4036a3a 100644 --- a/apps/playground/src/app/pages/showcase-section.ts +++ b/apps/playground/src/app/pages/showcase-section.ts @@ -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; diff --git a/apps/playground/src/index.html b/apps/playground/src/index.html index 358131f..3ee46a3 100644 --- a/apps/playground/src/index.html +++ b/apps/playground/src/index.html @@ -1,5 +1,5 @@ - + playground @@ -7,7 +7,8 @@ - + + diff --git a/apps/playground/src/styles.scss b/apps/playground/src/styles.scss index 4b42698..974220b 100644 --- a/apps/playground/src/styles.scss +++ b/apps/playground/src/styles.scss @@ -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 (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); } diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index e4e9035..2aca869 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -9,11 +9,17 @@ export * from './lib/util/common'; export * from './lib/util/dato-cms'; export * from './lib/util/device-configs'; export * from './lib/util/environments'; -export * from './lib/util/form-control'; export * from './lib/util/icon-registry'; export * from './lib/util/infinite-scroll'; // ./lib/util/list-configs: omitido(s) por colisión con otro módulo -> featureKey, getFeatureState, initialState -export { getListMode, ListConfigsFacade, listConfigsReducer, ListModeActionsType, resetListMode, setListMode } from './lib/util/list-configs'; +export { + getListMode, + ListConfigsFacade, + listConfigsReducer, + ListModeActionsType, + resetListMode, + setListMode, +} from './lib/util/list-configs'; export type { ListConfigsState, ListMode } from './lib/util/list-configs'; export * from './lib/util/loaders'; export * from './lib/util/local-storage'; @@ -40,10 +46,12 @@ export * from './lib/ui/data-grid'; export * from './lib/ui/data-table'; export * from './lib/ui/divider'; export * from './lib/ui/error-status-code'; +export * from './lib/ui/file-upload'; export * from './lib/ui/form-field'; export * from './lib/ui/icon'; export * from './lib/ui/image'; export * from './lib/ui/list-item'; +export * from './lib/ui/menu'; export * from './lib/ui/modal'; export * from './lib/ui/note'; export * from './lib/ui/notification'; @@ -51,11 +59,36 @@ export * from './lib/ui/paginator'; export * from './lib/ui/panel'; export * from './lib/ui/progress'; export * from './lib/ui/select'; -// ./lib/ui/slide-toggle: omitido(s) por colisión con otro módulo -> PiSlideToggleComponent export * from './lib/ui/slider'; export * from './lib/ui/spinner'; +export * from './lib/ui/stepper'; export * from './lib/ui/tab'; // ./lib/ui/text: omitido(s) por colisión con otro módulo -> PiDisabledDirective -export { PiBaseTextViewerComponent, PiTextAreaExpansionComponent, PiTextCenteredDirective, PiTextColorDirective, PiTextCoveredDirective, PiTextGroupDirective, PiTextGroupIndicatorDirective, PiTextLargeDirective, PiTextLetterSpacingDirective, PiTextMediumDirective, PiTextSizeDirective, PiTextSmallDirective, PiTextViewerComponent, PiTextViewerLinkComponent, PiTextWeightBlackDirective, PiTextWeightBoldDirective, PiTextWeightLightDirective, PiTextWeightMediumDirective, PiTextWeightNormalDirective, PiTextXLargeDirective, PiTextXSmallDirective, PiTextXXLargeDirective, PiTextXXSmallDirective, PiTextXXXLargeDirective } from './lib/ui/text'; +export { + PiBaseTextViewerComponent, + PiTextAreaExpansionComponent, + PiTextCenteredDirective, + PiTextColorDirective, + PiTextCoveredDirective, + PiTextGroupDirective, + PiTextGroupIndicatorDirective, + PiTextLargeDirective, + PiTextLetterSpacingDirective, + PiTextMediumDirective, + PiTextSizeDirective, + PiTextSmallDirective, + PiTextViewerComponent, + PiTextViewerLinkComponent, + PiTextWeightBlackDirective, + PiTextWeightBoldDirective, + PiTextWeightLightDirective, + PiTextWeightMediumDirective, + PiTextWeightNormalDirective, + PiTextXLargeDirective, + PiTextXSmallDirective, + PiTextXXLargeDirective, + PiTextXXSmallDirective, + PiTextXXXLargeDirective, +} from './lib/ui/text'; export * from './lib/ui/tooltip'; export * from './lib/ui/wrapper'; diff --git a/libs/ui/src/lib/styles/common/base/_pi-reset.scss b/libs/ui/src/lib/styles/common/base/_pi-reset.scss index b75860b..761bc9d 100644 --- a/libs/ui/src/lib/styles/common/base/_pi-reset.scss +++ b/libs/ui/src/lib/styles/common/base/_pi-reset.scss @@ -538,7 +538,17 @@ template { button, .pi-input, -[class*='mat-'] { +// `[class*='mat-']` alcanza a cualquier elemento de Angular Material. Hay que +// excluir los que forman tabla: el datepicker monta el calendario con una +// `` real, y forzarla a `display: block` le quita el contexto de +// formato de tabla y colapsa la rejilla de días en una sola columna. +// +// La exclusión va dentro de `:where()` a propósito: `:where()` no aporta +// especificidad, así que la regla sigue valiendo (0,1,0) como antes. Con un +// `:not(table):not(tr)...` normal subiría a (0,1,7) y pasaría a ganarle a +// reglas de Material que hasta ahora respetaba (los afijos de icono de los +// campos, entre otras). +[class*='mat-']:not(:where(table, thead, tbody, tfoot, tr, th, td)) { display: block; } diff --git a/libs/ui/src/lib/styles/common/components/_pi-card.scss b/libs/ui/src/lib/styles/common/components/_pi-card.scss index 0714c80..160e496 100644 --- a/libs/ui/src/lib/styles/common/components/_pi-card.scss +++ b/libs/ui/src/lib/styles/common/components/_pi-card.scss @@ -290,7 +290,7 @@ justify-content: space-between; width: -webkit-fill-available; padding: var(--pi-spacing-xx-large); - border-radius: var(--pi-border-radius-large); + border-radius: var(--pi-theme-card-radius); transition: var(--pi-animation); &:hover { @@ -318,7 +318,7 @@ // Has Shadow &.has-shadow { - box-shadow: var(--pi-box-shadow-panel); + box-shadow: var(--pi-theme-card-shadow, var(--pi-box-shadow-panel)); } &:not(.has-shadow) { box-shadow: none; @@ -505,7 +505,7 @@ } } -.pi-shell__deale-website, +// Solo la API `context` de `pi-card`; el tema ya viaja por tokens. .is-public { .pi-card { // Color diff --git a/libs/ui/src/lib/styles/common/components/_pi-note.scss b/libs/ui/src/lib/styles/common/components/_pi-note.scss index 7cf2df8..bc19878 100644 --- a/libs/ui/src/lib/styles/common/components/_pi-note.scss +++ b/libs/ui/src/lib/styles/common/components/_pi-note.scss @@ -8,7 +8,7 @@ $min-width: auto ) { .pi-shell { - &.pi-shell__deale-saas { + &.pi-shell__default { & > div { &.has-bottom-sticky-note { & > section { diff --git a/libs/ui/src/lib/styles/common/components/_pi-text.scss b/libs/ui/src/lib/styles/common/components/_pi-text.scss index 0ab30a8..cbb48f7 100644 --- a/libs/ui/src/lib/styles/common/components/_pi-text.scss +++ b/libs/ui/src/lib/styles/common/components/_pi-text.scss @@ -71,7 +71,7 @@ } } -.pi-shell__deale-saas { +.pi-shell__default { h3 { font: { size: var(--pi-font-size-medium); diff --git a/libs/ui/src/lib/styles/common/components/pi-button/_pi-button-fixed.scss b/libs/ui/src/lib/styles/common/components/pi-button/_pi-button-fixed.scss new file mode 100644 index 0000000..02a4512 --- /dev/null +++ b/libs/ui/src/lib/styles/common/components/pi-button/_pi-button-fixed.scss @@ -0,0 +1,78 @@ +// Posicionamiento fijo de botones flotantes (FAB). Es una utilidad de layout, +// no de tema: aplica igual en todos los shells. + +@mixin fixedButtonSideSpacing( + $top-spacing: calc(var(--pi-spacing-medium) + env(safe-area-inset-top)), + $side-spacing: var(--pi-spacing-medium), + $bottom-left-spacing: calc( + var(--pi-spacing-medium) + env(safe-area-inset-bottom) + ), + $bottom-right-spacing: calc( + 5.75rem + var(--pi-spacing-large) + env(safe-area-inset-bottom) + ) +) { + .pi-shell { + // Fixed + button, + .pi-icon-button, + .pi-action-button { + &.is-fixed { + position: fixed; + z-index: 999; + &.is-fixed--top-left, + &.is-fixed--top-right { + top: $top-spacing; + right: $side-spacing; + } + &.is-fixed--bottom-left { + bottom: $bottom-left-spacing; + left: $side-spacing; + } + &.is-fixed--bottom-right { + bottom: $bottom-right-spacing; + right: $side-spacing; + } + } + } + } +} + +html, +.cdk-overlay-container { + // Mobile + &.is-mobile { + @include fixedButtonSideSpacing( + calc(var(--pi-spacing-medium) + env(safe-area-inset-top)), + var(--pi-spacing-medium), + calc(var(--pi-spacing-medium) + env(safe-area-inset-bottom)), + calc(5.75rem + var(--pi-spacing-large) + env(safe-area-inset-bottom)) + ); + } + &.is-tablet { + &.is-tablet__small { + @include fixedButtonSideSpacing( + calc(var(--pi-spacing-x-large) + env(safe-area-inset-top)), + var(--pi-spacing-x-large), + calc(var(--pi-spacing-x-large) + env(safe-area-inset-bottom)), + calc(3.75rem + var(--pi-spacing-large) + env(safe-area-inset-bottom)) + ); + } + &.is-tablet__large { + @include fixedButtonSideSpacing( + calc(var(--pi-spacing-x-large) + env(safe-area-inset-top)), + var(--pi-spacing-x-large), + calc(var(--pi-spacing-x-large) + env(safe-area-inset-bottom)), + calc(5.75rem + var(--pi-spacing-large) + env(safe-area-inset-bottom)) + ); + } + } + // Tablet & Desktop + &.is-desktop { + @include fixedButtonSideSpacing( + calc(var(--pi-spacing-x-large) + env(safe-area-inset-top)), + var(--pi-spacing-x-large), + calc(var(--pi-spacing-x-large) + env(safe-area-inset-bottom)), + calc(5.75rem + var(--pi-spacing-large) + env(safe-area-inset-bottom)) + ); + } +} diff --git a/libs/ui/src/lib/styles/common/components/pi-button/_pi-button-theme.scss b/libs/ui/src/lib/styles/common/components/pi-button/_pi-button-theme.scss index 49b4984..24de6fe 100644 --- a/libs/ui/src/lib/styles/common/components/pi-button/_pi-button-theme.scss +++ b/libs/ui/src/lib/styles/common/components/pi-button/_pi-button-theme.scss @@ -1,11 +1,11 @@ -@mixin privateButtonsSizes { +@mixin themeButtonsSizes { &:not(.pi-close-button):not(.pi-hamburger):not(.pi-button__xx-small):not( .pi-button__x-large ):not(.pi-button__toggle):not(.pi-button__trimmed):not(.ch2-btn):not( .pi-button__icon--single ) { &:not(.pi-button__x-small):not(.pi-button__xx-small) { - height: var(--pi-spacing-xxx-large); + height: var(--pi-theme-button-height); } } @@ -36,15 +36,22 @@ } // Fill -@mixin privateButtonFillColor($color) { +@mixin themeButtonFillColor($color) { // Default color &:not(.pi-button__appearance--ghost):not(.pi-button__appearance--outline):not( .pi-color__white ) { background-color: var(--pi-color-#{$color}); + // Un tema puede inyectar aquí un degradado; por defecto es `none` y manda + // el `background-color` de arriba. + background-image: var(--pi-theme-button-fill-#{$color}, none); + box-shadow: var(--pi-theme-button-shadow); color: var(--pi-color-white); + font-weight: var(--pi-theme-button-font-weight); &:disabled { background-color: var(--pi-color-#{$color}-200); + background-image: none; + box-shadow: none; } &:hover { &:enabled { @@ -64,7 +71,7 @@ } // Ghost -@mixin privateButtonGhostColor($color) { +@mixin themeButtonGhostColor($color) { color: var(--pi-color-#{$color}); &:hover { &:enabled { @@ -77,7 +84,7 @@ } // Outline -@mixin privateButtonOutlineColor($color) { +@mixin themeButtonOutlineColor($color) { background-color: transparent; box-sizing: border-box; transition: all 0.16s ease-in; @@ -101,7 +108,7 @@ } } -@mixin privateButtonsColors { +@mixin themeButtonsColors { // Default &:not(.pi-close-button):not(.pi-hamburger):not(.mdc-switch):not( .pi-button__toggle @@ -134,20 +141,20 @@ // Fill // Primary &.pi-color__primary { - @include privateButtonFillColor(primary); + @include themeButtonFillColor(primary); } // Accent &.pi-color__accent { - @include privateButtonFillColor(accent); + @include themeButtonFillColor(accent); } // Warn &.pi-color__warn { - @include privateButtonFillColor(warn); + @include themeButtonFillColor(warn); } // Highlighted &.pi-color__highlighted { - @include privateButtonFillColor(highlighted); + @include themeButtonFillColor(highlighted); } // Ghost @@ -161,19 +168,19 @@ ):not(.pi-color__black):not(.pi-color__highlighted):not( .pi-color__grey ) { - @include privateButtonGhostColor(primary); + @include themeButtonGhostColor(primary); } // Ghost & Accent &.pi-color__accent { - @include privateButtonGhostColor(accent); + @include themeButtonGhostColor(accent); } // Ghost & Warn &.pi-color__warn { - @include privateButtonGhostColor(warn); + @include themeButtonGhostColor(warn); } // Ghost & Highlighted &.pi-color__highlighted { - @include privateButtonGhostColor(Highlighted); + @include themeButtonGhostColor(Highlighted); } // Ghost & White &.pi-color__white { @@ -224,24 +231,24 @@ &:not(.pi-color__primary):not(.pi-color__accent):not(.pi-color__warn):not( .pi-color__white ):not(.pi-color__highlighted):not(.pi-color__black) { - @include privateButtonOutlineColor(primary); + @include themeButtonOutlineColor(primary); } // Outline & Accent &.pi-color__accent { - @include privateButtonOutlineColor(accent); + @include themeButtonOutlineColor(accent); } // Outline & Warn &.pi-color__warn { - @include privateButtonOutlineColor(warn); + @include themeButtonOutlineColor(warn); } // Outline & Highlighted &.pi-color__highlighted { - @include privateButtonOutlineColor(highlighted); + @include themeButtonOutlineColor(highlighted); } &.pi-color__black { - @include privateButtonOutlineColor(grey); + @include themeButtonOutlineColor(grey); } // Outline & White &.pi-color__white { @@ -267,24 +274,30 @@ } } +// Implementación única de los botones para todos los temas: lo que cambia +// entre uno y otro son los tokens `--pi-theme-button-*`, no estos selectores. .pi-shell { - &.pi-shell__deale-saas { - .is-private { - button { - // Colors - @include privateButtonsColors; - // Sizes - @include privateButtonsSizes; - } - } + // Los ` } @else { @@ -54,7 +54,7 @@ @if (text()) { {{ text() }} } - + @if (!previous()) { } diff --git a/libs/ui/src/lib/ui/button/icon-button/icon-button.component.ts b/libs/ui/src/lib/ui/button/icon-button/icon-button.component.ts index 5ab359a..35185ca 100644 --- a/libs/ui/src/lib/ui/button/icon-button/icon-button.component.ts +++ b/libs/ui/src/lib/ui/button/icon-button/icon-button.component.ts @@ -1,9 +1,11 @@ import { + AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, HostBinding, input, + signal, viewChild, } from '@angular/core'; import { buttonSize, mode } from '../../../styles'; @@ -18,7 +20,10 @@ import { MatIcon } from '@angular/material/icon'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [MatIcon], }) -export class PiIconButtonComponent extends PiBaseButtonComponent { +export class PiIconButtonComponent + extends PiBaseButtonComponent + implements AfterViewInit +{ @HostBinding('class') get classes(): string { const customIconClass = this.iconType() === 'custom' && 'pi-icon-button__custom-icon'; @@ -58,8 +63,27 @@ export class PiIconButtonComponent extends PiBaseButtonComponent { return width ? `pi-button__${width}` : null; } + /** + * La etiqueta se puede dar de dos formas: por el input `text` o proyectando + * contenido (`Siguiente`). La plantilla + * pinta ambas, pero esta clase solo miraba `text`, así que los consumidores + * que proyectan (el banner, el paginador) quedaban marcados como «solo + * icono» y el botón colapsaba al cuadrado del icono, partiendo el texto. + */ + private readonly projectedText = + viewChild>('projectedText'); + + protected readonly hasProjectedText = signal(false); + + ngAfterViewInit(): void { + const el = this.projectedText()?.nativeElement; + this.hasProjectedText.set(!!el?.textContent?.trim()); + } + get textClass(): string | null { - return this.text() ? 'has-text' : 'pi-button__icon--single'; + return this.text() || this.hasProjectedText() + ? 'has-text' + : 'pi-button__icon--single'; } get iconPositionClass(): string | null { diff --git a/libs/ui/src/lib/ui/chip/styles/_size.scss b/libs/ui/src/lib/ui/chip/styles/_size.scss index 63cbe5f..8942857 100644 --- a/libs/ui/src/lib/ui/chip/styles/_size.scss +++ b/libs/ui/src/lib/ui/chip/styles/_size.scss @@ -2,7 +2,7 @@ @mixin chipSize($chip-height, $chip-border-radius) { &:not(.pi-chip-date):not(.pi-chip-value) { height: var(--pi-spacing-#{$chip-height}); - border-radius: $chip-border-radius; + border-radius: var(--pi-theme-chip-radius, #{$chip-border-radius}); } } @@ -13,12 +13,12 @@ &.pi-chip__single-icon { height: var(--pi-spacing-#{$chip-size}); width: var(--pi-spacing-#{$chip-size}); - border-radius: $border-radius; + border-radius: var(--pi-theme-chip-radius, #{$border-radius}); .pi-chip-affix { height: var(--pi-spacing-#{$chip-size}); width: var(--pi-spacing-#{$chip-size}); - border-radius: $border-radius; + border-radius: var(--pi-theme-chip-radius, #{$border-radius}); // Icon & > .pi-chip-affix__icon { height: var(--pi-spacing-#{$icon-size}); @@ -146,7 +146,7 @@ &:not(.pi-chip__single-icon) { .pi-chip-affix { height: $size; - border-radius: $affix-border-radius; + border-radius: var(--pi-theme-chip-radius, #{$affix-border-radius}); } } diff --git a/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.html b/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.html index f592e17..e1a4a4e 100644 --- a/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.html +++ b/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.html @@ -96,7 +96,10 @@ Data Direction: ROW --> + - - Template Error + diff --git a/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.ts b/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.ts index 49e3910..7aad36a 100644 --- a/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.ts +++ b/libs/ui/src/lib/ui/data-grid/data-grid/data-grid.component.ts @@ -19,12 +19,13 @@ import { } from '../entities/data-grid.model'; import { NgTemplateOutlet } from '@angular/common'; import { PiGridItemDirective } from '../directives/grid-item.directive'; +import { PiSkeletonComponent } from '../../progress/skeleton/skeleton.component'; @Component({ selector: 'pi-data-grid', templateUrl: './data-grid.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [PiGridItemDirective, NgTemplateOutlet], + imports: [PiGridItemDirective, NgTemplateOutlet, PiSkeletonComponent], }) export class PiDataGridComponent { @HostBinding('class') get classes(): string { diff --git a/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.html b/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.html new file mode 100644 index 0000000..73ab967 --- /dev/null +++ b/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.html @@ -0,0 +1,58 @@ + + + + + +@if (value().length) { +
    + @for (file of value(); track file.name + file.size; let i = $index) { +
  • + + {{ file.name }} + {{ readableSize(file.size) }} + +
  • + } +
+} + +@if (enableErrorMessage() && hasError()) { + + @for (message of errorMessages(); track message) { + {{ message | translate }} + } + +} diff --git a/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.scss b/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.scss new file mode 100644 index 0000000..92979b9 --- /dev/null +++ b/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.scss @@ -0,0 +1,106 @@ +:host { + display: block; +} + +.pi-file-upload { + &__dropzone { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--pi-spacing-xx-small); + width: 100%; + padding: var(--pi-spacing-x-large); + border: 2px dashed var(--pi-theme-field-border-color); + border-radius: var(--pi-theme-field-radius, var(--pi-border-radius-large)); + background-color: var(--pi-theme-field-surface); + color: var(--pi-color-grey); + font-family: inherit; + cursor: pointer; + transition: var(--pi-theme-button-transition); + + &:hover:not(:disabled) { + border-color: var(--pi-theme-field-border-color-hover); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } + } + + &__label { + color: var(--pi-color-black); + font-size: var(--pi-font-size-small); + font-weight: 600; + } + + &__hint { + font-size: var(--pi-font-size-xx-small); + } + + // El input nativo se oculta: lo abre el botón de la zona de arrastre. + &__native { + display: none; + } + + &__files { + display: flex; + flex-direction: column; + gap: var(--pi-spacing-xxx-small); + margin: var(--pi-spacing-x-small) 0 0; + padding: 0; + list-style: none; + } + + &__file { + display: flex; + align-items: center; + gap: var(--pi-spacing-xx-small); + padding: var(--pi-spacing-xx-small) var(--pi-spacing-x-small); + border-radius: var(--pi-border-radius-small); + background-color: var(--pi-color-grey-25); + font-size: var(--pi-font-size-x-small); + + small { + color: var(--pi-color-grey); + } + } + + &__file-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--pi-color-black); + } + + &__remove { + display: flex; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + } + } + + &__error { + display: block; + margin-top: var(--pi-spacing-xx-small); + color: var(--pi-color-warn); + font-size: var(--pi-font-size-x-small); + } +} + +// Realce mientras se arrastra un fichero encima. +:host(.is-dragging) .pi-file-upload__dropzone { + border-color: var(--pi-color-primary); + background-color: var(--pi-color-primary-50); +} + +:host(.has-error) .pi-file-upload__dropzone { + border-color: var(--pi-color-warn); +} diff --git a/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.ts b/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.ts new file mode 100644 index 0000000..1ccf86a --- /dev/null +++ b/libs/ui/src/lib/ui/file-upload/file-upload/file-upload.component.ts @@ -0,0 +1,204 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + HostListener, + computed, + input, + model, + output, + signal, + viewChild, + ElementRef, +} from '@angular/core'; +import { FormValueControl, ValidationError } from '@angular/forms/signals'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { ComponentFamilyClass } from '../../../styles'; +import { PiIconComponent } from '../../icon/icon/icon.component'; + +/** Motivo por el que se descartó un fichero. */ +export type PiFileRejectionReason = 'type' | 'size' | 'max-files'; + +export interface PiFileRejection { + file: File; + reason: PiFileRejectionReason; +} + +/** + * Subida de ficheros con arrastrar y soltar. + * + * `ComponentFamilyClass` ya declaraba la familia `file-upload` pero no existía + * ni componente ni estilos. + * + * Implementa `FormValueControl`, así que se enlaza con `[formField]` + * como cualquier otro campo. La validación de tipo y tamaño se resuelve aquí + * (el navegador solo filtra en el diálogo, no al soltar) y lo rechazado se + * comunica por `rejected` en vez de descartarse en silencio. + */ +@Component({ + selector: 'pi-file-upload', + templateUrl: './file-upload.component.html', + styleUrls: ['./file-upload.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiIconComponent, TranslatePipe], +}) +export class PiFileUploadComponent implements FormValueControl { + @HostBinding('class') get classes(): string { + return [ + ComponentFamilyClass.FileUpload, + this.isDragging() ? 'is-dragging' : '', + this.disabled() ? 'is-disabled' : '', + this.hasError() ? 'has-error' : '', + ].join(' '); + } + + /* --- Contrato FormValueControl --------------------------------------- */ + + readonly value = model([]); + + readonly errors = input([]); + readonly disabled = input(false); + readonly required = input(false); + readonly name = input(''); + + readonly touch = output(); + + /* --- Configuración ---------------------------------------------------- */ + + /** Igual que el `accept` nativo: `image/*`, `.pdf`, … */ + readonly accept = input(''); + + readonly multiple = input(false); + + /** Tamaño máximo por fichero, en bytes. */ + readonly maxSizeBytes = input(); + + /** Número máximo de ficheros cuando `multiple` está activo. */ + readonly maxFiles = input(); + + readonly label = input('common.fileUpload.dropHere'); + readonly hint = input(); + + readonly enableErrorMessage = input(true); + + /** Ficheros descartados en el último intento. */ + readonly rejected = output(); + + private readonly fileInput = + viewChild>('fileInput'); + + protected readonly isDragging = signal(false); + + protected readonly errorMessages = computed(() => + this.errors().map((error) => error.message ?? error.kind), + ); + + protected readonly hasError = computed(() => this.errors().length > 0); + + /* --- Arrastrar y soltar ----------------------------------------------- */ + + @HostListener('dragover', ['$event']) + protected onDragOver(event: DragEvent): void { + if (this.disabled()) return; + // Sin `preventDefault` el navegador abriría el fichero en la pestaña. + event.preventDefault(); + this.isDragging.set(true); + } + + @HostListener('dragleave', ['$event']) + protected onDragLeave(event: DragEvent): void { + event.preventDefault(); + this.isDragging.set(false); + } + + @HostListener('drop', ['$event']) + protected onDrop(event: DragEvent): void { + if (this.disabled()) return; + event.preventDefault(); + this.isDragging.set(false); + this.addFiles(Array.from(event.dataTransfer?.files ?? [])); + } + + protected openPicker(): void { + if (this.disabled()) return; + this.fileInput()?.nativeElement.click(); + } + + protected onPicked(event: Event): void { + const input = event.target as HTMLInputElement; + this.addFiles(Array.from(input.files ?? [])); + // Permite volver a elegir el mismo fichero justo después. + input.value = ''; + } + + protected remove(index: number): void { + const next = [...this.value()]; + next.splice(index, 1); + this.value.set(next); + this.touch.emit(); + } + + /** Tamaño legible; se usa en la lista de ficheros. */ + protected readableSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + private addFiles(incoming: File[]): void { + if (!incoming.length) return; + + const rejections: PiFileRejection[] = []; + const accepted: File[] = []; + + for (const file of incoming) { + if (!this.matchesAccept(file)) { + rejections.push({ file, reason: 'type' }); + continue; + } + const maxSize = this.maxSizeBytes(); + if (maxSize !== undefined && file.size > maxSize) { + rejections.push({ file, reason: 'size' }); + continue; + } + accepted.push(file); + } + + // En modo simple el último sustituye al anterior. + let next = this.multiple() ? [...this.value(), ...accepted] : accepted.slice(-1); + + const maxFiles = this.maxFiles(); + if (this.multiple() && maxFiles !== undefined && next.length > maxFiles) { + for (const file of next.slice(maxFiles)) { + rejections.push({ file, reason: 'max-files' }); + } + next = next.slice(0, maxFiles); + } + + this.value.set(next); + this.touch.emit(); + if (rejections.length) this.rejected.emit(rejections); + } + + /** + * Réplica de la comprobación del `accept` nativo, necesaria porque al soltar + * ficheros el navegador no filtra nada. + */ + private matchesAccept(file: File): boolean { + const accept = this.accept().trim(); + if (!accept) return true; + + return accept.split(',').some((rawPattern) => { + const pattern = rawPattern.trim().toLowerCase(); + if (!pattern) return false; + if (pattern.startsWith('.')) { + return file.name.toLowerCase().endsWith(pattern); + } + if (pattern.endsWith('/*')) { + return file.type.toLowerCase().startsWith(pattern.slice(0, -1)); + } + return file.type.toLowerCase() === pattern; + }); + } +} diff --git a/libs/ui/src/lib/ui/file-upload/index.ts b/libs/ui/src/lib/ui/file-upload/index.ts new file mode 100644 index 0000000..c98eebb --- /dev/null +++ b/libs/ui/src/lib/ui/file-upload/index.ts @@ -0,0 +1 @@ +export * from './file-upload/file-upload.component'; diff --git a/libs/ui/src/lib/ui/form-field/base-form-field.component.ts b/libs/ui/src/lib/ui/form-field/base-form-field.component.ts index a61e00c..d964cd9 100644 --- a/libs/ui/src/lib/ui/form-field/base-form-field.component.ts +++ b/libs/ui/src/lib/ui/form-field/base-form-field.component.ts @@ -3,12 +3,16 @@ import { Component, HostBinding, computed, + effect, input, model, output, signal, + viewChild, } from '@angular/core'; import { FormValueControl, ValidationError } from '@angular/forms/signals'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatInput } from '@angular/material/input'; import { ComponentFamilyClass } from '../../styles'; /** @@ -74,6 +78,35 @@ export abstract class PiBaseFormFieldComponent< protected readonly hasError = computed(() => this.errors().length > 0); + /** + * `MatInput` normalmente deriva `errorState` de un `NgControl` (`ngModel`, + * `formControlName`...); aquí no hay ninguno, el estado vive en `errors()` + * (relleno por la directiva `FormField` de Signal Forms). Sin este matcher + * Material nunca marca el campo como inválido (ni borde rojo ni mensaje), + * pase lo que pase con `touched`/`invalid`. + */ + protected readonly errorStateMatcher: ErrorStateMatcher = { + isErrorState: () => this.hasError(), + }; + + /** + * Sin `NgControl`, el `ngDoCheck` de `MatInput` nunca recalcula + * `errorState` por sí solo, así que forzamos el recálculo a mano cada vez + * que cambian los errores. Si el campo concreto no usa `matInput` (p. ej. + * `pi-search-field`), `matInputRef()` sale `undefined` y no hace nada. + */ + private readonly matInputRef = viewChild(MatInput); + + constructor() { + effect(() => { + this.hasError(); + const matInput = this.matInputRef(); + if (!matInput) return; + matInput.errorStateMatcher = this.errorStateMatcher; + matInput.updateErrorState(); + }); + } + protected get focusedClass(): string | boolean { return this.focused() ? 'is-focused' : false; } diff --git a/libs/ui/src/lib/ui/form-field/checkbox/checkbox.component.html b/libs/ui/src/lib/ui/form-field/checkbox/checkbox.component.html new file mode 100644 index 0000000..437a848 --- /dev/null +++ b/libs/ui/src/lib/ui/form-field/checkbox/checkbox.component.html @@ -0,0 +1,22 @@ + + @if (label()) { + {{ label() | translate }} + } + + + +@if (enableErrorMessage() && hasError()) { + + @for (message of errorMessages(); track message) { + {{ message | translate }} + } + +} diff --git a/libs/ui/src/lib/ui/form-field/checkbox/checkbox.component.ts b/libs/ui/src/lib/ui/form-field/checkbox/checkbox.component.ts new file mode 100644 index 0000000..831a2c4 --- /dev/null +++ b/libs/ui/src/lib/ui/form-field/checkbox/checkbox.component.ts @@ -0,0 +1,89 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, + computed, + input, + model, + output, +} from '@angular/core'; +import { FormCheckboxControl, ValidationError } from '@angular/forms/signals'; +import { MatCheckbox } from '@angular/material/checkbox'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { ComponentFamilyClass } from '../../../styles'; + +/** Contador para dar un `id` único a cada instancia. */ +let nextUniqueId = 0; + +/** + * Casilla de verificación. + * + * Implementa `FormCheckboxControl` (como `pi-slide-toggle`): el contrato de + * Signal Forms para casillas usa `checked` y prohíbe declarar `value`, así que + * no puede extender `PiBaseFormFieldComponent`. + * + * Envuelve `MatCheckbox` a propósito: los estilos de la librería + * (`common/vendors/_mat-checkbox.scss`) apuntan a las clases `.mat-mdc-checkbox` + * y `.mdc-checkbox__*`, así que heredarlos exige el componente de Material + * debajo. + */ +@Component({ + selector: 'pi-checkbox', + templateUrl: './checkbox.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatCheckbox, TranslatePipe], +}) +export class PiCheckboxComponent implements FormCheckboxControl { + @HostBinding('class') get classes(): string { + return [ + ComponentFamilyClass.Field, + 'pi-checkbox', + this.checked() ? 'is-checked' : '', + this.disabled() ? 'is-disabled' : '', + this.hasError() ? 'has-error' : '', + ].join(' '); + } + + /* --- Contrato FormCheckboxControl ------------------------------------ */ + + readonly checked = model(false); + readonly disabled = input(false); + readonly required = input(false); + readonly name = input(''); + readonly errors = input([]); + + readonly touch = output(); + + /* --- Presentación ----------------------------------------------------- */ + + /** Texto de la casilla. También se puede proyectar contenido libre. */ + readonly label = input(); + + /** Estado intermedio, para casillas que agrupan a otras. */ + readonly indeterminate = model(false); + + readonly enableErrorMessage = input(true); + + /** Mismo criterio que `PiBaseFormFieldComponent`. */ + protected readonly errorMessages = computed(() => + this.errors().map((error) => error.message ?? error.kind), + ); + + protected readonly hasError = computed(() => this.errors().length > 0); + + /** + * `name` solo lo rellena la directiva `FormField`; suelto llega vacío. Sin + * este respaldo todas las instancias compartirían `id` y el `for` del + * `