diff --git a/change/@fluentui-react-menu-d76677a3-6018-4f05-a403-e59905ec8e5f.json b/change/@fluentui-react-menu-d76677a3-6018-4f05-a403-e59905ec8e5f.json new file mode 100644 index 00000000000000..3f806a34a920eb --- /dev/null +++ b/change/@fluentui-react-menu-d76677a3-6018-4f05-a403-e59905ec8e5f.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: expose react-menu base hooks", + "packageName": "@fluentui/react-menu", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-menu/library/etc/react-menu.api.md b/packages/react-components/react-menu/library/etc/react-menu.api.md index 9814f2ee71551c..d7252e8a72a105 100644 --- a/packages/react-components/react-menu/library/etc/react-menu.api.md +++ b/packages/react-components/react-menu/library/etc/react-menu.api.md @@ -32,6 +32,12 @@ export const Menu: React_2.FC; // @public export const MENU_ENTER_EVENT = "fuimenuenter"; +// @public (undocumented) +export type MenuBaseProps = Omit; + +// @public (undocumented) +export type MenuBaseState = Omit; + // @public (undocumented) export type MenuCheckedValueChangeData = { checkedItems: string[]; @@ -167,6 +173,12 @@ export type MenuItemProps = Omit>, 'conten // @public export const MenuItemRadio: ForwardRefComponent; +// @public +export type MenuItemRadioBaseProps = MenuItemRadioProps; + +// @public +export type MenuItemRadioBaseState = MenuItemRadioState; + // @public (undocumented) export const menuItemRadioClassNames: SlotClassNames>; @@ -482,6 +494,13 @@ export const useMenu_unstable: (props: MenuProps & { }; }) => MenuState; +// @public +export const useMenuBase_unstable: (props: MenuBaseProps & { + safeZone?: boolean | { + timeout?: number; + }; +}) => MenuBaseState; + // @public (undocumented) export const useMenuContext_unstable: (selector: ContextSelector) => T; @@ -515,21 +534,33 @@ export const useMenuGroupStyles_unstable: (state: MenuGroupState) => MenuGroupSt // @public export const useMenuItem_unstable: (props: MenuItemProps, ref: React_2.Ref>) => MenuItemState; +// @public +export const useMenuItemBase_unstable: (props: MenuItemProps, ref: React_2.Ref>) => MenuItemState; + // @public export const useMenuItemCheckbox_unstable: (props: MenuItemCheckboxProps, ref: React_2.Ref>) => MenuItemCheckboxState; +// @public +export const useMenuItemCheckboxBase_unstable: (props: MenuItemCheckboxProps, ref: React_2.Ref>) => MenuItemCheckboxState; + // @public (undocumented) export const useMenuItemCheckboxStyles_unstable: (state: MenuItemCheckboxState) => MenuItemCheckboxState; // @public export const useMenuItemLink_unstable: (props: MenuItemLinkProps, ref: React_2.Ref) => MenuItemLinkState; +// @public +export const useMenuItemLinkBase_unstable: (props: MenuItemLinkProps, ref: React_2.Ref) => MenuItemLinkState; + // @public export const useMenuItemLinkStyles_unstable: (state: MenuItemLinkState) => MenuItemLinkState; // @public export const useMenuItemRadio_unstable: (props: MenuItemRadioProps, ref: React_2.Ref>) => MenuItemRadioState; +// @public +export const useMenuItemRadioBase_unstable: (props: MenuItemRadioBaseProps, ref: React_2.Ref>) => MenuItemRadioBaseState; + // @public (undocumented) export const useMenuItemRadioStyles_unstable: (state: MenuItemRadioState) => void; @@ -539,12 +570,18 @@ export const useMenuItemStyles_unstable: (state: MenuItemState) => MenuItemState // @public export const useMenuItemSwitch_unstable: (props: MenuItemSwitchProps, ref: React_2.Ref) => MenuItemSwitchState; +// @public +export const useMenuItemSwitchBase_unstable: (props: MenuItemSwitchProps, ref: React_2.Ref) => MenuItemSwitchState; + // @public export const useMenuItemSwitchStyles_unstable: (state: MenuItemSwitchState) => MenuItemSwitchState; // @public export const useMenuList_unstable: (props: MenuListProps, ref: React_2.Ref) => MenuListState; +// @public +export const useMenuListBase_unstable: (props: MenuListProps, ref: React_2.Ref) => MenuListState; + // @public (undocumented) export const useMenuListContext_unstable: (selector: ContextSelector) => T; @@ -557,6 +594,9 @@ export const useMenuListStyles_unstable: (state: MenuListState) => MenuListState // @public export const useMenuPopover_unstable: (props: MenuPopoverProps, ref: React_2.Ref) => MenuPopoverState; +// @public +export const useMenuPopoverBase_unstable: (props: MenuPopoverProps, ref: React_2.Ref) => MenuPopoverState; + // @public export const useMenuPopoverStyles_unstable: (state: MenuPopoverState) => MenuPopoverState; @@ -569,6 +609,14 @@ export const useMenuSplitGroupStyles_unstable: (state: MenuSplitGroupState) => M // @public export const useMenuTrigger_unstable: (props: MenuTriggerProps) => MenuTriggerState; +// @public +export const useMenuTriggerBase_unstable: (props: MenuTriggerProps, options?: UseMenuTriggerBaseOptions) => MenuTriggerState; + +// @public +export type UseMenuTriggerBaseOptions = { + focusFirst?: () => void; +}; + // @public (undocumented) export const useMenuTriggerContext_unstable: () => boolean; diff --git a/packages/react-components/react-menu/library/src/Menu.ts b/packages/react-components/react-menu/library/src/Menu.ts index 4a6cc2cb8481fd..5a5589befb9cc0 100644 --- a/packages/react-components/react-menu/library/src/Menu.ts +++ b/packages/react-components/react-menu/library/src/Menu.ts @@ -1,4 +1,6 @@ export type { + MenuBaseProps, + MenuBaseState, MenuContextValues, MenuOpenChangeData, MenuOpenEvent, @@ -8,4 +10,10 @@ export type { MenuSlots, MenuState, } from './components/Menu/index'; -export { Menu, renderMenu_unstable, useMenuContextValues_unstable, useMenu_unstable } from './components/Menu/index'; +export { + Menu, + renderMenu_unstable, + useMenuContextValues_unstable, + useMenuBase_unstable, + useMenu_unstable, +} from './components/Menu/index'; diff --git a/packages/react-components/react-menu/library/src/MenuItemLink.ts b/packages/react-components/react-menu/library/src/MenuItemLink.ts index 3fe714228eda6e..8dc273c4cf011d 100644 --- a/packages/react-components/react-menu/library/src/MenuItemLink.ts +++ b/packages/react-components/react-menu/library/src/MenuItemLink.ts @@ -3,6 +3,7 @@ export { MenuItemLink, menuItemLinkClassNames, renderMenuItemLink_unstable, + useMenuItemLinkBase_unstable, useMenuItemLinkStyles_unstable, useMenuItemLink_unstable, } from './components/MenuItemLink/index'; diff --git a/packages/react-components/react-menu/library/src/MenuItemRadio.ts b/packages/react-components/react-menu/library/src/MenuItemRadio.ts index b34a0459617a4f..885718903a94e0 100644 --- a/packages/react-components/react-menu/library/src/MenuItemRadio.ts +++ b/packages/react-components/react-menu/library/src/MenuItemRadio.ts @@ -1,9 +1,14 @@ -export type { MenuItemRadioProps, MenuItemRadioState } from './components/MenuItemRadio/index'; +export type { + MenuItemRadioBaseProps, + MenuItemRadioBaseState, + MenuItemRadioProps, + MenuItemRadioState, +} from './components/MenuItemRadio/index'; export { MenuItemRadio, menuItemRadioClassNames, renderMenuItemRadio_unstable, + useMenuItemRadioBase_unstable, useMenuItemRadioStyles_unstable, useMenuItemRadio_unstable, - useMenuItemRadioBase_unstable, } from './components/MenuItemRadio/index'; diff --git a/packages/react-components/react-menu/library/src/MenuList.ts b/packages/react-components/react-menu/library/src/MenuList.ts index bdf7061fb1a954..329d8a22f455b3 100644 --- a/packages/react-components/react-menu/library/src/MenuList.ts +++ b/packages/react-components/react-menu/library/src/MenuList.ts @@ -12,6 +12,7 @@ export { MenuList, menuListClassNames, renderMenuList_unstable, + useMenuListBase_unstable, useMenuListContextValues_unstable, useMenuListStyles_unstable, useMenuList_unstable, diff --git a/packages/react-components/react-menu/library/src/MenuPopover.ts b/packages/react-components/react-menu/library/src/MenuPopover.ts index 5e5ca3d44f3cdb..149421500fc622 100644 --- a/packages/react-components/react-menu/library/src/MenuPopover.ts +++ b/packages/react-components/react-menu/library/src/MenuPopover.ts @@ -3,6 +3,7 @@ export { MenuPopover, menuPopoverClassNames, renderMenuPopover_unstable, + useMenuPopoverBase_unstable, useMenuPopoverStyles_unstable, useMenuPopover_unstable, } from './components/MenuPopover/index'; diff --git a/packages/react-components/react-menu/library/src/MenuTrigger.ts b/packages/react-components/react-menu/library/src/MenuTrigger.ts index eb982a7695a6fc..aabd7fa221051e 100644 --- a/packages/react-components/react-menu/library/src/MenuTrigger.ts +++ b/packages/react-components/react-menu/library/src/MenuTrigger.ts @@ -1,2 +1,12 @@ -export type { MenuTriggerChildProps, MenuTriggerProps, MenuTriggerState } from './components/MenuTrigger/index'; -export { MenuTrigger, renderMenuTrigger_unstable, useMenuTrigger_unstable } from './components/MenuTrigger/index'; +export type { + MenuTriggerChildProps, + MenuTriggerProps, + MenuTriggerState, + UseMenuTriggerBaseOptions, +} from './components/MenuTrigger/index'; +export { + MenuTrigger, + renderMenuTrigger_unstable, + useMenuTrigger_unstable, + useMenuTriggerBase_unstable, +} from './components/MenuTrigger/index'; diff --git a/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts b/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts index 6cdb0b25a438ee..d0a69fe14e4179 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts +++ b/packages/react-components/react-menu/library/src/components/Menu/Menu.types.ts @@ -187,6 +187,10 @@ export type MenuState = ComponentState & safeZone?: React.ReactElement | null; }; +export type MenuBaseProps = Omit; + +export type MenuBaseState = Omit; + export type MenuContextValues = { menu: MenuContextValue; }; diff --git a/packages/react-components/react-menu/library/src/components/Menu/index.ts b/packages/react-components/react-menu/library/src/components/Menu/index.ts index f8f52e39aa00d2..7d311530cfb429 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/index.ts +++ b/packages/react-components/react-menu/library/src/components/Menu/index.ts @@ -1,5 +1,7 @@ export { Menu } from './Menu'; export type { + MenuBaseProps, + MenuBaseState, MenuContextValues, MenuOpenChangeData, MenuOpenEvent, @@ -10,5 +12,5 @@ export type { MenuState, } from './Menu.types'; export { renderMenu_unstable } from './renderMenu'; -export { useMenu_unstable } from './useMenu'; +export { useMenu_unstable, useMenuBase_unstable } from './useMenu'; export { useMenuContextValues_unstable } from './useMenuContextValues'; diff --git a/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx b/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx index d644f06c6e7d69..fb171f96e968a8 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx +++ b/packages/react-components/react-menu/library/src/components/Menu/useMenu.tsx @@ -27,7 +27,14 @@ import { useFocusFinders } from '@fluentui/react-tabster'; import { useMenuContext_unstable } from '../../contexts/menuContext'; import { MENU_SAFEZONE_TIMEOUT_EVENT, MENU_ENTER_EVENT, useOnMenuMouseEnter, useIsSubmenu } from '../../utils'; import { menuItemClassNames } from '../MenuItem/useMenuItemStyles.styles'; -import type { MenuOpenChangeData, MenuOpenEvent, MenuProps, MenuState } from './Menu.types'; +import type { + MenuBaseProps, + MenuBaseState, + MenuOpenChangeData, + MenuOpenEvent, + MenuProps, + MenuState, +} from './Menu.types'; import { MenuSurfaceMotion } from './MenuSurfaceMotion'; // If it's not possible to position the submenu in smaller viewports, try @@ -50,6 +57,34 @@ const submenuFallbackPositions: PositioningShorthandValue[] = [ * @param props - props from this instance of Menu */ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { timeout?: number } }): MenuState => { + const { surfaceMotion, ...baseProps } = props; + const baseState = useMenuBase_unstable(baseProps); + + return { + ...baseState, + components: { + surfaceMotion: MenuSurfaceMotion, + }, + surfaceMotion: presenceMotionSlot(surfaceMotion, { + elementType: MenuSurfaceMotion, + defaultProps: { + visible: baseState.open, + appear: true, + unmountOnExit: true, + }, + }), + }; +}; + +/** + * Base hook for Menu component, produces state required to render the component. + * It doesn't set any design-related slots specific to Menu such as `surfaceMotion`. + * + * @param props - props from this instance of Menu + */ +export const useMenuBase_unstable = ( + props: MenuBaseProps & { safeZone?: boolean | { timeout?: number } }, +): MenuBaseState => { const isSubmenu = useIsSubmenu(); const { hoverDelay = 500, @@ -190,9 +225,6 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim mountNode, triggerRef, menuPopoverRef, - components: { - surfaceMotion: MenuSurfaceMotion, - }, openOnContext, open, setOpen, @@ -200,14 +232,6 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim onCheckedValueChange, persistOnItemClick, safeZone: safeZoneHandle.elementToRender, - surfaceMotion: presenceMotionSlot(props.surfaceMotion, { - elementType: MenuSurfaceMotion, - defaultProps: { - visible: open, - appear: true, - unmountOnExit: true, - }, - }), }; }; diff --git a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx index 0b4a63b7180ec3..06b2ac1b55cddf 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItem/useMenuItem.tsx @@ -48,8 +48,6 @@ export const useMenuItem_unstable = (props: MenuItemProps, ref: React.Ref, +): MenuItemLinkState => { + const baseState = useMenuItemBase_unstable(props as MenuItemProps, null); + const _props = { ...props, ...(baseState.root as ExtractSlotProps>), ref, tabIndex: props.tabIndex }; + + return { + ...baseState, + components: { + // eslint-disable-next-line @typescript-eslint/no-deprecated + ...baseState.components, + root: 'a', + }, + root: slot.always( + getIntrinsicElementProps('a', { + role: 'menuitem', + ..._props, + }), + { elementType: 'a' }, + ), + }; +}; diff --git a/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.tsx b/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.tsx index f3c202078cc0d8..84549c96f60dfb 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItemRadio/useMenuItemRadio.tsx @@ -33,8 +33,6 @@ export const useMenuItemRadio_unstable = ( /** * Base hook for MenuItemRadio component, produces state required to render the component. * It doesn't set any design-related props specific to MenuItemRadio. - * - * @internal */ export const useMenuItemRadioBase_unstable = ( props: MenuItemRadioBaseProps, diff --git a/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.tsx b/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.tsx index 04d6aa81b47a21..7b7a4097f1a7be 100644 --- a/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuItemSwitch/useMenuItemSwitch.tsx @@ -34,7 +34,6 @@ export const useMenuItemSwitch_unstable = ( * Base hook for MenuItemSwitch component, produces state required to render the component. * It doesn't set any design-related props specific to MenuItemSwitch. * - * @internal * @param props - props from this instance of MenuItemSwitch * @param ref - reference to root HTMLDivElement of MenuItemSwitch */ diff --git a/packages/react-components/react-menu/library/src/components/MenuList/index.ts b/packages/react-components/react-menu/library/src/components/MenuList/index.ts index eb018a4ed49ead..c3ab47a74f6bd7 100644 --- a/packages/react-components/react-menu/library/src/components/MenuList/index.ts +++ b/packages/react-components/react-menu/library/src/components/MenuList/index.ts @@ -10,6 +10,6 @@ export type { UninitializedMenuListState, } from './MenuList.types'; export { renderMenuList_unstable } from './renderMenuList'; -export { useMenuList_unstable } from './useMenuList'; +export { useMenuList_unstable, useMenuListBase_unstable } from './useMenuList'; export { menuListClassNames, useMenuListStyles_unstable } from './useMenuListStyles.styles'; export { useMenuListContextValues_unstable } from './useMenuListContextValues'; diff --git a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts index 059387d90ff042..66a155c31ebbbe 100644 --- a/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts +++ b/packages/react-components/react-menu/library/src/components/MenuList/useMenuList.ts @@ -21,15 +21,21 @@ import { MenuContext } from '../../contexts/menuContext'; import type { MenuListProps, MenuListState } from './MenuList.types'; import { useValidateNesting } from '../../utils/useValidateNesting'; +const MENU_ITEM_ROLES = ['menuitem', 'menuitemcheckbox', 'menuitemradio']; +const MENU_ITEM_ROLES_SELECTOR = MENU_ITEM_ROLES.map(role => `[role="${role}"]`).join(','); + /** - * Returns the props and state required to render the component + * Returns the props and state required to render the component. + * + * Composes with `useMenuListBase_unstable` and adds Tabster-driven keyboard + * navigation: circular arrow-key focus, a `TabsterMoveFocusEvent` listener + * that lets `useMenuPopover_unstable` handle Tab key presses, a focus-aware + * `setFocusByFirstCharacter`, and the `hasIcons` / `hasCheckmarks` slot + * alignment hints sourced from the parent `MenuContext`. */ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref): MenuListState => { - const { findAllFocusable } = useFocusFinders(); - const { targetDocument } = useFluent(); const menuContext = useMenuContextSelectors(); const hasMenuContext = useHasParentContext(MenuContext); - const focusAttributes = useArrowNavigationGroup({ circular: true }); if (usingPropsAndMenuContext(props, menuContext, hasMenuContext)) { // TODO throw warnings in development safely @@ -37,84 +43,101 @@ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref(null); - const validateNestingRef = useValidateNesting('MenuList'); + const wrapperRef = React.useRef(null); + const { findAllFocusable } = useFocusFinders(); + const { targetDocument } = useFluent(); + const focusAttributes = useArrowNavigationGroup({ circular: true }); - React.useEffect(() => { - const element = innerRef.current; + const baseState = useMenuListBase_unstable(props, ref); + // recreate root non-mutatively: merge wrapperRef so the effect below can + // observe the rendered DOM element, and add Tabster arrow-nav attributes + const mergedRootRef = useMergedRefs(baseState.root.ref, wrapperRef) as React.Ref; - if (hasMenuContext && targetDocument && element) { - const onTabsterMoveFocus = (e: TabsterMoveFocusEvent) => { - const nextElement = e.detail.next; + React.useEffect(() => { + const element = wrapperRef.current; - if (nextElement && element.contains(targetDocument.activeElement) && !element.contains(nextElement)) { - // Preventing Tabster from handling Tab press, useMenuPopover will handle it. - e.preventDefault(); - } - }; + if (!hasMenuContext || !targetDocument || !element) { + return; + } - targetDocument.addEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); + const onTabsterMoveFocus = (e: TabsterMoveFocusEvent) => { + const nextElement = e.detail.next; + if (nextElement && element.contains(targetDocument.activeElement) && !element.contains(nextElement)) { + // Preventing Tabster from handling Tab press, useMenuPopover will handle it. + e.preventDefault(); + } + }; - return () => { - targetDocument.removeEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); - }; - } - }, [innerRef, targetDocument, hasMenuContext]); + targetDocument.addEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); + return () => { + targetDocument.removeEventListener(TabsterMoveFocusEventName, onTabsterMoveFocus); + }; + }, [hasMenuContext, targetDocument]); const setFocusByFirstCharacter = React.useCallback( (e: React.KeyboardEvent, itemEl: HTMLElement) => { - // TODO use some kind of children registration to reduce dependency on DOM roles - const acceptedRoles = ['menuitem', 'menuitemcheckbox', 'menuitemradio']; - if (!innerRef.current) { + if (!wrapperRef.current) { return; } - const menuItems = findAllFocusable( - innerRef.current, - (el: HTMLElement) => el.hasAttribute('role') && acceptedRoles.indexOf(el.getAttribute('role')!) !== -1, + wrapperRef.current, + (el: HTMLElement) => el.hasAttribute('role') && MENU_ITEM_ROLES.indexOf(el.getAttribute('role')!) !== -1, ); + focusItemMatchingFirstCharacter(menuItems, e.key, itemEl); + }, + [findAllFocusable], + ); - let startIndex = menuItems.indexOf(itemEl) + 1; - if (startIndex === menuItems.length) { - startIndex = 0; - } - - const firstChars = menuItems.map(menuItem => menuItem.textContent?.charAt(0).toLowerCase()); - const char = e.key.toLowerCase(); - - const getIndexFirstChars = (start: number, firstChar: string) => { - for (let i = start; i < firstChars.length; i++) { - if (char === firstChars[i]) { - return i; - } - } - return -1; - }; + return { + ...baseState, + root: { + ...focusAttributes, + ...baseState.root, + ref: mergedRootRef, + }, + setFocusByFirstCharacter, + }; +}; - // Check remaining slots in the menu - let index = getIndexFirstChars(startIndex, char); +/** + * Base hook for MenuList component, produces state required to render the component. + * + * Does not invoke any Tabster APIs internally: arrow-key navigation and the + * focus-aware `setFocusByFirstCharacter` are added by the wrapper + * `useMenuList_unstable`. The base's `setFocusByFirstCharacter` walks the DOM + * via `querySelectorAll` and does not filter by Tabster's focusability rules, + * so consumers integrating their own focus management should layer that on top. + * + * @param props - props from this instance of MenuList + * @param ref - reference to root HTMLElement of MenuList + */ +export const useMenuListBase_unstable = (props: MenuListProps, ref: React.Ref): MenuListState => { + const triggerId = useMenuContext_unstable(context => context.triggerId); + const checkedValuesContext = useMenuContext_unstable(context => context.checkedValues); + const onCheckedValueChangeContext = useMenuContext_unstable(context => context.onCheckedValueChange); + const hasIconsContext = useMenuContext_unstable(context => context.hasIcons); + const hasCheckmarksContext = useMenuContext_unstable(context => context.hasCheckmarks); + const hasMenuContext = useHasParentContext(MenuContext); - // If not found in remaining slots, check from beginning - if (index === -1) { - index = getIndexFirstChars(0, char); - } + const innerRef = React.useRef(null); + const validateNestingRef = useValidateNesting('MenuList'); - // If match was found... - if (index > -1) { - menuItems[index].focus(); - } - }, - [findAllFocusable], - ); + const setFocusByFirstCharacter = React.useCallback((e: React.KeyboardEvent, itemEl: HTMLElement) => { + if (!innerRef.current) { + return; + } + const menuItems = Array.from(innerRef.current.querySelectorAll(MENU_ITEM_ROLES_SELECTOR)); + focusItemMatchingFirstCharacter(menuItems, e.key, itemEl); + }, []); const [checkedValues, setCheckedValues] = useControllableState({ - state: props.checkedValues ?? (hasMenuContext ? menuContext.checkedValues : undefined), + state: props.checkedValues ?? (hasMenuContext ? checkedValuesContext : undefined), defaultState: props.defaultCheckedValues, initialState: {}, }); const handleCheckedValueChange = - props.onCheckedValueChange ?? (hasMenuContext ? menuContext.onCheckedValueChange : undefined); + props.onCheckedValueChange ?? (hasMenuContext ? onCheckedValueChangeContext : undefined); const toggleCheckbox = useEventCallback( (e: React.MouseEvent | React.KeyboardEvent, name: string, value: string, checked: boolean) => { @@ -148,15 +171,14 @@ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref, role: 'menu', - 'aria-labelledby': menuContext.triggerId, - ...focusAttributes, + 'aria-labelledby': triggerId, ...props, }), { elementType: 'div' }, ), - hasIcons: menuContext.hasIcons || false, - hasCheckmarks: menuContext.hasCheckmarks || false, checkedValues, + hasIcons: props.hasIcons ?? hasIconsContext ?? false, + hasCheckmarks: props.hasCheckmarks ?? hasCheckmarksContext ?? false, hasMenuContext, setFocusByFirstCharacter, selectRadio, @@ -164,6 +186,38 @@ export const useMenuList_unstable = (props: MenuListProps, ref: React.Ref { + let startIndex = menuItems.indexOf(current) + 1; + if (startIndex === menuItems.length) { + startIndex = 0; + } + + const firstChars = menuItems.map(menuItem => menuItem.textContent?.charAt(0).toLowerCase()); + const char = key.toLowerCase(); + + const getIndexFirstChars = (start: number) => { + for (let i = start; i < firstChars.length; i++) { + if (char === firstChars[i]) { + return i; + } + } + return -1; + }; + + let index = getIndexFirstChars(startIndex); + if (index === -1) { + index = getIndexFirstChars(0); + } + if (index > -1) { + menuItems[index].focus(); + } +}; + /** * Adds some sugar to fetching multiple context selector values */ diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts index b35443895f1cf3..ade82b0723b4f1 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/index.ts @@ -1,5 +1,5 @@ export { MenuPopover } from './MenuPopover'; export type { MenuPopoverProps, MenuPopoverSlots, MenuPopoverState } from './MenuPopover.types'; export { renderMenuPopover_unstable } from './renderMenuPopover'; -export { useMenuPopover_unstable } from './useMenuPopover'; +export { useMenuPopover_unstable, useMenuPopoverBase_unstable } from './useMenuPopover'; export { menuPopoverClassNames, useMenuPopoverStyles_unstable } from './useMenuPopoverStyles.styles'; diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts index 7b690b64123dc5..d225d6e66ca78e 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/useMenuPopover.ts @@ -13,15 +13,16 @@ import { dispatchMenuEnterEvent, useIsSubmenu } from '../../utils/index'; import type { MenuPopoverProps, MenuPopoverState } from './MenuPopover.types'; /** - * Create the state required to render MenuPopover. + * Base hook for MenuPopover, produces state required to render the component. * - * The returned state can be modified with hooks such as useMenuPopoverStyles_unstable, - * before being passed to renderMenuPopover_unstable. + * Does not invoke `@fluentui/react-tabster` focus-restoration or + * `@fluentui/react-motion` ref-forwarding APIs internally; the wrapper + * `useMenuPopover_unstable` layers those on top. * * @param props - props from this instance of MenuPopover * @param ref - reference to root HTMLElement of MenuPopover */ -export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { +export const useMenuPopoverBase_unstable = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { 'use no memo'; const safeZone = useMenuContext_unstable(context => context.safeZone); @@ -35,7 +36,6 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< const shouldCloseOnArrowLeft = useMenuListContext_unstable(ctx => ctx.shouldCloseOnArrowLeft ?? true); const canDispatchCustomEventRef = React.useRef(true); - const restoreFocusSourceAttributes = useRestoreFocusSource(); const [setThrottleTimeout, clearThrottleTimeout] = useTimeout(); const { dir } = useFluent(); @@ -73,20 +73,15 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< const rootProps = slot.always( getIntrinsicElementProps('div', { role: 'presentation', - ...restoreFocusSourceAttributes, ...props, // FIXME: // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` // but since it would be a breaking change to fix it, we are casting ref to it's proper type - ref: useMergedRefs( - ref, - popoverRef, - mouseOverListenerCallbackRef, - useMotionForwardedRef(), - ) as React.Ref, + ref: useMergedRefs(ref, popoverRef, mouseOverListenerCallbackRef) as React.Ref, }), { elementType: 'div' }, ); + const { onMouseEnter: onMouseEnterOriginal, onKeyDown: onKeyDownOriginal } = rootProps; rootProps.onMouseEnter = useEventCallback((event: React.MouseEvent) => { if (openOnHover || isSubmenu) { @@ -94,6 +89,7 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< } onMouseEnterOriginal?.(event); }); + rootProps.onKeyDown = useEventCallback((event: React.KeyboardEvent) => { const key = event.key; if (key === Escape || (isSubmenu && shouldCloseOnArrowLeft && key === CloseArrowKey)) { @@ -121,3 +117,27 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< root: rootProps, }; }; + +/** + * Create the state required to render MenuPopover. + * + * The returned state can be modified with hooks such as useMenuPopoverStyles_unstable, + * before being passed to renderMenuPopover_unstable. + * + * @param props - props from this instance of MenuPopover + * @param ref - reference to root HTMLElement of MenuPopover + */ +export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref): MenuPopoverState => { + const restoreFocusSourceAttributes = useRestoreFocusSource(); + const motionRef = useMotionForwardedRef(); + const baseState = useMenuPopoverBase_unstable(props, ref); + + return { + ...baseState, + root: { + ...restoreFocusSourceAttributes, + ...baseState.root, + ref: useMergedRefs(baseState.root.ref, motionRef) as React.Ref, + }, + }; +}; diff --git a/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts b/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts index 9b1124f7ee6a47..40b0220ee18f85 100644 --- a/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts +++ b/packages/react-components/react-menu/library/src/components/MenuTrigger/index.ts @@ -1,4 +1,5 @@ export { MenuTrigger } from './MenuTrigger'; export type { MenuTriggerChildProps, MenuTriggerProps, MenuTriggerState } from './MenuTrigger.types'; export { renderMenuTrigger_unstable } from './renderMenuTrigger'; -export { useMenuTrigger_unstable } from './useMenuTrigger'; +export { useMenuTrigger_unstable, useMenuTriggerBase_unstable } from './useMenuTrigger'; +export type { UseMenuTriggerBaseOptions } from './useMenuTrigger'; diff --git a/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts b/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts index 152c45a0f09ae7..29bad0a108e578 100644 --- a/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts +++ b/packages/react-components/react-menu/library/src/components/MenuTrigger/useMenuTrigger.ts @@ -26,15 +26,62 @@ function noop() { /** * Create the state required to render MenuTrigger. - * Clones the only child component and adds necessary event handling behaviours to open a popup menu + * Clones the only child component and adds necessary event handling behaviours to open a popup menu. + * + * Composes with `useMenuTriggerBase_unstable` and supplies Tabster's `findFirstFocusable` + * as the `focusFirst` callback so that submenu-trigger keyboard navigation honours + * Tabster movers, focus traps, and other Tabster-managed state. * * @param props - props from this instance of MenuTrigger */ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerState => { + const menuPopoverRef = useMenuContext_unstable(context => context.menuPopoverRef); + + const { findFirstFocusable } = useFocusFinders(); + + const focusFirst = React.useCallback(() => { + const firstFocusable = findFirstFocusable(menuPopoverRef.current); + firstFocusable?.focus(); + }, [findFirstFocusable, menuPopoverRef]); + + return useMenuTriggerBase_unstable(props, { focusFirst }); +}; + +/** + * Options accepted by `useMenuTriggerBase_unstable`. + */ +export type UseMenuTriggerBaseOptions = { + /** + * Pluggable "focus the first focusable element in the menu popover" callback, + * invoked when an already-open submenu trigger receives the open arrow key. + * + * Not provided by the base hook itself - the base hook is intentionally headless + * and leaves focus discovery to the caller. `useMenuTrigger_unstable` plugs in a + * Tabster-aware implementation; a headless consumer is expected to supply its own. + * If omitted, the keyboard handler is a no-op for that case. + */ + focusFirst?: () => void; +}; + +/** + * Base hook for MenuTrigger component, produces state required to render the component. + * + * Headless: this hook does not import from `@fluentui/react-tabster` and does not + * perform any focus discovery on its own. The submenu-already-open arrow-key path + * delegates to the optional `options.focusFirst` callback, which lets consumers wire + * up whichever focus-finding strategy fits their environment (Tabster, a native DOM + * query, a virtual focus manager, etc.). When `focusFirst` is not provided that path + * becomes a no-op. + * + * @public + */ +export const useMenuTriggerBase_unstable = ( + props: MenuTriggerProps, + options?: UseMenuTriggerBaseOptions, +): MenuTriggerState => { const { children, disableButtonEnhancement = false } = props; const triggerRef = useMenuContext_unstable(context => context.triggerRef); - const menuPopoverRef = useMenuContext_unstable(context => context.menuPopoverRef); const setOpen = useMenuContext_unstable(context => context.setOpen); const open = useMenuContext_unstable(context => context.open); const triggerId = useMenuContext_unstable(context => context.triggerId); @@ -44,11 +91,7 @@ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerSta const isSubmenu = useIsSubmenu(); const shouldOpenOnArrowRight = useMenuListContext_unstable(ctx => ctx.shouldOpenOnArrowRight ?? true); - const { findFirstFocusable } = useFocusFinders(); - const focusFirst = React.useCallback(() => { - const firstFocusable = findFirstFocusable(menuPopoverRef.current); - firstFocusable?.focus(); - }, [findFirstFocusable, menuPopoverRef]); + const focusFirst = options?.focusFirst; const openedWithKeyboardRef = React.useRef(false); const openedViaSafeZoneRef = React.useRef(false); @@ -113,7 +156,7 @@ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerSta // if menu is already open, can't rely on effects to focus if (open && key === OpenArrowKey && isSubmenu && shouldOpenOnArrowRight) { - focusFirst(); + focusFirst?.(); } }; diff --git a/packages/react-components/react-menu/library/src/index.ts b/packages/react-components/react-menu/library/src/index.ts index 056aa6df4e1b00..ff5dd5dab0a4fb 100644 --- a/packages/react-components/react-menu/library/src/index.ts +++ b/packages/react-components/react-menu/library/src/index.ts @@ -6,8 +6,16 @@ export type { MenuGroupContextValue } from './contexts/menuGroupContext'; export { MenuListProvider, useMenuListContext_unstable } from './contexts/menuListContext'; export type { MenuListContextValue } from './contexts/menuListContext'; -export { Menu, renderMenu_unstable, useMenuContextValues_unstable, useMenu_unstable } from './Menu'; +export { + Menu, + renderMenu_unstable, + useMenuBase_unstable, + useMenuContextValues_unstable, + useMenu_unstable, +} from './Menu'; export type { + MenuBaseProps, + MenuBaseState, MenuContextValues, MenuOpenChangeData, MenuOpenEvent, @@ -47,6 +55,7 @@ export { MenuItem, menuItemClassNames, renderMenuItem_unstable, + useMenuItemBase_unstable, useMenuItemStyles_unstable, useMenuItem_unstable, } from './MenuItem'; @@ -55,6 +64,7 @@ export { MenuItemCheckbox, menuItemCheckboxClassNames, renderMenuItemCheckbox_unstable, + useMenuItemCheckboxBase_unstable, useMenuItemCheckboxStyles_unstable, useMenuItemCheckbox_unstable, } from './MenuItemCheckbox'; @@ -63,14 +73,21 @@ export { MenuItemRadio, menuItemRadioClassNames, renderMenuItemRadio_unstable, + useMenuItemRadioBase_unstable, useMenuItemRadioStyles_unstable, useMenuItemRadio_unstable, } from './MenuItemRadio'; -export type { MenuItemRadioProps, MenuItemRadioState } from './MenuItemRadio'; +export type { + MenuItemRadioBaseProps, + MenuItemRadioBaseState, + MenuItemRadioProps, + MenuItemRadioState, +} from './MenuItemRadio'; export { MenuList, menuListClassNames, renderMenuList_unstable, + useMenuListBase_unstable, useMenuListContextValues_unstable, useMenuListStyles_unstable, useMenuList_unstable, @@ -90,6 +107,7 @@ export { MenuPopover, menuPopoverClassNames, renderMenuPopover_unstable, + useMenuPopoverBase_unstable, useMenuPopoverStyles_unstable, useMenuPopover_unstable, } from './MenuPopover'; @@ -102,8 +120,18 @@ export { useMenuSplitGroup_unstable, } from './MenuSplitGroup'; export type { MenuSplitGroupProps, MenuSplitGroupSlots, MenuSplitGroupState } from './MenuSplitGroup'; -export { MenuTrigger, renderMenuTrigger_unstable, useMenuTrigger_unstable } from './MenuTrigger'; -export type { MenuTriggerChildProps, MenuTriggerProps, MenuTriggerState } from './MenuTrigger'; +export { + MenuTrigger, + renderMenuTrigger_unstable, + useMenuTrigger_unstable, + useMenuTriggerBase_unstable, +} from './MenuTrigger'; +export type { + MenuTriggerChildProps, + MenuTriggerProps, + MenuTriggerState, + UseMenuTriggerBaseOptions, +} from './MenuTrigger'; export { useCheckmarkStyles_unstable } from './selectable/index'; export type { MenuItemSelectableProps, MenuItemSelectableState, SelectableHandler } from './selectable/index'; @@ -112,6 +140,7 @@ export { MenuItemLink, menuItemLinkClassNames, renderMenuItemLink_unstable, + useMenuItemLinkBase_unstable, useMenuItemLinkStyles_unstable, useMenuItemLink_unstable, } from './MenuItemLink'; @@ -120,16 +149,11 @@ export type { MenuItemLinkProps, MenuItemLinkSlots, MenuItemLinkState } from './ export { MENU_ENTER_EVENT, dispatchMenuEnterEvent, useOnMenuMouseEnter } from './utils'; export { MenuItemSwitch, - useMenuItemSwitch_unstable, - useMenuItemSwitchStyles_unstable, - renderMenuItemSwitch_unstable, menuItemSwitchClassNames, + renderMenuItemSwitch_unstable, + useMenuItemSwitchBase_unstable, + useMenuItemSwitchStyles_unstable, + useMenuItemSwitch_unstable, } from './MenuItemSwitch'; -export type { MenuItemSwitchProps, MenuItemSwitchState, MenuItemSwitchSlots } from './MenuItemSwitch'; - -// Experimental: Base hooks - will be enabled in the experimental release branch -// export { useMenuItemBase_unstable } from './MenuItem'; -// export { useMenuItemCheckboxBase_unstable } from './MenuItemCheckbox'; -// export { useMenuItemRadioBase_unstable } from './MenuItemRadio'; -// export { useMenuItemSwitchBase_unstable } from './MenuItemSwitch'; +export type { MenuItemSwitchProps, MenuItemSwitchSlots, MenuItemSwitchState } from './MenuItemSwitch';