Skip to content

Commit 1463088

Browse files
feat: add working multi select component
1 parent e7b23c6 commit 1463088

11 files changed

Lines changed: 339 additions & 62 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dist"
1818
],
1919
"dependencies": {
20+
"figures": "^6.1.0",
2021
"ink": "^5.2.1",
2122
"ink-big-text": "^2.0.0",
2223
"ink-divider": "^4.1.1",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

source/app.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Step1 from './import/Step1.js'
55
import Step2 from './import/Step2.js'
66
import Step3, { type Item } from './import/Step3.js'
77
import Step4 from './import/Step4.js'
8+
import Step5 from './import/Step5.js'
89
import { canShowStep } from './import/utils.js'
910

1011
const App = () => {
@@ -34,10 +35,16 @@ const App = () => {
3435
/>,
3536
<Step4
3637
onCompletion={finishStep}
37-
projectName={projectName}
38+
onSubmit={() => console.log()}
3839
installation={setupOption?.value}
3940
key={4}
4041
/>,
42+
<Step5
43+
onCompletion={finishStep}
44+
projectName={projectName}
45+
installation={setupOption?.value}
46+
key={5}
47+
/>,
4148
]
4249

4350
return (
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { Box, useInput } from 'ink'
2+
import React, { useCallback, useState } from 'react'
3+
import CheckBox from './components/Checkbox.js'
4+
import Indicator from './components/Indicator.js'
5+
import ItemComponent from './components/Item.js'
6+
7+
type Item<T> = {
8+
label: string
9+
value: T
10+
key?: string | number
11+
}
12+
13+
type MultiSelectProps<T> = {
14+
items: Item<T>[]
15+
defaultSelected?: Item<T>[]
16+
focus?: boolean
17+
initialIndex?: number
18+
indicatorComponent?: React.FC<{ isHighlighted: boolean }>
19+
checkboxComponent?: React.FC<{ isSelected: boolean }>
20+
itemComponent?: React.FC<{ isHighlighted: boolean; label: string }>
21+
limit?: number | null
22+
onSelect?: (selectedItem: Item<T>) => void
23+
onUnselect?: (unselectedItem: Item<T>) => void
24+
onSubmit?: (selectedItems: Item<T>[]) => void
25+
onHighlight?: (highlightedItem: Item<T>) => void
26+
}
27+
28+
const MultiSelect = <T,>({
29+
items = [],
30+
defaultSelected = [],
31+
focus = true,
32+
initialIndex = 0,
33+
indicatorComponent = Indicator,
34+
checkboxComponent = CheckBox,
35+
itemComponent = ItemComponent,
36+
limit = null,
37+
onSelect = () => {},
38+
onUnselect = () => {},
39+
onSubmit = () => {},
40+
onHighlight = () => {},
41+
}: MultiSelectProps<T>) => {
42+
const [highlightedIndex, setHighlightedIndex] = useState(initialIndex)
43+
const [selectedItems, setSelectedItems] = useState(defaultSelected)
44+
45+
const hasLimit = limit !== null && limit < items.length
46+
47+
const slicedItems = hasLimit ? items.slice(0, limit) : items
48+
49+
const includesItems = useCallback((item: Item<T>, selectedItems: Item<T>[]) => {
50+
return (
51+
selectedItems.filter(
52+
(selectedItem) => selectedItem.value === item.value && selectedItem.label === item.label,
53+
).length > 0
54+
)
55+
}, [])
56+
57+
const handleSelect = useCallback(
58+
(item: Item<T>) => {
59+
if (includesItems(item, selectedItems)) {
60+
const newSelectedItems = selectedItems.filter(
61+
(selectedItem) => selectedItem.value !== item.value && selectedItem.label !== item.label,
62+
)
63+
setSelectedItems(newSelectedItems)
64+
onUnselect(item)
65+
} else {
66+
const newSelectedItems = [...selectedItems, item]
67+
setSelectedItems(newSelectedItems)
68+
onSelect(item)
69+
}
70+
},
71+
[selectedItems, onSelect, onUnselect, includesItems],
72+
)
73+
74+
const handleSubmit = useCallback(() => {
75+
onSubmit(selectedItems)
76+
}, [selectedItems, onSubmit])
77+
78+
useInput(
79+
useCallback(
80+
(input, key) => {
81+
if (key.upArrow) {
82+
setHighlightedIndex((prevIndex) => {
83+
const index = prevIndex === 0 ? slicedItems.length - 1 : prevIndex - 1
84+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
85+
onHighlight(slicedItems[index]!)
86+
return index
87+
})
88+
} else if (key.downArrow) {
89+
setHighlightedIndex((prevIndex) => {
90+
const index = prevIndex === slicedItems.length - 1 ? 0 : prevIndex + 1
91+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
92+
onHighlight(slicedItems[index]!)
93+
return index
94+
})
95+
} else if (key.return) {
96+
handleSubmit()
97+
} else if (input === ' ') {
98+
// biome-ignore lint/style/noNonNullAssertion: <explanation>
99+
handleSelect(slicedItems[highlightedIndex]!)
100+
}
101+
},
102+
[onHighlight, handleSelect, handleSubmit, slicedItems, highlightedIndex],
103+
),
104+
{ isActive: focus },
105+
)
106+
107+
return (
108+
<Box flexDirection="column">
109+
{slicedItems.map((item, index) => {
110+
const key = item.key || item.label
111+
const isHighlighted = index === highlightedIndex
112+
const isSelected = includesItems(item, selectedItems)
113+
114+
return (
115+
<Box key={key}>
116+
{React.createElement(indicatorComponent, { isHighlighted })}
117+
{React.createElement(checkboxComponent, { isSelected })}
118+
{React.createElement(itemComponent, {
119+
...item,
120+
isHighlighted,
121+
})}
122+
</Box>
123+
)
124+
})}
125+
</Box>
126+
)
127+
}
128+
129+
export default MultiSelect
130+
131+
export { Indicator, ItemComponent, CheckBox, type Item }
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from "react";
2+
import { Box, Text } from "ink";
3+
import figures from "figures";
4+
5+
type CheckBoxProps = {
6+
isSelected: boolean;
7+
};
8+
9+
const CheckBox = ({ isSelected = false }: CheckBoxProps) => (
10+
<Box marginRight={1}>
11+
<Text color="green">
12+
{isSelected ? figures.circleFilled : figures.circle}
13+
</Text>
14+
</Box>
15+
);
16+
17+
export default CheckBox;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from "react";
2+
import { Box, Text } from "ink";
3+
import figures from "figures";
4+
5+
type IndicatorProps = {
6+
isHighlighted: boolean;
7+
};
8+
9+
const Indicator = ({ isHighlighted = false }: IndicatorProps) => (
10+
<Box marginRight={1}>
11+
<Text color={isHighlighted ? "blue" : undefined}>
12+
{isHighlighted ? figures.pointer : " "}
13+
</Text>
14+
</Box>
15+
);
16+
17+
export default Indicator;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from "react";
2+
import { Text } from "ink";
3+
4+
type ItemProps = {
5+
isHighlighted: boolean;
6+
label: string;
7+
};
8+
9+
const Item = ({ isHighlighted = false, label }: ItemProps) => (
10+
<Text color={isHighlighted ? "blue" : undefined}>{label}</Text>
11+
);
12+
13+
export default Item;

source/import/Multiselect/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {default, Item, Indicator, CheckBox, ItemComponent} from './MultiSelect.js';

source/import/Step3.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import figures from 'figures'
12
import { Text } from 'ink'
23
import Divider from 'ink-divider'
34
import SelectInput from 'ink-select-input'
@@ -45,7 +46,7 @@ const Step3: FC<Props> = ({ onCompletion, onSelect }) => {
4546
<Text color={'whiteBright'}>Choose installation type</Text>
4647
<SelectInput
4748
indicatorComponent={({ isSelected }) => (
48-
<Text color="green">{isSelected ? '> ' : ' '}</Text>
49+
<Text color="green">{isSelected ? `${figures.pointer} ` : ' '}</Text>
4950
)}
5051
itemComponent={({ label, isSelected }) => (
5152
<Text

source/import/Step4.tsx

Lines changed: 73 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,85 @@
1-
import { join } from 'node:path'
2-
import process from 'node:process'
3-
import { Box, Text } from 'ink'
4-
import Divider from 'ink-divider'
5-
import { Script, Spawn } from 'ink-spawn'
6-
import React, { type FC, useState } from 'react'
7-
import CustomInstallation from './CustomInstallation.js'
8-
import FullInstallation from './FullInstallation.js'
9-
import fullInstallation from './FullInstallation.js'
1+
import { Text } from 'ink'
2+
import MultiSelect from './Multiselect/index.js'
3+
4+
import React, { useState, type FC, useEffect } from 'react'
105
import type { Installation } from './Step3.js'
116

7+
interface Item {
8+
label: string
9+
value: string
10+
}
11+
1212
interface Props {
13-
installation: Installation | undefined
14-
projectName: string
1513
onCompletion: () => void
14+
onSubmit: (item: Item) => void
15+
installation: Installation | undefined
1616
}
1717

18-
const Step4: FC<Props> = ({ projectName, onCompletion, installation }) => {
19-
const projectDir = join(process.cwd(), projectName)
20-
const [canInstall, setCanInstall] = useState(false)
18+
const customPackages: Array<Item> = [
19+
{
20+
label: 'Component Demos',
21+
value: 'demo',
22+
},
23+
{
24+
label: 'Subgraph support',
25+
value: 'subgraph',
26+
},
27+
{
28+
label: 'Typedoc documentation support',
29+
value: 'typedoc',
30+
},
31+
{
32+
label: 'Vocs documentation support',
33+
value: 'vocs',
34+
},
35+
{
36+
label: 'Husky Git hooks support',
37+
value: 'husky',
38+
},
39+
]
40+
41+
const Step4: FC<Props> = ({ onCompletion, onSubmit, installation }) => {
42+
const [isFocused, setIsFocused] = useState(true)
43+
const [showCustomOptions, setShowCustomOptions] = useState(false)
44+
const skip = installation === 'full'
45+
46+
// biome-ignore lint/correctness/useExhaustiveDependencies: Run this only once
47+
useEffect(() => {
48+
if (skip) {
49+
onCompletion()
50+
}
51+
}, [])
52+
53+
const onHandleSubmit = (item: Array<Item>) => {
54+
// onSubmit(item)
55+
//
56+
// if (item.value === 'full') {
57+
// onCompletion()
58+
// } else {
59+
// setShowCustomOptions(true)
60+
// }
61+
// setIsFocused(false)
62+
}
2163

22-
return (
64+
return skip ? null : (
2365
<>
24-
<Divider
25-
titlePadding={2}
26-
titleColor={'whiteBright'}
27-
title={`Performing ${installation ?? 'full'} installation`}
66+
<Text color={'whiteBright'}>Choose optional packages</Text>
67+
<MultiSelect
68+
// indicatorComponent={({ isSelected }) => (
69+
// <Text color="green">{isSelected ? '> ' : ' '}</Text>
70+
// )}
71+
// itemComponent={({ label, isSelected }) => (
72+
// <Text
73+
// color={isSelected ? 'green' : 'white'}
74+
// bold={isSelected}
75+
// >
76+
// {label}
77+
// </Text>
78+
// )}
79+
// isFocused={isFocused}
80+
items={customPackages}
81+
onSubmit={onHandleSubmit}
2882
/>
29-
<Box
30-
flexDirection={'column'}
31-
gap={0}
32-
>
33-
<Script>
34-
<Box columnGap={1}>
35-
<Text color={'whiteBright'}>
36-
Creating{' '}
37-
<Text
38-
italic
39-
color={'white'}
40-
>
41-
.env.local
42-
</Text>{' '}
43-
file
44-
</Text>
45-
</Box>
46-
<Spawn
47-
shell
48-
cwd={projectDir}
49-
silent
50-
command={'cp'}
51-
args={['.env.example', '.env.local']}
52-
runningText={'Working...'}
53-
successText={'Done!'}
54-
failureText={'Error...'}
55-
onCompletion={() => setCanInstall(true)}
56-
/>
57-
</Script>
58-
{canInstall && installation === 'full' ? (
59-
<FullInstallation
60-
projectName={projectName}
61-
onCompletion={() => console.log()}
62-
/>
63-
) : (
64-
<CustomInstallation
65-
projectName={projectName}
66-
onCompletion={() => console.log()}
67-
/>
68-
)}
69-
</Box>
7083
</>
7184
)
7285
}

0 commit comments

Comments
 (0)