feat(web): add settings search command palette
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { useEffect, useId, useRef } from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingsSearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
placeholder?: string;
|
||||
itemLabel?: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SettingsSearchInput({
|
||||
value,
|
||||
onChange,
|
||||
resultCount,
|
||||
totalCount,
|
||||
placeholder = "Search settings",
|
||||
itemLabel = "settings sections",
|
||||
emptyLabel = "No matching settings",
|
||||
className,
|
||||
}: SettingsSearchInputProps) {
|
||||
const inputId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const hasQuery = value.trim().length > 0;
|
||||
const status = hasQuery
|
||||
? resultCount === 0
|
||||
? emptyLabel
|
||||
: `${resultCount} ${resultCount === 1 ? "match" : "matches"}`
|
||||
: `${totalCount} ${itemLabel}`;
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
|
||||
if (event.key.toLowerCase() !== "k") return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
document.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
document.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<label htmlFor={inputId} className="sr-only">
|
||||
{placeholder}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-10 rounded-xl pr-10 pl-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{hasQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear settings search"
|
||||
onClick={() => onChange("")}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 absolute top-1/2 right-2 inline-flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-2 text-xs" aria-live="polite">
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { countSettingsSearchItems, filterSettingsSearchGroups } from "./settingsSearch";
|
||||
|
||||
const groups = [
|
||||
{
|
||||
label: "Server",
|
||||
items: [
|
||||
{
|
||||
label: "General",
|
||||
description: "Authentication and logging",
|
||||
keywords: ["access token", "refresh token", "log level"],
|
||||
settings: [{ label: "Quiet Subsystems" }],
|
||||
},
|
||||
{
|
||||
label: "Database",
|
||||
description: "Postgres and Redis",
|
||||
keywords: ["connection url", "pool"],
|
||||
settings: [{ label: "Pool Max Open" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Playback",
|
||||
items: [
|
||||
{
|
||||
label: "Subtitles",
|
||||
description: "Skipping behavior, language, and style",
|
||||
keywords: ["forced subtitles", "captions"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("settingsSearch", () => {
|
||||
it("returns all groups when the query is empty", () => {
|
||||
const filtered = filterSettingsSearchGroups(groups, "");
|
||||
|
||||
expect(countSettingsSearchItems(filtered)).toBe(3);
|
||||
expect(filtered.map((group) => group.label)).toEqual(["Server", "Playback"]);
|
||||
});
|
||||
|
||||
it("matches item labels, descriptions, and keywords", () => {
|
||||
expect(filterSettingsSearchGroups(groups, "redis")).toEqual([
|
||||
{
|
||||
label: "Server",
|
||||
items: [groups[0]!.items[1]],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(filterSettingsSearchGroups(groups, "access token")).toEqual([
|
||||
{
|
||||
label: "Server",
|
||||
items: [groups[0]!.items[0]],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(filterSettingsSearchGroups(groups, "forced")).toEqual([
|
||||
{
|
||||
label: "Playback",
|
||||
items: [groups[1]!.items[0]],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches individual setting labels", () => {
|
||||
expect(filterSettingsSearchGroups(groups, "quiet subsystems")).toEqual([
|
||||
{
|
||||
label: "Server",
|
||||
items: [groups[0]!.items[0]],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(filterSettingsSearchGroups(groups, "pool max open")).toEqual([
|
||||
{
|
||||
label: "Server",
|
||||
items: [groups[0]!.items[1]],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches section labels by returning the full section", () => {
|
||||
const filtered = filterSettingsSearchGroups(groups, "server");
|
||||
|
||||
expect(filtered).toEqual([groups[0]]);
|
||||
expect(countSettingsSearchItems(filtered)).toBe(2);
|
||||
});
|
||||
|
||||
it("does not match short tokens from the middle of a word", () => {
|
||||
expect(filterSettingsSearchGroups(groups, "pin")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface SettingsSearchEntry {
|
||||
label: string;
|
||||
description?: string;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export interface SettingsSearchItem {
|
||||
label: string;
|
||||
description?: string;
|
||||
keywords?: readonly string[];
|
||||
settings?: readonly SettingsSearchEntry[];
|
||||
}
|
||||
|
||||
export interface SettingsSearchGroup<T extends SettingsSearchItem> {
|
||||
label: string;
|
||||
items: readonly T[];
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function searchTokens(query: string) {
|
||||
const normalized = normalizeSearchText(query);
|
||||
return normalized ? normalized.split(/\s+/) : [];
|
||||
}
|
||||
|
||||
function itemSearchText<T extends SettingsSearchItem>(group: SettingsSearchGroup<T>, item: T) {
|
||||
const settingText = item.settings?.flatMap((setting) => [
|
||||
setting.label,
|
||||
setting.description,
|
||||
...(setting.keywords ?? []),
|
||||
]);
|
||||
|
||||
return normalizeSearchText(
|
||||
[group.label, item.label, item.description, ...(item.keywords ?? []), ...(settingText ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
function entrySearchText(entry: SettingsSearchEntry) {
|
||||
return normalizeSearchText([entry.label, entry.description, ...(entry.keywords ?? [])].join(" "));
|
||||
}
|
||||
|
||||
function textMatchesTokens(text: string, tokens: string[]) {
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
|
||||
return tokens.every((token) =>
|
||||
words.some((word) => word.startsWith(token) || (token.length >= 4 && word.includes(token))),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterSettingsSearchEntries(
|
||||
entries: readonly SettingsSearchEntry[] | undefined,
|
||||
query: string,
|
||||
) {
|
||||
const tokens = searchTokens(query);
|
||||
|
||||
if (!entries?.length || !tokens.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return entries.filter((entry) => textMatchesTokens(entrySearchText(entry), tokens));
|
||||
}
|
||||
|
||||
export function filterSettingsSearchGroups<T extends SettingsSearchItem>(
|
||||
groups: readonly SettingsSearchGroup<T>[],
|
||||
query: string,
|
||||
): SettingsSearchGroup<T>[] {
|
||||
const tokens = searchTokens(query);
|
||||
|
||||
if (!tokens.length) {
|
||||
return groups.map((group) => ({ ...group, items: [...group.items] }));
|
||||
}
|
||||
|
||||
return groups
|
||||
.map((group) => {
|
||||
const groupText = normalizeSearchText(group.label);
|
||||
const groupMatches = textMatchesTokens(groupText, tokens);
|
||||
const items = groupMatches
|
||||
? [...group.items]
|
||||
: group.items.filter((item) => textMatchesTokens(itemSearchText(group, item), tokens));
|
||||
|
||||
return { ...group, items };
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
export function countSettingsSearchItems<T extends SettingsSearchItem>(
|
||||
groups: readonly SettingsSearchGroup<T>[],
|
||||
) {
|
||||
return groups.reduce((count, group) => count + group.items.length, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user