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
@@ -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>;