Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add focus management #295

Merged
merged 1 commit into from
Jun 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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 @@ -1108,6 +1108,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:/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
}}
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'd move this context provider and all the following methods into a separate file because this file becomes more complicated with those 200 supplementary lines. (ie. someone that want to improve ink might be lost in those lines whereas he does not need to understand focus management)

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree and this is what I wanted to do initially, but decided to leave it in App.tsx for now to reuse the data event handler on stdin stream, instead of having two separate ones. I think a better move here is to rewrite this file using hooks and extract individual functionality (focus management, exiting, raw mode) into hooks, which will clean this whole thing up.

{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