list.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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 { Icon, type IconProps } from "./icon"
  5. import { IconButton } from "./icon-button"
  6. import { TextField } from "./text-field"
  7. export interface ListSearchProps {
  8. placeholder?: string
  9. autofocus?: boolean
  10. hideIcon?: boolean
  11. class?: string
  12. }
  13. export interface ListProps<T> extends FilteredListProps<T> {
  14. class?: string
  15. children: (item: T) => JSX.Element
  16. emptyMessage?: string
  17. onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
  18. onMove?: (item: T | undefined) => void
  19. activeIcon?: IconProps["name"]
  20. filter?: string
  21. search?: ListSearchProps | boolean
  22. }
  23. export interface ListRef {
  24. onKeyDown: (e: KeyboardEvent) => void
  25. setScrollRef: (el: HTMLDivElement | undefined) => void
  26. }
  27. export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
  28. const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
  29. const [internalFilter, setInternalFilter] = createSignal("")
  30. const [store, setStore] = createStore({
  31. mouseActive: false,
  32. })
  33. const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
  34. const containerRect = container.getBoundingClientRect()
  35. const nodeRect = node.getBoundingClientRect()
  36. const top = nodeRect.top - containerRect.top + container.scrollTop
  37. const bottom = top + nodeRect.height
  38. const viewTop = container.scrollTop
  39. const viewBottom = viewTop + container.clientHeight
  40. const target =
  41. block === "center"
  42. ? top - container.clientHeight / 2 + nodeRect.height / 2
  43. : top < viewTop
  44. ? top
  45. : bottom > viewBottom
  46. ? bottom - container.clientHeight
  47. : viewTop
  48. const max = Math.max(0, container.scrollHeight - container.clientHeight)
  49. container.scrollTop = Math.max(0, Math.min(target, max))
  50. }
  51. const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList<T>(props)
  52. const searchProps = () => (typeof props.search === "object" ? props.search : {})
  53. const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
  54. createEffect(() => {
  55. if (props.filter !== undefined) {
  56. onInput(props.filter)
  57. }
  58. })
  59. createEffect((prev) => {
  60. if (!props.search) return
  61. const current = internalFilter()
  62. if (prev !== current) {
  63. onInput(current)
  64. }
  65. return current
  66. }, "")
  67. createEffect(
  68. on(
  69. filter,
  70. () => {
  71. scrollRef()?.scrollTo(0, 0)
  72. },
  73. { defer: true },
  74. ),
  75. )
  76. createEffect(() => {
  77. const scroll = scrollRef()
  78. if (!scroll) return
  79. if (!props.current) return
  80. const key = props.key(props.current)
  81. requestAnimationFrame(() => {
  82. const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
  83. if (!(element instanceof HTMLElement)) return
  84. scrollIntoView(scroll, element, "center")
  85. })
  86. })
  87. createEffect(() => {
  88. const all = flat()
  89. if (store.mouseActive || all.length === 0) return
  90. const scroll = scrollRef()
  91. if (!scroll) return
  92. if (active() === props.key(all[0])) {
  93. scroll.scrollTo(0, 0)
  94. return
  95. }
  96. const key = active()
  97. if (!key) return
  98. const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
  99. if (!(element instanceof HTMLElement)) return
  100. scrollIntoView(scroll, element, "center")
  101. })
  102. createEffect(() => {
  103. const all = flat()
  104. const current = active()
  105. const item = all.find((x) => props.key(x) === current)
  106. props.onMove?.(item)
  107. })
  108. const handleSelect = (item: T | undefined, index: number) => {
  109. props.onSelect?.(item, index)
  110. }
  111. const handleKey = (e: KeyboardEvent) => {
  112. setStore("mouseActive", false)
  113. if (e.key === "Escape") return
  114. const all = flat()
  115. const selected = all.find((x) => props.key(x) === active())
  116. const index = selected ? all.indexOf(selected) : -1
  117. props.onKeyEvent?.(e, selected)
  118. if (e.key === "Enter") {
  119. e.preventDefault()
  120. if (selected) handleSelect(selected, index)
  121. } else {
  122. onKeyDown(e)
  123. }
  124. }
  125. props.ref?.({
  126. onKeyDown: handleKey,
  127. setScrollRef,
  128. })
  129. function GroupHeader(groupProps: { category: string }): JSX.Element {
  130. const [stuck, setStuck] = createSignal(false)
  131. const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
  132. createEffect(() => {
  133. const scroll = scrollRef()
  134. const node = header()
  135. if (!scroll || !node) return
  136. const handler = () => {
  137. const rect = node.getBoundingClientRect()
  138. const scrollRect = scroll.getBoundingClientRect()
  139. setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0)
  140. }
  141. scroll.addEventListener("scroll", handler, { passive: true })
  142. handler()
  143. onCleanup(() => scroll.removeEventListener("scroll", handler))
  144. })
  145. return (
  146. <div data-slot="list-header" data-stuck={stuck()} ref={setHeader}>
  147. {groupProps.category}
  148. </div>
  149. )
  150. }
  151. return (
  152. <div data-component="list" classList={{ [props.class ?? ""]: !!props.class }}>
  153. <Show when={!!props.search}>
  154. <div data-slot="list-search" classList={{ [searchProps().class ?? ""]: !!searchProps().class }}>
  155. <div data-slot="list-search-container">
  156. <Show when={!searchProps().hideIcon}>
  157. <Icon name="magnifying-glass" />
  158. </Show>
  159. <TextField
  160. autofocus={searchProps().autofocus}
  161. variant="ghost"
  162. data-slot="list-search-input"
  163. type="text"
  164. value={internalFilter()}
  165. onChange={setInternalFilter}
  166. onKeyDown={handleKey}
  167. placeholder={searchProps().placeholder}
  168. spellcheck={false}
  169. autocorrect="off"
  170. autocomplete="off"
  171. autocapitalize="off"
  172. />
  173. </div>
  174. <Show when={internalFilter()}>
  175. <IconButton icon="circle-x" variant="ghost" onClick={() => setInternalFilter("")} />
  176. </Show>
  177. </div>
  178. </Show>
  179. <div ref={setScrollRef} data-slot="list-scroll">
  180. <Show
  181. when={flat().length > 0}
  182. fallback={
  183. <div data-slot="list-empty-state">
  184. <div data-slot="list-message">
  185. {props.emptyMessage ?? (grouped.loading ? "Loading" : "No results")} for{" "}
  186. <span data-slot="list-filter">&quot;{filter()}&quot;</span>
  187. </div>
  188. </div>
  189. }
  190. >
  191. <For each={grouped.latest}>
  192. {(group) => (
  193. <div data-slot="list-group">
  194. <Show when={group.category}>
  195. <GroupHeader category={group.category} />
  196. </Show>
  197. <div data-slot="list-items">
  198. <For each={group.items}>
  199. {(item, i) => (
  200. <button
  201. data-slot="list-item"
  202. data-key={props.key(item)}
  203. data-active={props.key(item) === active()}
  204. data-selected={item === props.current}
  205. onClick={() => handleSelect(item, i())}
  206. type="button"
  207. onMouseMove={(event) => {
  208. if (!moved(event)) return
  209. setStore("mouseActive", true)
  210. setActive(props.key(item))
  211. }}
  212. onMouseLeave={() => {
  213. if (!store.mouseActive) return
  214. setActive(null)
  215. }}
  216. >
  217. {props.children(item)}
  218. <Show when={item === props.current}>
  219. <span data-slot="list-item-selected-icon">
  220. <Icon name="check-small" />
  221. </span>
  222. </Show>
  223. <Show when={props.activeIcon}>
  224. {(icon) => (
  225. <span data-slot="list-item-active-icon">
  226. <Icon name={icon()} />
  227. </span>
  228. )}
  229. </Show>
  230. </button>
  231. )}
  232. </For>
  233. </div>
  234. </div>
  235. )}
  236. </For>
  237. </Show>
  238. </div>
  239. </div>
  240. )
  241. }