/** @jsxImportSource @fluentui-react-native/framework-base */ 'use strict'; import * as React from 'react'; import { View, I18nManager } from 'react-native'; import { filterViewProps } from '@fluentui-react-native/adapters'; import type { KeyPressEvent } from '@fluentui-react-native/interactive-hooks'; import { useAsPressable, useViewCommandFocus, useKeyDownProps } from '@fluentui-react-native/interactive-hooks'; import { Text } from '@fluentui-react-native/text'; import { foregroundColorTokens, textTokens, borderTokens, backgroundColorTokens, getPaletteFromTheme } from '@fluentui-react-native/tokens'; import type { ISlots } from '@uifabricshared/foundation-composable'; import type { IUseComposeStyling } from '@uifabricshared/foundation-compose'; import { compose } from '@uifabricshared/foundation-compose'; import { mergeSettings } from '@uifabricshared/foundation-settings'; import { settings, radioButtonSelectActionLabel } from './RadioButton.settings'; import type { IRadioButtonType, IRadioButtonProps, IRadioButtonSlotProps, IRadioButtonRenderData } from './RadioButton.types'; import { radioButtonName } from './RadioButton.types'; import { RadioGroupContext } from './RadioGroup'; const DirectionalArrowKeys = { ArrowUp: 'ArrowUp', ArrowDown: 'ArrowDown', ArrowRight: 'ArrowRight', ArrowLeft: 'ArrowLeft', } as const; type DirectionalArrowKeys = (typeof DirectionalArrowKeys)[keyof typeof DirectionalArrowKeys]; export const RadioButton = compose({ displayName: radioButtonName, usePrepareProps: (userProps: IRadioButtonProps, useStyling: IUseComposeStyling) => { const defaultComponentRef = React.useRef(null); const { content, buttonKey, disabled, accessibilityLabel, ariaLabel, componentRef = defaultComponentRef, accessibilityPosInSet, ariaPosInSet, accessibilitySetSize, ariaSetSize, ...rest } = userProps; // Grabs the context information from RadioGroup (currently selected button and client's onChange callback) const info = React.useContext(RadioGroupContext); const { addRadioButtonEnabledKey, addRadioButtonKey, buttonKeys, enabledButtonKeys, invoked, onChange, removeRadioButtonEnabledKey, removeRadioButtonKey, selectedKey, updateInvoked, updateSelectedButtonRef, } = info; const buttonRef = useViewCommandFocus(componentRef); /* We don't want to call the user's onChange multiple times on the same selection. */ const changeSelection = React.useCallback(() => { if (buttonKey != selectedKey) { onChange && onChange(buttonKey); updateSelectedButtonRef && componentRef && updateSelectedButtonRef(componentRef); } }, [buttonKey, componentRef, onChange, selectedKey, updateSelectedButtonRef]); /* We use the componentRef of the currently selected button to maintain the default tabbable element in a RadioGroup. Since the componentRef isn't generated until after initial render, we must update it once here. */ React.useEffect(() => { if (buttonKey == selectedKey) { updateSelectedButtonRef && componentRef && updateSelectedButtonRef(componentRef); } // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run only on mount/unmount }, []); // Explicitly only run on mount and unmount React.useEffect(() => { addRadioButtonKey(buttonKey); if (!disabled) { addRadioButtonEnabledKey(buttonKey); } return () => { removeRadioButtonKey(buttonKey); removeRadioButtonEnabledKey(buttonKey); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run only on mount/unmount }, []); const isRTL = I18nManager.isRTL; const onInvoke = React.useCallback( (e: KeyPressEvent) => { if (e.nativeEvent.key in DirectionalArrowKeys) { const length = enabledButtonKeys.length; const next = e.nativeEvent.key === DirectionalArrowKeys.ArrowDown || (isRTL ? e.nativeEvent.key === DirectionalArrowKeys.ArrowLeft : e.nativeEvent.key === DirectionalArrowKeys.ArrowRight); const currRadioButtonIndex = enabledButtonKeys.indexOf(selectedKey); let newCurrRadioButtonIndex; if (next) { newCurrRadioButtonIndex = (currRadioButtonIndex + 1) % length; } else { // previous newCurrRadioButtonIndex = (currRadioButtonIndex - 1 + length) % length; } onChange && onChange(enabledButtonKeys[newCurrRadioButtonIndex]); updateInvoked && updateInvoked(true); } }, [enabledButtonKeys, isRTL, onChange, selectedKey, updateInvoked], ); // Sets the updated selected button ref and focus if this Radio is selected via arrow key. React.useEffect(() => { if (invoked && buttonKey === selectedKey && !disabled) { updateSelectedButtonRef && componentRef && updateSelectedButtonRef(componentRef); componentRef?.current?.focus(); updateInvoked && updateInvoked(false); } // eslint-disable-next-line react-hooks/exhaustive-deps -- only re-run when invoked changes }, [invoked]); const keys = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft']; // Explicitly override onKeyDown to override the native behavior of moving focus with arrow keys. const onKeyDownProps = useKeyDownProps(onInvoke, ...keys); // Ensure focus is placed on button after click const changeSelectionWithFocus = React.useCallback(() => { if (buttonKey != selectedKey) { onChange && onChange(buttonKey); updateSelectedButtonRef && componentRef && updateSelectedButtonRef(componentRef); updateInvoked && updateInvoked(true); } }, [buttonKey, componentRef, onChange, selectedKey, updateInvoked, updateSelectedButtonRef]); /* RadioButton changes selection when focus is moved between each RadioButton and on a click */ const pressable = useAsPressable({ ...rest, onPress: changeSelectionWithFocus, onFocus: changeSelection, }); // Used when creating accessibility properties in mergeSettings below const onAccessibilityAction = React.useCallback( (event: { nativeEvent: { actionName: any } }) => { switch (event.nativeEvent.actionName) { case 'Select': changeSelection(); break; } }, [changeSelection], ); const state = { ...pressable.state, selected: selectedKey === userProps.buttonKey && !disabled, disabled: disabled || false, }; // Grab the styling information from the userProps, referencing the state as well as the props. const styleProps = useStyling(userProps, (override: string) => state[override] || userProps[override]); const slotProps = mergeSettings(styleProps, { root: { ...rest, ref: buttonRef, ...pressable.props, accessibilityRole: 'radio', accessibilityLabel: accessibilityLabel ?? ariaLabel ?? content, accessibilityState: { disabled: state.disabled, selected: state.selected }, accessibilityActions: [{ name: 'Select', label: radioButtonSelectActionLabel }], accessibilityPosInSet: accessibilityPosInSet ?? ariaPosInSet ?? buttonKeys.findIndex((x) => x == buttonKey) + 1, accessibilitySetSize: accessibilitySetSize ?? ariaSetSize ?? buttonKeys.length, focusable: !state.disabled, onAccessibilityAction: onAccessibilityAction, ...onKeyDownProps, }, content: { children: content }, }); return { slotProps }; }, render: (Slots: ISlots, _renderData: IRadioButtonRenderData, ...children: React.ReactNode[]) => { return ( {children} ); }, settings, slots: { root: View, button: { slotType: View, filter: filterViewProps }, innerCircle: { slotType: View, filter: filterViewProps }, content: Text, }, styles: { root: [], button: [borderTokens], innerCircle: [backgroundColorTokens], content: [foregroundColorTokens, textTokens, [{ source: 'textBorderColor', lookup: getPaletteFromTheme, target: 'borderColor' }]], }, }); export default RadioButton;