diff options
| author | Filip <[email protected]> | 2025-09-24 18:05:15 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-09-24 11:05:15 -0500 |
| commit | d3b6545e7c6069c9db031634b7890e6b8eb4de2a (patch) | |
| tree | 5457b28378cf453e2c138337fc1abee3b3423ed2 /packages/app/src/components/select-dialog.tsx | |
| parent | 3f911b22b011af74426699dac079b33badf862b1 (diff) | |
| download | opencode-d3b6545e7c6069c9db031634b7890e6b8eb4de2a.tar.gz opencode-d3b6545e7c6069c9db031634b7890e6b8eb4de2a.zip | |
feat(app): added command palette (#2630)
Co-authored-by: Adam <[email protected]>
Diffstat (limited to 'packages/app/src/components/select-dialog.tsx')
| -rw-r--r-- | packages/app/src/components/select-dialog.tsx | 216 |
1 files changed, 216 insertions, 0 deletions
diff --git a/packages/app/src/components/select-dialog.tsx b/packages/app/src/components/select-dialog.tsx new file mode 100644 index 000000000..2ff64f5da --- /dev/null +++ b/packages/app/src/components/select-dialog.tsx @@ -0,0 +1,216 @@ +import { createEffect, Show, For, createMemo, type JSX } from "solid-js" +import { Dialog } from "@kobalte/core/dialog" +import { Icon, IconButton } from "@/ui" +import { createStore } from "solid-js/store" +import { entries, flatMap, groupBy, map, mapValues, pipe } from "remeda" +import { createList } from "solid-list" +import fuzzysort from "fuzzysort" + +interface SelectDialogProps<T> { + items: T[] + key: (item: T) => string + render: (item: T) => JSX.Element + current?: T + placeholder?: string + filter?: + | false + | { + keys: string[] + } + groupBy?: (x: T) => string + onSelect?: (value: T | undefined) => void + onClose?: () => void +} + +export function SelectDialog<T>(props: SelectDialogProps<T>) { + let scrollRef: HTMLDivElement | undefined + const [store, setStore] = createStore({ + filter: "", + mouseActive: false, + }) + + const grouped = createMemo(() => { + const needle = store.filter.toLowerCase() + const result = pipe( + props.items, + (x) => + !needle || !props.filter + ? x + : fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj), + groupBy((x) => (props.groupBy ? props.groupBy(x) : "")), + mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))), + entries(), + map(([k, v]) => ({ category: k, items: v })), + ) + return result + }) + const flat = createMemo(() => { + return pipe( + grouped(), + flatMap((x) => x.items), + ) + }) + const list = createList({ + items: () => flat().map(props.key), + initialActive: props.current ? props.key(props.current) : undefined, + loop: true, + }) + const resetSelection = () => list.setActive(props.key(flat()[0])) + + createEffect(() => { + store.filter + scrollRef?.scrollTo(0, 0) + resetSelection() + }) + + createEffect(() => { + if (store.mouseActive) return + if (list.active() === props.key(flat()[0])) { + scrollRef?.scrollTo(0, 0) + return + } + const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`) + element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) + }) + + const handleInput = (value: string) => { + setStore("filter", value) + resetSelection() + } + + const handleSelect = (item: T) => { + props.onSelect?.(item) + props.onClose?.() + } + + const handleKey = (e: KeyboardEvent) => { + setStore("mouseActive", false) + + if (e.key === "Enter") { + e.preventDefault() + const selected = flat().find((x) => props.key(x) === list.active()) + if (selected) handleSelect(selected) + } else if (e.key === "Escape") { + e.preventDefault() + props.onClose?.() + } else { + list.onKeyDown(e) + } + } + + return ( + <Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}> + <Dialog.Portal> + <Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" /> + <Dialog.Content + class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl + shadow-[0_0_33px_rgba(0,0,0,0.8)] + bg-background border border-border-subtle/30 rounded-lg z-[101] + max-h-[60vh] flex flex-col" + > + <div class="border-b border-border-subtle/30"> + <div class="relative"> + <Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" /> + <input + type="text" + value={store.filter} + onInput={(e) => handleInput(e.currentTarget.value)} + onKeyDown={handleKey} + placeholder={props.placeholder} + class="w-full pl-10 pr-4 py-2 rounded-t-md + text-sm text-text placeholder-text-muted/70 + focus:outline-none" + autofocus + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + <div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2"> + {/* <Show when={fileResults.loading && mode() === "files"}> + <div class="text-text-muted"> + <Icon name="refresh" size={14} class="animate-spin" /> + </div> + </Show> */} + <Show when={store.filter}> + <IconButton + size="xs" + variant="ghost" + class="text-text-muted hover:text-text" + onClick={() => { + setStore("filter", "") + resetSelection() + }} + > + <Icon name="close" size={14} /> + </IconButton> + </Show> + </div> + </div> + </div> + <div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto"> + <Show + when={flat().length > 0} + fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>} + > + <For each={grouped()}> + {(group) => ( + <> + <div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase"> + {group.category} + </div> + <div class="p-2"> + <For each={group.items}> + {(item) => ( + <button + data-key={props.key(item)} + onClick={() => handleSelect(item)} + onMouseMove={() => { + setStore("mouseActive", true) + list.setActive(props.key(item)) + }} + classList={{ + "w-full px-3 py-2 flex items-center gap-3": true, + "rounded-md text-left transition-colors group": true, + "bg-background-element": props.key(item) === list.active(), + "hover:bg-background-element": true, + }} + > + {props.render(item)} + </button> + )} + </For> + </div> + </> + )} + </For> + </Show> + </div> + <div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted"> + <div class="flex items-center gap-5"> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ↑↓ + </kbd> + Navigate + </span> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ↵ + </kbd> + Select + </span> + <span class="flex items-center gap-1.5"> + <kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]"> + ESC + </kbd> + Close + </span> + </div> + <span>{`${flat().length} results`}</span> + </div> + </Dialog.Content> + </Dialog.Portal> + </Dialog> + ) +} |
