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 extends FilteredListProps { + 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(props: ListProps & { ref?: (ref: ListRef) => void }) { + const [scrollRef, setScrollRef] = createSignal(undefined) + const [store, setStore] = createStore({ + mouseActive: false, + }) + + const { filter, grouped, flat, reset, active, setActive, onKeyDown, onInput } = useFilteredList({ + 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 ( +
+ 0} + fallback={ +
+
+ {props.emptyMessage ?? "No results"} for "{filter()}" +
+
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item) => ( + + )} + +
+
+ )} +
+
+
+ ) +} 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 - extends FilteredListProps, + extends Omit, "filter">, Pick { 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(props: SelectDialogProps) { const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) let closeButton!: HTMLButtonElement let inputRef: HTMLInputElement | undefined - let [scrollRef, setScrollRef] = createSignal(undefined) - const [store, setStore] = createStore({ - mouseActive: false, - }) - - const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList({ - 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(props: SelectDialogProps) { 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(props: SelectDialogProps) { /> - { - onInput("") - reset() - }} - /> + setFilter("")} /> - - 0} - fallback={ -
-
- {props.emptyMessage ?? "No results"} for{" "} - "{filter()}" -
-
- } + + { + 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} > - - {(group) => ( -
- -
{group.category}
-
-
- - {(item) => ( - - )} - -
-
- )} -
-
+ {others.children} +
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);