index.tsx 14 KB

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