@wordpress/components
Version:
UI components for WordPress.
293 lines (268 loc) • 7.09 kB
JavaScript
/**
* External dependencies
*/
import {
Animated,
Easing,
Keyboard,
Platform,
StyleSheet,
Text,
View,
} from 'react-native';
/**
* WordPress dependencies
*/
import {
cloneElement,
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from '@wordpress/element';
import { usePrevious } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { createSlotFill } from '../slot-fill';
import styles from './style.scss';
const RIGHT_ALIGN_ARROW_OFFSET = 16;
const TOOLTIP_VERTICAL_OFFSET = 2;
const TooltipContext = createContext( {
onHandleScreenTouch: () => {},
} );
const { Fill, Slot } = createSlotFill( 'Tooltip' );
const useKeyboardVisibility = () => {
const [ keyboardVisible, setKeyboardVisible ] = useState( false );
const previousKeyboardVisible = usePrevious( keyboardVisible );
useEffect( () => {
const showListener = Keyboard.addListener( 'keyboardDidShow', () => {
if ( previousKeyboardVisible !== true ) {
setKeyboardVisible( true );
}
} );
const keyboardHideEvent = Platform.select( {
android: 'keyboardDidHide',
ios: 'keyboardWillHide',
} );
const hideListener = Keyboard.addListener( keyboardHideEvent, () => {
if ( previousKeyboardVisible !== false ) {
setKeyboardVisible( false );
}
} );
return () => {
showListener.remove();
hideListener.remove();
};
// See https://github.com/WordPress/gutenberg/pull/41166
}, [] );
return keyboardVisible;
};
const Tooltip = ( {
children,
position = 'top',
text,
visible: initialVisible = false,
} ) => {
const referenceElementRef = useRef( null );
const animationValue = useRef( new Animated.Value( 0 ) ).current;
const [ , horizontalPosition = 'center' ] = position.split( ' ' );
const [ visible, setVisible ] = useState( initialVisible );
const [ animating, setAnimating ] = useState( false );
const hidden = ! visible && ! animating;
const previousVisible = usePrevious( visible );
const [ referenceLayout, setReferenceLayout ] = useState( {
height: 0,
width: 0,
x: 0,
y: 0,
} );
const [ tooltipLayout, setTooltipLayout ] = useState( {
height: 0,
width: 0,
} );
const { onHandleScreenTouch } = useContext( TooltipContext );
const keyboardVisible = useKeyboardVisibility();
// Register callback to dismiss the tooltip whenever the screen is touched.
useEffect( () => {
if ( visible ) {
onHandleScreenTouch( () => {
setAnimating( true );
setVisible( false );
} );
}
return () => onHandleScreenTouch( null );
// See https://github.com/WordPress/gutenberg/pull/41166
}, [ visible ] );
// Manage visibility animation.
useEffect( () => {
if (
// Initial render and visibility enabled, animate show.
( typeof previousVisible === 'undefined' && visible ) ||
// Previously visible, animate hide
( previousVisible && previousVisible !== visible )
) {
setAnimating( true );
startAnimation();
}
// See https://github.com/WordPress/gutenberg/pull/41166
}, [ visible ] );
// Manage tooltip visibility and position in relation to keyboard.
useEffect( () => {
if ( ! visible ) {
return;
}
// Update tooltip position if keyboard is visible.
if ( keyboardVisible ) {
getReferenceElementPosition();
}
// Hide tooltip if keyboard hides
if ( typeof previousVisible !== 'undefined' && ! keyboardVisible ) {
setAnimating( true );
setVisible( false );
}
// See https://github.com/WordPress/gutenberg/pull/41166
}, [ visible, keyboardVisible ] );
// Manage tooltip position during keyboard frame changes.
useEffect( () => {
const frameListener = Keyboard.addListener(
'keyboardWillChangeFrame',
() => {
if ( visible ) {
getReferenceElementPosition();
}
}
);
return () => {
frameListener.remove();
};
}, [ visible ] );
const startAnimation = () => {
Animated.timing( animationValue, {
toValue: visible ? 1 : 0,
duration: visible ? 300 : 150,
useNativeDriver: true,
delay: visible ? 500 : 0,
easing: Easing.out( Easing.quad ),
} ).start( () => {
setAnimating( false );
} );
};
const tooltipStyles = [
styles.tooltip,
{
left:
referenceLayout.x +
Math.floor( referenceLayout.width / 2 ) -
( horizontalPosition === 'right'
? RIGHT_ALIGN_ARROW_OFFSET
: Math.floor( tooltipLayout.width / 2 ) ),
top:
referenceLayout.y -
tooltipLayout.height -
TOOLTIP_VERTICAL_OFFSET,
},
];
const tooltipBoxStyles = [
styles.tooltip__box,
horizontalPosition === 'right' && styles[ 'tooltip--rightAlign' ],
{
elevation: 2,
opacity: animationValue,
shadowColor: styles.tooltip__shadow?.color,
shadowOffset: { height: 2, width: 0 },
shadowOpacity: 0.25,
shadowRadius: 2,
transform: [
{
translateY: animationValue.interpolate( {
inputRange: [ 0, 1 ],
outputRange: [ visible ? 4 : -8, -8 ],
} ),
},
],
},
];
const arrowStyles = [
styles.tooltip__arrow,
horizontalPosition === 'right' &&
styles[ 'tooltip__arrow--rightAlign' ],
];
const getReferenceElementPosition = () => {
// rAF allows render to complete before calculating layout
// eslint-disable-next-line no-undef
requestAnimationFrame( () => {
if ( ! referenceElementRef.current ) {
return;
}
referenceElementRef.current.measure(
( _x, _y, width, height, pageX, pageY ) => {
setReferenceLayout( {
height,
width,
x: pageX,
y: pageY,
} );
}
);
} );
};
const getTooltipLayout = ( { nativeEvent } ) => {
const { height, width } = nativeEvent.layout;
setTooltipLayout( { height, width } );
};
if ( hidden ) {
return children;
}
return (
<>
{ cloneElement( children, {
ref: referenceElementRef,
onLayout: getReferenceElementPosition,
} ) }
<Fill>
<View onLayout={ getTooltipLayout } style={ tooltipStyles }>
<Animated.View style={ tooltipBoxStyles }>
<Text style={ styles.tooltip__text }>{ text }</Text>
<View style={ arrowStyles } />
</Animated.View>
</View>
</Fill>
</>
);
};
const TooltipSlot = ( { children, ...rest } ) => {
const [ handleScreenTouch, setHandleScreenTouch ] = useState( null );
const onHandleScreenTouch = ( callback ) => {
// Must use function to set state below as `callback` is a function itself.
setHandleScreenTouch( () => callback );
};
const handleTouchStart = () => {
handleScreenTouch();
setHandleScreenTouch( null );
};
// Memoize context value to avoid unnecessary rerenders of the Provider's children
// See https://github.com/WordPress/gutenberg/pull/41166
const value = useMemo( () => ( { onHandleScreenTouch } ) );
return (
<TooltipContext.Provider value={ value }>
<View
onTouchStart={
typeof handleScreenTouch === 'function'
? handleTouchStart
: undefined
}
pointerEvents="box-none"
style={ StyleSheet.absoluteFill }
testID="tooltip-overlay"
>
{ children }
<Slot { ...rest } />
</View>
</TooltipContext.Provider>
);
};
Tooltip.Slot = TooltipSlot;
export default Tooltip;