From 86c75cea8ec3a9a3984906778ec1ac523fcbfd1b Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sun, 12 Oct 2025 18:56:10 +0200 Subject: [PATCH] frontend: keyboard nav --- frontend/src/index.jsx | 179 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 3 deletions(-) diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 97de002..7a0826e 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -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 (
@@ -574,7 +607,27 @@ const DocumentsTable = ({
{filterBar}
-
+
{ + 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 ? (
Drop files anywhere or onto a folder to upload documents. @@ -599,7 +652,10 @@ const DocumentsTable = ({ 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 = ({ 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 = {