tailwindlabs / headlessui

Completely unstyled, fully accessible UI components, designed to integrate beautifully with Tailwind CSS.
https://headlessui.com
MIT License
25.9k stars 1.07k forks source link

Dialog: Unable to disable "outside click" behavior #621

Closed paulwongx closed 1 year ago

paulwongx commented 3 years ago

Is there an option to disable "outside click" behaviour? For example, I'd like to keep the dialog opened when click outside. It would be great if we can have the condition inside the useWindowEvent function.

Originally posted by @wengtytt in https://github.com/tailwindlabs/headlessui/pull/212#issuecomment-841353081

There also seems to be multiple feature requests regarding this. Is there a way to override this default behavior currently?

molteber commented 3 years ago

You can make it static and decide when to show the component yourself by using the close method

a react example could be something like this

const foo = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      {!isOpen && (
        <button onClick={() => setIsOpen(true)}>Open dialog</button>
      )}
      {isOpen && (
        <Dialog static open={isOpen} onClose={() => setIsOpen(false)}>
          // ...
        </Dialog>
      )}
    </>
  );
}

The open prop is still used for manage scroll locking and focus trapping, but as long as static is present, the actual element will always be rendered regardless of the open value, which allows you to control it yourself externally.

You can read more about it under Transitions (See the last code example, right before Accessibility notes)

wengtytt commented 3 years ago

Hi there, I forgot to post my solution for this problem. The trick is not the static property. Even you provide the static property, the onClose is still triggered when user clicks outside. (The ideal solution will be a property to disable the listener for clicking outside)

To solve the problem, we can provide an empty function as the onClose function and use static property with custom function to handle open and close for the modal.

<Dialog
    // ...
    static
    onClose={() => null}>
</Dialog>
dickerstu commented 3 years ago

I only needed to set the onClose to null using the solution from @wengtytt for this to work. Adding the static property kept the custom close events from working.

<Dialog
    // ...
    onClose={() => null}>
</Dialog>
paulwongx commented 3 years ago

Hi there, I forgot to post my solution for this problem. The trick is not the static property. Even you provide the static property, the onClose is still triggered when user clicks outside. (The ideal solution will be a property to disable the listener for clicking outside)

To solve the problem, we can provide an empty function as the onClose function and use static property with custom function to handle open and close for the modal.

<Dialog
    // ...
    static
    onClose={() => null}>
</Dialog>

Amazing. This works. Thank you! Edit: @iambryanhaney's answer is even better as it allows esc key to work too.

molteber commented 3 years ago

Hi there, I forgot to post my solution for this problem. The trick is not the static property. Even you provide the static property, the onClose is still triggered when user clicks outside. (The ideal solution will be a property to disable the listener for clicking outside)

To solve the problem, we can provide an empty function as the onClose function and use static property with custom function to handle open and close for the modal.

<Dialog
    // ...
    static
    onClose={() => null}>
</Dialog>

Oh 🤦‍♂️ I might've forgotten what i did to solve this.

Maybe i solved this myself by exchanging the overlay component with my own div, setting aria-hidden="true" and using the same classes.

That way you don't loose out on the close on esc etc. However, not sure if this has other drawbacks (by not using the provided overlay component)

iambryanhaney commented 3 years ago

Bypassing onClose will require you to set up your own keyboard listener to capture the esc key.

If we look in dialog.tsx, we can see that there is a mouseDown event listener on the window itself, which closes the Dialog if the event emitter is not a descendant of it.

useWindowEvent('mousedown', event => {
    let target = event.target as HTMLElement

    if (dialogState !== DialogStates.Open) return
    if (hasNestedDialogs) return
    if (internalDialogRef.current?.contains(target)) return

    close()
})

Simple solution: add pointer-events: none to the Dialog.Overlay...

Inline: style={{ pointerEvents: 'none' }} Tailwind: className="pointer-events-none"

...which will effectively disable click events outside of the Dialog's body.

If you have other interactive elements higher in the z-index, such as a toast notification, you can capture the mouseDown event in that element (while still listening for onClick) and prevent it from bubbling to the Dialog's window listener:

// In your toast notification component...
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onMouseDown={(e) => e.stopPropagation()}>

Notes on the eslint disable: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events

paulwongx commented 3 years ago

Bypassing onClose will require you to set up your own keyboard listener to capture the esc key.

If we look in dialog.tsx, we can see that there is a mouseDown event listener on the window itself, which closes the Dialog if the event emitter is not a descendant of it.

useWindowEvent('mousedown', event => {
    let target = event.target as HTMLElement

    if (dialogState !== DialogStates.Open) return
    if (hasNestedDialogs) return
    if (internalDialogRef.current?.contains(target)) return

    close()
})

Simple solution: add pointer-events: none to the Dialog.Overlay...

Inline: style={{ pointerEvents: 'none' }} Tailwind: className="pointer-events-none"

...which will effectively disable click events outside of the Dialog's body.

If you have other interactive elements higher in the z-index, such as a toast notification, you can capture the mouseDown event in that element (while still listening for onClick) and prevent it from bubbling to the Dialog's window listener:

// In your toast notification component...
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onMouseDown={(e) => e.stopPropagation()}>

Notes on the eslint disable: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events

Wow you're absolutely right - esc key not working is an issue with the onclick={()=>null} solution. This should be the accepted answer! Many thanks!

sujin-sreekumaran commented 2 years ago

static onClose={() => null}>

this solution doesn't work for me , my problem is if i click outside the dialog it never goes away but i can click the outside buttons and it is working when the modal is not even not closed.

anyone help?

sujin-sreekumaran commented 2 years ago

