Improve desktop command autocomplete to match combobox behavior (#53)

* feat(app): align command autocomplete with desktop combobox

* refactor(app): extract shared autocomplete UI and command hook

* refactor(app): rename autocomplete hooks to generic naming
This commit is contained in:
Mohamed Boudra
2026-02-18 11:58:57 +07:00
committed by GitHub
parent 483966a05b
commit 96a80036f6
7 changed files with 631 additions and 284 deletions
+25 -114
View File
@@ -5,7 +5,7 @@ import {
ActivityIndicator,
Platform,
} from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
@@ -25,11 +25,7 @@ import {
type MessageInputRef,
} from "./message-input";
import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete";
import {
useAgentCommandsQuery,
type DraftCommandConfig,
} from "@/hooks/use-agent-commands-query";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { focusWithRetries } from "@/utils/web-focus";
@@ -37,6 +33,8 @@ import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import { Autocomplete } from "@/components/ui/autocomplete";
import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete";
type QueuedMessage = {
id: string;
@@ -117,61 +115,20 @@ export function AgentInputArea({
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
// Command autocomplete logic
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const commandFilter = showCommandAutocomplete ? userInput.slice(1) : "";
const normalizedCommandDraftConfig = useMemo(() => {
if (!commandDraftConfig) {
return undefined;
}
const cwd = commandDraftConfig.cwd.trim();
if (!cwd) {
return undefined;
}
const modeId = commandDraftConfig.modeId?.trim() ?? "";
const model = commandDraftConfig.model?.trim() ?? "";
const thinkingOptionId = commandDraftConfig.thinkingOptionId?.trim() ?? "";
return {
provider: commandDraftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [commandDraftConfig]);
// Prefetch commands for real agents and for draft screens when context is available.
const isRealAgent = agentId && !agentId.startsWith("__");
const commandQueryDraftConfig = isRealAgent
? undefined
: normalizedCommandDraftConfig;
const canLoadCommands = Boolean(serverId) && (isRealAgent || !!commandQueryDraftConfig);
const { commands } = useAgentCommandsQuery({
const autocomplete = useAgentAutocomplete({
userInput,
setUserInput,
serverId,
agentId,
enabled: canLoadCommands,
draftConfig: commandQueryDraftConfig,
draftConfig: commandDraftConfig,
onAutocompleteApplied: () => {
messageInputRef.current?.focus();
},
});
// Filter commands for keyboard navigation
const filteredCommands = useMemo(() => {
if (!showCommandAutocomplete) return [];
const filterLower = commandFilter.toLowerCase();
return commands.filter((cmd) =>
cmd.name.toLowerCase().includes(filterLower)
);
}, [commands, commandFilter, showCommandAutocomplete]);
// Reset selection when filter changes
useEffect(() => {
setCommandSelectedIndex(0);
}, [commandFilter]);
// Clear send error when user edits the input
useEffect(() => {
if (sendError && userInput) {
@@ -185,7 +142,6 @@ export function AgentInputArea({
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
const messageInputRef = useRef<MessageInputRef>(null);
// Expose addImages function to parent for drag-and-drop support
const addImages = useCallback((images: ImageAttachment[]) => {
@@ -592,17 +548,9 @@ export function AgentInputArea({
queueMessage(payload.text, payload.images);
}, []);
// Handle command selection from autocomplete
const handleCommandSelect = useCallback(
(cmd: { name: string; description: string; argumentHint: string }) => {
setUserInput(`/${cmd.name} `);
messageInputRef.current?.focus();
},
[setUserInput]
);
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
// Handle keyboard navigation for command autocomplete
// Handle keyboard navigation for command autocomplete and stop action.
const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
if (
@@ -617,53 +565,15 @@ export function AgentInputArea({
return true;
}
if (!showCommandAutocomplete || filteredCommands.length === 0) {
return false;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setCommandSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredCommands.length - 1
);
return true;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setCommandSelectedIndex((prev) =>
prev < filteredCommands.length - 1 ? prev + 1 : 0
);
return true;
}
if (event.key === "Tab" || event.key === "Enter") {
event.preventDefault();
const selected = filteredCommands[commandSelectedIndex];
if (selected) {
handleCommandSelect(selected);
}
return true;
}
if (event.key === "Escape") {
event.preventDefault();
setUserInput("");
return true;
}
return false;
return autocomplete.onKeyPress(event);
},
[
showCommandAutocomplete,
filteredCommands,
commandSelectedIndex,
handleCommandSelect,
autocomplete,
hasSendableContent,
isAgentRunning,
isCancellingAgent,
isConnected,
setUserInput,
handleCancelAgent,
]
);
@@ -775,14 +685,15 @@ export function AgentInputArea({
)}
{/* Command autocomplete dropdown */}
{showCommandAutocomplete && canLoadCommands && (
<CommandAutocomplete
serverId={serverId}
agentId={agentId}
filter={commandFilter}
selectedIndex={commandSelectedIndex}
onSelect={handleCommandSelect}
draftConfig={commandQueryDraftConfig}
{autocomplete.isVisible && (
<Autocomplete
options={autocomplete.options}
selectedIndex={autocomplete.selectedIndex}
isLoading={autocomplete.isLoading}
errorMessage={autocomplete.errorMessage}
loadingText="Loading commands..."
emptyText="No commands found"
onSelect={autocomplete.onSelectOption}
/>
)}
@@ -1,170 +0,0 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
useAgentCommandsQuery,
type DraftCommandConfig,
} from "@/hooks/use-agent-commands-query";
import { Fonts } from "@/constants/theme";
import { Theme } from "@/styles/theme";
interface AgentSlashCommand {
name: string;
description: string;
argumentHint: string;
}
interface CommandAutocompleteProps {
serverId: string;
agentId: string;
filter: string;
selectedIndex: number;
onSelect: (command: AgentSlashCommand) => void;
draftConfig?: DraftCommandConfig;
}
export function CommandAutocomplete({
serverId,
agentId,
filter,
selectedIndex,
onSelect,
draftConfig,
}: CommandAutocompleteProps) {
const { theme } = useUnistyles();
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
serverId,
agentId,
enabled: true,
draftConfig,
});
// Filter commands based on input after /
const filterLower = filter.toLowerCase();
const filteredCommands = commands.filter((cmd) =>
cmd.name.toLowerCase().includes(filterLower)
);
if (isLoading) {
return (
<View style={styles.container}>
<View style={styles.loadingItem}>
<Text style={styles.loadingText}>Loading commands...</Text>
</View>
</View>
);
}
if (isError) {
return (
<View style={styles.container}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {error?.message ?? "Failed to load"}</Text>
</View>
</View>
);
}
if (filteredCommands.length === 0) {
return (
<View style={styles.container}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>No commands found</Text>
</View>
</View>
);
}
return (
<View style={styles.container}>
<ScrollView style={styles.scrollView} keyboardShouldPersistTaps="always">
{filteredCommands.map((cmd, index) => {
const isSelected = index === selectedIndex;
return (
<Pressable
key={cmd.name}
onPress={() => onSelect(cmd)}
style={[
styles.commandItem,
isSelected && {
backgroundColor: theme.colors.accent,
},
]}
>
<View style={styles.commandHeader}>
<Text style={styles.commandName}>/{cmd.name}</Text>
{cmd.argumentHint && (
<Text style={styles.commandArgs}>{cmd.argumentHint}</Text>
)}
</View>
<Text style={styles.commandDescription} numberOfLines={1}>
{cmd.description}
</Text>
</Pressable>
);
})}
</ScrollView>
</View>
);
}
export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: string) {
const filterLower = filter.toLowerCase();
return commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower));
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
maxHeight: 200,
},
scrollView: {
flexGrow: 0,
flexShrink: 1,
},
commandItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
commandHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
commandName: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
fontFamily: Fonts.mono,
},
commandArgs: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontFamily: Fonts.mono,
},
commandDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
loadingItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
loadingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
emptyItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>;
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
getAutocompleteFallbackIndex,
getAutocompleteScrollOffset,
orderAutocompleteOptions,
} from "./autocomplete-utils";
const OPTIONS = ["alpha", "beta", "gamma"];
describe("orderAutocompleteOptions", () => {
it("keeps first logical option closest to the input by default", () => {
expect(orderAutocompleteOptions(OPTIONS)).toEqual([
"gamma",
"beta",
"alpha",
]);
});
it("keeps normal top-down order when below-input is selected", () => {
expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual([
"alpha",
"beta",
"gamma",
]);
});
});
describe("getAutocompleteFallbackIndex", () => {
it("picks the option nearest the input by default", () => {
expect(getAutocompleteFallbackIndex(3)).toBe(2);
expect(getAutocompleteFallbackIndex(0)).toBe(-1);
});
it("picks top item when below-input ordering is used", () => {
expect(getAutocompleteFallbackIndex(3, "below-input")).toBe(0);
});
});
describe("getAutocompleteScrollOffset", () => {
it("scrolls up when the active item is above the viewport", () => {
expect(
getAutocompleteScrollOffset({
currentOffset: 120,
viewportHeight: 80,
itemTop: 90,
itemHeight: 20,
})
).toBe(90);
});
it("scrolls down when the active item is below the viewport", () => {
expect(
getAutocompleteScrollOffset({
currentOffset: 0,
viewportHeight: 100,
itemTop: 150,
itemHeight: 24,
})
).toBe(74);
});
});
@@ -0,0 +1,56 @@
import { getNextActiveIndex } from "./combobox-keyboard";
export type AutocompleteOptionsPosition = "above-input" | "below-input";
export function orderAutocompleteOptions<T>(
options: readonly T[],
position: AutocompleteOptionsPosition = "above-input"
): T[] {
if (position === "below-input") {
return [...options];
}
return [...options].reverse();
}
export function getAutocompleteFallbackIndex(
itemCount: number,
position: AutocompleteOptionsPosition = "above-input"
): number {
if (itemCount <= 0) {
return -1;
}
return position === "above-input" ? itemCount - 1 : 0;
}
export function getAutocompleteNextIndex(args: {
currentIndex: number;
itemCount: number;
key: "ArrowDown" | "ArrowUp";
}): number {
return getNextActiveIndex(args);
}
export function getAutocompleteScrollOffset(args: {
currentOffset: number;
viewportHeight: number;
itemTop: number;
itemHeight: number;
}): number {
if (args.viewportHeight <= 0) {
return args.currentOffset;
}
const itemBottom = args.itemTop + args.itemHeight;
const viewportTop = args.currentOffset;
const viewportBottom = args.currentOffset + args.viewportHeight;
if (args.itemTop < viewportTop) {
return Math.max(0, args.itemTop);
}
if (itemBottom > viewportBottom) {
return Math.max(0, itemBottom - args.viewportHeight);
}
return args.currentOffset;
}
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useRef } from "react";
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
export interface AutocompleteOption {
id: string;
label: string;
detail?: string;
description?: string;
}
interface AutocompleteProps {
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
isLoading?: boolean;
errorMessage?: string;
loadingText?: string;
emptyText?: string;
maxHeight?: number;
}
export function Autocomplete({
options,
selectedIndex,
onSelect,
isLoading = false,
errorMessage,
loadingText = "Loading...",
emptyText = "No results found",
maxHeight = 220,
}: AutocompleteProps) {
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
const scrollOffsetRef = useRef(0);
const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) {
return;
}
const layout = rowLayoutsRef.current.get(selectedIndex);
if (!layout) {
return;
}
const nextOffset = getAutocompleteScrollOffset({
currentOffset: scrollOffsetRef.current,
viewportHeight: viewportHeightRef.current,
itemTop: layout.top,
itemHeight: layout.height,
});
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return;
}
scrollOffsetRef.current = nextOffset;
scrollRef.current?.scrollTo({ y: nextOffset, animated: false });
}, [selectedIndex]);
const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false });
requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false });
});
}, []);
useEffect(() => {
rowLayoutsRef.current.clear();
scrollOffsetRef.current = 0;
}, [options]);
useEffect(() => {
if (options.length === 0) {
return;
}
pinToBottom();
}, [options, pinToBottom]);
useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible);
return () => {
cancelAnimationFrame(raf);
};
}, [ensureActiveItemVisible, options.length]);
const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height;
ensureActiveItemVisible();
},
[ensureActiveItemVisible]
);
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height,
});
ensureActiveItemVisible();
},
[ensureActiveItemVisible]
);
if (isLoading) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{loadingText}</Text>
</View>
</View>
);
}
if (errorMessage) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
</View>
);
}
if (options.length === 0) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{emptyText}</Text>
</View>
</View>
);
}
return (
<View style={[styles.container, { maxHeight }]}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
return (
<Pressable
key={option.id}
onLayout={(event) => handleRowLayout(index, event)}
onPress={() => onSelect(option)}
style={({ hovered = false, pressed }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{option.label}</Text>
{option.detail ? (
<Text style={styles.itemDetail}>{option.detail}</Text>
) : null}
</View>
{option.description ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{option.description}
</Text>
) : null}
</View>
</Pressable>
);
})}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
container: {
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
},
scrollView: {
flexGrow: 0,
flexShrink: 1,
},
scrollContent: {
paddingVertical: theme.spacing[1],
},
item: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
itemActive: {
backgroundColor: theme.colors.surface1,
},
itemMain: {
flex: 1,
minWidth: 0,
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
itemLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
itemDetail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
itemDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
marginTop: 2,
},
emptyItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>;
@@ -0,0 +1,125 @@
import { useCallback, useMemo } from "react";
import type { AutocompleteOption } from "@/components/ui/autocomplete";
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query";
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete";
interface UseAgentAutocompleteInput {
userInput: string;
setUserInput: (nextValue: string) => void;
serverId: string;
agentId: string;
draftConfig?: DraftCommandConfig;
onAutocompleteApplied?: () => void;
}
interface AgentAutocompleteResult {
isVisible: boolean;
options: AutocompleteOption[];
selectedIndex: number;
isLoading: boolean;
errorMessage?: string;
onSelectOption: (option: AutocompleteOption) => void;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
}
function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig
): DraftCommandConfig | undefined {
if (!draftConfig) {
return undefined;
}
const cwd = draftConfig.cwd.trim();
if (!cwd) {
return undefined;
}
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}
export function useAgentAutocomplete(
input: UseAgentAutocompleteInput
): AgentAutocompleteResult {
const {
userInput,
setUserInput,
serverId,
agentId,
draftConfig,
onAutocompleteApplied,
} = input;
const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
const filterQuery = showAutocomplete ? userInput.slice(1) : "";
const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig),
[draftConfig]
);
const isRealAgent = Boolean(agentId) && !agentId.startsWith("__");
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig;
const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig);
const isVisible = showAutocomplete && canLoadOptions;
const { commands, isLoading, isError, error } = useAgentCommandsQuery({
serverId,
agentId,
enabled: canLoadOptions,
draftConfig: queryDraftConfig,
});
const options = useMemo<AutocompleteOption[]>(() => {
if (!isVisible) {
return [];
}
const filterLower = filterQuery.toLowerCase();
const matches = commands.filter((cmd) =>
cmd.name.toLowerCase().includes(filterLower)
);
const orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map((cmd) => ({
id: cmd.name,
label: `/${cmd.name}`,
detail: cmd.argumentHint || undefined,
description: cmd.description,
}));
}, [commands, filterQuery, isVisible]);
const onSelectOption = useCallback(
(option: AutocompleteOption) => {
setUserInput(`/${option.id} `);
onAutocompleteApplied?.();
},
[onAutocompleteApplied, setUserInput]
);
const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible,
options,
query: filterQuery,
onSelectOption,
onEscape: () => setUserInput(""),
});
return {
isVisible,
options,
selectedIndex,
isLoading,
errorMessage: isError ? (error?.message ?? "Failed to load") : undefined,
onSelectOption,
onKeyPress,
};
}
+120
View File
@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getAutocompleteFallbackIndex,
getAutocompleteNextIndex,
type AutocompleteOptionsPosition,
} from "@/components/ui/autocomplete-utils";
interface UseAutocompleteInput<TOption> {
isVisible: boolean;
options: readonly TOption[];
query: string;
onSelectOption: (option: TOption) => void;
onEscape?: () => void;
optionsPosition?: AutocompleteOptionsPosition;
}
interface UseAutocompleteResult {
selectedIndex: number;
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean;
}
export function useAutocomplete<TOption>(
input: UseAutocompleteInput<TOption>
): UseAutocompleteResult {
const [selectedIndex, setSelectedIndex] = useState(-1);
const previousQueryRef = useRef("");
useEffect(() => {
if (!input.isVisible) {
previousQueryRef.current = input.query;
setSelectedIndex(-1);
return;
}
const queryChanged = previousQueryRef.current !== input.query;
previousQueryRef.current = input.query;
setSelectedIndex((current) => {
if (input.options.length === 0) {
return -1;
}
const fallbackIndex = getAutocompleteFallbackIndex(
input.options.length,
input.optionsPosition
);
if (queryChanged) {
return fallbackIndex;
}
if (current < 0 || current >= input.options.length) {
return fallbackIndex;
}
return current;
});
}, [input.isVisible, input.options.length, input.query, input.optionsPosition]);
const onKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => {
if (!input.isVisible || input.options.length === 0) {
return false;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setSelectedIndex((current) =>
getAutocompleteNextIndex({
currentIndex: current,
itemCount: input.options.length,
key: "ArrowUp",
})
);
return true;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setSelectedIndex((current) =>
getAutocompleteNextIndex({
currentIndex: current,
itemCount: input.options.length,
key: "ArrowDown",
})
);
return true;
}
if (event.key === "Tab" || event.key === "Enter") {
event.preventDefault();
const fallbackIndex = getAutocompleteFallbackIndex(
input.options.length,
input.optionsPosition
);
const resolvedIndex =
selectedIndex >= 0 && selectedIndex < input.options.length
? selectedIndex
: fallbackIndex;
const selectedOption = input.options[resolvedIndex];
if (selectedOption) {
input.onSelectOption(selectedOption);
}
return true;
}
if (event.key === "Escape" && input.onEscape) {
event.preventDefault();
input.onEscape();
return true;
}
return false;
},
[input, selectedIndex]
);
return {
selectedIndex,
onKeyPress,
};
}