prepara update

This commit is contained in:
2026-07-31 20:47:15 +02:00
parent a0908e1b5a
commit b02c885b83
74 changed files with 1379 additions and 883 deletions

3
.gitignore vendored
View File

@@ -8,6 +8,9 @@ out-tsc
# dependencies # dependencies
node_modules node_modules
# Credenciales de registro npm (Nexus u otro). Ver .npmrc.example.
.npmrc
# IDEs and editors # IDEs and editors
/.idea /.idea
.project .project

21
.npmrc.example Normal file
View File

@@ -0,0 +1,21 @@
# Copia este fichero a `.npmrc` (en la raíz del repo) y rellena los valores
# reales de tu Nexus. `.npmrc` está en .gitignore: nunca se commitea un token.
#
# Solo hace falta esto para PUBLICAR @pi/ui. Instalar paquetes públicos
# (@angular/*, rxjs, etc.) sigue yendo contra el registro público de npm salvo
# que tu Nexus también actúe de proxy/group para el scope por defecto.
# Redirige solo el scope @pi al repositorio npm (hosted) de Nexus.
# Cambia la URL por la de tu instancia y el nombre de tu repo npm-hosted.
@pi:registry=https://nexus.tuempresa.com/repository/npm-private/
# Autenticación por token (recomendado, funciona igual en local y en CI).
# El token se genera en Nexus: usuario > Security > NuGet/npm token, o vía
# `npm login --registry=https://nexus.tuempresa.com/repository/npm-private/`
# (esto último escribe la línea de abajo automáticamente en tu ~/.npmrc).
//nexus.tuempresa.com/repository/npm-private/:_authToken=${NEXUS_NPM_TOKEN}
# Nexus no es el registro público de npm: sin este flag, `npm publish`
# rechaza paquetes con scope si no lleva --access, y con un registro privado
# "restricted" es lo correcto (no lo confundas con "hazlo público en npmjs").
always-auth=true

View File

@@ -6,7 +6,6 @@ import {
} from '@angular/core'; } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, withInMemoryScrolling } from '@angular/router'; import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { provideStore } from '@ngrx/store';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { import {
@@ -16,7 +15,7 @@ import {
} from '@ngx-translate/core'; } from '@ngx-translate/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { IconRegistryService, deviceConfigsReducer, featureKey } from '@pi/ui'; import { DeviceConfigsService, IconRegistryService } from '@pi/ui';
/** /**
* Diccionario en línea: la librería resuelve los textos con `| translate`, y * Diccionario en línea: la librería resuelve los textos con `| translate`, y
@@ -62,12 +61,15 @@ export const appConfig: ApplicationConfig = {
// Los iconos son SVG registrados en el MatIconRegistry (assets/icons). // Los iconos son SVG registrados en el MatIconRegistry (assets/icons).
provideAppInitializer(() => inject(IconRegistryService).init()), provideAppInitializer(() => inject(IconRegistryService).init()),
// data-table, carousel y background-image leen el tipo de dispositivo;
// instanciarlo arranca el listener de breakpoints.
provideAppInitializer(() => {
inject(DeviceConfigsService);
}),
provideTranslateService({ provideTranslateService({
fallbackLang: 'es', fallbackLang: 'es',
loader: { provide: TranslateLoader, useClass: PlaygroundTranslateLoader }, loader: { provide: TranslateLoader, useClass: PlaygroundTranslateLoader },
}), }),
// data-table y background-image leen el tipo de dispositivo del store.
provideStore({ [featureKey]: deviceConfigsReducer }),
], ],
}; };

View File

@@ -9,10 +9,19 @@
</div> </div>
<div class="theme-toggles"> <div class="theme-toggles">
<label class="theme-toggle"> <div class="theme-switcher" role="radiogroup" aria-label="Tema">
<span>Tema alternativo</span> @for (option of themeOptions; track option.value) {
<pi-slide-toggle [(checked)]="alternativeTheme" /> <button
</label> type="button"
role="radio"
[attr.aria-checked]="theme() === option.value"
[class.is-active]="theme() === option.value"
(click)="theme.set(option.value)"
>
{{ option.label }}
</button>
}
</div>
<label class="theme-toggle"> <label class="theme-toggle">
<span>Modo oscuro</span> <span>Modo oscuro</span>
<pi-slide-toggle [(checked)]="darkMode" /> <pi-slide-toggle [(checked)]="darkMode" />
@@ -32,16 +41,14 @@
</nav> </nav>
<p class="theme-hint"> <p class="theme-hint">
Tema: Tema: <strong>{{ theme() }}</strong> ·
<strong>{{ alternativeTheme() ? 'alternative' : 'default' }}</strong> · <code>{{ shellClassByTheme[theme()] }}</code>
<code en el <code>&lt;body&gt;</code>. Los cuatro temas comparten exactamente los
>{{ alternativeTheme() ? 'pi-shell__alternative' : 'pi-shell__default'
}}</code
>
en el <code>&lt;body&gt;</code>. Los dos temas comparten exactamente los
mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y
<code>--pi-theme-*</code> que definen <code>themes/_default.scss</code> y <code>--pi-theme-*</code> que definen <code>themes/_default.scss</code>,
<code>themes/_alternative.scss</code>. <code>themes/_alternative.scss</code>, <code>themes/_minimal.scss</code> y
<code>themes/_gradient.scss</code>. Prueba "Gradient" y enfoca un campo del
formulario.
</p> </p>
</header> </header>

View File

@@ -47,6 +47,30 @@
white-space: nowrap; white-space: nowrap;
} }
.theme-switcher {
display: flex;
gap: 0.25rem;
padding: 0.2rem;
border: 1px solid var(--pg-card-border);
border-radius: 8px;
button {
padding: 0.3rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--pg-text-muted);
font-size: 0.8rem;
cursor: pointer;
&.is-active {
background: var(--pg-badge-bg);
color: var(--pg-badge-text);
font-weight: 600;
}
}
}
.theme-hint { .theme-hint {
margin: 0.75rem 0 0; margin: 0.75rem 0 0;
font-size: 0.8rem; font-size: 0.8rem;

View File

@@ -33,13 +33,30 @@ export class App {
protected readonly darkMode = signal(false); protected readonly darkMode = signal(false);
protected readonly themeOptions: ReadonlyArray<{
value: ThemeVariant;
label: string;
}> = [
{ value: 'default', label: 'Default' },
{ value: 'alternative', label: 'Alternative' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'gradient', label: 'Gradient' },
];
/** /**
* Tema visual de la librería. `ShellClass` devuelve las dos clases que hay * Tema visual de la librería. `ShellClass` devuelve las dos clases que hay
* que poner juntas (`pi-shell pi-shell__<tema>`); van en el `<body>` porque * que poner juntas (`pi-shell pi-shell__<tema>`); van en el `<body>` porque
* los overlays de Material (diálogos, snackbars) se montan fuera de * los overlays de Material (diálogos, snackbars) se montan fuera de
* `<app-root>` y también deben heredarlas. * `<app-root>` y también deben heredarlas.
*/ */
protected readonly alternativeTheme = signal(false); protected readonly theme = signal<ThemeVariant>('default');
protected readonly shellClassByTheme: Record<ThemeVariant, string> = {
default: ShellClass.Default,
alternative: ShellClass.Alternative,
minimal: ShellClass.Minimal,
gradient: ShellClass.Gradient,
};
constructor() { constructor() {
effect(() => { effect(() => {
@@ -50,10 +67,9 @@ export class App {
}); });
effect(() => { effect(() => {
const shell = this.alternativeTheme() this.document.body.className = this.shellClassByTheme[this.theme()];
? ShellClass.Alternative
: ShellClass.Default;
this.document.body.className = shell;
}); });
} }
} }
type ThemeVariant = 'default' | 'alternative' | 'minimal' | 'gradient';

View File

@@ -195,6 +195,26 @@
<pi-divider color="primary" width="large" label="Separador" /> <pi-divider color="primary" width="large" label="Separador" />
</app-section> </app-section>
<app-section
title="Avatar y Badge"
hint="pi-avatar cae en cascada a iniciales (con name) y luego a icono; pi-badge envuelve cualquier contenido proyectado con un indicador superpuesto."
>
<pi-avatar image="https://i.pravatar.cc/128?img=12" alt="Ada Lovelace" />
<pi-avatar name="Ada Lovelace" color="primary" />
<pi-avatar name="Grace Hopper" color="accent" size="large" />
<pi-avatar color="highlighted" shape="square" />
<pi-badge [value]="3" color="warn">
<pi-icon-button icon="notification-on" />
</pi-badge>
<pi-badge color="success">
<pi-avatar name="Ada Lovelace" color="primary" />
</pi-badge>
<pi-badge [value]="128" [max]="99" color="primary" position="top-left">
<pi-icon-button icon="email" />
</pi-badge>
</app-section>
<app-section <app-section
title="Cabecera y listado" title="Cabecera y listado"
hint="pi-headline para portadas de sección, pi-list-item para filas de listado. pi-headline resuelve pathImg contra un CDN externo fijo, así que la ilustración solo carga con red." hint="pi-headline para portadas de sección, pi-list-item para filas de listado. pi-headline resuelve pathImg contra un CDN externo fijo, así que la ilustración solo carga con red."
@@ -481,6 +501,49 @@
</button> </button>
</app-section> </app-section>
<app-section
title="Drawer"
hint="pi-drawer tampoco se usa como etiqueta: se abre vía MatDialog con getDrawerConfigs, igual que pi-modal pero anclado a un borde."
>
<button pi-primary (click)="openDemoDrawer('right')">
Abrir drawer (derecha)
</button>
<button pi-outline (click)="openDemoDrawer('left')">
Abrir drawer (izquierda)
</button>
<ng-template #drawerTemplate>
<p>Contenido de ejemplo dentro del drawer.</p>
<p>Puede llevar cualquier plantilla: formularios, listas, filtros…</p>
</ng-template>
</app-section>
<app-section
title="Popover"
hint="pi-popover es una directiva ([pi-popover]) sobre @angular/cdk/overlay: no hay componente Material de contenido enriquecido que envolver."
>
<button
pi-outline
pi-popover
[popoverContent]="popoverTemplate"
popoverPosition="below"
>
Click (popover)
</button>
<button
pi-outline
pi-popover
[popoverContent]="popoverTemplate"
popoverPosition="above"
popoverTrigger="hover"
>
Hover (popover)
</button>
<ng-template #popoverTemplate let-close="close">
<p>Contenido del popover, con cierre programático.</p>
<button pi-ghost (click)="close()">Cerrar</button>
</ng-template>
</app-section>
<app-section title="Data grid"> <app-section title="Data grid">
<div class="stack full"> <div class="stack full">
<pi-data-grid <pi-data-grid

