UNPKG

@shopify/polaris

Version:

Shopify’s admin product component library

617 lines (498 loc) 17.1 kB
--- name: Combobox category: Forms keywords: - autocomplete - searchable - typeahead - combobox - combo box - listbox - list box --- # Combobox Combobox is an accessible autocomplete input that enables merchants to filter a list of options and select one or more values. --- ## Anatomy ![A diagram of the Combobox component showing the smaller primitive components it is composed of.](/public_images/components/Combobox/combobox-anatomy.png) A combobox is made up of the following: 1. **TextField**: A text input that activates a popover displaying a list of options. As merchants type in the text field, the list of options is filtered by the input value. Options replace or add to the input value when selected. 2. **Popover**: An overlay containing a list of options. 3. **Listbox**: A list of options to filter and select or deselect. 4. **Listbox.Option**: The individual options to select or deselect. Check out the [listbox component documentation](https://polaris.shopify.com/components/forms/listbox) to learn how to compose it with various content. --- ## Best practices The `Combobox` component should: - Be clearly labeled so the merchant knows what kind of options will be available - Not be used within a popover - Indicate a loading state to the merchant while option data is being populated - Order items in an intentional way so it’s easy for the merchant to find a specific value --- ## Content guidelines The input field for `Combobox` should follow the [content guidelines](https://polaris.shopify.com/components/forms/text-field) for text fields. --- ## Sorting and filtering ### Sorting Item order should be intentional. Order them so it’s easy for the merchant to find a specific value. Some ways you can do this: - Sort options in alphabetical order - Display options based on how frequently the merchant selects an option If multiple options can be selected, move selected items to the top of the list. If this doesn’t work for your context, you can override this behavior. ### Filtering - By default, menu items are filtered based on whether or not they match the value of the textfield. - Filters are **not** case-sensitive by default. - You can apply custom filtering logic if the default behavior doesn’t make sense for your use case. --- ## Patterns ### Tags autocomplete The tag multi-select input enables merchants to efficiently add or remove tags from a resource, like a product or an order. It uses the inline autocomplete combobox pattern to present merchants with an editable list of tags to browse and select from. --- ## Examples ### Single select autocomplete Use when merchants can select one option from a predefined or editable list. ```jsx function ComboboxExample() { const deselectedOptions = useMemo( () => [ {value: 'rustic', label: 'Rustic'}, {value: 'antique', label: 'Antique'}, {value: 'vinyl', label: 'Vinyl'}, {value: 'vintage', label: 'Vintage'}, {value: 'refurbished', label: 'Refurbished'}, ], [], ); const [selectedOption, setSelectedOption] = useState(); const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState(deselectedOptions); const updateText = useCallback( (value) => { setInputValue(value); if (value === '') { setOptions(deselectedOptions); return; } const filterRegex = new RegExp(value, 'i'); const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); setOptions(resultOptions); }, [deselectedOptions], ); const updateSelection = useCallback( (selected) => { const matchedOption = options.find((option) => { return option.value.match(selected); }); setSelectedOption(selected); setInputValue((matchedOption && matchedOption.label) || ''); }, [options], ); const optionsMarkup = options.length > 0 ? options.map((option) => { const {label, value} = option; return ( <Listbox.Option key={`${value}`} value={value} selected={selectedOption === value} accessibilityLabel={label} > {label} </Listbox.Option> ); }) : null; return ( <div style={{height: '225px'}}> <Combobox activator={ <Combobox.TextField prefix={<Icon source={SearchMinor} />} onChange={updateText} label="Search tags" labelHidden value={inputValue} placeholder="Search tags" /> } > {options.length > 0 ? ( <Listbox onSelect={updateSelection}>{optionsMarkup}</Listbox> ) : null} </Combobox> </div> ); } ``` ### Multi-select autocomplete Use when merchants can select one or more options from a predefined or editable list. ```jsx function MultiComboboxExample() { const deselectedOptions = useMemo( () => [ {value: 'rustic', label: 'Rustic'}, {value: 'antique', label: 'Antique'}, {value: 'vinyl', label: 'Vinyl'}, {value: 'vintage', label: 'Vintage'}, {value: 'refurbished', label: 'Refurbished'}, ], [], ); const [selectedOptions, setSelectedOptions] = useState([]); const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState(deselectedOptions); const updateText = useCallback( (value) => { setInputValue(value); if (value === '') { setOptions(deselectedOptions); return; } const filterRegex = new RegExp(value, 'i'); const resultOptions = deselectedOptions.filter((option) => option.label.match(filterRegex), ); setOptions(resultOptions); }, [deselectedOptions], ); const updateSelection = useCallback( (selected) => { if (selectedOptions.includes(selected)) { setSelectedOptions( selectedOptions.filter((option) => option !== selected), ); } else { setSelectedOptions([...selectedOptions, selected]); } const matchedOption = options.find((option) => { return option.value.match(selected); }); updateText(''); }, [options, selectedOptions], ); const removeTag = useCallback( (tag) => () => { const options = [...selectedOptions]; options.splice(options.indexOf(tag), 1); setSelectedOptions(options); }, [selectedOptions], ); const tagsMarkup = selectedOptions.map((option) => ( <Tag key={`option-${option}`} onRemove={removeTag(option)}> {option} </Tag> )); const optionsMarkup = options.length > 0 ? options.map((option) => { const {label, value} = option; return ( <Listbox.Option key={`${value}`} value={value} selected={selectedOptions.includes(value)} accessibilityLabel={label} > {label} </Listbox.Option> ); }) : null; return ( <div style={{height: '225px'}}> <Combobox allowMultiple activator={ <Combobox.TextField prefix={<Icon source={SearchMinor} />} onChange={updateText} label="Search tags" labelHidden value={inputValue} placeholder="Search tags" /> } > {optionsMarkup ? ( <Listbox onSelect={updateSelection}>{optionsMarkup}</Listbox> ) : null} </Combobox> <TextContainer> <Stack>{tagsMarkup}</Stack> </TextContainer> </div> ); } ``` ### Multi-select autocomplete with vertical content Use to display selected options above the input value. ```jsx function MultiselectTagComboboxExample() { const [selectedTags, setSelectedTags] = useState(['Rustic']); const [value, setValue] = useState(''); const [suggestion, setSuggestion] = useState(''); const handleActiveOptionChange = useCallback( (activeOption) => { const activeOptionIsAction = activeOption === value; if (!activeOptionIsAction && !selectedTags.includes(activeOption)) { setSuggestion(activeOption); } else { setSuggestion(''); } }, [value, selectedTags], ); const updateSelection = useCallback( (selected) => { const nextSelectedTags = new Set([...selectedTags]); if (nextSelectedTags.has(selected)) { nextSelectedTags.delete(selected); } else { nextSelectedTags.add(selected); } setSelectedTags([...nextSelectedTags]); setValue(''); setSuggestion(''); }, [selectedTags], ); const removeTag = useCallback( (tag) => () => { updateSelection(tag); }, [updateSelection], ); const getAllTags = useCallback(() => { const savedTags = ['Rustic', 'Antique', 'Vinyl', 'Vintage', 'Refurbished']; return [...new Set([...savedTags, ...selectedTags].sort())]; }, [selectedTags]); const formatOptionText = useCallback( (option) => { const trimValue = value.trim().toLocaleLowerCase(); const matchIndex = option.toLocaleLowerCase().indexOf(trimValue); if (!value || matchIndex === -1) return option; const start = option.slice(0, matchIndex); const highlight = option.slice(matchIndex, matchIndex + trimValue.length); const end = option.slice(matchIndex + trimValue.length, option.length); return ( <p> {start} <TextStyle variation="strong">{highlight}</TextStyle> {end} </p> ); }, [value], ); const options = useMemo(() => { let list; const allTags = getAllTags(); const filterRegex = new RegExp(value, 'i'); if (value) { list = allTags.filter((tag) => tag.match(filterRegex)); } else { list = allTags; } return [...list]; }, [value, getAllTags]); const verticalContentMarkup = selectedTags.length > 0 ? ( <Stack spacing="extraTight" alignment="center"> {selectedTags.map((tag) => ( <Tag key={`option-${tag}`} onRemove={removeTag(tag)}> {tag} </Tag> ))} </Stack> ) : null; const optionMarkup = options.length > 0 ? options.map((option) => { return ( <Listbox.Option key={option} value={option} selected={selectedTags.includes(option)} accessibilityLabel={option} > <Listbox.TextOption selected={selectedTags.includes(option)}> {formatOptionText(option)} </Listbox.TextOption> </Listbox.Option> ); }) : null; const noResults = value && !getAllTags().includes(value); const actionMarkup = noResults ? ( <Listbox.Action value={value}>{`Add "${value}"`}</Listbox.Action> ) : null; const emptyStateMarkup = optionMarkup ? null : ( <EmptySearchResult title="" description={`No tags found matching "${value}"`} /> ); const listboxMarkup = optionMarkup || actionMarkup || emptyStateMarkup ? ( <Listbox autoSelection="FIRST" onSelect={updateSelection} onActiveOptionChange={handleActiveOptionChange} > {actionMarkup} {optionMarkup} </Listbox> ) : null; return ( <div style={{height: '225px'}}> <Combobox allowMultiple activator={ <Combobox.TextField autoComplete="off" label="Search tags" labelHidden value={value} suggestion={suggestion} placeholder="Search tags" verticalContent={verticalContentMarkup} onChange={setValue} /> } > {listboxMarkup} </Combobox> </div> ); } ``` ### Autocomplete with loading Use to indicate to merchants that the list data is being fetched. ```jsx function LoadingAutocompleteExample() { const deselectedOptions = useMemo( () => [ {value: 'rustic', label: 'Rustic'}, {value: 'antique', label: 'Antique'}, {value: 'vinyl', label: 'Vinyl'}, {value: 'vintage', label: 'Vintage'}, {value: 'refurbished', label: 'Refurbished'}, ], [], ); const [selectedOption, setSelectedOption] = useState(); const [inputValue, setInputValue] = useState(''); const [options, setOptions] = useState(deselectedOptions); const [loading, setLoading] = useState(false); const updateText = useCallback( (value) => { setInputValue(value); if (!loading) { setLoading(true); } setTimeout(() => { if (value === '') { setOptions(deselectedOptions); setLoading(false); return; } const filterRegex = new RegExp(value, 'i'); const resultOptions = options.filter((option) => option.label.match(filterRegex), ); setOptions(resultOptions); setLoading(false); }, 300); }, [deselectedOptions, loading, options], ); const updateSelection = useCallback( (selected) => { const matchedOption = options.find((option) => { return option.value.match(selected); }); setSelectedOption(selected); setInputValue((matchedOption && matchedOption.label) || ''); }, [options], ); const optionsMarkup = options.length > 0 ? options.map((option) => { const {label, value} = option; return ( <Listbox.Option key={`${value}`} value={value} selected={selectedOption === value} accessibilityLabel={label} > {label} </Listbox.Option> ); }) : null; const loadingMarkup = loading ? <Listbox.Loading /> : null; const listboxMarkup = optionsMarkup || loadingMarkup ? ( <Listbox onSelect={updateSelection}> {optionsMarkup && !loading ? optionsMarkup : null} {loadingMarkup} </Listbox> ) : null; return ( <div style={{height: '225px'}}> <Combobox activator={ <Combobox.TextField prefix={<Icon source={SearchMinor} />} onChange={updateText} label="Search tags" labelHidden value={inputValue} placeholder="Search tags" /> } > {listboxMarkup} </Combobox> </div> ); } ``` --- ## Related components - For an input field without suggested options, [use the text field component](https://polaris.shopify.com/components/forms/text-field) - For a list of selectable options not linked to an input field, [use the list box component](https://polaris.shopify.com/components/lists-and-tables/listbox) --- ## Accessibility <!-- content-for: android --> See Material Design and development documentation about accessibility for Android: - [Accessible design on Android](https://material.io/design/usability/accessibility.html) - [Accessible development on Android](https://developer.android.com/guide/topics/ui/accessibility/) <!-- /content-for --> <!-- content-for: ios --> See Apple’s Human Interface Guidelines and API documentation about accessibility for iOS: - [Accessible design on iOS](https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/accessibility/) - [Accessible development on iOS](https://developer.apple.com/accessibility/ios/) <!-- /content-for --> <!-- content-for: web --> ### Structure The `Combobox` component is based on the [ARIA 1.2 combobox pattern](https://www.w3.org/TR/wai-aria-practices-1.1/#combobox). It is a combination of a single-line `TextField` and a `Popover`. The current implementation expects a [`Listbox`](https://polaris.shopify.com/components/lists-and-tables/listbox) component to be used. The `Combobox` popover displays below the text field or other control by default so it is easy for merchants to discover and use. However, you can change the position with the `preferredPosition` prop. `Combobox` features can be challenging for merchants with visual, motor, and cognitive disabilities. Even when they’re built using best practices, these features can be difficult to use with some assistive technologies. Merchants should always be able to search, enter data, or perform other activities without relying on the combobox. <!-- usageblock --> #### Do - Use combobox as progressive enhancement to make the interface easier to use for most merchants. #### Don’t - Require that merchants make a selection from the combobox to complete a task. <!-- end --> ### Keyboard support - Give the combobox's text input keyboard focus with the <kbd>tab</kbd> key (or <kbd>shift</kbd> + <kbd>tab</kbd> when tabbing backwards) <!-- /content-for -->