refactor(libs): graduate Icon / LayoutStateService / brand tokens to libs/shared/* (#99)
## Summary
Per ADR-0020 §"Shared-libs graduation": the three primitives that `portal-shell` (today) and `portal-admin` (next) will both share move out of `apps/portal-shell/src/` and into the workspace libs. Mechanical move — no behaviour change, prepares the ground for the admin-app PR.
## Graduated
| Primitive | Old location | New location |
|---|---|---|
| `Icon` component (+ `IconName` type) | `apps/portal-shell/src/app/components/icon/` | [`libs/shared/ui/src/lib/icon/`](libs/shared/ui/src/lib/icon/) |
| `LayoutStateService` (+ `ThemeMode` type) | `apps/portal-shell/src/app/state/` | [`libs/shared/state/src/lib/`](libs/shared/state/src/lib/) |
| Brand-palette `@theme` block | `apps/portal-shell/src/styles.css` | [`libs/shared/tokens/src/brand-tokens.css`](libs/shared/tokens/src/brand-tokens.css) |
## Notable changes
- **Icon selector renamed `app-icon` → `lib-icon`.** Required by the lib's ESLint `@angular-eslint/component-selector` rule (prefix `lib` for shared libs). All ~20 usages in `portal-shell` templates updated in one sweep.
- **New `shared-state` library** generated via `nx g @nx/angular:library libs/shared/state`. Mirrors the existing `feature-auth` / `shared-ui` shape (vite + Angular + spec setup). `tsconfig.base.json` path alias `shared-state` added by the generator.
- **Lib tags rebalanced** to satisfy the module-boundary lint rule:
- `shared-state`: new lib → `scope:shared, type:shared`.
- `shared-ui`: was `scope:portal-shell` (scaffold default) → `scope:shared, type:shared`.
- **`shared-tokens` is now CSS-only.** The placeholder TS function is gone; `index.ts` is an empty `export {}` with a TODO note. Vitest config flips `passWithNoTests: true` so the test target stops failing on the empty lib.
- **`portal-shell`'s `styles.css`** drops the inline `@theme {}` block and instead `@import`s `../../../libs/shared/tokens/src/brand-tokens.css`. Both apps will read the same source.
- Placeholder scaffolded files in `shared-ui/src/lib/shared-ui/` removed.
## Verification
- `nx run-many -t lint test build` across `portal-shell, shared-ui, shared-state, shared-tokens` — green.
- Tests redistributed: **portal-shell 28**, **shared-state 9**, **shared-ui 3**, **shared-tokens 0** = 40 total (same as before the move).
- Production build: **128.7 KB gzip** initial per locale (was 128.5 KB on `main` — noise-level delta).
- Brand-color CSS variables (`--color-brand-primary-500: #12546c`, …) still inlined in the produced `styles-*.css`.
- `dist/.../browser/{en,fr}/` emitted as expected.
## What this PR explicitly does NOT do
- Move other components (Header, Sidebar, Footer, ThemeSwitcher, LocaleSwitcher). Those are app-specific shell concerns; if `portal-admin` ends up needing them, they graduate in their own PR.
- Set up `portal-admin`. That's the next PR on the admin track — and now imports from `shared-ui` / `shared-state` / `shared-tokens` will Just Work.
- Refactor `feature-auth`'s tagging. It still says `scope:portal-shell`; revisit when the auth implementation actually lands and the dual-audience design (per ADR-0008) makes the right scope obvious.
## Test plan
- [x] Per-project run-many — green.
- [x] Production build emits both locales with brand tokens applied.
- [ ] Manual: `pnpm exec nx serve portal-shell` — the dev experience is unchanged.
- [ ] Manual: serve-static + locale switcher / theme switcher / sidebar collapse / navigation — all the behaviours the moved primitives drive still work in production.
---------
Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #99
This commit was merged in pull request #99.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { LayoutStateService, type ThemeMode } from './lib/layout-state.service';
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LayoutStateService } from './layout-state.service';
|
||||
|
||||
describe('LayoutStateService', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove('dark');
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
describe('sidebar state', () => {
|
||||
it('defaults to expanded when no preference is persisted', () => {
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
expect(service.sidebarCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it('initialises from the persisted preference', () => {
|
||||
localStorage.setItem('portal-shell:sidebar-collapsed', '1');
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
expect(service.sidebarCollapsed()).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleSidebar flips the value and persists it', () => {
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
expect(service.sidebarCollapsed()).toBe(false);
|
||||
|
||||
service.toggleSidebar();
|
||||
expect(service.sidebarCollapsed()).toBe(true);
|
||||
TestBed.tick();
|
||||
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
|
||||
|
||||
service.toggleSidebar();
|
||||
expect(service.sidebarCollapsed()).toBe(false);
|
||||
TestBed.tick();
|
||||
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('0');
|
||||
});
|
||||
|
||||
it('setSidebarCollapsed writes the explicit value', () => {
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
service.setSidebarCollapsed(true);
|
||||
expect(service.sidebarCollapsed()).toBe(true);
|
||||
TestBed.tick();
|
||||
expect(localStorage.getItem('portal-shell:sidebar-collapsed')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme state', () => {
|
||||
it('defaults to auto when no preference is persisted', () => {
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
expect(service.themeMode()).toBe('auto');
|
||||
});
|
||||
|
||||
it('initialises from the persisted theme mode', () => {
|
||||
localStorage.setItem('portal-shell:theme-mode', 'dark');
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
expect(service.themeMode()).toBe('dark');
|
||||
});
|
||||
|
||||
it('ignores an invalid persisted value and falls back to auto', () => {
|
||||
localStorage.setItem('portal-shell:theme-mode', 'sepia' as unknown as string);
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
expect(service.themeMode()).toBe('auto');
|
||||
});
|
||||
|
||||
it('setThemeMode persists the choice and toggles the .dark class on <html>', () => {
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
const html = document.documentElement;
|
||||
|
||||
service.setThemeMode('dark');
|
||||
TestBed.tick();
|
||||
expect(localStorage.getItem('portal-shell:theme-mode')).toBe('dark');
|
||||
expect(html.classList.contains('dark')).toBe(true);
|
||||
expect(service.effectiveTheme()).toBe('dark');
|
||||
|
||||
service.setThemeMode('light');
|
||||
TestBed.tick();
|
||||
expect(localStorage.getItem('portal-shell:theme-mode')).toBe('light');
|
||||
expect(html.classList.contains('dark')).toBe(false);
|
||||
expect(service.effectiveTheme()).toBe('light');
|
||||
});
|
||||
|
||||
it('resolves auto via the system preference (jsdom defaults to light)', () => {
|
||||
const service = TestBed.inject(LayoutStateService);
|
||||
service.setThemeMode('auto');
|
||||
TestBed.tick();
|
||||
expect(service.themeMode()).toBe('auto');
|
||||
// jsdom's matchMedia reports no `prefers-color-scheme: dark` match,
|
||||
// so 'auto' resolves to light. We assert the resolution path —
|
||||
// not the literal value of the system preference.
|
||||
expect(service.effectiveTheme()).toBe('light');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Shell-level layout state shared by the header and the sidebar.
|
||||
*
|
||||
* v1 surfaces two settings:
|
||||
* - `sidebarCollapsed`: whether the sidebar is collapsed to its
|
||||
* icon-only rail. The header reads it to size its logo zone in
|
||||
* lock-step with the sidebar.
|
||||
* - `themeMode`: the user's color-scheme preference among
|
||||
* `light` / `dark` / `auto`. `effectiveTheme` resolves it to a
|
||||
* concrete `'light' | 'dark'` by consulting the system preference
|
||||
* when in `auto` mode, and reacts to OS-level theme changes.
|
||||
* A side-effect toggles the `.dark` class on <html> so every
|
||||
* `dark:` Tailwind utility flips at once.
|
||||
*
|
||||
* Both pieces of state persist in `localStorage` so the user's
|
||||
* preferences survive a reload without a backend round-trip.
|
||||
*
|
||||
* Lives outside of the components that consume it so multiple
|
||||
* surfaces stay synchronised without prop-drilling, and so the
|
||||
* service is the natural home for future cross-cutting shell state
|
||||
* (density, RTL, panel pinning, ...).
|
||||
*/
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = 'portal-shell:sidebar-collapsed';
|
||||
const THEME_STORAGE_KEY = 'portal-shell:theme-mode';
|
||||
const DARK_CLASS = 'dark';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'auto';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LayoutStateService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
private readonly _sidebarCollapsed = signal(readPersistedSidebarCollapsed());
|
||||
private readonly _themeMode = signal<ThemeMode>(readPersistedThemeMode());
|
||||
private readonly _systemPrefersDark = signal(systemPrefersDark(this.document));
|
||||
|
||||
readonly sidebarCollapsed = this._sidebarCollapsed.asReadonly();
|
||||
readonly themeMode = this._themeMode.asReadonly();
|
||||
readonly effectiveTheme = computed<'light' | 'dark'>(() => {
|
||||
const mode = this._themeMode();
|
||||
if (mode === 'auto') {
|
||||
return this._systemPrefersDark() ? 'dark' : 'light';
|
||||
}
|
||||
return mode;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const value = this._sidebarCollapsed();
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, value ? '1' : '0');
|
||||
} catch {
|
||||
// localStorage can throw in private-mode contexts; the UI
|
||||
// still works, the preference just doesn't persist.
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const mode = this._themeMode();
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// See above.
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const theme = this.effectiveTheme();
|
||||
const root = this.document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add(DARK_CLASS);
|
||||
} else {
|
||||
root.classList.remove(DARK_CLASS);
|
||||
}
|
||||
});
|
||||
|
||||
this.watchSystemPreference();
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this._sidebarCollapsed.update((v) => !v);
|
||||
}
|
||||
|
||||
setSidebarCollapsed(value: boolean): void {
|
||||
this._sidebarCollapsed.set(value);
|
||||
}
|
||||
|
||||
setThemeMode(mode: ThemeMode): void {
|
||||
this._themeMode.set(mode);
|
||||
}
|
||||
|
||||
private watchSystemPreference(): void {
|
||||
const mql = this.document.defaultView?.matchMedia?.('(prefers-color-scheme: dark)');
|
||||
if (!mql) {
|
||||
return;
|
||||
}
|
||||
const listener = (e: MediaQueryListEvent) => this._systemPrefersDark.set(e.matches);
|
||||
// `addEventListener` is the modern API; older Safari only exposes
|
||||
// the deprecated `addListener` — guarded for robustness.
|
||||
if (typeof mql.addEventListener === 'function') {
|
||||
mql.addEventListener('change', listener);
|
||||
} else {
|
||||
mql.addListener(listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedSidebarCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readPersistedThemeMode(): ThemeMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (raw === 'light' || raw === 'dark' || raw === 'auto') {
|
||||
return raw;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function systemPrefersDark(doc: Document): boolean {
|
||||
return doc.defaultView?.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-snapshots';
|
||||
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||
|
||||
setupTestBed();
|
||||
Reference in New Issue
Block a user