From 3f2b387932eecdac3717a589936e176a1003bc72 Mon Sep 17 00:00:00 2001 From: hazrid93 Date: Mon, 20 Jul 2026 10:28:05 +0000 Subject: [PATCH] feat: add Autocomplete component Closes #4587. Adds an Autocomplete text input that filters a collection of items by label as the user types and shows matching results in a dropdown. Controlled via value and onChangeText, calls onSelect with the chosen item and fills the input with its label. The dropdown appears on focus when the value is non empty, caps results with a configurable maximum, and shows an empty state when nothing matches. Supports a custom filter predicate, a disabled state that hides the dropdown, a controlled showResults flag, and theme aware corner masking. The component and its item and props types are exported from the public surface. --- src/components/Autocomplete/Autocomplete.tsx | 165 +++++++++++++++++++ src/components/Autocomplete/types.ts | 47 ++++++ src/index.tsx | 5 + 3 files changed, 217 insertions(+) create mode 100644 src/components/Autocomplete/Autocomplete.tsx create mode 100644 src/components/Autocomplete/types.ts diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000000..f779e0b7fb --- /dev/null +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,165 @@ +import * as React from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + Text, + View, + ViewStyle, +} from 'react-native'; + +import type { AutocompleteItem, AutocompleteProps } from './types'; +import { useInternalTheme } from '../../core/theming'; +import TextInput from '../TextInput/TextInput'; + +const defaultFilter = ( + item: T, + query: string +): boolean => { + if (!query) return true; + return item.label.toLowerCase().includes(query.toLowerCase()); +}; + +const styles = StyleSheet.create({ + container: { + position: 'relative', + } as ViewStyle, + list: { + position: 'absolute', + left: 0, + right: 0, + top: '100%', + zIndex: 1, + elevation: 4, + maxHeight: 240, + overflow: 'hidden', + } as ViewStyle, + item: { + paddingHorizontal: 16, + paddingVertical: 12, + } as ViewStyle, + itemLabel: { + fontSize: 16, + } as ViewStyle, + empty: { + paddingHorizontal: 16, + paddingVertical: 12, + } as ViewStyle, + emptyLabel: { + fontSize: 14, + } as ViewStyle, +}); + +/** + * Autocomplete text input. + * + * A text input with a dropdown of matching items that filters as the user + * types. Selecting an item calls `onSelect` with the chosen item. + * + * @param props + */ +function Autocomplete({ + data, + value, + onChangeText, + onSelect, + filter = defaultFilter, + label, + placeholder, + maxResults = 8, + showResults, + listStyle, + style, + theme: themeOverrides, + testID = 'autocomplete', + error, + disabled = false, +}: AutocompleteProps) { + const theme = useInternalTheme(themeOverrides); + const [focused, setFocused] = React.useState(false); + + const results = React.useMemo(() => { + const filtered = data.filter((item) => filter(item, value)); + return filtered.slice(0, maxResults); + }, [data, filter, value, maxResults]); + + const shouldShow = showResults ?? (focused && value.length > 0 && !disabled); + + const handleSelect = (item: T) => { + onChangeText(item.label); + onSelect(item); + }; + + const roundness = theme.isV3 ? theme.roundness : 4; + const surfaceColor = theme.isV3 ? theme.colors.surface : '#fff'; + const textColor = theme.isV3 ? theme.colors.onSurface : '#000'; + const borderColor = theme.isV3 + ? error + ? theme.colors.error + : theme.colors.outline + : error + ? '#B00020' + : '#ccc'; + + return ( + + setFocused(true)} + onBlur={() => setFocused(false)} + error={!!error} + disabled={disabled} + theme={themeOverrides} + right={error ? : undefined} + /> + {shouldShow && ( + + {results.length === 0 ? ( + + + No results + + + ) : ( + + {results.map((item, index) => ( + handleSelect(item)} + style={styles.item} + accessibilityRole="button" + accessibilityLabel={item.label} + > + + {item.label} + + + ))} + + )} + + )} + + ); +} + +export default Autocomplete; +export type { AutocompleteItem, AutocompleteProps }; diff --git a/src/components/Autocomplete/types.ts b/src/components/Autocomplete/types.ts new file mode 100644 index 0000000000..1ff30839d9 --- /dev/null +++ b/src/components/Autocomplete/types.ts @@ -0,0 +1,47 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +import type { ThemeProp } from '../../types'; + +export type AutocompleteItem = { + /** Stable key for the item. */ + key: string; + /** Label shown in the dropdown and used for default filtering. */ + label: string; + [key: string]: unknown; +}; + +export type AutocompleteProps = { + /** The full list of items to filter from. */ + data: T[]; + /** Current input value. */ + value: string; + /** Called when the input text changes. */ + onChangeText: (text: string) => void; + /** Called when the user selects an item from the dropdown. */ + onSelect: (item: T) => void; + /** + * Custom filter predicate. Defaults to a case-insensitive substring match on + * the item's `label`. + */ + filter?: (item: T, query: string) => boolean; + /** TextInput label. */ + label?: string; + /** TextInput placeholder. */ + placeholder?: string; + /** Maximum number of results to show. Defaults to 8. */ + maxResults?: number; + /** Whether to show the dropdown list. Defaults to true when value is non-empty. */ + showResults?: boolean; + /** Style applied to the dropdown list container. */ + listStyle?: StyleProp; + /** Style applied to the outer container. */ + style?: StyleProp; + /** @optional */ + theme?: ThemeProp; + /** TestID for testing. */ + testID?: string; + /** Error text to display below the input. */ + error?: string; + /** Whether the input is disabled. */ + disabled?: boolean; +}; diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..6e844a140a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,7 @@ export { default as Banner } from './components/Banner'; export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation'; export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; +export { default as Autocomplete } from './components/Autocomplete/Autocomplete'; export { default as Checkbox } from './components/Checkbox'; export { default as Chip } from './components/Chip/Chip'; export { default as DataTable } from './components/DataTable/DataTable'; @@ -86,6 +87,10 @@ export type { } from './components/BottomNavigation/BottomNavigation'; export type { Props as ButtonProps } from './components/Button/Button'; export type { Props as CardProps } from './components/Card/Card'; +export type { + AutocompleteItem, + AutocompleteProps, +} from './components/Autocomplete/types'; export type { Props as CardActionsProps } from './components/Card/CardActions'; export type { Props as CardContentProps } from './components/Card/CardContent'; export type { Props as CardCoverProps } from './components/Card/CardCover';