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:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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?.());
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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?.());
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user