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:
Molecule AI · core-uiux 2026-05-16 10:52:28 +00:00
parent 6383276e51
commit ea7e99e802
2 changed files with 396 additions and 3 deletions

View File

@ -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}`}

View File

@ -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);
});
});