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