diff --git a/examples/use-focus/index.js b/examples/use-focus/index.js
new file mode 100644
index 000000000..5d4a2ce8c
--- /dev/null
+++ b/examples/use-focus/index.js
@@ -0,0 +1,2 @@
+'use strict';
+require('import-jsx')('./use-focus');
diff --git a/examples/use-focus/use-focus.js b/examples/use-focus/use-focus.js
new file mode 100644
index 000000000..b075cb253
--- /dev/null
+++ b/examples/use-focus/use-focus.js
@@ -0,0 +1,27 @@
+/* eslint-disable react/prop-types */
+'use strict';
+const React = require('react');
+const {render, Box, Text, Color, useFocus} = require('../..');
+
+const Focus = () => (
+
+
+ Press Tab to focus next element, Shift+Tab to focus previous element, Esc
+ to reset focus.
+
+
+
+
+
+);
+
+const Item = ({label}) => {
+ const {isFocused} = useFocus();
+ return (
+
+ {label} {isFocused && (focused)}
+
+ );
+};
+
+render();
diff --git a/readme.md b/readme.md
index 0c1cda5a5..df7520f43 100644
--- a/readme.md
+++ b/readme.md
@@ -1139,6 +1139,128 @@ const Example = () => {
};
```
+### useFocus(options?)
+
+Component that uses `useFocus` hook becomes "focusable" to Ink, so when user presses Tab, Ink will switch focus to this component.
+If there are multiple components that execute `useFocus` hook, focus will be given to them in the order that these components are rendered in.
+This hook returns an object with `isFocused` boolean property, which determines if this component is focused or not.
+
+#### options
+
+##### autoFocus
+
+Type: `boolean`
+Default: `false`
+
+Auto focus this component, if there's no active (focused) component right now.
+
+##### isActive
+
+Type: `boolean`
+Default: `true`
+
+Enable or disable this component's focus, while still maintaining its position in the list of focusable components.
+This is useful for inputs that are temporarily disabled.
+
+```js
+import {useFocus} from 'ink';
+
+const Example = () => {
+ const {isFocused} = useFocus();
+
+ return {isFocused ? 'I am focused' : 'I am not focused'};
+};
+```
+
+See example in [examples/use-focus](examples/use-focus/use-focus.js).
+
+### useFocusManager
+
+This hook exposes methods to enable or disable focus management for all components or manually switch focus to next or previous components.
+
+#### enableFocus()
+
+Enable focus management for all components.
+
+**Note:** You don't need to call this method manually, unless you've disabled focus management. Focus management is enabled by default.
+
+```js
+import {useFocusManager} from 'ink';
+
+const Example = () => {
+ const {enableFocus} = useFocusManager();
+
+ useEffect(() => {
+ enableFocus();
+ }, []);
+
+ return …
+};
+```
+
+#### disableFocus()
+
+Disable focus management for all components.
+Currently active component (if there's one) will lose its focus.
+
+```js
+import {useFocusManager} from 'ink';
+
+const Example = () => {
+ const {disableFocus} = useFocusManager();
+
+ useEffect(() => {
+ disableFocus();
+ }, []);
+
+ return …
+};
+```
+
+#### focusNext()
+
+Switch focus to the next focusable component.
+If there's no active component right now, focus will be given to the first focusable component.
+If active component is the last in the list of focusable components, focus will be switched to the first component.
+
+**Note:** Ink calls this method when user presses Tab.
+
+```js
+import {useFocusManager} from 'ink';
+
+const Example = () => {
+ const {focusNext} = useFocusManager();
+
+ useEffect(() => {
+ focusNext();
+ }, []);
+
+ return …
+};
+```
+
+#### focusPrevious()
+
+Switch focus to the previous focusable component.
+If there's no active component right now, focus will be given to the first focusable component.
+If active component is the first in the list of focusable components, focus will be switched to the last component.
+
+**Note:** Ink calls this method when user presses Shift+Tab.
+
+```js
+import {useFocusManager} from 'ink';
+
+const Example = () => {
+ const {focusPrevious} = useFocusManager();
+
+ useEffect(() => {
+ focusPrevious();
+ }, []);
+
+ return …
+};
+```
+
## Useful Hooks
- [ink-use-stdout-dimensions](https://github.com/cameronhunter/ink-monorepo/tree/master/packages/ink-use-stdout-dimensions) - Subscribe to stdout dimensions.
diff --git a/src/components/App.tsx b/src/components/App.tsx
index 6b441318a..7f8fb0ca5 100644
--- a/src/components/App.tsx
+++ b/src/components/App.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
import React, {PureComponent} from 'react';
import type {ReactNode} from 'react';
import PropTypes from 'prop-types';
@@ -6,6 +7,11 @@ import AppContext from './AppContext';
import StdinContext from './StdinContext';
import StdoutContext from './StdoutContext';
import StderrContext from './StderrContext';
+import FocusContext from './FocusContext';
+
+const TAB = '\t';
+const SHIFT_TAB = '\u001B[Z';
+const ESC = '\u001B';
interface Props {
children: ReactNode;
@@ -18,10 +24,21 @@ interface Props {
onExit: (error?: Error) => void;
}
+interface State {
+ isFocusEnabled: boolean;
+ activeFocusId?: string;
+ focusables: Focusable[];
+}
+
+interface Focusable {
+ id: string;
+ isActive: boolean;
+}
+
// Root component for all Ink apps
// It renders stdin and stdout contexts, so that children can access them if needed
// It also handles Ctrl+C exiting and cursor visibility
-export default class App extends PureComponent {
+export default class App extends PureComponent {
static displayName = 'InternalApp';
static propTypes = {
children: PropTypes.node.isRequired,
@@ -34,6 +51,12 @@ export default class App extends PureComponent {
onExit: PropTypes.func.isRequired
};
+ state = {
+ isFocusEnabled: true,
+ activeFocusId: undefined,
+ focusables: []
+ };
+
// Count how many components enabled raw mode to avoid disabling
// raw mode until all components don't need it anymore
rawModeEnabledCount = 0;
@@ -69,7 +92,21 @@ export default class App extends PureComponent {
write: this.props.writeToStderr
}}
>
- {this.props.children}
+
+ {this.props.children}
+
@@ -137,6 +174,23 @@ export default class App extends PureComponent {
if (input === '\x03' && this.props.exitOnCtrlC) {
this.handleExit();
}
+
+ // Reset focus when there's an active focused component on Esc
+ if (input === ESC && this.state.activeFocusId) {
+ this.setState({
+ activeFocusId: undefined
+ });
+ }
+
+ if (this.state.isFocusEnabled && this.state.focusables.length > 0) {
+ if (input === TAB) {
+ this.focusNext();
+ }
+
+ if (input === SHIFT_TAB) {
+ this.focusPrevious();
+ }
+ }
};
handleExit = (error?: Error): void => {
@@ -146,4 +200,139 @@ export default class App extends PureComponent {
this.props.onExit(error);
};
+
+ enableFocus = (): void => {
+ this.setState({
+ isFocusEnabled: true
+ });
+ };
+
+ disableFocus = (): void => {
+ this.setState({
+ isFocusEnabled: false
+ });
+ };
+
+ focusNext = (): void => {
+ this.setState(previousState => {
+ const firstFocusableId = previousState.focusables[0].id;
+ const nextFocusableId = this.findNextFocusable(previousState);
+
+ return {
+ activeFocusId: nextFocusableId || firstFocusableId
+ };
+ });
+ };
+
+ focusPrevious = (): void => {
+ this.setState(previousState => {
+ const lastFocusableId =
+ previousState.focusables[previousState.focusables.length - 1].id;
+
+ const previousFocusableId = this.findPreviousFocusable(previousState);
+
+ return {
+ activeFocusId: previousFocusableId || lastFocusableId
+ };
+ });
+ };
+
+ addFocusable = (id: string, {autoFocus}: {autoFocus: boolean}): void => {
+ this.setState(previousState => {
+ let nextFocusId = previousState.activeFocusId;
+
+ if (!nextFocusId && autoFocus) {
+ nextFocusId = id;
+ }
+
+ return {
+ activeFocusId: nextFocusId,
+ focusables: [
+ ...previousState.focusables,
+ {
+ id,
+ isActive: true
+ }
+ ]
+ };
+ });
+ };
+
+ removeFocusable = (id: string): void => {
+ this.setState(previousState => ({
+ activeFocusId:
+ previousState.activeFocusId === id
+ ? undefined
+ : previousState.activeFocusId,
+ focusables: previousState.focusables.filter(focusable => {
+ return focusable.id !== id;
+ })
+ }));
+ };
+
+ activateFocusable = (id: string): void => {
+ this.setState(previousState => ({
+ focusables: previousState.focusables.map(focusable => {
+ if (focusable.id !== id) {
+ return focusable;
+ }
+
+ return {
+ id,
+ isActive: true
+ };
+ })
+ }));
+ };
+
+ deactivateFocusable = (id: string): void => {
+ this.setState(previousState => ({
+ activeFocusId:
+ previousState.activeFocusId === id
+ ? undefined
+ : previousState.activeFocusId,
+ focusables: previousState.focusables.map(focusable => {
+ if (focusable.id !== id) {
+ return focusable;
+ }
+
+ return {
+ id,
+ isActive: false
+ };
+ })
+ }));
+ };
+
+ findNextFocusable = (state: State): string | undefined => {
+ const activeIndex = state.focusables.findIndex(focusable => {
+ return focusable.id === state.activeFocusId;
+ });
+
+ for (
+ let index = activeIndex + 1;
+ index < state.focusables.length;
+ index++
+ ) {
+ if (state.focusables[index].isActive) {
+ return state.focusables[index].id;
+ }
+ }
+
+ return undefined;
+ };
+
+ findPreviousFocusable = (state: State): string | undefined => {
+ const activeIndex = state.focusables.findIndex(focusable => {
+ return focusable.id === state.activeFocusId;
+ });
+
+ for (let index = activeIndex - 1; index >= 0; index--) {
+ if (state.focusables[index].isActive) {
+ return state.focusables[index].id;
+ }
+ }
+
+ return undefined;
+ };
}
diff --git a/src/components/FocusContext.ts b/src/components/FocusContext.ts
new file mode 100644
index 000000000..b281941d5
--- /dev/null
+++ b/src/components/FocusContext.ts
@@ -0,0 +1,29 @@
+import {createContext} from 'react';
+
+export interface Props {
+ activeId?: string;
+ add: (id: string, options: {autoFocus: boolean}) => void;
+ remove: (id: string) => void;
+ activate: (id: string) => void;
+ deactivate: (id: string) => void;
+ enableFocus: () => void;
+ disableFocus: () => void;
+ focusNext: () => void;
+ focusPrevious: () => void;
+}
+
+const FocusContext = createContext({
+ activeId: undefined,
+ add: () => {},
+ remove: () => {},
+ activate: () => {},
+ deactivate: () => {},
+ enableFocus: () => {},
+ disableFocus: () => {},
+ focusNext: () => {},
+ focusPrevious: () => {}
+});
+
+FocusContext.displayName = 'InternalFocusContext';
+
+export default FocusContext;
diff --git a/src/devtools.ts b/src/devtools.ts
index 8c7805856..72aa89b0e 100644
--- a/src/devtools.ts
+++ b/src/devtools.ts
@@ -54,6 +54,13 @@ customGlobal.window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = [
value: 'InternalStdinContext',
isEnabled: true,
isValid: true
+ },
+ {
+ // ComponentFilterDisplayName
+ type: 2,
+ value: 'InternalFocusContext',
+ isEnabled: true,
+ isValid: true
}
];
diff --git a/src/hooks/use-focus-manager.ts b/src/hooks/use-focus-manager.ts
new file mode 100644
index 000000000..6eb47fd35
--- /dev/null
+++ b/src/hooks/use-focus-manager.ts
@@ -0,0 +1,46 @@
+import {useContext} from 'react';
+import FocusContext from '../components/FocusContext';
+import type {Props} from '../components/FocusContext';
+
+interface Output {
+ /**
+ * Enable focus management for all components.
+ */
+ enableFocus: Props['enableFocus'];
+
+ /**
+ * Disable focus management for all components. Currently active component (if there's one) will lose its focus.
+ */
+ disableFocus: Props['disableFocus'];
+
+ /**
+ * Switch focus to the next focusable component.
+ * If there's no active component right now, focus will be given to the first focusable component.
+ * If active component is the last in the list of focusable components, focus will be switched to the first component.
+ */
+ focusNext: Props['focusNext'];
+
+ /**
+ * Switch focus to the previous focusable component.
+ * If there's no active component right now, focus will be given to the first focusable component.
+ * If active component is the first in the list of focusable components, focus will be switched to the last component.
+ */
+ focusPrevious: Props['focusPrevious'];
+}
+
+/**
+ * This hook exposes methods to enable or disable focus management for all
+ * components or manually switch focus to next or previous components.
+ */
+const useFocusManager = (): Output => {
+ const focusContext = useContext(FocusContext);
+
+ return {
+ enableFocus: focusContext.enableFocus,
+ disableFocus: focusContext.disableFocus,
+ focusNext: focusContext.focusNext,
+ focusPrevious: focusContext.focusPrevious
+ };
+};
+
+export default useFocusManager;
diff --git a/src/hooks/use-focus.ts b/src/hooks/use-focus.ts
new file mode 100644
index 000000000..2d6a9f62e
--- /dev/null
+++ b/src/hooks/use-focus.ts
@@ -0,0 +1,73 @@
+import {useEffect, useContext, useMemo} from 'react';
+import FocusContext from '../components/FocusContext';
+import useStdin from './use-stdin';
+
+interface Input {
+ /**
+ * Enable or disable this component's focus, while still maintaining its position in the list of focusable components.
+ */
+ isActive?: boolean;
+
+ /**
+ * Auto focus this component, if there's no active (focused) component right now.
+ */
+ autoFocus?: boolean;
+}
+
+interface Output {
+ /**
+ * Determines whether this component is focused or not.
+ */
+ isFocused: boolean;
+}
+
+/**
+ * Component that uses `useFocus` hook becomes "focusable" to Ink,
+ * so when user presses Tab, Ink will switch focus to this component.
+ * If there are multiple components that execute `useFocus` hook, focus will be
+ * given to them in the order that these components are rendered in.
+ * This hook returns an object with `isFocused` boolean property, which
+ * determines if this component is focused or not.
+ */
+const useFocus = ({isActive = true, autoFocus = false}: Input = {}): Output => {
+ const {isRawModeSupported, setRawMode} = useStdin();
+ const {activeId, add, remove, activate, deactivate} = useContext(
+ FocusContext
+ );
+
+ const id = useMemo(() => Math.random().toString().slice(2, 7), []);
+
+ useEffect(() => {
+ add(id, {autoFocus});
+
+ return () => {
+ remove(id);
+ };
+ }, [id, autoFocus]);
+
+ useEffect(() => {
+ if (isActive) {
+ activate(id);
+ } else {
+ deactivate(id);
+ }
+ }, [isActive, id]);
+
+ useEffect(() => {
+ if (!isRawModeSupported || !isActive) {
+ return;
+ }
+
+ setRawMode(true);
+
+ return () => {
+ setRawMode(false);
+ };
+ }, [isActive]);
+
+ return {
+ isFocused: Boolean(id) && activeId === id
+ };
+};
+
+export default useFocus;
diff --git a/src/index.ts b/src/index.ts
index be1308577..2283c3e1e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -18,3 +18,5 @@ export {default as useApp} from './hooks/use-app';
export {default as useStdin} from './hooks/use-stdin';
export {default as useStdout} from './hooks/use-stdout';
export {default as useStderr} from './hooks/use-stderr';
+export {default as useFocus} from './hooks/use-focus';
+export {default as useFocusManager} from './hooks/use-focus-manager';
diff --git a/test/focus.tsx b/test/focus.tsx
new file mode 100644
index 000000000..827ead267
--- /dev/null
+++ b/test/focus.tsx
@@ -0,0 +1,392 @@
+import EventEmitter from 'events';
+import React, {useEffect} from 'react';
+import type {FC} from 'react';
+import delay from 'delay';
+import test from 'ava';
+import {spy} from 'sinon';
+import {render, Box, Text, useFocus, useFocusManager} from '..';
+
+const createStdout = () => ({
+ write: spy(),
+ columns: 100
+});
+
+const createStdin = () => {
+ const stdin = new EventEmitter();
+ stdin.isTTY = true;
+ stdin.setRawMode = spy();
+ stdin.setEncoding = () => {};
+ stdin.resume = () => {};
+
+ return stdin;
+};
+
+interface TestProps {
+ showFirst?: boolean;
+ disableSecond?: boolean;
+ autoFocus?: boolean;
+ disabled?: boolean;
+ focusNext?: boolean;
+ focusPrevious?: boolean;
+}
+
+const Test: FC = ({
+ showFirst = true,
+ disableSecond = false,
+ autoFocus = false,
+ disabled = false,
+ focusNext = false,
+ focusPrevious = false
+}) => {
+ const focusManager = useFocusManager();
+
+ useEffect(() => {
+ if (disabled) {
+ focusManager.disableFocus();
+ } else {
+ focusManager.enableFocus();
+ }
+ }, [disabled]);
+
+ useEffect(() => {
+ if (focusNext) {
+ focusManager.focusNext();
+ }
+ }, [focusNext]);
+
+ useEffect(() => {
+ if (focusPrevious) {
+ focusManager.focusPrevious();
+ }
+ }, [focusPrevious]);
+
+ return (
+
+ {showFirst && }
+
+
+
+ );
+};
+
+interface ItemProps {
+ label: string;
+ autoFocus: boolean;
+ disabled?: boolean;
+}
+
+const Item: FC = ({label, autoFocus, disabled = false}) => {
+ const {isFocused} = useFocus({
+ autoFocus,
+ isActive: !disabled
+ });
+
+ return (
+
+ {label} {isFocused && '✔'}
+
+ );
+};
+
+test('dont focus on register when auto focus is off', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+
+ t.is(stdout.write.lastCall.args[0], ['First', 'Second', 'Third'].join('\n'));
+});
+
+test('focus the first component to register', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First ✔', 'Second', 'Third'].join('\n')
+ );
+});
+
+test('unfocus active component on Esc', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\u001B');
+ await delay(100);
+ t.is(stdout.write.lastCall.args[0], ['First', 'Second', 'Third'].join('\n'));
+});
+
+test('switch focus to first component on Tab', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First ✔', 'Second', 'Third'].join('\n')
+ );
+});
+
+test('switch focus to the next component on Tab', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\t');
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second ✔', 'Third'].join('\n')
+ );
+});
+
+test('switch focus to the first component if currently focused component is the last one on Tab', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\t');
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second', 'Third ✔'].join('\n')
+ );
+
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First ✔', 'Second', 'Third'].join('\n')
+ );
+});
+
+test('skip disabled component on Tab', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second', 'Third ✔'].join('\n')
+ );
+});
+
+test('switch focus to the previous component on Shift+Tab', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second ✔', 'Third'].join('\n')
+ );
+
+ stdin.emit('data', '\u001B[Z');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First ✔', 'Second', 'Third'].join('\n')
+ );
+});
+
+test('switch focus to the last component if currently focused component is the first one on Shift+Tab', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\u001B[Z');
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second', 'Third ✔'].join('\n')
+ );
+});
+
+test('skip disabled component on Shift+Tab', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ stdin.emit('data', '\u001B[Z');
+ stdin.emit('data', '\u001B[Z');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First ✔', 'Second', 'Third'].join('\n')
+ );
+});
+
+test('reset focus when focused component unregisters', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ const {rerender} = render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ rerender();
+ await delay(100);
+
+ t.is(stdout.write.lastCall.args[0], ['Second', 'Third'].join('\n'));
+});
+
+test('focus first component after focused component unregisters', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ const {rerender} = render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ rerender();
+ await delay(100);
+
+ t.is(stdout.write.lastCall.args[0], ['Second', 'Third'].join('\n'));
+
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(stdout.write.lastCall.args[0], ['Second ✔', 'Third'].join('\n'));
+});
+
+test('toggle focus management', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ const {rerender} = render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ rerender();
+ await delay(100);
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First ✔', 'Second', 'Third'].join('\n')
+ );
+
+ rerender();
+ await delay(100);
+ stdin.emit('data', '\t');
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second ✔', 'Third'].join('\n')
+ );
+});
+
+test('manually focus next component', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ const {rerender} = render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ rerender();
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second ✔', 'Third'].join('\n')
+ );
+});
+
+test('manually focus previous component', async t => {
+ const stdout = createStdout();
+ const stdin = createStdin();
+ const {rerender} = render(, {
+ stdout,
+ stdin,
+ debug: true
+ });
+
+ await delay(100);
+ rerender();
+ await delay(100);
+
+ t.is(
+ stdout.write.lastCall.args[0],
+ ['First', 'Second', 'Third ✔'].join('\n')
+ );
+});