{results.map((genrePlaylist) => (
diff --git a/packages/app-kit-ui/src/genre-tree/GenreTreeView.test.tsx b/packages/app-kit-ui/src/genre-tree/GenreTreeView.test.tsx
index a0fccf1..8f6c163 100644
--- a/packages/app-kit-ui/src/genre-tree/GenreTreeView.test.tsx
+++ b/packages/app-kit-ui/src/genre-tree/GenreTreeView.test.tsx
@@ -1221,15 +1221,20 @@ describe("GenreTreeView", () => {
expect(treeWheelPropsMock.mock.calls.at(-1)?.[0].selectedNodeId).toBeNull();
});
- it("plays the selected genre from the search bar's Play button", () => {
+ function openBand(id: string) {
+ const output = treeWheelPropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ id });
+ render(<>{output}>);
+ }
+
+ it("plays the selected genre from the action band's Play button", () => {
const jazz = makePlaylist({ uuid: "gp1", name: "Jazz", criteria: { uuid: "c1", name: "Jazz" } });
useListFullGenrePlaylistsMock.mockReturnValue({ data: [jazz], isPending: false });
renderView();
fireEvent.click(screen.getByRole("button", { name: "Wheel" }));
- expect(screen.queryByRole("button", { name: "Play" })).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), { target: { value: "Ja" } });
fireEvent.click(screen.getByRole("button", { name: "Jazz" }));
+ openBand("gp1");
fireEvent.click(screen.getByRole("button", { name: "Play" }));
expect(playNewTrackListFromGenrePlaylistMock).toHaveBeenCalledWith(jazz, "me");
@@ -1244,6 +1249,7 @@ describe("GenreTreeView", () => {
fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), { target: { value: "Ja" } });
fireEvent.click(screen.getByRole("button", { name: "Jazz" }));
+ openBand("gp1");
fireEvent.click(screen.getByRole("button", { name: "Play" }));
expect(setIsPlayingMock).toHaveBeenCalledWith(true);
@@ -1262,13 +1268,14 @@ describe("GenreTreeView", () => {
fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), { target: { value: "Ja" } });
fireEvent.click(screen.getByRole("button", { name: "Jazz" }));
+ openBand("gp1");
fireEvent.click(screen.getByRole("button", { name: "Play" }));
await waitFor(() => expect(errorSpy).toHaveBeenCalledWith("Failed to load genre playlist tracks:", failure));
errorSpy.mockRestore();
});
- it("zooms from the search bar in pop-core when the selected genre has children", () => {
+ it("zooms from the action band in pop-core when the selected genre has children", () => {
const parent = makePlaylist({ uuid: "gp1", name: "Mainstream Pop", root: { uuid: "gp1" }, criteria: { uuid: "c1", name: "Mainstream Pop" } });
const child = makePlaylist({
uuid: "gp2",
@@ -1282,11 +1289,58 @@ describe("GenreTreeView", () => {
fireEvent.change(screen.getByRole("textbox", { name: "Search a genre" }), { target: { value: "Main" } });
fireEvent.click(screen.getByRole("button", { name: "Mainstream Pop" }));
- fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));
+ const output = treeWheelRadialPopCorePropsMock.mock.calls.at(-1)?.[0].renderExtraDetails({ id: "gp1" });
+ render(<>{output}>);
+ fireEvent.click(screen.getByRole("button", { name: "Focus" }));
expect(onFocusGenreChange).toHaveBeenCalledWith("gp1", "zoom-in");
});
+ it("wires the action band's Add, Rename and Parent actions to the host callbacks", () => {
+ const jazz = makePlaylist({ uuid: "gp1", name: "Jazz", criteria: { uuid: "c1", name: "Jazz" } });
+ useListFullGenrePlaylistsMock.mockReturnValue({ data: [jazz], isPending: false });
+ const props = renderView();
+ fireEvent.click(screen.getByRole("button", { name: "Wheel" }));
+ openBand("gp1");
+
+ fireEvent.click(screen.getByRole("button", { name: "Add" }));
+ fireEvent.click(screen.getByRole("button", { name: "Rename" }));
+ fireEvent.click(screen.getByRole("button", { name: "Parent" }));
+
+ expect(props.handleGenreCreationAction).toHaveBeenCalledWith(jazz.criteria);
+ expect(props.handleGenreRenameAction).toHaveBeenCalledWith(jazz.criteria);
+ });
+
+ it("hides the mutating band actions when read-only", () => {
+ useListFullGenrePlaylistsMock.mockReturnValue({ data: [makePlaylist()], isPending: false });
+ renderView({ readOnly: true });
+ fireEvent.click(screen.getByRole("button", { name: "Wheel" }));
+ openBand("gp1");
+
+ expect(screen.queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Rename" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Parent" })).not.toBeInTheDocument();
+ });
+
+ it("proxies the search field's Previous button to genre-tree-view's own previous button", () => {
+ useListFullGenrePlaylistsMock.mockReturnValue({ data: [makePlaylist()], isPending: false });
+ const { container } = render(
+ ,
+ );
+ const libPrevious = document.createElement("button");
+ libPrevious.className = "gtv-info-panel-previous";
+ const onClick = vi.fn();
+ libPrevious.addEventListener("click", onClick);
+ const overlay = document.createElement("div");
+ overlay.className = "search-overlay";
+ overlay.appendChild(libPrevious);
+ container.querySelector(".genre-tree-view")?.appendChild(overlay);
+
+ fireEvent.click(screen.getByRole("button", { name: "Previous", hidden: true }));
+
+ expect(onClick).toHaveBeenCalledOnce();
+ });
+
it("follows a controlled selectedNodeId and reports user selections without owning them", () => {
useListFullGenrePlaylistsMock.mockReturnValue({
data: [
diff --git a/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx b/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx
index b7380e5..4a578b8 100644
--- a/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx
+++ b/packages/app-kit-ui/src/genre-tree/GenreTreeView.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, type ReactNode } from "react";
+import { useCallback, useRef, type ComponentProps, type ReactNode } from "react";
import { z } from "zod";
import { Plus } from "lucide-react";
import { IconTextButton, Button } from "@behindthemusictree/ui";
@@ -16,7 +16,7 @@ import type {
GenreTreeViewMode,
} from "@behindthemusictree/genre-tree-view";
-import { CriteriaMinimum } from "@behindthemusictree/app-kit/genre-tree";
+import { CriteriaMinimum, CriteriaPlaylistSimple } from "@behindthemusictree/app-kit/genre-tree";
import { CriteriaOverview } from "@behindthemusictree/app-kit/genre-tree";
import { Scope } from "@behindthemusictree/app-kit/transport";
import { useGenreTreeViewState, useTrackList, TrackListOriginType } from "@behindthemusictree/app-kit/genre-tree";
@@ -31,6 +31,50 @@ import GenrePlaylistTreeWheel from "./playlist-tree/TreeWheel";
import GenrePlaylistTreeWheelRadialPopCore, { type GenreFocusProps } from "./playlist-tree/TreeWheelRadialPopCore";
import { GenreTreeWheelHandoff } from "./GenreTreeWheelHandoff";
import GenreSearch from "./GenreSearch";
+import GenreActionBand from "./GenreActionBand";
+
+// genre-tree-view's "previous" button (back to the last selected genre) renders inside the info panel, which sits
+// under the floating search bar. It is hidden and its click is proxied by the search field's own button, which
+// appears (fading in, sliding from the left) whenever the original exists; the field makes room for it.
+const PREVIOUS_BUTTON_CSS = `
+.genre-tree-view .gtv-info-panel-body { display: flex; flex-direction: column; }
+.genre-tree-view .gtv-info-panel-extra { order: -1; }
+.genre-tree-view .search-overlay .gtv-info-panel-previous { display: none; }
+.genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search input { padding-left: 44px; }
+.genre-tree-view:has(.search-overlay .gtv-info-panel-previous) .genre-search-previous { display: flex; }
+@keyframes genre-tree-view-previous-in { from { opacity: 0; transform: translate(-8px, -50%); } to { opacity: 1; transform: translate(0, -50%); } }
+@media (prefers-reduced-motion: no-preference) {
+ .genre-search-previous { animation: genre-tree-view-previous-in 200ms ease-out; }
+}
+`;
+
+type GenreNodeActionBandProps = Omit, "onPlayPause" | "isPlaying"> & {
+ genrePlaylist: CriteriaPlaylistSimple;
+ scope: Scope;
+};
+
+function GenreNodeActionBand({ genrePlaylist, scope, ...bandProps }: GenreNodeActionBandProps) {
+ const { isPlaying, setIsPlaying } = usePlayer();
+ const { trackList, playNewTrackListFromGenrePlaylist } = useTrackList();
+ const isCurrentTrackList =
+ trackList?.origin.type === TrackListOriginType.GENRE_PLAYLIST && trackList.origin.uuid === genrePlaylist.uuid;
+ const handlePlayPause = () => {
+ if (isCurrentTrackList) {
+ setIsPlaying(!isPlaying);
+ return;
+ }
+ playNewTrackListFromGenrePlaylist(genrePlaylist, scope).catch((error) => {
+ console.error("Failed to load genre playlist tracks:", error);
+ });
+ };
+ return (
+ 0 ? handlePlayPause : undefined}
+ isPlaying={isCurrentTrackList && isPlaying}
+ />
+ );
+}
export type {
ControlsOrientation,
@@ -149,31 +193,15 @@ export function GenreTreeView({
criteriaOverviewSchema,
});
+ const rootRef = useRef(null);
+ const handlePrevious = () => {
+ rootRef.current?.querySelector(".search-overlay .gtv-info-panel-previous")?.click();
+ };
+
const handleSelectedNodeChange = selectNode;
const handleGenreSearchClear = clearSelection;
const handleGenreSearchSelect = selectGenrePlaylist;
- const { isPlaying, setIsPlaying } = usePlayer();
- const { trackList, playNewTrackListFromGenrePlaylist } = useTrackList();
- const isSelectedPlaying =
- trackList?.origin.type === TrackListOriginType.GENRE_PLAYLIST &&
- trackList.origin.uuid === selectedGenrePlaylist?.uuid &&
- isPlaying;
- const canPlay =
- selectedGenrePlaylist !== undefined &&
- selectedGenrePlaylist !== null &&
- selectedGenrePlaylist.tracksCount > 0;
- const handleGenreSearchPlayPause = () => {
- if (!selectedGenrePlaylist) return;
- if (trackList?.origin.type === TrackListOriginType.GENRE_PLAYLIST && trackList.origin.uuid === selectedGenrePlaylist.uuid) {
- setIsPlaying(!isPlaying);
- return;
- }
- playNewTrackListFromGenrePlaylist(selectedGenrePlaylist, scope).catch((error) => {
- console.error("Failed to load genre playlist tracks:", error);
- });
- };
-
// Mirrors genre-tree-view's isZoomableNode: a node zooms when it has children and at most one pop-side child.
const selectedUuid = selectedGenrePlaylist?.uuid;
const selectedChildren = selectedUuid
@@ -189,15 +217,29 @@ export function GenreTreeView({
const handleGenreSearchZoomIn = () => {
if (selectedUuid) focus.onFocusGenreChange?.(selectedUuid, "zoom-in");
};
+ const zoomInRef = useRef<(() => void) | undefined>(undefined);
+ zoomInRef.current = canZoomIn ? handleGenreSearchZoomIn : undefined;
// Stable across selections: the overview fetch lives in GenreDetailExtras, so its loading
// states re-render only the info panel, never the tree.
const renderExtraDetails = useCallback(
(node: GenreTreeNode): ReactNode => {
const genrePlaylist = genrePlaylistByNodeId.get(node.id);
- const genreUuid = genrePlaylist?.criteria?.uuid;
- if (!genrePlaylist || !genreUuid) return null;
+ const criteria = genrePlaylist?.criteria;
+ if (!genrePlaylist || !criteria) return null;
+ const genreUuid = criteria.uuid;
return (
+ <>
+ handleGenreCreationAction(criteria)}
+ onRename={readOnly ? undefined : () => handleGenreRenameAction(criteria)}
+ onChangeParent={readOnly ? undefined : () => requestReparent(genrePlaylist.uuid)}
+ additionalActions={additionalActions}
+ />
key={genreUuid}
genreUuid={genreUuid}
@@ -213,9 +255,11 @@ export function GenreTreeView({
renderRecordingActions={renderRecordingActions}
renderTracksToolbar={renderTracksToolbar}
/>
+ >
);
},
- [genrePlaylistByNodeId, scope, getBackendBaseUrl, criteriaOverviewSchema,
+ [genrePlaylistByNodeId, additionalActions, readOnly, handleGenreCreationAction, handleGenreRenameAction, requestReparent,
+ scope, getBackendBaseUrl, criteriaOverviewSchema,
renderGenreDetailExtras,
renderArtistLink,
renderSummary,
@@ -237,9 +281,7 @@ export function GenreTreeView({
onSelect={handleGenreSearchSelect}
selectedName={selectedGenrePlaylist?.name ?? null}
onClear={handleGenreSearchClear}
- onZoomIn={canZoomIn ? handleGenreSearchZoomIn : undefined}
- onPlayPause={canPlay ? handleGenreSearchPlayPause : undefined}
- isPlaying={isSelectedPlaying}
+ onPrevious={handlePrevious}
/>
)}
{!isLoading && !isControlled && (
@@ -318,7 +360,8 @@ export function GenreTreeView({
);
return (
-
+
+
{actions}
@@ -331,7 +374,7 @@ export function GenreTreeView({
// the header inset (10px padding + 38px = the search bar's bottom edge) extends its fill behind the search bar,
// Google Maps style; the 8px bottom padding mirrors the 8px above the bar, and the hidden title's height is zeroed.
// The panel's own Zoom in button is hidden: the search bar carries it.
-
+
}>
({
) : viewMode === "pop-core" ? (
// The info panel's top edge aligns with the tree's, 8px above the h-10 search bar at top-5;
// the header inset (8px + 40px + 4px) extends its fill behind the search bar, Google Maps style.
-