335 lines
9.9 KiB
TypeScript
335 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-200 text-gray-900
|
|
transition-all duration-200
|
|
dark:text-white
|
|
dark:border-[#353534]
|
|
`
|
|
|
|
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>
|
|
)
|
|
}
|