import { isAndroid, isIos, isTouchable, isWeb, useIsomorphicLayoutEffect, } from '@tamagui/constants' import type { AllPlatforms, MediaQueryKey } from '@tamagui/core' import { createStyledContext, useMedia } from '@tamagui/core' import { withStaticProperties } from '@tamagui/helpers' import { getPortal } from '@tamagui/native' import { PortalHost, PortalItem } from '@tamagui/portal' import { StackZIndexContext } from '@tamagui/z-index-stack' import React, { createContext, useContext, useId, useMemo } from 'react' /** * External store for passing children from AdaptPortalContents to Adapt.Contents * when teleport is enabled. This bypasses PortalItem to avoid nested teleport * (inner portal inside an already-teleported Sheet) which breaks touch * coordinate mapping on iOS. */ type AdaptChildrenStore = { set(children: React.ReactNode): void get(): React.ReactNode subscribe(callback: () => void): () => void } function createAdaptChildrenStore(): AdaptChildrenStore { let children: React.ReactNode = null const listeners = new Set<() => void>() return { set(c) { children = c for (const l of listeners) l() }, get: () => children, subscribe(callback) { listeners.add(callback) return () => listeners.delete(callback) }, } } const AdaptChildrenStoreContext = createContext(null) const emptySubscribe = () => () => {} const emptyGet = () => null /** Renders adapt children from external store (used when teleport is enabled) */ function TeleportAdaptContents() { const store = useContext(AdaptChildrenStoreContext) const children = React.useSyncExternalStore( store?.subscribe ?? emptySubscribe, store?.get ?? emptyGet, store?.get ?? emptyGet ) return <>{children} } /** * Interfaces */ export type AdaptWhen = MediaQueryKeyString | boolean | null export type AdaptPlatform = AllPlatforms | 'touch' | null export type AdaptParentContextI = { Contents: Component scopeName: string platform: AdaptPlatform setPlatform: (when: AdaptPlatform) => any when: AdaptWhen setWhen: (when: AdaptWhen) => any portalName?: string lastScope?: string } type MediaQueryKeyString = MediaQueryKey extends string ? MediaQueryKey : never export type AdaptProps = { scope?: string when?: AdaptWhen platform?: AdaptPlatform children: React.JSX.Element | ((children: React.ReactNode) => React.ReactNode) } type Component = (props: any) => any export const AdaptContext = createStyledContext({ Contents: null as any, scopeName: '', portalName: '', platform: null as any, setPlatform: (x: AdaptPlatform) => {}, when: null as any, setWhen: () => {}, }) const LastAdaptContextScope = createContext('') export const ProvideAdaptContext = ({ children, ...context }: AdaptParentContextI & { children: any }) => { const scope = context.scopeName || '' const lastScope = useContext(LastAdaptContextScope) return ( {children} ) } export const useAdaptContext = (scope?: string) => { const lastScope = useContext(LastAdaptContextScope) const adaptScope = scope ?? lastScope return AdaptContext.useStyledContext(adaptScope) } /** * Hooks */ type AdaptParentProps = { children?: React.ReactNode Contents?: AdaptParentContextI['Contents'] scope: string portal?: | boolean | { forwardProps?: any } } const AdaptPortals = new Map() export const AdaptParent = ({ children, Contents, scope, portal }: AdaptParentProps) => { const id = useId() const portalName = `AdaptPortal${scope}${id}` const childrenStoreRef = React.useRef(null) if (!childrenStoreRef.current) { childrenStoreRef.current = createAdaptChildrenStore() } const isTeleport = process.env.TAMAGUI_TARGET === 'native' && getPortal().state.type === 'teleport' const FinalContents = useMemo(() => { if (Contents) { return Contents } // when teleport is enabled, use store-based children passing to avoid // nested teleport (inner PortalItem inside already-teleported Sheet) // which breaks touch coordinate mapping on iOS. if (isTeleport) { return TeleportAdaptContents } if (AdaptPortals.has(portalName)) { return AdaptPortals.get(portalName) } const element = () => { return ( ) } AdaptPortals.set(portalName, element) return element }, [portalName, Contents, isTeleport]) useIsomorphicLayoutEffect(() => { if (!isTeleport) { AdaptPortals.set(portalName, FinalContents) return () => { AdaptPortals.delete(portalName) } } }, [portalName, isTeleport]) const [when, setWhen] = React.useState(null) const [platform, setPlatform] = React.useState(null) return ( {children} ) } /** * Components */ export const AdaptContents = ({ scope, ...rest }: { scope?: string }) => { const context = useAdaptContext(scope) if (!context?.Contents) { throw new Error( process.env.NODE_ENV === 'production' ? `tamagui.dev/docs/intro/errors#warning-002` : `You're rendering a Tamagui component without nesting it inside a parent that is able to adapt.` ) } // forwards props - see shouldForwardSpace return React.createElement(context.Contents, { ...rest, key: `stable` }) } AdaptContents.shouldForwardSpace = true export const Adapt = withStaticProperties( function Adapt(props: AdaptProps) { const { platform, when, children, scope } = props const context = useAdaptContext(scope) const enabled = useAdaptIsActiveGiven(props) useIsomorphicLayoutEffect(() => { context?.setWhen?.((when || enabled) as AdaptWhen) context?.setPlatform?.(platform || null) }, [when, platform, enabled, context.setWhen, context.setPlatform]) useIsomorphicLayoutEffect(() => { return () => { context?.setWhen?.(null) context?.setPlatform?.(null) } }, []) let output: React.ReactNode if (typeof children === 'function') { const Component = context?.Contents output = children(Component ? : null) } else { output = children } return {!enabled ? null : output} }, { Contents: AdaptContents, } ) export const AdaptPortalContents = (props: { children: React.ReactNode scope?: string passThrough?: boolean }) => { const isActive = useAdaptIsActive(props.scope) const { portalName } = useAdaptContext(props.scope) const childrenStore = useContext(AdaptChildrenStoreContext) const isTeleport = !isWeb && getPortal().state.type === 'teleport' // when teleport is enabled, bypass PortalItem to avoid nested teleport // (inner portal inside already-teleported Sheet) which breaks touch // coordinate mapping on iOS. children are passed via external store // to TeleportAdaptContents rendered at Adapt.Contents. if (isTeleport && childrenStore) { return ( {props.children} ) } return ( {props.children} ) } function AdaptPortalTeleport({ isActive, store, children, }: { isActive: boolean store: AdaptChildrenStore children: React.ReactNode }) { // keep the teleport store in sync without clearing it between parent // renders. clearing only on real unmount or inactive lets React reconcile // sheet/dialog/popover contents instead of remounting the subtree. useIsomorphicLayoutEffect(() => { if (isActive) { store.set(children) } else { store.set(null) } }) useIsomorphicLayoutEffect(() => { return () => store.set(null) }, [store]) return isActive ? null : <>{children} } const useAdaptIsActiveGiven = ({ when, platform, }: Pick) => { const media = useMedia() if (when == null && platform == null) { return false } if (when === true) { return true } let enabled = false if (platform === 'touch') enabled = isTouchable else if (platform === 'native') enabled = !isWeb else if (platform === 'web') enabled = isWeb else if (platform === 'ios') enabled = isIos else if (platform === 'android') enabled = isAndroid if (platform && enabled == false) { return false } if (when && typeof when === 'string') { enabled = media[when] } return enabled } export const useAdaptIsActive = (scope?: string) => { const props = useAdaptContext(scope) return useAdaptIsActiveGiven(props) }