From d3b6545e7c6069c9db031634b7890e6b8eb4de2a Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:05:15 +0200 Subject: [PATCH] feat(app): added command palette (#2630) Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> --- packages/app/src/components/code.tsx | 2 +- packages/app/src/components/select-dialog.tsx | 216 ++++++++++++++++++ packages/app/src/components/select.tsx | 99 +------- packages/app/src/components/session-list.tsx | 2 +- packages/app/src/context/local.tsx | 54 ++--- packages/app/src/context/sync.tsx | 31 +-- packages/app/src/index.css | 13 ++ packages/app/src/pages/index.tsx | 92 ++++++-- packages/app/src/ui/button.tsx | 67 +++--- packages/app/src/ui/link.tsx | 8 +- packages/app/src/ui/tooltip.tsx | 2 +- 11 files changed, 381 insertions(+), 205 deletions(-) create mode 100644 packages/app/src/components/select-dialog.tsx diff --git a/packages/app/src/components/code.tsx b/packages/app/src/components/code.tsx index e6d5ba80..e4121d12 100644 --- a/packages/app/src/components/code.tsx +++ b/packages/app/src/components/code.tsx @@ -279,7 +279,7 @@ export function Code(props: Props) { }} innerHTML={html()} class=" - font-mono text-xs tracking-wide overflow-y-auto no-scrollbar h-full + font-mono text-xs tracking-wide overflow-y-auto h-full [&]:[counter-reset:line] [&_pre]:focus-visible:outline-none [&_pre]:overflow-x-auto [&_pre]:no-scrollbar diff --git a/packages/app/src/components/select-dialog.tsx b/packages/app/src/components/select-dialog.tsx new file mode 100644 index 00000000..2ff64f5d --- /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 { + 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(props: SelectDialogProps) { + 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 ( + open || props.onClose?.()}> + + + +
+
+ + 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" + /> +
+ {/* +
+ +
+
*/} + + { + setStore("filter", "") + resetSelection() + }} + > + + + +
+
+
+
(scrollRef = el)} class="relative flex-1 overflow-y-auto"> + 0} + fallback={
No results
} + > + + {(group) => ( + <> +
+ {group.category} +
+
+ + {(item) => ( + + )} + +
+ + )} +
+
+
+
+
+ + + ↑↓ + + Navigate + + + + ↵ + + Select + + + + ESC + + Close + +
+ {`${flat().length} results`} +
+
+
+
+ ) +} diff --git a/packages/app/src/components/select.tsx b/packages/app/src/components/select.tsx index a99eccbd..3df8c999 100644 --- a/packages/app/src/components/select.tsx +++ b/packages/app/src/components/select.tsx @@ -1,46 +1,26 @@ import { Select as KobalteSelect } from "@kobalte/core/select" -import { createEffect, createMemo, Show } from "solid-js" +import { createMemo } from "solid-js" import type { ComponentProps } from "solid-js" import { Icon } from "@/ui/icon" -import fuzzysort from "fuzzysort" import { pipe, groupBy, entries, map } from "remeda" -import { createStore } from "solid-js/store" +import { Button, type ButtonProps } from "@/ui" export interface SelectProps { - variant?: "default" | "outline" - size?: "sm" | "md" | "lg" placeholder?: string - filter?: - | false - | { - placeholder?: string - keys: string[] - } options: T[] current?: T value?: (x: T) => string label?: (x: T) => string groupBy?: (x: T) => string - onFilter?: (query: string) => void onSelect?: (value: T | undefined) => void class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] } -export function Select(props: SelectProps) { - let inputRef: HTMLInputElement | undefined = undefined - let listboxRef: HTMLUListElement | undefined = undefined - const [store, setStore] = createStore({ - filter: "", - }) +export function Select(props: SelectProps & ButtonProps) { const grouped = createMemo(() => { - const needle = store.filter.toLowerCase() const result = pipe( props.options, - (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) => a.title.localeCompare(b.title))), entries(), @@ -48,19 +28,6 @@ export function Select(props: SelectProps) { ) return result }) - // const flat = createMemo(() => { - // return pipe( - // grouped(), - // flatMap(({ options }) => options), - // ) - // }) - - createEffect(() => { - store.filter - listboxRef?.scrollTo(0, 0) - // setStore("selected", 0) - // scroll.scrollTo(0) - }) return ( @@ -89,36 +56,21 @@ export function Select(props: SelectProps) { {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} - + )} onChange={(v) => { - if (props.onSelect) props.onSelect(v ?? undefined) - if (v !== null) { - // close the select - } + props.onSelect?.(v ?? undefined) }} - onOpenChange={(v) => v || setStore("filter", "")} > @@ -140,13 +92,6 @@ export function Select(props: SelectProps) { { - if (!props.filter) return - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - return - } - inputRef?.focus() - }} classList={{ "min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true, "bg-background-panel p-1 shadow-md z-50": true, @@ -154,33 +99,7 @@ export function Select(props: SelectProps) { "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true, }} > - - (inputRef = el)} - id="select-filter" - type="text" - placeholder={props.filter ? props.filter.placeholder : "Filter items"} - value={store.filter} - onInput={(e) => setStore("filter", e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") { - e.preventDefault() - e.stopPropagation() - listboxRef?.focus() - } - }} - classList={{ - "w-full": true, - "px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true, - }} - /> - - (listboxRef = el)} - classList={{ - "overflow-y-auto max-h-48 no-scrollbar": true, - }} - /> + diff --git a/packages/app/src/components/session-list.tsx b/packages/app/src/components/session-list.tsx index e5756258..e0819780 100644 --- a/packages/app/src/components/session-list.tsx +++ b/packages/app/src/components/session-list.tsx @@ -7,7 +7,7 @@ export default function SessionList() { const local = useLocal() return ( - + {(session) => (