From ff9efe0ede2d4e4fe6a338ecd12c631e20a911c3 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 11 Mar 2026 14:12:10 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20focus=20skip=20l?= =?UTF-8?q?ink=20on=20headings=20and=20skip=20grid=20dropzone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We land keyboard users on page headings and keep the grid dropzone untabbable. --- CHANGELOG.md | 3 ++ .../e2e/__tests__/app-impress/header.spec.ts | 12 ++++--- .../__tests__/app-impress/left-panel.spec.ts | 9 +++-- .../impress/src/components/SkipToContent.tsx | 10 +++--- .../doc-table-content/components/Heading.tsx | 12 ++++--- .../docs/docs-grid/components/DocsGrid.tsx | 4 ++- .../docs-grid/components/DocsGridActions.tsx | 9 +++-- .../src/hooks/useRouteChangeCompleteFocus.tsx | 16 ++++----- .../apps/impress/src/layouts/MainLayout.tsx | 11 ------ src/frontend/apps/impress/src/layouts/conf.ts | 36 +++++++++++++++++++ 10 files changed, 79 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d1208be07..b3aa37b106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to ### Added - ✨(backend) add a is_first_connection flag to the User model#1938 +### Changed + +- ♿(frontend) focus skip link on headings and skip grid dropzone #1983 ## [v4.7.0] - 2026-03-09 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts index 430d5f91a2..19f9bbb47f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts @@ -190,7 +190,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('/'); @@ -205,10 +205,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(); }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts index c0cdfedcb7..bad7ed65ef 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts @@ -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 ({ diff --git a/src/frontend/apps/impress/src/components/SkipToContent.tsx b/src/frontend/apps/impress/src/components/SkipToContent.tsx index 9605cbbccd..d24374022b 100644 --- a/src/frontend/apps/impress/src/components/SkipToContent.tsx +++ b/src/frontend/apps/impress/src/components/SkipToContent.tsx @@ -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/conf'; 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' }); } }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx index db201f6347..91fc9bca5e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx @@ -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(`[data-id="${headingId}"]`) + ?.scrollIntoView({ + behavior: 'smooth', + inline: 'start', + block: 'start', + }); }} $radius="var(--c--globals--spacings--st)" $background={ diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx index bde62b36ca..a45a7ddba9 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -116,7 +116,9 @@ export const DocsGrid = ({ $padding={{ bottom: 'md', }} - {...(withUpload ? getRootProps({ className: 'dropzone' }) : {})} + {...(withUpload + ? getRootProps({ className: 'dropzone', tabIndex: -1 }) + : {})} > {withUpload && } { const { mutate: duplicateDoc } = useDuplicateDoc({ onSuccess: () => { - const mainContent = document.getElementById(MAIN_LAYOUT_ID); - if (mainContent) { - requestAnimationFrame(() => mainContent.focus()); - } + requestAnimationFrame(() => { + focusMainContentStart({ preventScroll: true }); + }); }, }); diff --git a/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx b/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx index 7ef76e8c96..92eb8c398e 100644 --- a/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx +++ b/src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx @@ -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/conf'; 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', }); diff --git a/src/frontend/apps/impress/src/layouts/MainLayout.tsx b/src/frontend/apps/impress/src/layouts/MainLayout.tsx index 9f591f76ab..273c3ae02a 100644 --- a/src/frontend/apps/impress/src/layouts/MainLayout.tsx +++ b/src/frontend/apps/impress/src/layouts/MainLayout.tsx @@ -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; - } `} > diff --git a/src/frontend/apps/impress/src/layouts/conf.ts b/src/frontend/apps/impress/src/layouts/conf.ts index 51ff029f2c..0510cca427 100644 --- a/src/frontend/apps/impress/src/layouts/conf.ts +++ b/src/frontend/apps/impress/src/layouts/conf.ts @@ -1 +1,37 @@ export const MAIN_LAYOUT_ID = `mainContent`; + +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; +};