Skip to content

Commit

Permalink
fix: modal generate a new portal context (#510)
Browse files Browse the repository at this point in the history
* fix: modal generate a new portal context

* fix: click misdetected on chrome
  • Loading branch information
Xstoudi authored Jun 22, 2023
1 parent 7dee260 commit 01edd09
Show file tree
Hide file tree
Showing 7 changed files with 148 additions and 52 deletions.
2 changes: 2 additions & 0 deletions src/components/hooks/useOnClickOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export function useOnClickOutside<T extends Node = Node>(
handler(event);
};

if (shadowElement === null) return;

shadowElement.addEventListener('mousedown', listener);
shadowElement.addEventListener('touchstart', listener);

Expand Down
74 changes: 46 additions & 28 deletions src/components/modal/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import styled from '@emotion/styled';
import type { ReactNode } from 'react';
import { useCallback, useImperativeHandle, useRef, useState } from 'react';

import { Button } from '..';
import { Portal } from '../root-layout/Portal';
import { RootLayoutProvider } from '../root-layout/RootLayoutContext';

import { useDialog } from './useDialog';

Expand Down Expand Up @@ -57,6 +59,8 @@ const ConfirmModalFooter = styled.div`
gap: 10px;
`;

type MaybeHTMLDialogElement = HTMLDialogElement | null;

export function ConfirmModal(props: ConfirmModalProps) {
const {
isOpen,
Expand All @@ -72,48 +76,62 @@ export function ConfirmModal(props: ConfirmModalProps) {
children,
} = props;

const dialogRef = useRef<HTMLDialogElement>(null);
const dialogProps = useDialog({
dialogRef,
isOpen,
requestCloseOnEsc,
requestCloseOnBackdrop,
onRequestClose,
});
const [portalDomNode, setPortalDomNode] =
useState<MaybeHTMLDialogElement>(null);
const dialogCallbackRef = useCallback((node: MaybeHTMLDialogElement) => {
setPortalDomNode(node);
}, []);

useImperativeHandle<MaybeHTMLDialogElement, MaybeHTMLDialogElement>(
dialogCallbackRef,
() => dialogRef.current,
);

if (!isOpen) {
return null;
}

return (
<Portal>
<ConfirmModalDialog {...dialogProps}>
<ConfirmModalContents headerColor={headerColor} style={{ maxWidth }}>
<ConfirmModalChildrenRoot headerColor={headerColor}>
{children}
</ConfirmModalChildrenRoot>
<ConfirmModalDialog {...dialogProps} ref={dialogRef}>
<RootLayoutProvider innerRef={portalDomNode}>
<ConfirmModalContents headerColor={headerColor} style={{ maxWidth }}>
<ConfirmModalChildrenRoot headerColor={headerColor}>
{children}
</ConfirmModalChildrenRoot>

<ConfirmModalFooter>
<Button
onClick={onConfirm}
backgroundColor={{
basic: 'hsla(243deg, 75%, 58%, 1)',
hover: 'hsla(245deg, 58%, 50%, 1)',
}}
color={{ basic: 'white' }}
>
{saveText}
</Button>
<Button
onClick={onCancel}
backgroundColor={{
basic: 'hsla(0deg, 72%, 50%, 1)',
hover: 'hsla(0deg, 73%, 42%, 1)',
}}
color={{ basic: 'white' }}
>
{cancelText}
</Button>
</ConfirmModalFooter>
</ConfirmModalContents>
<ConfirmModalFooter>
<Button
onClick={onConfirm}
backgroundColor={{
basic: 'hsla(243deg, 75%, 58%, 1)',
hover: 'hsla(245deg, 58%, 50%, 1)',
}}
color={{ basic: 'white' }}
>
{saveText}
</Button>
<Button
onClick={onCancel}
backgroundColor={{
basic: 'hsla(0deg, 72%, 50%, 1)',
hover: 'hsla(0deg, 73%, 42%, 1)',
}}
color={{ basic: 'white' }}
>
{cancelText}
</Button>
</ConfirmModalFooter>
</ConfirmModalContents>
</RootLayoutProvider>
</ConfirmModalDialog>
</Portal>
);
Expand Down
40 changes: 29 additions & 11 deletions src/components/modal/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import styled from '@emotion/styled';
import type { ReactElement, ReactNode } from 'react';
import { useCallback, useImperativeHandle, useRef, useState } from 'react';

import { Portal } from '../root-layout/Portal';
import { RootLayoutProvider } from '../root-layout/RootLayoutContext';

import ModalCloseButton from './ModalCloseButton';
import { useDialog } from './useDialog';
Expand Down Expand Up @@ -56,6 +58,8 @@ const ModalFooterStyled = styled.div`
padding: 10px 20px 10px 20px;
`;

type MaybeHTMLDialogElement = HTMLDialogElement | null;

export function Modal(props: ModalProps) {
const {
isOpen,
Expand All @@ -69,30 +73,44 @@ export function Modal(props: ModalProps) {
height,
} = props;

const dialogRef = useRef<HTMLDialogElement>(null);
const dialogProps = useDialog({
dialogRef,
isOpen,
requestCloseOnEsc,
requestCloseOnBackdrop,
onRequestClose,
});
const [portalDomNode, setPortalDomNode] =
useState<MaybeHTMLDialogElement>(null);
const dialogCallbackRef = useCallback((node: MaybeHTMLDialogElement) => {
setPortalDomNode(node);
}, []);

useImperativeHandle<MaybeHTMLDialogElement, MaybeHTMLDialogElement>(
dialogCallbackRef,
() => dialogRef.current,
);

if (!isOpen) {
return null;
}

return (
<Portal>
<DialogRoot {...dialogProps}>
<DialogContents
style={{
maxWidth,
height: height || 'max-content',
width: width || '100%',
}}
>
{children}
{hasCloseButton && <ModalCloseButton onClick={onRequestClose} />}
</DialogContents>
<DialogRoot {...dialogProps} ref={dialogRef}>
<RootLayoutProvider innerRef={portalDomNode}>
<DialogContents
style={{
maxWidth,
height: height || 'max-content',
width: width || '100%',
}}
>
{children}
{hasCloseButton && <ModalCloseButton onClick={onRequestClose} />}
</DialogContents>
</RootLayoutProvider>
</DialogRoot>
</Portal>
);
Expand Down
27 changes: 20 additions & 7 deletions src/components/modal/useDialog.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,34 @@
import {
MouseEventHandler,
ReactEventHandler,
RefObject,
useCallback,
useEffect,
useRef,
} from 'react';
import { useKbsDisableGlobal } from 'react-kbs';

export function useDialog({
dialogRef,
isOpen,
requestCloseOnEsc,
requestCloseOnBackdrop,
onRequestClose,
}: {
dialogRef: RefObject<HTMLDialogElement>;
isOpen: boolean;
requestCloseOnEsc: boolean;
requestCloseOnBackdrop: boolean;
onRequestClose?: () => void;
}) {
useKbsDisableGlobal(isOpen);

const dialogRef = useRef<HTMLDialogElement>(null);

useEffect(() => {
const dialog = dialogRef.current;
if (dialog && isOpen) {
dialog.showModal();
return () => dialog.close();
}
}, [isOpen]);
}, [dialogRef, isOpen]);

const onCancel = useCallback<ReactEventHandler<HTMLDialogElement>>(
(event) => {
Expand All @@ -42,15 +42,28 @@ export function useDialog({

const onClick = useCallback<MouseEventHandler<HTMLDialogElement>>(
(event) => {
const dialog = dialogRef.current;
if (!dialog) {
return;
}

// Ref: https://stackoverflow.com/questions/25864259/how-to-close-the-new-html-dialog-tag-by-clicking-on-its-backdrop
const rect = dialog.getBoundingClientRect();
const isInDialog =
rect.top <= event.clientY &&
event.clientY <= rect.top + rect.height &&
rect.left <= event.clientX &&
event.clientX <= rect.left + rect.width;

event.stopPropagation();
// Since the dialog has no size of itself, this condition is only
// `true` when we click on the backdrop.
if (event.target === event.currentTarget && requestCloseOnBackdrop) {
if (!isInDialog && requestCloseOnBackdrop) {
onRequestClose?.();
}
},
[requestCloseOnBackdrop, onRequestClose],
[dialogRef, requestCloseOnBackdrop, onRequestClose],
);

return { ref: dialogRef, onClick, onCancel };
return { onClick, onCancel };
}
3 changes: 3 additions & 0 deletions src/components/root-layout/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ interface PortalProps {

export function Portal(props: PortalProps) {
const element = useRootLayoutContext();
if (element === null) {
return null;
}
return createPortal(props.children, element);
}
11 changes: 6 additions & 5 deletions src/components/root-layout/RootLayoutContext.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { createContext, ReactNode, useContext } from 'react';

const rootLayoutContext = createContext<HTMLElement | null>(null);
const defaultPortalContext =
typeof document === 'undefined' ? null : document.body;

const rootLayoutContext = createContext<HTMLElement | null>(
defaultPortalContext,
);

export function useRootLayoutContext() {
const context = useContext(rootLayoutContext);
if (!context) {
throw new Error('RootLayoutContext was not found');
}

return context;
}

Expand Down
43 changes: 42 additions & 1 deletion stories/components/select.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';

import { Button } from '../../src/components';
import { Button, Modal, useOnOff } from '../../src/components';
import { Select } from '../../src/components/forms/Select';

export default {
Expand Down Expand Up @@ -300,3 +300,44 @@ export function ResetButton() {
</div>
);
}

export function InModal() {
const [isOpen, open, close] = useOnOff();
const [value, setValue] = useState<string | undefined>(undefined);
return (
<>
<Button
onClick={open}
backgroundColor={{
basic: 'hsla(243deg, 75%, 58%, 1)',
hover: 'hsla(245deg, 58%, 50%, 1)',
}}
color={{ basic: 'white' }}
>
Open
</Button>
<Modal
isOpen={isOpen}
onRequestClose={() => {
close();
}}
>
<Modal.Header>Select a fruit</Modal.Header>
<Modal.Body>
<p>Hello, world!</p>
<Select
value={value}
onSelect={setValue}
options={[
[
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Orange', value: 'orange' },
],
]}
/>
</Modal.Body>
</Modal>
</>
);
}

0 comments on commit 01edd09

Please sign in to comment.