feat(portal-shell): move theme switcher to footer, drop redundant settings icon #162
@@ -3,10 +3,9 @@
|
|||||||
aria-label="Page footer"
|
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"
|
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>
|
<p i18n="@@footer.copyright">© {{ year }} APF France handicap</p>
|
||||||
|
<span aria-hidden="true" class="text-gray-300 dark:text-gray-700">·</span>
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<app-locale-switcher />
|
|
||||||
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
|
<nav i18n-aria-label="@@footer.aria.legal" aria-label="Legal">
|
||||||
<a
|
<a
|
||||||
routerLink="/accessibility"
|
routerLink="/accessibility"
|
||||||
@@ -16,4 +15,9 @@
|
|||||||
>
|
>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<app-locale-switcher />
|
||||||
|
<app-theme-switcher />
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -42,4 +42,24 @@ describe('Footer', () => {
|
|||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
expect(fixture.nativeElement.querySelector('app-locale-switcher')).not.toBeNull();
|
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 { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { LocaleSwitcher } from '../locale-switcher/locale-switcher';
|
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.
|
* Thin full-width footer pinned to the bottom of the shell.
|
||||||
*
|
*
|
||||||
* v1 ships:
|
* Layout — two clusters separated by `justify-between`:
|
||||||
* - APF France handicap copyright with the current year.
|
* - **Left cluster** — APF France handicap copyright + accessibility
|
||||||
* - Locale switcher (FR / EN) — per ADR-0019, hard-refreshes to
|
* statement link, joined by a typographic separator. Static
|
||||||
* the matching `/fr/...` or `/en/...` prefix.
|
* informational content that anchors the page (who owns it, where
|
||||||
* - Accessibility statement link — single i18n-marked link
|
* to find legal info).
|
||||||
* pointing to the canonical `/accessibility` path.
|
* - **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
|
* A dev-only version badge lands here once `SERVICE_VERSION` is wired
|
||||||
* dev-only version badge lands here once `SERVICE_VERSION` is wired
|
|
||||||
* to a build-time injection.
|
* to a build-time injection.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-footer',
|
selector: 'app-footer',
|
||||||
imports: [RouterLink, LocaleSwitcher],
|
imports: [RouterLink, LocaleSwitcher, ThemeSwitcher],
|
||||||
templateUrl: './footer.html',
|
templateUrl: './footer.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,16 +61,6 @@
|
|||||||
>
|
>
|
||||||
<lib-icon name="circle-help" />
|
<lib-icon name="circle-help" />
|
||||||
</button>
|
</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') {
|
@switch (authState().kind) { @case ('loading') {
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|||||||
@@ -96,17 +96,23 @@ describe('Header', () => {
|
|||||||
expect(label).not.toBeNull();
|
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 { fixture } = await setup({ onBootstrap: 'anonymous' });
|
||||||
const root = fixture.nativeElement as HTMLElement;
|
const root = fixture.nativeElement as HTMLElement;
|
||||||
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
|
expect(root.querySelector('button[aria-label="Notifications"]')).not.toBeNull();
|
||||||
expect(root.querySelector('button[aria-label="Help"]')).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' });
|
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 () => {
|
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 { LayoutStateService } from 'shared-state';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { CapabilitiesService } from '../../services/capabilities.service';
|
import { CapabilitiesService } from '../../services/capabilities.service';
|
||||||
import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-of-page banner.
|
* Top-of-page banner.
|
||||||
@@ -18,18 +17,19 @@ import { ThemeSwitcher } from '../theme-switcher/theme-switcher';
|
|||||||
* `LayoutStateService`.
|
* `LayoutStateService`.
|
||||||
* - Global search input (placeholder; wired to a real search service
|
* - Global search input (placeholder; wired to a real search service
|
||||||
* once we have content to query).
|
* once we have content to query).
|
||||||
* - Action cluster: notifications, help, settings, and a user
|
* - Action cluster: notifications, help, and the user widget. The
|
||||||
* widget. The widget renders one of three states — loading,
|
* widget renders one of three states — loading, anonymous
|
||||||
* anonymous (a "Sign in" button), authenticated (avatar with
|
* (a "Sign in" button), authenticated (avatar dropdown) — driven
|
||||||
* initials + display name + "Sign out" button) — driven by
|
* by `AuthService` (ADR-0009). Theme + locale switchers live in
|
||||||
* `AuthService` (ADR-0009).
|
* 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
|
* Lives at app level (not in libs/shared/ui/) on purpose: one consumer
|
||||||
* for now. Promotion when a second app needs it.
|
* for now. Promotion when a second app needs it.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
imports: [RouterLink, Icon, ThemeSwitcher, UserMenu],
|
imports: [RouterLink, Icon, UserMenu],
|
||||||
templateUrl: './header.html',
|
templateUrl: './header.html',
|
||||||
styleUrl: './header.scss',
|
styleUrl: './header.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -44,10 +44,6 @@
|
|||||||
<source>Notifications</source>
|
<source>Notifications</source>
|
||||||
<target>Notifications</target>
|
<target>Notifications</target>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="header.authError" datatype="html">
|
||||||
<source>Can't reach the server</source>
|
<source>Can't reach the server</source>
|
||||||
<target>Serveur injoignable</target>
|
<target>Serveur injoignable</target>
|
||||||
|
|||||||
Reference in New Issue
Block a user