From 6ed19b647c7ca8ec041cf0bf8cce8188415703fa Mon Sep 17 00:00:00 2001 From: Ion Andrusciac Date: Thu, 13 Feb 2025 12:16:17 +0200 Subject: [PATCH] Converted `Popup` to functional component --- Popup/Popup.js | 641 +++++++++++++++++++++++++------------------------ 1 file changed, 321 insertions(+), 320 deletions(-) diff --git a/Popup/Popup.js b/Popup/Popup.js index 373ccae2e..81ad70902 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'; @@ -225,7 +226,6 @@ const PopupBase = kind({ 'aria-live': ({'aria-live': live, noAlertRole}) => ((typeof live !== 'undefined') ? live : (!noAlertRole && 'off' || null)), className: ({noOutline, position, styler}) => styler.append(position, position === 'fullscreen' || noOutline ? null : componentCss.outline), direction: ({position}) => transitionDirection[position], - noAnimation: ({noAnimation}) => (typeof ENACT_PACK_NO_ANIMATION !== 'undefined' && ENACT_PACK_NO_ANIMATION) || noAnimation, // When passing `role` prop to the Popup, the prop should work first. // If `noAlertRole` is true, alert role and aria-live will be removed. Contents of the popup won't be read automatically when opened. // Otherwise, `role` will be usually `alert`. @@ -279,271 +279,53 @@ 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}. * - * @class Popup + * @function Popup * @memberof limestone/Popup * @extends limestone/Popup.PopupBase * @ui * @public */ -class Popup extends Component { - - static propTypes = /** @lends limestone/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 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 limestone/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']) - }; - - 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(); - } - } - } +const Popup = (props) => { + const allComponentProps = setDefaultProps(props, popupDefaultProps); + const {noAnimation, noAutoDismiss, no5WayClose, onClose, open, position, scrimType, spotlightRestrict, ...rest} = allComponentProps; - 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); - } - } + // Assign the needed props to the rest object for the child component + Object.assign(rest, {noAnimation, position, spotlightRestrict}); - checkScrimNone(this.props); - } + const [activator, setActivator] = useState(null); + const [floatLayerOpen, setFloatLayerOpen] = useState(open); + const [popupOpen, setPopupOpen] = useState(open ? OpenState.OPEN : OpenState.CLOSED); + const [prevOpen, setPrevOpen] = useState(open); - 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 handleKeyDownRef = useRef(null); + const openRef = useRef(open); + const paused = useRef(new Pause('Popup')); + const handleKeyDown = useCallback((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; @@ -570,58 +352,29 @@ 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); + }, [allComponentProps, no5WayClose, onClose, position, spotlightRestrict]); - 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 && handleKeyDownRef.current === handleKeyDown) 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 + // 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(); @@ -633,57 +386,305 @@ class Popup extends Component { } } } - }; + }, [activator, handleKeyDown, 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; + if (!open && handleKeyDownRef.current === handleKeyDown) return; - const {containerId} = this.state; + on('keydown', handleKeyDownRef.current); - on('keydown', this.handleKeyDown); - - 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 + // In cases where the container contains no spottable controls or we're in pointer-mode, focus // cannot inherently set the active container or blur the active control, so we must do that // here. if (current) { current.blur(); } - Spotlight.setActiveContainer(containerId); + Spotlight.setActiveContainer(containerId.current); } - }; + }, [handleKeyDown, 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(); + } + } + + return () => { + if (openRef.current) { + off('keydown', handleKeyDownRef.current); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + Spotlight.remove(containerId.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + getDerivedStateFromProps(); + handleComponentUpdate(); + }, [getDerivedStateFromProps, handleComponentUpdate]); + + // Remove the keydown listener and add a new listener when the handleKeyDown function is re-created and Popup is open + useEffect(() => { + if (open && handleKeyDownRef.current !== handleKeyDown) { + off('keydown', handleKeyDownRef.current); + handleKeyDownRef.current = handleKeyDown; + on('keydown', handleKeyDownRef.current); + } + }, [handleKeyDown, open]); + + useEffect(() => { + openRef.current = open; + }, [open]); + + return ( + + = OpenState.OPENING} + spotlightId={containerId.current} + /> + + ); +}; + +Popup.displayName = "Popup"; +Popup.propTypes = /** @lends limestone/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 limestone/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};