Skip to content

Commit 4851241

Browse files
JuliRossiFBanfi
andcommitted
Keyboard accesibility for bulk edit [MAPS-29] (#10110)
* Bulk-Edit-App: Fix sorting error and edit button with no padding [INTEG-3103] (#10090) * Bulk-Edit-App: Freeze top row with Field Names [INTEG-2953] (#10076) * freeze top row with Field Names * removing unused import * making the status column sticky too (#10082) * not showing the edit button when loading entries * fix error when changing sorting * Bulk edit: Filter columns [INTEG-3089] (#10089) * Bulk-Edit-App: Freeze top row with Field Names [INTEG-2953] (#10076) * freeze top row with Field Names * removing unused import * making the status column sticky too (#10082) * wip * select all * Refactor FilterColumns and SortMenu components for improved layout and functionality * Fixing states and enhancing performance in the process by not calling the getContentType each time * Fix box issue * Renaming and fixing warnings * sticky * corrections PR comments * Fixing rebase conflicts --------- Co-authored-by: Franco Banfi <62450599+FBanfi@users.noreply.github.com> Co-authored-by: francobanfi <franco.banfi@external.contentful.com> * wip * Added new useKeyboardNavigation hook to encapsulate keyboard navigation logic. * Simplifying a bit * Refactor keyboard navigation logic in useKeyboardNavigation hook for improved readability and performance. Simplified moveFocus and extendFocusToEdge functions by removing unnecessary useCallback and enhancing selection handling. * Refactor EntryTable and TableHeader components to improve keyboard navigation and selection handling. Updated focus logic to use HEADERS_ROW constant for better readability and maintainability. Enhanced checkbox toggle functionality for header and row selections. * fixing issues * changing styles for keyboard accessibility * Refactors and tests * Fixing focus on first cell and edge navigation * Readding column selection * Refactoring styles * Refactor Table components to centralize cell focus and selection logic. * Fixing checked disable checkboxes * Refactor EntryTable, TableHeader, and TableRow components to unify cell focus and selection logic. Updated function signatures to use FocusPosition for better clarity and maintainability. Enhanced keyboard navigation handling in useKeyboardNavigation hook and corresponding tests. * Simplifying a few things. Enter doesn't do that much anymore * fix merge --------- Co-authored-by: Franco Banfi <62450599+FBanfi@users.noreply.github.com> Co-authored-by: francobanfi <franco.banfi@external.contentful.com>
1 parent e39cdce commit 4851241

File tree

11 files changed

+1222
-162
lines changed

11 files changed

+1222
-162
lines changed

apps/bulk-edit/src/locations/Page/components/EntryTable.tsx

Lines changed: 159 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import React, { useState, useMemo } from 'react';
1+
import React, { useState, useMemo, useCallback, useEffect } from 'react';
22
import { Table, Box, Pagination } from '@contentful/f36-components';
33
import { Entry, ContentTypeField } from '../types';
44
import { ContentTypeProps } from 'contentful-management';
55
import { styles } from '../styles';
66
import { TableHeader } from './TableHeader';
77
import { TableRow } from './TableRow';
8-
import { isCheckboxAllowed as isBulkEditable } from '../utils/entryUtils';
9-
import { DISPLAY_NAME_COLUMN, ENTRY_STATUS_COLUMN } from '../utils/constants';
8+
import { isCheckboxAllowed as isBulkEditable, getEntryUrl } from '../utils/entryUtils';
9+
import {
10+
DISPLAY_NAME_COLUMN,
11+
DISPLAY_NAME_INDEX,
12+
ENTRY_STATUS_COLUMN,
13+
HEADERS_ROW,
14+
} from '../utils/constants';
15+
import { useKeyboardNavigation, FocusPosition, FocusRange } from '../hooks/useKeyboardNavigation';
1016

1117
interface EntryTableProps {
1218
entries: Entry[];
@@ -79,14 +85,15 @@ export const EntryTable: React.FC<EntryTableProps> = ({
7985
const [headerCheckboxes, setHeaderCheckboxes] = useState<Record<string, boolean>>(
8086
getInitialCheckboxState(columnIds)
8187
);
82-
8388
const [rowCheckboxes, setRowCheckboxes] = useState<Record<string, Record<string, boolean>>>(
8489
getInitialRowCheckboxState(entries, columnIds)
8590
);
8691

87-
// Compute selected field (column)
92+
const getEntryId = (rowIndex: number) => {
93+
return entries[rowIndex]?.sys.id || null;
94+
};
95+
8896
const selectedFieldId = useMemo(() => {
89-
// Only one column can be selected at a time
9097
const checkedHeaderId = columnIds.find((columnId) => headerCheckboxes[columnId]);
9198
if (checkedHeaderId && allowedColumns[checkedHeaderId]) return checkedHeaderId;
9299
for (const entryId in rowCheckboxes) {
@@ -97,55 +104,162 @@ export const EntryTable: React.FC<EntryTableProps> = ({
97104
return null;
98105
}, [headerCheckboxes, rowCheckboxes, allowedColumns, columnIds]);
99106

100-
// Compute selected entry IDs for the selected field
101107
const selectedEntryIds = useMemo(() => {
102108
if (!selectedFieldId) return [];
103-
// If header is checked, all entries are selected
109+
104110
if (headerCheckboxes[selectedFieldId]) {
105111
return entries.map((e) => e.sys.id);
106112
}
107-
// Otherwise, collect entry IDs where the cell is checked
108113
return entries.filter((e) => rowCheckboxes[e.sys.id]?.[selectedFieldId]).map((e) => e.sys.id);
109114
}, [selectedFieldId, headerCheckboxes, rowCheckboxes, entries]);
110115

111-
React.useEffect(() => {
116+
useEffect(() => {
112117
if (onSelectionChange) {
113118
onSelectionChange({ selectedEntryIds, selectedFieldId });
114119
}
115120
}, [selectedEntryIds, selectedFieldId, onSelectionChange]);
116121

117-
function handleHeaderCheckboxChange(columnId: string, checked: boolean) {
118-
setHeaderCheckboxes((previous) => ({ ...previous, [columnId]: checked }));
122+
const handleHeaderCheckboxChange = useCallback(
123+
(columnId: string, checked: boolean) => {
124+
// Clear all header checkboxes and set only the target one
125+
// This is to avoid the issue where multiple header checkboxes are checked simultaneously
126+
setHeaderCheckboxes((previous) =>
127+
Object.fromEntries(columnIds.map((id) => [id, id === columnId ? checked : false]))
128+
);
119129

120-
setRowCheckboxes((previous) => {
121-
const updated: Record<string, Record<string, boolean>> = {};
122-
Object.entries(previous).forEach(([entryId, _]) => {
123-
updated[entryId] = Object.fromEntries(
124-
columnIds.map((id) => [id, id === columnId ? checked : false])
125-
);
130+
setRowCheckboxes((previous) => {
131+
const updated: Record<string, Record<string, boolean>> = {};
132+
Object.entries(previous).forEach(([entryId, _]) => {
133+
updated[entryId] = Object.fromEntries(
134+
columnIds.map((id) => [id, id === columnId ? checked : false])
135+
);
136+
});
137+
return updated;
138+
});
139+
},
140+
[columnIds]
141+
);
142+
143+
const handleCellCheckboxChange = useCallback(
144+
(entryId: string, columnId: string, checked: boolean) => {
145+
setRowCheckboxes((previous) => {
146+
const updated: Record<string, Record<string, boolean>> = {};
147+
Object.entries(previous).forEach(([currentEntryId, currentRow]) => {
148+
updated[currentEntryId] = Object.fromEntries(
149+
columnIds.map((id) => {
150+
if (id === columnId) {
151+
// For the target column, set the checkbox state for the target entry
152+
return [id, currentEntryId === entryId ? checked : currentRow[id]];
153+
} else {
154+
// Clear all other checkboxes
155+
return [id, false];
156+
}
157+
})
158+
);
159+
});
160+
return updated;
126161
});
127-
return updated;
128-
});
129-
}
130-
131-
function handleCellCheckboxChange(entryId: string, columnId: string, checked: boolean) {
132-
setRowCheckboxes((previous) => {
133-
const updated = { ...previous };
134-
updated[entryId] = {
135-
...previous[entryId],
136-
...Object.fromEntries(columnIds.map((id) => [id, id === columnId ? checked : false])),
137-
};
138-
return updated;
139-
});
140-
141-
setHeaderCheckboxes((previous) => ({
142-
...Object.fromEntries(columnIds.map((id) => [id, id === columnId ? false : previous[id]])),
143-
}));
144-
}
162+
163+
// Clear all header checkboxes when a cell is selected
164+
setHeaderCheckboxes(Object.fromEntries(columnIds.map((id) => [id, false])));
165+
},
166+
[columnIds]
167+
);
168+
169+
const toggleCheckbox = useCallback(
170+
(position: FocusPosition) => {
171+
const columnId = columnIds[position.column];
172+
if (!allowedColumns[columnId]) return;
173+
174+
if (position.row === HEADERS_ROW) {
175+
handleHeaderCheckboxChange(columnId, !headerCheckboxes[columnId]);
176+
} else {
177+
const entryId = getEntryId(position.row);
178+
if (entryId) {
179+
handleCellCheckboxChange(entryId, columnId, !rowCheckboxes[entryId]?.[columnId]);
180+
}
181+
}
182+
},
183+
[columnIds, handleHeaderCheckboxChange, handleCellCheckboxChange]
184+
);
185+
186+
const toggleCheckboxes = () => {
187+
if (!focusRange) {
188+
if (focusedCell) {
189+
toggleCheckbox(focusedCell);
190+
}
191+
return;
192+
}
193+
194+
const { start, end } = focusRange;
195+
const minRow = Math.min(start.row, end.row);
196+
const maxRow = Math.max(start.row, end.row);
197+
const columnId = columnIds[start.column];
198+
199+
if (!allowedColumns[columnId]) return;
200+
201+
const hasHeaderFocused = minRow <= HEADERS_ROW && maxRow >= HEADERS_ROW;
202+
203+
let allChecked = true;
204+
205+
if (hasHeaderFocused) {
206+
allChecked = headerCheckboxes[columnId];
207+
} else {
208+
for (let row = minRow; row <= maxRow; row++) {
209+
const entryId = getEntryId(row);
210+
if (entryId && !rowCheckboxes[entryId]?.[columnId]) {
211+
allChecked = false;
212+
break;
213+
}
214+
}
215+
}
216+
217+
const newState = !allChecked;
218+
219+
// Apply the new state
220+
if (hasHeaderFocused) {
221+
handleHeaderCheckboxChange(columnId, newState);
222+
} else {
223+
for (let row = minRow; row <= maxRow; row++) {
224+
const entryId = getEntryId(row);
225+
if (entryId) {
226+
handleCellCheckboxChange(entryId, columnId, newState);
227+
}
228+
}
229+
}
230+
};
231+
232+
const handleCellAction = (position: FocusPosition) => {
233+
const { row: rowIndex, column: columnIndex } = position;
234+
const columnId = columnIds[columnIndex];
235+
const isHeaderRow = rowIndex === HEADERS_ROW;
236+
237+
if (columnIndex === DISPLAY_NAME_INDEX && !isHeaderRow) {
238+
const entry = entries[rowIndex];
239+
if (entry) {
240+
const url = getEntryUrl(entry, spaceId, environmentId);
241+
window.open(url, '_blank', 'noopener,noreferrer');
242+
}
243+
} else if (allowedColumns[columnId]) {
244+
toggleCheckboxes();
245+
}
246+
};
247+
248+
const { focusedCell, focusRange, focusCell, tableRef } = useKeyboardNavigation({
249+
totalColumns: columnIds.length,
250+
entriesLength: entries.length,
251+
onCellAction: handleCellAction,
252+
});
145253

146254
return (
147255
<>
148-
<Table testId="bulk-edit-table" style={styles.table}>
256+
<Table
257+
ref={tableRef}
258+
testId="bulk-edit-table"
259+
style={styles.table}
260+
tabIndex={0}
261+
role="grid"
262+
aria-label="Bulk edit table with keyboard navigation">
149263
<TableHeader
150264
fields={fields}
151265
headerCheckboxes={headerCheckboxes}
@@ -158,9 +272,12 @@ export const EntryTable: React.FC<EntryTableProps> = ({
158272
: true,
159273
])
160274
)}
275+
focusedCell={focusedCell}
276+
focusRange={focusRange}
277+
onCellFocus={(position) => focusCell(position)}
161278
/>
162279
<Table.Body>
163-
{entries.map((entry) => (
280+
{entries.map((entry, rowIndex) => (
164281
<TableRow
165282
key={entry.sys.id}
166283
entry={entry}
@@ -181,6 +298,10 @@ export const EntryTable: React.FC<EntryTableProps> = ({
181298
: true,
182299
])
183300
)}
301+
rowIndex={rowIndex}
302+
focusedCell={focusedCell}
303+
focusRange={focusRange}
304+
onCellFocus={(position) => focusCell(position)}
184305
/>
185306
))}
186307
</Table.Body>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import tokens from '@contentful/f36-tokens';
2+
import { stickyCell } from '../styles';
3+
import { CELL_WIDTH, STICKY_SPACER_SPACING } from '../utils/constants';
4+
5+
export const headerStyles = {
6+
tableHead: {
7+
borderTop: `transparent`,
8+
},
9+
stickyTableRow: {
10+
background: tokens.colorWhite,
11+
position: 'sticky',
12+
top: 0,
13+
zIndex: 2,
14+
},
15+
tableHeader: {
16+
background: tokens.gray200,
17+
borderRight: `1px solid ${tokens.gray300}`,
18+
minWidth: `${CELL_WIDTH}px`,
19+
},
20+
stickyHeader: {
21+
background: tokens.gray200,
22+
position: 'sticky',
23+
left: 0,
24+
borderTop: `transparent`,
25+
},
26+
displayNameHeader: {
27+
...stickyCell,
28+
background: tokens.gray200,
29+
left: STICKY_SPACER_SPACING + CELL_WIDTH,
30+
},
31+
statusHeader: {
32+
...stickyCell,
33+
background: tokens.gray200,
34+
left: STICKY_SPACER_SPACING + CELL_WIDTH * 2,
35+
},
36+
} as const;

0 commit comments

Comments
 (0)