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/modal/modal.variants.ts b/src/components/ui/modal/modal.variants.ts index 5e44546c9..7f8e764ac 100644 --- a/src/components/ui/modal/modal.variants.ts +++ b/src/components/ui/modal/modal.variants.ts @@ -13,10 +13,10 @@ export const modalBoxVariants = cva( 'transition-discrete', 'opacity-0', 'scale-95', - 'group-open:opacity-100', - 'group-open:scale-100', - 'starting:group-open:opacity-0', - 'starting:group-open:scale-95', + 'group-open/modal:opacity-100', + 'group-open/modal:scale-100', + 'starting:group-open/modal:opacity-0', + 'starting:group-open/modal:scale-95', ], { variants: { @@ -64,6 +64,12 @@ export const modalScrollVariants = cva( export type ModalScrollVariant = typeof modalScrollVariants -export const modalDialogVariants = cva(['modal', 'modal-middle', 'group', 'p-2', 'md:p-4']) +export const modalDialogVariants = cva([ + 'modal', + 'modal-middle', + 'group', + 'p-2', + 'md:p-4', +]) export type ModalDialogVariant = typeof modalDialogVariants 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/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 + +
+ ) } 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) + } + />
)