mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 20:21:39 +03:00
fix(app): make dropdowns open instantly (#59268)
This commit is contained in:
committed by
GitHub
Unverified
parent
d78b817631
commit
112427aeb9
@@ -76,13 +76,14 @@ test("the combined location picker shows checkout branches and chooses the base
|
||||
const screen = page.locator('[data-component="new-session"]')
|
||||
const base = screen.getByRole("button", { name: "from timeline-quotes", exact: true })
|
||||
await expect(base).toBeVisible()
|
||||
expect(searches).toEqual([])
|
||||
await expect.poll(() => searches).toEqual([{ directory: view.directory, search: "", limit: 50 }])
|
||||
await base.click()
|
||||
const search = page.getByRole("combobox", { name: "Search branches", exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveAttribute("aria-busy", "true")
|
||||
await expect(search).toHaveCount(0)
|
||||
await expect(newSessionLocation(page)).toBeVisible()
|
||||
listed.resolve(["timeline-quotes", "release/base", "origin/dev"])
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveAttribute("aria-busy", "false")
|
||||
await expect(page.getByRole("option", { name: "release/base", exact: true })).toBeVisible()
|
||||
await search.fill("release")
|
||||
await expect(search).toHaveAttribute("aria-busy", "false")
|
||||
@@ -96,6 +97,15 @@ test("the combined location picker shows checkout branches and chooses the base
|
||||
{ directory: view.directory, search: "release", limit: 50 },
|
||||
])
|
||||
|
||||
await selected.click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(page.getByRole("option")).toHaveCount(3)
|
||||
// Reopening revalidates the branch list.
|
||||
expect(searches).toHaveLength(3)
|
||||
await page.getByRole("option", { name: "release/base", exact: true }).click()
|
||||
await expect(view.editor).toBeFocused()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -136,6 +146,55 @@ test("the combined location picker shows checkout branches and chooses the base
|
||||
.toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })])
|
||||
})
|
||||
|
||||
test("a pending branch picker can be dismissed before its results arrive", async ({ page }) => {
|
||||
const listed = Promise.withResolvers<string[]>()
|
||||
|
||||
const view = await openWorktreeDraft(page, {
|
||||
name: "BranchDismiss",
|
||||
vcs: () => ({ current: "dev", default: "dev" }),
|
||||
branches: () => listed.promise,
|
||||
})
|
||||
|
||||
const base = page.getByRole("button", { name: "from dev", exact: true })
|
||||
const search = page.getByRole("combobox", { name: "Search branches", exact: true })
|
||||
await base.click()
|
||||
await expect(base).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(search).toHaveCount(0)
|
||||
await view.editor.click()
|
||||
await expect(base).toHaveAttribute("aria-expanded", "false")
|
||||
await base.click()
|
||||
await base.press("Escape")
|
||||
await expect(base).toHaveAttribute("aria-expanded", "false")
|
||||
await base.click()
|
||||
await base.click()
|
||||
await expect(base).toHaveAttribute("aria-expanded", "false")
|
||||
listed.resolve(["dev", "origin/dev"])
|
||||
await expect(base).toHaveAttribute("aria-busy", "false")
|
||||
await expect(search).toHaveCount(0)
|
||||
await base.click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(page.getByRole("option")).toHaveText(["dev", "origin/dev"])
|
||||
})
|
||||
|
||||
test("reopening the branch picker shows branches created since it last opened", async ({ page }) => {
|
||||
let created = false
|
||||
|
||||
await openWorktreeDraft(page, {
|
||||
name: "BranchRefresh",
|
||||
vcs: () => ({ current: "dev", default: "dev" }),
|
||||
branches: () => (created ? ["dev", "feature/new"] : ["dev"]),
|
||||
})
|
||||
|
||||
const base = page.getByRole("button", { name: "from dev", exact: true })
|
||||
await base.click()
|
||||
await expect(page.getByRole("option")).toHaveText(["dev"])
|
||||
created = true
|
||||
await base.click()
|
||||
await expect(page.getByRole("combobox", { name: "Search branches", exact: true })).toHaveCount(0)
|
||||
await base.click()
|
||||
await expect(page.getByRole("option")).toHaveText(["dev", "feature/new"])
|
||||
})
|
||||
|
||||
test("branch search keeps results on screen while a query is pending", async ({ page }) => {
|
||||
const held = Promise.withResolvers<string[]>()
|
||||
|
||||
@@ -174,11 +233,64 @@ test("branch search keeps results on screen while a query is pending", async ({
|
||||
await search.pressSequentially("-missing")
|
||||
await expect(options).toHaveText(["timeline-quotes"])
|
||||
await expect(page.getByRole("listbox")).not.toHaveAttribute("data-emptied")
|
||||
|
||||
held.resolve([])
|
||||
await expect(page.getByText("No results found", { exact: true })).toBeVisible()
|
||||
await expect(search).toHaveAttribute("aria-busy", "false")
|
||||
})
|
||||
|
||||
test("the empty branch search state is as tall as a single result", async ({ page }) => {
|
||||
await openWorktreeDraft(page, {
|
||||
name: "BranchEmptyHeight",
|
||||
vcs: () => ({ current: "dev", default: "dev" }),
|
||||
branches: () => ["dev"],
|
||||
})
|
||||
|
||||
await page.getByRole("button", { name: "from dev", exact: true }).click()
|
||||
await expect(page.getByRole("option")).toHaveCount(1)
|
||||
const panel = page.getByRole("dialog")
|
||||
// Measured on open, before any search animates the panel.
|
||||
const single = await panel.evaluate((element) => element.getBoundingClientRect().height)
|
||||
|
||||
await page.getByRole("combobox", { name: "Search branches", exact: true }).fill("missing")
|
||||
await expect(page.getByText("No results found", { exact: true })).toBeVisible()
|
||||
await expect.poll(() => panel.evaluate((element) => element.getBoundingClientRect().height)).toBe(single)
|
||||
})
|
||||
|
||||
test("backspacing keeps confirmed branch results until the broader search answers", async ({ page }) => {
|
||||
const broader = Promise.withResolvers<string[]>()
|
||||
const closed = ["closed-search-beta", "closed-session-search", "reopen-closed-tab", "recently-closed-projects"]
|
||||
|
||||
await openWorktreeDraft(page, {
|
||||
name: "BranchBackspace",
|
||||
vcs: () => ({ current: "dev", default: "dev" }),
|
||||
branches: (input) => {
|
||||
if (input.search === "clo") return broader.promise
|
||||
|
||||
if (input.search) return closed
|
||||
|
||||
return [...closed, "origin/cloudflare-run-images", "origin/cloudflare-run-functions"]
|
||||
},
|
||||
})
|
||||
|
||||
await page.getByRole("button", { name: "from dev", exact: true }).click()
|
||||
const search = page.getByRole("combobox", { name: "Search branches", exact: true })
|
||||
const options = page.getByRole("option")
|
||||
await expect(options).toHaveCount(6)
|
||||
await search.fill("closed")
|
||||
await expect(search).toHaveAttribute("aria-busy", "false")
|
||||
await expect(options).toHaveText(closed)
|
||||
await search.press("Backspace")
|
||||
await search.press("Backspace")
|
||||
await search.press("Backspace")
|
||||
await expect(search).toHaveValue("clo")
|
||||
await expect(search).toHaveAttribute("aria-busy", "true")
|
||||
await expect(options).toHaveText(closed)
|
||||
broader.resolve([...closed, "clover-fix"])
|
||||
await expect(search).toHaveAttribute("aria-busy", "false")
|
||||
await expect(options).toHaveText([...closed, "clover-fix"])
|
||||
})
|
||||
|
||||
test("the dark new session panel exposes no lighter background at its rounded corners", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 935, height: 522 })
|
||||
await openDraft(page, { name: "PanelCorner", seed: { theme: { id: "oc-2", scheme: "dark" } } })
|
||||
|
||||
@@ -60,9 +60,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
|
||||
const [state, setState] = createStore({
|
||||
existing: undefined as { projectID: string; directory: string } | undefined,
|
||||
})
|
||||
const [state, setState] = createStore<{ existing?: { projectID: string; directory: string } }>({})
|
||||
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
@@ -267,7 +265,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
visible,
|
||||
branch,
|
||||
},
|
||||
branches: (search: string, signal: AbortSignal) =>
|
||||
branches: (search: string, signal?: AbortSignal) =>
|
||||
serverSDK.api.vcs.branch
|
||||
.list({ location: { directory: projectRoot() }, search, limit: 50 }, { signal })
|
||||
.then((response) => response.data),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Show } from "solid-js"
|
||||
import { createEffect, createResource, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
@@ -14,8 +15,50 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
const [state, setState] = createStore({ open: false, truncated: false })
|
||||
|
||||
let outside = false
|
||||
let trigger: HTMLButtonElement | undefined
|
||||
const creating = () => props.workspace.selection.value() === "create"
|
||||
|
||||
// Fetch before opening and retain the result across closes, rather than showing a smaller loading panel.
|
||||
const [branches, branchActions] = createResource(
|
||||
() => (creating() ? props.workspace.project.root() : undefined),
|
||||
async (directory) => ({
|
||||
directory,
|
||||
...(await props.workspace.branches("").then(
|
||||
(items) => ({ items, failed: false }),
|
||||
() => ({ items: [], failed: true }),
|
||||
)),
|
||||
}),
|
||||
)
|
||||
|
||||
const initial = () => {
|
||||
if (branches.state !== "ready" && branches.state !== "refreshing") return
|
||||
const result = branches.latest
|
||||
|
||||
return result?.directory === props.workspace.project.root() ? result : undefined
|
||||
}
|
||||
|
||||
// Until content mounts, Kobalte has no dismissable layer to cancel a pending open.
|
||||
createEffect(() => {
|
||||
if (!state.open || initial()) return
|
||||
|
||||
const close = (event: Event) => {
|
||||
if (event.target instanceof Node && trigger?.contains(event.target)) return
|
||||
setState("open", false)
|
||||
}
|
||||
|
||||
makeEventListener(document, "pointerdown", close, { capture: true })
|
||||
makeEventListener(document, "focusin", close)
|
||||
makeEventListener(window, "blur", close)
|
||||
makeEventListener(
|
||||
window,
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key === "Escape") setState("open", false)
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
})
|
||||
|
||||
const label = () =>
|
||||
creating()
|
||||
? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" })
|
||||
@@ -46,11 +89,20 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
>
|
||||
<Popover
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
onOpenChange={(open) => {
|
||||
setState("open", open)
|
||||
|
||||
// Revalidate on every open, as before the preload; the loaded list stays on screen meanwhile.
|
||||
if (open && initial()) branchActions.refetch()
|
||||
}}
|
||||
placement="bottom-start"
|
||||
gutter={4}
|
||||
>
|
||||
<Popover.Trigger class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 rounded-sm px-1.5 text-v2-text-text-muted transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-base">
|
||||
<Popover.Trigger
|
||||
ref={trigger}
|
||||
aria-busy={branches.loading}
|
||||
class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 rounded-sm px-1.5 text-v2-text-text-muted transition-colors hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-base"
|
||||
>
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={observe} dir="auto" class="min-w-0 truncate">
|
||||
{label()}
|
||||
@@ -58,37 +110,50 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll
|
||||
<Icon name="chevron-down" size="small" class="-ms-0.5 shrink-0 text-v2-icon-icon-muted" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
class="w-[243px] overflow-hidden rounded-lg bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onInteractOutside={() => (outside = true)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
// An outside click keeps focus where the user put it.
|
||||
if (event.defaultPrevented || outside) return (outside = false)
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
}}
|
||||
>
|
||||
<Typeahead
|
||||
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
emptyLabel={language.t("palette.empty")}
|
||||
retryLabel={language.t("common.retry")}
|
||||
source={props.workspace.branches}
|
||||
match={(branch, query) => branch.toLowerCase().includes(query.toLowerCase())}
|
||||
delay={100}
|
||||
key={(branch) => branch}
|
||||
selected={props.workspace.bar.branch()}
|
||||
onSelect={(branch) => {
|
||||
props.workspace.selection.branch(branch)
|
||||
setState("open", false)
|
||||
}}
|
||||
>
|
||||
{(branch) => (
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{branch}
|
||||
</span>
|
||||
)}
|
||||
</Typeahead>
|
||||
</Popover.Content>
|
||||
<Show when={initial()}>
|
||||
{(initial) => (
|
||||
<Popover.Content
|
||||
class="w-[243px] overflow-hidden rounded-lg bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onInteractOutside={() => (outside = true)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
// An outside click keeps focus where the user put it.
|
||||
if (event.defaultPrevented || outside) return (outside = false)
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
}}
|
||||
>
|
||||
<Typeahead
|
||||
initial={initial()}
|
||||
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
|
||||
emptyLabel={language.t("palette.empty")}
|
||||
retryLabel={language.t("common.retry")}
|
||||
source={async (query, signal) => {
|
||||
const directory = props.workspace.project.root()
|
||||
const items = await props.workspace.branches(query, signal)
|
||||
|
||||
if (!query && directory === props.workspace.project.root())
|
||||
branchActions.mutate({ directory, items, failed: false })
|
||||
|
||||
return items
|
||||
}}
|
||||
match={(branch, query) => branch.toLowerCase().includes(query.toLowerCase())}
|
||||
delay={100}
|
||||
key={(branch) => branch}
|
||||
selected={props.workspace.bar.branch()}
|
||||
onSelect={(branch) => {
|
||||
props.workspace.selection.branch(branch)
|
||||
setState("open", false)
|
||||
}}
|
||||
>
|
||||
{(branch) => (
|
||||
<span dir="auto" class="min-w-0 truncate">
|
||||
{branch}
|
||||
</span>
|
||||
)}
|
||||
</Typeahead>
|
||||
</Popover.Content>
|
||||
)}
|
||||
</Show>
|
||||
</Popover.Portal>
|
||||
</Popover>
|
||||
</Show>
|
||||
|
||||
@@ -13,20 +13,10 @@
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
transform-origin: var(--kb-popover-content-transform-origin);
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&[data-closed] {
|
||||
animation: popover-close 0.15s ease-out;
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
animation: popover-open 0.15s ease-out;
|
||||
}
|
||||
|
||||
[data-slot="popover-header"] {
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
@@ -74,25 +64,3 @@
|
||||
fill: var(--surface-raised-stronger-non-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popover-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes popover-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createEffect, createMemo, For, type JSX, onCleanup, Show } from "solid-js"
|
||||
import { createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { abort } from "@opencode/util/abort"
|
||||
@@ -12,6 +12,8 @@ export { createTypeahead } from "./controller"
|
||||
/** A search field over results from `source` that keeps rows on screen while a query is pending. */
|
||||
export function Typeahead<T>(props: {
|
||||
source: (query: string, signal: AbortSignal) => Promise<T[]>
|
||||
/** Preloaded opening results, including a failed request that the user can retry. */
|
||||
initial?: { items: T[]; failed?: boolean }
|
||||
/** Narrows earlier results while the request for the current query is pending. */
|
||||
match: (item: T, query: string) => boolean
|
||||
key: (item: T) => string
|
||||
@@ -27,17 +29,23 @@ export function Typeahead<T>(props: {
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
|
||||
const [store, setStore] = createStore<{ query: string; answered?: string; failed?: boolean; loading?: boolean }>({
|
||||
query: "",
|
||||
})
|
||||
const [store, setStore] = createStore<{
|
||||
query: string
|
||||
searched: boolean
|
||||
answered?: string
|
||||
failed?: boolean
|
||||
loading?: boolean
|
||||
}>({ query: "", searched: false, answered: props.initial ? "" : undefined })
|
||||
|
||||
const cache = new Map<string, T[]>()
|
||||
let seen: T[] = []
|
||||
let request: AbortController | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
onCleanup(() => abort(request))
|
||||
|
||||
const query = () => store.query.trim()
|
||||
// The parent owns the opening list, so a refresh reaches an open panel without remounting the input.
|
||||
const opening = () => (query() ? undefined : props.initial)
|
||||
const failed = () => (opening() ? opening()?.failed : store.failed)
|
||||
const pending = () => store.loading || store.answered !== query()
|
||||
|
||||
const search = (query: string) => {
|
||||
@@ -49,9 +57,7 @@ export function Typeahead<T>(props: {
|
||||
props.source(query, current.signal).then(
|
||||
(items) => {
|
||||
if (current.signal.aborted) return
|
||||
const keys = new Set(items.map(props.key))
|
||||
cache.set(query, items)
|
||||
seen = [...items, ...seen.filter((item) => !keys.has(props.key(item)))].slice(0, 500)
|
||||
setStore({ answered: query, failed: false, loading: false })
|
||||
},
|
||||
() => !current.signal.aborted && setStore({ answered: query, failed: true, loading: false }),
|
||||
@@ -59,27 +65,36 @@ export function Typeahead<T>(props: {
|
||||
}
|
||||
|
||||
// A cached or empty query answers at once; a typed one waits, and the request it supersedes is aborted.
|
||||
createEffect(() => {
|
||||
const next = query()
|
||||
createEffect(
|
||||
on(
|
||||
query,
|
||||
(next) => {
|
||||
if (!next || cache.has(next)) return search(next)
|
||||
abort(request)
|
||||
const timer = setTimeout(() => search(next), props.delay)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
},
|
||||
{ defer: !!props.initial },
|
||||
),
|
||||
)
|
||||
|
||||
if (!next || cache.has(next)) return search(next)
|
||||
abort(request)
|
||||
const timer = setTimeout(() => search(next), props.delay)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
// While a query is pending: its cached answer, else earlier rows that match it, else the rows on screen, marked stale.
|
||||
// Narrow visible rows while pending; broadening waits for a cached or server answer, avoiding speculative rows flashing.
|
||||
const results = createMemo<{ items: T[]; stale: boolean }>((previous) => {
|
||||
const next = query()
|
||||
const preloaded = opening()
|
||||
|
||||
if (preloaded) return { items: preloaded.failed ? [] : preloaded.items, stale: false }
|
||||
|
||||
if (store.answered === next && store.failed) return { items: [], stale: false }
|
||||
const cached = cache.get(next) ?? seen.filter((item) => props.match(item, next))
|
||||
const cached = cache.get(next) ?? previous?.items.filter((item) => props.match(item, next)) ?? []
|
||||
|
||||
if (cache.has(next) || cached.length > 0) return { items: cached, stale: false }
|
||||
|
||||
return { items: previous?.items ?? [], stale: !!previous?.items.length }
|
||||
})
|
||||
|
||||
const animateHeight = () => store.searched && results().items.length > 0
|
||||
|
||||
const typeahead = createTypeahead<T>({
|
||||
items: () => results().items,
|
||||
key: (item) => props.key(item),
|
||||
@@ -119,7 +134,7 @@ export function Typeahead<T>(props: {
|
||||
autocomplete="off"
|
||||
spellcheck={false}
|
||||
class="h-8 min-w-0 flex-1 outline-none placeholder:text-v2-text-text-faint"
|
||||
onInput={(event) => setStore("query", event.currentTarget.value)}
|
||||
onInput={(event) => setStore({ query: event.currentTarget.value, searched: true })}
|
||||
onKeyDown={typeahead.onKeyDown}
|
||||
/>
|
||||
<Show when={store.query}>
|
||||
@@ -137,8 +152,11 @@ export function Typeahead<T>(props: {
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
{/* Height follows the results on a critically damped spring (0.3 s, bounce 0); the first measurement snaps. */}
|
||||
<div class="overflow-hidden transition-[height] duration-[350ms] ease-[linear(0,0.097,0.282,0.466,0.619,0.736,0.821,0.881,0.921,0.949,0.967,0.979,0.986,0.991,1)] motion-reduce:transition-none">
|
||||
{/* Initial results and the empty state snap into place; other search resizing animates. */}
|
||||
<div
|
||||
class="overflow-hidden duration-[200ms] ease-[cubic-bezier(0.645,0.045,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "transition-[height]": animateHeight(), "transition-none": !animateHeight() }}
|
||||
>
|
||||
<div
|
||||
ref={(element) =>
|
||||
createResizeObserver(element, (rect) =>
|
||||
@@ -146,11 +164,7 @@ export function Typeahead<T>(props: {
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
id={typeahead.listID}
|
||||
role="listbox"
|
||||
class="max-h-56 overflow-y-auto px-1 pb-1 empty:hidden"
|
||||
>
|
||||
<div id={typeahead.listID} role="listbox" class="max-h-56 overflow-y-auto px-1 pb-1 empty:hidden">
|
||||
<For each={results().items}>
|
||||
{(item) => (
|
||||
<div
|
||||
@@ -178,10 +192,10 @@ export function Typeahead<T>(props: {
|
||||
{/* Stays mounted while a retry or a newer query is pending, so the list never collapses to nothing. */}
|
||||
<Show when={results().items.length === 0}>
|
||||
<div
|
||||
class="mx-1 mb-1 flex h-7 items-center px-2 text-v2-text-text-faint"
|
||||
class="mx-1 flex h-8 items-center px-2 pb-1 text-v2-text-text-faint"
|
||||
classList={{ invisible: store.answered === undefined }}
|
||||
>
|
||||
<Show when={store.failed} fallback={props.emptyLabel}>
|
||||
<Show when={failed()} fallback={props.emptyLabel}>
|
||||
<button
|
||||
type="button"
|
||||
class="-mx-2 flex h-7 flex-1 items-center rounded-sm px-2 text-start text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover"
|
||||
|
||||
@@ -23,9 +23,6 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: geometricPrecision;
|
||||
|
||||
transform-origin: var(--kb-menu-content-transform-origin);
|
||||
animation: menu-v2-in 120ms ease-out;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -196,11 +193,6 @@
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 60;
|
||||
animation: dropdown-menu-open 150ms ease-out;
|
||||
|
||||
&[data-closed] {
|
||||
animation: dropdown-menu-close 150ms ease-out;
|
||||
}
|
||||
|
||||
[data-component="menu-v2-item"] {
|
||||
position: relative;
|
||||
@@ -285,28 +277,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-menu-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dropdown-menu-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes menu-v2-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
Reference in New Issue
Block a user