This repository has been archived by the owner on Jun 5, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
5b49611
commit 9b31104
Showing
6 changed files
with
494 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<ComboboxContext.Provider | ||
value={{ | ||
popover: { | ||
...popover, | ||
isOpen: isMenuOpen, | ||
toggle, | ||
open, | ||
close, | ||
}, | ||
getInputProps, | ||
itemList, | ||
statusMessage, | ||
shouldShowStatusMessage, | ||
}} | ||
> | ||
{children} | ||
<Portal> | ||
<Status as={VisuallyHidden} id={`${id}-a11y-hints`}> | ||
{isMenuOpen && statusMessage} | ||
</Status> | ||
</Portal> | ||
</ComboboxContext.Provider> | ||
); | ||
} | ||
|
||
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<Component | ||
{...otherProps} | ||
as={forwardedAs} | ||
{...refs} | ||
{...getInputProps({onFocus, onKeyDown})} | ||
/> | ||
); | ||
}); | ||
|
||
export default ComboboxInput; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<PopoverCard | ||
renderInPlace | ||
renderWhenClosed | ||
isOpen={popover.isOpen} | ||
{...popover.props} | ||
ref={popover.setRef} | ||
arrow={popover.arrow} | ||
onUpdatePopover={popover.update} | ||
> | ||
{shouldShowStatusMessage ? ( | ||
<Box px="s" py="xs"> | ||
{statusMessage} | ||
</Box> | ||
) : ( | ||
<MenuListUI.Wrapper | ||
role="listbox" | ||
aria-label={ariaLabel} | ||
id={itemList.listId} | ||
tabIndex="-1" | ||
> | ||
{children} | ||
</MenuListUI.Wrapper> | ||
)} | ||
</PopoverCard> | ||
); | ||
} | ||
|
||
ComboboxMenu.propTypes = { | ||
/** | ||
* A label describing the contents of the dropdown list | ||
*/ | ||
'aria-label': PropTypes.string.isRequired, | ||
}; | ||
|
||
export default ComboboxMenu; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<MenuListUI.Item | ||
ref={itemRef} | ||
id={id} | ||
role="option" | ||
aria-selected={selected ? 'true' : null} | ||
aria-disabled={isDisabled ? 'true' : null} | ||
onClick={select} | ||
onMouseEnter={highlight} | ||
onMouseLeave={clearHighlightedItem} | ||
> | ||
<MenuListUI.Link | ||
forwardedAs="span" | ||
isActive={selected} | ||
isDisabled={isDisabled} | ||
isHighlighted={useHighlighted()} | ||
> | ||
{icon && <MenuListUI.ItemIcon name={icon} />} | ||
{children} | ||
</MenuListUI.Link> | ||
</MenuListUI.Item> | ||
); | ||
} | ||
|
||
export default ComboboxMenuItem; |
Oops, something went wrong.