├── .editorconfig
├── .gitattributes
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .npmrc
├── docs
├── alert.md
├── badge.md
├── confirm-input.md
├── email-input.md
├── multi-select.md
├── ordered-list.md
├── password-input.md
├── progress-bar.md
├── select.md
├── spinner.md
├── status-message.md
├── text-input.md
└── unordered-list.md
├── examples
├── alert.tsx
├── autocomplete.tsx
├── badge.tsx
├── confirm-input.tsx
├── email-input.tsx
├── multi-select.tsx
├── ordered-list.tsx
├── password-input.tsx
├── progress-bar.tsx
├── select.tsx
├── spinner.tsx
├── status-message.tsx
├── text-input.tsx
├── theming
│ ├── custom-component.tsx
│ ├── spinner.tsx
│ └── unordered-list.tsx
└── unordered-list.tsx
├── license
├── media
├── alert.png
├── badge.png
├── confirm-input.png
├── email-input-autocomplete.gif
├── email-input-basic.gif
├── email-input-default-value.gif
├── email-input-disabled.gif
├── email-input-submit.gif
├── email-input.gif
├── multi-select-basic.gif
├── multi-select-default-value.gif
├── multi-select-disabled.gif
├── multi-select-submit.gif
├── multi-select.gif
├── ordered-list.png
├── password-input-basic.gif
├── password-input-disabled.gif
├── password-input-submit.gif
├── password-input.gif
├── progress-bar.gif
├── select-basic.gif
├── select-default-value.gif
├── select-disabled.gif
├── select.gif
├── source
│ ├── confirm-input
│ │ ├── basic.tsx
│ │ └── readme.tsx
│ ├── email-input
│ │ ├── autocomplete.tsx
│ │ ├── basic.tsx
│ │ ├── default-value.tsx
│ │ ├── disabled.tsx
│ │ ├── readme.tsx
│ │ └── submit.tsx
│ ├── helpers
│ │ ├── escapes.ts
│ │ ├── input.ts
│ │ └── press.ts
│ ├── multi-select
│ │ ├── basic.tsx
│ │ ├── default-value.tsx
│ │ ├── disabled.tsx
│ │ ├── readme.tsx
│ │ └── submit.tsx
│ ├── password-input
│ │ ├── basic.tsx
│ │ ├── disabled.tsx
│ │ ├── readme.tsx
│ │ └── submit.tsx
│ ├── select
│ │ ├── basic.tsx
│ │ ├── default-value.tsx
│ │ ├── disabled.tsx
│ │ └── readme.tsx
│ └── text-input
│ │ ├── autocomplete.tsx
│ │ ├── basic.tsx
│ │ ├── default-value.tsx
│ │ ├── disabled.tsx
│ │ ├── readme.tsx
│ │ └── submit.tsx
├── spinner-theme.gif
├── spinner.gif
├── status-message.png
├── text-input-autocomplete.gif
├── text-input-basic.gif
├── text-input-default-value.gif
├── text-input-disabled.gif
├── text-input-submit.gif
├── text-input.gif
├── unordered-list-theme.png
└── unordered-list.png
├── package.json
├── readme.md
├── source
├── components
│ ├── alert
│ │ ├── alert.tsx
│ │ ├── index.ts
│ │ └── theme.ts
│ ├── badge
│ │ ├── badge.tsx
│ │ ├── index.ts
│ │ └── theme.ts
│ ├── confirm-input
│ │ ├── confirm-input.tsx
│ │ ├── index.ts
│ │ └── theme.ts
│ ├── email-input
│ │ ├── email-input.tsx
│ │ ├── index.ts
│ │ ├── theme.ts
│ │ ├── use-email-input-state.ts
│ │ └── use-email-input.ts
│ ├── multi-select
│ │ ├── index.ts
│ │ ├── multi-select-option.tsx
│ │ ├── multi-select.tsx
│ │ ├── theme.ts
│ │ ├── use-multi-select-state.ts
│ │ └── use-multi-select.ts
│ ├── ordered-list
│ │ ├── index.ts
│ │ ├── ordered-list-context.ts
│ │ ├── ordered-list-item-context.ts
│ │ ├── ordered-list-item.tsx
│ │ ├── ordered-list.tsx
│ │ └── theme.ts
│ ├── password-input
│ │ ├── index.ts
│ │ ├── password-input.tsx
│ │ ├── theme.ts
│ │ ├── use-password-input-state.ts
│ │ └── use-password-input.ts
│ ├── progress-bar
│ │ ├── index.ts
│ │ ├── progress-bar.tsx
│ │ └── theme.ts
│ ├── select
│ │ ├── index.ts
│ │ ├── select-option.tsx
│ │ ├── select.tsx
│ │ ├── theme.ts
│ │ ├── use-select-state.ts
│ │ └── use-select.ts
│ ├── spinner
│ │ ├── index.ts
│ │ ├── spinner.tsx
│ │ ├── theme.ts
│ │ └── use-spinner.ts
│ ├── status-message
│ │ ├── index.ts
│ │ ├── status-message.tsx
│ │ ├── theme.ts
│ │ └── types.ts
│ ├── text-input
│ │ ├── index.ts
│ │ ├── text-input.tsx
│ │ ├── theme.ts
│ │ ├── use-text-input-state.ts
│ │ └── use-text-input.ts
│ └── unordered-list
│ │ ├── constants.ts
│ │ ├── index.ts
│ │ ├── theme.ts
│ │ ├── unordered-list-context.ts
│ │ ├── unordered-list-item-context.ts
│ │ ├── unordered-list-item.tsx
│ │ └── unordered-list.tsx
├── index.ts
├── lib
│ └── option-map.ts
├── theme.tsx
└── types.ts
├── test
├── alert.tsx
├── badge.tsx
├── confirm-input.tsx
├── email-input.tsx
├── multi-select.tsx
├── ordered-list.tsx
├── password-input.tsx
├── progress-bar.tsx
├── select.tsx
├── spinner.tsx
├── status-message.tsx
├── text-input.tsx
└── unordered-list.tsx
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = tab
5 | end_of_line = lf
6 | charset = utf-8
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
10 | [*.yml]
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 | on: [push, pull_request]
3 |
4 | jobs:
5 | test:
6 | name: Test
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - uses: actions/setup-node@v4
11 | with:
12 | node-version: 18
13 | - run: npm install
14 | - run: npm test
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | .tsimp
4 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/docs/alert.md:
--------------------------------------------------------------------------------
1 | # Alert
2 |
3 | > `Alert` is used to focus user's attention to important messages.
4 |
5 | [Theme](../source/components/alert/theme.ts) | [Example code](../examples/alert.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React from 'react';
11 | import {render, Box} from 'ink';
12 | import {Alert} from '@inkjs/ui';
13 |
14 | function Example() {
15 | return (
16 |
17 | A new version of this CLI is available
18 |
19 | Your license is expired
20 |
21 |
22 | Current version of this CLI has been deprecated
23 |
24 |
25 | API won't be available tomorrow night
26 |
27 | );
28 | }
29 |
30 | render();
31 | ```
32 |
33 |
34 |
35 | ## Props
36 |
37 | ### children
38 |
39 | Type: `ReactNode`
40 |
41 | Message.
42 |
43 | ### variant
44 |
45 | Type: `'info' | 'success' | 'error' | 'warning'`
46 |
47 | Variant, which determines the color of the alert.
48 |
49 | ### title
50 |
51 | Type: `string`
52 |
53 | Title to show above the message.
54 |
--------------------------------------------------------------------------------
/docs/badge.md:
--------------------------------------------------------------------------------
1 | # Badge
2 |
3 | > `Badge` can be used to indicate a status of a certain item, usually positioned nearby the element it's related to.
4 |
5 | [Theme](../source/components/badge/theme.ts) | [Example code](../examples/badge.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React from 'react';
11 | import {render, Box} from 'ink';
12 | import {Badge} from '@inkjs/ui';
13 |
14 | function Example() {
15 | return (
16 |
17 | Pass
18 | Fail
19 | Warn
20 | Todo
21 |
22 | );
23 | }
24 |
25 | render();
26 | ```
27 |
28 |
29 |
30 | ## Props
31 |
32 | ### children
33 |
34 | Type: `ReactNode`
35 |
36 | Label.
37 |
38 | ### color
39 |
40 | Type: [`TextProps['color']`](https://github.com/vadimdemedes/ink#color)
41 |
42 | Color.
43 |
--------------------------------------------------------------------------------
/docs/confirm-input.md:
--------------------------------------------------------------------------------
1 | # Confirm input
2 |
3 | > `ConfirmInput` shows a common "Y/n" input to confirm or cancel an operation your CLI wants to perform.
4 |
5 | [Theme](../source/components/confirm-input/theme.ts) | [Example code](../examples/confirm-input.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React, {useState} from 'react';
11 | import {render, Box, Text} from 'ink';
12 | import {ConfirmInput} from '@inkjs/ui';
13 |
14 | function Example() {
15 | const [choice, setChoice] = useState<'agreed' | 'disagreed' | undefined>();
16 |
17 | return (
18 |
19 | {!choice && (
20 | <>
21 | Do you agree with terms of service?
22 | {
24 | setChoice('agreed');
25 | }}
26 | onCancel={() => {
27 | setChoice('disagreed');
28 | }}
29 | />
30 | >
31 | )}
32 |
33 | {choice === 'agreed' && I know you haven't read them, but ok}
34 | {choice === 'disagreed' && Ok, whatever}
35 |
36 | );
37 | }
38 |
39 | render();
40 | ```
41 |
42 | ## Props
43 |
44 | ### isDisabled
45 |
46 | Type: `boolean`\
47 | Default: `false`
48 |
49 | When disabled, user input is ignored.
50 |
51 | ### defaultChoice
52 |
53 | Type: `'confirm' | 'cancel'`\
54 | Default: `'confirm'`
55 |
56 | Default choice.
57 |
58 | ### submitOnEnter
59 |
60 | Type: `boolean`\
61 | Default: `true`
62 |
63 | Confirm or cancel when user presses enter, depending on the `defaultChoice` value.
64 | Can be useful to disable when an explicit confirmation is required, such as pressing Y key.
65 |
66 | ### onConfirm
67 |
68 | Type: `Function`
69 |
70 | Callback to trigger on confirmation.
71 |
72 | ### onCancel
73 |
74 | Type: `Function`
75 |
76 | Callback to trigger on cancellation.
77 |
--------------------------------------------------------------------------------
/docs/email-input.md:
--------------------------------------------------------------------------------
1 | # Email input
2 |
3 | > `EmailInput` is used for entering an email. After "@" character is entered, domain can be autocompleted from the list of most popular email providers.
4 |
5 | [Theme](../source/components/email-input/theme.ts) | [Example code](../examples/email-input.tsx)
6 |
7 | ## Usage
8 |
9 | `EmailInput` is an uncontrolled component. You can listen to value changes via `onChange` prop.
10 |
11 | ```tsx
12 | import React, {useState} from 'react';
13 | import {render, Box, Text} from 'ink';
14 | import {EmailInput} from '@inkjs/ui';
15 |
16 | function Example() {
17 | const [value, setValue] = useState('');
18 |
19 | return (
20 |
21 |
22 | Input value: "{value}"
23 |
24 | );
25 | }
26 |
27 | render();
28 | ```
29 |
30 |
31 |
32 | ### Default value
33 |
34 | Default value can be set via `defaultValue` prop.
35 |
36 | ```tsx
37 | import React, {useState} from 'react';
38 | import {render, Box, Text} from 'ink';
39 | import {EmailInput} from '@inkjs/ui';
40 |
41 | function Example() {
42 | const [value, setValue] = useState('jane@hey.com');
43 |
44 | return (
45 |
46 |
51 |
52 | Input value: "{value}"
53 |
54 | );
55 | }
56 |
57 | render();
58 | ```
59 |
60 |
61 |
62 | ### Autocomplete
63 |
64 | `EmailInput` suggests the domains of popular email providers once "@" character is entered. You can also customize the list of suggested domains by providing an array of strings in `domains` prop.
65 |
66 | When user presses enter, current suggestion will replace the input value.
67 |
68 | ```tsx
69 | import React, {useState} from 'react';
70 | import {render, Box, Text} from 'ink';
71 | import {EmailInput} from '@inkjs/ui';
72 |
73 | function Example() {
74 | const [value, setValue] = useState('');
75 |
76 | return (
77 |
78 |
83 |
84 | Input value: "{value}"
85 |
86 | );
87 | }
88 |
89 | render();
90 | ```
91 |
92 |
93 |
94 | ### Submit on enter
95 |
96 | When you're only looking for the final value when user presses enter, you can use `onSubmit` instead of `onChange` prop.
97 |
98 | ```tsx
99 | import React, {useState} from 'react';
100 | import {render, Box, Text} from 'ink';
101 | import {EmailInput} from '@inkjs/ui';
102 |
103 | function Example() {
104 | const [value, setValue] = useState('');
105 |
106 | return (
107 |
108 |
109 | Input value: "{value}"
110 |
111 | );
112 | }
113 |
114 | render();
115 | ```
116 |
117 |
118 |
119 | ### Disabled
120 |
121 | When there are two or more text inputs, only one should be receiving user input at a time, while others should be disabled via `isDisabled` prop.
122 |
123 | ```tsx
124 | import React, {useState} from 'react';
125 | import {render, Box, Text} from 'ink';
126 | import {EmailInput} from '@inkjs/ui';
127 |
128 | function Example() {
129 | const [activeInput, setActiveInput] = useState('first');
130 | const [first, setFirst] = useState('');
131 | const [second, setSecond] = useState('');
132 |
133 | return (
134 |
135 |
136 | {
141 | setActiveInput('second');
142 | }}
143 | />
144 |
145 | {
150 | setActiveInput('none');
151 | }}
152 | />
153 |
154 |
155 |
156 | First email: "{first}"
157 | Second email: "{second}"
158 |
159 |
160 | );
161 | }
162 |
163 | render();
164 | ```
165 |
166 |
167 |
168 | ## Props
169 |
170 | ### isDisabled
171 |
172 | Type: `boolean`\
173 | Default: `false`
174 |
175 | When disabled, user input is ignored.
176 |
177 | ### placeholder
178 |
179 | Type: `string`
180 |
181 | Text to display when input is empty.
182 |
183 | ### defaultValue
184 |
185 | Type: `string`
186 |
187 | Default input value.
188 |
189 | ### domains
190 |
191 | Type: `string[]`\
192 | Default: `["aol.com", "gmail.com", "yahoo.com", "hotmail.com", "live.com", "outlook.com", "icloud.com", "hey.com"]`
193 |
194 | Domains of email providers to autocomplete.
195 |
196 | ### onChange(value)
197 |
198 | Type: `Function`
199 |
200 | Callback when input value changes.
201 |
202 | #### value
203 |
204 | Type: `string`
205 |
206 | Input value.
207 |
208 | ### onSubmit(value)
209 |
210 | Type: `Function`
211 |
212 | Callback when enter is pressed.
213 |
214 | #### value
215 |
216 | Type: `string`
217 |
218 | Input value.
219 |
--------------------------------------------------------------------------------
/docs/multi-select.md:
--------------------------------------------------------------------------------
1 | # Multi select
2 |
3 | > `MultiSelect` is similar to [`Select`](select.md), except user can choose multiple options.
4 |
5 | [Theme](../source/components/multi-select/theme.ts) | [Example code](../examples/multi-select.tsx)
6 |
7 | ## Usage
8 |
9 | `MultiSelect` is an uncontrolled component. You can listen to value changes via `onChange` prop.
10 |
11 | ```tsx
12 | import React, {useState} from 'react';
13 | import {render, Box, Text} from 'ink';
14 | import {MultiSelect} from '@inkjs/ui';
15 |
16 | function Example() {
17 | const [value, setValue] = useState([]);
18 |
19 | return (
20 |
21 |
54 |
55 | Selected values: {value.join(', ')}
56 |
57 | );
58 | }
59 |
60 | render();
61 | ```
62 |
63 |
64 |
65 | ### Default value
66 |
67 | Default value can be set via `defaultValue` prop.
68 |
69 | ```tsx
70 | import React, {useState} from 'react';
71 | import {render, Box, Text} from 'ink';
72 | import {MultiSelect} from '@inkjs/ui';
73 |
74 | function Example() {
75 | const [value, setValue] = useState(['green']);
76 |
77 | return (
78 |
79 |
113 |
114 | Selected values: {value.join(', ')}
115 |
116 | );
117 | }
118 |
119 | render();
120 | ```
121 |
122 |
123 |
124 | ### Submit on enter
125 |
126 | When you're only looking for the final value when user presses enter, you can use `onSubmit` instead of `onChange` prop.
127 |
128 | ```tsx
129 | import React, {useState} from 'react';
130 | import {render, Box, Text} from 'ink';
131 | import {MultiSelect} from '@inkjs/ui';
132 |
133 | function Example() {
134 | const [value, setValue] = useState([]);
135 |
136 | return (
137 |
138 |
171 |
172 | Selected values: {value.join(', ')}
173 |
174 | );
175 | }
176 |
177 | render();
178 | ```
179 |
180 |
181 |
182 | ### Disabled
183 |
184 | When there are two or more selects, only one should be receiving user input at a time, while others should be disabled via `isDisabled` prop.
185 |
186 | ```tsx
187 | import React, {useState} from 'react';
188 | import {render, Box, Text} from 'ink';
189 | import {MultiSelect} from '@inkjs/ui';
190 |
191 | const options = [
192 | {
193 | label: 'Red',
194 | value: 'red',
195 | },
196 | {
197 | label: 'Green',
198 | value: 'green',
199 | },
200 | {
201 | label: 'Yellow',
202 | value: 'yellow',
203 | },
204 | {
205 | label: 'Blue',
206 | value: 'blue',
207 | },
208 | {
209 | label: 'Magenta',
210 | value: 'magenta',
211 | },
212 | {
213 | label: 'Cyan',
214 | value: 'cyan',
215 | },
216 | {
217 | label: 'White',
218 | value: 'white',
219 | },
220 | ];
221 |
222 | function Example() {
223 | const [activeInput, setActiveInput] = useState('primary');
224 | const [primaryColors, setPrimaryColors] = useState([]);
225 | const [secondaryColors, setSecondaryColors] = useState([]);
226 |
227 | return (
228 |
229 |
230 | {
235 | setActiveInput('secondary');
236 | }}
237 | />
238 |
239 | Primary colors: {primaryColors.join(', ')}
240 |
241 |
242 |
243 | {
248 | setActiveInput('none');
249 | }}
250 | />
251 |
252 | Secondary colors: {secondaryColors.join(', ')}
253 |
254 |
255 | );
256 | }
257 |
258 | render();
259 | ```
260 |
261 |
262 |
263 | ## Props
264 |
265 | ### isDisabled
266 |
267 | Type: `boolean`\
268 | Default: `false`
269 |
270 | When disabled, user input is ignored.
271 |
272 | ### visibleOptionCount
273 |
274 | Type: `number`\
275 | Default: `5`
276 |
277 | Number of visible options.
278 |
279 | ### highlightText
280 |
281 | Type: `string`
282 |
283 | Highlight text in option labels.
284 |
285 | ### options
286 |
287 | Type: `Array<{ label: string; value: string; }>`
288 |
289 | Options.
290 |
291 | ### defaultValue
292 |
293 | Type: `string[]`
294 |
295 | Default value.
296 |
297 | ### onChange(value)
298 |
299 | Type: `Function`
300 |
301 | Callback when selected options change.
302 |
303 | #### value
304 |
305 | Type: `string[]`
306 |
307 | Values of selected options.
308 |
309 | ### onSubmit(value)
310 |
311 | Type: `Function`
312 |
313 | Callback when user presses enter.
314 |
315 | #### value
316 |
317 | Type: `string[]`
318 |
319 | Value of selected options.
320 |
--------------------------------------------------------------------------------
/docs/ordered-list.md:
--------------------------------------------------------------------------------
1 | # Ordered list
2 |
3 | > `OrderedList` is used to show lists of numbered items.
4 |
5 | [Theme](../source/components/ordered-list/theme.ts) | [Example code](../examples/ordered-list.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React from 'react';
11 | import {render, Box, Text} from 'ink';
12 | import {OrderedList} from '@inkjs/ui';
13 |
14 | function Example() {
15 | return (
16 |
17 |
18 | Red
19 |
20 |
21 |
22 | Green
23 |
24 |
25 |
26 | Light
27 |
28 |
29 |
30 | Dark
31 |
32 |
33 |
34 |
35 |
36 | Blue
37 |
38 |
39 | );
40 | }
41 |
42 | render();
43 | ```
44 |
45 |
46 |
47 | ## Props
48 |
49 | ### OrderedList
50 |
51 | #### children
52 |
53 | Type: `ReactNode`
54 |
55 | List items.
56 |
57 | ### OrderedList.Item
58 |
59 | #### children
60 |
61 | Type: `ReactNode`
62 |
63 | List item content.
64 |
--------------------------------------------------------------------------------
/docs/password-input.md:
--------------------------------------------------------------------------------
1 | # Password input
2 |
3 | > `PasswordInput` is used for entering sensitive data, like passwords, API keys and so on. It works the same way as `PasswordInput`, except input value is masked and replaced with asterisks ("\*").
4 |
5 | [Theme](../source/components/password-input/theme.ts) | [Example code](../examples/password-input.tsx)
6 |
7 | ## Usage
8 |
9 | `PasswordInput` is an uncontrolled component. You can listen to value changes via `onChange` prop.
10 |
11 | ```tsx
12 | import React, {useState} from 'react';
13 | import {render, Box, Text} from 'ink';
14 | import {PasswordInput} from '@inkjs/ui';
15 | import input from '../helpers/input.js';
16 |
17 | function Example() {
18 | const [value, setValue] = useState('');
19 |
20 | return (
21 |
22 |
23 | Input value: "{value}"
24 |
25 | );
26 | }
27 |
28 | render();
29 | ```
30 |
31 |
32 |
33 | ### Submit on enter
34 |
35 | When you're only looking for the final value when user presses enter, you can use `onSubmit` instead of `onChange` prop.
36 |
37 | ```tsx
38 | import React, {useState} from 'react';
39 | import {render, Box, Text} from 'ink';
40 | import {PasswordInput} from '@inkjs/ui';
41 |
42 | function Example() {
43 | const [value, setValue] = useState('');
44 |
45 | return (
46 |
47 |
48 | Input value: "{value}"
49 |
50 | );
51 | }
52 |
53 | render();
54 | ```
55 |
56 |
57 |
58 | ### Disabled
59 |
60 | When there are two or more password inputs, only one should be receiving user input at a time, while others should be disabled via `isDisabled` prop.
61 |
62 | ```tsx
63 | import React, {useState} from 'react';
64 | import {render, Box, Text} from 'ink';
65 | import {PasswordInput} from '@inkjs/ui';
66 | import input from '../helpers/input.js';
67 | import press from '../helpers/press.js';
68 |
69 | function Example() {
70 | const [activeInput, setActiveInput] = useState('password');
71 | const [password, setPassword] = useState('');
72 | const [passwordConfirmation, setPasswordConfirmation] = useState('');
73 |
74 | return (
75 |
76 |
77 | {
82 | setActiveInput('passwordConfirmation');
83 | }}
84 | />
85 |
86 | {
91 | setActiveInput('none');
92 | }}
93 | />
94 |
95 |
96 |
97 | Password: "{password}"
98 | Password confirmation: "{passwordConfirmation}"
99 |
100 |
101 | );
102 | }
103 |
104 | render();
105 | ```
106 |
107 |
108 |
109 | ## Props
110 |
111 | ### isDisabled
112 |
113 | Type: `boolean`\
114 | Default: `false`
115 |
116 | When disabled, user input is ignored.
117 |
118 | ### placeholder
119 |
120 | Type: `string`
121 |
122 | Text to display when input is empty.
123 |
124 | ### onChange(value)
125 |
126 | Type: `Function`
127 |
128 | Callback when input value changes.
129 |
130 | #### value
131 |
132 | Type: `string`
133 |
134 | Input value.
135 |
136 | ### onSubmit(value)
137 |
138 | Type: `Function`
139 |
140 | Callback when enter is pressed.
141 |
142 | #### value
143 |
144 | Type: `string`
145 |
146 | Input value.
147 |
--------------------------------------------------------------------------------
/docs/progress-bar.md:
--------------------------------------------------------------------------------
1 | # Progress bar
2 |
3 | > `ProgressBar` is an extended version of [`Spinner`](spinner.md), where it's possible to calculate a progress percentage.
4 |
5 | [Theme](../source/components/progress-bar/theme.ts) | [Example code](../examples/progress-bar.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React, {useEffect, useState} from 'react';
11 | import {render, Box} from 'ink';
12 | import {ProgressBar} from '@inkjs/ui';
13 |
14 | function Example() {
15 | const [progress, setProgress] = useState(0);
16 |
17 | useEffect(() => {
18 | if (progress === 100) {
19 | return;
20 | }
21 |
22 | const timer = setTimeout(() => {
23 | setProgress(progress + 1);
24 | }, 50);
25 |
26 | return () => {
27 | clearInterval(timer);
28 | };
29 | }, [progress]);
30 |
31 | return (
32 |
33 |
34 |
35 | );
36 | }
37 |
38 | render();
39 | ```
40 |
41 |
42 |
43 | ## Props
44 |
45 | ### value
46 |
47 | Type: `number` \
48 | Minimum: `0` \
49 | Maximum: `100` \
50 | Default: `0`
51 |
52 | Progress.
53 |
--------------------------------------------------------------------------------
/docs/select.md:
--------------------------------------------------------------------------------
1 | # Select
2 |
3 | > `Select` shows a scrollable list of options for a user to choose from.
4 |
5 | [Theme](../source/components/select/theme.ts) | [Example code](../examples/select.tsx)
6 |
7 | ## Usage
8 |
9 | `Select` is an uncontrolled component. You can listen to value changes via `onChange` prop.
10 |
11 | ```tsx
12 | import React, {useState} from 'react';
13 | import {render, Box, Text} from 'ink';
14 | import {Select} from '@inkjs/ui';
15 |
16 | function Example() {
17 | const [value, setValue] = useState();
18 |
19 | return (
20 |
21 |
54 |
55 | Selected value: {value}
56 |
57 | );
58 | }
59 |
60 | render();
61 | ```
62 |
63 |
64 |
65 | ### Default value
66 |
67 | Default value can be set via `defaultValue` prop.
68 |
69 | ```tsx
70 | import React, {useState} from 'react';
71 | import {render, Box, Text} from 'ink';
72 | import {Select} from '@inkjs/ui';
73 |
74 | function Example() {
75 | const [value, setValue] = useState('green');
76 |
77 | return (
78 |
79 |
113 |
114 | Selected value: {value}
115 |
116 | );
117 | }
118 |
119 | render();
120 | ```
121 |
122 |
123 |
124 | ### Disabled
125 |
126 | When there are two or more selects, only one should be receiving user input at a time, while others should be disabled via `isDisabled` prop.
127 |
128 | ```tsx
129 | import React, {useState} from 'react';
130 | import {render, Box, Text} from 'ink';
131 | import {Select} from '@inkjs/ui';
132 |
133 | const options = [
134 | {
135 | label: 'Red',
136 | value: 'red',
137 | },
138 | {
139 | label: 'Green',
140 | value: 'green',
141 | },
142 | {
143 | label: 'Yellow',
144 | value: 'yellow',
145 | },
146 | {
147 | label: 'Blue',
148 | value: 'blue',
149 | },
150 | {
151 | label: 'Magenta',
152 | value: 'magenta',
153 | },
154 | {
155 | label: 'Cyan',
156 | value: 'cyan',
157 | },
158 | {
159 | label: 'White',
160 | value: 'white',
161 | },
162 | ];
163 |
164 | function Example() {
165 | const [activeInput, setActiveInput] = useState('primary');
166 | const [primaryColor, setPrimaryColor] = useState();
167 | const [secondaryColor, setSecondaryColor] = useState();
168 |
169 | return (
170 |
171 |
172 |
183 |
184 |
185 |
196 |
197 | );
198 | }
199 |
200 | render();
201 | ```
202 |
203 |
204 |
205 | ## Props
206 |
207 | ### isDisabled
208 |
209 | Type: `boolean`\
210 | Default: `false`
211 |
212 | When disabled, user input is ignored.
213 |
214 | ### visibleOptionCount
215 |
216 | Type: `number`\
217 | Default: `5`
218 |
219 | Number of visible options.
220 |
221 | ### highlightText
222 |
223 | Type: `string`
224 |
225 | Highlight text in option labels.
226 |
227 | ### options
228 |
229 | Type: `Array<{ label: string; value: string; }>`
230 |
231 | Options.
232 |
233 | ### defaultValue
234 |
235 | Type: `string`
236 |
237 | Default value.
238 |
239 | ### onChange(value)
240 |
241 | Type: `Function`
242 |
243 | Callback when selected option changes.
244 |
245 | #### value
246 |
247 | Type: `string`
248 |
249 | Value of the selected option.
250 |
--------------------------------------------------------------------------------
/docs/spinner.md:
--------------------------------------------------------------------------------
1 | # Spinner
2 |
3 | > `Spinner` indicates that something is being processed and CLI is waiting for it to complete.
4 |
5 | [Theme](../source/components/spinner/theme.ts) | [Example code](../examples/spinner.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React from 'react';
11 | import {render, Box} from 'ink';
12 | import {Spinner} from '@inkjs/ui';
13 |
14 | function Example() {
15 | return ;
16 | }
17 |
18 | render();
19 | ```
20 |
21 |
22 |
23 | ## Props
24 |
25 | ### label
26 |
27 | Type: `string`
28 |
29 | Label to show next to the spinner.
30 |
--------------------------------------------------------------------------------
/docs/status-message.md:
--------------------------------------------------------------------------------
1 | # Status message
2 |
3 | > `StatusMessage` can also be used to indicate a status, but when longer explanation of such status is required.
4 |
5 | [Theme](../source/components/status-message/theme.ts) | [Example code](../examples/status-message.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React from 'react';
11 | import {render, Box} from 'ink';
12 | import {StatusMessage} from '@inkjs/ui';
13 |
14 | function Example() {
15 | return (
16 |
17 | Success
18 | Error
19 | Warning
20 | Info
21 |
22 | );
23 | }
24 |
25 | render();
26 | ```
27 |
28 |
29 |
30 | ## Props
31 |
32 | ### children
33 |
34 | Type: `ReactNode`
35 |
36 | Message.
37 |
38 | ### variant
39 |
40 | Type: `'info' | 'success' | 'error' | 'warning'`
41 |
42 | Variant, which determines the color used in the status message.
43 |
--------------------------------------------------------------------------------
/docs/text-input.md:
--------------------------------------------------------------------------------
1 | # Text input
2 |
3 | > `TextInput` is used for entering any single-line input with an optional autocomplete.
4 |
5 | [Theme](../source/components/text-input/theme.ts) | [Example code](../examples/text-input.tsx)
6 |
7 | ## Usage
8 |
9 | `TextInput` is an uncontrolled component. You can listen to value changes via `onChange` prop.
10 |
11 | ```tsx
12 | import React, {useState} from 'react';
13 | import {render, Box, Text} from 'ink';
14 | import {TextInput} from '@inkjs/ui';
15 |
16 | function Example() {
17 | const [value, setValue] = useState('');
18 |
19 | return (
20 |
21 |
22 | Input value: "{value}"
23 |
24 | );
25 | }
26 |
27 | render();
28 | ```
29 |
30 |
31 |
32 | ### Default value
33 |
34 | Default value can be set via `defaultValue` prop.
35 |
36 | ```tsx
37 | import React, {useState} from 'react';
38 | import {render, Box, Text} from 'ink';
39 | import {TextInput} from '@inkjs/ui';
40 |
41 | function Example() {
42 | const [value, setValue] = useState('Jane');
43 |
44 | return (
45 |
46 |
51 |
52 | Input value: "{value}"
53 |
54 | );
55 | }
56 |
57 | render();
58 | ```
59 |
60 |
61 |
62 | ### Autocomplete
63 |
64 | `TextInput` can suggest values to autocomplete input with via `suggestions` prop, which contains an array of strings.
65 | As user is typing, `TextInput` finds the first item in `suggestions` array that begins with an input string that user has already entered. Matching of items in `suggestions` is case-sensitive.
66 |
67 | When user presses enter, current suggestion will replace the input value.
68 |
69 | ```tsx
70 | import React, {useState} from 'react';
71 | import {render, Box, Text} from 'ink';
72 | import {TextInput} from '@inkjs/ui';
73 |
74 | function Example() {
75 | const [value, setValue] = useState('');
76 |
77 | return (
78 |
79 |
84 |
85 | Input value: "{value}"
86 |
87 | );
88 | }
89 |
90 | render();
91 | ```
92 |
93 |
94 |
95 | ### Submit on enter
96 |
97 | When you're only looking for the final value when user presses enter, you can use `onSubmit` instead of `onChange` prop.
98 |
99 | ```tsx
100 | import React, {useState} from 'react';
101 | import {render, Box, Text} from 'ink';
102 | import {TextInput} from '@inkjs/ui';
103 |
104 | function Example() {
105 | const [value, setValue] = useState('');
106 |
107 | return (
108 |
109 |
110 | Input value: "{value}"
111 |
112 | );
113 | }
114 |
115 | render();
116 | ```
117 |
118 |
119 |
120 | ### Disabled
121 |
122 | When there are two or more text inputs, only one should be receiving user input at a time, while others should be disabled via `isDisabled` prop.
123 |
124 | ```tsx
125 | import React, {useState} from 'react';
126 | import {render, Box, Text} from 'ink';
127 | import {TextInput} from '@inkjs/ui';
128 |
129 | function Example() {
130 | const [activeInput, setActiveInput] = useState('name');
131 | const [name, setName] = useState('');
132 | const [surname, setSurname] = useState('');
133 |
134 | return (
135 |
136 |
137 | {
142 | setActiveInput('surname');
143 | }}
144 | />
145 |
146 | {
151 | setActiveInput('none');
152 | }}
153 | />
154 |
155 |
156 |
157 | Name: "{name}"
158 | Surname: "{surname}"
159 |
160 |
161 | );
162 | }
163 |
164 | render();
165 | ```
166 |
167 |
168 |
169 | ## Props
170 |
171 | ### isDisabled
172 |
173 | Type: `boolean`\
174 | Default: `false`
175 |
176 | When disabled, user input is ignored.
177 |
178 | ### placeholder
179 |
180 | Type: `string`
181 |
182 | Text to display when input is empty.
183 |
184 | ### defaultValue
185 |
186 | Type: `string`
187 |
188 | Default input value.
189 |
190 | ### suggestions
191 |
192 | Type: `string[]`
193 |
194 | Suggestions to autocomplete the input value.
195 |
196 | ### onChange(value)
197 |
198 | Type: `Function`
199 |
200 | Callback when input value changes.
201 |
202 | #### value
203 |
204 | Type: `string`
205 |
206 | Input value.
207 |
208 | ### onSubmit(value)
209 |
210 | Type: `Function`
211 |
212 | Callback when enter is pressed.
213 |
214 | #### value
215 |
216 | Type: `string`
217 |
218 | Input value.
219 |
--------------------------------------------------------------------------------
/docs/unordered-list.md:
--------------------------------------------------------------------------------
1 | # Unordered list
2 |
3 | > `UnorderedList` is used to show lists of items.
4 |
5 | [Theme](../source/components/unordered-list/theme.ts) | [Example code](../examples/unordered-list.tsx)
6 |
7 | ## Usage
8 |
9 | ```tsx
10 | import React from 'react';
11 | import {render, Box, Text} from 'ink';
12 | import {UnorderedList} from '@inkjs/ui';
13 |
14 | function Example() {
15 | return (
16 |
17 |
18 | Red
19 |
20 |
21 |
22 | Green
23 |
24 |
25 |
26 | Light
27 |
28 |
29 |
30 | Dark
31 |
32 |
33 |
34 |
35 |
36 | Blue
37 |
38 |
39 | );
40 | }
41 |
42 | render();
43 | ```
44 |
45 |
46 |
47 | ## Props
48 |
49 | ### UnorderedList
50 |
51 | #### children
52 |
53 | Type: `ReactNode`
54 |
55 | List items.
56 |
57 | ### UnorderedList.Item
58 |
59 | #### children
60 |
61 | Type: `ReactNode`
62 |
63 | List item content.
64 |
--------------------------------------------------------------------------------
/examples/alert.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/alert.tsx
4 | */
5 |
6 | import React from 'react';
7 | import {render, Box} from 'ink';
8 | import {Alert} from '../source/index.js';
9 |
10 | function Example() {
11 | return (
12 |
13 | A new version of this CLI is available
14 |
15 | Your license is expired
16 |
17 |
18 | Current version of this CLI has been deprecated
19 |
20 |
21 | API won't be available tomorrow night
22 |
23 | );
24 | }
25 |
26 | render();
27 |
--------------------------------------------------------------------------------
/examples/autocomplete.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/autocomplete.tsx
4 | */
5 |
6 | import React, {useMemo, useState} from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {TextInput, Select} from '../source/index.js';
9 |
10 | function Example() {
11 | const [filterText, setFilterText] = useState('');
12 | const [value, setValue] = useState();
13 |
14 | const options = useMemo(() => {
15 | return [
16 | {
17 | label: 'Red',
18 | value: 'red',
19 | },
20 | {
21 | label: 'Green',
22 | value: 'green',
23 | },
24 | {
25 | label: 'Yellow',
26 | value: 'yellow',
27 | },
28 | {
29 | label: 'Blue',
30 | value: 'blue',
31 | },
32 | {
33 | label: 'Magenta',
34 | value: 'magenta',
35 | },
36 | {
37 | label: 'Cyan',
38 | value: 'cyan',
39 | },
40 | {
41 | label: 'White',
42 | value: 'white',
43 | },
44 | ].filter(option => option.label.includes(filterText));
45 | }, [filterText]);
46 |
47 | return (
48 |
49 | {!value && (
50 | <>
51 |
52 |
53 |
59 | >
60 | )}
61 |
62 | {value && You've selected {value}}
63 |
64 | );
65 | }
66 |
67 | render();
68 |
--------------------------------------------------------------------------------
/examples/badge.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/badge.tsx
4 | */
5 |
6 | import React from 'react';
7 | import {render, Box} from 'ink';
8 | import {Badge} from '../source/index.js';
9 |
10 | function Example() {
11 | return (
12 |
13 | Pass
14 | Fail
15 | Warn
16 | Todo
17 |
18 | );
19 | }
20 |
21 | render();
22 |
--------------------------------------------------------------------------------
/examples/confirm-input.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/confirm-input.tsx
4 | */
5 |
6 | import React, {useState} from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {ConfirmInput} from '../source/index.js';
9 |
10 | function Example() {
11 | const [choice, setChoice] = useState<'agreed' | 'disagreed' | undefined>();
12 |
13 | return (
14 |
15 | {!choice && (
16 | <>
17 | Do you agree with terms of service?
18 | {
20 | setChoice('agreed');
21 | }}
22 | onCancel={() => {
23 | setChoice('disagreed');
24 | }}
25 | />
26 | >
27 | )}
28 |
29 | {choice === 'agreed' && I know you haven't read them, but ok}
30 | {choice === 'disagreed' && Ok, whatever}
31 |
32 | );
33 | }
34 |
35 | render();
36 |
--------------------------------------------------------------------------------
/examples/email-input.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/email-input.tsx
4 | */
5 |
6 | import React, {useState} from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {EmailInput} from '../source/index.js';
9 |
10 | function Example() {
11 | const [value, setValue] = useState('');
12 |
13 | return (
14 |
15 |
16 | Input value: "{value}"
17 |
18 | );
19 | }
20 |
21 | render();
22 |
--------------------------------------------------------------------------------
/examples/multi-select.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/multi-select.tsx
4 | */
5 |
6 | import React, {useState} from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {MultiSelect} from '../source/index.js';
9 |
10 | function Example() {
11 | const [value, setValue] = useState([]);
12 |
13 | return (
14 |
15 |
48 |
49 | Selected values: {value.join(', ')}
50 |
51 | );
52 | }
53 |
54 | render();
55 |
--------------------------------------------------------------------------------
/examples/ordered-list.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/ordered-list.tsx
4 | */
5 |
6 | import React from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {OrderedList} from '../source/index.js';
9 |
10 | function Example() {
11 | return (
12 |
13 |
14 |
15 | Red
16 |
17 |
18 |
19 | Green
20 |
21 |
22 |
23 | Light
24 |
25 |
26 |
27 | Dark
28 |
29 |
30 |
31 |
32 |
33 | Blue
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | render();
41 |
--------------------------------------------------------------------------------
/examples/password-input.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/password-input.tsx
4 | */
5 |
6 | import React, {useState} from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {PasswordInput} from '../source/index.js';
9 |
10 | function Example() {
11 | const [value, setValue] = useState('');
12 |
13 | return (
14 |
15 |
16 | Input value: "{value}"
17 |
18 | );
19 | }
20 |
21 | render();
22 |
--------------------------------------------------------------------------------
/examples/progress-bar.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/progress-bar.tsx
4 | */
5 |
6 | import React, {useEffect, useState} from 'react';
7 | import {render, Box} from 'ink';
8 | import {ProgressBar} from '../source/index.js';
9 |
10 | function Example() {
11 | const [progress, setProgress] = useState(0);
12 |
13 | useEffect(() => {
14 | if (progress === 100) {
15 | return;
16 | }
17 |
18 | const timer = setTimeout(() => {
19 | setProgress(progress + 1);
20 | }, 50);
21 |
22 | return () => {
23 | clearInterval(timer);
24 | };
25 | }, [progress]);
26 |
27 | return (
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | render();
35 |
--------------------------------------------------------------------------------
/examples/select.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/select.tsx
4 | */
5 |
6 | import React, {useState} from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {Select} from '../source/index.js';
9 |
10 | function Example() {
11 | const [value, setValue] = useState();
12 |
13 | return (
14 |
15 |
48 |
49 | Selected value: {value}
50 |
51 | );
52 | }
53 |
54 | render();
55 |
--------------------------------------------------------------------------------
/examples/spinner.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/spinner.tsx
4 | */
5 |
6 | import React from 'react';
7 | import {render, Box} from 'ink';
8 | import {Spinner} from '../source/index.js';
9 |
10 | function Example() {
11 | return (
12 |
13 |
14 |
15 | );
16 | }
17 |
18 | render();
19 |
--------------------------------------------------------------------------------
/examples/status-message.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/status-message.tsx
4 | */
5 |
6 | import React from 'react';
7 | import {render, Box} from 'ink';
8 | import {StatusMessage} from '../source/index.js';
9 |
10 | function Example() {
11 | return (
12 |
13 | Success
14 | Error
15 | Warning
16 | Info
17 |
18 | );
19 | }
20 |
21 | render();
22 |
--------------------------------------------------------------------------------
/examples/text-input.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/text-input.tsx
4 | */
5 |
6 | import React, {useState} from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {TextInput} from '../source/index.js';
9 |
10 | function Example() {
11 | const [value, setValue] = useState('');
12 |
13 | return (
14 |
15 |
16 | Input value: "{value}"
17 |
18 | );
19 | }
20 |
21 | render();
22 |
--------------------------------------------------------------------------------
/examples/theming/custom-component.tsx:
--------------------------------------------------------------------------------
1 | import React, {render, Text, type TextProps} from 'ink';
2 | import {
3 | ThemeProvider,
4 | defaultTheme,
5 | extendTheme,
6 | useComponentTheme,
7 | type ComponentTheme,
8 | } from '../../source/index.js';
9 |
10 | const customLabelTheme = {
11 | styles: {
12 | label: (): TextProps => ({
13 | color: 'green',
14 | }),
15 | },
16 | } satisfies ComponentTheme;
17 |
18 | type CustomLabelTheme = typeof customLabelTheme;
19 |
20 | const customTheme = extendTheme(defaultTheme, {
21 | components: {
22 | CustomLabel: customLabelTheme,
23 | },
24 | });
25 |
26 | function CustomLabel() {
27 | const {styles} = useComponentTheme('CustomLabel');
28 |
29 | return Hello world;
30 | }
31 |
32 | function Example() {
33 | return (
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | render();
41 |
--------------------------------------------------------------------------------
/examples/theming/spinner.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box, type TextProps} from 'ink';
3 | import {
4 | Spinner,
5 | ThemeProvider,
6 | defaultTheme,
7 | extendTheme,
8 | } from '../../source/index.js';
9 |
10 | const customTheme = extendTheme(defaultTheme, {
11 | components: {
12 | Spinner: {
13 | styles: {
14 | frame: (): TextProps => ({
15 | color: 'magenta',
16 | }),
17 | },
18 | },
19 | },
20 | });
21 |
22 | function Example() {
23 | return (
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | render();
33 |
--------------------------------------------------------------------------------
/examples/theming/unordered-list.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import {
4 | UnorderedList,
5 | ThemeProvider,
6 | extendTheme,
7 | defaultTheme,
8 | } from '../../source/index.js';
9 |
10 | const customTheme = extendTheme(defaultTheme, {
11 | components: {
12 | UnorderedList: {
13 | config: () => ({
14 | marker: '+',
15 | }),
16 | },
17 | },
18 | });
19 |
20 | function Example() {
21 | return (
22 |
23 |
24 |
25 |
26 | Red
27 |
28 |
29 |
30 | Green
31 |
32 |
33 |
34 | Light
35 |
36 |
37 |
38 | Dark
39 |
40 |
41 |
42 |
43 |
44 | Blue
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | render();
53 |
--------------------------------------------------------------------------------
/examples/unordered-list.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Run this example:
3 | * npm run example examples/unordered-list.tsx
4 | */
5 |
6 | import React from 'react';
7 | import {render, Box, Text} from 'ink';
8 | import {UnorderedList} from '../source/index.js';
9 |
10 | function Example() {
11 | return (
12 |
13 |
14 |
15 | Red
16 |
17 |
18 |
19 | Green
20 |
21 |
22 |
23 | Light
24 |
25 |
26 |
27 | Dark
28 |
29 |
30 |
31 |
32 |
33 | Blue
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | render();
41 |
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) Vadym Demedes (github.com/vadimdemedes)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/media/alert.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/alert.png
--------------------------------------------------------------------------------
/media/badge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/badge.png
--------------------------------------------------------------------------------
/media/confirm-input.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/confirm-input.png
--------------------------------------------------------------------------------
/media/email-input-autocomplete.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/email-input-autocomplete.gif
--------------------------------------------------------------------------------
/media/email-input-basic.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/email-input-basic.gif
--------------------------------------------------------------------------------
/media/email-input-default-value.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/email-input-default-value.gif
--------------------------------------------------------------------------------
/media/email-input-disabled.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/email-input-disabled.gif
--------------------------------------------------------------------------------
/media/email-input-submit.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/email-input-submit.gif
--------------------------------------------------------------------------------
/media/email-input.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/email-input.gif
--------------------------------------------------------------------------------
/media/multi-select-basic.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/multi-select-basic.gif
--------------------------------------------------------------------------------
/media/multi-select-default-value.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/multi-select-default-value.gif
--------------------------------------------------------------------------------
/media/multi-select-disabled.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/multi-select-disabled.gif
--------------------------------------------------------------------------------
/media/multi-select-submit.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/multi-select-submit.gif
--------------------------------------------------------------------------------
/media/multi-select.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/multi-select.gif
--------------------------------------------------------------------------------
/media/ordered-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/ordered-list.png
--------------------------------------------------------------------------------
/media/password-input-basic.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/password-input-basic.gif
--------------------------------------------------------------------------------
/media/password-input-disabled.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/password-input-disabled.gif
--------------------------------------------------------------------------------
/media/password-input-submit.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/password-input-submit.gif
--------------------------------------------------------------------------------
/media/password-input.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/password-input.gif
--------------------------------------------------------------------------------
/media/progress-bar.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/progress-bar.gif
--------------------------------------------------------------------------------
/media/select-basic.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/select-basic.gif
--------------------------------------------------------------------------------
/media/select-default-value.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/select-default-value.gif
--------------------------------------------------------------------------------
/media/select-disabled.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/select-disabled.gif
--------------------------------------------------------------------------------
/media/select.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/select.gif
--------------------------------------------------------------------------------
/media/source/confirm-input/basic.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import {ConfirmInput} from '../../../source/index.js';
4 |
5 | function Example() {
6 | const [choice, setChoice] = useState<'agreed' | 'disagreed' | undefined>();
7 |
8 | return (
9 |
10 | {!choice && (
11 | <>
12 | Do you agree with terms of service?
13 | {
15 | setChoice('agreed');
16 | }}
17 | onCancel={() => {
18 | setChoice('disagreed');
19 | }}
20 | />
21 | >
22 | )}
23 |
24 | {choice === 'agreed' && I know you haven't read them, but ok}
25 | {choice === 'disagreed' && Ok, whatever}
26 |
27 | );
28 | }
29 |
30 | render();
31 |
--------------------------------------------------------------------------------
/media/source/confirm-input/readme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box} from 'ink';
3 | import {ConfirmInput} from '../../../source/index.js';
4 |
5 | function Demo() {
6 | return (
7 |
8 | {
10 | // Confirmed
11 | }}
12 | onCancel={() => {
13 | // Cancelled
14 | }}
15 | />
16 |
17 | );
18 | }
19 |
20 | render();
21 |
--------------------------------------------------------------------------------
/media/source/email-input/autocomplete.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {EmailInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState('');
10 |
11 | return (
12 |
13 |
18 |
19 | Input value: "{value}"
20 |
21 | );
22 | }
23 |
24 | render();
25 |
26 | await delay(500);
27 | await input('jane@');
28 | await delay(500);
29 | await press('\r');
30 |
--------------------------------------------------------------------------------
/media/source/email-input/basic.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {EmailInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState('');
10 |
11 | return (
12 |
13 |
14 | Input value: "{value}"
15 |
16 | );
17 | }
18 |
19 | render();
20 |
21 | await delay(500);
22 | await input('jane@he');
23 | await delay(250);
24 | await press('\r');
25 |
--------------------------------------------------------------------------------
/media/source/email-input/default-value.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {EmailInput} from '../../../source/index.js';
5 | import {del} from '../helpers/escapes.js';
6 | import input from '../helpers/input.js';
7 | import press from '../helpers/press.js';
8 |
9 | function Example() {
10 | const [value, setValue] = useState('jane@hey.com');
11 |
12 | return (
13 |
14 |
19 |
20 | Input value: "{value}"
21 |
22 | );
23 | }
24 |
25 | render();
26 |
27 | await delay(500);
28 | await press(del, 12);
29 | await delay(250);
30 | await input('hopper@he');
31 | await delay(250);
32 | await press('\r');
33 |
--------------------------------------------------------------------------------
/media/source/email-input/disabled.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {EmailInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [activeInput, setActiveInput] = useState('first');
10 | const [first, setFirst] = useState('');
11 | const [second, setSecond] = useState('');
12 |
13 | return (
14 |
15 |
16 | {
21 | setActiveInput('second');
22 | }}
23 | />
24 |
25 | {
30 | setActiveInput('none');
31 | }}
32 | />
33 |
34 |
35 |
36 | First email: "{first}"
37 | Second email: "{second}"
38 |
39 |
40 | );
41 | }
42 |
43 | render();
44 |
45 | await delay(500);
46 | await input('jane@he');
47 | await delay(250);
48 | await press('\r');
49 | await delay(250);
50 | await input('hopper@he');
51 | await delay(250);
52 | await press('\r');
53 | await delay(1000);
54 |
--------------------------------------------------------------------------------
/media/source/email-input/readme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box} from 'ink';
3 | import delay from 'delay';
4 | import {EmailInput} from '../source/index.js';
5 | import {del} from '../helpers/escapes.js';
6 | import input from '../helpers/input.js';
7 | import press from '../helpers/press.js';
8 |
9 | function Demo() {
10 | return (
11 |
12 |
13 |
14 | );
15 | }
16 |
17 | render();
18 |
19 | await delay(500);
20 | await input('jane@');
21 | await delay(250);
22 | await input('ao');
23 | await delay(250);
24 | await press(del, 2);
25 | await delay(250);
26 | await input('hey');
27 | await delay(250);
28 | await press('\r');
29 |
--------------------------------------------------------------------------------
/media/source/email-input/submit.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {EmailInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState('');
10 |
11 | return (
12 |
13 |
14 | Input value: "{value}"
15 |
16 | );
17 | }
18 |
19 | render();
20 |
21 | await delay(500);
22 | await input('jane@he');
23 | await delay(250);
24 | await press('\r');
25 |
--------------------------------------------------------------------------------
/media/source/helpers/escapes.ts:
--------------------------------------------------------------------------------
1 | export const upArrow = '\u001B[A';
2 | export const downArrow = '\u001B[B';
3 | export const leftArrow = '\u001B[D';
4 | export const rightArrow = '\u001B[C';
5 | export const del = '\u007F';
6 |
--------------------------------------------------------------------------------
/media/source/helpers/input.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import delay from 'delay';
3 |
4 | export default async function input(characters: string) {
5 | for (const character of characters) {
6 | process.stdin.emit('data', character);
7 |
8 | // eslint-disable-next-line no-await-in-loop
9 | await delay(200 * Math.random());
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/media/source/helpers/press.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import delay from 'delay';
3 |
4 | export default async function press(character: string, times = 1) {
5 | for (let index = 0; index < times; index++) {
6 | process.stdin.emit('data', character);
7 |
8 | // eslint-disable-next-line no-await-in-loop
9 | await delay(200 * Math.random());
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/media/source/multi-select/basic.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {MultiSelect} from '../../../source/index.js';
5 | import {upArrow, downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState([]);
10 |
11 | return (
12 |
13 |
46 |
47 | Selected values: {value.join(', ')}
48 |
49 | );
50 | }
51 |
52 | render();
53 |
54 | await delay(500);
55 | await press(downArrow);
56 | await delay(250);
57 | await press(' ');
58 | await delay(250);
59 | await press(downArrow, 5);
60 | await delay(250);
61 | await press(' ');
62 | await delay(250);
63 | await press(upArrow, 6);
64 |
--------------------------------------------------------------------------------
/media/source/multi-select/default-value.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {MultiSelect} from '../../../source/index.js';
5 | import {downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState(['green']);
10 |
11 | return (
12 |
13 |
47 |
48 | Selected values: {value.join(', ')}
49 |
50 | );
51 | }
52 |
53 | render();
54 |
55 | await delay(500);
56 | await press(downArrow, 2);
57 | await delay(250);
58 | await press(' ');
59 | await delay(250);
60 | await press(downArrow, 5);
61 | await delay(250);
62 | await press(' ');
63 |
--------------------------------------------------------------------------------
/media/source/multi-select/disabled.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {MultiSelect} from '../../../source/index.js';
5 | import {downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | const options = [
9 | {
10 | label: 'Red',
11 | value: 'red',
12 | },
13 | {
14 | label: 'Green',
15 | value: 'green',
16 | },
17 | {
18 | label: 'Yellow',
19 | value: 'yellow',
20 | },
21 | {
22 | label: 'Blue',
23 | value: 'blue',
24 | },
25 | {
26 | label: 'Magenta',
27 | value: 'magenta',
28 | },
29 | {
30 | label: 'Cyan',
31 | value: 'cyan',
32 | },
33 | {
34 | label: 'White',
35 | value: 'white',
36 | },
37 | ];
38 |
39 | function Example() {
40 | const [activeInput, setActiveInput] = useState('primary');
41 | const [primaryColors, setPrimaryColors] = useState([]);
42 | const [secondaryColors, setSecondaryColors] = useState([]);
43 |
44 | return (
45 |
46 |
47 | {
52 | setActiveInput('secondary');
53 | }}
54 | />
55 |
56 | Primary colors: {primaryColors.join(', ')}
57 |
58 |
59 |
60 | {
65 | setActiveInput('none');
66 | }}
67 | />
68 |
69 | Secondary colors: {secondaryColors.join(', ')}
70 |
71 |
72 | );
73 | }
74 |
75 | render();
76 |
77 | await delay(500);
78 | await press(downArrow);
79 | await delay(250);
80 | await press(' ');
81 | await delay(250);
82 | await press(downArrow, 2);
83 | await delay(250);
84 | await press(' ');
85 | await delay(100);
86 | await press('\r');
87 | await delay(250);
88 | await press(downArrow, 5);
89 | await delay(250);
90 | await press(' ');
91 | await delay(100);
92 | await press('\r');
93 | await delay(1000);
94 |
--------------------------------------------------------------------------------
/media/source/multi-select/readme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box} from 'ink';
3 | import delay from 'delay';
4 | import {MultiSelect} from '../../../source/index.js';
5 | import {upArrow, downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | return (
10 |
11 |
43 |
44 | );
45 | }
46 |
47 | render();
48 |
49 | await delay(500);
50 | await press(downArrow);
51 | await delay(250);
52 | await press(' ');
53 | await delay(250);
54 | await press(downArrow, 5);
55 | await delay(250);
56 | await press(' ');
57 | await delay(250);
58 | await press(upArrow, 6);
59 |
--------------------------------------------------------------------------------
/media/source/multi-select/submit.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {MultiSelect} from '../../../source/index.js';
5 | import {upArrow, downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState([]);
10 |
11 | return (
12 |
13 |
46 |
47 | Selected values: {value.join(', ')}
48 |
49 | );
50 | }
51 |
52 | render();
53 |
54 | await delay(500);
55 | await press(downArrow);
56 | await delay(250);
57 | await press(' ');
58 | await delay(250);
59 | await press(downArrow, 5);
60 | await delay(250);
61 | await press(' ');
62 | await delay(250);
63 | await press('\r');
64 |
--------------------------------------------------------------------------------
/media/source/password-input/basic.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {PasswordInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 |
7 | function Example() {
8 | const [value, setValue] = useState('');
9 |
10 | return (
11 |
12 |
13 | Input value: "{value}"
14 |
15 | );
16 | }
17 |
18 | render();
19 |
20 | await delay(500);
21 | await input('secret');
22 |
--------------------------------------------------------------------------------
/media/source/password-input/disabled.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {PasswordInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [activeInput, setActiveInput] = useState('password');
10 | const [password, setPassword] = useState('');
11 | const [passwordConfirmation, setPasswordConfirmation] = useState('');
12 |
13 | return (
14 |
15 |
16 | {
21 | setActiveInput('passwordConfirmation');
22 | }}
23 | />
24 |
25 | {
30 | setActiveInput('none');
31 | }}
32 | />
33 |
34 |
35 |
36 | Password: "{password}"
37 | Password confirmation: "{passwordConfirmation}"
38 |
39 |
40 | );
41 | }
42 |
43 | render();
44 |
45 | await delay(500);
46 | await input('secret');
47 | await delay(250);
48 | await press('\r');
49 | await delay(250);
50 | await input('secret');
51 | await delay(250);
52 | await press('\r');
53 | await delay(1000);
54 |
--------------------------------------------------------------------------------
/media/source/password-input/readme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box} from 'ink';
3 | import delay from 'delay';
4 | import {PasswordInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 |
7 | function Demo() {
8 | return (
9 |
10 |
11 |
12 | );
13 | }
14 |
15 | render();
16 |
17 | await delay(500);
18 | await input('Hello world');
19 |
--------------------------------------------------------------------------------
/media/source/password-input/submit.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {PasswordInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState('');
10 |
11 | return (
12 |
13 |
14 | Input value: "{value}"
15 |
16 | );
17 | }
18 |
19 | render();
20 |
21 | await delay(500);
22 | await input('secret');
23 | await delay(250);
24 | await press('\r');
25 |
--------------------------------------------------------------------------------
/media/source/select/basic.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {Select} from '../../../source/index.js';
5 | import {downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState();
10 |
11 | return (
12 |
13 |
46 |
47 | Selected value: {value}
48 |
49 | );
50 | }
51 |
52 | render();
53 |
54 | await delay(500);
55 | await press(downArrow);
56 | await delay(250);
57 | await press('\r');
58 | await delay(250);
59 | await press(downArrow, 5);
60 | await delay(250);
61 | await press('\r');
62 |
--------------------------------------------------------------------------------
/media/source/select/default-value.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {Select} from '../../../source/index.js';
5 | import {downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState('green');
10 |
11 | return (
12 |
13 |
47 |
48 | Selected value: {value}
49 |
50 | );
51 | }
52 |
53 | render();
54 |
55 | await delay(500);
56 | await press(downArrow, 2);
57 | await delay(250);
58 | await press('\r');
59 | await delay(250);
60 | await press(downArrow, 5);
61 | await delay(250);
62 | await press('\r');
63 |
--------------------------------------------------------------------------------
/media/source/select/disabled.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {Select} from '../../../source/index.js';
5 | import {downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | const options = [
9 | {
10 | label: 'Red',
11 | value: 'red',
12 | },
13 | {
14 | label: 'Green',
15 | value: 'green',
16 | },
17 | {
18 | label: 'Yellow',
19 | value: 'yellow',
20 | },
21 | {
22 | label: 'Blue',
23 | value: 'blue',
24 | },
25 | {
26 | label: 'Magenta',
27 | value: 'magenta',
28 | },
29 | {
30 | label: 'Cyan',
31 | value: 'cyan',
32 | },
33 | {
34 | label: 'White',
35 | value: 'white',
36 | },
37 | ];
38 |
39 | function Example() {
40 | const [activeInput, setActiveInput] = useState('primary');
41 | const [primaryColor, setPrimaryColor] = useState();
42 | const [secondaryColor, setSecondaryColor] = useState();
43 |
44 | return (
45 |
46 |
47 |
58 |
59 |
60 |
71 |
72 | );
73 | }
74 |
75 | render();
76 |
77 | await delay(500);
78 | await press(downArrow);
79 | await delay(250);
80 | await press('\r');
81 | await delay(250);
82 | await press(downArrow, 5);
83 | await delay(250);
84 | await press('\r');
85 | await delay(1000);
86 |
--------------------------------------------------------------------------------
/media/source/select/readme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box} from 'ink';
3 | import delay from 'delay';
4 | import {Select} from '../../../source/index.js';
5 | import {upArrow, downArrow} from '../helpers/escapes.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | return (
10 |
11 |
43 |
44 | );
45 | }
46 |
47 | render();
48 |
49 | await delay(500);
50 | await press(downArrow);
51 | await delay(250);
52 | await press('\r');
53 | await delay(250);
54 | await press(downArrow, 5);
55 | await delay(250);
56 | await press('\r');
57 | await delay(250);
58 | await press(upArrow, 6);
59 |
--------------------------------------------------------------------------------
/media/source/text-input/autocomplete.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {TextInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState('');
10 |
11 | return (
12 |
13 |
18 |
19 | Input value: "{value}"
20 |
21 | );
22 | }
23 |
24 | render();
25 |
26 | await delay(500);
27 | await input('Ab');
28 | await delay(500);
29 | await press('\r');
30 |
--------------------------------------------------------------------------------
/media/source/text-input/basic.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {TextInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 |
7 | function Example() {
8 | const [value, setValue] = useState('');
9 |
10 | return (
11 |
12 |
13 | Input value: "{value}"
14 |
15 | );
16 | }
17 |
18 | render();
19 |
20 | await delay(500);
21 | await input('Hello world');
22 |
--------------------------------------------------------------------------------
/media/source/text-input/default-value.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {TextInput} from '../../../source/index.js';
5 | import {del} from '../helpers/escapes.js';
6 | import input from '../helpers/input.js';
7 | import press from '../helpers/press.js';
8 |
9 | function Example() {
10 | const [value, setValue] = useState('Jane');
11 |
12 | return (
13 |
14 |
19 |
20 | Input value: "{value}"
21 |
22 | );
23 | }
24 |
25 | render();
26 |
27 | await delay(500);
28 | await press(del, 4);
29 | await delay(250);
30 | await input('Hopper');
31 |
--------------------------------------------------------------------------------
/media/source/text-input/disabled.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {TextInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [activeInput, setActiveInput] = useState('name');
10 | const [name, setName] = useState('');
11 | const [surname, setSurname] = useState('');
12 |
13 | return (
14 |
15 |
16 | {
21 | setActiveInput('surname');
22 | }}
23 | />
24 |
25 | {
30 | setActiveInput('none');
31 | }}
32 | />
33 |
34 |
35 |
36 | Name: "{name}"
37 | Surname: "{surname}"
38 |
39 |
40 | );
41 | }
42 |
43 | render();
44 |
45 | await delay(500);
46 | await input('Jane');
47 | await delay(250);
48 | await press('\r');
49 | await delay(250);
50 | await input('Hopper');
51 | await delay(250);
52 | await press('\r');
53 | await delay(1000);
54 |
--------------------------------------------------------------------------------
/media/source/text-input/readme.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, Box} from 'ink';
3 | import delay from 'delay';
4 | import {TextInput} from '../../../source/index.js';
5 | import {leftArrow, rightArrow} from '../helpers/escapes.js';
6 | import input from '../helpers/input.js';
7 | import press from '../helpers/press.js';
8 |
9 | function Example() {
10 | return (
11 |
12 |
13 |
14 | );
15 | }
16 |
17 | render();
18 |
19 | await delay(500);
20 | await input('Hello world');
21 | await delay(350);
22 | await press(leftArrow, 6);
23 | await delay(350);
24 | await input(',');
25 | await delay(350);
26 | await press(rightArrow, 7);
27 |
--------------------------------------------------------------------------------
/media/source/text-input/submit.tsx:
--------------------------------------------------------------------------------
1 | import React, {useState} from 'react';
2 | import {render, Box, Text} from 'ink';
3 | import delay from 'delay';
4 | import {TextInput} from '../../../source/index.js';
5 | import input from '../helpers/input.js';
6 | import press from '../helpers/press.js';
7 |
8 | function Example() {
9 | const [value, setValue] = useState('');
10 |
11 | return (
12 |
13 |
14 | Input value: "{value}"
15 |
16 | );
17 | }
18 |
19 | render();
20 |
21 | await delay(500);
22 | await input('Hello world');
23 | await delay(250);
24 | await press('\r');
25 |
--------------------------------------------------------------------------------
/media/spinner-theme.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/spinner-theme.gif
--------------------------------------------------------------------------------
/media/spinner.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/spinner.gif
--------------------------------------------------------------------------------
/media/status-message.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/status-message.png
--------------------------------------------------------------------------------
/media/text-input-autocomplete.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/text-input-autocomplete.gif
--------------------------------------------------------------------------------
/media/text-input-basic.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/text-input-basic.gif
--------------------------------------------------------------------------------
/media/text-input-default-value.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/text-input-default-value.gif
--------------------------------------------------------------------------------
/media/text-input-disabled.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/text-input-disabled.gif
--------------------------------------------------------------------------------
/media/text-input-submit.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/text-input-submit.gif
--------------------------------------------------------------------------------
/media/text-input.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/text-input.gif
--------------------------------------------------------------------------------
/media/unordered-list-theme.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/unordered-list-theme.png
--------------------------------------------------------------------------------
/media/unordered-list.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vadimdemedes/ink-ui/14b1145da0123a48cfc2f0ec9ff33dff0633f464/media/unordered-list.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@inkjs/ui",
3 | "version": "2.0.0",
4 | "description": "Collection of customizable UI components for CLIs made with Ink",
5 | "license": "MIT",
6 | "repository": "vadimdemedes/ink-ui",
7 | "author": {
8 | "name": "Vadim Demedes",
9 | "email": "vadimdemedes@hey.com",
10 | "url": "https://github.com/vadimdemedes"
11 | },
12 | "publishConfig": {
13 | "access": "public"
14 | },
15 | "type": "module",
16 | "exports": {
17 | "types": "./build/index.d.ts",
18 | "default": "./build/index.js"
19 | },
20 | "engines": {
21 | "node": ">=18"
22 | },
23 | "scripts": {
24 | "dev": "tsc --watch",
25 | "build": "tsc",
26 | "prepare": "npm run build",
27 | "test": "tsc --noEmit && xo && ava",
28 | "example": "NODE_NO_WARNINGS=1 node --import=tsimp"
29 | },
30 | "files": [
31 | "build"
32 | ],
33 | "dependencies": {
34 | "chalk": "^5.3.0",
35 | "cli-spinners": "^3.0.0",
36 | "deepmerge": "^4.3.1",
37 | "figures": "^6.1.0"
38 | },
39 | "devDependencies": {
40 | "@sindresorhus/tsconfig": "^5.0.0",
41 | "@types/react": "^18.3.2",
42 | "@vdemedes/prettier-config": "^2.0.1",
43 | "ava": "^5.2.0",
44 | "boxen": "^7.1.1",
45 | "cat-names": "^4.0.0",
46 | "delay": "^6.0.0",
47 | "eslint-config-xo-react": "^0.27.0",
48 | "eslint-plugin-react": "^7.34.1",
49 | "eslint-plugin-react-hooks": "^4.6.2",
50 | "ink": "^5.0.0",
51 | "ink-testing-library": "^4.0.0",
52 | "prettier": "^3.2.5",
53 | "react": "^18.3.1",
54 | "tsimp": "^2.0.11",
55 | "typescript": "^5.4.5",
56 | "xo": "^0.58.0"
57 | },
58 | "peerDependencies": {
59 | "ink": ">=5"
60 | },
61 | "ava": {
62 | "extensions": {
63 | "ts": "module",
64 | "tsx": "module"
65 | },
66 | "nodeArguments": [
67 | "--import=tsimp"
68 | ],
69 | "environmentVariables": {
70 | "NODE_NO_WARNINGS": "1",
71 | "FORCE_COLOR": "true"
72 | }
73 | },
74 | "xo": {
75 | "extends": [
76 | "xo-react"
77 | ],
78 | "plugins": [
79 | "react"
80 | ],
81 | "prettier": true,
82 | "rules": {
83 | "react/no-unescaped-entities": "off",
84 | "unicorn/prevent-abbreviations": "off"
85 | }
86 | },
87 | "prettier": "@vdemedes/prettier-config"
88 | }
89 |
--------------------------------------------------------------------------------
/source/components/alert/alert.tsx:
--------------------------------------------------------------------------------
1 | import React, {type ReactNode} from 'react';
2 | import {Box, Text} from 'ink';
3 | import {useComponentTheme} from '../../theme.js';
4 | import {type Theme} from './theme.js';
5 |
6 | export type AlertProps = {
7 | /**
8 | * Message.
9 | */
10 | readonly children: ReactNode;
11 |
12 | /**
13 | * Variant, which determines the color of the alert.
14 | */
15 | readonly variant: 'info' | 'success' | 'error' | 'warning';
16 |
17 | /**
18 | * Title to show above the message.
19 | */
20 | readonly title?: string;
21 | };
22 |
23 | export function Alert({children, variant, title}: AlertProps) {
24 | const {styles, config} = useComponentTheme('Alert');
25 |
26 | return (
27 |
28 |
29 | {config({variant}).icon}
30 |
31 |
32 |
33 | {title && {title}}
34 | {children}
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/source/components/alert/index.ts:
--------------------------------------------------------------------------------
1 | export * from './alert.js';
2 |
--------------------------------------------------------------------------------
/source/components/alert/theme.ts:
--------------------------------------------------------------------------------
1 | import {type BoxProps, type TextProps} from 'ink';
2 | import figures from 'figures';
3 | import {type ComponentTheme} from '../../theme.js';
4 |
5 | const colorByVariant: Record = {
6 | info: 'blue',
7 | success: 'green',
8 | error: 'red',
9 | warning: 'yellow',
10 | };
11 |
12 | const theme = {
13 | styles: {
14 | container: ({variant}): BoxProps => ({
15 | flexGrow: 1,
16 | borderStyle: 'round',
17 | borderColor: colorByVariant[variant],
18 | gap: 1,
19 | paddingX: 1,
20 | }),
21 | iconContainer: (): BoxProps => ({
22 | flexShrink: 0,
23 | }),
24 | icon: ({variant}): TextProps => ({
25 | color: colorByVariant[variant],
26 | }),
27 | content: (): BoxProps => ({
28 | flexShrink: 1,
29 | flexGrow: 1,
30 | minWidth: 0,
31 | flexDirection: 'column',
32 | gap: 1,
33 | }),
34 | title: (): TextProps => ({
35 | bold: true,
36 | }),
37 | message: (): TextProps => ({}),
38 | },
39 | config({variant}) {
40 | let icon: string | undefined;
41 |
42 | if (variant === 'info') {
43 | icon = figures.info;
44 | }
45 |
46 | if (variant === 'success') {
47 | icon = figures.tick;
48 | }
49 |
50 | if (variant === 'error') {
51 | icon = figures.cross;
52 | }
53 |
54 | if (variant === 'warning') {
55 | icon = figures.warning;
56 | }
57 |
58 | return {icon};
59 | },
60 | } satisfies ComponentTheme;
61 |
62 | export default theme;
63 | export type Theme = typeof theme;
64 |
--------------------------------------------------------------------------------
/source/components/badge/badge.tsx:
--------------------------------------------------------------------------------
1 | import {Text, type TextProps} from 'ink';
2 | import React, {type ReactNode} from 'react';
3 | import {useComponentTheme} from '../../theme.js';
4 | import {type Theme} from './theme.js';
5 |
6 | export type BadgeProps = {
7 | /**
8 | * Label.
9 | */
10 | readonly children: ReactNode;
11 |
12 | /**
13 | * Color.
14 | *
15 | * @default "magenta"
16 | */
17 | readonly color?: TextProps['color'];
18 | };
19 |
20 | export function Badge({children, color = 'magenta'}: BadgeProps) {
21 | const {styles} = useComponentTheme('Badge');
22 |
23 | let formattedChildren = children;
24 |
25 | if (typeof children === 'string') {
26 | formattedChildren = children.toUpperCase();
27 | }
28 |
29 | return (
30 |
31 | {' '}
32 | {formattedChildren}{' '}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/source/components/badge/index.ts:
--------------------------------------------------------------------------------
1 | export * from './badge.js';
2 |
--------------------------------------------------------------------------------
/source/components/badge/theme.ts:
--------------------------------------------------------------------------------
1 | import {type TextProps} from 'ink';
2 | import {type ComponentTheme} from '../../theme.js';
3 |
4 | const theme = {
5 | styles: {
6 | container: ({color}: Pick): TextProps => ({
7 | backgroundColor: color,
8 | }),
9 | label: (): TextProps => ({
10 | color: 'black',
11 | }),
12 | },
13 | } satisfies ComponentTheme;
14 |
15 | export default theme;
16 | export type Theme = typeof theme;
17 |
--------------------------------------------------------------------------------
/source/components/confirm-input/confirm-input.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Text, useInput} from 'ink';
3 | import {useComponentTheme} from '../../theme.js';
4 | import {type Theme} from './theme.js';
5 |
6 | export type ConfirmInputProps = {
7 | /**
8 | * When disabled, user input is ignored.
9 | *
10 | * @default false
11 | */
12 | readonly isDisabled?: boolean;
13 |
14 | /**
15 | * Default choice.
16 | *
17 | * @default "confirm"
18 | */
19 | readonly defaultChoice?: 'confirm' | 'cancel';
20 |
21 | /**
22 | * Confirm or cancel when user presses enter, depending on the `defaultChoice` value.
23 | * Can be useful to disable when an explicit confirmation is required, such as pressing "Y" key.
24 | *
25 | * @default true
26 | */
27 | readonly submitOnEnter?: boolean; // eslint-disable-line react/boolean-prop-naming
28 |
29 | /**
30 | * Callback to trigger on confirmation.
31 | */
32 | readonly onConfirm: () => void;
33 |
34 | /**
35 | * Callback to trigger on cancellation.
36 | */
37 | readonly onCancel: () => void;
38 | };
39 |
40 | export function ConfirmInput({
41 | isDisabled = false,
42 | defaultChoice = 'confirm',
43 | submitOnEnter = true,
44 | onConfirm,
45 | onCancel,
46 | }: ConfirmInputProps) {
47 | useInput(
48 | (input, key) => {
49 | if (input.toLowerCase() === 'y') {
50 | onConfirm();
51 | }
52 |
53 | if (input.toLowerCase() === 'n') {
54 | onCancel();
55 | }
56 |
57 | if (key.return && submitOnEnter) {
58 | if (defaultChoice === 'confirm') {
59 | onConfirm();
60 | } else {
61 | onCancel();
62 | }
63 | }
64 | },
65 | {isActive: !isDisabled},
66 | );
67 |
68 | const {styles} = useComponentTheme('ConfirmInput');
69 |
70 | return (
71 |
72 | {defaultChoice === 'confirm' ? 'Y/n' : 'y/N'}
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/source/components/confirm-input/index.ts:
--------------------------------------------------------------------------------
1 | export * from './confirm-input.js';
2 |
--------------------------------------------------------------------------------
/source/components/confirm-input/theme.ts:
--------------------------------------------------------------------------------
1 | import {type TextProps} from 'ink';
2 | import {type ComponentTheme} from '../../theme.js';
3 |
4 | const theme = {
5 | styles: {
6 | input: ({isFocused}): TextProps => ({
7 | dimColor: !isFocused,
8 | }),
9 | },
10 | } satisfies ComponentTheme;
11 |
12 | export default theme;
13 | export type Theme = typeof theme;
14 |
--------------------------------------------------------------------------------
/source/components/email-input/email-input.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Text} from 'ink';
3 | import {useComponentTheme} from '../../theme.js';
4 | import {useEmailInputState} from './use-email-input-state.js';
5 | import {useEmailInput} from './use-email-input.js';
6 | import {type Theme} from './theme.js';
7 |
8 | export type EmailInputProps = {
9 | /**
10 | * When disabled, user input is ignored.
11 | *
12 | * @default false
13 | */
14 | readonly isDisabled?: boolean;
15 |
16 | /**
17 | * Text to display when input is empty.
18 | */
19 | readonly placeholder?: string;
20 |
21 | /**
22 | * Default input value.
23 | */
24 | readonly defaultValue?: string;
25 |
26 | /**
27 | * Domains of email providers to autocomplete.
28 | *
29 | * @default ["aol.com", "gmail.com", "yahoo.com", "hotmail.com", "live.com", "outlook.com", "icloud.com", "hey.com"]
30 | */
31 | readonly domains?: string[];
32 |
33 | /**
34 | * Callback when input value changes.
35 | */
36 | readonly onChange?: (value: string) => void;
37 |
38 | /**
39 | * Callback when enter is pressed. First argument is input value.
40 | */
41 | readonly onSubmit?: (value: string) => void;
42 | };
43 |
44 | export function EmailInput({
45 | isDisabled = false,
46 | defaultValue,
47 | placeholder = '',
48 | domains,
49 | onChange,
50 | onSubmit,
51 | }: EmailInputProps) {
52 | const state = useEmailInputState({
53 | defaultValue,
54 | domains,
55 | onChange,
56 | onSubmit,
57 | });
58 |
59 | const {inputValue} = useEmailInput({
60 | isDisabled,
61 | placeholder,
62 | state,
63 | });
64 |
65 | const {styles} = useComponentTheme('EmailInput');
66 |
67 | return {inputValue};
68 | }
69 |
--------------------------------------------------------------------------------
/source/components/email-input/index.ts:
--------------------------------------------------------------------------------
1 | export * from './email-input.js';
2 |
--------------------------------------------------------------------------------
/source/components/email-input/theme.ts:
--------------------------------------------------------------------------------
1 | import {type TextProps} from 'ink';
2 | import {type ComponentTheme} from '../../theme.js';
3 |
4 | const theme = {
5 | styles: {
6 | value: (): TextProps => ({}),
7 | },
8 | } satisfies ComponentTheme;
9 |
10 | export default theme;
11 | export type Theme = typeof theme;
12 |
--------------------------------------------------------------------------------
/source/components/email-input/use-email-input-state.ts:
--------------------------------------------------------------------------------
1 | import {useReducer, useCallback, useEffect, type Reducer, useMemo} from 'react';
2 |
3 | type State = {
4 | previousValue: string;
5 | value: string;
6 | cursorOffset: number;
7 | };
8 |
9 | type Action =
10 | | MoveCursorLeftAction
11 | | MoveCursorRightAction
12 | | InsertAction
13 | | DeleteAction;
14 |
15 | type MoveCursorLeftAction = {
16 | type: 'move-cursor-left';
17 | };
18 |
19 | type MoveCursorRightAction = {
20 | type: 'move-cursor-right';
21 | };
22 |
23 | type InsertAction = {
24 | type: 'insert';
25 | text: string;
26 | };
27 |
28 | type DeleteAction = {
29 | type: 'delete';
30 | };
31 |
32 | const reducer: Reducer = (state, action) => {
33 | switch (action.type) {
34 | case 'move-cursor-left': {
35 | return {
36 | ...state,
37 | cursorOffset: Math.max(0, state.cursorOffset - 1),
38 | };
39 | }
40 |
41 | case 'move-cursor-right': {
42 | return {
43 | ...state,
44 | cursorOffset: Math.min(state.value.length, state.cursorOffset + 1),
45 | };
46 | }
47 |
48 | case 'insert': {
49 | if (state.value.includes('@') && action.text.includes('@')) {
50 | return state;
51 | }
52 |
53 | return {
54 | ...state,
55 | previousValue: state.value,
56 | value:
57 | state.value.slice(0, state.cursorOffset) +
58 | action.text +
59 | state.value.slice(state.cursorOffset),
60 | cursorOffset: state.cursorOffset + action.text.length,
61 | };
62 | }
63 |
64 | case 'delete': {
65 | const newCursorOffset = Math.max(0, state.cursorOffset - 1);
66 |
67 | return {
68 | ...state,
69 | previousValue: state.value,
70 | value:
71 | state.value.slice(0, newCursorOffset) +
72 | state.value.slice(newCursorOffset + 1),
73 | cursorOffset: newCursorOffset,
74 | };
75 | }
76 | }
77 | };
78 |
79 | export type UseEmailInputStateProps = {
80 | /**
81 | * Initial value to display in a text input.
82 | */
83 | defaultValue?: string;
84 |
85 | /**
86 | * Domains of email providers to auto complete.
87 | *
88 | * @default ["aol.com", "gmail.com", "yahoo.com", "hotmail.com", "live.com", "outlook.com", "icloud.com", "hey.com"]
89 | */
90 | domains?: string[];
91 |
92 | /**
93 | * Callback when value updates.
94 | */
95 | onChange?: (value: string) => void;
96 |
97 | /**
98 | * Callback when `Enter` is pressed. First argument is a value of the input.
99 | */
100 | onSubmit?: (value: string) => void;
101 | };
102 |
103 | export type EmailInputState = State & {
104 | /**
105 | * Suggested auto completion.
106 | */
107 | suggestion: string | undefined;
108 |
109 | /**
110 | * Move cursor to the left.
111 | */
112 | moveCursorLeft: () => void;
113 |
114 | /**
115 | * Move cursor to the right.
116 | */
117 | moveCursorRight: () => void;
118 |
119 | /**
120 | * Insert text.
121 | */
122 | insert: (text: string) => void;
123 |
124 | /**
125 | * Delete character.
126 | */
127 | delete: () => void;
128 |
129 | /**
130 | * Submit input value.
131 | */
132 | submit: () => void;
133 | };
134 |
135 | export const useEmailInputState = ({
136 | defaultValue = '',
137 | domains = [
138 | 'aol.com',
139 | 'gmail.com',
140 | 'yahoo.com',
141 | 'hotmail.com',
142 | 'live.com',
143 | 'outlook.com',
144 | 'icloud.com',
145 | 'hey.com',
146 | ],
147 | onChange,
148 | onSubmit,
149 | }: UseEmailInputStateProps) => {
150 | const [state, dispatch] = useReducer(reducer, {
151 | previousValue: defaultValue,
152 | value: defaultValue,
153 | cursorOffset: defaultValue.length,
154 | });
155 |
156 | const suggestion = useMemo(() => {
157 | if (state.value.length === 0 || !state.value.includes('@')) {
158 | return;
159 | }
160 |
161 | const atIndex = state.value.indexOf('@');
162 | const enteredDomain = state.value.slice(atIndex + 1);
163 |
164 | return domains
165 | ?.find(domain => domain.startsWith(enteredDomain))
166 | ?.replace(enteredDomain, '');
167 | }, [state.value, domains]);
168 |
169 | const moveCursorLeft = useCallback(() => {
170 | dispatch({
171 | type: 'move-cursor-left',
172 | });
173 | }, []);
174 |
175 | const moveCursorRight = useCallback(() => {
176 | dispatch({
177 | type: 'move-cursor-right',
178 | });
179 | }, []);
180 |
181 | const insert = useCallback((text: string) => {
182 | dispatch({
183 | type: 'insert',
184 | text,
185 | });
186 | }, []);
187 |
188 | const deleteCharacter = useCallback(() => {
189 | dispatch({
190 | type: 'delete',
191 | });
192 | }, []);
193 |
194 | const submit = useCallback(() => {
195 | if (suggestion) {
196 | insert(suggestion);
197 | onSubmit?.(state.value + suggestion);
198 | return;
199 | }
200 |
201 | onSubmit?.(state.value);
202 | }, [state.value, suggestion, insert, onSubmit]);
203 |
204 | useEffect(() => {
205 | if (state.previousValue !== state.value) {
206 | onChange?.(state.value);
207 | }
208 | }, [state.previousValue, state.value, onChange]);
209 |
210 | return {
211 | ...state,
212 | suggestion,
213 | moveCursorLeft,
214 | moveCursorRight,
215 | insert,
216 | delete: deleteCharacter,
217 | submit,
218 | };
219 | };
220 |
--------------------------------------------------------------------------------
/source/components/email-input/use-email-input.ts:
--------------------------------------------------------------------------------
1 | import {useMemo} from 'react';
2 | import {useInput} from 'ink';
3 | import chalk from 'chalk';
4 | import {type EmailInputState} from './use-email-input-state.js';
5 |
6 | export type UseEmailInputProps = {
7 | /**
8 | * When disabled, user input is ignored.
9 | *
10 | * @default false
11 | */
12 | isDisabled?: boolean;
13 |
14 | /**
15 | * Text input state.
16 | */
17 | state: EmailInputState;
18 |
19 | /**
20 | * Text to display when `value` is empty.
21 | *
22 | * @default ""
23 | */
24 | placeholder?: string;
25 | };
26 |
27 | export type UseTextInputResult = {
28 | /**
29 | * Value to render inside the input.
30 | */
31 | inputValue: string;
32 | };
33 |
34 | const cursor = chalk.inverse(' ');
35 |
36 | export const useEmailInput = ({
37 | isDisabled = false,
38 | state,
39 | placeholder = '',
40 | }: UseEmailInputProps): UseTextInputResult => {
41 | const renderedPlaceholder = useMemo(() => {
42 | if (isDisabled) {
43 | return placeholder ? chalk.dim(placeholder) : '';
44 | }
45 |
46 | return placeholder && placeholder.length > 0
47 | ? chalk.inverse(placeholder[0]) + chalk.dim(placeholder.slice(1))
48 | : cursor;
49 | }, [isDisabled, placeholder]);
50 |
51 | const renderedValue = useMemo(() => {
52 | if (isDisabled) {
53 | return state.value;
54 | }
55 |
56 | let index = 0;
57 | let result = state.value.length > 0 ? '' : cursor;
58 |
59 | for (const char of state.value) {
60 | result += index === state.cursorOffset ? chalk.inverse(char) : char;
61 |
62 | index++;
63 | }
64 |
65 | if (state.suggestion) {
66 | if (state.cursorOffset === state.value.length) {
67 | result +=
68 | chalk.inverse(state.suggestion[0]) +
69 | chalk.dim(state.suggestion.slice(1));
70 | } else {
71 | result += chalk.dim(state.suggestion);
72 | }
73 |
74 | return result;
75 | }
76 |
77 | if (state.value.length > 0 && state.cursorOffset === state.value.length) {
78 | result += cursor;
79 | }
80 |
81 | return result;
82 | }, [isDisabled, state.value, state.cursorOffset, state.suggestion]);
83 |
84 | useInput(
85 | (input, key) => {
86 | if (
87 | key.upArrow ||
88 | key.downArrow ||
89 | (key.ctrl && input === 'c') ||
90 | key.tab ||
91 | (key.shift && key.tab)
92 | ) {
93 | return;
94 | }
95 |
96 | if (key.return) {
97 | state.submit();
98 | return;
99 | }
100 |
101 | if (key.leftArrow) {
102 | state.moveCursorLeft();
103 | } else if (key.rightArrow) {
104 | state.moveCursorRight();
105 | } else if (key.backspace || key.delete) {
106 | state.delete();
107 | } else {
108 | state.insert(input);
109 | }
110 | },
111 | {isActive: !isDisabled},
112 | );
113 |
114 | return {
115 | inputValue: state.value.length > 0 ? renderedValue : renderedPlaceholder,
116 | };
117 | };
118 |
--------------------------------------------------------------------------------
/source/components/multi-select/index.ts:
--------------------------------------------------------------------------------
1 | export * from './multi-select.js';
2 |
--------------------------------------------------------------------------------
/source/components/multi-select/multi-select-option.tsx:
--------------------------------------------------------------------------------
1 | import React, {type ReactNode} from 'react';
2 | import {Box, Text} from 'ink';
3 | import figures from 'figures';
4 | import {useComponentTheme} from '../../theme.js';
5 | import {type Theme} from './theme.js';
6 |
7 | export type MultiSelectOptionProps = {
8 | /**
9 | * Determines if option is focused.
10 | */
11 | readonly isFocused: boolean;
12 |
13 | /**
14 | * Determines if option is selected.
15 | */
16 | readonly isSelected: boolean;
17 |
18 | /**
19 | * Option label.
20 | */
21 | readonly children: ReactNode;
22 | };
23 |
24 | export function MultiSelectOption({
25 | isFocused,
26 | isSelected,
27 | children,
28 | }: MultiSelectOptionProps) {
29 | const {styles} = useComponentTheme('MultiSelect');
30 |
31 | return (
32 |
33 | {isFocused && {figures.pointer}}
34 |
35 | {children}
36 |
37 | {isSelected && (
38 | {figures.tick}
39 | )}
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/source/components/multi-select/multi-select.tsx:
--------------------------------------------------------------------------------
1 | import React, {type ReactNode} from 'react';
2 | import {Box, Text} from 'ink';
3 | import {useComponentTheme} from '../../theme.js';
4 | import {type Option} from '../../types.js';
5 | import {MultiSelectOption} from './multi-select-option.js';
6 | import {useMultiSelectState} from './use-multi-select-state.js';
7 | import {useMultiSelect} from './use-multi-select.js';
8 | import {type Theme} from './theme.js';
9 |
10 | export type MultiSelectProps = {
11 | /**
12 | * When disabled, user input is ignored.
13 | *
14 | * @default false
15 | */
16 | readonly isDisabled?: boolean;
17 |
18 | /**
19 | * Number of visible options.
20 | *
21 | * @default 5
22 | */
23 | readonly visibleOptionCount?: number;
24 |
25 | /**
26 | * Highlight text in option labels.
27 | * Useful for filtering options.
28 | */
29 | readonly highlightText?: string;
30 |
31 | /**
32 | * Options.
33 | */
34 | readonly options: Option[];
35 |
36 | /**
37 | * Initially selected option values.
38 | */
39 | readonly defaultValue?: string[];
40 |
41 | /**
42 | * Callback for selecting options.
43 | */
44 | readonly onChange?: (value: string[]) => void;
45 |
46 | /**
47 | * Callback when user presses enter.
48 | * First argument is an array of selected option values.
49 | */
50 | readonly onSubmit?: (value: string[]) => void;
51 | };
52 |
53 | export function MultiSelect({
54 | isDisabled = false,
55 | visibleOptionCount = 5,
56 | highlightText,
57 | options,
58 | defaultValue,
59 | onChange,
60 | onSubmit,
61 | }: MultiSelectProps) {
62 | const state = useMultiSelectState({
63 | visibleOptionCount,
64 | options,
65 | defaultValue,
66 | onChange,
67 | onSubmit,
68 | });
69 |
70 | useMultiSelect({isDisabled, state});
71 |
72 | const {styles} = useComponentTheme('MultiSelect');
73 |
74 | return (
75 |
76 | {state.visibleOptions.map(option => {
77 | // eslint-disable-next-line prefer-destructuring
78 | let label: ReactNode = option.label;
79 |
80 | if (highlightText && option.label.includes(highlightText)) {
81 | const index = option.label.indexOf(highlightText);
82 |
83 | label = (
84 | <>
85 | {option.label.slice(0, index)}
86 | {highlightText}
87 | {option.label.slice(index + highlightText.length)}
88 | >
89 | );
90 | }
91 |
92 | return (
93 |
98 | {label}
99 |
100 | );
101 | })}
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/source/components/multi-select/theme.ts:
--------------------------------------------------------------------------------
1 | import {type BoxProps, type TextProps} from 'ink';
2 | import {type ComponentTheme} from '../../theme.js';
3 |
4 | const theme = {
5 | styles: {
6 | container: (): BoxProps => ({
7 | flexDirection: 'column',
8 | }),
9 | option: ({isFocused}): BoxProps => ({
10 | gap: 1,
11 | paddingLeft: isFocused ? 0 : 2,
12 | }),
13 | selectedIndicator: (): TextProps => ({
14 | color: 'green',
15 | }),
16 | focusIndicator: (): TextProps => ({
17 | color: 'blue',
18 | }),
19 | label({isFocused, isSelected}): TextProps {
20 | let color: string | undefined;
21 |
22 | if (isSelected) {
23 | color = 'green';
24 | }
25 |
26 | if (isFocused) {
27 | color = 'blue';
28 | }
29 |
30 | return {color};
31 | },
32 | highlightedText: (): TextProps => ({
33 | bold: true,
34 | }),
35 | },
36 | } satisfies ComponentTheme;
37 |
38 | export default theme;
39 | export type Theme = typeof theme;
40 |
--------------------------------------------------------------------------------
/source/components/multi-select/use-multi-select-state.ts:
--------------------------------------------------------------------------------
1 | import {isDeepStrictEqual} from 'node:util';
2 | import {
3 | useReducer,
4 | type Reducer,
5 | useCallback,
6 | useMemo,
7 | useState,
8 | useEffect,
9 | } from 'react';
10 | import OptionMap from '../../lib/option-map.js';
11 | import {type Option} from '../../types.js';
12 |
13 | type State = {
14 | /**
15 | * Map where key is option's value and value is option's index.
16 | */
17 | optionMap: OptionMap;
18 |
19 | /**
20 | * Number of visible options.
21 | */
22 | visibleOptionCount: number;
23 |
24 | /**
25 | * Value of the currently focused option.
26 | */
27 | focusedValue: string | undefined;
28 |
29 | /**
30 | * Index of the first visible option.
31 | */
32 | visibleFromIndex: number;
33 |
34 | /**
35 | * Index of the last visible option.
36 | */
37 | visibleToIndex: number;
38 |
39 | /**
40 | * Values of previously selected options.
41 | */
42 | previousValue: string[];
43 |
44 | /**
45 | * Indexes of selected options.
46 | */
47 | value: string[];
48 | };
49 |
50 | type Action =
51 | | FocusNextOptionAction
52 | | FocusPreviousOptionAction
53 | | ToggleFocusedOptionAction
54 | | ResetAction;
55 |
56 | type FocusNextOptionAction = {
57 | type: 'focus-next-option';
58 | };
59 |
60 | type FocusPreviousOptionAction = {
61 | type: 'focus-previous-option';
62 | };
63 |
64 | type ToggleFocusedOptionAction = {
65 | type: 'toggle-focused-option';
66 | };
67 |
68 | type ResetAction = {
69 | type: 'reset';
70 | state: State;
71 | };
72 |
73 | const reducer: Reducer = (state, action) => {
74 | switch (action.type) {
75 | case 'focus-next-option': {
76 | if (!state.focusedValue) {
77 | return state;
78 | }
79 |
80 | const item = state.optionMap.get(state.focusedValue);
81 |
82 | if (!item) {
83 | return state;
84 | }
85 |
86 | // eslint-disable-next-line prefer-destructuring
87 | const next = item.next;
88 |
89 | if (!next) {
90 | return state;
91 | }
92 |
93 | const needsToScroll = next.index >= state.visibleToIndex;
94 |
95 | if (!needsToScroll) {
96 | return {
97 | ...state,
98 | focusedValue: next.value,
99 | };
100 | }
101 |
102 | const nextVisibleToIndex = Math.min(
103 | state.optionMap.size,
104 | state.visibleToIndex + 1,
105 | );
106 |
107 | const nextVisibleFromIndex =
108 | nextVisibleToIndex - state.visibleOptionCount;
109 |
110 | return {
111 | ...state,
112 | focusedValue: next.value,
113 | visibleFromIndex: nextVisibleFromIndex,
114 | visibleToIndex: nextVisibleToIndex,
115 | };
116 | }
117 |
118 | case 'focus-previous-option': {
119 | if (!state.focusedValue) {
120 | return state;
121 | }
122 |
123 | const item = state.optionMap.get(state.focusedValue);
124 |
125 | if (!item) {
126 | return state;
127 | }
128 |
129 | // eslint-disable-next-line prefer-destructuring
130 | const previous = item.previous;
131 |
132 | if (!previous) {
133 | return state;
134 | }
135 |
136 | const needsToScroll = previous.index <= state.visibleFromIndex;
137 |
138 | if (!needsToScroll) {
139 | return {
140 | ...state,
141 | focusedValue: previous.value,
142 | };
143 | }
144 |
145 | const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1);
146 |
147 | const nextVisibleToIndex =
148 | nextVisibleFromIndex + state.visibleOptionCount;
149 |
150 | return {
151 | ...state,
152 | focusedValue: previous.value,
153 | visibleFromIndex: nextVisibleFromIndex,
154 | visibleToIndex: nextVisibleToIndex,
155 | };
156 | }
157 |
158 | case 'toggle-focused-option': {
159 | if (!state.focusedValue) {
160 | return state;
161 | }
162 |
163 | if (state.value.includes(state.focusedValue)) {
164 | const newValue = new Set(state.value);
165 | newValue.delete(state.focusedValue);
166 |
167 | return {
168 | ...state,
169 | previousValue: state.value,
170 | value: [...newValue],
171 | };
172 | }
173 |
174 | return {
175 | ...state,
176 | previousValue: state.value,
177 | value: [...state.value, state.focusedValue],
178 | };
179 | }
180 |
181 | case 'reset': {
182 | return action.state;
183 | }
184 | }
185 | };
186 |
187 | export type UseMultiSelectStateProps = {
188 | /**
189 | * Number of visible options.
190 | *
191 | * @default 5
192 | */
193 | visibleOptionCount?: number;
194 |
195 | /**
196 | * Options.
197 | */
198 | options: Option[];
199 |
200 | /**
201 | * Initially selected option values.
202 | */
203 | defaultValue?: string[];
204 |
205 | /**
206 | * Callback for selecting options.
207 | */
208 | onChange?: (value: string[]) => void;
209 |
210 | /**
211 | * Callback when user presses enter.
212 | * First argument is an array of selected option values.
213 | */
214 | onSubmit?: (value: string[]) => void;
215 | };
216 |
217 | export type MultiSelectState = Pick<
218 | State,
219 | 'focusedValue' | 'visibleFromIndex' | 'visibleToIndex' | 'value'
220 | > & {
221 | /**
222 | * Visible options.
223 | */
224 | visibleOptions: Array