diff --git a/examples/aria/aria.tsx b/examples/aria/aria.tsx new file mode 100644 index 000000000..202c4ac70 --- /dev/null +++ b/examples/aria/aria.tsx @@ -0,0 +1,31 @@ +import React, {useState} from 'react'; +import {render, Text, Box, useInput} from 'ink'; + +function AriaExample() { + const [checked, setChecked] = useState(false); + + useInput(key => { + if (key === ' ') { + setChecked(!checked); + } + }); + + return ( + + + Press spacebar to toggle the checkbox. This example is best experienced + with a screen reader. + + + + {checked ? '[x]' : '[ ]'} + + + + + + + ); +} + +render(); diff --git a/examples/aria/index.ts b/examples/aria/index.ts new file mode 100644 index 000000000..d6fa24923 --- /dev/null +++ b/examples/aria/index.ts @@ -0,0 +1 @@ +import './aria.js'; diff --git a/examples/select-input/index.ts b/examples/select-input/index.ts new file mode 100644 index 000000000..9b7be348b --- /dev/null +++ b/examples/select-input/index.ts @@ -0,0 +1 @@ +import './select-input.js'; diff --git a/examples/select-input/select-input.tsx b/examples/select-input/select-input.tsx new file mode 100644 index 000000000..1f750a764 --- /dev/null +++ b/examples/select-input/select-input.tsx @@ -0,0 +1,54 @@ +import React, {useState} from 'react'; +import {render, Text, Box, useInput, useIsScreenReaderEnabled} from 'ink'; + +const items = ['Red', 'Green', 'Blue', 'Yellow', 'Magenta', 'Cyan']; + +function SelectInput() { + const [selectedIndex, setSelectedIndex] = useState(0); + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + + useInput((input, key) => { + if (key.upArrow) { + setSelectedIndex(previousIndex => + previousIndex === 0 ? items.length - 1 : previousIndex - 1, + ); + } + + if (key.downArrow) { + setSelectedIndex(previousIndex => + previousIndex === items.length - 1 ? 0 : previousIndex + 1, + ); + } + + if (isScreenReaderEnabled) { + const number = Number.parseInt(input, 10); + if (!Number.isNaN(number) && number > 0 && number <= items.length) { + setSelectedIndex(number - 1); + } + } + }); + + return ( + + Select a color: + {items.map((item, index) => { + const isSelected = index === selectedIndex; + const label = isSelected ? `> ${item}` : ` ${item}`; + const screenReaderLabel = `${index + 1}. ${item}`; + + return ( + + {label} + + ); + })} + + ); +} + +render(); diff --git a/readme.md b/readme.md index f847131ef..93fd73c9a 100644 --- a/readme.md +++ b/readme.md @@ -144,6 +144,7 @@ Feel free to play around with the code and fork this repl at [https://repl.it/@v - [API](#api) - [Testing](#testing) - [Using React Devtools](#using-react-devtools) +- [Screen Reader Support](#screen-reader-support) - [Useful Components](#useful-components) - [Useful Hooks](#useful-hooks) - [Examples](#examples) @@ -1968,6 +1969,26 @@ const Example = () => { }; ``` +### useIsScreenReaderEnabled() + +Returns whether screen reader is enabled. This is useful when you want to render a different output for screen readers. + +```jsx +import {useIsScreenReaderEnabled, Text} from 'ink'; + +const Example = () => { + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + + return ( + + {isScreenReaderEnabled + ? 'Screen reader is enabled' + : 'Screen reader is disabled'} + + ); +}; +``` + ## API #### render(tree, options?) @@ -2153,6 +2174,105 @@ You can even inspect and change the props of components, and see the results imm **Note**: You must manually quit your CLI via Ctrl+C after you're done testing. +## Screen Reader Support + +Ink has a basic support for screen readers. + +To enable it, you can either pass the `isScreenReaderEnabled` option to the `render` function or set the `INK_SCREEN_READER` environment variable to `true`. + +Ink implements a small subset of functionality from the [ARIA specification](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA). + +```jsx +render(, {isScreenReaderEnabled: true}); +``` + +When screen reader support is enabled, Ink will try its best to generate a screen-reader-friendly output. + +For example, for this code: + +```jsx + + Accept terms and conditions + +``` + +Ink will generate the following output for screen readers: + +``` +(checked) checkbox: Accept terms and conditions +``` + +You can also provide a custom label for screen readers, if you want to render something different for them. + +For example, if you are building a progress bar, you can use `aria-label` to provide a more descriptive label for screen readers. + +```jsx + + + 50% + +``` + +In the example above, screen reader will read "Progress: 50%", instead of "50%". + +### `aria-label` + +Type: `string` + +Label for the element for screen readers. + +### `aria-hidden` + +Type: `boolean`\ +Default: `false` + +Hide the element from screen readers. + +##### aria-role + +Type: `string` + +Role of the element. + +Supported values: +- `button` +- `checkbox` +- `radio` +- `radiogroup` +- `list` +- `listitem` +- `menu` +- `menuitem` +- `progressbar` +- `tab` +- `tablist` +- `timer` +- `toolbar` +- `table` + +##### aria-state + +Type: `object` + +State of the element. + +Supported values: +- `checked` (boolean) +- `disabled` (boolean) +- `expanded` (boolean) +- `selected` (boolean) + +## Creating Components + +When building custom components, it's important to keep accessibility in mind. While Ink provides the building blocks, ensuring your components are accessible will make your CLIs usable by a wider audience. + +### General Principles + +- **Provide screen reader-friendly output:** Use the `useIsScreenReaderEnabled` hook to detect if a screen reader is active. You can then render a more descriptive output for screen reader users. +- **Leverage ARIA props:** For components that have a specific role (e.g., a checkbox or a button), use the `aria-role`, `aria-state`, and `aria-label` props on `` and `` to provide semantic meaning to screen readers. + +For a practical example of building an accessible component, see the [ARIA example](/examples/aria/aria.tsx). + ## Useful Components - [ink-text-input](https://github.com/vadimdemedes/ink-text-input) - Text input. diff --git a/src/components/AccessibilityContext.ts b/src/components/AccessibilityContext.ts new file mode 100644 index 000000000..62a60afc2 --- /dev/null +++ b/src/components/AccessibilityContext.ts @@ -0,0 +1,5 @@ +import {createContext} from 'react'; + +export const accessibilityContext = createContext({ + isScreenReaderEnabled: false, +}); diff --git a/src/components/Box.tsx b/src/components/Box.tsx index 3a913278d..08d5ab8b2 100644 --- a/src/components/Box.tsx +++ b/src/components/Box.tsx @@ -1,16 +1,82 @@ -import React, {forwardRef, type PropsWithChildren} from 'react'; +import React, {forwardRef, useContext, type PropsWithChildren} from 'react'; import {type Except} from 'type-fest'; import {type Styles} from '../styles.js'; import {type DOMElement} from '../dom.js'; +import {accessibilityContext} from './AccessibilityContext.js'; import {backgroundContext} from './BackgroundContext.js'; -export type Props = Except; +export type Props = Except & { + /** + * Label for the element for screen readers. + */ + readonly 'aria-label'?: string; + + /** + * Hide the element from screen readers. + */ + readonly 'aria-hidden'?: boolean; + + /** + * Role of the element. + */ + readonly 'aria-role'?: + | 'button' + | 'checkbox' + | 'combobox' + | 'list' + | 'listbox' + | 'listitem' + | 'menu' + | 'menuitem' + | 'option' + | 'progressbar' + | 'radio' + | 'radiogroup' + | 'tab' + | 'tablist' + | 'table' + | 'textbox' + | 'timer' + | 'toolbar'; + + /** + * State of the element. + */ + readonly 'aria-state'?: { + readonly busy?: boolean; + readonly checked?: boolean; + readonly disabled?: boolean; + readonly expanded?: boolean; + readonly multiline?: boolean; + readonly multiselectable?: boolean; + readonly readonly?: boolean; + readonly required?: boolean; + readonly selected?: boolean; + }; +}; /** * `` is an essential Ink component to build your layout. It's like `
` 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', + ); +});