new components

This commit is contained in:
2026-08-06 18:10:55 +02:00
parent b02c885b83
commit f86db1556c
149 changed files with 7038 additions and 395 deletions

View File

@@ -72,6 +72,14 @@ hecho **no se pueden enlazar a mano** esas entradas en un elemento que ya use
Se puede ver funcionando en la app `playground` (`nx serve playground`).
## Theming: tokens CSS
El aspecto visual (color, forma, sombra, tipografía de controles) se resuelve
en custom properties (`--pi-color-*`, `--pi-theme-*`) aplicadas vía `ShellClass`
(`pi-shell pi-shell__<tema>`). Documentación completa, tabla de valores por
tema (Default/Alternative/Minimal/Gradient) y guía para añadir tokens o temas
nuevos: [`TOKENS.md`](./TOKENS.md).
| Componente | Contrato | Tipo del valor |
| ---------------------------------------------------- | --------------------- | ------------------------------------- |
| text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` |
@@ -106,6 +114,63 @@ Detalles a tener en cuenta:
Los selects (`ui/select`) siguen con `ControlValueAccessor`; quedaron fuera del
alcance de esta migración.
## Charts
`pi-chart` (un componente, un input `type`) se dividió en **4 componentes
independientes** — `pi-line-chart`, `pi-bar-chart`, `pi-pie-chart`,
`pi-doughnut-chart` —, cada uno en su propia carpeta, con su propio selector y
su propio registro de Chart.js. Ningún consumidor arrastra código de un tipo
de gráfico que no usa: `pi-pie-chart` no registra `LineController`/`BarController`.
Comparten dos bases abstractas (no un único componente con `@switch`):
- `PiBaseChartComponent` — el `<canvas>`, `device`, y el ciclo de vida que
llama a `buildChart()`.
- `PiBaseCartesianChartComponent extends PiBaseChartComponent` — de
`pi-line-chart`/`pi-bar-chart`: reciben `datasets` (una serie por
elemento, comparadas a lo largo de `xAxisMarks`), usan `pi-chart-legend`
(leyenda propia, no la de Chart.js) y comparten el tooltip/hover.
- `PiBaseArcChartComponent extends PiBaseChartComponent` — de
`pi-pie-chart`/`pi-doughnut-chart`: reciben `slices`
(`{ label, value, color }[]`, tipo `ChartSlice`), no `datasets` — la forma
de los datos es otra (una magnitud repartida en porciones, cada una con su
propio color, no varias series en el tiempo) — y usan la leyenda **nativa**
de Chart.js en vez de `pi-chart-legend`.
```html
<pi-line-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" />
<pi-line-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" [filled]="true" /> <!-- área -->
<pi-bar-chart [device]="device" [xAxisMarks]="meses" [datasets]="datasets" />
<pi-pie-chart [device]="device" [slices]="slices" />
<pi-doughnut-chart [device]="device" [slices]="slices" />
```
Detalles a tener en cuenta:
- **`[filled]="true"` en `pi-line-chart` exige el plugin `Filler` de
Chart.js.** Sin registrarlo, `fill: true` en el dataset no falla — se
ignora en silencio y el gráfico sale sin rellenar. Ya está registrado en
`line-chart.component.ts`; si se añade otro tipo relleno, hay que
registrarlo ahí también.
- **El área usa un color traducido a `rgba(…, 0.15)`, no el `backgroundColor`
del dataset tal cual.** Ese campo también pinta el relleno de los puntos;
si se reutilizase el color opaco para el área saldría un bloque sólido en
vez de una zona translúcida, así que `pointBackgroundColor` conserva el
color original y `backgroundColor` pasa a translúcido solo cuando `filled`
está activo (ver `hexToRgba` en `chart.configs.ts`).
- **`DatasetColor` no tenía `success`/`warn`** (solo `primary`/`accent`/
`highlighted`/`grey`), así que un `pie`/`doughnut` no podía representar
estados (pagado/pendiente/rechazado, etc.) con los mismos colores que
`pi-chip-status`. Se añadieron con los mismos hex que `--pi-color-success`/
`--pi-color-warn`.
- **`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`).
- **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.
**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

247
libs/ui/TOKENS.md Normal file
View File

@@ -0,0 +1,247 @@
# Tokens de `@pi/ui`
Todo el aspecto visual de la librería (color, forma, sombra, tipografía de
controles) se resuelve en **custom properties de CSS**, nunca en valores
hardcodeados dentro de un componente. Hay dos familias con propósitos
distintos, más un puñado de primitivas base:
| Familia | Dónde vive | Qué representa | Cuántos temas la redefinen |
| --------------- | ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------- |
| `--pi-color-*` | `:root` (`_pi-color-variables.scss`) | Paleta de marca (primary/accent/highlighted/warn/success/grey/white/black/background) | Todos excepto Minimal |
| `--pi-theme-*` | `.pi-shell` (`themes/_*.scss`) | Forma y comportamiento por superficie: botón, campo, card, panel, chip, overlay | Todos |
| Primitivas base | `:root` (`_pi-global-variables.scss`, `_pi-font-sizes-variables.scss`, `_pi-public-variables.scss`) | Espaciado, radios, sombras, tamaños de fuente, animaciones — sin tematizar | Ninguno |
Los componentes de `common/` (botón, campo, card, panel, chip, modal,
snackbar, tooltip, accordion…) **no leen la paleta ni la forma directamente**:
consumen `--pi-theme-*`, y son los ficheros de `styles/themes/` los que deciden
qué valor tiene cada token. Así, un tema nuevo no toca un solo selector de
componente — solo redefine tokens (ver `_alternative.scss`, `_minimal.scss`,
`_gradient.scss` como ejemplo).
## Cómo se aplica un tema
```ts
import { ShellClass } from '@pi/ui';
// en el elemento raíz (normalmente <body> o el contenedor de la app)
document.body.className = ShellClass.Alternative; // 'pi-shell pi-shell__alternative'
```
`ShellClass` expone `Default`, `Alternative`, `Minimal` y `Gradient`
(`libs/ui/src/lib/styles/configs.ts`). Cada uno corresponde a un mixin en
`libs/ui/src/lib/styles/themes/_<nombre>.scss`, incluido desde
`themes/index.scss`. Dentro de un shell se puede forzar el aspecto contrario en
un subárbol con `.is-private`/`.is-public` (p. ej. el input `context` de
`pi-card`).
### El patrón de fallback
Un token puede consumirse de dos formas, según si el tema por defecto lo
declara o no:
```scss
// El tema Default declara este token → todos los temas lo tienen.
border-radius: var(--pi-theme-card-radius);
// El tema Default NO lo declara → el segundo argumento de var() reproduce
// el valor original hardcodeado. Solo Minimal (u otro tema futuro) lo pisa.
border-radius: var(--pi-theme-chip-radius, #{$chip-border-radius});
```
Esto es intencional: los tokens que **todos** los temas existentes necesitan
personalizar (radio/altura/tipografía de botón, forma de campo) se declaran en
`_default.scss`. Los que solo un tema concreto toca (radio de overlays en
Minimal, degradado de foco en Gradient) se dejan sin declarar en Default —así
su valor por defecto es exactamente el que tenía el componente antes de
tokenizar, byte a byte, y ningún tema existente cambia de aspecto sin querer.
## `--pi-theme-*`: tabla completa
**D** = Default · **A** = Alternative · **M** = Minimal · **G** = Gradient.
Una celda vacía significa "no lo redefine, hereda el valor de la fila D (o el
fallback de `var()` si D tampoco lo declara, marcado como *(fallback)*)".
### Botón (`pi-button/_pi-button.scss`, `_pi-button-theme.scss`, `menu/dynamic-menu`)
| Token | D | A | M | G |
| --- | --- | --- | --- | --- |
| `--pi-theme-button-height` | `var(--pi-spacing-xxx-large)` (2.5rem) | `2.875rem` | `2.5rem` | — |
| `--pi-theme-button-radius` | `var(--pi-border-radius-medium)` | `999px` (píldora) | `var(--pi-border-radius-xx-small)` | — |
| `--pi-theme-button-font-weight` | `600` | `700` | `500` | — |
| `--pi-theme-button-letter-spacing` | `normal` | `0.04em` | `normal` | — |
| `--pi-theme-button-text-transform` | `none` | `uppercase` | `none` | — |
| `--pi-theme-button-shadow` | `none` | `0 6px 16px -6px rgba(106,63,212,.55)` | `none` | — |
| `--pi-theme-button-transition` | `background-color .16s ease-in` | `all .2s ease-out` | `background-color .12s ease-out, border-color .12s ease-out` | — |
| `--pi-theme-button-fill-primary` | `none` (relleno plano) | degradado `primary-900→primary-400` | `none` | degradado `primary-400→primary-900` |
| `--pi-theme-button-fill-accent` | `none` | degradado `accent-900→accent-400` | `none` | degradado `accent-400→accent-900` |
| `--pi-theme-button-fill-warn` | `none` | — | `none` | — |
| `--pi-theme-button-fill-success` | `none` | — | `none` | — |
| `--pi-theme-button-fill-highlighted` | `none` | degradado `highlighted→highlighted-300` | `none` | degradado `highlighted-300→highlighted` |
`--pi-theme-button-fill-#{$color}` se interpola por nombre de color en
`_pi-button-theme.scss:47` (`background-image: var(--pi-theme-button-fill-#{$color}, none)`),
así que un tema nuevo que quiera degradado en un color que no sea
primary/accent/highlighted solo tiene que declarar
`--pi-theme-button-fill-<color>`.
### Campos de formulario (`_mat-form-field.scss`, `file-upload`, `_mat-datepicker.scss`, `_mat-select.scss`)
| Token | D | A | M | G |
| --- | --- | --- | --- | --- |
| `--pi-theme-field-radius` | `0.875rem` | `999px` | `var(--pi-border-radius-xx-small)` | — |
| `--pi-theme-field-border-width` | `1px` | `2px` | `1px` | — |
| `--pi-theme-field-border-color` | `var(--pi-color-grey-200)` | `var(--pi-color-primary-100)` | `var(--pi-color-grey-200)` | — |
| `--pi-theme-field-border-color-hover` | `var(--pi-color-grey-300)` | `var(--pi-color-primary-300)` | `var(--pi-color-grey-400)` | — |
| `--pi-theme-field-border-color-focus` | `var(--pi-color-primary)` | `var(--pi-color-primary)` | `var(--pi-color-black)` | `transparent` (deja ver el degradado) |
| `--pi-theme-field-surface` | `var(--pi-color-white)` | `var(--pi-color-primary-25)` | `var(--pi-color-white)` | — |
| `--pi-theme-field-label-color` | `var(--pi-color-grey-300)` | `var(--pi-color-primary-900)` | `var(--pi-color-grey-400)` | — |
| `--pi-theme-field-label-transform` *(fallback: `none`)* | — | `uppercase` | `none` | — |
| `--pi-theme-field-label-letter-spacing` *(fallback: `normal`)* | — | `0.08em` | `normal` | — |
| `--pi-theme-field-label-weight` *(fallback: `inherit`)* | — | `700` | `500` | — |
| `--pi-theme-field-focus-gradient` *(fallback: `none`)* | — | — | — | `linear-gradient(120deg, primary, accent, highlighted)` |
### Superficies: card / panel / chip
| Token | D | A | M | G | Consumidores |
| --- | --- | --- | --- | --- | --- |
| `--pi-theme-card-radius` | `var(--pi-border-radius-large)` | `1.5rem` | `var(--pi-border-radius-xx-small)` | — | `_pi-card.scss`, `skeleton.component.scss` |
| `--pi-theme-card-shadow` | `var(--pi-box-shadow-panel)` | `0 10px 30px -12px rgba(59,13,107,.28)` | `none` | — | `_pi-card.scss` |
| `--pi-theme-panel-radius` | `var(--pi-border-radius-large)` | `1.5rem` | `var(--pi-border-radius-xx-small)` | — | `panel.component.scss`, `_mat-datepicker.scss`, `_mat-select.scss`, `dynamic-menu` |
| `--pi-theme-chip-radius` *(fallback: radio original por tamaño)* | — | `0.25rem` | `0` | — | `chip/styles/_size.scss` (×3, por tamaño y por affix) |
### Overlays: modal / notification / tooltip / accordion
Ningún tema salvo Minimal los redefine — Default/Alternative/Gradient usan
siempre el *fallback* de `var()` (el valor que tenían antes de tokenizar).
| Token | Fallback (D/A/G) | M | Consumidores |
| --- | --- | --- | --- |
| `--pi-theme-modal-radius` | `var(--pi-border-radius-medium)` (panel completo) / `var(--pi-border-radius-large)` (`.pi-modal__panel--full`) | `var(--pi-border-radius-xx-small)` | `_mat-dialog.scss` (×2) |
| `--pi-theme-notification-radius` | `var(--pi-border-radius-large)` | `var(--pi-border-radius-xx-small)` | `snack-bar.component.scss`, `_mat-snackbar.scss` (×2) |
| `--pi-theme-notification-shadow` | `var(--pi-box-shadow)` | `none` | `_mat-snackbar.scss` |
| `--pi-theme-tooltip-radius` | `var(--pi-border-radius-medium)` | `var(--pi-border-radius-xx-small)` | `_mat-tooltip.scss` |
| `--pi-theme-tooltip-shadow` | `var(--pi-box-shadow-panel-double)` | `none` | `_mat-tooltip.scss` |
| `--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)`) |
### Puente con Angular Material (`--mat-sys-*`)
Los componentes de Material (slider, checkbox, radio, progress…) no leen
`--pi-color-*`: pintan con sus propios roles `--mat-sys-*`, generados por
`mat.theme()` a partir de una paleta separada. `_default.scss` reengancha una
cosa con otra **una sola vez**, por indirección:
```scss
--mat-sys-primary: var(--pi-color-primary);
--mat-sys-on-primary: var(--pi-color-white);
--mat-sys-primary-container: var(--pi-color-primary-100);
--mat-sys-on-primary-container: var(--pi-color-primary-900);
--mat-sys-secondary: var(--pi-color-accent);
--mat-sys-on-secondary: var(--pi-color-white);
--mat-sys-secondary-container: var(--pi-color-accent-100);
--mat-sys-on-secondary-container: var(--pi-color-accent-900);
--mat-sys-tertiary: var(--pi-color-highlighted);
--mat-sys-on-tertiary: var(--pi-color-white);
--mat-sys-error: var(--pi-color-warn);
--mat-sys-on-error: var(--pi-color-white);
```
Como es indirección (`var(--pi-color-*)`, no un valor literal), un tema que
redefina `--pi-color-primary` etc. (como Alternative) tiñe también los
controles de Material sin tener que repetir este bloque.
## `--pi-color-*`: paleta de marca
Declarada una vez en `:root` (`_pi-color-variables.scss`) con los valores
Default, y redeclarada completa dentro de `.pi-shell__alternative` (Minimal y
Gradient **no** tocan la paleta — ver comentario en `_minimal.scss`: "minimalista
aquí es una cuestión de forma, no de color"). Se consume **directamente por
nombre** (no hay indirección `--pi-theme-*` de por medio) en ~100 ficheros de
la librería: bordes, fondos, texto, iconos, estados de badge/chip/status, etc.
Cada paleta (`primary`, `accent`, `highlighted`, `warn`, `success`) sigue la
misma escala de 10 pasos + variante rgba del tono 200:
`10 → 25 → 50 → 75 → 100 → 200 → 200-rgba → 300 → 400 → (base) → 900`
| Escala | primary (D) | accent (D) | highlighted (D) | warn (D) | success (D) |
| --- | --- | --- | --- | --- | --- |
| 10 | `#fdfdff` | `#fffdf9` | `#f9fbff` | `#fff9fa` | `#f7fcf9` |
| 25 | `#fafbff` | `#fffaed` | `#f2f5ff` | `#ffedf0` | `#e6f6ed` |
| 50 | `#f0f4ff` | `#fdf5e5` | `#e9eeff` | `#fcebeb` | `#def5e8` |
| 75 | `#eaefff` | `#fae5bf` | `#dae2ff` | `#ffe7ea` | `#c7ebdc` |
| 100 | `#d7e2fb` | `#fce0ad` | `#bccdfa` | `#f7cdcd` | `#bbe8d5` |
| 200 | `#c3d5ff` | `#f6d495` | `#6482d7` | `#f2acac` | `#83dbb6` |
| 200-rgba | `rgba(196,213,255,.15)` | `rgba(252,224,173,.15)` | `rgba(168,193,255,.15)` | `rgba(237,129,129,.15)` | *(no tiene)* |
| 300 | `#a3c0ff` | `#f2c26a` | `#436ac1` | `#ec8b8b` | `#60c99d` |
| 400 | `#8cacf2` | `#f6bc63` | `#1d3f8a` | `#e87272` | `#3ebe88` |
| base | `#799ef0` | `#eda82a` | `#0d2a6b` | `#e45959` | `#1cb273` |
| 900 | `#5772d4` | `#d78f0f` | `#030f3c` | `#c13737` | `#009758` |
**Neutros y utilidad**, sin escala de tema (no cambian entre Default/Alternative):
| Token | Valor | Uso |
| --- | --- | --- |
| `--pi-color-white` | `#ffffff` | — |
| `--pi-color-white-900` | `#f4f4f4` | — |
| `--pi-color-white-rgba-03/05/06/09` | blanco a 33/50/66/88% opacidad | overlays, disabled |
| `--pi-color-black-200` | `#323748` | — |
| `--pi-color-black` | `#1a1a1a` | texto, foco (Minimal) |
| `--pi-color-grey-10…900` | escala de 10 pasos, `#fcfcfd → #3d3d3d` | bordes, texto secundario |
| `--pi-color-background-300/400/500` | `#aeaeb6` / `#fcfcfc` / `#f7f7f7` | fondos de página |
En `_alternative.scss` la paleta completa (`primary`, `accent`, `highlighted`)
se redeclara con una gama azul→violeta / turquesa / violeta oscuro,
respectivamente. `warn` y `success` se dejan intactos a propósito en todos los
temas: son colores semánticos y cambiarlos perjudicaría la lectura del estado.
> Nota: `_pi-color-variables.scss` declara `--pi-color-grey-200` dos veces
> (líneas 328-329) y `--pi-color-white`/`--pi-color-background-400` con el
> mismo patrón en otro punto del fichero — inofensivo (la segunda declaración
> gana y tiene el mismo valor) pero es limpieza pendiente si se retoca el
> fichero.
## Primitivas base (sin tematizar)
Estas variables no cambian entre temas — son la escala de espaciado/forma/sombra
de toda la librería, y los tokens `--pi-theme-*` casi siempre se expresan en
función de ellas (`var(--pi-border-radius-large)`, `var(--pi-spacing-xxx-large)`…).
| Familia | Tokens | Rango |
| --- | --- | --- |
| Espaciado | `--pi-spacing-xxxxx-small` … `--pi-spacing-xxxx-large` | `0.0625rem` (1px) … `3rem` (48px), 11 pasos |
| Radio | `--pi-border-radius-xx-small` … `--pi-border-radius-x-large` | `0.125rem` (2px) … `1.375rem` (22px), 5 pasos |
| Sombra | `--pi-box-shadow`, `-hover`, `-double`, `-panel`, `-panel-double`, `-panel-disabled`, `-top`, `-inset` | valores `rgba` fijos, ver `_pi-global-variables.scss` |
| Animación | `--pi-animation`, `-fast`, `-slow` | `all .16s/.25s/.4s ease-out` |
| Tamaño de fuente | `--pi-font-size-xxx-small` … `-xxx-large` | `0.5rem` (8px) … `2.875rem` (46px), 9 pasos |
| Layout (header/footer/navbar/aside/card) | `--pi-height-*`, `--pi-width-*` | ver `_pi-global-variables.scss` |
| Landing/marketing | `--pi-public-font-size-*`, `--pi-public-height-*`, `--pi-public-width-*` | ver `_pi-public-variables.scss` |
`_pi-gradient-variables.scss` (`$pi-gradient-primary-light`, etc.) son
variables **Sass**, no custom properties de CSS — se resuelven en tiempo de
compilación y no forman parte del contrato de theming en runtime.
## Cómo tokenizar una superficie nueva
1. En el consumidor, cambia el valor hardcodeado por
`var(--pi-theme-<superficie>-<propiedad>, <valor-original>)`. El fallback
garantiza que ningún tema existente cambia de aspecto hasta que decida
redefinir el token.
2. Si **todos** los temas van a necesitar tocarlo pronto, decláralo también en
`defaultThemeTokens` (`_default.scss`) con el valor original — así queda
documentado como parte del contrato en vez de vivir solo como fallback
disperso en el CSS.
3. Añade el override en el/los temas que lo necesiten
(`_alternative.scss`, `_minimal.scss`, `_gradient.scss`).
4. Actualiza este documento.
## Cómo añadir un tema nuevo
1. Crea `libs/ui/src/lib/styles/themes/_<nombre>.scss` con un mixin
`<nombre>ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si
aplica, `--pi-color-*`) que cambian respecto a Default.
2. Aplícalo a `.pi-shell__<nombre>`.
3. Impórtalo desde `themes/index.scss`.
4. Añade la entrada a `ShellClass` en `styles/configs.ts`.
No hace falta tocar ningún componente: si consumen los tokens en vez de
valores literales, el tema nuevo se propaga solo.

BIN
libs/ui/TOKENS.pdf Normal file

Binary file not shown.

706
libs/ui/docs/theming.html Normal file
View File

@@ -0,0 +1,706 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>@pi/ui — Guía de theming</title>
<style>
:root {
--primary: #799ef0;
--primary-900: #5772d4;
--accent: #eda82a;
--highlighted: #0d2a6b;
--success: #1cb273;
--warn: #e45959;
--ink: #1a1a1a;
--grey-700: #44475a;
--grey-500: #6b7280;
--grey-400: #9ca3af;
--grey-200: #e5e7eb;
--grey-100: #f3f4f6;
--callout-bg: #eef1fd;
--callout-border: #5772d4;
--warncallout-bg: #fdecec;
--warncallout-border: #e45959;
--code-bg: #12162a;
--code-text: #dbe3f5;
--code-token: #9db4f5;
--code-comment: #7c869e;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.55;
}
code, .mono {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
white-space: nowrap;
}
pre.code, pre.code code {
white-space: pre-wrap;
}
p { margin: 0 0 10px; color: var(--grey-700); }
a { color: var(--primary-900); }
.token {
color: var(--primary-900);
background: #eef1fd;
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
}
section.page {
page-break-after: always;
padding: 4px 4px 24px;
}
section.page:last-child { page-break-after: auto; }
/* ---------- Cover page ---------- */
.cover-kicker {
color: var(--primary-900);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.12em;
margin-bottom: 18px;
}
.cover-title {
font-size: 34px;
font-weight: 800;
line-height: 1.15;
margin: 0 0 16px;
color: var(--ink);
}
.cover-subtitle {
font-size: 14px;
color: var(--grey-500);
max-width: 30em;
margin-bottom: 26px;
}
.dots { display: flex; gap: 10px; margin-bottom: 26px; }
.dot {
width: 26px; height: 26px; border-radius: 50%;
display: inline-block;
}
.cover-rule { border: none; border-top: 1px solid var(--grey-200); margin: 0 0 20px; }
.cover-note {
font-size: 11px;
color: var(--grey-400);
max-width: 34em;
margin-bottom: 34px;
}
.toc-item {
display: flex;
align-items: baseline;
gap: 14px;
padding: 11px 0;
border-bottom: 1px dashed var(--grey-200);
font-size: 13px;
}
.toc-num { color: var(--primary-900); font-weight: 700; width: 1.4em; }
/* ---------- Section header ---------- */
h1.section-title {
font-size: 19px;
font-weight: 800;
margin: 0 0 6px;
padding-bottom: 10px;
border-bottom: 2px solid var(--ink);
}
h1.section-title .num { color: var(--primary-900); margin-right: 8px; }
.lede { color: var(--grey-500); margin-bottom: 18px; font-size: 13px; }
h2.sub {
font-size: 13.5px;
font-weight: 700;
margin: 22px 0 8px;
}
/* ---------- Tables ---------- */
table { width: 100%; border-collapse: collapse; margin-bottom: 14px; }
thead th {
text-align: left;
font-size: 9.5px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--grey-500);
border-bottom: 1px solid var(--grey-200);
padding: 6px 10px 6px 0;
font-weight: 700;
}
tbody td {
padding: 8px 10px 8px 0;
border-bottom: 1px solid var(--grey-100);
vertical-align: top;
font-size: 12px;
color: var(--grey-700);
}
tbody tr:last-child td { border-bottom: none; }
td.tok { white-space: nowrap; }
small.note { color: var(--grey-400); font-size: 10.5px; }
/* ---------- Code blocks ---------- */
pre.code {
background: var(--code-bg);
color: var(--code-text);
border-radius: 8px;
padding: 16px 18px;
font-size: 11.5px;
line-height: 1.6;
overflow: hidden;
margin: 10px 0 16px;
white-space: pre-wrap;
word-break: break-word;
}
pre.code .t { color: var(--code-token); }
pre.code .c { color: var(--code-comment); font-style: italic; }
/* ---------- Callouts ---------- */
.callout {
background: var(--callout-bg);
border-left: 3px solid var(--callout-border);
border-radius: 4px;
padding: 12px 16px;
font-size: 11.5px;
color: #2c3550;
margin: 14px 0;
}
.callout.warn {
background: var(--warncallout-bg);
border-left-color: var(--warncallout-border);
color: #5a2a2a;
}
.callout b { color: inherit; }
/* ---------- Numbered steps ---------- */
.step { display: flex; gap: 14px; margin-bottom: 18px; align-items: flex-start; }
.step-num {
flex: none;
width: 24px; height: 24px; border-radius: 50%;
background: var(--primary-900); color: #fff;
font-size: 12px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
margin-top: 1px;
}
.step-body { flex: 1; }
.step-title { font-weight: 700; margin-bottom: 6px; font-size: 13px; }
/* ---------- Theme cards (§4) ---------- */
.theme-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 14px 0 18px;
}
.theme-card {
border: 1px solid var(--grey-200);
border-radius: 8px;
padding: 12px 14px;
}
.theme-card .name {
font-weight: 700;
font-size: 12.5px;
margin-bottom: 5px;
display: flex; align-items: center; gap: 7px;
}
.theme-card .name .swatch { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.theme-card p { margin: 0; font-size: 11.5px; color: var(--grey-500); }
/* ---------- Gradient demo (§5) ---------- */
.demo-row { display: flex; gap: 26px; margin: 16px 0 4px; align-items: flex-start; }
.demo-box {
width: 150px; height: 44px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
}
.demo-box.plain { border: 1px solid var(--grey-200); background: #fff; }
.demo-box.gradient {
border: 3px solid transparent;
background:
linear-gradient(#fff, #fff) padding-box,
linear-gradient(120deg, var(--primary), var(--accent), var(--highlighted)) border-box;
}
.demo-caption { font-size: 10.5px; color: var(--grey-500); text-align: center; margin-top: 6px; }
.demo-caption code { font-size: 10px; }
/* ---------- Checklist ---------- */
ul.check, ul.cross { list-style: none; padding: 0; margin: 0 0 20px; }
ul.check li, ul.cross li {
padding: 6px 0 6px 24px;
position: relative;
font-size: 12px;
color: var(--grey-700);
border-bottom: 1px solid var(--grey-100);
}
ul.check li:before {
content: "✓";
position: absolute; left: 0;
color: var(--success); font-weight: 700;
}
ul.cross li:before {
content: "✗";
position: absolute; left: 0;
color: var(--warn); font-weight: 700;
}
</style>
</head>
<body>
<!-- ================= PAGE 1: COVER ================= -->
<section class="page">
<div class="cover-kicker">@PI/UI — DESIGN SYSTEM</div>
<div class="cover-title">Cómo crear<br />temas</div>
<div class="cover-subtitle">
Guía práctica del sistema de theming: qué tokens existen, cómo se aplican,
y los pasos para dar de alta un tema nuevo sin tocar ni un selector de
componente.
</div>
<div class="dots">
<span class="dot" style="background:var(--primary)"></span>
<span class="dot" style="background:var(--accent)"></span>
<span class="dot" style="background:var(--highlighted)"></span>
<span class="dot" style="background:var(--success)"></span>
<span class="dot" style="background:var(--warn)"></span>
<span class="dot" style="background:var(--ink)"></span>
</div>
<hr class="cover-rule" />
<div class="cover-note">
Basada en el estado real de <code>libs/ui/src/lib/styles</code>: los cuatro
temas ya presentes en el repo (Default, Alternative, Minimal, Gradient)
sirven de ejemplo en todo el documento. Incluye los tokens de overlays
(modal, notificación, tooltip, accordion) añadidos tras la primera
versión de esta guía.
</div>
<div class="toc-item"><span class="toc-num">1</span> Arquitectura: shell + tokens</div>
<div class="toc-item"><span class="toc-num">2</span> El contrato de tokens</div>
<div class="toc-item"><span class="toc-num">3</span> Crear un tema, paso a paso</div>
<div class="toc-item"><span class="toc-num">4</span> Los cuatro temas del repo</div>
<div class="toc-item"><span class="toc-num">5</span> Caso de estudio: anillo de foco en degradado</div>
<div class="toc-item"><span class="toc-num">6</span> Modo oscuro y temas</div>
<div class="toc-item"><span class="toc-num">7</span> Checklist</div>
</section>
<!-- ================= PAGE 2: §1 ARQUITECTURA ================= -->
<section class="page">
<h1 class="section-title"><span class="num">1</span>Arquitectura: shell + tokens</h1>
<p class="lede">Dos piezas, ninguna es un selector de componente.</p>
<p>
Un tema en <code>@pi/ui</code> no es un solo fichero que reescriba
<code>.pi-button</code>, <code>.pi-card</code> o cualquier otro selector de
componente. Es, exclusivamente, un bloque que redefine
<b>variables CSS</b> (custom properties) bajo una clase contenedora. Los
componentes ya están escritos para leer esas variables — cambiarlas es
todo lo que hace falta.
</p>
<h2 class="sub">La clase contenedora: <code>.pi-shell</code></h2>
<p>
Todo tema se aplica añadiendo dos clases a un elemento raíz (normalmente
<code>&lt;body&gt;</code>, porque los overlays de Material — diálogos,
menús, snackbars — cuelgan de ahí y también deben heredar el tema):
</p>
<pre class="code">&lt;body class="<span class="t">pi-shell pi-shell__&lt;nombre-del-tema&gt;</span>"&gt;</pre>
<p>
<code>.pi-shell</code> por sí sola ya aplica el contrato completo de
tokens con sus valores por defecto (ver <code>themes/_default.scss</code>).
<code>.pi-shell__&lt;nombre&gt;</code> se aplica <i>a la vez</i>, no en su
lugar, y sobreescribe solo los tokens que ese tema quiere cambiar. Como
ambas clases tienen la misma especificidad, gana la que se declaró más
tarde en el CSS compilado — por eso el orden de <code>@forward</code> en
<code>themes/index.scss</code> importa (ver §3).
</p>
<div class="callout">
El helper <code>shellTheming()</code> (en
<code>libs/ui/src/lib/styles/configs.ts</code>) construye ese string por
ti: <code>shellTheming('minimal')</code> devuelve
<code>'pi-shell pi-shell__minimal'</code>. <code>ShellClass</code> ya
expone uno por tema — no hace falta escribir el string a mano.
</div>
<h2 class="sub">Las dos familias de variables</h2>
<table>
<thead>
<tr><th style="width:16%">Prefijo</th><th style="width:52%">Qué controla</th><th>Ejemplo</th></tr>
</thead>
<tbody>
<tr>
<td class="tok"><code class="token">--pi-color-*</code></td>
<td>Paleta de marca: <code>primary</code>, <code>accent</code>, <code>highlighted</code>, <code>warn</code>, <code>success</code>, <code>grey</code>, cada uno con escalones (<code>-10</code> a <code>-900</code>).</td>
<td><code>--pi-color-primary</code>, <code>--pi-color-primary-900</code></td>
</tr>
<tr>
<td class="tok"><code class="token">--pi-theme-*</code></td>
<td>Forma y comportamiento: radios, sombras, grosor de borde, peso/transformación de texto, rellenos de botón.</td>
<td><code>--pi-theme-button-radius</code>, <code>--pi-theme-card-shadow</code></td>
</tr>
</tbody>
</table>
<p>
Un tema puede tocar una familia, la otra, o ambas.
<code>_alternative.scss</code> cambia las dos (repaleta
primary/accent/highlighted <i>y</i> la forma). <code>_minimal.scss</code>
y <code>_gradient.scss</code> solo tocan <code>--pi-theme-*</code> — la
paleta de marca se queda igual a propósito.
</p>
<h2 class="sub">El puente con Angular Material</h2>
<p>
Los componentes nativos de Material (slider, checkbox, radio…) no leen
<code>--pi-color-*</code>: pintan con sus propios roles
<code>--mat-sys-*</code>, que genera <code>mat.theme()</code>. El puente se
declara <b>una sola vez</b>, en <code>_default.scss</code>, por
indirección:
</p>
<pre class="code"><span class="t">--mat-sys-primary</span>: var(--pi-color-primary);
<span class="t">--mat-sys-primary-container</span>: var(--pi-color-primary-100);
<span class="c">/* … */</span></pre>
<p>
Como es una indirección, cualquier tema que redefina
<code>--pi-color-primary</code> arrastra automáticamente a Material sin
tener que repetir el bloque del puente. Si tu tema no toca la paleta (como
Minimal o Gradient), no necesitas tocar esto en absoluto.
</p>
<div class="callout warn">
<b>Límite conocido:</b> el puente cubre color, no forma. El radio/sombra de
checkbox, radio, slider o mat-select vienen de tokens <code>--mdc-*</code>
que Angular Material fija en tiempo de compilación (no son variables de
runtime reasignables desde un tema). Aplanarlos también requeriría
overrides por componente — fuera del alcance de este contrato.
</div>
</section>
<!-- ================= PAGE 3-4: §2 CONTRATO DE TOKENS ================= -->
<section class="page">
<h1 class="section-title"><span class="num">2</span>El contrato de tokens</h1>
<p class="lede">Todo lo que un tema puede redefinir, con su valor en el tema por defecto y dónde se consume.</p>
<h2 class="sub">Botones</h2>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-button-height</code></td><td><code>var(--pi-spacing-xxx-large)</code> <small class="note">(2.5rem)</small></td><td><code>pi-button/_pi-button-theme.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-radius</code></td><td><code>var(--pi-border-radius-medium)</code></td><td><code>pi-button/_pi-button.scss</code>, <code>dynamic-menu.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-font-weight</code></td><td><code>600</code></td><td rowspan="4"><code>pi-button-theme.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-letter-spacing</code></td><td><code>normal</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-text-transform</code></td><td><code>none</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-shadow</code></td><td><code>none</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-transition</code></td><td><code>background-color .16s ease-in</code></td><td><code>pi-button-theme.scss</code>, <code>stepper.component.scss</code>, <code>file-upload.component.scss</code>, <code>dynamic-menu.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-button-fill-{primary,accent,warn,success,highlighted}</code></td><td><code>none</code> <small class="note">(manda el <code>background-color</code> sólido)</small></td><td><code>_pi-button-theme.scss</code></td></tr>
</tbody>
</table>
<h2 class="sub">Campos de formulario</h2>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-field-radius</code></td><td><code>0.875rem</code></td><td rowspan="7"><code>vendors/_mat-form-field.scss</code>, <code>file-upload.component.scss</code>, <code>_mat-datepicker.scss</code>, <code>_mat-select.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-width</code></td><td><code>1px</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-color</code></td><td><code>var(--pi-color-grey-200)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-color-hover</code></td><td><code>var(--pi-color-grey-300)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-border-color-focus</code></td><td><code>var(--pi-color-primary)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-surface</code></td><td><code>var(--pi-color-white)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-label-color</code></td><td><code>var(--pi-color-grey-300)</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-label-transform</code> / <code>-letter-spacing</code> / <code>-weight</code></td><td><small class="note">sin declarar → hereda el valor original del componente</small></td><td><code>_mat-form-field.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-field-focus-gradient</code></td><td><small class="note">sin declarar → <code>none</code> (opt-in, ver §5)</small></td><td><code>_mat-form-field.scss</code></td></tr>
</tbody>
</table>
<h2 class="sub">Superficies</h2>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-card-radius</code></td><td><code>var(--pi-border-radius-large)</code></td><td><code>components/_pi-card.scss</code>, <code>skeleton.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-card-shadow</code></td><td><code>var(--pi-box-shadow-panel)</code></td><td><code>_pi-card.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-panel-radius</code></td><td><code>var(--pi-border-radius-large)</code></td><td><code>panel.component.scss</code>, <code>_mat-select.scss</code>, <code>_mat-datepicker.scss</code>, <code>dynamic-menu.component.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-chip-radius</code></td><td><small class="note">sin declarar → radio original del chip</small></td><td><code>chip/styles/_size.scss</code> (×3)</td></tr>
</tbody>
</table>
<div class="callout">
Fíjate en el patrón <code>var(--pi-theme-x, &lt;valor-original&gt;)</code>
que usan varios consumidores (chip, panel, card, overlays). Eso significa
que <b>no es obligatorio declarar todos los tokens</b> en un tema nuevo:
los que omitas caen al valor que ya tenía el componente antes de que
existiera el sistema de theming. Solo declara lo que quieras cambiar.
</div>
</section>
<section class="page">
<h2 class="sub" style="margin-top:0">Overlays <small class="note">(modal, notificación, tooltip, accordion)</small></h2>
<p>
Grupo añadido después de la primera versión de este contrato. Ningún tema
salvo <b>Minimal</b> los redefine todavía: Default, Alternative y Gradient
usan siempre el segundo argumento de <code>var()</code>, que reproduce
exactamente la forma que tenía cada overlay antes de tokenizar.
</p>
<table>
<thead><tr><th style="width:34%">Token</th><th style="width:33%">Por defecto (fallback)</th><th>Se usa en</th></tr></thead>
<tbody>
<tr><td class="tok"><code class="token">--pi-theme-modal-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-medium)</code> <small class="note">(<code>var(--pi-border-radius-large)</code> en <code>.pi-modal__panel--full</code>)</small></td><td><code>vendors/_mat-dialog.scss</code> (×2)</td></tr>
<tr><td class="tok"><code class="token">--pi-theme-notification-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-large)</code></td><td><code>snack-bar.component.scss</code>, <code>_mat-snackbar.scss</code> (×2)</td></tr>
<tr><td class="tok"><code class="token">--pi-theme-notification-shadow</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-box-shadow)</code></td><td><code>_mat-snackbar.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-tooltip-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-medium)</code></td><td><code>_mat-tooltip.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-tooltip-shadow</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-box-shadow-panel-double)</code></td><td><code>_mat-tooltip.scss</code></td></tr>
<tr><td class="tok"><code class="token">--pi-theme-accordion-radius</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-border-radius-large)</code></td><td><code>accordion.component.scss</code>, <code>_horizontal-accordion.scss</code>, <code>_vertical-accordion.scss</code> (×6, incluye un <code>calc()</code>)</td></tr>
</tbody>
</table>
<div class="callout">
Minimal declara los seis en su mixin (<code>_minimal.scss</code>): aplana
todos los radios a <code>--pi-border-radius-xx-small</code> y apaga las dos
sombras (<code>none</code>) — mismo criterio que ya aplicaba a botón, campo
y card, extendido al resto de superficies flotantes.
</div>
<!-- ================= §3 CREAR UN TEMA ================= -->
<h1 class="section-title" style="margin-top:26px"><span class="num">3</span>Crear un tema, paso a paso</h1>
<p class="lede">Cuatro ficheros, ninguno toca un componente. Ejemplo con un tema ficticio <code>brutalist</code>.</p>
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<div class="step-title">Crea <code>libs/ui/src/lib/styles/themes/_brutalist.scss</code></div>
<p>Un mixin con los tokens que cambian, más una clase que lo aplica:</p>
<pre class="code">@mixin <span class="t">brutalistThemeTokens</span> {
--pi-theme-button-radius: 0;
--pi-theme-button-shadow: 4px 4px 0 var(--pi-color-black);
--pi-theme-card-radius: 0;
--pi-theme-card-shadow: none;
}
.pi-shell__brutalist {
@include brutalistThemeTokens;
}</pre>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<div class="step-title">Regístralo en <code>themes/index.scss</code></div>
<p>Añade el <code>@forward</code>/<code>@use</code> al final de la lista — el orden es el orden de prioridad en el CSS compilado:</p>
<pre class="code">@forward 'default';
@forward 'alternative';
@forward 'minimal';
@forward 'gradient';
@forward 'brutalist'; <span class="c">// ← nuevo, al final</span>
@use 'default' as *;
@use 'alternative' as *;
@use 'minimal' as *;
@use 'gradient' as *;
@use 'brutalist' as *; <span class="c">// ← nuevo, al final</span></pre>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<div class="step-title">Añade la entrada en <code>ShellClass</code> <small class="note">(<code>libs/ui/src/lib/styles/configs.ts</code>)</small></div>
<pre class="code">export const ShellClass = {
Default: shellTheming('default'),
Alternative: shellTheming('alternative'),
Minimal: shellTheming('minimal'),
Gradient: shellTheming('gradient'),
<span class="c">/* Bordes duros, sombra dura, sin medias tintas. */</span>
Brutalist: shellTheming('brutalist'),
};</pre>
</div>
</div>
<div class="step">
<div class="step-num">4</div>
<div class="step-body">
<div class="step-title">Aplícalo en la app consumidora</div>
<p>En el playground esto ya está resuelto con un signal + effect (ver <code>apps/playground/src/app/app.ts</code>); en cualquier otra app basta con:</p>
<pre class="code">import { ShellClass } from '@pi/ui';
document.body.className = ShellClass.Brutalist;</pre>
</div>
</div>
<div class="callout">
No hay quinto paso. No se toca ningún <code>.component.scss</code>, ningún
selector, ningún componente TypeScript. Si necesitas cambiar algo que
<b>no</b> está en el contrato de tokens (§2), esa es la señal de que hace
falta añadir un token nuevo al contrato — y entonces sí tocas el
<code>.scss</code> del componente afectado, pero solo una vez, para que
quede disponible para todos los temas futuros (así se añadió
<code>--pi-theme-field-focus-gradient</code>, ver §5).
</div>
</section>
<!-- ================= §4 LOS CUATRO TEMAS ================= -->
<section class="page">
<h1 class="section-title"><span class="num">4</span>Los cuatro temas del repo</h1>
<p class="lede">Cuatro implementaciones reales del mismo contrato, en <code>libs/ui/src/lib/styles/themes/</code>.</p>
<div class="theme-grid">
<div class="theme-card">
<div class="name"><span class="swatch" style="background:var(--primary)"></span>Default</div>
<p>Rellenos planos y sólidos, controles compactos. Declara el contrato completo — el resto de temas parten de aquí.</p>
</div>
<div class="theme-card">
<div class="name"><span class="swatch" style="background:#8a63f0"></span>Alternative</div>
<p>Aspecto de marketing: repaleta primary→violeta, accent→turquesa, botones en pastilla con degradado y sombra, campos con superficie tintada.</p>
</div>
<div class="theme-card">
<div class="name"><span class="swatch" style="background:var(--ink)"></span>Minimal</div>
<p>Esquinas casi rectas, sin sombra (separa por borde fino en vez de elevación), sin degradados ni versalitas. Ahora también aplana los overlays — modal, notificación, tooltip y accordion comparten el mismo radio recto. Misma paleta que Default.</p>
</div>
<div class="theme-card">
<div class="name"><span class="swatch" style="background:var(--accent)"></span>Gradient</div>
<p>Igual que Default en forma, pero con degradado en el relleno de los botones y en el anillo de foco de los campos.</p>
</div>
</div>
<p>
Los cuatro comparten <i>exactamente</i> los mismos selectores de
componente — la prueba de que el contrato de tokens funciona es que
ninguno necesitó tocar un <code>.component.ts</code> ni un
<code>.component.html</code>.
</p>
</section>
<!-- ================= §5 CASO DE ESTUDIO ================= -->
<section class="page">
<h1 class="section-title"><span class="num">5</span>Caso de estudio: anillo de foco en degradado</h1>
<p class="lede">Qué hacer cuando el efecto que quieres <b>no</b> lo cubre ningún token existente.</p>
<p>
El tema <i>Gradient</i> quería un borde en degradado al enfocar un campo.
El problema: el borde de un <code>&lt;mat-form-field appearance="outline"&gt;</code>
no es un <code>border</code> normal — es tres <code>&lt;div&gt;</code>
(<code>leading</code>, <code>notch</code>, <code>trailing</code>) cada uno
con su propio borde parcial, para dejar hueco a la etiqueta flotante. Un
<code>background</code> en degradado ahí se vería cortado en cada unión.
</p>
<h2 class="sub">La solución: un anillo aparte, no el borde del notch</h2>
<p>
En vez de pelear con los tres fragmentos, se añade un
<code>::after</code> sobre <code>.mat-mdc-text-field-wrapper</code> (la
caja completa, sin partir) que pinta <i>solo el borde</i> gracias a
<code>mask-composite</code>: se enmascara la copia
<code>padding-box</code> (el interior) fuera de la copia
<code>border-box</code> (el rectángulo completo), dejando visible
únicamente el anillo:
</p>
<small class="note">libs/ui/src/lib/styles/common/vendors/_mat-form-field.scss</small>
<pre class="code">.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: <span class="t">var(--pi-theme-field-focus-gradient, none)</span>;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}</pre>
<p>
El token nuevo, <code>--pi-theme-field-focus-gradient</code>, se añadió al
contrato compartido (consumido con fallback <code>none</code>) — así
cualquier tema puede optar por él sin que los demás se vean afectados.
<i>Gradient</i> además pone
<code>--pi-theme-field-border-color-focus: transparent</code> para apagar
el borde sólido del notch; si no, se vería el borde plano por debajo del
anillo.
</p>
<div class="demo-row">
<div>
<div class="demo-box plain"></div>
<div class="demo-caption">Sin degradado<br /><code>focus-gradient: none</code></div>
</div>
<div>
<div class="demo-box gradient"></div>
<div class="demo-caption">Con degradado<br />(anillo vía <code>mask-composite</code>)</div>
</div>
</div>
<div class="callout">
<b>Regla general:</b> cuando el efecto que buscas no cabe en una simple
reasignación de variable, la solución sigue siendo "un token opt-in con
fallback inerte" + "una técnica CSS aislada en el fichero compartido del
componente" — nunca un selector nuevo dentro del propio fichero del tema.
El fichero de tema solo declara <i>valores</i>.
</div>
</section>
<!-- ================= §6 MODO OSCURO ================= -->
<section class="page">
<h1 class="section-title"><span class="num">6</span>Modo oscuro y temas</h1>
<p class="lede">El modo oscuro del playground es ortogonal a los temas — conviven, no se solapan.</p>
<p>
<code>@pi/ui</code> no trae un tema oscuro propio. El playground activa
uno "best-effort" añadiendo la clase <code>dark-mode</code> al
<code>&lt;html&gt;</code> (no al <code>&lt;body&gt;</code>, que es donde
vive <code>.pi-shell</code>), y ahí reasigna solo los
<b>neutros</b> (<code>--pi-color-white</code>, <code>--pi-color-grey-*</code>,
<code>--pi-color-black</code>) — las paletas de marca
(<code>primary</code>, <code>accent</code>…) se dejan igual:
</p>
<small class="note">apps/playground/src/app/styles.scss</small>
<pre class="code">html.dark-mode {
--pi-color-white: #1c1f26;
--pi-color-grey-100: #2a2e38;
--pi-color-black: #f1f2f4;
<span class="c">/* … solo neutros, la marca no cambia … */</span>
}</pre>
<p>
Como es una clase distinta en un elemento distinto, cualquier combinación
(tema × modo oscuro) funciona sin código extra — son dos cascadas
independientes que solo coinciden en que ambas reasignan variables CSS. La
única excepción real es cuando un tema usa un escalón de marca
<i>como si fuera</i> superficie neutra (por ejemplo,
<code>--pi-color-primary-25</code> como fondo de campo en Alternative): ese
caso puntual sí necesita un ajuste en modo oscuro, ya presente en
<code>styles.scss</code> bajo <code>html.dark-mode .pi-shell__alternative</code>.
</p>
</section>
<!-- ================= §7 CHECKLIST ================= -->
<section class="page">
<h1 class="section-title"><span class="num">7</span>Checklist</h1>
<h2 class="sub" style="margin-top:6px">Antes de dar el tema por terminado</h2>
<ul class="check">
<li>El fichero nuevo vive en <code>themes/</code>, exporta un mixin y una clase <code>.pi-shell__&lt;nombre&gt;</code>.</li>
<li>Está en <code>themes/index.scss</code>, al final de ambos bloques (<code>@forward</code> y <code>@use</code>).</li>
<li>Tiene entrada en <code>ShellClass</code> (<code>configs.ts</code>), con un comentario de una línea que resuma el aspecto.</li>
<li>Solo declara los tokens que cambia — el resto hereda de <code>_default.scss</code> sin repetirlo.</li>
<li>Si toca <code>--pi-color-primary/accent/highlighted</code>, revisado que el puente <code>--mat-sys-*</code> (declarado una vez en default) sigue coherente.</li>
<li>Si toca overlays (modal/notificación/tooltip/accordion), revisado con las tres páginas de catálogo, no solo con un botón suelto.</li>
<li>Probado en el playground con las tres páginas de catálogo, no solo en un botón suelto.</li>
</ul>
<h2 class="sub">Evita</h2>
<ul class="cross">
<li>Añadir un selector de componente (<code>.pi-button.mi-tema</code>) dentro del fichero del tema — si hace falta, el token que falta va al contrato compartido, no aquí.</li>
<li>Redeclarar tokens que no cambian "por si acaso" — genera ruido y dificulta ver qué es realmente distinto en ese tema.</li>
<li>Tocar <code>warn</code> / <code>success</code> por estética — son colores semánticos; cambiarlos perjudica la lectura del estado (error/éxito).</li>
<li>Asumir que <code>--pi-theme-*</code> alcanza a los controles nativos de Material (checkbox, radio, slider): esos usan tokens <code>--mdc-*</code> fijados en compilación, fuera de este contrato (ver §1).</li>
</ul>
</section>
</body>
</html>

Binary file not shown.

View File

@@ -30,6 +30,7 @@ export * from './lib/ui/accordion';
export * from './lib/ui/avatar';
export * from './lib/ui/badge';
export * from './lib/ui/banner';
export * from './lib/ui/breadcrumb';
export * from './lib/ui/button';
export * from './lib/ui/card';
export * from './lib/ui/carousel';
@@ -39,9 +40,11 @@ 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/empty-state';
export * from './lib/ui/error-status-code';
export * from './lib/ui/file-upload';
export * from './lib/ui/form-field';
export * from './lib/ui/hub';
export * from './lib/ui/icon';
export * from './lib/ui/image';
export * from './lib/ui/list-item';
@@ -53,11 +56,16 @@ export * from './lib/ui/paginator';
export * from './lib/ui/panel';
export * from './lib/ui/popover';
export * from './lib/ui/progress';
export * from './lib/ui/rating';
export * from './lib/ui/segmented-control';
export * from './lib/ui/select';
export * from './lib/ui/slider';
export * from './lib/ui/spinner';
export * from './lib/ui/stat-card';
export * from './lib/ui/stepper';
export * from './lib/ui/tab';
export * from './lib/ui/timeline';
export * from './lib/ui/tree';
// ./lib/ui/text: omitido(s) por colisión con otro módulo -> PiDisabledDirective
export {
PiBaseTextViewerComponent,

View File

@@ -43,7 +43,7 @@
align-items: stretch;
justify-content: center;
padding: 0;
border-radius: var(--pi-border-radius-medium);
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-medium));
box-shadow: unset;
overflow: hidden;
overflow-y: auto;
@@ -129,7 +129,7 @@
width: 90vw;
height: 90vh;
.mat-mdc-dialog-container {
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-large));
}
}
}

View File

@@ -0,0 +1,27 @@
// El panel de `pi-hub` es un `MatDialogConfig.panelClass`: el overlay lo
// cuelga fuera de `pi-hub.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-hub__panel {
margin: 0;
.mat-mdc-dialog-container,
.mdc-dialog__surface {
border-radius: var(--pi-theme-modal-radius, var(--pi-border-radius-medium))
var(--pi-theme-modal-radius, var(--pi-border-radius-medium)) 0 0;
height: 100%;
}
animation: pi-hub-slide-up 0.25s ease-out;
}
}
@keyframes pi-hub-slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}

