import React, { forwardRef, useMemo, type ReactNode } from 'react'; import { Text, View, type TextProps, type ViewProps } from 'react-native'; import { Slot, dataAttributes, mergeDataAttributes } from '@cdx-ui/primitives'; import type { ForgeIcon } from '@cdx-ui/icons'; import { CheckCircle, EmergencyHome, Info, Warning } from '@cdx-ui/icons'; import { cn, ParentContext, useParentContext, useStyleContext } from '@cdx-ui/utils'; import { Icon, type IconProps } from '../Icon'; import { alertActionsVariants, alertBodyVariants, alertDescriptionVariants, alertIconVariants, alertRootVariants, alertTextVariants, alertTitleVariants, type AlertSeverity, type AlertVariantProps, } from './styles'; // ============================================================================= // CONTEXT // ============================================================================= const SCOPE = 'ALERT'; interface AlertContextValue { severity: AlertSeverity; } const useAlertStyleContext = (): AlertContextValue => { const ctx = useStyleContext(SCOPE) as { severity?: AlertSeverity }; return { severity: ctx?.severity ?? 'info' }; }; // Severity → default icon mapping const SEVERITY_ICONS: Record = { danger: EmergencyHome, warning: Warning, success: CheckCircle, info: Info, }; // ============================================================================= // ROOT // ============================================================================= export interface AlertProps extends ViewProps, AlertVariantProps { /** * Replace the root element with the consumer's child element via `Slot`. * When `true`, children are rendered as-is (no automatic icon/text/actions * layout splitting). Severity context is still provided so sub-components * receive the correct styles. */ asChild?: boolean; /** Severity level that controls background colour, icon, and live-region urgency. */ severity?: AlertSeverity; className?: string; /** * Sub-components (`Alert.Icon`, `Alert.Title`, `Alert.Description`, * `Alert.Actions`) must be rendered as **direct** children — wrapping them * in HOCs or `memo()` will break the automatic layout splitting. */ children?: ReactNode; } const AlertRoot = forwardRef( ({ asChild, severity = 'info', className, style, children, ...props }, ref) => { const isAssertive = severity === 'danger' || severity === 'warning'; const parent = useParentContext(); const ctx = useMemo(() => ({ ...parent, [SCOPE]: { severity } }), [parent, severity]); const rootClassName = cn(alertRootVariants({ severity }), className); const role = isAssertive ? 'alert' : 'status'; const liveRegion = isAssertive ? 'assertive' : 'polite'; const dataProps = mergeDataAttributes(props, dataAttributes({ slot: 'alert' })); if (asChild) { return ( {children} ); } // Split children into three regions: // 1. Alert.Icon — rendered at inline-start (left) // 2. Alert.Actions — rendered at inline-end (right), beside the text // 3. Everything else (Title, Description) — text column, flex-1 // // Layout: [Icon] [flex-1 flex-row: [text flex-1] [Actions]] const childArray = React.Children.toArray(children); const iconChild = childArray.find( (child) => React.isValidElement(child) && (child as React.ReactElement).type === AlertIconComponent, ); const actionsChild = childArray.find( (child) => React.isValidElement(child) && (child as React.ReactElement).type === AlertActions, ); const textChildren = childArray.filter( (child) => !React.isValidElement(child) || ((child as React.ReactElement).type !== AlertIconComponent && (child as React.ReactElement).type !== AlertActions), ); const hasBody = textChildren.length > 0 || !!actionsChild; return ( {iconChild} {hasBody ? ( {textChildren.length > 0 ? ( {textChildren} ) : null} {actionsChild} ) : null} ); }, ); AlertRoot.displayName = 'Alert'; // ============================================================================= // ICON // ============================================================================= export interface AlertIconProps extends Omit { asChild?: boolean; /** * Override the auto-rendered severity icon. Omit to use the default icon for * the current severity (EmergencyHome, Warning, CheckCircle, or Info). * Not required when `asChild` is set. */ as?: ForgeIcon; } const AlertIconComponent = forwardRef( ({ asChild, as: asOverride, className, style, ...props }, ref) => { const { severity } = useAlertStyleContext(); const computedClassName = cn(alertIconVariants({ severity }), className); if (asChild) { return ( ); } return ( ); }, ); AlertIconComponent.displayName = 'Alert.Icon'; // ============================================================================= // TITLE // ============================================================================= export interface AlertTitleProps extends TextProps { asChild?: boolean; className?: string; children?: ReactNode; } const AlertTitle = forwardRef( ({ asChild, className, style, children, ...props }, ref) => { const { severity } = useAlertStyleContext(); const computedClassName = cn(alertTitleVariants({ severity }), className); const Comp = asChild ? Slot : Text; return ( {children} ); }, ); AlertTitle.displayName = 'Alert.Title'; // ============================================================================= // DESCRIPTION // ============================================================================= export interface AlertDescriptionProps extends TextProps { asChild?: boolean; className?: string; children?: ReactNode; } const AlertDescription = forwardRef( ({ asChild, className, style, children, ...props }, ref) => { const { severity } = useAlertStyleContext(); const computedClassName = cn(alertDescriptionVariants({ severity }), className); const Comp = asChild ? Slot : Text; return ( {children} ); }, ); AlertDescription.displayName = 'Alert.Description'; // ============================================================================= // ACTIONS // ============================================================================= export interface AlertActionsProps extends ViewProps { /** * Replace the wrapper element with the consumer's child via `Slot`. * **Note:** when `true`, automatic `color={severity}` injection on * direct `Button`/`IconButton` children is bypassed — the consumer * must set `color` explicitly. */ asChild?: boolean; className?: string; children?: ReactNode; } const AlertActions = forwardRef( ({ asChild, className, style, children, ...props }, ref) => { const { severity } = useAlertStyleContext(); const computedClassName = cn(alertActionsVariants(), className); const Comp = asChild ? Slot : View; // Auto-inject color={severity} on direct Button / IconButton children unless // the consumer already provides a color prop. const injectedChildren = React.Children.map(children, (child) => { if (!React.isValidElement<{ color?: unknown }>(child)) return child; if ('color' in child.props) return child; return React.cloneElement(child, { color: severity }); }); return ( {asChild ? children : injectedChildren} ); }, ); AlertActions.displayName = 'Alert.Actions'; // ============================================================================= // COMPOUND EXPORT // ============================================================================= type AlertCompoundComponent = typeof AlertRoot & { Icon: typeof AlertIconComponent; Title: typeof AlertTitle; Description: typeof AlertDescription; Actions: typeof AlertActions; }; export const Alert = Object.assign(AlertRoot, { Icon: AlertIconComponent, Title: AlertTitle, Description: AlertDescription, Actions: AlertActions, }) as AlertCompoundComponent; export type { AlertVariantProps, AlertSeverity };