mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 00:48:32 +00:00
134 lines
3.5 KiB
TypeScript
134 lines
3.5 KiB
TypeScript
import * as React from "react";
|
|
|
|
import {
|
|
floatingItemClassName,
|
|
floatingItemFocusClassName,
|
|
} from "@/components/ui/floating-surface";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface ComboboxNavigationOptions {
|
|
open: boolean;
|
|
values: readonly string[];
|
|
selectedValue?: string;
|
|
onSelect: (value: string) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function useComboboxNavigation({
|
|
open,
|
|
values,
|
|
selectedValue,
|
|
onSelect,
|
|
onClose,
|
|
}: ComboboxNavigationOptions) {
|
|
const listboxId = React.useId();
|
|
const [activeValue, setActiveValue] = React.useState<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
setActiveValue(null);
|
|
return;
|
|
}
|
|
setActiveValue((current) => {
|
|
if (current && values.includes(current)) return current;
|
|
if (selectedValue && values.includes(selectedValue)) return selectedValue;
|
|
return values[0] ?? null;
|
|
});
|
|
}, [open, selectedValue, values]);
|
|
|
|
const activeIndex = activeValue ? values.indexOf(activeValue) : -1;
|
|
const activeOptionId = activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
|
|
|
|
React.useEffect(() => {
|
|
if (!activeOptionId) return;
|
|
const option = document.getElementById(activeOptionId);
|
|
option?.scrollIntoView?.({ block: "nearest" });
|
|
}, [activeOptionId]);
|
|
|
|
const move = (offset: number) => {
|
|
if (!values.length) return;
|
|
const nextIndex = activeIndex < 0
|
|
? offset > 0 ? 0 : values.length - 1
|
|
: (activeIndex + offset + values.length) % values.length;
|
|
setActiveValue(values[nextIndex]);
|
|
};
|
|
|
|
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (event.nativeEvent.isComposing) return;
|
|
switch (event.key) {
|
|
case "ArrowDown":
|
|
if (values.length) {
|
|
event.preventDefault();
|
|
move(1);
|
|
}
|
|
break;
|
|
case "ArrowUp":
|
|
if (values.length) {
|
|
event.preventDefault();
|
|
move(-1);
|
|
}
|
|
break;
|
|
case "Enter":
|
|
if (activeValue) {
|
|
event.preventDefault();
|
|
onSelect(activeValue);
|
|
}
|
|
break;
|
|
case "Escape":
|
|
event.preventDefault();
|
|
onClose();
|
|
break;
|
|
}
|
|
};
|
|
|
|
const expanded = open && values.length > 0;
|
|
const inputProps = {
|
|
role: "combobox" as const,
|
|
"aria-autocomplete": "list" as const,
|
|
"aria-controls": expanded ? listboxId : undefined,
|
|
"aria-expanded": expanded,
|
|
"aria-activedescendant": expanded ? activeOptionId : undefined,
|
|
onKeyDown: onInputKeyDown,
|
|
};
|
|
|
|
const listProps = {
|
|
id: listboxId,
|
|
role: "listbox" as const,
|
|
};
|
|
|
|
const getOptionProps = (value: string) => {
|
|
const index = values.indexOf(value);
|
|
return {
|
|
id: `${listboxId}-option-${index}`,
|
|
role: "option" as const,
|
|
"aria-selected": value === activeValue,
|
|
"data-highlighted": value === activeValue ? "" : undefined,
|
|
tabIndex: -1,
|
|
onPointerMove: () => setActiveValue(value),
|
|
onClick: () => onSelect(value),
|
|
};
|
|
};
|
|
|
|
return { inputProps, listProps, getOptionProps };
|
|
}
|
|
|
|
const ComboboxOption = React.forwardRef<
|
|
HTMLButtonElement,
|
|
React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
>(({ className, type = "button", ...props }, ref) => (
|
|
<button
|
|
ref={ref}
|
|
type={type}
|
|
className={cn(
|
|
floatingItemClassName,
|
|
floatingItemFocusClassName,
|
|
"w-full cursor-default text-left data-[highlighted]:bg-muted/85 data-[highlighted]:text-foreground",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
));
|
|
ComboboxOption.displayName = "ComboboxOption";
|
|
|
|
export { ComboboxOption };
|