️(frontend) fine grained accessibility for right panel

- improve semantics and aria attributes
- gives the focus to the panel when open or switching panels
- gives back the focus to the trigger element when closing the panel
This commit is contained in:
Anthony LC
2026-05-27 17:03:28 +02:00
parent 981aef0f18
commit e85e7d6c65
6 changed files with 107 additions and 14 deletions
@@ -547,4 +547,47 @@ test.describe('Doc Comments Side Panel', () => {
'bn-thread-mark-selected',
);
});
test('it checks comments accessibility', async ({ page, browserName }) => {
await createDoc(page, 'comment-doc-panel', browserName, 1);
// Create comment thread
const editor = await writeInEditor({ page, text: 'Hello World' });
await editor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Add comment' }).click();
const thread = page.locator('.bn-thread');
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
// Open comment side panel and check aria attributes
await page
.getByRole('button', { name: 'Show the comments sidebar' })
.click();
const elCommentsSidePanel = page.getByLabel('Comments side panel');
await expect(elCommentsSidePanel).not.toHaveAttribute('inert');
// Check panel get the focus when opening
await page.keyboard.press('Tab');
await expect(
elCommentsSidePanel.getByRole('button', { name: 'Filter comments' }),
).toBeFocused();
await page.keyboard.press('Tab');
// Check the focus goes back to the button that open the side panel
await expect(
elCommentsSidePanel.getByRole('button', {
name: 'Close the comments sidebar',
}),
).toBeFocused();
await page.keyboard.press('Enter');
await expect(elCommentsSidePanel).toBeHidden();
await expect(
page.getByRole('complementary', { name: 'Side panel' }),
).toHaveAttribute('inert');
await expect(
page.getByRole('button', { name: 'Show the comments sidebar' }),
).toBeFocused();
});
});
@@ -1,4 +1,8 @@
import { Button, Tooltip } from '@gouvfr-lasuite/cunningham-react';
import {
Button,
ButtonElement,
Tooltip,
} from '@gouvfr-lasuite/cunningham-react';
import { DropdownMenu } from '@gouvfr-lasuite/ui-kit';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,6 +13,7 @@ import SortingResolvedSVG from '@/assets/icons/ui-kit/filter-notification.svg';
import SortingOpenSVG from '@/assets/icons/ui-kit/filter_list.svg';
import { Box, ButtonCloseModal, Text } from '@/components/';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
import { useFocusStore } from '@/stores';
import { useCommentSidebarStore } from './useCommentSidebarStore';
@@ -43,7 +48,9 @@ export const CommentSideBar = ({ onClose }: CommentSideBarProps) => {
>
<Box $direction="row" $align="center" $justify="space-between">
<Box $direction="row" $align="center" $gap="2xs">
<Text $weight="bold">{t('Comments')}</Text>
<Text as="h2" $weight="bold" $size="16px" $margin="0">
{t('Comments')}
</Text>
<DropdownMenu
options={[
@@ -88,7 +95,6 @@ export const CommentSideBar = ({ onClose }: CommentSideBarProps) => {
e.preventDefault();
setOpen((o) => !o);
}}
tabIndex={-1}
/>
</Tooltip>
</DropdownMenu>
@@ -112,6 +118,8 @@ export const CommentSideBarButton = () => {
const { t } = useTranslation();
const { isPanelOpen, activePanel, setActivePanel, setIsPanelOpen } =
useRightPanelStore();
const buttonRef = useRef<ButtonElement>(null);
const { addLastFocus } = useFocusStore();
const isActive = isPanelOpen && activePanel === 'comments';
const ariaLabel = isActive
@@ -120,12 +128,14 @@ export const CommentSideBarButton = () => {
return (
<Button
ref={buttonRef}
size="small"
onClick={() => {
if (isActive) {
setIsPanelOpen(false);
} else {
setActivePanel('comments');
addLastFocus(buttonRef.current);
}
}}
aria-label={ariaLabel}
@@ -310,6 +310,13 @@ export const DocsCommentsStyle = createGlobalStyle<{
min-height: 0;
overflow: auto;
.bn-editor[contenteditable="false"]{
&:focus-visible {
outline: 2px solid var(--c--globals--colors--brand-400);
outline-offset: -2px;
}
}
.bn-threads-sidebar {
gap: 0;
border-radius: 0;
@@ -1,5 +1,5 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { useEffect, useState } from 'react';
import { Button, ButtonElement } from '@gouvfr-lasuite/cunningham-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -10,6 +10,7 @@ import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore';
import { useHeadingStore } from '@/docs/doc-editor/stores/useHeadingStore';
import { useRightPanelStore } from '@/features/right-panel/stores/useRightPanelStore';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useFocusStore } from '@/stores/useFocusStore';
import { Heading } from './Heading';
@@ -94,7 +95,9 @@ export const TableContentSideBar = ({ onClose }: TableContentSideBarProps) => {
`}
>
<Box $direction="row" $align="center" $justify="space-between">
<Text $weight="bold">{t('Table of Contents')}</Text>
<Text as="h2" $weight="bold" $size="16px" $margin="0">
{t('Table of Contents')}
</Text>
<ButtonCloseModal
aria-label={t('Close the table of contents sidebar')}
onClick={onClose}
@@ -140,6 +143,8 @@ export const TableContentSideBarButton = () => {
const { t } = useTranslation();
const { isPanelOpen, activePanel, setActivePanel, setIsPanelOpen } =
useRightPanelStore();
const buttonRef = useRef<ButtonElement>(null);
const { addLastFocus } = useFocusStore();
const isActive = isPanelOpen && activePanel === 'tableContent';
const ariaLabel = isActive
@@ -148,12 +153,14 @@ export const TableContentSideBarButton = () => {
return (
<Button
ref={buttonRef}
size="small"
onClick={() => {
if (isActive) {
setIsPanelOpen(false);
} else {
setActivePanel('tableContent');
addLastFocus(buttonRef.current);
}
}}
aria-label={ariaLabel}
@@ -148,6 +148,7 @@ export const ResizableLeftPanel = ({
<Panel
ref={ref}
className="--docs--resizable-left-panel"
inert={!isPanelOpen}
collapsible={!isPanelOpen}
collapsedSize={0}
style={{
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -7,7 +7,7 @@ import { CommentSideBar } from '@/features/docs/doc-editor/components/comments/C
import { useDocStore, useProviderStore } from '@/features/docs/doc-management';
import { TableContentSideBar } from '@/features/docs/doc-table-content/components/TableContentSideBar';
import { HEADER_HEIGHT } from '@/features/header';
import { useResponsiveStore } from '@/stores';
import { useFocusStore, useResponsiveStore } from '@/stores';
import {
RightPanelView,
@@ -22,6 +22,7 @@ export const RightPanel = () => {
const { provider, isReady } = useProviderStore();
const isProviderReady =
isReady && provider && provider?.configuration.name === doc?.id;
const { restoreFocus } = useFocusStore();
/**
* Keep rendering the last active panel during the close animation,
@@ -40,21 +41,41 @@ export const RightPanel = () => {
}
}, [activePanel]);
/**
* Focus the panel when it opens or when the rendered panel changes,
* so that keyboard users are placed in the panel content immediately.
*/
const panelRef = useRef<HTMLElement>(null);
useLayoutEffect(() => {
if (isPanelOpen) {
panelRef.current?.focus();
}
}, [isPanelOpen, renderedPanel]);
if (!doc || !isProviderReady) {
return null;
}
const ariaLabel = isPanelOpen
? t('Right panel, currently open')
: t('Right panel, currently closed');
const panelLabel =
renderedPanel === 'comments'
? t('Comments side panel')
: renderedPanel === 'tableContent'
? t('Table of contents side panel')
: t('Side panel');
const handleClose = () => setIsPanelOpen(false);
const handleClose = () => {
setIsPanelOpen(false);
restoreFocus();
};
return (
<Box
as="aside"
ref={panelRef}
tabIndex={-1}
className="--docs--right-panel"
aria-label={ariaLabel}
aria-expanded={isPanelOpen}
aria-label={panelLabel}
inert={!isPanelOpen}
$width="300px"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$position={isMobile ? 'absolute' : 'sticky'}
@@ -76,6 +97,10 @@ export const RightPanel = () => {
margin-left: 0rem;
width: 0;
`}
&:focus {
outline: none;
}
`}
>
{renderedPanel === 'tableContent' && (