import { useComposedRefs } from '@tamagui/compose-refs' import { isWeb } from '@tamagui/constants' import { composeEventHandlers } from '@tamagui/helpers' import { useLabelContext } from '@tamagui/label' import { usePrevious } from '@tamagui/use-previous' import * as React from 'react' import type { GestureResponderEvent, PressableProps, View, ViewProps } from 'react-native' type SwitchBaseProps = ViewProps & Pick export type SwitchExtraProps = { labeledBy?: string disabled?: boolean name?: string value?: string checked?: boolean defaultChecked?: boolean required?: boolean onCheckedChange?(checked: boolean): void } export type SwitchProps = SwitchBaseProps & SwitchExtraProps export type SwitchState = boolean function getState(checked: SwitchState) { return checked ? 'checked' : 'unchecked' } type InputProps = React.HTMLProps<'input'> interface BubbleInputProps extends Omit { checked: boolean control: HTMLElement | null bubbles: boolean } // TODO make this native friendly const BubbleInput = (props: BubbleInputProps) => { const { control, checked, bubbles = true, ...inputProps } = props const ref = React.useRef(null) const prevChecked = usePrevious(checked) // Bubble checked change to parents (e.g form change event) React.useEffect(() => { const input = ref.current! const inputProto = window.HTMLInputElement.prototype const descriptor = Object.getOwnPropertyDescriptor( inputProto, 'checked' ) as PropertyDescriptor const setChecked = descriptor.set if (prevChecked !== checked && setChecked) { const event = new Event('click', { bubbles }) setChecked.call(input, checked) input.dispatchEvent(event) } }, [prevChecked, checked, bubbles]) return ( // @ts-ignore ) } export function useSwitch( props: P, [checked, setChecked]: [SwitchState, React.Dispatch>], ref: React.Ref ) { if (process.env.TAMAGUI_TARGET === 'native') { return { switchProps: { onPress() { setChecked((prevChecked) => !prevChecked) }, } satisfies SwitchBaseProps, switchRef: ref, bubbleInput: null, } } else { const { disabled, name, value, required } = props const hasConsumerStoppedPropagationRef = React.useRef(false) const [button, setButton] = React.useState(null) const composedRefs = useComposedRefs(ref, setButton as any) // We set this to true by default so that events bubble to forms without JS (SSR) const isFormControl = isWeb ? button ? Boolean(button.closest('form')) : true : false const labelId = useLabelContext(button) const ariaLabelledBy = props['aria-labelledby'] || props.labeledBy || labelId return { switchProps: { role: 'switch', 'aria-checked': checked, ...(isWeb ? { tabIndex: disabled ? undefined : 0, 'data-state': getState(checked), 'data-disabled': disabled ? '' : undefined, disabled: disabled, } : {}), 'aria-labelledby': ariaLabelledBy, onPress: composeEventHandlers(props.onPress, (event: GestureResponderEvent) => { setChecked((prevChecked) => !prevChecked) if (isWeb && isFormControl) { hasConsumerStoppedPropagationRef.current = event.isPropagationStopped() // if switch is in a form, stop propagation from the button so that we only propagate // one click event (from the input). We propagate changes from an input so that native // form validation works and form events reflect switch updates. if (!hasConsumerStoppedPropagationRef.current) event.stopPropagation() } }), } satisfies SwitchBaseProps, switchRef: composedRefs, /** * insert as a sibling of your switch (should not be inside the switch) */ bubbleInput: isWeb && isFormControl ? ( ) : null, } } }