Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[EuiCodeBlock] Improve accessibility of expandable code blocks #8195

Merged
merged 6 commits into from
Dec 4, 2024
Merged
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
7 changes: 7 additions & 0 deletions packages/eui/changelogs/upcoming/8195.md
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ exports[`EuiCodeBlock renders a code block 1`] = `
class="euiCodeBlock__pre emotion-euiCodeBlock__pre-preWrap-padding"
tabindex="-1"
>
<span
aria-hidden="true"
class="euiScreenReaderOnly"
data-tabular-copy-marker="no-copy"
>
✄𐘗
</span>
<div
class="emotion-euiScreenReaderOnly"
>
text code block:
</div>
<span
aria-hidden="true"
class="euiScreenReaderOnly"
data-tabular-copy-marker="no-copy"
>
✄𐘗
</span>
<code
aria-label="aria-label"
class="euiCodeBlock__code emotion-euiCodeBlock__code"
Expand Down
63 changes: 61 additions & 2 deletions packages/eui/src/components/code/code_block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*/

import React from 'react';
import { fireEvent } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { requiredProps } from '../../test/required_props';
import { render } from '../../test/rtl';
Expand Down Expand Up @@ -147,7 +148,63 @@ describe('EuiCodeBlock', () => {
).toBeInTheDocument();
});

it('closes fullscreen mode when the Escape key is pressed', () => {
describe('keyboard navigation', () => {
it('correctly navigates fullscreen with keyboard', () => {
const { getByLabelText, baseElement } = render(
<EuiCodeBlock
{...requiredProps}
language="javascript"
overflowHeight={300}
>
const value = &quot;hello&quot;
</EuiCodeBlock>
);

(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(
<EuiCodeBlock
{...requiredProps}
Expand All @@ -169,6 +226,8 @@ describe('EuiCodeBlock', () => {
expect(
baseElement.querySelector('.euiCodeBlockFullScreen')
).not.toBeInTheDocument();

waitFor(() => expect(getByLabelText('Expand')).toHaveFocus());
});
});

Expand Down
28 changes: 25 additions & 3 deletions packages/eui/src/components/code/code_block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
useCombinedRefs,
useEuiTheme,
useEuiMemoizedStyles,
tabularCopyMarkers,
} from '../../services';
import { ExclusiveUnion } from '../common';
import {
Expand All @@ -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 = {
Expand Down Expand Up @@ -235,7 +238,6 @@ export const EuiCodeBlock: FunctionComponent<EuiCodeBlockProps> = ({
: preStyles.whiteSpace.preWrap.controlsOffset.xl),
],
tabIndex: 0,
onKeyDown,
};

return [preProps, preFullscreenProps];
Expand All @@ -245,7 +247,6 @@ export const EuiCodeBlock: FunctionComponent<EuiCodeBlockProps> = ({
isVirtualized,
hasControls,
paddingSize,
onKeyDown,
tabIndex,
]);

Expand All @@ -264,6 +265,25 @@ export const EuiCodeBlock: FunctionComponent<EuiCodeBlockProps> = ({
};
}, [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 = (
weronikaolejniczak marked this conversation as resolved.
Show resolved Hide resolved
<>
{tabularCopyMarkers.hiddenNoCopyBoundary}
<EuiScreenReaderOnly>
<div>{codeBlockLabel}</div>
</EuiScreenReaderOnly>
{tabularCopyMarkers.hiddenNoCopyBoundary}
</>
);

return (
<div
css={cssStyles}
Expand All @@ -280,6 +300,7 @@ export const EuiCodeBlock: FunctionComponent<EuiCodeBlockProps> = ({
/>
) : (
<pre {...preProps} ref={combinedRef} style={overflowHeightStyles}>
{codeBlockLabelElement}
<code {...codeProps}>{content}</code>
</pre>
)}
Expand All @@ -289,7 +310,7 @@ export const EuiCodeBlock: FunctionComponent<EuiCodeBlockProps> = ({
/>

{isFullScreen && (
<EuiCodeBlockFullScreenWrapper>
<EuiCodeBlockFullScreenWrapper onClose={onKeyDown}>
{isVirtualized ? (
<EuiCodeBlockVirtualized
data={data}
Expand All @@ -299,6 +320,7 @@ export const EuiCodeBlock: FunctionComponent<EuiCodeBlockProps> = ({
/>
) : (
<pre {...preFullscreenProps}>
{codeBlockLabelElement}
<code {...codeProps}>{content}</code>
</pre>
)}
Expand Down
18 changes: 13 additions & 5 deletions packages/eui/src/components/code/code_block_copy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down
56 changes: 51 additions & 5 deletions packages/eui/src/components/code/code_block_full_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import React, {
useCallback,
useMemo,
PropsWithChildren,
useRef,
} from 'react';
import { keys, useEuiMemoizedStyles } from '../../services';
import { useEuiI18n } from '../i18n';
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
Expand All @@ -29,13 +31,26 @@ export const useFullScreen = ({
}: {
overflowHeight?: number | string;
}) => {
const toggleButtonRef = useRef<HTMLButtonElement>(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<HTMLElement>) => {
if (event.key === keys.ESCAPE) {
Expand All @@ -48,6 +63,8 @@ export const useFullScreen = ({
event.preventDefault();
event.stopPropagation();
setIsFullScreen(false);

returnFocus();
}
}
}, []);
Expand All @@ -61,14 +78,25 @@ export const useFullScreen = ({
);

const fullScreenButton = useMemo(() => {
return showFullScreenButton ? (
const button = (
<EuiButtonIcon
buttonRef={toggleButtonRef}
className="euiCodeBlock__fullScreenButton"
onClick={toggleFullScreen}
iconType={isFullScreen ? 'fullScreenExit' : 'fullScreen'}
color="text"
aria-label={isFullScreen ? fullscreenCollapse : fullscreenExpand}
/>
);

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)
weronikaolejniczak marked this conversation as resolved.
Show resolved Hide resolved
<EuiDelayRender delay={10}>{button}</EuiDelayRender>
) : (
button
)
) : null;
}, [
showFullScreenButton,
Expand All @@ -89,19 +117,37 @@ export const useFullScreen = ({
* Portalled full screen wrapper
*/
export const EuiCodeBlockFullScreenWrapper: FunctionComponent<
PropsWithChildren
> = ({ children }) => {
PropsWithChildren & {
onClose: (event: React.KeyboardEvent<HTMLElement>) => void;
}
> = ({ children, onClose }) => {
const styles = useEuiMemoizedStyles(euiCodeBlockStyles);
const cssStyles = [
styles.euiCodeBlock,
styles.l, // Force fullscreen to use large font
styles.isFullScreen,
];

const ariaLabel = useEuiI18n(
'euiCodeBlockFullScreen.ariaLabel',
'Expanded code block'
);

const dialogProps = {
role: 'dialog',
'aria-modal': true,
'aria-label': ariaLabel,
onKeyDown: onClose,
};

return (
<EuiOverlayMask>
<EuiFocusTrap scrollLock preventScrollOnFocus clickOutsideDisables={true}>
<div className="euiCodeBlockFullScreen" css={cssStyles}>
<div
className="euiCodeBlockFullScreen"
css={cssStyles}
{...dialogProps}
>
{children}
</div>
</EuiFocusTrap>
Expand Down
1 change: 1 addition & 0 deletions packages/eui/src/services/copy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
export { copyToClipboard } from './copy_to_clipboard';
export {
tabularCopyMarkers,
noCopyBoundsRegex,
OverrideCopiedTabularContent,
} from './tabular_copy';
2 changes: 1 addition & 1 deletion packages/eui/src/services/copy/tabular_copy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
Expand Down
Loading