From af59ae183d51244a1ba8081acdaf5eb8ac26b613 Mon Sep 17 00:00:00 2001 From: Klink <85062+dogmar@users.noreply.github.com> Date: Mon, 30 Oct 2023 10:38:33 -0700 Subject: [PATCH] fix: Mouseup outside modal content causes modal to close (#533) --- src/components/HonorableModal.tsx | 243 ++++++++++++++++++++++++++++++ src/components/Modal.tsx | 4 +- src/components/Select.tsx | 32 ++-- src/index.ts | 4 + 4 files changed, 266 insertions(+), 17 deletions(-) create mode 100644 src/components/HonorableModal.tsx diff --git a/src/components/HonorableModal.tsx b/src/components/HonorableModal.tsx new file mode 100644 index 00000000..39496e09 --- /dev/null +++ b/src/components/HonorableModal.tsx @@ -0,0 +1,243 @@ +// Slight fork of honorable Modal +// https://raw.githubusercontent.com/dherault/honorable/6a7bb0773486a1610759660dfe27d42e50ca12e2/packages/honorable/src/components/Modal/Modal.tsx +// +// Fixes issue with clicks starting within the modal causing the modal to close +// if released outside the modal +import { + type MouseEvent, + type ReactElement, + type Ref, + cloneElement, + forwardRef, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import { Transition } from 'react-transition-group' +import PropTypes from 'prop-types' + +import { Div, useTheme } from 'honorable' +import useRootStyles from 'honorable/dist/hooks/useRootStyles.js' +import { useKeyDown } from '@react-hooks-library/core' +import { type ComponentProps } from 'honorable/dist/types.js' +import resolvePartStyles from 'honorable/dist/resolvers/resolvePartStyles.js' +import filterUndefinedValues from 'honorable/dist/utils/filterUndefinedValues.js' + +export const modalParts = ['Backdrop'] as const + +export const modalPropTypes = { + open: PropTypes.bool, + onClose: PropTypes.func, + fade: PropTypes.bool, + transitionDuration: PropTypes.number, + disableEscapeKey: PropTypes.bool, + portal: PropTypes.bool, +} + +export type ModalBaseProps = { + open?: boolean + onClose?: (event: MouseEvent | KeyboardEvent) => void + fade?: boolean + transitionDuration?: number + disableEscapeKey?: boolean + portal?: boolean +} + +export type ModalProps = ComponentProps< + ModalBaseProps, + 'div', + (typeof modalParts)[number] +> + +function ModalRef(props: ModalProps, ref: Ref) { + const { + open = true, + fade = true, + onClose, + transitionDuration = 250, + disableEscapeKey = false, + portal = false, + ...otherProps + } = props + const theme = useTheme() + const backdropRef = useRef() + const [isOpen, setIsOpen] = useState(open) + const [isClosing, setIsClosing] = useState(false) + const rootStyles = useRootStyles('Modal', props, theme) + const portalElement = useMemo(() => document.createElement('div'), []) + + const handleClose = useCallback( + (event: MouseEvent | KeyboardEvent) => { + if (typeof onClose === 'function') { + if (fade) { + setIsClosing(true) + setTimeout(() => { + setIsClosing(false) + onClose(event) + }, transitionDuration) + } else onClose(event) + } + }, + [fade, transitionDuration, onClose] + ) + + const handleBackdropClick = useCallback( + (event: MouseEvent) => { + if (event.target === backdropRef.current) { + handleClose(event) + } + }, + [handleClose] + ) + + const handleEscapeKey = useCallback( + (event: KeyboardEvent) => + isOpen && !isClosing && !disableEscapeKey && handleClose(event), + [isOpen, isClosing, disableEscapeKey, handleClose] + ) + + useKeyDown('Escape', handleEscapeKey) + + useEffect(() => { + if (fade && open) { + setIsOpen(true) + } else if (fade && !open) { + setIsClosing(true) + setTimeout(() => { + setIsClosing(false) + setIsOpen(false) + }, transitionDuration) + } else { + setIsOpen(open) + } + }, [fade, open, transitionDuration]) + + useEffect(() => { + const honorablePortalElement = document.getElementById('honorable-portal') + + if (portal && honorablePortalElement) { + honorablePortalElement.appendChild(portalElement) + + return () => { + honorablePortalElement.removeChild(portalElement) + } + } + }, [portal, portalElement]) + + if (!(open || isOpen || isClosing)) return null + + function wrapFadeOutter(element: ReactElement) { + if (!fade) return element + + const defaultStyle = { + opacity: 0, + transition: `opacity ${transitionDuration}ms ease`, + ...resolvePartStyles('BackdropDefaultStyle', props, theme), + } + + const transitionStyles = { + entering: { opacity: 1 }, + entered: { opacity: 1 }, + exiting: { opacity: 0 }, + exited: { opacity: 0 }, + ...resolvePartStyles('BackdropTransitionStyle', props, theme), + } + + return ( + + {(state: string) => + cloneElement(element, { + ...element.props, + ...defaultStyle, + ...transitionStyles[state as keyof typeof transitionStyles], + }) + } + + ) + } + + function renderInPortal(element: ReactElement) { + if (!portal) return element + + return createPortal(element, portalElement) + } + + function wrapFadeInner(element: ReactElement) { + if (!fade) return element + + const defaultStyle = { + opacity: 0, + transition: `opacity ${transitionDuration}ms ease`, + ...resolvePartStyles('InnerDefaultStyle', props, theme), + } + + const transitionStyles = { + entering: { opacity: 1 }, + entered: { opacity: 1 }, + exiting: { opacity: 0 }, + exited: { opacity: 0 }, + ...resolvePartStyles('InnerTransitionStyle', props, theme), + } + + return ( + + {(state: string) => + cloneElement(element, { + ...element.props, + ...defaultStyle, + ...transitionStyles[state as keyof typeof transitionStyles], + }) + } + + ) + } + + return renderInPortal( + wrapFadeOutter( +
+ {wrapFadeInner( +
+ )} +
+ ) + ) +} + +const BaseModal = forwardRef(ModalRef) + +BaseModal.displayName = 'Modal' +BaseModal.propTypes = modalPropTypes + +export const HonorableModal = memo(BaseModal) diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 803f82e5..0f35f9b6 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -1,5 +1,5 @@ import { type ReactNode, type Ref, forwardRef, useEffect } from 'react' -import { Flex, H1, Modal as HonorableModal, type ModalProps } from 'honorable' +import { Flex, H1, type ModalProps } from 'honorable' import PropTypes from 'prop-types' import styled, { type StyledComponentPropsWithRef } from 'styled-components' @@ -8,6 +8,8 @@ import { type ColorKey, type SeverityExt } from '../types' import useLockedBody from '../hooks/useLockedBody' +import { HonorableModal } from './HonorableModal' + import CheckRoundedIcon from './icons/CheckRoundedIcon' import type createIcon from './icons/createIcon' import ErrorIcon from './icons/ErrorIcon' diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 3d3d133e..95934f8b 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -232,22 +232,22 @@ const SelectInner = styled.div((_) => ({ position: 'relative', })) -function Select( - props: Omit< - SelectProps, - 'selectionMode' | 'selectedKeys' | 'onSelectionChange' - > & { - selectionMode?: 'single' - } & Pick, 'onSelectionChange'> -): ReactElement -function Select( - props: Omit< - SelectProps, - 'selectionMode' | 'selectedKey' | 'onSelectionChange' - > & { - selectionMode: 'multiple' - } & { onSelectionChange: (keys: Set) => any } -): ReactElement +export type SelectPropsSingle = Omit< + SelectProps, + 'selectionMode' | 'selectedKeys' | 'onSelectionChange' +> & { + selectionMode?: 'single' +} & Pick, 'onSelectionChange'> + +export type SelectPropsMultiple = Omit< + SelectProps, + 'selectionMode' | 'selectedKey' | 'onSelectionChange' +> & { + selectionMode: 'multiple' +} & { onSelectionChange: (keys: Set) => any } + +function Select(props: SelectPropsSingle): ReactElement +function Select(props: SelectPropsMultiple): ReactElement function Select({ children, selectedKey, diff --git a/src/index.ts b/src/index.ts index 9f9d8302..7e37478a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -90,6 +90,10 @@ export { } from './components/ListBoxItem' export { default as ListBoxItemChipList } from './components/ListBoxItemChipList' export { Select, SelectButton } from './components/Select' +export type { + SelectPropsSingle, + SelectPropsMultiple, +} from './components/Select' export { default as LoadingSpinner } from './components/LoadingSpinner' export { default as LoopingLogo } from './components/LoopingLogo' export { ComboBox } from './components/ComboBox'