diff --git a/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.spec.tsx b/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.spec.tsx index f62533e0c00..b3aaa656139 100644 --- a/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/ColumnsButton.spec.tsx @@ -29,7 +29,7 @@ describe('ColumnsButton', () => { screen .getByRole('menu') .querySelectorAll('li:not(.columns-selector-actions)') - ).toHaveLength(8); // 7 columns + the filter input li + ).toHaveLength(7); // Typing a filter fireEvent.change( screen.getByPlaceholderText('ra.action.search_columns'), @@ -43,7 +43,7 @@ describe('ColumnsButton', () => { screen .getByRole('menu') .querySelectorAll('li:not(.columns-selector-actions)') - ).toHaveLength(2); // only the column with 'DiA' in its label should remain + the filter input li + ).toHaveLength(1); }); screen.getByLabelText('Téstïng diàcritics'); // Clear the filter @@ -53,7 +53,7 @@ describe('ColumnsButton', () => { screen .getByRole('menu') .querySelectorAll('li:not(.columns-selector-actions)') - ).toHaveLength(8); + ).toHaveLength(7); }); }); }); diff --git a/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx b/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx index d90e6d0ce13..45a524e3614 100644 --- a/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx +++ b/packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx @@ -60,59 +60,187 @@ export const ColumnsSelector = ({ children }: ColumnsSelectorProps) => { const [columnFilter, setColumnFilter] = React.useState(''); - if (!container) return null; - const childrenArray = Children.toArray(children); const paddedColumnRanks = padRanks(columnRanks ?? [], childrenArray.length); const shouldDisplaySearchInput = childrenArray.length > 5; + const handleMove = (index1, index2) => { + const colRanks = !columnRanks + ? padRanks([], Math.max(index1, index2) + 1) + : Math.max(index1, index2) > columnRanks.length - 1 + ? padRanks(columnRanks, Math.max(index1, index2) + 1) + : columnRanks; + const index1Pos = colRanks.findIndex( + // eslint-disable-next-line eqeqeq + index => index == index1 + ); + const index2Pos = colRanks.findIndex( + // eslint-disable-next-line eqeqeq + index => index == index2 + ); + if (index1Pos === -1 || index2Pos === -1) { + return; + } + let newColumnRanks; + if (index1Pos > index2Pos) { + newColumnRanks = [ + ...colRanks.slice(0, index2Pos), + colRanks[index1Pos], + ...colRanks.slice(index2Pos, index1Pos), + ...colRanks.slice(index1Pos + 1), + ]; + } else { + newColumnRanks = [ + ...colRanks.slice(0, index1Pos), + ...colRanks.slice(index1Pos + 1, index2Pos + 1), + colRanks[index1Pos], + ...colRanks.slice(index2Pos + 1), + ]; + } + setColumnRanks(newColumnRanks); + return index2Pos; + }; + + const list = React.useRef(null); + const draggedItem = React.useRef(null); + const dropItem = React.useRef(null); + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Use setTimeout to let MenuList handle the focus management + setTimeout(() => { + if (document.activeElement?.tagName !== 'LI') { + return; + } + + if (event.key === ' ') { + if (!draggedItem.current) { + // Start dragging the currently focused item + draggedItem.current = + document.activeElement as HTMLLIElement; + draggedItem.current.classList.add('drag-active-keyboard'); + } else { + if (!dropItem.current) { + return; + } + // Drop the dragged item + draggedItem.current.classList.remove( + 'drag-active-keyboard' + ); + const itemToFocusIndex = handleMove( + draggedItem.current.dataset.index, + dropItem.current?.dataset.index + ); + setTimeout(() => { + // We wait for the DOM to update before focusing + // the item that was moved. + // We use the actual position it was moved to and not the data-index which may not be updated yet + if (itemToFocusIndex && list.current) { + const itemToFocus = + list.current.querySelectorAll('li')[ + itemToFocusIndex + ]; + if (itemToFocus) { + (itemToFocus as HTMLLIElement).focus(); + } + } + draggedItem.current = null; + }); + } + } + if (!draggedItem.current) { + return; + } + if (event.key === 'ArrowDown') { + // Swap the dragged item with the next one + const nextItem = draggedItem.current.nextElementSibling; + if (nextItem) { + draggedItem.current.parentNode?.insertBefore( + draggedItem.current, + nextItem.nextSibling + ); + dropItem.current = nextItem as HTMLLIElement; + draggedItem.current.focus(); + } else { + // Start of the list, move the dragged item as the first item + draggedItem.current.parentNode?.insertBefore( + draggedItem.current, + draggedItem.current?.parentNode?.firstChild + ); + dropItem.current = draggedItem.current?.parentNode + ?.firstChild as HTMLLIElement; + draggedItem.current.focus(); + } + } else if (event.key === 'ArrowUp') { + // Swap the dragged item with the previous one + const prevItem = draggedItem.current.previousElementSibling; + if (prevItem) { + draggedItem.current?.parentNode?.insertBefore( + draggedItem.current, + prevItem + ); + dropItem.current = prevItem as HTMLLIElement; + draggedItem.current.focus(); + } else { + // End of the list, move the dragged item as the last item + draggedItem.current?.parentNode?.appendChild( + draggedItem.current + ); + dropItem.current = draggedItem.current?.parentNode + ?.lastChild as HTMLLIElement; + draggedItem.current.focus(); + } + } + }); + }; + + if (!container) return null; + return createPortal( - + <> {shouldDisplaySearchInput ? ( - - { - if (typeof e === 'string') { - setColumnFilter(e); - return; - } - setColumnFilter(e.target.value); - }} - placeholder={translate('ra.action.search_columns', { - _: 'Search columns', - })} - InputProps={{ - endAdornment: ( - - - - ), - }} - resettable - autoFocus - size="small" - sx={{ mb: 1 }} - /> - + { + if (typeof e === 'string') { + setColumnFilter(e); + return; + } + setColumnFilter(e.target.value); + }} + placeholder={translate('ra.action.search_columns', { + _: 'Search columns', + })} + InputProps={{ + endAdornment: ( + + + + ), + }} + resettable + autoFocus + size="small" + sx={{ my: 1 }} + /> ) : null} - {paddedColumnRanks.map((position, index) => ( - - + {paddedColumnRanks.map((position, index) => ( + - {childrenArray[position]} - - - ))} + + {childrenArray[position]} + + + ))} + @@ -125,7 +253,7 @@ export const ColumnsSelector = ({ children }: ColumnsSelectorProps) => { Reset - , + , container ); }; diff --git a/packages/ra-ui-materialui/src/preferences/FieldToggle.tsx b/packages/ra-ui-materialui/src/preferences/FieldToggle.tsx index 184b88869c8..82eee5f9f2f 100644 --- a/packages/ra-ui-materialui/src/preferences/FieldToggle.tsx +++ b/packages/ra-ui-materialui/src/preferences/FieldToggle.tsx @@ -109,6 +109,7 @@ export const FieldToggle = (props: FieldToggleProps) => { onDragEnd={onMove ? handleDragEnd : undefined} onDragOver={onMove ? handleDragOver : undefined} data-index={index} + tabIndex={0} >