feat: add accessibility support (aria props, screen reader output)

- 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>
This commit is contained in:
Yunfei He
2026-05-26 16:26:10 +08:00
parent 83d9dd6ce8
commit 11c655c76e
9 changed files with 606 additions and 9 deletions
@@ -0,0 +1,344 @@
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");
});
});
+54 -2
View File
@@ -1,4 +1,5 @@
import { defineComponent, h, type PropType } from "vue";
import { defineComponent, h, inject, type PropType } from "vue";
import { AppContextKey } from "../context.ts";
type Spacing = number;
type FlexDirection = "row" | "row-reverse" | "column" | "column-reverse";
@@ -30,6 +31,38 @@ type BorderStyle =
| "classic"
| "arrow";
export type AriaRole =
| "button"
| "checkbox"
| "combobox"
| "list"
| "listbox"
| "listitem"
| "menu"
| "menuitem"
| "option"
| "progressbar"
| "radio"
| "radiogroup"
| "tab"
| "tablist"
| "table"
| "textbox"
| "timer"
| "toolbar";
export interface AriaState {
busy?: boolean;
checked?: boolean;
disabled?: boolean;
expanded?: boolean;
multiline?: boolean;
multiselectable?: boolean;
readonly?: boolean;
required?: boolean;
selected?: boolean;
}
export const Box = defineComponent({
name: "Box",
props: {
@@ -100,8 +133,27 @@ export const Box = defineComponent({
overflowX: String as PropType<"visible" | "hidden">,
overflowY: String as PropType<"visible" | "hidden">,
display: String as PropType<"flex" | "none">,
"aria-label": String,
"aria-hidden": Boolean,
"aria-role": String as PropType<AriaRole>,
"aria-state": Object as PropType<AriaState>,
},
setup(props, { slots }) {
return () => h("box", props as never, slots.default?.());
const appCtx = inject(AppContextKey, null);
return () => {
const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false;
// When screen reader is enabled and aria-hidden is set, render nothing.
if (isScreenReaderEnabled && props["aria-hidden"]) {
return null;
}
const ariaLabel = props["aria-label"];
const label = ariaLabel ? h("text", null, ariaLabel) : undefined;
return h("box", props as never, isScreenReaderEnabled && label ? [label] : slots.default?.());
};
},
});
+22 -3
View File
@@ -1,4 +1,5 @@
import { defineComponent, getCurrentInstance, h, type PropType } from "vue";
import { defineComponent, getCurrentInstance, h, inject, type PropType } from "vue";
import { AppContextKey } from "../context.ts";
type Color = string | [number, number, number];
type WrapMode =
@@ -21,16 +22,34 @@ export const Text = defineComponent({
strikethrough: Boolean,
inverse: Boolean,
wrap: { type: String as PropType<WrapMode>, default: "wrap" },
"aria-label": String,
"aria-hidden": Boolean,
},
setup(props, { slots }) {
const appCtx = inject(AppContextKey, null);
return () => {
const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false;
// When screen reader is enabled and aria-hidden is set, render nothing.
if (isScreenReaderEnabled && props["aria-hidden"]) {
return null;
}
const ariaLabel = props["aria-label"];
const children = isScreenReaderEnabled && ariaLabel ? ariaLabel : slots.default?.();
if (children === undefined || children === null) {
return null;
}
const insideText = isInsideText();
if (insideText) {
return h("virtual-text", props as never, slots.default?.());
return h("virtual-text", props as never, children);
}
// Match Ink's <Text> defaults: flexShrink=1 so text nodes shrink when
// they overflow their container (e.g. in no-wrap flex rows).
return h("text", { ...props, flexShrink: 1 } as never, slots.default?.());
return h("text", { ...props, flexShrink: 1 } as never, children);
};
},
});
+16 -2
View File
@@ -1,4 +1,5 @@
import { defineComponent, h, type PropType } from "vue";
import { defineComponent, h, inject, type PropType } from "vue";
import { AppContextKey } from "../context.ts";
type TransformFn = (line: string, lineIndex: number) => string;
@@ -6,8 +7,21 @@ export const Transform = defineComponent({
name: "Transform",
props: {
transform: { type: Function as PropType<TransformFn>, required: true },
accessibilityLabel: String,
},
setup(props, { slots }) {
return () => h("transform", { transform: props.transform }, slots.default?.());
const appCtx = inject(AppContextKey, null);
return () => {
const isScreenReaderEnabled = appCtx?.isScreenReaderEnabled ?? false;
// When screen reader is enabled and accessibilityLabel is set,
// render the label text instead of children.
if (isScreenReaderEnabled && props.accessibilityLabel) {
return h("transform", { transform: props.transform }, props.accessibilityLabel);
}
return h("transform", { transform: props.transform }, slots.default?.());
};
},
});
+19
View File
@@ -282,6 +282,25 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions<TuiNo
}
} else if (STYLE_PROPS.has(key)) {
(el as { props: Record<string, unknown> }).props[key] = next;
} else if (key === "aria-role" || key === "ariaRole") {
if (el.type === "box") {
el.internal_accessibility ??= {};
el.internal_accessibility.role = next as string;
}
} else if (key === "aria-state" || key === "ariaState") {
if (el.type === "box") {
el.internal_accessibility ??= {};
el.internal_accessibility.state = next as Record<string, boolean>;
}
} else if (
key === "aria-label" ||
key === "ariaLabel" ||
key === "aria-hidden" ||
key === "ariaHidden" ||
key === "accessibilityLabel"
) {
// Handled at the Vue component level (Box.ts / Text.ts / Transform.ts),
// not stored on the DOM node. Silently ignore so we don't warn.
} else if (key === "key" || key === "ref" || key.startsWith("on")) {
// Reserved by Vue / event keys, ignore.
} else if (process.env["NODE_ENV"] !== "production") {
+4
View File
@@ -43,6 +43,10 @@ export interface TuiBox extends NodeBase {
yoga: YogaNodeRef;
props: BoxProps;
paintDirty: boolean;
internal_accessibility?: {
role?: string;
state?: Record<string, boolean>;
};
}
export interface TuiText extends NodeBase {
+2 -1
View File
@@ -1,7 +1,7 @@
export { createApp, type TuiApp, type MountOptions } from "./render.ts";
export { renderToString, type RenderToStringOptions } from "./render-to-string.ts";
export { Box } from "./components/Box.ts";
export { Box, type AriaRole, type AriaState } from "./components/Box.ts";
export { Text } from "./components/Text.ts";
export { Newline } from "./components/Newline.ts";
export { Spacer } from "./components/Spacer.ts";
@@ -30,4 +30,5 @@ export {
type BoxMetrics,
type UseBoxMetricsResult,
} from "./composables/useBoxMetrics.ts";
export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts";
export type { DevState, DevErrorInfo } from "./hmr.ts";
+13 -1
View File
@@ -1 +1,13 @@
export { yogaNodeTracker } from "./host/yoga.ts";
export { yogaNodeTracker, attachYoga } from "./host/yoga.ts";
export {
createRoot,
createBox,
createText,
createTextLeaf,
type TuiRoot,
type TuiBox,
type TuiText,
type TuiNode,
} from "./host/nodes.ts";
export { renderScreenReaderOutput, type ScreenReaderOptions } from "./paint/screen-reader.ts";
export type { AppContext } from "./context.ts";
+132
View File
@@ -0,0 +1,132 @@
import Yoga from "yoga-layout";
import type { TuiNode, TuiText, TuiVirtualText, TuiBox } from "../host/nodes.ts";
/**
* Squash text content from a text/virtual-text node tree into plain text
* (no ANSI styling), suitable for screen reader output.
*/
function squashTextContent(node: TuiText | TuiVirtualText): string {
let text = "";
for (const child of node.children) {
if (child.type === "text-leaf") {
text += child.value;
} else if (child.type === "virtual-text") {
text += squashTextContent(child);
} else if (child.type === "transform") {
// Recurse into transform children, then apply the transform function.
let innerText = "";
for (const grandchild of child.children) {
if (grandchild.type === "text-leaf") {
innerText += grandchild.value;
} else if (grandchild.type === "virtual-text" || grandchild.type === "text") {
innerText += squashTextContent(grandchild);
}
}
if (innerText.length > 0 && child.transform) {
innerText = child.transform(innerText, 0);
}
text += innerText;
}
// Skip comments
}
return text;
}
export interface ScreenReaderOptions {
parentRole?: string;
skipStaticElements?: boolean;
}
/**
* Render a TUI node tree to a plain-text string suitable for screen readers.
*
* Ported from Ink's `renderNodeToScreenReaderOutput`.
*
* - `display: none` nodes are skipped.
* - Text nodes have their content squashed (no ANSI).
* - Box/root nodes recursively render children, joined by separator based on flexDirection.
* - Nodes with `internal_accessibility` get role and state info prepended.
*/
export function renderScreenReaderOutput(node: TuiNode, options: ScreenReaderOptions = {}): string {
// Skip static elements if requested
if (options.skipStaticElements && node.type === "static") {
return "";
}
// If display: none, return empty
if (
(node.type === "box" ||
node.type === "text" ||
node.type === "root" ||
node.type === "transform") &&
node.yoga.getDisplay() === Yoga.DISPLAY_NONE
) {
return "";
}
let output = "";
if (node.type === "text") {
output = squashTextContent(node);
} else if (node.type === "box" || node.type === "root") {
// Determine separator based on flex direction
const flexDirection =
node.type === "box" ? (node.props["flexDirection"] as string | undefined) : undefined;
const separator = flexDirection === "row" || flexDirection === "row-reverse" ? " " : "\n";
// Reverse children for reverse flex directions
const children =
flexDirection === "row-reverse" || flexDirection === "column-reverse"
? [...node.children].reverse()
: node.children;
const boxNode = node as TuiBox;
const parentRole = boxNode.internal_accessibility?.role;
output = children
.map((childNode) =>
renderScreenReaderOutput(childNode, {
parentRole: parentRole ?? options.parentRole,
skipStaticElements: options.skipStaticElements,
}),
)
.filter(Boolean)
.join(separator);
} else if (node.type === "transform") {
// Transform nodes: render children, then no transform is applied in screen reader mode
const children = node.children;
output = children
.map((childNode) =>
renderScreenReaderOutput(childNode, {
parentRole: options.parentRole,
skipStaticElements: options.skipStaticElements,
}),
)
.filter(Boolean)
.join("\n");
}
// Add accessibility annotations
if (node.type === "box") {
const accessibility = node.internal_accessibility;
if (accessibility) {
const { role, state } = accessibility;
if (state) {
const stateKeys = Object.keys(state) as Array<keyof typeof state>;
const stateDescription = stateKeys.filter((key) => state[key]).join(", ");
if (stateDescription) {
output = `(${stateDescription}) ${output}`;
}
}
if (role && role !== options.parentRole) {
output = `${role}: ${output}`;
}
}
}
return output;
}