2

Toast notifications can't be seen with <dialog> element

tl;dr

Using the built-in dialog.showModal() causes the <dialog> element to always be on top, so toast notification are hidden behind the ::backdrop.

Intro

Not too long ago, the <dialog> element received some nice functionality for showing and closing it easily with JavaScript. It also came along with a new ::backdrop pseudo-element for styling the backdrop overlay when the dialog is open.

Most of my projects use Next.js with Tailwind CSS. Because Next.js is built on React, I can take advantage of the Headless UI package by Tailwind Labs, and I have previously used their <Dialog> component instead of the <dialog> element. In this project, however, I decided to try the built-in <dialog> element functionality, for it's accessibility features.

In this case, I am using the dialog to allow users to change their password. Naturally, it's important to display notification to the user, to let them know whether the password was changed successfully. I'm using React Hot Toast to handle these notifications, of course. In previous projects, when using Headless UI's <Dialog> component, I haven't had any issues displaying these notifications.

With the built-in <dialog> element the notifications are stuck behind it. This isn't a big issue if the ::backdrop pseudo-element is transparent, but it always looks better if it is.

Completed Tests

I've already tested changing the z-index on both the <dialog> element and the <Toaster> component, via Tailwind classes and the style attribute. Neither work, even with extreme values.

Code

Keep in mind, I'm using the App Router.

In the <RootLayout>

<Toaster
  position='top-right'
  toastOptions={{
    duration: 3000
  }}
/>

<ChangePassword> Component

function ChangePassword({ username }: { username: string | undefined }) {

    const changePasswordDialogRef = useRef<HTMLDialogElement>(null),
          currentPasswordInputRef = useRef<HTMLInputElement>(null),
          newPasswordInputRef = useRef<HTMLInputElement>(null),
          formRef = useRef<HTMLFormElement>(null)

    const currentPasswordValueRef = useRef(''),
          newPasswordValueRef = useRef('')

    const currentPasswordIsValidRef = useRef(false),
          newPasswordIsValidRef = useRef(false)

    const openPasswordChangeModal = () => changePasswordDialogRef.current?.showModal()

    const closePasswordChangeModal = () => {
      formRef.current?.reset()
      changePasswordDialogRef.current?.close()
    }

    const [formSubmissionState, setFormSubmissionState] = useState<
      'incomplete' | 'ready' | 'loading' | 'success' | 'error'
    >('incomplete')

    const validateInputs = useCallback(
        (inputId: string, newValue: string) => {
          if (
            (inputId === 'currentPassword' && newValue.length >= 8) ||
            (inputId !== 'currentPassword' &&
            currentPasswordValueRef.current.length >= 8) ||
            getComputedStyle(currentPasswordInputRef.current as Element).position === 'static'
          ) {
            currentPasswordIsValidRef.current = true
          } else {
            currentPasswordIsValidRef.current = false
          }

          if (
            (inputId === 'newPassword' && newValue.length >= 8) ||
            (inputId !== 'newPassword' &&
            newPasswordValueRef.current.length >= 8) ||
            getComputedStyle(newPasswordInputRef.current as Element).position === 'static'
          ) {
            newPasswordIsValidRef.current = true
          } else {
        newPasswordIsValidRef.current = false
      }

      const currentPasswordIsValid = currentPasswordIsValidRef.current,
            newPasswordIsValid = newPasswordIsValidRef.current

      const validInputs = [currentPasswordIsValid, newPasswordIsValid]

      const inputsAreValid = validInputs.includes(false) ? false : true

      if (inputsAreValid && formSubmissionState !== 'ready')
        setFormSubmissionState('ready')
      if (!inputsAreValid && formSubmissionState !== 'incomplete')
        setFormSubmissionState('incomplete')
      },
      [formSubmissionState]
    )

    let inputChangeTimeout: NodeJS.Timeout

    const handleInputChange: ChangeEventHandler<HTMLInputElement> = e => {
      clearTimeout(inputChangeTimeout)

      inputChangeTimeout = setTimeout(async () => {
        const { id, value } = e.target

        switch (id) {
          case 'currentPassword':
            currentPasswordValueRef.current = value
            break
          case 'newPassword':
            newPasswordValueRef.current = value
            break
          default:
            null
        }

        validateInputs(id, value)
      }, 250)
    }

    const handleChangePassword = async () => {
      const currentPassword = currentPasswordValueRef.current,
            newPassword = newPasswordValueRef.current

      if (currentPassword === newPassword) {
        toast.error('You cannot set the new password to your old password.')
        throw new Error('You cannot set the new password to your old password.')
      }
      return true
    }

    const toastChangePassword: FormEventHandler<HTMLFormElement> = e => {
      e.preventDefault()
      e.stopPropagation()

      toast.promise(handleChangePassword(), {
        loading: 'Saving Password...',
        success: () => {
          closePasswordChangeModal()
          return 'Successfully changed password.'
        },
        error: 'Failed to change password. Please try again.'
      })
    }

    return (
        <>
          <div className='w-fit sm:ml-auto -mt-2'>
            <a
              href='#top'
              onClick={openPasswordChangeModal}
            >
              Change Password
            </a>
          </div>

          <dialog
            ref={changePasswordDialogRef}
            className='
              w-[calc(100%-2rem)] max-w-xs
              rounded-3xl
              shadow-xl dark:shadow-zinc-700

              backdrop:bg-zinc-100/60 dark:backdrop:bg-zinc-900/60
              backdrop:backdrop-blur-sm dark:backdrop:brightness-125
            '
          >

          <form
            ref={formRef}
            onSubmit={toastChangePassword}
          >

          <input
            ref={currentPasswordInputRef}
            name='Current Password'
            type='password'
            placeholder='••••••••'
            className='placeholder:font-black placeholder:tracking-widest autofill:static'
            minLength={8}
            onChange={handleInputChange}
            autoComplete='old-password'
            required
          />

          <input
            ref={newPasswordInputRef}
            name='New Password'
            type='password'
            placeholder='••••••••'
            className='placeholder:font-black placeholder:tracking-widest autofill:static'
            minLength={8}
            onChange={handleInputChange}
            autoComplete='new-password'
            required
          />

          <button type='submit'>Update Password</button>

        </form>
      </dialog>
    </>
  )
}
1
  • For now I'm choosing to just use the Headless UI component, as it does a great job with ARIA, but if anyone has a solution to this, that would be great.
    – andrilla
    Sep 16, 2023 at 19:18

1 Answer 1

1

I'm using slightly different packages, but ran into the same issue with react-hot-toast that I solved.

I'm using material-tailwind Dialog with react-hot-toast in a Next.js app. I found that setting the containerStyle prop in the Toaster component to {{zIndex: 99999}} moved the toast in front of the Dialog background blur so that the toast alert was in focus. I tried up to 9999 for the value of zIndex and it still didn't work there, but 99999 works for me.

react-hot-toast styling can be found here: https://react-hot-toast.com/docs/styling

The full Toaster component that worked for me is here which is placed in RootLayout in layout.tsx:

<Toaster
  position="bottom-left"
  containerStyle={{zIndex: 99999}}
  toastOptions={{
    success: {
      style: {
        background: 'lightblue',
      },
      iconTheme: {
        primary: 'white',
        secondary: 'black',
      }
    },
    error: {
      style: {
        background: 'palevioletred',
      },
    },
  }}
/>

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.