336 lines
9.9 KiB
TypeScript

import React, { useState, useRef, useEffect, useMemo } from "react"
import { Search, RotateCw, ChevronDown } from "lucide-react"
import { LoadingIndicator } from "./LoadingIndicator"
import { Empty } from "antd"
export interface SelectOption {
label: string | JSX.Element
value: string
}
interface SelectProps {
options: SelectOption[]
value?: string
onChange: (option: SelectOption) => void
placeholder?: string
onRefresh?: () => void
className?: string
dropdownClassName?: string
optionClassName?: string
searchClassName?: string
disabled?: boolean
isLoading?: boolean
loadingText?: string
filterOption?: (input: string, option: SelectOption) => boolean
}
export const PageAssistSelect: React.FC<SelectProps> = ({
options = [],
value,
onChange,
placeholder = "Select an option",
onRefresh,
className = "",
dropdownClassName = "",
optionClassName = "",
searchClassName = "",
disabled = false,
isLoading = false,
loadingText = "Loading...",
filterOption
}) => {
const [isOpen, setIsOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const optionsContainerRef = useRef<HTMLDivElement>(null)
const [activeIndex, setActiveIndex] = useState(-1)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
useEffect(() => {
try {
if (isOpen && optionsContainerRef.current && value) {
const selectedOptionElement = optionsContainerRef.current.querySelector(
`[data-value="${value}"]`
)
if (selectedOptionElement) {
selectedOptionElement.scrollIntoView({ block: "nearest" })
}
}
} catch (error) {
console.error("Error scrolling to selected option:", error)
}
}, [isOpen, value])
useEffect(() => {
if (!options) return
const filtered = options.filter((option) => {
if (!searchTerm) return true
if (filterOption) {
return filterOption(searchTerm, option)
}
if (typeof option.label === "string") {
return option.label.toLowerCase().includes(searchTerm.toLowerCase())
}
if (React.isValidElement(option.label)) {
const textContent = extractTextFromJSX(option.label)
return textContent.toLowerCase().includes(searchTerm.toLowerCase())
}
return false
})
setFilteredOptions(filtered)
setActiveIndex(-1)
}, [searchTerm, options, filterOption])
const extractTextFromJSX = (element: React.ReactElement): string => {
if (typeof element.props.children === "string") {
return element.props.children
}
if (Array.isArray(element.props.children)) {
return element.props.children
.map((child) => {
if (typeof child === "string") return child
if (React.isValidElement(child)) return extractTextFromJSX(child)
return ""
})
.join(" ")
}
if (React.isValidElement(element.props.children)) {
return extractTextFromJSX(element.props.children)
}
return ""
}
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation()
onRefresh?.()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled || isLoading) return
switch (e.key) {
case "Enter":
if (isOpen && activeIndex >= 0) {
e.preventDefault()
const selectedOption = filteredOptions[activeIndex]
if (selectedOption) {
onChange(selectedOption)
setIsOpen(false)
setSearchTerm("")
}
} else {
setIsOpen(!isOpen)
}
break
case " ":
if (!isOpen) {
e.preventDefault()
setIsOpen(true)
}
break
case "Escape":
setIsOpen(false)
break
case "ArrowDown":
e.preventDefault()
if (!isOpen) {
setIsOpen(true)
} else {
setActiveIndex((prev) =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
)
}
break
case "ArrowUp":
e.preventDefault()
setActiveIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
}
}
const defaultSelectClass = `
flex items-center justify-between p-2.5 rounded-lg border
${disabled || isLoading ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
${isOpen ? "ring-2 ring-blue-500" : ""}
bg-transparent border-gray-300 text-gray-900
transition-all duration-200
dark:text-white
dark:border-[#353534]
bg-white dark:bg-[#171717]
`
const defaultDropdownClass = `
absolute z-50 w-full mt-1 bg-white dark:bg-[#1e1e1f] dark:text-white rounded-lg shadow-lg
border border-gray-200 dark:border-[#353534]
`
const defaultSearchClass = `
w-full pl-8 pr-8 py-1.5 rounded-md
bg-gray-50 border border-gray-200
focus:outline-none focus:ring-2 focus:ring-gray-100
text-gray-900
dark:bg-[#1e1e1f] dark:text-white
dark:border-[#353534]
dark:focus:ring-gray-700
dark:focus:border-gray-700
dark:placeholder-gray-400
dark:bg-opacity-90
dark:hover:bg-opacity-100
dark:focus:bg-opacity-100
dark:hover:border-gray-700
dark:hover:bg-[#2a2a2b]
dark:focus:bg-[#2a2a2b]
`
const defaultOptionClass = `
p-2 cursor-pointer transition-colors duration-150
`
const selectedOption = useMemo(() => {
if (!value || !options) return null
return options?.find((opt) => opt.value === value)
}, [value, options])
if (!options) {
return (
<div className={`relative w-full ${className}`}>
<div className={`${defaultSelectClass} ${className}`}>
<LoadingIndicator />
<span>{loadingText}</span>
</div>
</div>
)
}
return (
<div ref={containerRef} className={`relative w-full ${className}`}>
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls="select-dropdown"
aria-label={placeholder}
tabIndex={disabled ? -1 : 0}
onClick={() => !disabled && !isLoading && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
className={`${defaultSelectClass} ${className}`}>
<span className="!truncate flex items-center gap-2 select-none">
{isLoading && <LoadingIndicator />}
{isLoading ? (
loadingText
) : selectedOption ? (
selectedOption.label
) : (
<span className="dark:text-gray-500 font-semibold text-[14px]">
{placeholder}
</span>
)}
</span>
<ChevronDown
aria-hidden="true"
className={`w-4 h-4 transition-transform duration-200 ${
isOpen ? "transform rotate-180" : ""
}`}
/>
</div>
{isOpen && (
<div
id="select-dropdown"
role="listbox"
className={`${defaultDropdownClass} ${dropdownClassName}`}>
<div className="p-2 border-b border-gray-200 dark:border-[#353534]">
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
className={`${defaultSearchClass} ${searchClassName}`}
disabled={isLoading}
aria-label="Search options"
/>
<Search
aria-hidden="true"
className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
/>
{onRefresh && (
<button
onClick={handleRefresh}
disabled={isLoading}
aria-label="Refresh options"
className={`absolute right-2 top-1/2 transform -translate-y-1/2
hover:text-blue-500 dark:hover:text-blue-400 transition-colors duration-200
${isLoading ? "cursor-not-allowed opacity-50" : ""}`}>
<RotateCw
aria-hidden="true"
className={`w-4 h-4 dark:text-gray-400 ${isLoading ? "animate-spin" : ""}`}
/>
</button>
)}{" "}
</div>
</div>
<div
ref={optionsContainerRef}
className="max-h-60 overflow-y-auto custom-scrollbar">
{isLoading ? (
<div className="p-4 text-center text-gray-500 flex items-center justify-center gap-2">
<LoadingIndicator />
<span>{loadingText}</span>
</div>
) : filteredOptions.length === 0 ? (
<div className="p-6">
<Empty />
</div>
) : (
filteredOptions.map((option, index) => (
<div
key={option.value}
role="option"
aria-selected={value === option.value}
data-value={option.value}
onClick={() => {
onChange(option)
setIsOpen(false)
setSearchTerm("")
}}
className={`
${defaultOptionClass}
${value === option.value ? "bg-blue-50 dark:bg-[#262627]" : "hover:bg-gray-100 dark:hover:bg-[#272728]"}
${activeIndex === index ? "bg-gray-100 dark:bg-[#272728]" : ""}
${optionClassName}`}>
{option.label}
</div>
))
)}
</div>
</div>
)}
</div>
)
}