import * as React from 'react' import cx from 'classnames' import { useEffect, useState, useRef, } from '@wordpress/element' import { Button, Icon, TextHighlight, Popover, } from '@wordpress/components' import { plus as plusIcon, search as searchIcon, } from '@wordpress/icons' import { FixedSizeList as List, // @ts-expect-error } from 'react-window' import { ActionInput, ActionInputProps, } from '@ska/components' import './style.scss' export interface SearchResult { id: string | number title: string content: string [key: string]: any } interface ResultsListProps { searchTerm: string results: SearchResult[] onSelect: SearchProps['onSelect'] focusSearch: () => void getResultTitle: (result: SearchResult) => string getResultContent: (result: SearchResult) => string getResultIcon: (result: SearchResult) => React.JSX.Element | null virtualized?: boolean } export interface SearchProps { className?: string onSelect: (result: SearchResult) => void onPaste?: (e: React.ClipboardEvent) => void getResults?: (search: string) => SearchResult[] fetchResults?: (search: string) => Promise actionInputProps?: Partial getResultTitle?: ResultsListProps['getResultTitle'] getResultContent?: ResultsListProps['getResultContent'] getResultIcon?: ResultsListProps['getResultIcon'] virtualized?: boolean } const Search: React.FC = ({ className, actionInputProps, getResults, fetchResults, onSelect, onPaste, getResultTitle = ({title}) => title, getResultContent = ({content}) => content, getResultIcon = () => plusIcon, virtualized = false, }) => { const [searchTerm, setSearchTerm] = useState('') const [lastSearch, setLastSearch] = useState('') const [results, setResults] = useState([]) const [resultsVisible, setResultsVisible] = useState(false) const searchRef = useRef(null) const resultsRef = useRef(null) const getRef = useRef(getResults) const fetchRef = useRef(fetchResults) useEffect(() => { if(!searchTerm) { setResults([]) return } if(getRef.current) { setResults(getRef.current(searchTerm)) setResultsVisible(true) } if(fetchRef.current) { fetchRef.current(searchTerm).then(setResults) } }, [searchTerm]) const focusFirstResult = () => { if(resultsRef.current?.querySelector('button')) { resultsRef.current.querySelector('button')?.focus() return true } return false } return (
setSearchTerm('')} onSubmit={() => { setLastSearch(searchTerm) if(results[0]) { onSelect(results[0]) setSearchTerm('') } }} onFocus={() => { if(searchTerm && !resultsVisible) { setResultsVisible(true) } }} onKeyDown={(e: React.KeyboardEvent) => { if(e.key === 'ArrowUp' && !searchTerm && lastSearch) { setSearchTerm(lastSearch) return } switch(e.key) { case 'Escape': if(results.length) { setResultsVisible(false) } else if(searchTerm) { setLastSearch(searchTerm) setSearchTerm('') } e.preventDefault() break case 'ArrowUp': case 'ArrowDown': if(focusFirstResult()) { e.preventDefault() } break case 'Home': case 'End': e.stopPropagation() break case 'z': if(e.ctrlKey) { if(lastSearch) { setSearchTerm(lastSearch) } e.preventDefault() e.stopPropagation() // Prevents whole block editor doing an undo } break } }} {...actionInputProps} inputProps={{ autoComplete: 'off', role: 'searchbox', // type=search doesn't have same styles as type=text ...actionInputProps.inputProps, }} /> {results.length > 0 && resultsVisible && <> { setSearchTerm('') onSelect(result) }} focusSearch={() => { (searchRef?.current?.querySelector('input[type="text"]') as HTMLInputElement)?.focus() }} getResultTitle={getResultTitle} getResultContent={getResultContent} getResultIcon={getResultIcon} virtualized={virtualized} /> }
) } const focusResult = (ref: {current: HTMLDivElement | null}, direction: number = 1) => { const sibling = direction > 0 ? 'nextElementSibling' : 'previousElementSibling' if( ref && ref.current && ref.current.ownerDocument.activeElement && ref.current.ownerDocument.activeElement.matches('.ska-search__results__result') && ref.current.ownerDocument.activeElement[sibling] && ref.current.ownerDocument.activeElement[sibling]?.matches('.ska-search__results__result') ) { (ref.current.ownerDocument.activeElement[sibling] as HTMLButtonElement).focus() return true } return false } const ResultsList: React.FC = ({ searchTerm, results, onSelect, focusSearch, getResultTitle, getResultContent, getResultIcon, virtualized = false, }) => { const resultsContainerRef = useRef(null) if(!results.length) { return null } const onKeyDown = (e: React.KeyboardEvent) => { switch(e.key) { case 'Escape': focusSearch() e.preventDefault() break case 'ArrowUp': if(!focusResult(resultsContainerRef, -1)) { focusSearch() } e.preventDefault() break case 'ArrowDown': if(!focusResult(resultsContainerRef, 1)) { focusSearch() } e.preventDefault() break } } const renderResult = (result: SearchResult, props: any = {}) => { const icon = getResultIcon(result) const title = getResultTitle(result) const content = getResultContent(result) return ( ) } if(virtualized) { const VirtualizedRow = ({index, style}) => { const result = results[index] return renderResult(result, {style}) } return ( {VirtualizedRow} ) } return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions
{results.map(result => renderResult(result))}
) } export default Search