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