1- import { useState , useMemo } from ' react' ;
2- import MuiSelect from ' @mui/material/Select' ;
3- import MenuItem from ' @mui/material/MenuItem' ;
4- import FormControl from ' @mui/material/FormControl' ;
5- import InputLabel from ' @mui/material/InputLabel' ;
6- import FormHelperText from ' @mui/material/FormHelperText' ;
7- import ListItemIcon from ' @mui/material/ListItemIcon' ;
8- import ListItemText from ' @mui/material/ListItemText' ;
9- import CircularProgress from ' @mui/material/CircularProgress' ;
10- import OutlinedInput from ' @mui/material/OutlinedInput' ;
11- import InputBase from ' @mui/material/InputBase' ;
12- import Chip from ' @mui/material/Chip' ;
13- import Box from ' @mui/material/Box' ;
14- import SearchIcon from ' @mui/icons-material/Search' ;
15- import ClearIcon from ' @mui/icons-material/Clear' ;
16- import type { SxProps , Theme } from ' @mui/material/styles' ;
17- import type { SelectChangeEvent } from ' @mui/material/Select' ;
1+ import { useState , useMemo } from " react" ;
2+ import MuiSelect from " @mui/material/Select" ;
3+ import MenuItem from " @mui/material/MenuItem" ;
4+ import FormControl from " @mui/material/FormControl" ;
5+ import InputLabel from " @mui/material/InputLabel" ;
6+ import FormHelperText from " @mui/material/FormHelperText" ;
7+ import ListItemIcon from " @mui/material/ListItemIcon" ;
8+ import ListItemText from " @mui/material/ListItemText" ;
9+ import CircularProgress from " @mui/material/CircularProgress" ;
10+ import OutlinedInput from " @mui/material/OutlinedInput" ;
11+ import InputBase from " @mui/material/InputBase" ;
12+ import Chip from " @mui/material/Chip" ;
13+ import Box from " @mui/material/Box" ;
14+ import SearchIcon from " @mui/icons-material/Search" ;
15+ import ClearIcon from " @mui/icons-material/Clear" ;
16+ import type { SxProps , Theme } from " @mui/material/styles" ;
17+ import type { SelectChangeEvent } from " @mui/material/Select" ;
1818
19- import type { SemanticColor , ComponentSize } from ' ../types' ;
20- import { toMuiInputSize , toMuiColor , INPUT_HEIGHTS } from ' ../types' ;
19+ import type { SemanticColor , ComponentSize } from " ../types" ;
20+ import { toMuiInputSize , toMuiColor , INPUT_HEIGHTS } from " ../types" ;
2121
2222export interface SelectOption {
2323 value : string ;
@@ -50,8 +50,8 @@ export default function Select({
5050 options,
5151 value,
5252 onChange,
53- size = 'md' ,
54- color = ' neutral' ,
53+ size = "md" ,
54+ color = " neutral" ,
5555 label,
5656 helperText,
5757 placeholder,
@@ -64,11 +64,20 @@ export default function Select({
6464 clearable = false ,
6565 sx,
6666} : SelectProps ) {
67- const [ search , setSearch ] = useState ( '' ) ;
67+ const [ search , setSearch ] = useState ( "" ) ;
68+ const [ isOpen , setIsOpen ] = useState ( false ) ;
69+ const [ focusedIndex , setFocusedIndex ] = useState ( - 1 ) ;
70+
6871 const muiSize = toMuiInputSize ( size ) ;
6972 const muiColor = toMuiColor ( color ) as any ;
70- const hasError = typeof error === 'string' ? ! ! error : error ;
71- const errorText = typeof error === 'string' ? error : undefined ;
73+ const hasError = typeof error === "string" ? ! ! error : error ;
74+ const errorText = typeof error === "string" ? error : undefined ;
75+
76+ const closeAndReset = ( ) => {
77+ setIsOpen ( false ) ;
78+ setFocusedIndex ( - 1 ) ;
79+ if ( searchable ) setSearch ( "" ) ;
80+ } ;
7281
7382 const filteredOptions = useMemo ( ( ) => {
7483 if ( ! searchable || ! search ) return options ;
@@ -80,18 +89,73 @@ export default function Select({
8089 onChange ( e . target . value ) ;
8190 } ;
8291
92+ const handleSearchKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
93+ if ( e . key === "Escape" ) {
94+ closeAndReset ( ) ;
95+ return ;
96+ }
97+
98+ if ( e . key === "ArrowDown" ) {
99+ e . preventDefault ( ) ;
100+ e . stopPropagation ( ) ;
101+ if ( filteredOptions . length === 0 ) return ;
102+ const next = focusedIndex + 1 ;
103+ setFocusedIndex ( next >= filteredOptions . length ? 0 : next ) ;
104+ return ;
105+ }
106+
107+ if ( e . key === "ArrowUp" ) {
108+ e . preventDefault ( ) ;
109+ e . stopPropagation ( ) ;
110+ const prev = focusedIndex - 1 ;
111+ setFocusedIndex ( prev < 0 ? filteredOptions . length - 1 : prev ) ;
112+ return ;
113+ }
114+
115+ if (
116+ e . key === "Enter" &&
117+ focusedIndex >= 0 &&
118+ focusedIndex < filteredOptions . length
119+ ) {
120+ e . preventDefault ( ) ;
121+ e . stopPropagation ( ) ;
122+ const opt = filteredOptions [ focusedIndex ] ;
123+ if ( ! opt . disabled ) {
124+ if ( multiple ) {
125+ const arr = Array . isArray ( value ) ? value : [ ] ;
126+ const newValue = arr . includes ( opt . value )
127+ ? arr . filter ( ( v ) => v !== opt . value )
128+ : [ ...arr , opt . value ] ;
129+ onChange ( newValue ) ;
130+ } else {
131+ onChange ( opt . value ) ;
132+ closeAndReset ( ) ;
133+ }
134+ }
135+ return ;
136+ }
137+
138+ e . stopPropagation ( ) ;
139+ } ;
140+
83141 return (
84142 < FormControl
85143 size = { muiSize }
86144 fullWidth = { fullWidth }
87145 error = { hasError }
88146 disabled = { disabled }
89- style = { { '--ov-input-height' : INPUT_HEIGHTS [ size ] } as React . CSSProperties }
147+ style = {
148+ { "--ov-input-height" : INPUT_HEIGHTS [ size ] } as React . CSSProperties
149+ }
90150 sx = { sx }
91151 >
92152 { label && (
93153 < InputLabel
94- color = { muiColor === 'default' || muiColor === 'inherit' ? undefined : muiColor }
154+ color = {
155+ muiColor === "default" || muiColor === "inherit"
156+ ? undefined
157+ : muiColor
158+ }
95159 shrink = { ! ! placeholder || undefined }
96160 >
97161 { label }
@@ -103,124 +167,155 @@ export default function Select({
103167 multiple = { multiple }
104168 displayEmpty = { ! ! placeholder }
105169 label = { label }
106- notched = { label ? ( ! ! placeholder || undefined ) : undefined }
170+ notched = { label ? ! ! placeholder || undefined : undefined }
107171 input = { multiple ? < OutlinedInput label = { label } /> : undefined }
108- onClose = { ( ) => { if ( searchable ) setSearch ( '' ) ; } }
172+ open = { isOpen }
173+ onOpen = { ( ) => {
174+ setIsOpen ( true ) ;
175+ setFocusedIndex ( - 1 ) ;
176+ } }
177+ onClose = { closeAndReset }
109178 renderValue = {
110179 multiple
111180 ? ( selected ) => {
112181 const arr = selected as string [ ] ;
113182 if ( arr . length === 0 && placeholder ) {
114- return < Box sx = { { color : 'var(--ov-fg-faint)' } } > { placeholder } </ Box > ;
183+ return (
184+ < Box sx = { { color : "var(--ov-fg-faint)" } } >
185+ { placeholder }
186+ </ Box >
187+ ) ;
115188 }
116189 return (
117- < Box sx = { { display : 'flex' , flexWrap : 'nowrap' , gap : 0.5 , overflow : 'hidden' , alignItems : 'center' } } >
190+ < Box
191+ sx = { {
192+ display : "flex" ,
193+ flexWrap : "nowrap" ,
194+ gap : 0.5 ,
195+ overflow : "hidden" ,
196+ alignItems : "center" ,
197+ } }
198+ >
118199 { arr . map ( ( val ) => {
119200 const opt = options . find ( ( o ) => o . value === val ) ;
120201 return (
121202 < Chip
122203 key = { val }
123204 label = { opt ?. label ?? val }
124205 size = "small"
125- { ...( clearable ? {
126- onDelete : ( e : React . SyntheticEvent ) => {
127- e . stopPropagation ( ) ;
128- onChange ( arr . filter ( v => v !== val ) ) ;
129- } ,
130- onMouseDown : ( e : React . MouseEvent ) => {
131- e . stopPropagation ( ) ;
132- } ,
133- } : { } ) }
206+ { ...( clearable
207+ ? {
208+ onDelete : ( e : React . SyntheticEvent ) => {
209+ e . stopPropagation ( ) ;
210+ onChange ( arr . filter ( ( v ) => v !== val ) ) ;
211+ } ,
212+ onMouseDown : ( e : React . MouseEvent ) => {
213+ e . stopPropagation ( ) ;
214+ } ,
215+ }
216+ : { } ) }
134217 />
135218 ) ;
136219 } ) }
137220 { clearable && arr . length > 1 && (
138- < span
139- role = "button"
221+ < button
222+ type = "button"
140223 aria-label = "Clear all"
141- onMouseDown = { ( e ) => {
224+ onClick = { ( e ) => {
142225 e . stopPropagation ( ) ;
143- e . preventDefault ( ) ;
144226 onChange ( [ ] ) ;
145227 } }
146228 style = { {
147- display : ' inline-flex' ,
148- alignItems : ' center' ,
229+ display : " inline-flex" ,
230+ alignItems : " center" ,
149231 flexShrink : 0 ,
150232 marginLeft : 2 ,
151- cursor : ' pointer' ,
152- color : ' var(--ov-fg-faint)' ,
233+ cursor : " pointer" ,
234+ color : " var(--ov-fg-faint)" ,
153235 lineHeight : 0 ,
236+ background : "none" ,
237+ border : "none" ,
238+ padding : 0 ,
154239 } }
155240 >
156241 < ClearIcon sx = { { fontSize : 16 } } />
157- </ span >
242+ </ button >
158243 ) }
159244 </ Box >
160245 ) ;
161246 }
162247 : placeholder && ! value
163248 ? ( ) => (
164- < Box sx = { { color : ' var(--ov-fg-faint)' } } > { placeholder } </ Box >
249+ < Box sx = { { color : " var(--ov-fg-faint)" } } > { placeholder } </ Box >
165250 )
166251 : undefined
167252 }
168253 MenuProps = { {
169254 PaperProps : {
170255 sx : {
171256 maxHeight : 300 ,
172- ...( size === 'xs' || size === 'sm' ? {
173- '& .MuiMenuItem-root' : {
174- fontSize : '0.8rem' ,
175- minHeight : 28 ,
176- padding : '3px 8px' ,
177- } ,
178- '& .MuiListItemText-root' : {
179- margin : 0 ,
180- } ,
181- '& .MuiListItemText-primary' : {
182- fontSize : '0.8rem' ,
183- } ,
184- '& .MuiListItemIcon-root' : {
185- minWidth : 22 ,
186- } ,
187- } : { } ) ,
257+ ...( size === "xs" || size === "sm"
258+ ? {
259+ "& .MuiMenuItem-root" : {
260+ fontSize : "0.8rem" ,
261+ minHeight : 28 ,
262+ padding : "3px 8px" ,
263+ } ,
264+ "& .MuiListItemText-root" : {
265+ margin : 0 ,
266+ } ,
267+ "& .MuiListItemText-primary" : {
268+ fontSize : "0.8rem" ,
269+ } ,
270+ "& .MuiListItemIcon-root" : {
271+ minWidth : 22 ,
272+ } ,
273+ }
274+ : { } ) ,
188275 } ,
189276 } ,
190277 } }
191278 >
192279 { searchable && (
193280 < Box
194281 sx = { { px : 1 , pb : 0.5 , pt : 0.5 } }
195- onKeyDown = { ( e ) => e . stopPropagation ( ) }
196282 onClickCapture = { ( e ) => e . stopPropagation ( ) }
197283 onMouseDown = { ( e ) => e . stopPropagation ( ) }
198284 >
199285 < Box
200286 sx = { {
201- display : ' flex' ,
202- alignItems : ' center' ,
287+ display : " flex" ,
288+ alignItems : " center" ,
203289 height : 28 ,
204- border : ' 1px solid var(--ov-border-default)' ,
205- borderRadius : ' 4px' ,
206- bgcolor : ' var(--ov-bg-base)' ,
290+ border : " 1px solid var(--ov-border-default)" ,
291+ borderRadius : " 4px" ,
292+ bgcolor : " var(--ov-bg-base)" ,
207293 px : 0.75 ,
208- ' &:focus-within' : { borderColor : ' var(--ov-accent)' } ,
294+ " &:focus-within" : { borderColor : " var(--ov-accent)" } ,
209295 } }
210296 >
211- < SearchIcon sx = { { fontSize : 14 , color : 'var(--ov-fg-faint)' , mr : 0.5 } } />
297+ < SearchIcon
298+ sx = { { fontSize : 14 , color : "var(--ov-fg-faint)" , mr : 0.5 } }
299+ />
212300 < InputBase
213301 autoFocus
214302 fullWidth
215303 placeholder = "Search..."
216304 value = { search }
217- onChange = { ( e ) => setSearch ( e . target . value ) }
305+ onChange = { ( e ) => {
306+ setSearch ( e . target . value ) ;
307+ setFocusedIndex ( - 1 ) ;
308+ } }
309+ onKeyDown = { handleSearchKeyDown }
218310 sx = { {
219311 flex : 1 ,
220- fontSize : '0.75rem' ,
221- color : 'var(--ov-fg-default)' ,
222- '& input' : { py : 0 , px : 0 } ,
223- '& input::placeholder' : { color : 'var(--ov-fg-faint)' , opacity : 1 } ,
312+ fontSize : "0.75rem" ,
313+ color : "var(--ov-fg-default)" ,
314+ "& input" : { py : 0 , px : 0 } ,
315+ "& input::placeholder" : {
316+ color : "var(--ov-fg-faint)" ,
317+ opacity : 1 ,
318+ } ,
224319 } }
225320 />
226321 </ Box >
@@ -231,9 +326,21 @@ export default function Select({
231326 < CircularProgress size = { 16 } sx = { { mr : 1 } } /> Loading...
232327 </ MenuItem >
233328 ) }
234- { filteredOptions . map ( ( opt ) => (
235- < MenuItem key = { opt . value } value = { opt . value } disabled = { opt . disabled } >
236- { opt . icon && < ListItemIcon sx = { { minWidth : 28 } } > { opt . icon } </ ListItemIcon > }
329+ { filteredOptions . map ( ( opt , idx ) => (
330+ < MenuItem
331+ key = { opt . value }
332+ value = { opt . value }
333+ disabled = { opt . disabled }
334+ { ...( searchable
335+ ? {
336+ onMouseEnter : ( ) => setFocusedIndex ( idx ) ,
337+ sx : idx === focusedIndex ? { bgcolor : "action.focus" } : undefined ,
338+ }
339+ : { } ) }
340+ >
341+ { opt . icon && (
342+ < ListItemIcon sx = { { minWidth : 28 } } > { opt . icon } </ ListItemIcon >
343+ ) }
237344 < ListItemText > { opt . label } </ ListItemText >
238345 </ MenuItem >
239346 ) ) }
@@ -248,4 +355,4 @@ export default function Select({
248355 ) ;
249356}
250357
251- Select . displayName = ' Select' ;
358+ Select . displayName = " Select" ;
0 commit comments