UNPKG

@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
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]); }