feat(portal-shell): move theme switcher to footer, drop redundant settings icon (#162)
## Summary Three coupled chrome adjustments in `portal-shell`'s shell layout, all motivated by the same realisation that the UserMenu introduced in #149 made the header's standalone Settings icon redundant and surfaced an inconsistency in the theme switcher's placement (header for everyone, soon to be split between header and user-menu depending on auth state). ## What lands ### 1. Drop the standalone Settings button from the header The UserMenu already carries a "Settings (Soon)" row for authenticated users, and Settings has no meaning for anonymous traffic. The header icon pointed nowhere; keeping it doubled the surface and forced readers to guess which control to use. `header.action.settings` is removed from [`messages.fr.xlf`](apps/portal-shell/src/locale/messages.fr.xlf) in the same step — the i18n-strict prod build is now back in sync with the source. ### 2. Move `<app-theme-switcher>` from the header to the footer The switcher now lives beside `<app-locale-switcher>` in a single **device-prefs cluster** on the right side of the footer. Two reasons: - **Consistency across auth states.** The alternative — keep it in the header for anonymous, move it into the user-menu once authenticated — was the original temptation. Rejected: changing the location of an identical control depending on whether the user is signed in violates Jakob's law and breaks muscle memory. Especially bad for an a11y-first platform per [ADR-0016](docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md): a reader who relies on dark mode shouldn't have to expand a menu they don't yet recognise on first visit to escape a flashing white background. - **Precedent.** [ADR-0019](docs/decisions/0019-internationalisation-angular-localize.md) already established the footer as the home for ambient device preferences (locale switcher). Theme is the obvious sibling — same family (per-device, non-identity). ### 3. Reshape the footer into two semantic clusters ``` [© APF France handicap · Accessibility statement] [locale • theme] ←——— info / legal ———→ ←—— device prefs ——→ ``` The Accessibility statement link moves from the right cluster (where it sat alongside the locale switcher) to the left, joining the copyright with a typographic separator (`·`). Left = static info / legal anchor; right = interactive prefs the reader controls. Keyboard order naturally follows: a Tab-traversal from the main content hits the informational anchor before the interactive controls — a small a11y win. ## What I left alone - **`portal-admin`'s header / footer.** No theme switcher there in v1 (the admin chrome is brand-primary-600 hardcoded); no settings icon either. ADR-0020 explicitly trims that admin chrome relative to the user shell — nothing to align right now. - **The user-menu's "Settings (Soon)" row.** That stays. PR 2 of the auth chantier ([#150](#150)) wired the row at the request of the chantier's staging; the Settings page itself lands in a future chantier and the row's pre-existing "Soon" badge keeps the affordance honest. ## Notes for the reviewer - **Why not also a "Theme: <current>" hint inside the user-menu?** Considered, deferred. The current Hint would be ornament without a corresponding *target*; once the Settings page exists and the theme has a settings sub-section, a "Theme: System" line in the user-menu makes sense as a deep-link affordance. Not before. - **Why a `·` text separator rather than a CSS border?** Border would force vertical alignment math (height, dark-mode color) for one line of footer text; a typographic mid-dot inherits text color, scales with the line, and is `aria-hidden` so screen readers don't read it as "middle dot". Smaller surface for one less moving piece. - **A11y check**: the left cluster's `<nav aria-label="Legal">` is preserved verbatim (just moved into the left `<div>` rather than the right). The accessibility statement keeps the same focus styles, keyboard behavior, and routerLink target. ## Test plan - [x] `pnpm nx test portal-shell` — **43 specs pass** (was 40; +3 net: dropped a Settings-button assertion + the "embeds theme switcher" header assertion swapped to its inverse, added two footer assertions for the theme switcher's new home + the cluster grouping). - [x] `pnpm nx run-many -t lint test build --projects=portal-shell` — green. - [x] `pnpm nx build portal-shell --configuration=production` — i18n-strict prod build passes, confirming the `header.action.settings` xlf removal didn't leave a dangling source-locale reference. - [ ] **Manual visual smoke**: open `pnpm nx serve portal-shell` in a browser, confirm: - The Settings cog is gone from the header. - The theme-switcher (sun/moon chip) now lives at the bottom-right of the footer, beside the locale chip. - Bottom-left of the footer reads `© 2026 APF France handicap · Accessibility statement`, the dot being decorative (no link). - Tabbing from the main content lands first on the accessibility link, then on the locale switcher, then on the theme switcher. - Toggling the theme still works as before, and toggling it persists across navigations and reloads (the underlying ThemeService is untouched). ## Follow-ups (optional) - The header still carries the global search form + notifications + help buttons. None of those are wired to live data yet — when they get real consumers, the equivalent "double-surface" check applies (is there a duplicate path in the user-menu? a redundant trigger?). For now the placeholder shapes are useful to anchor the layout. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #162
This commit was merged in pull request #162.
This commit is contained in:
@@ -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">© {{ 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>
|
||||
|
||||
Reference in New Issue
Block a user