From ff0b1b28cab11ffb74b559ee39c90518f2c2cefc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Sat, 1 Oct 2022 15:37:54 +0200 Subject: [PATCH] Convert ModalRoot to TSX+FC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- app/soapbox/components/modal_root.js | 233 ------------------ app/soapbox/components/modal_root.tsx | 213 ++++++++++++++++ .../features/ui/components/compose_modal.tsx | 4 +- 3 files changed, 214 insertions(+), 236 deletions(-) delete mode 100644 app/soapbox/components/modal_root.js create mode 100644 app/soapbox/components/modal_root.tsx diff --git a/app/soapbox/components/modal_root.js b/app/soapbox/components/modal_root.js deleted file mode 100644 index 9190c37d4..000000000 --- a/app/soapbox/components/modal_root.js +++ /dev/null @@ -1,233 +0,0 @@ -import classNames from 'clsx'; -import { createBrowserHistory } from 'history'; -import PropTypes from 'prop-types'; -import React from 'react'; -import 'wicg-inert'; -import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { cancelReplyCompose } from '../actions/compose'; -import { openModal, closeModal } from '../actions/modals'; - -const messages = defineMessages({ - confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, -}); - -export const checkComposeContent = compose => { - return !!compose && [ - compose.text.length > 0, - compose.spoiler_text.length > 0, - compose.media_attachments.size > 0, - compose.poll !== null, - ].some(check => check === true); -}; - -const mapStateToProps = state => ({ - hasComposeContent: checkComposeContent(state.compose.get('compose-modal')), - isEditing: state.compose.get('compose-modal')?.id !== null, -}); - -const mapDispatchToProps = (dispatch) => ({ - onOpenModal(type, opts) { - dispatch(openModal(type, opts)); - }, - onCloseModal(type) { - dispatch(closeModal(type)); - }, - onCancelReplyCompose() { - dispatch(closeModal('COMPOSE')); - dispatch(cancelReplyCompose()); - }, -}); - -@withRouter -class ModalRoot extends React.PureComponent { - - static propTypes = { - children: PropTypes.node, - onClose: PropTypes.func.isRequired, - onOpenModal: PropTypes.func.isRequired, - onCloseModal: PropTypes.func.isRequired, - onCancelReplyCompose: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - hasComposeContent: PropTypes.bool, - isEditing: PropTypes.bool, - type: PropTypes.string, - onCancel: PropTypes.func, - history: PropTypes.object, - }; - - state = { - revealed: !!this.props.children, - }; - - activeElement = this.state.revealed ? document.activeElement : null; - - handleKeyUp = (e) => { - if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) - && !!this.props.children) { - this.handleOnClose(); - } - } - - handleOnClose = () => { - const { onOpenModal, onCloseModal, hasComposeContent, isEditing, intl, type, onCancelReplyCompose } = this.props; - - if (hasComposeContent && type === 'COMPOSE') { - onOpenModal('CONFIRM', { - icon: require('@tabler/icons/trash.svg'), - heading: isEditing ? : , - message: isEditing ? : , - confirm: intl.formatMessage(messages.confirm), - onConfirm: () => onCancelReplyCompose(), - onCancel: () => onCloseModal('CONFIRM'), - }); - } else if (hasComposeContent && type === 'CONFIRM') { - onCloseModal('CONFIRM'); - } else { - this.props.onClose(); - } - }; - - handleKeyDown = (e) => { - if (e.key === 'Tab') { - const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); - const index = focusable.indexOf(e.target); - - let element; - - if (e.shiftKey) { - element = focusable[index - 1] || focusable[focusable.length - 1]; - } else { - element = focusable[index + 1] || focusable[0]; - } - - if (element) { - element.focus(); - e.stopPropagation(); - e.preventDefault(); - } - } - } - - componentDidMount() { - window.addEventListener('keyup', this.handleKeyUp, false); - window.addEventListener('keydown', this.handleKeyDown, false); - this.history = this.props.history || createBrowserHistory(); - } - - componentDidUpdate(prevProps) { - if (!!this.props.children && !prevProps.children) { - this.activeElement = document.activeElement; - this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); - - this._handleModalOpen(); - } else if (!prevProps.children) { - this.setState({ revealed: false }); - } - - if (!this.props.children && !!prevProps.children) { - this.activeElement.focus(); - this.activeElement = null; - this.getSiblings().forEach(sibling => sibling.removeAttribute('inert')); - - this._handleModalClose(prevProps.type); - } - - if (this.props.children) { - requestAnimationFrame(() => { - this.setState({ revealed: true }); - }); - - this._ensureHistoryBuffer(); - } - } - - componentWillUnmount() { - window.removeEventListener('keyup', this.handleKeyUp); - window.removeEventListener('keydown', this.handleKeyDown); - } - - _handleModalOpen() { - this._modalHistoryKey = Date.now(); - this.unlistenHistory = this.history.listen((_, action) => { - if (action === 'POP') { - this.handleOnClose(); - - if (this.props.onCancel) this.props.onCancel(); - } - }); - } - - _handleModalClose(type) { - if (this.unlistenHistory) { - this.unlistenHistory(); - } - if (!['FAVOURITES', 'MENTIONS', 'REACTIONS', 'REBLOGS', 'MEDIA'].includes(type)) { - const { state } = this.history.location; - if (state && state.soapboxModalKey === this._modalHistoryKey) { - this.history.goBack(); - } - } - } - - _ensureHistoryBuffer() { - const { pathname, state } = this.history.location; - if (!state || state.soapboxModalKey !== this._modalHistoryKey) { - this.history.push(pathname, { ...state, soapboxModalKey: this._modalHistoryKey }); - } - } - - getSiblings = () => { - return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node); - } - - setRef = ref => { - this.node = ref; - } - - render() { - const { children, type } = this.props; - const { revealed } = this.state; - const visible = !!children; - - if (!visible) { - return ( -
- ); - } - - return ( -
- - ); - } - -} - -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ModalRoot)); diff --git a/app/soapbox/components/modal_root.tsx b/app/soapbox/components/modal_root.tsx new file mode 100644 index 000000000..eabd450d0 --- /dev/null +++ b/app/soapbox/components/modal_root.tsx @@ -0,0 +1,213 @@ +import classNames from 'clsx'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import 'wicg-inert'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { cancelReplyCompose } from 'soapbox/actions/compose'; +import { openModal, closeModal } from 'soapbox/actions/modals'; +import { useAppDispatch, useAppSelector, usePrevious } from 'soapbox/hooks'; + +import type { UnregisterCallback } from 'history'; +import type { ReducerCompose } from 'soapbox/reducers/compose'; + +const messages = defineMessages({ + confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, +}); + +export const checkComposeContent = (compose?: ReturnType) => { + return !!compose && [ + compose.text.length > 0, + compose.spoiler_text.length > 0, + compose.media_attachments.size > 0, + compose.poll !== null, + ].some(check => check === true); +}; + +interface IModalRoot { + onCancel?: () => void, + onClose: (type?: string) => void, + type: string, +} + +const ModalRoot: React.FC = ({ children, onCancel, onClose, type }) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const [revealed, setRevealed] = useState(!!children); + + const ref = useRef(null); + const activeElement = useRef(revealed ? document.activeElement as HTMLDivElement | null : null); + const modalHistoryKey = useRef(); + const unlistenHistory = useRef(); + + const prevChildren = usePrevious(children); + const prevType = usePrevious(type); + + const isEditing = useAppSelector(state => state.compose.get('compose-modal')?.id !== null); + + const handleKeyUp = useCallback((e) => { + if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) + && !!children) { + handleOnClose(); + } + }, []); + + const handleOnClose = () => { + dispatch((_, getState) => { + const hasComposeContent = checkComposeContent(getState().compose.get('compose-modal')); + + if (hasComposeContent && type === 'COMPOSE') { + dispatch(openModal('CONFIRM', { + icon: require('@tabler/icons/trash.svg'), + heading: isEditing ? : , + message: isEditing ? : , + confirm: intl.formatMessage(messages.confirm), + onConfirm: () => { + dispatch(closeModal('COMPOSE')); + dispatch(cancelReplyCompose()); + }, + onCancel: () => { + dispatch(closeModal('CONFIRM')); + }, + })); + } else if (hasComposeContent && type === 'CONFIRM') { + dispatch(closeModal('CONFIRM')); + } else { + onClose(); + } + }); + }; + + const handleKeyDown = useCallback((e) => { + if (e.key === 'Tab') { + const focusable = Array.from(ref.current!.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + (element as HTMLDivElement).focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + }, []); + + const handleModalOpen = () => { + modalHistoryKey.current = Date.now(); + unlistenHistory.current = history.listen((_, action) => { + if (action === 'POP') { + handleOnClose(); + + if (onCancel) onCancel(); + } + }); + }; + + const handleModalClose = (type: string) => { + if (unlistenHistory.current) { + unlistenHistory.current(); + } + if (!['FAVOURITES', 'MENTIONS', 'REACTIONS', 'REBLOGS', 'MEDIA'].includes(type)) { + const { state } = history.location; + if (state && (state as any).soapboxModalKey === modalHistoryKey.current) { + history.goBack(); + } + } + }; + + const ensureHistoryBuffer = () => { + const { pathname, state } = history.location; + if (!state || (state as any).soapboxModalKey !== modalHistoryKey.current) { + history.push(pathname, { ...(state as any), soapboxModalKey: modalHistoryKey.current }); + } + }; + + const getSiblings = () => { + return Array(...(ref.current!.parentElement!.childNodes as any as ChildNode[])).filter(node => node !== ref.current); + }; + + useEffect(() => { + window.addEventListener('keyup', handleKeyUp, false); + window.addEventListener('keydown', handleKeyDown, false); + + return () => { + window.removeEventListener('keyup', handleKeyUp); + window.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + useEffect(() => { + if (!!children && !prevChildren) { + activeElement.current = document.activeElement as HTMLDivElement; + getSiblings().forEach(sibling => (sibling as HTMLDivElement).setAttribute('inert', 'true')); + + handleModalOpen(); + } else if (!prevChildren) { + setRevealed(false); + } + + if (!children && !!prevChildren) { + activeElement.current?.focus(); + activeElement.current = null; + getSiblings().forEach(sibling => (sibling as HTMLDivElement).removeAttribute('inert')); + + handleModalClose(prevType!); + } + + if (children) { + requestAnimationFrame(() => { + setRevealed(true); + }); + + ensureHistoryBuffer(); + } + }); + + const visible = !!children; + + if (!visible) { + return ( +
+ ); + } + + return ( +
+ + ); +}; + +export default ModalRoot; diff --git a/app/soapbox/features/ui/components/compose_modal.tsx b/app/soapbox/features/ui/components/compose_modal.tsx index 119bcd05f..5dc86766b 100644 --- a/app/soapbox/features/ui/components/compose_modal.tsx +++ b/app/soapbox/features/ui/components/compose_modal.tsx @@ -27,10 +27,8 @@ const ComposeModal: React.FC = ({ onClose }) => { const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!; - const hasComposeContent = checkComposeContent(compose); - const onClickClose = () => { - if (hasComposeContent) { + if (checkComposeContent(compose)) { dispatch(openModal('CONFIRM', { icon: require('@tabler/icons/trash.svg'), heading: statusId