Compare commits

10 Commits

Author SHA1 Message Date
43d5dcf1d3 Release @pi/ui 0.0.9: a field shows its errors once touched
An untouched field with errors was painted red from the start, so a
form with required fields opened full of «Campo obligatorio». Text
fields, selects, checkboxes, radio groups and file uploads now wait
until the field is touched (blur, or the form's submit()).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:25:39 +02:00
2dd05241fc Release @pi/ui 0.0.8: a ticked checkbox follows the theme's surface
Its box was filled with white, which glared in dark mode. It takes
--pi-theme-field-surface now, white when a theme leaves it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:48:32 +02:00
74b30e5427 Release @pi/ui 0.0.7: an empty checkbox can be seen
Its border was grey-100, 1.2:1 against white, so empty boxes all but
vanished in lists. It is grey-400 now (about 3.5:1, over the 3:1 WCAG
asks of controls), through --pi-theme-checkbox-border-color.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:50:02 +02:00
81ee7855a7 Release @pi/ui 0.0.6: pi-file-upload keeps its own look
Its drop zone is a <button>, so the library's global button rules
squashed it to the button height and dropped its dashed border. It is
left out of them, like Material's own buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:00:43 +02:00
cb82769f31 Release @pi/ui 0.0.5: center the dates of pi-date-range-field
Its two inputs are not .mat-mdc-input-element, so the rule that fills
the 48px box missed them and they sat at the top of the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:17:06 +02:00
4a33c9ade5 Drop lodash from @pi/ui
The three functions it used (isEqual, some with an object as criterion
and capitalize) live in util/common/compare.ts with the same behaviour,
so apps no longer pull a CommonJS library that Node's ESM loader cannot
resolve by path. TaskStream's initial bundle goes down to 1.88 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:52:28 +02:00
c6ca5eb6ab Release @pi/ui 0.0.4: import lodash functions with their extension
lodash declares no exports, so `lodash/isEqual` resolves in a bundler but
not in Node's ESM loader: in 0.0.3 apps could not load @pi/ui in tests or
when rendering on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:34:17 +02:00
dde8579fbc Release @pi/ui 0.0.3 to the npm-releases repository
It is where 0.0.2 lives and where apps install it from; npm-private
asked for credentials no one had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:37:47 +02:00
23f2c5404f Line up a number field without a prefix with the other fields
An empty matPrefix container still made Material reserve room for an
icon, pulling the text 12px left of every other field. The prefix is
only rendered when a pi-prefix is projected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:28:25 +02:00
499f8ac075 Keep Chart.js and the whole of lodash out of apps' initial bundle
Each chart registers what it needs when it is built instead of on
import, and lodash is imported one function at a time. In TaskStream
the initial bundle drops from 2.13 MB to 1.90 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:40 +02:00
32 changed files with 365 additions and 271 deletions

View File

@@ -7,13 +7,13 @@
# Redirige solo el scope @pi al repositorio npm (hosted) de Nexus. # 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. # 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). # 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 # 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). # (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` # 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 # rechaza paquetes con scope si no lleva --access, y con un registro privado

View File

@@ -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 `[formField]`: Angular lo rechaza con `NG8022`. Los validadores van en el
`form()`, no en la plantilla. `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`). Se puede ver funcionando en la app `playground` (`nx serve playground`).
## Theming: tokens CSS ## Theming: tokens CSS
@@ -184,11 +190,23 @@ Detalles a tener en cuenta:
- **`Dataset.cubicInterpolationMode` pasa a opcional.** Solo tiene sentido en - **`Dataset.cubicInterpolationMode` pasa a opcional.** Solo tiene sentido en
`line`; forzarlo en `bar`/`pie`/`doughnut` no aportaba nada y esos tipos ni `line`; forzarlo en `bar`/`pie`/`doughnut` no aportaba nada y esos tipos ni
siquiera usan `Dataset` (los dos últimos usan `ChartSlice`). 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 - **El `type="bar"` del `pi-chart` original nunca funcionó**: el input
existía pero `getLineChartConfigs()` construía siempre un chart `line` existía pero `getLineChartConfigs()` construía siempre un chart `line`
ignorando su valor. Se descubrió al dividir los componentes; `pi-bar-chart` ignorando su valor. Se descubrió al dividir los componentes; `pi-bar-chart`
es la primera versión que realmente renderiza barras. 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.** **El loader de servidor de traducciones ya no está en el barrel.**
`util/translate/loaders/translate-server.loader` usa `fs`, `path` y `process`; `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 exportarlo desde `@pi/ui` rompía el bundle de navegador de cualquier app que

View File

@@ -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-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-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-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-*`) ### Puente con Angular Material (`--mat-sys-*`)

