diff --git a/package.json b/package.json index 23520f8..6b33d0d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@astrojs/netlify": "^7.0.13", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "astro": "^6.4.4", + "fuse.js": "^7.5.0", "lit": "^3.3.2", "lucide": "^1.17.0", "undici": "^8.10.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea414e..67fc763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: astro: specifier: ^6.4.4 version: 6.4.7(@netlify/blobs@10.7.9)(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(yaml@2.9.0) + fuse.js: + specifier: ^7.5.0 + version: 7.5.0 lit: specifier: ^3.3.2 version: 3.3.3 @@ -2678,6 +2681,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.5.0: + resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} + engines: {node: '>=10'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -7949,6 +7956,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.5.0: {} + generator-function@2.0.1: {} get-amd-module-type@6.0.2: diff --git a/src/islands/device-search.ts b/src/islands/device-search.ts index 86830cd..57d4c50 100644 --- a/src/islands/device-search.ts +++ b/src/islands/device-search.ts @@ -8,7 +8,11 @@ import type { Ref } from "lit/directives/ref.js"; import { getDevices } from "../io/device.js"; import { m } from "../paraglide/messages.js"; import { localizeHref } from "../paraglide/runtime.js"; -import { browseFiltersToSearchParams } from "../types/browse/index.js"; +import { + browseFiltersToHref, + browseFiltersToQuery, + browseFiltersToSearchParams, +} from "../types/browse/index.js"; import { DeviceCategoryTopLevelId, topLevelCategoryResolver, @@ -27,7 +31,9 @@ import { render, } from "../utilities/presentation/index.js"; import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import { extractCategories, highlight, rank } from "../utilities/search.js"; import type { IoDimensionCategory } from "../io/dimension.js"; +import type { BrowseFilters } from "../types/browse/index.js"; import type { DevicePoly } from "../types/device/index.js"; type SectionCategory = { @@ -118,6 +124,7 @@ export class DeviceSearch extends LitElement { this._input?.addEventListener("keydown", this._onInputKeyDown); this._clearButton?.addEventListener("click", this._onClearButtonClick); + this._form?.addEventListener("submit", this._onFormSubmit); } override disconnectedCallback(): void { @@ -128,6 +135,7 @@ export class DeviceSearch extends LitElement { this._input?.removeEventListener("keydown", this._onInputKeyDown); this._clearButton?.removeEventListener("click", this._onClearButtonClick); + this._form?.removeEventListener("submit", this._onFormSubmit); this._resetFetched(); } @@ -153,18 +161,25 @@ export class DeviceSearch extends LitElement { return localizeHref(`/devices/${id}`); } - private _urlTerm(term: string): string { - const params = browseFiltersToSearchParams({ - category: new Set(), + // "hue lighting" becomes term "hue" in category "lighting" + private get _searchFilters(): BrowseFilters { + const { term, category } = extractCategories( + this._term, + DeviceCategoryTopLevelId.options.map((id) => ({ + id, + // returned label is translated based on currently selected language + label: device.category(id).label, + })), + ); + + return { + term: term.length > 0 ? term : undefined, + category, categoryMode: "include", localOnly: false, manufacturer: new Set(), manufacturerMode: "include", - term, - }); - - const qs = params.toString(); - return localizeHref(`/browse${qs ? "?" + qs : ""}`); + }; } private _urlManufacturer(manufacturer: string): string { @@ -300,6 +315,12 @@ export class DeviceSearch extends LitElement { } }; + // without a selected row, carry the extracted category into the browse page + private _onFormSubmit = (event: SubmitEvent) => { + event.preventDefault(); + void navigate(localizeHref(browseFiltersToHref(this._searchFilters))); + }; + private _onClearButtonClick = () => { if (this._input !== null) { this._input.value = ""; @@ -319,13 +340,15 @@ export class DeviceSearch extends LitElement { } private async _fetchDevices() { - const term = this._term.trim(); this._fetchAbort?.abort(); const controller = new AbortController(); this._fetchAbort = controller; try { - const { devices, total } = await getDevices({ term }, controller.signal); + const { devices, total } = await getDevices( + browseFiltersToQuery(this._searchFilters), + controller.signal, + ); this._fetchedDevices = { devices: devices.map(devicePolyMap), total, @@ -362,24 +385,13 @@ export class DeviceSearch extends LitElement { return { kind: "category", - items: - typeof this.dimensions?.categories !== "undefined" - ? Object.entries(this.dimensions?.categories) - ?.flatMap(([id, category]) => { - if (!is(id)) { - return []; - } - const { label } = device.category(id); - if ( - !label.toLocaleLowerCase().includes(term.toLocaleLowerCase()) - ) { - return []; - } - - return [{ id, count: category.count }]; - }) - .toSorted((a, b) => b.count - a.count) - : [], + items: rank( + Object.entries(this.dimensions?.categories ?? {}).flatMap( + ([id, { count }]) => (is(id) ? [{ id, count }] : []), + ), + term, + ({ id }) => device.category(id).label, + ), }; } @@ -395,10 +407,11 @@ export class DeviceSearch extends LitElement { private _sectionManufacturer(term: string): SectionManufacturer { return { kind: "manufacturer", - items: - this.dimensions?.manufacturers.filter(({ name }) => - name.toLocaleLowerCase().includes(term.toLocaleLowerCase()), - ) ?? [], + items: rank( + this.dimensions?.manufacturers ?? [], + term, + ({ name }) => name, + ), }; } @@ -464,9 +477,7 @@ export class DeviceSearch extends LitElement { ${unsafeHTML(render(category, PresentationRenderPresetRoleIcon.withSize(16)))}
-
- ${DeviceSearch._highlight(category.label, this._term)} -
+
${highlight(category.label, this._term)}
${c.count} `; @@ -489,7 +500,7 @@ export class DeviceSearch extends LitElement { > ${unsafeHTML(render(generic("users"), PresentationRenderPresetRoleIcon.withSize(16)))}
-
${DeviceSearch._highlight(m.name, this._term)}
+
${highlight(m.name, this._term)}
${m.count} ${DeviceSearch._highlight(d.manufacturer, this._term)}${highlight(d.manufacturer, this._term)} - - ${DeviceSearch._highlight(device.name(d), this._term)} - + ${highlight(device.name(d), this._term)} ${d.count} void navigate(this._urlTerm(this._term))} + @click=${() => void navigate(localizeHref(browseFiltersToHref(this._searchFilters)))} > ${unsafeHTML(render(generic("arrow"), PresentationRenderPresetRoleIcon.withSize(16)))} @@ -567,19 +576,6 @@ export class DeviceSearch extends LitElement { return html`
${rendered}
`; } - private static _highlight(text: string, term: string) { - if (!term) { - return text; - } - const i = text.toLowerCase().indexOf(term.toLowerCase()); - if (i < 0) { - return text; - } - return html`${text.slice(0, i)}${text.slice(i, i + term.length)}${text.slice( - i + term.length, - )}`; - } - protected override render() { const sections = this._sections; return html`
typeof s.items !== "undefined" && s.items.length === 0, ) - ? html`

${m.search_popover_no_matches()}

` + ? html`

+ ${m.search_popover_no_matches()} +

` : this._sections.map((s) => this._renderSection(s)) }
diff --git a/src/islands/filter-modal.ts b/src/islands/filter-modal.ts index 64ea8d3..9441f44 100644 --- a/src/islands/filter-modal.ts +++ b/src/islands/filter-modal.ts @@ -8,6 +8,7 @@ import { getLocale } from "../paraglide/runtime.js"; import { defineElementOnce } from "../utilities/define-element.js"; import * as presentation from "../utilities/presentation"; import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import { highlight, rank } from "../utilities/search.js"; export type FilterModalMode = "include" | "exclude"; @@ -17,7 +18,36 @@ export interface FilterModalOption { count?: number | undefined; } -type GroupingConfig = "alphabet" | "none"; +export type GroupingConfig = "alphabet" | "none"; + +export const groupOptions = ( + options: FilterModalOption[], + query: string, + groupBy: GroupingConfig, +): (readonly [string, FilterModalOption[]])[] => { + const matched = rank(options, query, ({ label }) => label); + if (groupBy === "none") { + return [["", matched] as const]; + } + + const groups = new Map(); + 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] : []), + ]; +}; export class FilterModal extends LitElement { @property() dim = ""; @@ -121,44 +151,6 @@ export class FilterModal extends LitElement { this._draftSelected = next; } - private _groups(): (readonly [string, FilterModalOption[]])[] { - const term = this._query.trim().toLowerCase(); - const matched = - term.length > 0 - ? this.options.filter((option) => - option.label.toLowerCase().includes(term), - ) - : this.options; - if (!this.lettergroups) { - return [["", matched] as const]; - } - - if (this._groupBy === "none") { - const sorted = [...matched].sort( - (a, b) => (b.count ?? 0) - (a.count ?? 0), - ); - return [["", sorted] as const]; - } - - const groups = new Map(); - 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 _renderModeButton(mode: FilterModalMode, text: string) { return html`