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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@ easy to spot when bumping.

## [Unreleased]

## [14.0.0] - 2026-10-26

### Added

- `GenreSearch` / `GenreTreeView`: optional `onQueryChange` / `onSearchQueryChange` and `extraResults` / `searchExtraResults` let the host add a second result group (e.g. artists) to the search dropdown, shown under "Genres" and the group's label. Unset, the dropdown is unchanged.
- `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
4 changes: 2 additions & 2 deletions packages/app-kit-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@behindthemusictree/app-kit-ui",
"version": "13.5.0",
"version": "14.0.0",
"description": "Styled BehindTheMusicTree React components (popups, auth callback, genre tree) built on the headless @behindthemusictree/app-kit",
"repository": {
"type": "git",
Expand Down Expand Up @@ -46,7 +46,7 @@
"test": "vitest run --coverage"
},
"peerDependencies": {
"@behindthemusictree/app-kit": "^13.5.0",
"@behindthemusictree/app-kit": "^14.0.0",
"@behindthemusictree/ui": "^0.2.0",
"@tanstack/react-query": ">=5.0.0",
"next": ">=14.0.0",
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>
);
}
80 changes: 59 additions & 21 deletions packages/app-kit-ui/src/genre-tree/GenreSearch.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,26 +96,64 @@ describe("GenreSearch", () => {
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();
describe("extraResults", () => {
const extra = (onSelect = vi.fn()) => ({
label: "Artists",
items: [{ key: "a", content: "Taylor Swift", onSelect }],
status: "1 artist found",
});

it("groups genres and extra results under headings", () => {
render(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} extraResults={extra()} />);

fireEvent.change(screen.getByRole("textbox"), { target: { value: "amb" } });

expect(screen.getByText("Genres")).toBeInTheDocument();
expect(screen.getByText("Artists")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Taylor Swift" })).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("1 artist found");
});

it("shows no headings without extra results", () => {
render(<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} />);

fireEvent.change(screen.getByRole("textbox"), { target: { value: "amb" } });

expect(screen.queryByText("Genres")).not.toBeInTheDocument();
});

it("reports typing, and clears the query when an extra result is picked", () => {
const onQueryChange = vi.fn();
const onSelect = vi.fn();
render(
<GenreSearch
genrePlaylists={genrePlaylists}
onSelect={vi.fn()}
onQueryChange={onQueryChange}
extraResults={extra(onSelect)}
/>,
);

fireEvent.change(screen.getByRole("textbox"), { target: { value: "tay" } });
expect(onQueryChange).toHaveBeenLastCalledWith("tay");
fireEvent.click(screen.getByRole("button", { name: "Taylor Swift" }));

expect(onSelect).toHaveBeenCalledOnce();
expect(onQueryChange).toHaveBeenLastCalledWith("");
expect(screen.getByRole("textbox")).toHaveValue("");
});

it("does not report the query when the selected name refills the input", () => {
const onQueryChange = vi.fn();
const { rerender } = render(
<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} onQueryChange={onQueryChange} />,
);

rerender(
<GenreSearch genrePlaylists={genrePlaylists} onSelect={vi.fn()} onQueryChange={onQueryChange} selectedName="Ambient" />,
);

expect(onQueryChange).not.toHaveBeenCalled();
});
});
});
Loading
Loading