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};