setQuery(event.target.value)} placeholder="ค้นหาตัวละคร…" aria-label="ค้นหาตัวละคร" />
+
{filtered.map((character) => {
const disabled = existing.has(character.key);
diff --git a/components/admin/glossary-manager.tsx b/components/admin/glossary-manager.tsx
index f24f9fd..4fa7dde 100644
--- a/components/admin/glossary-manager.tsx
+++ b/components/admin/glossary-manager.tsx
@@ -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({
รายการ
- [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({
)}
-
+
Shortcut
@@ -143,6 +146,7 @@ export function GlossaryManager({
setQuery(event.target.value)} />
+
{visibleAliases.map((entry) => {
const name = itemNames.get(`${entry.kind}:${entry.catalogKey}`);
return (
diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx
index 84f3af1..2ec8051 100644
--- a/components/admin/guide-section-editor.tsx
+++ b/components/admin/guide-section-editor.tsx
@@ -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({
) : null}
- 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
>
-
+
);
@@ -546,18 +545,13 @@ function CatalogSlotPicker({
}) {
const selected = items.find((item) => item.key === value) ?? null;
return (
- 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
>
-
+
);
}
@@ -640,7 +634,7 @@ function ArtifactSlotPicker({
: null;
return (
- 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
>
-
+
);
}
@@ -2887,14 +2876,12 @@ function TeamEditor({
const [multipleApplyGuideId, setMultipleApplyGuideId] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchTarget, setSearchTarget] = useState("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({
ค้นหา Team
+
อาร์ติแฟกต์
+
{filteredImportTeams.length ? (
{filteredImportTeams.map((team) => {
@@ -4212,6 +4203,7 @@ function TeamEditor({
/>
+
{filteredImportGuides.length ? (
{filteredImportGuides.map((sourceGuide) => (
diff --git a/components/admin/overview-image-generator.tsx b/components/admin/overview-image-generator.tsx
index 6a5300b..6c5ac77 100644
--- a/components/admin/overview-image-generator.tsx
+++ b/components/admin/overview-image-generator.tsx
@@ -51,6 +51,7 @@ import {
} from "@/components/ui/card";
import {
Combobox,
+ FuzzyCombobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
@@ -1066,7 +1067,7 @@ export function OverviewImageGenerator({
ตัวละคร
- {
@@ -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
>
@@ -1115,7 +1111,7 @@ export function OverviewImageGenerator({
)}
-
+
ชื่อ ธาตุ และประเภทอาวุธจะมาจาก Catalog
diff --git a/components/admin/structured-dashboard.tsx b/components/admin/structured-dashboard.tsx
index 6626752..fb6fe4b 100644
--- a/components/admin/structured-dashboard.tsx
+++ b/components/admin/structured-dashboard.tsx
@@ -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({
/>
) : null}
+
{filteredGuides.length ? (
{filteredGuides.map((guide) => (
diff --git a/components/public/structured-directory.tsx b/components/public/structured-directory.tsx
index 777ff4f..27cde2b 100644
--- a/components/public/structured-directory.tsx
+++ b/components/public/structured-directory.tsx
@@ -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 (
@@ -56,6 +59,7 @@ export function StructuredDirectory({ guides }: { guides: GuideCard[] }) {
aria-label="ค้นหาไกด์"
/>
+
{filtered.length ? (
{filtered.map((guide, index) => (
diff --git a/components/public/team-search.tsx b/components/public/team-search.tsx
index c3d9d09..f81f75b 100644
--- a/components/public/team-search.tsx
+++ b/components/public/team-search.tsx
@@ -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
("all");
const [dpsOrder, setDpsOrder] = useState(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 (
+
{groups.length > 1 ? (
{
diff --git a/components/search-notice.tsx b/components/search-notice.tsx
new file mode 100644
index 0000000..1e9c7b4
--- /dev/null
+++ b/components/search-notice.tsx
@@ -0,0 +1,3 @@
+export function SearchNotice({ fuzzy }: { fuzzy: boolean }) {
+ return fuzzy ? แสดงผลลัพธ์ที่ใกล้เคียง
: null;
+}
diff --git a/components/ui/combobox.tsx b/components/ui/combobox.tsx
index 39e2b93..086aa52 100644
--- a/components/ui/combobox.tsx
+++ b/components/ui/combobox.tsx
@@ -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({
+ items = [], searchNames, searchText, children, onInputValueChange, ...props
+}: ComboboxPrimitive.Root.Props & {
+ 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 (
+
+ {
+ setQuery(details.reason === "input-change" ? value : "")
+ onInputValueChange?.(value, details)
+ }}
+ >{children}
+
+ )
+}
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return
@@ -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 (
+ >
+
+ {children}
+
)
@@ -278,6 +309,7 @@ function useComboboxAnchor() {
}
export {
+ FuzzyCombobox,
Combobox,
ComboboxInput,
ComboboxContent,
diff --git a/lib/guides/team-search.test.ts b/lib/guides/team-search.test.ts
index afa3e5e..f85a2e1 100644
--- a/lib/guides/team-search.test.ts
+++ b/lib/guides/team-search.test.ts
@@ -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);
+ });
+});
diff --git a/lib/guides/team-search.ts b/lib/guides/team-search.ts
index bc82542..ce6dc46 100644
--- a/lib/guides/team-search.ts
+++ b/lib/guides/team-search.ts
@@ -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(
+ 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);
+}
diff --git a/lib/search.test.ts b/lib/search.test.ts
new file mode 100644
index 0000000..ec42e20
--- /dev/null
+++ b/lib/search.test.ts
@@ -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]);
+ });
+});
diff --git a/lib/search.ts b/lib/search.ts
new file mode 100644
index 0000000..4ad77c3
--- /dev/null
+++ b/lib/search.ts
@@ -0,0 +1,56 @@
+export interface SearchResult {
+ 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(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(
+ items: readonly T[],
+ query: string,
+ directText: (item: T) => string,
+ names: (item: T) => readonly string[],
+): SearchResult {
+ 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 };
+}