diff --git a/packages/eui/changelogs/upcoming/8195.md b/packages/eui/changelogs/upcoming/8195.md new file mode 100644 index 00000000000..8fe7b3a2f3b --- /dev/null +++ b/packages/eui/changelogs/upcoming/8195.md @@ -0,0 +1,7 @@ +**Accessibility** + +- Improved the accessibility of `EuiCodeBlock`s by + - adding screen reader only labels + - adding `role="dialog"` on in fullscreen mode + - ensuring focus is returned on closing fullscreen mode + diff --git a/packages/eui/src/components/code/__snapshots__/code_block.test.tsx.snap b/packages/eui/src/components/code/__snapshots__/code_block.test.tsx.snap index a4256be341a..2a1ce94508a 100644 --- a/packages/eui/src/components/code/__snapshots__/code_block.test.tsx.snap +++ b/packages/eui/src/components/code/__snapshots__/code_block.test.tsx.snap @@ -63,6 +63,25 @@ exports[`EuiCodeBlock renders a code block 1`] = ` class="euiCodeBlock__pre emotion-euiCodeBlock__pre-preWrap-padding" tabindex="-1" > + +
+ text code block: +
+ { ).toBeInTheDocument(); }); - it('closes fullscreen mode when the Escape key is pressed', () => { + describe('keyboard navigation', () => { + it('correctly navigates fullscreen with keyboard', () => { + const { getByLabelText, baseElement } = render( + + const value = "hello" + + ); + + (baseElement.querySelector( + '.euiCodeBlock__pre' + ) as HTMLPreElement)!.focus(); // start on focusable code block element + + expect(getByLabelText('Expand')).toBeInTheDocument(); + + userEvent.keyboard('{tab}'); + + waitFor(() => expect(getByLabelText('Expand')).toHaveFocus()); + + userEvent.keyboard('{enter}'); + + waitFor(() => + expect( + baseElement.querySelector('.euiCodeBlockFullScreen') + ).toBeInTheDocument() + ); + + userEvent.keyboard('{tab}'); + + waitFor(() => + expect( + baseElement.querySelector( + '.euiCodeBlockFullScreen .euiCodeBlock__pre' + ) + ).toHaveFocus() + ); + + userEvent.keyboard('{tab}'); + + waitFor(() => expect(getByLabelText('Collapse')).toHaveFocus()); + + userEvent.keyboard('{enter}'); + + waitFor(() => { + expect( + baseElement.querySelector('.euiCodeBlockFullScreen') + ).not.toBeInTheDocument(); + + expect(getByLabelText('Expand')).toHaveFocus(); + }); + }); + }); + + it('closes fullscreen mode when the escape key is pressed', () => { const { getByLabelText, baseElement } = render( { expect( baseElement.querySelector('.euiCodeBlockFullScreen') ).not.toBeInTheDocument(); + + waitFor(() => expect(getByLabelText('Expand')).toHaveFocus()); }); }); diff --git a/packages/eui/src/components/code/code_block.tsx b/packages/eui/src/components/code/code_block.tsx index 2a272a3583e..b9fcb4b3f78 100644 --- a/packages/eui/src/components/code/code_block.tsx +++ b/packages/eui/src/components/code/code_block.tsx @@ -14,6 +14,7 @@ import { useCombinedRefs, useEuiTheme, useEuiMemoizedStyles, + tabularCopyMarkers, } from '../../services'; import { ExclusiveUnion } from '../common'; import { @@ -37,6 +38,8 @@ import { euiCodeBlockPreStyles, euiCodeBlockCodeStyles, } from './code_block.styles'; +import { EuiScreenReaderOnly } from '../accessibility'; +import { useEuiI18n } from '../i18n'; // Based on observed line height for non-virtualized code blocks const fontSizeToRowHeightMap = { @@ -235,7 +238,6 @@ export const EuiCodeBlock: FunctionComponent = ({ : preStyles.whiteSpace.preWrap.controlsOffset.xl), ], tabIndex: 0, - onKeyDown, }; return [preProps, preFullscreenProps]; @@ -245,7 +247,6 @@ export const EuiCodeBlock: FunctionComponent = ({ isVirtualized, hasControls, paddingSize, - onKeyDown, tabIndex, ]); @@ -264,6 +265,25 @@ export const EuiCodeBlock: FunctionComponent = ({ }; }, [codeStyles, language, isVirtualized, rest]); + const codeBlockLabel = useEuiI18n( + 'euiCodeBlock.label', + '{language} code block:', + { + language, + } + ); + // pre tags don't accept aria-label without an + // appropriate role, we add a SR only text instead + const codeBlockLabelElement = ( + <> + {tabularCopyMarkers.hiddenNoCopyBoundary} + +
{codeBlockLabel}
+
+ {tabularCopyMarkers.hiddenNoCopyBoundary} + + ); + return (
= ({ /> ) : (
+          {codeBlockLabelElement}
           {content}
         
)} @@ -289,7 +310,7 @@ export const EuiCodeBlock: FunctionComponent = ({ /> {isFullScreen && ( - + {isVirtualized ? ( = ({ /> ) : (
+              {codeBlockLabelElement}
               {content}
             
)} diff --git a/packages/eui/src/components/code/code_block_copy.tsx b/packages/eui/src/components/code/code_block_copy.tsx index bd8cac1f0fa..50de985728f 100644 --- a/packages/eui/src/components/code/code_block_copy.tsx +++ b/packages/eui/src/components/code/code_block_copy.tsx @@ -7,6 +7,8 @@ */ import React, { ReactNode, useMemo } from 'react'; + +import { noCopyBoundsRegex } from '../../services'; import { useInnerText } from '../inner_text'; import { EuiCopy } from '../copy'; import { useEuiI18n } from '../i18n'; @@ -28,17 +30,23 @@ export const useCopy = ({ children: ReactNode; }) => { const [innerTextRef, _innerText] = useInnerText(''); - const innerText = useMemo( - () => + const innerText = useMemo(() => { + if (!_innerText) return; + + return ( _innerText + // remove text that should not be copied (e.g. screen reader instructions) + ?.replace(noCopyBoundsRegex, '') // Normalize line terminations to match native JS format ?.replace(NEW_LINE_REGEX_GLOBAL, '\n') + // remove initial line break (if there was hidden content removed) + ?.replace(/^\n/, '') // Reduce two or more consecutive new line characters to a single one // This is needed primarily because of how syntax highlighting // generated DOM elements affect `innerText` output. - .replace(/\n{2,}/g, '\n') || '', - [_innerText] - ); + ?.replace(/\n{2,}/g, '\n') || '' + ); + }, [_innerText]); const textToCopy = isVirtualized ? `${children}` : innerText; // Virtualized code blocks do not have inner text const showCopyButton = isCopyable && textToCopy; diff --git a/packages/eui/src/components/code/code_block_full_screen.tsx b/packages/eui/src/components/code/code_block_full_screen.tsx index 8e20ee5b561..52de94ae276 100644 --- a/packages/eui/src/components/code/code_block_full_screen.tsx +++ b/packages/eui/src/components/code/code_block_full_screen.tsx @@ -13,6 +13,7 @@ import React, { useCallback, useMemo, PropsWithChildren, + useRef, } from 'react'; import { keys, useEuiMemoizedStyles } from '../../services'; import { useEuiI18n } from '../i18n'; @@ -20,6 +21,7 @@ import { EuiButtonIcon } from '../button'; import { EuiFocusTrap } from '../focus_trap'; import { EuiOverlayMask } from '../overlay_mask'; import { euiCodeBlockStyles } from './code_block.styles'; +import { EuiDelayRender } from '../delay_render'; /** * Hook that returns fullscreen-related state/logic/utils @@ -29,13 +31,26 @@ export const useFullScreen = ({ }: { overflowHeight?: number | string; }) => { + const toggleButtonRef = useRef(null); + const showFullScreenButton = !!overflowHeight; const [isFullScreen, setIsFullScreen] = useState(false); + const returnFocus = () => { + // uses timeout to ensure focus is placed after potential other updates happen + setTimeout(() => { + toggleButtonRef.current?.focus(); + }); + }; + const toggleFullScreen = useCallback(() => { setIsFullScreen((isFullScreen) => !isFullScreen); - }, []); + + if (isFullScreen) { + returnFocus(); + } + }, [isFullScreen]); const onKeyDown = useCallback((event: KeyboardEvent) => { if (event.key === keys.ESCAPE) { @@ -48,6 +63,8 @@ export const useFullScreen = ({ event.preventDefault(); event.stopPropagation(); setIsFullScreen(false); + + returnFocus(); } } }, []); @@ -61,14 +78,25 @@ export const useFullScreen = ({ ); const fullScreenButton = useMemo(() => { - return showFullScreenButton ? ( + const button = ( + ); + + return showFullScreenButton ? ( + isFullScreen ? ( + // use delay to prevent label being updated in non-fullscreen state before fullscreen is opened + // otherwise this causes screen readers to read the collapse label before anything else (as the button was focused when opening) + {button} + ) : ( + button + ) ) : null; }, [ showFullScreenButton, @@ -89,8 +117,10 @@ export const useFullScreen = ({ * Portalled full screen wrapper */ export const EuiCodeBlockFullScreenWrapper: FunctionComponent< - PropsWithChildren -> = ({ children }) => { + PropsWithChildren & { + onClose: (event: React.KeyboardEvent) => void; + } +> = ({ children, onClose }) => { const styles = useEuiMemoizedStyles(euiCodeBlockStyles); const cssStyles = [ styles.euiCodeBlock, @@ -98,10 +128,26 @@ export const EuiCodeBlockFullScreenWrapper: FunctionComponent< styles.isFullScreen, ]; + const ariaLabel = useEuiI18n( + 'euiCodeBlockFullScreen.ariaLabel', + 'Expanded code block' + ); + + const dialogProps = { + role: 'dialog', + 'aria-modal': true, + 'aria-label': ariaLabel, + onKeyDown: onClose, + }; + return ( -
+
{children}
diff --git a/packages/eui/src/services/copy/index.ts b/packages/eui/src/services/copy/index.ts index 257fdb12e4c..bc86e71d5ba 100644 --- a/packages/eui/src/services/copy/index.ts +++ b/packages/eui/src/services/copy/index.ts @@ -9,5 +9,6 @@ export { copyToClipboard } from './copy_to_clipboard'; export { tabularCopyMarkers, + noCopyBoundsRegex, OverrideCopiedTabularContent, } from './tabular_copy'; diff --git a/packages/eui/src/services/copy/tabular_copy.tsx b/packages/eui/src/services/copy/tabular_copy.tsx index b7d5da489c1..c326533b1aa 100644 --- a/packages/eui/src/services/copy/tabular_copy.tsx +++ b/packages/eui/src/services/copy/tabular_copy.tsx @@ -22,7 +22,7 @@ export const CHARS = { NO_COPY_BOUND: '✄𐘗', }; // This regex finds all content between two bounds -const noCopyBoundsRegex = new RegExp( +export const noCopyBoundsRegex = new RegExp( `${CHARS.NO_COPY_BOUND}[^${CHARS.NO_COPY_BOUND}]*${CHARS.NO_COPY_BOUND}`, 'gs' );