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 0e4c5f03f..0a95a57bb 100644 --- a/readme.md +++ b/readme.md @@ -1108,6 +1108,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') + ); +});