- Make PaseoLogo theme-aware (uses foreground color instead of hardcoded white) - Add shadow tokens (sm/md/lg) to theme with per-scheme opacity, radius, and offset - Replace all 16 hardcoded shadow instances with spreadable theme.shadow tokens - Fix button icon color for default variant (use accentForeground, not foreground) - Fix dark background flash on startup (root layout used hardcoded darkTheme) - Add theme.colorScheme to replace fragile hex-string dark mode detection - Add scrollbarHandle and surfaceWorkspace tokens to eliminate isDark branching
343 lines
9.8 KiB
TypeScript
343 lines
9.8 KiB
TypeScript
import { useCallback, useEffect, useRef } from "react";
|
|
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { File, Folder } from "lucide-react-native";
|
|
import { Theme } from "@/styles/theme";
|
|
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
|
|
|
|
export interface AutocompleteOption {
|
|
id: string;
|
|
label: string;
|
|
detail?: string;
|
|
description?: string;
|
|
kind?: "command" | "file" | "directory";
|
|
}
|
|
|
|
interface AutocompleteProps {
|
|
options: readonly AutocompleteOption[];
|
|
selectedIndex: number;
|
|
onSelect: (option: AutocompleteOption) => void;
|
|
isLoading?: boolean;
|
|
errorMessage?: string;
|
|
loadingText?: string;
|
|
emptyText?: string;
|
|
maxHeight?: number;
|
|
}
|
|
|
|
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g;
|
|
|
|
function removeBoltGlyphs(value?: string): string | undefined {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
const cleaned = value.replace(BOLT_GLYPH_PATTERN, "").trim();
|
|
return cleaned.length > 0 ? cleaned : undefined;
|
|
}
|
|
|
|
export function Autocomplete({
|
|
options,
|
|
selectedIndex,
|
|
onSelect,
|
|
isLoading = false,
|
|
errorMessage,
|
|
loadingText = "Loading...",
|
|
emptyText = "No results found",
|
|
maxHeight = 220,
|
|
}: AutocompleteProps) {
|
|
const { theme } = useUnistyles();
|
|
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>
|
|
);
|
|
}
|
|
|
|
const selectedOption = options[selectedIndex];
|
|
|
|
return (
|
|
<View style={styles.outerWrapper}>
|
|
{selectedOption?.kind === "command" && selectedOption.description ? (
|
|
<View style={styles.detailCard}>
|
|
<Text style={styles.detailLabel}>
|
|
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
|
|
</Text>
|
|
<Text style={styles.detailDescription}>
|
|
{removeBoltGlyphs(selectedOption.description)}
|
|
</Text>
|
|
{selectedOption.detail ? (
|
|
<Text style={styles.detailHint}>{removeBoltGlyphs(selectedOption.detail)}</Text>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
<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;
|
|
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
|
|
const optionDescription = removeBoltGlyphs(option.description);
|
|
const isFileOrDir = option.kind === "directory" || option.kind === "file";
|
|
return (
|
|
<Pressable
|
|
key={option.id}
|
|
onLayout={(event) => handleRowLayout(index, event)}
|
|
onPress={() => onSelect(option)}
|
|
style={({ hovered = false, pressed }) => [
|
|
styles.item,
|
|
(hovered || pressed || isSelected) && styles.itemActive,
|
|
]}
|
|
>
|
|
{isFileOrDir ? (
|
|
<>
|
|
<View style={styles.itemLeading}>
|
|
{option.kind === "directory" ? (
|
|
<Folder size={14} color={theme.colors.foregroundMuted} />
|
|
) : (
|
|
<File size={14} color={theme.colors.foregroundMuted} />
|
|
)}
|
|
</View>
|
|
<View style={styles.itemMain}>
|
|
<View style={styles.itemHeader}>
|
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
|
{removeBoltGlyphs(option.detail) ? (
|
|
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
|
|
) : null}
|
|
</View>
|
|
{optionDescription ? (
|
|
<Text style={styles.itemDescription} numberOfLines={1}>
|
|
{optionDescription}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</>
|
|
) : (
|
|
<View style={styles.itemMainRow}>
|
|
<Text style={styles.itemLabel}>{optionLabel}</Text>
|
|
{optionDescription ? (
|
|
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
|
|
{optionDescription}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create(((theme: Theme) => ({
|
|
outerWrapper: {
|
|
gap: theme.spacing[1],
|
|
},
|
|
detailCard: {
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
borderRadius: theme.borderRadius.lg,
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[3],
|
|
...theme.shadow.md,
|
|
},
|
|
detailLabel: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
detailDescription: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
detailHint: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
container: {
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
borderRadius: theme.borderRadius.lg,
|
|
overflow: "hidden",
|
|
...theme.shadow.md,
|
|
},
|
|
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],
|
|
},
|
|
itemLeading: {
|
|
width: 18,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginRight: theme.spacing[1],
|
|
},
|
|
itemActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
itemMain: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
itemMainRow: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
itemHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
itemLabel: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
itemDetail: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
itemDescription: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: 2,
|
|
},
|
|
itemDescriptionInline: {
|
|
flex: 1,
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
emptyItem: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[3],
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
})) as any) as Record<string, any>;
|