list.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
  2. import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "solid-js"
  3. import { createStore } from "solid-js/store"
  4. import { useI18n } from "../context/i18n"
  5. import { Icon, type IconProps } from "./icon"
  6. import { IconButton } from "./icon-button"
  7. import { TextField } from "./text-field"
  8. function findByKey(container: HTMLElement, key: string) {
  9. const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
  10. for (const node of nodes) {
  11. if (node.getAttribute("data-key") === key) return node
  12. }
  13. }
  14. export interface ListSearchProps {
  15. placeholder?: string
  16. autofocus?: boolean
  17. hideIcon?: boolean
  18. class?: string
  19. action?: JSX.Element
  20. }
  21. export interface ListAddProps {
  22. class?: string
  23. render: () => JSX.Element
  24. }
  25. export interface ListAddProps {
  26. class?: string
  27. render: () => JSX.Element
  28. }
  29. export interface ListProps<T> extends FilteredListProps<T> {
  30. class?: string
  31. children: (item: T) => JSX.Element
  32. emptyMessage?: string
  33. loadingMessage?: string
  34. onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
  35. onMove?: (item: T | undefined) => void
  36. onFilter?: (value: string) => void
  37. activeIcon?: IconProps["name"]
  38. filter?: string
  39. search?: ListSearchProps | boolean
  40. itemWrapper?: (item: T, node: JSX.Element) => JSX.Element
  41. divider?: boolean
  42. add?: ListAddProps
  43. }
  44. export interface ListRef {
  45. onKeyDown: (e: KeyboardEvent) => void
  46. setScrollRef: (el: HTMLDivElement | undefined) => void
  47. }
  48. export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
  49. const i18n = useI18n()
  50. const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
  51. const [internalFilter, setInternalFilter] = createSignal("")
  52. const [store, setStore] = createStore({
  53. mouseActive: false,
  54. })
  55. const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
  56. const containerRect = container.getBoundingClientRect()
  57. const nodeRect = node.getBoundingClientRect()
  58. const top = nodeRect.top - containerRect.top + container.scrollTop
  59. const bottom = top + nodeRect.height
  60. const viewTop = container.scrollTop
  61. const viewBottom = viewTop + container.clientHeight
  62. const target =
  63. block === "center"
  64. ? top - container.clientHeight / 2 + nodeRect.height / 2
  65. : top < viewTop
  66. ? top
  67. : bottom > viewBottom
  68. ? bottom - container.clientHeight
  69. : viewTop
  70. const max = Math.max(0, container.scrollHeight - container.clientHeight)
  71. container.scrollTop = Math.max(0, Math.min(target, max))
  72. }
  73. const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
  74. const searchProps = () => (typeof props.search === "object" ? props.search : {})
  75. const searchAction = () => searchProps().action
  76. const addProps = () => props.add
  77. const showAdd = () => !!addProps()
  78. const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
  79. createEffect(() => {
  80. if (props.filter !== undefined) {
  81. onInput(props.filter)
  82. }
  83. })
  84. createEffect((prev) => {
  85. if (!props.search) return
  86. const current = internalFilter()
  87. if (prev !== current) {
  88. onInput(current)
  89. props.onFilter?.(current)
  90. }
  91. return current
  92. }, "")
  93. createEffect(
  94. on(
  95. filter,
  96. () => {
  97. scrollRef()?.scrollTo(0, 0)
  98. },
  99. { defer: true },
  100. ),
  101. )
  102. createEffect(() => {
  103. const scroll = scrollRef()
  104. if (!scroll) return
  105. if (!props.current) return
  106. const key = props.key(props.current)
  107. requestAnimationFrame(() => {
  108. const element = findByKey(scroll, key)
  109. if (!element) return
  110. scrollIntoView(scroll, element, "center")
  111. })
  112. })
  113. createEffect(() => {
  114. const all = flat()
  115. if (store.mouseActive || all.length === 0) return
  116. const scroll = scrollRef()
  117. if (!scroll) return
  118. if (active() === props.key(all[0])) {
  119. scroll.scrollTo(0, 0)
  120. return
  121. }
  122. const key = active()
  123. if (!key) return
  124. const element = findByKey(scroll, key)
  125. if (!element) return
  126. scrollIntoView(scroll, element, "center")
  127. })
  128. createEffect(() => {
  129. const all = flat()
  130. const current = active()
  131. const item = all.find((x) => props.key(x) === current)
  132. props.onMove?.(item)
  133. })
  134. const handleSelect = (item: T | undefined, index: number) => {
  135. props.onSelect?.(item, index)
  136. }
  137. const handleKey = (e: KeyboardEvent) => {
  138. setStore("mouseActive", false)
  139. if (e.key === "Escape") return
  140. const all = flat()
  141. const selected = all.find((x) => props.key(x) === active())
  142. const index = selected ? all.indexOf(selected) : -1
  143. props.onKeyEvent?.(e, selected)
  144. if (e.key === "Enter" && !e.isComposing) {
  145. e.preventDefault()
  146. if (selected) handleSelect(selected, index)
  147. } else {
  148. onKeyDown(e)
  149. }
  150. }
  151. props.ref?.({
  152. onKeyDown: handleKey,
  153. setScrollRef,
  154. })
  155. const renderAdd = () => {
  156. const add = addProps()
  157. if (!add) return null
  158. return (
  159. <div data-slot="list-item-add" classList={{ [add.class ?? ""]: !!add.class }}>
  160. {add.render()}
  161. </div>
  162. )
  163. }
  164. function GroupHeader(groupProps: { category: string }): JSX.Element {
  165. const [stuck, setStuck] = createSignal(false)
  166. const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
  167. createEffect(() => {
  168. const scroll = scrollRef()
  169. const node = header()
  170. if (!scroll || !node) return
  171. const handler = () => {
  172. const rect = node.getBoundingClientRect()
  173. const scrollRect = scroll.getBoundingClientRect()
  174. setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
  175. }
  176. scroll.addEventListener("scroll", handler, { passive: true })
  177. handler()
  178. onCleanup(() => scroll.removeEventListener("scroll", handler))
  179. })
  180. return (
  181. <div data-slot="list-header" data-stuck={stuck()} ref={setHeader}>
  182. {groupProps.category}
  183. </div>
  184. )
  185. }
  186. const emptyMessage = () => {
  187. if (grouped.loading) return props.loadingMessage ?? i18n.t("ui.list.loading")
  188. if (props.emptyMessage) return props.emptyMessage
  189. const query = filter()
  190. if (!query) return i18n.t("ui.list.empty")
  191. const suffix = i18n.t("ui.list.emptyWithFilter.suffix")
  192. return (
  193. <>
  194. <span>{i18n.t("ui.list.emptyWithFilter.prefix")}</span>
  195. <span data-slot="list-filter">&quot;{query}&quot;</span>
  196. <Show when={suffix}>
  197. <span>{suffix}</span>
  198. </Show>
  199. </>
  200. )
  201. }
  202. return (
  203. <div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
  204. <Show when={!!props.search}>
  205. <div data-slot="list-search-wrapper">
  206. <div data-slot="list-search" classList={{ [searchProps().class ?? ""]: !!searchProps().class }}>
  207. <div data-slot="list-search-container">
  208. <Show when={!searchProps().hideIcon}>
  209. <Icon name="magnifying-glass" />
  210. </Show>
  211. <TextField
  212. autofocus={searchProps().autofocus}
  213. variant="ghost"
  214. data-slot="list-search-input"
  215. type="text"
  216. value={internalFilter()}
  217. onChange={setInternalFilter}
  218. onKeyDown={handleKey}
  219. placeholder={searchProps().placeholder}
  220. spellcheck={false}
  221. autocorrect="off"
  222. autocomplete="off"
  223. autocapitalize="off"
  224. />
  225. </div>
  226. <Show when={internalFilter()}>
  227. <IconButton
  228. icon="circle-x"
  229. variant="ghost"
  230. onClick={() => setInternalFilter("")}
  231. aria-label={i18n.t("ui.list.clearFilter")}
  232. />
  233. </Show>
  234. </div>
  235. {searchAction()}
  236. </div>
  237. </Show>
  238. <div ref={setScrollRef} data-slot="list-scroll">
  239. <Show
  240. when={flat().length > 0 || showAdd()}
  241. fallback={
  242. <div data-slot="list-empty-state">
  243. <div data-slot="list-message">{emptyMessage()}</div>
  244. </div>
  245. }
  246. >
  247. <For each={grouped.latest}>
  248. {(group, groupIndex) => {
  249. const isLastGroup = () => groupIndex() === grouped.latest.length - 1
  250. return (
  251. <div data-slot="list-group">
  252. <Show when={group.category}>
  253. <GroupHeader category={group.category} />
  254. </Show>
  255. <div data-slot="list-items">
  256. <For each={group.items}>
  257. {(item, i) => {
  258. const node = (
  259. <button
  260. data-slot="list-item"
  261. data-key={props.key(item)}
  262. data-active={props.key(item) === active()}
  263. data-selected={item === props.current}
  264. onClick={() => handleSelect(item, i())}
  265. type="button"
  266. onMouseMove={(event) => {
  267. if (!moved(event)) return
  268. setStore("mouseActive", true)
  269. setActive(props.key(item))
  270. }}
  271. onMouseLeave={() => {
  272. if (!store.mouseActive) return
  273. setActive(null)
  274. }}
  275. >
  276. {props.children(item)}
  277. <Show when={item === props.current}>
  278. <span data-slot="list-item-selected-icon">
  279. <Icon name="check-small" />
  280. </span>
  281. </Show>
  282. <Show when={props.activeIcon}>
  283. {(icon) => (
  284. <span data-slot="list-item-active-icon">
  285. <Icon name={icon()} />
  286. </span>
  287. )}
  288. </Show>
  289. {props.divider && (i() !== group.items.length - 1 || (showAdd() && isLastGroup())) && (
  290. <span data-slot="list-item-divider" />
  291. )}
  292. </button>
  293. )
  294. if (props.itemWrapper) return props.itemWrapper(item, node)
  295. return node
  296. }}
  297. </For>
  298. <Show when={showAdd() && isLastGroup()}>{renderAdd()}</Show>
  299. </div>
  300. </div>
  301. )
  302. }}
  303. </For>
  304. <Show when={grouped.latest.length === 0 && showAdd()}>
  305. <div data-slot="list-group">
  306. <div data-slot="list-items">{renderAdd()}</div>
  307. </div>
  308. </Show>
  309. </Show>
  310. </div>
  311. </div>
  312. )
  313. }