import React, { forwardRef, useEffect, useMemo, type ReactElement, type ReactNode } from 'react'; import { ActivityIndicator, Text, View, type ActivityIndicatorProps, type PressableProps, type ViewProps, type TextProps, } from 'react-native'; import { ButtonRoot as ButtonRootPrimitive, Slot, dataAttributes, mergeDataAttributes, type IButtonProps, } from '@cdx-ui/primitives'; import { cn, flattenChildren, ParentContext, useParentContext, useStyleContext, } from '@cdx-ui/utils'; import { Icon, IconProps } from '../Icon'; import { positionToDataIcon, type IconSlotPosition } from '../../utils/positionToDataIcon'; import { type ButtonGroupVariantProps, type ButtonVariantProps, buttonGroupVariants, buttonIconVariants, buttonRootVariants, buttonSpinnerColorVariants, buttonSpinnerVariants, buttonTextVariants, } from './styles'; const SCOPE = 'BUTTON'; const useButtonStyleContext = () => useStyleContext(SCOPE) as ButtonVariantProps; // ============================================================================= // STYLED ROOT COMPONENT // ============================================================================= export type ButtonVariant = 'solid' | 'outline' | 'ghost'; /** @deprecated Use {@link ButtonVariant} (`'solid'`) instead. */ export type ButtonVariantStrong = 'strong'; let hasWarnedStrongButtonVariant = false; export interface ButtonProps extends PressableProps, IButtonProps { /** * Visual style variant. * @default 'solid' * @remarks `'solid' | 'outline' | 'ghost'` * @fumadocsType `'solid' | 'outline' | 'ghost'` */ variant?: | ButtonVariant /** * @deprecated Use `'solid'` instead. */ | 'strong'; /** * Color theme applied to the button surface and content. * @default 'action' */ color?: 'action' | 'danger' | 'warning' | 'success' | 'info'; /** * Height and horizontal padding scale. * @default 'default' */ size?: 'default' | 'small'; /** * Stretch the button to fill the width of its container. * @default false */ fullWidth?: boolean; /** Additional Uniwind/Tailwind classes, merged after the button's own styles. */ className?: string; /** Button content — typically `Button.Label`, `Button.Icon`, and/or `Button.Spinner`. */ children?: ReactNode; } const ButtonRoot = forwardRef( ( { variant: variantProp = 'solid', color = 'action', size = 'default', fullWidth = false, className, children, style, ...props }, ref, ) => { // TODO: Remove this once the `strong` variant is removed. useEffect(() => { if (__DEV__ && variantProp === 'strong' && !hasWarnedStrongButtonVariant) { hasWarnedStrongButtonVariant = true; console.warn( '[Button] The `strong` variant is deprecated and will be removed in a future major release. Use `solid` instead.', ); } }, [variantProp]); const variant = variantProp === 'strong' ? 'solid' : variantProp; const computedClassName = cn( buttonRootVariants({ variant, color, size, fullWidth }), className, ); const parent = useParentContext(); const ctx = useMemo( () => ({ ...parent, [SCOPE]: { variant, color, size, fullWidth } }), [parent, variant, color, size, fullWidth], ); return ( {children} ); }, ); ButtonRoot.displayName = 'Button'; // ============================================================================= // STYLED LABEL COMPONENT // ============================================================================= export interface ButtonLabelProps extends TextProps { /** Render the consumer's child element in place of the default `Text` host. */ asChild?: boolean; /** Additional Uniwind/Tailwind classes, merged after the label's own styles. */ className?: string; /** Label content (or a custom element when `asChild` is set). */ children?: ReactNode; } const ButtonLabel = forwardRef( ({ asChild, className, children, style, ...props }, ref) => { const { variant, color, size } = useButtonStyleContext(); const computedClassName = cn(buttonTextVariants({ variant, color, size }), className); const Comp = asChild ? Slot : Text; return ( {children} ); }, ); ButtonLabel.displayName = 'Button.Label'; // ============================================================================= // STYLED GROUP COMPONENT // ============================================================================= // TODO: Use ButtonGroupVariantProps export interface ButtonGroupProps extends Omit { /** Render the consumer's child element in place of the default `View` host. */ asChild?: boolean; /** Additional Uniwind/Tailwind classes, merged after the group's own styles. */ className?: string; /** The buttons to group together. */ children: ReactElement | ReactElement[]; /** * Layout direction of the grouped buttons. * @default 'row' */ flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'; /** Disable every button in the group. */ isDisabled?: boolean; /** Remove the gap between buttons and merge their adjoining borders. */ isAttached?: boolean; /** Reverse the visual order of the buttons. */ reversed?: boolean; /** Alias of `reversed`. */ isReversed?: boolean; } const ButtonGroup = forwardRef( ( { asChild, className, children, style, flexDirection = 'row', isAttached, isDisabled, isReversed, reversed, ...props }, ref, ) => { const direction = flexDirection.includes('reverse') ? flexDirection === 'column-reverse' ? 'column' : 'row' : flexDirection; let childrenArray = React.Children.toArray(flattenChildren(children)); childrenArray = isReversed || reversed ? [...childrenArray].reverse() : childrenArray; const computedChildren = childrenArray.map((child: any, index: number) => { if (typeof child === 'string' || typeof child === 'number') { return child; } const attachedStyles: Record = {}; if (index === 0) { if (direction === 'column') { attachedStyles.borderBottomLeftRadius = 0; attachedStyles.borderBottomRightRadius = 0; } else { attachedStyles.borderTopRightRadius = 0; attachedStyles.borderBottomRightRadius = 0; } } else if (index === childrenArray.length - 1) { if (direction === 'column') { attachedStyles.borderTopLeftRadius = 0; attachedStyles.borderTopRightRadius = 0; } else { attachedStyles.borderTopLeftRadius = 0; attachedStyles.borderBottomLeftRadius = 0; } } else { attachedStyles.borderRadius = 0; } const childProps = { isDisabled, ...child.props, style: { ...(isAttached ? attachedStyles : {}), ...child.props.style, }, }; return ( {React.cloneElement(child, childProps)} ); }); const groupClassName = cn( buttonGroupVariants({ flexDirection, isAttached: !!isAttached }), className, ); const Comp = asChild ? Slot : View; const gapProp = isAttached ? { gap: 0 } : {}; return ( {computedChildren} ); }, ); ButtonGroup.displayName = 'Button.Group'; // ============================================================================= // STYLED ICON COMPONENT // ============================================================================= export interface ButtonIconProps extends Omit { /** Render the consumer's child element in place of the default icon host. */ asChild?: boolean; /** * Forge icon component to render. Required unless `asChild` is set, in which * case the consumer's substituted element is rendered instead. */ as?: IconProps['as']; /** * Position hint that emits `data-icon="inline-start"` or `data-icon="inline-end"`. * CVA uses this attribute to apply leading/trailing padding logically (RTL-aware). * Omit to apply the symmetric baseline gap. */ position?: IconSlotPosition; } const ButtonIcon = ({ asChild, className, style, as, position, ...props }: ButtonIconProps) => { const { variant, color, size } = useButtonStyleContext(); const computedClassName = cn(buttonIconVariants({ variant, color, size }), className); if (asChild) { return ( ); } if (!as) { return null; } return ( ); }; ButtonIcon.displayName = 'Button.Icon'; // ============================================================================= // STYLED SPINNER COMPONENT // ============================================================================= export interface ButtonSpinnerProps extends ActivityIndicatorProps { /** Render the consumer's child element in place of the default `ActivityIndicator` host. */ asChild?: boolean; /** Additional Uniwind/Tailwind classes for the spinner container. */ className?: string; /** Uniwind `accent-*` color class override for the spinner. */ colorClassName?: string; /** * Position hint that emits `data-icon="inline-start"` or `data-icon="inline-end"`. * CVA uses this attribute to apply leading/trailing padding logically (RTL-aware). * Omit to apply the symmetric baseline gap. */ position?: IconSlotPosition; } const ButtonSpinner = forwardRef( ( { asChild, className, colorClassName, style, position, accessibilityLabel = 'Loading', ...props }, ref, ) => { const { variant, color } = useButtonStyleContext(); const computedClassName = cn(buttonSpinnerVariants(), className); const computedColorClassName = cn( buttonSpinnerColorVariants({ variant, color }), colorClassName, ); const Comp = asChild ? Slot : ActivityIndicator; return ( ); }, ); ButtonSpinner.displayName = 'Button.Spinner'; type ButtonCompoundComponent = typeof ButtonRoot & { Label: typeof ButtonLabel; Group: typeof ButtonGroup; Icon: typeof ButtonIcon; Spinner: typeof ButtonSpinner; }; export const Button = Object.assign(ButtonRoot, { Label: ButtonLabel, Group: ButtonGroup, Icon: ButtonIcon, Spinner: ButtonSpinner, }) as ButtonCompoundComponent; export type { ButtonVariantProps, ButtonGroupVariantProps };