Skip to content

Commit

Permalink
Merge branch 'tabs-refactor' of github.com:adobe/react-spectrum into …
Browse files Browse the repository at this point in the history
…tabs-refactor
  • Loading branch information
snowystinger committed Jan 14, 2025
2 parents 23c4960 + b56a7a3 commit 4c1bfa9
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 301 deletions.
106 changes: 62 additions & 44 deletions packages/@react-aria/utils/src/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,75 +11,93 @@
*/

import {flushSync} from 'react-dom';
import {RefObject, useCallback, useRef, useState} from 'react';
import {RefObject, useCallback, useState} from 'react';
import {useLayoutEffect} from './useLayoutEffect';

export function useEnterAnimation(ref: RefObject<HTMLElement | null>, isReady: boolean = true) {
let [isEntering, setEntering] = useState(true);
useAnimation(ref, isEntering && isReady, useCallback(() => setEntering(false), []));
return isEntering && isReady;
let isAnimationReady = isEntering && isReady;

// There are two cases for entry animations:
// 1. CSS @keyframes. The `animation` property is set during the isEntering state, and it is removed after the animation finishes.
// 2. CSS transitions. The initial styles are applied during the isEntering state, and removed immediately, causing the transition to occur.
//
// In the second case, cancel any transitions that were triggered prior to the isEntering = false state (when the transition is supposed to start).
// This can happen when isReady starts as false (e.g. popovers prior to placement calculation).
useLayoutEffect(() => {
if (isAnimationReady && ref.current && 'getAnimations' in ref.current) {
for (let animation of ref.current.getAnimations()) {
if (animation instanceof CSSTransition) {
animation.cancel();
}
}
}
}, [ref, isAnimationReady]);

useAnimation(ref, isAnimationReady, useCallback(() => setEntering(false), []));
return isAnimationReady;
}

