Adam 2 месяцев назад
Родитель
Сommit
58e66dd3d1

+ 107 - 0
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);
+        }
+      }
+    }
+  }
+}

+ 141 - 0
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>
+  )
+}

+ 9 - 109
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);
-        }
-      }
-    }
-  }
-}

+ 32 - 118
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>

+ 1 - 0
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);