Skip to content

Commit

Permalink
fix: Mouseup outside modal content causes modal to close (#533)
Browse files Browse the repository at this point in the history
  • Loading branch information
dogmar authored Oct 30, 2023
1 parent 7b53e74 commit af59ae1
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 17 deletions.
243 changes: 243 additions & 0 deletions src/components/HonorableModal.tsx
Original file line number Diff line number Diff line change
@@ -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<any>) {
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 (
<Transition
in={isOpen && !isClosing}
timeout={transitionDuration}
>
{(state: string) =>
cloneElement(element, {
...element.props,
...defaultStyle,
...transitionStyles[state as keyof typeof transitionStyles],
})
}
</Transition>
)
}

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 (
<Transition
in={isOpen && !isClosing}
timeout={transitionDuration}
>
{(state: string) =>
cloneElement(element, {
...element.props,
...defaultStyle,
...transitionStyles[state as keyof typeof transitionStyles],
})
}
</Transition>
)
}

return renderInPortal(
wrapFadeOutter(
<Div
ref={backdropRef}
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
position="fixed"
top="0"
left="0"
right="0"
bottom="0"
zIndex="1000"
backgroundColor="rgba(0, 0, 0, 0.5)"
onMouseDown={handleBackdropClick}
{...resolvePartStyles('Modal.Backdrop', props, theme)}
>
{wrapFadeInner(
<Div
ref={ref}
backgroundColor="background"
overflowY="auto"
margin={32}
{...rootStyles}
{...filterUndefinedValues(otherProps)}
/>
)}
</Div>
)
)
}

const BaseModal = forwardRef(ModalRef)

BaseModal.displayName = 'Modal'
BaseModal.propTypes = modalPropTypes

export const HonorableModal = memo(BaseModal)
4 changes: 3 additions & 1 deletion src/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down
32 changes: 16 additions & 16 deletions src/components/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,22 +232,22 @@ const SelectInner = styled.div((_) => ({
position: 'relative',
}))

function Select(
props: Omit<
SelectProps,
'selectionMode' | 'selectedKeys' | 'onSelectionChange'
> & {
selectionMode?: 'single'
} & Pick<AriaSelectProps<object>, 'onSelectionChange'>
): ReactElement
function Select(
props: Omit<
SelectProps,
'selectionMode' | 'selectedKey' | 'onSelectionChange'
> & {
selectionMode: 'multiple'
} & { onSelectionChange: (keys: Set<Key>) => any }
): ReactElement
export type SelectPropsSingle = Omit<
SelectProps,
'selectionMode' | 'selectedKeys' | 'onSelectionChange'
> & {
selectionMode?: 'single'
} & Pick<AriaSelectProps<object>, 'onSelectionChange'>

export type SelectPropsMultiple = Omit<
SelectProps,
'selectionMode' | 'selectedKey' | 'onSelectionChange'
> & {
selectionMode: 'multiple'
} & { onSelectionChange: (keys: Set<Key>) => any }

function Select(props: SelectPropsSingle): ReactElement
function Select(props: SelectPropsMultiple): ReactElement
function Select({
children,
selectedKey,
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down

0 comments on commit af59ae1

Please sign in to comment.