Skip to content

fix: raise overlays above open modals, and unclip the voice language menu - #437

Merged
sajjadmrx merged 6 commits into
feat/free-widget-canvasfrom
fix/overlay-stacking-behind-modal-top-layer
Sep 2, 2026
Merged

sajjadmrx merged 6 commits into
feat/free-widget-canvasfrom
fix/overlay-stacking-behind-modal-top-layer

Conversation

@ThePrimeShak

@ThePrimeShak ThePrimeShak commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Overlays rendered behind an open modal, and once lifted above it they still could not be clicked — clicking a toast button closed the modal instead of pressing the button. Reported for toasts, the habit widget dropdown, the bookmark three-dot menu, and the voice search language picker.

Root cause

showModal() does two separate things, and both bit us:

  1. Moves the dialog into the browser top layer — no z-index can beat that.
  2. Marks everything outside the dialog inert, so clicks pass straight through overlays and land on the dialog, matching the backdrop-close check e.target === dialogRef.current.

Changes

  • Portal gains a topLayer flag (existing component, no new files). It renders into a host it owns, promotes it via the Popover API so it paints above an open dialog, and appends it to the topmost open modal dialog — the only non-inert region. A one-shot close listener moves the host back to document.body so a toast already on screen survives the modal closing.
  • The host is created imperatively, not as JSX: React would otherwise own it as a portal root and throw NotFoundError on unmount once we re-parented it.
  • Host defaults to pointer-events: none (it spans the viewport, so otherwise it would swallow every click); children opt back in. react-hot-toast already marks each toast wrapper > * { pointer-events: auto }.
  • The toaster mounts at startup, before any modal exists, so every toast entry point re-raises the layer first.
  • Applied to: toast layer, Dropdown, bookmark context menu, Tooltip, ClickableTooltip.
  • Voice search language menu was a separate bug: a hand-rolled absolute popover clipped by the panel's overflow-hidden. Replaced with the Dropdown primitive, per AGENTS.md.

Also fixed here: every hover overlay in a modal revealing at once

In the shop, moving the pointer anywhere inside the modal revealed the "پیش‌نمایش" button on every wallpaper simultaneously.

Those buttons are plain CSS hover (opacity-0 group-hover:opacity-100), and each card does carry its own group. But group-hover compiles to:

.group-hover\:opacity-100:is(:where(.group):hover *)

which matches any hovered ancestor carrying .group, not the nearest one — and modalDialogVariants puts group on the <dialog> itself so modal-box can drive its open animation from group-open. The dialog wraps everything and covers the viewport, so entering it fired every group-hover inside.

The dialog now uses a named group (group/modal, with the four utilities as group-open/modal), so its marker is no longer .group. Naming the modal's group rather than the cards' is what fixes the whole class: group-open appears 4 times in one file, group-hover across 39 files — the leak affected every modal, not just wallpapers.

This one predates the branch; group has been on the dialog all along.

Deliberately left out

  • canvas-edit-toolbar.tsx still portals straight to document.body. It belongs behind a modal — opening "add widget" from it should cover it.
  • The sticker popover in advanced.modal.tsx is also a hand-rolled absolute popover prone to the same clipping, but it was not reported and is not inert, so it is left as-is.

Testing

  • npm run compile
  • npm test
  • npx biome check src
  • npm run build
  • Modal open/close animation verified intact in the built CSS — all four group-open/modal rules, both @starting-style blocks included
  • Manual check by owner: toast action button over the settings modal, habit widget dropdown, bookmark three-dot menu over the folder modal, voice search language menu, and the shop wallpaper hover

Note: #438 also edits modalDialogVariants. Whichever merges second will conflict; resolve by blending — that PR's shortened array plus group/modal in place of group.

ThePrimeShak and others added 6 commits September 1, 2026 23:26
Toasts and dropdowns rendered behind an open modal, and once they
were lifted above it their buttons still could not be clicked -
clicking one closed the modal instead of activating the button.

