♿️(frontend) focus skip link on headings and skip grid dropzone
We land keyboard users on page headings and keep the grid dropzone untabbable.
This commit is contained in:
@@ -68,6 +68,7 @@ and this project adheres to
|
||||
- ⬆️(frontend) upgrade Next.js to v16 #1980
|
||||
- ♿️(frontend) fix aria-label and landmark on document banner state #1986
|
||||
- 🌐(i18n) add "new window" translation key for waffle aria-label #1984
|
||||
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -191,7 +191,7 @@ test.describe('Header: Override configuration', () => {
|
||||
});
|
||||
|
||||
test.describe('Header: Skip to Content', () => {
|
||||
test('it displays skip link on first TAB and focuses main content on click', async ({
|
||||
test('it displays skip link on first TAB and focuses page heading on click', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
@@ -206,10 +206,12 @@ test.describe('Header: Skip to Content', () => {
|
||||
// The skip button should be visible and focused
|
||||
await expect(skipButton).toBeFocused();
|
||||
await expect(skipButton).toBeVisible();
|
||||
|
||||
// Clicking moves focus to the main content
|
||||
// Clicking moves focus to the page heading
|
||||
await skipButton.click();
|
||||
const mainContent = page.locator('main#mainContent');
|
||||
await expect(mainContent).toBeFocused();
|
||||
const pageHeading = page.getByRole('heading', {
|
||||
name: 'All docs',
|
||||
level: 2,
|
||||
});
|
||||
await expect(pageHeading).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Left panel desktop', () => {
|
||||
await expect(page.getByTestId('home-button')).toBeVisible();
|
||||
});
|
||||
|
||||
test('focuses main content after switching the docs filter', async ({
|
||||
test('focuses page heading after switching the docs filter', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
@@ -28,8 +28,11 @@ test.describe('Left panel desktop', () => {
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(/target=my_docs/);
|
||||
|
||||
const mainContent = page.locator('main#mainContent');
|
||||
await expect(mainContent).toBeFocused();
|
||||
const pageHeading = page.getByRole('heading', {
|
||||
name: 'My docs',
|
||||
level: 2,
|
||||
});
|
||||
await expect(pageHeading).toBeFocused();
|
||||
});
|
||||
|
||||
test('checks resize handle is present and functional on document page', async ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import { focusMainContentStart } from '@/layouts/utils';
|
||||
|
||||
export const SkipToContent = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -35,10 +35,10 @@ export const SkipToContent = () => {
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
||||
if (mainContent) {
|
||||
mainContent.focus();
|
||||
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
const focusTarget = focusMainContentStart();
|
||||
|
||||
if (focusTarget instanceof HTMLElement) {
|
||||
focusTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+7
-5
@@ -51,11 +51,13 @@ export const Heading = ({
|
||||
|
||||
editor.setTextCursorPosition(headingId, 'end');
|
||||
|
||||
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'start',
|
||||
block: 'start',
|
||||
});
|
||||
document
|
||||
.querySelector<HTMLElement>(`[data-id="${headingId}"]`)
|
||||
?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'start',
|
||||
block: 'start',
|
||||
});
|
||||
}}
|
||||
$radius="var(--c--globals--spacings--st)"
|
||||
$background={
|
||||
|
||||
@@ -116,7 +116,9 @@ export const DocsGrid = ({
|
||||
$padding={{
|
||||
bottom: 'md',
|
||||
}}
|
||||
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
|
||||
{...(withUpload
|
||||
? getRootProps({ className: 'dropzone', tabIndex: -1 })
|
||||
: {})}
|
||||
>
|
||||
{withUpload && <input {...getInputProps()} />}
|
||||
<DocGridTitleBar
|
||||
|
||||
+4
-5
@@ -19,7 +19,7 @@ import {
|
||||
useDuplicateDoc,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import { focusMainContentStart } from '@/layouts/utils';
|
||||
import { useFocusStore } from '@/stores';
|
||||
|
||||
import { DocMoveModal } from './DocMoveModal';
|
||||
@@ -55,10 +55,9 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
|
||||
|
||||
const { mutate: duplicateDoc } = useDuplicateDoc({
|
||||
onSuccess: () => {
|
||||
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
|
||||
if (mainContent) {
|
||||
requestAnimationFrame(() => mainContent.focus());
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
focusMainContentStart({ preventScroll: true });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
|
||||
import {
|
||||
focusMainContentStart,
|
||||
getMainContentFocusTarget,
|
||||
} from '@/layouts/utils';
|
||||
|
||||
export const useRouteChangeCompleteFocus = () => {
|
||||
const router = useRouter();
|
||||
@@ -25,27 +28,24 @@ export const useRouteChangeCompleteFocus = () => {
|
||||
lastCompletedPathRef.current = normalizedUrl;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const mainContent =
|
||||
document.getElementById(MAIN_LAYOUT_ID) ??
|
||||
document.getElementsByTagName('main')[0];
|
||||
const focusTarget = getMainContentFocusTarget();
|
||||
|
||||
if (!mainContent) {
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstHeading = mainContent.querySelector('h1, h2, h3');
|
||||
const prefersReducedMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
|
||||
if (isKeyboardNavigationRef.current) {
|
||||
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
|
||||
focusMainContentStart({ preventScroll: true });
|
||||
isKeyboardNavigationRef.current = false;
|
||||
}
|
||||
if (router.pathname === '/docs/[id]') {
|
||||
return;
|
||||
}
|
||||
(firstHeading ?? mainContent)?.scrollIntoView({
|
||||
focusTarget.scrollIntoView({
|
||||
behavior: prefersReducedMotion ? 'auto' : 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Header } from '@/features/header';
|
||||
import { HEADER_HEIGHT } from '@/features/header/conf';
|
||||
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
|
||||
@@ -94,7 +93,6 @@ const MainContent = ({
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
|
||||
|
||||
return (
|
||||
@@ -103,7 +101,6 @@ const MainContent = ({
|
||||
role="main"
|
||||
aria-label={t('Main content')}
|
||||
id={MAIN_LAYOUT_ID}
|
||||
tabIndex={-1}
|
||||
$align="center"
|
||||
$flex={1}
|
||||
$width="100%"
|
||||
@@ -120,14 +117,6 @@ const MainContent = ({
|
||||
$css={css`
|
||||
overflow-y: auto;
|
||||
overflow-x: clip;
|
||||
&:focus-visible::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 3px solid ${colorsTokens['brand-400']};
|
||||
pointer-events: none;
|
||||
z-index: 2001;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Skeleton>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MAIN_LAYOUT_ID } from './conf';
|
||||
|
||||
export const getMainContentElement = (): HTMLElement | null =>
|
||||
document.getElementById(MAIN_LAYOUT_ID) ??
|
||||
document.getElementsByTagName('main')[0] ??
|
||||
null;
|
||||
|
||||
export const getMainContentFocusTarget = (): HTMLElement | null => {
|
||||
const mainContent = getMainContentElement();
|
||||
|
||||
if (!mainContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstHeading =
|
||||
mainContent.querySelector('h1') ?? mainContent.querySelector('h2');
|
||||
|
||||
return firstHeading instanceof HTMLElement ? firstHeading : mainContent;
|
||||
};
|
||||
|
||||
export const focusMainContentStart = (
|
||||
options?: FocusOptions,
|
||||
): HTMLElement | null => {
|
||||
const focusTarget = getMainContentFocusTarget();
|
||||
|
||||
if (!focusTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!focusTarget.hasAttribute('tabindex')) {
|
||||
focusTarget.setAttribute('tabindex', '-1');
|
||||
}
|
||||
|
||||
focusTarget.focus(options);
|
||||
|
||||
return focusTarget;
|
||||
};
|
||||
Reference in New Issue
Block a user