@base-ui-components/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.
123 lines (121 loc) • 4.35 kB
JavaScript
import * as React from 'react';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useTimeout } from '@base-ui-components/utils/useTimeout';
import { stopEvent } from "../utils.js";
import { EMPTY_ARRAY } from "../../utils/constants.js";
/**
* 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
*/
export function useTypeahead(context, props) {
const store = 'rootStore' in context ? context.rootStore : context;
const open = store.useState('open');
const dataRef = store.context.dataRef;
const {
listRef,
activeIndex,
onMatch: onMatchProp,
onTypingChange,
enabled = true,
findMatch = null,
resetMs = 750,
ignoreKeys = EMPTY_ARRAY,
selectedIndex = null
} = props;
const timeout = useTimeout();
const stringRef = React.useRef('');
const prevIndexRef = React.useRef(selectedIndex ?? activeIndex ?? -1);
const matchIndexRef = React.useRef(null);
useIsoLayoutEffect(() => {
if (open) {
timeout.clear();
matchIndexRef.current = null;
stringRef.current = '';
}
}, [open, timeout]);
useIsoLayoutEffect(() => {
// Sync arrow key navigation but not typeahead navigation.
if (open && stringRef.current === '') {
prevIndexRef.current = selectedIndex ?? activeIndex ?? -1;
}
}, [open, selectedIndex, activeIndex]);
const setTypingChange = 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 = useStableCallback(event => {
function getMatchingIndex(list, orderedList, string) {
const str = findMatch ? findMatch(orderedList, string) : 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 === ' ') {
stopEvent(event);
}
}
if (listContent == null || ignoreKeys.includes(event.key) ||
// Character key.
event.key.length !== 1 ||
// Modifier key.
event.ctrlKey || event.metaKey || event.altKey) {
return;
}
if (open && event.key !== ' ') {
stopEvent(event);
setTypingChange(true);
}
// 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);
});
const prevIndex = 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 reference = React.useMemo(() => ({
onKeyDown
}), [onKeyDown]);
const floating = React.useMemo(() => {
return {
onKeyDown,
onKeyUp(event) {
if (event.key === ' ') {
setTypingChange(false);
}
}
};
}, [onKeyDown, setTypingChange]);
return React.useMemo(() => enabled ? {
reference,
floating
} : {}, [enabled, reference, floating]);
}