From 8f6ebdafb1a00dada032ffe303639d7cd3c9f96d Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Fri, 21 Aug 2026 12:34:00 +0300 Subject: [PATCH] feat(a11y): add skip link for improved navigation accessibility --- apps/backoffice/src/app/i18n/locales/am.ts | 4 ++ apps/backoffice/src/app/i18n/locales/en.ts | 5 +++ .../src/app/layouts/BackofficeLayout.tsx | 8 +++- apps/e2e/visual/theme.spec.ts | 39 +++++++++++++++++++ apps/portal/src/app/i18n/locales/am.ts | 4 ++ apps/portal/src/app/i18n/locales/en.ts | 5 +++ apps/portal/src/app/layouts/PortalLayout.tsx | 9 ++++- libs/ui/src/index.ts | 1 + libs/ui/src/lib/dev/ThemeGallery.tsx | 8 +++- libs/ui/src/lib/feedback/PageLoader.tsx | 9 ++++- libs/ui/src/lib/layout/SkipLink.tsx | 27 +++++++++++++ 11 files changed, 114 insertions(+), 5 deletions(-) create mode 100644 libs/ui/src/lib/layout/SkipLink.tsx diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 6fe440ddc..bf3dc571f 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -11,6 +11,10 @@ export const am: Translations = { tagline: "የቁጥጥር ማዕከል", }, + a11y: { + skipToContent: "ወደ ዋናው ይዘት ዝለል", + }, + msg: { genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።", serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 30c53417b..650c3a8dc 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -10,6 +10,11 @@ export const en = { tagline: 'Control Center', }, + // Strings only assistive technology encounters. + a11y: { + skipToContent: 'Skip to main content', + }, + msg: { genericError: 'Something went wrong. Please try again.', serverError: 'Server error. Please try again later.', diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx index de599a69a..57379323f 100644 --- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx +++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx @@ -8,6 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui'; import type { NavItem, NavSection } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui'; import { AppTopNav, filterByPermissions } from '@ema-platform/ui'; +import { SkipLink, MAIN_CONTENT_ID } from '@ema-platform/ui'; import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api'; import { usePermissions } from '@ema-platform/auth'; import { SUPPORTED_LANGUAGES } from '../i18n/config'; @@ -143,6 +144,10 @@ export function BackofficeLayout() { const isSidebar = layoutMode === "sidebar"; return ( + <> + {/* First focusable element on the page, so a keyboard user can bypass + the 20-plus nav items instead of tabbing through them every time. */} + )} - +
@@ -263,5 +268,6 @@ export function BackofficeLayout() { )}
+ ); } diff --git a/apps/e2e/visual/theme.spec.ts b/apps/e2e/visual/theme.spec.ts index 0618fa396..d0392de81 100644 --- a/apps/e2e/visual/theme.spec.ts +++ b/apps/e2e/visual/theme.spec.ts @@ -98,3 +98,42 @@ for (const app of APPS) { await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`); }); } + +/** + * The skip link, on a real app shell. + * + * Not on the gallery route: the point of a skip link is bypassing the nav, and + * the gallery has none. The login page is the shell-less public route both apps + * share, so this uses the landing route instead — it carries the chrome without + * needing a session. + * + * A skip link is invisible until focused, which means a broken one and a + * working one look identical in every screenshot. Only a focus test separates + * them. + */ +test.describe('skip link', () => { + for (const app of APPS) { + test(`${app.name} — reveals on focus and targets main`, async ({ page }) => { + await page.goto(`${app.url}/`, { waitUntil: 'networkidle' }); + + const link = page.locator('.ema-skip-link'); + if ((await link.count()) === 0) { + // The public landing route does not mount the app shell in every app; + // skipping is honest here, where asserting absence would be wrong. + test.skip(true, 'landing route does not mount the app shell'); + return; + } + + // Off-screen until focused... + await expect(link).not.toBeInViewport(); + + await page.keyboard.press('Tab'); + await expect(link).toBeFocused(); + await expect(link).toBeInViewport(); + + // ...and it must point at something that exists. + const href = await link.getAttribute('href'); + expect(href).toBe('#ema-main-content'); + }); + } +}); diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 19dacf17c..07b37d41e 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -10,6 +10,10 @@ export const am: Translations = { tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች', }, + a11y: { + skipToContent: 'ወደ ዋናው ይዘት ዝለል', + }, + msg: { genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።', serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index fa64dd40a..edb6fc354 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -9,6 +9,11 @@ export const en = { tagline: 'Maritime licensing & certification services', }, + // Strings only assistive technology encounters. + a11y: { + skipToContent: 'Skip to main content', + }, + msg: { genericError: 'Something went wrong. Please try again.', serverError: 'Server error. Please try again later.', diff --git a/apps/portal/src/app/layouts/PortalLayout.tsx b/apps/portal/src/app/layouts/PortalLayout.tsx index 58b0f17a9..544fe01a9 100644 --- a/apps/portal/src/app/layouts/PortalLayout.tsx +++ b/apps/portal/src/app/layouts/PortalLayout.tsx @@ -26,6 +26,8 @@ import { AppHeader, AppSidebar, filterByPermissions, + SkipLink, + MAIN_CONTENT_ID, } from "@ema-platform/ui"; import type { NavItem } from "@ema-platform/ui"; import { @@ -281,6 +283,10 @@ export function PortalLayout() { : "?"; return ( + <> + {/* First focusable element on the page, so a keyboard user can bypass + the nav instead of tabbing through it on every navigation. */} + - +
@@ -364,5 +370,6 @@ export function PortalLayout() { />
+ ); } diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 28d4bbd0e..d4c0a785b 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -19,6 +19,7 @@ export * from "./lib/layout/BrandAvatar"; export * from "./lib/layout/ColorSchemeToggle"; export * from "./lib/layout/LanguageSwitcher"; export * from "./lib/layout/PageHeader"; +export * from "./lib/layout/SkipLink"; export * from "./lib/input/PasswordRequirements"; export * from "./lib/input/CountrySelect"; export * from "./lib/input/PhoneInput"; diff --git a/libs/ui/src/lib/dev/ThemeGallery.tsx b/libs/ui/src/lib/dev/ThemeGallery.tsx index 77e7ec41e..ef78b9533 100644 --- a/libs/ui/src/lib/dev/ThemeGallery.tsx +++ b/libs/ui/src/lib/dev/ThemeGallery.tsx @@ -22,6 +22,7 @@ import { Title, useMantineTheme, } from '@mantine/core'; +import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink'; /** * Every primitive the theme controls, on one page. @@ -90,7 +91,11 @@ export function ThemeGallery() { ); return ( - + <> + {/* Mirrors the real app shells, so the skip-link contract is testable + without a session. */} + + Theme Gallery @@ -308,5 +313,6 @@ export function ThemeGallery() { + ); } diff --git a/libs/ui/src/lib/feedback/PageLoader.tsx b/libs/ui/src/lib/feedback/PageLoader.tsx index aa475eb66..38ae08268 100644 --- a/libs/ui/src/lib/feedback/PageLoader.tsx +++ b/libs/ui/src/lib/feedback/PageLoader.tsx @@ -111,6 +111,7 @@ export function PageLoader({ position: 'absolute', top: 0, bottom: 0, + left: 0, width: '40%', background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)', borderRadius: '2px', @@ -118,9 +119,13 @@ export function PageLoader({ }} /> diff --git a/libs/ui/src/lib/layout/SkipLink.tsx b/libs/ui/src/lib/layout/SkipLink.tsx new file mode 100644 index 000000000..49717a49d --- /dev/null +++ b/libs/ui/src/lib/layout/SkipLink.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from 'react-i18next'; + +/** The id the skip link targets. Exported so the main region cannot drift. */ +export const MAIN_CONTENT_ID = 'ema-main-content'; + +/** + * "Skip to main content" — the first thing a keyboard user should reach. + * + * Both apps put a sidebar of 20-plus navigation items before the page body, so + * without this, reaching the actual content means tabbing through every one of + * them on every navigation. WCAG 2.4.1 asks for a bypass; there was none. + * + * Hidden until focused, which is why it is positioned off-screen rather than + * `display: none` — the latter would make it unfocusable and defeat the point. + * Styling lives in `semantic.css` as `.ema-skip-link`. + * + * Render it as the first child of the shell, before the header. + */ +export function SkipLink() { + const { t } = useTranslation(); + + return ( + + {t('a11y.skipToContent', 'Skip to main content')} + + ); +}