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.
122 lines
3.6 KiB
TypeScript
122 lines
3.6 KiB
TypeScript
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match";
|
|
|
|
export type ComboboxOptionKind = "directory" | "file";
|
|
|
|
export interface ComboboxOptionModel {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
kind?: ComboboxOptionKind;
|
|
}
|
|
|
|
const DESCRIPTION_FALLBACK_TIER = 99;
|
|
|
|
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
|
const best = scoreTextFields(search, [opt.label, opt.id]);
|
|
if (best) return best;
|
|
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 {
|
|
options: ComboboxOptionModel[];
|
|
searchQuery: string;
|
|
searchable: boolean;
|
|
allowCustomValue: boolean;
|
|
customValuePrefix: string;
|
|
customValueDescription?: string;
|
|
customValueKind?: ComboboxOptionKind;
|
|
}
|
|
|
|
export function shouldShowCustomComboboxOption(input: {
|
|
options: ComboboxOptionModel[];
|
|
searchQuery: string;
|
|
searchable: boolean;
|
|
allowCustomValue: boolean;
|
|
}): boolean {
|
|
const sanitizedSearchValue = input.searchQuery.trim();
|
|
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
return !input.options.some(
|
|
(opt) =>
|
|
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
|
|
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
export function filterAndRankComboboxOptions(
|
|
options: ComboboxOptionModel[],
|
|
search: string,
|
|
): ComboboxOptionModel[] {
|
|
if (!search) return options;
|
|
const scored: { opt: ComboboxOptionModel; score: MatchScore }[] = [];
|
|
for (const opt of options) {
|
|
const score = scoreOption(opt, search);
|
|
if (score) scored.push({ opt, score });
|
|
}
|
|
scored.sort((a, b) => {
|
|
const cmp = compareMatchScores(a.score, b.score);
|
|
if (cmp !== 0) return cmp;
|
|
return a.opt.label.localeCompare(b.opt.label);
|
|
});
|
|
return scored.map((entry) => entry.opt);
|
|
}
|
|
|
|
export function buildVisibleComboboxOptions(
|
|
input: BuildVisibleComboboxOptionsInput,
|
|
): ComboboxOptionModel[] {
|
|
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : "";
|
|
const filteredOptions = filterAndRankComboboxOptions(input.options, normalizedSearch);
|
|
|
|
const sanitizedSearchValue = input.searchQuery.trim();
|
|
const showCustomOption = shouldShowCustomComboboxOption({
|
|
options: input.options,
|
|
searchQuery: input.searchQuery,
|
|
searchable: input.searchable,
|
|
allowCustomValue: input.allowCustomValue,
|
|
});
|
|
|
|
const visibleOptions: ComboboxOptionModel[] = [];
|
|
|
|
if (showCustomOption) {
|
|
const trimmedPrefix = input.customValuePrefix.trim();
|
|
const customLabel =
|
|
trimmedPrefix.length > 0
|
|
? `${trimmedPrefix} "${sanitizedSearchValue}"`
|
|
: sanitizedSearchValue;
|
|
visibleOptions.push({
|
|
id: sanitizedSearchValue,
|
|
label: customLabel,
|
|
description: input.customValueDescription,
|
|
kind: input.customValueKind,
|
|
});
|
|
}
|
|
|
|
visibleOptions.push(...filteredOptions);
|
|
return visibleOptions;
|
|
}
|
|
|
|
export function orderVisibleComboboxOptions(
|
|
visibleOptions: ComboboxOptionModel[],
|
|
optionsPosition: "below-search" | "above-search",
|
|
): ComboboxOptionModel[] {
|
|
if (optionsPosition !== "above-search") {
|
|
return visibleOptions;
|
|
}
|
|
return [...visibleOptions].toReversed();
|
|
}
|
|
|
|
export function getComboboxFallbackIndex(
|
|
itemCount: number,
|
|
optionsPosition: "below-search" | "above-search",
|
|
): number {
|
|
if (itemCount <= 0) {
|
|
return -1;
|
|
}
|
|
return optionsPosition === "above-search" ? itemCount - 1 : 0;
|
|
}
|