Skip to content

feat: added dropdown component #4102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions docs/docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const config = {
DrawerItem: 'Drawer/DrawerItem',
DrawerSection: 'Drawer/DrawerSection',
},
Dropdown: 'Dropdown/Dropdown',
FAB: {
FAB: 'FAB/FAB',
AnimatedFAB: 'FAB/AnimatedFAB',
Expand Down
1 change: 1 addition & 0 deletions docs/src/data/screenshots.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const screenshots = {
'Drawer.CollapsedItem': 'screenshots/drawer-collapsed.png',
'Drawer.Item': 'screenshots/drawer-item.png',
'Drawer.Section': 'screenshots/drawer-section.png',
Dropdown: 'screenshots/dropdown-menu-android.gif',
FAB: {
'all variants': 'screenshots/fab-1.png',
'all sizes': 'screenshots/fab-2.png',
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions example/src/ExampleList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import ChipExample from './Examples/ChipExample';
import DataTableExample from './Examples/DataTableExample';
import DialogExample from './Examples/DialogExample';
import DividerExample from './Examples/DividerExample';
import DropdownExample from './Examples/DropdownExample';
import FABExample from './Examples/FABExample';
import IconButtonExample from './Examples/IconButtonExample';
import ListAccordionExample from './Examples/ListAccordionExample';
Expand Down Expand Up @@ -72,6 +73,7 @@ export const mainExamples: Record<
dataTable: DataTableExample,
dialog: DialogExample,
divider: DividerExample,
dropdown: DropdownExample,
fab: FABExample,
iconButton: IconButtonExample,
listAccordion: ListAccordionExample,
Expand Down
135 changes: 135 additions & 0 deletions example/src/Examples/DropdownExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { ScrollView, StyleSheet, View } from 'react-native';

import { Dropdown, List, TextInput, useTheme } from 'react-native-paper';

const options = [
{
id: '1',
icon: 'dice-1',
title: 'Dice 1',
},
{
id: '2',
icon: 'dice-2',
title: 'Dice 2',
},
{
id: '3',
icon: 'dice-3',
title: 'Dice 3',
},
{
id: '4',
icon: 'dice-4',
title: 'Dice 4',
},
{
id: '5',
icon: 'dice-5',
title: 'Dice 5',
},
{
id: '6',
icon: 'dice-6',
title: 'Dice 6',
},
];

const DropdownExample = () => {
const theme = useTheme();

const [value, setValue] = useState<string | null>(null);

return (
<ScrollView style={{ backgroundColor: theme.colors.background }}>
<List.Section title="Uncontrolled dropdown">
<View style={styles.dropdown}>
<Dropdown onChange={console.log} placeholder="Select an Option">
{options.map(({ id, title }) => (
<Dropdown.Item key={id} title={title} value={title} />
))}
</Dropdown>
</View>
</List.Section>
<List.Section title="Uncontrolled dropdown with icon">
<View style={styles.dropdown}>
<Dropdown
onChange={console.log}
placeholder="Select an Option"
left={<TextInput.Icon icon="magnify" />}
>
{options.map(({ id, title }) => (
<Dropdown.Item key={id} title={title} value={title} />
))}
</Dropdown>
</View>
</List.Section>
<List.Section title="Uncontrolled dropdown with required value">
<View style={styles.dropdown}>
<Dropdown
onChange={console.log}
placeholder="Select an Option"
defaultValue="Dice 1"
required
>
{options.map(({ id, title }) => (
<Dropdown.Item key={id} title={title} value={title} />
))}
</Dropdown>
</View>
</List.Section>
<List.Section title="Controlled Dropdown">
<View style={styles.dropdown}>
<Dropdown
onChange={setValue}
placeholder="Select an Option"
value={value}
valueText={options.filter(({ id }) => id === value)[0]?.title}
>
{options.map(({ id, title }) => (
<Dropdown.Item key={id} title={title} value={id} />
))}
</Dropdown>
</View>
</List.Section>
<List.Section title="Customized dropdown">
<View style={styles.dropdown}>
<Dropdown
mode="outlined"
onChange={setValue}
placeholder="Select an Option"
value={value}
valueText={options.filter(({ id }) => id === value)[0]?.title}
left={
<TextInput.Icon
icon={
options.filter(({ id }) => id === value)[0]?.icon ?? 'magnify'
}
/>
}
>
{options.map(({ id, title, icon }) => (
<Dropdown.Item
key={id}
title={title}
value={id}
leadingIcon={icon}
/>
))}
</Dropdown>
</View>
</List.Section>
</ScrollView>
);
};

DropdownExample.title = 'Dropdown';

const styles = StyleSheet.create({
dropdown: {
paddingHorizontal: 8,
},
});

export default DropdownExample;
157 changes: 157 additions & 0 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { View } from 'react-native';

import DropdownContext from './DropdownContext';
import { Props as DropdownItemProps } from './DropdownItem';
import Menu from '../Menu/Menu';
import TextInput, { Props as TextInputProps } from '../TextInput/TextInput';
import TouchableRipple from '../TouchableRipple/TouchableRipple';

export interface Props extends Omit<TextInputProps, 'value' | 'onChange'> {
/**
* List of underlying dropdown options.
*/
children?:
| ReactElement<DropdownItemProps>
| Array<ReactElement<DropdownItemProps>>;
/**
* Callback called when the selected option changes.
* @param value new selected value
*/
onChange?: (value: string | null) => void;
/**
* Currently selected value in the dropdown. When undefined, the dropdown behaves as an uncontrolled input.
*/
value?: string | null;
/**
* Text displayed in the underlying TextInput. When undefined, the text displayed is equal to the selected value.
*/
valueText?: string;
/**
* Initial value for the dropdown.
*/
defaultValue?: string;
/**
* The clear button will show by default to remove the current value.
* If required is set to true, this button will not appear.
*/
required?: boolean;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have extra props for modifying UI components, like ViewContainerProps, MenuProps, and TextInputProps etc...

}

/**
* Dropdowns present a list of options which a user can select from.
* A selected option can represent a value in a form, or can be used as an action to filter or sort existing content.
*
* ## Usage
* ```js
* import * as React from 'react';
* import { Dropdown, Provider, Text, Title } from 'react-native-paper';
*
* const MyComponent = () => {
* const [selected, setSelected] = React.useState(null);
* const options = [
* {id: 1, name: 'Cookie', calories: 502},
* {id: 2, name: 'Candy', calories: 535},
* ];
*
* return (
* <Provider>
* <Dropdown onChange={setSelected}>
* {options.map(option => (
* <Dropdown.Item
* value={option.name}
* title={option.name}
* key={value.id}
* label={value.name}
* />
* ))}
* </Dropdown>
* <Title>{selectedOption}</Title>
* </Provider>
* );
* };
*
* export default MyComponent;
* ```
*/
const Dropdown = ({
value: valueFromProps,
valueText: valueTextFromProps,
required,
onChange,
children,
defaultValue,
...textInputProps
}: Props) => {
const isControlled = typeof valueFromProps !== 'undefined';

const [menu, setMenu] = useState<View | null>(null);
const [width, setWidth] = useState(0);
const [open, setOpen] = useState(false);
const [internalValue, setInternalValue] = useState<string | null>(
defaultValue ?? null
);

useEffect(() => {
menu?.measureInWindow((_x, _y, width, _height) => {
setWidth(width);
});
}, [open, menu]);

useEffect(() => {
if (typeof valueFromProps !== 'undefined') {
setInternalValue(valueFromProps);
}
}, [valueFromProps]);

const value = isControlled ? valueFromProps : internalValue;
const valueText =
typeof valueTextFromProps !== 'undefined' ? valueTextFromProps : value;

return (
<View>
<Menu
anchor={
<View ref={setMenu}>
<TouchableRipple onPress={() => setOpen(true)}>
<TextInput
editable={false}
right={

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would preferer to have a prop called something like disableClearButton in case I don't want the clear button, and having clearButton or renderClearButton prop that accepts function that's going to render the button in case I want another icon or look, or maybe as simple as clearButtonIcon.

value && !required ? (
<TextInput.Icon
icon="close-circle-outline"
onPress={() => {
onChange?.(null);
setInternalValue(null);
}}
/>
) : undefined
}
value={valueText ?? ''}
{...textInputProps}
/>
</TouchableRipple>
</View>
}
anchorPosition="bottom"
contentStyle={{ width: width }}
visible={open}
onDismiss={() => setOpen(false)}
>
<DropdownContext.Provider
value={{
onChange: (newValue: string) => {
setInternalValue(newValue);
onChange?.(newValue);
setOpen(false);
},
}}
>
{children}
</DropdownContext.Provider>
</Menu>
</View>
);
};

export default Dropdown;
9 changes: 9 additions & 0 deletions src/components/Dropdown/DropdownContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createContext } from 'react';

export interface DropdownContextData {
onChange?: (value: string) => void;
}

const DropdownContext = createContext<DropdownContextData>({});

export default DropdownContext;
23 changes: 23 additions & 0 deletions src/components/Dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React, { useContext } from 'react';

import DropdownContext from './DropdownContext';
import { Props as MenuItemProps } from '../Menu/MenuItem';
import MenuItem from '../Menu/MenuItem';

export interface Props extends Omit<MenuItemProps, 'onPress'> {
value: string;
}

const DropdownItem = (props: Props) => {
const dropdownContext = useContext(DropdownContext);

return (
<MenuItem
style={{ maxWidth: undefined }}
onPress={() => dropdownContext.onChange?.(props.value)}
{...props}
/>
);
};

export default DropdownItem;
17 changes: 17 additions & 0 deletions src/components/Dropdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import DropdownComponent from './Dropdown';
import DropdownItem from './DropdownItem';

type DropdownExport = typeof DropdownComponent & {
Item: typeof DropdownItem;
};

const Dropdown: DropdownExport = Object.assign(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't cleaner to write

Dropdown.Item = DropdownItem;

like do we need to use Object.assign?

// @component ./Dropdown.tsx
DropdownComponent,
{
// @component ./DropdownItem.tsx
Item: DropdownItem,
}
);

export default Dropdown;
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export { default as Chip } from './components/Chip/Chip';
export { default as DataTable } from './components/DataTable/DataTable';
export { default as Dialog } from './components/Dialog/Dialog';
export { default as Divider } from './components/Divider';
export { default as Dropdown } from './components/Dropdown';
export { default as FAB } from './components/FAB';
export { default as AnimatedFAB } from './components/FAB/AnimatedFAB';
export { default as HelperText } from './components/HelperText/HelperText';
Expand Down