View File

@@ -21,8 +21,8 @@
margin: var(--pi-spacing-x-large);
&.pi-snackbar {
padding: var(--pi-spacing-large) var(--pi-spacing-x-large);
border-radius: var(--pi-border-radius-large);
box-shadow: var(--pi-box-shadow);
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
box-shadow: var(--pi-theme-notification-shadow, var(--pi-box-shadow));
border: 1px solid;
&.pi-snackbar__info {
color: var(--pi-color-primary);
@@ -60,7 +60,7 @@
.pi-layout {
.pi-snackbar {
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large);
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
border: 1px solid;
}
}

View File

@@ -2,8 +2,8 @@
.mat-mdc-tooltip {
margin: var(--pi-spacing-xxx-small);
padding: var(--pi-spacing-xx-small) var(--pi-spacing-small);
box-shadow: var(--pi-box-shadow-panel-double);
border-radius: var(--pi-border-radius-medium);
box-shadow: var(--pi-theme-tooltip-shadow, var(--pi-box-shadow-panel-double));
border-radius: var(--pi-theme-tooltip-radius, var(--pi-border-radius-medium));
.mdc-tooltip__surface {
text-align: center;
}

View File

@@ -5,6 +5,7 @@
@forward 'mat-dialog';
@forward 'mat-drawer';
@forward 'mat-expansion-panel';
@forward 'mat-hub';
@forward 'mat-form-field';
@forward 'mat-menu';
@forward 'mat-progress-bar';

View File

@@ -43,6 +43,14 @@
--pi-theme-card-shadow: none;
--pi-theme-panel-radius: var(--pi-border-radius-xx-small);
--pi-theme-chip-radius: 0;
// --- Overlays: mismo criterio, mismas esquinas rectas -----------------
--pi-theme-modal-radius: var(--pi-border-radius-xx-small);
--pi-theme-notification-radius: var(--pi-border-radius-xx-small);
--pi-theme-notification-shadow: none;
--pi-theme-tooltip-radius: var(--pi-border-radius-xx-small);
--pi-theme-tooltip-shadow: none;
--pi-theme-accordion-radius: var(--pi-border-radius-xx-small);
}
.pi-shell__minimal {

View File

@@ -9,6 +9,17 @@ import {
} from '@angular/core';
import { status } from '../../../util/status';
/**
* Contexto de `ngTemplateOutletContext` para el header y el summary de un
* item: `item` es el propio `PiAccordionItemDirective` (de ahí `$implicit`),
* más su índice y si está expandido en ese momento.
*/
export interface AccordionItemTemplateContext {
$implicit: PiAccordionItemDirective;
index: number;
isExpanded: boolean;
}
/**
* The accordion header is a separated content of the accordion items group.
* If it is needed it must be used as a Header for the whole accordion.
@@ -26,18 +37,21 @@ export class PiAccordionHeaderDirective {
// Accordion Item Header Content
@Directive({ selector: '[pi-accordion-item-header-content]' })
export class PiAccordionItemHeaderDirective {
public readonly templateRef = inject<any>(TemplateRef);
public readonly templateRef = inject<
TemplateRef<AccordionItemTemplateContext>
>(TemplateRef);
}
// Accordion Item Summary Content
@Directive({ selector: '[pi-accordion-item-summary-content]' })
export class PiAccordionSummaryContentDirective {
readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
readonly templateRef =
inject<TemplateRef<AccordionItemTemplateContext>>(TemplateRef);
}
// Accordion Item Expandable Content
@Directive({ selector: '[pi-accordion-item-expandable-content]' })
export class PiAccordionItemContentDirective {
readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
}
/**

View File

@@ -4,7 +4,10 @@
& > header {
border: 1px solid;
border-color: var(--pi-color-#{$color}-75);
background-color: var(--pi-color-#{$color}-25);
background-color: var(
--pi-theme-accordion-surface-#{$color},
var(--pi-color-#{$color}-25)
);
&.is-expanded {
background-color: var(--pi-color-grey-10);
border-color: var(--pi-color-grey-25);
@@ -21,7 +24,7 @@
gap: var(--pi-spacing-large);
padding: var(--pi-spacing-x-small) var(--pi-spacing-small)
var(--pi-spacing-x-small);
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
&.is-expanded {
border: 1px solid;
}

View File

@@ -39,16 +39,16 @@
& > .pi-accordion-item-content__header {
&.is-expanded {
border-bottom: 1px solid;
border-top-left-radius: var(--pi-border-radius-large);
border-top-right-radius: var(--pi-border-radius-large);
border-top-left-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
border-top-right-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
&:not(.is-expanded) {
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
}
& > .pi-accordion-item-content__expandable {
border-bottom-left-radius: var(--pi-border-radius-large);
border-bottom-right-radius: var(--pi-border-radius-large);
border-bottom-left-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
border-bottom-right-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
}
}
@@ -113,14 +113,20 @@
& > .pi-accordion-item {
& > .pi-accordion-item-content__header {
&.is-expanded {
background-color: var(--pi-color-#{$color}-50);
background-color: var(
--pi-theme-accordion-surface-#{$color}-expanded,
var(--pi-color-#{$color}-50)
);
border-color: var(--pi-color-#{$color}-100);
}
}
&:hover {
& > .pi-accordion-item-content__header {
&:not(.is-expanded) {
background-color: var(--pi-color-#{$color}-50);
background-color: var(
--pi-theme-accordion-surface-#{$color}-expanded,
var(--pi-color-#{$color}-50)
);
border-color: var(--pi-color-#{$color}-100);
}
}
@@ -138,8 +144,12 @@
justify-content: center;
align-items: center;
height: var(--pi-spacing-xx-large);
border-top-left-radius: calc(var(--pi-border-radius-large) - 0.0625rem);
border-top-right-radius: calc(var(--pi-border-radius-large) - 0.0625rem);
border-top-left-radius: calc(
var(--pi-theme-accordion-radius, var(--pi-border-radius-large)) - 0.0625rem
);
border-top-right-radius: calc(
var(--pi-theme-accordion-radius, var(--pi-border-radius-large)) - 0.0625rem
);
transition: var(--pi-animation);
&.pi-color__primary {
background-color: var(--pi-color-primary-50);

View File

@@ -70,7 +70,7 @@
&.pi-accordion__appearance--fill {
gap: var(--pi-spacing-large);
& > section {
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-accordion-radius, var(--pi-border-radius-large));
}
header {
aside {
@@ -88,7 +88,10 @@
// Appearance Fill Color
@mixin accordionAppearanceFilleColor($color) {
& > section {
background-color: var(--pi-color-#{$color}-25);
background-color: var(
--pi-theme-accordion-surface-#{$color},
var(--pi-color-#{$color}-25)
);
}
}
@@ -97,7 +100,10 @@
& > section {
border: 1px solid;
border-color: var(--pi-color-#{$color}-75);
background-color: var(--pi-color-#{$color}-25);
background-color: var(
--pi-theme-accordion-surface-#{$color},
var(--pi-color-#{$color}-25)
);
}
}

View File

@@ -0,0 +1,13 @@
@for (item of visibleItems(); track $index; let i = $index) {
<pi-avatar
[image]="item.image"
[name]="item.name"
[alt]="item.alt"
[color]="item.color ?? 'primary'"
[size]="size()"
[style.z-index]="zIndexFor(i)"
/>
}
@if (overflowCount() > 0) {
<span class="pi-avatar-group__overflow">+{{ overflowCount() }}</span>
}

View File

@@ -0,0 +1,54 @@
@mixin overflowSize($dimension, $font-size) {
height: $dimension;
width: $dimension;
min-width: $dimension;
font-size: $font-size;
}
:host {
display: flex;
flex-direction: row;
// El anillo (box-shadow, no border: no cambia la caja) separa avatares
// solapados; se aplica desde aquí porque `pi-avatar` no sabe que puede
// vivir en un grupo.
pi-avatar {
position: relative;
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
border-radius: 50%;
&:not(:first-child) {
margin-left: calc(var(--pi-spacing-xx-small) * -1);
}
}
.pi-avatar-group__overflow {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 50%;
margin-left: calc(var(--pi-spacing-xx-small) * -1);
box-shadow: 0 0 0 0.125rem var(--pi-color-white);
background-color: var(--pi-color-grey-100);
color: var(--pi-color-grey);
font-weight: 700;
}
// Size (mismos escalones que pi-avatar, para que el "+N" encaje)
&.pi-avatar-group__size--x-small .pi-avatar-group__overflow {
@include overflowSize(1.5rem, var(--pi-font-size-xxx-small));
}
&.pi-avatar-group__size--small .pi-avatar-group__overflow {
@include overflowSize(2rem, var(--pi-font-size-xx-small));
}
&.pi-avatar-group__size--medium .pi-avatar-group__overflow {
@include overflowSize(2.5rem, var(--pi-font-size-x-small));
}
&.pi-avatar-group__size--large .pi-avatar-group__overflow {
@include overflowSize(3.25rem, var(--pi-font-size-small));
}
&.pi-avatar-group__size--x-large .pi-avatar-group__overflow {
@include overflowSize(4rem, var(--pi-font-size-medium));
}
}

View File

@@ -0,0 +1,75 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { AvatarGroupItem, PiAvatarGroupComponent } from './avatar-group.component';
describe('PiAvatarGroupComponent', () => {
let fixture: ComponentFixture<PiAvatarGroupComponent>;
const items: AvatarGroupItem[] = [
{ name: 'Ada Lovelace' },
{ name: 'Grace Hopper' },
{ name: 'Margaret Hamilton' },
{ name: 'Katherine Johnson' },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiAvatarGroupComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(PiAvatarGroupComponent);
fixture.componentRef.setInput('items', items);
});
function avatars(): HTMLElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('pi-avatar'));
}
function overflowText(): string | null {
return (
fixture.nativeElement
.querySelector('.pi-avatar-group__overflow')
?.textContent?.trim() ?? null
);
}
it('renders one pi-avatar per item when there is no max', () => {
fixture.detectChanges();
expect(avatars().length).toBe(items.length);
expect(overflowText()).toBeNull();
});
it('caps visible avatars at max and shows the overflow count', () => {
fixture.componentRef.setInput('max', 3);
fixture.detectChanges();
expect(avatars().length).toBe(3);
expect(overflowText()).toBe('+1');
});
it('does not show an overflow badge when max is at least the item count', () => {
fixture.componentRef.setInput('max', items.length);
fixture.detectChanges();
expect(avatars().length).toBe(items.length);
expect(overflowText()).toBeNull();
});
it('gives earlier avatars a higher z-index so they stack on top', () => {
fixture.detectChanges();
const zIndexes = avatars().map((el) => Number(el.style.zIndex));
expect(zIndexes).toEqual([4, 3, 2, 1]);
});
it('reflects size as a host class', () => {
fixture.componentRef.setInput('size', 'large');
fixture.detectChanges();
expect(fixture.nativeElement.className).toContain(
'pi-avatar-group__size--large',
);
});
});

View File

@@ -0,0 +1,55 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
computed,
input,
} from '@angular/core';
// Shared
import { iconSize, theming } from '../../../styles';
import { PiAvatarComponent } from '../avatar/avatar.component';
export interface AvatarGroupItem {
image?: string;
name?: string;
alt?: string;
color?: theming;
}
@Component({
selector: 'pi-avatar-group',
templateUrl: './avatar-group.component.html',
styleUrls: ['./avatar-group.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiAvatarComponent],
})
export class PiAvatarGroupComponent {
@HostBinding('class') get classes(): string {
return ['pi-avatar-group', `pi-avatar-group__size--${this.size()}`].join(
' ',
);
}
readonly items = input.required<AvatarGroupItem[]>();
/** Por encima de este número, el resto se resume en un "+N". */
readonly max = input<number>();
readonly size = input<iconSize>('medium');
protected readonly visibleItems = computed(() => {
const max = this.max();
const items = this.items();
return max ? items.slice(0, max) : items;
});
protected readonly overflowCount = computed(() => {
const max = this.max();
const items = this.items();
return max && items.length > max ? items.length - max : 0;
});
/** El primero debe quedar por encima: z-index descendente por índice. */
protected zIndexFor(index: number): number {
return this.visibleItems().length - index;
}
}

View File

@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { PiAvatarComponent } from './avatar.component';
describe('PiAvatarComponent', () => {
let fixture: ComponentFixture<PiAvatarComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiAvatarComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(PiAvatarComponent);
});
function initialsText(): string | null {
return (
fixture.nativeElement
.querySelector('.pi-avatar__initials')
?.textContent?.trim() ?? null
);
}
it('falls back to the icon when neither image nor name are set', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('pi-icon')).toBeTruthy();
expect(fixture.nativeElement.querySelector('img')).toBeNull();
expect(initialsText()).toBeNull();
});
it('falls back to initials when name is set but no image', () => {
fixture.componentRef.setInput('name', 'Ada Lovelace');
fixture.detectChanges();
expect(initialsText()).toBe('AL');
expect(fixture.nativeElement.querySelector('pi-icon')).toBeNull();
});
it('computes a single-letter initial from a single-word name', () => {
fixture.componentRef.setInput('name', 'Madonna');
fixture.detectChanges();
expect(initialsText()).toBe('M');
});
it('prefers the image over name/icon when all are provided', () => {
fixture.componentRef.setInput('image', 'https://example.com/a.png');
fixture.componentRef.setInput('name', 'Ada Lovelace');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img')).toBeTruthy();
expect(initialsText()).toBeNull();
});
it('falls back to initials when the image fails to load', () => {
fixture.componentRef.setInput('image', 'https://example.com/broken.png');
fixture.componentRef.setInput('name', 'Ada Lovelace');
fixture.detectChanges();
const img: HTMLImageElement = fixture.nativeElement.querySelector('img');
img.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img')).toBeNull();
expect(initialsText()).toBe('AL');
});
it('reflects size, shape and color as host classes', () => {
fixture.componentRef.setInput('size', 'large');
fixture.componentRef.setInput('shape', 'square');
fixture.componentRef.setInput('color', 'accent');
fixture.detectChanges();
const classes: string = fixture.nativeElement.className;
expect(classes).toContain('pi-avatar__size--large');
expect(classes).toContain('pi-avatar__shape--square');
expect(classes).toContain('pi-color__accent');
});
});

