wip(desktop): progress

This commit is contained in:
Adam 2025-12-10 15:16:57 -06:00
parent 190fa4c87a
commit 58e66dd3d1
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
5 changed files with 290 additions and 227 deletions

View file

@ -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);
}
}
}
}
}

View file

@ -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>
)
}

View file

@ -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);
}
}
}
}
}

View file

@ -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,
})
const [filter, setFilter] = createSignal("")
let listRef: ListRef | undefined
createEffect(() => {
filter()
scrollRef()?.scrollTo(0, 0)
reset()
})
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>

View file

@ -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);