fix
This commit is contained in:
@@ -7,13 +7,13 @@
|
|||||||
|
|
||||||
# Redirige solo el scope @pi al repositorio npm (hosted) de Nexus.
|
# Redirige solo el scope @pi al repositorio npm (hosted) de Nexus.
|
||||||
# Cambia la URL por la de tu instancia y el nombre de tu repo npm-hosted.
|
# Cambia la URL por la de tu instancia y el nombre de tu repo npm-hosted.
|
||||||
@pi:registry=https://nexus.tuempresa.com/repository/npm-private/
|
@pi:registry=https://nexus.pireon.dev/repository/npm-private/
|
||||||
|
|
||||||
# Autenticación por token (recomendado, funciona igual en local y en CI).
|
# Autenticación por token (recomendado, funciona igual en local y en CI).
|
||||||
# El token se genera en Nexus: usuario > Security > NuGet/npm token, o vía
|
# El token se genera en Nexus: usuario > Security > NuGet/npm token, o vía
|
||||||
# `npm login --registry=https://nexus.tuempresa.com/repository/npm-private/`
|
# `npm login --registry=https://nexus.pireon.dev/repository/npm-private/`
|
||||||
# (esto último escribe la línea de abajo automáticamente en tu ~/.npmrc).
|
# (esto último escribe la línea de abajo automáticamente en tu ~/.npmrc).
|
||||||
//nexus.tuempresa.com/repository/npm-private/:_authToken=${NEXUS_NPM_TOKEN}
|
//nexus.pireon.dev/repository/npm-private/:_authToken=${NEXUS_NPM_TOKEN}
|
||||||
|
|
||||||
# Nexus no es el registro público de npm: sin este flag, `npm publish`
|
# Nexus no es el registro público de npm: sin este flag, `npm publish`
|
||||||
# rechaza paquetes con scope si no lleva --access, y con un registro privado
|
# rechaza paquetes con scope si no lleva --access, y con un registro privado
|
||||||
|
|||||||
13
.stylelintrc.json
Normal file
13
.stylelintrc.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["./tools/stylelint-plugin-pi-theme/no-raw-color-token.mjs"],
|
||||||
|
"customSyntax": "postcss-scss",
|
||||||
|
"ignoreFiles": [
|
||||||
|
"libs/ui/src/lib/styles/themes/**/*.scss",
|
||||||
|
"libs/ui/src/lib/styles/common/abstracts/**/*.scss",
|
||||||
|
"dist/**",
|
||||||
|
"node_modules/**"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"pi/no-raw-color-token": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
[attr.aria-checked]="theme() === option.value"
|
[attr.aria-checked]="themeService.theme() === option.value"
|
||||||
[class.is-active]="theme() === option.value"
|
[class.is-active]="themeService.theme() === option.value"
|
||||||
(click)="theme.set(option.value)"
|
(click)="themeService.setTheme(option.value)"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</button>
|
</button>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="theme-toggle">
|
<label class="theme-toggle">
|
||||||
<span>Modo oscuro</span>
|
<span>Modo oscuro</span>
|
||||||
<pi-slide-toggle [(checked)]="darkMode" />
|
<pi-slide-toggle [(checked)]="themeService.darkMode" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<p class="theme-hint">
|
<p class="theme-hint">
|
||||||
Tema: <strong>{{ theme() }}</strong> ·
|
Tema: <strong>{{ themeService.theme() }}</strong> ·
|
||||||
<code>{{ shellClassByTheme[theme()] }}</code>
|
<code>{{ themeService.shellClass() }}</code>
|
||||||
en el <code><body></code>. Los cuatro temas comparten exactamente los
|
en el <code><body></code>. Los cuatro temas comparten exactamente los
|
||||||
mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y
|
mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y
|
||||||
<code>--pi-theme-*</code> que definen <code>themes/_default.scss</code>,
|
<code>--pi-theme-*</code> que definen <code>themes/_default.scss</code>,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { DOCUMENT } from '@angular/common';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
effect,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
import { PiLoaderBarComponent, PiSlideToggleComponent, ShellClass } from '@pi/ui';
|
import {
|
||||||
|
PiLoaderBarComponent,
|
||||||
|
PiSlideToggleComponent,
|
||||||
|
PiThemeService,
|
||||||
|
ThemeVariant,
|
||||||
|
} from '@pi/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell del playground: cabecera, navegación y estado de tema. El contenido de
|
* Shell del playground: cabecera, navegación y estado de tema. El contenido de
|
||||||
@@ -38,9 +36,12 @@ export class App {
|
|||||||
|
|
||||||
/* --- Tema y modo oscuro -------------------------------------------------- */
|
/* --- Tema y modo oscuro -------------------------------------------------- */
|
||||||
|
|
||||||
private readonly document = inject(DOCUMENT);
|
/**
|
||||||
|
* Aplicar `pi-shell pi-shell__<tema>` en `<body>` y `dark-mode` en `<html>`
|
||||||
protected readonly darkMode = signal(false);
|
* (con persistencia en `localStorage`) vive en `PiThemeService`, no aquí:
|
||||||
|
* cualquier app que consuma `@pi/ui` necesita este mismo cableado.
|
||||||
|
*/
|
||||||
|
protected readonly themeService = inject(PiThemeService);
|
||||||
|
|
||||||
protected readonly themeOptions: ReadonlyArray<{
|
protected readonly themeOptions: ReadonlyArray<{
|
||||||
value: ThemeVariant;
|
value: ThemeVariant;
|
||||||
@@ -51,34 +52,4 @@ export class App {
|
|||||||
{ value: 'minimal', label: 'Minimal' },
|
{ value: 'minimal', label: 'Minimal' },
|
||||||
{ value: 'gradient', label: 'Gradient' },
|
{ value: 'gradient', label: 'Gradient' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Tema visual de la librería. `ShellClass` devuelve las dos clases que hay
|
|
||||||
* que poner juntas (`pi-shell pi-shell__<tema>`); van en el `<body>` porque
|
|
||||||
* los overlays de Material (diálogos, snackbars) se montan fuera de
|
|
||||||
* `<app-root>` y también deben heredarlas.
|
|
||||||
*/
|
|
||||||
protected readonly theme = signal<ThemeVariant>('default');
|
|
||||||
|
|
||||||
protected readonly shellClassByTheme: Record<ThemeVariant, string> = {
|
|
||||||
default: ShellClass.Default,
|
|
||||||
alternative: ShellClass.Alternative,
|
|
||||||
minimal: ShellClass.Minimal,
|
|
||||||
gradient: ShellClass.Gradient,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
this.document.documentElement.classList.toggle(
|
|
||||||
'dark-mode',
|
|
||||||
this.darkMode(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
this.document.body.className = this.shellClassByTheme[this.theme()];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThemeVariant = 'default' | 'alternative' | 'minimal' | 'gradient';
|
|
||||||
|
|||||||
@@ -80,6 +80,24 @@ en custom properties (`--pi-color-*`, `--pi-theme-*`) aplicadas vía `ShellClass
|
|||||||
tema (Default/Alternative/Minimal/Gradient) y guía para añadir tokens o temas
|
tema (Default/Alternative/Minimal/Gradient) y guía para añadir tokens o temas
|
||||||
nuevos: [`TOKENS.md`](./TOKENS.md).
|
nuevos: [`TOKENS.md`](./TOKENS.md).
|
||||||
|
|
||||||
|
Tres piezas pensadas para que reskinear la librería en otro proyecto sea
|
||||||
|
barato:
|
||||||
|
|
||||||
|
- **`PiThemeService`** (`util/theme`) — inyectable, con `theme`/`darkMode`
|
||||||
|
como señales normales (mutables con setters o con `[(value)]`/`[(checked)]`
|
||||||
|
directo), persistencia en `localStorage` y aplicación de clases vía
|
||||||
|
`effect()`. Sustituye al cableado manual que antes vivía en cada app
|
||||||
|
consumidora (ver el `app.ts` del playground antes/después de esta
|
||||||
|
extracción).
|
||||||
|
- **`nx g @pi/ui:theme <nombre>`** — genera `_<nombre>.scss`, lo registra en
|
||||||
|
`themes/index.scss`, añade la entrada a `ShellClass` y extiende
|
||||||
|
`PiThemeService`; el tema queda seleccionable sin pasos manuales.
|
||||||
|
- **`nx run ui:lint-styles`** — regla de stylelint (`pi/no-raw-color-token`)
|
||||||
|
que detecta un `--pi-color-*` usado fuera del fallback de un
|
||||||
|
`--pi-theme-*`, la misma clase de bug que dejó colores de marca fijos en
|
||||||
|
modo oscuro (accordion/tab/select-option, antes de tokenizarlos). Target
|
||||||
|
aparte por ahora — ver TOKENS.md para el porqué y el estado de adopción.
|
||||||
|
|
||||||
| Componente | Contrato | Tipo del valor |
|
| Componente | Contrato | Tipo del valor |
|
||||||
| ---------------------------------------------------- | --------------------- | ------------------------------------- |
|
| ---------------------------------------------------- | --------------------- | ------------------------------------- |
|
||||||
| text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` |
|
| text, email, password, phone, icon, search, textarea | `FormValueControl` | `string` |
|
||||||
|
|||||||
@@ -20,10 +20,31 @@ componente — solo redefine tokens (ver `_alternative.scss`, `_minimal.scss`,
|
|||||||
|
|
||||||
## Cómo se aplica un tema
|
## Cómo se aplica un tema
|
||||||
|
|
||||||
|
La forma recomendada es `PiThemeService` — inyectable, con persistencia en
|
||||||
|
`localStorage` y sin tener que tocar el DOM a mano:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { PiThemeService } from '@pi/ui';
|
||||||
|
|
||||||
|
const themeService = inject(PiThemeService);
|
||||||
|
|
||||||
|
themeService.setTheme('alternative'); // pinta pi-shell pi-shell__alternative en <body>
|
||||||
|
themeService.toggleDarkMode(); // pinta/quita dark-mode en <html>
|
||||||
|
```
|
||||||
|
|
||||||
|
`theme`/`darkMode` son señales normales: también se pueden mutar con un
|
||||||
|
`[(value)]`/`[(checked)]` dos vías directo sobre la señal (ver el uso real en
|
||||||
|
`apps/playground/src/app/app.ts`/`app.html`). Por debajo, cada cambio dispara
|
||||||
|
un `effect()` que aplica la clase y persiste — cualquier camino de escritura
|
||||||
|
queda cubierto por igual, no solo los setters.
|
||||||
|
|
||||||
|
Si no quieres el servicio (p. ej. fuera de un contexto de inyección), el
|
||||||
|
mecanismo de bajo nivel sigue disponible:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { ShellClass } from '@pi/ui';
|
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'
|
document.body.className = ShellClass.Alternative; // 'pi-shell pi-shell__alternative'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,6 +143,8 @@ siempre el *fallback* de `var()` (el valor que tenían antes de tokenizar).
|
|||||||
| `--pi-theme-tooltip-radius` | `var(--pi-border-radius-medium)` | `var(--pi-border-radius-xx-small)` | `_mat-tooltip.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-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)`) |
|
| `--pi-theme-accordion-radius` | `var(--pi-border-radius-large)` | `var(--pi-border-radius-xx-small)` | `accordion.component.scss`, `_horizontal-accordion.scss`, `_vertical-accordion.scss` (×6, incluye un `calc(... - 0.0625rem)`) |
|
||||||
|
| `--pi-theme-overlay-divider-color` | `var(--pi-color-grey-100)` | — | `drawer.component.scss`, `hub.component.scss` (borde bajo la cabecera) |
|
||||||
|
| `--pi-theme-hub-grabber-color` | `var(--pi-color-grey-100)` | — | `hub.component.scss` (el asa superior) |
|
||||||
|
|
||||||
### Puente con Angular Material (`--mat-sys-*`)
|
### Puente con Angular Material (`--mat-sys-*`)
|
||||||
|
|
||||||
@@ -236,12 +259,55 @@ compilación y no forman parte del contrato de theming en runtime.
|
|||||||
|
|
||||||
## Cómo añadir un tema nuevo
|
## Cómo añadir un tema nuevo
|
||||||
|
|
||||||
|
La forma recomendada es el generador:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx g @pi/ui:theme mi-marca
|
||||||
|
```
|
||||||
|
|
||||||
|
Crea `libs/ui/src/lib/styles/themes/_mi-marca.scss` (en blanco, con los
|
||||||
|
grupos de tokens más habituales comentados a modo de chuleta), lo registra en
|
||||||
|
`themes/index.scss`, añade la entrada a `ShellClass` en `styles/configs.ts` y
|
||||||
|
extiende `ThemeVariant`/`PiThemeService` — el tema queda seleccionable con
|
||||||
|
`themeService.setTheme('mi-marca')` sin ningún paso manual más. Solo falta
|
||||||
|
descomentar y ajustar los tokens que definen el aspecto, y (si tu app expone un
|
||||||
|
selector de tema) añadirlo a esa lista.
|
||||||
|
|
||||||
|
Estos son los pasos que da por ti, si prefieres hacerlo a mano o entender qué
|
||||||
|
toca:
|
||||||
|
|
||||||
1. Crea `libs/ui/src/lib/styles/themes/_<nombre>.scss` con un mixin
|
1. Crea `libs/ui/src/lib/styles/themes/_<nombre>.scss` con un mixin
|
||||||
`<nombre>ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si
|
`<nombre>ThemeTokens` que redefina solo los tokens `--pi-theme-*` (y, si
|
||||||
aplica, `--pi-color-*`) que cambian respecto a Default.
|
aplica, `--pi-color-*`) que cambian respecto a Default.
|
||||||
2. Aplícalo a `.pi-shell__<nombre>`.
|
2. Aplícalo a `.pi-shell__<nombre>`.
|
||||||
3. Impórtalo desde `themes/index.scss`.
|
3. Impórtalo desde `themes/index.scss`.
|
||||||
4. Añade la entrada a `ShellClass` en `styles/configs.ts`.
|
4. Añade la entrada a `ShellClass` en `styles/configs.ts`.
|
||||||
|
5. Añade `'<nombre>'` a `ThemeVariant` y una entrada a `shellClassByVariant`
|
||||||
|
en `util/theme/theme.service.ts`.
|
||||||
|
|
||||||
No hace falta tocar ningún componente: si consumen los tokens en vez de
|
No hace falta tocar ningún componente: si consumen los tokens en vez de
|
||||||
valores literales, el tema nuevo se propaga solo.
|
valores literales, el tema nuevo se propaga solo.
|
||||||
|
|
||||||
|
## Lint de tokens: evitar que un color de marca se filtre sin querer
|
||||||
|
|
||||||
|
El bug que motivó tokenizar accordion/tab/select-option/drawer/hub esta misma
|
||||||
|
sesión (un `var(--pi-color-*)` a pelo en el componente, así que ningún tema ni
|
||||||
|
el modo oscuro puede redefinir esa superficie) tiene ahora una regla de
|
||||||
|
stylelint que lo detecta automáticamente: `pi/no-raw-color-token`
|
||||||
|
(`tools/stylelint-plugin-pi-theme/`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nx run ui:lint-styles
|
||||||
|
```
|
||||||
|
|
||||||
|
Marca cualquier `--pi-color-*` que aparezca fuera del *fallback* de un
|
||||||
|
`var(--pi-theme-*, ...)` — es decir, exactamente el patrón contrario al de
|
||||||
|
["El patrón de fallback"](#el-patrón-de-fallback). Están exentos
|
||||||
|
`styles/themes/**` (ahí `--pi-color-*` es legítimo: son las definiciones) y
|
||||||
|
`styles/common/abstracts/**` (donde se declaran las primitivas de la paleta).
|
||||||
|
|
||||||
|
**Estado actual: es un target aparte, no está enganchado a `nx lint` ni a CI.**
|
||||||
|
Al escribirse, señala ~500 usos preexistentes repartidos por componentes que
|
||||||
|
esta sesión no llegó a tokenizar — adoptarlo como gate exige ir migrándolos
|
||||||
|
primero (mismo patrón que en "Cómo tokenizar una superficie nueva"). Úsalo para
|
||||||
|
no introducir *más* mientras se migra el resto.
|
||||||
|
|||||||
Binary file not shown.
@@ -454,19 +454,32 @@
|
|||||||
<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-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-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>
|
<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>
|
||||||
|
<tr><td class="tok"><code class="token">--pi-theme-overlay-divider-color</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-color-grey-100)</code></td><td><code>drawer.component.scss</code>, <code>hub.component.scss</code></td></tr>
|
||||||
|
<tr><td class="tok"><code class="token">--pi-theme-hub-grabber-color</code></td><td><small class="note">sin declarar →</small> <code>var(--pi-color-grey-100)</code></td><td><code>hub.component.scss</code></td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="callout">
|
<div class="callout">
|
||||||
Minimal declara los seis en su mixin (<code>_minimal.scss</code>): aplana
|
Minimal declara los seis primeros en su mixin (<code>_minimal.scss</code>):
|
||||||
todos los radios a <code>--pi-border-radius-xx-small</code> y apaga las dos
|
aplana todos los radios a <code>--pi-border-radius-xx-small</code> y apaga
|
||||||
sombras (<code>none</code>) — mismo criterio que ya aplicaba a botón, campo
|
las dos sombras (<code>none</code>) — mismo criterio que ya aplicaba a
|
||||||
y card, extendido al resto de superficies flotantes.
|
botón, campo y card, extendido al resto de superficies flotantes. Los dos
|
||||||
|
últimos (drawer/hub) son más recientes y todavía ningún tema los redefine.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ================= §3 CREAR UN TEMA ================= -->
|
<!-- ================= §3 CREAR UN TEMA ================= -->
|
||||||
<h1 class="section-title" style="margin-top:26px"><span class="num">3</span>Crear un tema, paso a paso</h1>
|
<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>
|
<p class="lede">Cuatro ficheros, ninguno toca un componente. Ejemplo con un tema ficticio <code>brutalist</code>.</p>
|
||||||
|
|
||||||
|
<div class="callout">
|
||||||
|
Los cuatro pasos de esta página los da un generador:
|
||||||
|
<code>nx g @pi/ui:theme brutalist</code>. Crea el fichero del paso 1 (en
|
||||||
|
blanco, con los grupos de tokens más habituales comentados), hace los
|
||||||
|
pasos 2 y 3, y además extiende <code>PiThemeService</code> (paso 4) para
|
||||||
|
que el tema quede seleccionable sin ningún paso manual más. Lo que sigue
|
||||||
|
es para quien prefiera hacerlo a mano o quiera entender qué toca cada
|
||||||
|
paso.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<div class="step-num">1</div>
|
<div class="step-num">1</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
@@ -523,7 +536,13 @@
|
|||||||
<div class="step-num">4</div>
|
<div class="step-num">4</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<div class="step-title">Aplícalo en la app consumidora</div>
|
<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>
|
<p>Con <code>PiThemeService</code> (ver <code>apps/playground/src/app/app.ts</code>) basta con:</p>
|
||||||
|
<pre class="code">import { inject } from '@angular/core';
|
||||||
|
import { PiThemeService } from '@pi/ui';
|
||||||
|
|
||||||
|
const themeService = inject(PiThemeService);
|
||||||
|
themeService.setTheme('brutalist');</pre>
|
||||||
|
<p>Si el generador ya extendió <code>ThemeVariant</code>/<code>shellClassByVariant</code> (paso automático 4), no hace falta tocar nada más que este <code>setTheme</code>. Sin el servicio, el mecanismo de bajo nivel sigue funcionando igual:</p>
|
||||||
<pre class="code">import { ShellClass } from '@pi/ui';
|
<pre class="code">import { ShellClass } from '@pi/ui';
|
||||||
|
|
||||||
document.body.className = ShellClass.Brutalist;</pre>
|
document.body.className = ShellClass.Brutalist;</pre>
|
||||||
@@ -654,7 +673,11 @@ document.body.className = ShellClass.Brutalist;</pre>
|
|||||||
<code>@pi/ui</code> no trae un tema oscuro propio. El playground activa
|
<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
|
uno "best-effort" añadiendo la clase <code>dark-mode</code> al
|
||||||
<code><html></code> (no al <code><body></code>, que es donde
|
<code><html></code> (no al <code><body></code>, que es donde
|
||||||
vive <code>.pi-shell</code>), y ahí reasigna solo los
|
vive <code>.pi-shell</code>) — hoy vía
|
||||||
|
<code>PiThemeService.setDarkMode()</code>/<code>toggleDarkMode()</code>,
|
||||||
|
que hace ese toggle por ti (persistido en <code>localStorage</code>) en
|
||||||
|
vez de que cada app repita el mismo <code>effect()</code> — y ahí reasigna
|
||||||
|
solo los
|
||||||
<b>neutros</b> (<code>--pi-color-white</code>, <code>--pi-color-grey-*</code>,
|
<b>neutros</b> (<code>--pi-color-white</code>, <code>--pi-color-grey-*</code>,
|
||||||
<code>--pi-color-black</code>) — las paletas de marca
|
<code>--pi-color-black</code>) — las paletas de marca
|
||||||
(<code>primary</code>, <code>accent</code>…) se dejan igual:
|
(<code>primary</code>, <code>accent</code>…) se dejan igual:
|
||||||
@@ -684,12 +707,14 @@ document.body.className = ShellClass.Brutalist;</pre>
|
|||||||
|
|
||||||
<h2 class="sub" style="margin-top:6px">Antes de dar el tema por terminado</h2>
|
<h2 class="sub" style="margin-top:6px">Antes de dar el tema por terminado</h2>
|
||||||
<ul class="check">
|
<ul class="check">
|
||||||
|
<li>Se creó con <code>nx g @pi/ui:theme <nombre></code> — hace los tres puntos siguientes por ti.</li>
|
||||||
<li>El fichero nuevo vive en <code>themes/</code>, exporta un mixin y una clase <code>.pi-shell__<nombre></code>.</li>
|
<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>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>Tiene entrada en <code>ShellClass</code> (<code>configs.ts</code>) y en <code>PiThemeService</code> (<code>ThemeVariant</code>/<code>shellClassByVariant</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>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 <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>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>Si al tokenizar una superficie nueva tocaste un <code>.component.scss</code>, <code>nx run ui:lint-styles</code> no marca ese fichero (detecta <code>--pi-color-*</code> usado fuera de un fallback <code>--pi-theme-*</code>).</li>
|
||||||
<li>Probado en el playground con las tres páginas de catálogo, no solo en 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>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
10
libs/ui/generators.json
Normal file
10
libs/ui/generators.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/nrwl/nx/master/packages/nx/schemas/generators.json",
|
||||||
|
"generators": {
|
||||||
|
"theme": {
|
||||||
|
"factory": "./src/generators/theme/generator.cts",
|
||||||
|
"schema": "./src/generators/theme/schema.json",
|
||||||
|
"description": "Crea un nuevo tema (_<nombre>.scss + ShellClass + PiThemeService) para @pi/ui."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,13 @@
|
|||||||
"dest": "../../dist/libs/ui",
|
"dest": "../../dist/libs/ui",
|
||||||
"lib": {
|
"lib": {
|
||||||
"entryFile": "src/index.ts"
|
"entryFile": "src/index.ts"
|
||||||
}
|
},
|
||||||
|
"allowedNonPeerDependencies": ["lodash", "luxon", "ngx-infinite-scroll"],
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*.scss",
|
||||||
|
"input": "src/lib/styles",
|
||||||
|
"output": "/styles"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "@pi/ui",
|
"name": "@pi/ui",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"description": "Librería de componentes Angular standalone (botones, campos con Signal Forms, cards, charts, drawer/hub/modal, theming vía tokens CSS...).",
|
||||||
|
"license": "MIT",
|
||||||
|
"generators": "./generators.json",
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://nexus.pireon.dev/repository/npm-private/",
|
||||||
|
"access": "restricted"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@angular/animations": "^22.0.0",
|
||||||
|
"@angular/cdk": "^22.0.0",
|
||||||
"@angular/common": "^22.0.0",
|
"@angular/common": "^22.0.0",
|
||||||
"@angular/core": "^22.0.0"
|
"@angular/core": "^22.0.0",
|
||||||
|
"@angular/forms": "^22.0.0",
|
||||||
|
"@angular/material": "^22.0.0",
|
||||||
|
"@angular/router": "^22.0.0",
|
||||||
|
"@ngx-translate/core": "^18.0.0",
|
||||||
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
|
"chart.js": "^4.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.0",
|
||||||
|
"luxon": "^3.0.0",
|
||||||
|
"ngx-infinite-scroll": "^22.0.0"
|
||||||
},
|
},
|
||||||
"sideEffects": false
|
"sideEffects": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,27 @@
|
|||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"executor": "@nx/eslint:lint"
|
"executor": "@nx/eslint:lint"
|
||||||
|
},
|
||||||
|
"lint-styles": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "stylelint \"libs/ui/src/**/*.scss\" --config .stylelintrc.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build-generators": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"dependsOn": ["package"],
|
||||||
|
"outputs": [
|
||||||
|
"{workspaceRoot}/dist/{projectRoot}/generators",
|
||||||
|
"{workspaceRoot}/dist/{projectRoot}/generators.json"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"commands": [
|
||||||
|
"tsc -p libs/ui/tsconfig.generators.json",
|
||||||
|
"node libs/ui/scripts/finalize-generators-dist.cjs"
|
||||||
|
],
|
||||||
|
"parallel": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
libs/ui/scripts/finalize-generators-dist.cjs
Normal file
55
libs/ui/scripts/finalize-generators-dist.cjs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ng-packagr` (el `build`/`package` de `ui`) solo empaqueta lo alcanzable
|
||||||
|
* desde `src/index.ts` — los generadores (`src/generators/`) son Node/Nx
|
||||||
|
* plugin, no Angular, así que ng-packagr los ignora por completo. Este script
|
||||||
|
* los deja listos en `dist/libs/ui/` (ya compilados por
|
||||||
|
* `tsc -p tsconfig.generators.json`, que corre antes que este script) para
|
||||||
|
* que `generators.json` publicado apunte a ficheros que de verdad existen en
|
||||||
|
* el tarball — si no, `nx g @pi/ui:theme` funcionaría dentro de este
|
||||||
|
* monorepo pero fallaría para quien instale el paquete desde el registry.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const libRoot = path.join(__dirname, '..');
|
||||||
|
const distRoot = path.join(libRoot, '..', '..', 'dist', 'libs', 'ui');
|
||||||
|
|
||||||
|
const sourceManifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(libRoot, 'generators.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** `./src/generators/theme/generator.cts` (fuente) -> `./generators/theme/generator.cjs` (dist). */
|
||||||
|
function toDistPath(sourcePath) {
|
||||||
|
return sourcePath.replace('./src/generators/', './generators/').replace(/\.cts$/, '.cjs');
|
||||||
|
}
|
||||||
|
|
||||||
|
const distManifest = {
|
||||||
|
...sourceManifest,
|
||||||
|
generators: Object.fromEntries(
|
||||||
|
Object.entries(sourceManifest.generators).map(([name, config]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
factory: toDistPath(config.factory),
|
||||||
|
schema: toDistPath(config.schema),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(distRoot, 'generators.json'),
|
||||||
|
JSON.stringify(distManifest, null, 2) + '\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const { schema } of Object.values(sourceManifest.generators)) {
|
||||||
|
const sourceSchemaPath = path.join(libRoot, schema);
|
||||||
|
const destSchemaPath = path.join(distRoot, toDistPath(schema));
|
||||||
|
fs.mkdirSync(path.dirname(destSchemaPath), { recursive: true });
|
||||||
|
fs.copyFileSync(sourceSchemaPath, destSchemaPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✔ generators.json + schemas listos en dist/libs/ui/');
|
||||||
172
libs/ui/src/generators/theme/generator.cts
Normal file
172
libs/ui/src/generators/theme/generator.cts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { Tree, formatFiles, logger, names } from '@nx/devkit';
|
||||||
|
import type { ThemeGeneratorSchema } from './schema';
|
||||||
|
|
||||||
|
const THEMES_DIR = 'libs/ui/src/lib/styles/themes';
|
||||||
|
const CONFIGS_PATH = 'libs/ui/src/lib/styles/configs.ts';
|
||||||
|
const THEME_SERVICE_PATH = 'libs/ui/src/lib/util/theme/theme.service.ts';
|
||||||
|
|
||||||
|
export default async function themeGenerator(
|
||||||
|
tree: Tree,
|
||||||
|
options: ThemeGeneratorSchema,
|
||||||
|
): Promise<void> {
|
||||||
|
const { fileName: themeName, className, propertyName } = names(options.name);
|
||||||
|
const scssPath = `${THEMES_DIR}/_${themeName}.scss`;
|
||||||
|
|
||||||
|
if (tree.exists(scssPath)) {
|
||||||
|
throw new Error(
|
||||||
|
`Ya existe un tema en ${scssPath}. Elige otro nombre o bórralo primero.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.write(scssPath, buildThemeScss(themeName, propertyName));
|
||||||
|
registerInThemesIndex(tree, themeName);
|
||||||
|
addShellClassEntry(tree, className, themeName);
|
||||||
|
extendThemeService(tree, className, themeName);
|
||||||
|
|
||||||
|
await formatFiles(tree);
|
||||||
|
|
||||||
|
logger.info(`\n✔ Tema "${themeName}" creado:`);
|
||||||
|
logger.info(` - ${scssPath}`);
|
||||||
|
logger.info(` - registrado en ${THEMES_DIR}/index.scss`);
|
||||||
|
logger.info(` - añadido a ShellClass en ${CONFIGS_PATH}`);
|
||||||
|
logger.info(` - añadido a ThemeVariant/PiThemeService en ${THEME_SERVICE_PATH}`);
|
||||||
|
logger.info(
|
||||||
|
`\nEdita los tokens de ${scssPath} para definir el aspecto (ver TOKENS.md).`,
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`Ya es seleccionable vía PiThemeService.setTheme('${themeName}'); solo falta` +
|
||||||
|
` añadirlo a la UI de tu app (p. ej. \`themeOptions\` en tu shell) si quieres un botón.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildThemeScss(themeName: string, propertyName: string): string {
|
||||||
|
return `// TEMA ${themeName.toUpperCase()}
|
||||||
|
//
|
||||||
|
// Generado con "nx g @pi/ui:theme ${themeName}". Empieza en blanco a
|
||||||
|
// propósito: mientras no descomentes nada, este tema es visualmente idéntico
|
||||||
|
// al tema por defecto (todo cae al ".pi-shell" base). Descomenta y cambia solo
|
||||||
|
// los tokens que quieras redefinir — no hace falta declarar el resto. Lista
|
||||||
|
// completa de tokens "--pi-theme-*"/"--pi-color-*" disponibles: TOKENS.md.
|
||||||
|
|
||||||
|
@mixin ${propertyName}ThemeTokens {
|
||||||
|
// --- Paleta de marca ----------------------------------------------------
|
||||||
|
// --pi-color-primary: #799ef0;
|
||||||
|
// --pi-color-accent: #eda82a;
|
||||||
|
// --pi-color-highlighted: #0d2a6b;
|
||||||
|
|
||||||
|
// --- Botones --------------------------------------------------------------
|
||||||
|
// --pi-theme-button-radius: 999px;
|
||||||
|
// --pi-theme-button-shadow: none;
|
||||||
|
// --pi-theme-button-fill-primary: none;
|
||||||
|
|
||||||
|
// --- Campos de formulario ---------------------------------------------------
|
||||||
|
// --pi-theme-field-radius: 0.875rem;
|
||||||
|
// --pi-theme-field-border-color-focus: var(--pi-color-primary);
|
||||||
|
// --pi-theme-field-surface: var(--pi-color-white);
|
||||||
|
|
||||||
|
// --- Superficies ------------------------------------------------------------
|
||||||
|
// --pi-theme-card-radius: var(--pi-border-radius-large);
|
||||||
|
// --pi-theme-card-shadow: var(--pi-box-shadow-panel);
|
||||||
|
// --pi-theme-panel-radius: var(--pi-border-radius-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pi-shell__${themeName} {
|
||||||
|
@include ${propertyName}ThemeTokens;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerInThemesIndex(tree: Tree, themeName: string): void {
|
||||||
|
const path = `${THEMES_DIR}/index.scss`;
|
||||||
|
const content = tree.read(path, 'utf-8');
|
||||||
|
if (!content) throw new Error(`No se encontró ${path}.`);
|
||||||
|
|
||||||
|
const withForward = insertAfterLastMatch(
|
||||||
|
content,
|
||||||
|
/@forward '[^']+';/g,
|
||||||
|
`@forward '${themeName}';`,
|
||||||
|
);
|
||||||
|
const withUse = insertAfterLastMatch(
|
||||||
|
withForward,
|
||||||
|
/@use '[^']+' as \*;/g,
|
||||||
|
`@use '${themeName}' as *;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
tree.write(path, withUse);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addShellClassEntry(
|
||||||
|
tree: Tree,
|
||||||
|
className: string,
|
||||||
|
themeName: string,
|
||||||
|
): void {
|
||||||
|
patchObjectLiteral(
|
||||||
|
tree,
|
||||||
|
CONFIGS_PATH,
|
||||||
|
/export const ShellClass = \{[\s\S]*?\n\};/,
|
||||||
|
` /** Generado con \`nx g @pi/ui:theme ${themeName}\`. */\n ${className}: shellTheming('${themeName}'),\n};`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extendThemeService(
|
||||||
|
tree: Tree,
|
||||||
|
className: string,
|
||||||
|
themeName: string,
|
||||||
|
): void {
|
||||||
|
const path = THEME_SERVICE_PATH;
|
||||||
|
const content = tree.read(path, 'utf-8');
|
||||||
|
if (!content) throw new Error(`No se encontró ${path}.`);
|
||||||
|
|
||||||
|
const unionPattern =
|
||||||
|
/export type ThemeVariant = 'default' \| 'alternative' \| 'minimal' \| 'gradient'(?:\s*\|\s*'[^']+')*;/;
|
||||||
|
const updatedUnion = content.replace(unionPattern, (match) =>
|
||||||
|
match.replace(/;$/, ` | '${themeName}';`),
|
||||||
|
);
|
||||||
|
|
||||||
|
patchObjectLiteral(
|
||||||
|
tree,
|
||||||
|
path,
|
||||||
|
/const shellClassByVariant: Record<ThemeVariant, string> = \{[\s\S]*?\n\};/,
|
||||||
|
` '${themeName}': ShellClass.${className},\n};`,
|
||||||
|
updatedUnion,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sustituye el objeto literal que casa con `blockPattern` insertando
|
||||||
|
* `newClosing` justo antes de su `\n};` de cierre — funciona igual si el
|
||||||
|
* generador ya se ha ejecutado antes (siempre inserta antes del PRIMER cierre
|
||||||
|
* tras la apertura, no del último del fichero).
|
||||||
|
*/
|
||||||
|
function patchObjectLiteral(
|
||||||
|
tree: Tree,
|
||||||
|
path: string,
|
||||||
|
blockPattern: RegExp,
|
||||||
|
newClosing: string,
|
||||||
|
contentOverride?: string,
|
||||||
|
): void {
|
||||||
|
const content = contentOverride ?? tree.read(path, 'utf-8');
|
||||||
|
if (!content) throw new Error(`No se encontró ${path}.`);
|
||||||
|
|
||||||
|
const match = content.match(blockPattern);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`No se encontró el bloque esperado en ${path}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = match[0];
|
||||||
|
const updatedBlock = block.replace(/\n\};$/, `\n${newClosing}`);
|
||||||
|
tree.write(path, content.replace(block, updatedBlock));
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAfterLastMatch(
|
||||||
|
content: string,
|
||||||
|
pattern: RegExp,
|
||||||
|
newLine: string,
|
||||||
|
): string {
|
||||||
|
const matches = [...content.matchAll(pattern)];
|
||||||
|
if (matches.length === 0) return `${content}\n${newLine}`;
|
||||||
|
|
||||||
|
const last = matches[matches.length - 1];
|
||||||
|
const insertPos = (last.index ?? content.length) + last[0].length;
|
||||||
|
return `${content.slice(0, insertPos)}\n${newLine}${content.slice(insertPos)}`;
|
||||||
|
}
|
||||||
3
libs/ui/src/generators/theme/schema.d.ts
vendored
Normal file
3
libs/ui/src/generators/theme/schema.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface ThemeGeneratorSchema {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
16
libs/ui/src/generators/theme/schema.json
Normal file
16
libs/ui/src/generators/theme/schema.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/schema",
|
||||||
|
"$id": "PiUiTheme",
|
||||||
|
"title": "Genera un nuevo tema para @pi/ui",
|
||||||
|
"description": "Crea _<nombre>.scss, lo registra en themes/index.scss, añade una entrada a ShellClass (styles/configs.ts) y extiende ThemeVariant/PiThemeService para que quede utilizable de inmediato.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nombre del tema, en kebab-case (p. ej. \"acme-corp\").",
|
||||||
|
"$default": { "$source": "argv", "index": 0 },
|
||||||
|
"x-prompt": "¿Cómo se llama el tema?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ export * from './lib/util/affix';
|
|||||||
export * from './lib/util/amplitude';
|
export * from './lib/util/amplitude';
|
||||||
export * from './lib/util/color';
|
export * from './lib/util/color';
|
||||||
export * from './lib/util/common';
|
export * from './lib/util/common';
|
||||||
export * from './lib/util/dato-cms';
|
|
||||||
export * from './lib/util/device-configs';
|
export * from './lib/util/device-configs';
|
||||||
export * from './lib/util/environments';
|
export * from './lib/util/environments';
|
||||||
export * from './lib/util/icon-registry';
|
export * from './lib/util/icon-registry';
|
||||||
@@ -21,6 +20,7 @@ export * from './lib/util/router-store';
|
|||||||
export * from './lib/util/schema';
|
export * from './lib/util/schema';
|
||||||
export * from './lib/util/scroll-to';
|
export * from './lib/util/scroll-to';
|
||||||
export * from './lib/util/status';
|
export * from './lib/util/status';
|
||||||
|
export * from './lib/util/theme';
|
||||||
export * from './lib/util/translate';
|
export * from './lib/util/translate';
|
||||||
export * from './lib/util/validator';
|
export * from './lib/util/validator';
|
||||||
export * from './lib/util/value';
|
export * from './lib/util/value';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--pi-spacing-medium);
|
gap: var(--pi-spacing-medium);
|
||||||
padding: var(--pi-spacing-large) var(--pi-spacing-x-large);
|
padding: var(--pi-spacing-large) var(--pi-spacing-x-large);
|
||||||
border-bottom: 0.0625rem solid var(--pi-color-grey-100);
|
border-bottom: 0.0625rem solid var(--pi-theme-overlay-divider-color, var(--pi-color-grey-100));
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
height: 0.25rem;
|
height: 0.25rem;
|
||||||
margin: var(--pi-spacing-small) auto 0;
|
margin: var(--pi-spacing-small) auto 0;
|
||||||
border-radius: var(--pi-border-radius-x-small);
|
border-radius: var(--pi-border-radius-x-small);
|
||||||
background-color: var(--pi-color-grey-100);
|
background-color: var(--pi-theme-hub-grabber-color, var(--pi-color-grey-100));
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--pi-spacing-medium);
|
gap: var(--pi-spacing-medium);
|
||||||
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-large);
|
padding: var(--pi-spacing-medium) var(--pi-spacing-x-large) var(--pi-spacing-large);
|
||||||
border-bottom: 0.0625rem solid var(--pi-color-grey-100);
|
border-bottom: 0.0625rem solid var(--pi-theme-overlay-divider-color, var(--pi-color-grey-100));
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const datoCms = 'https://graphql.datocms.com/'; // our GraphQL API
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './util-dato-cms.module';
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
|
|
||||||
|
|
||||||
// Apollo
|
|
||||||
import { InMemoryCache } from '@apollo/client/core';
|
|
||||||
import { APOLLO_OPTIONS } from 'apollo-angular';
|
|
||||||
import { HttpLink } from 'apollo-angular/http';
|
|
||||||
|
|
||||||
// Shared
|
|
||||||
import { environment } from '../environments';
|
|
||||||
|
|
||||||
// datoCMS
|
|
||||||
import { datoCms } from './graphql.module';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sustituye a `PiSharedUtilDatoCmsModule`: apollo-angular ya no expone
|
|
||||||
* `ApolloModule`, la configuración se hace con providers.
|
|
||||||
*/
|
|
||||||
export function providePiDatoCms(): EnvironmentProviders {
|
|
||||||
return makeEnvironmentProviders([
|
|
||||||
{
|
|
||||||
provide: APOLLO_OPTIONS,
|
|
||||||
useFactory: (httpLink: HttpLink) => {
|
|
||||||
return {
|
|
||||||
cache: new InMemoryCache(),
|
|
||||||
link: httpLink.create({
|
|
||||||
uri: datoCms,
|
|
||||||
headers: {
|
|
||||||
Authorization: environment.DATO_KEY,
|
|
||||||
} as any,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
deps: [HttpLink],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
1
libs/ui/src/lib/util/theme/index.ts
Normal file
1
libs/ui/src/lib/util/theme/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './theme.service';
|
||||||
60
libs/ui/src/lib/util/theme/theme.service.spec.ts
Normal file
60
libs/ui/src/lib/util/theme/theme.service.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PiThemeService } from './theme.service';
|
||||||
|
|
||||||
|
describe('PiThemeService', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.body.className = '';
|
||||||
|
document.documentElement.classList.remove('dark-mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies `pi-shell pi-shell__default` to <body> by default', () => {
|
||||||
|
TestBed.inject(PiThemeService);
|
||||||
|
TestBed.flushEffects();
|
||||||
|
|
||||||
|
expect(document.body.className).toBe('pi-shell pi-shell__default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates <body> when the theme changes', () => {
|
||||||
|
const service = TestBed.inject(PiThemeService);
|
||||||
|
|
||||||
|
service.setTheme('alternative');
|
||||||
|
TestBed.flushEffects();
|
||||||
|
|
||||||
|
expect(document.body.className).toBe('pi-shell pi-shell__alternative');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles `dark-mode` on <html>', () => {
|
||||||
|
const service = TestBed.inject(PiThemeService);
|
||||||
|
|
||||||
|
service.setDarkMode(true);
|
||||||
|
TestBed.flushEffects();
|
||||||
|
expect(document.documentElement.classList.contains('dark-mode')).toBe(true);
|
||||||
|
|
||||||
|
service.toggleDarkMode();
|
||||||
|
TestBed.flushEffects();
|
||||||
|
expect(document.documentElement.classList.contains('dark-mode')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the theme and dark mode across a fresh instance (reload)', () => {
|
||||||
|
const service = TestBed.inject(PiThemeService);
|
||||||
|
service.setTheme('minimal');
|
||||||
|
service.setDarkMode(true);
|
||||||
|
TestBed.flushEffects();
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
const reloaded = TestBed.inject(PiThemeService);
|
||||||
|
|
||||||
|
expect(reloaded.theme()).toBe('minimal');
|
||||||
|
expect(reloaded.darkMode()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default theme when nothing valid is stored', () => {
|
||||||
|
localStorage.setItem('pi-theme', JSON.stringify('not-a-real-theme'));
|
||||||
|
|
||||||
|
const service = TestBed.inject(PiThemeService);
|
||||||
|
|
||||||
|
expect(service.theme()).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
98
libs/ui/src/lib/util/theme/theme.service.ts
Normal file
98
libs/ui/src/lib/util/theme/theme.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { ShellClass } from '../../styles';
|
||||||
|
import { LocalStorageService } from '../local-storage';
|
||||||
|
|
||||||
|
export type ThemeVariant = 'default' | 'alternative' | 'minimal' | 'gradient';
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = 'pi-theme';
|
||||||
|
const DARK_MODE_STORAGE_KEY = 'pi-dark-mode';
|
||||||
|
|
||||||
|
const shellClassByVariant: Record<ThemeVariant, string> = {
|
||||||
|
default: ShellClass.Default,
|
||||||
|
alternative: ShellClass.Alternative,
|
||||||
|
minimal: ShellClass.Minimal,
|
||||||
|
gradient: ShellClass.Gradient,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centraliza lo que hasta ahora cada app reimplementaba a mano (ver el
|
||||||
|
* `app.ts` del playground antes de esta extracción): fijar
|
||||||
|
* `pi-shell pi-shell__<tema>` en `<body>` — los overlays de Material (diálogo,
|
||||||
|
* snackbar) cuelgan fuera de `<app-root>` y también necesitan heredarlo — y
|
||||||
|
* `dark-mode` en `<html>`.
|
||||||
|
*
|
||||||
|
* `theme`/`darkMode` son señales normales: se pueden mutar tanto con
|
||||||
|
* `setTheme()`/`toggleDarkMode()` como con un `[(checked)]`/`[(value)]` dos
|
||||||
|
* vías directo sobre la señal. La aplicación de clases y la persistencia en
|
||||||
|
* `localStorage` viven en `effect()`, no en los setters, para que cualquier
|
||||||
|
* camino de escritura quede cubierto por igual.
|
||||||
|
*
|
||||||
|
* El modo oscuro en sí (qué tokens remapea `.dark-mode`) lo sigue definiendo
|
||||||
|
* cada app consumidora — este servicio solo pone o quita la clase.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PiThemeService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly localStorage = inject(LocalStorageService);
|
||||||
|
|
||||||
|
readonly theme = signal<ThemeVariant>(this.readStoredTheme());
|
||||||
|
readonly darkMode = signal<boolean>(this.readStoredDarkMode());
|
||||||
|
|
||||||
|
readonly shellClass = computed(() => shellClassByVariant[this.theme()]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
this.document.body.className = this.shellClass();
|
||||||
|
this.persist(THEME_STORAGE_KEY, this.theme());
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
this.document.documentElement.classList.toggle(
|
||||||
|
'dark-mode',
|
||||||
|
this.darkMode(),
|
||||||
|
);
|
||||||
|
this.persist(DARK_MODE_STORAGE_KEY, this.darkMode());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: ThemeVariant): void {
|
||||||
|
this.theme.set(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDarkMode(darkMode: boolean): void {
|
||||||
|
this.darkMode.set(darkMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDarkMode(): void {
|
||||||
|
this.darkMode.update((value) => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(key: string, value: unknown): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
this.localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStoredTheme(): ThemeVariant {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) return 'default';
|
||||||
|
const stored = this.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
return stored && stored in shellClassByVariant ? stored : 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStoredDarkMode(): boolean {
|
||||||
|
return (
|
||||||
|
isPlatformBrowser(this.platformId) &&
|
||||||
|
!!this.localStorage.getItem(DARK_MODE_STORAGE_KEY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
libs/ui/tsconfig.generators.json
Normal file
14
libs/ui/tsconfig.generators.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"target": "es2021",
|
||||||
|
"outDir": "../../dist/libs/ui/generators",
|
||||||
|
"rootDir": "src/generators",
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/generators/**/*.cts"]
|
||||||
|
}
|
||||||
1019
package-lock.json
generated
1019
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,13 +29,10 @@
|
|||||||
"@angular/platform-server": "22.0.6",
|
"@angular/platform-server": "22.0.6",
|
||||||
"@angular/router": "22.0.6",
|
"@angular/router": "22.0.6",
|
||||||
"@angular/ssr": "22.0.6",
|
"@angular/ssr": "22.0.6",
|
||||||
"@apollo/client": "^4.2.8",
|
|
||||||
"@ngx-translate/core": "^18.0.0",
|
"@ngx-translate/core": "^18.0.0",
|
||||||
"@ngx-translate/http-loader": "^18.0.0",
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
"apollo-angular": "^14.1.0",
|
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"graphql": "^17.0.2",
|
|
||||||
"lodash": "^4.18.1",
|
"lodash": "^4.18.1",
|
||||||
"luxon": "^3.7.2",
|
"luxon": "^3.7.2",
|
||||||
"ngx-infinite-scroll": "^22.0.0",
|
"ngx-infinite-scroll": "^22.0.0",
|
||||||
@@ -97,7 +94,9 @@
|
|||||||
"ng-packagr": "~22.0.0",
|
"ng-packagr": "~22.0.0",
|
||||||
"nx": "23.1.0",
|
"nx": "23.1.0",
|
||||||
"postcss": "^8.4.5",
|
"postcss": "^8.4.5",
|
||||||
|
"postcss-scss": "^4.0.9",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
"stylelint": "^17.14.1",
|
||||||
"supertest": "^7.1.4",
|
"supertest": "^7.1.4",
|
||||||
"ts-jest": "^29.4.7",
|
"ts-jest": "^29.4.7",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
|
|||||||
98
tools/stylelint-plugin-pi-theme/no-raw-color-token.mjs
Normal file
98
tools/stylelint-plugin-pi-theme/no-raw-color-token.mjs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import stylelint from 'stylelint';
|
||||||
|
|
||||||
|
const { createPlugin, utils } = stylelint;
|
||||||
|
const { report, ruleMessages, validateOptions } = utils;
|
||||||
|
|
||||||
|
const ruleName = 'pi/no-raw-color-token';
|
||||||
|
|
||||||
|
const messages = ruleMessages(ruleName, {
|
||||||
|
rejected: (token) =>
|
||||||
|
`"${token}" se usa directamente. Los componentes de la librería deben ` +
|
||||||
|
`consumir un token --pi-theme-* con este color como fallback ` +
|
||||||
|
`(var(--pi-theme-<algo>, ${token})) para que un tema o el modo oscuro ` +
|
||||||
|
`puedan redefinir la superficie sin tocar el componente. Ver TOKENS.md.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
url: 'https://github.com/pi/ui/tools/stylelint-plugin-pi-theme',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrato de esta librería (ver TOKENS.md): un componente NO consume
|
||||||
|
* `--pi-color-*` en crudo, lo hace a través de un `--pi-theme-*` con el color
|
||||||
|
* como fallback: `var(--pi-theme-x, var(--pi-color-y))`. Esta regla detecta
|
||||||
|
* el caso contrario — `--pi-color-*` referenciado fuera de cualquier
|
||||||
|
* fallback de `--pi-theme-*` — que es exactamente la clase de bug que dejó
|
||||||
|
* colores de marca fijos filtrando en modo oscuro (accordion/tab/select en
|
||||||
|
* esta misma librería, antes de tokenizarlos).
|
||||||
|
*
|
||||||
|
* Deliberadamente NO seguimos con `postcss-value-parser`: los valores aquí
|
||||||
|
* son casi siempre `var(...)` con como mucho una función envolvente
|
||||||
|
* (`linear-gradient(...)`, `rgba(...)`), así que un escaneo con paréntesis
|
||||||
|
* balanceados es suficiente y evita añadir una dependencia más solo para
|
||||||
|
* esta regla.
|
||||||
|
*/
|
||||||
|
function findMatchingParenEnd(value, openParenIndex) {
|
||||||
|
let depth = 1;
|
||||||
|
for (let i = openParenIndex + 1; i < value.length; i++) {
|
||||||
|
if (value[i] === '(') depth++;
|
||||||
|
else if (value[i] === ')') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rangos `[start, end)` de cada fallback de un `var(--pi-theme-*, ...)` — lo que haya dentro está exento. */
|
||||||
|
function findProtectedRanges(value) {
|
||||||
|
const ranges = [];
|
||||||
|
const pattern = /var\(\s*--pi-theme-[\w-]+/g;
|
||||||
|
let match;
|
||||||
|
while ((match = pattern.exec(value))) {
|
||||||
|
const openParenIndex = match.index + match[0].indexOf('(');
|
||||||
|
const end = findMatchingParenEnd(value, openParenIndex);
|
||||||
|
ranges.push([match.index, end]);
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProtected(index, ranges) {
|
||||||
|
return ranges.some(([start, end]) => index > start && index < end);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = (primaryOption) => {
|
||||||
|
return (root, result) => {
|
||||||
|
const validOptions = validateOptions(result, ruleName, {
|
||||||
|
actual: primaryOption,
|
||||||
|
possible: [true, false],
|
||||||
|
});
|
||||||
|
if (!validOptions || !primaryOption) return;
|
||||||
|
|
||||||
|
root.walkDecls((decl) => {
|
||||||
|
const value = decl.value;
|
||||||
|
if (!value || !value.includes('--pi-color-')) return;
|
||||||
|
|
||||||
|
const protectedRanges = findProtectedRanges(value);
|
||||||
|
const colorPattern = /--pi-color-[\w-]+/g;
|
||||||
|
let match;
|
||||||
|
while ((match = colorPattern.exec(value))) {
|
||||||
|
if (isProtected(match.index, protectedRanges)) continue;
|
||||||
|
|
||||||
|
report({
|
||||||
|
message: messages.rejected(match[0]),
|
||||||
|
node: decl,
|
||||||
|
word: match[0],
|
||||||
|
result,
|
||||||
|
ruleName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
rule.ruleName = ruleName;
|
||||||
|
rule.messages = messages;
|
||||||
|
rule.meta = meta;
|
||||||
|
|
||||||
|
export default createPlugin(ruleName, rule);
|
||||||
Reference in New Issue
Block a user