Skip to content
Merged
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

110 changes: 54 additions & 56 deletions src/islands/device-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}
Expand All @@ -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,
Comment thread
PhilipTrauner marked this conversation as resolved.
})),
);

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 {
Expand Down Expand Up @@ -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 = "";
Expand All @@ -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,
Expand Down Expand Up @@ -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,
),
};
}

Expand All @@ -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,
),
};
}

Expand Down Expand Up @@ -464,9 +477,7 @@ export class DeviceSearch extends LitElement {
${unsafeHTML(render(category, PresentationRenderPresetRoleIcon.withSize(16)))}

<div class="searchbox-popover-section-row-main">
<div>
${DeviceSearch._highlight(category.label, this._term)}
</div>
<div>${highlight(category.label, this._term)}</div>
</div>
<span class="searchbox-popover-section-count">${c.count}</span>
</button>`;
Expand All @@ -489,7 +500,7 @@ export class DeviceSearch extends LitElement {
>
${unsafeHTML(render(generic("users"), PresentationRenderPresetRoleIcon.withSize(16)))}
<div class="searchbox-popover-section-row-main">
<div>${DeviceSearch._highlight(m.name, this._term)}</div>
<div>${highlight(m.name, this._term)}</div>
</div>
<span class="searchbox-popover-section-count"
>${m.count}</span
Expand Down Expand Up @@ -530,11 +541,9 @@ export class DeviceSearch extends LitElement {

<div class="searchbox-popover-section-row-main">
<span class="searchbox-popover-section-row-secondary"
>${DeviceSearch._highlight(d.manufacturer, this._term)}</span
>${highlight(d.manufacturer, this._term)}</span
>
<span>
${DeviceSearch._highlight(device.name(d), this._term)}
</span>
<span> ${highlight(device.name(d), this._term)} </span>
</div>
<span class="searchbox-popover-section-count"
>${d.count}</span
Expand All @@ -547,7 +556,7 @@ export class DeviceSearch extends LitElement {
? html`<button
class="searchbox-popover-section-row"
id=${`more-device`}
@click=${() => void navigate(this._urlTerm(this._term))}
@click=${() => void navigate(localizeHref(browseFiltersToHref(this._searchFilters)))}
>
${unsafeHTML(render(generic("arrow"), PresentationRenderPresetRoleIcon.withSize(16)))}

Expand All @@ -567,19 +576,6 @@ export class DeviceSearch extends LitElement {
return html`<div class="searchbox-popover-section">${rendered}</div>`;
}

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)}<mark>${text.slice(i, i + term.length)}</mark>${text.slice(
i + term.length,
)}`;
}

protected override render() {
const sections = this._sections;
return html`<div
Expand All @@ -594,7 +590,9 @@ export class DeviceSearch extends LitElement {
sections.every(
(s) => typeof s.items !== "undefined" && s.items.length === 0,
)
? html`<p>${m.search_popover_no_matches()}</p>`
? html`<p class="searchbox-popover-empty">
${m.search_popover_no_matches()}
</p>`
: this._sections.map((s) => this._renderSection(s))
}
</div>
Expand Down
78 changes: 37 additions & 41 deletions src/islands/filter-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string, FilterModalOption[]>();
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 = "";
Expand Down Expand Up @@ -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<string, FilterModalOption[]>();
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`
<button
Expand All @@ -176,7 +168,11 @@ export class FilterModal extends LitElement {
if (!this._open) {
return nothing;
}
const matchedGroups = this._groups();
const matchedGroups = groupOptions(
this.options,
this._query,
this.lettergroups ? this._groupBy : "none",
);
const matchedCount = matchedGroups.reduce(
(sum, [, options]) => sum + options.length,
0,
Expand Down Expand Up @@ -269,7 +265,7 @@ export class FilterModal extends LitElement {
@change=${() => this._draftToggle(option.id)}
/>
<span class="filter-row-text"
>${option.label}</span
>${highlight(option.label, this._query)}</span
>
${
typeof option.count !== "undefined"
Expand Down
Loading
Loading