11c655c76e
- Box: aria-label, aria-hidden, aria-role (typed union), aria-state - Text: aria-label, aria-hidden - Transform: accessibilityLabel prop - internal_accessibility on TuiBox node (role + state only) - renderScreenReaderOutput() alternate render path - Component-level aria-hidden hides, aria-label replaces content - 14 new accessibility tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
345 lines
9.5 KiB
TypeScript
345 lines
9.5 KiB
TypeScript
import { defineComponent } from "vue";
|
|
import { describe, expect, test } from "vite-plus/test";
|
|
import { renderToString, Box, Text, Transform } from "@vue-tui/runtime";
|
|
import { render } from "@vue-tui/testing";
|
|
import {
|
|
createRoot,
|
|
createBox,
|
|
createText,
|
|
createTextLeaf,
|
|
attachYoga,
|
|
renderScreenReaderOutput,
|
|
type AppContext,
|
|
} from "@vue-tui/runtime/internal";
|
|
|
|
// Yoga.DIRECTION_LTR = 0
|
|
const DIRECTION_LTR = 0;
|
|
|
|
function createTestAppContext(): AppContext {
|
|
return {
|
|
exit: () => {},
|
|
stdout: process.stdout,
|
|
stderr: process.stderr,
|
|
stdin: process.stdin,
|
|
debug: false,
|
|
interactive: false,
|
|
isScreenReaderEnabled: false,
|
|
isRawModeSupported: false,
|
|
setRawMode: () => {},
|
|
writeToStdout: () => {},
|
|
writeToStderr: () => {},
|
|
cursorPosition: undefined,
|
|
setCursorPosition: () => {},
|
|
};
|
|
}
|
|
|
|
describe("renderScreenReaderOutput (unit)", () => {
|
|
test("renders text content", () => {
|
|
const root = createRoot(createTestAppContext());
|
|
attachYoga(root);
|
|
root.yoga.setWidth(80);
|
|
|
|
const text = createText();
|
|
attachYoga(text);
|
|
const leaf = createTextLeaf("Hello");
|
|
leaf.parent = text;
|
|
text.children.push(leaf);
|
|
text.parent = root;
|
|
root.children.push(text);
|
|
root.yoga.insertChild(text.yoga, 0);
|
|
|
|
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
|
|
|
const output = renderScreenReaderOutput(root);
|
|
expect(output).toBe("Hello");
|
|
|
|
root.yoga.freeRecursive();
|
|
});
|
|
|
|
test("joins row children with space", () => {
|
|
const root = createRoot(createTestAppContext());
|
|
attachYoga(root);
|
|
root.yoga.setWidth(80);
|
|
|
|
const box = createBox();
|
|
attachYoga(box);
|
|
box.props["flexDirection"] = "row";
|
|
// Yoga.FLEX_DIRECTION_ROW = 2
|
|
box.yoga.setFlexDirection(2);
|
|
box.parent = root;
|
|
root.children.push(box);
|
|
root.yoga.insertChild(box.yoga, 0);
|
|
|
|
const text1 = createText();
|
|
attachYoga(text1);
|
|
const leaf1 = createTextLeaf("Hello");
|
|
leaf1.parent = text1;
|
|
text1.children.push(leaf1);
|
|
text1.parent = box;
|
|
box.children.push(text1);
|
|
box.yoga.insertChild(text1.yoga, 0);
|
|
|
|
const text2 = createText();
|
|
attachYoga(text2);
|
|
const leaf2 = createTextLeaf("World");
|
|
leaf2.parent = text2;
|
|
text2.children.push(leaf2);
|
|
text2.parent = box;
|
|
box.children.push(text2);
|
|
box.yoga.insertChild(text2.yoga, 1);
|
|
|
|
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
|
|
|
const output = renderScreenReaderOutput(root);
|
|
expect(output).toBe("Hello World");
|
|
|
|
root.yoga.freeRecursive();
|
|
});
|
|
|
|
test("prepends role annotation", () => {
|
|
const root = createRoot(createTestAppContext());
|
|
attachYoga(root);
|
|
root.yoga.setWidth(80);
|
|
|
|
const box = createBox();
|
|
attachYoga(box);
|
|
box.internal_accessibility = { role: "button" };
|
|
box.parent = root;
|
|
root.children.push(box);
|
|
root.yoga.insertChild(box.yoga, 0);
|
|
|
|
const text = createText();
|
|
attachYoga(text);
|
|
const leaf = createTextLeaf("Click me");
|
|
leaf.parent = text;
|
|
text.children.push(leaf);
|
|
text.parent = box;
|
|
box.children.push(text);
|
|
box.yoga.insertChild(text.yoga, 0);
|
|
|
|
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
|
|
|
const output = renderScreenReaderOutput(root);
|
|
expect(output).toBe("button: Click me");
|
|
|
|
root.yoga.freeRecursive();
|
|
});
|
|
|
|
test("prepends state annotation", () => {
|
|
const root = createRoot(createTestAppContext());
|
|
attachYoga(root);
|
|
root.yoga.setWidth(80);
|
|
|
|
const box = createBox();
|
|
attachYoga(box);
|
|
box.internal_accessibility = {
|
|
role: "checkbox",
|
|
state: { checked: true, disabled: false },
|
|
};
|
|
box.parent = root;
|
|
root.children.push(box);
|
|
root.yoga.insertChild(box.yoga, 0);
|
|
|
|
const text = createText();
|
|
attachYoga(text);
|
|
const leaf = createTextLeaf("Option");
|
|
leaf.parent = text;
|
|
text.children.push(leaf);
|
|
text.parent = box;
|
|
box.children.push(text);
|
|
box.yoga.insertChild(text.yoga, 0);
|
|
|
|
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
|
|
|
const output = renderScreenReaderOutput(root);
|
|
expect(output).toBe("checkbox: (checked) Option");
|
|
|
|
root.yoga.freeRecursive();
|
|
});
|
|
|
|
test("skips display: none nodes", () => {
|
|
const root = createRoot(createTestAppContext());
|
|
attachYoga(root);
|
|
root.yoga.setWidth(80);
|
|
|
|
const box = createBox();
|
|
attachYoga(box);
|
|
// Yoga.DISPLAY_NONE = 1
|
|
box.yoga.setDisplay(1);
|
|
box.parent = root;
|
|
root.children.push(box);
|
|
root.yoga.insertChild(box.yoga, 0);
|
|
|
|
const text = createText();
|
|
attachYoga(text);
|
|
const leaf = createTextLeaf("Hidden");
|
|
leaf.parent = text;
|
|
text.children.push(leaf);
|
|
text.parent = box;
|
|
box.children.push(text);
|
|
box.yoga.insertChild(text.yoga, 0);
|
|
|
|
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
|
|
|
const output = renderScreenReaderOutput(root);
|
|
expect(output).toBe("");
|
|
|
|
root.yoga.freeRecursive();
|
|
});
|
|
|
|
test("does not duplicate parent role on child with same role", () => {
|
|
const root = createRoot(createTestAppContext());
|
|
attachYoga(root);
|
|
root.yoga.setWidth(80);
|
|
|
|
const outerBox = createBox();
|
|
attachYoga(outerBox);
|
|
outerBox.internal_accessibility = { role: "list" };
|
|
outerBox.parent = root;
|
|
root.children.push(outerBox);
|
|
root.yoga.insertChild(outerBox.yoga, 0);
|
|
|
|
const innerBox = createBox();
|
|
attachYoga(innerBox);
|
|
innerBox.internal_accessibility = { role: "list" };
|
|
innerBox.parent = outerBox;
|
|
outerBox.children.push(innerBox);
|
|
outerBox.yoga.insertChild(innerBox.yoga, 0);
|
|
|
|
const text = createText();
|
|
attachYoga(text);
|
|
const leaf = createTextLeaf("Item");
|
|
leaf.parent = text;
|
|
text.children.push(leaf);
|
|
text.parent = innerBox;
|
|
innerBox.children.push(text);
|
|
innerBox.yoga.insertChild(text.yoga, 0);
|
|
|
|
root.yoga.calculateLayout(80, undefined, DIRECTION_LTR);
|
|
|
|
const output = renderScreenReaderOutput(root);
|
|
// Inner box has same role as parent, so role is only shown on outer
|
|
expect(output).toBe("list: Item");
|
|
|
|
root.yoga.freeRecursive();
|
|
});
|
|
});
|
|
|
|
describe("Box aria props", () => {
|
|
test("renders aria-role and aria-state on box node", () => {
|
|
const output = renderToString(
|
|
defineComponent(() => () => (
|
|
<Box aria-role="button" aria-state={{ disabled: true }}>
|
|
<Text>Click</Text>
|
|
</Box>
|
|
)),
|
|
{ columns: 40 },
|
|
);
|
|
// The visual output should still contain the text
|
|
expect(output).toContain("Click");
|
|
});
|
|
|
|
test("aria-label does not affect normal rendering (screen reader disabled)", () => {
|
|
const output = renderToString(
|
|
defineComponent(() => () => (
|
|
<Box aria-label="my button">
|
|
<Text>visible text</Text>
|
|
</Box>
|
|
)),
|
|
{ columns: 40 },
|
|
);
|
|
expect(output).toContain("visible text");
|
|
});
|
|
|
|
test("aria-hidden does not hide box when screen reader is disabled", () => {
|
|
const output = renderToString(
|
|
defineComponent(() => () => (
|
|
<Box aria-hidden>
|
|
<Text>still visible</Text>
|
|
</Box>
|
|
)),
|
|
{ columns: 40 },
|
|
);
|
|
expect(output).toContain("still visible");
|
|
});
|
|
});
|
|
|
|
describe("Text aria props", () => {
|
|
test("renders normally with aria-label when screen reader is disabled", () => {
|
|
const output = renderToString(
|
|
defineComponent(() => () => (
|
|
<Box>
|
|
<Text aria-label="replacement">original text</Text>
|
|
</Box>
|
|
)),
|
|
{ columns: 40 },
|
|
);
|
|
expect(output).toContain("original text");
|
|
});
|
|
|
|
test("renders normally with aria-hidden when screen reader is disabled", () => {
|
|
const output = renderToString(
|
|
defineComponent(() => () => (
|
|
<Box>
|
|
<Text aria-hidden>hidden text</Text>
|
|
</Box>
|
|
)),
|
|
{ columns: 40 },
|
|
);
|
|
expect(output).toContain("hidden text");
|
|
});
|
|
});
|
|
|
|
describe("Transform accessibility", () => {
|
|
test("renders children normally when screen reader is disabled", () => {
|
|
const output = renderToString(
|
|
defineComponent(() => () => (
|
|
<Transform transform={(s: string) => s.toUpperCase()} accessibilityLabel="accessible label">
|
|
<Text>lowercase</Text>
|
|
</Transform>
|
|
)),
|
|
{ columns: 40 },
|
|
);
|
|
expect(output).toContain("LOWERCASE");
|
|
});
|
|
});
|
|
|
|
describe("integration: aria props via render", () => {
|
|
test("no unknown prop warnings for aria props", async () => {
|
|
// This test verifies that aria props don't trigger the "[vue-tui] unknown prop" warning
|
|
const App = defineComponent(() => () => (
|
|
<Box aria-role="button" aria-state={{ checked: true }} aria-label="test" aria-hidden={false}>
|
|
<Text aria-label="text label" aria-hidden={false}>
|
|
Hello
|
|
</Text>
|
|
</Box>
|
|
));
|
|
|
|
const { lastFrame } = await render(App, { columns: 40 });
|
|
expect(lastFrame()).toContain("Hello");
|
|
});
|
|
|
|
test("all aria props render without errors", async () => {
|
|
const App = defineComponent(() => () => (
|
|
<Box flexDirection="column">
|
|
<Box aria-role="list" aria-state={{ busy: true }}>
|
|
<Box aria-role="listitem">
|
|
<Text>Item 1</Text>
|
|
</Box>
|
|
<Box aria-role="listitem">
|
|
<Text>Item 2</Text>
|
|
</Box>
|
|
</Box>
|
|
<Transform transform={(s: string) => s} accessibilityLabel="transform label">
|
|
<Text>content</Text>
|
|
</Transform>
|
|
</Box>
|
|
));
|
|
|
|
const { lastFrame } = await render(App, { columns: 40 });
|
|
expect(lastFrame()).toContain("Item 1");
|
|
expect(lastFrame()).toContain("Item 2");
|
|
expect(lastFrame()).toContain("content");
|
|
});
|
|
});
|