@base-ui/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
161 lines (154 loc) • 5.91 kB
JavaScript
;
'use client';
var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.useTypeahead = useTypeahead;
var React = _interopRequireWildcard(require("react"));
var _useStableCallback = require("@base-ui/utils/useStableCallback");
var _useIsoLayoutEffect = require("@base-ui/utils/useIsoLayoutEffect");
var _useTimeout = require("@base-ui/utils/useTimeout");
var _utils = require("../utils");
/**
* Provides a matching callback that can be used to focus an item as the user
* types, often used in tandem with `useListNavigation()`.
* @see https://floating-ui.com/docs/useTypeahead
*/
function useTypeahead(context, props) {
const store = 'rootStore' in context ? context.rootStore : context;
const dataRef = store.context.dataRef;
const open = store.useState('open');
const {
listRef,
activeIndex,
onMatch: onMatchProp,
onTypingChange,
enabled = true,
resetMs = 750,
selectedIndex = null
} = props;
const timeout = (0, _useTimeout.useTimeout)();
const stringRef = React.useRef('');
const prevIndexRef = React.useRef(selectedIndex ?? activeIndex ?? -1);
const matchIndexRef = React.useRef(null);
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
if (!open && selectedIndex !== null) {
return;
}
timeout.clear();
matchIndexRef.current = null;
if (stringRef.current !== '') {
stringRef.current = '';
}
}, [open, selectedIndex, timeout]);
(0, _useIsoLayoutEffect.useIsoLayoutEffect)(() => {
// Sync arrow key navigation but not typeahead navigation.
if (open && stringRef.current === '') {
prevIndexRef.current = selectedIndex ?? activeIndex ?? -1;
}
}, [open, selectedIndex, activeIndex]);
const setTypingChange = (0, _useStableCallback.useStableCallback)(value => {
if (value) {
if (!dataRef.current.typing) {
dataRef.current.typing = value;
onTypingChange?.(value);
}
} else if (dataRef.current.typing) {
dataRef.current.typing = value;
onTypingChange?.(value);
}
});
const onKeyDown = (0, _useStableCallback.useStableCallback)(event => {
function getMatchingIndex(list, orderedList, string) {
const str = orderedList.find(text => text?.toLocaleLowerCase().indexOf(string.toLocaleLowerCase()) === 0);
return str ? list.indexOf(str) : -1;
}
const listContent = listRef.current;
if (stringRef.current.length > 0 && stringRef.current[0] !== ' ') {
if (getMatchingIndex(listContent, listContent, stringRef.current) === -1) {
setTypingChange(false);
} else if (event.key === ' ') {
(0, _utils.stopEvent)(event);
}
}
if (listContent == null ||
// Character key.
event.key.length !== 1 ||
// Modifier key.
event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (open && event.key !== ' ') {
(0, _utils.stopEvent)(event);
setTypingChange(true);
}
// Capture whether this is a new typing session before mutating the string.
const isNewSession = stringRef.current === '';
if (isNewSession) {
prevIndexRef.current = selectedIndex ?? activeIndex ?? -1;
}
// Bail out if the list contains a word like "llama" or "aaron". TODO:
// allow it in this case, too.
const allowRapidSuccessionOfFirstLetter = listContent.every(text => text ? text[0]?.toLocaleLowerCase() !== text[1]?.toLocaleLowerCase() : true);
// Allows the user to cycle through items that start with the same letter
// in rapid succession.
if (allowRapidSuccessionOfFirstLetter && stringRef.current === event.key) {
stringRef.current = '';
prevIndexRef.current = matchIndexRef.current;
}
stringRef.current += event.key;
timeout.start(resetMs, () => {
stringRef.current = '';
prevIndexRef.current = matchIndexRef.current;
setTypingChange(false);
});
// Compute the starting index for this search.
// If this is a new typing session (string is empty), base it on the current
// selection/active item; otherwise continue from the last matched index.
const prevIndex = isNewSession ? selectedIndex ?? activeIndex ?? -1 : prevIndexRef.current;
const index = getMatchingIndex(listContent, [...listContent.slice((prevIndex || 0) + 1), ...listContent.slice(0, (prevIndex || 0) + 1)], stringRef.current);
if (index !== -1) {
onMatchProp?.(index);
matchIndexRef.current = index;
} else if (event.key !== ' ') {
stringRef.current = '';
setTypingChange(false);
}
});
const onBlur = (0, _useStableCallback.useStableCallback)(event => {
const next = event.relatedTarget;
const currentDomReferenceElement = store.select('domReferenceElement');
const currentFloatingElement = store.select('floatingElement');
const withinReference = (0, _utils.contains)(currentDomReferenceElement, next);
const withinFloating = (0, _utils.contains)(currentFloatingElement, next);
// Keep the session if focus moves within the composite (reference <-> floating).
if (withinReference || withinFloating) {
return;
}
// End the current typing session when focus leaves the composite entirely.
timeout.clear();
stringRef.current = '';
prevIndexRef.current = matchIndexRef.current;
setTypingChange(false);
});
const reference = React.useMemo(() => ({
onKeyDown,
onBlur
}), [onKeyDown, onBlur]);
const floating = React.useMemo(() => {
return {
onKeyDown,
onKeyUp(event) {
if (event.key === ' ') {
setTypingChange(false);
}
},
onBlur
};
}, [onKeyDown, onBlur, setTypingChange]);
return React.useMemo(() => enabled ? {
reference,
floating
} : {}, [enabled, reference, floating]);
}