View File

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

View File

@@ -0,0 +1,87 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PiBadgeComponent } from './badge.component';
@Component({
template: `
<pi-badge [value]="value" [max]="max" [isHidden]="isHidden" color="warn">
<span class="projected-content">child</span>
</pi-badge>
`,
imports: [PiBadgeComponent],
})
class HostComponent {
value: string | number | undefined = undefined;
max = 99;
isHidden = false;
}
describe('PiBadgeComponent', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
});
function indicator(): HTMLElement | null {
return fixture.nativeElement.querySelector('.pi-badge__indicator');
}
it('always renders the projected content', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.projected-content'),
).toBeTruthy();
});
it('shows a dot with no text when value is not set', () => {
fixture.detectChanges();
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
true,
);
expect(indicator()?.textContent?.trim()).toBe('');
});
it('shows the numeric value when under max', () => {
fixture.componentInstance.value = 3;
fixture.detectChanges();
expect(indicator()?.classList.contains('pi-badge__indicator--dot')).toBe(
false,
);
expect(indicator()?.textContent?.trim()).toBe('3');
});
it('caps the numeric value at max with a "+" suffix', () => {
fixture.componentInstance.value = 150;
fixture.componentInstance.max = 99;
fixture.detectChanges();
expect(indicator()?.textContent?.trim()).toBe('99+');
});
it('shows a string value as-is, ignoring max', () => {
fixture.componentInstance.value = 'new';
fixture.detectChanges();
expect(indicator()?.textContent?.trim()).toBe('new');
});
it('hides the indicator without unmounting the projected content', () => {
fixture.componentInstance.value = 3;
fixture.componentInstance.isHidden = true;
fixture.detectChanges();
expect(indicator()).toBeNull();
expect(
fixture.nativeElement.querySelector('.projected-content'),
).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
<ol>
@for (item of items(); track item.label; let last = $last) {
<li>
@if (item.link && !last) {
<a [routerLink]="item.link">{{ item.label }}</a>
} @else {
<span [attr.aria-current]="last ? 'page' : null">{{
item.label
}}</span>
}
@if (!last) {
<pi-icon [icon]="separatorIcon()" size="x-small" color="grey" />
}
</li>
}
</ol>

View File

@@ -0,0 +1,40 @@
:host {
display: block;
ol {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--pi-spacing-xxx-small);
margin: 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: var(--pi-spacing-xxx-small);
// El reset global aplica `li + li { margin-top: ... }` pensado para listas
// de prosa (ver _pi-reset.scss); aquí el <ol> es una fila flex, así que
// ese margen solo desalinea verticalmente los elementos a partir del 2º.
margin-top: 0;
}
a {
color: var(--pi-color-grey);
text-decoration: none;
font-size: var(--pi-font-size-x-small);
&:hover {
color: var(--pi-color-primary);
text-decoration: underline;
}
}
span {
font-size: var(--pi-font-size-x-small);
color: var(--pi-color-black);
font-weight: 600;
}
}

View File

@@ -0,0 +1,62 @@
import { provideRouter } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { BreadcrumbItem, PiBreadcrumbComponent } from './breadcrumb.component';
describe('PiBreadcrumbComponent', () => {
let fixture: ComponentFixture<PiBreadcrumbComponent>;
const items: BreadcrumbItem[] = [
{ label: 'Inicio', link: ['/'] },
{ label: 'Componentes', link: ['/componentes'] },
{ label: 'Avatar' },
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiBreadcrumbComponent, MatIconTestingModule],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(PiBreadcrumbComponent);
fixture.componentRef.setInput('items', items);
fixture.detectChanges();
});
it('renders a routerLink anchor for every item except the last', () => {
const links: HTMLAnchorElement[] =
fixture.nativeElement.querySelectorAll('a');
expect(links.length).toBe(2);
expect(links[0].textContent?.trim()).toBe('Inicio');
expect(links[1].textContent?.trim()).toBe('Componentes');
});
it('renders the last item as plain text marked as the current page', () => {
const spans: HTMLSpanElement[] =
fixture.nativeElement.querySelectorAll('span');
const current = Array.from(spans).find(
(span) => span.textContent?.trim() === 'Avatar',
);
expect(current).toBeTruthy();
expect(current?.getAttribute('aria-current')).toBe('page');
});
it('renders one separator icon fewer than the number of items', () => {
const separators = fixture.nativeElement.querySelectorAll('pi-icon');
expect(separators.length).toBe(items.length - 1);
});
it('treats an item without a link as plain text even mid-list', () => {
fixture.componentRef.setInput('items', [
{ label: 'Sin enlace' },
{ label: 'Actual' },
]);
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('a').length).toBe(0);
});
});

