diff --git a/src/ParseUI.test.tsx b/src/ParseUI.test.tsx index 1242f608..e4b309ba 100644 --- a/src/ParseUI.test.tsx +++ b/src/ParseUI.test.tsx @@ -4225,6 +4225,47 @@ describe("ParseUI", () => { await waitFor(() => expect(mockSetActiveConcept).toHaveBeenCalledWith("2")); }); + it("scrolls the active concept row into view when navigating concepts in Compare mode", async () => { + // Compare/Tags navigation clears selectedRealizationKey, so the sidebar + // auto-scroll must fall back to the active concept's parent row — otherwise + // it would only ever fire on Annotate (regression: MC-460). + window.localStorage.setItem("parse.currentMode", "compare"); + mockConfig = { + ...mockConfig!, + concepts: [ + { id: "1", label: "water" }, + { id: "2", label: "hair" }, + { id: "3", label: "fire" }, + ], + }; + + const originalScrollIntoView = Element.prototype.scrollIntoView; + const scrollSpy = vi.fn(); + Element.prototype.scrollIntoView = scrollSpy; + try { + render(); + + const sidebar = await screen.findByTestId("concept-sidebar"); + fireEvent.click(within(sidebar).getByRole("button", { name: /hair/i })); + expect(screen.getByPlaceholderText(/Ask PARSE AI about hair/i)).toBeTruthy(); + scrollSpy.mockClear(); + + // No realization is selected in Compare mode, yet ArrowDown to "fire" + // should still scroll the concept-parent-button for the active concept. + fireEvent.keyDown(window, { key: "ArrowDown" }); + expect(await screen.findByPlaceholderText(/Ask PARSE AI about fire/i)).toBeTruthy(); + + await waitFor(() => { + const scrolledActiveRow = scrollSpy.mock.instances.some( + (instance) => (instance as HTMLElement)?.getAttribute?.("data-testid") === "concept-parent-button-3", + ); + expect(scrolledActiveRow).toBe(true); + }); + } finally { + Element.prototype.scrollIntoView = originalScrollIntoView; + } + }); + it("persists compare notes per concept via localStorage without requiring a blur", () => { const { unmount } = render(); const notesField = screen.getByPlaceholderText(/Add observations, etymological notes, or questions for review/i); diff --git a/src/ParseUI.tsx b/src/ParseUI.tsx index 544b48ef..5be61421 100644 --- a/src/ParseUI.tsx +++ b/src/ParseUI.tsx @@ -1808,13 +1808,18 @@ export function ParseUI() { }, [currentMode, goToConceptOffset, goToRealizationOffset, selectedRealizationKey, dispatchSelectedSidebarDelete]); useEffect(() => { - if (!selectedRealizationKey) return; - const activeChip = Array.from(document.querySelectorAll('[data-realization-key]')) - .find((element) => element.getAttribute('data-realization-key') === selectedRealizationKey); - if (typeof activeChip?.scrollIntoView === 'function') { - activeChip.scrollIntoView({ block: 'nearest' }); + // Keep the active sidebar entry in view. Annotate navigates by realization + // (selectedRealizationKey); Compare/Tags navigate by concept and clear that + // key, so fall back to the active concept's parent row there — otherwise the + // auto-scroll would only ever fire on Annotate. + const activeElement = selectedRealizationKey + ? Array.from(document.querySelectorAll('[data-realization-key]')) + .find((element) => element.getAttribute('data-realization-key') === selectedRealizationKey) + : document.querySelector(`[data-testid="concept-parent-button-${conceptId}"]`); + if (typeof activeElement?.scrollIntoView === 'function') { + activeElement.scrollIntoView({ block: 'nearest' }); } - }, [selectedRealizationKey]); + }, [selectedRealizationKey, conceptId]); const toggleSpeaker = (s: string) => { if (currentMode === 'annotate') {