Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/lib/components/dex/search-filters.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import XIcon from "@lucide/svelte/icons/x";
import { describeFilter, parseSearch, removeToken } from "$lib/dex/filters.js";

let { q, onchange }: { q: string; onchange: (q: string) => void } = $props();

const search = $derived(parseSearch(q));
</script>

{#if search.filters.length || search.invalid.length}
<ul class="flex flex-wrap gap-1.5" aria-label="Filters in your search">
{#each search.filters as filter (filter.raw)}
<li class="inline-flex h-7 items-center gap-1 rounded-md border bg-card pr-1 pl-2.5 text-sm">
{describeFilter(filter)}
<button
type="button"
onclick={() => onchange(removeToken(q, filter.raw))}
class="rounded-sm p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Remove {describeFilter(filter)}"
>
<XIcon class="size-3.5" />
</button>
</li>
{/each}
{#each search.invalid as raw (raw)}
<li class="inline-flex h-7 items-center gap-1 rounded-md border border-destructive/40 pr-1 pl-2.5 text-sm text-destructive">
Can’t read <code class="font-medium">{raw}</code>
<button
type="button"
onclick={() => onchange(removeToken(q, raw))}
class="rounded-sm p-0.5 hover:bg-destructive/10"
aria-label="Remove {raw}"
>
<XIcon class="size-3.5" />
</button>
</li>
{/each}
</ul>
{/if}
39 changes: 39 additions & 0 deletions src/lib/components/dex/search-tips.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import * as Popover from "$lib/components/ui/popover/index.js";
import { SEARCH_EXAMPLES } from "$lib/dex/filters.js";

let { onpick }: { onpick: (query: string) => void } = $props();

let open = $state(false);
</script>

<Popover.Root bind:open>
<Popover.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm" class="h-7 px-2 text-muted-foreground">Search tips</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content align="end" class="w-[min(28rem,calc(100vw-2rem))] p-0">
<p class="border-b px-4 py-3 text-sm text-muted-foreground">
Mix filters with a name. Every filter must match. Pick one to add it.
</p>
<ul class="py-1">
{#each SEARCH_EXAMPLES as example (example.query)}
<li>
<button
type="button"
onclick={() => {
onpick(example.query);
open = false;
}}
class="grid w-full grid-cols-[8.5rem_minmax(0,1fr)] items-baseline gap-3 px-4 py-1.5 text-left text-sm hover:bg-muted"
>
<code class="font-medium">{example.query}</code>
<span class="text-muted-foreground">{example.description}</span>
</button>
</li>
{/each}
</ul>
</Popover.Content>
</Popover.Root>
17 changes: 15 additions & 2 deletions src/lib/components/site/command-palette.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script lang="ts">
import DicesIcon from "@lucide/svelte/icons/dices";
import FileTextIcon from "@lucide/svelte/icons/file-text";
import ListFilterIcon from "@lucide/svelte/icons/list-filter";
import { goto } from "$app/navigation";
import TypeBadge from "$lib/components/pokemon/type-badge.svelte";
import * as Command from "$lib/components/ui/command/index.js";
import { loadDexList } from "$lib/dex/client.js";
import { parseSearch } from "$lib/dex/filters.js";
import { DEFAULT_QUERY, queryDex, type DexListEntry } from "$lib/dex/list.js";
import { matchScore } from "$lib/dex/search.js";
import { optimizedImage } from "$lib/images.js";
Expand All @@ -27,7 +29,9 @@
if (!palette.open) search = "";
});

const pokemon = $derived(search.trim() ? queryDex(entries, { ...DEFAULT_QUERY, q: search }).slice(0, 8) : []);
const matches = $derived(search.trim() ? queryDex(entries, { ...DEFAULT_QUERY, q: search }) : []);
const pokemon = $derived(matches.slice(0, 8));
const filtered = $derived(parseSearch(search).filters.length > 0);
const types = $derived(search.trim() ? TYPES.filter((t) => matchScore(search, t) !== null).slice(0, 3) : []);
const pages = $derived(navItems.filter((item) => !search.trim() || matchScore(search, item.label) !== null));

Expand Down Expand Up @@ -61,10 +65,19 @@
description="Find a Pokémon, type or page"
class="sm:max-w-xl"
>
<Command.Input bind:value={search} placeholder="Search Pokémon, types and pages" />
<Command.Input bind:value={search} placeholder="Search Pokémon, types and pages, or try spe>100" />
<Command.List class="max-h-[min(60vh,28rem)]">
<Command.Empty>No matches. Try a name or a dex number such as 25.</Command.Empty>

{#if filtered && matches.length}
<Command.Group heading="Filters">
<Command.Item value="all-matches" onSelect={() => open(`/?q=${encodeURIComponent(search.trim())}`)}>
<ListFilterIcon />
See all {matches.length} matches in the Pokédex
</Command.Item>
</Command.Group>
{/if}

{#if pokemon.length}
<Command.Group heading="Pokémon">
{#each pokemon as entry (entry.id)}
Expand Down
149 changes: 149 additions & 0 deletions src/lib/dex/filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vite-plus/test";
import { describeFilter, matchesFilters, parseSearch, removeToken, wantsForms } from "./filters.js";
import { DEFAULT_QUERY, queryDex, type DexListEntry } from "./list.js";

function entry(partial: Partial<DexListEntry> & Pick<DexListEntry, "id" | "name">): DexListEntry {
return {
slug: partial.name.toLowerCase(),
speciesId: partial.id,
isDefault: true,
types: ["normal"],
stats: [50, 50, 50, 50, 50, 50],
generation: 1,
height: 10,
weight: 100,
abilities: [],
legendary: false,
mythical: false,
baby: false,
final: true,
...partial,
};
}

const garchomp = entry({
id: 445,
name: "Garchomp",
types: ["dragon", "ground"],
stats: [108, 130, 95, 80, 85, 102],
generation: 4,
height: 19,
weight: 950,
abilities: ["sand-veil", "rough-skin"],
});
const gengar = entry({
id: 94,
name: "Gengar",
types: ["ghost", "poison"],
stats: [60, 65, 60, 130, 75, 110],
abilities: ["cursed-body"],
});
const megaGengar = entry({
id: 10038,
speciesId: 94,
name: "Mega Gengar",
form: "Mega",
isDefault: false,
types: ["ghost", "poison"],
stats: [60, 65, 80, 170, 95, 130],
abilities: ["shadow-tag"],
});
const alolanRaichu = entry({
id: 10100,
speciesId: 26,
name: "Alolan Raichu",
form: "Alolan",
isDefault: false,
types: ["electric", "psychic"],
});
const dex = [gengar, megaGengar, alolanRaichu, garchomp];

const pick = (q: string) => dex.filter((e) => matchesFilters(e, parseSearch(q).filters));

describe("parseSearch", () => {
it("separates filters from name text", () => {
const search = parseSearch("spe>100 gar type:ghost");
expect(search.text).toBe("gar");
expect(search.filters.map((f) => f.kind)).toEqual(["measure", "type"]);
expect(search.invalid).toEqual([]);
});

it("reports filters it cannot read", () => {
expect(parseSearch("type:cake spe:fast is:shiny").invalid).toEqual([
"type:cake",
"spe:fast",
"is:shiny",
]);
});

it("keeps Type: Null searchable", () => {
expect(parseSearch("type: null").text).toBe("type: null");
});

it("reads quoted ability names", () => {
const [filter] = parseSearch('ability:"rough skin"').filters;
expect(filter).toMatchObject({ kind: "ability", slug: "rough-skin" });
});
});

describe("matchesFilters", () => {
it("compares base stats and totals", () => {
expect(pick("spe>100")).toEqual([gengar, megaGengar, garchomp]);
expect(pick("spe>=110 spa<150")).toEqual([gengar]);
expect(pick("bst>=600")).toEqual([megaGengar, garchomp]);
});

it("compares height in metres and weight in kilograms", () => {
expect(pick("height>1.5 weight>=95")).toEqual([garchomp]);
});

it("filters by type matchups", () => {
expect(pick("weak:ice")).toEqual([garchomp]);
expect(pick("immune:normal")).toEqual([gengar, megaGengar]);
expect(pick("resist:fighting")).toEqual([alolanRaichu]);
});

it("filters by generation, flags and abilities", () => {
expect(pick("gen:sinnoh")).toEqual([garchomp]);
expect(pick("gen:1-3 is:regional")).toEqual([alolanRaichu]);
expect(pick("is:mega")).toEqual([megaGengar]);
expect(pick("ability:roughskin")).toEqual([garchomp]);
});

it("negates filters with a leading minus", () => {
expect(pick("-type:ghost")).toEqual([alolanRaichu, garchomp]);
expect(pick("!is:form")).toEqual([gengar, garchomp]);
});
});

describe("queryDex with filters", () => {
it("hides forms for plain filters and shows them for form filters", () => {
const names = (q: string) => queryDex(dex, { ...DEFAULT_QUERY, q }).map((e) => e.name);
expect(names("type:ghost")).toEqual(["Gengar"]);
expect(names("is:mega")).toEqual(["Mega Gengar"]);
expect(names("gen type:ghost")).toEqual(["Gengar", "Mega Gengar"]);
expect(wantsForms(parseSearch("-is:mega").filters)).toBe(false);
});
});

describe("describeFilter", () => {
it("writes filters in plain language", () => {
const describe = (q: string) => parseSearch(q).filters.map(describeFilter);
expect(describe("spe>100 -type:flying weak:ice")).toEqual([
"Speed above 100",
"Not Flying type",
"Weak to Ice",
]);
expect(describe("gen:1-3 is:final weight<=10")).toEqual([
"Generations I–III",
"Fully evolved",
"Weight at most 10 kg",
]);
});
});

describe("removeToken", () => {
it("drops one filter and keeps the rest", () => {
expect(removeToken("char spe>100 type:fire", "spe>100")).toBe("char type:fire");
});
});
Loading
Loading