View File

@@ -0,0 +1,32 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
input,
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { PiIconComponent } from '../../icon/icon/icon.component';
export interface BreadcrumbItem {
label: string;
/** Comandos de `routerLink`. Si se omite, se pinta como texto plano
* (típicamente el último elemento, la página actual). */
link?: unknown[];
}
@Component({
selector: 'pi-breadcrumb',
templateUrl: './breadcrumb.component.html',
styleUrls: ['./breadcrumb.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, PiIconComponent],
})
export class PiBreadcrumbComponent {
@HostBinding('class') className = 'pi-breadcrumb';
@HostBinding('attr.role') role = 'navigation';
@HostBinding('attr.aria-label') ariaLabel = 'Breadcrumb';
readonly items = input.required<BreadcrumbItem[]>();
readonly separatorIcon = input('chevron-forward');
}

View File

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

View File

@@ -39,7 +39,7 @@ export abstract class PiBaseButtonComponent {
return 'is-disabled';
}
onClickHandler(event: any): void {
onClickHandler(event: MouseEvent): void {
if (!this.disabled()) {
if (!this.allowPropagation()) {
event.preventDefault();

View File

@@ -25,12 +25,12 @@ export abstract class PiBaseCardComponent {
readonly execute = output<void>();
protected get hasShadowClass(): string | boolean | undefined {
return this.hasShadow() && 'has-shadow';
protected get hasShadowClass(): string {
return this.hasShadow() ? 'has-shadow' : '';
}
protected get isClickableClass(): string | false | undefined {
return this.isClickable() && 'is-clickable';
protected get isClickableClass(): string {
return this.isClickable() ? 'is-clickable' : '';
}
protected get colorClass(): string {

View File

@@ -10,6 +10,6 @@ import {
host: { class: 'pi-carousel pi-carousel__item' },
})
export class PiCarouselItemDirective {
public readonly templateRef = inject<TemplateRef<any>>(TemplateRef);
public readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
public readonly viewContainerRef = inject(ViewContainerRef);
}

View File

@@ -27,7 +27,7 @@
<!-- Paginator -->
@if (hasOverflow && !hidePaginator()) {
<footer>
@for (i of pages; track i; let index = $index) {
@for (i of pages(); track i; let index = $index) {
<div [class]="index === currentPageIndex ? 'is-active' : ''"></div>
}
</footer>

View File

@@ -3,7 +3,6 @@ import {
AfterViewChecked,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
@@ -11,6 +10,7 @@ import {
Renderer2,
input,
output,
signal,
viewChild,
contentChildren,
inject,
@@ -142,17 +142,22 @@ export class PiCarouselComponent
private destroyed$: Subject<void> = new Subject<void>();
private _currentItemIndex = 0;
set currentItem(index: number) {
this._currentItemIndex = index;
/**
* Signals en vez de campos llanos: se mutan desde listeners nativos
* (`Renderer2.listen` en `document.documentElement`, la suscripción a
* `device$`/`size$`) que no pasan por un binding de evento de plantilla, así
* que antes hacía falta `ChangeDetectorRef.detectChanges()`/`markForCheck()`
* a mano para que la plantilla se enterase. Escribir un signal ya
* reprograma la detección de cambios sola.
*/
readonly currentItem = signal(0);
readonly itemsPerPage = signal(0);
readonly pages = signal<number[]>([]);
private setCurrentItem(index: number): void {
this.currentItem.set(index);
this.itemChanged.emit(index);
}
get currentItem(): number {
return this._currentItemIndex;
}
itemsPerPage!: number;
pages!: Array<number>;
// Drag variables
isDragging = false;
@@ -168,7 +173,6 @@ export class PiCarouselComponent
private touchmoveListener!: () => void;
private touchendListener!: () => void;
private readonly cdr = inject(ChangeDetectorRef);
private readonly el = inject(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly deviceConfig = inject(DeviceConfigsFacade);
@@ -193,7 +197,7 @@ export class PiCarouselComponent
* Used for symmetric carousel to know how many items to show on each side of the current item.
*/
get numberOfSideItems(): number {
return Math.floor(this.itemsPerPage / 2);
return Math.floor(this.itemsPerPage() / 2);
}
/**
@@ -231,26 +235,26 @@ export class PiCarouselComponent
* Returns true if the number of items is greater than the number of items per page.
*/
get hasOverflow(): boolean {
return this.carouselItems().length > this.itemsPerPage;
return this.carouselItems().length > this.itemsPerPage();
}
get canSwipeNext(): boolean {
return this.movePerPage()
? this.currentItem + this.itemsPerPage < this.carouselItems().length
: this.currentItem < this.carouselItems().length - 1;
? this.currentItem() + this.itemsPerPage() < this.carouselItems().length
: this.currentItem() < this.carouselItems().length - 1;
}
get canSwipePrev(): boolean {
return this.movePerPage()
? this.currentItem - this.itemsPerPage >= 0
: this.currentItem > 0;
? this.currentItem() - this.itemsPerPage() >= 0
: this.currentItem() > 0;
}
/**
* Returns the index of the current page.
*/
get currentPageIndex(): number {
return Math.trunc(this.currentItem / this.itemsPerPage);
return Math.trunc(this.currentItem() / this.itemsPerPage());
}
ngAfterViewInit(): void {
@@ -278,58 +282,58 @@ export class PiCarouselComponent
reloadComponent(): void {
this.ngOnDestroy();
this.currentItem = 0;
this.setCurrentItem(0);
this.ngAfterViewInit();
this.ngAfterViewChecked();
this.cdr.markForCheck();
}
next(): void {
if (this.movePerPage()) {
this.currentPageIndex < this.pages.length - 1
? (this.currentItem = (this.currentPageIndex + 1) * this.itemsPerPage)
this.currentPageIndex < this.pages().length - 1
? this.setCurrentItem((this.currentPageIndex + 1) * this.itemsPerPage())
: null;
} else {
this.currentItem = this.currentItem + 1;
this.setCurrentItem(this.currentItem() + 1);
}
this.carouselTranslation(this.currentItem);
this.carouselTranslation(this.currentItem());
}
prev(): void {
if (this.movePerPage()) {
this.currentPageIndex > 0
? (this.currentItem = (this.currentPageIndex - 1) * this.itemsPerPage)
? this.setCurrentItem((this.currentPageIndex - 1) * this.itemsPerPage())
: null;
} else {
this.currentItem = this.currentItem - 1;
this.setCurrentItem(this.currentItem() - 1);
}
this.carouselTranslation(this.currentItem);
this.carouselTranslation(this.currentItem());
}
setItemsPerPage(): void {
const itemWidth = this.itemWidth();
if (!!itemWidth) {
this.itemsPerPage =
Math.floor(this.carouselContentElement.clientWidth / itemWidth) || 1;
this.itemsPerPage.set(
Math.floor(this.carouselContentElement.clientWidth / itemWidth) || 1,
);
} else {
const itemsDesktop = this.itemsDesktop();
const itemsTablet = this.itemsTablet();
const itemsMobile = this.itemsMobile();
switch (this.device()) {
case 'DESKTOP':
this.itemsPerPage = itemsDesktop
? itemsDesktop
: this.carouselItems().length;
this.itemsPerPage.set(
itemsDesktop ? itemsDesktop : this.carouselItems().length,
);
break;
case 'TABLET':
this.itemsPerPage = itemsTablet
? itemsTablet
: this.carouselItems().length;
this.itemsPerPage.set(
itemsTablet ? itemsTablet : this.carouselItems().length,
);
break;
case 'MOBILE':
this.itemsPerPage = itemsMobile
? itemsMobile
: this.carouselItems().length;
this.itemsPerPage.set(
itemsMobile ? itemsMobile : this.carouselItems().length,
);
break;
}
}
@@ -341,12 +345,12 @@ export class PiCarouselComponent
getItemClass(index: number): string {
let itemClass: string = 'pi-carousel__item pi-carousel__item--' + index;
if (index === this.currentItem) itemClass += ' ' + 'is-active';
if (index === this.currentItem()) itemClass += ' ' + 'is-active';
return itemClass;
}
selectItem(index: number): void {
this.currentItem = index;
this.setCurrentItem(index);
if (this.hasOverflow && !this.movePerPage()) {
this.carouselTranslation(index);
}
@@ -355,12 +359,11 @@ export class PiCarouselComponent
setPages(): void {
let totalPages: number;
let pages: Array<number> = [];
totalPages = Math.ceil(this.carouselItems().length / this.itemsPerPage);
totalPages = Math.ceil(this.carouselItems().length / this.itemsPerPage());
for (let i = 0; i < totalPages; i++) {
pages.push(i);
}
this.pages = pages;
this.cdr.detectChanges();
this.pages.set(pages);
}
private stopTouchDragEventListeners(): void {
@@ -456,7 +459,7 @@ export class PiCarouselComponent
const itemWidth = this.itemWidth();
const minDraggingWidth = itemWidth
? 0.8 * itemWidth
: 0.8 * (100 / this.itemsPerPage);
: 0.8 * (100 / this.itemsPerPage());
this.isDragging = true;
@@ -468,8 +471,8 @@ export class PiCarouselComponent
this.dragInitPosition - this.dragFinalPosition > minDraggingWidth &&
this.canSwipeNext
) {
this.currentItem++;
this.carouselTranslation(this.currentItem);
this.setCurrentItem(this.currentItem() + 1);
this.carouselTranslation(this.currentItem());
this.dragInitPosition = this.dragFinalPosition;
}
@@ -477,8 +480,8 @@ export class PiCarouselComponent
this.dragInitPosition - this.dragFinalPosition < -minDraggingWidth &&
this.canSwipePrev
) {
this.currentItem--;
this.carouselTranslation(this.currentItem);
this.setCurrentItem(this.currentItem() - 1);
this.carouselTranslation(this.currentItem());
this.dragInitPosition = this.dragFinalPosition;
}
}
@@ -486,7 +489,6 @@ export class PiCarouselComponent
private dragEnd(): void {
this.isDragging = false;
this.isMouseDown = false;
this.cdr.detectChanges();
}
private getCarouselItemsNativeElements(): void {
@@ -499,7 +501,7 @@ export class PiCarouselComponent
const type = this.type();
if (
type === 'linear' &&
currentItem <= this.carouselItems().length - this.itemsPerPage
currentItem <= this.carouselItems().length - this.itemsPerPage()
) {
this.carouselItemsTranslation(
currentItem,
@@ -524,7 +526,7 @@ export class PiCarouselComponent
// If we are at the end of the carousel
this.carouselItemsTranslation(
currentItem,
`-${(this.carouselItems().length - this.itemsPerPage) * 100}%`,
`-${(this.carouselItems().length - this.itemsPerPage()) * 100}%`,
this.direction(),
);
}
@@ -564,14 +566,14 @@ export class PiCarouselComponent
this.renderer.setStyle(
item,
'min-width',
`${100 / this.itemsPerPage}%`,
`${100 / this.itemsPerPage()}%`,
);
this.renderer.setStyle(
item,
'max-width',
`${100 / this.itemsPerPage}%`,
`${100 / this.itemsPerPage()}%`,
);
this.renderer.setStyle(item, 'width', `${100 / this.itemsPerPage}%`);
this.renderer.setStyle(item, 'width', `${100 / this.itemsPerPage()}%`);
}
});
}
@@ -580,7 +582,7 @@ export class PiCarouselComponent
this.renderer.setStyle(
this.carouselContentElement,
'min-height',
`${this.itemsPerPage * this.tallestElementHeightPx}px`,
`${this.itemsPerPage() * this.tallestElementHeightPx}px`,
);
this.carouselItemsNativeElements.forEach((item) => {
this.renderer.setStyle(

View File

@@ -4,6 +4,6 @@
<pi-chart-legend
[datasets]="datasets()"
[isComparativeChart]="isComparativeChart()"
[hoveredDatasetIndex]="hoveredDatasetIndex"
[hoveredDatasetIndex]="hoveredDatasetIndex()"
(triggerDatasetHover)="datasetHoveredInLegend($event)"
></pi-chart-legend>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LinearScale,
Tooltip,
} from 'chart.js';
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',
styleUrls: ['./bar-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
export class PiBarChartComponent extends PiBaseCartesianChartComponent {
get completedDatasets() {
return this.translatedDatasets().map((dataset) => ({
...dataset,
...barDatasetConfig,
}));
}
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'bar',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: this.getCartesianOptions('bar'),
});
}
}

View File

@@ -0,0 +1,72 @@
import { Component, input } from '@angular/core';
import { ChartData, ChartOptions } from 'chart.js';
import { ChartSlice } from '../../util/models';
import { arcDatasetConfig, tooltipConfig } from './chart.configs';
import { PiBaseChartComponent } from './base-chart.component';
/**
* Base de `pi-pie-chart` y `pi-doughnut-chart`: a diferencia de
* `PiBaseCartesianChartComponent` (varias series a lo largo de un eje), aquí
* hay una sola magnitud repartida en porciones — cada una con su propio
* color — así que usan `slices`, no `datasets`, y la leyenda nativa de
* Chart.js en vez de `pi-chart-legend`.
*/
@Component({ template: '' })
export abstract class PiBaseArcChartComponent extends PiBaseChartComponent {
readonly slices = input.required<Array<ChartSlice>>();
protected getArcData<T extends 'pie' | 'doughnut'>(): ChartData<T> {
const slices = this.slices();
return {
labels: slices.map((slice) => this.translate.instant(slice.label)),
datasets: [
{
data: slices.map((slice) => slice.value),
backgroundColor: slices.map((slice) => slice.color),
...arcDatasetConfig,
},
],
} as unknown as ChartData<T>;
}
protected getArcOptions<T extends 'pie' | 'doughnut'>(): ChartOptions<T> {
return {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
padding: 20,
font: {
family: 'Soehne',
size: 12,
},
},
},
tooltip: {
...tooltipConfig,
callbacks: {
label: (context): string => {
const label = context.label ?? '';
const value = context.parsed as number;
const dataset = context.dataset as unknown as {
data: number[];
};
const total = dataset.data.reduce(
(sum, current) => sum + current,
0,
);
const percentage = total
? Math.round((value / total) * 100)
: 0;
return `${label}: ${context.formattedValue} (${percentage}%)`;
},
},
},
},
} as ChartOptions<T>;
}
}

View File

@@ -0,0 +1,113 @@
import { Component, input, signal } from '@angular/core';
import {
ActiveElement,
Chart,
ChartEvent,
ChartOptions,
ChartType,
} from 'chart.js';
import { Dataset } from '../../util/models';
import { scalesConfig, tooltipConfig } from './chart.configs';
import { PiBaseChartComponent } from './base-chart.component';
/**
* Base de `pi-line-chart` y `pi-bar-chart`: ambos comparan una o varias
* `Dataset` (series) a lo largo de `xAxisMarks`, con la misma leyenda propia
* (`pi-chart-legend`, no la nativa de Chart.js) y el mismo tooltip. Lo único
* que cambia entre los dos es el `type` de Chart.js y el aspecto del propio
* dataset (con o sin puntos) — eso lo aporta cada subclase.
*/
@Component({ template: '' })
export abstract class PiBaseCartesianChartComponent extends PiBaseChartComponent {
readonly datasets = input.required<Array<Dataset>>();
readonly xAxisMarks = input<Array<string>>();
readonly isComparativeChart = input<boolean>(true);
/**
* Signal en vez de campo llano: `onHover` lo actualiza desde un callback de
* Chart.js (fuera de la zona de Angular), y escribir un signal ya reprograma
* la detección de cambios sola, sin `ChangeDetectorRef.detectChanges()`.
*/
readonly hoveredDatasetIndex = signal(-1);
/** Etiquetas traducidas; el resto del aspecto lo añade cada subclase. */
protected translatedDatasets(): Array<Dataset> {
return this.datasets().map((dataset) => ({
...dataset,
label: this.translate.instant(dataset.label),
}));
}
triggerHover(datasetIndex: number): void {
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
this.chart.setActiveElements([]);
} else {
this.chart.setActiveElements([{ datasetIndex, index: 0 }]);
}
this.chart.update();
}
datasetHoveredInLegend(index: number): void {
this.hoveredDatasetIndex.set(index);
this.triggerHover(index);
}
protected getCartesianOptions(chartType: ChartType): ChartOptions {
const isComparativeChart = this.isComparativeChart();
return {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
hover: {
mode: 'dataset',
},
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
this.hoveredDatasetIndex.set(
activeElements.length ? activeElements[0].datasetIndex : -1,
);
},
plugins: {
legend: {
display: false,
},
tooltip: {
...tooltipConfig,
filter: (tooltipItem): boolean => {
if (isComparativeChart) {
return (
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
);
}
return tooltipItem.element.active;
},
callbacks: {
labelTextColor: (context): string =>
context.dataset.borderColor as string,
label: (context): string => {
const datasetLabel = context.dataset.label ?? '';
const chartLabel =
datasetLabel.length > 12
? datasetLabel.slice(0, 12) + '...'
: datasetLabel;
const dataset = context.dataset as Dataset;
if (dataset.unitPosition === 'before') {
return (
chartLabel + ': ' + dataset.unit + context.formattedValue
);
}
return (
chartLabel + ': ' + context.formattedValue + dataset.unit
);
},
title: (): string => '',
},
},
},
interaction: {
intersect: false,
mode: 'index',
},
scales: scalesConfig,
} as ChartOptions<typeof chartType>;
}
}

View File

@@ -0,0 +1,39 @@
import {
AfterViewInit,
Component,
ElementRef,
inject,
input,
viewChild,
} from '@angular/core';
import { Chart } from 'chart.js';
import { TranslateService } from '@ngx-translate/core';
import { Device } from '../../util/device-configs';
/**
* Base común a los 4 gráficos (`pi-line-chart`, `pi-bar-chart`,
* `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.
*/
@Component({ template: '' })
export abstract class PiBaseChartComponent implements AfterViewInit {
readonly device = input.required<Device>();
readonly chartRef = viewChild.required('chart', {
read: ElementRef<HTMLCanvasElement>,
});
chart!: Chart;
protected readonly translate = inject(TranslateService);
ngAfterViewInit(): void {
this.chart = this.buildChart();
}
protected abstract buildChart(): Chart;
}

View File

@@ -81,7 +81,7 @@ export const scalesConfig: ScalesConfig = {
},
};
export const datasetConfig = {
export const lineDatasetConfig = {
borderWidth: 1.3,
hoverBorderWidth: 3.5,
pointRadius: 8,
@@ -89,3 +89,27 @@ export const datasetConfig = {
pointBorderWidth: 2,
pointHoverBorderWidth: 2,
};
/** `type="bar"`: sin puntos (no aplican a barras), esquinas redondeadas. */
export const barDatasetConfig = {
borderWidth: 0,
borderRadius: 6,
maxBarThickness: 32,
};
/** `pi-pie-chart`/`pi-doughnut-chart`: separación fina entre porciones. */
export const arcDatasetConfig = {
borderWidth: 2,
borderColor: DatasetColor.white,
hoverOffset: 12,
};
/** `#rrggbb` -> `rgba(r, g, b, alpha)`; usado por `pi-line-chart` para el área rellena. */
export function hexToRgba(hex: string, alpha: number): string {
const clean = hex.replace('#', '');
const value = parseInt(clean, 16);
const r = (value >> 16) & 255;
const g = (value >> 8) & 255;
const b = value & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@@ -1,34 +0,0 @@
:host {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
max-width: 100%;
& > div {
position: relative;
max-width: 100%;
}
}
html {
&:not(.is-desktop) {
:host {
& > div {
aspect-ratio: 1;
height: 100%;
max-height: 40vh;
}
}
}
&.is-desktop {
:host {
& > div {
& > canvas {
aspect-ratio: 1;
height: 100%;
}
}
}
}
}

View File

@@ -1,184 +0,0 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
input,
viewChild,
inject,
} from '@angular/core';
import { Device } from '../../../util/device-configs';
import { Dataset } from '../../../util/models';
import { TranslateService } from '@ngx-translate/core';
import {
BarController,
BarElement,
CategoryScale,
Chart,
Legend,
LineController,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
} from 'chart.js';
import { datasetConfig, scalesConfig, tooltipConfig } from '../chart.configs';
import { PiChartLegendComponent } from '../chart-legend/chart-legend.component';
function registerChartComponents() {
Chart.register(CategoryScale);
Chart.register(LinearScale);
Chart.register(BarController);
Chart.register(BarElement);
Chart.register(LineController);
Chart.register(LineElement);
Chart.register(PointElement);
Chart.register(Tooltip);
Chart.register(Legend);
Chart.register(Title);
}
registerChartComponents();
@Component({
selector: 'pi-chart',
templateUrl: './chart.component.html',
styleUrls: ['./chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
export class PiChartComponent implements AfterViewInit {
readonly type = input<'line' | 'bar'>('line');
readonly datasets = input.required<Array<Dataset>>();
readonly device = input.required<Device>();
readonly xAxisMarks = input<Array<string>>();
readonly isComparativeChart = input<boolean>(true);
private _hoveredDatasetIndex = -1;
set hoveredDatasetIndex(index: number) {
this._hoveredDatasetIndex = index;
}
get hoveredDatasetIndex(): number {
return this._hoveredDatasetIndex;
}
readonly chartRef = viewChild.required('chart', {
read: ElementRef<HTMLCanvasElement>,
});
chart!: Chart;
private readonly translate = inject(TranslateService);
private readonly cdr = inject(ChangeDetectorRef);
get completedDatasets() {
return this.datasets().map((dataset) => {
const datasetWithTranslatedLabels = {
...dataset,
label: this.translate.instant(dataset.label),
};
return {
...datasetWithTranslatedLabels,
...datasetConfig,
};
});
}
ngAfterViewInit(): void {
this.getLineChartConfigs();
}
triggerHover(datasetIndex: number) {
if (datasetIndex === -1 || this.chart.getActiveElements().length > 0) {
this.chart.setActiveElements([]);
} else {
this.chart.setActiveElements([
{
datasetIndex,
index: 0,
},
]);
}
this.chart.update();
}
getLineChartConfigs(): void {
const isComparativeChart = this.isComparativeChart();
this.chart = new Chart(this.chartRef().nativeElement, {
type: 'line',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP' ? true : false,
hover: {
mode: 'dataset',
},
onHover: (e: any, activeElements: any) => {
this.hoveredDatasetIndex =
activeElements.length && activeElements[0].datasetIndex;
this.cdr.detectChanges();
},
plugins: {
legend: {
display: false,
},
tooltip: {
...tooltipConfig,
filter: function (tooltipItem): boolean {
if (isComparativeChart) {
return (
tooltipItem.datasetIndex === 0 || tooltipItem.element.active
);
} else {
return tooltipItem.element.active;
}
},
callbacks: {
labelTextColor: function (context): string {
return context.dataset.borderColor as string;
},
label: function (context) {
const datasetLabel = context.dataset.label ?? '';
const chartLabel =
datasetLabel.length > 12
? datasetLabel.slice(0, 12) + '...'
: datasetLabel;
let label: string;
if ((context.dataset as any).unitPosition === 'before') {
label =
chartLabel +
': ' +
(context.dataset as any).unit +
context.formattedValue;
} else {
label =
chartLabel +
': ' +
context.formattedValue +
(context.dataset as any).unit;
}
return label;
},
title: function () {},
},
},
},
interaction: {
intersect: false,
mode: 'index',
},
scales: scalesConfig,
},
});
}
datasetHoveredInLegend(index: number): void {
this.hoveredDatasetIndex = index;
this.triggerHover(this.hoveredDatasetIndex);
}
}

View File

@@ -0,0 +1,3 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ArcElement,
Chart,
DoughnutController,
Legend,
Tooltip,
} 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',
styleUrls: ['./doughnut-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiDoughnutChartComponent extends PiBaseArcChartComponent {
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'doughnut',
data: this.getArcData<'doughnut'>(),
options: this.getArcOptions<'doughnut'>(),
});
}
}

View File

@@ -0,0 +1,11 @@
<div>
<canvas #chart aria-label="Gauge"></canvas>
<div class="pi-gauge-chart__value">
<span>
<strong>{{ clampedValue() }}</strong><small>/{{ max() }}</small>
</span>
@if (label()) {
<span class="pi-gauge-chart__label">{{ label() }}</span>
}
</div>
</div>

View File

@@ -0,0 +1,35 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
& > div {
position: relative;
}
}
.pi-gauge-chart__value {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
strong {
font-size: var(--pi-font-size-xx-large);
}
small {
font-size: var(--pi-font-size-small);
color: var(--pi-color-grey-300);
}
}
.pi-gauge-chart__label {
margin-top: var(--pi-spacing-xxxx-small);
font-size: var(--pi-font-size-x-small);
color: var(--pi-color-grey-300);
}

View File

@@ -0,0 +1,65 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
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
* `PiBaseCartesianChartComponent`/`PiBaseArcChartComponent`, pensadas para
* varios datasets o varias porciones. El texto central es HTML superpuesto,
* no un plugin de Chart.js: mismo criterio que `pi-line-chart` con
* `hexToRgba` — evitar un plugin nuevo cuando una superposición CSS basta.
*/
@Component({
selector: 'pi-gauge-chart',
templateUrl: './gauge-chart.component.html',
styleUrls: ['./gauge-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiGaugeChartComponent extends PiBaseChartComponent {
readonly value = input.required<number>();
readonly max = input(100);
readonly color = input<DatasetColor>(DatasetColor.primary);
/** Texto bajo el valor central (p. ej. "de satisfacción"). */
readonly label = input<string>();
protected readonly clampedValue = computed(() =>
Math.min(Math.max(this.value(), 0), this.max()),
);
protected buildChart(): Chart {
const clamped = this.clampedValue();
return new Chart(this.chartRef().nativeElement, {
type: 'doughnut',
data: {
datasets: [
{
data: [clamped, this.max() - clamped],
backgroundColor: [this.color(), DatasetColor.grey75],
borderWidth: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
circumference: 180,
rotation: -90,
cutout: '75%',
events: [],
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
},
});
}
}

View File

@@ -1,5 +1,14 @@
export * from './chart/chart.component';
export * from './base-arc-chart.component';
export * from './base-cartesian-chart.component';
export * from './base-chart.component';
export * from './chart.configs';
export * from './chart-legend/chart-legend.component';
export * from './line-chart/line-chart.component';
export * from './bar-chart/bar-chart.component';
export * from './pie-chart/pie-chart.component';
export * from './doughnut-chart/doughnut-chart.component';
export * from './radar-chart/radar-chart.component';
export * from './gauge-chart/gauge-chart.component';

View File

@@ -0,0 +1,9 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>
<pi-chart-legend
[datasets]="datasets()"
[isComparativeChart]="isComparativeChart()"
[hoveredDatasetIndex]="hoveredDatasetIndex()"
(triggerDatasetHover)="datasetHoveredInLegend($event)"
></pi-chart-legend>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
CategoryScale,
Chart,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
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',
styleUrls: ['./line-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
export class PiLineChartComponent extends PiBaseCartesianChartComponent {
/** Rellena el área bajo la línea (gráfico de área) en vez de solo trazarla. */
readonly filled = input(false);
get completedDatasets() {
const datasets = this.translatedDatasets().map((dataset) => ({
...dataset,
...lineDatasetConfig,
}));
if (!this.filled()) {
return datasets;
}
// Área suave en vez de un bloque sólido: la misma marca del dataset,
// pero translúcida. `pointBackgroundColor` conserva el color opaco
// original para que los puntos no salgan huecos por reusar
// `backgroundColor` para el área.
return datasets.map((dataset) => {
const solidColor = (dataset.backgroundColor ??
dataset.borderColor) as string;
return {
...dataset,
fill: true,
backgroundColor: hexToRgba(solidColor, 0.15),
pointBackgroundColor: solidColor,
};
});
}
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'line',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: this.getCartesianOptions('line'),
});
}
}

View File

@@ -0,0 +1,3 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,21 @@
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',
styleUrls: ['./pie-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PiPieChartComponent extends PiBaseArcChartComponent {
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'pie',
data: this.getArcData<'pie'>(),
options: this.getArcOptions<'pie'>(),
});
}
}

View File

@@ -0,0 +1,9 @@
<div>
<canvas #chart aria-label="Chart"></canvas>
</div>
<pi-chart-legend
[datasets]="datasets()"
[isComparativeChart]="isComparativeChart()"
[hoveredDatasetIndex]="hoveredDatasetIndex()"
(triggerDatasetHover)="datasetHoveredInLegend($event)"
></pi-chart-legend>

View File

@@ -0,0 +1,7 @@
@use '../styles/chart-canvas' as canvas;
:host {
@include canvas.chartCanvas;
}
@include canvas.chartCanvasResponsive;

View File

@@ -0,0 +1,106 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
ActiveElement,
Chart,
ChartEvent,
ChartOptions,
Filler,
Legend,
LineElement,
PointElement,
RadarController,
RadialLinearScale,
Tooltip,
} from 'chart.js';
import { Dataset, DatasetColor } from '../../../util/models';
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
* `getCartesianOptions` de `PiBaseCartesianChartComponent` — sí reutiliza el
* resto (hover por dataset, leyenda propia, tooltip).
*/
@Component({
selector: 'pi-radar-chart',
templateUrl: './radar-chart.component.html',
styleUrls: ['./radar-chart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChartLegendComponent],
})
export class PiRadarChartComponent extends PiBaseCartesianChartComponent {
/** Igual que `pi-line-chart[filled]`: relleno translúcido bajo cada polígono. */
protected get completedDatasets() {
return this.translatedDatasets().map((dataset) => {
const solidColor = (dataset.backgroundColor ??
dataset.borderColor) as string;
return {
...dataset,
...lineDatasetConfig,
fill: true,
backgroundColor: hexToRgba(solidColor, 0.15),
pointBackgroundColor: solidColor,
};
});
}
private getRadarOptions(): ChartOptions<'radar'> {
return {
responsive: true,
maintainAspectRatio: this.device() === 'DESKTOP',
onHover: (_event: ChartEvent, activeElements: ActiveElement[]) => {
this.hoveredDatasetIndex.set(
activeElements.length ? activeElements[0].datasetIndex : -1,
);
},
plugins: {
legend: { display: false },
tooltip: {
...tooltipConfig,
callbacks: {
labelTextColor: (context): string =>
context.dataset.borderColor as string,
label: (context): string => {
const dataset = context.dataset as Dataset;
return `${dataset.label}: ${context.formattedValue}${dataset.unit ?? ''}`;
},
},
},
},
scales: {
r: {
grid: { color: DatasetColor.grey50 },
angleLines: { color: DatasetColor.grey75 },
pointLabels: {
font: { family: 'Soehne', size: 12 },
color: DatasetColor.grey300,
},
ticks: { display: false },
},
},
} as ChartOptions<'radar'>;
}
protected buildChart(): Chart {
return new Chart(this.chartRef().nativeElement, {
type: 'radar',
data: {
labels: this.xAxisMarks(),
datasets: this.completedDatasets,
},
options: this.getRadarOptions(),
});
}
}

View File

@@ -0,0 +1,41 @@
/**
* Tamaño del lienzo, compartido por los 4 gráficos (line/bar/pie/doughnut).
* Cada componente lo incluye con `@include chartCanvas;` en su propio
* `:host` — así siguen siendo ficheros independientes sin repetir el CSS.
*/
@mixin chartCanvas {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
max-width: 100%;
& > div {
position: relative;
max-width: 100%;
}
}
@mixin chartCanvasResponsive {
html {
&:not(.is-desktop) {
:host {
& > div {
aspect-ratio: 1;
height: 100%;
max-height: 40vh;
}
}
}
&.is-desktop {
:host {
& > div {
& > canvas {
aspect-ratio: 1;
height: 100%;
}
}
}
}
}
}

View File

@@ -31,7 +31,7 @@ export class PiDataTableItemComponent implements AfterViewInit {
readonly justify = input<justify>('flex-start');
readonly borderType = input<borderType>();
readonly item = contentChild<TemplateRef<any>>('item');
readonly item = contentChild<TemplateRef<unknown>>('item');
readonly componentItem = viewChild<ElementRef>('componentItem');
ngAfterViewInit(): void {

View File

@@ -0,0 +1,115 @@
import { Component, TemplateRef, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core';
import { DrawerConfigKeys } from '../entities/drawer-configs';
import { getDrawerConfigs } from '../utils';
import { PiDrawerComponent } from './drawer.component';
@Component({
template: `<ng-template #content><p class="drawer-content">Hola</p></ng-template>`,
})
class HostComponent {
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
}
describe('PiDrawerComponent', () => {
let hostFixture: ComponentFixture<HostComponent>;
let dialog: MatDialog;
let overlayContainerElement: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
providers: [provideTranslateService()],
}).compileComponents();
hostFixture = TestBed.createComponent(HostComponent);
hostFixture.detectChanges();
dialog = TestBed.inject(MatDialog);
overlayContainerElement = TestBed.inject(
OverlayContainer,
).getContainerElement();
});
afterEach(() => {
dialog.closeAll();
});
it('renders the title and the projected template content', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.textContent).toContain('Detalle');
expect(overlayContainerElement.querySelector('.drawer-content')).toBeTruthy();
});
it('applies the right-side panel class for DrawerConfigKeys.Right', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-drawer__panel--right')).toBe(true);
});
it('applies the left-side panel class for DrawerConfigKeys.Left', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Left, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-drawer__panel--left')).toBe(true);
});
it('omits the title header when none is provided', () => {
dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.querySelector('header span')).toBeNull();
});
it('closes the dialog when the close button is clicked', fakeAsync(() => {
const dialogRef = dialog.open(
PiDrawerComponent,
getDrawerConfigs(DrawerConfigKeys.Right, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const closeSpy = jest.fn();
dialogRef.afterClosed().subscribe(closeSpy);
const closeButton: HTMLElement | null =
overlayContainerElement.querySelector('pi-close-button label');
closeButton?.click();
hostFixture.detectChanges();
tick(500);
expect(closeSpy).toHaveBeenCalled();
}));
});

View File

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

View File

@@ -0,0 +1,6 @@
<pi-icon [icon]="icon()" size="x-large" color="grey" />
<h4>{{ title() }}</h4>
@if (description()) {
<p>{{ description() }}</p>
}
<ng-content />

View File

@@ -0,0 +1,23 @@
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: var(--pi-spacing-xx-small);
padding: var(--pi-spacing-xx-large) var(--pi-spacing-large);
h4 {
margin: var(--pi-spacing-xx-small) 0 0;
color: var(--pi-color-black);
font-size: var(--pi-font-size-small);
font-weight: 600;
}
p {
margin: 0;
color: var(--pi-color-grey);
font-size: var(--pi-font-size-x-small);
max-width: 30ch;
}
}

