` in the browser.
*/
const Box = forwardRef
>(
- ({children, backgroundColor, ...style}, ref) => {
+ (
+ {
+ children,
+ backgroundColor,
+ 'aria-label': ariaLabel,
+ 'aria-hidden': ariaHidden,
+ 'aria-role': role,
+ 'aria-state': ariaState,
+ ...style
+ },
+ ref,
+ ) => {
+ const {isScreenReaderEnabled} = useContext(accessibilityContext);
+ const label = ariaLabel ? {ariaLabel} : undefined;
+ if (isScreenReaderEnabled && ariaHidden) {
+ return null;
+ }
+
const boxElement = (
>(
overflowX: style.overflowX ?? style.overflow ?? 'visible',
overflowY: style.overflowY ?? style.overflow ?? 'visible',
}}
+ internal_accessibility={{
+ role,
+ state: ariaState,
+ }}
>
- {children}
+ {isScreenReaderEnabled && label ? label : children}
);
diff --git a/src/components/ErrorOverview.tsx b/src/components/ErrorOverview.tsx
index adfdf615d..d9509e78d 100644
--- a/src/components/ErrorOverview.tsx
+++ b/src/components/ErrorOverview.tsx
@@ -67,6 +67,11 @@ export default function ErrorOverview({error}: Props) {
dimColor={line !== origin.line}
backgroundColor={line === origin.line ? 'red' : undefined}
color={line === origin.line ? 'white' : undefined}
+ aria-label={
+ line === origin.line
+ ? `Line ${line}, error`
+ : `Line ${line}`
+ }
>
{String(line).padStart(lineWidth, ' ')}:
@@ -99,6 +104,7 @@ export default function ErrorOverview({error}: Props) {
-
{line}
+ \t{' '}
);
@@ -110,7 +116,13 @@ export default function ErrorOverview({error}: Props) {
{parsedLine.function}
-
+
{' '}
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
diff --git a/src/components/Text.tsx b/src/components/Text.tsx
index 93ef38840..9f81aaffc 100644
--- a/src/components/Text.tsx
+++ b/src/components/Text.tsx
@@ -3,9 +3,20 @@ import chalk, {type ForegroundColorName} from 'chalk';
import {type LiteralUnion} from 'type-fest';
import colorize from '../colorize.js';
import {type Styles} from '../styles.js';
+import {accessibilityContext} from './AccessibilityContext.js';
import {backgroundContext} from './BackgroundContext.js';
export type Props = {
+ /**
+ * Label for the element for screen readers.
+ */
+ readonly 'aria-label'?: string;
+
+ /**
+ * Hide the element from screen readers.
+ */
+ readonly 'aria-hidden'?: boolean;
+
/**
* Change text color. Ink uses chalk under the hood, so all its functionality is supported.
*/
@@ -70,9 +81,15 @@ export default function Text({
inverse = false,
wrap = 'wrap',
children,
+ 'aria-label': ariaLabel,
+ 'aria-hidden': ariaHidden = false,
}: Props) {
+ const {isScreenReaderEnabled} = useContext(accessibilityContext);
const inheritedBackgroundColor = useContext(backgroundContext);
- if (children === undefined || children === null) {
+ const childrenOrAriaLabel =
+ isScreenReaderEnabled && ariaLabel ? ariaLabel : children;
+
+ if (childrenOrAriaLabel === undefined || childrenOrAriaLabel === null) {
return null;
}
@@ -115,12 +132,16 @@ export default function Text({
return children;
};
+ if (isScreenReaderEnabled && ariaHidden) {
+ return null;
+ }
+
return (
- {children}
+ {isScreenReaderEnabled && ariaLabel ? ariaLabel : children}
);
}
diff --git a/src/components/Transform.tsx b/src/components/Transform.tsx
index 43dbe9685..6778f4d2f 100644
--- a/src/components/Transform.tsx
+++ b/src/components/Transform.tsx
@@ -1,6 +1,13 @@
-import React, {type ReactNode} from 'react';
+import React, {useContext, type ReactNode} from 'react';
+import {accessibilityContext} from './AccessibilityContext.js';
export type Props = {
+ /**
+ * Screen-reader-specific text to output.
+ * If this is set, all children will be ignored.
+ */
+ readonly accessibilityLabel?: string;
+
/**
* Function which transforms children output. It accepts children and must return transformed children too.
*/
@@ -15,7 +22,13 @@ export type Props = {
* These use cases can't accept React nodes as input, they are expecting a string.
* That's what component does, it gives you an output string of its child components and lets you transform it in any way.
*/
-export default function Transform({children, transform}: Props) {
+export default function Transform({
+ children,
+ transform,
+ accessibilityLabel,
+}: Props) {
+ const {isScreenReaderEnabled} = useContext(accessibilityContext);
+
if (children === undefined || children === null) {
return null;
}
@@ -25,7 +38,9 @@ export default function Transform({children, transform}: Props) {
style={{flexGrow: 0, flexShrink: 1, flexDirection: 'row'}}
internal_transform={transform}
>
- {children}
+ {isScreenReaderEnabled && accessibilityLabel
+ ? accessibilityLabel
+ : children}
);
}
diff --git a/src/dom.ts b/src/dom.ts
index 41fe73405..59da3cb3c 100644
--- a/src/dom.ts
+++ b/src/dom.ts
@@ -28,6 +28,39 @@ export type DOMElement = {
childNodes: DOMNode[];
internal_transform?: OutputTransformer;
+ internal_accessibility?: {
+ role?:
+ | 'button'
+ | 'checkbox'
+ | 'combobox'
+ | 'list'
+ | 'listbox'
+ | 'listitem'
+ | 'menu'
+ | 'menuitem'
+ | 'option'
+ | 'progressbar'
+ | 'radio'
+ | 'radiogroup'
+ | 'tab'
+ | 'tablist'
+ | 'table'
+ | 'textbox'
+ | 'timer'
+ | 'toolbar';
+ state?: {
+ busy?: boolean;
+ checked?: boolean;
+ disabled?: boolean;
+ expanded?: boolean;
+ multiline?: boolean;
+ multiselectable?: boolean;
+ readonly?: boolean;
+ required?: boolean;
+ selected?: boolean;
+ };
+ };
+
// Internal properties
isStaticDirty?: boolean;
staticNode?: DOMElement;
@@ -61,6 +94,8 @@ export const createNode = (nodeName: ElementNames): DOMElement => {
childNodes: [],
parentNode: undefined,
yogaNode: nodeName === 'ink-virtual-text' ? undefined : Yoga.Node.create(),
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ internal_accessibility: {},
};
if (nodeName === 'ink-text') {
@@ -153,6 +188,11 @@ export const setAttribute = (
key: string,
value: DOMNodeAttribute,
): void => {
+ if (key === 'internal_accessibility') {
+ node.internal_accessibility = value as DOMElement['internal_accessibility'];
+ return;
+ }
+
node.attributes[key] = value;
};
diff --git a/src/global.d.ts b/src/global.d.ts
index dbecc92c7..28c94d06c 100644
--- a/src/global.d.ts
+++ b/src/global.d.ts
@@ -20,6 +20,7 @@ declare namespace Ink {
key?: Key;
ref?: LegacyRef;
style?: Except;
+ internal_accessibility?: DOMElement['internal_accessibility'];
};
type Text = {
@@ -29,5 +30,6 @@ declare namespace Ink {
// eslint-disable-next-line @typescript-eslint/naming-convention
internal_transform?: (children: string, index: number) => string;
+ internal_accessibility?: DOMElement['internal_accessibility'];
};
}
diff --git a/src/hooks/use-is-screen-reader-enabled.ts b/src/hooks/use-is-screen-reader-enabled.ts
new file mode 100644
index 000000000..c307ee98b
--- /dev/null
+++ b/src/hooks/use-is-screen-reader-enabled.ts
@@ -0,0 +1,13 @@
+import {useContext} from 'react';
+import {accessibilityContext} from '../components/AccessibilityContext.js';
+
+/**
+ * Returns whether screen reader is enabled. This is useful when you want to
+ * render a different output for screen readers.
+ */
+const useIsScreenReaderEnabled = (): boolean => {
+ const {isScreenReaderEnabled} = useContext(accessibilityContext);
+ return isScreenReaderEnabled;
+};
+
+export default useIsScreenReaderEnabled;
diff --git a/src/index.ts b/src/index.ts
index b81c39cda..8d0e59e31 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -23,5 +23,6 @@ export {default as useStdout} from './hooks/use-stdout.js';
export {default as useStderr} from './hooks/use-stderr.js';
export {default as useFocus} from './hooks/use-focus.js';
export {default as useFocusManager} from './hooks/use-focus-manager.js';
+export {default as useIsScreenReaderEnabled} from './hooks/use-is-screen-reader-enabled.js';
export {default as measureElement} from './measure-element.js';
export type {DOMElement} from './dom.js';
diff --git a/src/ink.tsx b/src/ink.tsx
index a006787ca..1f57c37e5 100644
--- a/src/ink.tsx
+++ b/src/ink.tsx
@@ -15,6 +15,7 @@ import * as dom from './dom.js';
import logUpdate, {type LogUpdate} from './log-update.js';
import instances from './instances.js';
import App from './components/App.js';
+import {accessibilityContext as AccessibilityContext} from './components/AccessibilityContext.js';
const noop = () => {};
@@ -25,6 +26,7 @@ export type Options = {
debug: boolean;
exitOnCtrlC: boolean;
patchConsole: boolean;
+ isScreenReaderEnabled?: boolean;
waitUntilExit?: () => Promise;
};
@@ -32,6 +34,8 @@ export default class Ink {
private readonly options: Options;
private readonly log: LogUpdate;
private readonly throttledLog: LogUpdate;
+ private readonly isScreenReaderEnabled: boolean;
+
// Ignore last render after unmounting a tree to prevent empty output before exit
private isUnmounted: boolean;
private lastOutput: string;
@@ -52,7 +56,13 @@ export default class Ink {
this.rootNode = dom.createNode('ink-root');
this.rootNode.onComputeLayout = this.calculateLayout;
- this.rootNode.onRender = options.debug
+ this.isScreenReaderEnabled =
+ options.isScreenReaderEnabled ??
+ process.env['INK_SCREEN_READER'] === 'true';
+
+ const unthrottled = options.debug || this.isScreenReaderEnabled;
+
+ this.rootNode.onRender = unthrottled
? this.onRender
: throttle(this.onRender, 32, {
leading: true,
@@ -61,7 +71,7 @@ export default class Ink {
this.rootNode.onImmediateRender = this.onRender;
this.log = logUpdate.create(options.stdout);
- this.throttledLog = options.debug
+ this.throttledLog = unthrottled
? this.log
: (throttle(this.log, undefined, {
leading: true,
@@ -150,7 +160,10 @@ export default class Ink {
return;
}
- const {output, outputHeight, staticOutput} = render(this.rootNode);
+ const {output, outputHeight, staticOutput} = render(
+ this.rootNode,
+ this.isScreenReaderEnabled,
+ );
// If output isn't empty, it means new children have been added to it
const hasStaticOutput = staticOutput && staticOutput !== '\n';
@@ -205,17 +218,21 @@ export default class Ink {
render(node: ReactNode): void {
const tree = (
-
- {node}
-
+
+ {node}
+
+
);
// @ts-expect-error the types for `react-reconciler` are not up to date with the library.
diff --git a/src/render-node-to-output.ts b/src/render-node-to-output.ts
index 59d3a6897..0ddda3081 100644
--- a/src/render-node-to-output.ts
+++ b/src/render-node-to-output.ts
@@ -29,6 +29,63 @@ const applyPaddingToText = (node: DOMElement, text: string): string => {
export type OutputTransformer = (s: string, index: number) => string;
+export const renderNodeToScreenReaderOutput = (
+ node: DOMElement,
+ options: {
+ parentRole?: string;
+ } = {},
+): string => {
+ if (node.yogaNode?.getDisplay() === Yoga.DISPLAY_NONE) {
+ return '';
+ }
+
+ let output = '';
+
+ if (node.nodeName === 'ink-text') {
+ output = squashTextNodes(node);
+ } else if (node.nodeName === 'ink-box' || node.nodeName === 'ink-root') {
+ const separator =
+ node.style.flexDirection === 'row' ||
+ node.style.flexDirection === 'row-reverse'
+ ? ' '
+ : '\n';
+
+ const childNodes =
+ node.style.flexDirection === 'row-reverse' ||
+ node.style.flexDirection === 'column-reverse'
+ ? [...node.childNodes].reverse()
+ : [...node.childNodes];
+
+ output = childNodes
+ .map(childNode =>
+ renderNodeToScreenReaderOutput(childNode as DOMElement, {
+ parentRole: node.internal_accessibility?.role,
+ }),
+ )
+ .filter(Boolean)
+ .join(separator);
+ }
+
+ if (node.internal_accessibility) {
+ const {role, state} = node.internal_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;
+};
+
// After nodes are laid out, render each to output object, which later gets rendered to terminal
const renderNodeToOutput = (
node: DOMElement,
diff --git a/src/render.ts b/src/render.ts
index 7893f211e..7745069a5 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -41,6 +41,14 @@ export type RenderOptions = {
* @default true
*/
patchConsole?: boolean;
+
+ /**
+ * Enable screen reader support.
+ * See https://github.com/vadimdemedes/ink/blob/master/readme.md#screen-reader-support
+ *
+ * @default process.env['INK_SCREEN_READER'] === 'true'
+ */
+ isScreenReaderEnabled?: boolean;
};
export type Instance = {
diff --git a/src/renderer.ts b/src/renderer.ts
index 4898a7b60..08e8bc81e 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,4 +1,6 @@
-import renderNodeToOutput from './render-node-to-output.js';
+import renderNodeToOutput, {
+ renderNodeToScreenReaderOutput,
+} from './render-node-to-output.js';
import Output from './output.js';
import {type DOMElement} from './dom.js';
@@ -8,14 +10,27 @@ type Result = {
staticOutput: string;
};
-const renderer = (node: DOMElement): Result => {
+const renderer = (node: DOMElement, isScreenReaderEnabled: boolean): Result => {
if (node.yogaNode) {
+ if (isScreenReaderEnabled) {
+ const output = renderNodeToScreenReaderOutput(node);
+ const outputHeight = output === '' ? 0 : output.split('\n').length;
+
+ return {
+ output,
+ outputHeight,
+ staticOutput: '',
+ };
+ }
+
const output = new Output({
width: node.yogaNode.getComputedWidth(),
height: node.yogaNode.getComputedHeight(),
});
- renderNodeToOutput(node, output, {skipStaticElements: true});
+ renderNodeToOutput(node, output, {
+ skipStaticElements: true,
+ });
let staticOutput;
diff --git a/test/helpers/render-to-string.ts b/test/helpers/render-to-string.ts
index 1e9e29e2a..0ffb6267f 100644
--- a/test/helpers/render-to-string.ts
+++ b/test/helpers/render-to-string.ts
@@ -3,13 +3,14 @@ import createStdout from './create-stdout.js';
export const renderToString: (
node: React.JSX.Element,
- options?: {columns: number},
+ options?: {columns?: number; isScreenReaderEnabled?: boolean},
) => string = (node, options) => {
const stdout = createStdout(options?.columns ?? 100);
render(node, {
stdout,
debug: true,
+ isScreenReaderEnabled: options?.isScreenReaderEnabled,
});
const output = stdout.get();
diff --git a/test/screen-reader.tsx b/test/screen-reader.tsx
new file mode 100644
index 000000000..2dfd24fe1
--- /dev/null
+++ b/test/screen-reader.tsx
@@ -0,0 +1,348 @@
+import test from 'ava';
+import React from 'react';
+import {Box, Text} from '../src/index.js';
+import {renderToString} from './helpers/render-to-string.js';
+
+test('render text for screen readers', t => {
+ const output = renderToString(
+
+ Not visible to screen readers
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'Hello World');
+});
+
+test('render text for screen readers with aria-hidden', t => {
+ const output = renderToString(
+
+ Not visible to screen readers
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, '');
+});
+
+test('render text for screen readers with aria-role', t => {
+ const output = renderToString(
+
+ Click me
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'button: Click me');
+});
+
+test('render select input for screen readers', t => {
+ const items = ['Red', 'Green', 'Blue'];
+
+ const output = renderToString(
+
+ Select a color:
+ {items.map((item, index) => {
+ const isSelected = index === 1;
+ const screenReaderLabel = `${index + 1}. ${item}`;
+
+ return (
+
+ {item}
+
+ );
+ })}
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(
+ output,
+ 'list: Select a color:\nlistitem: 1. Red\nlistitem: (selected) 2. Green\nlistitem: 3. Blue',
+ );
+});
+
+test('render aria-label only Text for screen readers', t => {
+ const output = renderToString(, {
+ isScreenReaderEnabled: true,
+ });
+
+ t.is(output, 'Screen-reader only');
+});
+
+test('render aria-label only Box for screen readers', t => {
+ const output = renderToString(, {
+ isScreenReaderEnabled: true,
+ });
+
+ t.is(output, 'Screen-reader only');
+});
+
+test('omit ANSI styling in screen-reader output', t => {
+ const output = renderToString(
+
+ {/* eslint-disable-next-line react/jsx-sort-props */}
+
+ Styled content
+
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'Styled content');
+});
+
+test('skip nodes with display:none style in screen-reader output', t => {
+ const output = renderToString(
+
+
+ Hidden
+
+ Visible
+ ,
+ {isScreenReaderEnabled: true},
+ );
+
+ t.is(output, 'Visible');
+});
+
+test('render multiple Text components', t => {
+ const output = renderToString(
+
+ Hello
+ World
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'Hello\nWorld');
+});
+
+test('render nested Box components with Text', t => {
+ const output = renderToString(
+
+ Hello
+
+ World
+
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'Hello\nWorld');
+});
+
+function NullComponent(): undefined {
+ return undefined;
+}
+
+test('render component that returns null', t => {
+ const output = renderToString(
+
+ Hello
+
+ World
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'Hello\nWorld');
+});
+
+test('render with aria-state.busy', t => {
+ const output = renderToString(
+
+ Loading
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, '(busy) Loading');
+});
+
+test('render with aria-state.checked', t => {
+ const output = renderToString(
+
+ Accept terms
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'checkbox: (checked) Accept terms');
+});
+
+test('render with aria-state.disabled', t => {
+ const output = renderToString(
+
+ Submit
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'button: (disabled) Submit');
+});
+
+test('render with aria-state.expanded', t => {
+ const output = renderToString(
+
+ Select
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'combobox: (expanded) Select');
+});
+
+test('render with aria-state.multiline', t => {
+ const output = renderToString(
+
+ Hello
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'textbox: (multiline) Hello');
+});
+
+test('render with aria-state.multiselectable', t => {
+ const output = renderToString(
+
+ Options
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'listbox: (multiselectable) Options');
+});
+
+test('render with aria-state.readonly', t => {
+ const output = renderToString(
+
+ Hello
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'textbox: (readonly) Hello');
+});
+
+test('render with aria-state.required', t => {
+ const output = renderToString(
+
+ Name
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'textbox: (required) Name');
+});
+
+test('render with aria-state.selected', t => {
+ const output = renderToString(
+
+ Blue
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'option: (selected) Blue');
+});
+
+test('render multi-line text', t => {
+ const output = renderToString(
+
+ Line 1
+ Line 2
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'Line 1\nLine 2');
+});
+
+test('render multi-line text with roles', t => {
+ const output = renderToString(
+
+
+ Item 1
+
+
+ Item 2
+
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(output, 'list: listitem: Item 1\nlistitem: Item 2');
+});
+
+test('render listbox with multiselectable options', t => {
+ const output = renderToString(
+
+
+ Option 1
+
+
+ Option 2
+
+
+ Option 3
+
+ ,
+ {
+ isScreenReaderEnabled: true,
+ },
+ );
+
+ t.is(
+ output,
+ 'listbox: (multiselectable) option: (selected) Option 1\noption: Option 2\noption: (selected) Option 3',
+ );
+});