✨(a11y) add skip to content button for keyboard accessibility
add SkipToContent component to meet RGAA skiplink requirement Signed-off-by: Cyril <c.gromoff@gmail.com> ✅(frontend) add e2e test for skiplink and fix broken accessibility test ensures skiplink behavior is tested and stabilizes a failing accessibility test Signed-off-by: Cyril <c.gromoff@gmail.com>
This commit is contained in:
22
CHANGELOG.md
22
CHANGELOG.md
@@ -6,6 +6,11 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿(frontend) add skip to content button for keyboard accessibility #1624
|
||||||
|
|
||||||
## [4.0.0] - 2025-12-01
|
## [4.0.0] - 2025-12-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -13,6 +18,14 @@ and this project adheres to
|
|||||||
- ✨ Add comments feature to the editor #1330
|
- ✨ Add comments feature to the editor #1330
|
||||||
- ✨(backend) Comments on text editor #1330
|
- ✨(backend) Comments on text editor #1330
|
||||||
- ✨(frontend) link to create new doc #1574
|
- ✨(frontend) link to create new doc #1574
|
||||||
|
- ♿(frontend) improve accessibility:
|
||||||
|
- ♿(frontend) add skip to content button for keyboard accessibility #1624
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(frontend) fix toolbar not activated when reader #1640
|
||||||
|
- 🐛(frontend) preserve left panel width on window resize #1588
|
||||||
|
- 🐛(frontend) prevent duplicate as first character in title #1595
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -23,12 +36,6 @@ and this project adheres to
|
|||||||
- ♿(frontend) improve share modal button accessibility #1626
|
- ♿(frontend) improve share modal button accessibility #1626
|
||||||
- ♿(frontend) improve screen reader support in DocShare modal #1628
|
- ♿(frontend) improve screen reader support in DocShare modal #1628
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) fix toolbar not activated when reader #1640
|
|
||||||
- 🐛(frontend) preserve left panel width on window resize #1588
|
|
||||||
- 🐛(frontend) prevent duplicate as first character in title #1595
|
|
||||||
|
|
||||||
## [3.10.0] - 2025-11-18
|
## [3.10.0] - 2025-11-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -48,6 +55,9 @@ and this project adheres to
|
|||||||
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
|
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
|
||||||
- ♿(frontend) improve accessibility and styling of summary table #1528
|
- ♿(frontend) improve accessibility and styling of summary table #1528
|
||||||
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
|
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
|
||||||
|
- 🐛(frontend) preserve @ character when esc is pressed after typing it #1512
|
||||||
|
- 🐛(frontend) make summary button fixed to remain visible during scroll #1581
|
||||||
|
- 🐛(frontend) fix pdf embed to use full width #1526
|
||||||
- 🐛(frontend) fix alignment of side menu #1597
|
- 🐛(frontend) fix alignment of side menu #1597
|
||||||
- 🐛(frontend) fix fallback translations with Trans #1620
|
- 🐛(frontend) fix fallback translations with Trans #1620
|
||||||
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
|
- 🐛(export) fix image overflow by limiting width to 600px during export #1525
|
||||||
|
|||||||
@@ -177,3 +177,27 @@ test.describe('Header: Override configuration', () => {
|
|||||||
await expect(logoImage).toHaveAttribute('alt', '');
|
await expect(logoImage).toHaveAttribute('alt', '');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('Header: Skip to Content', () => {
|
||||||
|
test('it displays skip link on first TAB and focuses main content on click', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wait for skip button to be mounted (client-side only component)
|
||||||
|
const skipButton = page.getByRole('button', { name: 'Go to content' });
|
||||||
|
await skipButton.waitFor({ state: 'attached' });
|
||||||
|
|
||||||
|
// First TAB shows the skip button
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
|
// The skip button should be visible and focused
|
||||||
|
await expect(skipButton).toBeFocused();
|
||||||
|
await expect(skipButton).toBeVisible();
|
||||||
|
|
||||||
|
// Clicking moves focus to the main content
|
||||||
|
await skipButton.click();
|
||||||
|
const mainContent = page.locator('main#mainContent');
|
||||||
|
await expect(mainContent).toBeFocused();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ test.describe('Language', () => {
|
|||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
|||||||
78
src/frontend/apps/impress/src/components/SkipToContent.tsx
Normal file
78
src/frontend/apps/impress/src/components/SkipToContent.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Button } from '@openfun/cunningham-react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
|
import { Box } from '@/components';
|
||||||
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
|
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||||
|
|
||||||
|
export const SkipToContent = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
// Reset focus after route change so first TAB goes to skip link
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRouteChange = () => {
|
||||||
|
(document.activeElement as HTMLElement)?.blur();
|
||||||
|
|
||||||
|
document.body.setAttribute('tabindex', '-1');
|
||||||
|
document.body.focus({ preventScroll: true });
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeAttribute('tabindex');
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on('routeChangeComplete', handleRouteChange);
|
||||||
|
return () => {
|
||||||
|
router.events.off('routeChangeComplete', handleRouteChange);
|
||||||
|
};
|
||||||
|
}, [router.events]);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
||||||
|
if (mainContent) {
|
||||||
|
mainContent.focus();
|
||||||
|
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
$css={css`
|
||||||
|
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px var(--c--globals--colors--white-000),
|
||||||
|
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
|
||||||
|
border-radius: var(--c--globals--spacings--st);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
onClick={handleClick}
|
||||||
|
type="button"
|
||||||
|
color="brand"
|
||||||
|
className="--docs--skip-to-content"
|
||||||
|
onFocus={() => setIsVisible(true)}
|
||||||
|
onBlur={() => setIsVisible(false)}
|
||||||
|
style={{
|
||||||
|
opacity: isVisible ? 1 : 0,
|
||||||
|
pointerEvents: isVisible ? 'auto' : 'none',
|
||||||
|
position: 'fixed',
|
||||||
|
top: spacingsTokens['2xs'],
|
||||||
|
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
|
||||||
|
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
|
||||||
|
zIndex: 9999,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Go to content')}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -11,5 +11,6 @@ export * from './Loading';
|
|||||||
export * from './modal';
|
export * from './modal';
|
||||||
export * from './Overlayer';
|
export * from './Overlayer';
|
||||||
export * from './separators';
|
export * from './separators';
|
||||||
|
export * from './SkipToContent';
|
||||||
export * from './Text';
|
export * from './Text';
|
||||||
export * from './TextErrors';
|
export * from './TextErrors';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Image from 'next/image';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
import { Box, StyledLink } from '@/components/';
|
import { Box, SkipToContent, StyledLink } from '@/components/';
|
||||||
import { useConfig } from '@/core/config';
|
import { useConfig } from '@/core/config';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import { ButtonLogin } from '@/features/auth';
|
import { ButtonLogin } from '@/features/auth';
|
||||||
@@ -25,72 +25,76 @@ export const Header = () => {
|
|||||||
config?.theme_customization?.header?.icon || componentTokens.icon;
|
config?.theme_customization?.header?.icon || componentTokens.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<>
|
||||||
as="header"
|
<SkipToContent />
|
||||||
role="banner"
|
<Box
|
||||||
$css={css`
|
as="header"
|
||||||
position: fixed;
|
role="banner"
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: ${HEADER_HEIGHT}px;
|
|
||||||
padding: 0 ${spacingsTokens['base']};
|
|
||||||
background-color: var(--c--contextuals--background--surface--primary);
|
|
||||||
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
|
|
||||||
`}
|
|
||||||
className="--docs--header"
|
|
||||||
>
|
|
||||||
{!isDesktop && <ButtonTogglePanel />}
|
|
||||||
<StyledLink
|
|
||||||
href="/"
|
|
||||||
data-testid="header-logo-link"
|
|
||||||
aria-label={t('Back to homepage')}
|
|
||||||
$css={css`
|
$css={css`
|
||||||
outline: none;
|
position: fixed;
|
||||||
&:focus-visible {
|
top: 0;
|
||||||
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
|
left: 0;
|
||||||
border-radius: var(--c--globals--spacings--st);
|
right: 0;
|
||||||
}
|
z-index: 1000;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: ${HEADER_HEIGHT}px;
|
||||||
|
padding: 0 ${spacingsTokens['base']};
|
||||||
|
background-color: var(--c--contextuals--background--surface--primary);
|
||||||
|
border-bottom: 1px solid
|
||||||
|
var(--c--contextuals--border--surface--primary);
|
||||||
`}
|
`}
|
||||||
|
className="--docs--header"
|
||||||
>
|
>
|
||||||
<Box
|
{!isDesktop && <ButtonTogglePanel />}
|
||||||
$align="center"
|
<StyledLink
|
||||||
$gap={spacingsTokens['3xs']}
|
href="/"
|
||||||
$direction="row"
|
data-testid="header-logo-link"
|
||||||
$position="relative"
|
aria-label={t('Back to homepage')}
|
||||||
$height="fit-content"
|
$css={css`
|
||||||
$margin={{ top: 'auto' }}
|
outline: none;
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
|
||||||
|
border-radius: var(--c--globals--spacings--st);
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Image
|
<Box
|
||||||
data-testid="header-icon-docs"
|
$align="center"
|
||||||
src={icon.src || ''}
|
$gap={spacingsTokens['3xs']}
|
||||||
alt=""
|
$direction="row"
|
||||||
width={0}
|
$position="relative"
|
||||||
height={0}
|
$height="fit-content"
|
||||||
style={{
|
$margin={{ top: 'auto' }}
|
||||||
width: icon.width,
|
>
|
||||||
height: icon.height,
|
<Image
|
||||||
}}
|
data-testid="header-icon-docs"
|
||||||
priority
|
src={icon.src || ''}
|
||||||
/>
|
alt=""
|
||||||
<Title headingLevel="h1" aria-hidden="true" />
|
width={0}
|
||||||
</Box>
|
height={0}
|
||||||
</StyledLink>
|
style={{
|
||||||
{!isDesktop ? (
|
width: icon.width,
|
||||||
<Box $direction="row" $gap={spacingsTokens['sm']}>
|
height: icon.height,
|
||||||
<LaGaufre />
|
}}
|
||||||
</Box>
|
priority
|
||||||
) : (
|
/>
|
||||||
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
<Title headingLevel="h1" aria-hidden="true" />
|
||||||
<ButtonLogin />
|
</Box>
|
||||||
<LanguagePicker />
|
</StyledLink>
|
||||||
<LaGaufre />
|
{!isDesktop ? (
|
||||||
</Box>
|
<Box $direction="row" $gap={spacingsTokens['sm']}>
|
||||||
)}
|
<LaGaufre />
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
|
||||||
|
<ButtonLogin />
|
||||||
|
<LanguagePicker />
|
||||||
|
<LaGaufre />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { css } from 'styled-components';
|
|||||||
import { Box, Icon, Text } from '@/components';
|
import { Box, Icon, Text } from '@/components';
|
||||||
import { Footer } from '@/features/footer';
|
import { Footer } from '@/features/footer';
|
||||||
import { LeftPanel } from '@/features/left-panel';
|
import { LeftPanel } from '@/features/left-panel';
|
||||||
|
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
|
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
|
||||||
@@ -34,8 +35,19 @@ export function HomeContent() {
|
|||||||
<Box
|
<Box
|
||||||
as="main"
|
as="main"
|
||||||
role="main"
|
role="main"
|
||||||
|
id={MAIN_LAYOUT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
className="--docs--home-content"
|
className="--docs--home-content"
|
||||||
aria-label={t('Main content')}
|
aria-label={t('Main content')}
|
||||||
|
$css={css`
|
||||||
|
&:focus {
|
||||||
|
outline: 3px solid var(--c--theme--colors--primary-600);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
&:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<HomeHeader />
|
<HomeHeader />
|
||||||
{isSmallMobile && (
|
{isSmallMobile && (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import { Header } from '@/features/header';
|
import { Header } from '@/features/header';
|
||||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||||
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
||||||
@@ -53,6 +54,7 @@ export function MainLayoutContent({
|
|||||||
}: PropsWithChildren<MainLayoutContentProps>) {
|
}: PropsWithChildren<MainLayoutContentProps>) {
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { colorsTokens } = useCunninghamTheme();
|
||||||
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
||||||
|
|
||||||
const mainContent = (
|
const mainContent = (
|
||||||
@@ -61,6 +63,7 @@ export function MainLayoutContent({
|
|||||||
role="main"
|
role="main"
|
||||||
aria-label={t('Main content')}
|
aria-label={t('Main content')}
|
||||||
id={MAIN_LAYOUT_ID}
|
id={MAIN_LAYOUT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
$align="center"
|
$align="center"
|
||||||
$flex={1}
|
$flex={1}
|
||||||
$width="100%"
|
$width="100%"
|
||||||
@@ -77,6 +80,10 @@ export function MainLayoutContent({
|
|||||||
$css={css`
|
$css={css`
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
|
&:focus {
|
||||||
|
outline: 3px solid ${colorsTokens['brand-400']};
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<Skeleton>
|
<Skeleton>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { css } from 'styled-components';
|
||||||
|
|
||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
import { Footer } from '@/features/footer';
|
import { Footer } from '@/features/footer';
|
||||||
@@ -7,6 +8,8 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
|
|||||||
import { LeftPanel } from '@/features/left-panel';
|
import { LeftPanel } from '@/features/left-panel';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
|
import { MAIN_LAYOUT_ID } from './conf';
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
withFooter?: boolean;
|
withFooter?: boolean;
|
||||||
}
|
}
|
||||||
@@ -27,8 +30,19 @@ export function PageLayout({
|
|||||||
<Box
|
<Box
|
||||||
as="main"
|
as="main"
|
||||||
role="main"
|
role="main"
|
||||||
|
id={MAIN_LAYOUT_ID}
|
||||||
|
tabIndex={-1}
|
||||||
$width="100%"
|
$width="100%"
|
||||||
$css="flex-grow:1;"
|
$css={css`
|
||||||
|
flex-grow: 1;
|
||||||
|
&:focus {
|
||||||
|
outline: 3px solid var(--c--theme--colors--primary-600);
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
&:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
`}
|
||||||
aria-label={t('Main content')}
|
aria-label={t('Main content')}
|
||||||
>
|
>
|
||||||
{!isDesktop && <LeftPanel />}
|
{!isDesktop && <LeftPanel />}
|
||||||
|
|||||||
Reference in New Issue
Block a user