Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. 'use client'
  2. import type { FC } from 'react'
  3. import React, { useEffect, useRef, useState } from 'react'
  4. import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
  5. import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
  6. import Badge from '../badge/index'
  7. import { RiCheckLine, RiLoader4Line } 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) => React.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. isLoading?: boolean
  52. onOpenChange?: (open: boolean) => void
  53. }
  54. const Select: FC<ISelectProps> = ({
  55. className,
  56. items = defaultItems,
  57. defaultValue = 1,
  58. disabled = false,
  59. onSelect,
  60. allowSearch = true,
  61. bgClassName = 'bg-components-input-bg-normal',
  62. overlayClassName,
  63. optionClassName,
  64. renderOption,
  65. }) => {
  66. const [query, setQuery] = useState('')
  67. const [open, setOpen] = useState(false)
  68. const [selectedItem, setSelectedItem] = useState<Item | null>(null)
  69. useEffect(() => {
  70. let defaultSelect = null
  71. const existed = items.find((item: Item) => item.value === defaultValue)
  72. if (existed)
  73. defaultSelect = existed
  74. setSelectedItem(defaultSelect)
  75. }, [defaultValue])
  76. const filteredItems: Item[]
  77. = query === ''
  78. ? items
  79. : items.filter((item) => {
  80. return item.name.toLowerCase().includes(query.toLowerCase())
  81. })
  82. return (
  83. <Combobox
  84. as="div"
  85. disabled={disabled}
  86. value={selectedItem}
  87. className={className}
  88. onChange={(value: Item) => {
  89. if (!disabled) {
  90. setSelectedItem(value)
  91. setOpen(false)
  92. onSelect(value)
  93. }
  94. }}>
  95. <div className={classNames('relative')}>
  96. <div className='group text-text-secondary'>
  97. {allowSearch
  98. ? <ComboboxInput
  99. className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
  100. onChange={(event) => {
  101. if (!disabled)
  102. setQuery(event.target.value)
  103. }}
  104. displayValue={(item: Item) => item?.name}
  105. />
  106. : <ComboboxButton onClick={
  107. () => {
  108. if (!disabled)
  109. setOpen(!open)
  110. }
  111. } className={classNames(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6`, optionClassName)}>
  112. <div className='w-0 grow truncate text-left' title={selectedItem?.name}>{selectedItem?.name}</div>
  113. </ComboboxButton>}
  114. <ComboboxButton className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" onClick={
  115. () => {
  116. if (!disabled)
  117. setOpen(!open)
  118. }
  119. }>
  120. {open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
  121. </ComboboxButton>
  122. </div>
  123. {(filteredItems.length > 0 && open) && (
  124. <ComboboxOptions className={`absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm ${overlayClassName}`}>
  125. {filteredItems.map((item: Item) => (
  126. <ComboboxOption
  127. key={item.value}
  128. value={item}
  129. className={({ active }: { active: boolean }) =>
  130. classNames(
  131. 'relative cursor-default select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
  132. active ? 'bg-state-base-hover' : '',
  133. optionClassName,
  134. )
  135. }
  136. >
  137. {({ /* active, */ selected }) => (
  138. <>
  139. {renderOption
  140. ? renderOption({ item, selected })
  141. : (
  142. <>
  143. <span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
  144. {selected && (
  145. <span
  146. className={classNames(
  147. 'absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary',
  148. )}
  149. >
  150. <RiCheckLine className="h-4 w-4" aria-hidden="true" />
  151. </span>
  152. )}
  153. </>
  154. )}
  155. </>
  156. )}
  157. </ComboboxOption>
  158. ))}
  159. </ComboboxOptions>
  160. )}
  161. </div>
  162. </Combobox >
  163. )
  164. }
  165. const SimpleSelect: FC<ISelectProps> = ({
  166. className,
  167. wrapperClassName = '',
  168. renderTrigger,
  169. items = defaultItems,
  170. defaultValue = 1,
  171. disabled = false,
  172. onSelect,
  173. onOpenChange,
  174. placeholder,
  175. optionWrapClassName,
  176. optionClassName,
  177. hideChecked,
  178. notClearable,
  179. renderOption,
  180. isLoading = false,
  181. }) => {
  182. const { t } = useTranslation()
  183. const localPlaceholder = placeholder || t('common.placeholder.select')
  184. const [selectedItem, setSelectedItem] = useState<Item | null>(null)
  185. const [open, setOpen] = useState(false)
  186. useEffect(() => {
  187. let defaultSelect = null
  188. const existed = items.find((item: Item) => item.value === defaultValue)
  189. if (existed)
  190. defaultSelect = existed
  191. setSelectedItem(defaultSelect)
  192. }, [defaultValue])
  193. const listboxRef = useRef<HTMLDivElement>(null)
  194. return (
  195. <Listbox ref={listboxRef}
  196. value={selectedItem}
  197. onChange={(value: Item) => {
  198. if (!disabled) {
  199. setSelectedItem(value)
  200. onSelect(value)
  201. }
  202. }}
  203. >
  204. <div className={classNames('group/simple-select relative h-9', wrapperClassName)}>
  205. {renderTrigger && <ListboxButton className='w-full'>{renderTrigger(selectedItem)}</ListboxButton>}
  206. {!renderTrigger && (
  207. <ListboxButton onClick={() => {
  208. // get data-open, use setTimeout to ensure the attribute is set
  209. setTimeout(() => {
  210. if (listboxRef.current) {
  211. const isOpen = listboxRef.current.getAttribute('data-open') !== null
  212. setOpen(isOpen)
  213. onOpenChange?.(isOpen)
  214. }
  215. })
  216. }} className={classNames(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}>
  217. <span className={classNames('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
  218. <span className="absolute inset-y-0 right-0 flex items-center pr-2">
  219. {isLoading ? <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />
  220. : (selectedItem && !notClearable)
  221. ? (
  222. <XMarkIcon
  223. onClick={(e) => {
  224. e.stopPropagation()
  225. setSelectedItem(null)
  226. onSelect({ name: '', value: '' })
  227. }}
  228. className="h-4 w-4 cursor-pointer text-text-quaternary"
  229. aria-hidden="false"
  230. />
  231. )
  232. : (
  233. open ? (
  234. <ChevronUpIcon
  235. className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
  236. aria-hidden="true"
  237. />
  238. ) : (
  239. <ChevronDownIcon
  240. className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
  241. aria-hidden="true"
  242. />
  243. )
  244. )}
  245. </span>
  246. </ListboxButton>
  247. )}
  248. {(!disabled) && (
  249. <ListboxOptions className={classNames('absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm', optionWrapClassName)}>
  250. {items.map((item: Item) => (
  251. <ListboxOption
  252. key={item.value}
  253. className={
  254. classNames(
  255. 'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
  256. optionClassName,
  257. )
  258. }
  259. value={item}
  260. disabled={disabled}
  261. >
  262. {({ /* active, */ selected }) => (
  263. <>
  264. {renderOption
  265. ? renderOption({ item, selected })
  266. : (<>
  267. <span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
  268. {selected && !hideChecked && (
  269. <span
  270. className={classNames(
  271. 'absolute inset-y-0 right-0 flex items-center pr-4 text-text-accent',
  272. )}
  273. >
  274. <RiCheckLine className="h-4 w-4" aria-hidden="true" />
  275. </span>
  276. )}
  277. </>)}
  278. </>
  279. )}
  280. </ListboxOption>
  281. ))}
  282. </ListboxOptions>
  283. )}
  284. </div>
  285. </Listbox>
  286. )
  287. }
  288. type PortalSelectProps = {
  289. value: string | number
  290. onSelect: (value: Item) => void
  291. items: Item[]
  292. placeholder?: string
  293. installedValue?: string | number
  294. renderTrigger?: (value?: Item) => React.JSX.Element | null
  295. triggerClassName?: string
  296. triggerClassNameFn?: (open: boolean) => string
  297. popupClassName?: string
  298. popupInnerClassName?: string
  299. readonly?: boolean
  300. hideChecked?: boolean
  301. }
  302. const PortalSelect: FC<PortalSelectProps> = ({
  303. value,
  304. onSelect,
  305. items,
  306. placeholder,
  307. installedValue,
  308. renderTrigger,
  309. triggerClassName,
  310. triggerClassNameFn,
  311. popupClassName,
  312. popupInnerClassName,
  313. readonly,
  314. hideChecked,
  315. }) => {
  316. const { t } = useTranslation()
  317. const [open, setOpen] = useState(false)
  318. const localPlaceholder = placeholder || t('common.placeholder.select')
  319. const selectedItem = value ? items.find(item => item.value === value) : undefined
  320. return (
  321. <PortalToFollowElem
  322. open={open}
  323. onOpenChange={setOpen}
  324. placement='bottom-start'
  325. offset={4}
  326. >
  327. <PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className='w-full'>
  328. {renderTrigger
  329. ? renderTrigger(selectedItem)
  330. : (
  331. <div
  332. className={classNames(`
  333. group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2.5 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}
  334. `, triggerClassName, triggerClassNameFn?.(open))}
  335. title={selectedItem?.name}
  336. >
  337. <span
  338. className={`
  339. grow truncate text-text-secondary
  340. ${!selectedItem?.name && 'text-components-input-text-placeholder'}
  341. `}
  342. >
  343. {selectedItem?.name ?? localPlaceholder}
  344. </span>
  345. <div className='mx-0.5'>{installedValue && selectedItem && selectedItem.value !== installedValue && <Badge>{installedValue} {'->'} {selectedItem.value} </Badge>}</div>
  346. <ChevronDownIcon className='h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary' />
  347. </div>
  348. )}
  349. </PortalToFollowElemTrigger>
  350. <PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
  351. <div
  352. className={classNames('max-h-60 overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)}
  353. >
  354. {items.map((item: Item) => (
  355. <div
  356. key={item.value}
  357. className={`
  358. flex h-9 cursor-pointer items-center justify-between rounded-lg px-2.5 text-text-secondary hover:bg-state-base-hover
  359. ${item.value === value && 'bg-state-base-hover'}
  360. `}
  361. title={item.name}
  362. onClick={() => {
  363. onSelect(item)
  364. setOpen(false)
  365. }}
  366. >
  367. <span
  368. className='w-0 grow truncate'
  369. title={item.name}
  370. >
  371. <span className='truncate'>{item.name}</span>
  372. {item.value === installedValue && (
  373. <Badge uppercase={true} className='ml-1 shrink-0'>INSTALLED</Badge>
  374. )}
  375. </span>
  376. {!hideChecked && item.value === value && (
  377. <RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
  378. )}
  379. </div>
  380. ))}
  381. </div>
  382. </PortalToFollowElemContent>
  383. </PortalToFollowElem>
  384. )
  385. }
  386. export { SimpleSelect, PortalSelect }
  387. export default React.memo(Select)