Improve mobile selectors and sheet inputs

Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization.
This commit is contained in:
Mohamed Boudra
2026-05-18 14:56:17 +07:00
parent 028839b3cb
commit 29ce6653fd
23 changed files with 1056 additions and 706 deletions
@@ -121,6 +121,16 @@ describe("filterAndRankComboboxOptions", () => {
const result = filterAndRankComboboxOptions(items, "202");
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
});
it("matches fuzzy character sequences after stronger substring matches", () => {
const items = [
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-4.1", label: "GPT-4.1" },
{ id: "gemini", label: "Gemini" },
];
const result = filterAndRankComboboxOptions(items, "gpt54");
expect(result.map((o) => o.id)).toEqual(["gpt-5.4"]);
});
});
describe("combobox above-search ordering", () => {
@@ -1,4 +1,4 @@
import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match";
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match";
export type ComboboxOptionKind = "directory" | "file";
@@ -12,18 +12,12 @@ export interface ComboboxOptionModel {
const DESCRIPTION_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const labelScore = scoreMatch(search, opt.label);
const idScore = scoreMatch(search, opt.id);
let best: MatchScore | null = null;
if (labelScore) best = labelScore;
if (idScore && (!best || compareMatchScores(idScore, best) < 0)) {
best = idScore;
}
const best = scoreTextFields(search, [opt.label, opt.id]);
if (best) return best;
if (opt.description && opt.description.toLowerCase().includes(search)) {
return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 };
}
return null;
if (!opt.description) return null;
const descriptionScore = scoreTextFields(search, [opt.description]);
if (!descriptionScore) return null;
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
}
export interface BuildVisibleComboboxOptionsInput {
+117 -47
View File
@@ -1,5 +1,13 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode, Ref } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
@@ -18,7 +26,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import {
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
@@ -38,11 +45,17 @@ import {
shouldShowCustomComboboxOption,
} from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
import {
AdaptiveTextInput,
InlineHeaderView,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
const IS_WEB = isWeb;
@@ -69,6 +82,15 @@ export interface ComboboxProps {
customValueKind?: "directory" | "file";
optionsPosition?: "below-search" | "above-search";
title?: string;
/**
* Structured header. When provided, replaces `title` + `stickyHeader` and
* is rendered via the shared SheetHeaderView (mobile) / InlineHeaderView
* (desktop). Built-in search (when `searchable=true` and no `header.search`)
* is folded into the header so its magnifying glass aligns with the title
* and any leading icon at the sheet's shared indent.
*/
header?: SheetHeader;
mobileChildrenScrollEnabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start";
@@ -136,6 +158,7 @@ export interface SearchInputProps {
onSubmitEditing?: () => void;
autoFocus?: boolean;
useBottomSheetInput?: boolean;
resetKey?: string | number;
}
export function SearchInput({
@@ -145,10 +168,10 @@ export function SearchInput({
onSubmitEditing,
autoFocus = false,
useBottomSheetInput = false,
resetKey,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) {
@@ -162,18 +185,35 @@ export function SearchInput({
return (
<View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as unknown as Ref<never>}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
{useBottomSheetInput ? (
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={value}
resetKey={resetKey}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
) : (
<TextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
)}
</View>
);
}
@@ -593,12 +633,14 @@ function useDesktopFloatingUpdate(
function useResetSearchOnOpen(
isOpen: boolean,
setSearchQueryWithCallback: (query: string) => void,
bumpSearchResetKey: () => void,
) {
useEffect(() => {
if (isOpen) {
setSearchQueryWithCallback("");
bumpSearchResetKey();
}
}, [isOpen, setSearchQueryWithCallback]);
}, [isOpen, setSearchQueryWithCallback, bumpSearchResetKey]);
}
interface DesktopResetSetters {
@@ -919,9 +961,13 @@ interface MobileBodyProps {
handleIndicatorStyle: { backgroundColor: string };
titleColor: string;
title: string;
header: SheetHeader | undefined;
onClose: () => void;
stickyHeader: ReactNode;
searchable: boolean;
hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
searchResetKey: number;
searchPlaceholder: string;
searchQuery: string;
setSearchQueryWithCallback: (query: string) => void;
@@ -981,29 +1027,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
/>
) : null}
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</IsolatedBottomSheetModal>
);
}
@@ -1015,6 +1072,7 @@ interface DesktopBodyProps {
shouldUseDesktopFade: boolean;
desktopContainerStyle: unknown;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode;
searchable: boolean;
searchPlaceholder: string;
@@ -1036,12 +1094,13 @@ interface DesktopBodyProps {
}
function DesktopComboboxChildrenBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode;
children: ReactNode;
}): ReactElement {
return (
<>
{props.stickyHeader}
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
<ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled"
@@ -1055,6 +1114,7 @@ function DesktopComboboxChildrenBody(props: {
}
function DesktopComboboxOptionsBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode;
searchable: boolean;
searchPlaceholder: string;
@@ -1085,8 +1145,8 @@ function DesktopComboboxOptionsBody(props: {
return (
<>
{props.stickyHeader}
{props.searchable ? (
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
{props.header || !props.searchable ? null : (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
@@ -1095,7 +1155,7 @@ function DesktopComboboxOptionsBody(props: {
autoFocus
useBottomSheetInput={false}
/>
) : null}
)}
{props.effectiveOptionsPosition === "above-search" ? (
<ScrollView
ref={props.desktopOptionsScrollRef}
@@ -1141,11 +1201,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
onLayout={props.handleDesktopContentLayout}
>
{props.hasChildren ? (
<DesktopComboboxChildrenBody stickyHeader={props.stickyHeader}>
<DesktopComboboxChildrenBody header={props.header} stickyHeader={props.stickyHeader}>
{props.children}
</DesktopComboboxChildrenBody>
) : (
<DesktopComboboxOptionsBody
header={props.header}
stickyHeader={props.stickyHeader}
searchable={props.searchable}
searchPlaceholder={props.searchPlaceholder}
@@ -1188,6 +1249,8 @@ export function Combobox({
customValueKind,
optionsPosition = "below-search",
title = "Select",
header,
mobileChildrenScrollEnabled = true,
open,
onOpenChange,
desktopPlacement = "top-start",
@@ -1214,6 +1277,7 @@ export function Combobox({
const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
@@ -1234,9 +1298,10 @@ export function Combobox({
const handleClose = useCallback(() => {
setOpen(false);
setSearchQueryWithCallback("");
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1465,9 +1530,13 @@ export function Combobox({
handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor}
title={title}
header={header}
onClose={handleClose}
stickyHeader={stickyHeader}
searchable={searchable}
hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery}
setSearchQueryWithCallback={setSearchQueryWithCallback}
@@ -1494,6 +1563,7 @@ export function Combobox({
shouldUseDesktopFade={shouldUseDesktopFade}
desktopContainerStyle={desktopContainerStyle}
handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader}
searchable={searchable}
searchPlaceholder={effectiveSearchPlaceholder}