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; +}