View File

@@ -0,0 +1,56 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { PiEmptyStateComponent } from './empty-state.component';
@Component({
template: `
<pi-empty-state [title]="title" [description]="description">
<button class="projected-action">Limpiar filtros</button>
</pi-empty-state>
`,
imports: [PiEmptyStateComponent],
})
class HostComponent {
title = 'Sin resultados';
description?: string = 'No hay elementos que coincidan con el filtro.';
}
describe('PiEmptyStateComponent', () => {
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
});
it('renders the title and description', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h4')?.textContent?.trim()).toBe(
'Sin resultados',
);
expect(fixture.nativeElement.querySelector('p')?.textContent?.trim()).toBe(
'No hay elementos que coincidan con el filtro.',
);
});
it('omits the description paragraph when none is provided', () => {
fixture.componentInstance.description = undefined;
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('p')).toBeNull();
});
it('renders the projected action', () => {
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.projected-action')?.textContent?.trim(),
).toBe('Limpiar filtros');
});
});

View File

@@ -0,0 +1,27 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
input,
} from '@angular/core';
import { PiIconComponent } from '../../icon/icon/icon.component';
/**
* Placeholder para listas/tablas/data-grid sin resultados. El icono, título y
* descripción son fijos; la acción (p. ej. "Limpiar filtros") se proyecta.
*/
@Component({
selector: 'pi-empty-state',
templateUrl: './empty-state.component.html',
styleUrls: ['./empty-state.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent],
})
export class PiEmptyStateComponent {
@HostBinding('class') className = 'pi-empty-state';
readonly icon = input('search');
readonly title = input.required<string>();
readonly description = input<string>();
}

View File

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

View File

@@ -0,0 +1,50 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<input
matInput
role="combobox"
aria-autocomplete="list"
[attr.aria-expanded]="isOpen()"
[attr.aria-controls]="listboxId"
[attr.aria-activedescendant]="
activeIndex() > -1 ? listboxId + '-option-' + activeIndex() : null
"
[value]="value()"
(input)="onInput($any($event.target).value)"
(keydown)="onKeydown($event)"
(focus)="onFocus()"
(blur)="onBlur()"
[placeholder]="placeholder()!"
[disabled]="disabled()"
/>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}
</mat-form-field>
@if (isOpen()) {
<ul class="pi-autocomplete-field__panel" [id]="listboxId" role="listbox">
@for (option of filteredOptions(); track option; let i = $index) {
<li
[id]="listboxId + '-option-' + i"
role="option"
tabindex="-1"
[attr.aria-selected]="i === activeIndex()"
[class.is-active]="i === activeIndex()"
(mousedown)="$event.preventDefault()"
(click)="selectOption(option)"
(keydown.enter)="selectOption(option)"
>
{{ option }}
</li>
} @empty {
<li class="pi-autocomplete-field__empty">{{ noResultsLabel() | translate }}</li>
}
</ul>
}

