import { type FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, createSignal, For, onCleanup, type JSX, on, Show } from "solid-js" import { createStore } from "solid-js/store" import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') for (const node of nodes) { if (node.getAttribute("data-key") === key) return node } } export interface ListSearchProps { placeholder?: string autofocus?: boolean hideIcon?: boolean class?: string action?: JSX.Element } export interface ListProps extends FilteredListProps { class?: string children: (item: T) => JSX.Element emptyMessage?: string loadingMessage?: string onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void onMove?: (item: T | undefined) => void activeIcon?: IconProps["name"] filter?: string search?: ListSearchProps | boolean itemWrapper?: (item: T, node: JSX.Element) => JSX.Element } export interface ListRef { onKeyDown: (e: KeyboardEvent) => void setScrollRef: (el: HTMLDivElement | undefined) => void } export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { const i18n = useI18n() const [scrollRef, setScrollRef] = createSignal(undefined) const [internalFilter, setInternalFilter] = createSignal("") const [store, setStore] = createStore({ mouseActive: false, }) const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => { const containerRect = container.getBoundingClientRect() const nodeRect = node.getBoundingClientRect() const top = nodeRect.top - containerRect.top + container.scrollTop const bottom = top + nodeRect.height const viewTop = container.scrollTop const viewBottom = viewTop + container.clientHeight const target = block === "center" ? top - container.clientHeight / 2 + nodeRect.height / 2 : top < viewTop ? top : bottom > viewBottom ? bottom - container.clientHeight : viewTop const max = Math.max(0, container.scrollHeight - container.clientHeight) container.scrollTop = Math.max(0, Math.min(target, max)) } const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) const searchProps = () => (typeof props.search === "object" ? props.search : {}) const searchAction = () => searchProps().action const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0 createEffect(() => { if (props.filter !== undefined) { onInput(props.filter) } }) createEffect((prev) => { if (!props.search) return const current = internalFilter() if (prev !== current) { onInput(current) } return current }, "") createEffect( on( filter, () => { scrollRef()?.scrollTo(0, 0) }, { defer: true }, ), ) createEffect(() => { const scroll = scrollRef() if (!scroll) return if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { const element = findByKey(scroll, key) if (!element) return scrollIntoView(scroll, element, "center") }) }) createEffect(() => { const all = flat() if (store.mouseActive || all.length === 0) return const scroll = scrollRef() if (!scroll) return if (active() === props.key(all[0])) { scroll.scrollTo(0, 0) return } const key = active() if (!key) return const element = findByKey(scroll, key) if (!element) return scrollIntoView(scroll, element, "center") }) createEffect(() => { const all = flat() const current = active() const item = all.find((x) => props.key(x) === current) props.onMove?.(item) }) const handleSelect = (item: T | undefined, index: number) => { props.onSelect?.(item, index) } const handleKey = (e: KeyboardEvent) => { setStore("mouseActive", false) if (e.key === "Escape") return const all = flat() const selected = all.find((x) => props.key(x) === active()) const index = selected ? all.indexOf(selected) : -1 props.onKeyEvent?.(e, selected) if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) } else { onKeyDown(e) } } props.ref?.({ onKeyDown: handleKey, setScrollRef, }) function GroupHeader(groupProps: { category: string }): JSX.Element { const [stuck, setStuck] = createSignal(false) const [header, setHeader] = createSignal(undefined) createEffect(() => { const scroll = scrollRef() const node = header() if (!scroll || !node) return const handler = () => { const rect = node.getBoundingClientRect() const scrollRect = scroll.getBoundingClientRect() setStuck(rect.top <= scrollRect.top + 1 && scroll.scrollTop > 0) } scroll.addEventListener("scroll", handler, { passive: true }) handler() onCleanup(() => scroll.removeEventListener("scroll", handler)) }) return (
{groupProps.category}
) } const emptyMessage = () => { if (grouped.loading) return props.loadingMessage ?? i18n.t("ui.list.loading") if (props.emptyMessage) return props.emptyMessage const query = filter() if (!query) return i18n.t("ui.list.empty") const suffix = i18n.t("ui.list.emptyWithFilter.suffix") return ( <> {i18n.t("ui.list.emptyWithFilter.prefix")} "{query}" {suffix} ) } return (
setInternalFilter("")} aria-label={i18n.t("ui.list.clearFilter")} />
{searchAction()}
0} fallback={
{emptyMessage()}
} > {(group) => (
{(item, i) => { const node = ( ) if (props.itemWrapper) return props.itemWrapper(item, node) return node }}
)}
) }