/** @jsxImportSource @fluentui-react-native/framework-base */ import * as React from 'react'; import { I18nManager, Platform, View } from 'react-native'; import type { SvgIconProps } from '@fluentui-react-native/icon'; import { Icon, createIconProps } from '@fluentui-react-native/icon'; import { useKeyDownProps, useViewCommandFocus, useAsPressable } from '@fluentui-react-native/interactive-hooks'; import { Text } from '@fluentui-react-native/text'; import { backgroundColorTokens, borderTokens, textTokens, foregroundColorTokens, 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 type { SvgProps } from 'react-native-svg'; import { Svg, G, Path } from 'react-native-svg'; import { CMContext } from './ContextualMenu'; import { settings } from './SubmenuItem.settings'; import type { SubmenuItemSlotProps, SubmenuItemState, SubmenuItemProps, SubmenuItemRenderData, SubmenuItemType } from './SubmenuItem.types'; import { submenuItemName } from './SubmenuItem.types'; export const SubmenuItem = compose({ displayName: submenuItemName, usePrepareProps: (userProps: SubmenuItemProps, useStyling: IUseComposeStyling) => { const defaultComponentRef = React.useRef(null); const { onHoverIn, onHoverOut, disabled, expanded, itemKey, icon, text, accessibilityLabel = userProps.text, onAccessibilityTap, onClick, componentRef = defaultComponentRef, ...rest } = userProps; // Grabs the context information from Submenu (currently selected menuItem and client's onItemClick callback) const context = React.useContext(CMContext); const cmRef = useViewCommandFocus(componentRef); const onItemHoverIn = React.useCallback((e) => { componentRef.current.focus(); onHoverIn && onHoverIn(e); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run only on mount/unmount }, []); const onHoverInDelay = Platform.select({ macos: 100, default: 500, // win32 }); const onItemHoverOut = React.useCallback((e) => { context?.dismissSubmenu && context.dismissSubmenu(); onHoverOut && onHoverOut(e); // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally run only on mount/unmount }, []); const onItemPress = React.useCallback(() => { if (!disabled) { onClick && onClick(); context?.onItemClick && context.onItemClick(itemKey); } }, [context, disabled, itemKey, onClick]); const pressable = useAsPressable({ ...rest, onPress: onItemPress, onHoverIn: onItemHoverIn, delayHoverIn: onHoverInDelay, onHoverOut: onItemHoverOut, }); /** * GH #1267 * We want onMouseEnter to fire right away to set focus, and then Pressable's onHoverIn to fire after a delay to show the submenu. * To achieve this, we override the onMouseEnter handler returned by useAsPressable, and replace it with our own. Inside our own * onMouseEnter handler, we call useAsPressable's onMouseEnter handler, which incorporates the delay passed to delayHoverIn * In the future, we can avoid needing to override onMouseEnter by handling submenu rendering internally rather than depending on the * client to conditionally render it with onHoverIn. */ const { onBlur, onMouseEnter, onMouseLeave, ...restPressableProps } = pressable.props; const onMouseEnterModified = React.useCallback( (e) => { componentRef.current.focus(); onMouseEnter && onMouseEnter(e); }, [componentRef, onMouseEnter], ); const onMouseLeaveModified = React.useCallback( (e) => { onBlur(e); onMouseLeave && onMouseLeave(e); }, [onBlur, onMouseLeave], ); const pressablePropsModified = { onBlur: onBlur, onMouseEnter: onMouseEnterModified, onMouseLeave: onMouseLeaveModified, ...restPressableProps, }; const state: SubmenuItemState = { ...pressable.state, selected: context.selectedKey === userProps.itemKey, disabled: userProps.disabled, content: !!text, icon: !!icon, }; const showSubmenuOnKeyDown = React.useCallback( (e: any) => { const rtlAwareOpenArrowKey = I18nManager.isRTL ? 'ArrowLeft' : 'ArrowRight'; if (e.nativeEvent.key === rtlAwareOpenArrowKey || e.nativeEvent.key === ' ' || e.nativeEvent.key === 'Enter') { onItemHoverIn(e); } }, [onItemHoverIn], ); const chevronSvg: React.FunctionComponent = (props: SvgProps) => { return ( ); }; const svgProps: SvgIconProps = { src: chevronSvg, }; /** * SubmenuItem launches the submenu onMouseEnter event. Submenu should be launched with Spacebar, Enter, or right arrow (flipped for RTL). * Explicitly override onKeyDown to override the native windows behavior of moving focus with arrow keys. */ const onKeyDownProps = useKeyDownProps(showSubmenuOnKeyDown, ' ', 'Enter', 'ArrowLeft', 'ArrowRight'); const onAccTap = onAccessibilityTap ?? onItemPress; // Default accessibility actions to help screen readers announce expanded/collapsed state // Only provide on win32 to follow platform-specific accessibility patterns const defaultAccessibilityActions = React.useMemo(() => { if (Platform.OS === ('win32' as any)) { return [ { name: 'Expand', label: 'Expand submenu' }, { name: 'Collapse', label: 'Collapse submenu' }, ]; } return []; }, []); // Merge user accessibility actions with defaults const finalAccessibilityActions = React.useMemo(() => { const userActions = userProps.accessibilityActions; if (userActions && userActions.length > 0) { return [...defaultAccessibilityActions, ...userActions]; } return defaultAccessibilityActions; }, [userProps.accessibilityActions, defaultAccessibilityActions]); const styleProps = useStyling(userProps, (override: string) => state[override] || userProps[override]); // create the merged slot props const slotProps = mergeSettings(styleProps, { root: { ref: cmRef, ...pressablePropsModified, ...onKeyDownProps, ...rest, accessible: true, accessibilityLabel: accessibilityLabel, accessibilityRole: 'menuitem', accessibilityState: { disabled: state.disabled ?? false, expanded: expanded ?? false, selected: state.selected }, accessibilityValue: { text: itemKey }, accessibilityActions: finalAccessibilityActions, disabled, focusable: !disabled, onAccessibilityTap: onAccTap, }, content: { accessible: false, children: text, }, icon: { ...createIconProps(icon), accessible: false, }, chevron: { ...createIconProps({ svgSource: svgProps, width: 12, height: 12 }), accessible: false, }, }); return { slotProps, state }; }, settings, render: (Slots: ISlots, renderData: SubmenuItemRenderData, ...children: React.ReactNode[]) => { // We shouldn't have to specify the source prop on Slots.icon, here, but we need another drop from @uifabricshared return ( {renderData!.state.icon && } {renderData!.state.content && } {children} ); }, slots: { root: View, startstack: View, icon: Icon as React.ComponentType, content: Text, endstack: View, chevron: Icon as React.ComponentType, }, styles: { root: [backgroundColorTokens, borderTokens], startstack: [], icon: [{ source: 'iconColor', lookup: getPaletteFromTheme, target: 'color' }], content: [textTokens, foregroundColorTokens], endstack: [], chevron: [{ source: 'chevronColor', lookup: getPaletteFromTheme, target: 'color' }], }, }); export default SubmenuItem;