@@ -36,7 +36,7 @@ import {inertValue, useEffectEvent, useId, useLabels, useLayoutEffect, useResize
36
36
import { Picker , PickerItem } from './TabsPicker' ;
37
37
import { Text , TextContext } from './Content' ;
38
38
import { useControlledState } from '@react-stately/utils' ;
39
- import { useDOMRef } from '@react-spectrum/utils' ;
39
+ import { useDOMRef , useMediaQuery } from '@react-spectrum/utils' ;
40
40
import { useHasTabbableChild } from '@react-aria/focus' ;
41
41
import { useLocale } from '@react-aria/i18n' ;
42
42
import { useSpectrumContextProps } from './useSpectrumContextProps' ;
@@ -77,7 +77,11 @@ export interface TabPanelProps extends Omit<AriaTabPanelProps, 'children' | 'sty
77
77
}
78
78
79
79
export const TabsContext = createContext < ContextValue < Partial < TabsProps > , DOMRefValue < HTMLDivElement > > > ( null ) ;
80
- const InternalTabsContext = createContext < Partial < TabsProps > > ( { } ) ;
80
+ const InternalTabsContext = createContext < Partial < TabsProps > & {
81
+ tablistRef ?: RefObject < HTMLDivElement | null > ,
82
+ prevRef ?: RefObject < DOMRect | null > ,
83
+ selectedKey ?: Key | null
84
+ } > ( { } ) ;
81
85
const CollapseContext = createContext ( {
82
86
showTabs : true ,
83
87
menuId : '' ,
@@ -115,6 +119,16 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
115
119
throw new Error ( 'An aria-label or aria-labelledby prop is required on Tabs for accessibility.' ) ;
116
120
}
117
121
122
+ let tablistRef = useRef < HTMLDivElement | null > ( null ) ;
123
+ let prevRef = useRef < DOMRect | null > ( null ) ;
124
+
125
+ let onChange = useEffectEvent ( ( val : Key ) => {
126
+ if ( tablistRef . current ) {
127
+ prevRef . current = tablistRef . current . querySelector ( '[role=tab][data-selected=true]' ) ?. getBoundingClientRect ( ) ?? null ;
128
+ }
129
+ setValue ( val ) ;
130
+ } ) ;
131
+
118
132
return (
119
133
< Provider
120
134
values = { [
@@ -124,7 +138,9 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
124
138
orientation,
125
139
disabledKeys,
126
140
selectedKey : value ,
127
- onSelectionChange : setValue ,
141
+ tablistRef,
142
+ prevRef,
143
+ onSelectionChange : onChange ,
128
144
labelBehavior,
129
145
'aria-label' : props [ 'aria-label' ] ,
130
146
'aria-labelledby' : props [ 'aria-labelledby' ]
@@ -135,7 +151,7 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
135
151
< CollapsingTabs
136
152
{ ...props }
137
153
selectedKey = { value }
138
- onSelectionChange = { setValue }
154
+ onSelectionChange = { onChange }
139
155
collection = { collection }
140
156
containerRef = { domRef } />
141
157
) }
@@ -193,48 +209,28 @@ export function TabList<T extends object>(props: TabListProps<T>): ReactNode | n
193
209
}
194
210
195
211
function TabListInner < T extends object > ( props : TabListProps < T > ) {
196
- let { density, isDisabled, disabledKeys, orientation, labelBehavior, 'aria-label' : ariaLabel , 'aria-labelledby' : ariaLabelledBy } = useContext ( InternalTabsContext ) ?? { } ;
197
- let state = useContext ( TabListStateContext ) ;
198
- let [ selectedTab , setSelectedTab ] = useState < HTMLElement | undefined > ( undefined ) ;
199
- let tablistRef = useRef < HTMLDivElement > ( null ) ;
200
-
201
- useLayoutEffect ( ( ) => {
202
- if ( tablistRef ?. current ) {
203
- let tab : HTMLElement | null = tablistRef . current . querySelector ( '[role=tab][data-selected=true]' ) ;
204
-
205
- if ( tab != null ) {
206
- setSelectedTab ( tab ) ;
207
- }
208
- }
209
- } , [ tablistRef , state ?. selectedItem ?. key ] ) ;
212
+ let {
213
+ tablistRef,
214
+ density,
215
+ labelBehavior,
216
+ 'aria-label' : ariaLabel ,
217
+ 'aria-labelledby' : ariaLabelledBy
218
+ } = useContext ( InternalTabsContext ) ?? { } ;
210
219
211
220
return (
212
221
< div
213
222
style = { props . UNSAFE_style }
214
223
className = { ( props . UNSAFE_className || '' ) + style ( { position : 'relative' } , getAllowedOverrides ( ) ) ( null , props . styles ) } >
215
- { orientation === 'vertical' &&
216
- < TabLine disabledKeys = { disabledKeys } isDisabled = { isDisabled } selectedTab = { selectedTab } orientation = { orientation } tabList = { props } density = { density } /> }
217
224
< RACTabList
218
225
{ ...props }
219
226
aria-label = { ariaLabel }
220
227
aria-labelledby = { ariaLabelledBy }
221
228
ref = { tablistRef }
222
229
className = { renderProps => tablist ( { ...renderProps , labelBehavior, density} ) } />
223
- { orientation === 'horizontal' &&
224
- < TabLine disabledKeys = { disabledKeys } isDisabled = { isDisabled } selectedTab = { selectedTab } orientation = { orientation } tabList = { props } density = { density } /> }
225
230
</ div >
226
231
) ;
227
232
}
228
233
229
- interface TabLineProps < T extends object > {
230
- disabledKeys : Iterable < Key > | undefined ,
231
- isDisabled : boolean | undefined ,
232
- selectedTab : HTMLElement | undefined ,
233
- orientation ?: Orientation ,
234
- tabList : TabListProps < T > ,
235
- density ?: 'compact' | 'regular'
236
- }
237
-
238
234
const selectedIndicator = style < { isDisabled : boolean , orientation ?: Orientation } > ( {
239
235
position : 'absolute' ,
240
236
backgroundColor : {
@@ -246,83 +242,39 @@ const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}
246
242
}
247
243
} ,
248
244
height : {
245
+ default : 'full' ,
249
246
orientation : {
250
247
horizontal : '[2px]'
251
248
}
252
249
} ,
253
250
width : {
251
+ default : 'full' ,
254
252
orientation : {
255
253
vertical : '[2px]'
256
254
}
257
255
} ,
258
256
bottom : {
257
+ default : 0
258
+ } ,
259
+ top : {
260
+ orientation : {
261
+ vertical : 0
262
+ }
263
+ } ,
264
+ left : {
259
265
orientation : {
260
266
horizontal : 0
261
267
}
262
268
} ,
269
+ insetStart : {
270
+ orientation : {
271
+ vertical : - 12
272
+ }
273
+ } ,
263
274
borderStyle : 'none' ,
264
- borderRadius : 'full' ,
265
- transitionDuration : 130 ,
266
- transitionTimingFunction : 'in-out'
275
+ borderRadius : 'full'
267
276
} ) ;
268
277
269
- function TabLine < T extends object > ( props : TabLineProps < T > ) {
270
- let {
271
- disabledKeys,
272
- isDisabled : isTabsDisabled ,
273
- selectedTab,
274
- orientation,
275
- tabList,
276
- density
277
- } = props ;
278
- let { direction} = useLocale ( ) ;
279
- let state = useContext ( TabListStateContext ) ;
280
-
281
- // We want to add disabled styling to the selection indicator only if all the Tabs are disabled
282
- let [ isDisabled , setIsDisabled ] = useState < boolean > ( false ) ;
283
- useEffect ( ( ) => {
284
- let isDisabled = isTabsDisabled || isAllTabsDisabled ( state ?. collection , disabledKeys ? new Set ( disabledKeys ) : new Set ( null ) ) ;
285
- setIsDisabled ( isDisabled ) ;
286
- } , [ state ?. collection , disabledKeys , isTabsDisabled , setIsDisabled ] ) ;
287
-
288
- let [ style , setStyle ] = useState < { transform : string | undefined , width : string | undefined , height : string | undefined } > ( {
289
- transform : undefined ,
290
- width : undefined ,
291
- height : undefined
292
- } ) ;
293
-
294
- let onResize = useCallback ( ( ) => {
295
- if ( selectedTab ) {
296
- let styleObj : { transform : string | undefined , width : string | undefined , height : string | undefined } = {
297
- transform : undefined ,
298
- width : undefined ,
299
- height : undefined
300
- } ;
301
-
302
- // In RTL, calculate the transform from the right edge of the tablist so that resizing the window doesn't break the Tabline position due to offsetLeft changes
303
- let offset = direction === 'rtl' ? - 1 * ( ( selectedTab . offsetParent as HTMLElement ) ?. offsetWidth - selectedTab . offsetWidth - selectedTab . offsetLeft ) : selectedTab . offsetLeft ;
304
- styleObj . transform = orientation === 'vertical'
305
- ? `translateY(${ selectedTab . offsetTop } px)`
306
- : `translateX(${ offset } px)` ;
307
-
308
- if ( orientation === 'horizontal' ) {
309
- styleObj . width = `${ selectedTab . offsetWidth } px` ;
310
- } else {
311
- styleObj . height = `${ selectedTab . offsetHeight } px` ;
312
- }
313
- setStyle ( styleObj ) ;
314
- }
315
- } , [ direction , setStyle , selectedTab , orientation ] ) ;
316
-
317
- useLayoutEffect ( ( ) => {
318
- onResize ( ) ;
319
- } , [ onResize , state ?. selectedItem ?. key , density , direction , orientation , tabList ] ) ;
320
-
321
- return (
322
- < div style = { { ...style } } className = { selectedIndicator ( { isDisabled, orientation} ) } />
323
- ) ;
324
- }
325
-
326
278
const tab = style < TabRenderProps & { density ?: 'compact' | 'regular' , labelBehavior ?: 'show' | 'hide' } > ( {
327
279
...focusRing ( ) ,
328
280
display : 'flex' ,
@@ -366,10 +318,11 @@ const icon = style({
366
318
} ) ;
367
319
368
320
export function Tab ( props : TabProps ) : ReactNode {
369
- let { density, labelBehavior} = useContext ( InternalTabsContext ) ?? { } ;
321
+ let { density, orientation , labelBehavior, prevRef } = useContext ( InternalTabsContext ) ?? { } ;
370
322
371
323
let contentId = useId ( ) ;
372
324
let ariaLabelledBy = props [ 'aria-labelledby' ] || '' ;
325
+
373
326
return (
374
327
< RACTab
375
328
{ ...props }
@@ -380,7 +333,9 @@ export function Tab(props: TabProps): ReactNode {
380
333
className = { renderProps => ( props . UNSAFE_className || '' ) + tab ( { ...renderProps , density, labelBehavior} , props . styles ) } >
381
334
{ ( {
382
335
// @ts -ignore
383
- isMenu
336
+ isMenu,
337
+ isSelected,
338
+ isDisabled
384
339
} ) => {
385
340
if ( isMenu ) {
386
341
return props . children ;
@@ -405,7 +360,13 @@ export function Tab(props: TabProps): ReactNode {
405
360
styles : icon
406
361
} ]
407
362
] } >
408
- { typeof props . children === 'string' ? < Text > { props . children } </ Text > : props . children }
363
+ < TabInner
364
+ isSelected = { isSelected }
365
+ orientation = { orientation ! }
366
+ isDisabled = { isDisabled }
367
+ prevRef = { prevRef } >
368
+ { typeof props . children === 'string' ? < Text > { props . children } </ Text > : props . children }
369
+ </ TabInner >
409
370
</ Provider >
410
371
) ;
411
372
}
@@ -414,6 +375,59 @@ export function Tab(props: TabProps): ReactNode {
414
375
) ;
415
376
}
416
377
378
+ function TabInner ( { isSelected, isDisabled, orientation, children, prevRef} : {
379
+ isSelected : boolean ,
380
+ isDisabled : boolean ,
381
+ orientation : Orientation ,
382
+ children : ReactNode ,
383
+ prevRef ?: RefObject < DOMRect | null >
384
+ } ) {
385
+ let reduceMotion = useMediaQuery ( '(prefers-reduced-motion: reduce)' ) ;
386
+ let ref = useRef < HTMLDivElement | null > ( null ) ;
387
+
388
+ useLayoutEffect ( ( ) => {
389
+ if ( isSelected && prevRef ?. current && ref ?. current && ! reduceMotion ) {
390
+ let currentItem = ref ?. current . getBoundingClientRect ( ) ;
391
+
392
+ if ( orientation === 'horizontal' ) {
393
+ let deltaX = prevRef . current . left - currentItem . left ;
394
+ ref . current . animate (
395
+ [
396
+ { transform : `translateX(${ deltaX } px)` , width : `${ prevRef . current . width } px` } ,
397
+ { transform : 'translateX(0px)' , width : '100%' }
398
+ ] ,
399
+ {
400
+ duration : 200 ,
401
+ easing : 'ease-out'
402
+ }
403
+ ) ;
404
+ } else {
405
+ let deltaY = prevRef . current . top - currentItem . top ;
406
+ ref . current . animate (
407
+ [
408
+ { transform : `translateY(${ deltaY } px)` , height : `${ prevRef . current . height } px` } ,
409
+ { transform : 'translateY(0px)' , height : '100%' }
410
+ ] ,
411
+ {
412
+ duration : 200 ,
413
+ easing : 'ease-out'
414
+ }
415
+ ) ;
416
+ }
417
+
418
+ prevRef . current = null ;
419
+ }
420
+ } , [ isSelected , reduceMotion , prevRef , orientation ] ) ;
421
+
422
+ return (
423
+ < >
424
+ { isSelected && < div ref = { ref } className = { selectedIndicator ( { isDisabled, orientation} ) } /> }
425
+ { children }
426
+ </ >
427
+ ) ;
428
+ }
429
+
430
+
417
431
const tabPanel = style ( {
418
432
...focusRing ( ) ,
419
433
marginTop : 4 ,
@@ -460,7 +474,7 @@ function CollapsedTabPanel(props: TabPanelProps) {
460
474
) ;
461
475
}
462
476
463
- function isAllTabsDisabled < T > ( collection : Collection < Node < T > > | undefined , disabledKeys : Set < Key > ) {
477
+ function isEveryTabDisabled < T > ( collection : Collection < Node < T > > | undefined , disabledKeys : Set < Key > ) {
464
478
let testKey : Key | null = null ;
465
479
if ( collection && collection . size > 0 ) {
466
480
testKey = collection . getFirstKey ( ) ;
@@ -530,7 +544,7 @@ let TabsMenu = (props: {valueId: string, items: Array<Node<any>>, onSelectionCha
530
544
} , [ _onSelectionChange ] ) ;
531
545
let state = useContext ( TabListStateContext ) ;
532
546
let allKeysDisabled = useMemo ( ( ) => {
533
- return isAllTabsDisabled ( state ?. collection , disabledKeys ? new Set ( disabledKeys ) : new Set ( ) ) ;
547
+ return isEveryTabDisabled ( state ?. collection , disabledKeys ? new Set ( disabledKeys ) : new Set ( ) ) ;
534
548
} , [ state ?. collection , disabledKeys ] ) ;
535
549
let labelProps = useLabels ( {
536
550
id,
0 commit comments