/* eslint-disable jsx-a11y/no-static-element-interactions */
/** biome-ignore-all lint/a11y/noStaticElementInteractions: We know what we are doing */
import React from "react";
import { ListboxGroup } from "../group/ListboxGroup";
import { ListboxInputSlot } from "../input-slot/ListboxInputSlot";
import { ListboxOption } from "../option/ListboxOption";
import { ListboxOptions } from "../options/ListboxOptions";
import { findNextOption, findPrevOption } from "./domHelpers";
export interface ListboxProps {
children: React.ReactNode;
setVirtuallyFocusedOptionId: (value: string) => void;
}
/**
* Low level component for displaying a list of selectable options with optional grouping.
* Keyboard navigation is implemented with virtual focus so that real focus can remain on an input field.
*/
function Listbox({ children, setVirtuallyFocusedOptionId }: ListboxProps) {
const virtuallyFocusOption = (element: HTMLElement | null) => {
setVirtuallyFocusedOptionId(element?.dataset.id || "");
element?.scrollIntoView({ block: "nearest" });
};
return (
{
const listbox =
event.currentTarget.querySelector('[role="listbox"]');
if (!listbox) {
return;
}
// Helper functions
const getFirstOption = (suffix: string = "") =>
listbox.querySelector(`[role="option"]${suffix}`);
const getLastOption = () => {
const allOptions =
listbox.querySelectorAll('[role="option"]');
return allOptions[allOptions.length - 1];
};
const focusedOptionElm = getFirstOption('[data-virtual-focus="true"]');
// Doesn't make sense to have real focus on one option and virtual focus on another at the same time.
// Not sure if it matters, though 🤔
const optionElmWithRealFocus = getFirstOption(":focus");
if (optionElmWithRealFocus) {
listbox.focus();
}
const virtuallyFocusWithFallback = (
getNextElement: (currentOption: HTMLElement) => HTMLElement | null,
getFallback: () => HTMLElement | null,
) => {
event.preventDefault();
if (!focusedOptionElm) {
virtuallyFocusOption(getFallback());
return;
}
const nextOption = getNextElement(focusedOptionElm);
if (!nextOption) {
virtuallyFocusOption(getFallback());
return;
}
virtuallyFocusOption(nextOption);
};
switch (event.key) {
case "ArrowDown":
virtuallyFocusWithFallback(findNextOption, getFirstOption);
break;
case "ArrowUp":
virtuallyFocusWithFallback(findPrevOption, getLastOption);
break;
case "Home":
event.preventDefault();
virtuallyFocusOption(getFirstOption());
break;
case "End":
event.preventDefault();
virtuallyFocusOption(getLastOption());
break;
case "Enter":
case "Accept":
if (focusedOptionElm) {
focusedOptionElm.click();
}
break;
// TODO: Consider implementing PageUp/PageDown too
}
}}
>
{children}
);
}
Listbox.InputSlot = ListboxInputSlot;
Listbox.Options = ListboxOptions;
Listbox.Option = ListboxOption;
Listbox.Group = ListboxGroup;
export default Listbox;