template_0205
This commit is contained in:
89
RN_TEMPLATE/app/components/AutoImage.tsx
Normal file
89
RN_TEMPLATE/app/components/AutoImage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useLayoutEffect, useState } from "react"
|
||||
import { Image, ImageProps, ImageURISource, Platform, PixelRatio } from "react-native"
|
||||
|
||||
export interface AutoImageProps extends ImageProps {
|
||||
/**
|
||||
* How wide should the image be?
|
||||
*/
|
||||
maxWidth?: number
|
||||
/**
|
||||
* How tall should the image be?
|
||||
*/
|
||||
maxHeight?: number
|
||||
headers?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that will return the scaled dimensions of an image based on the
|
||||
* provided dimensions' aspect ratio. If no desired dimensions are provided,
|
||||
* it will return the original dimensions of the remote image.
|
||||
*
|
||||
* How is this different from `resizeMode: 'contain'`? Firstly, you can
|
||||
* specify only one side's size (not both). Secondly, the image will scale to fit
|
||||
* the desired dimensions instead of just being contained within its image-container.
|
||||
* @param {number} remoteUri - The URI of the remote image.
|
||||
* @param {number} dimensions - The desired dimensions of the image. If not provided, the original dimensions will be returned.
|
||||
* @returns {[number, number]} - The scaled dimensions of the image.
|
||||
*/
|
||||
export function useAutoImage(
|
||||
remoteUri: string,
|
||||
headers?: {
|
||||
[key: string]: string
|
||||
},
|
||||
dimensions?: [maxWidth?: number, maxHeight?: number],
|
||||
): [width: number, height: number] {
|
||||
const [[remoteWidth, remoteHeight], setRemoteImageDimensions] = useState([0, 0])
|
||||
const remoteAspectRatio = remoteWidth / remoteHeight
|
||||
const [maxWidth, maxHeight] = dimensions ?? []
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!remoteUri) return
|
||||
|
||||
if (!headers) {
|
||||
Image.getSize(remoteUri, (w, h) => setRemoteImageDimensions([w, h]))
|
||||
} else {
|
||||
Image.getSizeWithHeaders(remoteUri, headers, (w, h) => setRemoteImageDimensions([w, h]))
|
||||
}
|
||||
}, [remoteUri, headers])
|
||||
|
||||
if (Number.isNaN(remoteAspectRatio)) return [0, 0]
|
||||
|
||||
if (maxWidth && maxHeight) {
|
||||
const aspectRatio = Math.min(maxWidth / remoteWidth, maxHeight / remoteHeight)
|
||||
return [
|
||||
PixelRatio.roundToNearestPixel(remoteWidth * aspectRatio),
|
||||
PixelRatio.roundToNearestPixel(remoteHeight * aspectRatio),
|
||||
]
|
||||
} else if (maxWidth) {
|
||||
return [maxWidth, PixelRatio.roundToNearestPixel(maxWidth / remoteAspectRatio)]
|
||||
} else if (maxHeight) {
|
||||
return [PixelRatio.roundToNearestPixel(maxHeight * remoteAspectRatio), maxHeight]
|
||||
} else {
|
||||
return [remoteWidth, remoteHeight]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An Image component that automatically sizes a remote or data-uri image.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/AutoImage/}
|
||||
* @param {AutoImageProps} props - The props for the `AutoImage` component.
|
||||
* @returns {JSX.Element} The rendered `AutoImage` component.
|
||||
*/
|
||||
export function AutoImage(props: AutoImageProps) {
|
||||
const { maxWidth, maxHeight, ...ImageProps } = props
|
||||
const source = props.source as ImageURISource
|
||||
const headers = source?.headers
|
||||
|
||||
const [width, height] = useAutoImage(
|
||||
Platform.select({
|
||||
web: (source?.uri as string) ?? (source as string),
|
||||
default: source?.uri as string,
|
||||
}),
|
||||
headers,
|
||||
[maxWidth, maxHeight],
|
||||
)
|
||||
|
||||
return <Image {...ImageProps} style={[{ width, height }, props.style]} />
|
||||
}
|
||||
71
RN_TEMPLATE/app/components/Avatar.tsx
Normal file
71
RN_TEMPLATE/app/components/Avatar.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { StyleProp, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { Image, ImageStyle } from "expo-image"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { Text } from "./Text"
|
||||
|
||||
export interface AvatarProps {
|
||||
/**
|
||||
* The image URI to display
|
||||
*/
|
||||
uri?: string | null
|
||||
/**
|
||||
* Fallback text to display when no image (usually first letter of name)
|
||||
*/
|
||||
fallback?: string
|
||||
/**
|
||||
* Size of the avatar (width and height)
|
||||
* @default 80
|
||||
*/
|
||||
size?: number
|
||||
/**
|
||||
* Optional style override for the container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable Avatar component with expo-image for efficient caching.
|
||||
* Displays an image or a fallback letter when no image is available.
|
||||
*/
|
||||
export function Avatar(props: AvatarProps) {
|
||||
const { uri, fallback = "U", size = s(80), style } = props
|
||||
const { themed, theme } = useAppTheme()
|
||||
|
||||
const borderRadius = size / 2
|
||||
const fontSize = size * 0.4
|
||||
|
||||
if (uri) {
|
||||
return (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={[$image, { width: size, height: size, borderRadius }, style as ImageStyle]}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
transition={200}
|
||||
placeholder={{ blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4" }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[themed($placeholder), { width: size, height: size, borderRadius }, style]}>
|
||||
<Text style={{ color: theme.colors.palette.neutral100, fontSize, fontWeight: "bold" }}>
|
||||
{fallback.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $image: ImageStyle = {
|
||||
backgroundColor: "#e1e1e1",
|
||||
}
|
||||
|
||||
const $placeholder: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
backgroundColor: colors.tint,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
})
|
||||
349
RN_TEMPLATE/app/components/Button.tsx
Normal file
349
RN_TEMPLATE/app/components/Button.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
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: () => ({}),
|
||||
}
|
||||
315
RN_TEMPLATE/app/components/Card.tsx
Normal file
315
RN_TEMPLATE/app/components/Card.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { ComponentType, Fragment, ReactElement } from "react"
|
||||
import {
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
type Presets = "default" | "reversed"
|
||||
|
||||
interface CardProps extends TouchableOpacityProps {
|
||||
/**
|
||||
* One of the different types of text presets.
|
||||
*/
|
||||
preset?: Presets
|
||||
/**
|
||||
* How the content should be aligned vertically. This is especially (but not exclusively) useful
|
||||
* when the card is a fixed height but the content is dynamic.
|
||||
*
|
||||
* `top` (default) - aligns all content to the top.
|
||||
* `center` - aligns all content to the center.
|
||||
* `space-between` - spreads out the content evenly.
|
||||
* `force-footer-bottom` - aligns all content to the top, but forces the footer to the bottom.
|
||||
*/
|
||||
verticalAlignment?: "top" | "center" | "space-between" | "force-footer-bottom"
|
||||
/**
|
||||
* Custom component added to the left of the card body.
|
||||
*/
|
||||
LeftComponent?: ReactElement
|
||||
/**
|
||||
* Custom component added to the right of the card body.
|
||||
*/
|
||||
RightComponent?: ReactElement
|
||||
/**
|
||||
* The heading text to display if not using `headingTx`.
|
||||
*/
|
||||
heading?: TextProps["text"]
|
||||
/**
|
||||
* Heading text which is looked up via i18n.
|
||||
*/
|
||||
headingTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional heading options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
headingTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for heading text.
|
||||
*/
|
||||
headingStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the heading Text component.
|
||||
*/
|
||||
HeadingTextProps?: TextProps
|
||||
/**
|
||||
* Custom heading component.
|
||||
* Overrides all other `heading*` props.
|
||||
*/
|
||||
HeadingComponent?: ReactElement
|
||||
/**
|
||||
* The content text to display if not using `contentTx`.
|
||||
*/
|
||||
content?: TextProps["text"]
|
||||
/**
|
||||
* Content text which is looked up via i18n.
|
||||
*/
|
||||
contentTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional content options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
contentTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for content text.
|
||||
*/
|
||||
contentStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the content Text component.
|
||||
*/
|
||||
ContentTextProps?: TextProps
|
||||
/**
|
||||
* Custom content component.
|
||||
* Overrides all other `content*` props.
|
||||
*/
|
||||
ContentComponent?: ReactElement
|
||||
/**
|
||||
* The footer text to display if not using `footerTx`.
|
||||
*/
|
||||
footer?: TextProps["text"]
|
||||
/**
|
||||
* Footer text which is looked up via i18n.
|
||||
*/
|
||||
footerTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional footer options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
footerTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for footer text.
|
||||
*/
|
||||
footerStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the footer Text component.
|
||||
*/
|
||||
FooterTextProps?: TextProps
|
||||
/**
|
||||
* Custom footer component.
|
||||
* Overrides all other `footer*` props.
|
||||
*/
|
||||
FooterComponent?: ReactElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards are useful for displaying related information in a contained way.
|
||||
* If a ListItem displays content horizontally, a Card can be used to display content vertically.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Card/}
|
||||
* @param {CardProps} props - The props for the `Card` component.
|
||||
* @returns {JSX.Element} The rendered `Card` component.
|
||||
*/
|
||||
export function Card(props: CardProps) {
|
||||
const {
|
||||
content,
|
||||
contentTx,
|
||||
contentTxOptions,
|
||||
footer,
|
||||
footerTx,
|
||||
footerTxOptions,
|
||||
heading,
|
||||
headingTx,
|
||||
headingTxOptions,
|
||||
ContentComponent,
|
||||
HeadingComponent,
|
||||
FooterComponent,
|
||||
LeftComponent,
|
||||
RightComponent,
|
||||
verticalAlignment = "top",
|
||||
style: $containerStyleOverride,
|
||||
contentStyle: $contentStyleOverride,
|
||||
headingStyle: $headingStyleOverride,
|
||||
footerStyle: $footerStyleOverride,
|
||||
ContentTextProps,
|
||||
HeadingTextProps,
|
||||
FooterTextProps,
|
||||
...WrapperProps
|
||||
} = props
|
||||
|
||||
const {
|
||||
themed,
|
||||
theme: { spacing },
|
||||
} = useAppTheme()
|
||||
|
||||
const preset: Presets = props.preset ?? "default"
|
||||
const isPressable = !!WrapperProps.onPress
|
||||
const isHeadingPresent = !!(HeadingComponent || heading || headingTx)
|
||||
const isContentPresent = !!(ContentComponent || content || contentTx)
|
||||
const isFooterPresent = !!(FooterComponent || footer || footerTx)
|
||||
|
||||
const Wrapper = (isPressable ? TouchableOpacity : View) as ComponentType<
|
||||
TouchableOpacityProps | ViewProps
|
||||
>
|
||||
const HeaderContentWrapper = verticalAlignment === "force-footer-bottom" ? View : Fragment
|
||||
|
||||
const $containerStyle: StyleProp<ViewStyle> = [
|
||||
themed($containerPresets[preset]),
|
||||
$containerStyleOverride,
|
||||
]
|
||||
const $headingStyle = [
|
||||
themed($headingPresets[preset]),
|
||||
(isFooterPresent || isContentPresent) && { marginBottom: spacing.xxxs },
|
||||
$headingStyleOverride,
|
||||
HeadingTextProps?.style,
|
||||
]
|
||||
const $contentStyle = [
|
||||
themed($contentPresets[preset]),
|
||||
isHeadingPresent && { marginTop: spacing.xxxs },
|
||||
isFooterPresent && { marginBottom: spacing.xxxs },
|
||||
$contentStyleOverride,
|
||||
ContentTextProps?.style,
|
||||
]
|
||||
const $footerStyle = [
|
||||
themed($footerPresets[preset]),
|
||||
(isHeadingPresent || isContentPresent) && { marginTop: spacing.xxxs },
|
||||
$footerStyleOverride,
|
||||
FooterTextProps?.style,
|
||||
]
|
||||
const $alignmentWrapperStyle = [
|
||||
$alignmentWrapper,
|
||||
{ justifyContent: $alignmentWrapperFlexOptions[verticalAlignment] },
|
||||
LeftComponent && { marginStart: spacing.md },
|
||||
RightComponent && { marginEnd: spacing.md },
|
||||
]
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
style={$containerStyle}
|
||||
activeOpacity={0.8}
|
||||
accessibilityRole={isPressable ? "button" : undefined}
|
||||
{...WrapperProps}
|
||||
>
|
||||
{LeftComponent}
|
||||
|
||||
<View style={$alignmentWrapperStyle}>
|
||||
<HeaderContentWrapper>
|
||||
{HeadingComponent ||
|
||||
(isHeadingPresent && (
|
||||
<Text
|
||||
weight="bold"
|
||||
text={heading}
|
||||
tx={headingTx}
|
||||
txOptions={headingTxOptions}
|
||||
{...HeadingTextProps}
|
||||
style={$headingStyle}
|
||||
/>
|
||||
))}
|
||||
|
||||
{ContentComponent ||
|
||||
(isContentPresent && (
|
||||
<Text
|
||||
weight="normal"
|
||||
text={content}
|
||||
tx={contentTx}
|
||||
txOptions={contentTxOptions}
|
||||
{...ContentTextProps}
|
||||
style={$contentStyle}
|
||||
/>
|
||||
))}
|
||||
</HeaderContentWrapper>
|
||||
|
||||
{FooterComponent ||
|
||||
(isFooterPresent && (
|
||||
<Text
|
||||
weight="normal"
|
||||
size="xs"
|
||||
text={footer}
|
||||
tx={footerTx}
|
||||
txOptions={footerTxOptions}
|
||||
{...FooterTextProps}
|
||||
style={$footerStyle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{RightComponent}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const $containerBase: ThemedStyle<ViewStyle> = (theme) => ({
|
||||
borderRadius: theme.spacing.md,
|
||||
padding: theme.spacing.xs,
|
||||
borderWidth: 1,
|
||||
shadowColor: theme.colors.palette.neutral800,
|
||||
shadowOffset: { width: 0, height: s(12) },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: s(12.81),
|
||||
elevation: 16,
|
||||
minHeight: s(96),
|
||||
})
|
||||
|
||||
const $alignmentWrapper: ViewStyle = {
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
}
|
||||
|
||||
const $alignmentWrapperFlexOptions = {
|
||||
"top": "flex-start",
|
||||
"center": "center",
|
||||
"space-between": "space-between",
|
||||
"force-footer-bottom": "space-between",
|
||||
} as const
|
||||
|
||||
const $containerPresets: Record<Presets, ThemedStyleArray<ViewStyle>> = {
|
||||
default: [
|
||||
$styles.row,
|
||||
$containerBase,
|
||||
(theme) => ({
|
||||
backgroundColor: theme.colors.palette.neutral100,
|
||||
borderColor: theme.colors.palette.neutral300,
|
||||
}),
|
||||
],
|
||||
reversed: [
|
||||
$styles.row,
|
||||
$containerBase,
|
||||
(theme) => ({
|
||||
backgroundColor: theme.colors.palette.neutral800,
|
||||
borderColor: theme.colors.palette.neutral500,
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
const $headingPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
|
||||
default: [],
|
||||
reversed: [(theme) => ({ color: theme.colors.palette.neutral100 })],
|
||||
}
|
||||
|
||||
const $contentPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
|
||||
default: [],
|
||||
reversed: [(theme) => ({ color: theme.colors.palette.neutral100 })],
|
||||
}
|
||||
|
||||
const $footerPresets: Record<Presets, ThemedStyleArray<TextStyle>> = {
|
||||
default: [],
|
||||
reversed: [(theme) => ({ color: theme.colors.palette.neutral100 })],
|
||||
}
|
||||
276
RN_TEMPLATE/app/components/Dialog.tsx
Normal file
276
RN_TEMPLATE/app/components/Dialog.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Modal as RNModal, Pressable, StyleProp, TextStyle, View, ViewStyle } from "react-native"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { Button } from "./Button"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
type Presets = "default" | "destructive"
|
||||
|
||||
export interface DialogProps {
|
||||
/**
|
||||
* Whether the dialog is visible.
|
||||
*/
|
||||
visible: boolean
|
||||
/**
|
||||
* Callback when the dialog is closed.
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Dialog preset style.
|
||||
* - `default`: Normal dialog with filled confirm button
|
||||
* - `destructive`: Dangerous action with red confirm button
|
||||
* @default "default"
|
||||
*/
|
||||
preset?: Presets
|
||||
/**
|
||||
* Title text which is looked up via i18n.
|
||||
*/
|
||||
titleTx?: TextProps["tx"]
|
||||
/**
|
||||
* Title text to display if not using `titleTx`.
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* Optional title options to pass to i18n.
|
||||
*/
|
||||
titleTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Message text which is looked up via i18n.
|
||||
*/
|
||||
messageTx?: TextProps["tx"]
|
||||
/**
|
||||
* Message text to display if not using `messageTx`.
|
||||
*/
|
||||
message?: string
|
||||
/**
|
||||
* Optional message options to pass to i18n.
|
||||
*/
|
||||
messageTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Confirm button text which is looked up via i18n.
|
||||
* @default "common:ok"
|
||||
*/
|
||||
confirmTx?: TextProps["tx"]
|
||||
/**
|
||||
* Confirm button text to display if not using `confirmTx`.
|
||||
*/
|
||||
confirmText?: string
|
||||
/**
|
||||
* Callback when confirm button is pressed.
|
||||
*/
|
||||
onConfirm?: () => void
|
||||
/**
|
||||
* Cancel button text which is looked up via i18n.
|
||||
* @default "common:cancel"
|
||||
*/
|
||||
cancelTx?: TextProps["tx"]
|
||||
/**
|
||||
* Cancel button text to display if not using `cancelTx`.
|
||||
*/
|
||||
cancelText?: string
|
||||
/**
|
||||
* Callback when cancel button is pressed. If not provided, cancel button will not be shown.
|
||||
*/
|
||||
onCancel?: () => void
|
||||
/**
|
||||
* Whether to show the cancel button.
|
||||
* @default true (if onCancel is provided)
|
||||
*/
|
||||
showCancel?: boolean
|
||||
/**
|
||||
* Whether the confirm button is in loading state.
|
||||
*/
|
||||
loading?: boolean
|
||||
/**
|
||||
* Whether to close the dialog when pressing the overlay.
|
||||
* @default false
|
||||
*/
|
||||
closeOnOverlayPress?: boolean
|
||||
/**
|
||||
* Style overrides for the content container.
|
||||
*/
|
||||
contentStyle?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple dialog component for confirmations and alerts.
|
||||
* Use this for simple confirm/cancel dialogs. For complex content, use Modal instead.
|
||||
* @param {DialogProps} props - The props for the `Dialog` component.
|
||||
* @returns {JSX.Element} The rendered `Dialog` component.
|
||||
* @example
|
||||
* // Simple alert
|
||||
* <Dialog
|
||||
* visible={showAlert}
|
||||
* onClose={() => setShowAlert(false)}
|
||||
* title="Success"
|
||||
* message="Operation completed successfully."
|
||||
* onConfirm={() => setShowAlert(false)}
|
||||
* />
|
||||
*
|
||||
* @example
|
||||
* // Destructive confirmation
|
||||
* <Dialog
|
||||
* visible={showConfirm}
|
||||
* onClose={() => setShowConfirm(false)}
|
||||
* preset="destructive"
|
||||
* titleTx="common:confirm"
|
||||
* messageTx="session:confirmLogoutMessage"
|
||||
* confirmTx="session:logout"
|
||||
* onConfirm={handleLogout}
|
||||
* onCancel={() => setShowConfirm(false)}
|
||||
* />
|
||||
*/
|
||||
export function Dialog(props: DialogProps) {
|
||||
const {
|
||||
visible,
|
||||
onClose,
|
||||
preset = "default",
|
||||
titleTx,
|
||||
title,
|
||||
titleTxOptions,
|
||||
messageTx,
|
||||
message,
|
||||
messageTxOptions,
|
||||
confirmTx = "common:ok",
|
||||
confirmText,
|
||||
onConfirm,
|
||||
cancelTx = "common:cancel",
|
||||
cancelText,
|
||||
onCancel,
|
||||
showCancel = !!onCancel,
|
||||
loading = false,
|
||||
closeOnOverlayPress = false,
|
||||
contentStyle: $contentStyleOverride,
|
||||
} = props
|
||||
|
||||
const { themed, theme } = useAppTheme()
|
||||
|
||||
const isDestructive = preset === "destructive"
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm?.()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleOverlayPress = () => {
|
||||
if (closeOnOverlayPress) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const $confirmButtonStyle: StyleProp<ViewStyle> = [
|
||||
themed($button),
|
||||
isDestructive && themed($destructiveButton),
|
||||
]
|
||||
|
||||
return (
|
||||
<RNModal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Pressable style={themed($overlay)} onPress={handleOverlayPress}>
|
||||
<Pressable
|
||||
style={[themed($contentContainer), $contentStyleOverride]}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Title */}
|
||||
{(titleTx || title) && (
|
||||
<Text
|
||||
preset="subheading"
|
||||
tx={titleTx}
|
||||
text={title}
|
||||
txOptions={titleTxOptions}
|
||||
style={themed($title)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
{(messageTx || message) && (
|
||||
<Text
|
||||
tx={messageTx}
|
||||
text={message}
|
||||
txOptions={messageTxOptions}
|
||||
style={themed($message)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<View style={themed($buttonContainer)}>
|
||||
{showCancel && (
|
||||
<Button
|
||||
preset="default"
|
||||
tx={cancelTx}
|
||||
text={cancelText}
|
||||
style={themed($button)}
|
||||
onPress={handleCancel}
|
||||
disabled={loading}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
preset={isDestructive ? "default" : "filled"}
|
||||
tx={confirmTx}
|
||||
text={confirmText}
|
||||
style={$confirmButtonStyle}
|
||||
textStyle={isDestructive ? { color: theme.colors.error } : undefined}
|
||||
onPress={handleConfirm}
|
||||
loading={loading}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</RNModal>
|
||||
)
|
||||
}
|
||||
|
||||
const $overlay: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||||
flex: 1,
|
||||
backgroundColor: colors.palette.overlay50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: spacing.lg,
|
||||
})
|
||||
|
||||
const $contentContainer: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: s(12),
|
||||
width: "100%",
|
||||
maxWidth: s(320),
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.lg,
|
||||
})
|
||||
|
||||
const $title: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.sm,
|
||||
})
|
||||
|
||||
const $message: ThemedStyle<TextStyle> = ({ colors, spacing }) => ({
|
||||
textAlign: "center",
|
||||
color: colors.textDim,
|
||||
marginBottom: spacing.lg,
|
||||
})
|
||||
|
||||
const $buttonContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
})
|
||||
|
||||
const $button: ThemedStyle<ViewStyle> = () => ({
|
||||
flex: 1,
|
||||
minWidth: s(100),
|
||||
})
|
||||
|
||||
const $destructiveButton: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
borderColor: colors.error,
|
||||
})
|
||||
249
RN_TEMPLATE/app/components/EmptyState.tsx
Normal file
249
RN_TEMPLATE/app/components/EmptyState.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { Image, ImageProps, ImageStyle, StyleProp, TextStyle, View, ViewStyle } from "react-native"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
|
||||
import { Button, ButtonProps } from "./Button"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
const sadFace = require("@assets/images/sad-face.png")
|
||||
|
||||
interface EmptyStateProps {
|
||||
/**
|
||||
* An optional prop that specifies the text/image set to use for the empty state.
|
||||
*/
|
||||
preset?: "generic"
|
||||
/**
|
||||
* Style override for the container.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* An Image source to be displayed above the heading.
|
||||
*/
|
||||
imageSource?: ImageProps["source"]
|
||||
/**
|
||||
* Style overrides for image.
|
||||
*/
|
||||
imageStyle?: StyleProp<ImageStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the Image component.
|
||||
*/
|
||||
ImageProps?: Omit<ImageProps, "source">
|
||||
/**
|
||||
* The heading text to display if not using `headingTx`.
|
||||
*/
|
||||
heading?: TextProps["text"]
|
||||
/**
|
||||
* Heading text which is looked up via i18n.
|
||||
*/
|
||||
headingTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional heading options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
headingTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for heading text.
|
||||
*/
|
||||
headingStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the heading Text component.
|
||||
*/
|
||||
HeadingTextProps?: TextProps
|
||||
/**
|
||||
* The content text to display if not using `contentTx`.
|
||||
*/
|
||||
content?: TextProps["text"]
|
||||
/**
|
||||
* Content text which is looked up via i18n.
|
||||
*/
|
||||
contentTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional content options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
contentTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for content text.
|
||||
*/
|
||||
contentStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the content Text component.
|
||||
*/
|
||||
ContentTextProps?: TextProps
|
||||
/**
|
||||
* The button text to display if not using `buttonTx`.
|
||||
*/
|
||||
button?: TextProps["text"]
|
||||
/**
|
||||
* Button text which is looked up via i18n.
|
||||
*/
|
||||
buttonTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional button options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
buttonTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for button.
|
||||
*/
|
||||
buttonStyle?: ButtonProps["style"]
|
||||
/**
|
||||
* Style overrides for button text.
|
||||
*/
|
||||
buttonTextStyle?: ButtonProps["textStyle"]
|
||||
/**
|
||||
* Called when the button is pressed.
|
||||
*/
|
||||
buttonOnPress?: ButtonProps["onPress"]
|
||||
/**
|
||||
* Pass any additional props directly to the Button component.
|
||||
*/
|
||||
ButtonProps?: ButtonProps
|
||||
}
|
||||
|
||||
interface EmptyStatePresetItem {
|
||||
imageSource: ImageProps["source"]
|
||||
heading: TextProps["text"]
|
||||
content: TextProps["text"]
|
||||
button: TextProps["text"]
|
||||
}
|
||||
|
||||
/**
|
||||
* A component to use when there is no data to display. It can be utilized to direct the user what to do next.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/EmptyState/}
|
||||
* @param {EmptyStateProps} props - The props for the `EmptyState` component.
|
||||
* @returns {JSX.Element} The rendered `EmptyState` component.
|
||||
*/
|
||||
export function EmptyState(props: EmptyStateProps) {
|
||||
const {
|
||||
theme,
|
||||
themed,
|
||||
theme: { spacing },
|
||||
} = useAppTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const EmptyStatePresets = {
|
||||
generic: {
|
||||
imageSource: sadFace,
|
||||
heading: t("emptyStateComponent:generic.heading"),
|
||||
content: t("emptyStateComponent:generic.content"),
|
||||
button: t("emptyStateComponent:generic.button"),
|
||||
} as EmptyStatePresetItem,
|
||||
} as const
|
||||
|
||||
const preset = EmptyStatePresets[props.preset ?? "generic"]
|
||||
|
||||
const {
|
||||
button = preset.button,
|
||||
buttonTx,
|
||||
buttonOnPress,
|
||||
buttonTxOptions,
|
||||
content = preset.content,
|
||||
contentTx,
|
||||
contentTxOptions,
|
||||
heading = preset.heading,
|
||||
headingTx,
|
||||
headingTxOptions,
|
||||
imageSource = preset.imageSource,
|
||||
style: $containerStyleOverride,
|
||||
buttonStyle: $buttonStyleOverride,
|
||||
buttonTextStyle: $buttonTextStyleOverride,
|
||||
contentStyle: $contentStyleOverride,
|
||||
headingStyle: $headingStyleOverride,
|
||||
imageStyle: $imageStyleOverride,
|
||||
ButtonProps,
|
||||
ContentTextProps,
|
||||
HeadingTextProps,
|
||||
ImageProps,
|
||||
} = props
|
||||
|
||||
const isImagePresent = !!imageSource
|
||||
const isHeadingPresent = !!(heading || headingTx)
|
||||
const isContentPresent = !!(content || contentTx)
|
||||
const isButtonPresent = !!(button || buttonTx)
|
||||
|
||||
const $containerStyles = [$containerStyleOverride]
|
||||
const $imageStyles = [
|
||||
$image,
|
||||
(isHeadingPresent || isContentPresent || isButtonPresent) && { marginBottom: spacing.xxxs },
|
||||
$imageStyleOverride,
|
||||
ImageProps?.style,
|
||||
]
|
||||
const $headingStyles = [
|
||||
themed($heading),
|
||||
isImagePresent && { marginTop: spacing.xxxs },
|
||||
(isContentPresent || isButtonPresent) && { marginBottom: spacing.xxxs },
|
||||
$headingStyleOverride,
|
||||
HeadingTextProps?.style,
|
||||
]
|
||||
const $contentStyles = [
|
||||
themed($content),
|
||||
(isImagePresent || isHeadingPresent) && { marginTop: spacing.xxxs },
|
||||
isButtonPresent && { marginBottom: spacing.xxxs },
|
||||
$contentStyleOverride,
|
||||
ContentTextProps?.style,
|
||||
]
|
||||
const $buttonStyles = [
|
||||
(isImagePresent || isHeadingPresent || isContentPresent) && { marginTop: spacing.xl },
|
||||
$buttonStyleOverride,
|
||||
ButtonProps?.style,
|
||||
]
|
||||
|
||||
return (
|
||||
<View style={$containerStyles}>
|
||||
{isImagePresent && (
|
||||
<Image
|
||||
source={imageSource}
|
||||
{...ImageProps}
|
||||
style={$imageStyles}
|
||||
tintColor={theme.colors.palette.neutral900}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isHeadingPresent && (
|
||||
<Text
|
||||
preset="subheading"
|
||||
text={heading}
|
||||
tx={headingTx}
|
||||
txOptions={headingTxOptions}
|
||||
{...HeadingTextProps}
|
||||
style={$headingStyles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isContentPresent && (
|
||||
<Text
|
||||
text={content}
|
||||
tx={contentTx}
|
||||
txOptions={contentTxOptions}
|
||||
{...ContentTextProps}
|
||||
style={$contentStyles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isButtonPresent && (
|
||||
<Button
|
||||
onPress={buttonOnPress}
|
||||
text={button}
|
||||
tx={buttonTx}
|
||||
txOptions={buttonTxOptions}
|
||||
textStyle={$buttonTextStyleOverride}
|
||||
{...ButtonProps}
|
||||
style={$buttonStyles}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $image: ImageStyle = { alignSelf: "center" }
|
||||
const $heading: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
textAlign: "center",
|
||||
paddingHorizontal: spacing.lg,
|
||||
})
|
||||
const $content: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
textAlign: "center",
|
||||
paddingHorizontal: spacing.lg,
|
||||
})
|
||||
337
RN_TEMPLATE/app/components/Header.tsx
Normal file
337
RN_TEMPLATE/app/components/Header.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { ReactElement } from "react"
|
||||
import {
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { isRTL } from "@/i18n"
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
import { ExtendedEdge, useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
|
||||
|
||||
import { IconTypes, PressableIcon } from "./Icon"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
export interface HeaderProps {
|
||||
/**
|
||||
* The layout of the title relative to the action components.
|
||||
* - `center` will force the title to always be centered relative to the header. If the title or the action buttons are too long, the title will be cut off.
|
||||
* - `flex` will attempt to center the title relative to the action buttons. If the action buttons are different widths, the title will be off-center relative to the header.
|
||||
*/
|
||||
titleMode?: "center" | "flex"
|
||||
/**
|
||||
* Optional title style override.
|
||||
*/
|
||||
titleStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Optional outer title container style override.
|
||||
*/
|
||||
titleContainerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional inner header wrapper style override.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional outer header container style override.
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Background color
|
||||
*/
|
||||
backgroundColor?: string
|
||||
/**
|
||||
* Title text to display if not using `tx` or nested components.
|
||||
*/
|
||||
title?: TextProps["text"]
|
||||
/**
|
||||
* Title text which is looked up via i18n.
|
||||
*/
|
||||
titleTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
titleTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Icon that should appear on the left.
|
||||
* Can be used with `onLeftPress`.
|
||||
*/
|
||||
leftIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the left icon
|
||||
*/
|
||||
leftIconColor?: string
|
||||
/**
|
||||
* Left action text to display if not using `leftTx`.
|
||||
* Can be used with `onLeftPress`. Overrides `leftIcon`.
|
||||
*/
|
||||
leftText?: TextProps["text"]
|
||||
/**
|
||||
* Left action text text which is looked up via i18n.
|
||||
* Can be used with `onLeftPress`. Overrides `leftIcon`.
|
||||
*/
|
||||
leftTx?: TextProps["tx"]
|
||||
/**
|
||||
* Left action custom ReactElement if the built in action props don't suffice.
|
||||
* Overrides `leftIcon`, `leftTx` and `leftText`.
|
||||
*/
|
||||
LeftActionComponent?: ReactElement
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
leftTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* What happens when you press the left icon or text action.
|
||||
*/
|
||||
onLeftPress?: TouchableOpacityProps["onPress"]
|
||||
/**
|
||||
* Icon that should appear on the right.
|
||||
* Can be used with `onRightPress`.
|
||||
*/
|
||||
rightIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the right icon
|
||||
*/
|
||||
rightIconColor?: string
|
||||
/**
|
||||
* Right action text to display if not using `rightTx`.
|
||||
* Can be used with `onRightPress`. Overrides `rightIcon`.
|
||||
*/
|
||||
rightText?: TextProps["text"]
|
||||
/**
|
||||
* Right action text text which is looked up via i18n.
|
||||
* Can be used with `onRightPress`. Overrides `rightIcon`.
|
||||
*/
|
||||
rightTx?: TextProps["tx"]
|
||||
/**
|
||||
* Right action custom ReactElement if the built in action props don't suffice.
|
||||
* Overrides `rightIcon`, `rightTx` and `rightText`.
|
||||
*/
|
||||
RightActionComponent?: ReactElement
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
rightTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* What happens when you press the right icon or text action.
|
||||
*/
|
||||
onRightPress?: TouchableOpacityProps["onPress"]
|
||||
/**
|
||||
* Override the default edges for the safe area.
|
||||
*/
|
||||
safeAreaEdges?: ExtendedEdge[]
|
||||
}
|
||||
|
||||
interface HeaderActionProps {
|
||||
backgroundColor?: string
|
||||
icon?: IconTypes
|
||||
iconColor?: string
|
||||
text?: TextProps["text"]
|
||||
tx?: TextProps["tx"]
|
||||
txOptions?: TextProps["txOptions"]
|
||||
onPress?: TouchableOpacityProps["onPress"]
|
||||
ActionComponent?: ReactElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Header that appears on many screens. Will hold navigation buttons and screen title.
|
||||
* The Header is meant to be used with the `screenOptions.header` option on navigators, routes, or screen components via `navigation.setOptions({ header })`.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Header/}
|
||||
* @param {HeaderProps} props - The props for the `Header` component.
|
||||
* @returns {JSX.Element} The rendered `Header` component.
|
||||
*/
|
||||
export function Header(props: HeaderProps) {
|
||||
const {
|
||||
theme: { colors },
|
||||
themed,
|
||||
} = useAppTheme()
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
backgroundColor = colors.background,
|
||||
LeftActionComponent,
|
||||
leftIcon,
|
||||
leftIconColor,
|
||||
leftText,
|
||||
leftTx,
|
||||
leftTxOptions,
|
||||
onLeftPress,
|
||||
onRightPress,
|
||||
RightActionComponent,
|
||||
rightIcon,
|
||||
rightIconColor,
|
||||
rightText,
|
||||
rightTx,
|
||||
rightTxOptions,
|
||||
safeAreaEdges = ["top"],
|
||||
title,
|
||||
titleMode = "center",
|
||||
titleTx,
|
||||
titleTxOptions,
|
||||
titleContainerStyle: $titleContainerStyleOverride,
|
||||
style: $styleOverride,
|
||||
titleStyle: $titleStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
} = props
|
||||
|
||||
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
|
||||
|
||||
const titleContent = titleTx ? t(titleTx, titleTxOptions) : title
|
||||
|
||||
return (
|
||||
<View style={[$container, $containerInsets, { backgroundColor }, $containerStyleOverride]}>
|
||||
<View style={[$styles.row, $wrapper, $styleOverride]}>
|
||||
<HeaderAction
|
||||
tx={leftTx}
|
||||
text={leftText}
|
||||
icon={leftIcon}
|
||||
iconColor={leftIconColor}
|
||||
onPress={onLeftPress}
|
||||
txOptions={leftTxOptions}
|
||||
backgroundColor={backgroundColor}
|
||||
ActionComponent={LeftActionComponent}
|
||||
/>
|
||||
|
||||
{!!titleContent && (
|
||||
<View
|
||||
style={[
|
||||
$titleWrapperPointerEvents,
|
||||
titleMode === "center" && themed($titleWrapperCenter),
|
||||
titleMode === "flex" && $titleWrapperFlex,
|
||||
$titleContainerStyleOverride,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
weight="medium"
|
||||
size="md"
|
||||
text={titleContent}
|
||||
style={[$title, $titleStyleOverride]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<HeaderAction
|
||||
tx={rightTx}
|
||||
text={rightText}
|
||||
icon={rightIcon}
|
||||
iconColor={rightIconColor}
|
||||
onPress={onRightPress}
|
||||
txOptions={rightTxOptions}
|
||||
backgroundColor={backgroundColor}
|
||||
ActionComponent={RightActionComponent}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HeaderActionProps} props - The props for the `HeaderAction` component.
|
||||
* @returns {JSX.Element} The rendered `HeaderAction` component.
|
||||
*/
|
||||
function HeaderAction(props: HeaderActionProps) {
|
||||
const { backgroundColor, icon, text, tx, txOptions, onPress, ActionComponent, iconColor } = props
|
||||
const { themed } = useAppTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const content = tx ? t(tx, txOptions) : text
|
||||
|
||||
if (ActionComponent) return ActionComponent
|
||||
|
||||
if (content) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={themed([$actionTextContainer, { backgroundColor }])}
|
||||
onPress={onPress}
|
||||
disabled={!onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text weight="medium" size="md" text={content} style={themed($actionText)} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
return (
|
||||
<PressableIcon
|
||||
size={s(24)}
|
||||
icon={icon}
|
||||
color={iconColor}
|
||||
onPress={onPress}
|
||||
containerStyle={[
|
||||
themed([$actionIconContainer, { backgroundColor }]),
|
||||
isRTL ? { transform: [{ rotate: "180deg" }] } : {},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <View style={[$actionFillerContainer, { backgroundColor }]} />
|
||||
}
|
||||
|
||||
const $wrapper: ViewStyle = {
|
||||
height: s(56),
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}
|
||||
|
||||
const $container: ViewStyle = {
|
||||
width: "100%",
|
||||
}
|
||||
|
||||
const $title: TextStyle = {
|
||||
textAlign: "center",
|
||||
}
|
||||
|
||||
const $actionTextContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
flexGrow: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
paddingHorizontal: spacing.md,
|
||||
zIndex: 2,
|
||||
})
|
||||
|
||||
const $actionText: ThemedStyle<TextStyle> = ({ colors }) => ({
|
||||
color: colors.tint,
|
||||
})
|
||||
|
||||
const $actionIconContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
flexGrow: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
paddingHorizontal: spacing.md,
|
||||
zIndex: 2,
|
||||
})
|
||||
|
||||
const $actionFillerContainer: ViewStyle = {
|
||||
width: s(16),
|
||||
}
|
||||
|
||||
const $titleWrapperPointerEvents: ViewStyle = {
|
||||
pointerEvents: "none",
|
||||
}
|
||||
|
||||
const $titleWrapperCenter: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
position: "absolute",
|
||||
paddingHorizontal: spacing.xxl,
|
||||
zIndex: 1,
|
||||
})
|
||||
|
||||
const $titleWrapperFlex: ViewStyle = {
|
||||
justifyContent: "center",
|
||||
flexGrow: 1,
|
||||
}
|
||||
198
RN_TEMPLATE/app/components/Icon.tsx
Normal file
198
RN_TEMPLATE/app/components/Icon.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { ComponentType } from "react"
|
||||
import {
|
||||
StyleProp,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart2,
|
||||
Bell,
|
||||
Bug,
|
||||
Camera,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Github,
|
||||
Globe,
|
||||
HandMetal,
|
||||
Heart,
|
||||
Home,
|
||||
Info,
|
||||
Layers,
|
||||
Lock,
|
||||
LogOut,
|
||||
Mail,
|
||||
MapPin,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Monitor,
|
||||
Moon,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Settings,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Sun,
|
||||
Tablet,
|
||||
Tag,
|
||||
User,
|
||||
Users,
|
||||
X,
|
||||
LucideProps,
|
||||
} from "lucide-react-native"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
// Map icon names to Lucide components
|
||||
const iconRegistry: Record<string, ComponentType<LucideProps>> = {
|
||||
back: ArrowLeft,
|
||||
barChart: BarChart2,
|
||||
bell: Bell,
|
||||
camera: Camera,
|
||||
caretLeft: ChevronLeft,
|
||||
caretRight: ChevronRight,
|
||||
check: Check,
|
||||
clap: HandMetal,
|
||||
community: Users,
|
||||
components: Layers,
|
||||
copy: Copy,
|
||||
creditCard: CreditCard,
|
||||
debug: Bug,
|
||||
fileText: FileText,
|
||||
github: Github,
|
||||
globe: Globe,
|
||||
heart: Heart,
|
||||
hidden: EyeOff,
|
||||
home: Home,
|
||||
info: Info,
|
||||
ladybug: Bug,
|
||||
lock: Lock,
|
||||
logout: LogOut,
|
||||
mail: Mail,
|
||||
menu: Menu,
|
||||
monitor: Monitor,
|
||||
moon: Moon,
|
||||
more: MoreHorizontal,
|
||||
pencil: Pencil,
|
||||
pin: MapPin,
|
||||
podcast: Mic,
|
||||
settings: Settings,
|
||||
shield: Shield,
|
||||
slack: MessageSquare,
|
||||
smartphone: Smartphone,
|
||||
sun: Sun,
|
||||
tablet: Tablet,
|
||||
tag: Tag,
|
||||
user: User,
|
||||
view: Eye,
|
||||
x: X,
|
||||
}
|
||||
|
||||
export type IconTypes = keyof typeof iconRegistry
|
||||
|
||||
type BaseIconProps = {
|
||||
/**
|
||||
* The name of the icon
|
||||
*/
|
||||
icon: IconTypes
|
||||
|
||||
/**
|
||||
* An optional tint color for the icon
|
||||
*/
|
||||
color?: string
|
||||
|
||||
/**
|
||||
* An optional size for the icon. If not provided, defaults to 24.
|
||||
*/
|
||||
size?: number
|
||||
|
||||
/**
|
||||
* Style overrides for the icon container
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
|
||||
/**
|
||||
* Stroke width for the icon (default: 2)
|
||||
*/
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
type PressableIconProps = Omit<TouchableOpacityProps, "style"> & BaseIconProps
|
||||
type IconProps = Omit<ViewProps, "style"> & BaseIconProps
|
||||
|
||||
/**
|
||||
* A component to render a registered icon.
|
||||
* It is wrapped in a <TouchableOpacity />
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Icon/}
|
||||
* @param {PressableIconProps} props - The props for the `PressableIcon` component.
|
||||
* @returns {JSX.Element} The rendered `PressableIcon` component.
|
||||
*/
|
||||
export function PressableIcon(props: PressableIconProps) {
|
||||
const {
|
||||
icon,
|
||||
color,
|
||||
size = s(24),
|
||||
containerStyle: $containerStyleOverride,
|
||||
strokeWidth = 2,
|
||||
...pressableProps
|
||||
} = props
|
||||
|
||||
const { theme } = useAppTheme()
|
||||
const IconComponent = iconRegistry[icon]
|
||||
|
||||
if (!IconComponent) {
|
||||
if (__DEV__) console.warn(`Icon "${icon}" not found in registry`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity {...pressableProps} style={$containerStyleOverride}>
|
||||
<IconComponent color={color ?? theme.colors.text} size={size} strokeWidth={strokeWidth} />
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A component to render a registered icon.
|
||||
* It is wrapped in a <View />, use `PressableIcon` if you want to react to input
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Icon/}
|
||||
* @param {IconProps} props - The props for the `Icon` component.
|
||||
* @returns {JSX.Element} The rendered `Icon` component.
|
||||
*/
|
||||
export function Icon(props: IconProps) {
|
||||
const {
|
||||
icon,
|
||||
color,
|
||||
size = s(24),
|
||||
containerStyle: $containerStyleOverride,
|
||||
strokeWidth = 2,
|
||||
...viewProps
|
||||
} = props
|
||||
|
||||
const { theme } = useAppTheme()
|
||||
const IconComponent = iconRegistry[icon]
|
||||
|
||||
if (!IconComponent) {
|
||||
if (__DEV__) console.warn(`Icon "${icon}" not found in registry`)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...viewProps} style={$containerStyleOverride}>
|
||||
<IconComponent color={color ?? theme.colors.text} size={size} strokeWidth={strokeWidth} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export { iconRegistry }
|
||||
257
RN_TEMPLATE/app/components/ListItem.tsx
Normal file
257
RN_TEMPLATE/app/components/ListItem.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { forwardRef, ReactElement } from "react"
|
||||
import {
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { Icon, IconTypes } from "./Icon"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
export interface ListItemProps extends TouchableOpacityProps {
|
||||
/**
|
||||
* How tall the list item should be.
|
||||
* Default: 56
|
||||
*/
|
||||
height?: number
|
||||
/**
|
||||
* Whether to show the top separator.
|
||||
* Default: false
|
||||
*/
|
||||
topSeparator?: boolean
|
||||
/**
|
||||
* Whether to show the bottom separator.
|
||||
* Default: false
|
||||
*/
|
||||
bottomSeparator?: boolean
|
||||
/**
|
||||
* Text to display if not using `tx` or nested components.
|
||||
*/
|
||||
text?: TextProps["text"]
|
||||
/**
|
||||
* Text which is looked up via i18n.
|
||||
*/
|
||||
tx?: TextProps["tx"]
|
||||
/**
|
||||
* Children components.
|
||||
*/
|
||||
children?: TextProps["children"]
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
txOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Optional text style override.
|
||||
*/
|
||||
textStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the Text component.
|
||||
*/
|
||||
TextProps?: TextProps
|
||||
/**
|
||||
* Optional View container style override.
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional TouchableOpacity style override.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Icon that should appear on the left.
|
||||
*/
|
||||
leftIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the left icon
|
||||
*/
|
||||
leftIconColor?: string
|
||||
/**
|
||||
* Icon that should appear on the right.
|
||||
*/
|
||||
rightIcon?: IconTypes
|
||||
/**
|
||||
* An optional tint color for the right icon
|
||||
*/
|
||||
rightIconColor?: string
|
||||
/**
|
||||
* Right action custom ReactElement.
|
||||
* Overrides `rightIcon`.
|
||||
*/
|
||||
RightComponent?: ReactElement
|
||||
/**
|
||||
* Left action custom ReactElement.
|
||||
* Overrides `leftIcon`.
|
||||
*/
|
||||
LeftComponent?: ReactElement
|
||||
}
|
||||
|
||||
interface ListItemActionProps {
|
||||
icon?: IconTypes
|
||||
iconColor?: string
|
||||
Component?: ReactElement
|
||||
size: number
|
||||
side: "left" | "right"
|
||||
}
|
||||
|
||||
/**
|
||||
* A styled row component that can be used in FlatList, SectionList, or by itself.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/ListItem/}
|
||||
* @param {ListItemProps} props - The props for the `ListItem` component.
|
||||
* @returns {JSX.Element} The rendered `ListItem` component.
|
||||
*/
|
||||
export const ListItem = forwardRef<View, ListItemProps>(function ListItem(
|
||||
props: ListItemProps,
|
||||
ref,
|
||||
) {
|
||||
const {
|
||||
bottomSeparator,
|
||||
children,
|
||||
height = s(56),
|
||||
LeftComponent,
|
||||
leftIcon,
|
||||
leftIconColor,
|
||||
RightComponent,
|
||||
rightIcon,
|
||||
rightIconColor,
|
||||
style,
|
||||
text,
|
||||
TextProps,
|
||||
topSeparator,
|
||||
tx,
|
||||
txOptions,
|
||||
textStyle: $textStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
...TouchableOpacityProps
|
||||
} = props
|
||||
const { themed } = useAppTheme()
|
||||
|
||||
const isTouchable =
|
||||
TouchableOpacityProps.onPress !== undefined ||
|
||||
TouchableOpacityProps.onPressIn !== undefined ||
|
||||
TouchableOpacityProps.onPressOut !== undefined ||
|
||||
TouchableOpacityProps.onLongPress !== undefined
|
||||
|
||||
const $textStyles = [$textStyle, $textStyleOverride, TextProps?.style]
|
||||
|
||||
const $containerStyles = [
|
||||
topSeparator && $separatorTop,
|
||||
bottomSeparator && $separatorBottom,
|
||||
$containerStyleOverride,
|
||||
]
|
||||
|
||||
const $touchableStyles = [$styles.row, $touchableStyle, { minHeight: height }, style]
|
||||
|
||||
const Wrapper = isTouchable ? TouchableOpacity : View
|
||||
|
||||
return (
|
||||
<View ref={ref} style={themed($containerStyles)}>
|
||||
<Wrapper {...TouchableOpacityProps} style={$touchableStyles}>
|
||||
<ListItemAction
|
||||
side="left"
|
||||
size={height}
|
||||
icon={leftIcon}
|
||||
iconColor={leftIconColor}
|
||||
Component={LeftComponent}
|
||||
/>
|
||||
|
||||
<Text {...TextProps} tx={tx} text={text} txOptions={txOptions} style={themed($textStyles)}>
|
||||
{children}
|
||||
</Text>
|
||||
|
||||
<ListItemAction
|
||||
side="right"
|
||||
size={height}
|
||||
icon={rightIcon}
|
||||
iconColor={rightIconColor}
|
||||
Component={RightComponent}
|
||||
/>
|
||||
</Wrapper>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {ListItemActionProps} props - The props for the `ListItemAction` component.
|
||||
* @returns {JSX.Element | null} The rendered `ListItemAction` component.
|
||||
*/
|
||||
function ListItemAction(props: ListItemActionProps) {
|
||||
const { icon, Component, iconColor, size, side } = props
|
||||
const { themed } = useAppTheme()
|
||||
|
||||
const $iconContainerStyles = [$iconContainer]
|
||||
|
||||
if (Component) {
|
||||
return (
|
||||
<View
|
||||
style={themed([
|
||||
$iconContainerStyles,
|
||||
side === "left" && $iconContainerLeft,
|
||||
side === "right" && $iconContainerRight,
|
||||
{ height: size },
|
||||
])}
|
||||
>
|
||||
{Component}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (icon !== undefined) {
|
||||
return (
|
||||
<Icon
|
||||
size={s(24)}
|
||||
icon={icon}
|
||||
color={iconColor}
|
||||
containerStyle={themed([
|
||||
$iconContainerStyles,
|
||||
side === "left" && $iconContainerLeft,
|
||||
side === "right" && $iconContainerRight,
|
||||
{ height: size },
|
||||
])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const $separatorTop: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.separator,
|
||||
})
|
||||
|
||||
const $separatorBottom: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.separator,
|
||||
})
|
||||
|
||||
const $textStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
paddingVertical: spacing.xs,
|
||||
alignSelf: "center",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
})
|
||||
|
||||
const $touchableStyle: ViewStyle = {
|
||||
alignItems: "flex-start",
|
||||
}
|
||||
|
||||
const $iconContainer: ViewStyle = {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
flexGrow: 0,
|
||||
}
|
||||
const $iconContainerLeft: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
marginEnd: spacing.md,
|
||||
})
|
||||
|
||||
const $iconContainerRight: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
marginStart: spacing.md,
|
||||
})
|
||||
386
RN_TEMPLATE/app/components/Modal.tsx
Normal file
386
RN_TEMPLATE/app/components/Modal.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import { ReactNode } from "react"
|
||||
import {
|
||||
Modal as RNModal,
|
||||
ModalProps as RNModalProps,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { Button, ButtonProps } from "./Button"
|
||||
import { Icon } from "./Icon"
|
||||
import { Text, TextProps } from "./Text"
|
||||
|
||||
type Presets = "center" | "bottom" | "fullscreen"
|
||||
|
||||
export interface ModalProps extends Omit<RNModalProps, "children"> {
|
||||
/**
|
||||
* Whether the modal is visible.
|
||||
*/
|
||||
visible: boolean
|
||||
/**
|
||||
* Callback when the modal is requested to close.
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Modal position preset.
|
||||
* - `center`: Centered dialog (default)
|
||||
* - `bottom`: Bottom sheet style, slides up from bottom
|
||||
* - `fullscreen`: Full screen modal
|
||||
* @default "center"
|
||||
*/
|
||||
preset?: Presets
|
||||
/**
|
||||
* Title text which is looked up via i18n.
|
||||
*/
|
||||
titleTx?: TextProps["tx"]
|
||||
/**
|
||||
* Title text to display if not using `titleTx`.
|
||||
*/
|
||||
title?: string
|
||||
/**
|
||||
* Optional title options to pass to i18n.
|
||||
*/
|
||||
titleTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for the title text.
|
||||
*/
|
||||
titleStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Whether to show the close button in the header.
|
||||
* @default true
|
||||
*/
|
||||
showCloseButton?: boolean
|
||||
/**
|
||||
* Whether to close the modal when pressing the overlay.
|
||||
* @default true
|
||||
*/
|
||||
closeOnOverlayPress?: boolean
|
||||
/**
|
||||
* The content to display inside the modal.
|
||||
*/
|
||||
children?: ReactNode
|
||||
/**
|
||||
* Style overrides for the overlay.
|
||||
*/
|
||||
overlayStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Style overrides for the content container.
|
||||
*/
|
||||
contentStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Props for the confirm button (right button).
|
||||
* If provided, the footer buttons will be shown.
|
||||
*/
|
||||
confirmButtonProps?: ButtonProps
|
||||
/**
|
||||
* Props for the cancel button (left button).
|
||||
* If provided along with confirmButtonProps, both buttons will be shown.
|
||||
*/
|
||||
cancelButtonProps?: ButtonProps
|
||||
/**
|
||||
* Custom footer component.
|
||||
* Overrides confirmButtonProps and cancelButtonProps.
|
||||
*/
|
||||
FooterComponent?: ReactNode
|
||||
/**
|
||||
* Animation type for the modal.
|
||||
* Defaults based on preset: "fade" for center, "slide" for bottom/fullscreen
|
||||
*/
|
||||
animationType?: "none" | "slide" | "fade"
|
||||
/**
|
||||
* Maximum width of the modal content (only applies to "center" preset).
|
||||
* @default 400
|
||||
*/
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable modal component with overlay, title, close button, and footer actions.
|
||||
* Supports theming, internationalization, and multiple position presets.
|
||||
* @param {ModalProps} props - The props for the `Modal` component.
|
||||
* @returns {JSX.Element} The rendered `Modal` component.
|
||||
* @example
|
||||
* // Center modal (default)
|
||||
* <Modal
|
||||
* visible={isVisible}
|
||||
* onClose={() => setIsVisible(false)}
|
||||
* titleTx="common:confirm"
|
||||
* >
|
||||
* <Text tx="modal:content" />
|
||||
* </Modal>
|
||||
*
|
||||
* @example
|
||||
* // Bottom sheet modal
|
||||
* <Modal
|
||||
* visible={isVisible}
|
||||
* onClose={() => setIsVisible(false)}
|
||||
* preset="bottom"
|
||||
* titleTx="profile:editProfile"
|
||||
* >
|
||||
* <TextField ... />
|
||||
* </Modal>
|
||||
*/
|
||||
export function Modal(props: ModalProps) {
|
||||
const {
|
||||
visible,
|
||||
onClose,
|
||||
preset = "center",
|
||||
titleTx,
|
||||
title,
|
||||
titleTxOptions,
|
||||
titleStyle: $titleStyleOverride,
|
||||
showCloseButton = true,
|
||||
closeOnOverlayPress = true,
|
||||
children,
|
||||
overlayStyle: $overlayStyleOverride,
|
||||
contentStyle: $contentStyleOverride,
|
||||
confirmButtonProps,
|
||||
cancelButtonProps,
|
||||
FooterComponent,
|
||||
animationType,
|
||||
maxWidth = s(400),
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { themed, theme } = useAppTheme()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const hasTitleArea = !!(titleTx || title || showCloseButton)
|
||||
const hasFooter = !!(FooterComponent || confirmButtonProps)
|
||||
|
||||
// Determine animation type based on preset
|
||||
const resolvedAnimationType = animationType ?? (preset === "center" ? "fade" : "slide")
|
||||
|
||||
// Build overlay style based on preset
|
||||
const $overlayStyle: StyleProp<ViewStyle> = [
|
||||
themed($overlayBase),
|
||||
preset === "center" && themed($overlayCenter),
|
||||
preset === "bottom" && $overlayBottom,
|
||||
preset === "fullscreen" && $overlayFullscreen,
|
||||
$overlayStyleOverride,
|
||||
]
|
||||
|
||||
// Build content container style based on preset
|
||||
const $contentContainerStyle: StyleProp<ViewStyle> = [
|
||||
themed($contentBase),
|
||||
preset === "center" && [themed($contentCenter), { maxWidth }],
|
||||
preset === "bottom" && [themed($contentBottom), { paddingBottom: insets.bottom || s(24) }],
|
||||
preset === "fullscreen" && [
|
||||
themed($contentFullscreen),
|
||||
{ paddingTop: insets.top, paddingBottom: insets.bottom },
|
||||
],
|
||||
$contentStyleOverride,
|
||||
]
|
||||
|
||||
const $titleStyle: StyleProp<TextStyle> = [
|
||||
themed($titleBase),
|
||||
preset === "bottom" && $titleBottom,
|
||||
$titleStyleOverride,
|
||||
]
|
||||
|
||||
const renderHeader = () =>
|
||||
hasTitleArea ? (
|
||||
<View style={preset === "bottom" ? $headerBottom : $header}>
|
||||
{(titleTx || title) && (
|
||||
<Text
|
||||
preset="subheading"
|
||||
tx={titleTx}
|
||||
text={title}
|
||||
txOptions={titleTxOptions}
|
||||
style={$titleStyle}
|
||||
/>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={[themed($closeButton), preset === "bottom" && $closeButtonBottom]}
|
||||
hitSlop={{ top: s(10), bottom: s(10), left: s(10), right: s(10) }}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close"
|
||||
>
|
||||
<Icon icon="x" size={20} color={theme.colors.textDim} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : null
|
||||
|
||||
const renderFooter = () =>
|
||||
hasFooter ? (
|
||||
<View style={themed($footer)}>
|
||||
{FooterComponent ? (
|
||||
FooterComponent
|
||||
) : (
|
||||
<>
|
||||
{cancelButtonProps && (
|
||||
<Button preset="default" style={themed($footerButton)} {...cancelButtonProps} />
|
||||
)}
|
||||
{confirmButtonProps && (
|
||||
<Button preset="filled" style={themed($footerButton)} {...confirmButtonProps} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : null
|
||||
|
||||
// For bottom preset, wrap content in KeyboardAwareScrollView
|
||||
const renderContent = () => {
|
||||
if (preset === "bottom") {
|
||||
return (
|
||||
<KeyboardAwareScrollView
|
||||
bottomOffset={Platform.OS === "ios" ? 20 : 0}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={$scrollContent}
|
||||
>
|
||||
{renderHeader()}
|
||||
<View style={themed($body)}>{children}</View>
|
||||
{renderFooter()}
|
||||
</KeyboardAwareScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderHeader()}
|
||||
<View style={themed($body)}>{children}</View>
|
||||
{renderFooter()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RNModal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType={resolvedAnimationType}
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
{...rest}
|
||||
>
|
||||
<Pressable style={$overlayStyle} onPress={closeOnOverlayPress ? onClose : undefined}>
|
||||
<Pressable style={$contentContainerStyle} onPress={(e) => e.stopPropagation()}>
|
||||
{/* Drag Handle for bottom preset */}
|
||||
{preset === "bottom" && <View style={themed($dragHandle)} />}
|
||||
|
||||
{renderContent()}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</RNModal>
|
||||
)
|
||||
}
|
||||
|
||||
// Base styles
|
||||
const $overlayBase: ThemedStyle<ViewStyle> = () => ({
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
})
|
||||
|
||||
const $contentBase: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
})
|
||||
|
||||
// Center preset styles
|
||||
const $overlayCenter: ThemedStyle<ViewStyle> = () => ({
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: s(24),
|
||||
})
|
||||
|
||||
const $contentCenter: ThemedStyle<ViewStyle> = () => ({
|
||||
borderRadius: s(12),
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
})
|
||||
|
||||
// Bottom preset styles
|
||||
const $overlayBottom: ViewStyle = {
|
||||
justifyContent: "flex-end",
|
||||
}
|
||||
|
||||
const $contentBottom: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
borderTopLeftRadius: s(20),
|
||||
borderTopRightRadius: s(20),
|
||||
paddingTop: spacing.sm,
|
||||
maxHeight: "90%",
|
||||
})
|
||||
|
||||
const $dragHandle: ThemedStyle<ViewStyle> = ({ colors, spacing }) => ({
|
||||
width: s(36),
|
||||
height: s(4),
|
||||
borderRadius: s(2),
|
||||
backgroundColor: colors.separator,
|
||||
alignSelf: "center",
|
||||
marginBottom: spacing.sm,
|
||||
})
|
||||
|
||||
const $titleBottom: TextStyle = {
|
||||
textAlign: "center",
|
||||
}
|
||||
|
||||
// Fullscreen preset styles
|
||||
const $overlayFullscreen: ViewStyle = {
|
||||
backgroundColor: "transparent",
|
||||
}
|
||||
|
||||
const $contentFullscreen: ThemedStyle<ViewStyle> = () => ({
|
||||
flex: 1,
|
||||
borderRadius: 0,
|
||||
})
|
||||
|
||||
// Common styles
|
||||
const $header: ViewStyle = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}
|
||||
|
||||
const $headerBottom: ViewStyle = {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}
|
||||
|
||||
const $titleBase: ThemedStyle<TextStyle> = () => ({
|
||||
textAlign: "left",
|
||||
})
|
||||
|
||||
const $closeButton: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
padding: spacing.xxs,
|
||||
marginLeft: spacing.sm,
|
||||
})
|
||||
|
||||
const $closeButtonBottom: ViewStyle = {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
}
|
||||
|
||||
const $body: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
paddingVertical: spacing.md,
|
||||
})
|
||||
|
||||
const $footer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
})
|
||||
|
||||
const $footerButton: ThemedStyle<ViewStyle> = () => ({
|
||||
minWidth: s(80),
|
||||
})
|
||||
|
||||
const $scrollContent: ViewStyle = {
|
||||
flexGrow: 1,
|
||||
}
|
||||
306
RN_TEMPLATE/app/components/Screen.tsx
Normal file
306
RN_TEMPLATE/app/components/Screen.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import { ReactNode, useRef, useState } from "react"
|
||||
import {
|
||||
KeyboardAvoidingView,
|
||||
KeyboardAvoidingViewProps,
|
||||
LayoutChangeEvent,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ScrollViewProps,
|
||||
StyleProp,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { useScrollToTop } from "@react-navigation/native"
|
||||
import { SystemBars, SystemBarsProps, SystemBarStyle } from "react-native-edge-to-edge"
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import { s } from "@/utils/responsive"
|
||||
import { ExtendedEdge, useSafeAreaInsetsStyle } from "@/utils/useSafeAreaInsetsStyle"
|
||||
|
||||
export const DEFAULT_BOTTOM_OFFSET = s(50)
|
||||
|
||||
interface BaseScreenProps {
|
||||
/**
|
||||
* Children components.
|
||||
*/
|
||||
children?: ReactNode
|
||||
/**
|
||||
* Style for the outer content container useful for padding & margin.
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Style for the inner content container useful for padding & margin.
|
||||
*/
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Override the default edges for the safe area.
|
||||
*/
|
||||
safeAreaEdges?: ExtendedEdge[]
|
||||
/**
|
||||
* Background color
|
||||
*/
|
||||
backgroundColor?: string
|
||||
/**
|
||||
* System bar setting. Defaults to dark.
|
||||
*/
|
||||
systemBarStyle?: SystemBarStyle
|
||||
/**
|
||||
* By how much should we offset the keyboard? Defaults to 0.
|
||||
*/
|
||||
keyboardOffset?: number
|
||||
/**
|
||||
* By how much we scroll up when the keyboard is shown. Defaults to 50.
|
||||
*/
|
||||
keyboardBottomOffset?: number
|
||||
/**
|
||||
* Pass any additional props directly to the SystemBars component.
|
||||
*/
|
||||
SystemBarsProps?: SystemBarsProps
|
||||
/**
|
||||
* Pass any additional props directly to the KeyboardAvoidingView component.
|
||||
*/
|
||||
KeyboardAvoidingViewProps?: KeyboardAvoidingViewProps
|
||||
}
|
||||
|
||||
interface FixedScreenProps extends BaseScreenProps {
|
||||
preset?: "fixed"
|
||||
}
|
||||
interface ScrollScreenProps extends BaseScreenProps {
|
||||
preset?: "scroll"
|
||||
/**
|
||||
* Should keyboard persist on screen tap. Defaults to handled.
|
||||
* Only applies to scroll preset.
|
||||
*/
|
||||
keyboardShouldPersistTaps?: "handled" | "always" | "never"
|
||||
/**
|
||||
* Pass any additional props directly to the ScrollView component.
|
||||
*/
|
||||
ScrollViewProps?: ScrollViewProps
|
||||
}
|
||||
|
||||
interface AutoScreenProps extends Omit<ScrollScreenProps, "preset"> {
|
||||
preset?: "auto"
|
||||
/**
|
||||
* Threshold to trigger the automatic disabling/enabling of scroll ability.
|
||||
* Defaults to `{ percent: 0.92 }`.
|
||||
*/
|
||||
scrollEnabledToggleThreshold?: { percent?: number; point?: number }
|
||||
}
|
||||
|
||||
export type ScreenProps = ScrollScreenProps | FixedScreenProps | AutoScreenProps
|
||||
|
||||
const isIos = Platform.OS === "ios"
|
||||
|
||||
type ScreenPreset = "fixed" | "scroll" | "auto"
|
||||
|
||||
/**
|
||||
* @param {ScreenPreset?} preset - The preset to check.
|
||||
* @returns {boolean} - Whether the preset is non-scrolling.
|
||||
*/
|
||||
function isNonScrolling(preset?: ScreenPreset) {
|
||||
return !preset || preset === "fixed"
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook that handles the automatic enabling/disabling of scroll ability based on the content size and screen size.
|
||||
* @param {UseAutoPresetProps} props - The props for the `useAutoPreset` hook.
|
||||
* @returns {{boolean, Function, Function}} - The scroll state, and the `onContentSizeChange` and `onLayout` functions.
|
||||
*/
|
||||
function useAutoPreset(props: AutoScreenProps): {
|
||||
scrollEnabled: boolean
|
||||
onContentSizeChange: (w: number, h: number) => void
|
||||
onLayout: (e: LayoutChangeEvent) => void
|
||||
} {
|
||||
const { preset, scrollEnabledToggleThreshold } = props
|
||||
const { percent = 0.92, point = 0 } = scrollEnabledToggleThreshold || {}
|
||||
|
||||
const scrollViewHeight = useRef<null | number>(null)
|
||||
const scrollViewContentHeight = useRef<null | number>(null)
|
||||
const [scrollEnabled, setScrollEnabled] = useState(true)
|
||||
|
||||
function updateScrollState() {
|
||||
if (scrollViewHeight.current === null || scrollViewContentHeight.current === null) return
|
||||
|
||||
// check whether content fits the screen then toggle scroll state according to it
|
||||
const contentFitsScreen = (function () {
|
||||
if (point) {
|
||||
return scrollViewContentHeight.current < scrollViewHeight.current - point
|
||||
} else {
|
||||
return scrollViewContentHeight.current < scrollViewHeight.current * percent
|
||||
}
|
||||
})()
|
||||
|
||||
// content is less than the size of the screen, so we can disable scrolling
|
||||
if (scrollEnabled && contentFitsScreen) setScrollEnabled(false)
|
||||
|
||||
// content is greater than the size of the screen, so let's enable scrolling
|
||||
if (!scrollEnabled && !contentFitsScreen) setScrollEnabled(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} w - The width of the content.
|
||||
* @param {number} h - The height of the content.
|
||||
*/
|
||||
function onContentSizeChange(w: number, h: number) {
|
||||
// update scroll-view content height
|
||||
scrollViewContentHeight.current = h
|
||||
updateScrollState()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {LayoutChangeEvent} e = The layout change event.
|
||||
*/
|
||||
function onLayout(e: LayoutChangeEvent) {
|
||||
const { height } = e.nativeEvent.layout
|
||||
// update scroll-view height
|
||||
scrollViewHeight.current = height
|
||||
updateScrollState()
|
||||
}
|
||||
|
||||
// update scroll state on every render
|
||||
if (preset === "auto") updateScrollState()
|
||||
|
||||
return {
|
||||
scrollEnabled: preset === "auto" ? scrollEnabled : true,
|
||||
onContentSizeChange,
|
||||
onLayout,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScreenProps} props - The props for the `ScreenWithoutScrolling` component.
|
||||
* @returns {JSX.Element} - The rendered `ScreenWithoutScrolling` component.
|
||||
*/
|
||||
function ScreenWithoutScrolling(props: ScreenProps) {
|
||||
const { style, contentContainerStyle, children, preset } = props
|
||||
return (
|
||||
<View style={[$outerStyle, style]}>
|
||||
<View style={[$innerStyle, preset === "fixed" && $justifyFlexEnd, contentContainerStyle]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ScreenProps} props - The props for the `ScreenWithScrolling` component.
|
||||
* @returns {JSX.Element} - The rendered `ScreenWithScrolling` component.
|
||||
*/
|
||||
function ScreenWithScrolling(props: ScreenProps) {
|
||||
const {
|
||||
children,
|
||||
keyboardShouldPersistTaps = "handled",
|
||||
keyboardBottomOffset = DEFAULT_BOTTOM_OFFSET,
|
||||
contentContainerStyle,
|
||||
ScrollViewProps,
|
||||
style,
|
||||
} = props as ScrollScreenProps
|
||||
|
||||
const ref = useRef<ScrollView>(null)
|
||||
|
||||
const { scrollEnabled, onContentSizeChange, onLayout } = useAutoPreset(props as AutoScreenProps)
|
||||
|
||||
// Add native behavior of pressing the active tab to scroll to the top of the content
|
||||
// More info at: https://reactnavigation.org/docs/use-scroll-to-top/
|
||||
useScrollToTop(ref)
|
||||
|
||||
return (
|
||||
<KeyboardAwareScrollView
|
||||
bottomOffset={keyboardBottomOffset}
|
||||
{...{ keyboardShouldPersistTaps, scrollEnabled, ref }}
|
||||
{...ScrollViewProps}
|
||||
onLayout={(e) => {
|
||||
onLayout(e)
|
||||
ScrollViewProps?.onLayout?.(e)
|
||||
}}
|
||||
onContentSizeChange={(w: number, h: number) => {
|
||||
onContentSizeChange(w, h)
|
||||
ScrollViewProps?.onContentSizeChange?.(w, h)
|
||||
}}
|
||||
style={[$outerStyle, ScrollViewProps?.style, style]}
|
||||
contentContainerStyle={[
|
||||
$innerStyle,
|
||||
ScrollViewProps?.contentContainerStyle,
|
||||
contentContainerStyle,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</KeyboardAwareScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a screen component that provides a consistent layout and behavior for different screen presets.
|
||||
* The `Screen` component can be used with different presets such as "fixed", "scroll", or "auto".
|
||||
* It handles safe area insets, status bar settings, keyboard avoiding behavior, and scrollability based on the preset.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Screen/}
|
||||
* @param {ScreenProps} props - The props for the `Screen` component.
|
||||
* @returns {JSX.Element} The rendered `Screen` component.
|
||||
*/
|
||||
export function Screen(props: ScreenProps) {
|
||||
const {
|
||||
theme: { colors },
|
||||
themeContext,
|
||||
} = useAppTheme()
|
||||
const {
|
||||
backgroundColor,
|
||||
KeyboardAvoidingViewProps,
|
||||
keyboardOffset = 0,
|
||||
safeAreaEdges,
|
||||
SystemBarsProps,
|
||||
systemBarStyle,
|
||||
} = props
|
||||
|
||||
const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
$containerStyle,
|
||||
{ backgroundColor: backgroundColor || colors.background },
|
||||
$containerInsets,
|
||||
]}
|
||||
>
|
||||
<SystemBars
|
||||
style={systemBarStyle || (themeContext === "dark" ? "light" : "dark")}
|
||||
{...SystemBarsProps}
|
||||
/>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={isIos ? "padding" : "height"}
|
||||
keyboardVerticalOffset={keyboardOffset}
|
||||
{...KeyboardAvoidingViewProps}
|
||||
style={[$styles.flex1, KeyboardAvoidingViewProps?.style]}
|
||||
>
|
||||
{isNonScrolling(props.preset) ? (
|
||||
<ScreenWithoutScrolling {...props} />
|
||||
) : (
|
||||
<ScreenWithScrolling {...props} />
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $containerStyle: ViewStyle = {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}
|
||||
|
||||
const $outerStyle: ViewStyle = {
|
||||
flex: 1,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}
|
||||
|
||||
const $justifyFlexEnd: ViewStyle = {
|
||||
justifyContent: "flex-end",
|
||||
}
|
||||
|
||||
const $innerStyle: ViewStyle = {
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "stretch",
|
||||
}
|
||||
120
RN_TEMPLATE/app/components/Skeleton.tsx
Normal file
120
RN_TEMPLATE/app/components/Skeleton.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { createContext, useContext, useEffect } from "react"
|
||||
import { StyleProp, View, ViewStyle } from "react-native"
|
||||
import Animated, {
|
||||
SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
Easing,
|
||||
interpolate,
|
||||
} from "react-native-reanimated"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
// Shared animation context for synchronized pulse
|
||||
const PulseContext = createContext<SharedValue<number> | null>(null)
|
||||
|
||||
export interface SkeletonProps {
|
||||
/**
|
||||
* Width of the skeleton. Can be number or percentage string.
|
||||
* @default "100%"
|
||||
*/
|
||||
width?: number | `${number}%`
|
||||
/**
|
||||
* Height of the skeleton.
|
||||
* @default 16
|
||||
*/
|
||||
height?: number
|
||||
/**
|
||||
* Border radius. Use "round" for circle.
|
||||
* @default 4
|
||||
*/
|
||||
radius?: number | "round"
|
||||
/**
|
||||
* Style overrides
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
/**
|
||||
* A skeleton placeholder with pulse animation.
|
||||
* When used inside SkeletonContainer, all skeletons animate in sync.
|
||||
*/
|
||||
export function Skeleton(props: SkeletonProps) {
|
||||
const { width = "100%", height = s(16), radius = s(4), style } = props
|
||||
const { theme } = useAppTheme()
|
||||
|
||||
// Use shared animation from context, or create own
|
||||
const sharedProgress = useContext(PulseContext)
|
||||
const localProgress = useSharedValue(0)
|
||||
const progress = sharedProgress || localProgress
|
||||
|
||||
// Only start local animation if not using shared context
|
||||
useEffect(() => {
|
||||
if (!sharedProgress) {
|
||||
localProgress.value = withRepeat(
|
||||
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}, [sharedProgress, localProgress])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(progress.value, [0, 1], [0.4, 1]),
|
||||
}))
|
||||
|
||||
const borderRadius = radius === "round" ? (typeof height === "number" ? height / 2 : 999) : radius
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{ width, height, borderRadius, backgroundColor: theme.colors.border },
|
||||
animatedStyle,
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SkeletonContainerProps {
|
||||
/**
|
||||
* Children (Skeleton components)
|
||||
*/
|
||||
children: React.ReactNode
|
||||
/**
|
||||
* Style overrides for container
|
||||
*/
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
/**
|
||||
* Container that synchronizes pulse animation across all child Skeletons.
|
||||
*
|
||||
* @example
|
||||
* <SkeletonContainer>
|
||||
* <Skeleton width="60%" height={14} />
|
||||
* <Skeleton width={40} height={40} radius="round" />
|
||||
* </SkeletonContainer>
|
||||
*/
|
||||
export function SkeletonContainer(props: SkeletonContainerProps) {
|
||||
const { children, style } = props
|
||||
|
||||
const progress = useSharedValue(0)
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withRepeat(
|
||||
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
true,
|
||||
)
|
||||
}, [progress])
|
||||
|
||||
return (
|
||||
<PulseContext.Provider value={progress}>
|
||||
<View style={style}>{children}</View>
|
||||
</PulseContext.Provider>
|
||||
)
|
||||
}
|
||||
23
RN_TEMPLATE/app/components/Text.test.tsx
Normal file
23
RN_TEMPLATE/app/components/Text.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NavigationContainer } from "@react-navigation/native"
|
||||
import { render } from "@testing-library/react-native"
|
||||
|
||||
import { Text } from "./Text"
|
||||
import { ThemeProvider } from "../theme/context"
|
||||
|
||||
/* This is an example component test using react-native-testing-library. For more
|
||||
* information on how to write your own, see the documentation here:
|
||||
* https://callstack.github.io/react-native-testing-library/ */
|
||||
const testText = "Test string"
|
||||
|
||||
describe("Text", () => {
|
||||
it("should render the component", () => {
|
||||
const { getByText } = render(
|
||||
<ThemeProvider>
|
||||
<NavigationContainer>
|
||||
<Text text={testText} />
|
||||
</NavigationContainer>
|
||||
</ThemeProvider>,
|
||||
)
|
||||
expect(getByText(testText)).toBeDefined()
|
||||
})
|
||||
})
|
||||
118
RN_TEMPLATE/app/components/Text.tsx
Normal file
118
RN_TEMPLATE/app/components/Text.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ReactNode, forwardRef, ForwardedRef } from "react"
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { StyleProp, Text as RNText, TextProps as RNTextProps, TextStyle } from "react-native"
|
||||
import { TOptions } from "i18next"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { isRTL, TxKeyPath } from "@/i18n"
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import type { ThemedStyle, ThemedStyleArray } from "@/theme/types"
|
||||
import { typography } from "@/theme/typography"
|
||||
import { fs } from "@/utils/responsive"
|
||||
|
||||
type Sizes = keyof typeof $sizeStyles
|
||||
type Weights = keyof typeof typography.primary
|
||||
type Presets = "default" | "bold" | "heading" | "subheading" | "formLabel" | "formHelper"
|
||||
|
||||
export interface TextProps extends RNTextProps {
|
||||
/**
|
||||
* Text which is looked up via i18n.
|
||||
*/
|
||||
tx?: TxKeyPath
|
||||
/**
|
||||
* The text to display if not using `tx` or nested components.
|
||||
*/
|
||||
text?: string
|
||||
/**
|
||||
* Optional options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
txOptions?: TOptions
|
||||
/**
|
||||
* An optional style override useful for padding & margin.
|
||||
*/
|
||||
style?: StyleProp<TextStyle>
|
||||
/**
|
||||
* One of the different types of text presets.
|
||||
*/
|
||||
preset?: Presets
|
||||
/**
|
||||
* Text weight modifier.
|
||||
*/
|
||||
weight?: Weights
|
||||
/**
|
||||
* Text size modifier.
|
||||
*/
|
||||
size?: Sizes
|
||||
/**
|
||||
* Children components.
|
||||
*/
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* For your text displaying needs.
|
||||
* This component is a HOC over the built-in React Native one.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Text/}
|
||||
* @param {TextProps} props - The props for the `Text` component.
|
||||
* @returns {JSX.Element} The rendered `Text` component.
|
||||
*/
|
||||
export const Text = forwardRef(function Text(props: TextProps, ref: ForwardedRef<RNText>) {
|
||||
const { weight, size, tx, txOptions, text, children, style: $styleOverride, ...rest } = props
|
||||
const { themed } = useAppTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const i18nText = tx && t(tx, txOptions)
|
||||
const content = i18nText || text || children
|
||||
|
||||
const preset: Presets = props.preset ?? "default"
|
||||
const $styles: StyleProp<TextStyle> = [
|
||||
$rtlStyle,
|
||||
themed($presets[preset]),
|
||||
weight && $fontWeightStyles[weight],
|
||||
size && $sizeStyles[size],
|
||||
$styleOverride,
|
||||
]
|
||||
|
||||
return (
|
||||
<RNText {...rest} style={$styles} ref={ref}>
|
||||
{content}
|
||||
</RNText>
|
||||
)
|
||||
})
|
||||
|
||||
const $sizeStyles = {
|
||||
xxl: { fontSize: fs(36), lineHeight: fs(44) } satisfies TextStyle,
|
||||
xl: { fontSize: fs(24), lineHeight: fs(34) } satisfies TextStyle,
|
||||
lg: { fontSize: fs(20), lineHeight: fs(32) } satisfies TextStyle,
|
||||
md: { fontSize: fs(18), lineHeight: fs(26) } satisfies TextStyle,
|
||||
sm: { fontSize: fs(16), lineHeight: fs(24) } satisfies TextStyle,
|
||||
xs: { fontSize: fs(14), lineHeight: fs(21) } satisfies TextStyle,
|
||||
xxs: { fontSize: fs(12), lineHeight: fs(18) } satisfies TextStyle,
|
||||
}
|
||||
|
||||
const $fontWeightStyles = Object.entries(typography.primary).reduce((acc, [weight, fontFamily]) => {
|
||||
return { ...acc, [weight]: { fontFamily } }
|
||||
}, {}) as Record<Weights, TextStyle>
|
||||
|
||||
const $baseStyle: ThemedStyle<TextStyle> = (theme) => ({
|
||||
...$sizeStyles.sm,
|
||||
...$fontWeightStyles.normal,
|
||||
color: theme.colors.text,
|
||||
})
|
||||
|
||||
const $presets: Record<Presets, ThemedStyleArray<TextStyle>> = {
|
||||
default: [$baseStyle],
|
||||
bold: [$baseStyle, { ...$fontWeightStyles.bold }],
|
||||
heading: [
|
||||
$baseStyle,
|
||||
{
|
||||
...$sizeStyles.xxl,
|
||||
...$fontWeightStyles.bold,
|
||||
},
|
||||
],
|
||||
subheading: [$baseStyle, { ...$sizeStyles.lg, ...$fontWeightStyles.medium }],
|
||||
formLabel: [$baseStyle, { ...$fontWeightStyles.medium }],
|
||||
formHelper: [$baseStyle, { ...$sizeStyles.sm, ...$fontWeightStyles.normal }],
|
||||
}
|
||||
const $rtlStyle: TextStyle = isRTL ? { writingDirection: "rtl" } : {}
|
||||
292
RN_TEMPLATE/app/components/TextField.tsx
Normal file
292
RN_TEMPLATE/app/components/TextField.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { ComponentType, forwardRef, Ref, useImperativeHandle, useRef } from "react"
|
||||
import {
|
||||
ImageStyle,
|
||||
StyleProp,
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { isRTL } from "@/i18n"
|
||||
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"
|
||||
|
||||
export interface TextFieldAccessoryProps {
|
||||
style: StyleProp<ViewStyle | TextStyle | ImageStyle>
|
||||
status: TextFieldProps["status"]
|
||||
multiline: boolean
|
||||
editable: boolean
|
||||
}
|
||||
|
||||
export interface TextFieldProps extends Omit<TextInputProps, "ref"> {
|
||||
/**
|
||||
* A style modifier for different input states.
|
||||
*/
|
||||
status?: "error" | "disabled"
|
||||
/**
|
||||
* The label text to display if not using `labelTx`.
|
||||
*/
|
||||
label?: TextProps["text"]
|
||||
/**
|
||||
* Label text which is looked up via i18n.
|
||||
*/
|
||||
labelTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional label options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
labelTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Pass any additional props directly to the label Text component.
|
||||
*/
|
||||
LabelTextProps?: TextProps
|
||||
/**
|
||||
* The helper text to display if not using `helperTx`.
|
||||
*/
|
||||
helper?: TextProps["text"]
|
||||
/**
|
||||
* Helper text which is looked up via i18n.
|
||||
*/
|
||||
helperTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional helper options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
helperTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Pass any additional props directly to the helper Text component.
|
||||
*/
|
||||
HelperTextProps?: TextProps
|
||||
/**
|
||||
* The placeholder text to display if not using `placeholderTx`.
|
||||
*/
|
||||
placeholder?: TextProps["text"]
|
||||
/**
|
||||
* Placeholder text which is looked up via i18n.
|
||||
*/
|
||||
placeholderTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional placeholder options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
placeholderTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Optional input style override.
|
||||
*/
|
||||
style?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Style overrides for the container
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Style overrides for the input wrapper
|
||||
*/
|
||||
inputWrapperStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* An optional component to render on the right side of the input.
|
||||
* Example: `RightAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
|
||||
* Note: It is a good idea to memoize this.
|
||||
*/
|
||||
RightAccessory?: ComponentType<TextFieldAccessoryProps>
|
||||
/**
|
||||
* An optional component to render on the left side of the input.
|
||||
* Example: `LeftAccessory={(props) => <Icon icon="ladybug" containerStyle={props.style} color={props.editable ? colors.textDim : colors.text} />}`
|
||||
* Note: It is a good idea to memoize this.
|
||||
*/
|
||||
LeftAccessory?: ComponentType<TextFieldAccessoryProps>
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that allows for the entering and editing of text.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/TextField/}
|
||||
* @param {TextFieldProps} props - The props for the `TextField` component.
|
||||
* @returns {JSX.Element} The rendered `TextField` component.
|
||||
*/
|
||||
export const TextField = forwardRef(function TextField(props: TextFieldProps, ref: Ref<TextInput>) {
|
||||
const {
|
||||
labelTx,
|
||||
label,
|
||||
labelTxOptions,
|
||||
placeholderTx,
|
||||
placeholder,
|
||||
placeholderTxOptions,
|
||||
helper,
|
||||
helperTx,
|
||||
helperTxOptions,
|
||||
status,
|
||||
RightAccessory,
|
||||
LeftAccessory,
|
||||
HelperTextProps,
|
||||
LabelTextProps,
|
||||
style: $inputStyleOverride,
|
||||
containerStyle: $containerStyleOverride,
|
||||
inputWrapperStyle: $inputWrapperStyleOverride,
|
||||
...TextInputProps
|
||||
} = props
|
||||
const input = useRef<TextInput>(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
themed,
|
||||
theme: { colors },
|
||||
} = useAppTheme()
|
||||
|
||||
const disabled = TextInputProps.editable === false || status === "disabled"
|
||||
|
||||
const placeholderContent = placeholderTx ? t(placeholderTx, placeholderTxOptions) : placeholder
|
||||
|
||||
const $containerStyles = [$containerStyleOverride]
|
||||
|
||||
const $labelStyles = [$labelStyle, LabelTextProps?.style]
|
||||
|
||||
const $inputWrapperStyles = [
|
||||
$styles.row,
|
||||
$inputWrapperStyle,
|
||||
status === "error" && { borderColor: colors.error },
|
||||
TextInputProps.multiline && { minHeight: s(112) },
|
||||
LeftAccessory && { paddingStart: 0 },
|
||||
RightAccessory && { paddingEnd: 0 },
|
||||
$inputWrapperStyleOverride,
|
||||
]
|
||||
|
||||
const $inputStyles: ThemedStyleArray<TextStyle> = [
|
||||
$inputStyle,
|
||||
disabled && { color: colors.textDim },
|
||||
isRTL && { textAlign: "right" as TextStyle["textAlign"] },
|
||||
TextInputProps.multiline && { height: "auto" },
|
||||
$inputStyleOverride,
|
||||
]
|
||||
|
||||
const $helperStyles = [
|
||||
$helperStyle,
|
||||
status === "error" && { color: colors.error },
|
||||
HelperTextProps?.style,
|
||||
]
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function focusInput() {
|
||||
if (disabled) return
|
||||
|
||||
input.current?.focus()
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => input.current as TextInput)
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={$containerStyles}
|
||||
onPress={focusInput}
|
||||
accessibilityState={{ disabled }}
|
||||
>
|
||||
{!!(label || labelTx) && (
|
||||
<Text
|
||||
preset="formLabel"
|
||||
text={label}
|
||||
tx={labelTx}
|
||||
txOptions={labelTxOptions}
|
||||
{...LabelTextProps}
|
||||
style={themed($labelStyles)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={themed($inputWrapperStyles)}>
|
||||
{!!LeftAccessory && (
|
||||
<LeftAccessory
|
||||
style={themed($leftAccessoryStyle)}
|
||||
status={status}
|
||||
editable={!disabled}
|
||||
multiline={TextInputProps.multiline ?? false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
ref={input}
|
||||
underlineColorAndroid={colors.transparent}
|
||||
textAlignVertical="top"
|
||||
placeholder={placeholderContent}
|
||||
placeholderTextColor={colors.textDim}
|
||||
{...TextInputProps}
|
||||
editable={!disabled}
|
||||
style={themed($inputStyles)}
|
||||
/>
|
||||
|
||||
{!!RightAccessory && (
|
||||
<RightAccessory
|
||||
style={themed($rightAccessoryStyle)}
|
||||
status={status}
|
||||
editable={!disabled}
|
||||
multiline={TextInputProps.multiline ?? false}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{!!(helper || helperTx) && (
|
||||
<Text
|
||||
preset="formHelper"
|
||||
text={helper}
|
||||
tx={helperTx}
|
||||
txOptions={helperTxOptions}
|
||||
{...HelperTextProps}
|
||||
style={themed($helperStyles)}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
})
|
||||
|
||||
const $labelStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
marginBottom: spacing.xs,
|
||||
})
|
||||
|
||||
const $inputWrapperStyle: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
alignItems: "flex-start",
|
||||
borderWidth: 1,
|
||||
borderRadius: s(4),
|
||||
backgroundColor: colors.palette.neutral200,
|
||||
borderColor: colors.palette.neutral400,
|
||||
overflow: "hidden",
|
||||
})
|
||||
|
||||
const $inputStyle: ThemedStyle<TextStyle> = ({ colors, typography, spacing }) => ({
|
||||
flex: 1,
|
||||
alignSelf: "stretch",
|
||||
fontFamily: typography.primary.normal,
|
||||
color: colors.text,
|
||||
fontSize: fs(16),
|
||||
height: s(24),
|
||||
// https://github.com/facebook/react-native/issues/21720#issuecomment-532642093
|
||||
paddingVertical: 0,
|
||||
paddingHorizontal: 0,
|
||||
marginVertical: spacing.xs,
|
||||
marginHorizontal: spacing.sm,
|
||||
})
|
||||
|
||||
const $helperStyle: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
marginTop: spacing.xs,
|
||||
})
|
||||
|
||||
const $rightAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
marginEnd: spacing.xs,
|
||||
height: s(40),
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
})
|
||||
|
||||
const $leftAccessoryStyle: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
||||
marginStart: spacing.xs,
|
||||
height: s(40),
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
})
|
||||
106
RN_TEMPLATE/app/components/Toggle/Checkbox.tsx
Normal file
106
RN_TEMPLATE/app/components/Toggle/Checkbox.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useRef, useCallback } from "react"
|
||||
import { Animated, StyleProp, View, ViewStyle } from "react-native"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { Icon, IconTypes } from "../Icon"
|
||||
import { $inputOuterBase, BaseToggleInputProps, ToggleProps, Toggle } from "./Toggle"
|
||||
|
||||
export interface CheckboxToggleProps extends Omit<ToggleProps<CheckboxInputProps>, "ToggleInput"> {
|
||||
/**
|
||||
* Checkbox-only prop that changes the icon used for the "on" state.
|
||||
*/
|
||||
icon?: IconTypes
|
||||
}
|
||||
|
||||
interface CheckboxInputProps extends BaseToggleInputProps<CheckboxToggleProps> {
|
||||
icon?: CheckboxToggleProps["icon"]
|
||||
}
|
||||
/**
|
||||
* @param {CheckboxToggleProps} props - The props for the `Checkbox` component.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Checkbox}
|
||||
* @returns {JSX.Element} The rendered `Checkbox` component.
|
||||
*/
|
||||
export function Checkbox(props: CheckboxToggleProps) {
|
||||
const { icon, ...rest } = props
|
||||
const checkboxInput = useCallback(
|
||||
(toggleProps: CheckboxInputProps) => <CheckboxInput {...toggleProps} icon={icon} />,
|
||||
[icon],
|
||||
)
|
||||
return <Toggle accessibilityRole="checkbox" {...rest} ToggleInput={checkboxInput} />
|
||||
}
|
||||
|
||||
function CheckboxInput(props: CheckboxInputProps) {
|
||||
const {
|
||||
on,
|
||||
status,
|
||||
disabled,
|
||||
icon = "check",
|
||||
outerStyle: $outerStyleOverride,
|
||||
innerStyle: $innerStyleOverride,
|
||||
} = props
|
||||
|
||||
const {
|
||||
theme: { colors },
|
||||
} = useAppTheme()
|
||||
|
||||
const opacity = useRef(new Animated.Value(0))
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(opacity.current, {
|
||||
toValue: on ? 1 : 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}, [on])
|
||||
|
||||
const offBackgroundColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral200,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const outerBorderColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.error,
|
||||
!on && colors.palette.neutral800,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const onBackgroundColor = [
|
||||
disabled && colors.transparent,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const iconTintColor = [
|
||||
disabled && colors.palette.neutral600,
|
||||
status === "error" && colors.error,
|
||||
colors.palette.accent100,
|
||||
].filter(Boolean)[0] as string | undefined
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
$inputOuter,
|
||||
{ backgroundColor: offBackgroundColor, borderColor: outerBorderColor },
|
||||
$outerStyleOverride,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
$styles.toggleInner,
|
||||
{ backgroundColor: onBackgroundColor },
|
||||
$innerStyleOverride,
|
||||
{ opacity: opacity.current },
|
||||
]}
|
||||
>
|
||||
<Icon icon={icon} size={s(20)} color={iconTintColor} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $inputOuter: StyleProp<ViewStyle> = [$inputOuterBase, { borderRadius: s(4) }]
|
||||
107
RN_TEMPLATE/app/components/Toggle/Radio.tsx
Normal file
107
RN_TEMPLATE/app/components/Toggle/Radio.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { StyleProp, View, ViewStyle, Animated } from "react-native"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { $inputOuterBase, BaseToggleInputProps, ToggleProps, Toggle } from "./Toggle"
|
||||
|
||||
export interface RadioToggleProps extends Omit<ToggleProps<RadioInputProps>, "ToggleInput"> {
|
||||
/**
|
||||
* Optional style prop that affects the dot View.
|
||||
*/
|
||||
inputDetailStyle?: ViewStyle
|
||||
}
|
||||
|
||||
interface RadioInputProps extends BaseToggleInputProps<RadioToggleProps> {}
|
||||
|
||||
/**
|
||||
* @param {RadioToggleProps} props - The props for the `Radio` component.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Radio}
|
||||
* @returns {JSX.Element} The rendered `Radio` component.
|
||||
*/
|
||||
export function Radio(props: RadioToggleProps) {
|
||||
return <Toggle accessibilityRole="radio" {...props} ToggleInput={RadioInput} />
|
||||
}
|
||||
|
||||
function RadioInput(props: RadioInputProps) {
|
||||
const {
|
||||
on,
|
||||
status,
|
||||
disabled,
|
||||
outerStyle: $outerStyleOverride,
|
||||
innerStyle: $innerStyleOverride,
|
||||
detailStyle: $detailStyleOverride,
|
||||
} = props
|
||||
|
||||
const {
|
||||
theme: { colors },
|
||||
} = useAppTheme()
|
||||
|
||||
const opacity = useRef(new Animated.Value(0))
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(opacity.current, {
|
||||
toValue: on ? 1 : 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}, [on])
|
||||
|
||||
const offBackgroundColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral200,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const outerBorderColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.error,
|
||||
!on && colors.palette.neutral800,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const onBackgroundColor = [
|
||||
disabled && colors.transparent,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral100,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const dotBackgroundColor = [
|
||||
disabled && colors.palette.neutral600,
|
||||
status === "error" && colors.error,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
$inputOuter,
|
||||
{ backgroundColor: offBackgroundColor, borderColor: outerBorderColor },
|
||||
$outerStyleOverride,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
$styles.toggleInner,
|
||||
{ backgroundColor: onBackgroundColor },
|
||||
$innerStyleOverride,
|
||||
{ opacity: opacity.current },
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[$radioDetail, { backgroundColor: dotBackgroundColor }, $detailStyleOverride]}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $radioDetail: ViewStyle = {
|
||||
width: s(12),
|
||||
height: s(12),
|
||||
borderRadius: s(6),
|
||||
}
|
||||
|
||||
const $inputOuter: StyleProp<ViewStyle> = [$inputOuterBase, { borderRadius: s(12) }]
|
||||
256
RN_TEMPLATE/app/components/Toggle/Switch.tsx
Normal file
256
RN_TEMPLATE/app/components/Toggle/Switch.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useMemo, useRef, useCallback } from "react"
|
||||
import { Animated, Platform, StyleProp, View, ViewStyle } from "react-native"
|
||||
|
||||
import { Icon } from "@/components/Icon"
|
||||
import { isRTL } from "@/i18n"
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { $inputOuterBase, BaseToggleInputProps, Toggle, ToggleProps } from "./Toggle"
|
||||
|
||||
export interface SwitchToggleProps extends Omit<ToggleProps<SwitchInputProps>, "ToggleInput"> {
|
||||
/**
|
||||
* Switch-only prop that adds a text/icon label for on/off states.
|
||||
*/
|
||||
accessibilityMode?: "text" | "icon"
|
||||
/**
|
||||
* Optional style prop that affects the knob View.
|
||||
* Note: `width` and `height` rules should be points (numbers), not percentages.
|
||||
*/
|
||||
inputDetailStyle?: Omit<ViewStyle, "width" | "height"> & { width?: number; height?: number }
|
||||
}
|
||||
|
||||
interface SwitchInputProps extends BaseToggleInputProps<SwitchToggleProps> {
|
||||
accessibilityMode?: SwitchToggleProps["accessibilityMode"]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SwitchToggleProps} props - The props for the `Switch` component.
|
||||
* @see [Documentation and Examples]{@link https://docs.infinite.red/ignite-cli/boilerplate/app/components/Switch}
|
||||
* @returns {JSX.Element} The rendered `Switch` component.
|
||||
*/
|
||||
export function Switch(props: SwitchToggleProps) {
|
||||
const { accessibilityMode, ...rest } = props
|
||||
const switchInput = useCallback(
|
||||
(toggleProps: SwitchInputProps) => (
|
||||
<SwitchInput {...toggleProps} accessibilityMode={accessibilityMode} />
|
||||
),
|
||||
[accessibilityMode],
|
||||
)
|
||||
return <Toggle accessibilityRole="switch" {...rest} ToggleInput={switchInput} />
|
||||
}
|
||||
|
||||
function SwitchInput(props: SwitchInputProps) {
|
||||
const {
|
||||
on,
|
||||
status,
|
||||
disabled,
|
||||
outerStyle: $outerStyleOverride,
|
||||
innerStyle: $innerStyleOverride,
|
||||
detailStyle: $detailStyleOverride,
|
||||
} = props
|
||||
|
||||
const {
|
||||
theme: { colors },
|
||||
themed,
|
||||
} = useAppTheme()
|
||||
|
||||
const animate = useRef(new Animated.Value(on ? 1 : 0)) // Initial value is set based on isActive
|
||||
const opacity = useRef(new Animated.Value(0))
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(animate.current, {
|
||||
toValue: on ? 1 : 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true, // Enable native driver for smoother animations
|
||||
}).start()
|
||||
}, [on])
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(opacity.current, {
|
||||
toValue: on ? 1 : 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}).start()
|
||||
}, [on])
|
||||
|
||||
const knobSizeFallback = 2
|
||||
|
||||
const knobWidth = [$detailStyleOverride?.width, $switchDetail?.width, knobSizeFallback].find(
|
||||
(v) => typeof v === "number",
|
||||
)
|
||||
|
||||
const knobHeight = [$detailStyleOverride?.height, $switchDetail?.height, knobSizeFallback].find(
|
||||
(v) => typeof v === "number",
|
||||
)
|
||||
|
||||
const offBackgroundColor = [
|
||||
disabled && colors.palette.neutral400,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.neutral300,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const onBackgroundColor = [
|
||||
disabled && colors.transparent,
|
||||
status === "error" && colors.errorBackground,
|
||||
colors.palette.secondary500,
|
||||
].filter(Boolean)[0]
|
||||
|
||||
const knobBackgroundColor = (function () {
|
||||
if (on) {
|
||||
return [
|
||||
$detailStyleOverride?.backgroundColor,
|
||||
status === "error" && colors.error,
|
||||
disabled && colors.palette.neutral600,
|
||||
colors.palette.neutral100,
|
||||
].filter(Boolean)[0]
|
||||
} else {
|
||||
return [
|
||||
$innerStyleOverride?.backgroundColor,
|
||||
disabled && colors.palette.neutral600,
|
||||
status === "error" && colors.error,
|
||||
colors.palette.neutral200,
|
||||
].filter(Boolean)[0]
|
||||
}
|
||||
})()
|
||||
|
||||
const rtlAdjustment = isRTL ? -1 : 1
|
||||
const $themedSwitchInner = useMemo(() => themed([$styles.toggleInner, $switchInner]), [themed])
|
||||
|
||||
const offsetLeft = ($innerStyleOverride?.paddingStart ||
|
||||
$innerStyleOverride?.paddingLeft ||
|
||||
$themedSwitchInner?.paddingStart ||
|
||||
$themedSwitchInner?.paddingLeft ||
|
||||
0) as number
|
||||
|
||||
const offsetRight = ($innerStyleOverride?.paddingEnd ||
|
||||
$innerStyleOverride?.paddingRight ||
|
||||
$themedSwitchInner?.paddingEnd ||
|
||||
$themedSwitchInner?.paddingRight ||
|
||||
0) as number
|
||||
|
||||
const outputRange =
|
||||
Platform.OS === "web"
|
||||
? isRTL
|
||||
? [+(knobWidth || 0) + offsetRight, offsetLeft]
|
||||
: [offsetLeft, +(knobWidth || 0) + offsetRight]
|
||||
: [rtlAdjustment * offsetLeft, rtlAdjustment * (+(knobWidth || 0) + offsetRight)]
|
||||
|
||||
const $animatedSwitchKnob = animate.current.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange,
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={[$inputOuter, { backgroundColor: offBackgroundColor }, $outerStyleOverride]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
$themedSwitchInner,
|
||||
{ backgroundColor: onBackgroundColor },
|
||||
$innerStyleOverride,
|
||||
{ opacity: opacity.current },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SwitchAccessibilityLabel {...props} role="on" />
|
||||
<SwitchAccessibilityLabel {...props} role="off" />
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
$switchDetail,
|
||||
$detailStyleOverride,
|
||||
{ transform: [{ translateX: $animatedSwitchKnob }] },
|
||||
{ width: knobWidth, height: knobHeight },
|
||||
{ backgroundColor: knobBackgroundColor },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ToggleInputProps & { role: "on" | "off" }} props - The props for the `SwitchAccessibilityLabel` component.
|
||||
* @returns {JSX.Element} The rendered `SwitchAccessibilityLabel` component.
|
||||
*/
|
||||
function SwitchAccessibilityLabel(props: SwitchInputProps & { role: "on" | "off" }) {
|
||||
const { on, disabled, status, accessibilityMode, role, innerStyle, detailStyle } = props
|
||||
|
||||
const {
|
||||
theme: { colors },
|
||||
} = useAppTheme()
|
||||
|
||||
if (!accessibilityMode) return null
|
||||
|
||||
const shouldLabelBeVisible = (on && role === "on") || (!on && role === "off")
|
||||
|
||||
const $switchAccessibilityStyle: StyleProp<ViewStyle> = [
|
||||
$switchAccessibility,
|
||||
role === "off" && { end: "5%" },
|
||||
role === "on" && { left: "5%" },
|
||||
]
|
||||
|
||||
const color = (function () {
|
||||
if (disabled) return colors.palette.neutral600
|
||||
if (status === "error") return colors.error
|
||||
if (!on) return innerStyle?.backgroundColor || colors.palette.secondary500
|
||||
return detailStyle?.backgroundColor || colors.palette.neutral100
|
||||
})() as string
|
||||
|
||||
return (
|
||||
<View style={$switchAccessibilityStyle}>
|
||||
{accessibilityMode === "text" && shouldLabelBeVisible && (
|
||||
<View
|
||||
style={[
|
||||
role === "on" && $switchAccessibilityLine,
|
||||
role === "on" && { backgroundColor: color },
|
||||
role === "off" && $switchAccessibilityCircle,
|
||||
role === "off" && { borderColor: color },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{accessibilityMode === "icon" && shouldLabelBeVisible && (
|
||||
<Icon icon={role === "off" ? "hidden" : "view"} size={14} color={color} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $inputOuter: StyleProp<ViewStyle> = [
|
||||
$inputOuterBase,
|
||||
{ height: s(32), width: s(56), borderRadius: s(16), borderWidth: 0 },
|
||||
]
|
||||
|
||||
const $switchInner: ThemedStyle<ViewStyle> = ({ colors }) => ({
|
||||
borderColor: colors.transparent,
|
||||
position: "absolute",
|
||||
paddingStart: 4,
|
||||
paddingEnd: 4,
|
||||
})
|
||||
|
||||
const $switchDetail: SwitchToggleProps["inputDetailStyle"] = {
|
||||
borderRadius: s(12),
|
||||
position: "absolute",
|
||||
width: s(24),
|
||||
height: s(24),
|
||||
}
|
||||
|
||||
const $switchAccessibility: ViewStyle = {
|
||||
width: "40%",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
const $switchAccessibilityLine: ViewStyle = {
|
||||
width: 2,
|
||||
height: s(12),
|
||||
}
|
||||
|
||||
const $switchAccessibilityCircle: ViewStyle = {
|
||||
borderWidth: 2,
|
||||
width: s(12),
|
||||
height: s(12),
|
||||
borderRadius: s(6),
|
||||
}
|
||||
286
RN_TEMPLATE/app/components/Toggle/Toggle.tsx
Normal file
286
RN_TEMPLATE/app/components/Toggle/Toggle.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import { ComponentType, FC, useMemo } from "react"
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
ImageStyle,
|
||||
StyleProp,
|
||||
SwitchProps,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
TouchableOpacity,
|
||||
TouchableOpacityProps,
|
||||
View,
|
||||
ViewProps,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
|
||||
import { useAppTheme } from "@/theme/context"
|
||||
import { $styles } from "@/theme/styles"
|
||||
import type { ThemedStyle } from "@/theme/types"
|
||||
import { s } from "@/utils/responsive"
|
||||
|
||||
import { Text, TextProps } from "../Text"
|
||||
|
||||
export interface ToggleProps<T> extends Omit<TouchableOpacityProps, "style"> {
|
||||
/**
|
||||
* A style modifier for different input states.
|
||||
*/
|
||||
status?: "error" | "disabled"
|
||||
/**
|
||||
* If false, input is not editable. The default value is true.
|
||||
*/
|
||||
editable?: TextInputProps["editable"]
|
||||
/**
|
||||
* The value of the field. If true the component will be turned on.
|
||||
*/
|
||||
value?: boolean
|
||||
/**
|
||||
* Invoked with the new value when the value changes.
|
||||
*/
|
||||
onValueChange?: SwitchProps["onValueChange"]
|
||||
/**
|
||||
* Style overrides for the container
|
||||
*/
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Style overrides for the input wrapper
|
||||
*/
|
||||
inputWrapperStyle?: StyleProp<ViewStyle>
|
||||
/**
|
||||
* Optional input wrapper style override.
|
||||
* This gives the inputs their size, shape, "off" background-color, and outer border.
|
||||
*/
|
||||
inputOuterStyle?: ViewStyle
|
||||
/**
|
||||
* Optional input style override.
|
||||
* This gives the inputs their inner characteristics and "on" background-color.
|
||||
*/
|
||||
inputInnerStyle?: ViewStyle
|
||||
/**
|
||||
* Optional detail style override.
|
||||
* See Checkbox, Radio, and Switch for more details
|
||||
*/
|
||||
inputDetailStyle?: ViewStyle
|
||||
/**
|
||||
* The position of the label relative to the action component.
|
||||
* Default: right
|
||||
*/
|
||||
labelPosition?: "left" | "right"
|
||||
/**
|
||||
* The label text to display if not using `labelTx`.
|
||||
*/
|
||||
label?: TextProps["text"]
|
||||
/**
|
||||
* Label text which is looked up via i18n.
|
||||
*/
|
||||
labelTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional label options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
labelTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Style overrides for label text.
|
||||
*/
|
||||
labelStyle?: StyleProp<TextStyle>
|
||||
/**
|
||||
* Pass any additional props directly to the label Text component.
|
||||
*/
|
||||
LabelTextProps?: TextProps
|
||||
/**
|
||||
* The helper text to display if not using `helperTx`.
|
||||
*/
|
||||
helper?: TextProps["text"]
|
||||
/**
|
||||
* Helper text which is looked up via i18n.
|
||||
*/
|
||||
helperTx?: TextProps["tx"]
|
||||
/**
|
||||
* Optional helper options to pass to i18n. Useful for interpolation
|
||||
* as well as explicitly setting locale or translation fallbacks.
|
||||
*/
|
||||
helperTxOptions?: TextProps["txOptions"]
|
||||
/**
|
||||
* Pass any additional props directly to the helper Text component.
|
||||
*/
|
||||
HelperTextProps?: TextProps
|
||||
/**
|
||||
* The input control for the type of toggle component
|
||||
*/
|
||||
ToggleInput: FC<BaseToggleInputProps<T>>
|
||||
}
|
||||
|
||||
export interface BaseToggleInputProps<T> {
|
||||
on: boolean
|
||||
status: ToggleProps<T>["status"]
|
||||
disabled: boolean
|
||||
outerStyle: ViewStyle
|
||||
innerStyle: ViewStyle
|
||||
detailStyle: ViewStyle | ImageStyle
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a boolean input.
|
||||
* This is a controlled component that requires an onValueChange callback that updates the value prop in order for the component to reflect user actions. If the value prop is not updated, the component will continue to render the supplied value prop instead of the expected result of any user actions.
|
||||
* @param {ToggleProps} props - The props for the `Toggle` component.
|
||||
* @returns {JSX.Element} The rendered `Toggle` component.
|
||||
*/
|
||||
export function Toggle<T>(props: ToggleProps<T>) {
|
||||
const {
|
||||
editable = true,
|
||||
status,
|
||||
value,
|
||||
onPress,
|
||||
onValueChange,
|
||||
labelPosition = "right",
|
||||
helper,
|
||||
helperTx,
|
||||
helperTxOptions,
|
||||
HelperTextProps,
|
||||
containerStyle: $containerStyleOverride,
|
||||
inputWrapperStyle: $inputWrapperStyleOverride,
|
||||
ToggleInput,
|
||||
accessibilityRole,
|
||||
...WrapperProps
|
||||
} = props
|
||||
|
||||
const {
|
||||
theme: { colors },
|
||||
themed,
|
||||
} = useAppTheme()
|
||||
|
||||
const disabled = editable === false || status === "disabled" || props.disabled
|
||||
|
||||
const Wrapper = useMemo(
|
||||
() => (disabled ? View : TouchableOpacity) as ComponentType<TouchableOpacityProps | ViewProps>,
|
||||
[disabled],
|
||||
)
|
||||
|
||||
const $containerStyles = [$containerStyleOverride]
|
||||
const $inputWrapperStyles = [$styles.row, $inputWrapper, $inputWrapperStyleOverride]
|
||||
const $helperStyles = themed([
|
||||
$helper,
|
||||
status === "error" && { color: colors.error },
|
||||
HelperTextProps?.style,
|
||||
])
|
||||
|
||||
/**
|
||||
* @param {GestureResponderEvent} e - The event object.
|
||||
*/
|
||||
function handlePress(e: GestureResponderEvent) {
|
||||
if (disabled) return
|
||||
onValueChange?.(!value)
|
||||
onPress?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
activeOpacity={1}
|
||||
accessibilityRole={accessibilityRole}
|
||||
accessibilityState={{ checked: value, disabled }}
|
||||
{...WrapperProps}
|
||||
style={$containerStyles}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<View style={$inputWrapperStyles}>
|
||||
{labelPosition === "left" && <FieldLabel<T> {...props} labelPosition={labelPosition} />}
|
||||
|
||||
<ToggleInput
|
||||
on={!!value}
|
||||
disabled={!!disabled}
|
||||
status={status}
|
||||
outerStyle={props.inputOuterStyle ?? {}}
|
||||
innerStyle={props.inputInnerStyle ?? {}}
|
||||
detailStyle={props.inputDetailStyle ?? {}}
|
||||
/>
|
||||
|
||||
{labelPosition === "right" && <FieldLabel<T> {...props} labelPosition={labelPosition} />}
|
||||
</View>
|
||||
|
||||
{!!(helper || helperTx) && (
|
||||
<Text
|
||||
preset="formHelper"
|
||||
text={helper}
|
||||
tx={helperTx}
|
||||
txOptions={helperTxOptions}
|
||||
{...HelperTextProps}
|
||||
style={$helperStyles}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ToggleProps} props - The props for the `FieldLabel` component.
|
||||
* @returns {JSX.Element} The rendered `FieldLabel` component.
|
||||
*/
|
||||
function FieldLabel<T>(props: ToggleProps<T>) {
|
||||
const {
|
||||
status,
|
||||
label,
|
||||
labelTx,
|
||||
labelTxOptions,
|
||||
LabelTextProps,
|
||||
labelPosition,
|
||||
labelStyle: $labelStyleOverride,
|
||||
} = props
|
||||
|
||||
const {
|
||||
theme: { colors },
|
||||
themed,
|
||||
} = useAppTheme()
|
||||
|
||||
if (!label && !labelTx && !LabelTextProps?.children) return null
|
||||
|
||||
const $labelStyle = themed([
|
||||
$label,
|
||||
status === "error" && { color: colors.error },
|
||||
labelPosition === "right" && $labelRight,
|
||||
labelPosition === "left" && $labelLeft,
|
||||
$labelStyleOverride,
|
||||
LabelTextProps?.style,
|
||||
])
|
||||
|
||||
return (
|
||||
<Text
|
||||
preset="formLabel"
|
||||
text={label}
|
||||
tx={labelTx}
|
||||
txOptions={labelTxOptions}
|
||||
{...LabelTextProps}
|
||||
style={$labelStyle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const $inputWrapper: ViewStyle = {
|
||||
alignItems: "center",
|
||||
}
|
||||
|
||||
export const $inputOuterBase: ViewStyle = {
|
||||
height: s(24),
|
||||
width: s(24),
|
||||
borderWidth: 2,
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
justifyContent: "space-between",
|
||||
flexDirection: "row",
|
||||
}
|
||||
|
||||
const $helper: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
marginTop: spacing.xs,
|
||||
})
|
||||
|
||||
const $label: TextStyle = {
|
||||
flex: 1,
|
||||
}
|
||||
|
||||
const $labelRight: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
marginStart: spacing.md,
|
||||
})
|
||||
|
||||
const $labelLeft: ThemedStyle<TextStyle> = ({ spacing }) => ({
|
||||
marginEnd: spacing.md,
|
||||
})
|
||||
Reference in New Issue
Block a user