@@ -41,44 +41,93 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
4141 const tabRefs = useRef ( new Map < string , HTMLDivElement > ( ) ) ;
4242 const dragState = useRef < { id : string | null ; index : number } > ( { id : null , index : - 1 } ) ;
4343 const [ menuState , setMenuState ] = useState < MenuState > ( INITIAL_MENU_STATE ) ;
44- const [ isOverflowing , setIsOverflowing ] = useState ( false ) ;
44+ const [ scrollState , setScrollState ] = useState ( {
45+ canScrollLeft : false ,
46+ canScrollRight : false ,
47+ hiddenTabIds : [ ] as string [ ] ,
48+ } ) ;
4549
46- const updateOverflow = useCallback ( ( ) => {
50+ const updateScrollState = useCallback ( ( ) => {
4751 const container = scrollContainerRef . current ;
4852 if ( ! container ) {
49- setIsOverflowing ( false ) ;
53+ setScrollState ( { canScrollLeft : false , canScrollRight : false , hiddenTabIds : [ ] } ) ;
5054 return ;
5155 }
52- setIsOverflowing ( container . scrollWidth > container . clientWidth + 1 ) ;
53- } , [ ] ) ;
56+
57+ const { scrollLeft, scrollWidth, clientWidth } = container ;
58+ const containerRect = container . getBoundingClientRect ( ) ;
59+ const hiddenTabIds : string [ ] = [ ] ;
60+
61+ for ( const id of openDocumentIds ) {
62+ const element = tabRefs . current . get ( id ) ;
63+ if ( ! element ) continue ;
64+ const rect = element . getBoundingClientRect ( ) ;
65+ const isHiddenLeft = rect . right <= containerRect . left + 2 ;
66+ const isHiddenRight = rect . left >= containerRect . right - 2 ;
67+ if ( isHiddenLeft || isHiddenRight ) {
68+ hiddenTabIds . push ( id ) ;
69+ }
70+ }
71+
72+ const canScrollLeft = scrollLeft > 1 ;
73+ const canScrollRight = scrollLeft + clientWidth < scrollWidth - 1 ;
74+
75+ setScrollState ( ( previous ) => {
76+ if (
77+ previous . canScrollLeft === canScrollLeft &&
78+ previous . canScrollRight === canScrollRight &&
79+ previous . hiddenTabIds . length === hiddenTabIds . length &&
80+ previous . hiddenTabIds . every ( ( id , index ) => id === hiddenTabIds [ index ] )
81+ ) {
82+ return previous ;
83+ }
84+ return {
85+ canScrollLeft,
86+ canScrollRight,
87+ hiddenTabIds,
88+ } ;
89+ } ) ;
90+ } , [ openDocumentIds ] ) ;
5491
5592 useEffect ( ( ) => {
56- updateOverflow ( ) ;
57- } , [ updateOverflow , openDocumentIds . length , documents ] ) ;
93+ updateScrollState ( ) ;
94+ } , [ updateScrollState , openDocumentIds . length , documents ] ) ;
5895
5996 useEffect ( ( ) => {
6097 const container = scrollContainerRef . current ;
6198 if ( ! container || typeof ResizeObserver === 'undefined' ) return ;
6299
63- const observer = new ResizeObserver ( ( ) => updateOverflow ( ) ) ;
100+ const observer = new ResizeObserver ( ( ) => updateScrollState ( ) ) ;
64101 observer . observe ( container ) ;
65102 if ( container . parentElement ) {
66103 observer . observe ( container . parentElement ) ;
67104 }
68- window . addEventListener ( 'resize' , updateOverflow ) ;
105+ window . addEventListener ( 'resize' , updateScrollState ) ;
69106 return ( ) => {
70107 observer . disconnect ( ) ;
71- window . removeEventListener ( 'resize' , updateOverflow ) ;
108+ window . removeEventListener ( 'resize' , updateScrollState ) ;
72109 } ;
73- } , [ updateOverflow ] ) ;
110+ } , [ updateScrollState ] ) ;
74111
75112 useEffect ( ( ) => {
76113 if ( ! activeDocumentId ) return ;
77114 const element = tabRefs . current . get ( activeDocumentId ) ;
78115 if ( element ) {
79116 element . scrollIntoView ( { behavior : 'smooth' , inline : 'nearest' , block : 'nearest' } ) ;
117+ requestAnimationFrame ( ( ) => updateScrollState ( ) ) ;
80118 }
81- } , [ activeDocumentId ] ) ;
119+ } , [ activeDocumentId , updateScrollState ] ) ;
120+
121+ useEffect ( ( ) => {
122+ const container = scrollContainerRef . current ;
123+ if ( ! container ) return ;
124+
125+ const handleScroll = ( ) => updateScrollState ( ) ;
126+ container . addEventListener ( 'scroll' , handleScroll , { passive : true } ) ;
127+ return ( ) => {
128+ container . removeEventListener ( 'scroll' , handleScroll ) ;
129+ } ;
130+ } , [ updateScrollState ] ) ;
82131
83132 const closeMenu = useCallback ( ( ) => {
84133 setMenuState ( INITIAL_MENU_STATE ) ;
@@ -110,8 +159,11 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
110159
111160 const openOverflowMenu = useCallback ( ( event : React . MouseEvent < HTMLButtonElement > ) => {
112161 event . preventDefault ( ) ;
162+ if ( ! scrollState . hiddenTabIds . length ) {
163+ return ;
164+ }
113165 const rect = event . currentTarget . getBoundingClientRect ( ) ;
114- const items : MenuItem [ ] = openDocumentIds . map ( ( id ) => {
166+ const items : MenuItem [ ] = scrollState . hiddenTabIds . map ( ( id ) => {
115167 const doc = docsById . get ( id ) ;
116168 const displayTitle = doc ?. title ?. trim ( ) || 'Untitled Document' ;
117169 return {
@@ -126,7 +178,41 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
126178 position : { x : rect . left , y : rect . bottom + 4 } ,
127179 items,
128180 } ) ;
129- } , [ openDocumentIds , docsById , onSelectTab , activeDocumentId ] ) ;
181+ } , [ scrollState . hiddenTabIds , docsById , onSelectTab , activeDocumentId ] ) ;
182+
183+ const scrollToDirection = useCallback ( ( direction : 'left' | 'right' ) => {
184+ const container = scrollContainerRef . current ;
185+ if ( ! container ) return ;
186+
187+ const { clientWidth, scrollLeft } = container ;
188+ if ( ! openDocumentIds . length ) return ;
189+
190+ if ( direction === 'left' ) {
191+ for ( let index = openDocumentIds . length - 1 ; index >= 0 ; index -= 1 ) {
192+ const id = openDocumentIds [ index ] ;
193+ const element = tabRefs . current . get ( id ) ;
194+ if ( ! element ) continue ;
195+ const tabStart = element . offsetLeft ;
196+ if ( tabStart < scrollLeft - 1 ) {
197+ container . scrollTo ( { left : tabStart , behavior : 'smooth' } ) ;
198+ return ;
199+ }
200+ }
201+ container . scrollTo ( { left : 0 , behavior : 'smooth' } ) ;
202+ } else {
203+ for ( let index = 0 ; index < openDocumentIds . length ; index += 1 ) {
204+ const id = openDocumentIds [ index ] ;
205+ const element = tabRefs . current . get ( id ) ;
206+ if ( ! element ) continue ;
207+ const tabEnd = element . offsetLeft + element . offsetWidth ;
208+ if ( tabEnd > scrollLeft + clientWidth + 1 ) {
209+ container . scrollTo ( { left : tabEnd - clientWidth , behavior : 'smooth' } ) ;
210+ return ;
211+ }
212+ }
213+ container . scrollTo ( { left : container . scrollWidth - clientWidth , behavior : 'smooth' } ) ;
214+ }
215+ } , [ openDocumentIds ] ) ;
130216
131217 const handleDragStart = useCallback ( ( event : React . DragEvent < HTMLDivElement > , tabId : string , index : number ) => {
132218 dragState . current = { id : tabId , index } ;
@@ -242,24 +328,44 @@ const DocumentTabs: React.FC<DocumentTabsProps> = ({
242328 < div className = "flex items-center gap-1 px-2 w-full h-full" >
243329 < div
244330 ref = { scrollContainerRef }
245- className = "flex-1 overflow-hidden h-full "
331+ className = "flex-1 h-full overflow-x-auto overflow-y- hidden scrollbar-hidden "
246332 onDragOver = { handleDragOver }
247333 onDrop = { handleContainerDrop }
334+ role = "tablist"
248335 >
249- < div className = "flex items-stretch gap-1 overflow-x-auto h-full pr-2" role = "tablist ">
336+ < div className = "flex items-stretch gap-1 h-full min-w-max pr-2" >
250337 { tabElements }
251338 </ div >
252339 </ div >
253- { isOverflowing && openDocumentIds . length > 0 && (
340+ < div className = "flex items-center gap-1" >
341+ < button
342+ type = "button"
343+ className = "flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70 disabled:opacity-40 disabled:cursor-default"
344+ onClick = { ( ) => scrollToDirection ( 'left' ) }
345+ aria-label = "Scroll tabs left"
346+ disabled = { ! scrollState . canScrollLeft }
347+ >
348+ < ChevronDownIcon className = "w-4 h-4 -rotate-90" />
349+ </ button >
350+ < button
351+ type = "button"
352+ className = "flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70 disabled:opacity-40 disabled:cursor-default"
353+ onClick = { ( ) => scrollToDirection ( 'right' ) }
354+ aria-label = "Scroll tabs right"
355+ disabled = { ! scrollState . canScrollRight }
356+ >
357+ < ChevronDownIcon className = "w-4 h-4 rotate-90" />
358+ </ button >
254359 < button
255360 type = "button"
256- className = "flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70"
361+ className = "flex items-center justify-center w-7 h-7 rounded-md bg-secondary text-text-secondary hover:text-text-main hover:bg-secondary/80 border border-border-color/70 disabled:opacity-40 disabled:cursor-default "
257362 onClick = { openOverflowMenu }
258- aria-label = "Show all tabs"
363+ aria-label = "Show hidden tabs"
364+ disabled = { ! scrollState . hiddenTabIds . length }
259365 >
260366 < ChevronDownIcon className = "w-4 h-4" />
261367 </ button >
262- ) }
368+ </ div >
263369 </ div >
264370 < ContextMenu
265371 isOpen = { menuState . isOpen }
0 commit comments