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:
@@ -29,7 +29,7 @@
|
||||
<div
|
||||
class="flex w-full max-w-xl items-center gap-2 rounded-full border border-gray-200 bg-gray-50 px-4 py-2 focus-within:border-brand-primary-500 focus-within:bg-white focus-within:ring-2 focus-within:ring-brand-primary-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-brand-primary-300 dark:focus-within:bg-gray-900 dark:focus-within:ring-brand-primary-700"
|
||||
>
|
||||
<app-icon name="search" [size]="18" />
|
||||
<lib-icon name="search" [size]="18" />
|
||||
<input
|
||||
id="global-search"
|
||||
type="search"
|
||||
@@ -47,7 +47,7 @@
|
||||
i18n-aria-label="@@header.action.notifications"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<app-icon name="bell" />
|
||||
<lib-icon name="bell" />
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="absolute right-2 top-2 h-2 w-2 rounded-full bg-brand-accent-400 ring-2 ring-white dark:ring-gray-900"
|
||||
@@ -59,7 +59,7 @@
|
||||
i18n-aria-label="@@header.action.help"
|
||||
aria-label="Help"
|
||||
>
|
||||
<app-icon name="circle-help" />
|
||||
<lib-icon name="circle-help" />
|
||||
</button>
|
||||
<app-theme-switcher />
|
||||
<button
|
||||
@@ -68,7 +68,7 @@
|
||||
i18n-aria-label="@@header.action.settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<app-icon name="settings" />
|
||||
<lib-icon name="settings" />
|
||||
</button>
|
||||
<span
|
||||
i18n-aria-label="@@header.action.userMenu"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { Header } from './header';
|
||||
import { LayoutStateService } from '../../state/layout-state.service';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Icon } from '../icon/icon';
|
||||
import { Icon } from 'shared-ui';
|
||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
||||
import { LayoutStateService } from '../../state/layout-state.service';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
|
||||
/**
|
||||
* Top-of-page banner.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Icon } from './icon';
|
||||
|
||||
@Component({
|
||||
selector: 'app-icon-test-host',
|
||||
imports: [Icon],
|
||||
template: `<app-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('app-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(
|
||||
'app-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('app-icon svg') as SVGElement | null;
|
||||
expect(svg?.getAttribute('width')).toBe('32');
|
||||
expect(svg?.getAttribute('height')).toBe('32');
|
||||
expect(svg?.getAttribute('stroke-width')).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
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 for the SPA.
|
||||
*
|
||||
* v1 is backed by `lucide-angular`. The migration to an in-house
|
||||
* icomoon-generated set (sprite SVG under
|
||||
* `apps/portal-shell/public/icons/`) is a single-file change to
|
||||
* THIS component — every consumer just writes `<app-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 — but 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: 'app-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()]);
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
[cdkMenuTriggerFor]="localeMenu"
|
||||
[attr.aria-label]="triggerAriaLabel"
|
||||
>
|
||||
<app-icon name="globe" [size]="14" />
|
||||
<lib-icon name="globe" [size]="14" />
|
||||
<span>{{ currentDisplayName }}</span>
|
||||
<app-icon name="chevron-down" [size]="12" />
|
||||
<lib-icon name="chevron-down" [size]="12" />
|
||||
</button>
|
||||
|
||||
<ng-template #localeMenu>
|
||||
@@ -30,7 +30,7 @@
|
||||
>
|
||||
<span class="locale-switcher__label">{{ locale.displayName }}</span>
|
||||
@if (locale.code === currentCode) {
|
||||
<app-icon name="check" [size]="16" />
|
||||
<lib-icon name="check" [size]="16" />
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu';
|
||||
import { ChangeDetectionStrategy, Component, ViewEncapsulation, inject } from '@angular/core';
|
||||
import { Icon } from '../icon/icon';
|
||||
import { Icon } from 'shared-ui';
|
||||
|
||||
/**
|
||||
* Locale switcher rendered in the footer.
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
[attr.aria-label]="collapsed() ? item.label : null"
|
||||
[title]="collapsed() ? item.label : ''"
|
||||
>
|
||||
<app-icon [name]="item.icon" />
|
||||
<lib-icon [name]="item.icon" />
|
||||
@if (!collapsed()) {
|
||||
<span class="sidebar__label">{{ item.label }}</span>
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
[attr.aria-label]="collapsed() ? item.label : null"
|
||||
[title]="collapsed() ? item.label : ''"
|
||||
>
|
||||
<app-icon [name]="item.icon" />
|
||||
<lib-icon [name]="item.icon" />
|
||||
@if (!collapsed()) {
|
||||
<span class="sidebar__label">{{ item.label }}</span>
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
[attr.aria-label]="toggleAriaLabel()"
|
||||
>
|
||||
<app-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
|
||||
<lib-icon [name]="collapsed() ? 'chevron-right' : 'chevron-left'" />
|
||||
@if (!collapsed()) {
|
||||
<span class="sidebar__label" i18n="@@sidebar.toggle.collapse">Collapse</span>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { Icon, type IconName } from '../icon/icon';
|
||||
import { LayoutStateService } from '../../state/layout-state.service';
|
||||
import { Icon, type IconName } from 'shared-ui';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
|
||||
/**
|
||||
* Primary side navigation.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[cdkMenuTriggerFor]="themeMenu"
|
||||
[attr.aria-label]="triggerAriaLabel()"
|
||||
>
|
||||
<app-icon [name]="currentIcon()" />
|
||||
<lib-icon [name]="currentIcon()" />
|
||||
</button>
|
||||
|
||||
<ng-template #themeMenu>
|
||||
@@ -25,10 +25,10 @@
|
||||
role="menuitemradio"
|
||||
(click)="select(option.mode)"
|
||||
>
|
||||
<app-icon [name]="option.icon" />
|
||||
<lib-icon [name]="option.icon" />
|
||||
<span class="theme-switcher__label">{{ option.label }}</span>
|
||||
@if (mode() === option.mode) {
|
||||
<app-icon name="check" [size]="16" />
|
||||
<lib-icon name="check" [size]="16" />
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LayoutStateService } from '../../state/layout-state.service';
|
||||
import { LayoutStateService } from 'shared-state';
|
||||
import { ThemeSwitcher } from './theme-switcher';
|
||||
|
||||
describe('ThemeSwitcher', () => {
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Icon, type IconName } from '../icon/icon';
|
||||
import { LayoutStateService, type ThemeMode } from '../../state/layout-state.service';
|
||||
import { Icon, type IconName } from 'shared-ui';
|
||||
import { LayoutStateService, type ThemeMode } from 'shared-state';
|
||||
|
||||
/**
|
||||
* Light / dark / auto color-scheme switcher rendered in the header.
|
||||
|
||||
Reference in New Issue
Block a user