feat(portal-shell): move theme switcher to footer, drop redundant settings icon #162

Merged
julien merged 1 commits from feat/portal-shell-theme-switcher-to-footer into main 2026-05-16 01:14:37 +02:00
7 changed files with 60 additions and 38 deletions
@@ -3,10 +3,9 @@
aria-label="Page footer"
class="flex h-10 shrink-0 items-center justify-between border-t border-gray-200 bg-white px-4 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 sm:px-6"
>
<div class="flex items-center gap-3">
<p i18n="@@footer.copyright">&copy; {{ year }} APF France handicap</p>
<div class="flex items-center gap-4">
<app-locale-switcher />
<span aria-hidden="true" class="text-gray-300 dark:text-gray-700">·</span>
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
<a
routerLink="/accessibility"
@@ -16,4 +15,9 @@
>
</nav>
</div>
<div class="flex items-center gap-4">
<app-locale-switcher />
<app-theme-switcher />
</div>
</footer>
@@ -42,4 +42,24 @@ describe('Footer', () => {
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-locale-switcher')).not.toBeNull();
});
it('embeds the theme switcher (moved here from the header)', async () => {
const fixture = TestBed.createComponent(Footer);
await fixture.whenStable();
expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
});
it('groups copyright + accessibility on the left, switchers on the right', async () => {
const fixture = TestBed.createComponent(Footer);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const clusters = Array.from(root.querySelectorAll('footer > div'));
expect(clusters).toHaveLength(2);
// Left cluster — info / legal.
expect(clusters[0]?.querySelector('p')?.textContent).toContain('APF France handicap');
expect(clusters[0]?.querySelector('a[href="/accessibility"]')).not.toBeNull();
// Right cluster — device preferences.
expect(clusters[1]?.querySelector('app-locale-switcher')).not.toBeNull();
expect(clusters[1]?.querySelector('app-theme-switcher')).not.toBeNull();
});
});
@@ -1,24 +1,30 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { LocaleSwitcher } from '../locale-switcher/locale-switcher';
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
/**
* Thin full-width footer pinned to the bottom of the shell.
*
* v1 ships:
* - APF France handicap copyright with the current year.
* - Locale switcher (FR / EN) — per ADR-0019, hard-refreshes to
* the matching `/fr/...` or `/en/...` prefix.
* - Accessibility statement link — single i18n-marked link
* pointing to the canonical `/accessibility` path.
* Layout — two clusters separated by `justify-between`:
* - **Left cluster** — APF France handicap copyright + accessibility
* statement link, joined by a typographic separator. Static
* informational content that anchors the page (who owns it, where
* to find legal info).
* - **Right cluster** — device preferences the reader controls:
* locale switcher (per ADR-0019) and theme switcher (light / dark
* / system). The theme switcher is in the footer rather than the
* header so the same trigger location works for anonymous and
* authenticated readers; moving it between auth states would
* break consistency for a control whose purpose (changing read
* conditions) is identical across both.
*
* The footer is reserved for cross-cutting page furniture. A
* dev-only version badge lands here once `SERVICE_VERSION` is wired
* A dev-only version badge lands here once `SERVICE_VERSION` is wired
* to a build-time injection.
*/
@Component({
selector: 'app-footer',
imports: [RouterLink, LocaleSwitcher],
imports: [RouterLink, LocaleSwitcher, ThemeSwitcher],
templateUrl: './footer.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -61,16 +61,6 @@
>
<lib-icon name="circle-help" />
</button>
<app-theme-switcher />
<button
type="button"
class="inline-flex h-11 w-11 items-center justify-center rounded-full text-gray-600 transition-colors hover:bg-gray-100 hover:text-brand-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-primary-500 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-brand-primary-300"
i18n-aria-label="@@header.action.settings"
aria-label="Settings"
>
<lib-icon name="settings" />
</button>
@switch (authState().kind) { @case ('loading') {
<span
aria-hidden="true"
@@ -96,17 +96,23 @@ describe('Header', () => {
expect(label).not.toBeNull();
});
it('renders the notifications / help / settings action buttons', async () => {
it('renders the notifications / help action buttons', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
const root = fixture.nativeElement as HTMLElement;
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
expect(root.querySelector('button[aria-label="Help"]')).not.toBeNull();
expect(root.querySelector('button[aria-label="Settings"]')).not.toBeNull();
});
it('embeds the theme switcher', async () => {
it('no longer embeds the theme switcher (moved to the footer)', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
expect(fixture.nativeElement.querySelector('app-theme-switcher')).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-theme-switcher')).toBeNull();
});
it('no longer embeds a standalone Settings button (UserMenu carries it)', async () => {
const { fixture } = await setup({ onBootstrap: 'anonymous' });
expect(
(fixture.nativeElement as HTMLElement).querySelector('button[aria-label="Settings"]'),
).toBeNull();
});
it('renders the logo zone, expanded with wordmark by default', async () => {
@@ -5,7 +5,6 @@ import { Icon, UserMenu, type UserMenuItem } from 'shared-ui';
import { LayoutStateService } from 'shared-state';
import { environment } from '../../../environments/environment';
import { CapabilitiesService } from '../../services/capabilities.service';
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
/**
* Top-of-page banner.
@@ -18,18 +17,19 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
* `LayoutStateService`.
* - Global search input (placeholder; wired to a real search service
* once we have content to query).
* - Action cluster: notifications, help, settings, and a user
* widget. The widget renders one of three states — loading,
* anonymous (a "Sign in" button), authenticated (avatar with
* initials + display name + "Sign out" button) — driven by
* `AuthService` (ADR-0009).
* - Action cluster: notifications, help, and the user widget. The
* widget renders one of three states — loading, anonymous
* (a "Sign in" button), authenticated (avatar dropdown) — driven
* by `AuthService` (ADR-0009). Theme + locale switchers live in
* the footer alongside the accessibility statement so a single
* "device-prefs" cluster carries them across auth states.
*
* Lives at app level (not in libs/shared/ui/) on purpose: one consumer
* for now. Promotion when a second app needs it.
*/
@Component({
selector: 'app-header',
imports: [RouterLink, Icon, ThemeSwitcher, UserMenu],
imports: [RouterLink, Icon, UserMenu],
templateUrl: './header.html',
styleUrl: './header.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -44,10 +44,6 @@
<source>Notifications</source>
<target>Notifications</target>
</trans-unit>
<trans-unit id="header.action.settings" datatype="html">
<source>Settings</source>
<target>Paramètres</target>
</trans-unit>
<trans-unit id="header.authError" datatype="html">
<source>Can't reach the server</source>
<target>Serveur injoignable</target>