index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { Fragment, useEffect, useState } from 'react'
  4. import { Combobox, Listbox, Transition } from '@headlessui/react'
  5. import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
  6. import { useTranslation } from 'react-i18next'
  7. import classNames from '@/utils/classnames'
  8. import {
  9. PortalToFollowElem,
  10. PortalToFollowElemContent,
  11. PortalToFollowElemTrigger,
  12. } from '@/app/components/base/portal-to-follow-elem'
  13. const defaultItems = [
  14. { value: 1, name: 'option1' },
  15. { value: 2, name: 'option2' },
  16. { value: 3, name: 'option3' },
  17. { value: 4, name: 'option4' },
  18. { value: 5, name: 'option5' },
  19. { value: 6, name: 'option6' },
  20. { value: 7, name: 'option7' },
  21. ]
  22. export type Item = {
  23. value: number | string
  24. name: string
  25. }
  26. export type ISelectProps = {
  27. className?: string
  28. wrapperClassName?: string
  29. items?: Item[]
  30. defaultValue?: number | string
  31. disabled?: boolean
  32. onSelect: (value: Item) => void
  33. allowSearch?: boolean
  34. bgClassName?: string
  35. placeholder?: string
  36. overlayClassName?: string
  37. optionClassName?: string
  38. }
  39. const Select: FC<ISelectProps> = ({
  40. className,
  41. items = defaultItems,
  42. defaultValue = 1,
  43. disabled = false,
  44. onSelect,
  45. allowSearch = true,
  46. bgClassName = 'bg-gray-100',
  47. overlayClassName,
  48. optionClassName,
  49. }) => {
  50. const [query, setQuery] = useState('')
  51. const [open, setOpen] = useState(false)
  52. const [selectedItem, setSelectedItem] = useState<Item | null>(null)
  53. useEffect(() => {
  54. let defaultSelect = null
  55. const existed = items.find((item: Item) => item.value === defaultValue)
  56. if (existed)
  57. defaultSelect = existed
  58. setSelectedItem(defaultSelect)
  59. }, [defaultValue])
  60. const filteredItems: Item[]
  61. = query === ''
  62. ? items
  63. : items.filter((item) => {
  64. return item.name.toLowerCase().includes(query.toLowerCase())
  65. })
  66. return (
  67. <Combobox
  68. as="div"
  69. disabled={disabled}
  70. value={selectedItem}
  71. className={className}
  72. onChange={(value: Item) => {
  73. if (!disabled) {
  74. setSelectedItem(value)
  75. setOpen(false)
  76. onSelect(value)
  77. }
  78. }}>
  79. <div className={classNames('relative')}>
  80. <div className='group text-gray-800'>
  81. {allowSearch
  82. ? <Combobox.Input
  83. className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 cursor-not-allowed`}
  84. onChange={(event) => {
  85. if (!disabled)
  86. setQuery(event.target.value)
  87. }}
  88. displayValue={(item: Item) => item?.name}
  89. />
  90. : <Combobox.Button onClick={
  91. () => {
  92. if (!disabled)
  93. setOpen(!open)
  94. }
  95. } className={classNames(optionClassName, `flex items-center h-9 w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200`)}>
  96. <div className='w-0 grow text-left truncate' title={selectedItem?.name}>{selectedItem?.name}</div>
  97. </Combobox.Button>}
  98. <Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none group-hover:bg-gray-200" onClick={
  99. () => {
  100. if (!disabled)
  101. setOpen(!open)
  102. }
  103. }>
  104. {open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
  105. </Combobox.Button>
  106. </div>
  107. {filteredItems.length > 0 && (
  108. <Combobox.Options className={`absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm ${overlayClassName}`}>
  109. {filteredItems.map((item: Item) => (
  110. <Combobox.Option
  111. key={item.value}
  112. value={item}
  113. className={({ active }: { active: boolean }) =>
  114. classNames(
  115. optionClassName,
  116. 'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700',
  117. active ? 'bg-gray-100' : '',
  118. )
  119. }
  120. >
  121. {({ /* active, */ selected }) => (
  122. <>
  123. <span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
  124. {selected && (
  125. <span
  126. className={classNames(
  127. 'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
  128. )}
  129. >
  130. <CheckIcon className="h-5 w-5" aria-hidden="true" />
  131. </span>
  132. )}
  133. </>
  134. )}
  135. </Combobox.Option>
  136. ))}
  137. </Combobox.Options>
  138. )}
  139. </div>
  140. </Combobox >
  141. )
  142. }
  143. const SimpleSelect: FC<ISelectProps> = ({
  144. className,
  145. wrapperClassName = '',
  146. items = defaultItems,
  147. defaultValue = 1,
  148. disabled = false,
  149. onSelect,
  150. placeholder,
  151. }) => {
  152. const { t } = useTranslation()
  153. const localPlaceholder = placeholder || t('common.placeholder.select')
  154. const [selectedItem, setSelectedItem] = useState<Item | null>(null)
  155. useEffect(() => {
  156. let defaultSelect = null
  157. const existed = items.find((item: Item) => item.value === defaultValue)
  158. if (existed)
  159. defaultSelect = existed
  160. setSelectedItem(defaultSelect)
  161. }, [defaultValue])
  162. return (
  163. <Listbox
  164. value={selectedItem}
  165. onChange={(value: Item) => {
  166. if (!disabled) {
  167. setSelectedItem(value)
  168. onSelect(value)
  169. }
  170. }}
  171. >
  172. <div className={`relative h-9 ${wrapperClassName}`}>
  173. <Listbox.Button className={`w-full h-full rounded-lg border-0 bg-gray-100 py-1.5 pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover:bg-gray-200 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'} ${className}`}>
  174. <span className={classNames('block truncate text-left', !selectedItem?.name && 'text-gray-400')}>{selectedItem?.name ?? localPlaceholder}</span>
  175. <span className="absolute inset-y-0 right-0 flex items-center pr-2">
  176. {selectedItem
  177. ? (
  178. <XMarkIcon
  179. onClick={(e) => {
  180. e.stopPropagation()
  181. setSelectedItem(null)
  182. onSelect({ name: '', value: '' })
  183. }}
  184. className="h-5 w-5 text-gray-400 cursor-pointer"
  185. aria-hidden="false"
  186. />
  187. )
  188. : (
  189. <ChevronDownIcon
  190. className="h-5 w-5 text-gray-400"
  191. aria-hidden="true"
  192. />
  193. )}
  194. </span>
  195. </Listbox.Button>
  196. {!disabled && (
  197. <Transition
  198. as={Fragment}
  199. leave="transition ease-in duration-100"
  200. leaveFrom="opacity-100"
  201. leaveTo="opacity-0"
  202. >
  203. <Listbox.Options className="absolute z-10 mt-1 px-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm">
  204. {items.map((item: Item) => (
  205. <Listbox.Option
  206. key={item.value}
  207. className={({ active }) =>
  208. `relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : ''
  209. }`
  210. }
  211. value={item}
  212. disabled={disabled}
  213. >
  214. {({ /* active, */ selected }) => (
  215. <>
  216. <span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
  217. {selected && (
  218. <span
  219. className={classNames(
  220. 'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
  221. )}
  222. >
  223. <CheckIcon className="h-5 w-5" aria-hidden="true" />
  224. </span>
  225. )}
  226. </>
  227. )}
  228. </Listbox.Option>
  229. ))}
  230. </Listbox.Options>
  231. </Transition>
  232. )}
  233. </div>
  234. </Listbox>
  235. )
  236. }
  237. type PortalSelectProps = {
  238. value: string | number
  239. onSelect: (value: Item) => void
  240. items: Item[]
  241. placeholder?: string
  242. popupClassName?: string
  243. }
  244. const PortalSelect: FC<PortalSelectProps> = ({
  245. value,
  246. onSelect,
  247. items,
  248. placeholder,
  249. popupClassName,
  250. }) => {
  251. const { t } = useTranslation()
  252. const [open, setOpen] = useState(false)
  253. const localPlaceholder = placeholder || t('common.placeholder.select')
  254. const selectedItem = items.find(item => item.value === value)
  255. return (
  256. <PortalToFollowElem
  257. open={open}
  258. onOpenChange={setOpen}
  259. placement='bottom-start'
  260. offset={4}
  261. >
  262. <PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} className='w-full'>
  263. <div
  264. className={`
  265. flex items-center justify-between px-2.5 h-9 rounded-lg border-0 bg-gray-100 text-sm cursor-pointer
  266. `}
  267. title={selectedItem?.name}
  268. >
  269. <span
  270. className={`
  271. grow truncate
  272. ${!selectedItem?.name && 'text-gray-400'}
  273. `}
  274. >
  275. {selectedItem?.name ?? localPlaceholder}
  276. </span>
  277. <ChevronDownIcon className='shrink-0 h-4 w-4 text-gray-400' />
  278. </div>
  279. </PortalToFollowElemTrigger>
  280. <PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
  281. <div
  282. className='px-1 py-1 max-h-60 overflow-auto rounded-md bg-white text-base shadow-lg border-gray-200 border-[0.5px] focus:outline-none sm:text-sm'
  283. >
  284. {items.map((item: Item) => (
  285. <div
  286. key={item.value}
  287. className={`
  288. flex items-center justify-between px-2.5 h-9 cursor-pointer rounded-lg hover:bg-gray-100 text-gray-700
  289. ${item.value === value && 'bg-gray-100'}
  290. `}
  291. title={item.name}
  292. onClick={() => {
  293. onSelect(item)
  294. setOpen(false)
  295. }}
  296. >
  297. <span
  298. className='w-0 grow truncate'
  299. title={item.name}
  300. >
  301. {item.name}
  302. </span>
  303. {item.value === value && (
  304. <CheckIcon className='shrink-0 h-4 w-4' />
  305. )}
  306. </div>
  307. ))}
  308. </div>
  309. </PortalToFollowElemContent>
  310. </PortalToFollowElem>
  311. )
  312. }
  313. export { SimpleSelect, PortalSelect }
  314. export default React.memo(Select)