Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43d5dcf1d3 | |||
| 2dd05241fc | |||
| 74b30e5427 | |||
| 81ee7855a7 | |||
| cb82769f31 | |||
| 4a33c9ade5 | |||
| c6ca5eb6ab | |||
| dde8579fbc | |||
| 23f2c5404f | |||
| 499f8ac075 |
@@ -7,13 +7,13 @@
|
||||
|
||||
# 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.pireon.dev/repository/npm-private/
|
||||
@pi:registry=https://nexus.pireon.dev/repository/npm-releases/
|
||||
|
||||
# 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.pireon.dev/repository/npm-private/`
|
||||
# `npm login --registry=https://nexus.pireon.dev/repository/npm-releases/`
|
||||
# (esto último escribe la línea de abajo automáticamente en tu ~/.npmrc).
|
||||
//nexus.pireon.dev/repository/npm-private/:_authToken=${NEXUS_NPM_TOKEN}
|
||||
//nexus.pireon.dev/repository/npm-releases/:_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
|
||||
|
||||
@@ -70,6 +70,12 @@ hecho **no se pueden enlazar a mano** esas entradas en un elemento que ya use
|
||||
`[formField]`: Angular lo rechaza con `NG8022`. Los validadores van en el
|
||||
`form()`, no en la plantilla.
|
||||
|
||||
Los errores se ven cuando el campo está **tocado**: al salir de él, o al
|
||||
enviar con `submit()` de Signal Forms, que marca todo el formulario. Un campo
|
||||
obligatorio vacío no sale en rojo nada más abrir el formulario. Para volver a
|
||||
abrir un formulario como nuevo, `f().reset(valor)` pone los valores y quita el
|
||||
estado de tocado.
|
||||
|
||||
Se puede ver funcionando en la app `playground` (`nx serve playground`).
|
||||
|
||||
## Theming: tokens CSS
|
||||
@@ -184,11 +190,23 @@ Detalles a tener en cuenta:
|
||||
- **`Dataset.cubicInterpolationMode` pasa a opcional.** Solo tiene sentido en
|
||||
`line`; forzarlo en `bar`/`pie`/`doughnut` no aportaba nada y esos tipos ni
|
||||
siquiera usan `Dataset` (los dos últimos usan `ChartSlice`).
|
||||
- **Cada gráfico registra lo suyo en `buildChart()`, no al importar.** El
|
||||
paquete es un único `fesm`, así que un `Chart.register(...)` a nivel de
|
||||
módulo se ejecutaba en cualquier app que importase `@pi/ui` y metía Chart.js
|
||||
entero en su bundle inicial, pintase gráficos o no (en TaskStream, unos
|
||||
230 kB). Registrar dos veces lo mismo no hace nada. Si se añade un tipo de
|
||||
gráfico, su registro va ahí también.
|
||||
- **El `type="bar"` del `pi-chart` original nunca funcionó**: el input
|
||||
existía pero `getLineChartConfigs()` construía siempre un chart `line`
|
||||
ignorando su valor. Se descubrió al dividir los componentes; `pi-bar-chart`
|
||||
es la primera versión que realmente renderiza barras.
|
||||
|
||||
**Sin lodash.** Las tres funciones que se usaban (`isEqual`, `some` con un
|
||||
objeto como criterio y `capitalize`) están en `util/common/compare.ts`, con la
|
||||
misma semántica. lodash es CommonJS y sin mapa de `exports`: entero pesaba en el
|
||||
bundle de quien usara un select, y por funciones (`lodash/isEqual`) Node en ESM
|
||||
(Vitest, SSR) no lo resolvía.
|
||||
|
||||
**El loader de servidor de traducciones ya no está en el barrel.**
|
||||
`util/translate/loaders/translate-server.loader` usa `fs`, `path` y `process`;
|
||||
exportarlo desde `@pi/ui` rompía el bundle de navegador de cualquier app que
|
||||
|
||||
@@ -145,6 +145,7 @@ siempre el *fallback* de `var()` (el valor que tenían antes de tokenizar).
|
||||
| `--pi-theme-accordion-radius` | `var(--pi-border-radius-large)` | `var(--pi-border-radius-xx-small)` | `accordion.component.scss`, `_horizontal-accordion.scss`, `_vertical-accordion.scss` (×6, incluye un `calc(... - 0.0625rem)`) |
|
||||
| `--pi-theme-overlay-divider-color` | `var(--pi-color-grey-100)` | — | `drawer.component.scss`, `hub.component.scss` (borde bajo la cabecera) |
|
||||
| `--pi-theme-hub-grabber-color` | `var(--pi-color-grey-100)` | — | `hub.component.scss` (el asa superior) |
|
||||
| `--pi-theme-checkbox-border-color` | `var(--pi-color-grey-400)` | — | `_mat-checkbox.scss` (borde de la casilla sin marcar; necesita 3:1 contra el fondo para verse) |
|
||||
|
||||
### Puente con Angular Material (`--mat-sys-*`)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"lib": {
|
||||
"entryFile": "src/index.ts"
|
||||
},
|
||||
"allowedNonPeerDependencies": ["lodash", "luxon", "ngx-infinite-scroll"],
|
||||
"allowedNonPeerDependencies": ["luxon", "ngx-infinite-scroll"],
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*.scss",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@pi/ui",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.9",
|
||||
"description": "Librería de componentes Angular standalone (botones, campos con Signal Forms, cards, charts, drawer/hub/modal, theming vía tokens CSS...).",
|
||||
"license": "MIT",
|
||||
"generators": "./generators.json",
|
||||
"publishConfig": {
|
||||
"registry": "https://nexus.pireon.dev/repository/npm-private/",
|
||||
"registry": "https://nexus.pireon.dev/repository/npm-releases/",
|
||||
"access": "restricted"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -21,7 +21,6 @@
|
||||
"chart.js": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.0",
|
||||
"luxon": "^3.0.0",
|
||||
"ngx-infinite-scroll": "^22.0.0"
|
||||
},
|
||||
@@ -29,4 +28,4 @@
|
||||
"exports": {
|
||||
"./styles/*": "./styles/*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +280,10 @@
|
||||
// Los `<button>` internos de Angular Material quedan fuera: el calendario del
|
||||
// datepicker, por ejemplo, monta cada día como un `<button>` y el
|
||||
// `min-width: 5.5rem` de la librería lo sacaba fuera del panel. La exclusión
|
||||
// va en `:where()` para no alterar la especificidad de la regla.
|
||||
button:not(:where([class*='mat-'])) {
|
||||
// va en `:where()` para no alterar la especificidad de la regla. La zona de
|
||||
// `pi-file-upload` también es un `<button>` (para el teclado), pero con su
|
||||
// propio aspecto: sin excluirla, el alto y el relleno de botón la aplastaban.
|
||||
button:not(:where([class*='mat-'], .pi-file-upload__dropzone)) {
|
||||
// Colors
|
||||
@include themeButtonsColors;
|
||||
// Sizes
|
||||
@@ -293,8 +295,10 @@
|
||||
// Los `<button>` internos de Angular Material quedan fuera: el calendario del
|
||||
// datepicker, por ejemplo, monta cada día como un `<button>` y el
|
||||
// `min-width: 5.5rem` de la librería lo sacaba fuera del panel. La exclusión
|
||||
// va en `:where()` para no alterar la especificidad de la regla.
|
||||
button:not(:where([class*='mat-'])) {
|
||||
// va en `:where()` para no alterar la especificidad de la regla. La zona de
|
||||
// `pi-file-upload` también es un `<button>` (para el teclado), pero con su
|
||||
// propio aspecto: sin excluirla, el alto y el relleno de botón la aplastaban.
|
||||
button:not(:where([class*='mat-'], .pi-file-upload__dropzone)) {
|
||||
// Colors
|
||||
@include themeButtonsColors;
|
||||
// Sizes
|
||||
|
||||
@@ -145,8 +145,10 @@ html,
|
||||
// Los `<button>` internos de Angular Material quedan fuera: el calendario del
|
||||
// datepicker, por ejemplo, monta cada día como un `<button>` y el
|
||||
// `min-width: 5.5rem` de la librería lo sacaba fuera del panel. La exclusión
|
||||
// va en `:where()` para no alterar la especificidad de la regla.
|
||||
button:not(:where([class*='mat-'])) {
|
||||
// va en `:where()` para no alterar la especificidad de la regla. La zona de
|
||||
// `pi-file-upload` también es un `<button>` (para el teclado), pero con su
|
||||
// propio aspecto: sin excluirla, el alto y el relleno de botón la aplastaban.
|
||||
button:not(:where([class*='mat-'], .pi-file-upload__dropzone)) {
|
||||
// Default properties
|
||||
@include defaultButtonsProperties;
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
&:enabled {
|
||||
&:not(:checked):not(:indeterminate):not([data-indeterminate='true']) {
|
||||
& ~ .mdc-checkbox__background {
|
||||
border-color: var(--pi-color-#{$color}) !important;
|
||||
border-color: var(
|
||||
--pi-theme-checkbox-border-color,
|
||||
var(--pi-color-#{$color})
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +21,9 @@
|
||||
.pi-select-option {
|
||||
@include checkboxColor(primary-200);
|
||||
}
|
||||
@include checkboxColor(grey-100);
|
||||
// grey-400: an empty box needs 3:1 against the page to be seen at all
|
||||
// (WCAG 1.4.11); grey-100 gave 1.2:1 and the box vanished on white.
|
||||
@include checkboxColor(grey-400);
|
||||
|
||||
// Checkbox
|
||||
.mdc-checkbox__background {
|
||||
@@ -136,7 +141,10 @@
|
||||
&:checked {
|
||||
& ~ .mdc-checkbox__background {
|
||||
border-color: var(--pi-color-primary);
|
||||
background-color: var(--pi-color-white);
|
||||
background-color: var(
|
||||
--pi-theme-field-surface,
|
||||
var(--pi-color-white)
|
||||
);
|
||||
}
|
||||
}
|
||||
& ~ .mdc-checkbox__background {
|
||||
@@ -163,7 +171,10 @@
|
||||
&:checked {
|
||||
& ~ .mdc-checkbox__background {
|
||||
border-color: var(--pi-color-primary);
|
||||
background-color: var(--pi-color-white);
|
||||
background-color: var(
|
||||
--pi-theme-field-surface,
|
||||
var(--pi-color-white)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// `pi-date-range-field`: sus dos fechas no son `.mat-mdc-input-element`,
|
||||
// así que la regla de arriba no las alcanza y se quedaban pegadas arriba
|
||||
// de la caja. Ocupan toda su altura y se centran como un input normal.
|
||||
.mat-date-range-input,
|
||||
.mat-date-range-input-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.mdc-text-field--disabled {
|
||||
.mat-mdc-input-element,
|
||||
.mdc-notched-outline,
|
||||
|
||||
@@ -12,8 +12,6 @@ import { barDatasetConfig } from '../chart.configs';
|
||||
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||
|
||||
Chart.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-bar-chart',
|
||||
templateUrl: './bar-chart.component.html',
|
||||
@@ -30,6 +28,9 @@ export class PiBarChartComponent extends PiBaseCartesianChartComponent {
|
||||
}
|
||||
|
||||
protected buildChart(): Chart {
|
||||
// Aquí y no al importar: así una app que nunca pinta este gráfico no
|
||||
// arrastra Chart.js. Registrar dos veces no hace nada.
|
||||
Chart.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend);
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
|
||||
@@ -15,9 +15,10 @@ import { Device } from '../../util/device-configs';
|
||||
* `pi-pie-chart`, `pi-doughnut-chart`): el `<canvas>`, el ciclo de vida que
|
||||
* construye el `Chart` una vez la vista está lista, y el `TranslateService`
|
||||
* que todos usan para las etiquetas. Cada tipo registra solo los
|
||||
* controladores/elementos de Chart.js que necesita — no hay un registro
|
||||
* global compartido, así que un consumidor que solo importe `pi-pie-chart`
|
||||
* no arrastra el código de líneas/barras.
|
||||
* controladores/elementos de Chart.js que necesita, y lo hace en
|
||||
* `buildChart`, no al importar el módulo: el paquete es un único `fesm`, así
|
||||
* que un `Chart.register` a nivel de módulo metería Chart.js entero en el
|
||||
* bundle inicial de cualquier app que use `@pi/ui`, pinte gráficos o no.
|
||||
*/
|
||||
@Component({ template: '' })
|
||||
export abstract class PiBaseChartComponent implements AfterViewInit {
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
} from 'chart.js';
|
||||
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
|
||||
|
||||
Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-doughnut-chart',
|
||||
templateUrl: './doughnut-chart.component.html',
|
||||
@@ -18,6 +16,9 @@ Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
|
||||
})
|
||||
export class PiDoughnutChartComponent extends PiBaseArcChartComponent {
|
||||
protected buildChart(): Chart {
|
||||
// Aquí y no al importar: así una app que nunca pinta este gráfico no
|
||||
// arrastra Chart.js. Registrar dos veces no hace nada.
|
||||
Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'doughnut',
|
||||
data: this.getArcData<'doughnut'>(),
|
||||
|
||||
@@ -8,8 +8,6 @@ import { ArcElement, Chart, DoughnutController } from 'chart.js';
|
||||
import { DatasetColor } from '../../../util/models';
|
||||
import { PiBaseChartComponent } from '../base-chart.component';
|
||||
|
||||
Chart.register(ArcElement, DoughnutController);
|
||||
|
||||
/**
|
||||
* Un único valor sobre `max`, no una serie ni porciones — por eso extiende
|
||||
* directamente `PiBaseChartComponent` (la base raíz) en vez de
|
||||
@@ -36,6 +34,9 @@ export class PiGaugeChartComponent extends PiBaseChartComponent {
|
||||
);
|
||||
|
||||
protected buildChart(): Chart {
|
||||
// Aquí y no al importar: así una app que nunca pinta este gráfico no
|
||||
// arrastra Chart.js. Registrar dos veces no hace nada.
|
||||
Chart.register(ArcElement, DoughnutController);
|
||||
const clamped = this.clampedValue();
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'doughnut',
|
||||
|
||||
@@ -14,17 +14,6 @@ import { hexToRgba, lineDatasetConfig } from '../chart.configs';
|
||||
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||
|
||||
Chart.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-line-chart',
|
||||
templateUrl: './line-chart.component.html',
|
||||
@@ -61,6 +50,9 @@ export class PiLineChartComponent extends PiBaseCartesianChartComponent {
|
||||
}
|
||||
|
||||
protected buildChart(): Chart {
|
||||
// Aquí y no al importar: así una app que nunca pinta este gráfico no
|
||||
// arrastra Chart.js. Registrar dos veces no hace nada.
|
||||
Chart.register(CategoryScale, LinearScale, LineController, LineElement, PointElement, Filler, Tooltip, Legend);
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { ArcElement, Chart, Legend, PieController, Tooltip } from 'chart.js';
|
||||
import { PiBaseArcChartComponent } from '../base-arc-chart.component';
|
||||
|
||||
Chart.register(ArcElement, PieController, Tooltip, Legend);
|
||||
|
||||
@Component({
|
||||
selector: 'pi-pie-chart',
|
||||
templateUrl: './pie-chart.component.html',
|
||||
@@ -12,6 +10,9 @@ Chart.register(ArcElement, PieController, Tooltip, Legend);
|
||||
})
|
||||
export class PiPieChartComponent extends PiBaseArcChartComponent {
|
||||
protected buildChart(): Chart {
|
||||
// Aquí y no al importar: así una app que nunca pinta este gráfico no
|
||||
// arrastra Chart.js. Registrar dos veces no hace nada.
|
||||
Chart.register(ArcElement, PieController, Tooltip, Legend);
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'pie',
|
||||
data: this.getArcData<'pie'>(),
|
||||
|
||||
@@ -17,16 +17,6 @@ import { hexToRgba, lineDatasetConfig, tooltipConfig } from '../chart.configs';
|
||||
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
|
||||
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
|
||||
|
||||
Chart.register(
|
||||
RadarController,
|
||||
RadialLinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Filler,
|
||||
Tooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
/**
|
||||
* A diferencia de `pi-line-chart`/`pi-bar-chart`, la escala radial (`r`) no
|
||||
* encaja en `scalesConfig` (pensado para `x`/`y`), así que no reutiliza
|
||||
@@ -94,6 +84,9 @@ export class PiRadarChartComponent extends PiBaseCartesianChartComponent {
|
||||
}
|
||||
|
||||
protected buildChart(): Chart {
|
||||
// Aquí y no al importar: así una app que nunca pinta este gráfico no
|
||||
// arrastra Chart.js. Registrar dos veces no hace nada.
|
||||
Chart.register(RadarController, RadialLinearScale, LineElement, PointElement, Filler, Tooltip, Legend);
|
||||
return new Chart(this.chartRef().nativeElement, {
|
||||
type: 'radar',
|
||||
data: {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ComponentFamilyClass, theming } from '../../../styles';
|
||||
import { Date } from '../../../util/models';
|
||||
import { PiBaseChipComponent } from '../base-chip.component';
|
||||
import { DateTime } from 'luxon';
|
||||
import _ from 'lodash';
|
||||
import { capitalize } from '../../../util/common/compare';
|
||||
|
||||
@Component({
|
||||
selector: 'pi-chip-date',
|
||||
@@ -73,8 +73,8 @@ export class PiChipDateComponent extends PiBaseChipComponent {
|
||||
day: dateObj.day.toString() || '',
|
||||
month: dateObj.monthShort || '',
|
||||
weekDay: isDateToday
|
||||
? _.capitalize(dateObj.toRelativeCalendar() ?? '')
|
||||
: _.capitalize(dateObj.weekdayShort || ''),
|
||||
? capitalize(dateObj.toRelativeCalendar() ?? '')
|
||||
: capitalize(dateObj.weekdayShort || ''),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ export class PiFileUploadComponent implements FormValueControl<File[]> {
|
||||
|
||||
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
|
||||
readonly disabled = input(false);
|
||||
readonly touched = input(false);
|
||||
readonly required = input(false);
|
||||
readonly name = input('');
|
||||
|
||||
@@ -94,7 +95,10 @@ export class PiFileUploadComponent implements FormValueControl<File[]> {
|
||||
this.errors().map((error) => error.message ?? error.kind),
|
||||
);
|
||||
|
||||
protected readonly hasError = computed(() => this.errors().length > 0);
|
||||
/** El error se ve cuando el campo está tocado (al salir de él o al enviar el formulario), no antes. */
|
||||
protected readonly hasError = computed(
|
||||
() => this.touched() && this.errors().length > 0,
|
||||
);
|
||||
|
||||
/* --- Arrastrar y soltar ----------------------------------------------- */
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ export abstract class PiBaseFormFieldComponent<
|
||||
this.errors().map((error) => error.message ?? error.kind),
|
||||
);
|
||||
|
||||
protected readonly hasError = computed(() => this.errors().length > 0);
|
||||
/** El error se ve cuando el campo está tocado (al salir de él o al enviar el formulario), no antes. */
|
||||
protected readonly hasError = computed(
|
||||
() => this.touched() && this.errors().length > 0,
|
||||
);
|
||||
|
||||
/**
|
||||
* `MatInput` normalmente deriva `errorState` de un `NgControl` (`ngModel`,
|
||||
|
||||
@@ -49,6 +49,7 @@ export class PiCheckboxComponent implements FormCheckboxControl {
|
||||
|
||||
readonly checked = model(false);
|
||||
readonly disabled = input(false);
|
||||
readonly touched = input(false);
|
||||
readonly required = input(false);
|
||||
readonly name = input('');
|
||||
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
|
||||
@@ -70,7 +71,10 @@ export class PiCheckboxComponent implements FormCheckboxControl {
|
||||
this.errors().map((error) => error.message ?? error.kind),
|
||||
);
|
||||
|
||||
protected readonly hasError = computed(() => this.errors().length > 0);
|
||||
/** El error se ve cuando el campo está tocado (al salir de él o al enviar el formulario), no antes. */
|
||||
protected readonly hasError = computed(
|
||||
() => this.touched() && this.errors().length > 0,
|
||||
);
|
||||
|
||||
/**
|
||||
* `name` solo lo rellena la directiva `FormField`; suelto llega vacío. Sin
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<mat-form-field [appearance]="appearance()" floatLabel="always">
|
||||
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
|
||||
|
||||
<ng-container matPrefix>
|
||||
<ng-content select="pi-prefix"></ng-content>
|
||||
</ng-container>
|
||||
<!--
|
||||
Only when something is actually projected. An empty `matPrefix` container
|
||||
still makes Material treat the field as having an icon prefix, which
|
||||
reserves space and pulls the text 12px left of every other field.
|
||||
-->
|
||||
@if (hasPrefix()) {
|
||||
<ng-container matPrefix>
|
||||
<ng-content select="pi-prefix"></ng-content>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<ng-container matSuffix>
|
||||
<ng-content select="pi-suffix"></ng-content>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
contentChild,
|
||||
HostBinding,
|
||||
Injectable,
|
||||
} from '@angular/core';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { PiAutofocusDirective } from '../directives/autofocus.directive';
|
||||
import { numericValue } from '../numeric-value';
|
||||
import { PiPrefixDirective } from '../../../util/affix/affix.directive';
|
||||
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
|
||||
@@ -40,4 +42,7 @@ export class PiNumberFieldComponent extends PiBaseFormFieldComponent<
|
||||
@HostBinding('class') className = `pi-form-field__number`;
|
||||
|
||||
protected readonly rawValue = numericValue(this.value);
|
||||
|
||||
/** Whether a consumer projected a `pi-prefix`; see the template. */
|
||||
protected readonly hasPrefix = contentChild(PiPrefixDirective);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export class PiRadioGroupComponent<
|
||||
|
||||
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
|
||||
readonly disabled = input(false);
|
||||
readonly touched = input(false);
|
||||
readonly required = input(false);
|
||||
readonly name = input('');
|
||||
|
||||
@@ -77,7 +78,10 @@ export class PiRadioGroupComponent<
|
||||
this.errors().map((error) => error.message ?? error.kind),
|
||||
);
|
||||
|
||||
protected readonly hasError = computed(() => this.errors().length > 0);
|
||||
/** El error se ve cuando el campo está tocado (al salir de él o al enviar el formulario), no antes. */
|
||||
protected readonly hasError = computed(
|
||||
() => this.touched() && this.errors().length > 0,
|
||||
);
|
||||
|
||||
/** Agrupa los `<input type="radio">`; debe ser único por grupo. */
|
||||
private readonly uniqueName = `pi-radio-group-${nextUniqueId++}`;
|
||||
|
||||
@@ -51,8 +51,9 @@ export abstract class PiBaseSelectComponent<
|
||||
/** Se emite cuando el usuario interactúa; `FormField` lo usa para marcar `touched`. */
|
||||
readonly touch = output<void>();
|
||||
|
||||
/** El error se ve cuando el campo está tocado, no antes. */
|
||||
protected get hasError(): boolean {
|
||||
return this.errors().length > 0;
|
||||
return this.touched() && this.errors().length > 0;
|
||||
}
|
||||
|
||||
// ======================
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
|
||||
import _ from 'lodash';
|
||||
import { isEqual } from '../../../util/common/compare';
|
||||
import { PiBaseSelectComponent } from '../base-select.component';
|
||||
import { PiSelectOptionComponent } from '../select-option/select-option.component';
|
||||
import { Device } from '../../../util/device-configs';
|
||||
@@ -97,7 +97,7 @@ export class PiSelectCarouselComponent
|
||||
* @returns The selected option.
|
||||
*/
|
||||
get selectedOption(): any {
|
||||
return this.options().find((option) => _.isEqual(this.value(), option));
|
||||
return this.options().find((option) => isEqual(this.value(), option));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
|
||||
import { PiBaseSelectComponent } from '../base-select.component';
|
||||
import { PiSelectOptionComponent } from '../select-option/select-option.component';
|
||||
import _ from 'lodash';
|
||||
import { isMatch } from '../../../util/common/compare';
|
||||
import { MatCheckboxChange, MatCheckbox } from '@angular/material/checkbox';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent } from '../../icon/icon/icon.component';
|
||||
@@ -114,7 +114,9 @@ export class PiSelectMultipleComponent
|
||||
get selectedOptions(): Array<any> {
|
||||
const value = this.value();
|
||||
if (!value) return [];
|
||||
return this.options().filter((option) => _.some(value, option));
|
||||
return this.options().filter((option) =>
|
||||
value.some((selected: unknown) => isMatch(selected, option)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
|
||||
import { PiBaseSelectComponent } from '../base-select.component';
|
||||
import { PiSelectOptionComponent } from '../select-option/select-option.component';
|
||||
import _ from 'lodash';
|
||||
import { isEqual } from '../../../util/common/compare';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent } from '../../icon/icon/icon.component';
|
||||
import { PiDisabledDirective } from '../../../util/status/directives/status.directive';
|
||||
@@ -103,7 +103,7 @@ export class PiSelectPeriodComponent
|
||||
* @returns The selected option.
|
||||
*/
|
||||
get selectedOption(): any {
|
||||
return this.options().find((option) => _.isEqual(this.value(), option));
|
||||
return this.options().find((option) => isEqual(this.value(), option));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { TranslateService, TranslatePipe } from '@ngx-translate/core';
|
||||
import { PiBaseSelectComponent } from '../base-select.component';
|
||||
import { PiSelectOptionComponent } from '../select-option/select-option.component';
|
||||
import _ from 'lodash';
|
||||
import { isEqual } from '../../../util/common/compare';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { PiIconComponent } from '../../icon/icon/icon.component';
|
||||
import { PiDisabledDirective } from '../../../util/status/directives/status.directive';
|
||||
@@ -116,7 +116,7 @@ export class PiSelectComponent
|
||||
* @returns The selected option.
|
||||
*/
|
||||
get selectedOption(): any {
|
||||
return this.options().find((option) => _.isEqual(this.value(), option));
|
||||
return this.options().find((option) => isEqual(this.value(), option));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
39
libs/ui/src/lib/util/common/compare.spec.ts
Normal file
39
libs/ui/src/lib/util/common/compare.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { capitalize, isEqual, isMatch } from './compare';
|
||||
|
||||
describe('compare', () => {
|
||||
it('isEqual compara en profundidad', () => {
|
||||
expect(
|
||||
isEqual({ id: 1, tags: ['a', 'b'] }, { tags: ['a', 'b'], id: 1 }),
|
||||
).toBe(true);
|
||||
expect(isEqual({ id: 1 }, { id: 1, extra: true })).toBe(false);
|
||||
expect(isEqual([1, [2, 3]], [1, [2, 3]])).toBe(true);
|
||||
expect(isEqual([1, 2], [2, 1])).toBe(false);
|
||||
expect(isEqual(new Date(0), new Date(0))).toBe(true);
|
||||
expect(isEqual(NaN, NaN)).toBe(true);
|
||||
expect(isEqual(null, undefined)).toBe(false);
|
||||
expect(isEqual('1', 1)).toBe(false);
|
||||
expect(isEqual({ a: { b: 1 } }, { a: { b: 2 } })).toBe(false);
|
||||
});
|
||||
|
||||
it('isMatch acepta lo que contiene el criterio, como `_.some(lista, objeto)`', () => {
|
||||
const option = { id: 2, name: 'Dos' };
|
||||
expect(
|
||||
[
|
||||
{ id: 1, name: 'Uno' },
|
||||
{ id: 2, name: 'Dos', extra: 1 },
|
||||
].some((v) => isMatch(v, option)),
|
||||
).toBe(true);
|
||||
expect([{ id: 2 }].some((v) => isMatch(v, option))).toBe(false);
|
||||
expect(isMatch({ a: { b: 1, c: 2 } }, { a: { b: 1 } })).toBe(true);
|
||||
expect(isMatch({ tags: ['a', 'b', 'c'] }, { tags: ['c', 'a'] })).toBe(true);
|
||||
expect(isMatch({ tags: ['a'] }, { tags: ['z'] })).toBe(false);
|
||||
expect(isMatch('x', 'x')).toBe(true);
|
||||
expect(isMatch(null, { id: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it('capitalize pone la primera en mayúscula y el resto en minúscula', () => {
|
||||
expect(capitalize('lun')).toBe('Lun');
|
||||
expect(capitalize('HOY')).toBe('Hoy');
|
||||
expect(capitalize('')).toBe('');
|
||||
});
|
||||
});
|
||||
66
libs/ui/src/lib/util/common/compare.ts
Normal file
66
libs/ui/src/lib/util/common/compare.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Las tres utilidades de lodash que usaba la librería (`isEqual`, `some` con
|
||||
* un objeto como criterio y `capitalize`), con la misma semántica en los
|
||||
* casos que se dan aquí: valores JSON, fechas, arrays y objetos planos. Sin
|
||||
* lodash, `@pi/ui` no arrastra una librería CommonJS al bundle de nadie.
|
||||
*/
|
||||
|
||||
/** Igualdad profunda: `isEqual({ a: [1] }, { a: [1] })` es `true`. */
|
||||
export function isEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) return true;
|
||||
if (a instanceof Date && b instanceof Date)
|
||||
return a.getTime() === b.getTime();
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
return (
|
||||
Array.isArray(a) &&
|
||||
Array.isArray(b) &&
|
||||
a.length === b.length &&
|
||||
a.every((item, i) => isEqual(item, b[i]))
|
||||
);
|
||||
}
|
||||
if (!isPlainObject(a) || !isPlainObject(b)) return false;
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length &&
|
||||
keys.every((key) => hasOwn(b, key) && isEqual(a[key], b[key]))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Si `value` contiene lo que pide `source`: cada propiedad de `source` está
|
||||
* en `value` con un valor que a su vez la contiene (y el resto da igual).
|
||||
* Es lo que hace `_.some(lista, objeto)` con cada elemento.
|
||||
*/
|
||||
export function isMatch(value: unknown, source: unknown): boolean {
|
||||
if (isPlainObject(source)) {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
Object.keys(source).every(
|
||||
(key) => hasOwn(value, key) && isMatch(value[key], source[key]),
|
||||
)
|
||||
);
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
// Como lodash: cada elemento pedido tiene que estar en el array.
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
source.every((wanted) => value.some((item) => isMatch(item, wanted)))
|
||||
);
|
||||
}
|
||||
return isEqual(value, source);
|
||||
}
|
||||
|
||||
/** Primera letra en mayúscula y el resto en minúscula: «lun» → «Lun». */
|
||||
export function capitalize(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
function hasOwn(object: object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(object, key);
|
||||
}
|
||||
328
package-lock.json
generated
328
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,6 @@
|
||||
"@ngx-translate/http-loader": "^18.0.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"express": "^4.21.2",
|
||||
"lodash": "^4.18.1",
|
||||
"luxon": "^3.7.2",
|
||||
"ngx-infinite-scroll": "^22.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
@@ -71,7 +70,6 @@
|
||||
"@swc/helpers": "0.5.18",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "~30.0.0",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/luxon": "^3.7.2",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
|
||||
Reference in New Issue
Block a user