Skip to content

fix: FLIP animation for tabs #8407

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/@react-spectrum/s2/src/SegmentedControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ function DefaultSelectionTracker(props: DefaultSelectionTrackProps) {
isRegistered.current = true;
state.toggleKey(value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

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

useLayoutEffect(() => {
register?.(props.id);
}, []);
}, [register, props.id]);

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

prevRef.current = null;
}
}, [isSelected, reduceMotion]);
}, [isSelected, reduceMotion, prevRef, currentSelectedRef]);

return (
<ToggleButton
Expand Down
206 changes: 110 additions & 96 deletions packages/@react-spectrum/s2/src/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import {inertValue, useEffectEvent, useId, useLabels, useLayoutEffect, useResize
import {Picker, PickerItem} from './TabsPicker';
import {Text, TextContext} from './Content';
import {useControlledState} from '@react-stately/utils';
import {useDOMRef} from '@react-spectrum/utils';
import {useDOMRef, useMediaQuery} from '@react-spectrum/utils';
import {useHasTabbableChild} from '@react-aria/focus';
import {useLocale} from '@react-aria/i18n';
import {useSpectrumContextProps} from './useSpectrumContextProps';
Expand Down Expand Up @@ -77,7 +77,11 @@ export interface TabPanelProps extends Omit<AriaTabPanelProps, 'children' | 'sty
}

export const TabsContext = createContext<ContextValue<Partial<TabsProps>, DOMRefValue<HTMLDivElement>>>(null);
const InternalTabsContext = createContext<Partial<TabsProps>>({});
const InternalTabsContext = createContext<Partial<TabsProps> & {
tablistRef?: RefObject<HTMLDivElement | null>,
prevRef?: RefObject<DOMRect | null>,
selectedKey?: Key | null
}>({});
const CollapseContext = createContext({
showTabs: true,
menuId: '',
Expand Down Expand Up @@ -115,6 +119,16 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
throw new Error('An aria-label or aria-labelledby prop is required on Tabs for accessibility.');
}

let tablistRef = useRef<HTMLDivElement | null>(null);
let prevRef = useRef<DOMRect | null>(null);

let onChange = useEffectEvent((val: Key) => {
if (tablistRef.current) {
prevRef.current = tablistRef.current.querySelector('[role=tab][data-selected=true]')?.getBoundingClientRect() ?? null;
}
setValue(val);
});

return (
<Provider
values={[
Expand All @@ -124,7 +138,9 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
orientation,
disabledKeys,
selectedKey: value,
onSelectionChange: setValue,
tablistRef,
prevRef,
onSelectionChange: onChange,
labelBehavior,
'aria-label': props['aria-label'],
'aria-labelledby': props['aria-labelledby']
Expand All @@ -135,7 +151,7 @@ export const Tabs = forwardRef(function Tabs(props: TabsProps, ref: DOMRef<HTMLD
<CollapsingTabs
{...props}
selectedKey={value}
onSelectionChange={setValue}
onSelectionChange={onChange}
collection={collection}
containerRef={domRef} />
)}
Expand Down Expand Up @@ -193,48 +209,28 @@ export function TabList<T extends object>(props: TabListProps<T>): ReactNode | n
}

function TabListInner<T extends object>(props: TabListProps<T>) {
let {density, isDisabled, disabledKeys, orientation, labelBehavior, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy} = useContext(InternalTabsContext) ?? {};
let state = useContext(TabListStateContext);
let [selectedTab, setSelectedTab] = useState<HTMLElement | undefined>(undefined);
let tablistRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
if (tablistRef?.current) {
let tab: HTMLElement | null = tablistRef.current.querySelector('[role=tab][data-selected=true]');

if (tab != null) {
setSelectedTab(tab);
}
}
}, [tablistRef, state?.selectedItem?.key]);
let {
tablistRef,
density,
labelBehavior,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy
} = useContext(InternalTabsContext) ?? {};

return (
<div
style={props.UNSAFE_style}
className={(props.UNSAFE_className || '') + style({position: 'relative'}, getAllowedOverrides())(null, props.styles)}>
{orientation === 'vertical' &&
<TabLine disabledKeys={disabledKeys} isDisabled={isDisabled} selectedTab={selectedTab} orientation={orientation} tabList={props} density={density} />}
<RACTabList
{...props}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
ref={tablistRef}
className={renderProps => tablist({...renderProps, labelBehavior, density})} />
{orientation === 'horizontal' &&
<TabLine disabledKeys={disabledKeys} isDisabled={isDisabled} selectedTab={selectedTab} orientation={orientation} tabList={props} density={density} />}
</div>
);
}

interface TabLineProps<T extends object> {
disabledKeys: Iterable<Key> | undefined,
isDisabled: boolean | undefined,
selectedTab: HTMLElement | undefined,
orientation?: Orientation,
tabList: TabListProps<T>,
density?: 'compact' | 'regular'
}

const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}>({
position: 'absolute',
backgroundColor: {
Expand All @@ -246,83 +242,39 @@ const selectedIndicator = style<{isDisabled: boolean, orientation?: Orientation}
}
},
height: {
default: 'full',
orientation: {
horizontal: '[2px]'
}
},
width: {
default: 'full',
orientation: {
vertical: '[2px]'
}
},
bottom: {
default: 0
},
top: {
orientation: {
vertical: 0
}
},
left: {
orientation: {
horizontal: 0
}
},
insetStart: {
orientation: {
vertical: -12
}
},
borderStyle: 'none',
borderRadius: 'full',
transitionDuration: 130,
transitionTimingFunction: 'in-out'
borderRadius: 'full'
});

