diff --git a/src/components/message/virtualized-message-thread.test.tsx b/src/components/message/virtualized-message-thread.test.tsx new file mode 100644 index 000000000..7ac6a53fe --- /dev/null +++ b/src/components/message/virtualized-message-thread.test.tsx @@ -0,0 +1,106 @@ +import type { ReactNode } from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const testState = vi.hoisted(() => ({ + scrollRef: { current: null as HTMLDivElement | null }, +})) + +vi.mock("use-stick-to-bottom", () => ({ + useStickToBottomContext: () => ({ scrollRef: testState.scrollRef }), +})) + +vi.mock("virtua", () => ({ + Virtualizer: ({ children }: { children: ReactNode }) => <>{children}, +})) + +vi.mock("@/components/ai-elements/message-thread", () => ({ + MessageThreadContent: ({ + children, + scrollClassName, + }: { + children: ReactNode + scrollClassName?: string + }) => ( +
{ + testState.scrollRef.current = element + }} + className={scrollClassName} + data-testid="viewport" + > + {children} +
+ ), +})) + +import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread" + +function renderThread( + content: ReactNode =
text
+) { + return render( + item.id} + renderItem={() => content} + /> + ) +} + +function pointerDown(element: HTMLElement, button: number) { + fireEvent(element, new MouseEvent("pointerdown", { bubbles: true, button })) +} + +beforeEach(() => { + testState.scrollRef.current = null +}) + +describe("VirtualizedMessageThread focus origin", () => { + it("marks pointer-origin focus and clears it on blur", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + pointerDown(screen.getByTestId("content"), 0) + + expect(document.activeElement).toBe(viewport) + expect(viewport).toHaveAttribute("data-focus-origin", "pointer") + expect(viewport.className).toContain( + "data-[focus-origin=pointer]:focus-visible:ring-0" + ) + + fireEvent.blur(viewport) + expect(viewport).not.toHaveAttribute("data-focus-origin") + }) + + it("keeps keyboard-origin focus distinguishable", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + viewport.focus() + + expect(document.activeElement).toBe(viewport) + expect(viewport).not.toHaveAttribute("data-focus-origin") + expect(viewport.className).toContain("focus-visible:ring-2") + }) + + it("does not mark focus when an interactive control is clicked", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + pointerDown(screen.getByTestId("action"), 0) + + expect(viewport).not.toHaveAttribute("data-focus-origin") + expect(document.activeElement).not.toBe(viewport) + }) + + it("does not mark focus for a right click", () => { + renderThread() + const viewport = screen.getByTestId("viewport") + + pointerDown(screen.getByTestId("content"), 2) + + expect(viewport).not.toHaveAttribute("data-focus-origin") + expect(document.activeElement).not.toBe(viewport) + }) +}) diff --git a/src/components/message/virtualized-message-thread.tsx b/src/components/message/virtualized-message-thread.tsx index e1987203f..ced6583ab 100644 --- a/src/components/message/virtualized-message-thread.tsx +++ b/src/components/message/virtualized-message-thread.tsx @@ -103,6 +103,9 @@ function VirtualizedMessageThreadImpl({ const el = scrollRef.current if (!el) return el.tabIndex = 0 + const clearPointerFocus = () => { + el.removeAttribute("data-focus-origin") + } const onPointerDown = (e: PointerEvent) => { // Ignore right-click and macOS ctrl-click (both open the context menu). if (e.button !== 0 || e.ctrlKey) return @@ -117,10 +120,16 @@ function VirtualizedMessageThreadImpl({ ) ) return + el.setAttribute("data-focus-origin", "pointer") el.focus({ preventScroll: true }) } el.addEventListener("pointerdown", onPointerDown) - return () => el.removeEventListener("pointerdown", onPointerDown) + el.addEventListener("blur", clearPointerFocus) + return () => { + el.removeEventListener("pointerdown", onPointerDown) + el.removeEventListener("blur", clearPointerFocus) + clearPointerFocus() + } }, [scrollRef]) // Pre-compute the three possible padding styles so every render reuses @@ -146,7 +155,7 @@ function VirtualizedMessageThreadImpl({ {items.length === 0 ? (