// SPDX-License-Identifier: AGPL-3.0-or-later import styles from '@app/features/app/components/dialogs/Modal.module.css'; import {resolveModalBackdropMotionSpec, resolveModalMotionSpec} from '@app/features/app/components/dialogs/ModalMotion'; import {useModalBackHandler} from '@app/features/app/hooks/useModalBackHandler'; import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands'; import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager'; import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope'; import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext'; import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import OverlayStack from '@app/features/ui/state/OverlayStack'; import {ModalStackContext} from '@app/features/ui/utils/ModalStackContext'; import { type HeaderProps, type ModalContextValue, type ModalProps, type ScreenReaderLabelProps, useHeaderLogic, useModalLogic, useScreenReaderLabelLogic, } from '@app/features/ui/utils/ModalUtils'; import {wasPointerDownInside} from '@app/lib/overlay/DismissGuard'; import {FloatingFocusManager, FloatingOverlay, FloatingPortal, useFloating} from '@floating-ui/react'; import {useLingui} from '@lingui/react/macro'; import {XIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {motion, type Transition} from 'framer-motion'; import {observer} from 'mobx-react-lite'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; const ModalContext = React.createContext(null); const useModalContext = () => { const context = useContext(ModalContext); if (!context) { throw new Error('Modal components must be used within a Modal.Root'); } return context; }; const RootComponent = React.forwardRef( ( { children, className, size = 'medium', initialFocusRef, centered = false, onClose, onAnimationComplete, backdropSlot, transitionPreset = 'default', disableHistoryManagement = false, ...props }, ref, ) => { const modalSurfaceRef = useRef(null); const backdropContaminatedRef = useRef(false); const [labelRegistry, setLabelRegistry] = useState>>({}); const {refs, context} = useFloating({ open: true, }); const portalHost = usePortalHost(); const modalDocument = portalHost?.ownerDocument ?? document; const {stackIndex, isVisible, needsBackdrop, isTopmost, restoreFocusOnClose} = useContext(ModalStackContext); const { isMobile, isFullscreenOnMobile, useFullscreenLayer, useMobileEdgeToEdge, prefersReducedMotion, modalContextValue, handleBackdropClick, handleClose, } = useModalLogic({ size, centered, onClose, onAnimationComplete, }); useModalBackHandler(handleClose, disableHistoryManagement); const isFirstModal = stackIndex === 0; useEffect(() => { if (!isFirstModal) { return; } PopoutCommands.closeAllForDocument(modalDocument); }, [isFirstModal, modalDocument]); const isInstantTransition = transitionPreset === 'instant'; const shouldAnimateRoot = !prefersReducedMotion && !isInstantTransition; const [isRootAnimating, setIsRootAnimating] = useState(shouldAnimateRoot); const setModalSurfaceWrapperRef = useCallback((node: HTMLDivElement | null) => { modalSurfaceRef.current = node; }, []); const setMotionElementRef = useCallback( (node: HTMLDivElement | null) => { if (typeof ref === 'function') { ref(node); } else if (ref) { (ref as React.MutableRefObject).current = node; } }, [ref], ); const motionSpec = resolveModalMotionSpec({ transitionPreset, prefersReducedMotion, isFullscreenOnMobile, }); const backdropMotionSpec = resolveModalBackdropMotionSpec({ transitionPreset, prefersReducedMotion, isMobile, }); const handleBackdropClickEvent = useCallback( (event: React.MouseEvent) => { if (event.target === event.currentTarget) { event.preventDefault(); event.stopPropagation(); if (wasPointerDownInside(modalSurfaceRef.current)) { return; } if (backdropContaminatedRef.current) { return; } backdropContaminatedRef.current = true; setTimeout(() => { backdropContaminatedRef.current = false; }, 100); handleBackdropClick(onClose); } }, [onClose, handleBackdropClick], ); const handleAnimationStart = useCallback(() => { FocusRingManager.setRingsEnabled(false); setIsRootAnimating(shouldAnimateRoot); }, [shouldAnimateRoot]); const handleAnimationComplete = useCallback(() => { FocusRingManager.setRingsEnabled(KeyboardMode.keyboardModeEnabled); setIsRootAnimating(false); onAnimationComplete?.(); }, [onAnimationComplete]); const enhancedModalContextValue = useMemo(() => { const originalRegisterLabel = modalContextValue.registerLabel; return { ...modalContextValue, registerLabel: (source: 'header' | 'screen-reader', id: string) => { setLabelRegistry((current) => ({...current, [source]: id})); return originalRegisterLabel(source, id); }, }; }, [modalContextValue]); const labelledBy = useMemo(() => { const ids = Object.values(labelRegistry).filter(Boolean); return ids.length > 0 ? ids.join(' ') : undefined; }, [labelRegistry]); const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); const [acquiredZIndex, setAcquiredZIndex] = useState(null); useEffect(() => { const zIndex = OverlayStack.acquire(); setAcquiredZIndex(zIndex); return () => { OverlayStack.release(); }; }, []); const fallbackZIndex = OverlayStack.peek(); const modalZIndex = acquiredZIndex ?? fallbackZIndex; const backdropZIndex = acquiredZIndex != null ? acquiredZIndex - 1 : fallbackZIndex - 1; const isInteractive = isVisible && isTopmost; const overlayStyle = useMemo( () => ({ zIndex: modalZIndex, display: 'flex', alignItems: 'center', justifyContent: 'center', inset: 0, pointerEvents: isInteractive ? 'auto' : 'none', }) as const, [isInteractive, modalZIndex], ); const layerVisibilityStyle = useMemo(() => { const visibility: React.CSSProperties['visibility'] = isVisible ? 'visible' : 'hidden'; return { opacity: isVisible ? 1 : 0, visibility, }; }, [isVisible]); const isCenteredOnMobile = isMobile && !useFullscreenLayer; return ( {needsBackdrop && isVisible && ( )} {isCenteredOnMobile && ( )} {backdropSlot ? (
{backdropSlot}
) : null}
{ if (!isInteractive) { return []; } const inside: Array = []; modalDocument.querySelectorAll('iframe[src*="hcaptcha"], .h-captcha').forEach((el) => inside.push(el)); const popoutsRoot = modalDocument.querySelector('[data-popouts-root]'); if (popoutsRoot) inside.push(popoutsRoot); const mediaViewerPortalRoot = modalDocument.querySelector('[data-media-viewer-portal-root]'); if (mediaViewerPortalRoot) inside.push(mediaViewerPortalRoot); modalDocument.querySelectorAll('[data-floating-ui-portal]').forEach((el) => inside.push(el)); modalDocument .querySelectorAll('[data-rsbs-root], [data-rsbs-backdrop], [data-rsbs-overlay]') .forEach((el) => inside.push(el)); const nativeTitlebar = modalDocument.querySelector('[data-native-titlebar]'); if (nativeTitlebar) inside.push(nativeTitlebar); return inside; }} data-flx="app.modal.floating-focus-manager" >
{children}
); }, ); RootComponent.displayName = 'ModalRoot'; export const Root = observer(RootComponent); export const Header = React.forwardRef( ({children, icon, title, variant = 'light', hideCloseButton = false, onClose, id, ...props}, ref) => { const {i18n} = useLingui(); const modalContextValue = useModalContext(); const {headingId, handleClose} = useHeaderLogic({ title, onClose, id, modalContextValue, }); return (
{icon}

{title}

{!hideCloseButton && ( )}
{children}
); }, ); Header.displayName = 'ModalHeader'; type ContentProps = React.ComponentPropsWithoutRef & { children: React.ReactNode; className?: string; padding?: 'default' | 'none'; }; export const Content = React.forwardRef( ({children, className, padding = 'default', ...props}, ref) => ( {children} ), ); Content.displayName = 'ModalContent'; interface FooterProps { children: React.ReactNode; className?: string; stretchButtons?: boolean; } export const Footer = React.forwardRef( ({children, className, stretchButtons, ...props}, ref) => (
{children}
), ); Footer.displayName = 'ModalFooter'; export const ScreenReaderLabel: React.FC = ({text, id}) => { const modalContextValue = useModalContext(); const {labelId} = useScreenReaderLabelLogic({ text, id, modalContextValue, }); return ( {text} ); }; ScreenReaderLabel.displayName = 'ModalScreenReaderLabel'; type InsetCloseButtonProps = Omit, 'type'> & { ariaLabel?: string; }; export const InsetCloseButton = React.forwardRef( ({ariaLabel, className, ...props}, ref) => { const {i18n} = useLingui(); return (
); }, ); InsetCloseButton.displayName = 'ModalInsetCloseButton'; interface ContentLayoutProps { children: React.ReactNode; className?: string; } export const ContentLayout = React.forwardRef( ({children, className, ...props}, ref) => (
{children}
), ); ContentLayout.displayName = 'ModalContentLayout'; interface DescriptionProps extends React.HTMLAttributes { children: React.ReactNode; } export const Description = React.forwardRef( ({children, className, ...props}, ref) => (
{children}
), ); Description.displayName = 'ModalDescription'; interface InputGroupProps { children: React.ReactNode; className?: string; } export const InputGroup = React.forwardRef(({children, className, ...props}, ref) => (
{children}
)); InputGroup.displayName = 'ModalInputGroup'; interface FormFooterProps { children: React.ReactNode; className?: string; stretchButtons?: boolean; } export const FormFooter = React.forwardRef( ({children, className, stretchButtons, ...props}, ref) => (
{children}
), ); FormFooter.displayName = 'ModalFormFooter';