mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-06 17:57:51 +02:00
♿️(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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+13
-3
@@ -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;
|
||||
|
||||
+10
-3
@@ -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' && (
|
||||
|
||||
Reference in New Issue
Block a user