template_0205

This commit is contained in:
Sofio
2026-02-05 13:16:05 +08:00
commit d93e4d9c9f
197 changed files with 52810 additions and 0 deletions

View 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]} />
}

View 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",
})

View 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: () => ({}),
}

View 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 })],
}

View 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,
})

View 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,
})

View 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,
}

View 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 }

View 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,
})

View 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,
}

View 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",
}

View 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>
)
}

View 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()
})
})

View 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" } : {}

View 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",
})

View 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) }]

View 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) }]

View 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),
}

View 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,
})