@@ -25,12 +25,28 @@ const tabsScrollableClassName = css({
2525 marginBottom : "-1px" ,
2626} ) ;
2727
28+ const tabContainer = css ( {
29+ display : "flex" ,
30+ alignItems : "center" ,
31+ outline : "none" ,
32+ position : "relative" ,
33+ paddingRight : "20px" ,
34+ margin : "1px 0" ,
35+
36+ "&:has(button:focus)" : {
37+ outline : "$colors$accent auto 1px" ,
38+ } ,
39+ } ) ;
40+
2841const closeButtonClassName = css ( {
2942 padding : "0 $space$1 0 $space$1" ,
3043 borderRadius : "$border$radius" ,
3144 marginLeft : "$space$1" ,
3245 width : "$space$5" ,
3346 visibility : "hidden" ,
47+ cursor : "pointer" ,
48+ position : "absolute" ,
49+ right : "0px" ,
3450
3551 svg : {
3652 width : "$space$3" ,
@@ -46,15 +62,22 @@ export const tabButton = css({
4662 height : "$layout$headerHeight" ,
4763 whiteSpace : "nowrap" ,
4864
49- "&:focus" : { outline : "none" } ,
50- [ `&:hover > .${ closeButtonClassName } ` ] : { visibility : "unset" } ,
65+ "&:focus" : {
66+ outline : "none" ,
67+ } ,
68+ [ `&:hover ~ .${ closeButtonClassName } ` ] : { visibility : "visible" } ,
5169} ) ;
5270
5371export interface FileTabsProps {
5472 /**
5573 * This adds a close button next to each file with a unique trigger to close it.
5674 */
5775 closableTabs ?: boolean ;
76+ /**
77+ * unique id appended with active files. This is
78+ * used in aria-controls value along the combination of activeFile
79+ */
80+ activeFileUniqueId ?: string ;
5881}
5982
6083/**
@@ -70,6 +93,7 @@ export const FileTabs = ({
7093 const classNames = useClassNames ( ) ;
7194
7295 const { activeFile, visibleFiles, setActiveFile } = sandpack ;
96+ const [ hoveredIndex , setIsHoveredIndex ] = React . useState < null | number > ( null ) ;
7397
7498 const handleCloseFile = ( ev : React . MouseEvent < HTMLDivElement > ) : void => {
7599 ev . stopPropagation ( ) ;
@@ -111,6 +135,56 @@ export const FileTabs = ({
111135 }
112136 } ;
113137
138+ const onKeyDown = ( {
139+ e,
140+ index,
141+ } : {
142+ e : React . KeyboardEvent < HTMLElement > ;
143+ index : number ;
144+ } ) => {
145+ const target = e . currentTarget as HTMLElement ;
146+
147+ switch ( e . key ) {
148+ case "ArrowLeft" :
149+ {
150+ const leftSibling = target . previousElementSibling as HTMLElement ;
151+
152+ if ( leftSibling ) {
153+ leftSibling . querySelector ( "button" ) ?. focus ( ) ;
154+ setActiveFile ( visibleFiles [ index - 1 ] ) ;
155+ }
156+ }
157+ break ;
158+ case "ArrowRight" :
159+ {
160+ const rightSibling = target . nextElementSibling as HTMLElement ;
161+
162+ if ( rightSibling ) {
163+ rightSibling . querySelector ( "button" ) ?. focus ( ) ;
164+ setActiveFile ( visibleFiles [ index + 1 ] ) ;
165+ }
166+ }
167+ break ;
168+ case "Home" : {
169+ const parent = target . parentElement as HTMLElement ;
170+
171+ const firstChild = parent . firstElementChild as HTMLElement ;
172+ firstChild . querySelector ( "button" ) ?. focus ( ) ;
173+ setActiveFile ( visibleFiles [ 0 ] ) ;
174+ break ;
175+ }
176+ case "End" : {
177+ const parent = target . parentElement as HTMLElement ;
178+ const lastChild = parent . lastElementChild as HTMLElement ;
179+ lastChild . querySelector ( "button" ) ?. focus ( ) ;
180+ setActiveFile ( visibleFiles [ - 1 ] ) ;
181+ break ;
182+ }
183+ default :
184+ break ;
185+ }
186+ } ;
187+
114188 return (
115189 < div
116190 className = { classNames ( "tabs" , [ tabsClassName , className ] ) }
@@ -124,27 +198,44 @@ export const FileTabs = ({
124198 ] ) }
125199 role = "tablist"
126200 >
127- { visibleFiles . map ( ( filePath ) => (
128- < button
129- key = { filePath }
201+ { visibleFiles . map ( ( filePath , index ) => (
202+ < div
203+ aria-controls = { ` ${ filePath } - ${ props . activeFileUniqueId } -tab-panel` }
130204 aria-selected = { filePath === activeFile }
131- className = { classNames ( "tab-button" , [ buttonClassName , tabButton ] ) }
132- data-active = { filePath === activeFile }
133- onClick = { ( ) : void => setActiveFile ( filePath ) }
205+ className = { classNames ( "tab-container" , [ tabContainer ] ) }
206+ onKeyDown = { ( e ) => onKeyDown ( { e, index } ) }
207+ onMouseEnter = { ( ) => setIsHoveredIndex ( index ) }
208+ onMouseLeave = { ( ) => setIsHoveredIndex ( null ) }
134209 role = "tab"
135- title = { filePath }
136- type = "button"
137210 >
138- { getTriggerText ( filePath ) }
211+ < button
212+ key = { filePath }
213+ className = { classNames ( "tab-button" , [ buttonClassName , tabButton ] ) }
214+ data-active = { filePath === activeFile }
215+ id = { `${ filePath } -${ props . activeFileUniqueId } -tab` }
216+ onClick = { ( ) : void => setActiveFile ( filePath ) }
217+ tabIndex = { filePath === activeFile ? 0 : - 1 }
218+ title = { filePath }
219+ type = "button"
220+ >
221+ { getTriggerText ( filePath ) }
222+ </ button >
139223 { closableTabs && visibleFiles . length > 1 && (
140224 < span
141225 className = { classNames ( "close-button" , [ closeButtonClassName ] ) }
142226 onClick = { handleCloseFile }
227+ style = { {
228+ visibility :
229+ filePath === activeFile || hoveredIndex === index
230+ ? "visible"
231+ : "hidden" ,
232+ } }
233+ tabIndex = { filePath === activeFile ? 0 : - 1 }
143234 >
144235 < CloseIcon />
145236 </ span >
146237 ) }
147- </ button >
238+ </ div >
148239 ) ) }
149240 </ div >
150241 </ div >
0 commit comments