mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
wip(desktop): progress
This commit is contained in:
parent
190fa4c87a
commit
58e66dd3d1
5 changed files with 290 additions and 227 deletions
107
packages/ui/src/components/list.css
Normal file
107
packages/ui/src/components/list.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
packages/ui/src/components/list.tsx
Normal file
141
packages/ui/src/components/list.tsx
Normal 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">"{filter()}"</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">"{filter()}"</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue