Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ easy to spot when bumping.

## [Unreleased]

### Added

- `GenreTreeView` (wheel, pop-core): genre-tree-view's "Previous" button now sits inside the search field, left of the genre name, and fades in sliding from the left; the field's left padding widens to make room.
- `GenreActionBand`: Google Maps-style band under the detail panel header (icon over label) holding Play/Pause, Focus, Add, Rename, Parent and the `additionalActions`; genre-tree-view's own toolbar and its ⋮ menu are hidden.

### Breaking

- `GenreSearch`: `onZoomIn`, `onPlayPause` and `isPlaying` are removed; those actions moved to the detail panel's action band. The search icon now sits right of the field's text, left of the clear button.

## [13.5.0] - 2026-10-26

### Added
Expand Down
51 changes: 51 additions & 0 deletions packages/app-kit-ui/src/genre-tree/GenreActionBand.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import type { GenreTreeAction, GenreTreeNode } from "@behindthemusictree/genre-tree-view";
import GenreActionBand from "./GenreActionBand";

const node = { id: "gp1" } as GenreTreeNode;

describe("GenreActionBand", () => {
it("renders nothing when no action applies", () => {
const { container } = render(<GenreActionBand node={node} />);

expect(container).toBeEmptyDOMElement();
});

it("renders each provided action and wires its click", () => {
const handlers = {
onPlayPause: vi.fn(),
onZoomIn: vi.fn(),
onAddChild: vi.fn(),
onRename: vi.fn(),
onChangeParent: vi.fn(),
};
render(<GenreActionBand node={node} {...handlers} />);

for (const name of ["Play", "Focus", "Add", "Rename", "Parent"]) {
fireEvent.click(screen.getByRole("button", { name }));
}

for (const handler of Object.values(handlers)) expect(handler).toHaveBeenCalledOnce();
});

it("labels the play action Pause while playing", () => {
render(<GenreActionBand node={node} onPlayPause={vi.fn()} isPlaying />);

expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
});

it("renders additional actions and disables those that are not enabled", () => {
const onClick = vi.fn();
const actions: GenreTreeAction[] = [
{ key: "a", label: () => "Merge", icon: () => <span />, onClick },
{ key: "b", label: () => "Locked", icon: () => <span />, onClick, enabled: () => false },
];
render(<GenreActionBand node={node} additionalActions={() => actions} />);

fireEvent.click(screen.getByRole("button", { name: "Merge" }));

expect(onClick).toHaveBeenCalledWith(expect.anything(), node);
expect(screen.getByRole("button", { name: "Locked" })).toBeDisabled();
});
});
103 changes: 103 additions & 0 deletions packages/app-kit-ui/src/genre-tree/GenreActionBand.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

import type { ReactNode } from "react";
import { MoveUpRight, Pause, Pencil, Play, Plus, ZoomIn } from "lucide-react";
import type {
GenreTreeAction,
GenreTreeNode,
} from "@behindthemusictree/genre-tree-view";

export type GenreActionBandProps = {
node: GenreTreeNode;
/** When set, shows the Play/Pause action. */
onPlayPause?: () => void;
isPlaying?: boolean;
/** When set, shows the "Zoom in" action. */
onZoomIn?: () => void;
/** When set, shows the "Add" (sub-genre) action. */
onAddChild?: () => void;
/** When set, shows the "Rename" action. */
onRename?: () => void;
/** When set, shows the "Parent" (change parent) action. */
onChangeParent?: () => void;
additionalActions?: (node: GenreTreeNode) => GenreTreeAction[];
};

type BandItem = {
key: string;
label: string;
icon: ReactNode;
onClick: (event: React.MouseEvent) => void;
disabled?: boolean;
};

export default function GenreActionBand({
node,
onPlayPause,
isPlaying = false,
onZoomIn,
onAddChild,
onRename,
onChangeParent,
additionalActions,
}: GenreActionBandProps) {
const items: BandItem[] = [];
if (onPlayPause) {
items.push({
key: "play-pause",
label: isPlaying ? "Pause" : "Play",
icon: isPlaying ? (
<Pause aria-hidden="true" className="w-5 h-5" />
) : (
<Play aria-hidden="true" className="w-5 h-5" />
),
onClick: onPlayPause,
});
}
if (onZoomIn) {
items.push({
key: "zoom-in",
label: "Focus",
icon: <ZoomIn aria-hidden="true" className="w-5 h-5" />,
onClick: onZoomIn,
});
}
if (onAddChild) {
items.push({ key: "add-child", label: "Add", icon: <Plus aria-hidden="true" className="w-5 h-5" />, onClick: onAddChild });
}
if (onRename) {
items.push({ key: "rename", label: "Rename", icon: <Pencil aria-hidden="true" className="w-5 h-5" />, onClick: onRename });
}
if (onChangeParent) {
items.push({ key: "change-parent", label: "Parent", icon: <MoveUpRight aria-hidden="true" className="w-5 h-5" />, onClick: onChangeParent });
}
for (const action of additionalActions?.(node) ?? []) {
items.push({
key: action.key,
label: action.label(node),
icon: action.icon(node),
onClick: (event) => action.onClick(event, node),
disabled: action.enabled ? !action.enabled(node) : false,
});
}
if (items.length === 0) return null;

return (
<div className="genre-action-band flex gap-1 px-2 py-3 overflow-x-auto border-b border-[var(--color-neutral-200)]">
{items.map((item) => (
<button
key={item.key}
type="button"
disabled={item.disabled}
onClick={item.onClick}
className="group flex flex-col items-center gap-1 w-16 shrink-0 text-xs text-center text-[var(--color-text)] bg-transparent disabled:opacity-50"
>
<span className="flex items-center justify-center w-10 h-10 rounded-full bg-[var(--color-neutral-100)] transition-colors group-hover:bg-[var(--color-neutral-200)] group-disabled:group-hover:bg-[var(--color-neutral-100)]">
{item.icon}
</span>
{item.label}
</button>
))}
</div>
);
}
23 changes: 0 additions & 23 deletions packages/app-kit-ui/src/genre-tree/GenreSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,27 +95,4 @@ describe("GenreSearch", () => {
expect(onClear).toHaveBeenCalledTimes(1);
expect(screen.getByLabelText("Search a genre")).toHaveValue("");
});

it("shows Zoom in only when onZoomIn is given, and fires it", () => {
const onZoomIn = vi.fn();
const { rerender } = render(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} />);
expect(screen.queryByRole("button", { name: "Zoom in" })).not.toBeInTheDocument();

rerender(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} onZoomIn={onZoomIn} />);
fireEvent.click(screen.getByRole("button", { name: "Zoom in" }));

expect(onZoomIn).toHaveBeenCalledOnce();
});

it("shows Play or Pause per isPlaying when onPlayPause is given, and fires it", () => {
const onPlayPause = vi.fn();
const { rerender } = render(
<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} onPlayPause={onPlayPause} />,
);
fireEvent.click(screen.getByRole("button", { name: "Play" }));
expect(onPlayPause).toHaveBeenCalledOnce();

rerender(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} onPlayPause={onPlayPause} isPlaying />);
expect(screen.getByRole("button", { name: "Pause" })).toBeInTheDocument();
});
});
65 changes: 21 additions & 44 deletions packages/app-kit-ui/src/genre-tree/GenreSearch.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Pause, Play, Search, X, ZoomIn } from "lucide-react";
import { ArrowLeft, Search, X } from "lucide-react";
import { Input } from "@behindthemusictree/ui";

import { CriteriaPlaylistSimple, useGenreSearch } from "@behindthemusictree/app-kit/genre-tree";
Expand All @@ -10,13 +10,10 @@ export type GenreSearchProps = {
onSelect: (genrePlaylist: CriteriaPlaylistSimple) => void;
/** Name of the currently selected genre; fills the input whenever it changes. */
selectedName?: string | null;
/** When set, renders a "Previous" button left of the text; shown only when the host's CSS reveals `.genre-search-previous`. */
onPrevious?: () => void;
/** Called by the clear button, after it empties the input. */
onClear?: () => void;
/** When set, shows a "Zoom in" button that fires it. */
onZoomIn?: () => void;
/** When set, shows a Play/Pause button at the right end of the field that fires it. */
onPlayPause?: () => void;
isPlaying?: boolean;
placeholder?: string;
};

Expand All @@ -25,9 +22,7 @@ export default function GenreSearch({
onSelect,
selectedName = null,
onClear,
onZoomIn,
onPlayPause,
isPlaying = false,
onPrevious,
placeholder = "Search a genre…",
}: GenreSearchProps) {
const { query, setQuery, results, canClear, clear, select } = useGenreSearch({
Expand All @@ -39,31 +34,29 @@ export default function GenreSearch({

return (
<div className="genre-search relative w-[264px] shrink-0">
<Search
aria-hidden="true"
className="absolute left-3 top-1/2 w-4 h-4 text-[var(--color-neutral-500)] -translate-y-1/2 pointer-events-none"
/>
{onPrevious && (
<button
type="button"
aria-label="Previous"
title="Previous"
className="genre-search-previous absolute left-2 top-1/2 z-10 hidden p-1.5 text-[var(--color-neutral-500)] bg-transparent rounded-full -translate-y-1/2 hover:bg-[var(--color-neutral-100)] hover:text-[var(--color-text)]"
onClick={onPrevious}
>
<ArrowLeft aria-hidden="true" className="w-4 h-4" />
</button>
)}
<Input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
aria-label="Search a genre"
className={`pl-9 ${onPlayPause ? (onZoomIn ? "pr-24" : "pr-16") : onZoomIn ? "pr-16" : "pr-9"} bg-white border-[var(--color-neutral-200)] rounded-full shadow-md`}
className="pl-4 pr-16 transition-[padding] duration-200 ease-out motion-reduce:transition-none bg-white border-[var(--color-neutral-200)] rounded-full shadow-md"
/>
{(onZoomIn || onPlayPause || canClear) && (
<div className="absolute right-2 top-1/2 flex items-center -translate-y-1/2">
{onZoomIn && (
<button
type="button"
aria-label="Zoom in"
title="Zoom in"
className="p-1 text-[var(--color-neutral-500)] bg-transparent rounded-full hover:bg-[var(--color-neutral-100)] hover:text-[var(--color-text)]"
onClick={onZoomIn}
>
<ZoomIn aria-hidden="true" className="w-4 h-4" />
</button>
)}
<div className="absolute right-2 top-1/2 flex items-center -translate-y-1/2">
<span className="p-1 text-[var(--color-neutral-500)] pointer-events-none">
<Search aria-hidden="true" className="w-4 h-4" />
</span>
{canClear && (
<button
type="button"
Expand All @@ -74,23 +67,7 @@ export default function GenreSearch({
<X aria-hidden="true" className="w-4 h-4" />
</button>
)}
{onPlayPause && (
<button
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
title={isPlaying ? "Pause" : "Play"}
className="p-1 text-[var(--color-neutral-500)] bg-transparent rounded-full hover:bg-[var(--color-neutral-100)] hover:text-[var(--color-text)]"
onClick={onPlayPause}
>
{isPlaying ? (
<Pause aria-hidden="true" className="w-4 h-4" />
) : (
<Play aria-hidden="true" className="w-4 h-4" />
)}
</button>
)}
</div>
)}
</div>
{results.length > 0 && (
<ul className="genre-search-results absolute z-40 w-full max-h-64 mt-1 overflow-y-auto bg-white border border-[var(--color-neutral-200)] rounded-xl shadow-md">
{results.map((genrePlaylist) => (
Expand Down
Loading
Loading