Skip to content

Commit dce0967

Browse files
committed
Implement reordering columns using the keyboard
1 parent 72e6f6d commit dce0967

File tree

2 files changed

+179
-44
lines changed

2 files changed

+179
-44
lines changed

packages/ra-ui-materialui/src/list/datatable/ColumnsSelector.tsx

Lines changed: 172 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -60,59 +60,187 @@ export const ColumnsSelector = ({ children }: ColumnsSelectorProps) => {
6060

6161
const [columnFilter, setColumnFilter] = React.useState<string>('');
6262

63-
if (!container) return null;
64-
6563
const childrenArray = Children.toArray(children);
6664
const paddedColumnRanks = padRanks(columnRanks ?? [], childrenArray.length);
6765
const shouldDisplaySearchInput = childrenArray.length > 5;
6866

67+
const handleMove = (index1, index2) => {
68+
const colRanks = !columnRanks
69+
? padRanks([], Math.max(index1, index2) + 1)
70+
: Math.max(index1, index2) > columnRanks.length - 1
71+
? padRanks(columnRanks, Math.max(index1, index2) + 1)
72+
: columnRanks;
73+
const index1Pos = colRanks.findIndex(
74+
// eslint-disable-next-line eqeqeq
75+
index => index == index1
76+
);
77+
const index2Pos = colRanks.findIndex(
78+
// eslint-disable-next-line eqeqeq
79+
index => index == index2
80+
);
81+
if (index1Pos === -1 || index2Pos === -1) {
82+
return;
83+
}
84+
let newColumnRanks;
85+
if (index1Pos > index2Pos) {
86+
newColumnRanks = [
87+
...colRanks.slice(0, index2Pos),
88+
colRanks[index1Pos],
89+
...colRanks.slice(index2Pos, index1Pos),
90+
...colRanks.slice(index1Pos + 1),
91+
];
92+
} else {
93+
newColumnRanks = [
94+
...colRanks.slice(0, index1Pos),
95+
...colRanks.slice(index1Pos + 1, index2Pos + 1),
96+
colRanks[index1Pos],
97+
...colRanks.slice(index2Pos + 1),
98+
];
99+
}
100+
setColumnRanks(newColumnRanks);
101+
return index2Pos;
102+
};
103+
104+
const list = React.useRef<HTMLUListElement | null>(null);
105+
const draggedItem = React.useRef<HTMLLIElement | null>(null);
106+
const dropItem = React.useRef<HTMLLIElement | null>(null);
107+
108+
const handleKeyDown = (event: React.KeyboardEvent) => {
109+
// Use setTimeout to let MenuList handle the focus management
110+
setTimeout(() => {
111+
if (document.activeElement?.tagName !== 'LI') {
112+
return;
113+
}
114+
115+
if (event.key === ' ') {
116+
if (!draggedItem.current) {
117+
// Start dragging the currently focused item
118+
draggedItem.current =
119+
document.activeElement as HTMLLIElement;
120+
draggedItem.current.classList.add('drag-active-keyboard');
121+
} else {
122+
if (!dropItem.current) {
123+
return;
124+
}
125+
// Drop the dragged item
126+
draggedItem.current.classList.remove(
127+
'drag-active-keyboard'
128+
);
129+
const itemToFocusIndex = handleMove(
130+
draggedItem.current.dataset.index,
131+
dropItem.current?.dataset.index
132+
);
133+
setTimeout(() => {
134+
// We wait for the DOM to update before focusing
135+
// the item that was moved.
136+
// We use the actual position it was moved to and not the data-index which may not be updated yet
137+
if (itemToFocusIndex && list.current) {
138+
const itemToFocus =
139+
list.current.querySelectorAll('li')[
140+
itemToFocusIndex
141+
];
142+
if (itemToFocus) {
143+
(itemToFocus as HTMLLIElement).focus();
144+
}
145+
}
146+
draggedItem.current = null;
147+
});
148+
}
149+
}
150+
if (!draggedItem.current) {
151+
return;
152+
}
153+
if (event.key === 'ArrowDown') {
154+
// Swap the dragged item with the next one
155+
const nextItem = draggedItem.current.nextElementSibling;
156+
if (nextItem) {
157+
draggedItem.current.parentNode?.insertBefore(
158+
draggedItem.current,
159+
nextItem.nextSibling
160+
);
161+
dropItem.current = nextItem as HTMLLIElement;
162+
draggedItem.current.focus();
163+
} else {
164+
// Start of the list, move the dragged item as the first item
165+
draggedItem.current.parentNode?.insertBefore(
166+
draggedItem.current,
167+
draggedItem.current?.parentNode?.firstChild
168+
);
169+
dropItem.current = draggedItem.current?.parentNode
170+
?.firstChild as HTMLLIElement;
171+
draggedItem.current.focus();
172+
}
173+
} else if (event.key === 'ArrowUp') {
174+
// Swap the dragged item with the previous one
175+
const prevItem = draggedItem.current.previousElementSibling;
176+
if (prevItem) {
177+
draggedItem.current?.parentNode?.insertBefore(
178+
draggedItem.current,
179+
prevItem
180+
);
181+
dropItem.current = prevItem as HTMLLIElement;
182+
draggedItem.current.focus();
183+
} else {
184+
// End of the list, move the dragged item as the last item
185+
draggedItem.current?.parentNode?.appendChild(
186+
draggedItem.current
187+
);
188+
dropItem.current = draggedItem.current?.parentNode
189+
?.lastChild as HTMLLIElement;
190+
draggedItem.current.focus();
191+
}
192+
}
193+
});
194+
};
195+
196+
if (!container) return null;
197+
69198
return createPortal(
70-
<MenuList>
199+
<>
71200
{shouldDisplaySearchInput ? (
72-
<Box component="li" tabIndex={-1}>
73-
<ResettableTextField
74-
hiddenLabel
75-
label=""
76-
value={columnFilter}
77-
onChange={e => {
78-
if (typeof e === 'string') {
79-
setColumnFilter(e);
80-
return;
81-
}
82-
setColumnFilter(e.target.value);
83-
}}
84-
placeholder={translate('ra.action.search_columns', {
85-
_: 'Search columns',
86-
})}
87-
InputProps={{
88-
endAdornment: (
89-
<InputAdornment position="end">
90-
<SearchIcon color="disabled" />
91-
</InputAdornment>
92-
),
93-
}}
94-
resettable
95-
autoFocus
96-
size="small"
97-
sx={{ mb: 1 }}
98-
/>
99-
</Box>
201+
<ResettableTextField
202+
hiddenLabel
203+
label=""
204+
value={columnFilter}
205+
onChange={e => {
206+
if (typeof e === 'string') {
207+
setColumnFilter(e);
208+
return;
209+
}
210+
setColumnFilter(e.target.value);
211+
}}
212+
placeholder={translate('ra.action.search_columns', {
213+
_: 'Search columns',
214+
})}
215+
InputProps={{
216+
endAdornment: (
217+
<InputAdornment position="end">
218+
<SearchIcon color="disabled" />
219+
</InputAdornment>
220+
),
221+
}}
222+
resettable
223+
autoFocus
224+
size="small"
225+
sx={{ my: 1 }}
226+
/>
100227
) : null}
101-
{paddedColumnRanks.map((position, index) => (
102-
<DataTableColumnRankContext.Provider
103-
value={position}
104-
key={index}
105-
>
106-
<DataTableColumnFilterContext.Provider
107-
value={columnFilter}
228+
<MenuList onKeyDown={handleKeyDown} ref={list}>
229+
{paddedColumnRanks.map((position, index) => (
230+
<DataTableColumnRankContext.Provider
231+
value={position}
108232
key={index}
109233
>
110-
{childrenArray[position]}
111-
</DataTableColumnFilterContext.Provider>
112-
</DataTableColumnRankContext.Provider>
113-
))}
234+
<DataTableColumnFilterContext.Provider
235+
value={columnFilter}
236+
key={index}
237+
>
238+
{childrenArray[position]}
239+
</DataTableColumnFilterContext.Provider>
240+
</DataTableColumnRankContext.Provider>
241+
))}
242+
</MenuList>
114243
<Box
115-
component="li"
116244
className="columns-selector-actions"
117245
sx={{ textAlign: 'center', mt: 1 }}
118246
>
@@ -125,7 +253,7 @@ export const ColumnsSelector = ({ children }: ColumnsSelectorProps) => {
125253
Reset
126254
</Button>
127255
</Box>
128-
</MenuList>,
256+
</>,
129257
container
130258
);
131259
};

packages/ra-ui-materialui/src/preferences/FieldToggle.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export const FieldToggle = (props: FieldToggleProps) => {
109109
onDragEnd={onMove ? handleDragEnd : undefined}
110110
onDragOver={onMove ? handleDragOver : undefined}
111111
data-index={index}
112+
tabIndex={0}
112113
>
113114
<label htmlFor={`switch_${index}`}>
114115
<Switch
@@ -169,6 +170,12 @@ const Root = styled('li', {
169170
visibility: 'hidden',
170171
},
171172
},
173+
'&.drag-active-keyboard': {
174+
outline: `1px solid ${(theme.vars || theme).palette.action.selected}`,
175+
'& .MuiSwitch-root, & svg': {
176+
visibility: 'hidden',
177+
},
178+
},
172179
}));
173180

174181
declare module '@mui/material/styles' {

0 commit comments

Comments
 (0)