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>
</>
)
}