Both symptoms trace to one line in modal.tsx:

  if (!dialog.open) dialog.showModal()

showModal() does two things. It moves the dialog into the browser's
top layer, which no z-index can ever beat, and it marks everything
outside the dialog as inert. Toasts and dropdowns were portaled to
document.body, so they lost on both counts: painted underneath, and
inert. An inert element takes no pointer events, so the click fell
through to the <dialog> - which daisyUI's .modal stretches over the
whole viewport - and matched the backdrop-close check
(e.target === dialogRef.current), closing the modal.

Portal now takes a topLayer flag. Such a portal renders into a host
element it owns, promotes it with the Popover API so it paints above
an open dialog, and appends it to the topmost open modal dialog when
there is one, since that subtree is the only region that is not
inert. A one-shot close listener moves the host back to the body
when that dialog closes, so a toast already on screen does not
vanish with the modal.

The host element is created imperatively rather than rendered as
JSX: React would otherwise own it as a portal root and throw
NotFoundError on unmount after we re-parented it away from
document.body.

The host spans the viewport, so it defaults to pointer-events: none
and children opt back in; without that it would swallow every click
on the page. The dropdown already sets pointer-events-auto, and
react-hot-toast marks each toast wrapper "> * { pointer-events:
auto }".

The toaster mounts once at startup, before any modal exists, so it
would sit below a later dialog in top layer order. Every toast entry
point re-raises the layer first.
The bookmark three-dot menu rendered behind the bookmark folder
modal, and both tooltip components had the same defect.

All three called createPortal(..., document.body) directly and
relied on a z-index, which cannot win against a dialog opened with
showModal(); they were also inert while a modal was open, so their
buttons took no clicks.

Moved them onto Portal's topLayer flag and marked their content
pointer-events-auto, since the top layer host is transparent to
pointer events. The tooltips are reached from inside the settings
modal (wallpapers and account tabs), so they hit this in practice
rather than in theory.

canvas-edit-toolbar.tsx still portals straight to document.body and
is deliberately left alone: it belongs behind a modal, since opening
"add widget" from it should cover it.
Opening the language picker in voice search showed a menu cut off at
the panel's edge.

Unlike the other overlay bugs fixed alongside it, this one has
nothing to do with modals or the top layer. The menu was a hand
rolled popover:

  <div className="absolute bottom-full mb-2 left-0 ... min-w-[120px]">

and the voice search panel it sits in is overflow-hidden, so the
menu was clipped as soon as it extended past the panel.

Replaced it with the Dropdown primitive, which portals its content
out and cannot be clipped by an ancestor. Per AGENTS.md, popovers
are not hand rolled here. The local open/close state goes away with
it, since Dropdown owns that.
In the shop, moving the pointer anywhere inside the modal revealed
the "پیش‌نمایش" button on every wallpaper at the same time, without
the pointer being over any of them.

The buttons are plain CSS hover, not tooltips:

  opacity-0 group-hover:opacity-100

and each card does carry its own group. The problem is what
group-hover compiles to:

  .group-hover\:opacity-100:is(:where(.group):hover *)

That matches any hovered ancestor carrying .group, not the nearest
one, and modalDialogVariants puts group on the <dialog> itself so
modal-box can drive its open animation from group-open. The dialog
is an ancestor of everything in the modal and covers the whole
viewport, so as soon as the pointer entered it every group-hover
inside fired together.

Switched the dialog to a named group. It is now group/modal and the
four open-state utilities are group-open/modal, so the dialog's
marker is no longer .group and descendant group-hover cannot see it.

Naming the modal's group rather than renaming the cards' is what
fixes the whole class of this: group-open appears four times, all in
this file, while group-hover appears across 39 files, so the leak
affected every modal in the app rather than only the wallpapers.

The open and close animation is unchanged - all four rules, both
@starting-style blocks included, were checked in the built CSS.

This predates the overlay work on this branch; group has been on the
dialog all along.
@sajjadmrx
sajjadmrx merged commit 4ab888e into feat/free-widget-canvas Sep 2, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants