257 lines
7.5 KiB
TypeScript
257 lines
7.5 KiB
TypeScript
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),
|
|
}
|