Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/components/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -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 = <T extends AutocompleteItem>(
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<T extends AutocompleteItem = AutocompleteItem>({
data,
value,
onChangeText,
onSelect,
filter = defaultFilter,
label,
placeholder,
maxResults = 8,
showResults,
listStyle,
style,
theme: themeOverrides,
testID = 'autocomplete',
error,
disabled = false,
}: AutocompleteProps<T>) {
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 (
<View testID={testID} style={[styles.container, style]}>
<TextInput
testID={`${testID}-input`}
label={label}
placeholder={placeholder}
value={value}
onChangeText={onChangeText}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
error={!!error}
disabled={disabled}
theme={themeOverrides}
right={error ? <TextInput.Affix text={error} /> : undefined}
/>
{shouldShow && (
<View
testID={`${testID}-list`}
style={[
styles.list,
{
backgroundColor: surfaceColor,
borderRadius: roundness,
borderWidth: 1,
borderColor: borderColor,
} as ViewStyle,
listStyle,
]}
accessibilityRole="list"
>
{results.length === 0 ? (
<View style={styles.empty} testID={`${testID}-empty`}>
<Text style={[styles.emptyLabel, { color: textColor }]}>
No results
</Text>
</View>
) : (
<ScrollView testID={`${testID}-scroll`} nestedScrollEnabled>
{results.map((item, index) => (
<Pressable
key={item.key}
testID={`${testID}-item-${index}`}
onPress={() => handleSelect(item)}
style={styles.item}
accessibilityRole="button"
accessibilityLabel={item.label}
>
<Text style={[styles.itemLabel, { color: textColor }]}>
{item.label}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
)}
</View>
);
}

export default Autocomplete;
export type { AutocompleteItem, AutocompleteProps };
47 changes: 47 additions & 0 deletions src/components/Autocomplete/types.ts
Original file line number Diff line number Diff line change
@@ -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<T extends AutocompleteItem = AutocompleteItem> = {
/** 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<ViewStyle>;
/** Style applied to the outer container. */
style?: StyleProp<ViewStyle>;
/** @optional */
theme?: ThemeProp;
/** TestID for testing. */
testID?: string;
/** Error text to display below the input. */
error?: string;
/** Whether the input is disabled. */
disabled?: boolean;
};
5 changes: 5 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down