diff --git a/Popup/Popup.js b/Popup/Popup.js index ab3ab660a..5416d492b 100644 --- a/Popup/Popup.js +++ b/Popup/Popup.js @@ -11,9 +11,10 @@ import {is} from '@enact/core/keymap'; import {on, off} from '@enact/core/dispatcher'; +import {setDefaultProps} from '@enact/core/util'; import FloatingLayer from '@enact/ui/FloatingLayer'; import kind from '@enact/core/kind'; -import {Component} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import PropTypes from 'prop-types'; import Spotlight, {getDirection} from '@enact/spotlight'; import Pause from '@enact/spotlight/Pause'; @@ -278,6 +279,15 @@ const OpenState = { OPEN: 2 }; +const popupDefaultProps = { + noAnimation: false, + noAutoDismiss: false, + open: false, + position: 'bottom', + scrimType: 'translucent', + spotlightRestrict: 'self-only' +}; + /** * A stateful component that renders a popup in a * {@link ui/FloatingLayer.FloatingLayer|FloatingLayer}. @@ -288,261 +298,30 @@ const OpenState = { * @ui * @public */ -class Popup extends Component { - - static propTypes = /** @lends sandstone/Popup.Popup.prototype */ { - /** - * Prevents closing the popup via 5-way navigation out of the content. - * - * @type {Boolean} - * @default false - * @private - */ - no5WayClose: PropTypes.bool, - - /** - * Disables transition animation. - * - * @type {Boolean} - * @default false - * @public - */ - noAnimation: PropTypes.bool, - - /** - * Indicates that the popup will not trigger `onClose` when the user presses the cancel/back (e.g. `ESC`) key or - * taps outside of the popup. - * - * @type {Boolean} - * @default false - * @public - */ - noAutoDismiss: PropTypes.bool, - - /** - * Called on: - * - * * pressing `ESC` key, - * * clicking on outside the boundary of the popup, or - * * moving spotlight focus outside the boundary of the popup when `spotlightRestrict` is - * `'self-first'`. - * - * Event payload: - * - * * pressing `ESC` key, carries `detail` property containing `inputType` value of `'key'`. - * * clicking outside the boundary of the popup, carries `detail` property containing - * `inputType` value of `'click'`. - * - * It is the responsibility of the callback to set the `open` property to `false`. - * - * @type {Function} - * @public - */ - onClose: PropTypes.func, - - /** - * Called after hide transition has completed, and immediately with no transition. - * - * @type {Function} - * @public - */ - onHide: PropTypes.func, - - /** - * Called when a key is pressed. - * - * @type {Function} - * @public - */ - onKeyDown: PropTypes.func, +const Popup = (props) => { + const allComponentProps = setDefaultProps(props, popupDefaultProps); + const {noAnimation, open, position, spotlightRestrict} = allComponentProps; + const {noAutoDismiss, no5WayClose, onClose, scrimType, ...rest} = allComponentProps; - /** - * Called after show transition has completed, and immediately with no transition. - * - * Note: The function does not run if Popup is initially opened and - * {@link sandstone/Popup.PopupBase.noAnimation|noAnimation} is `true`. - * - * @type {Function} - * @public - */ - onShow: PropTypes.func, - - /** - * Controls the visibility of the Popup. - * - * By default, the Popup and its contents are not rendered until `open`. - * - * @type {Boolean} - * @default false - * @public - */ - open: PropTypes.bool, - - /** - * Position of the Popup on the screen. - * - * @type {('bottom'|'center'|'fullscreen'|'left'|'right'|'top')} - * @default 'bottom' - * @public - */ - position: PropTypes.oneOf(['bottom', 'center', 'fullscreen', 'left', 'right', 'top']), - - /** - * Scrim type. - * - * * Values: `'transparent'`, `'translucent'`, or `'none'`. - * - * `'none'` is not compatible with `spotlightRestrict` of `'self-only'`, use a transparent scrim - * to prevent mouse focus when using popup. - * - * @type {('transparent'|'translucent'|'none')} - * @default 'translucent' - * @public - */ - scrimType: PropTypes.oneOf(['transparent', 'translucent', 'none']), + const [activator, setActivator] = useState(null); + const [floatLayerOpen, setFloatLayerOpen] = useState(open); + const [popupOpen, setPopupOpen] = useState(open ? OpenState.OPEN : OpenState.CLOSED); + const [prevOpen, setPrevOpen] = useState(open); - /** - * Restricts or prioritizes navigation when focus attempts to leave the popup. - * - * * Values: `'self-first'`, or `'self-only'`. - * - * When using `self-first`, attempts to leave the popup via 5-way will fire `onClose` based - * on the following values of `position`: - * - * * `'bottom'` - When leaving via 5-way up - * * `'top'` - When leaving via 5-way down - * * `'left'` - When leaving via 5-way right - * * `'right'` - When leaving via 5-way left - * * `'center'` - When leaving via any 5-way direction - * - * Note: If `onClose` is not set, then this has no effect on 5-way navigation. If the popup - * has no spottable children, 5-way navigation will cause the Popup to fire `onClose`. - * - * @type {('self-first'|'self-only')} - * @default 'self-only' - * @public - */ - spotlightRestrict: PropTypes.oneOf(['self-first', 'self-only']) - }; - - static defaultProps = { - noAnimation: false, - noAutoDismiss: false, - open: false, - position: 'bottom', - scrimType: 'translucent', - spotlightRestrict: 'self-only' - }; - - static getDerivedStateFromProps (props, state) { - if (props.open !== state.prevOpen) { - if (props.open) { - return { - popupOpen: props.noAnimation || state.floatLayerOpen ? OpenState.OPEN : OpenState.CLOSED, - floatLayerOpen: true, - activator: Spotlight.getCurrent(), - prevOpen: props.open - }; - } else { - // Disables the spotlight conatiner of popup when `noAnimation` set - if (props.noAnimation) { - const node = getContainerNode(state.containerId); - if (node) { - node.dataset['spotlightContainerDisabled'] = true; - } - } - - return { - popupOpen: OpenState.CLOSED, - floatLayerOpen: state.popupOpen === OpenState.OPEN ? !props.noAnimation : false, - activator: props.noAnimation ? null : state.activator, - prevOpen: props.open - }; - } - } - return null; - } - - constructor (props) { - super(props); - this.paused = new Pause('Popup'); - this.state = { - floatLayerOpen: this.props.open, - popupOpen: this.props.open ? OpenState.OPEN : OpenState.CLOSED, - prevOpen: this.props.open, - containerId: Spotlight.add(), - activator: null - }; - checkScrimNone(this.props); - } - - // Spot the content after it's mounted. - componentDidMount () { - if (this.props.open) { - // If the popup is open on mount, we need to pause spotlight so nothing steals focus - // while the popup is rendering. - this.paused.pause(); - if (getContainerNode(this.state.containerId)) { - this.spotPopupContent(); - } - } - } - - componentDidUpdate (prevProps, prevState) { - if (this.props.open !== prevProps.open) { - if (!this.props.noAnimation) { - if (!this.props.open && this.state.popupOpen === OpenState.CLOSED) { - // If the popup is supposed to be closed (!this.props.open) and is actually - // fully closed (OpenState.CLOSED), we must resume spotlight navigation. This - // can occur when quickly toggling a Popup open and closed. - this.paused.resume(); - } else { - // Otherwise, we pause spotlight so it is locked until the popup is ready - this.paused.pause(); - } - } else if (this.props.open) { - forwardShow(null, this.props); - this.spotPopupContent(); - } else if (prevProps.open) { - forwardHide(null, this.props); - this.spotActivator(prevState.activator); - } - } - - checkScrimNone(this.props); - } - - componentWillUnmount () { - if (this.props.open) { - off('keydown', this.handleKeyDown); - } - Spotlight.remove(this.state.containerId); - } - - handleFloatingLayerOpen = () => { - if (!this.props.noAnimation && this.state.popupOpen !== OpenState.OPEN) { - this.setState({ - popupOpen: OpenState.OPENING - }); - } else if (this.state.popupOpen === OpenState.OPEN && this.props.open) { - this.spotPopupContent(); - } - }; - - handleKeyDown = (ev) => { - const {onClose, no5WayClose, position, spotlightRestrict} = this.props; + const containerId = useRef(Spotlight.add()); + const paused = useRef(new Pause('Popup')); + const handleKeyDown = (ev) => { if (no5WayClose) return; - const {containerId} = this.state; const keyCode = ev.keyCode; const direction = getDirection(keyCode); - const spottables = Spotlight.getSpottableDescendants(containerId).length; + const spottables = Spotlight.getSpottableDescendants(containerId.current).length; const current = Spotlight.getCurrent(); - if (direction && (!spottables || current && getContainerNode(containerId).contains(current))) { + if (direction && (!spottables || current && getContainerNode(containerId.current).contains(current))) { // explicitly restrict navigation in order to manage focus state when attempting to leave the popup - Spotlight.set(containerId, {restrict: 'self-only'}); + Spotlight.set(containerId.current, {restrict: 'self-only'}); if (onClose) { let focusChanged; @@ -569,58 +348,31 @@ class Popup extends Component { ev.stopPropagation(); // set the pointer mode to false on keydown Spotlight.setPointerMode(false); - forwardCustom('onClose')(null, this.props); + forwardCustom('onClose')(null, allComponentProps); } } } }; - handleDismiss = (ev) => { - forwardCustom('onClose', () => ({detail: ev?.detail}))(null, this.props); - }; - - handlePopupHide = (ev) => { - forwardHide(ev, this.props); - - this.setState({ - floatLayerOpen: false, - activator: null - }); - - if (!ev.currentTarget || ev.currentTarget.getAttribute('data-spotlight-id') === this.state.containerId) { - this.spotActivator(this.state.activator); - } - }; - - handlePopupShow = (ev) => { - forwardShow(ev, this.props); + const handleKeyDownRef = useRef(handleKeyDown); - this.setState({ - popupOpen: OpenState.OPEN - }); - - if (!ev.currentTarget || ev.currentTarget.getAttribute('data-spotlight-id') === this.state.containerId) { - this.spotPopupContent(); - } - }; - - spotActivator = (activator) => { - this.paused.resume(); + const spotActivator = useCallback(() => { + paused.current.resume(); // only spot the activator if the popup is closed - if (this.props.open) return; + if (open) return; const current = Spotlight.getCurrent(); - const containerNode = getContainerNode(this.state.containerId); + const containerNode = getContainerNode(containerId.current); const lastContainerId = getLastContainer(); - off('keydown', this.handleKeyDown); + off('keydown', handleKeyDownRef.current); // if there is no currently-spotted control or it is wrapped by the popup's container, we // know it's safe to change focus if (!current || (containerNode && containerNode.contains(current))) { // attempt to set focus to the activator, if available - if (!Spotlight.isPaused()) { + if (!Spotlight.isPaused() || !paused.current.isPaused()) { if (activator) { if (!Spotlight.focus(activator)) { Spotlight.focus(); @@ -632,19 +384,17 @@ class Popup extends Component { } } } - }; + }, [activator, open]); - spotPopupContent = () => { - this.paused.resume(); + const spotPopupContent = useCallback(() => { + paused.current.resume(); // only spot the activator if the popup is open - if (!this.props.open) return; - - const {containerId} = this.state; + if (!open) return; - on('keydown', this.handleKeyDown); + on('keydown', handleKeyDownRef.current); - if (!Spotlight.isPaused() && !Spotlight.focus(containerId)) { + if (!Spotlight.isPaused() && !Spotlight.focus(containerId.current)) { const current = Spotlight.getCurrent(); // In cases where the container contains no spottable controls or we're in pointer-mode, focus @@ -653,36 +403,275 @@ class Popup extends Component { if (current) { current.blur(); } - Spotlight.setActiveContainer(containerId); + Spotlight.setActiveContainer(containerId.current); } - }; + }, [open]); + + const getDerivedStateFromProps = useCallback(() => { + if (open !== prevOpen) { + if (open) { + setPopupOpen(noAnimation || floatLayerOpen ? OpenState.OPEN : OpenState.CLOSED); + setFloatLayerOpen(true); + setActivator(Spotlight.getCurrent()); + setPrevOpen(open); + } else { + // Disables the spotlight conatiner of popup when `noAnimation` set + if (noAnimation) { + const node = getContainerNode(containerId.current); + if (node) { + node.dataset['spotlightContainerDisabled'] = true; + } + } - render () { - const {noAutoDismiss, scrimType, ...rest} = this.props; + setPopupOpen(OpenState.CLOSED); + setFloatLayerOpen(popupOpen === OpenState.OPEN ? !noAnimation : false); + setActivator(noAnimation ? null : activator); + setPrevOpen(open); + } + } + }, [activator, floatLayerOpen, noAnimation, open, popupOpen, prevOpen]); + + const handleComponentUpdate = useCallback(() => { + if (open !== prevOpen) { + if (!noAnimation) { + if (!open && popupOpen === OpenState.OPENING || !open && popupOpen === OpenState.OPEN) { + // If the popup is supposed to be closed (!open) and is actually not fully + // closed (OpenState.OPENING or OpenState.OPEN), we must resume spotlight navigation. This + // can occur when quickly toggling a Popup open and closed. + paused.current.resume(); + } else { + // Otherwise, we pause spotlight so it is locked until the popup is ready + paused.current.pause(); + } + } else if (open) { + forwardShow(null, allComponentProps); + spotPopupContent(); + } else if (prevOpen) { + forwardHide(null, allComponentProps); + spotActivator(); + } + } - delete rest.no5WayClose; - delete rest.onClose; + checkScrimNone(allComponentProps); + }, [allComponentProps, noAnimation, open, popupOpen, prevOpen, spotActivator, spotPopupContent]); - return ( - - = OpenState.OPENING} - spotlightId={this.state.containerId} - /> - - ); - } -} + const handleDismiss = useCallback((ev) => { + forwardCustom('onClose', () => ({detail: ev?.detail}))(null, allComponentProps); + }, [allComponentProps]); + + const handleFloatingLayerOpen = useCallback(() => { + if (!noAnimation && popupOpen !== OpenState.OPEN) { + setPopupOpen(OpenState.OPENING); + } else if (popupOpen === OpenState.OPEN && open) { + spotPopupContent(); + } + }, [noAnimation, open, popupOpen, spotPopupContent]); + + const handlePopupHide = useCallback((ev) => { + forwardHide(ev, allComponentProps); + + setFloatLayerOpen(false); + setActivator(null); + + if (!ev.currentTarget || ev.currentTarget.getAttribute('data-spotlight-id') === containerId.current) { + spotActivator(); + } + }, [allComponentProps, spotActivator]); + + const handlePopupShow = useCallback((ev) => { + forwardShow(ev, allComponentProps); + + setPopupOpen(OpenState.OPEN); + + if (!ev.currentTarget || ev.currentTarget.getAttribute('data-spotlight-id') === containerId.current) { + spotPopupContent(); + } + }, [allComponentProps, spotPopupContent]); + + // Spot the content after it's mounted. + useEffect(() => { + if (open) { + // If the popup is open on mount, we need to pause spotlight so nothing steals focus + // while the popup is rendering. + paused.current.pause(); + if (getContainerNode(containerId.current)) { + spotPopupContent(); + } + } + + const idRef = containerId.current; + const keyDownRef = handleKeyDownRef.current; + + return () => { + if (open) { + off('keydown', keyDownRef); + } + Spotlight.remove(idRef); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + getDerivedStateFromProps(); + handleComponentUpdate(); + }, [getDerivedStateFromProps, handleComponentUpdate]); + + return ( + + = OpenState.OPENING && open} + spotlightId={containerId.current} + /> + + ); +}; + +Popup.displayName = "Popup"; +Popup.propTypes = /** @lends sandstone/Popup.Popup.prototype */ { + /** + * Prevents closing the popup via 5-way navigation out of the content. + * + * @type {Boolean} + * @default false + * @private + */ + no5WayClose: PropTypes.bool, + + /** + * Disables transition animation. + * + * @type {Boolean} + * @default false + * @public + */ + noAnimation: PropTypes.bool, + + /** + * Indicates that the popup will not trigger `onClose` when the user presses the cancel/back (e.g. `ESC`) key or + * taps outside of the popup. + * + * @type {Boolean} + * @default false + * @public + */ + noAutoDismiss: PropTypes.bool, + + /** + * Called on: + * + * * pressing `ESC` key, + * * clicking on outside the boundary of the popup, or + * * moving spotlight focus outside the boundary of the popup when `spotlightRestrict` is + * `'self-first'`. + * + * Event payload: + * + * * pressing `ESC` key, carries `detail` property containing `inputType` value of `'key'`. + * * clicking outside the boundary of the popup, carries `detail` property containing + * `inputType` value of `'click'`. + * + * It is the responsibility of the callback to set the `open` property to `false`. + * + * @type {Function} + * @public + */ + onClose: PropTypes.func, + + /** + * Called after hide transition has completed, and immediately with no transition. + * + * @type {Function} + * @public + */ + onHide: PropTypes.func, + + /** + * Called when a key is pressed. + * + * @type {Function} + * @public + */ + onKeyDown: PropTypes.func, + + /** + * Called after show transition has completed, and immediately with no transition. + * + * Note: The function does not run if Popup is initially opened and + * {@link sandstone/Popup.PopupBase.noAnimation|noAnimation} is `true`. + * + * @type {Function} + * @public + */ + onShow: PropTypes.func, + + /** + * Controls the visibility of the Popup. + * + * By default, the Popup and its contents are not rendered until `open`. + * + * @type {Boolean} + * @default false + * @public + */ + open: PropTypes.bool, + + /** + * Position of the Popup on the screen. + * + * @type {('bottom'|'center'|'fullscreen'|'left'|'right'|'top')} + * @default 'bottom' + * @public + */ + position: PropTypes.oneOf(['bottom', 'center', 'fullscreen', 'left', 'right', 'top']), + + /** + * Scrim type. + * + * * Values: `'transparent'`, `'translucent'`, or `'none'`. + * + * `'none'` is not compatible with `spotlightRestrict` of `'self-only'`, use a transparent scrim + * to prevent mouse focus when using popup. + * + * @type {('transparent'|'translucent'|'none')} + * @default 'translucent' + * @public + */ + scrimType: PropTypes.oneOf(['transparent', 'translucent', 'none']), + + /** + * Restricts or prioritizes navigation when focus attempts to leave the popup. + * + * * Values: `'self-first'`, or `'self-only'`. + * + * When using `self-first`, attempts to leave the popup via 5-way will fire `onClose` based + * on the following values of `position`: + * + * * `'bottom'` - When leaving via 5-way up + * * `'top'` - When leaving via 5-way down + * * `'left'` - When leaving via 5-way right + * * `'right'` - When leaving via 5-way left + * * `'center'` - When leaving via any 5-way direction + * + * Note: If `onClose` is not set, then this has no effect on 5-way navigation. If the popup + * has no spottable children, 5-way navigation will cause the Popup to fire `onClose`. + * + * @type {('self-first'|'self-only')} + * @default 'self-only' + * @public + */ + spotlightRestrict: PropTypes.oneOf(['self-first', 'self-only']) +}; +Popup.defaultPropValues = popupDefaultProps; export default Popup; export {Popup, PopupBase};