diff --git a/src/components/Frame/Frame.tsx b/src/components/Frame/Frame.tsx index 6df160ce43a..8fa44c57b7c 100644 --- a/src/components/Frame/Frame.tsx +++ b/src/components/Frame/Frame.tsx @@ -1,18 +1,16 @@ -import React, {createRef} from 'react'; +import React, {useState, useRef, useEffect, useCallback, useMemo} from 'react'; import {MobileCancelMajorMonotone} from '@shopify/polaris-icons'; import {durationSlow} from '@shopify/polaris-tokens'; import {CSSTransition} from '@material-ui/react-transition-group'; import {classNames} from '../../utilities/css'; import {Icon} from '../Icon'; -import {EventListener} from '../EventListener'; -import { - withAppProvider, - WithAppProviderProps, -} from '../../utilities/with-app-provider'; import {Backdrop} from '../Backdrop'; import {TrapFocus} from '../TrapFocus'; import {dataPolarisTopBar, layer} from '../shared'; +import {useForcibleToggle} from '../../utilities/use-toggle'; import {setRootProperty} from '../../utilities/set-root-property'; +import {useI18n} from '../../utilities/i18n'; +import {useMediaQuery} from '../../utilities/media-query'; import { FrameContext, ContextualSaveBarProps, @@ -48,14 +46,6 @@ export interface FrameProps { onNavigationDismiss?(): void; } -interface State { - skipFocused?: boolean; - globalRibbonHeight: number; - loadingStack: number; - toastMessages: (ToastPropsWithID)[]; - showContextualSaveBar: boolean; -} - export const GLOBAL_RIBBON_CUSTOM_PROPERTY = '--global-ribbon-height'; export const APP_FRAME_MAIN = 'AppFrameMain'; @@ -66,340 +56,292 @@ const APP_FRAME_NAV = 'AppFrameNav'; const APP_FRAME_TOP_BAR = 'AppFrameTopBar'; const APP_FRAME_LOADING_BAR = 'AppFrameLoadingBar'; -type CombinedProps = FrameProps & WithAppProviderProps; - -class Frame extends React.PureComponent { - state: State = { - skipFocused: false, - globalRibbonHeight: 0, - loadingStack: 0, - toastMessages: [], - showContextualSaveBar: false, - }; - - private contextualSaveBar: ContextualSaveBarProps | null; - private globalRibbonContainer: HTMLDivElement | null = null; - private navigationNode = createRef(); - private skipToMainContentTargetNode = - this.props.skipToContentTarget || React.createRef(); - - componentDidMount() { - this.handleResize(); - if (this.props.globalRibbon) { - return; +export function Frame({ + children, + navigation, + topBar, + globalRibbon, + onNavigationDismiss, + skipToContentTarget, + showMobileNavigation = false, +}: FrameProps) { + const i18n = useI18n(); + const {isNavigationCollapsed} = useMediaQuery(); + + const globalRibbonContainer = useRef(null); + const navigationNode = useRef(null); + const skipToMainContentTargetNode = useRef(null); + + const [loadingStack, setLoadingStack] = useState(0); + const [toastMessages, setToastMessages] = useState<(ToastPropsWithID)[]>([]); + const [ + skipFocused, + {forceTrue: forceTrueSkipFocused, forceFalse: forceFalseSkipFocused}, + ] = useForcibleToggle(false); + const [ + showContextualSaveBar, + { + forceTrue: forceTrueShowContextualSaveBar, + forceFalse: forceFalseShowContextualSaveBar, + }, + ] = useForcibleToggle(false); + const [ + contextualSaveBarProps, + setContextualSaveBarProps, + ] = useState(null); + + const findNavigationNode = useCallback(() => navigationNode.current, []); + + const handleNavigationDismiss = useCallback(() => { + if (onNavigationDismiss != null) { + onNavigationDismiss(); } - this.setGlobalRibbonRootProperty(); - } - - componentDidUpdate(prevProps: FrameProps) { - if (this.props.globalRibbon !== prevProps.globalRibbon) { - this.setGlobalRibbonHeight(); + }, [onNavigationDismiss]); + + const handleNavKeydown = useCallback( + (event: React.KeyboardEvent) => { + const {key} = event; + + if (key === 'Escape') { + handleNavigationDismiss(); + } + }, + [handleNavigationDismiss], + ); + + const handleClick = useCallback(() => { + if (skipToContentTarget && skipToContentTarget.current) { + skipToContentTarget.current.focus(); + } else if (skipToMainContentTargetNode.current) { + skipToMainContentTargetNode.current.focus(); } - } - - render() { - const { - skipFocused, - loadingStack, - toastMessages, - showContextualSaveBar, - } = this.state; - const { - children, - navigation, - topBar, - globalRibbon, - showMobileNavigation = false, - skipToContentTarget, - polaris: { - intl, - mediaQuery: {isNavigationCollapsed}, - }, - } = this.props; - - const navClassName = classNames( - styles.Navigation, - showMobileNavigation && styles['Navigation-visible'], - ); + }, [skipToContentTarget]); - const mobileNavHidden = isNavigationCollapsed && !showMobileNavigation; - const mobileNavShowing = isNavigationCollapsed && showMobileNavigation; - const tabIndex = mobileNavShowing ? 0 : -1; - - const navigationMarkup = navigation ? ( - - - - - - ) : null; + const showToast = useCallback((toast: ToastPropsWithID) => { + setToastMessages((toastMessages) => { + const hasToastById = Boolean( + toastMessages.find(({id}) => id === toast.id), + ); - const loadingMarkup = - loadingStack > 0 ? ( -
- -
- ) : null; + return hasToastById ? toastMessages : [...toastMessages, toast]; + }); + }, []); - const contextualSaveBarMarkup = ( - - - + const hideToast = useCallback(({id}: ToastID) => { + setToastMessages((toastMessages) => + toastMessages.filter(({id: toastId}) => id !== toastId), ); - - const topBarMarkup = topBar ? ( -
{ + setLoadingStack((loadingStack) => loadingStack + 1); + }, []); + + const stopLoading = useCallback(() => { + setLoadingStack((loadingStack) => Math.max(0, loadingStack - 1)); + }, []); + + const setContextualSaveBar = useCallback( + (props: ContextualSaveBarProps) => { + setContextualSaveBarProps({...props}); + forceTrueShowContextualSaveBar(); + }, + [forceTrueShowContextualSaveBar], + ); + + const removeContextualSaveBar = useCallback(() => { + setContextualSaveBarProps(null); + forceFalseShowContextualSaveBar(); + }, [forceFalseShowContextualSaveBar]); + + useEffect(() => { + if (globalRibbon && globalRibbonContainer.current) { + const globalRibbonHeight = globalRibbonContainer.current.offsetHeight; + setRootProperty( + GLOBAL_RIBBON_CUSTOM_PROPERTY, + `${globalRibbonHeight}px`, + null, + ); + } + }, [globalRibbon]); + + const context = useMemo( + () => ({ + showToast, + hideToast, + startLoading, + stopLoading, + setContextualSaveBar, + removeContextualSaveBar, + }), + [ + hideToast, + removeContextualSaveBar, + setContextualSaveBar, + showToast, + startLoading, + stopLoading, + ], + ); + + const navClassName = classNames( + styles.Navigation, + showMobileNavigation && styles['Navigation-visible'], + ); + + const skipTarget = skipToContentTarget + ? (skipToContentTarget.current && skipToContentTarget.current.id) || '' + : APP_FRAME_MAIN_ANCHOR_TARGET; + + const mobileNavHidden = isNavigationCollapsed && !showMobileNavigation; + const mobileNavShowing = isNavigationCollapsed && showMobileNavigation; + const tabIndex = mobileNavShowing ? 0 : -1; + + const navigationMarkup = navigation ? ( + + - {topBar} + + + + ) : null; + + const loadingMarkup = + loadingStack > 0 ? ( +
+
) : null; - const globalRibbonMarkup = globalRibbon ? ( -
+ + + ); + + const topBarMarkup = topBar ? ( +
+ {topBar} +
+ ) : null; + + const globalRibbonMarkup = globalRibbon ? ( +
+ {globalRibbon} +
+ ) : null; + + const skipClassName = classNames(styles.Skip, skipFocused && styles.focused); + + const skipMarkup = ( + + {i18n.translate('Polaris.Frame.skipToContent')} + +
+ ); + + const navigationAttributes = navigation + ? { + 'data-has-navigation': true, + } + : {}; + + const frameClassName = classNames( + styles.Frame, + navigation && styles.hasNav, + topBar && styles.hasTopBar, + ); + + const navigationOverlayMarkup = + showMobileNavigation && isNavigationCollapsed ? ( + ) : null; - const skipClassName = classNames( - styles.Skip, - skipFocused && styles.focused, - ); - - const skipTarget = skipToContentTarget - ? (skipToContentTarget.current && skipToContentTarget.current.id) || '' - : APP_FRAME_MAIN_ANCHOR_TARGET; - - const skipMarkup = ( -
- + ); + + return ( + + - ); - - const navigationAttributes = navigation - ? { - 'data-has-navigation': true, - } - : {}; - - const frameClassName = classNames( - styles.Frame, - navigation && styles.hasNav, - topBar && styles.hasTopBar, - ); - - const navigationOverlayMarkup = - showMobileNavigation && isNavigationCollapsed ? ( - - ) : null; - - const skipToMainContentTarget = skipToContentTarget ? null : ( - // eslint-disable-next-line jsx-a11y/anchor-is-valid - - ); - - const context = { - showToast: this.showToast, - hideToast: this.hideToast, - startLoading: this.startLoading, - stopLoading: this.stopLoading, - setContextualSaveBar: this.setContextualSaveBar, - removeContextualSaveBar: this.removeContextualSaveBar, - }; - - return ( - -
- {skipMarkup} - {topBarMarkup} - {navigationMarkup} - {contextualSaveBarMarkup} - {loadingMarkup} - {navigationOverlayMarkup} -
- {skipToMainContentTarget} -
{children}
-
- - {globalRibbonMarkup} - -
-
- ); - } - - private setGlobalRibbonHeight = () => { - const {globalRibbonContainer} = this; - if (globalRibbonContainer) { - this.setState( - { - globalRibbonHeight: globalRibbonContainer.offsetHeight, - }, - this.setGlobalRibbonRootProperty, - ); - } - }; - - private setGlobalRibbonRootProperty = () => { - const {globalRibbonHeight} = this.state; - setRootProperty( - GLOBAL_RIBBON_CUSTOM_PROPERTY, - `${globalRibbonHeight}px`, - null, - ); - }; - - private showToast = (toast: ToastPropsWithID) => { - this.setState(({toastMessages}: State) => { - const hasToastById = - toastMessages.find(({id}) => id === toast.id) != null; - return { - toastMessages: hasToastById ? toastMessages : [...toastMessages, toast], - }; - }); - }; - - private hideToast = ({id}: ToastID) => { - this.setState(({toastMessages}: State) => { - return { - toastMessages: toastMessages.filter(({id: toastId}) => id !== toastId), - }; - }); - }; - - private setContextualSaveBar = (props: ContextualSaveBarProps) => { - const {showContextualSaveBar} = this.state; - this.contextualSaveBar = {...props}; - if (showContextualSaveBar === true) { - this.forceUpdate(); - } else { - this.setState({showContextualSaveBar: true}); - } - }; - - private removeContextualSaveBar = () => { - this.contextualSaveBar = null; - this.setState({showContextualSaveBar: false}); - }; - - private startLoading = () => { - this.setState(({loadingStack}: State) => ({ - loadingStack: loadingStack + 1, - })); - }; - - private stopLoading = () => { - this.setState(({loadingStack}: State) => ({ - loadingStack: Math.max(0, loadingStack - 1), - })); - }; - - private handleResize = () => { - if (this.props.globalRibbon) { - this.setGlobalRibbonHeight(); - } - }; - - private handleFocus = () => { - this.setState({skipFocused: true}); - }; - - private handleBlur = () => { - this.setState({skipFocused: false}); - }; - - private handleClick = () => { - this.skipToMainContentTargetNode.current && - this.skipToMainContentTargetNode.current.focus(); - }; - - private handleNavigationDismiss = () => { - const {onNavigationDismiss} = this.props; - if (onNavigationDismiss != null) { - onNavigationDismiss(); - } - }; - - private setGlobalRibbonContainer = (node: HTMLDivElement) => { - this.globalRibbonContainer = node; - }; - - private handleNavKeydown = (event: React.KeyboardEvent) => { - const {key} = event; - - if (key === 'Escape') { - this.handleNavigationDismiss(); - } - }; - - private findNavigationNode = () => { - return this.navigationNode.current; - }; + + ); } const navTransitionClasses = { @@ -409,7 +351,3 @@ const navTransitionClasses = { exit: classNames(styles['Navigation-exit']), exitActive: classNames(styles['Navigation-exitActive']), }; - -// Use named export once withAppProvider is refactored away -// eslint-disable-next-line import/no-default-export -export default withAppProvider()(Frame); diff --git a/src/components/Frame/components/ToastManager/tests/ToastManager.test.tsx b/src/components/Frame/components/ToastManager/tests/ToastManager.test.tsx index 607a528e89f..50d41dc08c1 100644 --- a/src/components/Frame/components/ToastManager/tests/ToastManager.test.tsx +++ b/src/components/Frame/components/ToastManager/tests/ToastManager.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import {timer} from '@shopify/jest-dom-mocks'; import {mountWithAppProvider} from 'test-utilities/legacy'; import {Toast} from '../../Toast'; -import Frame from '../../../Frame'; +import {Frame} from '../../../Frame'; import {ToastManager} from '..'; window.matchMedia = diff --git a/src/components/Frame/index.ts b/src/components/Frame/index.ts index 5c0e5343228..2cbd6b0a2f0 100644 --- a/src/components/Frame/index.ts +++ b/src/components/Frame/index.ts @@ -1,6 +1,4 @@ -import Frame, {FrameProps} from './Frame'; - -export {Frame, FrameProps}; +export {Frame, FrameProps} from './Frame'; export { DEFAULT_TOAST_DURATION, diff --git a/src/components/Frame/tests/Frame.test.tsx b/src/components/Frame/tests/Frame.test.tsx index 7be9c2da9ad..657dc05ac58 100644 --- a/src/components/Frame/tests/Frame.test.tsx +++ b/src/components/Frame/tests/Frame.test.tsx @@ -8,7 +8,7 @@ import { ContextualSaveBar as PolarisContextualSavebar, Loading as PolarisLoading, } from 'components'; -import Frame from '../Frame'; +import {Frame} from '../Frame'; import { ContextualSaveBar as FrameContextualSavebar, Loading as FrameLoading,