list.tsx 5.7 KB

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