1import { lightTheme, darkTheme, borderRadius, shadows, palette } from '@expo/styleguide-native';
2import * as React from 'react';
3import { Text as RNText, Animated, useColorScheme, Pressable as RNPressable } from 'react-native';
4
5import { create } from './create-primitive';
6import { rounded, margin, padding, text } from './theme';
7
8const AnimatedPressable = Animated.createAnimatedComponent(RNPressable);
9
10const Text = create(RNText, {
11  base: {
12    fontWeight: '400',
13    color: lightTheme.text.default,
14    fontSize: 16,
15  },
16
17  props: {
18    accessibilityRole: 'text',
19  },
20
21  variants: {
22    ...text,
23
24    color: {
25      primary: { color: lightTheme.button.primary.foreground },
26      secondary: { color: lightTheme.button.secondary.foreground },
27      tertiary: { color: lightTheme.button.tertiary.foreground },
28      ghost: { color: lightTheme.button.ghost.foreground },
29      transparent: { color: lightTheme.button.transparent.foreground },
30      default: { color: lightTheme.text.default },
31    },
32  },
33
34  selectors: {
35    dark: {
36      base: {
37        color: darkTheme.text.default,
38      },
39
40      color: {
41        primary: { color: darkTheme.button.primary.foreground },
42        secondary: { color: darkTheme.button.secondary.foreground },
43        tertiary: { color: darkTheme.button.tertiary.foreground },
44        ghost: { color: darkTheme.button.ghost.foreground },
45        transparent: { color: darkTheme.button.transparent.foreground },
46        default: { color: darkTheme.text.default },
47      },
48    },
49  },
50});
51
52const Container = create(AnimatedPressable, {
53  base: {
54    overflow: 'hidden',
55    borderRadius: borderRadius.medium,
56  },
57
58  props: {
59    accessibilityRole: 'button',
60    android_disableSound: true,
61  },
62
63  variants: {
64    bg: {
65      default: { backgroundColor: lightTheme.background.default },
66      primary: { backgroundColor: lightTheme.button.primary.background },
67      secondary: { backgroundColor: lightTheme.button.secondary.background },
68      tertiary: { backgroundColor: lightTheme.button.tertiary.background },
69      ghost: { backgroundColor: lightTheme.button.ghost.background },
70      transparent: { backgroundColor: lightTheme.button.transparent.background },
71      disabled: { backgroundColor: lightTheme.status.default },
72    },
73
74    border: {
75      ghost: { borderColor: lightTheme.button.ghost.border, borderWidth: 1 },
76    },
77
78    shadow: {
79      button: shadows.button,
80    },
81
82    ...rounded,
83    ...padding,
84    ...margin,
85  },
86
87  selectors: {
88    dark: {
89      bg: {
90        default: { backgroundColor: darkTheme.background.default },
91        primary: { backgroundColor: darkTheme.button.primary.background },
92        secondary: { backgroundColor: darkTheme.button.secondary.background },
93        tertiary: { backgroundColor: darkTheme.button.tertiary.background },
94        ghost: { backgroundColor: darkTheme.button.ghost.background },
95        transparent: { backgroundColor: darkTheme.button.transparent.background },
96        disabled: { backgroundColor: darkTheme.status.default },
97      },
98    },
99  },
100});
101
102export const Button = {
103  Container,
104  ScaleOnPressContainer,
105  Text,
106};
107
108type ScalingPressableProps = {
109  minScale?: number;
110};
111
112type NoOptionals<T> = {
113  [P in keyof T]-?: T[P];
114};
115
116type ContainerProps = React.ComponentProps<typeof Container>;
117type ContainerBackgroundColors = NoOptionals<ContainerProps>['bg'];
118
119const lightHighlightColorMap: Record<ContainerBackgroundColors, string> = {
120  disabled: 'transparent',
121  default: lightTheme.background.secondary,
122  primary: lightTheme.background.tertiary,
123  secondary: lightTheme.background.quaternary,
124  tertiary: palette.light.gray[600],
125  ghost: lightTheme.background.tertiary,
126  transparent: lightTheme.background.secondary,
127};
128
129const darkHighlightColorMap: Record<ContainerBackgroundColors, string> = {
130  disabled: 'transparent',
131  default: darkTheme.background.secondary,
132  primary: darkTheme.background.tertiary,
133  secondary: darkTheme.background.quaternary,
134  tertiary: palette.dark.gray[600],
135  ghost: darkTheme.background.tertiary,
136  transparent: darkTheme.background.secondary,
137};
138
139const highlightColorMap = {
140  dark: darkHighlightColorMap,
141  light: lightHighlightColorMap,
142};
143
144function ScaleOnPressContainer({
145  minScale = 0.975,
146  ...props
147}: React.ComponentProps<typeof Container> & ScalingPressableProps) {
148  const theme = useColorScheme();
149  const animatedValue = React.useRef(new Animated.Value(0));
150  const [isPressing, setIsPressing] = React.useState(false);
151
152  const onPressIn = React.useCallback(() => {
153    setIsPressing(true);
154
155    Animated.spring(animatedValue.current, {
156      toValue: 1,
157      stiffness: 1000,
158      damping: 500,
159      mass: 3,
160      overshootClamping: true,
161      useNativeDriver: true,
162    }).start();
163  }, []);
164
165  const onPressOut = React.useCallback(() => {
166    setIsPressing(false);
167    Animated.spring(animatedValue.current, {
168      toValue: 0,
169      stiffness: 1000,
170      damping: 500,
171      mass: 3,
172      overshootClamping: true,
173      useNativeDriver: true,
174    }).start();
175  }, []);
176
177  const scale = animatedValue.current.interpolate({
178    inputRange: [0, 1],
179    outputRange: [1, minScale],
180  });
181
182  const scaleStyle = { transform: [{ scale }] };
183
184  let backgroundColor = 'transparent';
185
186  if (props.bg && isPressing && theme != null) {
187    backgroundColor = highlightColorMap[theme][props.bg];
188  }
189
190  const underlayStyle = {
191    backgroundColor,
192  };
193
194  return (
195    <Container onPressIn={onPressIn} onPressOut={onPressOut} {...props} style={[scaleStyle]}>
196      <Animated.View style={underlayStyle}>{props.children}</Animated.View>
197    </Container>
198  );
199}
200