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" > +
+ {
).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'
);