Skip to content
Open
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
106 changes: 106 additions & 0 deletions src/components/message/virtualized-message-thread.test.tsx
Original file line number Diff line number Diff line change
@@ -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
}) => (
<div
ref={(element) => {
testState.scrollRef.current = element
}}
className={scrollClassName}
data-testid="viewport"
>
{children}
</div>
),
}))

import { VirtualizedMessageThread } from "@/components/message/virtualized-message-thread"

function renderThread(
content: ReactNode = <div data-testid="content">text</div>
) {
return render(
<VirtualizedMessageThread
items={[{ id: "message-1" }]}
getItemKey={(item) => 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(<button data-testid="action">Action</button>)
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)
})
})
13 changes: 11 additions & 2 deletions src/components/message/virtualized-message-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ function VirtualizedMessageThreadImpl<T>({
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
Expand All @@ -117,10 +120,16 @@ function VirtualizedMessageThreadImpl<T>({
)
)
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
Expand All @@ -146,7 +155,7 @@ function VirtualizedMessageThreadImpl<T>({
<MessageScrollProvider value={scrollContextValue}>
<MessageThreadContent
className={cn("mx-0 max-w-none p-0", contentClassName)}
scrollClassName="scrollbar-thin overscroll-contain [overflow-anchor:none] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
scrollClassName="scrollbar-thin overscroll-contain [overflow-anchor:none] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset data-[focus-origin=pointer]:focus-visible:ring-0"
{...contentProps}
>
{items.length === 0 ? (
Expand Down
Loading