This commit is contained in:
2026-08-06 20:51:46 +02:00
parent f86db1556c
commit d1b70ea7d2
30 changed files with 1601 additions and 260 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -1 +0,0 @@
export const datoCms = 'https://graphql.datocms.com/'; // our GraphQL API

View File

@@ -1 +0,0 @@
export * from './util-dato-cms.module';

View File

@@ -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],
},
]);
}

View File

@@ -0,0 +1 @@
export * from './theme.service';

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

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