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
146 changes: 144 additions & 2 deletions src/components/ai-elements/file-tree.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { render, screen } from "@testing-library/react"
import { describe, expect, it } from "vitest"
import { fireEvent, render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

import { resolveFileTreeDropZone } from "@/lib/file-tree-dnd"

import { FileTree, FileTreeFile, FileTreeFolder } from "./file-tree"

Expand Down Expand Up @@ -59,3 +61,143 @@ describe("FileTree keyboard focus topology", () => {
expect(container).not.toHaveAttribute("aria-activedescendant")
})
})

describe("FileTree trailing row actions", () => {
afterEach(() => {
vi.restoreAllMocks()
})

const onDragOver = vi.fn()

function renderWithActions() {
onDragOver.mockClear()
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
const view = render(
<FileTree expanded={new Set(["dir"])}>
<FileTreeFolder
path="dir"
name="dir"
depth={0}
dropTargetDir="dir"
rowProps={{ draggable: true, onDragOver }}
actions={
<button type="button" aria-label="folder action">
</button>
}
>
<FileTreeFile
path="dir/file.ts"
name="file.ts"
depth={1}
actions={
<button type="button" aria-label="file action">
</button>
}
/>
</FileTreeFolder>
</FileTree>
)
return { ...view, consoleError }
}

it("keeps the folder's action out of the header button", () => {
// The folder header is a native <button>. HTML forbids a button inside a
// button and React reports the nesting as a hydration error, so the action
// must be a sibling — not a child — of the header.
const { container, consoleError } = renderWithActions()

expect(container.querySelector("button button")).toBeNull()
expect(consoleError).not.toHaveBeenCalled()
})

it("leaves the folder header's accessible name to the folder alone", () => {
// A nested action would be walked into the header's name computation, so
// the row would announce as "dir ⋯" instead of "dir".
renderWithActions()

expect(screen.getByRole("button", { name: "dir" })).toBeInTheDocument()
})

it("renders both rows' actions", () => {
renderWithActions()

expect(screen.getByLabelText("folder action")).toBeInTheDocument()
expect(screen.getByLabelText("file action")).toBeInTheDocument()
})

it("keeps the folder's drop zone covering the action", () => {
// The action is a sibling of the header, so a drop zone left on the header
// alone would stop at the action's edge: `resolveFileTreeDropZone` walks UP
// from whatever the pointer hit, and would find nothing above it. Dropping
// on the ⋯ strip of a destination folder would then silently do nothing.
renderWithActions()
const action = screen.getByLabelText("folder action")

expect(resolveFileTreeDropZone(action)).toEqual({
kind: "dir",
destDir: "dir",
})
})

it("still fires the row's drag handlers from over the action", () => {
// Same hole on the web path, which uses the React dragover/drop handlers
// rather than the coordinate hit-test.
renderWithActions()

fireEvent.dragOver(screen.getByLabelText("folder action"))

expect(onDragOver).toHaveBeenCalledTimes(1)
})

it("keeps the folder header filling the row beside its action", () => {
// The header no longer spans the row on its own (the action is a sibling),
// so without `grow` it shrinks to its name and clicking the empty right
// half of a folder row stops expanding it. jsdom has no layout, so this
// pins the class; the width was measured in a browser (493px of a 525px
// row — the rest is the action strip).
renderWithActions()

expect(screen.getByRole("button", { name: "dir" })).toHaveClass("grow")
})

it("dims the whole row, action included, from rowProps' className", () => {
// `dragging && "opacity-70"` marks the row as the drag source; leaving it
// on the header would dim everything but the ⋯.
render(
<FileTree expanded={new Set()}>
<FileTreeFolder
path="dir"
name="dir"
depth={0}
rowProps={{ className: "opacity-70" }}
actions={
<button type="button" aria-label="dimmed action">
</button>
}
/>
</FileTree>
)

const action = screen.getByLabelText("dimmed action")
expect(action.closest(".opacity-70")).not.toBeNull()
})

it("publishes the row-hover group both rows' actions can reveal from", () => {
// The action is hidden at rest and revealed on row hover; :hover only
// propagates to ancestors, so the group has to sit on an element that
// encloses BOTH the row content and the action.
const { container } = renderWithActions()

for (const action of [
screen.getByLabelText("folder action"),
screen.getByLabelText("file action"),
]) {
const group = action.closest(".group\\/file-tree-row")
expect(group).not.toBeNull()
expect(container.contains(group)).toBe(true)
}
})
})
Loading
Loading