Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions examples/aria/aria.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box flexDirection="column">
<Text>
Press spacebar to toggle the checkbox. This example is best experienced
with a screen reader.
</Text>
<Box marginTop={1}>
<Box aria-role="checkbox" aria-state={{checked}}>
<Text>{checked ? '[x]' : '[ ]'}</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text aria-hidden="true">This text is hidden from screen readers.</Text>
</Box>
</Box>
);
}

render(<AriaExample />);
1 change: 1 addition & 0 deletions examples/aria/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './aria.js';
1 change: 1 addition & 0 deletions examples/select-input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './select-input.js';
54 changes: 54 additions & 0 deletions examples/select-input/select-input.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box flexDirection="column" aria-role="list">
<Text>Select a color:</Text>
{items.map((item, index) => {
const isSelected = index === selectedIndex;
const label = isSelected ? `> ${item}` : ` ${item}`;
const screenReaderLabel = `${index + 1}. ${item}`;

return (
<Box
key={item}
aria-role="listitem"
aria-state={{selected: isSelected}}
aria-label={isScreenReaderEnabled ? screenReaderLabel : undefined}
>
<Text color={isSelected ? 'blue' : undefined}>{label}</Text>
</Box>
);
})}
</Box>
);
}

render(<SelectInput />);
120 changes: 120 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 (
<Text>
{isScreenReaderEnabled
? 'Screen reader is enabled'
: 'Screen reader is disabled'}
</Text>
);
};
```

## API

#### render(tree, options?)
Expand Down Expand Up @@ -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 <kbd>Ctrl</kbd>+<kbd>C</kbd> 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(<MyApp />, {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
<Box aria-role="checkbox" aria-state={{checked: true}}>
Accept terms and conditions
</Box>
```

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
<Box>
<Box width="50%" height={1} backgroundColor="green" />
<Text aria-label="Progress: 50%">50%</Text>
</Box>
```

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 `<Box>` and `<Text>` 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.
Expand Down
5 changes: 5 additions & 0 deletions src/components/AccessibilityContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {createContext} from 'react';

export const accessibilityContext = createContext({
isScreenReaderEnabled: false,
});
78 changes: 74 additions & 4 deletions src/components/Box.tsx
Original file line number Diff line number Diff line change
@@ -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<Styles, 'textWrap'>;
export type Props = Except<Styles, 'textWrap'> & {
/**
* 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;
};
};

/**
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` in the browser.
*/
const Box = forwardRef<DOMElement, PropsWithChildren<Props>>(
({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 ? <ink-text>{ariaLabel}</ink-text> : undefined;
if (isScreenReaderEnabled && ariaHidden) {
return null;
}

const boxElement = (
<ink-box
ref={ref}
Expand All @@ -24,8 +90,12 @@ const Box = forwardRef<DOMElement, PropsWithChildren<Props>>(
overflowX: style.overflowX ?? style.overflow ?? 'visible',
overflowY: style.overflowY ?? style.overflow ?? 'visible',
}}
internal_accessibility={{
role,
state: ariaState,
}}
>
{children}
{isScreenReaderEnabled && label ? label : children}
</ink-box>
);

Expand Down
14 changes: 13 additions & 1 deletion src/components/ErrorOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, ' ')}:
</Text>
Expand Down Expand Up @@ -99,6 +104,7 @@ export default function ErrorOverview({error}: Props) {
<Text dimColor>- </Text>
<Text dimColor bold>
{line}
\t{' '}
</Text>
</Box>
);
Expand All @@ -110,7 +116,13 @@ export default function ErrorOverview({error}: Props) {
<Text dimColor bold>
{parsedLine.function}
</Text>
<Text dimColor color="gray">
<Text
dimColor
color="gray"
aria-label={`at ${
cleanupPath(parsedLine.file) ?? ''
} line ${parsedLine.line} column ${parsedLine.column}`}
>
{' '}
({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:
{parsedLine.column})
Expand Down
Loading