frontend: keyboard nav
This commit is contained in:
+176
-3
@@ -523,6 +523,8 @@ const DocumentsTable = ({
|
|||||||
onDocumentDelete,
|
onDocumentDelete,
|
||||||
filterBar,
|
filterBar,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
onDocumentListFocus,
|
||||||
|
onDocumentListKeyDown,
|
||||||
}) => {
|
}) => {
|
||||||
const showingSearchResults = searchResults !== null;
|
const showingSearchResults = searchResults !== null;
|
||||||
const rows = showingSearchResults ? searchResults : documents;
|
const rows = showingSearchResults ? searchResults : documents;
|
||||||
@@ -534,6 +536,37 @@ const DocumentsTable = ({
|
|||||||
() => new Set(draggingDocumentIds || []),
|
() => new Set(draggingDocumentIds || []),
|
||||||
[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 (
|
return (
|
||||||
<section className="documents-panel column">
|
<section className="documents-panel column">
|
||||||
@@ -574,7 +607,27 @@ const DocumentsTable = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="column-body">
|
<div className="column-body">
|
||||||
<div className="column-toolbar">{filterBar}</div>
|
<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 ? (
|
{!showingSearchResults && !subfolders.length && rows.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
Drop files anywhere or onto a folder to upload documents.
|
Drop files anywhere or onto a folder to upload documents.
|
||||||
@@ -599,7 +652,10 @@ const DocumentsTable = ({
|
|||||||
<tr
|
<tr
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className={`folder${isDraggingFolder ? ' is-dragging' : ''}`}
|
className={`folder${isDraggingFolder ? ' is-dragging' : ''}`}
|
||||||
onClick={() => onFolderSelect(folder.id)}
|
onClick={() => {
|
||||||
|
scrollRef.current?.focus({ preventScroll: true });
|
||||||
|
onFolderSelect(folder.id);
|
||||||
|
}}
|
||||||
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
onDragOver={(event) => onFolderDragOver(event, folder.id)}
|
||||||
onDragLeave={onFolderDragLeave}
|
onDragLeave={onFolderDragLeave}
|
||||||
onDrop={(event) => onFolderDrop(event, folder.id)}
|
onDrop={(event) => onFolderDrop(event, folder.id)}
|
||||||
@@ -651,8 +707,12 @@ const DocumentsTable = ({
|
|||||||
<tr
|
<tr
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className={rowClasses.join(' ')}
|
className={rowClasses.join(' ')}
|
||||||
|
id={`document-row-${doc.id}`}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
onClick={(event) => onDocumentRowClick(doc.id, event)}
|
onClick={(event) => {
|
||||||
|
scrollRef.current?.focus({ preventScroll: true });
|
||||||
|
onDocumentRowClick(doc.id, event);
|
||||||
|
}}
|
||||||
onDoubleClick={(event) => {
|
onDoubleClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (onDocumentOpen) {
|
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(
|
const handleDocumentDragStart = useCallback(
|
||||||
(event, documentId) => {
|
(event, documentId) => {
|
||||||
const selection = selectedDocumentIds.includes(documentId)
|
const selection = selectedDocumentIds.includes(documentId)
|
||||||
@@ -4335,6 +4506,8 @@ const AppLayout = () => {
|
|||||||
onDocumentDragEnd: handleDocumentDragEnd,
|
onDocumentDragEnd: handleDocumentDragEnd,
|
||||||
filterBar,
|
filterBar,
|
||||||
tagLookupById,
|
tagLookupById,
|
||||||
|
onDocumentListFocus: handleDocumentListFocus,
|
||||||
|
onDocumentListKeyDown: handleDocumentListKeyDown,
|
||||||
};
|
};
|
||||||
|
|
||||||
const detailPanelProps = {
|
const detailPanelProps = {
|
||||||
|
|||||||
Reference in New Issue
Block a user