// biome-ignore-all lint/style/noExcessiveLinesPerFile: FAB shell, morph transition, and trigger/pane layouts collocated by design import { type ComponentType, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { Platform, Pressable, type StyleProp, StyleSheet, useWindowDimensions, View, type ViewStyle } from 'react-native'; import type { IconProps } from 'rn-motion-ui-icons/icon-props'; import { AddLine as Plus } from 'rn-motion-ui-icons/icons/add-line'; import { CloseLine as X } from 'rn-motion-ui-icons/icons/close-line'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { EASE_OUT, springLayout } from '../../../lib/ease'; import { elevated as elevatedSurface, type SurfaceElevation } from '../../../lib/elevated'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { TIMING_INSTANT } from '../../../theme/motion'; import { BUTTON_SIZE, FLOATING_ICON_SIDE } from '../../buttons/Button/button-scale'; import { IconButton } from '../../buttons/IconButton/icon-button'; import { Surface } from '../../display/Surface/surface'; import { ThemedIcon } from '../../icon/themed-icon'; import { useBlurTargetRef } from '../Overlay/blur-context'; import { OutsidePressBackdrop, type OutsidePressFrame } from '../Overlay/outside-press-backdrop'; import type { OverlayType } from '../Overlay/overlay-type'; import { TeleportedOverlay } from '../Overlay/teleported-overlay'; import { getWebDocument, isWebNode, type WebPointerEvent } from '../Overlay/web-document'; const PANE_RADIUS = 24; /** Web animates the size through Moti; Fabric can't round-trip layout props * through `useAnimatedStyle`, so native keeps a static size and drives the * change via this layout transition. */ const IS_WEB = Platform.OS === 'web'; /** The pre-7.0.0 morph spring — underdamped (ζ≈0.65) so the pane overshoots and * settles with a visible bounce, the "unfolding" read. Fabric can't stagger * width against height (one layout transition drives the whole size), so both * axes share this bouncy spring and the radius mirrors it for lockstep. */ const MORPH_SPRING = { stiffness: 200, damping: 18, mass: 0.95 }; const MORPH_LAYOUT = springLayout(MORPH_SPRING); /** Web's staggered springs — width snaps open fast, height bounces, reading as * unfolding. Web animates the size through Moti, so it still gets the per-axis * stagger Fabric can't express; native shares the base `MORPH_SPRING`. */ const WEB_MORPH_TRANSITION = { type: 'spring' as const, ...MORPH_SPRING, width: { type: 'spring' as const, stiffness: 350, damping: 30, mass: 0.55 }, borderRadius: { type: 'spring' as const, stiffness: 350, damping: 30, mass: 0.55 }, } satisfies import('../../../moti/core/types').MotiTransition; /** Fabric-safe radius spring — mirrors `MORPH_LAYOUT` so the corner stays in * lockstep with the layout-driven resize. */ const NATIVE_MORPH_TRANSITION = { type: 'spring' as const, ...MORPH_SPRING }; /** * The root's static frame. `pointerEvents: 'box-none'` MUST come from * StyleSheet.create, not an inline style object: on web `box-none` is not real * CSS but a polyfill the StyleSheet compiler expands into two rules * (`pointer-events: none` on the node, `pointer-events: auto` on its direct * children). That expansion only runs in the atomic/class path — RNW's inline * path explicitly does not support `pointerEvents` and passes `box-none` * through to the DOM, where the browser drops it as invalid CSS and the node * keeps `pointer-events: auto`. Because the root is pinned at the full expanded * size in both states (see the geometry comment in the JSX), an inline box-none * would leave that whole invisible box swallowing every click over the content * beneath the FAB corner — the context-menu rows that happen to overlap it. * Native reads the same style object directly, so the class path is correct on * both targets. The size and `left`/`right` anchor stay inline: they are the * geometry that actually changes. */ const fabRootStyles = StyleSheet.create({ root: { position: 'absolute', bottom: 16, zIndex: 30, pointerEvents: 'box-none', }, }); /** * The shell's animated geometry. Web animates the size through Moti alongside * the radius (smooth staggered springs); Fabric keeps a static size and drives * the change via the `layout` transition (layout props don't round-trip Yoga). */ /** Everything the shell's geometry depends on, passed as one bag so the helper * stays under the parameter cap. */ type FabShellGeometry = { open: boolean; expandedWidth: number; expandedHeight: number; left: boolean; triggerSize: number; }; function fabShellGeometry({ open, expandedWidth, expandedHeight, left, triggerSize }: FabShellGeometry) { // The collapsed trigger is an IconButton (a square pill) at the FAB's `size`. // The shared ramp's `size` px owns that square, so the shell's resting footprint // can never drift from the size of the button that fills it; a pill rounds to // half the side. const size = { width: open ? expandedWidth : triggerSize, height: open ? expandedHeight : triggerSize }; const anchor = left ? { left: 0 } : { right: 0 }; return { animate: IS_WEB ? { ...size, borderRadius: open ? PANE_RADIUS : triggerSize / 2 } : { borderRadius: open ? PANE_RADIUS : triggerSize / 2 }, style: IS_WEB ? anchor : { ...size, ...anchor }, }; } /** Handed to render-prop children so panel content can close the FAB. */ export type MorphingFABApi = { /** Close the FAB back to its collapsed trigger. */ close: () => void; }; /** FAB size — the collapsed trigger's IconButton size, read from the shared ramp * so it lines up with a Button or IconButton of the same size. */ export type MorphingFABSize = 'sm' | 'md' | 'lg'; export type MorphingFABProps = { /** Expanded pane content, or a render-prop receiving `{ close }`. */ children: ReactNode | ((api: MorphingFABApi) => ReactNode); /** Collapsed trigger icon component. Defaults to a plus (`AddLine`). Rendered * through the trigger's IconButton at 20px with the foreground stroke colour. */ icon?: ComponentType; /** Collapsed trigger glyph color; defaults to the foreground token. */ iconColor?: string; /** Classes for the collapsed button surface; the expanded pane keeps its elevation. */ triggerClassName?: string; position?: 'bottom-right' | 'bottom-left'; /** Collapsed trigger size — the trigger's IconButton and the shell's resting * footprint stand at the shared interactive ramp, so the FAB lines up with a * Button or IconButton of the same size. @default 'lg' */ size?: MorphingFABSize; /** * Swap the trigger's and pane's ladder shadow for the input field's large, * diffuse halo (`shadow-floating`). It replaces the `shadow-elevated-N` rung * rather than adding to it, so both keep their `elevation` tint but trade the * layered drop for the halo. @default false */ floating?: boolean; /** * Surface elevation level (0–3) — drives the background tint (`bg-surface-N`) * and the `shadow-elevated-N` recipe. `0` is the flat resting surface — a * `surface-3` fill with no shadow or border. @default 0 */ elevation?: SurfaceElevation; /** * Backdrop blur radius in px/dp. `0` keeps the trigger and pane solid; any * positive value frosts both — a `glass` tint over a backdrop blur (with the * specular edge light when `rim` is set). @default 0 */ blurRadius?: number; /** Opacity of the frosted tint (0–1); only thins the fill when `blurRadius` is set. @default 1 */ opacity?: number; /** Draw the glass edge light — the `Rim` specular ring around the trigger and pane. @default false */ rim?: boolean; /** Rim width in px/dp. @default 1 */ rimWidth?: number; /** Peak alpha (0–1) of the rim's specular highlight — lower is subtler. @default 0.5 */ intensity?: number; /** Expanded pane width in px. Defaults to 300. */ expandedWidth?: number; /** Expanded pane height in px. Defaults to 230. */ expandedHeight?: number; /** * Stretch the expanded pane to the screen width with the same symmetric 16 px * left/right inset the FAB already hugs its corner with, instead of the fixed * `expandedWidth` pane, so left, right and bottom all hug the screen by the * same amount. @default false */ hug?: boolean; /** Controlled open state. */ open?: boolean; /** Uncontrolled initial open state. */ defaultOpen?: boolean; /** Called whenever the FAB opens or closes. */ onOpenChange?: (open: boolean) => void; /** * Fires once the FAB has finished opening — the moment its pane has unfolded * far enough to be considered "presented". The FAB renders inline (no `Modal`), * so this rides the `open` flip rather than iOS `Modal.onShow`. Use it to focus * a `TextInput` inside the pane once the pane is on screen. */ onShow?: () => void; /** Close affordance rendered in a top-right header row while open. * Defaults to a small ×. Pass `null` to omit it when the pane * content owns its own close control. */ closeIcon?: ReactNode | null; style?: StyleProp; accessibilityLabel?: string; testID?: string; /** testID for the collapsed trigger button. */ triggerTestID?: string; /** * The scrim behind the pane: `"blur"`, `"opacity"`, or `"none"`. Defaults to * `"opacity"` to dim the content behind the expanded pane. Use `"none"` to opt out. */ overlay?: OverlayType; /** * When true (default), pressing/clicking outside the pane closes the FAB. * Works on every platform: web listens on the document, native gets a * full-window transparent backdrop measured from the FAB's position. * @default true */ closeOnOutsidePress?: boolean; }; /** * A floating action button that morphs into a rounded pane. Collapsed it is a * circular IconButton (the `lg` size, plus icon by default) whose `floating` / * `elevation` drive its styling. Tapping it springs the shell open into a * floating surface of `expandedWidth`×`expandedHeight` and renders `children` * inside. The pane closes via the top-right close affordance, the render-prop * `close()`, or the controlled `open` prop. * * The collapsed trigger is styled entirely by the IconButton — the shell only * paints its surface (background, rim, shadow) once expanded. * * Use the render-prop form to build interactive content — a feedback form, * an action menu, or any custom flow — directly inside the expanded pane. */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: the open/teleport branches gate the blur teleport and backdrop — flattening them would repeat the shell subtree // biome-ignore lint/complexity/noExcessiveLinesPerFunction: the shell wires trigger measurement, outside-press handling, the morph pane, and the Android blur teleport around shared refs/state — splitting would prop-drill the shared values across function boundaries export function MorphingFAB({ children, icon, iconColor, triggerClassName, position = 'bottom-right', size = 'lg', floating = false, elevation = 0, blurRadius = 0, opacity = 1, rim = false, rimWidth, intensity, expandedWidth = 300, expandedHeight = 230, hug = false, open: openProp, defaultOpen = false, onOpenChange, onShow, closeIcon, style, accessibilityLabel, testID = 'morphing-fab', triggerTestID = 'morphing-fab-trigger', overlay = 'opacity', closeOnOutsidePress = true, }: MorphingFABProps) { const reduce = useReducedMotion(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const open = openProp ?? internalOpen; const left = position === 'bottom-left'; // On Android the blur must render OUTSIDE the `BlurTarget` it frosts (see // `OverlayHost`), so a `"blur"` FAB teleports its backdrop + shell there — // the morph still runs, the shell just lives in the overlay host instead of // inline. The host tree is ALSO where the Fabric layout transition captures // its start frame correctly: inside the `BlurTarget`/ScrollView tree the // shell's LinearTransition starts from the pane's top-left corner, so the // pane appears to grow from there instead of unfolding from the trigger // (verified on-device: blur/teleported morphs correctly, none/opacity do // not). Whenever a host exists, render through it for EVERY `overlay` — // `overlay` still decides the scrim, teleporting only relocates the // backdrop + shell into the host. Without a provider (`blurTargetRef` // null) the FAB stays inline. const blurTargetRef = useBlurTargetRef(); const teleported = Platform.OS === 'android' && blurTargetRef !== null; const setOpen = useCallback( (next: boolean) => { if (openProp === undefined) setInternalOpen(next); onOpenChange?.(next); }, [openProp, onOpenChange], ); const handleOpen = useCallback(() => setOpen(true), [setOpen]); const handleClose = useCallback(() => setOpen(false), [setOpen]); // The FAB renders inline — no `Modal`/`onShow` — so it fires `onShow` once the // pane has opened, giving a consumer the same "now it is safe to focus content" // moment a modal-backed menu exposes. // biome-ignore lint/plugin: fires in response to the open flip — a presentational side effect, not derived render state useEffect(() => { if (open) onShow?.(); }, [open, onShow]); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const rootRef = useRef(null); /** * The outside-press backdrop's window-covering frame — negative offsets from * the FAB's window position, so an absolutely-positioned child inside the * (small, corner-anchored) root still covers the whole window and catches * outside taps on native (web closes via its document listener instead). * Null while closed or before the async native measure lands. */ const [backdropFrame, setBackdropFrame] = useState(null); // Measure the root's window position whenever the pane opens (or the window // resizes while open — rotation) so the native outside-press backdrop exactly // covers the window. `measureInWindow` is async on native; until the frame // lands there is simply no backdrop yet. // biome-ignore lint/plugin: measuring the root on open/rotate is a DOM/native measure side effect, not derived state — the backdrop frame must follow the window useEffect(() => { if (!(open && (overlay !== 'none' || closeOnOutsidePress))) { setBackdropFrame(null); return; } rootRef.current?.measureInWindow((x, y) => { setBackdropFrame({ top: -y, left: -x, width: windowWidth, height: windowHeight }); }); }, [open, overlay, closeOnOutsidePress, windowWidth, windowHeight]); /** * The root's fixed window corner — the FAB pins its bottom edge and one * horizontal edge (`left`/`right`) as it grows, so this is that bottom corner. * Measured on every root layout (mount, open/close, rotation) via the root's * `onLayout` below; the teleported overlay derives its top-left from this minus * the current shell size, so it never re-measures (and never glitches) when the * pane opens or closes. */ const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); // Guards the async `measureInWindow` round-trip: the mount-time measure can // be dispatched before the root's first native layout (or an ancestor's // settle) and its callback can arrive LAST, pinning the closed FAB at a // stale spot until the next open re-measures it. Each measure bumps the // sequence, so only the newest measure's callback may write the anchor. const measureSeq = useRef(0); const measureAnchor = useCallback(() => { measureSeq.current += 1; const seq = measureSeq.current; if (!teleported) { setAnchor(null); return; } // The FAB pins one horizontal edge and the bottom edge as it grows, so the // anchor is that fixed corner: bottom-left for `position="bottom-left"`, // bottom-right otherwise. rootRef.current?.measureInWindow((x, y, width, height) => { if (seq !== measureSeq.current) return; setAnchor({ x: left ? x : x + width, y: y + height }); }); }, [teleported, left]); // `onLayout` alone misses the teleport toggle — flipping `overlay` to "blur" // changes the children, not the root's own layout, so no layout event fires and // the shell never measures. Run the measure once per teleport/left change // (mount + toggle); `onLayout` below covers rotation and size changes, and the // extra run on every `open` covers the root having scrolled under the FAB // while closed (the teleported shell must open where the trigger IS, not where // it was at mount). // biome-ignore lint/plugin: measuring the root is a native measure side effect, not derived state — the teleported overlay must follow the window // biome-ignore lint/correctness/useExhaustiveDependencies: `open` is an intentional re-measure trigger (scroll-under-FAB while closed), not a body dependency useEffect(() => { measureAnchor(); }, [measureAnchor, open]); // Post-mount settle re-measure: the FIRST measure (and the root's own // `onLayout`, which only fires when the root's frame in its PARENT changes) // can both miss a window-space drift caused by an ANCESTOR finishing its // layout after mount — a centering stage, insets, storybook chrome. The // root's parent-relative frame is unchanged under that translation, so no // layout event fires again, and the closed FAB would sit at the pre-settle // spot until the first open re-measures it. Measure once more after the // initial layout has settled so the resting trigger is right from the start. // biome-ignore lint/plugin: deferred re-measure after the native layout settles — a DOM/native measure side effect, not derived render state useEffect(() => { if (!teleported) return; const timer = setTimeout(measureAnchor, 150); return () => clearTimeout(timer); }, [measureAnchor, teleported]); // Close on an outside press (web). The FAB is inline — no modal backdrop to // catch a stray press — so a document-level `pointerdown` listener detects a // press landing anywhere but the FAB and folds it shut. `getWebDocument()` // returns undefined off-web, where an inline control has no outside press. // biome-ignore lint/plugin: document-level pointerdown can't be expressed as an RN handler or derived state useEffect(() => { if (!(open && closeOnOutsidePress)) return; const doc = getWebDocument(); if (!doc) return; const onPointerDown = (event: WebPointerEvent) => { const node = rootRef.current; const target = event.target; if (isWebNode(node) && node.contains(target)) return; setOpen(false); }; doc.addEventListener('pointerdown', onPointerDown); return () => doc.removeEventListener('pointerdown', onPointerDown); }, [open, closeOnOutsidePress, setOpen]); // Web restores the pre-7.0.0 staggered springs (width snaps open fast, height // bounces); Fabric springs only the radius and drives the size via the layout // transition above. const morphSpring = IS_WEB ? WEB_MORPH_TRANSITION : NATIVE_MORPH_TRANSITION; const morphTransition = reduce ? TIMING_INSTANT : morphSpring; const paneEnterTransition = reduce ? TIMING_INSTANT : { type: 'timing' as const, duration: 200, delay: 150, easing: EASE_OUT }; const resolvedPane = typeof children === 'function' ? children({ close: handleClose }) : children; // Match the IconButton's visible side so the collapsed shell has no empty rim. const triggerSize = floating ? FLOATING_ICON_SIDE[size] : BUTTON_SIZE[size].px; const glass = blurRadius > 0; // `hug` stretches the expanded pane to the screen width with the same 16 px // left/right inset the FAB already hugs its bottom and anchored side with, so // every inset edge agrees instead of the fixed `expandedWidth` pane. const resolvedExpandedWidth = Math.max(triggerSize, hug ? windowWidth - 32 : Math.min(expandedWidth, windowWidth - 32)); const shell = fabShellGeometry({ open, expandedWidth: resolvedExpandedWidth, expandedHeight, left, triggerSize }); // The teleported wrapper's top-left: the fixed root's top-left — its bottom // corner (the anchor) minus the expanded size. The root never resizes, so // this is constant and the teleported shell can't drift from the inline one. const rootWindow = anchor ? { x: left ? anchor.x : anchor.x - resolvedExpandedWidth, y: anchor.y - expandedHeight } : null; // Outside-press backdrop (native): covers the whole window so a tap anywhere // outside the pane folds it back — the web path is the document listener // above. When `overlay` is on it also dims the page. Teleported it passes // `blurInline={false}` so the frost actually renders OUT of the BlurTarget. // Keyed + wrapped in `AnimatePresence` at the call sites below so closing the // FAB runs the backdrop's exit fade (dim + blur out over 200 ms) instead of // popping the scrim off in the same frame the pane starts folding — the // progressive unblur the pane's enter already has. const backdrop = open && (overlay !== 'none' || closeOnOutsidePress) && backdropFrame !== null ? ( ) : null; const shellContent = ( <> {open ? ( {closeIcon === null ? null : ( {closeIcon ?? } )} {resolvedPane} ) : ( )} ); const shellView = glass ? ( {shellContent} ) : ( {shellContent} ); return ( {teleported ? ( {backdrop} {shellView} ) : ( <> {backdrop} {shellView} )} ); }