@primer/react
Version:
An implementation of GitHub's Primer Design System using React
100 lines (92 loc) • 4.28 kB
JavaScript
;
var index = require('../../node_modules/@github/combobox-nav/dist/index.js');
var React = require('react');
var useId = require('../../hooks/useId.js');
/**
* Lightweight hook wrapper around the GitHub `Combobox` class from `@github/combobox-nav`.
* With this hook, keyboard navigation through suggestions is automatically handled and
* accessibility attributes are added.
*
* `useCombobox` will set nearly all necessary attributes by effect, but you **must** set
* `role="option"` on list items in order for them to be 'seen' by the combobox. Style the
* currently highlighted option with the `[aria-selected="true"]` selector.
*/
const useCombobox = ({
isOpen,
listElement: list,
inputElement: input,
onCommit: externalOnCommit,
options,
tabInsertsSuggestions = false,
defaultFirstOption = false
}) => {
const id = useId.useId();
const optionIdPrefix = `combobox-${id}__option`;
const isOpenRef = React.useRef(isOpen);
const [comboboxInstance, setComboboxInstance] = React.useState(null);
/** Get all option element instances. */
const getOptionElements = React.useCallback(() => {
var _list$querySelectorAl;
return [...((_list$querySelectorAl = list === null || list === void 0 ? void 0 : list.querySelectorAll('[role=option]')) !== null && _list$querySelectorAl !== void 0 ? _list$querySelectorAl : [])];
}, [list]);
const onCommit = React.useCallback(e => {
const nativeEvent = e;
const indexAttr = nativeEvent.target.getAttribute('data-combobox-list-index');
const index = indexAttr !== null ? parseInt(indexAttr, 10) : NaN;
const option = options[index];
if (option) externalOnCommit({
nativeEvent,
option
});
}, [options, externalOnCommit]);
// Prevent focus leaving the input when clicking an item
const onOptionMouseDown = React.useCallback(e => e.preventDefault(), []);
React.useEffect(function initializeComboboxInstance() {
if (input && list) {
if (!list.getAttribute('role')) list.setAttribute('role', 'listbox');
const cb = new index(input, list, {
tabInsertsSuggestions,
defaultFirstOption
});
// By using state instead of a ref here, we trigger the toggleKeyboardEventHandling
// effect. Otherwise we'd have to depend on isOpen in this effect to start the instance
// if it's initially open
setComboboxInstance(cb);
return () => {
cb.destroy();
setComboboxInstance(null);
};
}
}, [input, list, tabInsertsSuggestions, defaultFirstOption]);
React.useEffect(function toggleKeyboardEventHandling() {
const wasOpen = isOpenRef.current;
// It cannot be open if the instance hasn't yet been initialized
isOpenRef.current = isOpen && comboboxInstance !== null;
if (isOpen === wasOpen || !comboboxInstance) return;
if (isOpen) {
comboboxInstance.start();
} else {
comboboxInstance.stop();
}
}, [isOpen, comboboxInstance]);
React.useEffect(function bindCommitEvent() {
list === null || list === void 0 ? void 0 : list.addEventListener('combobox-commit', onCommit);
return () => list === null || list === void 0 ? void 0 : list.removeEventListener('combobox-commit', onCommit);
}, [onCommit, list]);
React.useLayoutEffect(() => {
const optionElements = getOptionElements();
// Ensure each option has a unique ID (required by the Combobox class), but respect user provided IDs
for (const [i, option] of optionElements.entries()) {
if (!option.id || option.id.startsWith(optionIdPrefix)) option.id = `${optionIdPrefix}-${i}`;
option.setAttribute('data-combobox-list-index', i.toString());
option.addEventListener('mousedown', onOptionMouseDown);
// the combobox class has a bug where it resets the default on navigate, but not on clearSelection
option.removeAttribute('data-combobox-option-default');
}
comboboxInstance === null || comboboxInstance === void 0 ? void 0 : comboboxInstance.clearSelection();
return () => {
for (const option of optionElements) option.removeEventListener('mousedown', onOptionMouseDown);
};
}, [getOptionElements, optionIdPrefix, options, comboboxInstance, onOptionMouseDown]);
};
exports.useCombobox = useCombobox;