you can also return the empty obj, it prevents the outside click ! onClose={() => {} }>

Rasul1996 commented 1 year ago

Bypassing onClose will require you to set up your own keyboard listener to capture the esc key.

If we look in dialog.tsx, we can see that there is a mouseDown event listener on the window itself, which closes the Dialog if the event emitter is not a descendant of it.

useWindowEvent('mousedown', event => {
    let target = event.target as HTMLElement

    if (dialogState !== DialogStates.Open) return
    if (hasNestedDialogs) return
    if (internalDialogRef.current?.contains(target)) return

    close()
})

Simple solution: add pointer-events: none to the Dialog.Overlay...

Inline: style={{ pointerEvents: 'none' }} Tailwind: className="pointer-events-none"

...which will effectively disable click events outside of the Dialog's body.

If you have other interactive elements higher in the z-index, such as a toast notification, you can capture the mouseDown event in that element (while still listening for onClick) and prevent it from bubbling to the Dialog's window listener:

// In your toast notification component...
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onMouseDown={(e) => e.stopPropagation()}>

Notes on the eslint disable: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events

This is exactly what i was looking for, many thanks !!!

ponnamkarthik commented 1 year ago

I tried to set static and create a dummy @close function but its not working i was still able to close the dialog as i click outside

tolerance-go commented 1 year ago
               <Dialog
                  ref={containerRef}
                  as='div'
                  className='relative z-10'
                  onClose={() => {}}
                  onClick={(event) => {
                     /**
                      * disable Close modal when click outside of modal
                      */
                     if (
                        isElementChild(
                           containerRef.current!,
                           event.target as HTMLElement,
                        )
                     ) {
                        closeModal()
                     }
                  }}
               >

I want to close the modal on click outside, but I use this method to solve the nested modal situation

sujin-sreekumaran commented 1 year ago
               <Dialog
                  ref={containerRef}
                  as='div'
                  className='relative z-10'
                  onClose={() => {}}
                  onClick={(event) => {
                     /**
                      * disable Close modal when click outside of modal
                      */
                     if (
                        isElementChild(
                           containerRef.current!,
                           event.target as HTMLElement,
                        )
                     ) {
                        closeModal()
                     }
                  }}
               >

I want to close the modal on click outside, but I use this method to solve the nested modal situation

That's the solution that worked for me i mentioned above

brandalx commented 1 year ago

i had same error so i tried to solve my way and this actually work i did this by removing the style of pointer events like so:

` onClose: () => { set({ type: null, isOpen: false });

// Delay restoring pointer events for 2 seconds
setTimeout(() => {
  document.body.style.pointerEvents = "auto";
}, 1000);

}, }));`

Note, i did it with small timeout to make sure that first all modal scripts are completed and then im manually removing that. Hope it helps!

ramialkaro commented 1 year ago

Bypassing onClose will require you to set up your own keyboard listener to capture the esc key.

If we look in dialog.tsx, we can see that there is a mouseDown event listener on the window itself, which closes the Dialog if the event emitter is not a descendant of it.

useWindowEvent('mousedown', event => {
    let target = event.target as HTMLElement

    if (dialogState !== DialogStates.Open) return
    if (hasNestedDialogs) return
    if (internalDialogRef.current?.contains(target)) return

    close()
})

Simple solution: add pointer-events: none to the Dialog.Overlay...

Inline: style={{ pointerEvents: 'none' }} Tailwind: className="pointer-events-none"

...which will effectively disable click events outside of the Dialog's body.

If you have other interactive elements higher in the z-index, such as a toast notification, you can capture the mouseDown event in that element (while still listening for onClick) and prevent it from bubbling to the Dialog's window listener:

// In your toast notification component...
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div onMouseDown={(e) => e.stopPropagation()}>

Notes on the eslint disable: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-static-element-interactions.md#case-the-event-handler-is-only-being-used-to-capture-bubbled-events

The version UI 1.6 have been depreacted the Dialog.Overlay. https://headlessui.com/react/dialog#dialog-overlay

At least with version 1.7.17 the issue it not happen.

afterxleep commented 1 month ago

The pointer-events-none did not work for me on 1.7, so I'm just moving the backdrop to be part of the DialogPanel. This way, there are no clicks outside :).

<template>
  <TransitionRoot as="template" :show="open">
    <Dialog class="relative z-10" @close="onClose">      
      <DialogPanel>
        <TransitionChild as="template" enter="ease-out duration-300" enter-from="opacity-0" enter-to="opacity-100" leave="ease-in duration-200" leave-from="opacity-100" leave-to="opacity-0">
          <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
        </TransitionChild>
        <-- Fixed backdrop here -->
        <div id="backdrop" class="fixed inset-0 z-10 w-screen overflow-y-auto">          
          ...rest of your dialog content
        </div>
    </DialogPanel>
    </Dialog>
  </TransitionRoot>
</template>

If you want to also prevent the ESC key handler, you can add a key listener with capture mode:

With Vue, it'd be something like this.

function keyPress(e) {
  if (e.key === "Escape") {
    console.log("Escape key pressed");
    e.stopImmediatePropagation();
  }
}

onMounted(() => {
  console.log("mounted");
  window.addEventListener('keydown', keyPress, true); // 'true' enables capture mode
});

onUnmounted(() => {
  console.log("unmounted");
  window.removeEventListener('keydown', keyPress, true);
});
sko-kr commented 1 month ago

Just make DialogPanel fullscreen, then add dialog content div as a direct child of DialogPanel and style that as though it is DialogPanel. This way we don't lose built in functionality such as close on ESC key.