/** * RootView.tsx * * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT license. * * The top-most view (rendered into window.body) that's used for proper * layering or modals, etc. in the web implementation of the ReactXP * cross-platform library. */ import * as PropTypes from 'prop-types'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { Types } from '../common/Interfaces'; import { recalcPositionFromLayoutData, RecalcResult } from '../common/PopupContainerViewBase'; import Timers from '../common/utils/Timers'; import AccessibilityAnnouncer from './AccessibilityAnnouncer'; import FocusManager from './utils/FocusManager'; import Input from './Input'; import * as _ from './utils/lodashMini'; import ModalContainer from './ModalContainer'; import PopupContainerView from './PopupContainerView'; import UserInterface from './UserInterface'; export class PopupDescriptor { constructor(public popupId: string, public popupOptions: Types.PopupOptions) {} } export interface RootViewProps extends Types.CommonProps { mainView?: React.ReactNode; modal?: React.ReactElement; activePopup?: PopupDescriptor; cachedPopup?: PopupDescriptor[]; autoDismiss?: boolean; autoDismissDelay?: number; onDismissPopup?: () => void; keyBoardFocusOutline?: string; mouseFocusOutline?: string; writingDirection?: 'auto' | 'rtl' | 'ltr'; } export interface RootViewState extends RecalcResult { // We need to measure the popup before it can be positioned. This indicates that we're in the "measuring" phase. isMeasuringPopup: boolean; // Measured (unconstrained) dimensions of the popup; not valid if isMeasuringPopup is false. popupWidth: number; popupHeight: number; // Assign css focus class if focus is due to Keyboard or mouse focusClass: string | undefined; } // Button code for when right click is pressed in a mouse event const _rightClickButtonCode = 2; const KEY_CODE_TAB = 9; const KEY_CODE_ESC = 27; // Setting the expected default box-sizing for everything. if (typeof document !== 'undefined') { const defaultBoxSizing = '*, *:before, *:after { box-sizing: border-box; }'; const style = document.createElement('style'); style.type = 'text/css'; style.appendChild(document.createTextNode(defaultBoxSizing)); document.head.appendChild(style); } export interface MainViewContext { isInRxMainView?: boolean; } // This helper class wraps the main view and passes a boolean value // "isInRxMainView" to all children found within it. This is used to // prevent gesture handling within the main view when a modal is displayed. export class MainViewContainer extends React.Component, Types.Stateless> implements React.ChildContextProvider { static childContextTypes: React.ValidationMap = { isInRxMainView: PropTypes.bool }; getChildContext(): MainViewContext { return { isInRxMainView: true }; } render() { return ( this.props.children ); } } export class RootView extends React.Component { static childContextTypes: React.ValidationMap = { focusManager: PropTypes.object }; private _mountedComponent: Element | undefined; private _hidePopupTimer: number | undefined; private _respositionPopupTimer: number | undefined; private _clickHandlerInstalled = false; private _keyboardHandlerInstalled = false; private _focusManager: FocusManager; private _isNavigatingWithKeyboardUpateTimer: number | undefined; private _shouldEnableKeyboardNavigationModeOnFocus = false; private _applicationIsNotActive = false; private _applicationIsNotActiveTimer: number | undefined; private _prevFocusedElement: HTMLElement | undefined; private _updateKeyboardNavigationModeOnFocusTimer: number | undefined; constructor(props: RootViewProps) { super(props); this.state = this._getInitialState(); // Initialize the root FocusManager which is aware of all // focusable elements. this._focusManager = new FocusManager(undefined); } getChildContext() { // Provide the context with root FocusManager to all descendants. return { focusManager: this._focusManager }; } private _getInitialState(): RootViewState { return { isMeasuringPopup: true, anchorPosition: 'left', anchorOffset: 0, popupY: 0, popupX: 0, popupWidth: 0, popupHeight: 0, constrainedPopupWidth: 0, constrainedPopupHeight: 0, focusClass: this.props.mouseFocusOutline }; } UNSAFE_componentWillReceiveProps(prevProps: RootViewProps) { if (this.props.activePopup !== prevProps.activePopup) { this._stopHidePopupTimer(); // If the popup changes, reset our state. this.setState(this._getInitialState()); } } componentDidUpdate(prevProps: RootViewProps, prevState: RootViewState) { if (this.props.activePopup) { this._stopHidePopupTimer(); this._recalcPosition(); if (!this._respositionPopupTimer) { this._startRepositionPopupTimer(); } this._startHidePopupTimer(); if (!this._clickHandlerInstalled) { document.addEventListener('mousedown', this._tryClosePopup); document.addEventListener('touchstart', this._tryClosePopup); this._clickHandlerInstalled = true; } } else { this._stopRepositionPopupTimer(); if (this._clickHandlerInstalled) { document.removeEventListener('mousedown', this._tryClosePopup); document.removeEventListener('touchstart', this._tryClosePopup); this._clickHandlerInstalled = false; } } } componentDidMount() { if (this.props.activePopup) { this._recalcPosition(); } if (this.props.activePopup) { this._startRepositionPopupTimer(); } if (!this._keyboardHandlerInstalled) { window.addEventListener('keydown', this._onKeyDown); window.addEventListener('keyup', this._onKeyUp); window.addEventListener('keydown', this._onKeyDownCapture, true); // Capture! window.addEventListener('mousedown', this._onMouseDownCapture, true); // Capture! window.addEventListener('focusin', this._onFocusIn); window.addEventListener('focusout', this._onFocusOut); this._keyboardHandlerInstalled = true; } } componentWillUnmount() { this._stopHidePopupTimer(); this._stopRepositionPopupTimer(); if (this._keyboardHandlerInstalled) { window.removeEventListener('keydown', this._onKeyDown); window.removeEventListener('keyup', this._onKeyUp); window.removeEventListener('keydown', this._onKeyDownCapture, true); window.removeEventListener('mousedown', this._onMouseDownCapture, true); window.removeEventListener('focusin', this._onFocusIn); window.removeEventListener('focusout', this._onFocusOut); this._keyboardHandlerInstalled = false; } } private _renderPopup(popup: PopupDescriptor, hidden: boolean): JSX.Element { const popupContainerStyle: React.CSSProperties = { display: 'flex', position: 'fixed', zIndex: 100001 }; if (!hidden) { popupContainerStyle.top = this.state.popupY; popupContainerStyle.left = this.state.popupX; // Are we artificially constraining the width and/or height? if (this.state.constrainedPopupWidth && this.state.constrainedPopupWidth !== this.state.popupWidth) { popupContainerStyle.width = this.state.constrainedPopupWidth; } if (this.state.constrainedPopupHeight && this.state.constrainedPopupHeight !== this.state.popupHeight) { popupContainerStyle.height = this.state.constrainedPopupHeight; } } const key = (popup.popupOptions.cacheable ? 'CP:' : 'P:') + popup.popupId; const renderedPopup = (hidden ? popup.popupOptions.renderPopup('top', 0, 0, 0) : popup.popupOptions.renderPopup( this.state.anchorPosition, this.state.anchorOffset, this.state.constrainedPopupWidth, this.state.constrainedPopupHeight) ); return ( ); } render() { const rootViewStyle = { width: '100%', height: '100%', display: 'flex', cursor: 'default' }; const optionalPopups: JSX.Element[] = []; if (this.props.activePopup) { optionalPopups.push(this._renderPopup(this.props.activePopup, false)); } if (this.props.cachedPopup) { this.props.cachedPopup.forEach(popup => optionalPopups.push(this._renderPopup(popup, true))); } let optionalModal: JSX.Element | null = null; if (this.props.modal) { optionalModal = ( { this.props.modal } ); } return (
{ this.props.mainView } { optionalModal } { optionalPopups }
); } protected _onMount = (component: PopupContainerView | null) => { const domNode = component && ReactDOM.findDOMNode(component) as HTMLElement | null; this._mountedComponent = domNode ? domNode : undefined; } private _tryClosePopup = (e: MouseEvent) => { // Dismiss a visible popup if there's a click outside. const popupContainer = this._mountedComponent; if (!popupContainer) { return; } let clickInPopup = false; let el = e.target as HTMLElement | undefined; while (el) { if (el === popupContainer) { clickInPopup = true; break; } el = el.parentElement || undefined; } if (!clickInPopup && e.button !== _rightClickButtonCode) { _.defer(() => { if (this.props.activePopup) { const anchorReference = this.props.activePopup.popupOptions.getAnchor(); const isClickOnAnchor = this._determineIfClickOnElement(anchorReference, e.srcElement as Element); let isClickOnContainer = false; if (!isClickOnAnchor && this.props.activePopup.popupOptions.getElementTriggeringPopup) { const containerRef = this.props.activePopup.popupOptions.getElementTriggeringPopup(); isClickOnContainer = this._determineIfClickOnElement(containerRef, e.srcElement as Element); } if (isClickOnAnchor || isClickOnContainer) { // If the press event was on the anchor, we can notify the caller about it. // Showing another animation while dimissing the popup creates a conflict in the UI making it not doing one of the // two animations (i.e.: Opening an actionsheet while dismissing a popup). We introduce this delay to make sure // the popup dimissing animation has finished before we call the event handler. if (this.props.activePopup.popupOptions.onAnchorPressed) { Timers.setTimeout(() => { // We can't pass through the DOM event argument to the anchor event handler as the event we have at this // point is a DOM Event and the anchor expect a Synthetic event. There doesn't seem to be any way to convert // between them. Passing null for now. this.props.activePopup!.popupOptions.onAnchorPressed!(undefined); }, 500); } // If the popup is meant to behave like a toggle, we should not dimiss the popup from here since the event came // from the anchor/container of the popup. The popup will be dismissed during the click handling of the // anchor/container. if (this.props.activePopup.popupOptions.dismissIfShown) { return; } } if (this.props.activePopup.popupOptions.preventDismissOnPress) { return; } } this._dismissPopup(); }); } } private _determineIfClickOnElement(elementReference: React.Component, eventSource: Element | null | undefined): boolean { try { const element = ReactDOM.findDOMNode(elementReference) as HTMLElement | null; const isClickOnElement = !!element && !!eventSource && element.contains(eventSource); return isClickOnElement; } catch { // Exception is due to race condition with unmounting. return false; } } private _onMouseDownCapture = (e: MouseEvent) => { if (e && (e.clientX === 0) && (e.clientY === 0) && (e.screenX === 0) && (e.screenY === 0)) { // This is most likely an event triggered by NVDA when Enter or // Space is pressed, do not dismiss the keyboard navigation mode. return; } this._shouldEnableKeyboardNavigationModeOnFocus = false; this._updateKeyboardNavigationState(false); } private _onKeyDownCapture = (e: KeyboardEvent) => { if (e.keyCode === KEY_CODE_TAB) { this._updateKeyboardNavigationState(true); } if (e.keyCode === KEY_CODE_ESC) { // If Esc is pressed and the focused element stays the same after some time, // switch the keyboard navigation off to dismiss the outline. const activeElement = document.activeElement; if (this._isNavigatingWithKeyboardUpateTimer) { Timers.clearTimeout(this._isNavigatingWithKeyboardUpateTimer); } this._isNavigatingWithKeyboardUpateTimer = Timers.setTimeout(() => { this._isNavigatingWithKeyboardUpateTimer = undefined; if ((document.activeElement === activeElement) && activeElement && (activeElement !== document.body)) { this._updateKeyboardNavigationState(false); } }, 500); } } private _onFocusIn = (e: FocusEvent) => { // When the screen reader is being used, we need to enable the keyboard navigation // mode. It's not possible to detect the screen reader on web. To work it around we // apply the following assumption: if the focus is moved without using the mouse and // not from the application code with focus() method, it is most likely moved by the // screen reader. this._cancelApplicationIsNotActive(); const target = e.target as HTMLElement; if (this._updateKeyboardNavigationModeOnFocusTimer) { Timers.clearTimeout(this._updateKeyboardNavigationModeOnFocusTimer); } this._updateKeyboardNavigationModeOnFocusTimer = Timers.setTimeout(() => { this._updateKeyboardNavigationModeOnFocusTimer = undefined; const prev = this._prevFocusedElement; const curShouldEnable = this._shouldEnableKeyboardNavigationModeOnFocus; this._prevFocusedElement = target; this._shouldEnableKeyboardNavigationModeOnFocus = true; if (this._applicationIsNotActive) { this._applicationIsNotActive = false; return; } if ((prev === target) || (target === FocusManager.getLastFocusedProgrammatically(true))) { return; } if (!UserInterface.isNavigatingWithKeyboard() && curShouldEnable) { this._updateKeyboardNavigationState(true); } }, 0); } private _onFocusOut = (e: FocusEvent) => { // If the focus is out and nothing is focused after some time, most likely // the application has been deactivated, so the next focusin will be about // activating the application back again and should be ignored. // This is a safety pillow for checking that _prevFocusedElement is changed, // as _prevFocusedElement might be gone while the application is not active. this._requestApplicationIsNotActive(); } private _requestApplicationIsNotActive() { this._cancelApplicationIsNotActive(); this._applicationIsNotActiveTimer = Timers.setTimeout(() => { this._applicationIsNotActiveTimer = undefined; this._applicationIsNotActive = true; }, 100); } private _cancelApplicationIsNotActive() { if (this._applicationIsNotActiveTimer) { Timers.clearTimeout(this._applicationIsNotActiveTimer); this._applicationIsNotActiveTimer = undefined; } } private _updateKeyboardNavigationState(isNavigatingWithKeyboard: boolean) { if (this._isNavigatingWithKeyboardUpateTimer) { Timers.clearTimeout(this._isNavigatingWithKeyboardUpateTimer); this._isNavigatingWithKeyboardUpateTimer = undefined; } if (UserInterface.isNavigatingWithKeyboard() !== isNavigatingWithKeyboard) { UserInterface.keyboardNavigationEvent.fire(isNavigatingWithKeyboard); const focusClass = isNavigatingWithKeyboard ? this.props.keyBoardFocusOutline : this.props.mouseFocusOutline; if (this.state.focusClass !== focusClass) { this.setState({ focusClass: focusClass }); } } } private _onKeyDown = (e: KeyboardEvent) => { Input.dispatchKeyDown(e as any); } private _onKeyUp = (e: KeyboardEvent) => { if (this.props.activePopup && (e.keyCode === KEY_CODE_ESC)) { if (e.stopPropagation) { e.stopPropagation(); } this._dismissPopup(); return; } Input.dispatchKeyUp(e as any); } private _onMouseEnter(e: React.MouseEvent) { this._stopHidePopupTimer(); } private _onMouseLeave(e: React.MouseEvent) { this._startHidePopupTimer(); } private _startHidePopupTimer() { if (this.props.autoDismiss) { // Should we immediately hide it, or did the caller request a delay? if (!_.isUndefined(this.props.autoDismissDelay) && this.props.autoDismissDelay > 0) { this._hidePopupTimer = Timers.setTimeout(() => { this._hidePopupTimer = undefined; this._dismissPopup(); }, this.props.autoDismissDelay); } else { this._dismissPopup(); } } } private _stopHidePopupTimer() { if (this._hidePopupTimer) { Timers.clearTimeout(this._hidePopupTimer); this._hidePopupTimer = undefined; } } private _dismissPopup() { if (this.props.onDismissPopup) { this.props.onDismissPopup(); } } private _startRepositionPopupTimer() { this._respositionPopupTimer = Timers.setInterval(() => { this._recalcPosition(); }, 500); } private _stopRepositionPopupTimer() { if (this._respositionPopupTimer) { Timers.clearInterval(this._respositionPopupTimer); this._respositionPopupTimer = undefined; } } // Recalculates the position and constrained size of the popup based on the current position of the anchor and the // window size. If necessary, it also measures the unconstrained size of the popup. private _recalcPosition() { // Make a copy of the old state. const newState: RootViewState = _.extend({}, this.state); if (this.state.isMeasuringPopup) { // Get the width/height of the popup. const popup = this._mountedComponent; if (!popup) { return; } newState.isMeasuringPopup = false; newState.popupHeight = popup.clientHeight; newState.popupWidth = popup.clientWidth; } // Get the anchor element. const anchorComponent = this.props.activePopup!.popupOptions.getAnchor(); // if the anchor is unmounted, dismiss the popup. // Prevents app crash when we try to get dom node from unmounted Component if (!anchorComponent) { this._dismissPopup(); return; } let anchor: HTMLElement | null = null; try { anchor = ReactDOM.findDOMNode(anchorComponent) as HTMLElement | null; } catch { anchor = null; } // If the anchor has disappeared, dismiss the popup. if (!anchor) { this._dismissPopup(); return; } // Calculate the absolute position of the anchor element's top/left. const anchorRect = anchor.getBoundingClientRect(); const popupDims = { width: newState.popupWidth, height: newState.popupHeight }; // Get the width/height of root view window. const windowDims = { width: window.innerWidth, height: window.innerHeight }; // Run the common recalc function and see what magic it spits out. const result = recalcPositionFromLayoutData(windowDims, anchorRect, popupDims, this.props.activePopup!.popupOptions.positionPriorities, this.props.activePopup!.popupOptions.useInnerPositioning); if (!result) { this._dismissPopup(); return; } _.extend(newState, result); if (!_.isEqual(newState, this.state)) { this.setState(newState); } } } export default RootView;