select-dialog.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { createEffect, Show, For, createMemo, type JSX, createResource } from "solid-js"
  2. import { Dialog } from "@kobalte/core/dialog"
  3. import { Icon, IconButton } from "@/ui"
  4. import { createStore } from "solid-js/store"
  5. import { entries, flatMap, groupBy, map, pipe } from "remeda"
  6. import { createList } from "solid-list"
  7. import fuzzysort from "fuzzysort"
  8. interface SelectDialogProps<T> {
  9. items: T[] | ((filter: string) => Promise<T[]>)
  10. key: (item: T) => string
  11. render: (item: T) => JSX.Element
  12. filter?: string[]
  13. current?: T
  14. placeholder?: string
  15. groupBy?: (x: T) => string
  16. onSelect?: (value: T | undefined) => void
  17. onClose?: () => void
  18. }
  19. export function SelectDialog<T>(props: SelectDialogProps<T>) {
  20. let scrollRef: HTMLDivElement | undefined
  21. const [store, setStore] = createStore({
  22. filter: "",
  23. mouseActive: false,
  24. })
  25. const [grouped] = createResource(
  26. () => store.filter,
  27. async (filter) => {
  28. const needle = filter.toLowerCase()
  29. const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
  30. const result = pipe(
  31. all,
  32. (x) => {
  33. if (!needle) return x
  34. if (!props.filter && Array.isArray(x) && x.every((e) => typeof e === "string")) {
  35. return fuzzysort.go(needle, x).map((x) => x.target) as T[]
  36. }
  37. return fuzzysort.go(needle, x, { keys: props.filter! }).map((x) => x.obj)
  38. },
  39. groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
  40. // mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
  41. entries(),
  42. map(([k, v]) => ({ category: k, items: v })),
  43. )
  44. return result
  45. },
  46. )
  47. const flat = createMemo(() => {
  48. return pipe(
  49. grouped() || [],
  50. flatMap((x) => x.items),
  51. )
  52. })
  53. const list = createList({
  54. items: () => flat().map(props.key),
  55. initialActive: props.current ? props.key(props.current) : undefined,
  56. loop: true,
  57. })
  58. const resetSelection = () => {
  59. const all = flat()
  60. if (all.length === 0) return
  61. list.setActive(props.key(all[0]))
  62. }
  63. createEffect(() => {
  64. store.filter
  65. scrollRef?.scrollTo(0, 0)
  66. resetSelection()
  67. })
  68. createEffect(() => {
  69. const all = flat()
  70. if (store.mouseActive || all.length === 0) return
  71. if (list.active() === props.key(all[0])) {
  72. scrollRef?.scrollTo(0, 0)
  73. return
  74. }
  75. const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
  76. element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
  77. })
  78. const handleInput = (value: string) => {
  79. setStore("filter", value)
  80. resetSelection()
  81. }
  82. const handleSelect = (item: T) => {
  83. props.onSelect?.(item)
  84. props.onClose?.()
  85. }
  86. const handleKey = (e: KeyboardEvent) => {
  87. setStore("mouseActive", false)
  88. if (e.key === "Enter") {
  89. e.preventDefault()
  90. const selected = flat().find((x) => props.key(x) === list.active())
  91. if (selected) handleSelect(selected)
  92. } else if (e.key === "Escape") {
  93. e.preventDefault()
  94. props.onClose?.()
  95. } else {
  96. list.onKeyDown(e)
  97. }
  98. }
  99. return (
  100. <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
  101. <Dialog.Portal>
  102. <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
  103. <Dialog.Content
  104. class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
  105. shadow-[0_0_33px_rgba(0,0,0,0.8)]
  106. bg-background border border-border-subtle/30 rounded-lg z-[101]
  107. max-h-[60vh] flex flex-col"
  108. >
  109. <div class="border-b border-border-subtle/30">
  110. <div class="relative">
  111. <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
  112. <input
  113. type="text"
  114. value={store.filter}
  115. onInput={(e) => handleInput(e.currentTarget.value)}
  116. onKeyDown={handleKey}
  117. placeholder={props.placeholder}
  118. class="w-full pl-10 pr-4 py-2 rounded-t-md
  119. text-sm text-text placeholder-text-muted/70
  120. focus:outline-none"
  121. autofocus
  122. spellcheck={false}
  123. autocorrect="off"
  124. autocomplete="off"
  125. autocapitalize="off"
  126. />
  127. <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
  128. {/* <Show when={fileResults.loading && mode() === "files"}>
  129. <div class="text-text-muted">
  130. <Icon name="refresh" size={14} class="animate-spin" />
  131. </div>
  132. </Show> */}
  133. <Show when={store.filter}>
  134. <IconButton
  135. size="xs"
  136. variant="ghost"
  137. class="text-text-muted hover:text-text"
  138. onClick={() => {
  139. setStore("filter", "")
  140. resetSelection()
  141. }}
  142. >
  143. <Icon name="close" size={14} />
  144. </IconButton>
  145. </Show>
  146. </div>
  147. </div>
  148. </div>
  149. <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
  150. <Show
  151. when={flat().length > 0}
  152. fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
  153. >
  154. <For each={grouped()}>
  155. {(group) => (
  156. <>
  157. <Show when={group.category}>
  158. <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
  159. {group.category}
  160. </div>
  161. </Show>
  162. <div class="p-2">
  163. <For each={group.items}>
  164. {(item) => (
  165. <button
  166. data-key={props.key(item)}
  167. onClick={() => handleSelect(item)}
  168. onMouseMove={() => {
  169. setStore("mouseActive", true)
  170. list.setActive(props.key(item))
  171. }}
  172. classList={{
  173. "w-full px-3 py-2 flex items-center gap-3": true,
  174. "rounded-md text-left transition-colors group": true,
  175. "bg-background-element": props.key(item) === list.active(),
  176. }}
  177. >
  178. {props.render(item)}
  179. </button>
  180. )}
  181. </For>
  182. </div>
  183. </>
  184. )}
  185. </For>
  186. </Show>
  187. </div>
  188. <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
  189. <div class="flex items-center gap-5">
  190. <span class="flex items-center gap-1.5">
  191. <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
  192. ↑↓
  193. </kbd>
  194. Navigate
  195. </span>
  196. <span class="flex items-center gap-1.5">
  197. <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
  198. </kbd>
  199. Select
  200. </span>
  201. <span class="flex items-center gap-1.5">
  202. <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
  203. ESC
  204. </kbd>
  205. Close
  206. </span>
  207. </div>
  208. <span>{`${flat().length} results`}</span>
  209. </div>
  210. </Dialog.Content>
  211. </Dialog.Portal>
  212. </Dialog>
  213. )
  214. }