mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
feat(a11y): add skip link for improved navigation accessibility
This commit is contained in:
@@ -11,6 +11,10 @@ export const am: Translations = {
|
|||||||
tagline: "የቁጥጥር ማዕከል",
|
tagline: "የቁጥጥር ማዕከል",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
a11y: {
|
||||||
|
skipToContent: "ወደ ዋናው ይዘት ዝለል",
|
||||||
|
},
|
||||||
|
|
||||||
msg: {
|
msg: {
|
||||||
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
genericError: "የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።",
|
||||||
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
serverError: "የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።",
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ export const am: Translations = {
|
|||||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
a11y: {
|
||||||
|
skipToContent: 'ወደ ዋናው ይዘት ዝለል',
|
||||||
|
},
|
||||||
|
|
||||||
msg: {
|
msg: {
|
||||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
27
libs/ui/src/lib/layout/SkipLink.tsx
Normal file
27
libs/ui/src/lib/layout/SkipLink.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user