import { useCallback, useEffect } from 'react';
import { AccessibilityInfo, Platform, TouchableOpacity, View } from 'react-native';
import { useReducedMotion } from '../../../hooks/use-reduced-motion';
import { cn } from '../../../lib/cn';
import type { SurfaceElevation } from '../../../lib/elevated';
import { MotiView } from '../../../moti/components/view';
import { AnimatePresence } from '../../../moti/presence/animate-presence';
import { TIMING_BASE } from '../../../theme/motion';
import { Button } from '../../buttons/Button/button';
import { Surface } from '../../display/Surface/surface';
import { Text } from '../../typography/Text/text';
import { OverlayScrim } from '../Overlay/overlay-scrim';
import { OverlayShell, type OverlayShellContext } from '../Overlay/overlay-shell';
import type { OverlayType } from '../Overlay/overlay-type';
import { type ActionFeedbackState, MORPH_CONTENT_TRANSITION, RM_TRANSITION } from './action-feedback-motion';
import { MorphIcon } from './morph-icon';
const SUCCESS_AUTO_CLOSE_MS = 2500;
type StateContentProps = {
state: ActionFeedbackState;
reduced: boolean;
loadingMessage?: string;
successLabel?: string;
successMessage?: string;
errorTitle: string;
errorMessage?: string;
tagline?: string;
dismissLabel: string;
onDismiss: () => void;
testID?: string;
};
/**
* The text under the morph vessel, cross-faded as the state changes.
*
* Each block is a flex child of a `gap-4` column, so rendering one with no text
* inside still costs a 16px gap under the icon — hence the emptiness checks.
* `error` always has a title and a button, so it always renders.
*/
function StateContent(props: StateContentProps) {
const { state, reduced, loadingMessage, successLabel, successMessage, errorTitle, errorMessage } = props;
const { tagline, dismissLabel, onDismiss, testID } = props;
const transition = reduced ? RM_TRANSITION : MORPH_CONTENT_TRANSITION;
const hasLoadingContent = Boolean(loadingMessage || tagline);
const hasSuccessContent = Boolean(successLabel || successMessage || tagline);
const taglineText = tagline ? {tagline} : null;
return (
{state === 'loading' && hasLoadingContent && (
{loadingMessage ? {loadingMessage} : null}
{taglineText}
)}
{state === 'success' && hasSuccessContent && (
{successLabel ? (
{successLabel}
) : null}
{successMessage ? {successMessage} : null}
{taglineText}
)}
{state === 'error' && (
{errorTitle}
{errorMessage ? (
{errorMessage}
) : null}
{taglineText}
)}
);
}
type AnnouncementParts = {
state: ActionFeedbackState;
loadingMessage?: string;
successLabel?: string;
successMessage?: string;
errorTitle: string;
errorMessage?: string;
};
/**
* What the outcome sounds like: the same words that are on screen for the
* active state, joined so it reads as one sentence rather than fragments.
*/
function announcementFor(parts: AnnouncementParts): string {
const { state, loadingMessage, successLabel, successMessage, errorTitle, errorMessage } = parts;
if (state === 'loading') return loadingMessage ?? '';
if (state === 'success') return [successLabel, successMessage].filter(Boolean).join('. ');
return [errorTitle, errorMessage].filter(Boolean).join('. ');
}
export type ActionFeedbackModalProps = {
open?: boolean;
/**
* Fires when the modal asks to be dismissed — always with `false`, whether
* from the backdrop, the dismiss button, or the success auto-close. `open` is
* the consumer's own state, so there is no open direction to report.
*/
onOpenChange?: (open: boolean) => void;
state: ActionFeedbackState;
loadingMessage?: string;
successLabel?: string;
successMessage?: string;
errorMessage?: string;
errorTitle?: string;
dismissLabel?: string;
tagline?: string;
/**
* Swap the panel'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 the panel keeps its `elevation` tint but trades the
* layered drop for the halo. @default false
*/
floating?: boolean;
/** Surface elevation (0–3) — drives the drop shadow + dark-mode rim. `0` is the flat resting surface (no shadow or border). Defaults to 3. */
elevation?: SurfaceElevation;
testID?: string;
/** The scrim behind the panel: `"blur"`, `"opacity"`, or `"none"`. Defaults to `"blur"`. */
overlay?: OverlayType;
/** When false, pressing outside the panel will not close it. Defaults to true. */
closeOnOutsidePress?: boolean;
/**
* Fires after the modal has fully presented (iOS `Modal.onShow`) — the moment
* it is safe to request keyboard focus on content inside it. No-op on web.
*/
onShow?: () => void;
};
export function ActionFeedbackModal({
open: openProp,
onOpenChange,
state,
loadingMessage,
successLabel,
successMessage,
errorMessage,
errorTitle = 'Error',
dismissLabel = 'Dismiss',
tagline,
floating = false,
elevation = 3,
testID,
overlay = 'blur',
closeOnOutsidePress = true,
onShow,
}: ActionFeedbackModalProps) {
const isOpen = openProp ?? false;
const isDismissible = state === 'error';
const canDismiss = isDismissible && closeOnOutsidePress;
const liveRegion = state === 'error' ? 'assertive' : 'polite';
const reduced = useReducedMotion();
const announcement = announcementFor({ state, loadingMessage, successLabel, successMessage, errorTitle, errorMessage });
// iOS has no live-region equivalent — `accessibilityLiveRegion` is Android
// only, and VoiceOver does not re-read a subtree that changed under it. So the
// outcome is pushed explicitly there. On Android the live region below already
// fires, and on web `announceForAccessibility` is a no-op in react-native-web,
// so both would otherwise double-announce.
// biome-ignore lint/plugin: a screen-reader announcement is an imperative side effect keyed on the resolved state — there is nothing to derive.
useEffect(() => {
if (Platform.OS !== 'ios' || !isOpen || state === 'loading' || !announcement) return;
AccessibilityInfo.announceForAccessibility(announcement);
}, [isOpen, state, announcement]);
const handleClose = useCallback(() => {
onOpenChange?.(false);
}, [onOpenChange]);
// Auto-close after success.
// biome-ignore lint/plugin: timer side-effect cannot be expressed as derived state — fires once when success lands, cleans up on unmount
useEffect(() => {
if (isOpen && state === 'success') {
const timer = setTimeout(handleClose, SUCCESS_AUTO_CLOSE_MS);
return () => clearTimeout(timer);
}
}, [isOpen, state, handleClose]);
const handleBackdropPress = useCallback(() => {
if (closeOnOutsidePress && isDismissible) handleClose();
}, [closeOnOutsidePress, isDismissible, handleClose]);
const renderContent = ({ open: isAnimOpen, onExitComplete }: OverlayShellContext) => (
{isAnimOpen ? (
{/* One persistent live region rather than per-state ones: aria-live
only announces mutations *inside* a region that already existed,
and each state block mounts fresh. This wrapper outlives the
swaps, so loading → success/error is announced. Errors interrupt
(`assertive`); progress and success wait their turn. */}
) : null}
);
return (
{renderContent}
);
}
export type { ActionFeedbackState } from './action-feedback-motion';