new components
This commit is contained in:
@@ -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
247
libs/ui/TOKENS.md
Normal 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
BIN
libs/ui/TOKENS.pdf
Normal file
Binary file not shown.
706
libs/ui/docs/theming.html
Normal file
706
libs/ui/docs/theming.html
Normal 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><body></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"><body class="<span class="t">pi-shell pi-shell__<nombre-del-tema></span>"></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__<nombre></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, <valor-original>)</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><mat-form-field appearance="outline"></code>
|
||||
no es un <code>border</code> normal — es tres <code><div></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><html></code> (no al <code><body></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__<nombre></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.
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal file
27
libs/ui/src/lib/styles/common/vendors/_mat-hub.scss
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal file
81
libs/ui/src/lib/ui/avatar/avatar/avatar.component.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1 +1,2 @@
|
||||
export * from './avatar/avatar.component';
|
||||
export * from './avatar-group/avatar-group.component';
|
||||
|
||||
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal file
87
libs/ui/src/lib/ui/badge/badge/badge.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
1
libs/ui/src/lib/ui/breadcrumb/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './breadcrumb/breadcrumb.component';
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
<pi-chart-legend
|
||||
[datasets]="datasets()"
|
||||
[isComparativeChart]="isComparativeChart()"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex"
|
||||
[hoveredDatasetIndex]="hoveredDatasetIndex()"
|
||||
(triggerDatasetHover)="datasetHoveredInLegend($event)"
|
||||
></pi-chart-legend>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal file
42
libs/ui/src/lib/ui/chart/bar-chart/bar-chart.component.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal file
72
libs/ui/src/lib/ui/chart/base-arc-chart.component.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal file
113
libs/ui/src/lib/ui/chart/base-cartesian-chart.component.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal file
39
libs/ui/src/lib/ui/chart/base-chart.component.ts
Normal 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;
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
@@ -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'>(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal file
73
libs/ui/src/lib/ui/chart/line-chart/line-chart.component.ts
Normal 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'),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
<canvas #chart aria-label="Chart"></canvas>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal file
21
libs/ui/src/lib/ui/chart/pie-chart/pie-chart.component.ts
Normal 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'>(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '../styles/chart-canvas' as canvas;
|
||||
|
||||
:host {
|
||||
@include canvas.chartCanvas;
|
||||
}
|
||||
|
||||
@include canvas.chartCanvasResponsive;
|
||||
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal file
106
libs/ui/src/lib/ui/chart/radar-chart/radar-chart.component.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal file
41
libs/ui/src/lib/ui/chart/styles/_chart-canvas.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/drawer/drawer/drawer.component.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
@@ -2,5 +2,5 @@ import { TemplateRef } from '@angular/core';
|
||||
|
||||
export interface DrawerData {
|
||||
title?: string;
|
||||
template: TemplateRef<any>;
|
||||
template: TemplateRef<unknown>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<pi-icon [icon]="icon()" size="x-large" color="grey" />
|
||||
<h4>{{ title() }}</h4>
|
||||
@if (description()) {
|
||||
<p>{{ description() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
}
|
||||
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
1
libs/ui/src/lib/ui/empty-state/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './empty-state/empty-state.component';
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal file
43
libs/ui/src/lib/ui/hub/entities/hub-configs.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
6
libs/ui/src/lib/ui/hub/entities/hub.model.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { TemplateRef } from '@angular/core';
|
||||
|
||||
export interface HubData {
|
||||
title?: string;
|
||||
template: TemplateRef<unknown>;
|
||||
}
|
||||
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal file
12
libs/ui/src/lib/ui/hub/hub/hub.component.html
Normal 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>
|
||||
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal file
30
libs/ui/src/lib/ui/hub/hub/hub.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal file
115
libs/ui/src/lib/ui/hub/hub/hub.component.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal file
31
libs/ui/src/lib/ui/hub/hub/hub.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
libs/ui/src/lib/ui/hub/index.ts
Normal file
9
libs/ui/src/lib/ui/hub/index.ts
Normal 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';
|
||||
16
libs/ui/src/lib/ui/hub/utils.ts
Normal file
16
libs/ui/src/lib/ui/hub/utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
233
libs/ui/src/lib/ui/popover/popover.directive.spec.ts
Normal file
233
libs/ui/src/lib/ui/popover/popover.directive.spec.ts
Normal 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();
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@if (isLoading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
1
libs/ui/src/lib/ui/rating/index.ts
Normal file
1
libs/ui/src/lib/ui/rating/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './rating/rating.component';
|
||||
25
libs/ui/src/lib/ui/rating/rating/rating.component.html
Normal file
25
libs/ui/src/lib/ui/rating/rating/rating.component.html
Normal 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>
|
||||
}
|
||||
35
libs/ui/src/lib/ui/rating/rating/rating.component.scss
Normal file
35
libs/ui/src/lib/ui/rating/rating/rating.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
175
libs/ui/src/lib/ui/rating/rating/rating.component.spec.ts
Normal file
175
libs/ui/src/lib/ui/rating/rating/rating.component.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
110
libs/ui/src/lib/ui/rating/rating/rating.component.ts
Normal file
110
libs/ui/src/lib/ui/rating/rating/rating.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
1
libs/ui/src/lib/ui/segmented-control/index.ts
Normal file
1
libs/ui/src/lib/ui/segmented-control/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './segmented-control/segmented-control.component';
|
||||
@@ -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
Reference in New Issue
Block a user