summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-10 15:16:57 -0600
committerAdam <[email protected]>2025-12-10 15:17:04 -0600
commit58e66dd3d1dfd975195dac916fb4b23093404243 (patch)
treedff8b9aaf7026a4b74fd3f519bdf18374697f062
parent190fa4c87aa2b3f954a419f716add1fc29e4011e (diff)
downloadopencode-58e66dd3d1dfd975195dac916fb4b23093404243.tar.gz
opencode-58e66dd3d1dfd975195dac916fb4b23093404243.zip
wip(desktop): progress
-rw-r--r--packages/ui/src/components/list.css107
-rw-r--r--packages/ui/src/components/list.tsx141
-rw-r--r--packages/ui/src/components/select-dialog.css118
-rw-r--r--packages/ui/src/components/select-dialog.tsx150
-rw-r--r--packages/ui/src/styles/index.css1
5 files changed, 290 insertions, 227 deletions
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
new file mode 100644
index 000000000..63d9a2fe1
--- /dev/null
+++ b/packages/ui/src/components/list.css
@@ -0,0 +1,107 @@
+[data-component="list"] {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+
+ [data-slot="list-empty-state"] {
+ display: flex;
+ padding: 32px 0px;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ align-self: stretch;
+
+ [data-slot="list-message"] {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 2px;
+ color: var(--text-weak);
+ text-align: center;
+
+ /* text-14-regular */
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-style: normal;
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-large); /* 142.857% */
+ letter-spacing: var(--letter-spacing-normal);
+ }
+
+ [data-slot="list-filter"] {
+ color: var(--text-strong);
+ }
+ }
+
+ [data-slot="list-group"] {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+
+ [data-slot="list-header"] {
+ display: flex;
+ height: 28px;
+ padding: 0 10px;
+ justify-content: space-between;
+ align-items: center;
+ align-self: stretch;
+ background: var(--surface-raised-stronger-non-alpha);
+ position: sticky;
+ top: 0;
+
+ color: var(--text-base);
+
+ /* text-14-medium */
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large); /* 142.857% */
+ letter-spacing: var(--letter-spacing-normal);
+ }
+
+ [data-slot="list-items"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+
+ [data-slot="list-item"] {
+ display: flex;
+ width: 100%;
+ height: 28px;
+ padding: 4px 10px;
+ align-items: center;
+ color: var(--text-strong);
+
+ /* text-14-medium */
+ font-family: var(--font-family-sans);
+ font-size: 14px;
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large); /* 142.857% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ [data-slot="list-item-selected-icon"] {
+ color: var(--icon-strong-base);
+ }
+ [data-slot="list-item-active-icon"] {
+ display: none;
+ color: var(--icon-strong-base);
+ }
+
+ &[data-active="true"] {
+ border-radius: var(--radius-md);
+ background: var(--surface-raised-base-hover);
+ [data-slot="list-item-active-icon"] {
+ display: block;
+ }
+ }
+ &:active {
+ background: var(--surface-raised-base-active);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
new file mode 100644
index 000000000..3fbeb35f6
--- /dev/null
+++ b/packages/ui/src/components/list.tsx
@@ -0,0 +1,141 @@
+import { createEffect, Show, For, type JSX, createSignal } from "solid-js"
+import { createStore } from "solid-js/store"
+import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
+import { Icon, IconProps } from "./icon"
+
+export interface ListProps<T> extends FilteredListProps<T> {
+ children: (item: T) => JSX.Element
+ emptyMessage?: string
+ onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
+ activeIcon?: IconProps["name"]
+ filter?: string
+}
+
+export interface ListRef {
+ onKeyDown: (e: KeyboardEvent) => void
+ setScrollRef: (el: HTMLDivElement | undefined) => void
+}
+
+export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) {
+ const [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
+ const [store, setStore] = createStore({
+ mouseActive: false,
+ })
+
+ const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
+ items: props.items,
+ key: props.key,
+ filterKeys: props.filterKeys,
+ current: props.current,
+ groupBy: props.groupBy,
+ sortBy: props.sortBy,
+ sortGroupsBy: props.sortGroupsBy,
+ })
+
+ createEffect(() => {
+ if (props.filter === undefined) return
+ onInput(props.filter)
+ })
+
+ createEffect(() => {
+ filter()
+ scrollRef()?.scrollTo(0, 0)
+ reset()
+ })
+
+ createEffect(() => {
+ if (!scrollRef()) return
+ if (!props.current) return
+ const key = props.key(props.current)
+ requestAnimationFrame(() => {
+ const element = scrollRef()!.querySelector(`[data-key="${key}"]`)
+ element?.scrollIntoView({ block: "center" })
+ })
+ })
+
+ createEffect(() => {
+ const all = flat()
+ if (store.mouseActive || all.length === 0) return
+ if (active() === props.key(all[0])) {
+ scrollRef()?.scrollTo(0, 0)
+ return
+ }
+ const element = scrollRef()?.querySelector(`[data-key="${active()}"]`)
+ element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+ })
+
+ const handleSelect = (item: T | undefined) => {
+ props.onSelect?.(item)
+ }
+
+ const handleKey = (e: KeyboardEvent) => {
+ setStore("mouseActive", false)
+ if (e.key === "Escape") return
+
+ const all = flat()
+ const selected = all.find((x) => props.key(x) === active())
+ props.onKeyEvent?.(e, selected)
+
+ if (e.key === "Enter") {
+ e.preventDefault()
+ if (selected) handleSelect(selected)
+ } else {
+ onKeyDown(e)
+ }
+ }
+
+ props.ref?.({
+ onKeyDown: handleKey,
+ setScrollRef,
+ })
+
+ return (
+ <div ref={setScrollRef} data-component="list">
+ <Show
+ when={flat().length > 0}
+ fallback={
+ <div data-slot="list-empty-state">
+ <div data-slot="list-message">
+ {props.emptyMessage ?? "No results"} for <span data-slot="list-filter">&quot;{filter()}&quot;</span>
+ </div>
+ </div>
+ }
+ >
+ <For each={grouped()}>
+ {(group) => (
+ <div data-slot="list-group">
+ <Show when={group.category}>
+ <div data-slot="list-header">{group.category}</div>
+ </Show>
+ <div data-slot="list-items">
+ <For each={group.items}>
+ {(item) => (
+ <button
+ data-slot="list-item"
+ data-key={props.key(item)}
+ data-active={props.key(item) === active()}
+ data-selected={item === props.current}
+ onClick={() => handleSelect(item)}
+ onMouseMove={() => {
+ setStore("mouseActive", true)
+ setActive(props.key(item))
+ }}
+ >
+ {props.children(item)}
+ <Show when={item === props.current}>
+ <Icon data-slot="list-item-selected-icon" name="check-small" />
+ </Show>
+ <Show when={props.activeIcon}>
+ {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
+ </Show>
+ </button>
+ )}
+ </For>
+ </div>
+ </div>
+ )}
+ </For>
+ </Show>
+ </div>
+ )
+}
diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css
index f5687ad8e..9759174a6 100644
--- a/packages/ui/src/components/select-dialog.css
+++ b/packages/ui/src/components/select-dialog.css
@@ -5,6 +5,14 @@
overflow: hidden;
gap: 20px;
padding: 0 10px;
+
+ [data-slot="dialog-body"] {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
}
[data-component="select-dialog-input"] {
@@ -22,7 +30,7 @@
[data-slot="select-dialog-input-container"] {
display: flex;
align-items: center;
- gap: 12px;
+ gap: 16px;
flex: 1 0 0;
/* [data-slot="select-dialog-icon"] {} */
@@ -34,111 +42,3 @@
/* [data-slot="select-dialog-clear-button"] {} */
}
-
-[data-component="select-dialog"] {
- display: flex;
- flex-direction: column;
- gap: 20px;
-
- [data-slot="select-dialog-empty-state"] {
- display: flex;
- padding: 32px 0px;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- gap: 8px;
- align-self: stretch;
-
- [data-slot="select-dialog-message"] {
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 2px;
- color: var(--text-weak);
- text-align: center;
-
- /* text-14-regular */
- font-family: var(--font-family-sans);
- font-size: 14px;
- font-style: normal;
- font-weight: var(--font-weight-regular);
- line-height: var(--line-height-large); /* 142.857% */
- letter-spacing: var(--letter-spacing-normal);
- }
-
- [data-slot="select-dialog-filter"] {
- color: var(--text-strong);
- }
- }
-
- [data-slot="select-dialog-group"] {
- position: relative;
- display: flex;
- flex-direction: column;
-
- [data-slot="select-dialog-header"] {
- display: flex;
- height: 28px;
- padding: 0 10px;
- justify-content: space-between;
- align-items: center;
- align-self: stretch;
- background: var(--surface-raised-stronger-non-alpha);
- position: sticky;
- top: 0;
-
- color: var(--text-base);
-
- /* text-14-medium */
- font-family: var(--font-family-sans);
- font-size: 14px;
- font-style: normal;
- font-weight: var(--font-weight-medium);
- line-height: var(--line-height-large); /* 142.857% */
- letter-spacing: var(--letter-spacing-normal);
- }
-
- [data-slot="select-dialog-list"] {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- align-self: stretch;
-
- [data-slot="select-dialog-item"] {
- display: flex;
- width: 100%;
- height: 28px;
- padding: 4px 10px;
- align-items: center;
- color: var(--text-strong);
-
- /* text-14-medium */
- font-family: var(--font-family-sans);
- font-size: 14px;
- font-style: normal;
- font-weight: var(--font-weight-medium);
- line-height: var(--line-height-large); /* 142.857% */
- letter-spacing: var(--letter-spacing-normal);
-
- [data-slot="select-dialog-item-selected-icon"] {
- color: var(--icon-strong-base);
- }
- [data-slot="select-dialog-item-active-icon"] {
- display: none;
- color: var(--icon-strong-base);
- }
-
- &[data-active="true"] {
- border-radius: var(--radius-md);
- background: var(--surface-raised-base-hover);
- [data-slot="select-dialog-item-active-icon"] {
- display: block;
- }
- }
- &:active {
- background: var(--surface-raised-base-active);
- }
- }
- }
- }
-}
diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx
index 86f723225..952ba881f 100644
--- a/packages/ui/src/components/select-dialog.tsx
+++ b/packages/ui/src/components/select-dialog.tsx
@@ -1,98 +1,46 @@
-import { createEffect, Show, For, type JSX, splitProps, createSignal } from "solid-js"
-import { createStore } from "solid-js/store"
-import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
+import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js"
import { Dialog, DialogProps } from "./dialog"
-import { Icon, IconProps } from "./icon"
+import { Icon } from "./icon"
import { Input } from "./input"
import { IconButton } from "./icon-button"
+import { List, ListRef, ListProps } from "./list"
interface SelectDialogProps<T>
- extends FilteredListProps<T>,
+ extends Omit<ListProps<T>, "filter">,
Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
title: string
placeholder?: string
- emptyMessage?: string
- children: (item: T) => JSX.Element
- onSelect?: (value: T | undefined) => void
- onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void
actions?: JSX.Element
- activeIcon?: IconProps["name"]
}
export function SelectDialog<T>(props: SelectDialogProps<T>) {
const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
let closeButton!: HTMLButtonElement
let inputRef: HTMLInputElement | undefined
- let [scrollRef, setScrollRef] = createSignal<HTMLDivElement | undefined>(undefined)
- const [store, setStore] = createStore({
- mouseActive: false,
- })
-
- const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
- items: others.items,
- key: others.key,
- filterKeys: others.filterKeys,
- current: others.current,
- groupBy: others.groupBy,
- sortBy: others.sortBy,
- sortGroupsBy: others.sortGroupsBy,
- })
-
- createEffect(() => {
- filter()
- scrollRef()?.scrollTo(0, 0)
- reset()
- })
+ const [filter, setFilter] = createSignal("")
+ let listRef: ListRef | undefined
createEffect(() => {
- if (!scrollRef()) return
- if (!others.current) return
- const key = others.key(others.current)
+ if (!props.current) return
+ const key = props.key(props.current)
requestAnimationFrame(() => {
- const element = scrollRef()!.querySelector(`[data-key="${key}"]`)
+ const element = document.querySelector(`[data-key="${key}"]`)
element?.scrollIntoView({ block: "center" })
})
})
- createEffect(() => {
- const all = flat()
- if (store.mouseActive || all.length === 0) return
- if (active() === others.key(all[0])) {
- scrollRef()?.scrollTo(0, 0)
- return
- }
- const element = scrollRef()?.querySelector(`[data-key="${active()}"]`)
- element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
- })
-
- const handleInput = (value: string) => {
- onInput(value)
- reset()
- }
-
const handleSelect = (item: T | undefined) => {
others.onSelect?.(item)
closeButton.click()
}
const handleKey = (e: KeyboardEvent) => {
- setStore("mouseActive", false)
if (e.key === "Escape") return
-
- const all = flat()
- const selected = all.find((x) => others.key(x) === active())
- props.onKeyEvent?.(e, selected)
-
- if (e.key === "Enter") {
- e.preventDefault()
- if (selected) handleSelect(selected)
- } else {
- onKeyDown(e)
- }
+ listRef?.onKeyDown(e)
}
const handleOpenChange = (open: boolean) => {
- if (!open) clear()
+ if (!open) setFilter("")
props.onOpenChange?.(open)
}
@@ -113,7 +61,7 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
data-slot="select-dialog-input"
type="text"
value={filter()}
- onChange={(value) => handleInput(value)}
+ onChange={setFilter}
onKeyDown={handleKey}
placeholder={others.placeholder}
spellcheck={false}
@@ -123,63 +71,29 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) {
/>
</div>
<Show when={filter()}>
- <IconButton
- icon="circle-x"
- variant="ghost"
- onClick={() => {
- onInput("")
- reset()
- }}
- />
+ <IconButton icon="circle-x" variant="ghost" onClick={() => setFilter("")} />
</Show>
</div>
- <Dialog.Body ref={setScrollRef} data-component="select-dialog" class="no-scrollbar">
- <Show
- when={flat().length > 0}
- fallback={
- <div data-slot="select-dialog-empty-state">
- <div data-slot="select-dialog-message">
- {props.emptyMessage ?? "No results"} for{" "}
- <span data-slot="select-dialog-filter">&quot;{filter()}&quot;</span>
- </div>
- </div>
- }
+ <Dialog.Body>
+ <List
+ ref={(ref) => {
+ listRef = ref
+ }}
+ items={others.items}
+ key={others.key}
+ filterKeys={others.filterKeys}
+ current={others.current}
+ groupBy={others.groupBy}
+ sortBy={others.sortBy}
+ sortGroupsBy={others.sortGroupsBy}
+ emptyMessage={others.emptyMessage}
+ activeIcon={others.activeIcon}
+ filter={filter()}
+ onSelect={handleSelect}
+ onKeyEvent={others.onKeyEvent}
>
- <For each={grouped()}>
- {(group) => (
- <div data-slot="select-dialog-group">
- <Show when={group.category}>
- <div data-slot="select-dialog-header">{group.category}</div>
- </Show>
- <div data-slot="select-dialog-list">
- <For each={group.items}>
- {(item) => (
- <button
- data-slot="select-dialog-item"
- data-key={others.key(item)}
- data-active={others.key(item) === active()}
- data-selected={item === others.current}
- onClick={() => handleSelect(item)}
- onMouseMove={() => {
- setStore("mouseActive", true)
- setActive(others.key(item))
- }}
- >
- {others.children(item)}
- <Show when={item === others.current}>
- <Icon data-slot="select-dialog-item-selected-icon" name="check-small" />
- </Show>
- <Show when={others.activeIcon}>
- {(icon) => <Icon data-slot="select-dialog-item-active-icon" name={icon()} />}
- </Show>
- </button>
- )}
- </For>
- </div>
- </div>
- )}
- </For>
- </Show>
+ {others.children}
+ </List>
</Dialog.Body>
</div>
</Dialog>
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 074859f35..4c7f6e80b 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -22,6 +22,7 @@
@import "../components/icon.css" layer(components);
@import "../components/icon-button.css" layer(components);
@import "../components/input.css" layer(components);
+@import "../components/list.css" layer(components);
@import "../components/logo.css" layer(components);
@import "../components/markdown.css" layer(components);
@import "../components/message-part.css" layer(components);