diff --git a/CHANGELOG.md b/CHANGELOG.md
index da8cb2b..f539f14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+## [4.11.0] - 2026-09-17
+
+### Added
+
+- **genre-tree**: `GenreTreeView` now has a search box to find a genre by name and select it,
+ highlighting the matching node in the tree.
+
+### Changed
+
+- Bumped `@behindthemusictree/genre-tree-view` catalog pin to 1.7.0.
+
## [4.10.1] - 2026-09-11
### Changed
diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json
index daa37ed..67621b4 100644
--- a/packages/app-kit/package.json
+++ b/packages/app-kit/package.json
@@ -1,6 +1,6 @@
{
"name": "@behindthemusictree/app-kit",
- "version": "4.10.1",
+ "version": "4.11.0",
"description": "Shared transport, auth, popup, UI, player, and genre-tree plumbing for BehindTheMusicTree React apps",
"repository": {
"type": "git",
diff --git a/packages/app-kit/src/genre-tree/GenreSearch.test.tsx b/packages/app-kit/src/genre-tree/GenreSearch.test.tsx
new file mode 100644
index 0000000..e92f82c
--- /dev/null
+++ b/packages/app-kit/src/genre-tree/GenreSearch.test.tsx
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import GenreSearch from "./GenreSearch";
+import { CriteriaPlaylistSimple } from "./schemas/criteria-playlist/simple";
+
+const makeGenrePlaylist = (name: string): CriteriaPlaylistSimple => ({
+ uuid: name,
+ name,
+ criteria: null,
+ parent: null,
+ root: { uuid: "root", name: "root" },
+ tracksCount: 0,
+ createdOn: "2026-01-01T00:00:00Z",
+ updatedOn: null,
+});
+
+describe("GenreSearch", () => {
+ const genrePlaylists = [makeGenrePlaylist("Deep House"), makeGenrePlaylist("Ambient")];
+
+ it("renders no results before typing", () => {
+ render();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
+
+ it("shows matching results as the user types", () => {
+ render();
+
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "hou" } });
+
+ expect(screen.getByText("Deep House")).toBeInTheDocument();
+ expect(screen.queryByText("Ambient")).not.toBeInTheDocument();
+ });
+
+ it("calls onSelect with the matching genre playlist when a result is clicked", () => {
+ const onSelect = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "amb" } });
+ fireEvent.click(screen.getByText("Ambient"));
+
+ expect(onSelect).toHaveBeenCalledWith(genrePlaylists[1]);
+ });
+
+ it("clears the query and results after a selection", () => {
+ render();
+
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "amb" } });
+ fireEvent.click(screen.getByText("Ambient"));
+
+ expect(screen.getByRole("textbox")).toHaveValue("");
+ expect(screen.queryByText("Ambient")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/app-kit/src/genre-tree/GenreSearch.tsx b/packages/app-kit/src/genre-tree/GenreSearch.tsx
new file mode 100644
index 0000000..df3e460
--- /dev/null
+++ b/packages/app-kit/src/genre-tree/GenreSearch.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useState } from "react";
+import { Input } from "@behindthemusictree/ui";
+
+import { CriteriaPlaylistSimple } from "./schemas/criteria-playlist/simple";
+import { searchGenrePlaylistsByName } from "./lib/genre-search";
+
+export type GenreSearchProps = {
+ genrePlaylists: CriteriaPlaylistSimple[];
+ onSelect: (genrePlaylist: CriteriaPlaylistSimple) => void;
+ placeholder?: string;
+};
+
+export default function GenreSearch({
+ genrePlaylists,
+ onSelect,
+ placeholder = "Search a genre…",
+}: GenreSearchProps) {
+ const [query, setQuery] = useState("");
+ const results = searchGenrePlaylistsByName(genrePlaylists, query);
+
+ return (
+
+
setQuery(event.target.value)}
+ placeholder={placeholder}
+ aria-label="Search a genre"
+ />
+ {results.length > 0 && (
+
+ {results.map((genrePlaylist) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx
index 47502d5..4fff0cb 100644
--- a/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx
+++ b/packages/app-kit/src/genre-tree/GenreTreeView.test.tsx
@@ -873,4 +873,61 @@ describe("GenreTreeView", () => {
expect(screen.getByText("—")).toBeInTheDocument();
});
});
+
+ describe("genre search", () => {
+ it("selecting a search result updates the info panel the same way a node click does", () => {
+ useListFullGenrePlaylistsMock.mockReturnValue({
+ data: { results: [makePlaylist({ uuid: "gp1", name: "Jazz", criteria: { uuid: "c1", name: "Jazz" } })] },
+ isPending: false,
+ });
+ useFetchGenreDetailMock.mockImplementation((id: string | null) =>
+ id === "c1"
+ ? {
+ data: {
+ uuid: "c1",
+ name: "Jazz",
+ summary: "Improvised music with swung rhythms.",
+ tracksCount: 5,
+ tracksArchivedCount: 0,
+ children: [],
+ essentialTracks: [],
+ },
+ isPending: false,
+ }
+ : { data: undefined, isPending: false },
+ );
+ renderView();
+
+ fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), {
+ target: { value: "Jazz" },
+ });
+ fireEvent.click(screen.getByText("Jazz"));
+
+ expect(useFetchGenreDetailMock).toHaveBeenCalledWith("c1");
+
+ fireEvent.click(screen.getByRole("button", { name: "Wheel" }));
+ const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({
+ id: "gp1",
+ });
+ render(<>{output}>);
+
+ expect(screen.getByText("Improvised music with swung rhythms.")).toBeInTheDocument();
+ });
+
+ it("passes the selected node id through to the active tree renderer for highlighting", () => {
+ useListFullGenrePlaylistsMock.mockReturnValue({
+ data: { results: [makePlaylist({ uuid: "gp1", name: "Jazz", criteria: { uuid: "c1", name: "Jazz" } })] },
+ isPending: false,
+ });
+ renderView();
+
+ fireEvent.click(screen.getByRole("button", { name: "Wheel" }));
+ fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), {
+ target: { value: "Jazz" },
+ });
+ fireEvent.click(screen.getByText("Jazz"));
+
+ expect(treeWheelPropsMock.mock.calls.at(-1)?.[0].selectedNodeId).toBe("gp1");
+ });
+ });
});
diff --git a/packages/app-kit/src/genre-tree/GenreTreeView.tsx b/packages/app-kit/src/genre-tree/GenreTreeView.tsx
index a029921..7c4cc0d 100644
--- a/packages/app-kit/src/genre-tree/GenreTreeView.tsx
+++ b/packages/app-kit/src/genre-tree/GenreTreeView.tsx
@@ -32,6 +32,7 @@ import GenrePlaylistTreePerRoot from "./playlist-tree/TreePerRoot";
import GenrePlaylistTreeWheel from "./playlist-tree/TreeWheel";
import GenrePlaylistTreeWheelRadialPopCore from "./playlist-tree/TreeWheelRadialPopCore";
import { GenreTreeWheelHandoff } from "./GenreTreeWheelHandoff";
+import GenreSearch from "./GenreSearch";
export type { GenreTreeViewMode } from "@behindthemusictree/genre-tree-view";
@@ -71,6 +72,9 @@ export function GenreTreeView({
const [selectedGenreUuid, setSelectedGenreUuid] = useState(
null,
);
+ // The genre-playlist/node id (GenreTreeNode.id), distinct from selectedGenreUuid (the
+ // criteria id used to fetch detail) — passed to the tree renderers for visual highlighting.
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
const { data: selectedGenreDetail, isPending: isLoadingSelectedGenre } =
useFetchGenreDetail(selectedGenreUuid, scope, getBackendBaseUrl);
@@ -85,10 +89,19 @@ export function GenreTreeView({
genrePlaylists?.results as CriteriaPlaylistSimple[] | undefined
)?.find((gp) => gp.uuid === node.id);
setSelectedGenreUuid(genrePlaylist?.criteria?.uuid ?? null);
+ setSelectedNodeId(node.id);
},
[genrePlaylists?.results],
);
+ const handleGenreSearchSelect = useCallback(
+ (genrePlaylist: CriteriaPlaylistSimple) => {
+ setSelectedGenreUuid(genrePlaylist.criteria?.uuid ?? null);
+ setSelectedNodeId(genrePlaylist.uuid);
+ },
+ [],
+ );
+
// The info panel can also navigate via its own ancestor/child chips, which don't go through
// onNodeClick — so the node passed here isn't guaranteed to be the one selectedGenreDetail was
// fetched for. Render nothing rather than stale essential tracks when they've diverged.
@@ -256,6 +269,12 @@ export function GenreTreeView({
)}
+ {!isLoading && (
+
+ )}
{!isLoading && !readOnly && (
({
additionalActions={additionalActions}
onNodeClick={handleNodeClick}
renderExtraDetails={renderExtraDetails}
+ selectedNodeId={selectedNodeId}
readOnly={readOnly}
allowWheelRotation={allowWheelRotation}
showToolbar={showToolbar}
@@ -330,6 +350,7 @@ export function GenreTreeView({
additionalActions={additionalActions}
onNodeClick={handleNodeClick}
renderExtraDetails={renderExtraDetails}
+ selectedNodeId={selectedNodeId}
readOnly={readOnly}
allowWheelRotation={allowWheelRotation}
showToolbar={showToolbar}
@@ -361,6 +382,7 @@ export function GenreTreeView({
additionalActions={additionalActions}
onNodeClick={handleNodeClick}
renderExtraDetails={renderExtraDetails}
+ selectedNodeId={selectedNodeId}
readOnly={readOnly}
showToolbar={showToolbar}
/>
diff --git a/packages/app-kit/src/genre-tree/index.ts b/packages/app-kit/src/genre-tree/index.ts
index a31bf5c..7df0f4b 100644
--- a/packages/app-kit/src/genre-tree/index.ts
+++ b/packages/app-kit/src/genre-tree/index.ts
@@ -2,6 +2,8 @@
export * from "./GenreTreeView";
export { default as GenrePlaylistTreePerRoot } from "./playlist-tree/TreePerRoot";
export type { GenrePlaylistTreePerRootProps } from "./playlist-tree/TreePerRoot";
+export { default as GenreSearch } from "./GenreSearch";
+export type { GenreSearchProps } from "./GenreSearch";
export {
GenreTreeSkeleton,
GenreTreeWheelSkeleton,
@@ -66,3 +68,4 @@ export { libraryEndpoints, libraryQueryKeys } from "./api/library";
export * from "./lib/rating";
export * from "./lib/formatting";
export * from "./lib/genre-playlist-helpers";
+export * from "./lib/genre-search";
diff --git a/packages/app-kit/src/genre-tree/lib/genre-search.test.ts b/packages/app-kit/src/genre-tree/lib/genre-search.test.ts
new file mode 100644
index 0000000..227bb71
--- /dev/null
+++ b/packages/app-kit/src/genre-tree/lib/genre-search.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from "vitest";
+import { searchGenrePlaylistsByName } from "./genre-search";
+import { CriteriaPlaylistSimple } from "../schemas/criteria-playlist/simple";
+
+const makeGenrePlaylist = (name: string): CriteriaPlaylistSimple => ({
+ uuid: name,
+ name,
+ criteria: null,
+ parent: null,
+ root: { uuid: "root", name: "root" },
+ tracksCount: 0,
+ createdOn: "2026-01-01T00:00:00Z",
+ updatedOn: null,
+});
+
+describe("searchGenrePlaylistsByName", () => {
+ const genrePlaylists = [
+ makeGenrePlaylist("Deep House"),
+ makeGenrePlaylist("Tech House"),
+ makeGenrePlaylist("Ambient"),
+ ];
+
+ it("returns nothing for an empty query", () => {
+ expect(searchGenrePlaylistsByName(genrePlaylists, "")).toEqual([]);
+ });
+
+ it("returns nothing for a whitespace-only query", () => {
+ expect(searchGenrePlaylistsByName(genrePlaylists, " ")).toEqual([]);
+ });
+
+ it("matches case-insensitively", () => {
+ expect(searchGenrePlaylistsByName(genrePlaylists, "house")).toEqual([
+ genrePlaylists[0],
+ genrePlaylists[1],
+ ]);
+ });
+
+ it("matches a substring anywhere in the name", () => {
+ expect(searchGenrePlaylistsByName(genrePlaylists, "mbien")).toEqual([genrePlaylists[2]]);
+ });
+
+ it("returns an empty array when nothing matches", () => {
+ expect(searchGenrePlaylistsByName(genrePlaylists, "jazz")).toEqual([]);
+ });
+});
diff --git a/packages/app-kit/src/genre-tree/lib/genre-search.ts b/packages/app-kit/src/genre-tree/lib/genre-search.ts
new file mode 100644
index 0000000..39cb83b
--- /dev/null
+++ b/packages/app-kit/src/genre-tree/lib/genre-search.ts
@@ -0,0 +1,14 @@
+import { CriteriaPlaylistSimple } from "../schemas/criteria-playlist/simple";
+
+/** Case-insensitive substring match on genre name. An empty/whitespace query matches nothing. */
+export const searchGenrePlaylistsByName = (
+ genrePlaylists: CriteriaPlaylistSimple[],
+ query: string,
+): CriteriaPlaylistSimple[] => {
+ const trimmedQuery = query.trim().toLowerCase();
+ if (!trimmedQuery) return [];
+
+ return genrePlaylists.filter((genrePlaylist) =>
+ genrePlaylist.name.toLowerCase().includes(trimmedQuery),
+ );
+};
diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx
index ecd7403..6bebb35 100644
--- a/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx
+++ b/packages/app-kit/src/genre-tree/playlist-tree/TreePerRoot.tsx
@@ -38,6 +38,8 @@ export type GenrePlaylistTreePerRootProps = {
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
onNodeClick?: (node: GenreTreeNode) => void;
renderExtraDetails?: (node: GenreTreeNode) => ReactNode;
+ /** Overrides which node is shown highlighted, e.g. from a search selection. */
+ selectedNodeId?: string | null;
/** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */
readOnly?: boolean;
/** When false, suppresses the hover toolbar on every node. Defaults to true. */
@@ -58,6 +60,7 @@ export default function GenrePlaylistTreePerRoot({
additionalActions,
onNodeClick,
renderExtraDetails,
+ selectedNodeId,
readOnly = false,
showToolbar,
}: GenrePlaylistTreePerRootProps) {
@@ -189,6 +192,7 @@ export default function GenrePlaylistTreePerRoot({
additionalActions={additionalActions}
onNodeClick={onNodeClick}
renderExtraDetails={renderExtraDetails}
+ selectedNodeId={selectedNodeId}
showToolbar={showToolbar}
/>
);
diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx
index 0f3f659..2ce3628 100644
--- a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx
+++ b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheel.tsx
@@ -36,6 +36,8 @@ export type GenrePlaylistTreeWheelProps = {
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
onNodeClick?: (node: GenreTreeNode) => void;
renderExtraDetails?: (node: GenreTreeNode) => ReactNode;
+ /** Overrides which node is shown highlighted, e.g. from a search selection. */
+ selectedNodeId?: string | null;
/** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */
readOnly?: boolean;
/** When false, clicking a chip still selects its root, but the wheel doesn't spin to the
@@ -58,6 +60,7 @@ export default function GenrePlaylistTreeWheel({
additionalActions,
onNodeClick,
renderExtraDetails,
+ selectedNodeId,
readOnly = false,
allowWheelRotation,
showToolbar,
@@ -189,6 +192,7 @@ export default function GenrePlaylistTreeWheel({
additionalActions={additionalActions}
onNodeClick={onNodeClick}
renderExtraDetails={renderExtraDetails}
+ selectedNodeId={selectedNodeId}
allowWheelRotation={allowWheelRotation}
showToolbar={showToolbar}
/>
diff --git a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx
index 7561fef..85c42d6 100644
--- a/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx
+++ b/packages/app-kit/src/genre-tree/playlist-tree/TreeWheelRadialPopCore.tsx
@@ -34,6 +34,8 @@ export type GenrePlaylistTreeWheelRadialPopCoreProps = {
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
onNodeClick?: (node: GenreTreeNode) => void;
renderExtraDetails?: (node: GenreTreeNode) => ReactNode;
+ /** Overrides which node is shown highlighted, e.g. from a search selection. */
+ selectedNodeId?: string | null;
/** When true, suppresses per-node create/rename/reparent affordances. Defaults to false. */
readOnly?: boolean;
/** When false, clicking a chip still selects its root, but the ring doesn't spin to the
@@ -56,6 +58,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore
additionalActions,
onNodeClick,
renderExtraDetails,
+ selectedNodeId,
readOnly = false,
allowWheelRotation,
showToolbar,
@@ -176,6 +179,7 @@ export default function GenrePlaylistTreeWheelRadialPopCore
additionalActions={additionalActions}
onNodeClick={onNodeClick}
renderExtraDetails={renderExtraDetails}
+ selectedNodeId={selectedNodeId}
allowWheelRotation={allowWheelRotation}
showToolbar={showToolbar}
/>
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b7dfccb..c236e31 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -10,8 +10,8 @@ catalogs:
specifier: 12.0.0
version: 12.0.0
'@behindthemusictree/genre-tree-view':
- specifier: 1.6.1
- version: 1.6.1
+ specifier: 1.7.0
+ version: 1.7.0
'@behindthemusictree/ui':
specifier: ^0.1.0
version: 0.1.0
@@ -31,7 +31,7 @@ importers:
version: link:../../packages/app-kit
'@behindthemusictree/genre-tree-view':
specifier: 'catalog:'
- version: 1.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@behindthemusictree/ui':
specifier: 'catalog:'
version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -77,7 +77,7 @@ importers:
version: 12.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@behindthemusictree/genre-tree-view':
specifier: 'catalog:'
- version: 1.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@behindthemusictree/ui':
specifier: 'catalog:'
version: 0.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -267,8 +267,8 @@ packages:
react: '>=18.0.0'
react-dom: '>=18.0.0'
- '@behindthemusictree/genre-tree-view@1.6.1':
- resolution: {integrity: sha512-fAy6jYBbwY08DNvDdiW8tBy86AC6xqpTJVEiEK/jGPg8FwYeOj/n9EAhzh5m9vkpSm6zEcTyxfuO4p1MLg+EUg==, tarball: https://npm.pkg.github.com/download/@behindthemusictree/genre-tree-view/1.6.1/a6b060b0f13912c289237133ab024e8bd67d28c3}
+ '@behindthemusictree/genre-tree-view@1.7.0':
+ resolution: {integrity: sha512-vn1A2+5Eg1gjT7HF9Lbm+SQZqEeQojxOo27lkIoSMEJHXLCVIu0l7+IzRw1sfRC+p5d67sdsEdjjoxz2+puxpg==, tarball: https://npm.pkg.github.com/download/@behindthemusictree/genre-tree-view/1.7.0/fade0c4f803c481adfc75a86d10104a7a01a39b9}
peerDependencies:
react: '>=18.0.0'
react-dom: '>=18.0.0'
@@ -2522,7 +2522,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- '@behindthemusictree/genre-tree-view@1.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ '@behindthemusictree/genre-tree-view@1.7.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
d3: 7.9.0
react: 18.3.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 77c133c..956f205 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,7 +4,7 @@ packages:
catalog:
"@behindthemusictree/brand": 12.0.0
- "@behindthemusictree/genre-tree-view": 1.6.1
+ "@behindthemusictree/genre-tree-view": 1.7.0
"@behindthemusictree/ui": ^0.1.0
allowBuilds: