From f74803ce1e92a9490a87c2a95777d0af5468fa5c Mon Sep 17 00:00:00 2001 From: Shak Date: Tue, 1 Sep 2026 23:26:55 +0330 Subject: [PATCH 1/4] fix: let overlays escape the modal top layer and its inertness 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 - 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. --- src/common/toast.tsx | 14 ++++ src/components/ui/dropdown/dropdown.tsx | 84 ++++++++++--------- src/components/ui/portal/portal.tsx | 102 +++++++++++++++++++++++- src/pages/root.tsx | 34 ++++---- 4 files changed, 174 insertions(+), 60 deletions(-) diff --git a/src/common/toast.tsx b/src/common/toast.tsx index 909f99bb9..d11c48867 100644 --- a/src/common/toast.tsx +++ b/src/common/toast.tsx @@ -5,6 +5,14 @@ import { playAlarm } from './play-alarm' import { translateError } from '@/common/utils/translate-error' import { Icon } from '../icons' import { cn } from '@/common/utils/cn' +import { raiseToTopLayer } from '@/components/ui/portal/portal' + +export const TOAST_TOP_LAYER_ID = 'widgetify-toast-top-layer' + +function raiseToastLayer() { + if (typeof document === 'undefined') return + raiseToTopLayer(document.getElementById(TOAST_TOP_LAYER_ID)) +} export type ToastType = 'success' | 'error' | 'info' | 'warning' @@ -169,6 +177,8 @@ export function showToast( type: ToastType = 'info', options?: ToastOptions ) { + raiseToastLayer() + const theme = TOAST_THEMES[type] || TOAST_THEMES.info const title = options?.title ?? theme.defaultTitle const actionText = options?.actionText ?? theme.defaultActionText @@ -239,6 +249,8 @@ export function showCustomToast( message: React.ReactNode | string, options?: ToastOptions ) { + raiseToastLayer() + if (options?.sound !== false) { playNativeToastSound('info') } @@ -253,6 +265,8 @@ export function showCustomToast( } export function showPreviewToast(itemName: string, onCancel: () => void): string { + raiseToastLayer() + const id = `preview-${Date.now()}` playNativeToastSound('info') diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx index 1a7adb1a9..a41659536 100644 --- a/src/components/ui/dropdown/dropdown.tsx +++ b/src/components/ui/dropdown/dropdown.tsx @@ -208,51 +208,49 @@ export function Dropdown({ {trigger} - - - {isOpen && !disabled && ( -
+ {isOpen && !disabled && ( + + - -
- {dropdownContent} -
-
-
- )} -
-
+ {dropdownContent} + + + + )} + ) } diff --git a/src/components/ui/portal/portal.tsx b/src/components/ui/portal/portal.tsx index 06072988b..ffc3378a7 100644 --- a/src/components/ui/portal/portal.tsx +++ b/src/components/ui/portal/portal.tsx @@ -1,12 +1,110 @@ -import type { ReactNode } from 'react' +import { + useEffect, + useLayoutEffect, + useState, + type CSSProperties, + type ReactNode, +} from 'react' import { createPortal } from 'react-dom' export interface PortalProps { children: ReactNode container?: Element | null + topLayer?: boolean + id?: string + className?: string + style?: CSSProperties } -export function Portal({ children, container = document.body }: PortalProps) { +const TOP_LAYER_HOST_CSS = + 'position:fixed;inset:0;width:auto;height:auto;max-width:none;max-height:none;margin:0;padding:0;border:none;background:transparent;overflow:visible;color:inherit;pointer-events:none;' + +function topmostModalDialog(): HTMLElement | null { + let topmost: HTMLElement | null = null + + try { + for (const dialog of document.querySelectorAll('dialog[open]')) { + if (dialog.matches(':modal')) topmost = dialog as HTMLElement + } + } catch { + return null + } + + return topmost +} + +export function raiseToTopLayer(host: HTMLElement | null) { + if (!host) return + + const parent = topmostModalDialog() ?? document.body + + if (host.parentElement !== parent) { + parent.appendChild(host) + + if (parent !== document.body) { + parent.addEventListener('close', () => raiseToTopLayer(host), { + once: true, + }) + } + } + + if (typeof host.showPopover !== 'function') return + + try { + host.hidePopover() + } catch {} + + try { + host.showPopover() + } catch {} +} + +type TopLayerHostProps = Pick + +function TopLayerHost({ children, id, className, style }: TopLayerHostProps) { + const [host] = useState(() => { + const el = document.createElement('div') + el.setAttribute('popover', 'manual') + return el + }) + + useLayoutEffect(() => { + if (id) host.id = id + host.className = className ?? '' + host.style.cssText = TOP_LAYER_HOST_CSS + if (style) Object.assign(host.style, style) + }) + + useEffect(() => { + raiseToTopLayer(host) + + return () => { + try { + host.hidePopover() + } catch {} + host.remove() + } + }, [host]) + + return createPortal(children, host) +} + +export function Portal({ + children, + container = document.body, + topLayer, + id, + className, + style, +}: PortalProps) { + if (topLayer) { + return ( + + {children} + + ) + } + if (!container) return null return createPortal(children, container) diff --git a/src/pages/root.tsx b/src/pages/root.tsx index 640c918d3..e855e977d 100644 --- a/src/pages/root.tsx +++ b/src/pages/root.tsx @@ -3,6 +3,8 @@ import { Toaster } from 'react-hot-toast' import Analytics from '@/analytics' import { purgeDeprecatedStorageKeys } from '@/common/storage' import { listenEvent } from '@/common/utils/call-event' +import { TOAST_TOP_LAYER_ID } from '@/common/toast' +import { Portal } from '@/components/ui' import { GeneralSettingProvider, useGeneralSetting, @@ -39,23 +41,25 @@ export function RootLayout() {
- + + duration: 5000, + }} + /> + ) From 8372386715383e81f73e544dfb4c4a619600a856 Mon Sep 17 00:00:00 2001 From: Shak Date: Tue, 1 Sep 2026 23:27:08 +0330 Subject: [PATCH 2/4] fix: raise context menu and tooltips above open modals 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. --- .../ui/tooltip/clickable-tooltip.tsx | 11 +-- src/components/ui/tooltip/tooltip.tsx | 14 +-- .../modal/bookmark-context-menu.tsx | 97 ++++++++++--------- 3 files changed, 61 insertions(+), 61 deletions(-) diff --git a/src/components/ui/tooltip/clickable-tooltip.tsx b/src/components/ui/tooltip/clickable-tooltip.tsx index 2a5cbd527..8a5e54d4e 100644 --- a/src/components/ui/tooltip/clickable-tooltip.tsx +++ b/src/components/ui/tooltip/clickable-tooltip.tsx @@ -1,7 +1,7 @@ import { Motion as motion, Presence } from '@/common/motion' import { type ReactNode, useEffect, useRef, useState, type RefObject } from 'react' -import ReactDOM from 'react-dom' +import { Portal } from '../portal/portal' type Position = | 'top' @@ -208,12 +208,12 @@ const ClickableTooltip = ({ return ( <> - {ReactDOM.createPortal( + {isOpen && ( )} - , - document.body - )} + + ) } diff --git a/src/components/ui/tooltip/tooltip.tsx b/src/components/ui/tooltip/tooltip.tsx index 5df9cddb5..125234b2a 100644 --- a/src/components/ui/tooltip/tooltip.tsx +++ b/src/components/ui/tooltip/tooltip.tsx @@ -1,7 +1,7 @@ import { twMerge } from 'tailwind-merge' import { Motion as motion, Presence } from '@/common/motion' import { type ReactNode, useEffect, useRef, useState } from 'react' -import ReactDOM from 'react-dom' +import { Portal } from '../portal/portal' type Position = | 'top' @@ -217,12 +217,12 @@ export const Tooltip = ({ {children} - {(alwaysShow || isVisible) && - ReactDOM.createPortal( + {(alwaysShow || isVisible) && ( + - , - document.body - )} + + + )} ) } diff --git a/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx b/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx index 778bd1d53..d3854f8f9 100644 --- a/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx +++ b/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { createPortal } from 'react-dom' +import { Portal } from '@/components/ui' import { Icon } from '@/src/icons' interface BookmarkContextMenuProps { @@ -60,62 +60,63 @@ export function BookmarkContextMenu({ Math.max(10, window.innerHeight - MENU_HEIGHT - 10) ) - return createPortal( -
e.stopPropagation()} - onContextMenu={(e) => { - e.preventDefault() - e.stopPropagation() - }} - > - {onOpenInNewTab && ( + return ( + +
e.stopPropagation()} + onContextMenu={(e) => { + e.preventDefault() + e.stopPropagation() + }} + > + {onOpenInNewTab && ( + + )} + - )} - +
-
- - -
, - document.body + +
+ ) } From ff255cc51ded2d247012178efc28acda981ac2eb Mon Sep 17 00:00:00 2001 From: Shak Date: Tue, 1 Sep 2026 23:27:22 +0330 Subject: [PATCH 3/4] fix: stop the voice search language menu from being clipped 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:
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. --- .../search/voice/voice-search.portal.tsx | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/layouts/search/voice/voice-search.portal.tsx b/src/layouts/search/voice/voice-search.portal.tsx index a46a3edf7..06faaec03 100644 --- a/src/layouts/search/voice/voice-search.portal.tsx +++ b/src/layouts/search/voice/voice-search.portal.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useVoiceSearch } from './use-voice-search' -import { Portal } from '@/components/ui' +import { Dropdown, Portal } from '@/components/ui' import { Icon } from '@/src/icons' interface VoiceSearchPortalProps { @@ -23,7 +23,6 @@ export function VoiceSearchPortal({ portalRef, }: VoiceSearchPortalProps) { const [selectedLanguage, setSelectedLanguage] = useState('fa-IR') - const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false) const { isListening, currentTranscript, startVoiceSearch, stopVoiceSearch } = useVoiceSearch((result) => { @@ -86,32 +85,27 @@ export function VoiceSearchPortal({
-
- - - {isLanguageMenuOpen && ( -
- {languages.map((lang) => ( - - ))} + + + { + languages.find((l) => l.code === selectedLanguage) + ?.name + }
- )} -
+ } + options={languages.map((lang) => ({ + id: lang.code, + label: lang.name, + }))} + onOptionSelect={(option) => + setSelectedLanguage(option.id as Language) + } + />