feat(a11y): add skip link for improved navigation accessibility

This commit is contained in:
fitse-yotor
2026-08-21 12:34:00 +03:00
parent e74ef1c672
commit 8f6ebdafb1
11 changed files with 114 additions and 5 deletions

View File

@@ -11,6 +11,10 @@ export const am: Translations = {
tagline: "የቁጥጥር ማዕከል", tagline: "የቁጥጥር ማዕከል",
}, },
a11y: {
skipToContent: "ወደ ዋናው ይዘት ዝለል",
},
msg: { msg: {
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።", genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።", serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",

View File

@@ -10,6 +10,11 @@ export const en = {
tagline: 'Control Center', tagline: 'Control Center',
}, },
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: { msg: {
genericError: 'Something went wrong. Please try again.', genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.', serverError: 'Server error. Please try again later.',

View File

@@ -8,6 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui';
import type { NavItem, NavSection } from '@ema-platform/ui'; import type { NavItem, NavSection } from '@ema-platform/ui';
import { notify } from '@ema-platform/ui'; import { notify } from '@ema-platform/ui';
import { AppTopNav, filterByPermissions } 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 { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
import { usePermissions } from '@ema-platform/auth'; import { usePermissions } from '@ema-platform/auth';
import { SUPPORTED_LANGUAGES } from '../i18n/config'; import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -143,6 +144,10 @@ export function BackofficeLayout() {
const isSidebar = layoutMode === "sidebar"; const isSidebar = layoutMode === "sidebar";
return ( 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. */}
<SkipLink />
<AppShell <AppShell
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }} header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
navbar={ navbar={
@@ -226,7 +231,7 @@ export function BackofficeLayout() {
</AppShell.Navbar> </AppShell.Navbar>
)} )}
<AppShell.Main> <AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter"> <div key={location.pathname} className="ema-page-enter">
<Outlet /> <Outlet />
</div> </div>
@@ -263,5 +268,6 @@ export function BackofficeLayout() {
</Drawer> </Drawer>
)} )}
</AppShell> </AppShell>
</>
); );
} }

View File

@@ -98,3 +98,42 @@ for (const app of APPS) {
await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`); 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');
});
}
});

View File

@@ -10,6 +10,10 @@ export const am: Translations = {
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች', tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
}, },
a11y: {
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
},
msg: { msg: {
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።', genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።', serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',

View File

@@ -9,6 +9,11 @@ export const en = {
tagline: 'Maritime licensing & certification services', tagline: 'Maritime licensing & certification services',
}, },
// Strings only assistive technology encounters.
a11y: {
skipToContent: 'Skip to main content',
},
msg: { msg: {
genericError: 'Something went wrong. Please try again.', genericError: 'Something went wrong. Please try again.',
serverError: 'Server error. Please try again later.', serverError: 'Server error. Please try again later.',

View File

@@ -26,6 +26,8 @@ import {
AppHeader, AppHeader,
AppSidebar, AppSidebar,
filterByPermissions, filterByPermissions,
SkipLink,
MAIN_CONTENT_ID,
} from "@ema-platform/ui"; } from "@ema-platform/ui";
import type { NavItem } from "@ema-platform/ui"; import type { NavItem } from "@ema-platform/ui";
import { import {
@@ -281,6 +283,10 @@ export function PortalLayout() {
: "?"; : "?";
return ( return (
<>
{/* First focusable element on the page, so a keyboard user can bypass
the nav instead of tabbing through it on every navigation. */}
<SkipLink />
<AppShell <AppShell
header={{ height: 74 }} header={{ height: 74 }}
navbar={{ navbar={{
@@ -335,7 +341,7 @@ export function PortalLayout() {
/> />
</AppShell.Navbar> </AppShell.Navbar>
<AppShell.Main> <AppShell.Main id={MAIN_CONTENT_ID}>
<div key={location.pathname} className="ema-page-enter"> <div key={location.pathname} className="ema-page-enter">
<Outlet /> <Outlet />
</div> </div>
@@ -364,5 +370,6 @@ export function PortalLayout() {
/> />
</Drawer> </Drawer>
</AppShell> </AppShell>
</>
); );
} }

View File

@@ -19,6 +19,7 @@ export * from "./lib/layout/BrandAvatar";
export * from "./lib/layout/ColorSchemeToggle"; export * from "./lib/layout/ColorSchemeToggle";
export * from "./lib/layout/LanguageSwitcher"; export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader"; export * from "./lib/layout/PageHeader";
export * from "./lib/layout/SkipLink";
export * from "./lib/input/PasswordRequirements"; export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect"; export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput"; export * from "./lib/input/PhoneInput";

View File

@@ -22,6 +22,7 @@ import {
Title, Title,
useMantineTheme, useMantineTheme,
} from '@mantine/core'; } from '@mantine/core';
import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
/** /**
* Every primitive the theme controls, on one page. * Every primitive the theme controls, on one page.
@@ -90,7 +91,11 @@ export function ThemeGallery() {
); );
return ( return (
<Box p="xl" style={{ maxWidth: 1100, margin: '0 auto' }}> <>
{/* Mirrors the real app shells, so the skip-link contract is testable
without a session. */}
<SkipLink />
<Box id={MAIN_CONTENT_ID} p="xl" style={{ maxWidth: 1100, margin: '0 auto' }}>
<Stack gap="xl"> <Stack gap="xl">
<Stack gap={4}> <Stack gap={4}>
<Title order={1}>Theme Gallery</Title> <Title order={1}>Theme Gallery</Title>
@@ -308,5 +313,6 @@ export function ThemeGallery() {
</Section> </Section>
</Stack> </Stack>
</Box> </Box>
</>
); );
} }

View File

@@ -111,6 +111,7 @@ export function PageLoader({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
bottom: 0, bottom: 0,
left: 0,
width: '40%', width: '40%',
background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)', background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)',
borderRadius: '2px', borderRadius: '2px',
@@ -118,9 +119,13 @@ export function PageLoader({
}} }}
/> />
<style>{` <style>{`
/* Translating rather than animating \`left\`: the latter runs
layout on every frame of an animation that plays during page
loads, which is exactly when the main thread is busiest.
Reduced motion is handled globally in semantic.css. */
@keyframes ema-shimmer { @keyframes ema-shimmer {
0% { left: -40%; } 0% { transform: translateX(-100%); }
100% { left: 100%; } 100% { transform: translateX(250%); }
} }
`}</style> `}</style>
</Box> </Box>

View File

@@ -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 (
<a className="ema-skip-link" href={`#${MAIN_CONTENT_ID}`}>
{t('a11y.skipToContent', 'Skip to main content')}
</a>
);
}