function TabLine<T extends object>(props: TabLineProps<T>) {
let {
disabledKeys,
isDisabled: isTabsDisabled,
selectedTab,
orientation,
tabList,
density
} = props;
let {direction} = useLocale();
let state = useContext(TabListStateContext);

// We want to add disabled styling to the selection indicator only if all the Tabs are disabled
let [isDisabled, setIsDisabled] = useState<boolean>(false);
useEffect(() => {
let isDisabled = isTabsDisabled || isAllTabsDisabled(state?.collection, disabledKeys ? new Set(disabledKeys) : new Set(null));
setIsDisabled(isDisabled);
}, [state?.collection, disabledKeys, isTabsDisabled, setIsDisabled]);

let [style, setStyle] = useState<{transform: string | undefined, width: string | undefined, height: string | undefined}>({
transform: undefined,
width: undefined,
height: undefined
});

let onResize = useCallback(() => {
if (selectedTab) {
let styleObj: { transform: string | undefined, width: string | undefined, height: string | undefined } = {
transform: undefined,
width: undefined,
height: undefined
};

// 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
let offset = direction === 'rtl' ? -1 * ((selectedTab.offsetParent as HTMLElement)?.offsetWidth - selectedTab.offsetWidth - selectedTab.offsetLeft) : selectedTab.offsetLeft;
styleObj.transform = orientation === 'vertical'
? `translateY(${selectedTab.offsetTop}px)`
: `translateX(${offset}px)`;

if (orientation === 'horizontal') {
styleObj.width = `${selectedTab.offsetWidth}px`;
} else {
styleObj.height = `${selectedTab.offsetHeight}px`;
}
setStyle(styleObj);
}
}, [direction, setStyle, selectedTab, orientation]);

useLayoutEffect(() => {
onResize();
}, [onResize, state?.selectedItem?.key, density, direction, orientation, tabList]);

return (
<div style={{...style}} className={selectedIndicator({isDisabled, orientation})} />
);
}

const tab = style<TabRenderProps & {density?: 'compact' | 'regular', labelBehavior?: 'show' | 'hide'}>({
...focusRing(),
display: 'flex',
Expand Down Expand Up @@ -366,10 +318,11 @@ const icon = style({
});

export function Tab(props: TabProps): ReactNode {
let {density, labelBehavior} = useContext(InternalTabsContext) ?? {};
let {density, orientation, labelBehavior, prevRef} = useContext(InternalTabsContext) ?? {};

let contentId = useId();
let ariaLabelledBy = props['aria-labelledby'] || '';

return (
<RACTab
{...props}
Expand All @@ -380,7 +333,9 @@ export function Tab(props: TabProps): ReactNode {
className={renderProps => (props.UNSAFE_className || '') + tab({...renderProps, density, labelBehavior}, props.styles)}>
{({
// @ts-ignore
isMenu
isMenu,
isSelected,
isDisabled
}) => {
if (isMenu) {
return props.children;
Expand All @@ -405,7 +360,13 @@ export function Tab(props: TabProps): ReactNode {
styles: icon
}]
]}>
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
<TabInner
isSelected={isSelected}
orientation={orientation!}
isDisabled={isDisabled}
prevRef={prevRef}>
{typeof props.children === 'string' ? <Text>{props.children}</Text> : props.children}
</TabInner>
</Provider>
);
}
Expand All @@ -414,6 +375,59 @@ export function Tab(props: TabProps): ReactNode {
);
}

function TabInner({isSelected, isDisabled, orientation, children, prevRef}: {
isSelected: boolean,
isDisabled: boolean,
orientation: Orientation,
children: ReactNode,
prevRef?: RefObject<DOMRect | null>
}) {
let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
let ref = useRef<HTMLDivElement | null>(null);

useLayoutEffect(() => {
if (isSelected && prevRef?.current && ref?.current && !reduceMotion) {
let currentItem = ref?.current.getBoundingClientRect();

if (orientation === 'horizontal') {
let deltaX = prevRef.current.left - currentItem.left;
ref.current.animate(
[
{transform: `translateX(${deltaX}px)`, width: `${prevRef.current.width}px`},
{transform: 'translateX(0px)', width: '100%'}
],
{
duration: 200,
easing: 'ease-out'
}
);
} else {
let deltaY = prevRef.current.top - currentItem.top;
ref.current.animate(
[
{transform: `translateY(${deltaY}px)`, height: `${prevRef.current.height}px`},
{transform: 'translateY(0px)', height: '100%'}
],
{
duration: 200,
easing: 'ease-out'
}
);
}

prevRef.current = null;
}
}, [isSelected, reduceMotion, prevRef, orientation]);

return (
<>
{isSelected && <div ref={ref} className={selectedIndicator({isDisabled, orientation})} />}
{children}
</>
);
}


const tabPanel = style({
...focusRing(),
marginTop: 4,
Expand Down Expand Up @@ -460,7 +474,7 @@ function CollapsedTabPanel(props: TabPanelProps) {
);
}

function isAllTabsDisabled<T>(collection: Collection<Node<T>> | undefined, disabledKeys: Set<Key>) {
function isEveryTabDisabled<T>(collection: Collection<Node<T>> | undefined, disabledKeys: Set<Key>) {
let testKey: Key | null = null;
if (collection && collection.size > 0) {
testKey = collection.getFirstKey();
Expand Down Expand Up @@ -530,7 +544,7 @@ let TabsMenu = (props: {valueId: string, items: Array<Node<any>>, onSelectionCha
}, [_onSelectionChange]);
let state = useContext(TabListStateContext);
let allKeysDisabled = useMemo(() => {
return isAllTabsDisabled(state?.collection, disabledKeys ? new Set(disabledKeys) : new Set());
return isEveryTabDisabled(state?.collection, disabledKeys ? new Set(disabledKeys) : new Set());
}, [state?.collection, disabledKeys]);
let labelProps = useLabels({
id,
Expand Down