import React, {
Fragment,
useState,
useCallback,
useEffect,
useRef
} from 'react'
import { Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import { useMountedEffect, useDebounce } from '../custom-hooks'
function Option({ isHighlighted, onClick, onMouseEnter, text, id }) {
const className = classNames(
'relative cursor-pointer select-none py-2 px-3 text-gray-900 dark:text-gray-300',
{
'bg-gray-100 dark:bg-gray-700': isHighlighted
}
)
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
{text}
)
}
function scrollTo(wrapper, id) {
if (wrapper) {
const el = wrapper.querySelector('#' + id)
if (el) {
el.scrollIntoView({ block: 'center' })
}
}
}
function optionId(index) {
return `plausible-combobox-option-${index}`
}
export default function PlausibleCombobox({
values,
fetchOptions,
singleOption,
isDisabled,
autoFocus,
freeChoice,
disabledOptions,
onSelect,
placeholder,
forceLoading,
className,
boxClass
}) {
const isEmpty = values.length === 0
const [options, setOptions] = useState([])
const [isLoading, setLoading] = useState(false)
const [isOpen, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [highlightedIndex, setHighlightedIndex] = useState(0)
const searchRef = useRef(null)
const containerRef = useRef(null)
const listRef = useRef(null)
const loading = isLoading || !!forceLoading
const visibleOptions = [...options]
if (
freeChoice &&
search.length > 0 &&
options.every((option) => option.value !== search)
) {
visibleOptions.push({ value: search, label: search, freeChoice: true })
}
const afterFetchOptions = useCallback((loadedOptions) => {
setLoading(false)
setHighlightedIndex(0)
setOptions(loadedOptions)
}, [])
const initialFetchOptions = useCallback(() => {
setLoading(true)
fetchOptions('').then(afterFetchOptions)
}, [fetchOptions, afterFetchOptions])
const searchOptions = useCallback(() => {
if (isOpen) {
setLoading(true)
fetchOptions(search).then(afterFetchOptions)
}
}, [search, isOpen, fetchOptions, afterFetchOptions])
const debouncedSearchOptions = useDebounce(searchOptions)
useEffect(() => {
if (isOpen) {
initialFetchOptions()
}
}, [isOpen, initialFetchOptions])
useMountedEffect(() => {
debouncedSearchOptions()
}, [search])
function highLight(index) {
let newIndex = index
if (index < 0) {
newIndex = visibleOptions.length - 1
} else if (index >= visibleOptions.length) {
newIndex = 0
}
setHighlightedIndex(newIndex)
scrollTo(listRef.current, optionId(newIndex))
}
function onKeyDown(e) {
if (e.key === 'Enter') {
if (!isOpen || loading || visibleOptions.length === 0) return null
selectOption(visibleOptions[highlightedIndex])
e.preventDefault()
}
if (e.key === 'Escape') {
if (!isOpen || loading) return null
setOpen(false)
searchRef.current?.focus()
e.preventDefault()
}
if (e.key === 'ArrowDown') {
if (isOpen) {
highLight(highlightedIndex + 1)
} else {
setOpen(true)
}
}
if (e.key === 'ArrowUp') {
if (isOpen) {
highLight(highlightedIndex - 1)
} else {
setOpen(true)
}
}
}
function isOptionDisabled(option) {
const optionAlreadySelected = values.some(
(val) => val.value === option.value
)
const optionDisabled = (disabledOptions || []).some(
(val) => val?.value === option.value
)
return optionAlreadySelected || optionDisabled
}
function onInput(e) {
if (!isOpen) {
setOpen(true)
}
setSearch(e.target.value)
}
function toggleOpen() {
if (!isOpen) {
setOpen(true)
searchRef.current.focus()
} else {
setSearch('')
setOpen(false)
}
}
function selectOption(option) {
if (singleOption) {
onSelect([option])
} else {
searchRef.current.focus()
onSelect([...values, option])
}
setOpen(false)
setSearch('')
}
function removeOption(option, e) {
e.stopPropagation()
const newValues = values.filter((val) => val.value !== option.value)
onSelect(newValues)
searchRef.current.focus()
setOpen(false)
}
const handleClick = useCallback((e) => {
if (containerRef.current && containerRef.current.contains(e.target)) {
return
}
setSearch('')
setOpen(false)
}, [])
useEffect(() => {
document.addEventListener('mousedown', handleClick, false)
return () => {
document.removeEventListener('mousedown', handleClick, false)
}
}, [handleClick])
useEffect(() => {
if (singleOption && isEmpty && autoFocus) {
searchRef.current.focus()
}
}, [isEmpty, singleOption, autoFocus])
const searchBoxClass =
'border-none py-1 px-0 w-full inline-block rounded-md focus:outline-hidden focus:ring-0 text-sm'
const containerClass = classNames('relative w-full', {
[className]: !!className,
'opacity-30 cursor-default pointer-events-none': isDisabled
})
function renderSingleOptionContent() {
const itemSelected = values.length === 1
return (
{itemSelected && renderSingleSelectedItem()}
)
}
function renderSingleSelectedItem() {
if (search === '') {
return (
{values[0].label}
)
}
}
function renderMultiOptionContent() {
return (
<>
{values.map((value) => {
return (
{value.label}
removeOption(value, e)}
className="cursor-pointer font-bold ml-1"
>
×
)
})}
>
)
}
function renderDropDownContent() {
const matchesFound =
visibleOptions.length > 0 &&
visibleOptions.some((option) => !isOptionDisabled(option))
if (loading) {
return (
Loading options...
)
}
if (matchesFound) {
return visibleOptions
.filter((option) => !isOptionDisabled(option))
.map((option, i) => {
const text = option.freeChoice
? `Filter by '${option.label}'`
: option.label
return (