feat: add keyboard navigation for relations reordering

This commit is contained in:
Josh 2022-11-07 17:38:30 +00:00
parent a60f265940
commit 09d8c286f4
5 changed files with 256 additions and 12 deletions

View File

@ -2,6 +2,7 @@ import React, { useRef, useState, useMemo, useEffect } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { FixedSizeList as List } from 'react-window';
import { useIntl } from 'react-intl';
import { ReactSelect } from '@strapi/helper-plugin';
import { Status } from '@strapi/design-system/Status';
@ -12,6 +13,7 @@ import { FieldLabel, FieldError, FieldHint, Field } from '@strapi/design-system/
import { TextButton } from '@strapi/design-system/TextButton';
import { Typography } from '@strapi/design-system/Typography';
import { Tooltip } from '@strapi/design-system/Tooltip';
import { VisuallyHidden } from '@strapi/design-system/VisuallyHidden';
import Cross from '@strapi/icons/Cross';
import Refresh from '@strapi/icons/Refresh';
@ -22,6 +24,8 @@ import { RelationList } from './components/RelationList';
import { Option } from './components/Option';
import { RELATION_GUTTER, RELATION_ITEM_HEIGHT } from './constants';
import { getTrad } from '../../utils';
const LinkEllipsis = styled(Link)`
white-space: nowrap;
overflow: hidden;
@ -76,9 +80,13 @@ const RelationInput = ({
size,
}) => {
const [value, setValue] = useState(null);
const [overflow, setOverflow] = useState('');
const [liveText, setLiveText] = useState('');
const listRef = useRef();
const outerListRef = useRef();
const [overflow, setOverflow] = useState('');
const { formatMessage } = useIntl();
const { data } = searchResults;
@ -213,12 +221,68 @@ const RelationInput = ({
onSearch();
};
/**
*
* @param {number} newIndex
* @param {number} currentIndex
*
* @returns {void}
*/
const handleUpdatePositionOfRelation = (newIndex, currentIndex) => {
if (onRelationReorder) {
if (onRelationReorder && newIndex >= 0 && newIndex < relations.length) {
onRelationReorder(currentIndex, newIndex);
const item = relations[currentIndex];
setLiveText(`${item.mainField ?? item.id}. New position in list: ${getItemPos(newIndex)}`);
}
};
/**
*
* @param {number} index
* @returns {string}
*/
const getItemPos = (index) => `${index + 1} of ${relations.length}`;
/**
*
* @param {number} index
* @returns {void}
*/
const handleGrabItem = (index) => {
const item = relations[index];
setLiveText(
`${item.mainField ?? item.id}, grabbed. Current position in list: ${getItemPos(
index
)}. Press up and down arrow to change position, Spacebar to drop, Escape to cancel.`
);
};
/**
*
* @param {number} index
* @returns {void}
*/
const handleDropItem = (index) => {
const item = relations[index];
setLiveText(
`${item.mainField ?? item.id}, dropped. Final position in list: ${getItemPos(index)}`
);
};
/**
*
* @param {number} index
* @returns {void}
*/
const handleCancel = (index) => {
const item = relations[index];
setLiveText(`${item.mainField ?? item.id}, dropped. Re-order cancelled.`);
};
return (
<Field error={error} name={name} hint={description} id={id}>
<Relation
@ -287,6 +351,13 @@ const RelationInput = ({
}
>
<RelationList overflow={overflow}>
<VisuallyHidden id={`${name}-item-instructions`}>
{formatMessage({
id: getTrad('components.RelationInput.instructions'),
defaultMessage: `Press spacebar to grab and re-order`,
})}
</VisuallyHidden>
<VisuallyHidden aria-live="assertive">{liveText}</VisuallyHidden>
<List
height={dynamicListHeight}
ref={listRef}
@ -294,7 +365,11 @@ const RelationInput = ({
itemCount={totalNumberOfRelations}
itemSize={RELATION_ITEM_HEIGHT + RELATION_GUTTER}
itemData={{
ariaDescribedBy: `${name}-item-instructions`,
disabled,
handleCancel,
handleDropItem,
handleGrabItem,
labelDisconnectRelation,
onRelationDisconnect,
publicationStateTranslations,
@ -395,7 +470,11 @@ RelationInput.propTypes = {
*/
const ListItem = ({ data, index, style }) => {
const {
ariaDescribedBy,
disabled,
handleCancel,
handleDropItem,
handleGrabItem,
labelDisconnectRelation,
onRelationDisconnect,
publicationStateTranslations,
@ -409,11 +488,11 @@ const ListItem = ({ data, index, style }) => {
return (
<RelationItem
disabled={disabled}
ariaDescribedBy={ariaDescribedBy}
canDrag={canDrag}
disabled={disabled}
id={id}
index={index}
updatePositionOfRelation={updatePositionOfRelation}
endAction={
<DisconnectButton
data-testid={`remove-relation-${id}`}
@ -425,11 +504,15 @@ const ListItem = ({ data, index, style }) => {
<Icon width="12px" as={Cross} />
</DisconnectButton>
}
onCancel={handleCancel}
onDrop={handleDropItem}
onGrab={handleGrabItem}
style={{
...style,
bottom: style.bottom ?? 0 + RELATION_GUTTER,
height: style.height ?? 0 - RELATION_GUTTER,
}}
updatePositionOfRelation={updatePositionOfRelation}
>
<BoxEllipsis minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
<Tooltip description={mainField ?? `${id}`}>
@ -462,7 +545,11 @@ ListItem.defaultProps = {
ListItem.propTypes = {
data: PropTypes.shape({
ariaDescribedBy: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
handleCancel: PropTypes.func,
handleDropItem: PropTypes.func,
handleGrabItem: PropTypes.func,
labelDisconnectRelation: PropTypes.string.isRequired,
onRelationDisconnect: PropTypes.func.isRequired,
publicationStateTranslations: PropTypes.shape({

View File

@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { useDrag, useDrop } from 'react-dnd';
@ -10,6 +10,7 @@ import { IconButton } from '@strapi/design-system/IconButton';
import Drag from '@strapi/icons/Drag';
import { composeRefs } from '../../../utils';
import { RELATION_GUTTER } from '../constants';
const ChildrenWrapper = styled(Flex)`
width: 100%;
@ -20,6 +21,7 @@ const ChildrenWrapper = styled(Flex)`
const RELATION_ITEM_DRAG_TYPE = 'RelationItem';
export const RelationItem = ({
ariaDescribedBy,
children,
canDrag,
disabled,
@ -27,9 +29,13 @@ export const RelationItem = ({
style,
id,
index,
onCancel,
onDropItem,
onGrabItem,
updatePositionOfRelation,
...props
}) => {
const [isSelected, setIsSelected] = useState(false);
const relationRef = useRef(null);
const [{ handlerId }, dropRef] = useDrop({
@ -85,8 +91,84 @@ export const RelationItem = ({
const composedRefs = composeRefs(relationRef, dragRef);
/**
* @type {(movement: 'UP' | 'DOWN') => void})}
*/
const handleMove = (movement) => {
if (!isSelected) {
return;
}
if (movement === 'UP') {
updatePositionOfRelation(index - 1, index);
} else if (movement === 'DOWN') {
updatePositionOfRelation(index + 1, index);
}
};
const handleDragClick = () => {
if (isSelected) {
if (onDropItem) {
onDropItem(index);
}
setIsSelected(false);
} else {
if (onGrabItem) {
onGrabItem(index);
}
setIsSelected(true);
}
};
const handleCancel = () => {
setIsSelected(false);
if (onCancel) {
onCancel(index);
}
};
/**
* @type {React.KeyboardEventHandler<HTMLButtonElement>}
*/
const handleKeyDown = (e) => {
if (e.key === 'Tab' && !isSelected) {
return;
}
e.preventDefault();
switch (e.key) {
case ' ':
handleDragClick();
break;
case 'Escape':
handleCancel();
break;
case 'ArrowDown':
case 'ArrowRight':
handleMove('DOWN');
break;
case 'ArrowUp':
case 'ArrowLeft':
handleMove('UP');
break;
default:
}
};
return (
<Box style={style} as="li" ref={dropRef}>
<Box
style={style}
as="li"
ref={dropRef}
aria-describedby={ariaDescribedBy}
cursor={canDrag ? 'all-scroll' : 'default'}
>
{isDragging ? (
<Box
ref={dragPreviewRef}
@ -99,7 +181,7 @@ export const RelationItem = ({
borderColor="primary600"
borderWidth="1px"
background="primary100"
height="100%"
height={`calc(100% - ${RELATION_GUTTER}px)`}
/>
) : (
<Flex
@ -118,7 +200,14 @@ export const RelationItem = ({
>
{/* TODO: swap this out for using children when DS is updated */}
{canDrag ? (
<IconButton marginRight={1} aria-label="Drag" noBorder icon={<Drag />} />
<IconButton
marginRight={1}
aria-label="Drag"
noBorder
icon={<Drag />}
onClick={handleDragClick}
onKeyDown={handleKeyDown}
/>
) : null}
<ChildrenWrapper justifyContent="space-between">{children}</ChildrenWrapper>
{endAction && <Box paddingLeft={4}>{endAction}</Box>}
@ -129,20 +218,28 @@ export const RelationItem = ({
};
RelationItem.defaultProps = {
ariaDescribedBy: '',
canDrag: false,
disabled: false,
endAction: undefined,
onCancel: undefined,
onDropItem: undefined,
onGrabItem: undefined,
style: undefined,
updatePositionOfRelation: undefined,
};
RelationItem.propTypes = {
ariaDescribedBy: PropTypes.string,
canDrag: PropTypes.bool,
children: PropTypes.node.isRequired,
disabled: PropTypes.bool,
endAction: PropTypes.node,
id: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
onCancel: PropTypes.func,
onDropItem: PropTypes.func,
onGrabItem: PropTypes.func,
style: PropTypes.shape({
height: PropTypes.number,
left: PropTypes.number,

View File

@ -1,18 +1,23 @@
import React from 'react';
import { render } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider, lightTheme } from '@strapi/design-system';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { RelationItem } from '../RelationItem';
const setup = ({ endAction }) =>
const setup = ({ endAction, testingDnd = false, ...props }) =>
render(
<ThemeProvider theme={lightTheme}>
<DndProvider backend={HTML5Backend}>
<RelationItem id={0} index={0} endAction={endAction}>
<RelationItem canDrag={testingDnd} id={0} index={0} endAction={endAction} {...props}>
First relation
</RelationItem>
{testingDnd ? (
<RelationItem canDrag={testingDnd} id={1} index={1} endAction={endAction} {...props}>
Second relation
</RelationItem>
) : null}
</DndProvider>
</ThemeProvider>
);
@ -29,4 +34,58 @@ describe('Content-Manager || RelationInput || RelationItem', () => {
expect(getByText('end action here')).toBeInTheDocument();
});
describe('Reordering relations', () => {
it('should not move with arrow keys if the button is not pressed first', () => {
const updatePositionOfRelationMock = jest.fn();
setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true });
const [draggedItem] = screen.getAllByLabelText('Drag');
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
expect(updatePositionOfRelationMock).not.toBeCalled();
});
it('should move with the arrow keys if the button has been activated first', () => {
const updatePositionOfRelationMock = jest.fn();
setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true });
const [draggedItem] = screen.getAllByLabelText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'ArrowDown', code: 'ArrowDown' });
expect(updatePositionOfRelationMock).toBeCalledWith(1, 0);
});
it('should not fire reorderRelation if the item is trying to go up and is the first item', () => {
const updatePositionOfRelationMock = jest.fn();
setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true });
const [draggedItem] = screen.getAllByLabelText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'ArrowUp', code: 'ArrowUp' });
expect(updatePositionOfRelationMock).not.toBeCalled();
});
it('should exit drag and drop mode when the escape key is pressed', () => {
const updatePositionOfRelationMock = jest.fn();
setup({ updatePositionOfRelation: updatePositionOfRelationMock, testingDnd: true });
const [draggedItem] = screen.getAllByLabelText('Drag');
fireEvent.keyDown(draggedItem, { key: ' ', code: 'Space' });
fireEvent.keyDown(draggedItem, { key: 'Escape', code: 'Escape' });
fireEvent.keyDown(draggedItem, { key: 'ArrowUp', code: 'ArrowUp' });
expect(updatePositionOfRelationMock).not.toBeCalled();
});
});
});

View File

@ -389,7 +389,7 @@ describe('RelationInputDataManager', () => {
);
});
test('Reorder an entity', () => {
test('reordering an entity', () => {
const { reorderRelation } = useCMEditViewDataManager();
setup();

View File

@ -554,6 +554,7 @@
"content-manager.components.LeftMenu.single-types": "Single Types",
"content-manager.components.LimitSelect.itemsPerPage": "Items per page",
"content-manager.components.NotAllowedInput.text": "No permissions to see this field",
"content-manager.components.RelationInput.instructions": "Press spacebar to grab and re-order",
"content-manager.components.RepeatableComponent.error-message": "The component(s) contain error(s)",
"content-manager.components.Search.placeholder": "Search for an entry...",
"content-manager.components.Select.draft-info-title": "State: Draft",