View File

@@ -0,0 +1,40 @@
:host {
position: relative;
display: block;
}
.pi-autocomplete-field__panel {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 20;
margin: var(--pi-spacing-xxxx-small) 0 0;
padding: var(--pi-spacing-xxxx-small) 0;
max-height: 14rem;
overflow-y: auto;
list-style: none;
background: var(--pi-color-white);
border-radius: var(--pi-border-radius-medium);
box-shadow: var(--pi-box-shadow-panel);
li {
padding: var(--pi-spacing-small) var(--pi-spacing-medium);
font-size: var(--pi-font-size-small);
cursor: pointer;
&:hover,
&.is-active {
background: var(--pi-color-primary-25);
}
}
}
.pi-autocomplete-field__empty {
color: var(--pi-color-grey-300);
cursor: default;
&:hover {
background: transparent !important;
}
}

View File

@@ -0,0 +1,126 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostBinding,
HostListener,
computed,
inject,
input,
signal,
} from '@angular/core';
import {
MatFormField,
MatLabel,
MatError,
} from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
/**
* Campo de texto con sugerencias filtradas mientras se escribe. El menú es
* un `<ul>` propio con `position: absolute` (mismo criterio que el menú de
* `pi-select`) en vez de `mat-autocomplete`/CDK Overlay: no hace falta
* escapar contenedores con `overflow: hidden` para este caso de uso.
*/
@Component({
selector: 'pi-autocomplete-field',
templateUrl: './autocomplete-field.component.html',
styleUrls: ['./autocomplete-field.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatFormField, MatLabel, MatInput, MatError, TranslatePipe],
})
export class PiAutocompleteFieldComponent extends PiBaseFormFieldComponent<string> {
@HostBinding('class') className = 'pi-form-field__autocomplete';
private static nextId = 0;
readonly options = input.required<readonly string[]>();
readonly noResultsLabel = input('Sin resultados');
protected readonly listboxId = `pi-autocomplete-listbox-${PiAutocompleteFieldComponent.nextId++}`;
protected readonly isOpen = signal(false);
protected readonly activeIndex = signal(-1);
private readonly el = inject(ElementRef);
protected readonly filteredOptions = computed(() => {
const query = this.value().trim().toLowerCase();
if (!query) return this.options();
return this.options().filter((option) =>
option.toLowerCase().includes(query),
);
});
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
if (!this.el.nativeElement.contains(event.target)) {
this.close();
}
}
protected onInput(text: string): void {
this.value.set(text);
this.activeIndex.set(-1);
this.isOpen.set(true);
}
protected onKeydown(event: KeyboardEvent): void {
if (
!this.isOpen() &&
(event.key === 'ArrowDown' || event.key === 'ArrowUp')
) {
this.isOpen.set(true);
return;
}
const options = this.filteredOptions();
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.activeIndex.update((index) =>
Math.min(index + 1, options.length - 1),
);
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex.update((index) => Math.max(index - 1, 0));
break;
case 'Enter': {
const active = options[this.activeIndex()];
if (active) {
event.preventDefault();
this.selectOption(active);
}
break;
}
case 'Escape':
this.close();
break;
}
}
protected selectOption(option: string): void {
this.value.set(option);
this.touch.emit();
this.close();
}
override onFocus(): void {
super.onFocus();
this.isOpen.set(true);
}
override onBlur(): void {
super.onBlur();
this.close();
}
private close(): void {
this.isOpen.set(false);
this.activeIndex.set(-1);
}
}

