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

Fix: a11y - edit page - add block/blocks-chooser #6597

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions packages/volto/news/5212.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed accessibility issues in the "Add Block" modal of the editor where focus now stays inside the modal while navigating. @Manas-Kenge
65 changes: 50 additions & 15 deletions packages/volto/src/components/manage/BlockChooser/BlockChooser.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import useUser from '@plone/volto/hooks/user/useUser';
import PropTypes from 'prop-types';
import filter from 'lodash/filter';
Expand Down Expand Up @@ -37,10 +37,12 @@ const BlockChooser = ({
properties = {},
navRoot,
contentType,
onClose,
}) => {
const intl = useIntl();
const user = useUser();
const hasAllowedBlocks = !isEmpty(allowedBlocks);
const accordionRefs = useRef([]);

const filteredBlocksConfig = filter(blocksConfig, (item) => {
// Check if the block is well formed (has at least id and title)
Expand Down Expand Up @@ -76,7 +78,7 @@ const BlockChooser = ({

let blocksAvailable = {};
const mostUsedBlocks = filter(filteredBlocksConfig, (item) => item.mostUsed);
if (mostUsedBlocks) {
if (mostUsedBlocks.length) {
blocksAvailable.mostUsed = mostUsedBlocks;
}
const groupedBlocks = groupBy(filteredBlocksConfig, (item) => item.group);
Expand All @@ -88,15 +90,31 @@ const BlockChooser = ({
const groupBlocksOrder = filter(config.blocks.groupBlocksOrder, (item) =>
Object.keys(blocksAvailable).includes(item.id),
);
const [activeIndex, setActiveIndex] = React.useState(0);

function handleClick(e, titleProps) {
const { index } = titleProps;
const newIndex = activeIndex === index ? -1 : index;
const handleAccordionKeyDown = (e, index) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
handleAccordionInteraction(index);
}
};

setActiveIndex(newIndex);
}
const [filterValue, setFilterValue] = React.useState('');
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose?.();
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);

const [activeIndex, setActiveIndex] = useState(0);
const handleAccordionInteraction = (index) => {
setActiveIndex(activeIndex === index ? -1 : index);
};

const [filterValue, setFilterValue] = useState('');

const getFormatMessage = (message) =>
intl.formatMessage({
Expand Down Expand Up @@ -141,6 +159,9 @@ const BlockChooser = ({
});
e.stopPropagation();
}}
tabIndex={0}
role="button"
aria-label={getFormatMessage(block.title)}
>
<Icon name={block.icon} size="36px" />
{getFormatMessage(block.title)}
Expand All @@ -158,13 +179,15 @@ const BlockChooser = ({
config.experimental.addBlockButton.enabled ? ' new-add-block' : ''
}`}
ref={blockChooserRef}
role="dialog"
aria-label="Block chooser"
>
<BlockChooserSearch
onChange={(value) => setFilterValue(value)}
searchValue={filterValue}
/>
{filterValue ? (
<>
<div role="list">
{map(blocksAvailableFilter(filteredBlocksConfig), (block) => (
<ButtonGroup block={block} key={block.id} />
))}
Expand All @@ -176,7 +199,7 @@ const BlockChooser = ({
/>
</h4>
)}
</>
</div>
) : (
<Accordion fluid styled className="form">
{map(groupBlocksOrder, (groupName, index) => (
Expand All @@ -191,9 +214,15 @@ const BlockChooser = ({
groupName.title
} blocks`
}
aria-expanded={activeIndex === index}
aria-controls={`section-${groupName.id}`}
active={activeIndex === index}
index={index}
onClick={handleClick}
onClick={() => handleAccordionInteraction(index)}
onKeyDown={(e) => handleAccordionKeyDown(e, index)}
ref={(el) => (accordionRefs.current[index] = el)}
role="button"
tabIndex={0}
>
{intl.formatMessage({
id: groupName.id,
Expand All @@ -208,17 +237,22 @@ const BlockChooser = ({
</div>
</Accordion.Title>
<Accordion.Content
id={`section-${groupName.id}`}
className={groupName.id}
active={activeIndex === index}
role="region"
aria-labelledby={`header-${groupName.id}`}
>
<AnimateHeight
animateOpacity
duration={500}
height={activeIndex === index ? 'auto' : 0}
>
{map(blocksAvailable[groupName.id], (block) => (
<ButtonGroup block={block} key={block.id} />
))}
<div role="list">
{map(blocksAvailable[groupName.id], (block) => (
<ButtonGroup block={block} key={block.id} />
))}
</div>
</AnimateHeight>
</Accordion.Content>
</React.Fragment>
Expand All @@ -235,6 +269,7 @@ BlockChooser.propTypes = {
onInsertBlock: PropTypes.func,
allowedBlocks: PropTypes.arrayOf(PropTypes.string),
blocksConfig: PropTypes.objectOf(PropTypes.any),
onClose: PropTypes.func,
};

export default React.forwardRef((props, ref) => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@ describe('BlocksChooser', () => {
);
expect(container.firstChild).not.toHaveTextContent('Video');
// There are 2 because the others are aria-hidden="true"
expect(screen.getAllByRole('button')).toHaveLength(2);
const blockButtons = screen
.getAllByRole('button')
.filter((button) => button.classList.contains('basic'));

expect(blockButtons).toHaveLength(2);
});
it('allowedBlocks bypasses showRestricted', () => {
config.blocks.blocksConfig.listing.restricted = true;
Expand Down Expand Up @@ -184,7 +188,10 @@ describe('BlocksChooser', () => {
);
expect(container.firstChild).not.toHaveTextContent('Video');
// There's 1 because the others are aria-hidden="true"
expect(screen.getAllByRole('button')).toHaveLength(1);
const blockButtons = screen
.getAllByRole('button')
.filter((button) => button.classList.contains('basic'));
expect(blockButtons).toHaveLength(1);
expect(container.firstChild).toHaveTextContent('Title');
});
it('uses custom blocksConfig test', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ export const ButtonComponent = (props) => {
onShowBlockChooser,
} = props;

const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
onShowBlockChooser();
}
};

return (
<Button
icon
Expand All @@ -37,7 +44,10 @@ export const ButtonComponent = (props) => {
e.stopPropagation();
onShowBlockChooser();
}}
onKeyDown={handleKeyDown}
className={className}
aria-haspopup="dialog"
aria-expanded={false}
>
<Icon name={addSVG} className={className} size={size} />
</Button>
Expand All @@ -61,7 +71,7 @@ const BlockChooserButton = (props) => {

const { disableNewBlocks } = data;
const [addNewBlockOpened, setAddNewBlockOpened] = React.useState(false);

const triggerButtonRef = React.useRef(null);
const blockChooserRef = React.useRef();

const handleClickOutside = React.useCallback((e) => {
Expand All @@ -73,6 +83,11 @@ const BlockChooserButton = (props) => {
setAddNewBlockOpened(false);
}, []);

const handleClose = React.useCallback(() => {
setAddNewBlockOpened(false);
triggerButtonRef.current?.focus();
}, []);

const Component = buttonComponent || ButtonComponent;

React.useEffect(() => {
Expand All @@ -82,6 +97,17 @@ const BlockChooserButton = (props) => {
};
}, [handleClickOutside]);

React.useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape' && addNewBlockOpened) {
handleClose();
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [addNewBlockOpened, handleClose]);

const [referenceElement, setReferenceElement] = React.useState(null);
const [popperElement, setPopperElement] = React.useState(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
Expand Down Expand Up @@ -109,10 +135,18 @@ const BlockChooserButton = (props) => {
{!disableNewBlocks &&
(config.experimental.addBlockButton.enabled ||
!blockHasValue(data)) && (
<Ref innerRef={setReferenceElement}>
<Ref
innerRef={(node) => {
setReferenceElement(node);
if (node) {
triggerButtonRef.current = node;
}
}}
>
<Component
{...props}
onShowBlockChooser={() => setAddNewBlockOpened(true)}
aria-expanded={addNewBlockOpened}
/>
</Ref>
)}
Expand All @@ -122,24 +156,27 @@ const BlockChooserButton = (props) => {
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
role="dialog"
aria-modal="true"
>
<BlockChooser
onMutateBlock={
onMutateBlock
? (id, value) => {
setAddNewBlockOpened(false);
handleClose();
onMutateBlock(id, value);
}
: null
}
onInsertBlock={
onInsertBlock
? (id, value) => {
setAddNewBlockOpened(false);
handleClose();
onInsertBlock(id, value);
}
: null
}
initialFocus="search"
currentBlock={block}
allowedBlocks={allowedBlocks}
blocksConfig={blocksConfig}
Expand All @@ -148,6 +185,7 @@ const BlockChooserButton = (props) => {
ref={blockChooserRef}
navRoot={navRoot}
contentType={contentType}
onClose={handleClose}
/>
</div>,
document.body,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,23 @@ const BlockChooserSearch = ({ onChange, searchValue }) => {
const intl = useIntl();
const searchInput = useRef(null);

React.useEffect(() => {
searchInput.current?.focus();
}, []);

const handleClearSearch = () => {
onChange('');
searchInput.current.focus();
};

const handleKeyDown = (e) => {
if (e.key === 'Escape') {
handleClearSearch();
}
};

return (
<Form style={{ padding: '0.5em' }}>
<Form style={{ padding: '0.5em' }} role="search">
<Form.Field
className="searchbox"
style={{ borderLeft: 0, height: '2em', padding: 0 }}
Expand All @@ -29,21 +44,27 @@ const BlockChooserSearch = ({ onChange, searchValue }) => {
<Input
aria-label={intl.formatMessage(messages.search)}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleKeyDown}
name="SearchableText"
value={searchValue}
autoComplete="off"
placeholder={intl.formatMessage(messages.search)}
title={intl.formatMessage(messages.search)}
ref={searchInput}
autofocus
/>
{searchValue && (
<Button
className="clear-search-button"
aria-label={intl.formatMessage(messages.clear)}
onClick={() => {
onChange('');
searchInput.current.focus();
onClick={handleClearSearch}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClearSearch();
}
}}
type="button"
>
<Icon name={clearSVG} size="18px" />
</Button>
Expand Down
Loading
Loading