refactor(libs): graduate Icon / LayoutStateService / brand tokens to libs/shared/*
Per ADR-0020 §"Shared-libs graduation": the three primitives that
both `portal-shell` (today) and `portal-admin` (next) will share
move out of `apps/portal-shell/src/` and into the workspace libs.
Mechanical move — no behaviour change.
Graduated:
- `Icon` component → `libs/shared/ui/src/lib/icon/`. Selector
renamed `app-icon` → `lib-icon` (consumed via `<lib-icon>`),
`IconName` re-exported from `shared-ui`.
- `LayoutStateService` (+ `ThemeMode` type) →
`libs/shared/state/src/lib/`. New library generated via
`nx g @nx/angular:library` to match the existing
`feature-auth` / `shared-ui` shape.
- Brand-palette `@theme` block →
`libs/shared/tokens/src/brand-tokens.css`. `portal-shell`'s
`styles.css` imports it by relative path.
Lib tags updated so the module-boundary lint rule lets both apps
consume them:
- `shared-state`: new lib → `scope:shared, type:shared`.
- `shared-ui`: was `scope:portal-shell` (stopgap from the initial
scaffold) → `scope:shared, type:shared`.
Side-edits:
- `tsconfig.base.json` gains the `shared-state` path alias
(generator-applied).
- `shared-tokens` is now CSS-only — its placeholder TS function is
removed and its vitest config flips `passWithNoTests: true` since
there is nothing to test there yet.
- Placeholder `shared-ui/shared-ui.{ts,html,css,spec.ts}` files
removed.
Verification:
- Lint / test / build run-many across the four projects — green.
- portal-shell: 28 tests, shared-state: 9, shared-ui: 3 (= the same
40 specs as before, redistributed across the new libs).
- Production build: 128.7 KB gzip initial per locale (was 128.5 KB
on main — noise-level delta).
- Brand tokens still inlined in the produced `styles-*.css`.
- Both `dist/.../browser/{en,fr}/` emitted as expected.
No public-API breakage. The migration to `portal-admin` is now a
clean `import { Icon } from 'shared-ui'` + `LayoutStateService`
inject + `@import` of `brand-tokens.css` on admin's `styles.css` —
no duplication.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# shared-state
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test shared-state` to execute the unit tests.
|
||||
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'lib',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'lib',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "shared-state",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/state/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "type:shared"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/test-setup.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/libs/shared/state',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: () => [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
name: 'shared-state',
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/shared/state',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Brand palette — APF charte graphique.
|
||||
*
|
||||
* primary: #12546c (dark teal) — header bar, sidebar active items,
|
||||
* primary buttons, headings on light bg
|
||||
* accent: #f7a919 (warm orange) — CTAs, highlights, focus rings on
|
||||
* branded surfaces
|
||||
*
|
||||
* The two scales below are hand-tuned (each step roughly halves the
|
||||
* delta from the previous toward white / black). Tailwind v4 surfaces
|
||||
* them automatically as utility classes — `bg-brand-primary-700`,
|
||||
* `text-brand-accent-500`, `ring-brand-primary-500`, etc.
|
||||
*
|
||||
* Use the `500` step as the canonical brand value; the lighter / darker
|
||||
* steps are for hover / focus / muted text without dropping out of the
|
||||
* brand family.
|
||||
*
|
||||
* Owned by `libs/shared/tokens` so every app in the workspace
|
||||
* (`portal-shell`, `portal-admin`) reads from the same source. To
|
||||
* consume, `@import` this file from the app's entry stylesheet.
|
||||
*/
|
||||
@theme {
|
||||
--color-brand-primary-50: #eaf3f7;
|
||||
--color-brand-primary-100: #cae0e8;
|
||||
--color-brand-primary-200: #9ac4d2;
|
||||
--color-brand-primary-300: #5fa2b6;
|
||||
--color-brand-primary-400: #2f7f96;
|
||||
--color-brand-primary-500: #12546c;
|
||||
--color-brand-primary-600: #0f475c;
|
||||
--color-brand-primary-700: #0c394a;
|
||||
--color-brand-primary-800: #082a36;
|
||||
--color-brand-primary-900: #051c24;
|
||||
--color-brand-primary-950: #020e12;
|
||||
|
||||
--color-brand-accent-50: #fef5e0;
|
||||
--color-brand-accent-100: #fce6b3;
|
||||
--color-brand-accent-200: #fad17a;
|
||||
--color-brand-accent-300: #f8bb47;
|
||||
--color-brand-accent-400: #f7a919;
|
||||
--color-brand-accent-500: #de9415;
|
||||
--color-brand-accent-600: #b97a11;
|
||||
--color-brand-accent-700: #93600d;
|
||||
--color-brand-accent-800: #6e4709;
|
||||
--color-brand-accent-900: #4a3006;
|
||||
--color-brand-accent-950: #271903;
|
||||
}
|
||||
@@ -1 +1,7 @@
|
||||
export * from './lib/shared-tokens';
|
||||
// `shared-tokens` is currently CSS-only — see `./brand-tokens.css`,
|
||||
// which apps import from their entry stylesheet (e.g.
|
||||
// `apps/portal-shell/src/styles.css`). The TS module exists so the
|
||||
// Nx lib boilerplate / package layout stays uniform with the other
|
||||
// shared libs; expand when a typed token API (e.g. CSS-variable
|
||||
// name constants for runtime lookups) is genuinely needed.
|
||||
export {};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { sharedTokens } from './shared-tokens';
|
||||
|
||||
describe('sharedTokens', () => {
|
||||
it('should work', () => {
|
||||
expect(sharedTokens()).toEqual('shared-tokens');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
export function sharedTokens(): string {
|
||||
return 'shared-tokens';
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export default defineConfig(() => ({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
// CSS-only library — no TS to test yet. Pass instead of failing
|
||||
// the gate; revisit when a typed token API lands.
|
||||
passWithNoTests: true,
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/libs/shared/tokens',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"sourceRoot": "libs/shared/ui/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:portal-shell", "type:shared"],
|
||||
"tags": ["scope:shared", "type:shared"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './lib/shared-ui/shared-ui';
|
||||
export { Icon, type IconName } from './lib/icon/icon';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Icon } from './icon';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-icon-test-host',
|
||||
imports: [Icon],
|
||||
template: `<lib-icon [name]="name" [size]="size" [strokeWidth]="strokeWidth" />`,
|
||||
})
|
||||
class IconTestHost {
|
||||
name: 'home' | 'bell' = 'home';
|
||||
size = 20;
|
||||
strokeWidth = 1.5;
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({ imports: [IconTestHost] }).compileComponents();
|
||||
});
|
||||
|
||||
it('renders a lucide <svg> for the given name', async () => {
|
||||
const fixture = TestBed.createComponent(IconTestHost);
|
||||
await fixture.whenStable();
|
||||
const svg = fixture.nativeElement.querySelector('lib-icon svg') as SVGElement | null;
|
||||
expect(svg).not.toBeNull();
|
||||
expect(svg?.classList.contains('lucide')).toBe(true);
|
||||
});
|
||||
|
||||
it('marks the lucide host as decorative (aria-hidden)', async () => {
|
||||
const fixture = TestBed.createComponent(IconTestHost);
|
||||
await fixture.whenStable();
|
||||
const host = fixture.nativeElement.querySelector(
|
||||
'lib-icon lucide-angular',
|
||||
) as HTMLElement | null;
|
||||
expect(host?.getAttribute('aria-hidden')).toBe('true');
|
||||
});
|
||||
|
||||
it('propagates size and strokeWidth to the underlying <svg>', async () => {
|
||||
const fixture = TestBed.createComponent(IconTestHost);
|
||||
fixture.componentInstance.size = 32;
|
||||
fixture.componentInstance.strokeWidth = 2;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const svg = fixture.nativeElement.querySelector('lib-icon svg') as SVGElement | null;
|
||||
expect(svg?.getAttribute('width')).toBe('32');
|
||||
expect(svg?.getAttribute('height')).toBe('32');
|
||||
expect(svg?.getAttribute('stroke-width')).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||
import {
|
||||
Accessibility,
|
||||
Bell,
|
||||
Building2,
|
||||
Calendar,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
FileText,
|
||||
Folder,
|
||||
Globe,
|
||||
Grid2x2,
|
||||
House,
|
||||
LayoutDashboard,
|
||||
type LucideIconData,
|
||||
LucideAngularModule,
|
||||
Mail,
|
||||
Monitor,
|
||||
Moon,
|
||||
Search,
|
||||
Settings,
|
||||
Store,
|
||||
Sun,
|
||||
UsersRound,
|
||||
Wrench,
|
||||
} from 'lucide-angular';
|
||||
|
||||
/**
|
||||
* Icon façade — single point of truth for the icon set across every
|
||||
* app in the workspace (`portal-shell` today, `portal-admin` next).
|
||||
*
|
||||
* v1 is backed by `lucide-angular`. The migration to an in-house
|
||||
* icomoon-generated set (sprite SVG) is a single-file change to
|
||||
* THIS component — every consumer just writes `<lib-icon name="…">`,
|
||||
* so swapping the implementation never touches them.
|
||||
*
|
||||
* Convention: `name` is the lowercase, kebab-cased "logical" name we
|
||||
* will keep across implementations (`home`, `bar-chart`, etc.). The
|
||||
* registry below maps each logical name to its lucide-angular icon
|
||||
* data. When the icomoon export lands, each name will instead map to
|
||||
* a `<symbol id="icon-…">` in the sprite — the template-side
|
||||
* contract stays identical.
|
||||
*/
|
||||
|
||||
// Adding an icon to the registry: import the lucide pascal-case name
|
||||
// above (Tailwind eslint sorts the import block), then add a single
|
||||
// `'kebab-name': PascalImport,` line below. Both lists are alphabetical
|
||||
// so contributors don't have to think about placement.
|
||||
const ICON_REGISTRY = {
|
||||
accessibility: Accessibility,
|
||||
bell: Bell,
|
||||
'building-2': Building2,
|
||||
calendar: Calendar,
|
||||
check: Check,
|
||||
'chevron-down': ChevronDown,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevron-right': ChevronRight,
|
||||
'circle-help': CircleHelp,
|
||||
'file-text': FileText,
|
||||
folder: Folder,
|
||||
globe: Globe,
|
||||
'grid-2x2': Grid2x2,
|
||||
home: House,
|
||||
'layout-dashboard': LayoutDashboard,
|
||||
mail: Mail,
|
||||
monitor: Monitor,
|
||||
moon: Moon,
|
||||
search: Search,
|
||||
settings: Settings,
|
||||
store: Store,
|
||||
sun: Sun,
|
||||
'users-round': UsersRound,
|
||||
wrench: Wrench,
|
||||
} satisfies Record<string, LucideIconData>;
|
||||
|
||||
export type IconName = keyof typeof ICON_REGISTRY;
|
||||
|
||||
@Component({
|
||||
selector: 'lib-icon',
|
||||
imports: [LucideAngularModule],
|
||||
template: `<lucide-angular
|
||||
[img]="icon()"
|
||||
[size]="size()"
|
||||
[strokeWidth]="strokeWidth()"
|
||||
aria-hidden="true"
|
||||
/>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Icon {
|
||||
readonly name = input.required<IconName>();
|
||||
readonly size = input<number>(20);
|
||||
/** Lucide draws with stroke-width 2 by default; 1.5 reads slightly
|
||||
* lighter and matches the visual weight of most icomoon sets. */
|
||||
readonly strokeWidth = input<number>(1.5);
|
||||
|
||||
protected readonly icon = computed(() => ICON_REGISTRY[this.name()]);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<p>SharedUi works!</p>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { SharedUi } from './shared-ui';
|
||||
|
||||
describe('SharedUi', () => {
|
||||
let component: SharedUi;
|
||||
let fixture: ComponentFixture<SharedUi>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [SharedUi],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(SharedUi);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-shared-ui',
|
||||
imports: [],
|
||||
templateUrl: './shared-ui.html',
|
||||
styleUrl: './shared-ui.css',
|
||||
})
|
||||
export class SharedUi {}
|
||||
Reference in New Issue
Block a user