@assistant-ui/react
Version:
Open-source TypeScript/React library for building production-grade AI chat experiences
151 lines (138 loc) • 4.23 kB
text/typescript
import { useEffectEvent, useState } from "react";
import { resource } from "@assistant-ui/tap";
import type {
Unstable_TriggerCategory,
Unstable_TriggerItem,
} from "@assistant-ui/core";
/** Relies on `Unstable_TriggerCategory` never carrying a `type` field. */
function isTriggerItem(
x: Unstable_TriggerItem | Unstable_TriggerCategory,
): x is Unstable_TriggerItem {
return "type" in x;
}
/** Key event shape accepted by the keyboard handler. */
export type TriggerPopoverKeyEvent = {
readonly key: string;
readonly shiftKey: boolean;
preventDefault(): void;
};
export type TriggerKeyboardResourceOutput = {
/** Index of the currently highlighted entry within the navigable list. */
readonly highlightedIndex: number;
/** ID of the currently highlighted item (for `aria-activedescendant`). */
readonly highlightedItemId: string | undefined;
/** Move the highlight to an entry index (e.g. from pointer hover). Out-of-range values are ignored. */
highlightIndex(index: number): void;
/** Handle a key event; returns `true` if it was consumed. */
handleKeyDown(e: TriggerPopoverKeyEvent): boolean;
};
/**
* Owns keyboard-driven highlight state for the popover. Delegates selection,
* category drill-in, back, and close to the callbacks supplied by the parent.
*/
const useTriggerKeyboardResource = ({
navigableList,
isSearchMode,
activeCategoryId,
query,
popoverId,
open,
selectItem,
selectCategory,
goBack,
close,
}: {
navigableList: readonly (Unstable_TriggerCategory | Unstable_TriggerItem)[];
isSearchMode: boolean;
activeCategoryId: string | null;
query: string;
popoverId: string;
open: boolean;
selectItem: (item: Unstable_TriggerItem) => void;
selectCategory: (categoryId: string) => void;
goBack: () => void;
close: () => void;
}): TriggerKeyboardResourceOutput => {
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [highlightScope, setHighlightScope] = useState({
navigableList,
isSearchMode,
activeCategoryId,
});
if (
highlightScope.navigableList !== navigableList ||
highlightScope.isSearchMode !== isSearchMode ||
highlightScope.activeCategoryId !== activeCategoryId
) {
setHighlightScope({ navigableList, isSearchMode, activeCategoryId });
setHighlightedIndex(0);
}
const highlightIndex = useEffectEvent((index: number) => {
if (index < 0 || index >= navigableList.length) return;
if (index === highlightedIndex) return;
setHighlightedIndex(index);
});
const handleKeyDown = useEffectEvent((e: TriggerPopoverKeyEvent): boolean => {
if (!open) return false;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
setHighlightedIndex((prev) => {
const len = navigableList.length;
if (len === 0) return 0;
return prev < len - 1 ? prev + 1 : 0;
});
return true;
}
case "ArrowUp": {
e.preventDefault();
setHighlightedIndex((prev) => {
const len = navigableList.length;
if (len === 0) return 0;
return prev > 0 ? prev - 1 : len - 1;
});
return true;
}
case "Enter":
case "Tab": {
if (e.shiftKey) return false;
e.preventDefault();
const item = navigableList[highlightedIndex];
if (!item) return true;
if (isTriggerItem(item)) {
selectItem(item);
} else {
selectCategory(item.id);
}
return true;
}
case "Escape": {
e.preventDefault();
close();
return true;
}
case "Backspace": {
if (activeCategoryId && query === "") {
e.preventDefault();
goBack();
return true;
}
return false;
}
default:
return false;
}
});
const highlightedEntry = navigableList[highlightedIndex];
const highlightedItemId =
open && highlightedEntry
? `${popoverId}-option-${highlightedEntry.id}`
: undefined;
return {
highlightedIndex,
highlightedItemId,
highlightIndex,
handleKeyDown,
};
};
export const TriggerKeyboardResource = resource(useTriggerKeyboardResource);