diff --git a/packages/volto/news/5212.bugfix b/packages/volto/news/5212.bugfix new file mode 100644 index 00000000000..670d053aeee --- /dev/null +++ b/packages/volto/news/5212.bugfix @@ -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 \ No newline at end of file diff --git a/packages/volto/src/components/manage/BlockChooser/BlockChooser.jsx b/packages/volto/src/components/manage/BlockChooser/BlockChooser.jsx index e72672555b9..895d556bc9c 100644 --- a/packages/volto/src/components/manage/BlockChooser/BlockChooser.jsx +++ b/packages/volto/src/components/manage/BlockChooser/BlockChooser.jsx @@ -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'; @@ -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) @@ -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); @@ -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({ @@ -141,6 +159,9 @@ const BlockChooser = ({ }); e.stopPropagation(); }} + tabIndex={0} + role="button" + aria-label={getFormatMessage(block.title)} > {getFormatMessage(block.title)} @@ -158,13 +179,15 @@ const BlockChooser = ({ config.experimental.addBlockButton.enabled ? ' new-add-block' : '' }`} ref={blockChooserRef} + role="dialog" + aria-label="Block chooser" > setFilterValue(value)} searchValue={filterValue} /> {filterValue ? ( - <> +
{map(blocksAvailableFilter(filteredBlocksConfig), (block) => ( ))} @@ -176,7 +199,7 @@ const BlockChooser = ({ /> )} - +
) : ( {map(groupBlocksOrder, (groupName, index) => ( @@ -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, @@ -208,17 +237,22 @@ const BlockChooser = ({ - {map(blocksAvailable[groupName.id], (block) => ( - - ))} +
+ {map(blocksAvailable[groupName.id], (block) => ( + + ))} +
@@ -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) => ( diff --git a/packages/volto/src/components/manage/BlockChooser/BlockChooser.test.jsx b/packages/volto/src/components/manage/BlockChooser/BlockChooser.test.jsx index d2bd1bbe5ab..eaa9708f45a 100644 --- a/packages/volto/src/components/manage/BlockChooser/BlockChooser.test.jsx +++ b/packages/volto/src/components/manage/BlockChooser/BlockChooser.test.jsx @@ -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; @@ -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', () => { diff --git a/packages/volto/src/components/manage/BlockChooser/BlockChooserButton.jsx b/packages/volto/src/components/manage/BlockChooser/BlockChooserButton.jsx index d75055f3750..db05e322114 100644 --- a/packages/volto/src/components/manage/BlockChooser/BlockChooserButton.jsx +++ b/packages/volto/src/components/manage/BlockChooser/BlockChooserButton.jsx @@ -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 ( @@ -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) => { @@ -73,6 +83,11 @@ const BlockChooserButton = (props) => { setAddNewBlockOpened(false); }, []); + const handleClose = React.useCallback(() => { + setAddNewBlockOpened(false); + triggerButtonRef.current?.focus(); + }, []); + const Component = buttonComponent || ButtonComponent; React.useEffect(() => { @@ -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, { @@ -109,10 +135,18 @@ const BlockChooserButton = (props) => { {!disableNewBlocks && (config.experimental.addBlockButton.enabled || !blockHasValue(data)) && ( - + { + setReferenceElement(node); + if (node) { + triggerButtonRef.current = node; + } + }} + > setAddNewBlockOpened(true)} + aria-expanded={addNewBlockOpened} /> )} @@ -122,12 +156,14 @@ const BlockChooserButton = (props) => { ref={setPopperElement} style={styles.popper} {...attributes.popper} + role="dialog" + aria-modal="true" > { - setAddNewBlockOpened(false); + handleClose(); onMutateBlock(id, value); } : null @@ -135,11 +171,12 @@ const BlockChooserButton = (props) => { onInsertBlock={ onInsertBlock ? (id, value) => { - setAddNewBlockOpened(false); + handleClose(); onInsertBlock(id, value); } : null } + initialFocus="search" currentBlock={block} allowedBlocks={allowedBlocks} blocksConfig={blocksConfig} @@ -148,6 +185,7 @@ const BlockChooserButton = (props) => { ref={blockChooserRef} navRoot={navRoot} contentType={contentType} + onClose={handleClose} /> , document.body, diff --git a/packages/volto/src/components/manage/BlockChooser/BlockChooserSearch.jsx b/packages/volto/src/components/manage/BlockChooser/BlockChooserSearch.jsx index b76ff49ffef..db178603a95 100644 --- a/packages/volto/src/components/manage/BlockChooser/BlockChooserSearch.jsx +++ b/packages/volto/src/components/manage/BlockChooser/BlockChooserSearch.jsx @@ -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 ( -
+ { 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 && ( diff --git a/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooser.test.jsx.snap b/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooser.test.jsx.snap index b0f6bc9a54f..37996fb0e24 100644 --- a/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooser.test.jsx.snap +++ b/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooser.test.jsx.snap @@ -3,10 +3,13 @@ exports[`BlocksChooser Fallback BlockChooser component onMutateBlock 1`] = `
@@ -328,10 +414,13 @@ exports[`BlocksChooser Fallback BlockChooser component onMutateBlock 1`] = ` exports[`BlocksChooser renders a BlockChooser component 1`] = `
diff --git a/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooserButton.test.jsx.snap b/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooserButton.test.jsx.snap index cb9d6b9b3c3..4b58458fda6 100644 --- a/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooserButton.test.jsx.snap +++ b/packages/volto/src/components/manage/BlockChooser/__snapshots__/BlockChooserButton.test.jsx.snap @@ -13,6 +13,8 @@ exports[`Can render a custom button 1`] = ` exports[`Renders a button 1`] = `