View File

@@ -1,8 +1,10 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
TemplateRef,
inject, inject,
signal, signal,
viewChild,
} from '@angular/core'; } from '@angular/core';
import { import {
FormField, FormField,
@@ -19,6 +21,8 @@ import {
PiAccordionItemContentDirective, PiAccordionItemContentDirective,
PiAccordionItemDirective, PiAccordionItemDirective,
PiActionButtonComponent, PiActionButtonComponent,
PiAvatarComponent,
PiBadgeComponent,
PiBannerComponent, PiBannerComponent,
PiBaseDataTableComponent, PiBaseDataTableComponent,
PiCardBottomContentDirective, PiCardBottomContentDirective,
@@ -46,6 +50,9 @@ import {
DatasetColor, DatasetColor,
Device, Device,
PiDividerComponent, PiDividerComponent,
DrawerConfigKeys,
PiDrawerComponent,
getDrawerConfigs,
PiEmailFieldComponent, PiEmailFieldComponent,
PiExpansionPanelComponent, PiExpansionPanelComponent,
PiGhostButtonDirective, PiGhostButtonDirective,
@@ -83,6 +90,7 @@ import {
PiPanelExpandableContentDirective, PiPanelExpandableContentDirective,
PiPasswordFieldComponent, PiPasswordFieldComponent,
PiPhoneFieldComponent, PiPhoneFieldComponent,
PiPopoverDirective,
PiProgressBarComponent, PiProgressBarComponent,
PiRadioButtonComponent, PiRadioButtonComponent,
PiRange, PiRange,
@@ -189,6 +197,8 @@ interface DemoModel {
PiIconComponent, PiIconComponent,
PiTextViewerComponent, PiTextViewerComponent,
PiDividerComponent, PiDividerComponent,
PiAvatarComponent,
PiBadgeComponent,
PiChipComponent, PiChipComponent,
PiChipStatusComponent, PiChipStatusComponent,
PiChipValueComponent, PiChipValueComponent,
@@ -205,6 +215,7 @@ interface DemoModel {
PiListItemRightContentDirective, PiListItemRightContentDirective,
PiListItemRightIconDirective, PiListItemRightIconDirective,
PiTooltipDirective, PiTooltipDirective,
PiPopoverDirective,
PiNotFoundComponent, PiNotFoundComponent,
// Feedback // Feedback
PiProgressSpinnerComponent, PiProgressSpinnerComponent,
@@ -497,6 +508,23 @@ export class CatalogPage {
); );
} }
/* --- Drawer -------------------------------------------------------------- */
/** Igual que `pi-modal`: se abre desde TypeScript vía `MatDialog`. */
private readonly drawerTemplate =
viewChild.required<TemplateRef<unknown>>('drawerTemplate');
protected openDemoDrawer(side: 'left' | 'right'): void {
this.dialog.open(
PiDrawerComponent,
getDrawerConfigs(
side === 'left' ? DrawerConfigKeys.Left : DrawerConfigKeys.Right,
{ title: 'Detalle', template: this.drawerTemplate() },
),
);
this.log(`drawer abierto (${side})`);
}
/* --- Datos: grid, tabla, gráfico, carrusel ------------------------------ */ /* --- Datos: grid, tabla, gráfico, carrusel ------------------------------ */
protected readonly employees = [ protected readonly employees = [

View File

@@ -180,10 +180,9 @@ El origen tiene símbolos duplicados. En `index.ts` gana el primero y el otro se
omite (queda anotado en el fichero). Siguen siendo accesibles por ruta directa: omite (queda anotado en el fichero). Siguen siendo accesibles por ruta directa:
| Símbolo | Se exporta desde | Se omite de | | Símbolo | Se exporta desde | Se omite de |
| ----------------------------------------------- | --------------------- | ------------------- | | ------------------------- | ---------------- | ------------------ |
| `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` | | `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` |
| `PiDisabledDirective` | `util/status` | `ui/text` | | `PiDisabledDirective` | `util/status` | `ui/text` |
| `featureKey`, `getFeatureState`, `initialState` | `util/device-configs` | `util/list-configs` |
## APIs de terceros actualizadas ## APIs de terceros actualizadas
@@ -197,7 +196,6 @@ omite (queda anotado en el fichero). Siguen siendo accesibles por ruta directa:
- **Tests**: no se copiaron los `*.spec.ts` del origen. - **Tests**: no se copiaron los `*.spec.ts` del origen.
- **`Sector`** vivía en `deale-saas/util/models` (lib de aplicación, fuera de alcance); se trajo la interfaz a `util/models`. - **`Sector`** vivía en `deale-saas/util/models` (lib de aplicación, fuera de alcance); se trajo la interfaz a `util/models`.
- **ngrx 21 con Angular 22**: instalado con `--legacy-peer-deps` (aún no declara soporte para 22).
- **Lint**: `nx lint ui` falla con avisos heredados del código original — - **Lint**: `nx lint ui` falla con avisos heredados del código original —
`prefer-inject` (51), `directive-selector` (46), `no-explicit-any` (35), `prefer-inject` (51), `directive-selector` (46), `no-explicit-any` (35),
`no-unused-expressions` (33), a11y (`click-events-have-key-events`, `no-unused-expressions` (33), a11y (`click-events-have-key-events`,

BIN
libs/ui/docs/theming.pdf Normal file

Binary file not shown.

View File

@@ -23,6 +23,23 @@
"development": {} "development": {}
} }
}, },
"package": {
"executor": "@nx/angular:package",
"outputs": [
"{workspaceRoot}/dist/{projectRoot}"
],
"defaultConfiguration": "production",
"options": {
"project": "libs/ui/ng-package.json",
"tsConfig": "libs/ui/tsconfig.lib.json"
},
"configurations": {
"production": {
"tsConfig": "libs/ui/tsconfig.lib.publish.json"
},
"development": {}
}
},
"test": { "test": {
"executor": "@nx/jest:jest", "executor": "@nx/jest:jest",
"outputs": [ "outputs": [

View File

@@ -11,16 +11,7 @@ export * from './lib/util/device-configs';
export * from './lib/util/environments'; export * from './lib/util/environments';
export * from './lib/util/icon-registry'; export * from './lib/util/icon-registry';
export * from './lib/util/infinite-scroll'; export * from './lib/util/infinite-scroll';
// ./lib/util/list-configs: omitido(s) por colisión con otro módulo -> featureKey, getFeatureState, initialState export * 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/loaders';
export * from './lib/util/local-storage'; export * from './lib/util/local-storage';
export * from './lib/util/menu'; export * from './lib/util/menu';
@@ -36,6 +27,8 @@ export * from './lib/util/value';
export * from './lib/util/view-title'; export * from './lib/util/view-title';
export * from './lib/ui/accordion'; export * from './lib/ui/accordion';
export * from './lib/ui/avatar';
export * from './lib/ui/badge';
export * from './lib/ui/banner'; export * from './lib/ui/banner';
export * from './lib/ui/button'; export * from './lib/ui/button';
export * from './lib/ui/card'; export * from './lib/ui/card';
@@ -45,6 +38,7 @@ export * from './lib/ui/chip';
export * from './lib/ui/data-grid'; export * from './lib/ui/data-grid';
export * from './lib/ui/data-table'; export * from './lib/ui/data-table';
export * from './lib/ui/divider'; export * from './lib/ui/divider';
export * from './lib/ui/drawer';
export * from './lib/ui/error-status-code'; export * from './lib/ui/error-status-code';
export * from './lib/ui/file-upload'; export * from './lib/ui/file-upload';
export * from './lib/ui/form-field'; export * from './lib/ui/form-field';
@@ -57,6 +51,7 @@ export * from './lib/ui/note';
export * from './lib/ui/notification'; export * from './lib/ui/notification';
export * from './lib/ui/paginator'; export * from './lib/ui/paginator';
export * from './lib/ui/panel'; export * from './lib/ui/panel';
export * from './lib/ui/popover';
export * from './lib/ui/progress'; export * from './lib/ui/progress';
export * from './lib/ui/select'; export * from './lib/ui/select';
export * from './lib/ui/slider'; export * from './lib/ui/slider';

View File

@@ -0,0 +1,17 @@
// El panel de `pi-popover` es un overlay de `@angular/cdk/overlay`: cuelga de
// `.cdk-overlay-container`, fuera del árbol del componente que lo dispara.
.cdk-overlay-container {
.pi-popover__panel {
// `.cdk-overlay-pane` ya es `display: flex` (así lo fija el propio CDK);
// el `<ng-template>` del consumidor no aporta un contenedor propio, así
// que sus hijos quedan como items flex directos de este panel. Sin
// `column` quedarían en fila en vez de apilados.
flex-direction: column;
gap: var(--pi-spacing-x-small);
background-color: var(--pi-color-white);
border-radius: var(--pi-border-radius-medium);
box-shadow: var(--pi-box-shadow-panel);
padding: var(--pi-spacing-medium);
max-width: 20rem;
}
}

View File

@@ -5,6 +5,7 @@
@forward 'pi-list-item'; @forward 'pi-list-item';
@forward 'pi-note'; @forward 'pi-note';
@forward 'pi-panel'; @forward 'pi-panel';
@forward 'pi-popover';
@forward 'pi-spinner'; @forward 'pi-spinner';
@forward 'pi-tab'; @forward 'pi-tab';
@forward 'pi-text-viewer'; @forward 'pi-text-viewer';

View File

@@ -0,0 +1,40 @@
// El panel de `pi-drawer` es un `MatDialogConfig.panelClass`: el overlay lo
// cuelga fuera de `pi-drawer.component`, así que estos estilos no pueden ir
// en el scss del componente (view encapsulation no llegaría hasta ahí).
.cdk-overlay-container,
.pi-shell {
.pi-drawer__panel {
margin: 0;
.mat-mdc-dialog-container,
.mdc-dialog__surface {
border-radius: 0;
height: 100%;
}
&.pi-drawer__panel--right {
animation: pi-drawer-slide-in-right 0.25s ease-out;
}
&.pi-drawer__panel--left {
animation: pi-drawer-slide-in-left 0.25s ease-out;
}
}
}
@keyframes pi-drawer-slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes pi-drawer-slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}

View File

@@ -17,6 +17,35 @@
} }
} }
// Anillo de foco en degradado: opt-in por tema vía
// `--pi-theme-field-focus-gradient` (ver `themes/_gradient.scss`). Los
// temas que no la declaren pintan `background: none` y no se ve nada.
//
// El notched-outline dibuja su borde partido en 3 piezas (leading/notch/
// trailing) para dejar hueco a la etiqueta flotante; un degradado
// continuo ahí se vería cortado. Por eso el anillo va aparte, como
// `::after` de `.mat-mdc-text-field-wrapper` (la caja completa, sin
// partir), con `mask-composite` para que solo pinte el borde y no tape
// ni el texto ni los iconos.
.mat-mdc-text-field-wrapper {
position: relative;
}
&.mat-focused .mat-mdc-text-field-wrapper::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: var(--pi-theme-field-radius);
padding: var(--pi-theme-field-border-width, 1px);
background: var(--pi-theme-field-focus-gradient, none);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
// NOT Invalid // NOT Invalid
&:not(.mat-form-field-invalid) { &:not(.mat-form-field-invalid) {
&:not(.mat-focused) { &:not(.mat-focused) {

View File

@@ -3,6 +3,7 @@
@forward 'mat-chip'; @forward 'mat-chip';
@forward 'mat-datepicker'; @forward 'mat-datepicker';
@forward 'mat-dialog'; @forward 'mat-dialog';
@forward 'mat-drawer';
@forward 'mat-expansion-panel'; @forward 'mat-expansion-panel';
@forward 'mat-form-field'; @forward 'mat-form-field';
@forward 'mat-menu'; @forward 'mat-menu';

View File

@@ -14,6 +14,10 @@ export const ShellClass = {
Default: shellTheming('default'), Default: shellTheming('default'),
/** Aspecto de marketing: degradados, brillo al hover y controles mayores. */ /** Aspecto de marketing: degradados, brillo al hover y controles mayores. */
Alternative: shellTheming('alternative'), Alternative: shellTheming('alternative'),
/** Esquinas casi rectas, borde fino en vez de sombra, sin degradados. */
Minimal: shellTheming('minimal'),
/** Como Default, pero con anillo en degradado al enfocar un campo. */
Gradient: shellTheming('gradient'),
}; };
// View component level // View component level

View File

@@ -0,0 +1,49 @@
// TEMA GRADIENT
//
// Como `_alternative.scss`, reusa el contrato de tokens sin tocar selectores
// de componente. La firma de este tema es el degradado: en el anillo de foco
// de los campos (`--pi-theme-field-focus-gradient`, consumido en
// `_mat-form-field.scss`) y en el relleno de los botones
// (`--pi-theme-button-fill-*`, consumido en `_pi-button-theme.scss`, el mismo
// mecanismo que ya usa `_alternative.scss`). El resto (forma, tamaño,
// tipografía) se deja igual que el tema por defecto para que el foco visual
// sea justo el degradado.
//
// `--pi-theme-field-border-color-focus: transparent` apaga el borde sólido
// del notched-outline al enfocar: si no, se vería el borde plano por debajo
// del anillo en degradado.
//
// `warn`/`success` no se tocan (igual que en `_alternative.scss`): son
// colores semánticos y un degradado ahí perjudicaría la lectura del estado.
@mixin gradientThemeTokens {
// --- Campos: anillo de foco en degradado ------------------------------
--pi-theme-field-focus-gradient: linear-gradient(
120deg,
var(--pi-color-primary),
var(--pi-color-accent),
var(--pi-color-highlighted)
);
--pi-theme-field-border-color-focus: transparent;
// --- Botones: relleno en degradado -------------------------------------
--pi-theme-button-fill-primary: linear-gradient(
120deg,
var(--pi-color-primary-400),
var(--pi-color-primary-900)
);
--pi-theme-button-fill-accent: linear-gradient(
120deg,
var(--pi-color-accent-400),
var(--pi-color-accent-900)
);
--pi-theme-button-fill-highlighted: linear-gradient(
120deg,
var(--pi-color-highlighted-300),
var(--pi-color-highlighted)
);
}
.pi-shell__gradient {
@include gradientThemeTokens;
}

View File

@@ -0,0 +1,50 @@
// TEMA MINIMALISTA
//
// Sale del lenguaje visual "Material" (relleno con degradado, esquinas muy
// redondeadas, sombra de elevación) hacia algo más plano: esquinas casi
// rectas, borde fino en vez de sombra, sin versalitas ni interletrado. Reusa
// el mismo contrato de tokens que `_alternative.scss`, así que tampoco
// duplica ni un selector de componente.
//
// La paleta de marca (--pi-color-*) se deja intacta a propósito: "minimalista"
// aquí es una cuestión de forma (radio, sombra, peso), no de color.
@mixin minimalThemeTokens {
// --- Botones: esquinas casi rectas, sin sombra ni degradado -----------
--pi-theme-button-height: 2.5rem;
--pi-theme-button-radius: var(--pi-border-radius-xx-small);
--pi-theme-button-font-weight: 500;
--pi-theme-button-letter-spacing: normal;
--pi-theme-button-text-transform: none;
--pi-theme-button-shadow: none;
--pi-theme-button-transition:
background-color 0.12s ease-out,
border-color 0.12s ease-out;
--pi-theme-button-fill-primary: none;
--pi-theme-button-fill-accent: none;
--pi-theme-button-fill-warn: none;
--pi-theme-button-fill-success: none;
--pi-theme-button-fill-highlighted: none;
// --- Campos: mismas esquinas rectas, borde fino y neutro --------------
--pi-theme-field-radius: var(--pi-border-radius-xx-small);
--pi-theme-field-border-width: 1px;
--pi-theme-field-border-color: var(--pi-color-grey-200);
--pi-theme-field-border-color-hover: var(--pi-color-grey-400);
--pi-theme-field-border-color-focus: var(--pi-color-black);
--pi-theme-field-surface: var(--pi-color-white);
--pi-theme-field-label-color: var(--pi-color-grey-400);
--pi-theme-field-label-transform: none;
--pi-theme-field-label-letter-spacing: normal;
--pi-theme-field-label-weight: 500;
// --- Superficies: separan por borde, no por elevación -----------------
--pi-theme-card-radius: var(--pi-border-radius-xx-small);
--pi-theme-card-shadow: none;
--pi-theme-panel-radius: var(--pi-border-radius-xx-small);
--pi-theme-chip-radius: 0;
}
.pi-shell__minimal {
@include minimalThemeTokens;
}

View File

@@ -1,7 +1,11 @@
// El orden importa: `default` declara el contrato completo de tokens y // El orden importa: `default` declara el contrato completo de tokens y los
// `alternative` lo sobreescribe. // demás temas lo sobreescriben.
@forward 'default'; @forward 'default';
@forward 'alternative'; @forward 'alternative';
@forward 'minimal';
@forward 'gradient';
@use 'default' as *; @use 'default' as *;
@use 'alternative' as *; @use 'alternative' as *;
@use 'minimal' as *;
@use 'gradient' as *;

View File

@@ -0,0 +1,7 @@
@if (showImage()) {
<img [src]="image()!" [alt]="alt() ?? name() ?? ''" (error)="onImageError()" />
} @else if (initials()) {
<span class="pi-avatar__initials">{{ initials() }}</span>
} @else {
<pi-icon [icon]="icon()" [size]="size()" />
}

View File

@@ -0,0 +1,73 @@
@mixin avatarColor($color) {
background-color: var(--pi-color-#{$color}-75);
color: var(--pi-color-#{$color});
}
@mixin avatarSize($dimension, $font-size) {
height: $dimension;
width: $dimension;
min-width: $dimension;
.pi-avatar__initials {
font-size: $font-size;
}
}
:host {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
img {
height: 100%;
width: 100%;
object-fit: cover;
}
.pi-avatar__initials {
font-weight: 700;
line-height: 1;
user-select: none;
}
// Shape
&.pi-avatar__shape--circle {
border-radius: 50%;
}
&.pi-avatar__shape--square {
border-radius: var(--pi-border-radius-small);
}
// Size
&.pi-avatar__size--x-small {
@include avatarSize(1.5rem, var(--pi-font-size-xxx-small));
}
&.pi-avatar__size--small {
@include avatarSize(2rem, var(--pi-font-size-xx-small));
}
&.pi-avatar__size--medium {
@include avatarSize(2.5rem, var(--pi-font-size-x-small));
}
&.pi-avatar__size--large {
@include avatarSize(3.25rem, var(--pi-font-size-small));
}
&.pi-avatar__size--x-large {
@include avatarSize(4rem, var(--pi-font-size-medium));
}
// Color (fallback: iniciales o icono)
&.pi-color__primary {
@include avatarColor(primary);
}
&.pi-color__accent {
@include avatarColor(accent);
}
&.pi-color__highlighted {
@include avatarColor(highlighted);
}
&.pi-color__grey {
@include avatarColor(grey);
}
}

View File

@@ -0,0 +1,60 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
computed,
input,
signal,
} from '@angular/core';
// Shared
import { iconSize, theming } from '../../../styles';
import { PiIconComponent } from '../../icon/icon/icon.component';
@Component({
selector: 'pi-avatar',
templateUrl: './avatar.component.html',
styleUrls: ['./avatar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent],
})
export class PiAvatarComponent {
@HostBinding('class') get classes(): string {
return [
'pi-avatar',
`pi-avatar__size--${this.size()}`,
`pi-avatar__shape--${this.shape()}`,
`pi-color__${this.color()}`,
].join(' ');
}
// Content
readonly image = input<string>();
readonly alt = input<string>();
readonly name = input<string>();
/** Icono de respaldo cuando no hay `image` ni `name`. */
readonly icon = input('user');
// Skin
readonly size = input<iconSize>('medium');
readonly shape = input<'circle' | 'square'>('circle');
readonly color = input<theming>('primary');
private readonly imageFailed = signal(false);
protected readonly showImage = computed(
() => !!this.image() && !this.imageFailed(),
);
protected readonly initials = computed(() => {
const value = this.name()?.trim();
if (!value) return '';
const [first, ...rest] = value.split(/\s+/).filter(Boolean);
const last = rest[rest.length - 1];
return ((first?.[0] ?? '') + (last?.[0] ?? '')).toUpperCase();
});
protected onImageError(): void {
this.imageFailed.set(true);
}
}

View File

@@ -0,0 +1 @@
export * from './avatar/avatar.component';

View File

@@ -0,0 +1,9 @@
<ng-content />
@if (!isHidden()) {
<span [class]="indicatorClasses">
@if (hasValue()) {
{{ displayValue() }}
}
</span>
}

View File

@@ -0,0 +1,97 @@
@mixin badgeColor($background, $color) {
background-color: $background;
color: $color;
}
@mixin badgePosition($vertical, $horizontal, $translate-x, $translate-y) {
#{$vertical}: 0;
#{$horizontal}: 0;
transform: translate($translate-x, $translate-y);
}
:host {
position: relative;
display: inline-flex;
}
.pi-badge__indicator {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
line-height: 1;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
// Position
&.pi-badge__position--top-right {
@include badgePosition(top, right, 50%, -50%);
}
&.pi-badge__position--top-left {
@include badgePosition(top, left, -50%, -50%);
}
&.pi-badge__position--bottom-right {
@include badgePosition(bottom, right, 50%, 50%);
}
&.pi-badge__position--bottom-left {
@include badgePosition(bottom, left, -50%, 50%);
}
// Size (with value)
&.pi-badge__size--medium {
min-width: 1.125rem;
height: 1.125rem;
padding: 0 var(--pi-spacing-xxx-small);
font-size: var(--pi-font-size-xxx-small);
}
&.pi-badge__size--small {
min-width: 0.875rem;
height: 0.875rem;
padding: 0 var(--pi-spacing-xxxx-small);
font-size: 0.4375rem;
}
// Dot (no value)
&.pi-badge__indicator--dot {
padding: 0;
&.pi-badge__size--medium {
min-width: 0.5rem;
width: 0.5rem;
height: 0.5rem;
}
&.pi-badge__size--small {
min-width: 0.375rem;
width: 0.375rem;
height: 0.375rem;
}
}
// Color
&.pi-color__primary {
@include badgeColor(var(--pi-color-primary), var(--pi-color-white));
}
&.pi-color__accent {
@include badgeColor(var(--pi-color-accent), var(--pi-color-black));
}
&.pi-color__warn {
@include badgeColor(var(--pi-color-warn), var(--pi-color-white));
}
&.pi-color__success {
@include badgeColor(var(--pi-color-success), var(--pi-color-white));
}
&.pi-color__highlighted {
@include badgeColor(var(--pi-color-highlighted), var(--pi-color-white));
}
&.pi-color__grey {
@include badgeColor(var(--pi-color-grey), var(--pi-color-white));
}
&.pi-color__black {
@include badgeColor(var(--pi-color-black), var(--pi-color-white));
}
&.pi-color__white {
@include badgeColor(var(--pi-color-white), var(--pi-color-black));
border: 0.0625rem solid var(--pi-color-grey-200);
}
}

View File

@@ -0,0 +1,60 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
computed,
input,
} from '@angular/core';
// Shared
import { styleType } from '../../../styles';
@Component({
selector: 'pi-badge',
templateUrl: './badge.component.html',
styleUrls: ['./badge.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiBadgeComponent {
@HostBinding('class') className = 'pi-badge';
// Content
/** Si no se define, se muestra un punto sin valor. */
readonly value = input<string | number>();
/** Solo aplica cuando `value` es numérico: por encima se muestra `${max}+`. */
readonly max = input(99);
// Skin
readonly color = input<styleType>('warn');
readonly position = input<
'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
>('top-right');
readonly size = input<'small' | 'medium'>('medium');
/** Oculta el indicador sin desmontar el contenido proyectado. */
readonly isHidden = input(false);
protected readonly hasValue = computed(() => {
const value = this.value();
return value !== undefined && value !== null && value !== '';
});
protected readonly displayValue = computed(() => {
const value = this.value();
if (typeof value === 'number') {
const max = this.max();
return value > max ? `${max}+` : `${value}`;
}
return value ?? '';
});
protected get indicatorClasses(): string {
return [
'pi-badge__indicator',
`pi-badge__position--${this.position()}`,
`pi-badge__size--${this.size()}`,
`pi-color__${this.color()}`,
this.hasValue() ? '' : 'pi-badge__indicator--dot',
].join(' ');
}
}

View File

@@ -0,0 +1 @@
export * from './badge/badge.component';

View File

@@ -23,7 +23,7 @@
@if (text()) { @if (text()) {
<span>{{ text() }}</span> <span>{{ text() }}</span>
} }
<span #projectedText><ng-content></ng-content></span> <span #projectedText class="pi-icon-button__projected"><ng-content></ng-content></span>
<img src="/assets/icons/{{ icon() }}.svg" /> <img src="/assets/icons/{{ icon() }}.svg" />
</button> </button>
} @else { } @else {
@@ -54,7 +54,7 @@
@if (text()) { @if (text()) {
<span>{{ text() }}</span> <span>{{ text() }}</span>
} }
<span #projectedText><ng-content></ng-content></span> <span #projectedText class="pi-icon-button__projected"><ng-content></ng-content></span>
@if (!previous()) { @if (!previous()) {
<mat-icon [svgIcon]="icon()!" alt="icon"></mat-icon> <mat-icon [svgIcon]="icon()!" alt="icon"></mat-icon>
} }

View File

@@ -17,6 +17,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--pi-spacing-xx-small); gap: var(--pi-spacing-xx-small);
// `#projectedText` se pinta siempre (se usa para detectar si hay
// contenido proyectado), pero sin `display: contents` cuenta como hijo
// flex vacío y el `gap` de arriba desplaza el icono del centro real
// cuando no se proyecta nada (p. ej. `<pi-icon-button icon="x" />`).
.pi-icon-button__projected {
display: contents;
}
&:not(:disabled) { &:not(:disabled) {
&:hover { &:hover {
cursor: pointer; cursor: pointer;

View File

@@ -6,15 +6,11 @@ import {
effect, effect,
inject, inject,
} from '@angular/core'; } from '@angular/core';
import { Store } from '@ngrx/store';
import { DataTableItem } from '../entities/data-table-item.model'; import { DataTableItem } from '../entities/data-table-item.model';
import { ComponentFamilyClass } from '../../../styles'; import { ComponentFamilyClass } from '../../../styles';
import { import { DeviceConfigsFacade } from '../../../util/device-configs';
DeviceConfigsState,
DeviceTypeSelectors,
} from '../../../util/device-configs';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
@Component({ @Component({
@@ -37,7 +33,7 @@ export class PiBaseDataTableComponent {
readonly type = input<'header' | 'data'>('data'); readonly type = input<'header' | 'data'>('data');
readonly hideValues = input(false); readonly hideValues = input(false);
private readonly store = inject<Store<DeviceConfigsState>>(Store); private readonly deviceConfigsFacade = inject(DeviceConfigsFacade);
constructor() { constructor() {
// `hideHeader` lee inputs; las otras dos líneas del antiguo `ngOnInit` solo // `hideHeader` lee inputs; las otras dos líneas del antiguo `ngOnInit` solo
@@ -54,10 +50,9 @@ export class PiBaseDataTableComponent {
} }
private hideHeader(): void { private hideHeader(): void {
this.store this.deviceConfigsFacade.device$
.select(DeviceTypeSelectors.getDevice)
.pipe(take(1)) .pipe(take(1))
.subscribe((deviceType: string) => { .subscribe((deviceType) => {
if (deviceType !== 'DESKTOP' && this.hideValues()) { if (deviceType !== 'DESKTOP' && this.hideValues()) {
delete this.items()?.[0]?.values; delete this.items()?.[0]?.values;
} }

View File

@@ -8,16 +8,8 @@ import {
input, input,
contentChild, contentChild,
viewChild, viewChild,
inject,
} from '@angular/core'; } from '@angular/core';
import { textStyle, justify, borderType } from '../../../styles'; import { textStyle, justify, borderType } from '../../../styles';
import { Store } from '@ngrx/store';
// Store
import { DeviceConfigsState } from '../../../util/device-configs';
// RxJS
import { take } from 'rxjs/operators';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
@Component({ @Component({
selector: 'pi-data-table-item', selector: 'pi-data-table-item',
@@ -42,8 +34,6 @@ export class PiDataTableItemComponent implements AfterViewInit {
readonly item = contentChild<TemplateRef<any>>('item'); readonly item = contentChild<TemplateRef<any>>('item');
readonly componentItem = viewChild<ElementRef>('componentItem'); readonly componentItem = viewChild<ElementRef>('componentItem');
private readonly store = inject<Store<DeviceConfigsState>>(Store);
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.setCellClassByIndex(); this.setCellClassByIndex();
} }

View File

@@ -0,0 +1,10 @@
<header>
@if (data.title) {
<span bold l>{{ data.title }}</span>
}
<pi-close-button size="small" [hasLabel]="false" (click)="close()" />
</header>
<main>
<ng-container [ngTemplateOutlet]="data.template" />
</main>

View File

@@ -0,0 +1,21 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-large) var(--pi-spacing-x-large);
border-bottom: 0.0625rem solid var(--pi-color-grey-100);
}
main {
flex-grow: 1;
overflow-y: auto;
padding: var(--pi-spacing-x-large);
}
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NgTemplateOutlet } from '@angular/common';
import { DrawerData } from '../entities/drawer.model';
import { PiCloseButtonComponent } from '../../button/close-button/close-button.component';
import { PiTextWeightBoldDirective } from '../../text/directives/text-weights.directive';
import { PiTextLargeDirective } from '../../text/directives/text-sizes.directive';
@Component({
selector: 'pi-drawer',
templateUrl: './drawer.component.html',
styleUrls: ['./drawer.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NgTemplateOutlet,
PiCloseButtonComponent,
PiTextWeightBoldDirective,
PiTextLargeDirective,
],
})
export class PiDrawerComponent {
@HostBinding('class') className = 'pi-drawer';
readonly dialogRef = inject<MatDialogRef<PiDrawerComponent>>(MatDialogRef);
readonly data = inject<DrawerData>(MAT_DIALOG_DATA);
close(): void {
this.dialogRef.close();
}
}

View File

@@ -0,0 +1,41 @@
import { MatDialogConfig } from '@angular/material/dialog';
export enum DrawerConfigKeys {
Left = 'left',
Right = 'right',
}
export type DrawerConfigs = {
[key in DrawerConfigKeys]: MatDialogConfig;
};
/**
* `enterAnimationDuration`/`exitAnimationDuration` a `0ms`: la animación de
* `MatDialog` (fade + scale desde el centro) no encaja con un panel anclado
* al borde. El deslizamiento real lo hace `_mat-drawer.scss` sobre
* `panelClass`, fuera del ciclo de animación de Material.
*/
export const drawerConfigs: DrawerConfigs = {
[DrawerConfigKeys.Right]: {
height: '100vh',
maxHeight: '100vh',
width: '24rem',
maxWidth: '90vw',
position: { top: '0', right: '0' },
backdropClass: 'pi-backdrop',
panelClass: ['pi-drawer__panel', 'pi-drawer__panel--right'],
enterAnimationDuration: '0ms',
exitAnimationDuration: '0ms',
},
[DrawerConfigKeys.Left]: {
height: '100vh',
maxHeight: '100vh',
width: '24rem',
maxWidth: '90vw',
position: { top: '0', left: '0' },
backdropClass: 'pi-backdrop',
panelClass: ['pi-drawer__panel', 'pi-drawer__panel--left'],
enterAnimationDuration: '0ms',
exitAnimationDuration: '0ms',
},
};

View File

@@ -0,0 +1,6 @@
import { TemplateRef } from '@angular/core';
export interface DrawerData {
title?: string;
template: TemplateRef<any>;
}

View File

@@ -0,0 +1,9 @@
// Component
export * from './drawer/drawer.component';
// Entities
export * from './entities/drawer.model';
export * from './entities/drawer-configs';
// Utils
export * from './utils';

View File

@@ -0,0 +1,16 @@
import { MatDialogConfig } from '@angular/material/dialog';
import { DrawerConfigKeys, drawerConfigs } from './entities/drawer-configs';
import { DrawerData } from './entities/drawer.model';
// Drawer config
export function getDrawerConfigs(
key: DrawerConfigKeys,
data: DrawerData,
disableClose?: boolean,
): MatDialogConfig<DrawerData> {
return {
...(disableClose && { disableClose }),
...drawerConfigs[key],
data,
};
}

View File

@@ -5,14 +5,8 @@ export * from './entities/notification.data';
// Services // Services
export * from './infrastructure/notification.service'; export * from './infrastructure/notification.service';
// State
export * from './state/notification.actions';
export * from './state/notification.effects';
// Utils // Utils
export * from './utils'; export * from './utils';
// Module
// Components // Components
export * from './snack-bar/snack-bar.component'; export * from './snack-bar/snack-bar.component';

View File

@@ -1,29 +0,0 @@
import { createAction, props } from '@ngrx/store';
export const displaySuccess = createAction(
'[CORE NOTIFICATION] Display Success',
props<{
payload: string;
description?: string;
}>(),
);
export const displayError = createAction(
'[CORE NOTIFICATION] Display Error',
props<{
payload: string;
description?: string;
}>(),
);
export const displayInfo = createAction(
'[CORE NOTIFICATION] Display Info',
props<{
payload: string;
description?: string;
}>(),
);
export const triggerNotificationAction = createAction(
'[CORE NOTIFICATION] Trigger Notification Action',
);

View File

@@ -1,62 +0,0 @@
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as NotificationActions from './notification.actions';
import { map, switchMap, tap } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { NotificationService } from '../infrastructure/notification.service';
@Injectable()
export class NotificationEffects {
constructor(
private action$: Actions,
private _notification: NotificationService,
) {}
displaySuccess$ = createEffect(
() => {
return this.action$.pipe(
ofType(NotificationActions.displaySuccess),
map((action) => {
return action;
}),
tap((action) => {
this._notification.success(action.payload, action.description);
}),
);
},
{ dispatch: false },
);
displayError$ = createEffect(
() => {
return this.action$.pipe(
ofType(NotificationActions.displayError),
map((action) => {
return action;
}),
tap((action) => {
this._notification.error(action.payload, action.description);
}),
);
},
{ dispatch: false },
);
displayInfo$ = createEffect(
() => {
return this.action$.pipe(
ofType(NotificationActions.displayInfo),
map((action) => {
return action;
}),
tap((action) => {
this._notification.info(action.payload, action.description);
}),
);
},
{ dispatch: false },
);
}

View File

@@ -0,0 +1 @@
export * from './popover.directive';

View File

@@ -0,0 +1,226 @@
import {
Directive,
ElementRef,
OnDestroy,
TemplateRef,
ViewContainerRef,
inject,
input,
output,
} from '@angular/core';
import {
ConnectedPosition,
Overlay,
OverlayRef,
} from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
export type PopoverPosition = 'above' | 'below' | 'before' | 'after';
export type PopoverTrigger = 'click' | 'hover';
export interface PopoverContext {
close: () => void;
}
const GAP = 8;
/** Cada posición trae su opuesta como fallback si no cabe en el viewport. */
const POSITIONS: Record<PopoverPosition, ConnectedPosition[]> = {
below: [
{
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
offsetY: GAP,
},
{
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
offsetY: -GAP,
},
],
above: [
{
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
offsetY: -GAP,
},
{
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
offsetY: GAP,
},
],
after: [
{
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: GAP,
},
{
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -GAP,
},
],
before: [
{
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -GAP,
},
{
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: GAP,
},
],
};
/**
* A diferencia de `pi-tooltip` (envuelve `MatTooltip`, solo texto), aquí no
* hay componente Material equivalente para contenido enriquecido: se monta
* directamente sobre `@angular/cdk/overlay`.
*/
@Directive({
selector: '[pi-popover]',
host: {
'(click)': 'onHostClick()',
'(mouseenter)': 'onHostMouseEnter()',
'(mouseleave)': 'onHostMouseLeave()',
},
})
export class PiPopoverDirective implements OnDestroy {
readonly popoverContent = input<TemplateRef<PopoverContext>>();
readonly popoverPosition = input<PopoverPosition>('below');
readonly popoverTrigger = input<PopoverTrigger>('click');
readonly popoverPanelClass = input<string | string[]>('pi-popover__panel');
readonly opened = output<void>();
readonly closed = output<void>();
private readonly overlay = inject(Overlay);
private readonly elementRef = inject(ElementRef);
private readonly viewContainerRef = inject(ViewContainerRef);
private overlayRef?: OverlayRef;
private closeTimeoutId?: ReturnType<typeof setTimeout>;
protected onHostClick(): void {
if (this.popoverTrigger() === 'click') this.toggle();
}
protected onHostMouseEnter(): void {
if (this.popoverTrigger() === 'hover') {
this.cancelScheduledClose();
this.show();
}
}
protected onHostMouseLeave(): void {
if (this.popoverTrigger() === 'hover') this.scheduleClose();
}
toggle(): void {
if (this.isOpen()) {
this.close();
} else {
this.show();
}
}
isOpen(): boolean {
return !!this.overlayRef?.hasAttached();
}
show(): void {
const template = this.popoverContent();
if (!template || this.isOpen()) return;
const overlayRef = this.getOverlayRef();
const context: PopoverContext = { close: () => this.close() };
overlayRef.attach(
new TemplatePortal(template, this.viewContainerRef, context),
);
this.opened.emit();
}
close(): void {
this.cancelScheduledClose();
if (!this.isOpen()) return;
this.overlayRef?.detach();
this.closed.emit();
}
ngOnDestroy(): void {
this.overlayRef?.dispose();
this.cancelScheduledClose();
}
private getOverlayRef(): OverlayRef {
if (this.overlayRef) return this.overlayRef;
const isHoverTrigger = this.popoverTrigger() === 'hover';
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions(POSITIONS[this.popoverPosition()])
.withFlexibleDimensions(false)
.withPush(true);
const overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
hasBackdrop: !isHoverTrigger,
backdropClass: 'cdk-overlay-transparent-backdrop',
panelClass: this.popoverPanelClass(),
});
if (!isHoverTrigger) {
overlayRef.backdropClick().subscribe(() => this.close());
} else {
// Sin backdrop en modo hover: el ratón entra/sale directamente del panel.
overlayRef.overlayElement.addEventListener('mouseenter', () =>
this.cancelScheduledClose(),
);
overlayRef.overlayElement.addEventListener('mouseleave', () =>
this.scheduleClose(),
);
}
overlayRef.keydownEvents().subscribe((event) => {
if (event.key === 'Escape') this.close();
});
this.overlayRef = overlayRef;
return overlayRef;
}
private scheduleClose(): void {
this.cancelScheduledClose();
// Margen para que el ratón viaje del disparador al panel sin que cierre.
this.closeTimeoutId = setTimeout(() => this.close(), 100);
}
private cancelScheduledClose(): void {
if (this.closeTimeoutId) {
clearTimeout(this.closeTimeoutId);
this.closeTimeoutId = undefined;
}
}
}

View File

@@ -1,46 +1,41 @@
// Angular // Angular
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
// Ngrx
import { Store } from '@ngrx/store';
// Rxjs // Rxjs
import { Observable } from 'rxjs'; import { BehaviorSubject, Observable, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
// State // State
import { Browser, Device, Size } from '../entities/device-configs.model'; import { Browser, Device, Size } from '../entities/device-configs.model';
import * as fromActions from '../state/device-configs.actions'; import { getUserBrowser } from '../services/device-configs.service';
import * as fromState from '../state/device-configs.reducer';
import * as fromSelectors from '../state/device-configs.selectors';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class DeviceConfigsFacade { export class DeviceConfigsFacade {
// `inject()` en vez de parámetro de constructor: los inicializadores de private readonly deviceSubject = new BehaviorSubject<Device>('DESKTOP');
// propiedad de abajo se evalúan antes que los parámetros del constructor. private readonly sizeSubject = new BehaviorSubject<Size | null>(null);
private readonly store = inject<Store<fromState.DeviceConfigsState>>(Store); private readonly browserSubject = new BehaviorSubject<Browser>('UNKNOWN');
// Device // Device
readonly device$: Observable<Device> = this.store.select( readonly device$: Observable<Device> = this.deviceSubject.asObservable();
fromSelectors.getDevice,
);
// `null` hasta que se resuelve el tamaño (así lo declara el estado inicial) // `null` hasta que se resuelve el tamaño (así lo declara el estado inicial)
readonly size$: Observable<Size | null> = this.store.select( readonly size$: Observable<Size | null> = this.sizeSubject.asObservable();
fromSelectors.getSize, readonly isSmallDevice$: Observable<boolean> = combineLatest([
); this.device$,
readonly isSmallDevice$: Observable<boolean> = this.store.select( this.size$,
fromSelectors.getIsSmallDevice, ]).pipe(map(([device, size]) => device !== 'DESKTOP' && size !== 'LARGE'));
);
// Browser // Browser
readonly browser$: Observable<Browser> = this.store.select( readonly browser$: Observable<Browser> = this.browserSubject.asObservable();
fromSelectors.getBrowser,
);
getBrowser(): void { getBrowser(): void {
this.store.dispatch(fromActions.BrowserActions.getBrowser()); this.browserSubject.next(getUserBrowser());
} }
setDevice(device: Device): void { setDevice(device: Device): void {
this.store.dispatch(fromActions.DeviceActions.saveDeviceType({ device })); this.deviceSubject.next(device);
}
setSize(size: Size): void {
this.sizeSubject.next(size);
} }
} }

View File

@@ -7,12 +7,3 @@ export * from './entities/device-configs.model';
// Services // Services
export * from './services/device-configs.service'; export * from './services/device-configs.service';
// State
export * from './state/device-configs.actions';
export * from './state/device-configs.effects';
export * from './state/device-configs.selectors';
export * from './state/device-configs.reducer';
import * as DeviceTypeActions from './state/device-configs.actions';
import * as DeviceTypeSelectors from './state/device-configs.selectors';
export { DeviceTypeActions, DeviceTypeSelectors };

View File

@@ -1,8 +1,6 @@
import { BreakpointObserver } from '@angular/cdk/layout'; import { BreakpointObserver } from '@angular/cdk/layout';
import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID, inject } from '@angular/core'; import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { Store } from '@ngrx/store';
// Entities // Entities
import { import {
@@ -23,25 +21,9 @@ import {
import { Browser } from '../entities/device-configs.model'; import { Browser } from '../entities/device-configs.model';
// State // State
import * as fromActions from '../state/device-configs.actions'; import { DeviceConfigsFacade } from '../application/device-configs.facade';
import { DeviceConfigsState } from '../state/device-configs.reducer';
@Injectable({ export function getUserBrowser(): Browser {
providedIn: 'root',
})
export class DeviceConfigsService {
private htmlTag!: HTMLElement;
private readonly store = inject<Store<DeviceConfigsState>>(Store);
private readonly breakpointObserver = inject(BreakpointObserver);
private readonly document = inject<Document>(DOCUMENT);
readonly platformId = inject<Object>(PLATFORM_ID);
constructor() {
this.deviceConfigsHandler();
}
getUserBrowser(): Browser {
const userAgent = window.navigator.userAgent; const userAgent = window.navigator.userAgent;
if (/MSIE|Trident/.test(userAgent)) { if (/MSIE|Trident/.test(userAgent)) {
return 'EXPLORER'; return 'EXPLORER';
@@ -58,6 +40,25 @@ export class DeviceConfigsService {
} }
} }
@Injectable({
providedIn: 'root',
})
export class DeviceConfigsService {
private htmlTag!: HTMLElement;
private readonly deviceConfigsFacade = inject(DeviceConfigsFacade);
private readonly breakpointObserver = inject(BreakpointObserver);
private readonly document = inject<Document>(DOCUMENT);
readonly platformId = inject<Object>(PLATFORM_ID);
constructor() {
this.deviceConfigsHandler();
}
getUserBrowser(): Browser {
return getUserBrowser();
}
private deviceConfigsHandler(): void { private deviceConfigsHandler(): void {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
this.htmlTag = this.document?.documentElement; this.htmlTag = this.document?.documentElement;
@@ -82,12 +83,8 @@ export class DeviceConfigsService {
if (result.matches) { if (result.matches) {
// Mobile // Mobile
if (result.breakpoints[MaxMobileBreakpoint]) { if (result.breakpoints[MaxMobileBreakpoint]) {
this.store.dispatch( this.deviceConfigsFacade.setDevice('MOBILE');
fromActions.DeviceActions.saveDeviceType({ device: 'MOBILE' }), this.deviceConfigsFacade.setSize('SMALL');
);
this.store.dispatch(
fromActions.DeviceActions.saveDeviceSize({ size: 'SMALL' }),
);
this.setClassIsMobile(); this.setClassIsMobile();
} }
// Tablet // Tablet
@@ -95,18 +92,14 @@ export class DeviceConfigsService {
result.breakpoints[MinTabletBreakpoint] && result.breakpoints[MinTabletBreakpoint] &&
result.breakpoints[MaxTabletBreakpoint] result.breakpoints[MaxTabletBreakpoint]
) { ) {
this.store.dispatch( this.deviceConfigsFacade.setDevice('TABLET');
fromActions.DeviceActions.saveDeviceType({ device: 'TABLET' }),
);
this.setClassIsTablet(); this.setClassIsTablet();
// Tablet S // Tablet S
if ( if (
result.breakpoints[MinTabletSmallBreakpoint] && result.breakpoints[MinTabletSmallBreakpoint] &&
result.breakpoints[MaxTabletSmallBreakpoint] result.breakpoints[MaxTabletSmallBreakpoint]
) { ) {
this.store.dispatch( this.deviceConfigsFacade.setSize('SMALL');
fromActions.DeviceActions.saveDeviceSize({ size: 'SMALL' }),
);
this.setClassIsTabletSmall(); this.setClassIsTabletSmall();
} }
// Tablet L // Tablet L
@@ -114,27 +107,21 @@ export class DeviceConfigsService {
result.breakpoints[MinTabletLargeBreakpoint] && result.breakpoints[MinTabletLargeBreakpoint] &&
result.breakpoints[MaxTabletLargeBreakpoint] result.breakpoints[MaxTabletLargeBreakpoint]
) { ) {
this.store.dispatch( this.deviceConfigsFacade.setSize('LARGE');
fromActions.DeviceActions.saveDeviceSize({ size: 'LARGE' }),
);
this.setClassIsTabletLarge(); this.setClassIsTabletLarge();
} }
} }
// Desktop // Desktop
if (result.breakpoints[MinDesktopBreakpoint]) { if (result.breakpoints[MinDesktopBreakpoint]) {
this.store.dispatch( this.deviceConfigsFacade.setDevice('DESKTOP');
fromActions.DeviceActions.saveDeviceType({ device: 'DESKTOP' }),
);
this.setClassIsDesktop(); this.setClassIsDesktop();
// Desktop S // Desktop S
if ( if (
result.breakpoints[MinDesktopSmallBreakpoint] && result.breakpoints[MinDesktopSmallBreakpoint] &&
result.breakpoints[MaxDesktopSmallBreakpoint] result.breakpoints[MaxDesktopSmallBreakpoint]
) { ) {
this.store.dispatch( this.deviceConfigsFacade.setSize('SMALL');
fromActions.DeviceActions.saveDeviceSize({ size: 'SMALL' }),
);
this.setClassIsDesktopSmall(); this.setClassIsDesktopSmall();
} }
// Desktop M // Desktop M
@@ -142,16 +129,12 @@ export class DeviceConfigsService {
result.breakpoints[MinDesktopMediumBreakpoint] && result.breakpoints[MinDesktopMediumBreakpoint] &&
result.breakpoints[MaxDesktopMediumBreakpoint] result.breakpoints[MaxDesktopMediumBreakpoint]
) { ) {
this.store.dispatch( this.deviceConfigsFacade.setSize('MEDIUM');
fromActions.DeviceActions.saveDeviceSize({ size: 'MEDIUM' }),
);
this.setClassIsDesktopMedium(); this.setClassIsDesktopMedium();
} }
// Desktop L // Desktop L
if (result.breakpoints[MinDesktopLargeBreakpoint]) { if (result.breakpoints[MinDesktopLargeBreakpoint]) {
this.store.dispatch( this.deviceConfigsFacade.setSize('LARGE');
fromActions.DeviceActions.saveDeviceSize({ size: 'LARGE' }),
);
this.setClassIsDesktopLarge(); this.setClassIsDesktopLarge();
} }
} }

View File

@@ -1,21 +0,0 @@
import { createActionGroup, emptyProps, props } from '@ngrx/store';
// Entities
import { Device, Size, Browser } from '../entities/device-configs.model';
export const DeviceActions = createActionGroup({
source: '[Main] Get Device Breakpoints',
events: {
'Save Device Type': props<{ device: Device }>(),
'Save Device Size': props<{ size: Size }>(),
},
});
export const BrowserActions = createActionGroup({
source: '[Main] Get Browser',
events: {
'Get Browser': emptyProps(),
'Get Browser Success': props<{ response: Browser }>(),
'Get Browser Error': emptyProps(),
},
});

View File

@@ -1,36 +0,0 @@
import { Injectable, inject } from '@angular/core';
// Rxjs
import { of } from 'rxjs';
import { catchError, exhaustMap, map } from 'rxjs/operators';
// Ngrx
import { Actions, createEffect, ofType } from '@ngrx/effects';
// Actions
import * as fromActions from './device-configs.actions';
// Service
import { DeviceConfigsService } from '../services/device-configs.service';
@Injectable()
export class DeviceConfigsEffects {
private readonly deviceConfigsService = inject(DeviceConfigsService);
private readonly actions = inject(Actions);
getBrowser$ = createEffect(() => {
return this.actions.pipe(
ofType(fromActions.BrowserActions.getBrowser),
exhaustMap(() =>
of(this.deviceConfigsService.getUserBrowser()).pipe(
map((response) => {
return fromActions.BrowserActions.getBrowserSuccess({ response });
}),
catchError(() => {
return of(fromActions.BrowserActions.getBrowserError());
}),
),
),
);
});
}

View File

@@ -1,32 +0,0 @@
import { createReducer, on } from '@ngrx/store';
import * as fromActions from './device-configs.actions';
import { Device, Size, Browser } from '../entities/device-configs.model';
export const featureKey = 'CORE_deviceConfigs';
export interface DeviceConfigsState {
device: Device;
size: Size | null;
browser: Browser;
}
export const initialState: DeviceConfigsState = {
device: 'DESKTOP',
size: null,
browser: 'UNKNOWN',
};
export const deviceConfigsReducer = createReducer(
initialState,
on(fromActions.DeviceActions.saveDeviceType, (state, action) => {
return { ...state, device: action.device };
}),
on(fromActions.DeviceActions.saveDeviceSize, (state, action) => {
return { ...state, size: action.size };
}),
on(fromActions.BrowserActions.getBrowserSuccess, (state, action) => {
return { ...state, browser: action.response };
}),
);

View File

@@ -1,24 +0,0 @@
import { createSelector, createFeatureSelector } from '@ngrx/store';
import * as fromReducer from './device-configs.reducer';
export const getFeatureState =
createFeatureSelector<fromReducer.DeviceConfigsState>(fromReducer.featureKey);
// Device Breakpoints
export const getDevice = createSelector(
getFeatureState,
(state) => state.device,
);
export const getSize = createSelector(getFeatureState, (state) => state.size);
// Mobile & Tablet S
export const getIsSmallDevice = createSelector(getFeatureState, (state) => {
return state.device !== 'DESKTOP' && state.size !== 'LARGE';
});
// Browser
export const getBrowser = createSelector(
getFeatureState,
(state) => state.browser,
);

View File

@@ -1,32 +1,24 @@
// Angular // Angular
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
// Ngrx
import { Store } from '@ngrx/store';
// Rxjs // Rxjs
import { Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
// State export type ListMode = 'grid' | 'list';
import * as fromSelectors from '../state/list-configs.selectors';
import * as fromActions from '../state/list-configs.actions'; const initialMode: ListMode = 'list';
import * as fromState from '../state/list-configs.reducer';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ListConfigsFacade { export class ListConfigsFacade {
// `inject()` en vez de parámetro de constructor: los inicializadores de private readonly modeSubject = new BehaviorSubject<ListMode>(initialMode);
// propiedad de abajo se evalúan antes que los parámetros del constructor.
private readonly store = inject<Store<fromState.ListConfigsState>>(Store);
readonly mode$: Observable<fromState.ListMode> = this.store.select( readonly mode$: Observable<ListMode> = this.modeSubject.asObservable();
fromSelectors.getListMode,
);
setListMode(payload: fromState.ListMode): void { setListMode(payload: ListMode): void {
this.store.dispatch(fromActions.setListMode({ payload })); this.modeSubject.next(payload);
} }
resetListMode(): void { resetListMode(): void {
this.store.dispatch(fromActions.resetListMode()); this.modeSubject.next(initialMode);
} }
} }

View File

@@ -1,7 +1,2 @@
// Facade // Facade
export * from './application/list-configs.facade'; export * from './application/list-configs.facade';
// State
export * from './state/list-configs.actions';
export * from './state/list-configs.selectors';
export * from './state/list-configs.reducer';

View File

@@ -1,15 +0,0 @@
import { createAction, props } from '@ngrx/store';
import { ListMode } from './list-configs.reducer';
export enum ListModeActionsType {
SET_LIST_MODE = '[Main] Set List Mode',
RESET_LIST_MODE = '[Main] Reset List Mode',
}
export const setListMode = createAction(
ListModeActionsType.SET_LIST_MODE,
props<{ payload: ListMode }>(),
);
export const resetListMode = createAction(ListModeActionsType.RESET_LIST_MODE);

View File

@@ -1,25 +0,0 @@
import { createReducer, on } from '@ngrx/store';
import { resetListMode, setListMode } from './list-configs.actions';
export const featureKey = 'CORE_listConfigs';
export interface ListConfigsState {
mode: ListMode;
}
export type ListMode = 'grid' | 'list';
export const initialState: ListConfigsState = {
mode: 'list',
};
export const listConfigsReducer = createReducer(
initialState,
on(setListMode, (state, action) => {
return { ...state, mode: action.payload };
}),
on(resetListMode, (state) => {
return { ...initialState };
}),
);

View File

@@ -1,11 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromReducer from './list-configs.reducer';
export const getFeatureState =
createFeatureSelector<fromReducer.ListConfigsState>(fromReducer.featureKey);
export const getListMode = createSelector(
getFeatureState,
(state) => state.mode,
);

View File

@@ -1,32 +1,20 @@
// Angular // Angular
import { Injectable, inject } from '@angular/core'; import { Injectable } from '@angular/core';
// RxJS // RxJS
import { Observable, Subscribable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
// NgRx @Injectable({ providedIn: 'root' })
import { Store, select } from '@ngrx/store';
// Store
import * as LoaderSelectors from '../state/loader.selectors';
import * as LoaderActions from '../state/loader.actions';
@Injectable()
export class LoaderFacade { export class LoaderFacade {
// `inject()` en vez de parámetro de constructor: los inicializadores de private readonly loaderSubject = new BehaviorSubject<boolean>(false);
// propiedad de abajo se evalúan antes que los parámetros del constructor.
private readonly store = inject(Store);
loader$: Observable<boolean> = this.store.pipe( loader$: Observable<boolean> = this.loaderSubject.asObservable();
select(LoaderSelectors.getLoader),
);
showLoader$?: Observable<unknown> | Subscribable<unknown> | Promise<unknown>;
showLoader() { showLoader(): void {
this.store.dispatch(LoaderActions.showLoader()); this.loaderSubject.next(true);
} }
hideLoader() { hideLoader(): void {
this.store.dispatch(LoaderActions.hideLoader()); this.loaderSubject.next(false);
} }
} }

View File

@@ -1,10 +1,2 @@
// Module
export * from './util-loader.module';
// Facade // Facade
export * from './aplication/loader.facade'; export * from './aplication/loader.facade';
// State
export * from './state/loader.actions';
export * from './state/loader.reducer';
export * from './state/loader.selectors';

View File

@@ -1,6 +0,0 @@
// NgRx
import { createAction } from '@ngrx/store';
export const showLoader = createAction('[Loader] Show Loader');
export const hideLoader = createAction('[Loader] Hide Loader');

View File

@@ -1,40 +0,0 @@
// NgRx
import { createReducer, on, Action } from '@ngrx/store';
// Store
import * as LoaderActions from './loader.actions';
export const loadersKey = 'CORE_loaderState';
export interface LoaderState {
loader: boolean;
}
export const initialLoaderState: LoaderState = {
loader: false,
};
export const loaderReducer = createReducer(
initialLoaderState,
on(LoaderActions.showLoader, (state) => {
return {
...state,
loader: true,
};
}),
on(LoaderActions.hideLoader, (state) => {
return {
...state,
loader: false,
};
}),
);
export function loaderReducerFunction(
state: LoaderState | undefined,
action: Action,
) {
return loaderReducer(state, action);
}

View File

@@ -1,11 +0,0 @@
// NgRx
import { createFeatureSelector, createSelector } from '@ngrx/store';
// Store
import { loadersKey, LoaderState } from './loader.reducer';
export const selectLoaderState = createFeatureSelector<LoaderState>(loadersKey);
export const getLoader = createSelector(selectLoaderState, (state) => {
return state.loader;
});

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoreModule } from '@ngrx/store';
import * as fromReducer from './state/loader.reducer';
@NgModule({
imports: [
CommonModule,
StoreModule.forFeature(
fromReducer.loadersKey,
fromReducer.loaderReducerFunction,
),
],
})
export class PiSharedUtilLoadersModule {}

View File

@@ -1,31 +0,0 @@
// Angular
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
// Router
import { RouterStateUrl } from './state/router.reducer';
// Ngrx
import * as fromRouter from '@ngrx/router-store';
export class CustomSerializer implements fromRouter.RouterStateSerializer<RouterStateUrl> {
serialize(routerState: RouterStateSnapshot): RouterStateUrl {
const { url } = routerState;
const { queryParams } = routerState.root;
let route: ActivatedRouteSnapshot = routerState.root;
while (route.firstChild) {
route = route.firstChild;
}
const { params, routeConfig } = route;
// Only return an object including the URL, params and query params
// instead of the entire snapshot
return {
url,
params,
queryParams,
title: typeof routeConfig?.title === 'string' ? routeConfig.title : '',
};
}
}

View File

@@ -1,10 +1,5 @@
export * from './util-router-store.module'; // Facade
export * from './router.facade';
// State
export * from './state/router.actions';
export * from './state/router.effects';
export * from './state/router.reducer';
export * from './state/router.selectors';
// Error handler // Error handler
export * from './error-handler'; export * from './error-handler';

View File

@@ -0,0 +1,55 @@
// Angular
import { Injectable, inject } from '@angular/core';
import { Location } from '@angular/common';
import { Title } from '@angular/platform-browser';
import { NavigationEnd, NavigationExtras, Router } from '@angular/router';
// RxJS
import { Observable } from 'rxjs';
import { filter, map, startWith } from 'rxjs/operators';
export interface RouterNavigationPayload {
path: any[];
query?: object;
extras?: NavigationExtras;
}
@Injectable({ providedIn: 'root' })
export class RouterFacade {
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly title = inject(Title);
private readonly navigationEnd$: Observable<NavigationEnd> =
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
);
readonly route$: Observable<string> = this.navigationEnd$.pipe(
map(() => this.router.url),
startWith(this.router.url),
);
readonly title$: Observable<string> = this.navigationEnd$.pipe(
map(() => this.title.getTitle()),
startWith(this.title.getTitle()),
);
navigateTo({ path, query, extras }: RouterNavigationPayload): void {
this.router.navigate(path, { queryParams: query, ...extras });
}
navigateRefreshTo({ path, query, extras }: RouterNavigationPayload): void {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.router.onSameUrlNavigation = 'reload';
this.router.navigate(path, { queryParams: query, ...extras });
}
navigateBack(): void {
this.location.back();
}
navigateForward(): void {
this.location.forward();
}
}

View File

@@ -1,29 +0,0 @@
import { NavigationExtras } from '@angular/router';
import { createAction, props } from '@ngrx/store';
export const navigateTo = createAction(
'[Core Router] Navigate To',
props<{
payload: {
path: any[];
query?: object;
extras?: NavigationExtras;
};
}>(),
);
export const navigateRefreshTo = createAction(
'[Core Router] Navigate Refresh To',
props<{
payload: {
path: any[];
query?: object;
extras?: NavigationExtras;
};
}>(),
);
export const navigateBack = createAction('[Core Router] Navigate Back');
export const navigateForward = createAction('[Core Router] Navigate Forward');

View File

@@ -1,74 +0,0 @@
import { Actions, createEffect, ofType } from '@ngrx/effects';
import * as RouterActions from './router.actions';
import { map, tap } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Location } from '@angular/common';
import { Router } from '@angular/router';
@Injectable()
export class RouterEffects {
navigateTo$ = createEffect(
() => {
return this.action$.pipe(
ofType(RouterActions.navigateTo),
map((action) => {
return action.payload;
}),
tap(({ path, query: queryParams, extras }) => {
this._router.navigate(path, { queryParams, ...extras });
}),
);
},
{ dispatch: false },
);
navigateRefreshTo$ = createEffect(
() => {
return this.action$.pipe(
ofType(RouterActions.navigateRefreshTo),
map((action) => {
return action.payload;
}),
tap(({ path, query: queryParams, extras }) => {
this._router.routeReuseStrategy.shouldReuseRoute = () => false;
this._router.onSameUrlNavigation = 'reload';
this._router.navigate(path, { queryParams, ...extras });
}),
);
},
{ dispatch: false },
);
navigateBack$ = createEffect(
() => {
return this.action$.pipe(
ofType(RouterActions.navigateBack),
tap(() => {
this._location.back();
}),
);
},
{ dispatch: false },
);
navigateForward$ = createEffect(
() => {
return this.action$.pipe(
ofType(RouterActions.navigateForward),
tap(() => {
this._location.forward();
}),
);
},
{ dispatch: false },
);
constructor(
private _location: Location,
private action$: Actions,
private _router: Router,
) {}
}

View File

@@ -1,16 +0,0 @@
import * as fromRouter from '@ngrx/router-store';
import { Params } from '@angular/router';
export const coreRouterFeatureKey = 'CORE_router';
export interface RouterStateUrl {
url: string;
queryParams: Params;
params: Params;
title: string;
}
export interface RouterState {
routerReducer: fromRouter.RouterReducerState<RouterStateUrl>;
}

View File

@@ -1,18 +0,0 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import * as fromRouter from '@ngrx/router-store';
import * as fromReducer from './router.reducer';
export const getRouterState = createFeatureSelector<
fromRouter.RouterReducerState<fromReducer.RouterStateUrl>
>(fromReducer.coreRouterFeatureKey);
export const getTitle = createSelector(getRouterState, (state) => {
return state?.state?.title;
});
export const getRoute = createSelector(
getRouterState,
(state) => state?.state?.url,
);

View File

@@ -1,16 +0,0 @@
import { ErrorHandler, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterState, StoreRouterConnectingModule } from '@ngrx/router-store';
import { CustomSerializer } from './custom-route-serializer';
@NgModule({
imports: [
CommonModule,
StoreRouterConnectingModule.forRoot({
routerState: RouterState.Minimal,
serializer: CustomSerializer,
}),
],
})
export class PiSharedUtilRouterStoreModule {}

View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.lib.prod.json",
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

311
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@
"build": "nx build ui", "build": "nx build ui",
"build:playground": "nx build playground", "build:playground": "nx build playground",
"build:all": "nx run-many -t build", "build:all": "nx run-many -t build",
"package:ui": "nx run ui:package",
"pack:ui": "nx run ui:package && npm pack ./dist/libs/ui --pack-destination ./dist",
"publish:ui": "nx run ui:package && npm publish ./dist/libs/ui",
"lint": "nx lint ui", "lint": "nx lint ui",
"format": "prettier --write \"{apps,libs}/**/*.{ts,html,scss,md}\"" "format": "prettier --write \"{apps,libs}/**/*.{ts,html,scss,md}\""
}, },
@@ -27,9 +30,6 @@
"@angular/router": "22.0.6", "@angular/router": "22.0.6",
"@angular/ssr": "22.0.6", "@angular/ssr": "22.0.6",
"@apollo/client": "^4.2.8", "@apollo/client": "^4.2.8",
"@ngrx/effects": "^21.1.1",
"@ngrx/router-store": "^21.1.1",
"@ngrx/store": "^21.1.1",
"@ngx-translate/core": "^18.0.0", "@ngx-translate/core": "^18.0.0",
"@ngx-translate/http-loader": "^18.0.0", "@ngx-translate/http-loader": "^18.0.0",
"apollo-angular": "^14.1.0", "apollo-angular": "^14.1.0",