summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-09-19 06:05:05 -0500
committerAdam <[email protected]>2025-09-19 07:18:39 -0500
commitb1e6b9c7c9ebed944f8267bd0fb90404eba546fb (patch)
treeb2f814e012c1285c94d8262f6041186f7c93af64 /packages/app/src/components
parent20cb5a7c5657db6efe0cfed3e0de09dbb3d7bd2b (diff)
downloadopencode-b1e6b9c7c9ebed944f8267bd0fb90404eba546fb.tar.gz
opencode-b1e6b9c7c9ebed944f8267bd0fb90404eba546fb.zip
wip: desktop work
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/select.tsx184
1 files changed, 184 insertions, 0 deletions
diff --git a/packages/app/src/components/select.tsx b/packages/app/src/components/select.tsx
new file mode 100644
index 000000000..6849f90f1
--- /dev/null
+++ b/packages/app/src/components/select.tsx
@@ -0,0 +1,184 @@
+import { Select as KobalteSelect } from "@kobalte/core/select"
+import { createEffect, 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"
+
+export interface SelectProps<T> {
+ variant?: "default" | "outline"
+ size?: "sm" | "md" | "lg"
+ placeholder?: string
+ options: T[]
+ current?: T
+ value?: (x: T) => string
+ label?: (x: T) => string
+ groupBy?: (x: T) => string
+ filterKeys: string[]
+ onFilter?: (query: string) => void
+ onSelect?: (value: T | undefined) => void
+ class?: ComponentProps<"div">["class"]
+ classList?: ComponentProps<"div">["classList"]
+}
+
+export function Select<T>(props: SelectProps<T>) {
+ let inputRef: HTMLInputElement | undefined = undefined
+ let listboxRef: HTMLUListElement | undefined = undefined
+ let contentRef: HTMLDivElement | undefined = undefined
+ const [store, setStore] = createStore({
+ filter: "",
+ })
+ const grouped = createMemo(() => {
+ const needle = store.filter.toLowerCase()
+ const result = pipe(
+ props.options,
+ (x) => (!needle ? x : fuzzysort.go(needle, x, { keys: props.filterKeys }).map((x) => x.obj)),
+ groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
+ // mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
+ entries(),
+ map(([k, v]) => ({ category: k, options: v })),
+ )
+ 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 (
+ <KobalteSelect<T, { category: string; options: T[] }>
+ allowDuplicateSelectionEvents={false}
+ disallowEmptySelection={true}
+ closeOnSelection={false}
+ value={props.current}
+ options={grouped()}
+ optionValue={(x) => (props.value ? props.value(x) : (x as string))}
+ optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
+ optionGroupChildren="options"
+ placeholder={props.placeholder}
+ sectionComponent={(props) => (
+ <KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2">
+ {props.section.rawValue.category}
+ </KobalteSelect.Section>
+ )}
+ itemComponent={(itemProps) => (
+ <KobalteSelect.Item
+ classList={{
+ "relative flex cursor-pointer select-none items-center": true,
+ "rounded-sm px-2 py-0.5 text-xs outline-none text-text": true,
+ "transition-colors data-[disabled]:pointer-events-none": true,
+ "data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true,
+ [props.class ?? ""]: !!props.class,
+ }}
+ {...itemProps}
+ >
+ <KobalteSelect.ItemLabel>
+ {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
+ </KobalteSelect.ItemLabel>
+ <KobalteSelect.ItemIndicator
+ classList={{
+ "ml-auto": true,
+ }}
+ >
+ <Icon name="checkmark" size={16} />
+ </KobalteSelect.ItemIndicator>
+ </KobalteSelect.Item>
+ )}
+ onChange={(v) => {
+ if (props.onSelect) props.onSelect(v ?? undefined)
+ if (v !== null) {
+ // close the select
+ }
+ }}
+ onOpenChange={(v) => v || setStore("filter", "")}
+ >
+ <KobalteSelect.Trigger
+ classList={{
+ ...(props.classList ?? {}),
+ "flex w-full items-center justify-between rounded-md transition-colors": true,
+ "focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true,
+ "disabled:cursor-not-allowed disabled:opacity-50": true,
+ "data-[placeholder-shown]:text-text-muted cursor-pointer": true,
+ "hover:bg-background-element focus-visible:ring-border-active": true,
+ "bg-background-element text-text": props.variant === "default" || !props.variant,
+ "border-2 border-border bg-transparent text-text": props.variant === "outline",
+ "h-6 pl-2 text-xs": props.size === "sm",
+ "h-8 pl-3 text-sm": props.size === "md" || !props.size,
+ "h-10 pl-4 text-base": props.size === "lg",
+ [props.class ?? ""]: !!props.class,
+ }}
+ >
+ <KobalteSelect.Value<T>>
+ {(state) => {
+ const selected = state.selectedOption() ?? props.current
+ if (!selected) return props.placeholder || ""
+ if (props.label) return props.label(selected)
+ return selected as string
+ }}
+ </KobalteSelect.Value>
+ <KobalteSelect.Icon
+ classList={{
+ "size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true,
+ }}
+ >
+ <Icon name="chevron-down" size={24} />
+ </KobalteSelect.Icon>
+ </KobalteSelect.Trigger>
+ <KobalteSelect.Portal>
+ <KobalteSelect.Content
+ ref={(el) => (contentRef = el)}
+ onKeyDown={(e) => {
+ 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,
+ "data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true,
+ "data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
+ }}
+ >
+ <form>
+ <input
+ ref={(el) => (inputRef = el)}
+ id="select-filter"
+ type="text"
+ placeholder="Filter models"
+ 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,
+ }}
+ />
+ </form>
+ <KobalteSelect.Listbox
+ ref={(el) => (listboxRef = el)}
+ classList={{
+ "overflow-y-auto max-h-48 no-scrollbar": true,
+ }}
+ />
+ </KobalteSelect.Content>
+ </KobalteSelect.Portal>
+ </KobalteSelect>
+ )
+}