Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('/');
Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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('/');
Expand All @@ -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 ({
Expand Down
10 changes: 5 additions & 5 deletions src/frontend/apps/impress/src/components/SkipToContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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' });
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ export const DocsGrid = ({
$padding={{
bottom: 'md',
}}
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
{...(withUpload
? getRootProps({ className: 'dropzone', tabIndex: -1 })
: {})}
>
{withUpload && <input {...getInputProps()} />}
<DocGridTitleBar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
useTrans,
} from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { focusMainContentStart } from '@/layouts/conf';
import { useFocusStore } from '@/stores';

import { DocMoveModal } from './DocMoveModal';
Expand All @@ -40,10 +40,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 });
});
},
});

Expand Down
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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',
});
Expand Down
11 changes: 0 additions & 11 deletions src/frontend/apps/impress/src/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -94,7 +93,6 @@ const MainContent = ({
const { isDesktop } = useResponsiveStore();

const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;

return (
Expand All @@ -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%"
Expand All @@ -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>
Expand Down
36 changes: 36 additions & 0 deletions src/frontend/apps/impress/src/layouts/conf.ts
Original file line number Diff line number Diff line change
@@ -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;
};
Loading