import { XRegularIcon } from "alouette-icons/phosphor-icons/X"; import { type ReactNode, useId } from "react"; import { Platform, Pressable, Modal as RNModal, useWindowDimensions, } from "react-native"; import type { VariantProps } from "tailwind-variants"; import type { Accent } from "../../core/AlouetteConfig"; import { useReducedMotion } from "../../core/ReducedMotionContext"; import { tv } from "../../core/tv"; import { useScrollEndState } from "../../core/useScrollEndState"; import { IconButton } from "../actions/IconButton"; import { Icon, type SVGIconElement } from "../primitives/Icon"; import { ScrollView } from "../primitives/ScrollView"; import { Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { PortalAccentScope } from "./PortalAccentScope"; // Yoga only knows `relative` and `absolute` — `position: sticky` is a web-only // feature and the class compiles away on native. A component that pins an // element to the edge of a scroll box has to lay it out differently there. const supportsStickyPosition = Platform.OS === "web"; // The panel padding is split in half so the web scrollbar sits in a balanced // gutter: one half stays on the panel (outside the scroll box, between the panel // edge and the bar), the other moves into the scroll content container (inside // it, between the bar and the content). The header is outside the scroll box, so // it carries the inner half itself to stay aligned with the scrolling content; // the footer carries the bottom half, which the scroll content drops (pb-0). const modalVariants = tv({ slots: { // w-full so the panel shrinks on small screens (the backdrop padding keeps a // margin); max-w caps it on wide viewports. panel: "w-full max-h-full", inset: "bg-highlight shadow-l", header: "flex-row items-center gap-xs", scrollContent: "", // `sticky` pins it to the bottom of the scroll box on web. The border is // transparent at rest so toggling it can't shift the layout. footer: "flex-row items-center justify-end gap-m sticky bottom-0 bg-highlight border-t border-transparent", }, variants: { size: { sm: { panel: "max-w-[360px]", inset: "rounded-sm p-sm", // As tall as the close button, shown or not, so the title never moves. header: "pl-xs min-h-control-sm", scrollContent: "p-xs", footer: "py-xs", }, md: { panel: "max-w-[520px]", inset: "rounded-sm p-m", header: "pl-m min-h-control-md", scrollContent: "p-m", footer: "py-sm", }, lg: { panel: "max-w-[720px]", inset: "rounded-md p-l", header: "pl-l min-h-control-md", scrollContent: "p-l", footer: "py-m", }, }, withFooter: { true: { scrollContent: "pb-0" }, }, // Native has no sticky positioning, so the footer sits below the scroll box // instead of inside it — outside the scroll content container it has to // carry that container's horizontal padding itself to stay aligned with the // body. detachedFooter: { true: {}, }, // Only while the footer overlaps scrolled-past content does it need a rule // separating it from the body; at the end of the scroll it sits in flow. stuck: { true: { footer: "border-border-muted" }, }, }, compoundVariants: [ { size: "sm", detachedFooter: true, class: { footer: "px-xs" } }, { size: "md", detachedFooter: true, class: { footer: "px-m" } }, { size: "lg", detachedFooter: true, class: { footer: "px-l" } }, ], defaultVariants: { size: "md" }, }); type ModalVariantProps = VariantProps; export interface ModalProps { visible: boolean; /** * Called when the user dismisses the modal — backdrop press, close button, the * Android back button, or the Escape key (web). */ onClose: () => void; children: ReactNode; /** * Heading rendered in the fixed header; also labels the dialog for assistive * tech. */ title: string; /** Accent-tinted icon rendered before the title in the header. */ icon?: SVGIconElement; /** Actions row rendered below the body (e.g. Cancel/Confirm buttons). */ footer?: ReactNode; accent?: Accent; size?: ModalVariantProps["size"]; /** Hide the header close button (the modal stays dismissible otherwise). */ hideCloseButton?: boolean; /** Accessible label for the close button. */ closeButtonAriaLabel?: string; /** * `alertdialog` for interruptions that require an explicit response * (destructive confirmation, errors). Defaults to `dialog`. */ role?: "alertdialog" | "dialog"; /** ID of the element describing the dialog (announced by assistive tech). */ "aria-describedby"?: string; testID?: string; } export interface ModalPanelProps extends Omit< ModalProps, "accent" | "visible" > { /** `true` under `Modal`; a panel rendered in flow is a non-modal dialog. */ "aria-modal"?: boolean; } /** * The dialog panel of `Modal` without its overlay, so a story can render it in * flow: a snapshot never captures the portal an open `Modal` renders into. */ export function ModalPanel({ onClose, children, icon, footer, size = "md", title, hideCloseButton = false, closeButtonAriaLabel = "Close", role = "dialog", "aria-modal": ariaModal, "aria-describedby": ariaDescribedby, testID, }: ModalPanelProps): ReactNode { const { height: windowHeight } = useWindowDimensions(); const titleId = useId(); const iconSize = size === "lg" ? "md" : size; const { isScrolledToEnd, scrollViewProps } = useScrollEndState(); const styles = modalVariants({ size, withFooter: footer !== undefined, stuck: footer !== undefined && !isScrolledToEnd, detachedFooter: !supportsStickyPosition, }); const footerElement = footer === undefined ? null : ( {footer} ); return ( {/* Header sits outside the scroll box: the title and the close button stay put while the body scrolls under them. */} {icon === undefined ? null : ( )} {title} {hideCloseButton ? null : ( } accent="neutral" variant="soft" size={iconSize} aria-label={closeButtonAriaLabel} onPress={onClose} /> )} {/* Pixel maxHeight (not a %) so the ScrollView sizes to its content and only scrolls once it exceeds ~70% of the screen; `shrink` lets it give way to the header and the detached footer when the panel hits the screen height. */} {children} {/* Web keeps the footer in the scroll content, where `sticky` pins it to the bottom edge and the body scrolls under it. */} {supportsStickyPosition ? footerElement : null} {/* Native has no sticky positioning: the footer sits after the scroll box instead. Same result — the ScrollView grows with its content up to its maxHeight, so the footer is right below short content and pinned under a full-height body. */} {supportsStickyPosition ? null : footerElement} ); } export function Modal({ visible, onClose, accent, ...panelProps }: ModalProps): ReactNode { const reducedMotion = useReducedMotion(); return ( {/* Backdrop is an absolutely-filled sibling behind the panel, so it catches outside clicks without wrapping the panel — clicks inside the panel never reach it. aria-hidden + focusable={false} keep this dismiss target out of the accessibility tree and the tab order. */} ); }