<script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import ArrowRight from "$icon/arrow-right";
import Search from "$icon/search";
import SearchX from "$icon/search-x";
import { getAuth } from "#lib/auth.svelte.ts";
import { createBobbinClient } from "#lib/api/client.ts";
import { repoKey, type RepoCardData } from "#lib/components/profile/types.ts";
import RepoCard from "#lib/components/repo/RepoCard.svelte";
import { searchRepos, type RepoSearchOutcome } from "#lib/components/search/repoSearch.ts";
import {
QUICK_SEARCH_DELAY,
QUICK_SEARCH_LIMIT,
moveActive,
shortcutLabel
} from "#lib/components/shell/topbarSearch.ts";
import Button from "#lib/components/ui/Button.svelte";
import { combobox } from "#lib/components/ui/Combobox.svelte";
import Input from "#lib/components/ui/Input.svelte";
import { kbd } from "#lib/components/ui/Kbd.svelte";
import { isMac } from "#lib/components/ui/keyLabels.ts";
interface Props {
sheet?: boolean;
onnavigate?: () => void;
}
let { sheet = false, onnavigate }: Props = $props();
const auth = getAuth();
const id = $props.id();
const listId = `${id}-list`;
const optionId = (index: number) => `${id}-option-${index}`;
const classes = combobox();
let value = $state("");
let focused = $state(false);
let loading = $state(false);
let activeIndex = $state(-1);
let outcome = $state<{ query: string; result: RepoSearchOutcome } | null>(null);
let input = $state<HTMLInputElement>();
let mac = $state<boolean>();
const query = $derived(value.trim());
const items = $derived(outcome?.result.result?.items ?? []);
const open = $derived((focused || sheet) && query !== "" && outcome !== null);
const searchHref = $derived(resolve(`/search?q=${encodeURIComponent(query)}` as "/"));
const repoHref = (repo: RepoCardData) => resolve(`/${repo.ownerHandle}/${repo.name}` as "/");
$effect(() => {
mac = isMac();
if (sheet) requestAnimationFrame(() => input?.focus());
});
$effect(() => {
const q = query;
if (!q) {
outcome = null;
loading = false;
return;
}
loading = true;
let stale = false;
const timer = setTimeout(async () => {
const result = await searchRepos(createBobbinClient({ serviceUrl: auth.bobbinUrl }), {
q,
viewerDid: auth.currentDid ?? undefined,
limit: QUICK_SEARCH_LIMIT
}).catch((): RepoSearchOutcome => ({ result: null, error: "Could not search." }));
if (stale) return;
outcome = { query: q, result };
activeIndex = -1;
loading = false;
}, QUICK_SEARCH_DELAY);
return () => {
stale = true;
clearTimeout(timer);
};
});
const finish = () => {
input?.blur();
onnavigate?.();
};
const onkeydown = (event: KeyboardEvent) => {
if (event.isComposing) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
if (!open || items.length === 0) return;
event.preventDefault();
activeIndex = moveActive(activeIndex, event.key === "ArrowDown" ? 1 : -1, items.length);
} else if (event.key === "Enter") {
const repo = open ? items[activeIndex] : undefined;
if (repo) {
event.preventDefault();
void goto(repoHref(repo));
}
finish();
} else if (event.key === "Escape" && !sheet) {
event.preventDefault();
input?.blur();
}
};
const onWindowKeydown = (event: KeyboardEvent) => {
if (sheet || event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
if (event.key.toLowerCase() !== "k") return;
event.preventDefault();
input?.focus();
input?.select();
};
const onfocusout = (event: FocusEvent) => {
const container = event.currentTarget as HTMLElement;
if (!container.contains(event.relatedTarget as Node | null)) focused = false;
};
const onOptionClick = (repo: RepoCardData) => {
void goto(repoHref(repo));
finish();
};
</script>
<svelte:window onkeydown={onWindowKeydown} />
<form
action={resolve("/search")}
method="GET"
role="search"
class={sheet ? "flex min-h-0 flex-1 flex-col gap-xs" : "relative hidden md:block"}
onfocusin={() => (focused = true)}
{onfocusout}
>
<Input
bind:element={input}
bind:value
name="q"
type="search"
placeholder="Search"
label="Search"
iconLeft={Search}
{loading}
suffix={!sheet && mac !== undefined && !focused && !value ? shortcutLabel(mac) : undefined}
suffixClass={kbd()}
class={sheet ? "w-full" : "w-80"}
size={sheet ? "regular" : "small"}
autocomplete="off"
enterkeyhint="search"
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={open && activeIndex >= 0 ? optionId(activeIndex) : undefined}
{onkeydown}
/>
{#if open && outcome}
<div
role="presentation"
class={sheet
? "flex min-h-0 flex-1 flex-col"
: "absolute inset-x-0 top-full z-50 mt-2xs flex max-h-[calc(100dvh-4rem)] flex-col overflow-hidden rounded-md bg-background-default shadow-regular edge-ring dark:shadow-none"}
onmousedown={(event) => event.preventDefault()}
>
{#if items.length > 0}
<div
id={listId}
role="listbox"
aria-label="Repositories"
class={classes.list({
class: sheet && "-mx-xs flex flex-col gap-xs px-none pt-none pb-xs"
})}
>
{#each items as repo, index (repoKey(repo))}
<div
id={optionId(index)}
role="option"
tabindex={-1}
aria-selected={index === activeIndex}
data-active={index === activeIndex || undefined}
class={classes.option({ class: sheet && "py-xs" })}
onclick={() => onOptionClick(repo)}
{onkeydown}
onpointermove={() => (activeIndex = index)}
>
<RepoCard
{repo}
starButton={false}
links={false}
border={false}
background={false}
padding="none"
class="w-full"
/>
</div>
{/each}
</div>
<div
class={classes.footer({
class: [
"flex items-center justify-between typography-paragraph-small text-foreground-muted",
sheet ? "pt-sm" : "px-sm py-xs"
]
})}
>
<span
>Showing {items.length} of {outcome.result.result?.total ??
items.length}</span
>
<Button href={searchHref} icon={ArrowRight} iconSide="right" onclick={finish}>
All results
</Button>
</div>
{:else}
<p id={listId} class={classes.empty({ class: sheet && "my-auto" })}>
{#if outcome.result.error}
{outcome.result.error}
{:else}
<SearchX
class="mx-auto mb-sm size-10 text-foreground-disabled"
aria-hidden="true"
/>
No results for "{outcome.query}"
{/if}
</p>
{/if}
</div>
{/if}
</form>