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

View File

@@ -179,11 +179,10 @@ corrigió la aridad a `track trackByFn(i)`.
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:
| Símbolo | Se exporta desde | Se omite de |
| ----------------------------------------------- | --------------------- | ------------------- |
| `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` |
| `PiDisabledDirective` | `util/status` | `ui/text` |
| `featureKey`, `getFeatureState`, `initialState` | `util/device-configs` | `util/list-configs` |
| Símbolo | Se exporta desde | Se omite de |
| ------------------------- | ---------------- | ------------------ |
| `PiSlideToggleComponent` | `ui/form-field` | `ui/slide-toggle` |
| `PiDisabledDirective` | `util/status` | `ui/text` |
## 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.
- **`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 —
`prefer-inject` (51), `directive-selector` (46), `no-explicit-any` (35),
`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": {}
}
},
"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": {
"executor": "@nx/jest:jest",
"outputs": [

View File

@@ -11,16 +11,7 @@ export * from './lib/util/device-configs';
export * from './lib/util/environments';
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 type { ListConfigsState, ListMode } from './lib/util/list-configs';
export * from './lib/util/list-configs';
export * from './lib/util/loaders';
export * from './lib/util/local-storage';
export * from './lib/util/menu';
@@ -36,6 +27,8 @@ export * from './lib/util/value';
export * from './lib/util/view-title';
export * from './lib/ui/accordion';
export * from './lib/ui/avatar';
export * from './lib/ui/badge';
export * from './lib/ui/banner';
export * from './lib/ui/button';
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-table';
export * from './lib/ui/divider';
export * from './lib/ui/drawer';
export * from './lib/ui/error-status-code';
export * from './lib/ui/file-upload';
export * from './lib/ui/form-field';
@@ -57,6 +51,7 @@ export * from './lib/ui/note';
export * from './lib/ui/notification';
export * from './lib/ui/paginator';
export * from './lib/ui/panel';
export * from './lib/ui/popover';
export * from './lib/ui/progress';
export * from './lib/ui/select';
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-note';
@forward 'pi-panel';
@forward 'pi-popover';
@forward 'pi-spinner';
@forward 'pi-tab';
@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(.mat-form-field-invalid) {
&:not(.mat-focused) {

View File

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

View File

@@ -14,6 +14,10 @@ export const ShellClass = {
Default: shellTheming('default'),
/** Aspecto de marketing: degradados, brillo al hover y controles mayores. */
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 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
// `alternative` lo sobreescribe.
// El orden importa: `default` declara el contrato completo de tokens y los
// demás temas lo sobreescriben.
@forward 'default';
@forward 'alternative';
@forward 'minimal';
@forward 'gradient';
@use 'default' 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()) {
<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" />
</button>
} @else {
@@ -54,7 +54,7 @@
@if (text()) {
<span>{{ text() }}</span>
}
<span #projectedText><ng-content></ng-content></span>
<span #projectedText class="pi-icon-button__projected"><ng-content></ng-content></span>
@if (!previous()) {
<mat-icon [svgIcon]="icon()!" alt="icon"></mat-icon>
}

View File

@@ -17,6 +17,14 @@
align-items: center;
justify-content: center;
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) {
&:hover {
cursor: pointer;

View File

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

View File

@@ -8,16 +8,8 @@ import {
input,
contentChild,
viewChild,
inject,
} from '@angular/core';
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';
@Component({
selector: 'pi-data-table-item',
@@ -42,8 +34,6 @@ export class PiDataTableItemComponent implements AfterViewInit {
readonly item = contentChild<TemplateRef<any>>('item');
readonly componentItem = viewChild<ElementRef>('componentItem');
private readonly store = inject<Store<DeviceConfigsState>>(Store);
ngAfterViewInit(): void {
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
export * from './infrastructure/notification.service';
// State
export * from './state/notification.actions';
export * from './state/notification.effects';
// Utils
export * from './utils';
// Module
// Components
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
import { Injectable, inject } from '@angular/core';
// Ngrx
import { Store } from '@ngrx/store';
import { Injectable } from '@angular/core';
// Rxjs
import { Observable } from 'rxjs';
import { BehaviorSubject, Observable, combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';
// State
import { Browser, Device, Size } from '../entities/device-configs.model';
import * as fromActions from '../state/device-configs.actions';
import * as fromState from '../state/device-configs.reducer';
import * as fromSelectors from '../state/device-configs.selectors';
import { getUserBrowser } from '../services/device-configs.service';
@Injectable({ providedIn: 'root' })
export class DeviceConfigsFacade {
// `inject()` en vez de parámetro de constructor: los inicializadores de
// propiedad de abajo se evalúan antes que los parámetros del constructor.
private readonly store = inject<Store<fromState.DeviceConfigsState>>(Store);
private readonly deviceSubject = new BehaviorSubject<Device>('DESKTOP');
private readonly sizeSubject = new BehaviorSubject<Size | null>(null);
private readonly browserSubject = new BehaviorSubject<Browser>('UNKNOWN');
// Device
readonly device$: Observable<Device> = this.store.select(
fromSelectors.getDevice,
);
readonly device$: Observable<Device> = this.deviceSubject.asObservable();
// `null` hasta que se resuelve el tamaño (así lo declara el estado inicial)
readonly size$: Observable<Size | null> = this.store.select(
fromSelectors.getSize,
);
readonly isSmallDevice$: Observable<boolean> = this.store.select(
fromSelectors.getIsSmallDevice,
);
readonly size$: Observable<Size | null> = this.sizeSubject.asObservable();
readonly isSmallDevice$: Observable<boolean> = combineLatest([
this.device$,
this.size$,
]).pipe(map(([device, size]) => device !== 'DESKTOP' && size !== 'LARGE'));
// Browser
readonly browser$: Observable<Browser> = this.store.select(
fromSelectors.getBrowser,
);
readonly browser$: Observable<Browser> = this.browserSubject.asObservable();
getBrowser(): void {
this.store.dispatch(fromActions.BrowserActions.getBrowser());
this.browserSubject.next(getUserBrowser());
}
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
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 { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { Inject, Injectable, PLATFORM_ID, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
// Entities
import {
@@ -23,8 +21,24 @@ import {
import { Browser } from '../entities/device-configs.model';
// State
import * as fromActions from '../state/device-configs.actions';
import { DeviceConfigsState } from '../state/device-configs.reducer';
import { DeviceConfigsFacade } from '../application/device-configs.facade';
export function getUserBrowser(): Browser {
const userAgent = window.navigator.userAgent;
if (/MSIE|Trident/.test(userAgent)) {
return 'EXPLORER';
} else if (/Firefox/.test(userAgent)) {
return 'FIREFOX';
} else if (/Edg|Edge/.test(userAgent)) {
return 'EDGE';
} else if (/Chrome/.test(userAgent) && !/Edg|Edge/.test(userAgent)) {
return 'CHROME';
} else if (/Safari/.test(userAgent) && !/Edg|Edge/.test(userAgent)) {
return 'SAFARI';
} else {
return 'UNKNOWN';
}
}
@Injectable({
providedIn: 'root',
@@ -32,7 +46,7 @@ import { DeviceConfigsState } from '../state/device-configs.reducer';
export class DeviceConfigsService {
private htmlTag!: HTMLElement;
private readonly store = inject<Store<DeviceConfigsState>>(Store);
private readonly deviceConfigsFacade = inject(DeviceConfigsFacade);
private readonly breakpointObserver = inject(BreakpointObserver);
private readonly document = inject<Document>(DOCUMENT);
readonly platformId = inject<Object>(PLATFORM_ID);
@@ -42,20 +56,7 @@ export class DeviceConfigsService {
}
getUserBrowser(): Browser {
const userAgent = window.navigator.userAgent;
if (/MSIE|Trident/.test(userAgent)) {
return 'EXPLORER';
} else if (/Firefox/.test(userAgent)) {
return 'FIREFOX';
} else if (/Edg|Edge/.test(userAgent)) {
return 'EDGE';
} else if (/Chrome/.test(userAgent) && !/Edg|Edge/.test(userAgent)) {
return 'CHROME';
} else if (/Safari/.test(userAgent) && !/Edg|Edge/.test(userAgent)) {
return 'SAFARI';
} else {
return 'UNKNOWN';
}
return getUserBrowser();
}
private deviceConfigsHandler(): void {
@@ -82,12 +83,8 @@ export class DeviceConfigsService {
if (result.matches) {
// Mobile
if (result.breakpoints[MaxMobileBreakpoint]) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceType({ device: 'MOBILE' }),
);
this.store.dispatch(
fromActions.DeviceActions.saveDeviceSize({ size: 'SMALL' }),
);
this.deviceConfigsFacade.setDevice('MOBILE');
this.deviceConfigsFacade.setSize('SMALL');
this.setClassIsMobile();
}
// Tablet
@@ -95,18 +92,14 @@ export class DeviceConfigsService {
result.breakpoints[MinTabletBreakpoint] &&
result.breakpoints[MaxTabletBreakpoint]
) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceType({ device: 'TABLET' }),
);
this.deviceConfigsFacade.setDevice('TABLET');
this.setClassIsTablet();
// Tablet S
if (
result.breakpoints[MinTabletSmallBreakpoint] &&
result.breakpoints[MaxTabletSmallBreakpoint]
) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceSize({ size: 'SMALL' }),
);
this.deviceConfigsFacade.setSize('SMALL');
this.setClassIsTabletSmall();
}
// Tablet L
@@ -114,27 +107,21 @@ export class DeviceConfigsService {
result.breakpoints[MinTabletLargeBreakpoint] &&
result.breakpoints[MaxTabletLargeBreakpoint]
) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceSize({ size: 'LARGE' }),
);
this.deviceConfigsFacade.setSize('LARGE');
this.setClassIsTabletLarge();
}
}
// Desktop
if (result.breakpoints[MinDesktopBreakpoint]) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceType({ device: 'DESKTOP' }),
);
this.deviceConfigsFacade.setDevice('DESKTOP');
this.setClassIsDesktop();
// Desktop S
if (
result.breakpoints[MinDesktopSmallBreakpoint] &&
result.breakpoints[MaxDesktopSmallBreakpoint]
) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceSize({ size: 'SMALL' }),
);
this.deviceConfigsFacade.setSize('SMALL');
this.setClassIsDesktopSmall();
}
// Desktop M
@@ -142,16 +129,12 @@ export class DeviceConfigsService {
result.breakpoints[MinDesktopMediumBreakpoint] &&
result.breakpoints[MaxDesktopMediumBreakpoint]
) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceSize({ size: 'MEDIUM' }),
);
this.deviceConfigsFacade.setSize('MEDIUM');
this.setClassIsDesktopMedium();
}
// Desktop L
if (result.breakpoints[MinDesktopLargeBreakpoint]) {
this.store.dispatch(
fromActions.DeviceActions.saveDeviceSize({ size: 'LARGE' }),
);
this.deviceConfigsFacade.setSize('LARGE');
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
import { Injectable, inject } from '@angular/core';
// Ngrx
import { Store } from '@ngrx/store';
import { Injectable } from '@angular/core';
// Rxjs
import { Observable } from 'rxjs';
import { BehaviorSubject, Observable } from 'rxjs';
// State
import * as fromSelectors from '../state/list-configs.selectors';
import * as fromActions from '../state/list-configs.actions';
import * as fromState from '../state/list-configs.reducer';
export type ListMode = 'grid' | 'list';
const initialMode: ListMode = 'list';
@Injectable({ providedIn: 'root' })
export class ListConfigsFacade {
// `inject()` en vez de parámetro de constructor: los inicializadores de
// propiedad de abajo se evalúan antes que los parámetros del constructor.
private readonly store = inject<Store<fromState.ListConfigsState>>(Store);
private readonly modeSubject = new BehaviorSubject<ListMode>(initialMode);
readonly mode$: Observable<fromState.ListMode> = this.store.select(
fromSelectors.getListMode,
);
readonly mode$: Observable<ListMode> = this.modeSubject.asObservable();
setListMode(payload: fromState.ListMode): void {
this.store.dispatch(fromActions.setListMode({ payload }));
setListMode(payload: ListMode): void {
this.modeSubject.next(payload);
}
resetListMode(): void {
this.store.dispatch(fromActions.resetListMode());
this.modeSubject.next(initialMode);
}
}

View File

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

View File

@@ -1,10 +1,2 @@
// Module
export * from './util-loader.module';
// 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';
// State
export * from './state/router.actions';
export * from './state/router.effects';
export * from './state/router.reducer';
export * from './state/router.selectors';
// Facade
export * from './router.facade';
// 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"
}
}