44 * SPDX-License-Identifier: Apache-2.0
55 */
66
7- import React from 'react' ;
8- import { Text , Box } from 'ink' ;
9- import SelectInput , {
10- type ItemProps as InkSelectItemProps ,
11- type IndicatorProps as InkSelectIndicatorProps ,
12- } from 'ink-select-input' ;
7+ import React , { useEffect , useState } from 'react' ;
8+ import { Text , Box , useInput } from 'ink' ;
139import { Colors } from '../../colors.js' ;
1410
1511/**
@@ -20,6 +16,8 @@ export interface RadioSelectItem<T> {
2016 label : string ;
2117 value : T ;
2218 disabled ?: boolean ;
19+ themeNameDisplay ?: string ;
20+ themeTypeDisplay ?: string ;
2321}
2422
2523/**
@@ -28,115 +26,132 @@ export interface RadioSelectItem<T> {
2826 */
2927export interface RadioButtonSelectProps < T > {
3028 /** An array of items to display as radio options. */
31- items : Array <
32- RadioSelectItem < T > & {
33- themeNameDisplay ?: string ;
34- themeTypeDisplay ?: string ;
35- }
36- > ;
37-
29+ items : Array < RadioSelectItem < T > > ;
3830 /** The initial index selected */
3931 initialIndex ?: number ;
40-
4132 /** Function called when an item is selected. Receives the `value` of the selected item. */
4233 onSelect : ( value : T ) => void ;
43-
4434 /** Function called when an item is highlighted. Receives the `value` of the selected item. */
4535 onHighlight ?: ( value : T ) => void ;
46-
4736 /** Whether this select input is currently focused and should respond to input. */
4837 isFocused ?: boolean ;
38+ /** Whether to show the scroll arrows. */
39+ showScrollArrows ?: boolean ;
40+ /** The maximum number of items to show at once. */
41+ maxItemsToShow ?: number ;
4942}
5043
5144/**
52- * A specialized SelectInput component styled to look like radio buttons.
53- * It uses '◉' for selected and '○' for unselected items .
45+ * A custom component that displays a list of items with radio buttons,
46+ * supporting scrolling and keyboard navigation .
5447 *
5548 * @template T The type of the value associated with each radio item.
5649 */
5750export function RadioButtonSelect < T > ( {
5851 items,
59- initialIndex,
52+ initialIndex = 0 ,
6053 onSelect,
6154 onHighlight,
62- isFocused, // This prop indicates if the current RadioButtonSelect group is focused
55+ isFocused,
56+ showScrollArrows = true ,
57+ maxItemsToShow = 10 ,
6358} : RadioButtonSelectProps < T > ) : React . JSX . Element {
64- const handleSelect = ( item : RadioSelectItem < T > ) => {
65- onSelect ( item . value ) ;
66- } ;
67- const handleHighlight = ( item : RadioSelectItem < T > ) => {
68- if ( onHighlight ) {
69- onHighlight ( item . value ) ;
70- }
71- } ;
59+ const [ activeIndex , setActiveIndex ] = useState ( initialIndex ) ;
60+ const [ scrollOffset , setScrollOffset ] = useState ( 0 ) ;
7261
73- /**
74- * Custom indicator component displaying radio button style (◉/○).
75- * Color changes based on whether the item is selected and if its group is focused.
76- */
77- function DynamicRadioIndicator ( {
78- isSelected = false ,
79- } : InkSelectIndicatorProps ) : React . JSX . Element {
80- return (
81- < Box minWidth = { 2 } flexShrink = { 0 } >
82- < Text color = { isSelected ? Colors . AccentGreen : Colors . Foreground } >
83- { isSelected ? '●' : '○' }
84- </ Text >
85- </ Box >
62+ useEffect ( ( ) => {
63+ const newScrollOffset = Math . max (
64+ 0 ,
65+ Math . min ( activeIndex - maxItemsToShow + 1 , items . length - maxItemsToShow ) ,
8666 ) ;
87- }
67+ if ( activeIndex < scrollOffset ) {
68+ setScrollOffset ( activeIndex ) ;
69+ } else if ( activeIndex >= scrollOffset + maxItemsToShow ) {
70+ setScrollOffset ( newScrollOffset ) ;
71+ }
72+ } , [ activeIndex , items . length , scrollOffset , maxItemsToShow ] ) ;
8873
89- /**
90- * Custom item component for displaying the label.
91- * Color changes based on whether the item is selected and if its group is focused.
92- * Now also handles displaying theme type with custom color.
93- */
94- function CustomThemeItemComponent (
95- props : InkSelectItemProps ,
96- ) : React . JSX . Element {
97- const { isSelected = false , label } = props ;
98- const itemWithThemeProps = props as typeof props & {
99- themeNameDisplay ?: string ;
100- themeTypeDisplay ?: string ;
101- disabled ?: boolean ;
102- } ;
74+ useInput (
75+ ( input , key ) => {
76+ if ( input === 'k' || key . upArrow ) {
77+ const newIndex = activeIndex > 0 ? activeIndex - 1 : items . length - 1 ;
78+ setActiveIndex ( newIndex ) ;
79+ onHighlight ?.( items [ newIndex ] ! . value ) ;
80+ }
81+ if ( input === 'j' || key . downArrow ) {
82+ const newIndex = activeIndex < items . length - 1 ? activeIndex + 1 : 0 ;
83+ setActiveIndex ( newIndex ) ;
84+ onHighlight ?.( items [ newIndex ] ! . value ) ;
85+ }
86+ if ( key . return ) {
87+ onSelect ( items [ activeIndex ] ! . value ) ;
88+ }
10389
104- let textColor = Colors . Foreground ;
105- if ( isSelected ) {
106- textColor = Colors . AccentGreen ;
107- } else if ( itemWithThemeProps . disabled === true ) {
108- textColor = Colors . Gray ;
109- }
90+ // Enable selection directly from number keys.
91+ if ( / ^ [ 1 - 9 ] $ / . test ( input ) ) {
92+ const targetIndex = Number . parseInt ( input , 10 ) - 1 ;
93+ if ( targetIndex >= 0 && targetIndex < visibleItems . length ) {
94+ const selectedItem = visibleItems [ targetIndex ] ;
95+ if ( selectedItem ) {
96+ onSelect ?.( selectedItem . value ) ;
97+ }
98+ }
99+ }
100+ } ,
101+ { isActive : isFocused && items . length > 0 } ,
102+ ) ;
110103
111- if (
112- itemWithThemeProps . themeNameDisplay &&
113- itemWithThemeProps . themeTypeDisplay
114- ) {
115- return (
116- < Text color = { textColor } wrap = "truncate" >
117- { itemWithThemeProps . themeNameDisplay } { ' ' }
118- < Text color = { Colors . Gray } > { itemWithThemeProps . themeTypeDisplay } </ Text >
104+ const visibleItems = items . slice ( scrollOffset , scrollOffset + maxItemsToShow ) ;
105+
106+ return (
107+ < Box flexDirection = "column" >
108+ { showScrollArrows && (
109+ < Text color = { scrollOffset > 0 ? Colors . Foreground : Colors . Gray } >
110+ ▲
119111 </ Text >
120- ) ;
121- }
112+ ) }
113+ { visibleItems . map ( ( item , index ) => {
114+ const itemIndex = scrollOffset + index ;
115+ const isSelected = activeIndex === itemIndex ;
122116
123- return (
124- < Text color = { textColor } wrap = "truncate" >
125- { label }
126- </ Text >
127- ) ;
128- }
117+ let textColor = Colors . Foreground ;
118+ if ( isSelected ) {
119+ textColor = Colors . AccentGreen ;
120+ } else if ( item . disabled ) {
121+ textColor = Colors . Gray ;
122+ }
129123
130- initialIndex = initialIndex ?? 0 ;
131- return (
132- < SelectInput
133- indicatorComponent = { DynamicRadioIndicator }
134- itemComponent = { CustomThemeItemComponent }
135- items = { items }
136- initialIndex = { initialIndex }
137- onSelect = { handleSelect }
138- onHighlight = { handleHighlight }
139- isFocused = { isFocused }
140- />
124+ return (
125+ < Box key = { item . label } >
126+ < Box minWidth = { 2 } flexShrink = { 0 } >
127+ < Text color = { isSelected ? Colors . AccentGreen : Colors . Foreground } >
128+ { isSelected ? '●' : '○' }
129+ </ Text >
130+ </ Box >
131+ { item . themeNameDisplay && item . themeTypeDisplay ? (
132+ < Text color = { textColor } wrap = "truncate" >
133+ { item . themeNameDisplay } { ' ' }
134+ < Text color = { Colors . Gray } > { item . themeTypeDisplay } </ Text >
135+ </ Text >
136+ ) : (
137+ < Text color = { textColor } wrap = "truncate" >
138+ { item . label }
139+ </ Text >
140+ ) }
141+ </ Box >
142+ ) ;
143+ } ) }
144+ { showScrollArrows && (
145+ < Text
146+ color = {
147+ scrollOffset + maxItemsToShow < items . length
148+ ? Colors . Foreground
149+ : Colors . Gray
150+ }
151+ >
152+ ▼
153+ </ Text >
154+ ) }
155+ </ Box >
141156 ) ;
142157}
0 commit comments