import { ActionImpl, getListboxItemId, KBAR_LISTBOX, useKBar } from 'kbar'; import { usePointerMovedSinceMount } from 'kbar/lib/utils'; import * as React from 'react'; import { useVirtual } from 'react-virtual'; // From https://github.com/timc1/kbar/blob/main/src/KBarResults.tsx // TODO: Go back to KBarResults from kbar when https://github.com/timc1/kbar/issues/281 is fixed // Remember to remove dependency on react-virtual when removing this file const START_INDEX = 0; interface RenderParams { item: T; active: boolean; } interface KBarResultsProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any items: any[]; onRender: (params: RenderParams) => React.ReactElement; maxHeight?: number; } export const KBarResults = (props: KBarResultsProps) => { const activeRef = React.useRef(null); const parentRef = React.useRef(null); // store a ref to all items so we do not have to pass // them as a dependency when setting up event listeners. const itemsRef = React.useRef(props.items); itemsRef.current = props.items; const rowVirtualizer = useVirtual({ size: itemsRef.current.length, parentRef, }); const { query, search, currentRootActionId, activeIndex, options } = useKBar((state) => ({ search: state.searchQuery, currentRootActionId: state.currentRootActionId, activeIndex: state.activeIndex, })); React.useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === 'ArrowUp' || (event.ctrlKey && event.key === 'p')) { event.preventDefault(); query.setActiveIndex((index) => { let nextIndex = index > START_INDEX ? index - 1 : index; // avoid setting active index on a group if (typeof itemsRef.current[nextIndex] === 'string') { if (nextIndex === 0) { return index; } nextIndex -= 1; } return nextIndex; }); } else if (event.key === 'ArrowDown' || (event.ctrlKey && event.key === 'n')) { event.preventDefault(); query.setActiveIndex((index) => { let nextIndex = index < itemsRef.current.length - 1 ? index + 1 : index; // avoid setting active index on a group if (typeof itemsRef.current[nextIndex] === 'string') { if (nextIndex === itemsRef.current.length - 1) { return index; } nextIndex += 1; } return nextIndex; }); } else if (event.key === 'Enter') { event.preventDefault(); // storing the active dom element in a ref prevents us from // having to calculate the current action to perform based // on the `activeIndex`, which we would have needed to add // as part of the dependencies array. activeRef.current?.click(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [query]); // destructuring here to prevent linter warning to pass // entire rowVirtualizer in the dependencies array. const { scrollToIndex } = rowVirtualizer; React.useEffect(() => { scrollToIndex(activeIndex, { // ensure that if the first item in the list is a group // name and we are focused on the second item, to not // scroll past that group, hiding it. align: activeIndex <= 1 ? 'end' : 'auto', }); }, [activeIndex, scrollToIndex]); React.useEffect(() => { // TODO(tim): fix scenario where async actions load in // and active index is reset to the first item. i.e. when // users register actions and bust the `useRegisterActions` // cache, we won't want to reset their active index as they // are navigating the list. query.setActiveIndex( // avoid setting active index on a group typeof props.items[START_INDEX] === 'string' ? START_INDEX + 1 : START_INDEX ); }, [search, currentRootActionId, props.items, query]); const execute = React.useCallback( (ev: React.MouseEvent, item: RenderParams['item']) => { if (typeof item === 'string') { return; } // ActionImpl constructor copies all properties from action onto ActionImpl // so our url property is secretly there, but completely untyped // Preferably this change is upstreamed and ActionImpl has this // eslint-disable-next-line const url = (item as ActionImpl & { url?: string }).url; if (item.command) { item.command.perform(item); query.toggle(); } else if (url) { if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) { query.toggle(); } } else { query.setSearch(''); query.setCurrentRootAction(item.id); } options.callbacks?.onSelectAction?.(item); }, [query, options] ); const pointerMoved = usePointerMovedSinceMount(); return (
{rowVirtualizer.virtualItems.map((virtualRow) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const item = itemsRef.current[virtualRow.index] as ActionImpl & { url?: string; target?: React.HTMLAttributeAnchorTarget; }; // ActionImpl constructor copies all properties from action onto ActionImpl // so our url property is secretly there, but completely untyped // Preferably this change is upstreamed and ActionImpl has this const { target, url } = item; const handlers = typeof item !== 'string' && { onPointerMove: () => pointerMoved && activeIndex !== virtualRow.index && query.setActiveIndex(virtualRow.index), onPointerDown: () => query.setActiveIndex(virtualRow.index), onClick: (ev: React.MouseEvent) => execute(ev, item), }; const active = virtualRow.index === activeIndex; const childProps = { id: getListboxItemId(virtualRow.index), role: 'option', 'aria-selected': active, style: { position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${virtualRow.start}px)`, } as const, ...handlers, }; const renderedItem = React.cloneElement( props.onRender({ item, active, }), { ref: virtualRow.measureRef, } ); if (url) { return ( ) : null} {...childProps} > {renderedItem} ); } return (
) : null} {...childProps} > {renderedItem}
); })}
); };