mirror of
https://github.com/strapi/strapi.git
synced 2025-11-01 18:33:55 +00:00
feat: add keyboard navigation for relations reordering
This commit is contained in:
parent
a60f265940
commit
09d8c286f4
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -389,7 +389,7 @@ describe('RelationInputDataManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('Reorder an entity', () => {
|
||||
test('reordering an entity', () => {
|
||||
const { reorderRelation } = useCMEditViewDataManager();
|
||||
setup();
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user