View File

@@ -0,0 +1,36 @@
<mat-form-field [appearance]="appearance()" floatLabel="always">
<mat-label><ng-content select="pi-label"></ng-content></mat-label>
<mat-date-range-input
[rangePicker]="picker"
[min]="min() ?? null"
[max]="max() ?? null"
[disabled]="disabled()"
>
<input
matStartDate
[value]="value().start"
[placeholder]="startPlaceholder() | translate"
(dateChange)="onStartChange($any($event).value)"
(focus)="onFocus()"
/>
<input
matEndDate
[value]="value().end"
[placeholder]="endPlaceholder() | translate"
(dateChange)="onEndChange($any($event).value)"
(blur)="onBlur()"
/>
</mat-date-range-input>
<mat-datepicker-toggle matSuffix [for]="picker" />
<mat-date-range-picker #picker />
</mat-form-field>
@if (enableErrorMessage() && hasError()) {
<mat-error>
@for (message of errorMessages(); track message) {
{{ message | translate }}
}
</mat-error>
}

View File

@@ -0,0 +1,67 @@
import { ChangeDetectionStrategy, Component, HostBinding, input } from '@angular/core';
import {
MatFormField,
MatLabel,
MatSuffix,
MatError,
} from '@angular/material/form-field';
import {
MatDateRangeInput,
MatStartDate,
MatEndDate,
MatDateRangePicker,
MatDatepickerToggle,
} from '@angular/material/datepicker';
import { provideNativeDateAdapter } from '@angular/material/core';
import { TranslatePipe } from '@ngx-translate/core';
import { PiBaseFormFieldComponent } from '../base-form-field.component';
export interface DateRange {
start: Date | null;
end: Date | null;
}
/**
* Rango de fechas, hermano de `pi-date-field`: mismo `provideNativeDateAdapter`
* (el proyecto no tiene adaptador de luxon/moment) y misma herencia de
* `PiBaseFormFieldComponent`, pero con `value` como `{ start, end }` en vez de
* `Date | null` — `mat-date-range-input` necesita dos `<input>` (`matStartDate`
* / `matEndDate`), no uno.
*/
@Component({
selector: 'pi-date-range-field',
templateUrl: './date-range-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideNativeDateAdapter()],
imports: [
MatFormField,
MatLabel,
MatSuffix,
MatDateRangeInput,
MatStartDate,
MatEndDate,
MatDateRangePicker,
MatDatepickerToggle,
MatError,
TranslatePipe,
],
})
export class PiDateRangeFieldComponent extends PiBaseFormFieldComponent<DateRange> {
@HostBinding('class') className = 'pi-form-field__date-range';
readonly min = input<Date | undefined>(undefined);
readonly max = input<Date | undefined>(undefined);
readonly startPlaceholder = input('Desde');
readonly endPlaceholder = input('Hasta');
protected onStartChange(start: Date | null): void {
this.value.set({ ...this.value(), start });
}
protected onEndChange(end: Date | null): void {
this.value.set({ ...this.value(), end });
this.touch.emit();
}
}

View File

@@ -1,4 +1,6 @@
export * from './date-field/date-field.component';
export * from './date-range-field/date-range-field.component';
export * from './autocomplete-field/autocomplete-field.component';
export * from './checkbox/checkbox.component';
export * from './radio-group/radio-group.component';
export * from './directives/label.directive';

View File

@@ -0,0 +1,43 @@
import { MatDialogConfig } from '@angular/material/dialog';
export enum HubConfigKeys {
Default = 'default',
Full = 'full',
}
export type HubConfigs = {
[key in HubConfigKeys]: MatDialogConfig;
};
/**
* `enterAnimationDuration`/`exitAnimationDuration` a `0ms`: igual que
* `pi-drawer`, la animación de `MatDialog` (fade + scale desde el centro) no
* encaja con un panel anclado a un borde. El deslizamiento real lo hace
* `_mat-hub.scss` sobre `panelClass`, fuera del ciclo de animación de
* Material. Solo se fija `position.bottom`: al dejar `left`/`right` sin
* definir, el propio `cdk-global-overlay-wrapper` centra el panel en
* horizontal por defecto.
*/
export const hubConfigs: HubConfigs = {
[HubConfigKeys.Default]: {
width: '100vw',
maxWidth: '34rem',
maxHeight: '80vh',
position: { bottom: '0' },
backdropClass: 'pi-backdrop',
panelClass: ['pi-hub__panel'],
enterAnimationDuration: '0ms',
exitAnimationDuration: '0ms',
},
[HubConfigKeys.Full]: {
width: '100vw',
maxWidth: '34rem',
height: '92vh',
maxHeight: '92vh',
position: { bottom: '0' },
backdropClass: 'pi-backdrop',
panelClass: ['pi-hub__panel', 'pi-hub__panel--full'],
enterAnimationDuration: '0ms',
exitAnimationDuration: '0ms',
},
};

View File

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

View File

@@ -0,0 +1,12 @@
<div class="pi-hub__grabber" aria-hidden="true"></div>
<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,30 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
.pi-hub__grabber {
flex: none;
width: 2.5rem;
height: 0.25rem;
margin: var(--pi-spacing-small) auto 0;
border-radius: var(--pi-border-radius-x-small);
background-color: var(--pi-color-grey-100);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pi-spacing-medium);
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-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,115 @@
import { Component, TemplateRef, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
import { MatDialog } from '@angular/material/dialog';
import { provideTranslateService } from '@ngx-translate/core';
import { HubConfigKeys } from '../entities/hub-configs';
import { getHubConfigs } from '../utils';
import { PiHubComponent } from './hub.component';
@Component({
template: `<ng-template #content><p class="hub-content">Hola</p></ng-template>`,
})
class HostComponent {
@ViewChild('content', { static: true }) content!: TemplateRef<unknown>;
}
describe('PiHubComponent', () => {
let hostFixture: ComponentFixture<HostComponent>;
let dialog: MatDialog;
let overlayContainerElement: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
providers: [provideTranslateService()],
}).compileComponents();
hostFixture = TestBed.createComponent(HostComponent);
hostFixture.detectChanges();
dialog = TestBed.inject(MatDialog);
overlayContainerElement = TestBed.inject(
OverlayContainer,
).getContainerElement();
});
afterEach(() => {
dialog.closeAll();
});
it('renders the title and the projected template content', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.textContent).toContain('Detalle');
expect(overlayContainerElement.querySelector('.hub-content')).toBeTruthy();
});
it('applies the panel class for HubConfigKeys.Default', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-hub__panel')).toBe(true);
});
it('applies the full panel class for HubConfigKeys.Full', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Full, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
expect(pane?.classList.contains('pi-hub__panel--full')).toBe(true);
});
it('omits the title header when none is provided', () => {
dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
expect(overlayContainerElement.querySelector('header span')).toBeNull();
});
it('closes the dialog when the close button is clicked', fakeAsync(() => {
const dialogRef = dialog.open(
PiHubComponent,
getHubConfigs(HubConfigKeys.Default, {
title: 'Detalle',
template: hostFixture.componentInstance.content,
}),
);
hostFixture.detectChanges();
const closeSpy = jest.fn();
dialogRef.afterClosed().subscribe(closeSpy);
const closeButton: HTMLElement | null =
overlayContainerElement.querySelector('pi-close-button label');
closeButton?.click();
hostFixture.detectChanges();
tick(500);
expect(closeSpy).toHaveBeenCalled();
}));
});

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 { HubData } from '../entities/hub.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-hub',
templateUrl: './hub.component.html',
styleUrls: ['./hub.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NgTemplateOutlet,
PiCloseButtonComponent,
PiTextWeightBoldDirective,
PiTextLargeDirective,
],
})
export class PiHubComponent {
@HostBinding('class') className = 'pi-hub';
readonly dialogRef = inject<MatDialogRef<PiHubComponent>>(MatDialogRef);
readonly data = inject<HubData>(MAT_DIALOG_DATA);
close(): void {
this.dialogRef.close();
}
}

View File

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

View File

@@ -0,0 +1,16 @@
import { MatDialogConfig } from '@angular/material/dialog';
import { HubConfigKeys, hubConfigs } from './entities/hub-configs';
import { HubData } from './entities/hub.model';
// Hub config
export function getHubConfigs(
key: HubConfigKeys,
data: HubData,
disableClose?: boolean,
): MatDialogConfig<HubData> {
return {
...(disableClose && { disableClose }),
...hubConfigs[key],
data,
};
}

View File

@@ -1,5 +1,5 @@
@if (type() === 'custom') {
<img src="/assets/icons/{{ icon() }}.svg" />
} @else {
<mat-icon size="size" [svgIcon]="icon()!" color="color"></mat-icon>
<mat-icon [svgIcon]="icon()!"></mat-icon>
}

View File

