index.tsx 13 KB

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