fix(FilesTab): add aria-hidden to decorative emoji icons (WCAG 1.1.1)
FileTree.tsx renders emoji icons (📁, 📄, 🐍, 💠, etc.) and chevrons (▼/▶) that convey no semantic meaning — they are purely decorative. Add aria-hidden="true" to all three spans so screen readers skip them and users are not read a stream of emoji characters. Also adds FileTree.render.test.tsx with 16 tests covering: - Empty state - File row render, selection, emoji aria-hidden, selected highlight - Directory row render, expand/collapse, loading ellipsis, emoji aria-hidden - Nested child visibility gated on expandedDirs - WCAG accessibility assertion for all decorative spans Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6383276e51
commit
ea7e99e802
@ -209,8 +209,8 @@ function TreeItem({
|
||||
onContextMenu={(e) => openContextMenu(e, node)}
|
||||
{...dragProps}
|
||||
>
|
||||
<span className="text-[9px] text-ink-mid w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
||||
<span className="text-[10px]">📁</span>
|
||||
<span aria-hidden="true" className="text-[9px] text-ink-mid w-3">{isLoading ? "…" : expanded ? "▼" : "▶"}</span>
|
||||
<span aria-hidden="true" className="text-[10px]">📁</span>
|
||||
<span className="text-[10px] text-ink-mid flex-1">{node.name}</span>
|
||||
<button
|
||||
aria-label={`Delete ${node.name}`}
|
||||
@ -251,7 +251,7 @@ function TreeItem({
|
||||
onClick={() => onSelect(node.path)}
|
||||
onContextMenu={(e) => openContextMenu(e, node)}
|
||||
>
|
||||
<span className="text-[9px]">{getIcon(node.name, false)}</span>
|
||||
<span aria-hidden="true" className="text-[9px]">{getIcon(node.name, false)}</span>
|
||||
<span className="text-[10px] flex-1 truncate font-mono">{node.name}</span>
|
||||
<button
|
||||
aria-label={`Delete ${node.name}`}
|
||||
|
||||
@ -0,0 +1,393 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Tests for FileTree render behavior and accessibility.
|
||||
//
|
||||
// Covers:
|
||||
// - Empty state (no nodes renders nothing)
|
||||
// - File row: name text, emoji icon has aria-hidden, delete button
|
||||
// - Directory row: name text, chevron and folder emoji have aria-hidden
|
||||
// - onSelect fires on file row click
|
||||
// - onToggleDir fires on directory row click
|
||||
// - Loading indicator replaces chevron for a pending dir
|
||||
// - File emoji icon is aria-hidden (WCAG 1.1.1)
|
||||
// - Directory chevron and folder icon are aria-hidden (WCAG 1.1.1)
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Mock FileTreeContextMenu so right-click tests don't need to manage
|
||||
// portal rendering into document.body.
|
||||
vi.mock("../FileTreeContextMenu", () => ({
|
||||
FileTreeContextMenu: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const makeFile = (name: string, path = name): TreeNode => ({
|
||||
name,
|
||||
path,
|
||||
isDir: false,
|
||||
children: [],
|
||||
size: 0,
|
||||
});
|
||||
|
||||
const makeDir = (name: string, path = name, children: TreeNode[] = []): TreeNode => ({
|
||||
name,
|
||||
path,
|
||||
isDir: true,
|
||||
children,
|
||||
size: 0,
|
||||
});
|
||||
|
||||
describe("FileTree — empty state", () => {
|
||||
it("renders nothing when nodes array is empty", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
// No text nodes from the tree should appear
|
||||
expect(screen.queryByText("config.yaml")).toBeNull();
|
||||
expect(screen.queryByText("src")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — file rows", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onDownload = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onSelect.mockReset();
|
||||
onDelete.mockReset();
|
||||
onDownload.mockReset();
|
||||
});
|
||||
|
||||
it("renders the file name text", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeFile("config.yaml")]}
|
||||
selectedPath={null}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("config.yaml")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("calls onSelect with the file path when clicked", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeFile("readme.md")]}
|
||||
selectedPath={null}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText("readme.md"));
|
||||
expect(onSelect).toHaveBeenCalledWith("readme.md");
|
||||
});
|
||||
|
||||
it("renders the emoji icon span with aria-hidden=true (WCAG 1.1.1)", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeFile("app.py")]}
|
||||
selectedPath={null}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
// The emoji icon (🐍 for .py) is rendered in a <span> with aria-hidden
|
||||
const iconSpans = screen.getAllByText("🐍");
|
||||
expect(iconSpans.length).toBeGreaterThan(0);
|
||||
iconSpans.forEach((span) => {
|
||||
expect(span.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights the selected file row", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeFile("main.ts"), makeFile("lib.ts")]}
|
||||
selectedPath="main.ts"
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
// main.ts row gets the selected background class
|
||||
const mainRow = screen.getByText("main.ts").parentElement!;
|
||||
expect(mainRow.className).toContain("bg-blue-900");
|
||||
});
|
||||
|
||||
it("renders a Delete button with aria-label per file row", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeFile("old.txt")]}
|
||||
selectedPath={null}
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /delete old\.txt/i })).not.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — directory rows", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onDownload = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onToggleDir.mockReset();
|
||||
onDelete.mockReset();
|
||||
onDownload.mockReset();
|
||||
});
|
||||
|
||||
it("renders the directory name", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("src")]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("src")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the folder emoji (📁) with aria-hidden=true (WCAG 1.1.1)", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("src")]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
const folderIcons = screen.getAllByText("📁");
|
||||
expect(folderIcons.length).toBeGreaterThan(0);
|
||||
folderIcons.forEach((span) => {
|
||||
expect(span.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders chevron ▶ when directory is collapsed (aria-hidden)", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("docs")]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
const chevrons = screen.getAllByText("▶");
|
||||
expect(chevrons.length).toBeGreaterThan(0);
|
||||
chevrons.forEach((span) => {
|
||||
expect(span.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders chevron ▼ when directory is expanded (aria-hidden)", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("src")]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set(["src"])}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
const chevrons = screen.getAllByText("▼");
|
||||
expect(chevrons.length).toBeGreaterThan(0);
|
||||
chevrons.forEach((span) => {
|
||||
expect(span.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onToggleDir with the dir path when clicked", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("lib")]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByText("lib"));
|
||||
expect(onToggleDir).toHaveBeenCalledWith("lib");
|
||||
});
|
||||
|
||||
it("shows loading ellipsis (…) in place of chevron while loadingDir matches (aria-hidden)", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("src")]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir="src"
|
||||
/>
|
||||
);
|
||||
const loaders = screen.getAllByText("…");
|
||||
expect(loaders.length).toBeGreaterThan(0);
|
||||
loaders.forEach((span) => {
|
||||
expect(span.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders children when directory is in expandedDirs", () => {
|
||||
const child = makeFile("nested.txt", "src/nested.txt");
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("src", "src", [child])]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set(["src"])}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("nested.txt")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not render children when directory is not expanded", () => {
|
||||
const child = makeFile("nested.txt", "src/nested.txt");
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("src", "src", [child])]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={onDelete}
|
||||
onDownload={onDownload}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set()}
|
||||
onToggleDir={onToggleDir}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("nested.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — drag-drop target highlight", () => {
|
||||
it("applies drop-target outline class when hoverDir matches a directory path", () => {
|
||||
const child = makeFile("child.md", "src/child.md");
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[makeDir("src", "src", [child])]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set(["src"])}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
onDropToTarget={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// The inner div for the "src" row does not yet have the drop target class
|
||||
const srcRow = screen.getByText("src").parentElement!;
|
||||
expect(srcRow.className).not.toContain("outline-accent");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — WCAG accessibility", () => {
|
||||
it("all decorative emoji spans have aria-hidden=true", () => {
|
||||
render(
|
||||
<FileTree
|
||||
nodes={[
|
||||
makeDir("assets"),
|
||||
makeFile("style.css"),
|
||||
makeFile("app.ts"),
|
||||
]}
|
||||
selectedPath={null}
|
||||
onSelect={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
canDelete={true}
|
||||
expandedDirs={new Set(["assets"])}
|
||||
onToggleDir={vi.fn()}
|
||||
loadingDir={null}
|
||||
/>
|
||||
);
|
||||
// Collect every span that contains only a single emoji / chevron character
|
||||
// and verify it has aria-hidden.
|
||||
const allSpans = document.querySelectorAll(
|
||||
'span[aria-hidden="true"]'
|
||||
);
|
||||
// At minimum we expect: 📁 (assets folder), ▼ (expanded chevron),
|
||||
// CSS icon, TS icon. All should have aria-hidden.
|
||||
expect(allSpans.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user