();
- const ungrouped: FilterModalOption[] = [];
- for (const option of matched) {
- const letter = option.label.at(0)?.normalize().trim().toUpperCase();
- if (typeof letter === "undefined" || !/[A-Z]/.test(letter)) {
- ungrouped.push(option);
- continue;
- }
- const list = groups.get(letter) ?? [];
- list.push(option);
- groups.set(letter, list);
- }
-
- return [
- ...[...groups.entries()].sort(([a], [b]) => a.localeCompare(b)),
- ...(ungrouped.length > 0 ? [["#", ungrouped] as const] : []),
- ];
- }
-
private _renderRoot(filters: SheetFilters) {
return html`
@@ -399,7 +363,11 @@ export class FilterSheet extends LitElement {
private _renderDimension(filters: SheetFilters, config: DimensionConfig) {
const selected = filters[config.dim];
- const groups = this._groups(config);
+ const groups = groupOptions(
+ config.options,
+ this._query,
+ config.letterGroups ? this._groupBy : "none",
+ );
const matchedCount = groups.reduce(
(sum, [, options]) => sum + options.length,
0,
@@ -430,7 +398,7 @@ export class FilterSheet extends LitElement {
${on ? icon("check", 16) : nothing}
${option.label}${highlight(option.label, this._query)}
${
typeof option.count !== "undefined"
diff --git a/src/styles/base.css b/src/styles/base.css
index bfc6aab..3fe1169 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -47,6 +47,14 @@ a:visited {
color: var(--link);
}
+mark {
+ background: var(--highlight-soft);
+ color: inherit;
+ font-weight: var(--fw-semibold);
+ border-radius: var(--radius-1);
+ padding: 0 1px;
+}
+
button {
font-family: inherit;
}
diff --git a/src/styles/searchbox.css b/src/styles/searchbox.css
index acf8b84..a144de6 100644
--- a/src/styles/searchbox.css
+++ b/src/styles/searchbox.css
@@ -93,6 +93,13 @@
flex-direction: column;
}
+.searchbox-popover-empty {
+ margin: 0;
+ padding: var(--space-3) var(--space-5);
+ color: var(--fg-muted);
+ font-size: var(--fs-14);
+}
+
.searchbox-popover-section {
display: flex;
flex-direction: column;
@@ -174,13 +181,6 @@
font-size: var(--fs-12);
flex-shrink: 0;
}
-.searchbox-popover-section-row mark {
- background: var(--highlight-soft);
- color: inherit;
- font-weight: var(--fw-semibold);
- border-radius: var(--radius-1);
- padding: 0 1px;
-}
.searchbox-more-text {
color: var(--link);
font-weight: var(--fw-medium);
diff --git a/src/utilities/search.ts b/src/utilities/search.ts
new file mode 100644
index 0000000..8685b2e
--- /dev/null
+++ b/src/utilities/search.ts
@@ -0,0 +1,173 @@
+import Fuse from "fuse.js";
+import { html } from "lit";
+import type { TemplateResult } from "lit";
+
+const OPTIONS = {
+ ignoreDiacritics: true,
+ ignoreLocation: true,
+ includeMatches: true,
+ includeScore: true,
+ threshold: 0.3,
+} as const;
+
+const tokens = (term: string): string[] =>
+ term.split(/\s+/).filter((token) => token.length > 0);
+
+// index of the first occurrence of `token` starting a word, else of any
+const indexOf = (text: string, token: string): number => {
+ const lower = text.toLowerCase();
+ const needle = token.toLowerCase();
+ let at = lower.indexOf(needle);
+ const first = at;
+ while (at > 0 && /[\p{L}\p{N}]/u.test(lower[at - 1] ?? "")) {
+ at = lower.indexOf(needle, at + 1);
+ }
+ return at === -1 ? first : at;
+};
+
+/**
+ * fuzzy, word-order independent filtering. ranks by amount of matched term
+ * words, then by where term words start (the label over a later word over
+ * none), then by match quality, then by count. an empty term keeps every item
+ */
+export const rank = (
+ items: readonly T[],
+ term: string,
+ label: (item: T) => string,
+): T[] => {
+ const byCount = (a: T, b: T) => (b.count ?? 0) - (a.count ?? 0);
+ const query = tokens(term);
+ // per term word: 2 when starting the label, 1 when starting a later word
+ const starts = (item: T) => {
+ const [first = "", ...rest] = label(item)
+ .toLowerCase()
+ .split(/[^\p{L}\p{N}]+/u)
+ .filter((word) => word.length > 0);
+ return query.reduce((sum, token) => {
+ const lower = token.toLowerCase();
+ return (
+ sum +
+ (first.startsWith(lower)
+ ? 2
+ : rest.some((word) => word.startsWith(lower))
+ ? 1
+ : 0)
+ );
+ }, 0);
+ };
+ if (query.length === 0) {
+ return items.toSorted(byCount);
+ }
+
+ return (
+ new Fuse(items, { ...OPTIONS, keys: [{ name: "label", getFn: label }] })
+ .search({ $or: query.map((token) => ({ label: token })) })
+ .map((result) => ({ ...result, starts: starts(result.item) }))
+ // scores bucketed to tenths so that count decides between
+ // similarly good matches, a weighted blend if this ranks poorly
+ .toSorted(
+ (a, b) =>
+ (b.matches?.length ?? 0) - (a.matches?.length ?? 0) ||
+ b.starts - a.starts ||
+ Math.round((a.score ?? 0) * 10) - Math.round((b.score ?? 0) * 10) ||
+ byCount(a.item, b.item),
+ )
+ .map(({ item }) => item)
+ );
+};
+
+/**
+ * moves term words naming exactly one category (prefix, typo or accent
+ * tolerant) into a category selection, as the api is unaware of translations
+ */
+export const extractCategories = (
+ term: string,
+ categories: readonly { id: Id; label: string }[],
+): { term: string; category: Set } => {
+ const fuse = new Fuse(
+ categories.flatMap(({ id, label }) =>
+ tokens(label).map((word) => ({ id, word })),
+ ),
+ // distance 0 anchors matches at word start, "leak" shouldn't hit "cleaning".
+ // stricter than ranking, as a wrong category silently hides results:
+ // "nest" shouldn't hit "networking"
+ {
+ keys: ["word"],
+ ignoreDiacritics: true,
+ includeScore: true,
+ distance: 0,
+ threshold: 0.25,
+ },
+ );
+ // only the best matches compete: exact "weather" beats fuzzy "water".
+ // four letter words must match exactly, "nest" shouldn't hit "networking"
+ const search = (token: string) => {
+ const results = fuse.search(token);
+ const top = Math.min(...results.map(({ score }) => score ?? 1));
+ return results.filter(
+ ({ score }) =>
+ (score ?? 1) <= top + 0.05 && (token.length >= 5 || (score ?? 1) < 0.1),
+ );
+ };
+ // short words are mostly conjunctions ("and", "und", "y")
+ const candidates = tokens(term).map(
+ (token) =>
+ [
+ token,
+ new Set(
+ token.length >= 4 ? search(token).map(({ item }) => item.id) : [],
+ ),
+ ] as const,
+ );
+ const category = new Set(
+ candidates.flatMap(([, ids]) => (ids.size === 1 ? [...ids] : [])),
+ );
+
+ return {
+ // also drops ambiguous words of an extracted category ("climate control")
+ term: candidates
+ .filter(([, ids]) => ![...ids].some((id) => category.has(id)))
+ .map(([token]) => token)
+ .join(" "),
+ category,
+ };
+};
+
+/**
+ * wraps one segment per term word in ``: its first occurrence when typed
+ * as is (preferring one starting a word), else the longest segment of the typo
+ * tolerant match
+ */
+export const highlight = (
+ text: string,
+ term: string,
+): (string | TemplateResult)[] => {
+ const ranges = tokens(term)
+ .flatMap((token): (readonly [number, number])[] => {
+ const at = indexOf(text, token);
+ if (at !== -1) {
+ return [[at, at + token.length - 1]];
+ }
+ const { isMatch, indices } = Fuse.match(token, text, OPTIONS);
+ const longest = indices?.toSorted(([a, b], [c, d]) => d - c - (b - a))[0];
+ return isMatch && typeof longest !== "undefined" ? [longest] : [];
+ })
+ .toSorted(([a], [b]) => a - b);
+
+ const parts: (string | TemplateResult)[] = [];
+ let at = 0;
+ for (const [start, end] of ranges) {
+ if (end < at) {
+ continue;
+ }
+ const from = Math.max(start, at);
+ parts.push(
+ text.slice(at, from),
+ html`${text.slice(from, end + 1)}`,
+ );
+ at = end + 1;
+ }
+ parts.push(text.slice(at));
+
+ return parts;
+};
diff --git a/test/search.test.ts b/test/search.test.ts
new file mode 100644
index 0000000..1bed179
--- /dev/null
+++ b/test/search.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it } from "vitest";
+
+import { extractCategories, highlight, rank } from "../src/utilities/search";
+
+const manufacturers = [
+ { name: "Samsung Electronics", count: 1510 },
+ { name: "Samjin", count: 7 },
+ { name: "AirBeamTV", count: 1 },
+ { name: "Xiaomi", count: 140 },
+ { name: "Aqara", count: 50 },
+ { name: "Xiaomi Aqara", count: 28 },
+ { name: "BSH Hausgeräte", count: 1 },
+];
+const names = (term: string) =>
+ rank(manufacturers, term, ({ name }) => name).map(({ name }) => name);
+
+const categories = [
+ { id: "lighting", label: "Lighting" },
+ { id: "printing", label: "Printing" },
+ { id: "cleaning", label: "Cleaning" },
+ { id: "climate-control", label: "Climate control" },
+ { id: "security-and-access-control", label: "Security and access control" },
+ { id: "pool-and-spa", label: "Pool and spa" },
+ { id: "weather", label: "Weather" },
+ { id: "water-management", label: "Water management" },
+ { id: "networking", label: "Networking" },
+ { id: "monitoring", label: "Monitoring" },
+ { id: "power-and-energy", label: "Power and energy" },
+];
+const extract = (term: string) => {
+ const result = extractCategories(term, categories);
+ return { term: result.term, category: [...result.category] };
+};
+
+describe("rank", () => {
+ it("keeps everything, sorted by count, for an empty term", () => {
+ expect(names(" ")).toEqual(
+ manufacturers
+ .toSorted((a, b) => b.count - a.count)
+ .map(({ name }) => name),
+ );
+ });
+
+ it("prefers count among equally good matches", () => {
+ expect(names("sam")[0]).toBe("Samsung Electronics");
+ });
+
+ it("tolerates unrelated words, word order, typos and accents", () => {
+ expect(names("samsung tv")[0]).toBe("Samsung Electronics");
+ expect(names("electronics samsung")[0]).toBe("Samsung Electronics");
+ expect(names("aqara xiaomi")[0]).toBe("Xiaomi Aqara");
+ expect(names("xiomi")[0]).toBe("Xiaomi");
+ expect(names("hausgerate")).toEqual(["BSH Hausgeräte"]);
+ });
+
+ it("finds single letters", () => {
+ expect(names("x")[0]).toBe("Xiaomi");
+ });
+
+ it("prefers matches at the start of a word over count", () => {
+ const labels = [
+ { label: "Networking", count: 138 },
+ { label: "Kitchen and household", count: 3 },
+ ];
+ expect(
+ rank(labels, "ki", ({ label }) => label).map(({ label }) => label),
+ ).toEqual(["Kitchen and household", "Networking"]);
+ });
+
+ it("prefers matches at the start of the label over later words", () => {
+ const labels = [
+ { label: "TP-Link", count: 336 },
+ { label: "AEON Labs", count: 47 },
+ { label: "Lutron", count: 78 },
+ { label: "Legrand", count: 29 },
+ ];
+ expect(
+ rank(labels, "l", ({ label }) => label).map(({ label }) => label),
+ ).toEqual(["Lutron", "Legrand", "TP-Link", "AEON Labs"]);
+ });
+
+ it("drops items matching no word", () => {
+ expect(names("zzzz")).toEqual([]);
+ });
+});
+
+describe("extractCategories", () => {
+ it("moves (partial, misspelled) category names out of the term", () => {
+ expect(extract("hue lighting")).toEqual({
+ term: "hue",
+ category: ["lighting"],
+ });
+ expect(extract("philips hue light")).toEqual({
+ term: "philips hue",
+ category: ["lighting"],
+ });
+ expect(extract("ligthing")).toEqual({ term: "", category: ["lighting"] });
+ expect(extract("print brother")).toEqual({
+ term: "brother",
+ category: ["printing"],
+ });
+ });
+
+ it("drops ambiguous words belonging to an extracted category", () => {
+ expect(extract("climate control")).toEqual({
+ term: "",
+ category: ["climate-control"],
+ });
+ });
+
+ it("lets an exact word win over fuzzy alternatives", () => {
+ expect(extract("weather station")).toEqual({
+ term: "station",
+ category: ["weather"],
+ });
+ });
+
+ it("does not mistake manufacturer names for categories", () => {
+ for (const name of ["Nest Labs", "Flo by Moen", "MINI", "Slim Devices"]) {
+ expect(extract(name).category).toEqual([]);
+ }
+ });
+
+ it("leaves ambiguous, short and mid-word matches alone", () => {
+ expect(extract("cont")).toEqual({ term: "cont", category: [] });
+ expect(extract("and")).toEqual({ term: "and", category: [] });
+ expect(extract("leak detector")).toEqual({
+ term: "leak detector",
+ category: [],
+ });
+ expect(extract("powerview hub")).toEqual({
+ term: "powerview hub",
+ category: [],
+ });
+ expect(extract("tp-link")).toEqual({ term: "tp-link", category: [] });
+ });
+});
+
+describe("highlight", () => {
+ const marked = (text: string, term: string) =>
+ highlight(text, term)
+ .map((part) =>
+ typeof part === "string" ? part : `[${String(part.values[0])}]`,
+ )
+ .join("");
+
+ it("marks one segment per term word", () => {
+ expect(marked("Siemens", "e")).toBe("Si[e]mens");
+ expect(marked("ecobee", "e")).toBe("[e]cobee");
+ expect(marked("Kitchen and household", "ki")).toBe(
+ "[Ki]tchen and household",
+ );
+ expect(marked("Xiaomi Aqara", "aqara xiaomi")).toBe("[Xiaomi] [Aqara]");
+ expect(marked("Paulmann Licht", "l")).toBe("Paulmann [L]icht");
+ });
+
+ it("marks the closest segment for typos and accents", () => {
+ expect(marked("Samsung Electronics", "samsnug")).toBe(
+ "[Samsung] Electronics",
+ );
+ expect(marked("BSH Hausgeräte", "hausgerate")).toBe("BSH [Hausgeräte]");
+ });
+});