View File

@@ -4,7 +4,7 @@
"lib": { "lib": {
"entryFile": "src/index.ts" "entryFile": "src/index.ts"
}, },
"allowedNonPeerDependencies": ["lodash", "luxon", "ngx-infinite-scroll"], "allowedNonPeerDependencies": ["luxon", "ngx-infinite-scroll"],
"assets": [ "assets": [
{ {
"glob": "**/*.scss", "glob": "**/*.scss",

View File

@@ -1,11 +1,11 @@
{ {
"name": "@pi/ui", "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...).", "description": "Librería de componentes Angular standalone (botones, campos con Signal Forms, cards, charts, drawer/hub/modal, theming vía tokens CSS...).",
"license": "MIT", "license": "MIT",
"generators": "./generators.json", "generators": "./generators.json",
"publishConfig": { "publishConfig": {
"registry": "https://nexus.pireon.dev/repository/npm-private/", "registry": "https://nexus.pireon.dev/repository/npm-releases/",
"access": "restricted" "access": "restricted"
}, },
"peerDependencies": { "peerDependencies": {
@@ -21,7 +21,6 @@
"chart.js": "^4.0.0" "chart.js": "^4.0.0"
}, },
"dependencies": { "dependencies": {
"lodash": "^4.17.0",
"luxon": "^3.0.0", "luxon": "^3.0.0",
"ngx-infinite-scroll": "^22.0.0" "ngx-infinite-scroll": "^22.0.0"
}, },

View File

@@ -280,8 +280,10 @@
// Los `<button>` internos de Angular Material quedan fuera: el calendario del // Los `<button>` internos de Angular Material quedan fuera: el calendario del
// datepicker, por ejemplo, monta cada día como un `<button>` y el // 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 // `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. // va en `:where()` para no alterar la especificidad de la regla. La zona de
button:not(:where([class*='mat-'])) { // `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 // Colors
@include themeButtonsColors; @include themeButtonsColors;
// Sizes // Sizes
@@ -293,8 +295,10 @@
// Los `<button>` internos de Angular Material quedan fuera: el calendario del // Los `<button>` internos de Angular Material quedan fuera: el calendario del
// datepicker, por ejemplo, monta cada día como un `<button>` y el // 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 // `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. // va en `:where()` para no alterar la especificidad de la regla. La zona de
button:not(:where([class*='mat-'])) { // `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 // Colors
@include themeButtonsColors; @include themeButtonsColors;
// Sizes // Sizes

View File

@@ -145,8 +145,10 @@ html,
// Los `<button>` internos de Angular Material quedan fuera: el calendario del // Los `<button>` internos de Angular Material quedan fuera: el calendario del
// datepicker, por ejemplo, monta cada día como un `<button>` y el // 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 // `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. // va en `:where()` para no alterar la especificidad de la regla. La zona de
button:not(:where([class*='mat-'])) { // `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 // Default properties
@include defaultButtonsProperties; @include defaultButtonsProperties;

View File

@@ -5,7 +5,10 @@
&:enabled { &:enabled {
&:not(:checked):not(:indeterminate):not([data-indeterminate='true']) { &:not(:checked):not(:indeterminate):not([data-indeterminate='true']) {
& ~ .mdc-checkbox__background { & ~ .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 { .pi-select-option {
@include checkboxColor(primary-200); @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 // Checkbox
.mdc-checkbox__background { .mdc-checkbox__background {
@@ -136,7 +141,10 @@
&:checked { &:checked {
& ~ .mdc-checkbox__background { & ~ .mdc-checkbox__background {
border-color: var(--pi-color-primary); 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 { & ~ .mdc-checkbox__background {
@@ -163,7 +171,10 @@
&:checked { &:checked {
& ~ .mdc-checkbox__background { & ~ .mdc-checkbox__background {
border-color: var(--pi-color-primary); border-color: var(--pi-color-primary);
background-color: var(--pi-color-white); background-color: var(
--pi-theme-field-surface,
var(--pi-color-white)
);
} }
} }
} }

View File

@@ -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 { &.mdc-text-field--disabled {
.mat-mdc-input-element, .mat-mdc-input-element,
.mdc-notched-outline, .mdc-notched-outline,

View File

@@ -12,8 +12,6 @@ import { barDatasetConfig } from '../chart.configs';
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component'; import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component'; import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
Chart.register(CategoryScale, LinearScale, BarController, BarElement, Tooltip, Legend);
@Component({ @Component({
selector: 'pi-bar-chart', selector: 'pi-bar-chart',
templateUrl: './bar-chart.component.html', templateUrl: './bar-chart.component.html',
@@ -30,6 +28,9 @@ export class PiBarChartComponent extends PiBaseCartesianChartComponent {
} }
protected buildChart(): Chart { 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, { return new Chart(this.chartRef().nativeElement, {
type: 'bar', type: 'bar',
data: { data: {

View File

@@ -15,9 +15,10 @@ import { Device } from '../../util/device-configs';
* `pi-pie-chart`, `pi-doughnut-chart`): el `<canvas>`, el ciclo de vida que * `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` * construye el `Chart` una vez la vista está lista, y el `TranslateService`
* que todos usan para las etiquetas. Cada tipo registra solo los * que todos usan para las etiquetas. Cada tipo registra solo los
* controladores/elementos de Chart.js que necesita — no hay un registro * controladores/elementos de Chart.js que necesita, y lo hace en
* global compartido, así que un consumidor que solo importe `pi-pie-chart` * `buildChart`, no al importar el módulo: el paquete es un único `fesm`, así
* no arrastra el código de líneas/barras. * 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: '' }) @Component({ template: '' })
export abstract class PiBaseChartComponent implements AfterViewInit { export abstract class PiBaseChartComponent implements AfterViewInit {

View File

@@ -8,8 +8,6 @@ import {
} from 'chart.js'; } from 'chart.js';
import { PiBaseArcChartComponent } from '../base-arc-chart.component'; import { PiBaseArcChartComponent } from '../base-arc-chart.component';
Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
@Component({ @Component({
selector: 'pi-doughnut-chart', selector: 'pi-doughnut-chart',
templateUrl: './doughnut-chart.component.html', templateUrl: './doughnut-chart.component.html',
@@ -18,6 +16,9 @@ Chart.register(ArcElement, DoughnutController, Tooltip, Legend);
}) })
export class PiDoughnutChartComponent extends PiBaseArcChartComponent { export class PiDoughnutChartComponent extends PiBaseArcChartComponent {
protected buildChart(): Chart { 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, { return new Chart(this.chartRef().nativeElement, {
type: 'doughnut', type: 'doughnut',
data: this.getArcData<'doughnut'>(), data: this.getArcData<'doughnut'>(),

View File

@@ -8,8 +8,6 @@ import { ArcElement, Chart, DoughnutController } from 'chart.js';
import { DatasetColor } from '../../../util/models'; import { DatasetColor } from '../../../util/models';
import { PiBaseChartComponent } from '../base-chart.component'; import { PiBaseChartComponent } from '../base-chart.component';
Chart.register(ArcElement, DoughnutController);
/** /**
* Un único valor sobre `max`, no una serie ni porciones — por eso extiende * Un único valor sobre `max`, no una serie ni porciones — por eso extiende
* directamente `PiBaseChartComponent` (la base raíz) en vez de * directamente `PiBaseChartComponent` (la base raíz) en vez de
@@ -36,6 +34,9 @@ export class PiGaugeChartComponent extends PiBaseChartComponent {
); );
protected buildChart(): Chart { 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(); const clamped = this.clampedValue();
return new Chart(this.chartRef().nativeElement, { return new Chart(this.chartRef().nativeElement, {
type: 'doughnut', type: 'doughnut',

View File

@@ -14,17 +14,6 @@ import { hexToRgba, lineDatasetConfig } from '../chart.configs';
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component'; import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component'; import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
Chart.register(
CategoryScale,
LinearScale,
LineController,
LineElement,
PointElement,
Filler,
Tooltip,
Legend,
);
@Component({ @Component({
selector: 'pi-line-chart', selector: 'pi-line-chart',
templateUrl: './line-chart.component.html', templateUrl: './line-chart.component.html',
@@ -61,6 +50,9 @@ export class PiLineChartComponent extends PiBaseCartesianChartComponent {
} }
protected buildChart(): Chart { 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, { return new Chart(this.chartRef().nativeElement, {
type: 'line', type: 'line',
data: { data: {

View File

@@ -2,8 +2,6 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ArcElement, Chart, Legend, PieController, Tooltip } from 'chart.js'; import { ArcElement, Chart, Legend, PieController, Tooltip } from 'chart.js';
import { PiBaseArcChartComponent } from '../base-arc-chart.component'; import { PiBaseArcChartComponent } from '../base-arc-chart.component';
Chart.register(ArcElement, PieController, Tooltip, Legend);
@Component({ @Component({
selector: 'pi-pie-chart', selector: 'pi-pie-chart',
templateUrl: './pie-chart.component.html', templateUrl: './pie-chart.component.html',
@@ -12,6 +10,9 @@ Chart.register(ArcElement, PieController, Tooltip, Legend);
}) })
export class PiPieChartComponent extends PiBaseArcChartComponent { export class PiPieChartComponent extends PiBaseArcChartComponent {
protected buildChart(): Chart { 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, { return new Chart(this.chartRef().nativeElement, {
type: 'pie', type: 'pie',
data: this.getArcData<'pie'>(), data: this.getArcData<'pie'>(),

View File

@@ -17,16 +17,6 @@ import { hexToRgba, lineDatasetConfig, tooltipConfig } from '../chart.configs';
import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component'; import { PiBaseCartesianChartComponent } from '../base-cartesian-chart.component';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.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 * 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 * encaja en `scalesConfig` (pensado para `x`/`y`), así que no reutiliza
@@ -94,6 +84,9 @@ export class PiRadarChartComponent extends PiBaseCartesianChartComponent {
} }
protected buildChart(): Chart { 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, { return new Chart(this.chartRef().nativeElement, {
type: 'radar', type: 'radar',
data: { data: {

View File

@@ -10,7 +10,7 @@ import { ComponentFamilyClass, theming } from '../../../styles';
import { Date } from '../../../util/models'; import { Date } from '../../../util/models';
import { PiBaseChipComponent } from '../base-chip.component'; import { PiBaseChipComponent } from '../base-chip.component';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import _ from 'lodash'; import { capitalize } from '../../../util/common/compare';
@Component({ @Component({
selector: 'pi-chip-date', selector: 'pi-chip-date',
@@ -73,8 +73,8 @@ export class PiChipDateComponent extends PiBaseChipComponent {
day: dateObj.day.toString() || '', day: dateObj.day.toString() || '',
month: dateObj.monthShort || '', month: dateObj.monthShort || '',
weekDay: isDateToday weekDay: isDateToday
? _.capitalize(dateObj.toRelativeCalendar() ?? '') ? capitalize(dateObj.toRelativeCalendar() ?? '')
: _.capitalize(dateObj.weekdayShort || ''), : capitalize(dateObj.weekdayShort || ''),
}; };
} }
} }

View File

@@ -59,6 +59,7 @@ export class PiFileUploadComponent implements FormValueControl<File[]> {
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]); readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
readonly disabled = input(false); readonly disabled = input(false);
readonly touched = input(false);
readonly required = input(false); readonly required = input(false);
readonly name = input(''); readonly name = input('');
@@ -94,7 +95,10 @@ export class PiFileUploadComponent implements FormValueControl<File[]> {
this.errors().map((error) => error.message ?? error.kind), 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 ----------------------------------------------- */ /* --- Arrastrar y soltar ----------------------------------------------- */

View File

@@ -76,7 +76,10 @@ export abstract class PiBaseFormFieldComponent<
this.errors().map((error) => error.message ?? error.kind), 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`, * `MatInput` normalmente deriva `errorState` de un `NgControl` (`ngModel`,

View File

@@ -49,6 +49,7 @@ export class PiCheckboxComponent implements FormCheckboxControl {
readonly checked = model(false); readonly checked = model(false);
readonly disabled = input(false); readonly disabled = input(false);
readonly touched = input(false);
readonly required = input(false); readonly required = input(false);
readonly name = input(''); readonly name = input('');
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]); readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
@@ -70,7 +71,10 @@ export class PiCheckboxComponent implements FormCheckboxControl {
this.errors().map((error) => error.message ?? error.kind), 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 * `name` solo lo rellena la directiva `FormField`; suelto llega vacío. Sin

View File

@@ -1,9 +1,16 @@
<mat-form-field [appearance]="appearance()" floatLabel="always"> <mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label> <mat-label><ng-content select="pi-label"></ng-content></mat-label>
<!--
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-container matPrefix>
<ng-content select="pi-prefix"></ng-content> <ng-content select="pi-prefix"></ng-content>
</ng-container> </ng-container>
}
<ng-container matSuffix> <ng-container matSuffix>
<ng-content select="pi-suffix"></ng-content> <ng-content select="pi-suffix"></ng-content>

View File

@@ -1,6 +1,7 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
contentChild,
HostBinding, HostBinding,
Injectable, Injectable,
} from '@angular/core'; } from '@angular/core';
@@ -15,6 +16,7 @@ import {
import { MatInput } from '@angular/material/input'; import { MatInput } from '@angular/material/input';
import { PiAutofocusDirective } from '../directives/autofocus.directive'; import { PiAutofocusDirective } from '../directives/autofocus.directive';
import { numericValue } from '../numeric-value'; import { numericValue } from '../numeric-value';
import { PiPrefixDirective } from '../../../util/affix/affix.directive';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
@@ -40,4 +42,7 @@ export class PiNumberFieldComponent extends PiBaseFormFieldComponent<
@HostBinding('class') className = `pi-form-field__number`; @HostBinding('class') className = `pi-form-field__number`;
protected readonly rawValue = numericValue(this.value); protected readonly rawValue = numericValue(this.value);
/** Whether a consumer projected a `pi-prefix`; see the template. */
protected readonly hasPrefix = contentChild(PiPrefixDirective);
} }

View File

@@ -57,6 +57,7 @@ export class PiRadioGroupComponent<
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]); readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
readonly disabled = input(false); readonly disabled = input(false);
readonly touched = input(false);
readonly required = input(false); readonly required = input(false);
readonly name = input(''); readonly name = input('');
@@ -77,7 +78,10 @@ export class PiRadioGroupComponent<
this.errors().map((error) => error.message ?? error.kind), 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. */ /** Agrupa los `<input type="radio">`; debe ser único por grupo. */
private readonly uniqueName = `pi-radio-group-${nextUniqueId++}`; private readonly uniqueName = `pi-radio-group-${nextUniqueId++}`;

View File

@@ -51,8 +51,9 @@ export abstract class PiBaseSelectComponent<
/** Se emite cuando el usuario interactúa; `FormField` lo usa para marcar `touched`. */ /** Se emite cuando el usuario interactúa; `FormField` lo usa para marcar `touched`. */
readonly touch = output<void>(); readonly touch = output<void>();
/** El error se ve cuando el campo está tocado, no antes. */
protected get hasError(): boolean { protected get hasError(): boolean {
return this.errors().length > 0; return this.touched() && this.errors().length > 0;
} }
// ====================== // ======================

View File

@@ -9,7 +9,7 @@ import {
inject, inject,
} from '@angular/core'; } from '@angular/core';
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import _ from 'lodash'; import { isEqual } from '../../../util/common/compare';
import { PiBaseSelectComponent } from '../base-select.component'; import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component'; import { PiSelectOptionComponent } from '../select-option/select-option.component';
import { Device } from '../../../util/device-configs'; import { Device } from '../../../util/device-configs';
@@ -97,7 +97,7 @@ export class PiSelectCarouselComponent
* @returns The selected option. * @returns The selected option.
*/ */
get selectedOption(): any { get selectedOption(): any {
return this.options().find((option) => _.isEqual(this.value(), option)); return this.options().find((option) => isEqual(this.value(), option));
} }
/** /**

View File

@@ -11,7 +11,7 @@ import {
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component'; import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.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 { MatCheckboxChange, MatCheckbox } from '@angular/material/checkbox';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { PiIconComponent } from '../../icon/icon/icon.component'; import { PiIconComponent } from '../../icon/icon/icon.component';
@@ -114,7 +114,9 @@ export class PiSelectMultipleComponent
get selectedOptions(): Array<any> { get selectedOptions(): Array<any> {
const value = this.value(); const value = this.value();
if (!value) return []; if (!value) return [];
return this.options().filter((option) => _.some(value, option)); return this.options().filter((option) =>
value.some((selected: unknown) => isMatch(selected, option)),
);
} }
/** /**

View File

@@ -11,7 +11,7 @@ import {
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component'; import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component'; import { PiSelectOptionComponent } from '../select-option/select-option.component';
import _ from 'lodash'; import { isEqual } from '../../../util/common/compare';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { PiIconComponent } from '../../icon/icon/icon.component'; import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiDisabledDirective } from '../../../util/status/directives/status.directive'; import { PiDisabledDirective } from '../../../util/status/directives/status.directive';
@@ -103,7 +103,7 @@ export class PiSelectPeriodComponent
* @returns The selected option. * @returns The selected option.
*/ */
get selectedOption(): any { get selectedOption(): any {
return this.options().find((option) => _.isEqual(this.value(), option)); return this.options().find((option) => isEqual(this.value(), option));
} }
/** /**

View File

@@ -13,7 +13,7 @@ import {
import { TranslateService, TranslatePipe } from '@ngx-translate/core'; import { TranslateService, TranslatePipe } from '@ngx-translate/core';
import { PiBaseSelectComponent } from '../base-select.component'; import { PiBaseSelectComponent } from '../base-select.component';
import { PiSelectOptionComponent } from '../select-option/select-option.component'; import { PiSelectOptionComponent } from '../select-option/select-option.component';
import _ from 'lodash'; import { isEqual } from '../../../util/common/compare';
import { NgTemplateOutlet } from '@angular/common'; import { NgTemplateOutlet } from '@angular/common';
import { PiIconComponent } from '../../icon/icon/icon.component'; import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiDisabledDirective } from '../../../util/status/directives/status.directive'; import { PiDisabledDirective } from '../../../util/status/directives/status.directive';
@@ -116,7 +116,7 @@ export class PiSelectComponent
* @returns The selected option. * @returns The selected option.
*/ */
get selectedOption(): any { get selectedOption(): any {
return this.options().find((option) => _.isEqual(this.value(), option)); return this.options().find((option) => isEqual(this.value(), option));
} }
/** /**

View 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('');
});
});

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,6 @@
"@ngx-translate/http-loader": "^18.0.0", "@ngx-translate/http-loader": "^18.0.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"express": "^4.21.2", "express": "^4.21.2",
"lodash": "^4.18.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"ngx-infinite-scroll": "^22.0.0", "ngx-infinite-scroll": "^22.0.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -71,7 +70,6 @@
"@swc/helpers": "0.5.18", "@swc/helpers": "0.5.18",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/jest": "~30.0.0", "@types/jest": "~30.0.0",
"@types/lodash": "^4.17.24",
"@types/luxon": "^3.7.2", "@types/luxon": "^3.7.2",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",