feat : add fuzzy matching for searching
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: fuzzy-search
|
||||
description: Add or maintain buzz-sheet name searches using the project’s shared collection-level fuzzy fallback. Use for guide, team, character, weapon, artifact, and glossary searches or catalog pickers; exclude numeric and fixed-option dropdowns.
|
||||
---
|
||||
|
||||
# Buzz-sheet fuzzy search
|
||||
|
||||
Use this project’s shared search implementation for finding entities by name or alias. Preserve explicit user choices if they call for different tolerance or ranking.
|
||||
|
||||
## Default behavior
|
||||
|
||||
- Preserve existing case-insensitive substring matching and its searchable fields. Return direct results in their original order.
|
||||
- Attempt fuzzy matching only when the entire eligible collection has no direct results. Do not decide fallback independently for each item.
|
||||
- Restrict fuzzy candidates to names, aliases, and individual whitespace-separated words. Keep overview prose and internal keys available for existing direct searches, but exclude them from fuzzy candidates.
|
||||
- Normalize with NFC, trim, lowercase, and compare Unicode code points. Keep queries shorter than four code points strict.
|
||||
- Use bounded optimal-string-alignment Damerau–Levenshtein distance, including adjacent transpositions. Allow one edit for 4–7 code points and two for longer queries, capped at 25% of query length. Rank by edit distance with stable source-order ties.
|
||||
- Empty queries return the eligible collection. Show the localized close-match notice only when fuzzy fallback returns results.
|
||||
|
||||
## Reuse the project implementation
|
||||
|
||||
Use `searchItems` from `lib/search.ts`, returning `{ items, fuzzy }`. Supply separate selectors for existing direct-search text and fuzzy candidate names. Use `SearchNotice` from `components/search-notice.tsx` for fallback feedback.
|
||||
|
||||
Use `searchTeams` from `lib/guides/team-search.ts` for collection-level team searches. Apply active groups, permissions, and category restrictions before matching. Explicit DPS sorting takes precedence over fuzzy ranking. Direct team queries must match within one candidate name, not span multiple members.
|
||||
|
||||
Use `FuzzyCombobox` from `components/ui/combobox.tsx` for catalog pickers, passing `items` and `searchNames`. It retains Base UI selection handling and disables native filtering with `filter={null}` after filtering the collection. Do not add another filter that would discard typo results. Preserve object identities and single/multiple selection behavior.
|
||||
|
||||
Keep searches local against the already loaded collections. Reuse these utilities instead of adding another search engine or dependency.
|
||||
|
||||
## Verify observable behavior
|
||||
|
||||
Test collection-wide direct-match precedence, empty and short queries, edits and transpositions, aliases, multiword names, Thai/Unicode text, thresholds, unrelated inputs, stable ranking, and exclusion of prose/keys from fuzzy candidates. `muala` should directly match `Mualani`; `mulani` should find it through fallback.
|
||||
|
||||
For pickers, verify keyboard selection, clearing, reopening, popup inputs, and multiple selection against the actual component. For teams, verify category restrictions and explicit sorting after matching. Run `bun run test`, `bun run lint`, and `bun run typecheck`; report pre-existing failures separately.
|
||||
@@ -0,0 +1,3 @@
|
||||
interface:
|
||||
display_name: "Fuzzy Search"
|
||||
short_description: "Add typo-tolerant searches with direct-match fallback"
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { searchItems } from "@/lib/search";
|
||||
import { SearchNotice } from "@/components/search-notice";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeftIcon, CheckIcon, SearchIcon } from "lucide-react";
|
||||
@@ -28,12 +31,10 @@ export function CreateGuideFlow({ characters, existingKeys }: { characters: Char
|
||||
const [overview, setOverview] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const existing = useMemo(() => new Set(existingKeys), [existingKeys]);
|
||||
const filtered = characters.filter((character) =>
|
||||
[character.name, ...character.aliases]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th")),
|
||||
);
|
||||
const search = searchItems(characters, query,
|
||||
(character) => [character.name, ...character.aliases].join(" "),
|
||||
(character) => [character.name, ...character.aliases]);
|
||||
const filtered = search.items;
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
@@ -79,6 +80,7 @@ export function CreateGuideFlow({ characters, existingKeys }: { characters: Char
|
||||
<InputGroupAddon><SearchIcon /></InputGroupAddon>
|
||||
<InputGroupInput value={query} onChange={(event) => setQuery(event.target.value)} placeholder="ค้นหาตัวละคร…" aria-label="ค้นหาตัวละคร" />
|
||||
</InputGroup>
|
||||
<SearchNotice fuzzy={search.fuzzy} />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{filtered.map((character) => {
|
||||
const disabled = existing.has(character.key);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { searchItems } from "@/lib/search";
|
||||
import { SearchNotice } from "@/components/search-notice";
|
||||
|
||||
import Image from "next/image";
|
||||
import { SearchIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
@@ -10,7 +13,7 @@ import { addGlossaryAlias, removeGlossaryAlias } from "@/app/admin/actions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
|
||||
import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@@ -46,11 +49,10 @@ export function GlossaryManager({
|
||||
items.map((catalogItem) => [`${catalogKind}:${catalogItem.key}`, catalogItem.name] as const),
|
||||
),
|
||||
), [catalogs]);
|
||||
const visibleAliases = aliases.filter((entry) =>
|
||||
`${entry.alias} ${itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? entry.catalogKey}`
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th")),
|
||||
);
|
||||
const search = searchItems(aliases, query,
|
||||
(entry) => `${entry.alias} ${itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? entry.catalogKey}`,
|
||||
(entry) => [entry.alias, itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? ""]);
|
||||
const visibleAliases = search.items;
|
||||
|
||||
const submit = () => startTransition(async () => {
|
||||
const result = await addGlossaryAlias({ kind, catalogKey: item?.key, alias });
|
||||
@@ -90,8 +92,9 @@ export function GlossaryManager({
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>รายการ</FieldLabel>
|
||||
<Combobox
|
||||
<FuzzyCombobox
|
||||
items={catalogs[kind]}
|
||||
searchNames={(value) => [value.name, ...aliases.filter((entry) => entry.kind === kind && entry.catalogKey === value.key).map((entry) => entry.alias)]}
|
||||
value={item}
|
||||
onValueChange={setItem}
|
||||
itemToStringLabel={(value) => value.name}
|
||||
@@ -114,7 +117,7 @@ export function GlossaryManager({
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</FuzzyCombobox>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="glossary-alias">Shortcut</FieldLabel>
|
||||
@@ -143,6 +146,7 @@ export function GlossaryManager({
|
||||
<Input id="glossary-search" className="pl-9" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</div>
|
||||
</Field>
|
||||
<SearchNotice fuzzy={search.fuzzy} />
|
||||
{visibleAliases.map((entry) => {
|
||||
const name = itemNames.get(`${entry.kind}:${entry.catalogKey}`);
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { searchItems } from "@/lib/search";
|
||||
import { SearchNotice } from "@/components/search-notice";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -78,6 +81,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
FuzzyCombobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
@@ -168,7 +172,7 @@ import {
|
||||
type TeamToolValue,
|
||||
} from "@/lib/guides/team-tools";
|
||||
import {
|
||||
matchesTeamSearch,
|
||||
searchTeams,
|
||||
type TeamSearchTarget,
|
||||
} from "@/lib/guides/team-search";
|
||||
import {
|
||||
@@ -475,18 +479,13 @@ function CatalogPicker({
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Combobox
|
||||
<FuzzyCombobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
filter={(item, query) =>
|
||||
[item.name, ...(item.aliases ?? [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th"))
|
||||
}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput
|
||||
@@ -519,7 +518,7 @@ function CatalogPicker({
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</FuzzyCombobox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -546,18 +545,13 @@ function CatalogSlotPicker({
|
||||
}) {
|
||||
const selected = items.find((item) => item.key === value) ?? null;
|
||||
return (
|
||||
<Combobox
|
||||
<FuzzyCombobox
|
||||
items={items}
|
||||
value={selected}
|
||||
onValueChange={(next) => onChange(next?.key ?? "")}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
filter={(item, query) =>
|
||||
[item.name, ...(item.aliases ?? [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th"))
|
||||
}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxTrigger
|
||||
@@ -606,7 +600,7 @@ function CatalogSlotPicker({
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -640,7 +634,7 @@ function ArtifactSlotPicker({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
<FuzzyCombobox
|
||||
multiple
|
||||
items={items}
|
||||
value={selected}
|
||||
@@ -650,12 +644,7 @@ function ArtifactSlotPicker({
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
isItemEqualToValue={catalogItemsEqual}
|
||||
filter={(item, query) =>
|
||||
[item.name, ...(item.aliases ?? [])]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th"))
|
||||
}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxTrigger
|
||||
@@ -711,7 +700,7 @@ function ArtifactSlotPicker({
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</FuzzyCombobox>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2887,14 +2876,12 @@ function TeamEditor({
|
||||
const [multipleApplyGuideId, setMultipleApplyGuideId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
|
||||
const filteredTeams = useMemo(() => {
|
||||
const teamSearch = useMemo(() => {
|
||||
const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
|
||||
return teams
|
||||
.map((team, teamIndex) => ({ team, teamIndex }))
|
||||
.filter(({ team }) => matchesTeamSearch({
|
||||
return searchTeams(teams.map((team, teamIndex) => ({ team, teamIndex })), ({ team }) => ({
|
||||
team: [team.title],
|
||||
character: team.members.flatMap((member) => {
|
||||
const name = characterNames.get(member.characterKey);
|
||||
@@ -2912,27 +2899,29 @@ function TeamEditor({
|
||||
return name ?? [];
|
||||
}),
|
||||
),
|
||||
}, searchQuery, searchTarget));
|
||||
}), searchQuery, searchTarget);
|
||||
}, [artifacts, characters, searchQuery, searchTarget, teams, weapons]);
|
||||
const activeImportGroup = activeImportGuide?.groups[Number(guideImportCondition)]
|
||||
?? activeImportGuide?.groups[0];
|
||||
const filteredImportTeams = useMemo(() => {
|
||||
const importTeamSearch = useMemo(() => {
|
||||
const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]]));
|
||||
return (activeImportGroup?.teams ?? []).filter((team) => matchesTeamSearch({
|
||||
return searchTeams(activeImportGroup?.teams ?? [], (team) => ({
|
||||
team: [team.title],
|
||||
character: team.members.flatMap((member) => characterNames.get(member.characterKey) ?? [member.characterName]),
|
||||
weapon: team.members.flatMap((member) => member.weaponKey ? weaponNames.get(member.weaponKey) ?? [] : []),
|
||||
artifact: team.members.flatMap((member) => [member.artifactKey, member.secondArtifactKey].flatMap((key) => key ? artifactNames.get(key) ?? [] : [])),
|
||||
}, guideTeamQuery, guideTeamSearchTarget));
|
||||
}), guideTeamQuery, guideTeamSearchTarget);
|
||||
}, [activeImportGroup, artifacts, characters, guideTeamQuery, guideTeamSearchTarget, weapons]);
|
||||
const filteredImportGuides = useMemo(() => {
|
||||
const query = guideListQuery.trim().toLocaleLowerCase();
|
||||
return (importGuides ?? []).filter((sourceGuide) =>
|
||||
`${sourceGuide.name} ${sourceGuide.characterKey}`.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
const importGuideSearch = useMemo(() => {
|
||||
return searchItems(importGuides ?? [], guideListQuery,
|
||||
(sourceGuide) => `${sourceGuide.name} ${sourceGuide.characterKey}`,
|
||||
(sourceGuide) => [sourceGuide.name]);
|
||||
}, [guideListQuery, importGuides]);
|
||||
const filteredTeams = teamSearch.items;
|
||||
const filteredImportTeams = importTeamSearch.items;
|
||||
const filteredImportGuides = importGuideSearch.items;
|
||||
const value = {
|
||||
groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, teams: group.teams.map((team) => ({
|
||||
title: team.title,
|
||||
@@ -3351,6 +3340,7 @@ function TeamEditor({
|
||||
<FieldGroup className="mt-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="team-search">ค้นหา Team</FieldLabel>
|
||||
<SearchNotice fuzzy={teamSearch.fuzzy} />
|
||||
<div className="relative">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
@@ -4104,6 +4094,7 @@ function TeamEditor({
|
||||
<ToggleGroupItem value="artifact">อาร์ติแฟกต์</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<SearchNotice fuzzy={importTeamSearch.fuzzy} />
|
||||
{filteredImportTeams.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{filteredImportTeams.map((team) => {
|
||||
@@ -4212,6 +4203,7 @@ function TeamEditor({
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<SearchNotice fuzzy={importGuideSearch.fuzzy} />
|
||||
{filteredImportGuides.length ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredImportGuides.map((sourceGuide) => (
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Combobox,
|
||||
FuzzyCombobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
@@ -1066,7 +1067,7 @@ export function OverviewImageGenerator({
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel>ตัวละคร</FieldLabel>
|
||||
<Combobox
|
||||
<FuzzyCombobox
|
||||
items={characters}
|
||||
value={character}
|
||||
onValueChange={(value) => {
|
||||
@@ -1082,12 +1083,7 @@ export function OverviewImageGenerator({
|
||||
}}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.key}
|
||||
filter={(item, query) =>
|
||||
[item.name, ...item.aliases]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("th")
|
||||
.includes(query.trim().toLocaleLowerCase("th"))
|
||||
}
|
||||
searchNames={(item) => [item.name, ...(item.aliases ?? [])]}
|
||||
autoHighlight
|
||||
>
|
||||
<ComboboxInput className="w-full" placeholder="ค้นหาตัวละคร…" />
|
||||
@@ -1115,7 +1111,7 @@ export function OverviewImageGenerator({
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</FuzzyCombobox>
|
||||
<FieldDescription>
|
||||
ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog
|
||||
</FieldDescription>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { searchItems } from "@/lib/search";
|
||||
import { SearchNotice } from "@/components/search-notice";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -88,12 +91,10 @@ export function StructuredDashboard({
|
||||
);
|
||||
const syncJobId = syncJob?.id;
|
||||
const syncJobStatus = syncJob?.status;
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredGuides = guides.filter((guide) =>
|
||||
`${guide.name} ${guide.characterKey} ${guide.overview}`
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
const search = searchItems(guides, query,
|
||||
(guide) => `${guide.name} ${guide.characterKey} ${guide.overview}`,
|
||||
(guide) => [guide.name]);
|
||||
const filteredGuides = search.items;
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncJobId || !syncJobStatus || ["completed", "unchanged", "cancelled", "error"].includes(syncJobStatus)) return;
|
||||
@@ -212,6 +213,7 @@ export function StructuredDashboard({
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
<SearchNotice fuzzy={search.fuzzy} />
|
||||
{filteredGuides.length ? (
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredGuides.map((guide) => (
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { searchItems } from "@/lib/search";
|
||||
import { SearchNotice } from "@/components/search-notice";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { SearchIcon, UsersIcon } from "lucide-react";
|
||||
@@ -39,10 +42,10 @@ type GuideCard = {
|
||||
export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const presence = useVisitorPresence(null);
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const filtered = guides.filter((guide) =>
|
||||
`${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`.toLowerCase().includes(normalized),
|
||||
);
|
||||
const search = searchItems(guides, query,
|
||||
(guide) => `${guide.name} ${guide.overview} ${guide.aliases.join(" ")}`,
|
||||
(guide) => [guide.name, ...guide.aliases]);
|
||||
const filtered = search.items;
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<InputGroup className="h-11 max-w-2xl">
|
||||
@@ -56,6 +59,7 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
|
||||
aria-label="ค้นหาไกด์"
|
||||
/>
|
||||
</InputGroup>
|
||||
<SearchNotice fuzzy={search.fuzzy} />
|
||||
{filtered.length ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((guide, index) => (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SearchNotice } from "@/components/search-notice";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ArrowUpRightIcon, SearchIcon, XIcon } from "lucide-react";
|
||||
@@ -19,7 +21,7 @@ import { rarityGradientClass } from "@/lib/catalog/rarity";
|
||||
import { isCustomTravelerKey } from "@/lib/catalog/travelers";
|
||||
import type { PublicGuide } from "@/lib/guides/queries";
|
||||
import {
|
||||
matchesTeamSearch,
|
||||
searchTeams,
|
||||
sortTeamsByDps,
|
||||
type DpsOrder,
|
||||
type TeamSearchTarget,
|
||||
@@ -379,18 +381,21 @@ export function TeamSearch({ groups }: { groups: PublicGuide["teamGroups"] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [searchTarget, setSearchTarget] = useState<TeamSearchTarget>("all");
|
||||
const [dpsOrder, setDpsOrder] = useState<DpsOrder>(null);
|
||||
const filteredTeams = useMemo(
|
||||
() => sortTeamsByDps(teams.filter((team) => matchesTeamSearch({
|
||||
const search = useMemo(
|
||||
() => searchTeams(teams, (team) => ({
|
||||
team: [team.title],
|
||||
character: team.members.flatMap((member) => [member.character.name, ...member.character.aliases]),
|
||||
weapon: team.members.flatMap((member) => member.weapon ? [member.weapon.name, ...member.weapon.aliases] : []),
|
||||
artifact: team.members.flatMap((member) => [member.artifact, member.secondArtifact].flatMap((artifact) => artifact ? [artifact.name, ...artifact.aliases] : [])),
|
||||
}, query, searchTarget)), dpsOrder),
|
||||
[dpsOrder, query, searchTarget, teams],
|
||||
}), query, searchTarget),
|
||||
[query, searchTarget, teams],
|
||||
);
|
||||
|
||||
const filteredTeams = sortTeamsByDps(search.items, dpsOrder);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<SearchNotice fuzzy={search.fuzzy} />
|
||||
{groups.length > 1 ? (
|
||||
<ScrollableNav ariaLabel="เลือกเงื่อนไขทีม" className="w-full min-w-0">
|
||||
<ToggleGroup aria-label="เลือกเงื่อนไขทีม" variant="outline" value={[activeCondition]} onValueChange={(values) => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function SearchNotice({ fuzzy }: { fuzzy: boolean }) {
|
||||
return fuzzy ? <p role="status" className="px-2 py-1 text-sm text-muted-foreground">แสดงผลลัพธ์ที่ใกล้เคียง</p> : null;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import * as React from "react"
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { searchItems } from "@/lib/search"
|
||||
import { SearchNotice } from "@/components/search-notice"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
InputGroup,
|
||||
@@ -14,6 +16,30 @@ import {
|
||||
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root
|
||||
const FuzzyContext = React.createContext(false)
|
||||
|
||||
function FuzzyCombobox<Value, Multiple extends boolean | undefined = false>({
|
||||
items = [], searchNames, searchText, children, onInputValueChange, ...props
|
||||
}: ComboboxPrimitive.Root.Props<Value, Multiple> & {
|
||||
items: Value[];
|
||||
searchNames: (item: Value) => readonly string[];
|
||||
searchText?: (item: Value) => string;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const result = React.useMemo(() => searchItems(items, query,
|
||||
searchText ?? ((item) => searchNames(item).join(" ")), searchNames),
|
||||
[items, query, searchNames, searchText])
|
||||
return (
|
||||
<FuzzyContext.Provider value={result.fuzzy}>
|
||||
<ComboboxPrimitive.Root {...props} items={result.items} filter={null}
|
||||
onInputValueChange={(value, details) => {
|
||||
setQuery(details.reason === "input-change" ? value : "")
|
||||
onInputValueChange?.(value, details)
|
||||
}}
|
||||
>{children}</ComboboxPrimitive.Root>
|
||||
</FuzzyContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
|
||||
@@ -85,6 +111,7 @@ function ComboboxInput({
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
children,
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
@@ -97,6 +124,7 @@ function ComboboxContent({
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
const fuzzy = React.useContext(FuzzyContext)
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
@@ -112,7 +140,10 @@ function ComboboxContent({
|
||||
data-chips={!!anchor}
|
||||
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<SearchNotice fuzzy={fuzzy} />
|
||||
{children}
|
||||
</ComboboxPrimitive.Popup>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
)
|
||||
@@ -278,6 +309,7 @@ function useComboboxAnchor() {
|
||||
}
|
||||
|
||||
export {
|
||||
FuzzyCombobox,
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { matchesTeamSearch, sortTeamsByDps } from "./team-search";
|
||||
import { matchesTeamSearch, searchTeams, sortTeamsByDps } from "./team-search";
|
||||
|
||||
const team = {
|
||||
team: ["Vaporize Team"],
|
||||
@@ -40,3 +40,33 @@ describe("team search", () => {
|
||||
expect(sortTeamsByDps(teams, null).map((item) => item.name)).toEqual(["middle", "best", "tie"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("team fuzzy fallback", () => {
|
||||
it("matches typos only in the selected category", () => {
|
||||
expect(searchTeams([team], (item) => item, "mulani", "character")).toEqual({ items: [team], fuzzy: true });
|
||||
expect(searchTeams([team], (item) => item, "mulani", "artifact").items).toEqual([]);
|
||||
expect(searchTeams([team], (item) => item, "mulani", "all").items).toEqual([team]);
|
||||
});
|
||||
|
||||
it("decides fallback across all eligible teams", () => {
|
||||
const direct = { ...team, character: ["Mulani"] };
|
||||
expect(searchTeams([team, direct], (item) => item, "mulani", "all")).toEqual({ items: [direct], fuzzy: false });
|
||||
expect(searchTeams([team], (item) => item, "", "all")).toEqual({ items: [team], fuzzy: false });
|
||||
expect(searchTeams([], (item) => item, "mulani", "all")).toEqual({ items: [], fuzzy: false });
|
||||
});
|
||||
|
||||
it("does not allow direct queries to span separate names", () => {
|
||||
const values = { ...team, character: ["Jean", "Mualani"] };
|
||||
expect(searchTeams([values], (item) => item, "Jean Mualani", "character").items).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies DPS ordering after fuzzy matching and preserves fuzzy ranking without sorting", () => {
|
||||
const teams = [
|
||||
{ ...team, character: ["Mualani"], rotationSeconds: 10, members: [{ damage: 100 }] },
|
||||
{ ...team, character: ["Muloni"], rotationSeconds: 5, members: [{ damage: 100 }] },
|
||||
];
|
||||
const matches = searchTeams(teams, (item) => item, "mulani", "character").items;
|
||||
expect(sortTeamsByDps(matches, "best")).toEqual([teams[1], teams[0]]);
|
||||
expect(sortTeamsByDps(matches, null)).toEqual(teams);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeSearch, searchItems } from "../search";
|
||||
|
||||
export type TeamSearchTarget =
|
||||
| "all"
|
||||
| "team"
|
||||
@@ -47,7 +49,7 @@ export function matchesTeamSearch(
|
||||
query: string,
|
||||
target: TeamSearchTarget,
|
||||
): boolean {
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const normalizedQuery = normalizeSearch(query);
|
||||
if (!normalizedQuery) return true;
|
||||
|
||||
const candidates = target === "all"
|
||||
@@ -55,6 +57,23 @@ export function matchesTeamSearch(
|
||||
: values[target];
|
||||
|
||||
return candidates.some((value) =>
|
||||
value.toLocaleLowerCase().includes(normalizedQuery),
|
||||
normalizeSearch(value).includes(normalizedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
export function searchTeams<T>(
|
||||
teams: readonly T[],
|
||||
values: (team: T) => TeamSearchValues,
|
||||
query: string,
|
||||
target: TeamSearchTarget,
|
||||
) {
|
||||
const candidates = (team: T) => {
|
||||
const fields = values(team);
|
||||
return target === "all" ? Object.values(fields).flat() : fields[target];
|
||||
};
|
||||
// Separate candidates retain the existing rule that a direct match must
|
||||
// occur within one name, rather than spanning two members or categories.
|
||||
const direct = teams.filter((team) => matchesTeamSearch(values(team), query, target));
|
||||
if (direct.length || !query.trim()) return { items: direct, fuzzy: false };
|
||||
return searchItems(teams, query, () => "", candidates);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { searchItems } from "./search";
|
||||
|
||||
const items = [
|
||||
{ name: "Mualani", aliases: ["Shark Surfer"], overview: "A hydro guide", key: "internal" },
|
||||
{ name: "Obsidian Codex", aliases: ["Codex"], overview: "An artifact", key: "obsidian" },
|
||||
];
|
||||
const search = (query: string) => searchItems(items, query,
|
||||
(item) => `${item.name} ${item.aliases.join(" ")} ${item.overview} ${item.key}`,
|
||||
(item) => [item.name, ...item.aliases]);
|
||||
const names = (items: string[], query: string) => searchItems(items, query, (item) => item, (item) => [item]);
|
||||
|
||||
describe("fuzzy search fallback", () => {
|
||||
it("preserves direct substring matches, case, whitespace, and empty queries", () => {
|
||||
expect(search(" MUALA ")).toEqual({ items: [items[0]], fuzzy: false });
|
||||
expect(search(" ")).toEqual({ items, fuzzy: false });
|
||||
expect(search("hydro").items).toEqual([items[0]]);
|
||||
});
|
||||
|
||||
it("does not append typo matches when another item matches directly", () => {
|
||||
expect(names(["Mualani", "Mulani Guide"], "mulani")).toEqual({ items: ["Mulani Guide"], fuzzy: false });
|
||||
});
|
||||
|
||||
it.each(["mulani", "muallani", "mualoni", "mualnai"])("finds missing, extra, substituted, or swapped characters: %s", (query) => {
|
||||
expect(search(query)).toEqual({ items: [items[0]], fuzzy: true });
|
||||
});
|
||||
|
||||
it("matches aliases and individual words in multiword names", () => {
|
||||
expect(search("surfre")).toEqual({ items: [items[0]], fuzzy: true });
|
||||
expect(search("codxe")).toEqual({ items: [items[1]], fuzzy: true });
|
||||
expect(search("obsidain codex")).toEqual({ items: [items[1]], fuzzy: true });
|
||||
});
|
||||
|
||||
it("does not fuzzy match overview prose or internal keys", () => {
|
||||
expect(search("hydor")).toEqual({ items: [], fuzzy: false });
|
||||
expect(search("interanl")).toEqual({ items: [], fuzzy: false });
|
||||
});
|
||||
|
||||
it("keeps short queries strict and enforces edit limits", () => {
|
||||
expect(names(["Jean"], "jen")).toEqual({ items: [], fuzzy: false });
|
||||
expect(names(["Mualani"], "muloni").items).toEqual([]);
|
||||
expect(names(["abcdefgh"], "abxdxfgh")).toEqual({ items: ["abcdefgh"], fuzzy: true });
|
||||
expect(names(["abcdefgh"], "abxdxygh").items).toEqual([]);
|
||||
expect(search("unrelated").items).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes composed Unicode and handles Thai code points", () => {
|
||||
expect(names(["Café"], "Cafe\u0301")).toEqual({ items: ["Café"], fuzzy: false });
|
||||
expect(names(["ตัวละคร"], "ตัวลคร")).toEqual({ items: ["ตัวละคร"], fuzzy: true });
|
||||
expect(names(["😀abcd"], "😀abdc")).toEqual({ items: ["😀abcd"], fuzzy: true });
|
||||
});
|
||||
|
||||
it("ranks by distance with stable ties and retains item identities", () => {
|
||||
expect(names(["abxdxfgh", "abcxefgh", "abcdxfgh"], "abcdefgh").items)
|
||||
.toEqual(["abcxefgh", "abcdxfgh", "abxdxfgh"]);
|
||||
expect(search("mulani").items[0]).toBe(items[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface SearchResult<T> {
|
||||
items: T[];
|
||||
fuzzy: boolean;
|
||||
}
|
||||
|
||||
export function normalizeSearch(value: string): string {
|
||||
return value.normalize("NFC").trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
// Bounded optimal-string-alignment distance, including adjacent transpositions.
|
||||
function editDistance(left: string[], right: string[], limit: number): number {
|
||||
if (Math.abs(left.length - right.length) > limit) return limit + 1;
|
||||
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
let beforePrevious = previous;
|
||||
for (let i = 1; i <= left.length; i++) {
|
||||
const current = Array<number>(right.length + 1).fill(limit + 1);
|
||||
current[0] = i;
|
||||
for (let j = Math.max(1, i - limit); j <= Math.min(right.length, i + limit); j++) {
|
||||
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1,
|
||||
previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
|
||||
if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
|
||||
current[j] = Math.min(current[j], beforePrevious[j - 2] + 1);
|
||||
}
|
||||
}
|
||||
beforePrevious = previous;
|
||||
previous = current;
|
||||
}
|
||||
return previous[right.length];
|
||||
}
|
||||
|
||||
export function searchItems<T>(
|
||||
items: readonly T[],
|
||||
query: string,
|
||||
directText: (item: T) => string,
|
||||
names: (item: T) => readonly string[],
|
||||
): SearchResult<T> {
|
||||
const normalized = normalizeSearch(query);
|
||||
if (!normalized) return { items: [...items], fuzzy: false };
|
||||
const direct = items.filter((item) => normalizeSearch(directText(item)).includes(normalized));
|
||||
if (direct.length) return { items: direct, fuzzy: false };
|
||||
const characters = Array.from(normalized);
|
||||
if (characters.length < 4) return { items: [], fuzzy: false };
|
||||
const limit = Math.min(characters.length < 8 ? 1 : 2, Math.floor(characters.length / 4));
|
||||
const matches = items.map((item, index) => {
|
||||
let distance = limit + 1;
|
||||
for (const name of names(item)) {
|
||||
const candidate = normalizeSearch(name);
|
||||
for (const part of new Set([candidate, ...candidate.split(/\s+/u)])) {
|
||||
distance = Math.min(distance, editDistance(characters, Array.from(part), limit));
|
||||
}
|
||||
}
|
||||
return { item, index, distance };
|
||||
}).filter(({ distance }) => distance <= limit)
|
||||
.sort((a, b) => a.distance - b.distance || a.index - b.index);
|
||||
return { items: matches.map(({ item }) => item), fuzzy: matches.length > 0 };
|
||||
}
|
||||
Reference in New Issue
Block a user