diff --git a/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx
new file mode 100644
index 0000000..1544f89
--- /dev/null
+++ b/packages/runtime-tests/integration/accessibility/screen-reader.test.tsx
@@ -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(() => () => (
+
+ Click
+
+ )),
+ { 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(() => () => (
+
+ visible text
+
+ )),
+ { columns: 40 },
+ );
+ expect(output).toContain("visible text");
+ });
+
+ test("aria-hidden does not hide box when screen reader is disabled", () => {
+ const output = renderToString(
+ defineComponent(() => () => (
+
+ still visible
+
+ )),
+ { 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(() => () => (
+
+ original text
+
+ )),
+ { columns: 40 },
+ );
+ expect(output).toContain("original text");
+ });
+
+ test("renders normally with aria-hidden when screen reader is disabled", () => {
+ const output = renderToString(
+ defineComponent(() => () => (
+
+ hidden text
+
+ )),
+ { columns: 40 },
+ );
+ expect(output).toContain("hidden text");
+ });
+});
+
+describe("Transform accessibility", () => {
+ test("renders children normally when screen reader is disabled", () => {
+ const output = renderToString(
+ defineComponent(() => () => (
+ s.toUpperCase()} accessibilityLabel="accessible label">
+ lowercase
+
+ )),
+ { 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(() => () => (
+
+
+ Hello
+
+
+ ));
+
+ const { lastFrame } = await render(App, { columns: 40 });
+ expect(lastFrame()).toContain("Hello");
+ });
+
+ test("all aria props render without errors", async () => {
+ const App = defineComponent(() => () => (
+
+
+
+ Item 1
+
+
+ Item 2
+
+
+ s} accessibilityLabel="transform label">
+ content
+
+
+ ));
+
+ const { lastFrame } = await render(App, { columns: 40 });
+ expect(lastFrame()).toContain("Item 1");
+ expect(lastFrame()).toContain("Item 2");
+ expect(lastFrame()).toContain("content");
+ });
+});
diff --git a/packages/runtime/src/components/Box.ts b/packages/runtime/src/components/Box.ts
index 10eef65..0ad2a77 100644
--- a/packages/runtime/src/components/Box.ts
+++ b/packages/runtime/src/components/Box.ts
@@ -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,
+ "aria-state": Object as PropType,
},
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?.());
+ };
},
});
diff --git a/packages/runtime/src/components/Text.ts b/packages/runtime/src/components/Text.ts
index c450869..ae0f8ae 100644
--- a/packages/runtime/src/components/Text.ts
+++ b/packages/runtime/src/components/Text.ts
@@ -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, 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 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);
};
},
});
diff --git a/packages/runtime/src/components/Transform.ts b/packages/runtime/src/components/Transform.ts
index 93bd5ee..596f4fc 100644
--- a/packages/runtime/src/components/Transform.ts
+++ b/packages/runtime/src/components/Transform.ts
@@ -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, 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?.());
+ };
},
});
diff --git a/packages/runtime/src/host/node-ops.ts b/packages/runtime/src/host/node-ops.ts
index 6b3da0c..cfc4f7d 100644
--- a/packages/runtime/src/host/node-ops.ts
+++ b/packages/runtime/src/host/node-ops.ts
@@ -282,6 +282,25 @@ export function buildNodeOps(options: TtyRendererOptions): RendererOptions }).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;
+ }
+ } 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") {
diff --git a/packages/runtime/src/host/nodes.ts b/packages/runtime/src/host/nodes.ts
index fa2884b..26a4d66 100644
--- a/packages/runtime/src/host/nodes.ts
+++ b/packages/runtime/src/host/nodes.ts
@@ -43,6 +43,10 @@ export interface TuiBox extends NodeBase {
yoga: YogaNodeRef;
props: BoxProps;
paintDirty: boolean;
+ internal_accessibility?: {
+ role?: string;
+ state?: Record;
+ };
}
export interface TuiText extends NodeBase {
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 2d0bd75..109640b 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -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";
diff --git a/packages/runtime/src/internal.ts b/packages/runtime/src/internal.ts
index 960175f..a5c267e 100644
--- a/packages/runtime/src/internal.ts
+++ b/packages/runtime/src/internal.ts
@@ -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";
diff --git a/packages/runtime/src/paint/screen-reader.ts b/packages/runtime/src/paint/screen-reader.ts
new file mode 100644
index 0000000..118027e
--- /dev/null
+++ b/packages/runtime/src/paint/screen-reader.ts
@@ -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;
+ const stateDescription = stateKeys.filter((key) => state[key]).join(", ");
+
+ if (stateDescription) {
+ output = `(${stateDescription}) ${output}`;
+ }
+ }
+
+ if (role && role !== options.parentRole) {
+ output = `${role}: ${output}`;
+ }
+ }
+ }
+
+ return output;
+}