Skip to content

Commit

Permalink
[EuiCodeBlock] Improve accessibility of expandable code blocks (elast…
Browse files Browse the repository at this point in the history
  • Loading branch information
mgadewoll authored Dec 4, 2024
1 parent 4dec6dd commit d6119ee
Show file tree
Hide file tree
Showing 8 changed files with 178 additions and 16 deletions.
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 = (
<>
{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)
<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

0 comments on commit d6119ee

Please sign in to comment.