Skip to content

Commit 8884fc7

Browse files
authored
fix: FLIP animation for tabs (#8407)
* fix: FLIP animation for tabs * move indicator down into tab
1 parent 6503446 commit 8884fc7

File tree

2 files changed

+113
-98
lines changed

2 files changed

+113
-98
lines changed

packages/@react-spectrum/s2/src/SegmentedControl.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ function DefaultSelectionTracker(props: DefaultSelectionTrackProps) {
203203
isRegistered.current = true;
204204
state.toggleKey(value);
205205
}
206+
// eslint-disable-next-line react-hooks/exhaustive-deps
206207
}, []);
207208

208209
return (
@@ -229,7 +230,7 @@ export const SegmentedControlItem = /*#__PURE__*/ forwardRef(function SegmentedC
229230

230231
useLayoutEffect(() => {
231232
register?.(props.id);
232-
}, []);
233+
}, [register, props.id]);
233234

234235
useLayoutEffect(() => {
235236
if (isSelected && prevRef?.current && currentSelectedRef?.current && !reduceMotion) {
@@ -250,7 +251,7 @@ export const SegmentedControlItem = /*#__PURE__*/ forwardRef(function SegmentedC
250251

251252
prevRef.current = null;
252253
}
253-
}, [isSelected, reduceMotion]);
254+
}, [isSelected, reduceMotion, prevRef, currentSelectedRef]);
254255

255256
return (
256257
<ToggleButton

packages/@react-spectrum/s2/src/Tabs.tsx

Lines changed: 110 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {inertValue, useEffectEvent, useId, useLabels, useLayoutEffect, useResize
3636
import {Picker, PickerItem} from './TabsPicker';
3737
import {Text, TextContext} from './Content';
3838
import {useControlledState} from '@react-stately/utils';
39-
import {useDOMRef} from '@react-spectrum/utils';
39+
import {useDOMRef, useMediaQuery} from '@react-spectrum/utils';
4040
import {useHasTabbableChild} from '@react-aria/focus';
4141
import {useLocale} from '@react-aria/i18n';
4242
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -77,7 +77,11 @@ export interface TabPanelProps extends Omit<AriaTabPanelProps, 'children' | 'sty
7777
}
7878

7979
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+
}>({});
8185
const CollapseContext = createContext({
8286
showTabs: true,
8387
menuId: '',
@@ -115,6 +119,16 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
115119
throw new Error('An aria-label or aria-labelledby prop is required on Tabs for accessibility.');
116120
}
117121

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+
118132
return (
119133
<Provider
120134
values={[
@@ -124,7 +138,9 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
124138
orientation,
125139
disabledKeys,
126140
selectedKey: value,
127-
onSelectionChange: setValue,
141+
tablistRef,
142+
prevRef,
143+
onSelectionChange: onChange,
128144
labelBehavior,
129145
'aria-label': props['aria-label'],
130146
'aria-labelledby': props['aria-labelledby']
@@ -135,7 +151,7 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
135151
<CollapsingTabs
136152
{...props}
137153
selectedKey={value}
138-
onSelectionChange={setValue}
154+
onSelectionChange={onChange}
139155
collection={collection}
140156
containerRef={domRef} />
141157
)}
@@ -193,48 +209,28 @@ export function TabList<T extends object>(props: TabListProps<T>): ReactNode | n
193209
}
194210

195211
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) ?? {};
210219

211220
return (
212221
<div
213222
style={props.UNSAFE_style}
214223
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} />}
217224
<RACTabList
218225
{...props}
219226
aria-label={ariaLabel}
220227
aria-labelledby={ariaLabelledBy}
221228
ref={tablistRef}
222229
className={renderProps => tablist({...renderProps, labelBehavior, density})} />
223-
{orientation === 'horizontal' &&
224-
<TabLine disabledKeys={disabledKeys} isDisabled={isDisabled} selectedTab={selectedTab} orientation={orientation} tabList={props} density={density} />}
225230
</div>
226231
);
227232
}
228233

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-
238234
const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}>({
239235
position: 'absolute',
240236
backgroundColor: {
@@ -246,83 +242,39 @@ const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}
246242
}
247243
},
248244
height: {
245+
default: 'full',
249246
orientation: {
250247
horizontal: '[2px]'
251248
}
252249
},
253250
width: {
251+
default: 'full',
254252
orientation: {
255253
vertical: '[2px]'
256254
}
257255
},
258256
bottom: {
257+
default: 0
258+
},
259+
top: {
260+
orientation: {
261+
vertical: 0
262+
}
263+
},
264+
left: {
259265
orientation: {
260266
horizontal: 0
261267
}
262268
},
269+
insetStart: {
270+
orientation: {
271+
vertical: -12
272+
}
273+
},
263274
borderStyle: 'none',
264-
borderRadius: 'full',
265-
transitionDuration: 130,
266-
transitionTimingFunction: 'in-out'
275+
borderRadius: 'full'
267276
});
268277

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-
326278
const tab = style<TabRenderProps & {density?: 'compact' | 'regular', labelBehavior?: 'show' | 'hide'}>({
327279
...focusRing(),
328280
display: 'flex',
@@ -366,10 +318,11 @@ const icon = style({
366318
});
367319

368320
export function Tab(props: TabProps): ReactNode {
369-
let {density, labelBehavior} = useContext(InternalTabsContext) ?? {};
321+
let {density, orientation, labelBehavior, prevRef} = useContext(InternalTabsContext) ?? {};
370322

371323
let contentId = useId();
372324
let ariaLabelledBy = props['aria-labelledby'] || '';
325+
373326
return (
374327
<RACTab
375328
{...props}
@@ -380,7 +333,9 @@ export function Tab(props: TabProps): ReactNode {
380333
className={renderProps => (props.UNSAFE_className || '') + tab({...renderProps, density, labelBehavior}, props.styles)}>
381334
{({
382335
// @ts-ignore
383-
isMenu
336+
isMenu,
337+
isSelected,
338+
isDisabled
384339
}) => {
385340
if (isMenu) {
386341
return props.children;
@@ -405,7 +360,13 @@ export function Tab(props: TabProps): ReactNode {
405360
styles: icon
406361
}]
407362
]}>
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>
409370
</Provider>
410371
);
411372
}
@@ -414,6 +375,59 @@ export function Tab(props: TabProps): ReactNode {
414375
);
415376
}
416377

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+
417431
const tabPanel = style({
418432
...focusRing(),
419433
marginTop: 4,
@@ -460,7 +474,7 @@ function CollapsedTabPanel(props: TabPanelProps) {
460474
);
461475
}
462476

463-
function isAllTabsDisabled<T>(collection: Collection<Node<T>> | undefined, disabledKeys: Set<Key>) {
477+
function isEveryTabDisabled<T>(collection: Collection<Node<T>> | undefined, disabledKeys: Set<Key>) {
464478
let testKey: Key | null = null;
465479
if (collection && collection.size > 0) {
466480
testKey = collection.getFirstKey();
@@ -530,7 +544,7 @@ let TabsMenu = (props: {valueId: string, items: Array<Node<any>>, onSelectionCha
530544
}, [_onSelectionChange]);
531545
let state = useContext(TabListStateContext);
532546
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());
534548
}, [state?.collection, disabledKeys]);
535549
let labelProps = useLabels({
536550
id,

0 commit comments

Comments
 (0)