350 lines
9.5 KiB
TypeScript
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: () => ({}),
|
|
}
|