diff --git a/src/Combobox/Combobox.js b/src/Combobox/Combobox.js new file mode 100644 index 00000000..0311d680 --- /dev/null +++ b/src/Combobox/Combobox.js @@ -0,0 +1,247 @@ +import React, {useEffect, useRef, createContext} from 'react'; +import PropTypes from 'prop-types'; +import {useItemList} from 'use-item-list'; + +import {KEY_CODES} from '../constants'; +import {mergeCallbacks} from '../utils'; + +import useEventListener from '../useEventListener'; +import useOnClickOutside from '../useOnClickOutside'; +import usePopover from '../usePopover'; +import usePopoverState from '../usePopoverState'; +import useUniqueId from '../useUniqueId'; + +import Portal from '../Portal'; +import Status from '../Status'; +import VisuallyHidden from '../VisuallyHidden'; + +function defaultGetA11yStatusMessage({resultCount, inputValue}) { + if (inputValue?.length && !resultCount) { + return 'No suggestions available'; + } + + if (resultCount > 0) { + return [ + resultCount, + 'suggestions available.', + 'Use up and down arrow keys to navigate. Press Enter key to select.', + ].join(' '); + } + + return ''; +} + +const ComboboxContext = createContext(); + +function Combobox({ + id: idProp, + inputValue, + resultCount, + onSelect, + getStatusMessage, + shouldShowStatusMessage, + children, +}) { + const inputRef = useRef(); + const popoverRef = useRef(); + const id = useUniqueId(idProp); + const statusMessage = getStatusMessage({resultCount, inputValue}); + + const popover = usePopover({ + ref: popoverRef, + referenceRef: inputRef, + placement: 'bottom-start', + positionFixed: false, + adaptivePositioning: true, + matchReferenceWidth: true, + arrowSize: 0, + }); + + const {isOpen, open, close, toggle} = usePopoverState({ + onOpen: onOpenMenu, + }); + + const canMenuBeOpen = + (shouldShowStatusMessage && Boolean(statusMessage)) || + Boolean(resultCount); + const isMenuOpen = canMenuBeOpen && isOpen; + + const itemList = useItemList({ + id, + selected: inputValue, + initialHighlightedIndex: 0, + onSelect: handleSelect, + }); + + const shouldOpenOnValueChange = useRef(true); + + function handleSelect(selectedItem) { + // Normally, the dropdown menu is opened whenever + // the input value changes. However we don't want that + // if the value was changed as the result of a selection + // as it would re-open the menu + shouldOpenOnValueChange.current = false; + onSelect(selectedItem); + close(); + } + + useEffect(() => { + if (inputValue.length && shouldOpenOnValueChange.current) { + open(); + } + shouldOpenOnValueChange.current = true; + }, [inputValue]); // eslint-disable-line react-hooks/exhaustive-deps + + function onOpenMenu() { + // itemList.setHighlightedItem(0); + } + + function handleGlobalMenuKeyEvents(event) { + if (event.keyCode === KEY_CODES.ESC) { + event.preventDefault(); + close(); + } + } + + function handleInputKeyEvents(event) { + if (shouldShowStatusMessage) { + return; + } + + if (event.keyCode === KEY_CODES.ARROW_UP) { + event.preventDefault(); + if (isMenuOpen) { + itemList.moveHighlightedItem(-1); + } else { + open(); + } + } + if (event.keyCode === KEY_CODES.ARROW_DOWN) { + event.preventDefault(); + if (isMenuOpen) { + itemList.moveHighlightedItem(1); + } else { + open(); + } + } + if (event.keyCode === KEY_CODES.TAB) { + if (isMenuOpen) { + itemList.selectHighlightedItem(); + close(); + } + } + if (event.keyCode === KEY_CODES.ENTER) { + event.preventDefault(); + if (isMenuOpen) { + close(); + itemList.selectHighlightedItem(); + } + } + } + + // Handle global keyboard events when the menu is open + useEventListener('keydown', handleGlobalMenuKeyEvents, { + isEnabled: isMenuOpen, + }); + + // Close the menu when clicking outside of the input + useOnClickOutside([inputRef, popoverRef], close, isMenuOpen); + + const highlightedItemId = itemList.useHighlightedItemId(); + + const getInputProps = ({onFocus, onKeyDown}) => ({ + id, + value: inputValue, + role: 'combobox', + 'aria-controls': itemList.listId, + 'aria-expanded': isMenuOpen ? 'true' : 'false', + 'aria-haspopup': 'true', + 'aria-activedescendant': isMenuOpen ? highlightedItemId : null, + 'aria-autocomplete': 'list', + autoComplete: 'off', + spellCheck: 'false', + onKeyDown: mergeCallbacks(onKeyDown, handleInputKeyEvents), + onFocus: mergeCallbacks(onFocus, () => { + if (!isMenuOpen) { + open(); + } + }), + }); + + return ( + + {children} + + + {isMenuOpen && statusMessage} + + + + ); +} + +Combobox.defaultProps = { + shouldShowStatusMessage: false, + getStatusMessage: defaultGetA11yStatusMessage, +}; + +Combobox.propTypes = { + /** + * Unique ID for the combobox that will be passed to the input element + * and used as the basis for the IDs of other associated elements. + * Will be automatically generated if not provided. + */ + id: PropTypes.string, + /** + * The value of the input field. Will also be passed on to the wrapped + * input field automatically + */ + inputValue: PropTypes.string.isRequired, + /** + * The number of rendered autocomplete suggestions, i.e. menu items + * that are rendered using the ComboboxMenuItem component + */ + resultCount: PropTypes.number.isRequired, + /** + * Callback to provide custom status messages which will be used + * for screenreader announcements and in the UI when the `shouldShowStatusMessage` + * option is enabled. + * The passed function is called with an object containing `resultCount` and + * `inputValue`, and should return suitable strings to notify users of the number + * of results and how to navigate the combobox. + */ + getStatusMessage: PropTypes.func, + /** + * Set this to true to make the status message returned from `getStatusMessage` + * visible in the dropdown menu, e.g. to display "No suggestions available" when + * there are no results. Never set this to `true` when there _are_ results, and always + * make it dependent on e.g. the number of results or the length of `inputValue`, + * as it will replace the options in the menu. + */ + shouldShowStatusMessage: PropTypes.bool, + /** + * Function to be called when a menu option was selected by the user. + * Will be called with an object containing the option's `value` and `text`. + */ + onSelect: PropTypes.func.isRequired, + children: PropTypes.node.isRequired, +}; + +export {ComboboxContext}; + +// @component +export default Combobox; diff --git a/src/Combobox/ComboboxInput.js b/src/Combobox/ComboboxInput.js new file mode 100644 index 00000000..560cbae9 --- /dev/null +++ b/src/Combobox/ComboboxInput.js @@ -0,0 +1,45 @@ +import React, {useContext, forwardRef} from 'react'; +import useMergedRefs from '../useMergedRefs'; +import {ComboboxContext} from './Combobox'; + +const ComboboxInput = forwardRef(function ComboboxInput(props, outerRef) { + const { + as: Component = 'input', + forwardedAs, + refKey = 'ref', + onFocus, + onKeyDown, + ...otherProps + } = props; + + const { + getInputProps, + popover: {setReferenceRef}, + } = useContext(ComboboxContext); + + // If there's a custom refKey, use that as the popover target + // and pass the regular ref on as usual + // Otherwise merge the refs and attach them to the default one + const mergedRefs = useMergedRefs([setReferenceRef, outerRef]); + const mergedCustomRefs = useMergedRefs([ + setReferenceRef, + otherProps[refKey], + ]); + const refs = { + ref: refKey !== 'ref' ? outerRef : mergedRefs, + }; + if (refKey !== 'ref') { + refs[refKey] = mergedCustomRefs; + } + + return ( + + ); +}); + +export default ComboboxInput; diff --git a/src/Combobox/ComboboxMenu.js b/src/Combobox/ComboboxMenu.js new file mode 100644 index 00000000..076b1b0a --- /dev/null +++ b/src/Combobox/ComboboxMenu.js @@ -0,0 +1,53 @@ +import React, {useContext} from 'react'; +import PropTypes from 'prop-types'; + +import * as MenuListUI from '../MenuList'; +import Box from '../Box'; +import PopoverCard from '../PopoverCard'; + +import {ComboboxContext} from './Combobox'; + +function ComboboxMenu({children, 'aria-label': ariaLabel}) { + const { + popover, + itemList, + statusMessage, + shouldShowStatusMessage, + } = useContext(ComboboxContext); + + return ( + + {shouldShowStatusMessage ? ( + + {statusMessage} + + ) : ( + + {children} + + )} + + ); +} + +ComboboxMenu.propTypes = { + /** + * A label describing the contents of the dropdown list + */ + 'aria-label': PropTypes.string.isRequired, +}; + +export default ComboboxMenu; diff --git a/src/Combobox/ComboboxMenuItem.js b/src/Combobox/ComboboxMenuItem.js new file mode 100644 index 00000000..833d42af --- /dev/null +++ b/src/Combobox/ComboboxMenuItem.js @@ -0,0 +1,48 @@ +import React, {useRef, useContext} from 'react'; + +import * as MenuListUI from '../MenuList'; + +import {ComboboxContext} from './Combobox'; + +function ComboboxMenuItem({value, icon, isDisabled, children}) { + const itemRef = useRef(); + const {itemList} = useContext(ComboboxContext); + const { + id, + select, + selected, + highlight, + useHighlighted, + clearHighlightedItem, + } = itemList.useItem({ + ref: itemRef, + value: value ?? children, + text: children, + disabled: isDisabled, + }); + + return ( + + + {icon && } + {children} + + + ); +} + +export default ComboboxMenuItem; diff --git a/src/Combobox/README.mdx b/src/Combobox/README.mdx new file mode 100644 index 00000000..35e46851 --- /dev/null +++ b/src/Combobox/README.mdx @@ -0,0 +1,97 @@ +--- +name: Combobox +menu: Components +--- + +import {Playground, Props} from 'docz'; +import {Combobox, ComboboxInput, ComboboxMenu, ComboboxMenuItem} from './'; + +# Combobox + +An accessible combobox component that can be used with any input element. + +Implemented based on the recommendations from the [WAI-ARIA Authoring Practices 1.2 working draft](https://www.w3.org/TR/wai-aria-practices-1.2/#combobox). + +## Examples + + + {() => { + const defaultOptions = [ + 'Apple', + 'Apricot', + 'Banana', + 'Blueberry', + 'Cherry', + 'Fig', + 'Feijoa', + 'Gooseberry', + 'Jackfruit', + 'Jujube', + 'Kiwi', + 'Kumquat', + 'Lingonberry', + 'Lychee', + 'Mandarin', + 'Mango', + 'Melon', + 'Nectarine', + 'Orange', + 'Papaya', + 'Passion Fruit', + 'Pear', + 'Persimmon', + 'Physalis', + 'Pineapple', + 'Plum', + 'Pomegranate', + 'Pomelo', + 'Raspberry', + 'Starfruit', + 'Strawberry', + 'Watermelon', + 'Wumpa', + ]; + const inputRef = React.useRef(); + const [value, setValue] = React.useState(''); + const [options, setOptions] = React.useState(defaultOptions); + React.useEffect(() => { + const filteredOptions = defaultOptions.filter(option => + option.toLowerCase().includes(value.toLowerCase()) + ); + setOptions(filteredOptions); + }, [value]); + return ( +
+ + { + setValue(selectedItem.value); + inputRef.current.focus(); + }} + > + setValue(e.target.value)} + /> + + {options.map(item => ( + + {item} + + ))} + + +
+ ); + }} +
+ +## Props + + diff --git a/src/Combobox/index.js b/src/Combobox/index.js new file mode 100644 index 00000000..d5d51362 --- /dev/null +++ b/src/Combobox/index.js @@ -0,0 +1,4 @@ +export {default as Combobox, ComboboxContext} from './Combobox'; +export {default as ComboboxInput} from './ComboboxInput'; +export {default as ComboboxMenu} from './ComboboxMenu'; +export {default as ComboboxMenuItem} from './ComboboxMenuItem';