2025-09-26 11:45:06 +02:00
|
|
|
/*
|
|
|
|
|
* Copyright 2025 New Vector Ltd.
|
|
|
|
|
*
|
|
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
|
|
|
|
* Please see LICENSE files in the repository root for full details.
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-01 17:26:34 +02:00
|
|
|
import {
|
|
|
|
|
useCallback,
|
|
|
|
|
useRef,
|
|
|
|
|
type RefObject,
|
|
|
|
|
type KeyboardEvent,
|
|
|
|
|
type KeyboardEventHandler,
|
|
|
|
|
type FocusEventHandler,
|
|
|
|
|
type FocusEvent,
|
|
|
|
|
} from "react";
|
2025-09-26 11:45:06 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* A hook that provides keyboard navigation for a list of options.
|
|
|
|
|
*/
|
2025-10-01 17:26:34 +02:00
|
|
|
export function useListKeyboardNavigation(): {
|
2025-09-26 11:45:06 +02:00
|
|
|
listRef: RefObject<HTMLUListElement | null>;
|
|
|
|
|
onKeyDown: KeyboardEventHandler<HTMLUListElement>;
|
2025-10-01 17:26:34 +02:00
|
|
|
onFocus: FocusEventHandler<HTMLUListElement>;
|
2025-09-26 11:45:06 +02:00
|
|
|
} {
|
|
|
|
|
const listRef = useRef<HTMLUListElement>(null);
|
|
|
|
|
|
2025-10-01 17:26:34 +02:00
|
|
|
const onFocus = useCallback((evt: FocusEvent<HTMLUListElement>) => {
|
|
|
|
|
if (!listRef.current) return;
|
|
|
|
|
|
|
|
|
|
if (evt.target === listRef.current) {
|
|
|
|
|
// By default, focus the selected item
|
|
|
|
|
let selectedChild = listRef.current?.firstElementChild;
|
|
|
|
|
|
|
|
|
|
// If there is a selected item, focus that instead
|
|
|
|
|
for (const child of listRef.current.children) {
|
|
|
|
|
if (child.getAttribute("aria-selected") === "true") {
|
|
|
|
|
selectedChild = child;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(selectedChild as HTMLElement)?.focus();
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-26 11:45:06 +02:00
|
|
|
const onKeyDown = useCallback((evt: KeyboardEvent<HTMLUListElement>) => {
|
|
|
|
|
const { key } = evt;
|
|
|
|
|
|
|
|
|
|
let handled = false;
|
|
|
|
|
|
|
|
|
|
switch (key) {
|
|
|
|
|
case "Enter":
|
|
|
|
|
case " ": {
|
|
|
|
|
handled = true;
|
|
|
|
|
(document.activeElement as HTMLElement).click();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "ArrowDown": {
|
|
|
|
|
handled = true;
|
|
|
|
|
const currentFocus = document.activeElement;
|
|
|
|
|
if (listRef.current?.contains(currentFocus) && currentFocus) {
|
|
|
|
|
(currentFocus.nextElementSibling as HTMLElement)?.focus();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "ArrowUp": {
|
|
|
|
|
handled = true;
|
|
|
|
|
const currentFocus = document.activeElement;
|
|
|
|
|
if (listRef.current?.contains(currentFocus) && currentFocus) {
|
|
|
|
|
(currentFocus.previousElementSibling as HTMLElement)?.focus();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "Home": {
|
|
|
|
|
handled = true;
|
|
|
|
|
(listRef.current?.firstElementChild as HTMLElement)?.focus();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "End": {
|
|
|
|
|
handled = true;
|
|
|
|
|
(listRef.current?.lastElementChild as HTMLElement)?.focus();
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (handled) {
|
|
|
|
|
evt.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
2025-10-01 17:26:34 +02:00
|
|
|
return { listRef, onKeyDown, onFocus };
|
2025-09-26 11:45:06 +02:00
|
|
|
}
|