diff --git a/CHANGELOG.md b/CHANGELOG.md index 13add86..634ec63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.6.2] - 2026-09-12 + +### Fixed + +- Setting `selectedNodeId` now opens and centers that node's info panel, exactly as a direct click + on it would, instead of only highlighting it — so a consumer-driven selection (e.g. from its own + search box) produces the same visible result as clicking the node. `GenreTreeWheel` / + `GenreTreeWheelRight` swap in the node's owning root first, and `GenreTreeWheelRadialPopCore` + expands the center node's subtree first when the selection lands inside it. +- `GenreTreeWheel`, `GenreTreeWheelRight`, `GenreTreeWheelRadial` and + `GenreTreeWheelRadialPopCore` now honour `selectedNodeId` at all — the prop was accepted by + their types but ignored, so it had no effect on those four renderers. + ## [1.6.1] - 2026-09-11 ### Fixed diff --git a/packages/genre-tree-view/package.json b/packages/genre-tree-view/package.json index d929ca7..49a8d43 100644 --- a/packages/genre-tree-view/package.json +++ b/packages/genre-tree-view/package.json @@ -1,6 +1,6 @@ { "name": "@behindthemusictree/genre-tree-view", - "version": "1.6.1", + "version": "1.6.2", "description": "Reusable, presentational D3-based genre/hierarchy tree visualization component for React", "repository": { "type": "git", diff --git a/packages/genre-tree-view/src/GenreTree.tsx b/packages/genre-tree-view/src/GenreTree.tsx index 9c01f5c..2ea6501 100644 --- a/packages/genre-tree-view/src/GenreTree.tsx +++ b/packages/genre-tree-view/src/GenreTree.tsx @@ -194,6 +194,26 @@ export function GenreTree({ effectiveSelectedNodeId, ]); + // Mirrors the onNodeClick/onSelectNode handling: an externally-controlled selection (e.g. from a + // search result) centers the viewport on that node and opens its info panel exactly as a direct + // click would. Declared after the draw effect above so the node's group element already exists, + // and skipped when `interactive` is false since the panel then belongs to an ancestor. + useEffect(() => { + if (!interactive || !selectedNodeIdProp) return; + if (panel?.node.id === selectedNodeIdProp) return; + const targetNode = nodes.find((node) => node.id === selectedNodeIdProp); + const element = svgRef.current?.querySelector( + `#group-${CSS.escape(selectedNodeIdProp)}`, + ); + if (!targetNode || !element) return; + centerOnElementRef.current( + element, + ZOOM_FOCUS_SCALE, + resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH), + ); + showNodeInfoRef.current(targetNode, element, viewportRef.current); + }, [interactive, selectedNodeIdProp, nodes, panel]); + useEffect(() => { const svgElement = svgRef.current; return () => { diff --git a/packages/genre-tree-view/src/GenreTreeWheelBase.tsx b/packages/genre-tree-view/src/GenreTreeWheelBase.tsx index 9bf6bff..576ddd7 100644 --- a/packages/genre-tree-view/src/GenreTreeWheelBase.tsx +++ b/packages/genre-tree-view/src/GenreTreeWheelBase.tsx @@ -84,6 +84,7 @@ export function WheelCore({ additionalActions, showToolbar = true, renderExtraDetails, + selectedNodeId, }: WheelCoreProps) { const treeOrientation: TreeOrientation = direction === "left" ? "horizontal-anchored" : "vertical"; @@ -244,6 +245,50 @@ export function WheelCore({ } }; + // An externally-controlled selection (e.g. from a search result) may belong to a root other than + // the one currently mounted — swap it in the same way a chip click would before trying to locate + // the node's DOM element below. Adjusted during render rather than in an effect so the swap is + // part of the same commit (see React's "adjusting state when a prop changes"), and only on an + // actual change of the prop so a later chip click isn't undone. + const [appliedSelectedNodeId, setAppliedSelectedNodeId] = + useState(selectedNodeId); + if (selectedNodeId !== appliedSelectedNodeId) { + setAppliedSelectedNodeId(selectedNodeId); + const owningGroupIndex = selectedNodeId + ? groups.findIndex((group) => + group.nodes.some((node) => node.id === selectedNodeId), + ) + : -1; + if ( + owningGroupIndex !== -1 && + groups[owningGroupIndex].root.id !== effectiveRootId + ) { + handleChipClick( + groups[owningGroupIndex].root.id, + getChipAngle(owningGroupIndex, groups.length), + ); + } + } + + // Mirrors the onNodeClick/onSelectNode handling below: once the owning root's subtree is + // mounted, center the viewport on the externally-selected node and open its info panel exactly + // as a direct click would. + useEffect(() => { + if (!selectedNodeId || panel?.node.id === selectedNodeId) return; + const targetNode = nodes.find((node) => node.id === selectedNodeId); + const element = treeAnchorRef.current?.querySelector( + `#group-${CSS.escape(selectedNodeId)}`, + ); + if (!targetNode || !element) return; + panZoom.centerOnElement( + element, + ZOOM_FOCUS_SCALE, + resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH), + ); + showNodeInfo(targetNode, element, viewportRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps -- panZoom re-created on pan/zoom; guarded by the panel?.node.id check above + }, [selectedNodeId, effectiveRootId, nodes, panel, showNodeInfo]); + return (
groupNodesByRoot(nodes), [nodes]); @@ -420,6 +421,25 @@ export function WheelRadialCore({ setTopRootId(rootId); }; + // Every root's core subtree is always mounted (see the SVG-building effect above), so an + // externally-controlled selection (e.g. from a search result) never needs a root swap — just + // locate its element and open the panel exactly as a direct click would. + useEffect(() => { + if (!selectedNodeId || panel?.node.id === selectedNodeId) return; + const targetNode = nodes.find((node) => node.id === selectedNodeId); + const element = viewportRef.current?.querySelector( + `#group-${CSS.escape(selectedNodeId)}`, + ); + if (!targetNode || !element) return; + panZoom.centerOnElement( + element, + ZOOM_FOCUS_SCALE, + resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH), + ); + showNodeInfo(targetNode, element, viewportRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps -- panZoom re-created on pan/zoom; guarded by the panel?.node.id check above + }, [selectedNodeId, nodes, panel, showNodeInfo]); + // One divider per boundary between two angularly-adjacent roots — each root's own continuous // (unwrapped) angle plus half its weight-proportional width (sectorSpanByRootId) lands exactly on // the boundary with its next neighbor, since computeRadialLayout tiled the roots' widths diff --git a/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx b/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx index 3a702d9..1641d62 100644 --- a/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx +++ b/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx @@ -134,6 +134,7 @@ export function WheelRadialPopCoreCore({ showToolbar = true, renderExtraDetails, allowWheelRotation = true, + selectedNodeId, }: WheelRadialPopCoreProps) { const centerNode = nodes.find( (node) => node.parentId === null && node.name === CENTER_NODE_NAME, @@ -828,6 +829,44 @@ export function WheelRadialPopCoreCore({ if (allowWheelRotation) setRotationTopRootId(rootId); }; + // Every ring root's core/pop branches are always mounted, but the center "Mainstream Pop" node's + // own subtree only renders once expanded — so an externally-controlled selection (e.g. from a + // search result) landing in there needs to expand it first. Adjusted during render rather than in + // an effect so the expansion is part of the same commit (see React's "adjusting state when a prop + // changes"), and only on an actual change of the prop so a later manual collapse isn't undone. + const [appliedSelectedNodeId, setAppliedSelectedNodeId] = + useState(selectedNodeId); + if (selectedNodeId !== appliedSelectedNodeId) { + setAppliedSelectedNodeId(selectedNodeId); + if ( + selectedNodeId && + selectedNodeId !== centerNode.id && + centerSubtreeNodeIds.has(selectedNodeId) && + !isPopExpanded + ) { + setIsPopExpanded(true); + } + } + + // Mirrors the onNodeClick/onSelectNode handling below: once the node's sector (and, for a + // center-subtree node, the expanded center) is mounted, center the viewport on the + // externally-selected node and open its info panel exactly as a direct click would. + useEffect(() => { + if (!selectedNodeId || panel?.node.id === selectedNodeId) return; + const targetNode = nodes.find((node) => node.id === selectedNodeId); + const element = viewportRef.current?.querySelector( + `#group-${CSS.escape(selectedNodeId)}`, + ); + if (!targetNode || !element) return; + panZoom.centerOnElement( + element, + ZOOM_FOCUS_SCALE, + resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH), + ); + showNodeInfo(targetNode, element, viewportRef.current); + // eslint-disable-next-line react-hooks/exhaustive-deps -- panZoom re-created on pan/zoom; guarded by the panel?.node.id check above + }, [selectedNodeId, nodes, panel, showNodeInfo, isPopExpanded]); + // One divider per boundary between two angularly-adjacent ring roots — see WheelRadialCore's own // copy of this computation for why each root's own continuous angle plus half its // weight-proportional width (sectorSpanByRootId) lands exactly on the boundary with its next diff --git a/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx index e82a869..624f3e7 100644 --- a/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx +++ b/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx @@ -531,6 +531,31 @@ describe("GenreTree", () => { qsSpy.mockRestore(); rectSpy.mockRestore(); }); + + it("opens for an externally-controlled selectedNodeId, as a direct click would", () => { + const { container, rerender } = render( + , + ); + const wrapper = container.firstChild as HTMLElement; + const rectSpy = mockRects(container, wrapper, 400); + expect(container.querySelector(".gtv-info-panel")).toBeFalsy(); + + rerender(); + + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Child A"); + + rectSpy.mockRestore(); + }); + + it("leaves the panel to its ancestor when selectedNodeId is set on a non-interactive tree", () => { + const { container, rerender } = render( + , + ); + + rerender(); + + expect(container.querySelector(".gtv-info-panel")).toBeFalsy(); + }); }); }); }); diff --git a/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx index a93c84f..3b1a2ec 100644 --- a/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx +++ b/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx @@ -404,6 +404,61 @@ describe("GenreTreeWheel", () => { rectSpy.mockRestore(); }); + it("opens for an externally-controlled selectedNodeId, swapping in its owning root first", () => { + const { container, rerender } = render( + , + ); + const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; + const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this === wheelContainer) return makeRect(0, 0, 800, 600); + return makeRect(400, 0, 10, 10); + }); + expect(chipFor(container, "Rock").className).toContain("gtv-wheel-chip--selected"); + + rerender(); + + expect(chipFor(container, "Electronic").className).toContain("gtv-wheel-chip--selected"); + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Techno"); + + rerender(); + expect(chipFor(container, "Electronic").className).toContain("gtv-wheel-chip--selected"); + + rectSpy.mockRestore(); + }); + + it("does not swap roots for an externally-controlled selectedNodeId already in the active root", () => { + const { container, rerender } = render( + , + ); + const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; + const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this === wheelContainer) return makeRect(0, 0, 800, 600); + return makeRect(400, 0, 10, 10); + }); + + rerender(); + + expect(chipFor(container, "Rock").className).toContain("gtv-wheel-chip--selected"); + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Punk"); + + rectSpy.mockRestore(); + }); + + it("ignores an externally-controlled selectedNodeId that matches no node", () => { + const { container, rerender } = render( + , + ); + + rerender(); + + expect(container.querySelector(".gtv-info-panel")).toBeFalsy(); + expect(chipFor(container, "Rock").className).toContain("gtv-wheel-chip--selected"); + }); + it("switches to the parent node when its chip is clicked", () => { const { container } = render(); const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; diff --git a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx index 5226a1a..9184d87 100644 --- a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx +++ b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx @@ -593,6 +593,31 @@ describe("GenreTreeWheelRadial", () => { rectSpy.mockRestore(); }); + it("opens for an externally-controlled selectedNodeId in any sector, no root swap needed", () => { + const { container, rerender } = render( + , + ); + const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; + const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this === wheelContainer) return makeRect(0, 0, 800, 600); + return makeRect(400, 0, 10, 10); + }); + + rerender(); + + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass"); + + rerender(); + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass"); + + rerender(); + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass"); + + rectSpy.mockRestore(); + }); + it("switches to the parent node when its chip is clicked", () => { const { container } = render(); const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; diff --git a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx index 19bf144..bab480d 100644 --- a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx +++ b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx @@ -896,6 +896,82 @@ describe("GenreTreeWheelRadialPopCore", () => { rectSpy.mockRestore(); }); + it("opens for an externally-controlled selectedNodeId in a ring sector", () => { + const { container, rerender } = render( + , + ); + const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; + const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this === wheelContainer) return makeRect(0, 0, 800, 600); + return makeRect(400, 0, 10, 10); + }); + + rerender(); + + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bebop"); + + rerender(); + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bebop"); + + rectSpy.mockRestore(); + }); + + it("ignores an externally-controlled selectedNodeId equal to the center node itself", () => { + const { container, rerender } = render( + , + ); + const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; + const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this === wheelContainer) return makeRect(0, 0, 800, 600); + return makeRect(400, 0, 10, 10); + }); + + rerender(); + + expect(container.querySelector(".gtv-wheel-chip--center.gtv-wheel-chip--circle")).toBeTruthy(); + expect(container.querySelector(".gtv-info-panel")).toBeFalsy(); + + rectSpy.mockRestore(); + }); + + it("expands the center subtree first when an externally-controlled selectedNodeId lands in it", () => { + const nodesWithCenterChildren: GenreTreeNode[] = [ + ...NODES_WITH_POP, + { id: "pop-child", parentId: "pop", name: "Radio Hits", itemCount: 1 }, + { id: "pop-child-2", parentId: "pop", name: "Chart Toppers", itemCount: 1 }, + ]; + const { container, rerender } = render( + , + ); + const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; + const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + if (this === wheelContainer) return makeRect(0, 0, 800, 600); + return makeRect(400, 0, 10, 10); + }); + expect(container.querySelector(".gtv-wheel-center-sector #group-pop-child")).toBeFalsy(); + + rerender( + , + ); + + expect(container.querySelector(".gtv-wheel-center-sector #group-pop-child")).toBeTruthy(); + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Radio Hits"); + + rerender( + , + ); + + expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Chart Toppers"); + + rectSpy.mockRestore(); + }); + it("switches to the parent node when its chip is clicked", () => { const { container } = render(); const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement; diff --git a/packages/genre-tree-view/src/types.ts b/packages/genre-tree-view/src/types.ts index 2d7922d..525b5ec 100644 --- a/packages/genre-tree-view/src/types.ts +++ b/packages/genre-tree-view/src/types.ts @@ -111,7 +111,11 @@ export interface GenreTreeProps { /** Overrides which node is shown highlighted (with unrelated nodes/links dimmed) instead of * GenreTree's own internally-tracked clicked node — used by `interactive={false}` instances * (e.g. GenreTreeWheel's nested tree) whose own info panel, and thus own selection, is owned - * by an ancestor rather than by this instance. Leave unset to use the internal selection. */ + * by an ancestor rather than by this instance. On the top-level exported renderers + * (`GenreTreeWheel`, `GenreTreeWheelRight`, `GenreTreeWheelRadial`, + * `GenreTreeWheelRadialPopCore`, `GenreTree` itself), setting this also opens and centers the + * info panel for that node — the same effect a direct click on it would have — swapping in its + * owning root/sector first if needed. Leave unset to use the internal selection. */ selectedNodeId?: string | null; /** Renders extra content into the built-in info panel for the clicked node, below its built-in * fields — e.g. a consumer-fetched detail like essential tracks. The library has no knowledge