index.tsx 10 KB

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