frontend: keyboard nav
This commit is contained in:
+176
-3
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user