list.tsx 6.9 KB

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