mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 16:44:05 +08:00
feat(tree): Home/End and typeahead keyboard navigation
This commit is contained in:
@@ -118,6 +118,10 @@ function DocTreeInner<T extends object>(
|
|||||||
// Set by the keyboard handler when the navigation target hasn't been
|
// Set by the keyboard handler when the navigation target hasn't been
|
||||||
// virtualized yet. Consumed by registerRowElement when the row mounts.
|
// virtualized yet. Consumed by registerRowElement when the row mounts.
|
||||||
const pendingFocusIdRef = useRef<string | null>(null);
|
const pendingFocusIdRef = useRef<string | null>(null);
|
||||||
|
// Typeahead state: accumulated buffer, plus the timer that clears it after
|
||||||
|
// ~500ms of no typing. Refs only — no re-render needed per keystroke.
|
||||||
|
const typeaheadBufferRef = useRef('');
|
||||||
|
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const contextId = useMemo(
|
const contextId = useMemo(
|
||||||
() => uniqueContextId ?? Symbol('doc-tree'),
|
() => uniqueContextId ?? Symbol('doc-tree'),
|
||||||
[uniqueContextId],
|
[uniqueContextId],
|
||||||
@@ -235,15 +239,21 @@ function DocTreeInner<T extends object>(
|
|||||||
// hand-off when the target row is currently virtualized out of view.
|
// hand-off when the target row is currently virtualized out of view.
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLUListElement>) => {
|
(e: React.KeyboardEvent<HTMLUListElement>) => {
|
||||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
// Ctrl/Alt/Meta are reserved for browser/OS shortcuts; bail out.
|
||||||
if (
|
// Shift is allowed through so typeahead can match capital letters.
|
||||||
e.key !== 'ArrowDown' &&
|
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
||||||
e.key !== 'ArrowUp' &&
|
const isNavKey =
|
||||||
e.key !== 'ArrowLeft' &&
|
!e.shiftKey &&
|
||||||
e.key !== 'ArrowRight'
|
(e.key === 'ArrowDown' ||
|
||||||
) {
|
e.key === 'ArrowUp' ||
|
||||||
return;
|
e.key === 'ArrowLeft' ||
|
||||||
}
|
e.key === 'ArrowRight' ||
|
||||||
|
e.key === 'Home' ||
|
||||||
|
e.key === 'End');
|
||||||
|
// Single printable character → typeahead. e.key.length === 1 excludes
|
||||||
|
// multi-char names like "ArrowDown", "Enter", "Tab", etc.
|
||||||
|
const isTypeahead = e.key.length === 1 && !isNavKey;
|
||||||
|
if (!isNavKey && !isTypeahead) return;
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.matches('input, textarea, [contenteditable="true"]')) return;
|
if (target.matches('input, textarea, [contenteditable="true"]')) return;
|
||||||
@@ -267,6 +277,38 @@ function DocTreeInner<T extends object>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Typeahead: accumulate printable chars, jump to next row whose label
|
||||||
|
// starts with the buffer. Same-letter presses cycle through matches; a
|
||||||
|
// multi-char buffer searches from the current row so the user can
|
||||||
|
// refine the prefix. Buffer resets after ~500ms of no typing.
|
||||||
|
if (isTypeahead) {
|
||||||
|
e.preventDefault();
|
||||||
|
const wasEmpty = typeaheadBufferRef.current.length === 0;
|
||||||
|
typeaheadBufferRef.current = (
|
||||||
|
typeaheadBufferRef.current + e.key
|
||||||
|
).toLowerCase();
|
||||||
|
const buffer = typeaheadBufferRef.current;
|
||||||
|
if (typeaheadTimerRef.current) {
|
||||||
|
clearTimeout(typeaheadTimerRef.current);
|
||||||
|
}
|
||||||
|
typeaheadTimerRef.current = setTimeout(() => {
|
||||||
|
typeaheadBufferRef.current = '';
|
||||||
|
typeaheadTimerRef.current = null;
|
||||||
|
}, 500);
|
||||||
|
// Single-char buffer cycles to the next match (start at idx + 1);
|
||||||
|
// multi-char buffer can keep matching the current row.
|
||||||
|
const startIdx = wasEmpty ? (idx + 1) % flat.length : idx;
|
||||||
|
for (let i = 0; i < flat.length; i++) {
|
||||||
|
const probeIdx = (startIdx + i) % flat.length;
|
||||||
|
const label = getDragLabel(flat[probeIdx].node).toLowerCase();
|
||||||
|
if (label.startsWith(buffer)) {
|
||||||
|
focusByIndex(probeIdx);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const row = flat[idx];
|
const row = flat[idx];
|
||||||
const hasChildren =
|
const hasChildren =
|
||||||
(row.node.children && row.node.children.length > 0) ||
|
(row.node.children && row.node.children.length > 0) ||
|
||||||
@@ -312,9 +354,25 @@ function DocTreeInner<T extends object>(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'Home':
|
||||||
|
e.preventDefault();
|
||||||
|
focusByIndex(0);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
e.preventDefault();
|
||||||
|
focusByIndex(flat.length - 1);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[flat, openIds, onToggle, virtualizer],
|
[flat, openIds, onToggle, virtualizer, getDragLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear the typeahead timer if the component unmounts mid-buffer.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (data.length === 0 && emptyState) {
|
if (data.length === 0 && emptyState) {
|
||||||
|
|||||||
Reference in New Issue
Block a user