Skip to content

Commit

Permalink
Add focus management (#295)
Browse files Browse the repository at this point in the history
  • Loading branch information
Vadim Demedes authored Jun 13, 2020
1 parent 125148a commit 706fdb2
Show file tree
Hide file tree
Showing 10 changed files with 891 additions and 2 deletions.
2 changes: 2 additions & 0 deletions examples/use-focus/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
'use strict';
require('import-jsx')('./use-focus');
27 changes: 27 additions & 0 deletions examples/use-focus/use-focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* eslint-disable react/prop-types */
'use strict';
const React = require('react');
const {render, Box, Text, Color, useFocus} = require('../..');

const Focus = () => (
<Box flexDirection="column" padding={1}>
<Box marginBottom={1}>
Press Tab to focus next element, Shift+Tab to focus previous element, Esc
to reset focus.
</Box>
<Item label="First" />
<Item label="Second" />
<Item label="Third" />
</Box>
);

const Item = ({label}) => {
const {isFocused} = useFocus();
return (
<Text>
{label} {isFocused && <Color green>(focused)</Color>}
</Text>
);
};

render(<Focus />);
122 changes: 122 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,128 @@ const Example = () => {
};
```
### useFocus(options?)
Component that uses `useFocus` hook becomes "focusable" to Ink, so when user presses <kbd>Tab</kbd>, 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`<br>
Default: `false`
Auto focus this component, if there's no active (focused) component right now.
##### isActive
Type: `boolean`<br>
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 <Text>{isFocused ? 'I am focused' : 'I am not focused'}</Text>;
};
```
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 <kbd>Tab</kbd>.
```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 <kbd>Shift</kbd>+<kbd>Tab</kbd>.
```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.
Expand Down
193 changes: 191 additions & 2 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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<Props> {
export default class App extends PureComponent<Props, State> {
static displayName = 'InternalApp';
static propTypes = {
children: PropTypes.node.isRequired,
Expand All @@ -34,6 +51,12 @@ export default class App extends PureComponent<Props> {
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;
Expand Down Expand Up @@ -69,7 +92,21 @@ export default class App extends PureComponent<Props> {
write: this.props.writeToStderr
}}
>
{this.props.children}
<FocusContext.Provider
value={{
activeId: this.state.activeFocusId,
add: this.addFocusable,
remove: this.removeFocusable,
activate: this.activateFocusable,
deactivate: this.deactivateFocusable,
enableFocus: this.enableFocus,
disableFocus: this.disableFocus,
focusNext: this.focusNext,
focusPrevious: this.focusPrevious
}}
>
{this.props.children}
</FocusContext.Provider>
</StderrContext.Provider>
</StdoutContext.Provider>
</StdinContext.Provider>
Expand Down Expand Up @@ -137,6 +174,23 @@ export default class App extends PureComponent<Props> {
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 => {
Expand All @@ -146,4 +200,139 @@ export default class App extends PureComponent<Props> {

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;
};
}
Loading

0 comments on commit 706fdb2

Please sign in to comment.