export function useExitAnimation(ref: RefObject<HTMLElement | null>, isOpen: boolean) {
// State to trigger a re-render after animation is complete, which causes the element to be removed from the DOM.
// Ref to track the state we're in, so we don't immediately reset isExiting to true after the animation.
let [isExiting, setExiting] = useState(false);
let [exitState, setExitState] = useState('idle');
let [exitState, setExitState] = useState<'closed' | 'open' | 'exiting'>(isOpen ? 'open' : 'closed');

// If isOpen becomes false, set isExiting to true.
if (!isOpen && ref.current && exitState === 'idle') {
isExiting = true;
setExiting(true);
setExitState('exiting');
}

// If we exited, and the element has been removed, reset exit state to idle.
if (!ref.current && exitState === 'exited') {
setExitState('idle');
switch (exitState) {
case 'open':
// If isOpen becomes false, set the state to exiting.
if (!isOpen) {
setExitState('exiting');
}
break;
case 'closed':
case 'exiting':
// If we are exiting and isOpen becomes true, the animation was interrupted.
// Reset the state to open.
if (isOpen) {
setExitState('open');
}
break;
}

let isExiting = exitState === 'exiting';
useAnimation(
ref,
isExiting,
useCallback(() => {
setExitState('exited');
setExiting(false);
// Set the state to closed, which will cause the element to be unmounted.
setExitState(state => state === 'exiting' ? 'closed' : state);
}, [])
);

return isExiting;
}

function useAnimation(ref: RefObject<HTMLElement | null>, isActive: boolean, onEnd: () => void) {
let prevAnimation = useRef<string | null>(null);
if (isActive && ref.current) {
// This is ok because we only read it in the layout effect below, immediately after the commit phase.
// We could move this to another effect that runs every render, but this would be unnecessarily slow.
// We only need the computed style right before the animation becomes active.
// eslint-disable-next-line rulesdir/pure-render
prevAnimation.current = window.getComputedStyle(ref.current).animation;
}

useLayoutEffect(() => {
if (isActive && ref.current) {
// Make sure there's actually an animation, and it wasn't there before we triggered the update.
let computedStyle = window.getComputedStyle(ref.current);
if (computedStyle.animationName && computedStyle.animationName !== 'none' && computedStyle.animation !== prevAnimation.current) {
let onAnimationEnd = (e: AnimationEvent) => {
if (e.target === ref.current) {
element.removeEventListener('animationend', onAnimationEnd);
flushSync(() => {onEnd();});
}
};

let element = ref.current;
element.addEventListener('animationend', onAnimationEnd);
return () => {
element.removeEventListener('animationend', onAnimationEnd);
};
} else {
if (!('getAnimations' in ref.current)) {
// JSDOM
onEnd();
return;
}

let animations = ref.current.getAnimations();
if (animations.length === 0) {
onEnd();
return;
}

let canceled = false;
Promise.all(animations.map(a => a.finished)).then(() => {
if (!canceled) {
flushSync(() => {
onEnd();
});
}
}).catch(() => {});

return () => {
canceled = true;
};
}
}, [ref, isActive, onEnd]);
}
33 changes: 15 additions & 18 deletions packages/@react-spectrum/s2/src/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,13 @@ import {DOMRef, DOMRefValue, Key} from '@react-types/shared';
import {FocusScope, useKeyboard} from 'react-aria';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {keyframes} from '../style/style-macro' with {type: 'macro'};
import {style} from '../style' with {type: 'macro'};
import {useControlledState} from '@react-stately/utils';
import {useDOMRef} from '@react-spectrum/utils';
import {useExitAnimation, useResizeObserver} from '@react-aria/utils';
import {useEnterAnimation, useExitAnimation, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';
import {useSpectrumContextProps} from './useSpectrumContextProps';

const slideIn = keyframes(`
from { transform: translateY(100%); opacity: 0 }
to { transform: translateY(0px); opacity: 1 }
`);

const slideOut = keyframes(`
from { transform: translateY(0px); opacity: 1 }
to { transform: translateY(100%); opacity: 0 }
`);

const actionBarStyles = style({
borderRadius: 'lg',
'--s2-container-bg': {
Expand Down Expand Up @@ -77,11 +66,16 @@ const actionBarStyles = style({
},
marginX: 'auto',
maxWidth: 960,
animation: {
isInContainer: slideIn,
isExiting: slideOut
transition: 'default',
transitionDuration: 200,
translateY: {
isEntering: 'full',
isExiting: 'full'
},
animationDuration: 200
opacity: {
isEntering: 0,
isExiting: 0
}
});

export interface ActionBarProps extends SlotProps {
Expand Down Expand Up @@ -158,12 +152,15 @@ const ActionBarInner = forwardRef(function ActionBarInner(props: ActionBarProps
}
}, [stringFormatter, scrollRef]);

let objectRef = useObjectRef(ref);
let isEntering = useEnterAnimation(objectRef, !!scrollRef);

return (
<FocusScope restoreFocus>
<div
ref={ref}
ref={objectRef}
{...keyboardProps}
className={actionBarStyles({isEmphasized, isInContainer: !!scrollRef, isExiting})}
className={actionBarStyles({isEmphasized, isInContainer: !!scrollRef, isEntering, isExiting})}
style={{insetInlineEnd: `calc(var(--insetEnd) + ${scrollbarWidth}px)`}}>
<div className={style({order: 1, marginStart: 'auto'})}>
<ActionButtonGroup
Expand Down
61 changes: 17 additions & 44 deletions packages/@react-spectrum/s2/src/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {colorScheme} from './style-utils' with {type: 'macro'};
import {ColorSchemeContext} from './Provider';
import {DOMRef} from '@react-types/shared';
import {forwardRef, MutableRefObject, useCallback, useContext} from 'react';
import {keyframes} from '../style/style-macro' with {type: 'macro'};
import {ModalOverlay, ModalOverlayProps, Modal as RACModal, useLocale} from 'react-aria-components';
import {style} from '../style' with {type: 'macro'};
import {useDOMRef} from '@react-spectrum/utils';
Expand All @@ -28,28 +27,6 @@ interface ModalProps extends ModalOverlayProps {
size?: 'S' | 'M' | 'L' | 'fullscreen' | 'fullscreenTakeover'
}

const fade = keyframes(`
from {
opacity: 0;
}
to {
opacity: 1;
}
`);

const fadeAndSlide = keyframes(`
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
`);

const modalOverlayStyles = style({
...colorScheme(),
position: 'fixed',
Expand All @@ -59,17 +36,14 @@ const modalOverlayStyles = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
animation: {
isEntering: fade,
isExiting: fade
opacity: {
isEntering: 0,
isExiting: 0
},
animationDuration: {
isEntering: 250,
transition: 'opacity',
transitionDuration: {
default: 250,
isExiting: 130
},
animationDirection: {
isEntering: 'normal',
isExiting: 'reverse'
}
});

Expand Down Expand Up @@ -141,23 +115,22 @@ export const Modal = forwardRef(function Modal(props: ModalProps, ref: DOMRef<HT
value: 'layer-2'
},
backgroundColor: '--s2-container-bg',
animation: {
isEntering: fadeAndSlide,
isExiting: fade
opacity: {
isEntering: 0,
isExiting: 0
},
translateY: {
isEntering: 20
},
animationDuration: {
isEntering: 250,
transition: '[opacity, translate]',
transitionDuration: {
default: 250,
isExiting: 130
},
animationDelay: {
isEntering: 160,
transitionDelay: {
default: 160,
isExiting: 0
},
animationDirection: {
isEntering: 'normal',
isExiting: 'reverse'
},
animationFillMode: 'both',
// Transparent outline for WHCM.
outlineStyle: 'solid',
outlineWidth: 1,
Expand Down
Loading

0 comments on commit 4c1bfa9

Please sign in to comment.