All files / design-system/components Button.tsx

100% Statements 13/13
95.23% Branches 20/21
100% Functions 2/2
100% Lines 13/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128        12x                                                                                                         12x 21x     16x   5x         12x           12x                           12x   21x 21x     21x   21x                                                           12x  
import { forwardRef } from 'react';
import { View, StyleSheet } from 'react-native';
import { Button as TamaguiButton, Spinner, Text, styled, GetProps } from 'tamagui';
 
const StyledButton = styled(TamaguiButton, {
  name: 'Button',
  borderRadius: '$4',
  pressStyle: { scale: 0.97, opacity: 0.9 },
  animation: 'quick',
 
  variants: {
    variant: {
      primary: {
        backgroundColor: '$primary',
        hoverStyle: { backgroundColor: '$primaryDark' },
      },
      secondary: {
        backgroundColor: '$color3',
        hoverStyle: { backgroundColor: '$color4' },
      },
      outline: {
        backgroundColor: 'transparent',
        borderWidth: 1,
        borderColor: '$borderColor',
        hoverStyle: { backgroundColor: '$color2' },
      },
      ghost: {
        backgroundColor: 'transparent',
        hoverStyle: { backgroundColor: '$color2' },
      },
      danger: {
        backgroundColor: '$red',
        hoverStyle: { backgroundColor: '$red10' },
      },
    },
    size: {
      sm: { height: 42, paddingHorizontal: '$3' },
      md: { height: 52, paddingHorizontal: '$4' },
      lg: { height: 60, paddingHorizontal: '$5' },
    },
    fullWidth: {
      true: { width: '100%' },
    },
    rounded: {
      true: { borderRadius: 9999 },
    },
  } as const,
});
 
type StyledButtonProps = GetProps<typeof StyledButton>;
 
interface ButtonProps extends StyledButtonProps {
  loading?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
}
 
const getTextColor = (variant?: string) => {
  switch (variant) {
    case 'primary':
    case 'danger':
      return 'white';
    default:
      return '$color11';
  }
};
 
// 映射 size 到 Tamagui 字体大小 token
const FONT_SIZE_MAP: Record<string, number> = {
  sm: 14,
  md: 16,
  lg: 18,
};
 
const styles = StyleSheet.create({
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
  },
  iconLeft: {
    marginRight: 8,
  },
  iconRight: {
    marginLeft: 8,
  },
});
 
export const Button = forwardRef<React.ElementRef<typeof StyledButton>, ButtonProps>(
  ({ children, loading, disabled, leftIcon, rightIcon, variant, size, testID, ...props }, ref) => {
    const textColor = getTextColor(variant as string);
    const fontSize = FONT_SIZE_MAP[size as string] || 16;
 
    // 同时设置 testID 和 accessibilityLabel 以支持 Detox E2E 测试
    const a11yProps = testID ? { testID, accessibilityLabel: testID, nativeID: testID } : {};
 
    return (
      <StyledButton
        ref={ref}
        disabled={disabled || loading}
        opacity={disabled ? 0.5 : 1}
        variant={variant}
        size={size}
        {...a11yProps}
        {...props}
      >
        {loading ? (
          <Spinner size="small" color={textColor} />
        ) : (
          <View style={styles.row}>
            {leftIcon ? <View style={styles.iconLeft}>{leftIcon}</View> : null}
            {typeof children === 'string' ? (
              <Text color={textColor} fontSize={fontSize} fontWeight="600">
                {children}
              </Text>
            ) : children ? (
              <View>{children}</View>
            ) : null}
            {rightIcon ? <View style={styles.iconRight}>{rightIcon}</View> : null}
          </View>
        )}
      </StyledButton>
    );
  }
);
 
Button.displayName = 'Button';