fix
This commit is contained in:
@@ -7,13 +7,13 @@
|
||||
|
||||
# Redirige solo el scope @pi al repositorio npm (hosted) de Nexus.
|
||||
# Cambia la URL por la de tu instancia y el nombre de tu repo npm-hosted.
|
||||
@pi:registry=https://nexus.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).
|
||||
# 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).
|
||||
//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`
|
||||
# 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
|
||||
type="button"
|
||||
role="radio"
|
||||
[attr.aria-checked]="theme() === option.value"
|
||||
[class.is-active]="theme() === option.value"
|
||||
(click)="theme.set(option.value)"
|
||||
[attr.aria-checked]="themeService.theme() === option.value"
|
||||
[class.is-active]="themeService.theme() === option.value"
|
||||
(click)="themeService.setTheme(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
<label class="theme-toggle">
|
||||
<span>Modo oscuro</span>
|
||||
<pi-slide-toggle [(checked)]="darkMode" />
|
||||
<pi-slide-toggle [(checked)]="themeService.darkMode" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,8 +43,8 @@
|
||||
</nav>
|
||||
|
||||
<p class="theme-hint">
|
||||
Tema: <strong>{{ theme() }}</strong> ·
|
||||
<code>{{ shellClassByTheme[theme()] }}</code>
|
||||
Tema: <strong>{{ themeService.theme() }}</strong> ·
|
||||
<code>{{ themeService.shellClass() }}</code>
|
||||
en el <code><body></code>. Los cuatro temas comparten exactamente los
|
||||
mismos selectores: solo cambian los tokens <code>--pi-color-*</code> y
|
||||
<code>--pi-theme-*</code> que definen <code>themes/_default.scss</code>,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
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
|
||||
@@ -38,9 +36,12 @@ export class App {
|
||||
|
||||
/* --- Tema y modo oscuro -------------------------------------------------- */
|
||||
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
protected readonly darkMode = signal(false);
|
||||
/**
|
||||
* Aplicar `pi-shell pi-shell__<tema>` en `<body>` y `dark-mode` en `<html>`
|
||||
* (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<{
|
||||
value: ThemeVariant;
|
||||
@@ -51,34 +52,4 @@ export class App {
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ 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
|
||||
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 |
|
||||
| ---------------------------------------------------- | --------------------- | ------------------------------------- |
|
||||
| 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
|
||||
|
||||
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
|
||||
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'
|
||||
```
|
||||
|
||||
@@ -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-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-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-*`)
|
||||
|
||||
@@ -236,12 +259,55 @@ compilación y no forman parte del contrato de theming en runtime.
|
||||
|
||||
## 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
|
||||
`<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`.
|
||||
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
|
||||
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-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-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>
|
||||
</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.
|
||||
Minimal declara los seis primeros 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. Los dos
|
||||
últimos (drawer/hub) son más recientes y todavía ningún tema los redefine.
|
||||
</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="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-num">1</div>
|
||||
<div class="step-body">
|
||||
@@ -523,7 +536,13 @@
|
||||
<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>
|
||||
<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';
|
||||
|
||||
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
|
||||
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
|
||||
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>,
|
||||
<code>--pi-color-black</code>) — las paletas de marca
|
||||
(<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>
|
||||
<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>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>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 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>
|
||||
</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",
|
||||
"lib": {
|
||||
"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",
|
||||
"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": {
|
||||
"@angular/animations": "^22.0.0",
|
||||
"@angular/cdk": "^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
|
||||
}
|
||||
|
||||
@@ -52,6 +52,27 @@
|
||||
},
|
||||
"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/color';
|
||||
export * from './lib/util/common';
|
||||
export * from './lib/util/dato-cms';
|
||||
export * from './lib/util/device-configs';
|
||||
export * from './lib/util/environments';
|
||||
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/scroll-to';
|
||||
export * from './lib/util/status';
|
||||
export * from './lib/util/theme';
|
||||
export * from './lib/util/translate';
|
||||
export * from './lib/util/validator';
|
||||
export * from './lib/util/value';
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
justify-content: space-between;
|
||||
gap: var(--pi-spacing-medium);
|
||||
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 {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
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);
|
||||
background-color: var(--pi-theme-hub-grabber-color, var(--pi-color-grey-100));
|
||||
}
|
||||
|
||||
header {
|
||||
@@ -19,7 +19,7 @@
|
||||
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);
|
||||
border-bottom: 0.0625rem solid var(--pi-theme-overlay-divider-color, var(--pi-color-grey-100));
|
||||
}
|
||||
|
||||
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/router": "22.0.6",
|
||||
"@angular/ssr": "22.0.6",
|
||||
"@apollo/client": "^4.2.8",
|
||||
"@ngx-translate/core": "^18.0.0",
|
||||
"@ngx-translate/http-loader": "^18.0.0",
|
||||
"apollo-angular": "^14.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"express": "^4.21.2",
|
||||
"graphql": "^17.0.2",
|
||||
"lodash": "^4.18.1",
|
||||
"luxon": "^3.7.2",
|
||||
"ngx-infinite-scroll": "^22.0.0",
|
||||
@@ -97,7 +94,9 @@
|
||||
"ng-packagr": "~22.0.0",
|
||||
"nx": "23.1.0",
|
||||
"postcss": "^8.4.5",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"prettier": "^3.8.1",
|
||||
"stylelint": "^17.14.1",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.4.7",
|
||||
"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