Files
RN_Template/RN_TEMPLATE/app/components/Button.tsx
2026-02-05 13:16:05 +08:00

350 lines
9.5 KiB
TypeScript

import { ComponentType, useEffect, useRef } from "react"
import {
Animated,
Pressable,
PressableProps,
PressableStateCallbackType,
StyleProp,
TextStyle,
View,
ViewStyle,
} from "react-native"
import { useAppTheme } from "@/theme/context"
import { $styles } from "@/theme/styles"
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
import { s, fs } from "@/utils/responsive"
import { Text, TextProps } from "./Text"
/**
* Three-dot loading animation component
*/
function LoadingDots({ color }: { color: string }) {
const dot1 = useRef(new Animated.Value(0)).current
const dot2 = useRef(new Animated.Value(0)).current
const dot3 = useRef(new Animated.Value(0)).current
useEffect(() => {
const animateDot = (dot: Animated.Value, delay: number) => {
return Animated.loop(
Animated.sequence([
Animated.delay(delay),
Animated.timing(dot, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(dot, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]),
)
}
const animation = Animated.parallel([
animateDot(dot1, 0),
animateDot(dot2, 150),
animateDot(dot3, 300),
])
animation.start()
return () => animation.stop()
}, [dot1, dot2, dot3])
const dotStyle = (animatedValue: Animated.Value) => ({
width: s(8),
height: s(8),
borderRadius: s(4),
backgroundColor: color,
marginHorizontal: 3,
opacity: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
}),
transform: [
{
scale: animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 1.2],
}),
},
],
})
return (
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "center" }}>
<Animated.View style={dotStyle(dot1)} />
<Animated.View style={dotStyle(dot2)} />
<Animated.View style={dotStyle(dot3)} />
</View>
)
}
type Presets = "default" | "filled" | "reversed" | "link"
export interface ButtonAccessoryProps {
style: StyleProp<any>
pressableState: PressableStateCallbackType
disabled?: boolean
}
export interface ButtonProps extends PressableProps {
/**
* Text which is looked up via i18n.
*/
tx?: TextProps["tx"]
/**
* The text to display if not using `tx` or nested components.
*/
text?: TextProps["text"]
/**
* Optional options to pass to i18n. Useful for interpolation
* as well as explicitly setting locale or translation fallbacks.
*/
txOptions?: TextProps["txOptions"]
/**
* An optional style override useful for padding & margin.
*/
style?: StyleProp<ViewStyle>
/**
* An optional style override for the "pressed" state.
*/
pressedStyle?: StyleProp<ViewStyle>
/**
* An optional style override for the button text.
*/
textStyle?: StyleProp<TextStyle>
/**
* An optional style override for the button text when in the "pressed" state.
*/
pressedTextStyle?: StyleProp<TextStyle>
/**
* An optional style override for the button text when in the "disabled" state.
*/
disabledTextStyle?: StyleProp<TextStyle>
/**
* One of the different types of button presets.
*/
preset?: Presets
/**
* An optional component to render on the right side of the text.
* Example: `RightAccessory={(props) => <View {...props} />}`
*/
RightAccessory?: ComponentType<ButtonAccessoryProps>
/**
* An optional component to render on the left side of the text.
* Example: `LeftAccessory={(props) => <View {...props} />}`
*/
LeftAccessory?: ComponentType<ButtonAccessoryProps>
/**
* Children components.
*/
children?: React.ReactNode
/**
* disabled prop, accessed directly for declarative styling reasons.
* https://reactnative.dev/docs/pressable#disabled
*/
disabled?: boolean
/**
* An optional style override for the disabled state
*/
disabledStyle?: StyleProp<ViewStyle>
/**
* Show loading state with animated dots
*/
loading?: boolean
}
/**
* A component that allows users to take actions and make choices.
* Wraps the Text component with a Pressable component.
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Button/}
* @param {ButtonProps} props - The props for the `Button` component.
* @returns {JSX.Element} The rendered `Button` component.
* @example
* <Button
* tx="common:ok"
* style={styles.button}
* textStyle={styles.buttonText}
* onPress={handleButtonPress}
* />
*/
export function Button(props: ButtonProps) {
const {
tx,
text,
txOptions,
style: $viewStyleOverride,
pressedStyle: $pressedViewStyleOverride,
textStyle: $textStyleOverride,
pressedTextStyle: $pressedTextStyleOverride,
disabledTextStyle: $disabledTextStyleOverride,
children,
RightAccessory,
LeftAccessory,
disabled,
disabledStyle: $disabledViewStyleOverride,
loading,
...rest
} = props
const {
themed,
theme: { colors },
} = useAppTheme()
const preset: Presets = props.preset ?? "default"
// Get loading dots color based on preset
const loadingDotsColor =
preset === "reversed" ? colors.palette.neutral100 : colors.palette.neutral800
/**
* @param {PressableStateCallbackType} root0 - The root object containing the pressed state.
* @param {boolean} root0.pressed - The pressed state.
* @returns {StyleProp<ViewStyle>} The view style based on the pressed state.
*/
function $viewStyle({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> {
return [
themed($viewPresets[preset]),
$viewStyleOverride,
!!pressed && themed([$pressedViewPresets[preset], $pressedViewStyleOverride]),
!!disabled && $disabledViewStyleOverride,
]
}
/**
* @param {PressableStateCallbackType} root0 - The root object containing the pressed state.
* @param {boolean} root0.pressed - The pressed state.
* @returns {StyleProp<TextStyle>} The text style based on the pressed state.
*/
function $textStyle({ pressed }: PressableStateCallbackType): StyleProp<TextStyle> {
return [
themed($textPresets[preset]),
$textStyleOverride,
!!pressed && themed([$pressedTextPresets[preset], $pressedTextStyleOverride]),
!!disabled && $disabledTextStyleOverride,
]
}
return (
<Pressable
style={$viewStyle}
accessibilityRole="button"
accessibilityState={{ disabled: !!disabled || !!loading }}
{...rest}
disabled={disabled || loading}
>
{(state) => (
<>
{!!LeftAccessory && !loading && (
<LeftAccessory style={$leftAccessoryStyle} pressableState={state} disabled={disabled} />
)}
{loading ? (
<LoadingDots color={loadingDotsColor} />
) : (
<Text tx={tx} text={text} txOptions={txOptions} style={$textStyle(state)}>
{children}
</Text>
)}
{!!RightAccessory && !loading && (
<RightAccessory
style={$rightAccessoryStyle}
pressableState={state}
disabled={disabled}
/>
)}
</>
)}
</Pressable>
)
}
const $baseViewStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
minHeight: s(56),
borderRadius: s(4),
justifyContent: "center",
alignItems: "center",
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm,
overflow: "hidden",
})
const $baseTextStyle: ThemedStyle<TextStyle> = ({ typography }) => ({
fontSize: fs(16),
lineHeight: fs(20),
fontFamily: typography.primary.medium,
textAlign: "center",
flexShrink: 1,
flexGrow: 0,
zIndex: 2,
})
const $rightAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginStart: spacing.xs,
zIndex: 1,
})
const $leftAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
marginEnd: spacing.xs,
zIndex: 1,
})
const $viewPresets: Record<Presets, ThemedStyleArray<ViewStyle>> = {
default: [
$styles.row,
$baseViewStyle,
({ colors }) => ({
borderWidth: 1,
borderColor: colors.palette.neutral400,
backgroundColor: colors.palette.neutral100,
}),
],
filled: [
$styles.row,
$baseViewStyle,
({ colors }) => ({ backgroundColor: colors.palette.neutral300 }),
],
reversed: [
$styles.row,
$baseViewStyle,
({ colors }) => ({ backgroundColor: colors.palette.neutral800 }),
],
link: [
$styles.row,
() => ({
minHeight: 0,
paddingVertical: 0,
paddingHorizontal: 0,
backgroundColor: "transparent",
borderWidth: 0,
justifyContent: "center",
alignItems: "center",
}),
],
}
const $textPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
default: [$baseTextStyle],
filled: [$baseTextStyle],
reversed: [$baseTextStyle, ({ colors }) => ({ color: colors.palette.neutral100 })],
link: [({ colors }) => ({ color: colors.tint, fontSize: fs(14) })],
}
const $pressedViewPresets: Record<Presets, ThemedStyle<ViewStyle>> = {
default: ({ colors }) => ({ backgroundColor: colors.palette.neutral200 }),
filled: ({ colors }) => ({ backgroundColor: colors.palette.neutral400 }),
reversed: ({ colors }) => ({ backgroundColor: colors.palette.neutral700 }),
link: () => ({ opacity: 0.7 }),
}
const $pressedTextPresets: Record<Presets, ThemedStyle<TextStyle>> = {
default: () => ({ opacity: 0.9 }),
filled: () => ({ opacity: 0.9 }),
reversed: () => ({ opacity: 0.9 }),
link: () => ({}),
}