@@ -27,9 +27,9 @@
}
}
@mixin defaultIconColor {
@mixin defaultIconColor($color) {
.mat-icon {
color: var(--pi-color-primary);
color: $color;
}
}
@@ -64,6 +64,26 @@
&.pi-icon__x-large {
@include defaultIconSize(var(--pi-spacing-xx-large));
}
// Color (la apariencia "fill" tiene la suya, más abajo)
&.pi-color__primary {
@include defaultIconColor(var(--pi-color-primary));
}
&.pi-color__accent {
@include defaultIconColor(var(--pi-color-accent));
}
&.pi-color__warn {
@include defaultIconColor(var(--pi-color-warn));
}
&.pi-color__highlighted {
@include defaultIconColor(var(--pi-color-highlighted));
}
&.pi-color__white {
@include defaultIconColor(var(--pi-color-white));
}
&.pi-color__grey {
@include defaultIconColor(var(--pi-color-grey));
}
}
&.pi-icon__appearance--fill {

View File

@@ -40,7 +40,7 @@ export class PiIconComponent {
const borderRadiusValue = this.borderRadius();
const borderRadiusClass =
borderRadiusValue && `pi-icon__border-radius--${borderRadiusValue}`;
const strokeClass = this.isStroked() === true && 'is-stroked';
const strokeClass = this.isStroked() ? 'is-stroked' : '';
return [
'pi-icon',
ComponentFamilyClass.Text,

View File

@@ -19,7 +19,7 @@ interface ModalData {
export interface DialogModalData extends ModalData {
type: ModalType.DIALOG;
pathImg: string;
template?: TemplateRef<any>;
template?: TemplateRef<unknown>;
description?: string;
label?: string;
buttonText: {
@@ -31,5 +31,5 @@ export interface DialogModalData extends ModalData {
export interface CustomModalData extends ModalData {
type: ModalType.CUSTOM;
template: TemplateRef<any>;
template: TemplateRef<unknown>;
}

View File

@@ -17,7 +17,7 @@
align-items: center;
gap: var(--pi-spacing-x-small);
margin: auto;
border-radius: var(--pi-border-radius-large);
border-radius: var(--pi-theme-notification-radius, var(--pi-border-radius-large));
text-align: center;
div {
display: flex;

View File

@@ -69,8 +69,8 @@ export abstract class PiBasePanelComponent {
return `pi-color__${this.color()}`;
}
protected get hasShadowClass(): string | boolean {
return this.hasShadow() && 'has-shadow';
protected get hasShadowClass(): string {
return this.hasShadow() ? 'has-shadow' : '';
}
protected get heightClass(): string | undefined {

View File

@@ -0,0 +1,233 @@
import { Component } from '@angular/core';
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing';
import { OverlayContainer } from '@angular/cdk/overlay';
import {
PiPopoverDirective,
PopoverPosition,
PopoverTrigger,
} from './popover.directive';
@Component({
template: `
<button
pi-popover
[popoverContent]="content"
[popoverPosition]="position"
[popoverTrigger]="trigger"
(opened)="opened()"
(closed)="closed()"
>
Trigger
</button>
<ng-template #content let-close="close">
<p class="popover-content">Hola</p>
<button class="popover-close" (click)="close()">Cerrar</button>
</ng-template>
`,
imports: [PiPopoverDirective],
})
class HostComponent {
position: PopoverPosition = 'below';
trigger: PopoverTrigger = 'click';
opened = jest.fn();
closed = jest.fn();
}
describe('PiPopoverDirective', () => {
let overlayContainerElement: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
overlayContainerElement = TestBed.inject(
OverlayContainer,
).getContainerElement();
});
afterEach(() => {
TestBed.inject(OverlayContainer).ngOnDestroy();
});
/**
* El `trigger` se fija ANTES del primer `detectChanges()`, nunca
* reasignado después: en este entorno de test, mutar un campo del host y
* volver a llamar a `detectChanges()` una segunda vez no propaga de forma
* fiable un signal input (comprobado con un repro mínimo ajeno a este
* directive). Cada configuración distinta usa su propio fixture desde el
* principio.
*/
function createFixture(
overrides: Partial<Pick<HostComponent, 'trigger' | 'position'>> = {},
): ComponentFixture<HostComponent> {
const fixture = TestBed.createComponent(HostComponent);
Object.assign(fixture.componentInstance, overrides);
fixture.detectChanges();
return fixture;
}
function triggerEl(fixture: ComponentFixture<HostComponent>): HTMLButtonElement {
return fixture.nativeElement.querySelector('button');
}
function popoverContent(): HTMLElement | null {
return overlayContainerElement.querySelector('.popover-content');
}
describe('click trigger (default)', () => {
it('is closed initially', () => {
createFixture();
expect(popoverContent()).toBeNull();
});
it('opens on click and emits `opened`', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
expect(fixture.componentInstance.opened).toHaveBeenCalledTimes(1);
});
it('toggles closed on a second click and emits `closed`', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
triggerEl(fixture).click();
fixture.detectChanges();
expect(popoverContent()).toBeNull();
expect(fixture.componentInstance.closed).toHaveBeenCalledTimes(1);
});
it('closes when the backdrop is clicked', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
const backdrop: HTMLElement | null =
overlayContainerElement.parentElement?.querySelector(
'.cdk-overlay-backdrop',
) ?? null;
backdrop?.click();
fixture.detectChanges();
expect(popoverContent()).toBeNull();
});
it('closes when Escape is pressed', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
const pane = overlayContainerElement.querySelector('.cdk-overlay-pane');
pane?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
fixture.detectChanges();
expect(popoverContent()).toBeNull();
});
it('closes via the `close` function exposed in the template context', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
const closeButton: HTMLElement | null =
overlayContainerElement.querySelector('.popover-close');
closeButton?.click();
fixture.detectChanges();
expect(popoverContent()).toBeNull();
});
it('disposes the overlay when the host is destroyed', () => {
const fixture = createFixture();
triggerEl(fixture).click();
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
fixture.destroy();
expect(popoverContent()).toBeNull();
});
});
describe('hover trigger', () => {
it('opens on mouseenter without a backdrop', () => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
expect(
overlayContainerElement.parentElement?.querySelector(
'.cdk-overlay-backdrop',
),
).toBeNull();
});
it('does not close immediately on mouseleave, only after the debounce', fakeAsync(() => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
triggerEl(fixture).dispatchEvent(new Event('mouseleave'));
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
tick(100);
fixture.detectChanges();
expect(popoverContent()).toBeNull();
}));
it('cancels the scheduled close if the mouse re-enters before the debounce elapses', fakeAsync(() => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
triggerEl(fixture).dispatchEvent(new Event('mouseleave'));
tick(50);
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
tick(100);
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
}));
it('stays open while the mouse is over the panel itself', fakeAsync(() => {
const fixture = createFixture({ trigger: 'hover' });
triggerEl(fixture).dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
triggerEl(fixture).dispatchEvent(new Event('mouseleave'));
const pane = overlayContainerElement.querySelector(
'.cdk-overlay-pane',
) as HTMLElement;
pane.dispatchEvent(new Event('mouseenter'));
tick(100);
fixture.detectChanges();
expect(popoverContent()).toBeTruthy();
}));
});
});

View File

@@ -1,3 +1,5 @@
export * from './loader-bar/loader-bar.component';
export * from './progress-bar/progress-bar.component';
export * from './progress-spinner/progress-spinner.component';

View File

@@ -0,0 +1,3 @@
@if (isLoading()) {
<mat-progress-bar mode="indeterminate" />
}

View File

@@ -0,0 +1,12 @@
:host {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1100; // por encima de mat-dialog/mat-menu (1000, ver overlay-prebuilt.css)
pointer-events: none;
mat-progress-bar {
height: 0.1875rem;
}
}

View File

@@ -0,0 +1,57 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoaderFacade } from '../../../util/loaders';
import { PiLoaderBarComponent } from './loader-bar.component';
describe('PiLoaderBarComponent', () => {
let fixture: ComponentFixture<PiLoaderBarComponent>;
let loaderFacade: LoaderFacade;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiLoaderBarComponent],
}).compileComponents();
fixture = TestBed.createComponent(PiLoaderBarComponent);
loaderFacade = TestBed.inject(LoaderFacade);
});
function progressBar(): HTMLElement | null {
return fixture.nativeElement.querySelector('mat-progress-bar');
}
it('does not render the bar while the loader is idle', () => {
fixture.detectChanges();
expect(progressBar()).toBeNull();
});
it('renders the bar as soon as LoaderFacade.showLoader() is called', () => {
fixture.detectChanges();
loaderFacade.showLoader();
fixture.detectChanges();
expect(progressBar()).toBeTruthy();
expect(progressBar()?.getAttribute('mode')).toBe('indeterminate');
});
it('hides the bar again once LoaderFacade.hideLoader() is called', () => {
fixture.detectChanges();
loaderFacade.showLoader();
fixture.detectChanges();
loaderFacade.hideLoader();
fixture.detectChanges();
expect(progressBar()).toBeNull();
});
it('reflects the loader state even for instances created after showLoader()', () => {
loaderFacade.showLoader();
fixture.detectChanges();
expect(progressBar()).toBeTruthy();
});
});

View File

@@ -0,0 +1,33 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
inject,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatProgressBar } from '@angular/material/progress-bar';
import { LoaderFacade } from '../../../util/loaders';
/**
* Barra de carga global: se muestra/oculta sola siguiendo `LoaderFacade`, sin
* inputs. El host la ancla arriba del todo (`position: fixed`); la app solo
* tiene que ponerla una vez en el shell y llamar a `showLoader()`/
* `hideLoader()` donde corresponda (p. ej. un interceptor HTTP).
*/
@Component({
selector: 'pi-loader-bar',
templateUrl: './loader-bar.component.html',
styleUrls: ['./loader-bar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatProgressBar],
})
export class PiLoaderBarComponent {
@HostBinding('class') className = 'pi-loader-bar';
private readonly loaderFacade = inject(LoaderFacade);
protected readonly isLoading = toSignal(this.loaderFacade.loader$, {
initialValue: false,
});
}

View File

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

View File

@@ -0,0 +1,25 @@
@for (star of stars(); track star) {
<button
type="button"
class="pi-rating__star pi-button__trimmed"
[attr.aria-label]="'Valorar ' + star + ' de ' + stars().length"
[attr.aria-pressed]="star <= value()"
[disabled]="!isInteractive()"
(mouseenter)="onEnter(star)"
(mouseleave)="onLeave()"
(click)="onClick(star)"
>
@if (starTemplate(); as template) {
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="starContext(star)"
/>
} @else {
<pi-icon
[icon]="isFilled(star) ? 'star-fill' : 'star-outline'"
[size]="size()"
[color]="color()"
/>
}
</button>
}

View File

@@ -0,0 +1,35 @@
:host {
display: inline-flex;
gap: var(--pi-spacing-xxx-small);
.pi-rating__star {
display: flex;
// El reset global de botones (`_pi-button.scss`) pone `min-width: 5.5rem`
// y `height: var(--pi-theme-button-height)` en cualquier `<button>` sin
// clase `mat-*`; sin este override cada estrella se infla a 88px.
min-width: 0;
height: auto;
padding: 0;
border: none;
background: none;
line-height: 0;
cursor: pointer;
&:disabled {
cursor: default;
}
}
&.pi-rating__readonly .pi-rating__star {
pointer-events: none;
}
&.is-disabled {
opacity: 0.5;
.pi-rating__star {
cursor: not-allowed;
pointer-events: none;
}
}
}

View File

@@ -0,0 +1,175 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { By } from '@angular/platform-browser';
import { PiIconComponent } from '../../icon/icon/icon.component';
import { PiRatingComponent } from './rating.component';
describe('PiRatingComponent', () => {
let fixture: ComponentFixture<PiRatingComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PiRatingComponent, MatIconTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(PiRatingComponent);
});
function starButtons(): HTMLButtonElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('button'));
}
function iconNames(): string[] {
return fixture.debugElement
.queryAll(By.directive(PiIconComponent))
.map((debugEl) => debugEl.componentInstance.icon());
}
it('renders `max` stars (5 by default)', () => {
fixture.detectChanges();
expect(starButtons().length).toBe(5);
});
it('renders a custom number of stars via `max`', () => {
fixture.componentRef.setInput('max', 3);
fixture.detectChanges();
expect(starButtons().length).toBe(3);
});
it('fills stars up to the current value', () => {
fixture.componentRef.setInput('value', 3);
fixture.detectChanges();
expect(iconNames()).toEqual([
'star-fill',
'star-fill',
'star-fill',
'star-outline',
'star-outline',
]);
});
it('sets the value on click', () => {
fixture.detectChanges();
starButtons()[3].click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe(4);
expect(iconNames()).toEqual([
'star-fill',
'star-fill',
'star-fill',
'star-fill',
'star-outline',
]);
});
it('previews the hovered value without committing it until clicked', () => {
fixture.componentRef.setInput('value', 1);
fixture.detectChanges();
starButtons()[3].dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(iconNames().filter((n) => n === 'star-fill').length).toBe(4);
expect(fixture.componentInstance.value()).toBe(1);
starButtons()[3].dispatchEvent(new Event('mouseleave'));
fixture.detectChanges();
expect(iconNames().filter((n) => n === 'star-fill').length).toBe(1);
});
it('ignores clicks and hover when readonly is true', () => {
fixture.componentRef.setInput('value', 2);
fixture.componentRef.setInput('readonly', true);
fixture.detectChanges();
const buttons = starButtons();
expect(buttons.every((button) => button.disabled)).toBe(true);
buttons[4].dispatchEvent(new Event('mouseenter'));
fixture.detectChanges();
expect(iconNames().filter((n) => n === 'star-fill').length).toBe(2);
buttons[4].click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe(2);
});
it('ignores clicks and hover when disabled is true', () => {
fixture.componentRef.setInput('value', 1);
fixture.componentRef.setInput('disabled', true);
fixture.detectChanges();
const buttons = starButtons();
expect(buttons.every((button) => button.disabled)).toBe(true);
buttons[4].click();
fixture.detectChanges();
expect(fixture.componentInstance.value()).toBe(1);
});
it('emits touch when a click changes the value', () => {
const touchSpy = jest.fn();
fixture.componentInstance.touch.subscribe(touchSpy);
fixture.detectChanges();
starButtons()[2].click();
fixture.detectChanges();
expect(touchSpy).toHaveBeenCalledTimes(1);
});
it('does not emit touch when readonly blocks the click', () => {
const touchSpy = jest.fn();
fixture.componentInstance.touch.subscribe(touchSpy);
fixture.componentRef.setInput('readonly', true);
fixture.detectChanges();
starButtons()[2].click();
fixture.detectChanges();
expect(touchSpy).not.toHaveBeenCalled();
});
});
describe('PiRatingComponent with a custom starTemplate', () => {
@Component({
template: `
<pi-rating [value]="3" [starTemplate]="star" />
<ng-template #star let-index let-filled="filled">
<span class="custom-star">{{ index }}:{{ filled }}</span>
</ng-template>
`,
imports: [PiRatingComponent],
})
class HostComponent {}
let fixture: ComponentFixture<HostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent],
}).compileComponents();
fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
});
function customStars(): HTMLElement[] {
return Array.from(fixture.nativeElement.querySelectorAll('.custom-star'));
}
it('renders the custom template instead of pi-icon', () => {
expect(fixture.nativeElement.querySelector('pi-icon')).toBeNull();
expect(customStars().length).toBe(5);
});
it('passes the star index and filled state to the template context', () => {
const texts = customStars().map((el) => el.textContent?.trim());
expect(texts).toEqual(['1:true', '2:true', '3:true', '4:false', '5:false']);
});
});

View File

@@ -0,0 +1,110 @@
import {
ChangeDetectionStrategy,
Component,
HostBinding,
TemplateRef,
computed,
input,
model,
output,
signal,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { FormValueControl, ValidationError } from '@angular/forms/signals';
// Shared
import { iconSize, theming } from '../../../styles';
import { PiIconComponent } from '../../icon/icon/icon.component';
/** Contexto que recibe la plantilla pasada en `starTemplate`. */
export interface RatingStarContext {
/** Permite `let-star` además de `let-star="index"`. */
$implicit: number;
index: number;
filled: boolean;
}
/**
* Implementa `FormValueControl<number>`: basta con enlazar `[formField]` en
* el consumidor (como en `pi-slide-toggle`) y la directiva `FormField`
* mantiene `value` sincronizado con el `FieldTree`, además de rellenar
* `disabled`/`readonly`/`invalid`/`touched`/`required`/`name`/`errors`.
*/
@Component({
selector: 'pi-rating',
templateUrl: './rating.component.html',
styleUrls: ['./rating.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiIconComponent, NgTemplateOutlet],
})
export class PiRatingComponent implements FormValueControl<number> {
@HostBinding('class') get classes(): string {
return [
'pi-rating',
this.readonly() ? 'pi-rating__readonly' : '',
this.disabled() ? 'is-disabled' : '',
].join(' ');
}
readonly size = input<iconSize>('medium');
readonly color = input<theming>('accent');
/** Sustituye el icono por defecto (p. ej. corazones en vez de estrellas). */
readonly starTemplate = input<TemplateRef<RatingStarContext>>();
/* --- Contrato FormValueControl ---------------------------------------- */
/** Valoración actual, entera (sin medias estrellas). */
readonly value = model(0);
readonly disabled = input(false);
readonly readonly = input(false);
readonly invalid = input(false);
readonly touched = input(false);
readonly required = input(false);
readonly name = input('');
readonly errors = input<readonly ValidationError.WithOptionalFieldTree[]>(
[],
);
/**
* Número de estrellas (también el valor máximo válido): mismo campo que
* espera `FormUiControl.max` para enlazar un validador `max()`, no hace
* falta una segunda entrada.
*/
readonly max = input<number | undefined>(5);
/** Se emite tras cada clic que cambia el valor (no hay blur en un rating). */
readonly touch = output<void>();
/* --- Interacción -------------------------------------------------------- */
protected readonly isInteractive = computed(
() => !this.readonly() && !this.disabled(),
);
protected readonly stars = computed(() =>
Array.from({ length: this.max() ?? 5 }, (_, i) => i + 1),
);
private readonly hoverValue = signal<number | null>(null);
protected isFilled(star: number): boolean {
return star <= (this.hoverValue() ?? this.value());
}
protected starContext(star: number): RatingStarContext {
return { $implicit: star, index: star, filled: this.isFilled(star) };
}
protected onEnter(star: number): void {
if (this.isInteractive()) this.hoverValue.set(star);
}
protected onLeave(): void {
this.hoverValue.set(null);
}
protected onClick(star: number): void {
if (!this.isInteractive()) return;
this.value.set(star);
this.touch.emit();
}
}

View File

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

View File

@@ -0,0 +1,11 @@
@for (option of options(); track option.value) {
<button
type="button"
role="radio"
[attr.aria-checked]="value() === option.value"
[class.is-active]="value() === option.value"
(click)="select(option)"
>
{{ option.label }}
</button>
}

Some files were not shown because too many files have changed in this diff Show More