frontend: keyboard nav

This commit is contained in:
2025-10-12 18:56:10 +02:00
parent 3f0c607383
commit 86c75cea8e
+176 -3
View File
@@ -523,6 +523,8 @@ const DocumentsTable = ({
onDocumentDelete,
filterBar,
tagLookupById,
onDocumentListFocus,
onDocumentListKeyDown,
}) => {
const showingSearchResults = searchResults !== null;
const rows = showingSearchResults ? searchResults : documents;
@@ -534,6 +536,37 @@ const DocumentsTable = ({
() => new Set(draggingDocumentIds || []),
[draggingDocumentIds],
);
const scrollRef = useRef(null);
const ensureFocusedRowVisible = useCallback(() => {
if (!focusedDocumentId) return;
const container = scrollRef.current;
if (!container) return;
const row = container.querySelector(`#document-row-${focusedDocumentId}`);
if (!row || !container.contains(row)) {
return;
}
const header = container.querySelector('thead');
const headerHeight = header ? header.getBoundingClientRect().height : 0;
const rowTop = row.offsetTop;
const rowBottom = rowTop + row.offsetHeight;
const visibleTop = container.scrollTop + headerHeight;
const visibleBottom = container.scrollTop + container.clientHeight;
if (rowTop < visibleTop) {
container.scrollTop = Math.max(rowTop - headerHeight, 0);
return;
}
if (rowBottom > visibleBottom) {
const nextScrollTop = rowBottom - container.clientHeight;
container.scrollTop = Math.max(nextScrollTop, 0);
}
}, [focusedDocumentId]);
useEffect(() => {
ensureFocusedRowVisible();
}, [ensureFocusedRowVisible]);
return (
<section className="documents-panel column">
@@ -574,7 +607,27 @@ const DocumentsTable = ({
</div>
<div className="column-body">
<div className="column-toolbar">{filterBar}</div>
<div className="documents-scroll">
<div
ref={scrollRef}
className="documents-scroll"
tabIndex={0}
onFocus={(event) => {
if (event.target === scrollRef.current) {
onDocumentListFocus?.();
}
}}
onKeyDown={(event) => {
if (event.target !== scrollRef.current) {
return;
}
if (onDocumentListKeyDown) {
onDocumentListKeyDown(event);
}
}}
aria-activedescendant={
focusedDocumentId ? `document-row-${focusedDocumentId}` : undefined
}
>
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
<div className="empty-state">
Drop files anywhere or onto a folder to upload documents.
@@ -599,7 +652,10 @@ const DocumentsTable = ({
<tr
key={folder.id}
className={`folder${isDraggingFolder ? ' is-dragging' : ''}`}
onClick={() => onFolderSelect(folder.id)}
onClick={() => {
scrollRef.current?.focus({ preventScroll: true });
onFolderSelect(folder.id);
}}
onDragOver={(event) => onFolderDragOver(event, folder.id)}
onDragLeave={onFolderDragLeave}
onDrop={(event) => onFolderDrop(event, folder.id)}
@@ -651,8 +707,12 @@ const DocumentsTable = ({
<tr
key={doc.id}
className={rowClasses.join(' ')}
id={`document-row-${doc.id}`}
aria-selected={isSelected}
onClick={(event) => onDocumentRowClick(doc.id, event)}
onClick={(event) => {
scrollRef.current?.focus({ preventScroll: true });
onDocumentRowClick(doc.id, event);
}}
onDoubleClick={(event) => {
event.stopPropagation();
if (onDocumentOpen) {
@@ -2217,6 +2277,117 @@ const AppLayout = () => {
],
);
const handleDocumentListFocus = useCallback(() => {
if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) {
return;
}
for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) {
const candidate = selectedDocumentIds[index];
if (visibleDocumentIds.includes(candidate)) {
setFocusedDocumentId(candidate);
return;
}
}
if (visibleDocumentIds.length) {
const firstId = visibleDocumentIds[0];
applySelection([firstId], { anchor: firstId, interactedIds: [firstId] });
}
}, [
focusedDocumentId,
visibleDocumentIds,
selectedDocumentIds,
setFocusedDocumentId,
applySelection,
]);
const handleDocumentListKeyDown = useCallback(
(event) => {
const { key, shiftKey } = event;
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(key)) {
return;
}
if (!visibleDocumentIds.length) {
return;
}
event.preventDefault();
const activeId = (() => {
if (focusedDocumentId && visibleDocumentIds.includes(focusedDocumentId)) {
return focusedDocumentId;
}
for (let index = selectedDocumentIds.length - 1; index >= 0; index -= 1) {
const candidate = selectedDocumentIds[index];
if (visibleDocumentIds.includes(candidate)) {
return candidate;
}
}
return null;
})();
let currentIndex = activeId ? visibleDocumentIds.indexOf(activeId) : -1;
let nextIndex = currentIndex;
if (key === 'ArrowDown') {
nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, visibleDocumentIds.length - 1);
} else if (key === 'ArrowUp') {
if (currentIndex === -1) {
nextIndex = visibleDocumentIds.length - 1;
} else {
nextIndex = Math.max(currentIndex - 1, 0);
}
} else if (key === 'Home') {
nextIndex = 0;
} else if (key === 'End') {
nextIndex = visibleDocumentIds.length - 1;
}
if (nextIndex === -1 || nextIndex >= visibleDocumentIds.length) {
return;
}
if (nextIndex === currentIndex && key !== 'Home' && key !== 'End') {
return;
}
const targetId = visibleDocumentIds[nextIndex];
if (!targetId) {
return;
}
if (shiftKey) {
let anchorId = selectionAnchorRef.current;
if (!anchorId || !visibleDocumentIds.includes(anchorId)) {
anchorId = activeId || targetId;
}
const anchorIndex = visibleDocumentIds.indexOf(anchorId);
const boundedAnchorIndex = anchorIndex === -1 ? nextIndex : anchorIndex;
const start = Math.min(boundedAnchorIndex, nextIndex);
const end = Math.max(boundedAnchorIndex, nextIndex);
const range = visibleDocumentIds.slice(start, end + 1);
applySelection(range, { anchor: anchorId, interactedIds: [targetId] });
} else {
applySelection([targetId], { anchor: targetId, interactedIds: [targetId] });
}
if (focusedDocumentId !== targetId) {
setFocusedDocumentId(targetId);
}
},
[
visibleDocumentIds,
focusedDocumentId,
selectedDocumentIds,
applySelection,
selectionAnchorRef,
setFocusedDocumentId,
],
);
const handleDocumentDragStart = useCallback(
(event, documentId) => {
const selection = selectedDocumentIds.includes(documentId)
@@ -4335,6 +4506,8 @@ const AppLayout = () => {
onDocumentDragEnd: handleDocumentDragEnd,
filterBar,
tagLookupById,
onDocumentListFocus: handleDocumentListFocus,
onDocumentListKeyDown: handleDocumentListKeyDown,
};
const detailPanelProps = {