index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  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. } & Record<string, any>
  26. export type ISelectProps = {
  27. className?: string
  28. wrapperClassName?: string
  29. renderTrigger?: (value: Item | null) => JSX.Element | null
  30. items?: Item[]
  31. defaultValue?: number | string
  32. disabled?: boolean
  33. onSelect: (value: Item) => void
  34. allowSearch?: boolean
  35. bgClassName?: string
  36. placeholder?: string
  37. overlayClassName?: string
  38. optionWrapClassName?: string
  39. optionClassName?: string
  40. hideChecked?: boolean
  41. notClearable?: boolean
  42. renderOption?: ({
  43. item,
  44. selected,
  45. }: {
  46. item: Item
  47. selected: boolean
  48. }) => React.ReactNode
  49. }
  50. const Select: FC<ISelectProps> = ({
  51. className,
  52. items = defaultItems,
  53. defaultValue = 1,
  54. disabled = false,
  55. onSelect,
  56. allowSearch = true,
  57. bgClassName = 'bg-gray-100',
  58. overlayClassName,
  59. optionClassName,
  60. renderOption,
  61. }) => {
  62. const [query, setQuery] = useState('')
  63. const [open, setOpen] = useState(false)
  64. const [selectedItem, setSelectedItem] = useState<Item | null>(null)
  65. useEffect(() => {
  66. let defaultSelect = null
  67. const existed = items.find((item: Item) => item.value === defaultValue)
  68. if (existed)
  69. defaultSelect = existed
  70. setSelectedItem(defaultSelect)
  71. // eslint-disable-next-line react-hooks/exhaustive-deps
  72. }, [defaultValue])
  73. const filteredItems: Item[]
  74. = query === ''
  75. ? items
  76. : items.filter((item) => {
  77. return item.name.toLowerCase().includes(query.toLowerCase())
  78. })
  79. return (
  80. <Combobox
  81. as="div"
  82. disabled={disabled}
  83. value={selectedItem}
  84. className={className}
  85. onChange={(value: Item) => {
  86. if (!disabled) {
  87. setSelectedItem(value)
  88. setOpen(false)
  89. onSelect(value)
  90. }
  91. }}>
  92. <div className={classNames('relative')}>
  93. <div className='group text-gray-800'>
  94. {allowSearch
  95. ? <Combobox.Input
  96. 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 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
  97. onChange={(event) => {
  98. if (!disabled)
  99. setQuery(event.target.value)
  100. }}
  101. displayValue={(item: Item) => item?.name}
  102. />
  103. : <Combobox.Button onClick={
  104. () => {
  105. if (!disabled)
  106. setOpen(!open)
  107. }
  108. } className={classNames(`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`, optionClassName)}>
  109. <div className='w-0 grow text-left truncate' title={selectedItem?.name}>{selectedItem?.name}</div>
  110. </Combobox.Button>}
  111. <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={
  112. () => {
  113. if (!disabled)
  114. setOpen(!open)
  115. }
  116. }>
  117. {open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
  118. </Combobox.Button>
  119. </div>
  120. {(filteredItems.length > 0 && open) && (
  121. <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}`}>
  122. {filteredItems.map((item: Item) => (
  123. <Combobox.Option
  124. key={item.value}
  125. value={item}
  126. className={({ active }: { active: boolean }) =>
  127. classNames(
  128. 'relative cursor-default select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700',
  129. active ? 'bg-gray-100' : '',
  130. optionClassName,
  131. )
  132. }
  133. >
  134. {({ /* active, */ selected }) => (
  135. <>
  136. {renderOption
  137. ? renderOption({ item, selected })
  138. : (
  139. <>
  140. <span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
  141. {selected && (
  142. <span
  143. className={classNames(
  144. 'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
  145. )}
  146. >
  147. <CheckIcon className="h-5 w-5" aria-hidden="true" />
  148. </span>
  149. )}
  150. </>
  151. )}
  152. </>
  153. )}
  154. </Combobox.Option>
  155. ))}
  156. </Combobox.Options>
  157. )}
  158. </div>
  159. </Combobox >
  160. )
  161. }
  162. const SimpleSelect: FC<ISelectProps> = ({
  163. className,
  164. wrapperClassName = '',
  165. renderTrigger,
  166. items = defaultItems,
  167. defaultValue = 1,
  168. disabled = false,
  169. onSelect,
  170. placeholder,
  171. optionWrapClassName,
  172. optionClassName,
  173. hideChecked,
  174. notClearable,
  175. renderOption,
  176. }) => {
  177. const { t } = useTranslation()
  178. const localPlaceholder = placeholder || t('common.placeholder.select')
  179. const [selectedItem, setSelectedItem] = useState<Item | null>(null)
  180. useEffect(() => {
  181. let defaultSelect = null
  182. const existed = items.find((item: Item) => item.value === defaultValue)
  183. if (existed)
  184. defaultSelect = existed
  185. setSelectedItem(defaultSelect)
  186. // eslint-disable-next-line react-hooks/exhaustive-deps
  187. }, [defaultValue])
  188. return (
  189. <Listbox
  190. value={selectedItem}
  191. onChange={(value: Item) => {
  192. if (!disabled) {
  193. setSelectedItem(value)
  194. onSelect(value)
  195. }
  196. }}
  197. >
  198. <div className={classNames('group/simple-select relative h-9', wrapperClassName)}>
  199. {renderTrigger && <Listbox.Button className='w-full'>{renderTrigger(selectedItem)}</Listbox.Button>}
  200. {!renderTrigger && (
  201. <Listbox.Button className={classNames(`flex items-center w-full h-full rounded-lg border-0 bg-gray-100 pl-3 pr-10 sm:text-sm sm:leading-6 focus-visible:outline-none focus-visible:bg-gray-200 group-hover/simple-select:bg-state-base-hover-alt ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}>
  202. <span className={classNames('block truncate text-left system-sm-regular text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
  203. <span className="absolute inset-y-0 right-0 flex items-center pr-2">
  204. {(selectedItem && !notClearable)
  205. ? (
  206. <XMarkIcon
  207. onClick={(e) => {
  208. e.stopPropagation()
  209. setSelectedItem(null)
  210. onSelect({ name: '', value: '' })
  211. }}
  212. className="h-4 w-4 text-text-quaternary cursor-pointer"
  213. aria-hidden="false"
  214. />
  215. )
  216. : (
  217. <ChevronDownIcon
  218. className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
  219. aria-hidden="true"
  220. />
  221. )}
  222. </span>
  223. </Listbox.Button>
  224. )}
  225. {!disabled && (
  226. <Transition
  227. as={Fragment}
  228. leave="transition ease-in duration-100"
  229. leaveFrom="opacity-100"
  230. leaveTo="opacity-0"
  231. >
  232. <Listbox.Options className={classNames('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', optionWrapClassName)}>
  233. {items.map((item: Item) => (
  234. <Listbox.Option
  235. key={item.value}
  236. className={({ active }) =>
  237. classNames(
  238. `relative cursor-pointer select-none py-2 pl-3 pr-9 rounded-lg hover:bg-gray-100 text-gray-700 ${active ? 'bg-gray-100' : ''}`,
  239. optionClassName,
  240. )
  241. }
  242. value={item}
  243. disabled={disabled}
  244. >
  245. {({ /* active, */ selected }) => (
  246. <>
  247. {renderOption
  248. ? renderOption({ item, selected })
  249. : (<>
  250. <span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
  251. {selected && !hideChecked && (
  252. <span
  253. className={classNames(
  254. 'absolute inset-y-0 right-0 flex items-center pr-4 text-gray-700',
  255. )}
  256. >
  257. <CheckIcon className="h-5 w-5" aria-hidden="true" />
  258. </span>
  259. )}
  260. </>)}
  261. </>
  262. )}
  263. </Listbox.Option>
  264. ))}
  265. </Listbox.Options>
  266. </Transition>
  267. )}
  268. </div>
  269. </Listbox>
  270. )
  271. }
  272. type PortalSelectProps = {
  273. value: string | number
  274. onSelect: (value: Item) => void
  275. items: Item[]
  276. placeholder?: string
  277. renderTrigger?: (value?: Item) => JSX.Element | null
  278. triggerClassName?: string
  279. triggerClassNameFn?: (open: boolean) => string
  280. popupClassName?: string
  281. popupInnerClassName?: string
  282. readonly?: boolean
  283. hideChecked?: boolean
  284. }
  285. const PortalSelect: FC<PortalSelectProps> = ({
  286. value,
  287. onSelect,
  288. items,
  289. placeholder,
  290. renderTrigger,
  291. triggerClassName,
  292. triggerClassNameFn,
  293. popupClassName,
  294. popupInnerClassName,
  295. readonly,
  296. hideChecked,
  297. }) => {
  298. const { t } = useTranslation()
  299. const [open, setOpen] = useState(false)
  300. const localPlaceholder = placeholder || t('common.placeholder.select')
  301. const selectedItem = items.find(item => item.value === value)
  302. return (
  303. <PortalToFollowElem
  304. open={open}
  305. onOpenChange={setOpen}
  306. placement='bottom-start'
  307. offset={4}
  308. >
  309. <PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className='w-full'>
  310. {renderTrigger
  311. ? renderTrigger(selectedItem)
  312. : (
  313. <div
  314. className={classNames(`
  315. flex items-center justify-between px-2.5 h-9 rounded-lg border-0 bg-gray-100 text-sm ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}
  316. `, triggerClassName, triggerClassNameFn?.(open))}
  317. title={selectedItem?.name}
  318. >
  319. <span
  320. className={`
  321. grow truncate
  322. ${!selectedItem?.name && 'text-gray-400'}
  323. `}
  324. >
  325. {selectedItem?.name ?? localPlaceholder}
  326. </span>
  327. <ChevronDownIcon className='shrink-0 h-4 w-4 text-gray-400' />
  328. </div>
  329. )}
  330. </PortalToFollowElemTrigger>
  331. <PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
  332. <div
  333. className={classNames('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', popupInnerClassName)}
  334. >
  335. {items.map((item: Item) => (
  336. <div
  337. key={item.value}
  338. className={`
  339. flex items-center justify-between px-2.5 h-9 cursor-pointer rounded-lg hover:bg-gray-100 text-gray-700
  340. ${item.value === value && 'bg-gray-100'}
  341. `}
  342. title={item.name}
  343. onClick={() => {
  344. onSelect(item)
  345. setOpen(false)
  346. }}
  347. >
  348. <span
  349. className='w-0 grow truncate'
  350. title={item.name}
  351. >
  352. {item.name}
  353. </span>
  354. {!hideChecked && item.value === value && (
  355. <CheckIcon className='shrink-0 h-4 w-4 text-text-accent' />
  356. )}
  357. </div>
  358. ))}
  359. </div>
  360. </PortalToFollowElemContent>
  361. </PortalToFollowElem>
  362. )
  363. }
  364. export { SimpleSelect, PortalSelect }
  365. export default React.memo(Select)