From a33e519eae35a2a5f98564737c9b4e4ba64e91b8 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Tue, 23 Jun 2020 20:08:55 +0300 Subject: [PATCH] Upgrade for Ink 3 (#58) --- index.d.ts | 59 ------------ index.test-d.tsx | 30 ------ package.json | 66 +++++++------- readme.md | 52 +++++------ source/index.tsx | 191 ++++++++++++++++++++++++++++++++++++++ src/index.js | 232 ----------------------------------------------- test.js | 48 ++++++++-- tsconfig.json | 8 ++ 8 files changed, 292 insertions(+), 394 deletions(-) delete mode 100644 index.d.ts delete mode 100644 index.test-d.tsx create mode 100644 source/index.tsx delete mode 100644 src/index.js create mode 100644 tsconfig.json diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index b5aba97..0000000 --- a/index.d.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from 'react'; - -interface CommonProps { - /** - * Text to display when `value` is empty. - */ - placeholder?: string; - - /** - * Listen to user's input. Useful in case there are multiple input components - * at the same time and input must be "routed" to a specific component. - */ - focus?: boolean; - - /** - * Replace all chars and mask the value. Useful for password inputs. - */ - mask?: string; - - /** - * Whether to show cursor and allow navigation inside text input with arrow keys. - */ - showCursor?: boolean; - - /** - * Highlight pasted text - */ - highlightPastedText?: boolean; - - /** - * Function to call when `Enter` is pressed, where first argument is a value of the input. - */ - onSubmit?: (value: string) => void; -} - -export interface InkTextInputProps extends CommonProps { - /** - * Value to display in a text input. - */ - value: string; - - /** - * Function to call when value updates. - */ - onChange: (value: string) => void; -} - -export interface InkUncontrolledTextInputProps extends CommonProps { - /** - * Function to call when `Enter` is pressed, where first argument is a value of the input. - */ - onSubmit: (value: string) => void; -} - -export default class InkTextInput extends React.Component {} - -export class UncontrolledTextInput extends React.Component< - InkUncontrolledTextInputProps -> {} diff --git a/index.test-d.tsx b/index.test-d.tsx deleted file mode 100644 index a74a3a1..0000000 --- a/index.test-d.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; -import InkTextInput, { UncontrolledTextInput } from '.'; - -const handler = (value: string) => console.log(value); - -const Input = () => ; -const AllPropsInput = () => ( - -); - -const UncontrolledInput = () => ; -const AllPropsUncontrolledInput = () => ( - -); diff --git a/package.json b/package.json index cabefb8..614065b 100644 --- a/package.json +++ b/package.json @@ -10,19 +10,18 @@ "url": "github.com/vadimdemedes" }, "main": "build", + "types": "build/index.d.ts", "engines": { "node": ">=10" }, "scripts": { - "test": "xo && FORCE_COLOR=1 ava && npm run typecheck", - "build": "babel src --out-dir=build", - "prepare": "npm run build", - "pretest": "npm run build", - "typecheck": "tsc --noEmit --jsx react index.test-d.tsx" + "test": "tsc --noEmit && xo && FORCE_COLOR=1 ava", + "build": "tsc", + "prepare": "tsc", + "pretest": "tsc" }, "files": [ - "build", - "index.d.ts" + "build" ], "keywords": [ "ink", @@ -37,51 +36,54 @@ "query" ], "dependencies": { - "chalk": "^3.0.0", - "prop-types": "^15.5.10" + "chalk": "^4.1.0", + "type-fest": "^0.15.1" }, "devDependencies": { - "@babel/cli": "^7.1.2", - "@babel/core": "^7.1.2", - "@babel/plugin-proposal-class-properties": "^7.1.0", - "@babel/preset-react": "^7.0.0", + "@ava/babel": "^1.0.1", + "@babel/preset-react": "^7.10.1", + "@sindresorhus/tsconfig": "^0.7.0", "@types/react": "^16.8.8", "@vdemedes/prettier-config": "^1.0.0", - "ava": "^1.3.1", - "babel-eslint": "^10.0.1", - "eslint-config-xo-react": "^0.17.0", - "eslint-plugin-react": "^7.11.1", + "ava": "^3.9.0", + "delay": "^4.3.0", + "eslint-config-xo-react": "^0.23.0", + "eslint-plugin-react": "^7.20.0", + "eslint-plugin-react-hooks": "^4.0.4", "husky": "^4.2.5", - "ink": "^2.0.0", - "ink-testing-library": "^1.0.0", + "ink": "^3.0.0-3", + "ink-testing-library": "^2.0.0", "prettier": "^2.0.5", "pretty-quick": "^2.0.1", "react": "^16.5.2", "sinon": "^7.2.7", - "typescript": "^3.3.3333", - "xo": "^0.24.0" + "typescript": "^3.9.5", + "xo": "^0.32.0" }, "peerDependencies": { - "ink": "^2.0.0", + "ink": "^3.0.0-3", "react": "^16.5.2" }, - "babel": { - "plugins": [ - "@babel/plugin-proposal-class-properties" - ], - "presets": [ - "@ava/stage-4", - "@babel/preset-react" - ] + "ava": { + "babel": { + "testOptions": { + "presets": [ + "@babel/preset-react" + ] + } + } }, "xo": { - "parser": "babel-eslint", "extends": [ "xo-react" ], + "plugins": [ + "react" + ], "prettier": true, "rules": { - "react/no-unused-prop-types": 1, + "react/no-unused-prop-types": 0, + "react/prop-types": 0, "unicorn/no-hex-escape": 0 } }, diff --git a/readme.md b/readme.md index 31b6f1d..226b506 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,8 @@ > Text input component for [Ink](https://github.com/vadimdemedes/ink). +Looking for a version compatible with Ink 2.x? Check out [previous release](https://github.com/vadimdemedes/ink-text-input/tree/v3.3.0). + ## Install ``` @@ -11,34 +13,23 @@ $ npm install ink-text-input ## Usage ```jsx -import React from 'react'; -import { render, Box } from 'ink'; +import React, { useState } from 'react'; +import { render, Box, Text } from 'ink'; import TextInput from 'ink-text-input'; -class SearchQuery extends React.Component { - constructor() { - super(); - - this.state = { - query: '' - }; - - this.handleChange = this.handleChange.bind(this); - } +const SearchQuery = () => { + const [query, setQuery] = useState(''); - render() { - return ( - - Enter your query: - + return ( + + + Enter your query: - ); - } - handleChange(query) { - this.setState({ query }); - } -} + + + ); +}; render(); ``` @@ -61,14 +52,14 @@ Text to display when `value` is empty. ### showCursor -Type: `boolean`
+Type: `boolean`\ Default: `true` Whether to show cursor and allow navigation inside text input with arrow keys. ### highlightPastedText -Type: `boolean`
+Type: `boolean`\ Default: `false` Highlight pasted text. @@ -102,7 +93,7 @@ This component also exposes an [uncontrolled](https://reactjs.org/docs/uncontrol ```jsx import React from 'react'; -import { render, Box } from 'ink'; +import { render, Box, Text } from 'ink'; import { UncontrolledTextInput } from 'ink-text-input'; const SearchQuery = () => { @@ -112,7 +103,10 @@ const SearchQuery = () => { return ( - Enter your query: + + Enter your query: + + ); @@ -120,7 +114,3 @@ const SearchQuery = () => { render(); ``` - -## License - -MIT © [Vadim Demedes](https://github.com/vadimdemedes) diff --git a/source/index.tsx b/source/index.tsx new file mode 100644 index 0000000..67fe888 --- /dev/null +++ b/source/index.tsx @@ -0,0 +1,191 @@ +import * as React from 'react'; +import { useState } from 'react'; +import type { FC } from 'react'; +import { Text, useInput } from 'ink'; +import chalk = require('chalk'); +import type { Except } from 'type-fest'; + +interface Props { + /** + * Text to display when `value` is empty. + */ + placeholder?: string; + + /** + * Listen to user's input. Useful in case there are multiple input components + * at the same time and input must be "routed" to a specific component. + */ + focus?: boolean; + + /** + * Replace all chars and mask the value. Useful for password inputs. + */ + mask?: string; + + /** + * Whether to show cursor and allow navigation inside text input with arrow keys. + */ + showCursor?: boolean; + + /** + * Highlight pasted text + */ + highlightPastedText?: boolean; + + /** + * Value to display in a text input. + */ + value: string; + + /** + * Function to call when value updates. + */ + onChange: (value: string) => void; + + /** + * Function to call when `Enter` is pressed, where first argument is a value of the input. + */ + onSubmit?: (value: string) => void; +} + +const TextInput: FC = ({ + value: originalValue, + placeholder = '', + focus = true, + mask, + highlightPastedText = false, + showCursor = true, + onChange, + onSubmit +}) => { + const [{ cursorOffset, cursorWidth }, setState] = useState({ + cursorOffset: (originalValue || '').length, + cursorWidth: 0 + }); + + const cursorActualWidth = highlightPastedText ? cursorWidth : 0; + + const value = mask ? mask.repeat(originalValue.length) : originalValue; + let renderedValue = value; + let renderedPlaceholder; + + // Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes + if (showCursor && focus) { + renderedPlaceholder = + placeholder.length > 0 + ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) + : chalk.inverse(' '); + + renderedValue = value.length > 0 ? '' : chalk.inverse(' '); + + let i = 0; + + for (const char of value) { + if (i >= cursorOffset - cursorActualWidth && i <= cursorOffset) { + renderedValue += chalk.inverse(char); + } else { + renderedValue += char; + } + + i++; + } + + if (value.length > 0 && cursorOffset === value.length) { + renderedValue += chalk.inverse(' '); + } + } + + useInput( + (input, key) => { + if ( + key.upArrow || + key.downArrow || + (key.ctrl && input === 'c') || + key.tab || + (key.shift && key.tab) + ) { + return; + } + + if (key.return) { + if (onSubmit) { + onSubmit(originalValue); + } + + return; + } + + let nextCursorOffset = cursorOffset; + let nextValue = originalValue; + let nextCursorWidth = 0; + + if (key.leftArrow) { + if (showCursor) { + nextCursorOffset--; + } + } else if (key.rightArrow) { + if (showCursor) { + nextCursorOffset++; + } + } else if (key.backspace || key.delete) { + if (cursorOffset > 0) { + nextValue = + originalValue.slice(0, cursorOffset - 1) + + originalValue.slice(cursorOffset, originalValue.length); + + nextCursorOffset--; + } + } else { + nextValue = + originalValue.slice(0, cursorOffset) + + input + + originalValue.slice(cursorOffset, originalValue.length); + + nextCursorOffset += input.length; + + if (input.length > 1) { + nextCursorWidth = input.length; + } + } + + if (cursorOffset < 0) { + nextCursorOffset = 0; + } + + if (cursorOffset > originalValue.length) { + nextCursorOffset = originalValue.length; + } + + setState({ + cursorOffset: nextCursorOffset, + cursorWidth: nextCursorWidth + }); + + if (nextValue !== originalValue) { + onChange(nextValue); + } + }, + { isActive: focus } + ); + + return ( + + {placeholder + ? value.length > 0 + ? renderedValue + : renderedPlaceholder + : renderedValue} + + ); +}; + +export default TextInput; + +export const UncontrolledTextInput: FC> = props => { + const [value, setValue] = useState(''); + + return ; +}; diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 5c6003c..0000000 --- a/src/index.js +++ /dev/null @@ -1,232 +0,0 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { Color, StdinContext } from 'ink'; -import chalk from 'chalk'; - -const ARROW_UP = '\u001B[A'; -const ARROW_DOWN = '\u001B[B'; -const ARROW_LEFT = '\u001B[D'; -const ARROW_RIGHT = '\u001B[C'; -const ENTER = '\r'; -const CTRL_C = '\x03'; -const BACKSPACE = '\x08'; -const DELETE = '\u007F'; -const TAB = '\t'; -const SHIFT_TAB = '\u001B[Z'; - -class TextInput extends PureComponent { - static propTypes = { - value: PropTypes.string.isRequired, - placeholder: PropTypes.string, - focus: PropTypes.bool, - mask: PropTypes.string, - highlightPastedText: PropTypes.bool, - showCursor: PropTypes.bool, - stdin: PropTypes.object.isRequired, - setRawMode: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onSubmit: PropTypes.func - }; - - static defaultProps = { - placeholder: '', - showCursor: true, - focus: true, - mask: undefined, - highlightPastedText: false, - onSubmit: undefined - }; - - state = { - cursorOffset: (this.props.value || '').length, - cursorWidth: 0 - }; - - isMounted = false; - - render() { - const { - value: originalValue, - placeholder, - showCursor, - focus, - mask, - highlightPastedText - } = this.props; - - const { cursorOffset, cursorWidth } = this.state; - const value = mask ? mask.repeat(originalValue.length) : originalValue; - const hasValue = value.length > 0; - let renderedValue = value; - let renderedPlaceholder; - const cursorActualWidth = highlightPastedText ? cursorWidth : 0; - - // Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes - if (showCursor && focus) { - renderedPlaceholder = - placeholder.length > 0 - ? chalk.inverse(placeholder[0]) + placeholder.slice(1) - : chalk.inverse(' '); - - renderedValue = value.length > 0 ? '' : chalk.inverse(' '); - - let i = 0; - for (const char of value) { - if (i >= cursorOffset - cursorActualWidth && i <= cursorOffset) { - renderedValue += chalk.inverse(char); - } else { - renderedValue += char; - } - - i++; - } - - if (value.length > 0 && cursorOffset === value.length) { - renderedValue += chalk.inverse(' '); - } - } - - return ( - - {placeholder - ? hasValue - ? renderedValue - : renderedPlaceholder - : renderedValue} - - ); - } - - componentDidMount() { - const { stdin, setRawMode } = this.props; - - this.isMounted = true; - setRawMode(true); - stdin.on('data', this.handleInput); - } - - componentWillUnmount() { - const { stdin, setRawMode } = this.props; - - this.isMounted = false; - stdin.removeListener('data', this.handleInput); - setRawMode(false); - } - - handleInput = data => { - const { - value: originalValue, - focus, - showCursor, - onChange, - onSubmit - } = this.props; - - const { cursorOffset: originalCursorOffset } = this.state; - - if (focus === false || this.isMounted === false) { - return; - } - - const s = String(data); - - if ( - s === ARROW_UP || - s === ARROW_DOWN || - s === CTRL_C || - s === TAB || - s === SHIFT_TAB - ) { - return; - } - - if (s === ENTER) { - if (onSubmit) { - onSubmit(originalValue); - } - - return; - } - - let cursorOffset = originalCursorOffset; - let value = originalValue; - let cursorWidth = 0; - - if (s === ARROW_LEFT) { - if (showCursor) { - cursorOffset--; - } - } else if (s === ARROW_RIGHT) { - if (showCursor) { - cursorOffset++; - } - } else if (s === BACKSPACE || s === DELETE) { - if (cursorOffset > 0) { - value = - value.slice(0, cursorOffset - 1) + - value.slice(cursorOffset, value.length); - - cursorOffset--; - } - } else { - value = - value.slice(0, cursorOffset) + - s + - value.slice(cursorOffset, value.length); - - cursorOffset += s.length; - - if (s.length > 1) { - cursorWidth = s.length; - } - } - - if (cursorOffset < 0) { - cursorOffset = 0; - } - - if (cursorOffset > value.length) { - cursorOffset = value.length; - } - - this.setState({ cursorOffset, cursorWidth }); - - if (value !== originalValue) { - onChange(value); - } - }; -} - -export default class TextInputWithStdin extends PureComponent { - render() { - return ( - - {({ stdin, setRawMode }) => ( - - )} - - ); - } -} - -export class UncontrolledTextInput extends PureComponent { - state = { - value: '' - }; - - setValue(value) { - this.setState({ value }); - } - - setValue = this.setValue.bind(this); - - render() { - return ( - - ); - } -} diff --git a/test.js b/test.js index c0cc5dc..0b6a365 100644 --- a/test.js +++ b/test.js @@ -3,6 +3,7 @@ import test from 'ava'; import chalk from 'chalk'; import { render } from 'ink-testing-library'; import sinon from 'sinon'; +import delay from 'delay'; import TextInput, { UncontrolledTextInput } from '.'; const noop = () => {}; @@ -38,7 +39,7 @@ test('display placeholder', t => { ); - t.is(lastFrame(), chalk.dim(`${chalk.inverse('P')}laceholder`)); + t.is(lastFrame(), chalk.inverse('P') + chalk.grey('laceholder')); }); test('display value with mask', t => { @@ -49,7 +50,7 @@ test('display value with mask', t => { t.is(lastFrame(), `*****${chalk.inverse(' ')}`); }); -test('accept input (controlled)', t => { +test('accept input (controlled)', async t => { const StatefulTextInput = () => { const [value, setValue] = useState(''); @@ -59,19 +60,23 @@ test('accept input (controlled)', t => { const { stdin, lastFrame } = render(); t.is(lastFrame(), CURSOR); + await delay(100); stdin.write('X'); + await delay(100); t.is(lastFrame(), `X${CURSOR}`); }); -test('accept input (uncontrolled)', t => { +test('accept input (uncontrolled)', async t => { const { stdin, lastFrame } = render(); t.is(lastFrame(), CURSOR); + await delay(100); stdin.write('X'); + await delay(100); t.is(lastFrame(), `X${CURSOR}`); }); -test('ignore input when not in focus', t => { +test('ignore input when not in focus', async t => { const StatefulTextInput = () => { const [value, setValue] = useState(''); @@ -81,11 +86,13 @@ test('ignore input when not in focus', t => { const { stdin, frames, lastFrame } = render(); t.is(lastFrame(), ''); + await delay(100); stdin.write('X'); + await delay(100); t.is(frames.length, 1); }); -test('ignore input for Tab and Shift+Tab keys', t => { +test('ignore input for Tab and Shift+Tab keys', async t => { const Test = () => { const [value, setValue] = useState(''); @@ -94,13 +101,16 @@ test('ignore input for Tab and Shift+Tab keys', t => { const { stdin, lastFrame } = render(); + await delay(100); stdin.write('\t'); + await delay(100); t.is(lastFrame(), CURSOR); stdin.write('\u001B[Z'); + await delay(100); t.is(lastFrame(), CURSOR); }); -test('onSubmit', t => { +test('onSubmit', async t => { const onSubmit = sinon.spy(); const StatefulTextInput = () => { @@ -113,15 +123,18 @@ test('onSubmit', t => { t.is(lastFrame(), CURSOR); + await delay(100); stdin.write('X'); + await delay(100); stdin.write(ENTER); + await delay(100); t.is(lastFrame(), `X${CURSOR}`); t.true(onSubmit.calledWith('X')); t.true(onSubmit.calledOnce); }); -test('paste and move cursor', t => { +test('paste and move cursor', async t => { const StatefulTextInput = () => { const [value, setValue] = useState(''); @@ -131,28 +144,34 @@ test('paste and move cursor', t => { const { stdin, lastFrame } = render(); // Need this to invert each char separately - const inverse = str => { - return str + const inverse = string => { + return string .split('') .map(c => chalk.inverse(c)) .join(''); }; + await delay(100); stdin.write('A'); + await delay(100); stdin.write('B'); + await delay(100); t.is(lastFrame(), `AB${CURSOR}`); stdin.write(ARROW_LEFT); + await delay(100); t.is(lastFrame(), `A${chalk.inverse('B')}`); stdin.write('Hello World'); + await delay(100); t.is(lastFrame(), `A${inverse('Hello WorldB')}`); stdin.write(ARROW_RIGHT); + await delay(100); t.is(lastFrame(), `AHello WorldB${CURSOR}`); }); -test('delete at the beginning of text', t => { +test('delete at the beginning of text', async t => { const Test = () => { const [value, setValue] = useState(''); @@ -161,15 +180,24 @@ test('delete at the beginning of text', t => { const { stdin, lastFrame } = render(); + await delay(100); stdin.write('T'); + await delay(100); stdin.write('e'); + await delay(100); stdin.write('s'); + await delay(100); stdin.write('t'); stdin.write(ARROW_LEFT); + await delay(100); stdin.write(ARROW_LEFT); + await delay(100); stdin.write(ARROW_LEFT); + await delay(100); stdin.write(ARROW_LEFT); + await delay(100); stdin.write(DELETE); + await delay(100); t.is(lastFrame(), `${chalk.inverse('T')}est`); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e950093 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@sindresorhus/tsconfig", + "compilerOptions": { + "outDir": "build", + "target": "es2018", + "lib": ["es2018"] + } +}