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 48a21261edb069..e3965f24bbab1f 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 @@ -12,7 +12,6 @@ import type { ComponentState } from '@fluentui/react-utilities'; import type { ContextSelector } from '@fluentui/react-context-selector'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import type { PortalProps } from '@fluentui/react-portal'; import type { PositioningShorthand } from '@fluentui/react-positioning'; import { PositioningVirtualElement } from '@fluentui/react-positioning'; import * as React_2 from 'react'; @@ -41,10 +40,13 @@ export type MenuCheckedValueChangeData = { export type MenuCheckedValueChangeEvent = React_2.MouseEvent | React_2.KeyboardEvent; // @public -export type MenuContextValue = Pick & { +export type MenuContextValue = Pick & { open: boolean; triggerId: string; defaultCheckedValues?: Record; + popoverId: string; + positioning?: MenuState['positioning']; + submenuFallbackPositions?: MenuState['submenuFallbackPositions']; }; // @public (undocumented) @@ -331,13 +333,13 @@ export type MenuPopoverSlots = { }; // @public -export type MenuPopoverState = ComponentState & Pick & { - inline: boolean; +export type MenuPopoverState = ComponentState & { safeZone?: React_2.ReactElement | null; + mountNode?: HTMLElement | null | undefined; }; // @public -export type MenuProps = ComponentProps & Pick & Pick & { +export type MenuProps = ComponentProps & Pick & { children: [JSXElement, JSXElement] | JSXElement; hoverDelay?: number; inline?: boolean; @@ -349,6 +351,7 @@ export type MenuProps = ComponentProps & Pick & Pick; // @public (undocumented) -export type MenuState = ComponentState & Required> & { +export type MenuState = ComponentState & Required> & Pick & { contextTarget?: PositioningVirtualElement; isSubmenu: boolean; menuPopover: React_2.ReactNode; @@ -390,6 +393,8 @@ export type MenuState = ComponentState & Required void; defaultCheckedValues?: Record; safeZone?: React_2.ReactElement | null; + popoverId: string; + submenuFallbackPositions?: string[]; }; // @public diff --git a/packages/react-components/react-menu/library/src/components/Menu/Menu.test.tsx b/packages/react-components/react-menu/library/src/components/Menu/Menu.test.tsx index 58d3fe512e16d5..1f4201971c1852 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/Menu.test.tsx +++ b/packages/react-components/react-menu/library/src/components/Menu/Menu.test.tsx @@ -265,29 +265,10 @@ describe('Menu', () => { expect(document.body.querySelector('[role="menu"]')).not.toBeNull(); }); - it('should render menu inline when configured by prop', () => { - // Arrange - const { container } = render( - - - - - - - Item - - - , - ); - - // Assert - expect(container.querySelector('[role="menu"]')).not.toBeNull(); - }); - it('should call onCheckedValueChange when item is selected', () => { const onCheckedValueChange = jest.fn(); const { getAllByRole } = render( - + @@ -315,7 +296,7 @@ describe('Menu', () => { it('should control checked items with checkedValues prop', () => { const { container } = render( - + @@ -344,7 +325,7 @@ describe('Menu', () => { it('should call onCheckedValueChange (applied to MenuList) when item is selected', () => { const onCheckedValueChange = jest.fn(); const { getAllByRole } = render( - + @@ -372,7 +353,7 @@ describe('Menu', () => { it('should control checked items with checkedValues prop (applied to MenuList)', () => { const { container } = render( - + 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 7d392253b47f3e..3a84437ce8767b 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 @@ -1,7 +1,9 @@ import * as React from 'react'; -import { PositioningVirtualElement, SetVirtualMouseTarget } from '@fluentui/react-positioning'; -import type { PositioningShorthand } from '@fluentui/react-positioning'; -import type { PortalProps } from '@fluentui/react-portal'; +import type { + PositioningShorthand, + PositioningVirtualElement, + SetVirtualMouseTarget, +} from '@fluentui/react-positioning'; import type { ComponentProps, ComponentState, JSXElement } from '@fluentui/react-utilities'; import type { MenuContextValue } from '../../contexts/menuContext'; import type { MenuListProps } from '../MenuList/MenuList.types'; @@ -12,7 +14,6 @@ export type MenuSlots = {}; * Extends and drills down Menulist props to simplify API */ export type MenuProps = ComponentProps & - Pick & Pick< MenuListProps, 'checkedValues' | 'defaultCheckedValues' | 'hasCheckmarks' | 'hasIcons' | 'onCheckedValueChange' @@ -31,7 +32,7 @@ export type MenuProps = ComponentProps & /** * Root menus are rendered out of DOM order on `document.body`, use this to render the menu in DOM order * This option is disregarded for submenus - * + * @deprecated With HTML Popover API all menus are inline * @default false */ inline?: boolean; @@ -89,6 +90,11 @@ export type MenuProps = ComponentProps & * @default false */ closeOnScroll?: boolean; + + /** + * @deprecated Popovers are always rendered in DOM order with HTML Popover API + */ + mountNode?: HTMLElement; }; export type MenuState = ComponentState & @@ -97,8 +103,6 @@ export type MenuState = ComponentState & MenuProps, | 'hasCheckmarks' | 'hasIcons' - | 'mountNode' - | 'inline' | 'checkedValues' | 'onCheckedValueChange' | 'open' @@ -108,7 +112,8 @@ export type MenuState = ComponentState & | 'openOnContext' | 'persistOnItemClick' > - > & { + > & + Pick & { /** * Anchors the popper to the mouse click for context events */ @@ -172,6 +177,13 @@ export type MenuState = ComponentState & * An optional safe zone area to be rendered around the menu */ safeZone?: React.ReactElement | null; + + /** + * Popover ID for HTML Popover API. + */ + popoverId: string; + + submenuFallbackPositions?: string[]; }; export type MenuContextValues = { diff --git a/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx b/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx index 8693e8177d64be..d906f1f0a34172 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx +++ b/packages/react-components/react-menu/library/src/components/Menu/renderMenu.tsx @@ -10,7 +10,7 @@ export const renderMenu_unstable = (state: MenuState, contextValues: MenuContext return ( {state.menuTrigger} - {state.open && state.menuPopover} + {state.menuPopover} ); }; 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 d27f72c4e7ce3a..de062d56226ac8 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 @@ -1,21 +1,17 @@ import * as React from 'react'; import { - resolvePositioningShorthand, usePositioningMouseTarget, - usePositioning, useSafeZoneArea, type PositioningShorthandValue, } from '@fluentui/react-positioning'; import { useControllableState, useId, - useOnClickOutside, useEventCallback, useOnScrollOutside, elementContains, useTimeout, useFirstMount, - useMergedRefs, } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { useFocusFinders } from '@fluentui/react-tabster'; @@ -48,7 +44,6 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim const isSubmenu = useIsSubmenu(); const { hoverDelay = 500, - inline = false, hasCheckmarks = false, hasIcons = false, closeOnScroll = false, @@ -56,21 +51,21 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim persistOnItemClick = false, openOnHover = isSubmenu, defaultCheckedValues, - mountNode = null, safeZone, } = props; const { targetDocument } = useFluent(); + const popoverId = useId('popover'); const triggerId = useId('menu'); const [contextTarget, setContextTarget] = usePositioningMouseTarget(); - const positioningOptions = { - position: isSubmenu ? 'after' : 'below', - align: isSubmenu ? 'top' : 'start', - target: props.openOnContext ? contextTarget : undefined, - fallbackPositions: isSubmenu ? submenuFallbackPositions : undefined, - ...resolvePositioningShorthand(props.positioning), - } as const; + // const positioningOptions = { + // position: isSubmenu ? 'after' : 'below', + // align: isSubmenu ? 'top' : 'start', + // target: props.openOnContext ? contextTarget : undefined, + // fallbackPositions: isSubmenu ? submenuFallbackPositions : undefined, + // ...resolvePositioningShorthand(props.positioning), + // } as const; const children = React.Children.toArray(props.children) as React.ReactElement[]; @@ -95,7 +90,7 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim menuPopover = children[0]; } - const { targetRef, containerRef } = usePositioning(positioningOptions); + // const { targetRef, containerRef } = usePositioning(positioningOptions); const enableSafeZone = safeZone && openOnHover; const safeZoneDescriptorRef = React.useRef({ @@ -139,8 +134,8 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim }, }); - const triggerRef = useMergedRefs(targetRef, safeZoneHandle.targetRef); - const menuPopoverRef = useMergedRefs(containerRef, safeZoneHandle.containerRef); + const triggerRef = safeZoneHandle.targetRef; //useMergedRefs(targetRef, safeZoneHandle.targetRef); + const menuPopoverRef = safeZoneHandle.containerRef; //useMergedRefs(containerRef, safeZoneHandle.containerRef); // TODO Better way to narrow types ? const [open, setOpen] = useMenuOpenState({ @@ -163,7 +158,6 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim }); return { - inline, hoverDelay, triggerId, isSubmenu, @@ -175,7 +169,6 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim closeOnScroll, menuTrigger, menuPopover, - mountNode, triggerRef, menuPopoverRef, components: {}, @@ -186,6 +179,9 @@ export const useMenu_unstable = (props: MenuProps & { safeZone?: boolean | { tim onCheckedValueChange, persistOnItemClick, safeZone: safeZoneHandle.elementToRender, + popoverId, + positioning: props.positioning, + submenuFallbackPositions, }; }; @@ -282,15 +278,15 @@ const useMenuOpenState = ( } }); - useOnClickOutside({ - contains: elementContains, - disabled: !open, - element: targetDocument, - refs: [state.menuPopoverRef, !state.openOnContext && state.triggerRef].filter( - Boolean, - ) as React.MutableRefObject[], - callback: event => setOpen(event, { open: false, type: 'clickOutside', event }), - }); + // useOnClickOutside({ + // contains: elementContains, + // disabled: !open, + // element: targetDocument, + // refs: [state.menuPopoverRef, !state.openOnContext && state.triggerRef].filter( + // Boolean, + // ) as React.MutableRefObject[], + // callback: event => setOpen(event, { open: false, type: 'clickOutside', event }), + // }); // only close on scroll for context, or when closeOnScroll is specified const closeOnScroll = state.openOnContext || state.closeOnScroll; diff --git a/packages/react-components/react-menu/library/src/components/Menu/useMenuContextValues.ts b/packages/react-components/react-menu/library/src/components/Menu/useMenuContextValues.ts index 4f5a5fd32b7a76..9fd9bdd899c996 100644 --- a/packages/react-components/react-menu/library/src/components/Menu/useMenuContextValues.ts +++ b/packages/react-components/react-menu/library/src/components/Menu/useMenuContextValues.ts @@ -5,10 +5,8 @@ export function useMenuContextValues_unstable(state: MenuState): MenuContextValu checkedValues, hasCheckmarks, hasIcons, - inline, isSubmenu, menuPopoverRef, - mountNode, onCheckedValueChange, open, openOnContext, @@ -18,6 +16,9 @@ export function useMenuContextValues_unstable(state: MenuState): MenuContextValu setOpen, triggerId, triggerRef, + popoverId, + positioning, + submenuFallbackPositions, } = state; // This context is created with "@fluentui/react-context-selector", these is no sense to memoize it @@ -25,10 +26,8 @@ export function useMenuContextValues_unstable(state: MenuState): MenuContextValu checkedValues, hasCheckmarks, hasIcons, - inline, isSubmenu, menuPopoverRef, - mountNode, onCheckedValueChange, open, openOnContext, @@ -38,6 +37,9 @@ export function useMenuContextValues_unstable(state: MenuState): MenuContextValu setOpen, triggerId, triggerRef, + popoverId, + positioning, + submenuFallbackPositions, }; return { menu }; diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/MenuPopover.types.ts b/packages/react-components/react-menu/library/src/components/MenuPopover/MenuPopover.types.ts index d7e2cd0b6eb72e..ceed4b95882e4a 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/MenuPopover.types.ts +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/MenuPopover.types.ts @@ -1,4 +1,3 @@ -import type { PortalProps } from '@fluentui/react-portal'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; import * as React from 'react'; @@ -14,13 +13,11 @@ export type MenuPopoverProps = ComponentProps; /** * State used in rendering MenuPopover */ -export type MenuPopoverState = ComponentState & - Pick & { - /** - * Root menus are rendered out of DOM order on `document.body`, use this to render the menu in DOM order - * This option is disregarded for submenus - */ - inline: boolean; +export type MenuPopoverState = ComponentState & { + safeZone?: React.ReactElement | null; - safeZone?: React.ReactElement | null; - }; + /** + * @deprecated Popover is always rendered in DOM order with HTML Popover API + */ + mountNode?: HTMLElement | null | undefined; +}; diff --git a/packages/react-components/react-menu/library/src/components/MenuPopover/renderMenuPopover.tsx b/packages/react-components/react-menu/library/src/components/MenuPopover/renderMenuPopover.tsx index a89d4e3f32e587..e954554d73f611 100644 --- a/packages/react-components/react-menu/library/src/components/MenuPopover/renderMenuPopover.tsx +++ b/packages/react-components/react-menu/library/src/components/MenuPopover/renderMenuPopover.tsx @@ -3,7 +3,6 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import { MenuPopoverSlots, MenuPopoverState } from './MenuPopover.types'; -import { Portal } from '@fluentui/react-portal'; /** * Render the final JSX of MenuPopover @@ -11,19 +10,10 @@ import { Portal } from '@fluentui/react-portal'; export const renderMenuPopover_unstable = (state: MenuPopoverState): JSXElement => { assertSlots(state); - if (state.inline) { - return ( - <> - - {state.safeZone} - - ); - } - return ( - + <> {state.safeZone} - + ); }; 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 bdfc5059e7f87e..9e624d9e2180dd 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 @@ -2,6 +2,7 @@ import { ArrowLeft, Tab, ArrowRight, Escape } from '@fluentui/keyboard-keys'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { useRestoreFocusSource } from '@fluentui/react-tabster'; import { getIntrinsicElementProps, useEventCallback, useMergedRefs, slot, useTimeout } from '@fluentui/react-utilities'; +import { resolvePositioningShorthand, toAnchorInset } from '@fluentui/react-positioning'; import * as React from 'react'; import { useMenuContext_unstable } from '../../contexts/menuContext'; @@ -26,6 +27,8 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< const open = useMenuContext_unstable(context => context.open); const openOnHover = useMenuContext_unstable(context => context.openOnHover); const triggerRef = useMenuContext_unstable(context => context.triggerRef); + const popoverId = useMenuContext_unstable(context => context.popoverId); + const positioning = useMenuContext_unstable(context => context.positioning); const isSubmenu = useIsSubmenu(); const canDispatchCustomEventRef = React.useRef(true); @@ -61,13 +64,12 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< return () => clearThrottleTimeout(); }, [clearThrottleTimeout]); - const inline = useMenuContext_unstable(context => context.inline) ?? false; - const mountNode = useMenuContext_unstable(context => context.mountNode); - const rootProps = slot.always( getIntrinsicElementProps('div', { role: 'presentation', ...restoreFocusSourceAttributes, + id: popoverId, + popover: 'auto', ...props, // FIXME: // `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement` @@ -102,9 +104,19 @@ export const useMenuPopover_unstable = (props: MenuPopoverProps, ref: React.Ref< onKeyDownOriginal?.(event); }); + const { align, position } = resolvePositioningShorthand(positioning); + + rootProps.style = React.useMemo(() => { + const anchorInset = toAnchorInset(align, position); + return { + positionAnchor: `--${popoverId}`, + margin: 0, + ...anchorInset, + ...props.style, + }; + }, [popoverId, props.style, align, position]); + return { - inline, - mountNode, safeZone, components: { root: 'div' }, root: rootProps, 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 90dfd3ca043506..120550349ee645 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 @@ -37,6 +37,8 @@ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerSta const triggerId = useMenuContext_unstable(context => context.triggerId); const openOnHover = useMenuContext_unstable(context => context.openOnHover); const openOnContext = useMenuContext_unstable(context => context.openOnContext); + // React 18 doesn't recognize "popoverTarget" so use all lowercase + const popovertarget = useMenuContext_unstable(context => context.popoverId); const isSubmenu = useIsSubmenu(); @@ -171,12 +173,25 @@ export const useMenuTrigger_unstable = (props: MenuTriggerProps): MenuTriggerSta triggerChildProps, ); + const baseProps = openOnContext + ? contextMenuProps + : disableButtonEnhancement + ? triggerChildProps + : ariaButtonTriggerChildProps; + + const triggerProps = { + // All lowercase for React 18 + popovertarget, + popovertargetaction: open ? 'hide' : 'show', + ...baseProps, + style: { + anchorName: `--${popovertarget}`, + }, + }; + return { isSubmenu, - children: applyTriggerPropsToChildren( - children, - openOnContext ? contextMenuProps : disableButtonEnhancement ? triggerChildProps : ariaButtonTriggerChildProps, - ), + children: applyTriggerPropsToChildren(children, triggerProps), }; }; diff --git a/packages/react-components/react-menu/library/src/contexts/menuContext.ts b/packages/react-components/react-menu/library/src/contexts/menuContext.ts index 9e52d632437ed7..ae04fae52f8555 100644 --- a/packages/react-components/react-menu/library/src/contexts/menuContext.ts +++ b/packages/react-components/react-menu/library/src/contexts/menuContext.ts @@ -15,14 +15,13 @@ const menuContextDefaultValue: MenuContextValue = { isSubmenu: false, triggerRef: { current: null } as unknown as React.MutableRefObject, menuPopoverRef: { current: null } as unknown as React.MutableRefObject, - mountNode: null, triggerId: '', openOnContext: false, openOnHover: false, hasIcons: false, hasCheckmarks: false, - inline: false, persistOnItemClick: false, + popoverId: '', }; /** @@ -38,12 +37,10 @@ export type MenuContextValue = Pick< | 'menuPopoverRef' | 'setOpen' | 'isSubmenu' - | 'mountNode' | 'triggerId' | 'hasIcons' | 'hasCheckmarks' | 'persistOnItemClick' - | 'inline' | 'checkedValues' | 'onCheckedValueChange' | 'safeZone' @@ -56,6 +53,9 @@ export type MenuContextValue = Pick< * the signature remains just to avoid breaking changes */ defaultCheckedValues?: Record; + popoverId: string; + positioning?: MenuState['positioning']; + submenuFallbackPositions?: MenuState['submenuFallbackPositions']; }; export const MenuProvider = MenuContext.Provider; diff --git a/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts b/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts index fe528c115cf42d..e5f8c3095bcdf0 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/Popover.types.ts @@ -44,7 +44,7 @@ export type PopoverProps = Pick & { /** * Popovers are rendered out of DOM order on `document.body` by default, use this to render the popover in DOM order - * + * @deprecated all Popovers are inline now. * @default false */ inline?: boolean; @@ -158,6 +158,7 @@ export type PopoverState = Pick< | 'trapFocus' | 'withArrow' | 'inertTrapFocus' + | 'positioning' > & Required> & Pick & { @@ -201,6 +202,8 @@ export type PopoverState = Pick< * Ref of the PopoverTrigger */ triggerRef: React.MutableRefObject; + + popoverId: string | undefined; }; /** diff --git a/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx b/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx index 4095c65437700c..df2e7a7060c90e 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx +++ b/packages/react-components/react-popover/library/src/components/Popover/renderPopover.tsx @@ -12,7 +12,6 @@ export const renderPopover_unstable = (state: PopoverState): JSXElement => { appearance, arrowRef, contentRef, - inline, mountNode, open, openOnContext, @@ -24,6 +23,8 @@ export const renderPopover_unstable = (state: PopoverState): JSXElement => { triggerRef, withArrow, inertTrapFocus, + positioning, + popoverId, } = state; return ( @@ -32,7 +33,6 @@ export const renderPopover_unstable = (state: PopoverState): JSXElement => { appearance, arrowRef, contentRef, - inline, mountNode, open, openOnContext, @@ -44,10 +44,12 @@ export const renderPopover_unstable = (state: PopoverState): JSXElement => { trapFocus, inertTrapFocus, withArrow, + positioning, + popoverId, }} > {state.popoverTrigger} - {state.open && state.popoverSurface} + {state.popoverSurface} ); }; diff --git a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts index ec842bfcd6b188..cdf5197733000e 100644 --- a/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-popover/library/src/components/Popover/usePopover.ts @@ -6,6 +6,7 @@ import { useOnScrollOutside, elementContains, useTimeout, + useId, } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import { @@ -38,6 +39,8 @@ export const usePopover_unstable = (props: PopoverProps): PopoverState => { const children = React.Children.toArray(props.children) as React.ReactElement[]; + const popoverId = useId('fui-popover-'); + if (process.env.NODE_ENV !== 'production') { if (children.length === 0) { // eslint-disable-next-line no-console @@ -101,42 +104,42 @@ export const usePopover_unstable = (props: PopoverProps): PopoverState => { }); // only close on scroll for context, or when closeOnScroll is specified - const closeOnScroll = initialState.openOnContext || initialState.closeOnScroll; - useOnScrollOutside({ - contains: elementContains, - element: targetDocument, - callback: ev => setOpen(ev, false), - refs: [positioningRefs.triggerRef, positioningRefs.contentRef], - disabled: !open || !closeOnScroll, - }); - - const { findFirstFocusable } = useFocusFinders(); - const activateModal = useActivateModal(); - - React.useEffect(() => { - if (props.unstable_disableAutoFocus) { - return; - } - - const contentElement = positioningRefs.contentRef.current; - - if (open && contentElement) { - const shouldFocusContainer = !isNaN(contentElement.getAttribute('tabIndex') ?? undefined); - const firstFocusable = shouldFocusContainer ? contentElement : findFirstFocusable(contentElement); - - firstFocusable?.focus(); - - if (shouldFocusContainer) { - // Modal activation happens automatically when something inside the modal is focused programmatically. - // When the container is focused, we need to activate the modal manually. - activateModal(contentElement); - } - } - }, [findFirstFocusable, activateModal, open, positioningRefs.contentRef, props.unstable_disableAutoFocus]); + // const closeOnScroll = initialState.openOnContext || initialState.closeOnScroll; + // useOnScrollOutside({ + // contains: elementContains, + // element: targetDocument, + // callback: ev => setOpen(ev, false), + // refs: [positioningRefs.triggerRef, positioningRefs.contentRef], + // disabled: !open || !closeOnScroll, + // }); + + // const { findFirstFocusable } = useFocusFinders(); + // const activateModal = useActivateModal(); + + // React.useEffect(() => { + // if (props.unstable_disableAutoFocus) { + // return; + // } + + // const contentElement = positioningRefs.contentRef.current; + + // if (open && contentElement) { + // const shouldFocusContainer = !isNaN(contentElement.getAttribute('tabIndex') ?? undefined); + // const firstFocusable = shouldFocusContainer ? contentElement : findFirstFocusable(contentElement); + + // firstFocusable?.focus(); + + // if (shouldFocusContainer) { + // // Modal activation happens automatically when something inside the modal is focused programmatically. + // // When the container is focused, we need to activate the modal manually. + // activateModal(contentElement); + // } + // } + // }, [findFirstFocusable, activateModal, open, positioningRefs.contentRef, props.unstable_disableAutoFocus]); return { ...initialState, - ...positioningRefs, + // ...positioningRefs, // eslint-disable-next-line @typescript-eslint/no-deprecated inertTrapFocus: props.inertTrapFocus ?? (props.legacyTrapFocus === undefined ? false : !props.legacyTrapFocus), popoverTrigger, @@ -147,6 +150,8 @@ export const usePopover_unstable = (props: PopoverProps): PopoverState => { setContextTarget, contextTarget, inline: props.inline ?? false, + positioning: props.positioning, + popoverId, }; }; diff --git a/packages/react-components/react-popover/library/src/components/PopoverSurface/PopoverSurface.types.ts b/packages/react-components/react-popover/library/src/components/PopoverSurface/PopoverSurface.types.ts index 3fedd34f669ea0..a894f673e998a4 100644 --- a/packages/react-components/react-popover/library/src/components/PopoverSurface/PopoverSurface.types.ts +++ b/packages/react-components/react-popover/library/src/components/PopoverSurface/PopoverSurface.types.ts @@ -17,7 +17,7 @@ export type PopoverSurfaceSlots = { * PopoverSurface State */ export type PopoverSurfaceState = ComponentState & - Pick & { + Pick & { /** * CSS class for the arrow element */ diff --git a/packages/react-components/react-popover/library/src/components/PopoverSurface/renderPopoverSurface.tsx b/packages/react-components/react-popover/library/src/components/PopoverSurface/renderPopoverSurface.tsx index c6fd9e11622ce9..e6d90a860ad87a 100644 --- a/packages/react-components/react-popover/library/src/components/PopoverSurface/renderPopoverSurface.tsx +++ b/packages/react-components/react-popover/library/src/components/PopoverSurface/renderPopoverSurface.tsx @@ -2,7 +2,6 @@ /** @jsxImportSource @fluentui/react-jsx-runtime */ import { assertSlots } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; -import { Portal } from '@fluentui/react-portal'; import type { PopoverSurfaceSlots, PopoverSurfaceState } from './PopoverSurface.types'; /** @@ -18,9 +17,5 @@ export const renderPopoverSurface_unstable = (state: PopoverSurfaceState): JSXEl ); - if (state.inline) { - return surface; - } - - return {surface}; + return surface; }; diff --git a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts index 56bbcb3713483a..444f37ebb5ada9 100644 --- a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts +++ b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurface.ts @@ -1,8 +1,10 @@ import * as React from 'react'; -import { getIntrinsicElementProps, useMergedRefs, slot } from '@fluentui/react-utilities'; +import { getIntrinsicElementProps, useMergedRefs, slot, useFirstMount } from '@fluentui/react-utilities'; import { useModalAttributes } from '@fluentui/react-tabster'; import { usePopoverContext_unstable } from '../../popoverContext'; import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.types'; +import { resolvePositioningShorthand } from '@fluentui/react-positioning'; +import type { Alignment, Position } from '@fluentui/react-positioning'; /** * Create the state required to render PopoverSurface. @@ -17,8 +19,11 @@ export const usePopoverSurface_unstable = ( props: PopoverSurfaceProps, ref: React.Ref, ): PopoverSurfaceState => { + const popoverId = usePopoverContext_unstable(context => context.popoverId); + const positioningCtx = usePopoverContext_unstable(context => context.positioning); const contentRef = usePopoverContext_unstable(context => context.contentRef); const openOnHover = usePopoverContext_unstable(context => context.openOnHover); + const open = usePopoverContext_unstable(context => context.open); const setOpen = usePopoverContext_unstable(context => context.setOpen); const mountNode = usePopoverContext_unstable(context => context.mountNode); const arrowRef = usePopoverContext_unstable(context => context.arrowRef); @@ -27,15 +32,16 @@ export const usePopoverSurface_unstable = ( const appearance = usePopoverContext_unstable(context => context.appearance); const trapFocus = usePopoverContext_unstable(context => context.trapFocus); const inertTrapFocus = usePopoverContext_unstable(context => context.inertTrapFocus); - const inline = usePopoverContext_unstable(context => context.inline); const { modalAttributes } = useModalAttributes({ trapFocus, legacyTrapFocus: !inertTrapFocus, alwaysFocusable: !trapFocus, }); + const surfaceRef = React.useRef(); + const positioning = resolvePositioningShorthand(positioningCtx); + const state: PopoverSurfaceState = { - inline, appearance, withArrow, size, @@ -49,11 +55,20 @@ export const usePopoverSurface_unstable = ( // FIXME: // `contentRef` 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, contentRef) as React.Ref, + ref: useMergedRefs(ref, contentRef, surfaceRef) as React.Ref, role: trapFocus ? 'dialog' : 'group', 'aria-modal': trapFocus ? true : undefined, + id: popoverId, + popover: openOnHover ? 'hint' : 'auto', ...modalAttributes, ...props, + style: { + positionAnchor: `--${popoverId}`, + positionArea: toPositionArea(positioning?.align, positioning?.position), + positionTryFallbacks: 'flip-block, flip-inline', + margin: 0, + ...props.style, + }, }), { elementType: 'div' }, ), @@ -62,7 +77,9 @@ export const usePopoverSurface_unstable = ( const { onMouseEnter: onMouseEnterOriginal, onMouseLeave: onMouseLeaveOriginal, - onKeyDown: onKeyDownOriginal, + // onKeyDown: onKeyDownOriginal, + // @ts-expect-error React types are missing this event + onToggle: onToggleOriginal, } = state.root; state.root.onMouseEnter = (e: React.MouseEvent) => { if (openOnHover) { @@ -80,16 +97,68 @@ export const usePopoverSurface_unstable = ( onMouseLeaveOriginal?.(e); }; - state.root.onKeyDown = (e: React.KeyboardEvent) => { - // only close if the event happened inside the current popover - // If using a stack of inline popovers, the user should call `stopPropagation` to avoid dismissing the entire stack - if (e.key === 'Escape' && contentRef.current?.contains(e.target as HTMLDivElement)) { - e.preventDefault(); - setOpen(e, false); - } + // React 18 doesn't properly support `popover` + // so manually wire up the events :/ + // See: https://github.com/facebook/react/issues/27479 + // React.useEffect(() => { + // const onToggle = (e: any) => { + // if (e.oldState === 'open' && e.newState === 'closed' && e.target === state.root.ref?.current) { + // setOpen(e, false); + // } - onKeyDownOriginal?.(e); - }; + // onToggleOriginal?.(e); + // }; + // surfaceRef.current?.addEventListener('toggle', onToggle); + + // return () => surfaceRef.current?.removeEventListener('toggle', onToggle); + // }, [onToggleOriginal]); + + const isFirst = useFirstMount(); + // Handle the default open case + React.useEffect(() => { + if (open && isFirst) { + surfaceRef.current?.showPopover(); + } + }, [open, isFirst]); return state; }; + +const getPositionMap = (rtl?: boolean): Record => ({ + above: 'top', + below: 'bottom', + before: rtl ? 'right' : 'left', + after: rtl ? 'left' : 'right', +}); + +const getAlignmentMap = (): Record => ({ + start: 'start', + end: 'end', + top: 'start', + bottom: 'end', + center: undefined, +}); + +const shouldAlignToCenter = (p?: Position, a?: Alignment): boolean => { + const positionedVertically = p === 'above' || p === 'below'; + const alignedVertically = a === 'top' || a === 'bottom'; + + return (positionedVertically && alignedVertically) || (!positionedVertically && !alignedVertically); +}; + +const toPositionArea = (align?: Alignment, position?: Position, rtl?: boolean): string => { + const alignment = shouldAlignToCenter(position, align) ? 'center' : align; + + const computedPosition = position && getPositionMap(rtl)[position]; + const computedAlignment = alignment && getAlignmentMap()[alignment]; + + if (computedPosition && computedAlignment) { + return `${computedPosition} ${computedAlignment}`; + } + + if (!computedPosition) { + return 'top span-all'; + } + + return computedPosition; +}; diff --git a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts index 2ade8fead91afe..bb059e0d1aebc1 100644 --- a/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts +++ b/packages/react-components/react-popover/library/src/components/PopoverSurface/usePopoverSurfaceStyles.styles.ts @@ -33,12 +33,6 @@ const useStyles = makeStyles({ `drop-shadow(0 8px 16px ${tokens.colorNeutralShadowKey})`, }, - inline: { - // When rendering inline, the PopoverSurface will be rendered under relatively positioned elements such as Input. - // This is due to the surface being positioned as absolute, therefore zIndex: 1 ensures that won't happen. - zIndex: 1, - }, - inverted: { backgroundColor: tokens.colorNeutralBackgroundStatic, color: tokens.colorNeutralForegroundStaticInverted, @@ -70,7 +64,6 @@ export const usePopoverSurfaceStyles_unstable = (state: PopoverSurfaceState): Po state.root.className = mergeClasses( popoverSurfaceClassNames.root, styles.root, - state.inline && styles.inline, state.size === 'small' && styles.smallPadding, state.size === 'medium' && styles.mediumPadding, state.size === 'large' && styles.largePadding, diff --git a/packages/react-components/react-popover/library/src/components/PopoverTrigger/usePopoverTrigger.ts b/packages/react-components/react-popover/library/src/components/PopoverTrigger/usePopoverTrigger.ts index dbf4735a363336..db774c6a616f13 100644 --- a/packages/react-components/react-popover/library/src/components/PopoverTrigger/usePopoverTrigger.ts +++ b/packages/react-components/react-popover/library/src/components/PopoverTrigger/usePopoverTrigger.ts @@ -25,6 +25,7 @@ export const usePopoverTrigger_unstable = (props: PopoverTriggerProps): PopoverT const { children, disableButtonEnhancement = false } = props; const child = getTriggerChild(children); + const popoverTarget = usePopoverContext_unstable(context => context.popoverId); const open = usePopoverContext_unstable(context => context.open); const setOpen = usePopoverContext_unstable(context => context.setOpen); const toggleOpen = usePopoverContext_unstable(context => context.toggleOpen); @@ -88,13 +89,17 @@ export const usePopoverTrigger_unstable = (props: PopoverTriggerProps): PopoverT triggerChildProps, ); + const ariaButtonProps = useARIAButtonProps( + child?.type === 'button' || child?.type === 'a' ? child.type : 'div', + openOnContext ? contextMenuProps : disableButtonEnhancement ? triggerChildProps : ariaButtonTriggerChildProps, + ); + return { - children: applyTriggerPropsToChildren( - props.children, - useARIAButtonProps( - child?.type === 'button' || child?.type === 'a' ? child.type : 'div', - openOnContext ? contextMenuProps : disableButtonEnhancement ? triggerChildProps : ariaButtonTriggerChildProps, - ), - ), + children: applyTriggerPropsToChildren(props.children, { + ...ariaButtonProps, + popovertarget: popoverTarget, + popovertargetaction: open ? 'hide' : 'show', + style: { anchorName: `--${popoverTarget}` }, + }), }; }; diff --git a/packages/react-components/react-popover/library/src/popoverContext.ts b/packages/react-components/react-popover/library/src/popoverContext.ts index 6da37392f36c22..5ac8e730ff8720 100644 --- a/packages/react-components/react-popover/library/src/popoverContext.ts +++ b/packages/react-components/react-popover/library/src/popoverContext.ts @@ -16,7 +16,8 @@ const popoverContextDefaultValue: PopoverContextValue = { openOnHover: false, size: 'medium' as const, trapFocus: false, - inline: false, + positioning: undefined, + popoverId: undefined, }; export const PopoverProvider = PopoverContext.Provider; @@ -40,7 +41,8 @@ export type PopoverContextValue = Pick< | 'appearance' | 'trapFocus' | 'inertTrapFocus' - | 'inline' + | 'positioning' + | 'popoverId' >; export const usePopoverContext_unstable = (selector: ContextSelector): T => diff --git a/packages/react-components/react-popover/stories/src/Popover/PopoverDefault.stories.tsx b/packages/react-components/react-popover/stories/src/Popover/PopoverDefault.stories.tsx index 13ff87cf3db4ef..32d2b861418a62 100644 --- a/packages/react-components/react-popover/stories/src/Popover/PopoverDefault.stories.tsx +++ b/packages/react-components/react-popover/stories/src/Popover/PopoverDefault.stories.tsx @@ -20,14 +20,17 @@ const ExampleContent = () => { ); }; +// style={{ width: 1500, height: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> export const Default = (props: PopoverProps): JSXElement => ( - - - - +
+ + + + - - - - + + + + +
); diff --git a/packages/react-components/react-popover/stories/src/Popover/PopoverWithArrow.stories.tsx b/packages/react-components/react-popover/stories/src/Popover/PopoverWithArrow.stories.tsx index e45ca7e84e73f0..f0e3bbc3fe17b5 100644 --- a/packages/react-components/react-popover/stories/src/Popover/PopoverWithArrow.stories.tsx +++ b/packages/react-components/react-popover/stories/src/Popover/PopoverWithArrow.stories.tsx @@ -20,15 +20,17 @@ const ExampleContent = () => { }; export const WithArrow = (): JSXElement => ( - - - - +
+ + + + - - - - + + + + +
); WithArrow.parameters = { diff --git a/packages/react-components/react-positioning/library/etc/react-positioning.api.md b/packages/react-components/react-positioning/library/etc/react-positioning.api.md index 23afaabefee931..9ae629b1bb03a7 100644 --- a/packages/react-components/react-positioning/library/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/library/etc/react-positioning.api.md @@ -130,6 +130,9 @@ export function resolvePositioningShorthand(shorthand: PositioningShorthand | un // @public (undocumented) export type SetVirtualMouseTarget = (event: React_2.MouseEvent | MouseEvent | undefined | null) => void; +// @public (undocumented) +export const toAnchorInset: (align?: Alignment, position?: Position) => Record; + // @internal (undocumented) export function usePositioning(options: PositioningProps & PositioningOptions): UsePositioningReturn; diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index fe5e0f371a9949..20e453092ad448 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -9,7 +9,7 @@ export { usePositioning } from './usePositioning'; export { usePositioningMouseTarget } from './usePositioningMouseTarget'; export { useSafeZoneArea } from './hooks/useSafeZoneArea/useSafeZoneArea'; export type { UseSafeZoneOptions } from './hooks/useSafeZoneArea/useSafeZoneArea'; -export { resolvePositioningShorthand, mergeArrowOffset } from './utils/index'; +export { resolvePositioningShorthand, mergeArrowOffset, toAnchorInset } from './utils/index'; export type { Alignment, diff --git a/packages/react-components/react-positioning/library/src/utils/anchorPositioning/toAnchorInset.ts b/packages/react-components/react-positioning/library/src/utils/anchorPositioning/toAnchorInset.ts new file mode 100644 index 00000000000000..577c6b9743014f --- /dev/null +++ b/packages/react-components/react-positioning/library/src/utils/anchorPositioning/toAnchorInset.ts @@ -0,0 +1,47 @@ +import type { Alignment, Position } from '../../types'; + +const shouldAlignToCenter = (p?: Position, a?: Alignment): boolean => { + const positionedVertically = p === 'above' || p === 'below'; + const alignedVertically = a === 'top' || a === 'bottom'; + + return (positionedVertically && alignedVertically) || (!positionedVertically && !alignedVertically); +}; + +const POSITION_MAP: Record = { + above: 'anchor(start)', + below: 'anchor(end)', + before: 'anchor(start)', + after: 'anchor(end)', +}; + +const ALIGNMENT_MAP: Record = { + start: 'anchor(start)', + end: 'anchor(end)', + top: 'anchor(start)', + bottom: 'anchor(end)', + center: undefined, +}; +export const toAnchorInset = (align?: Alignment, position?: Position): Record => { + const alignment = shouldAlignToCenter(position, align) ? 'center' : align; + + const computedPosition = position && POSITION_MAP[position]; + const computedAlignment = alignment && ALIGNMENT_MAP[alignment]; + + if (computedPosition && computedAlignment) { + return { + insetBlockStart: computedPosition, + insetInlineStart: computedAlignment, + }; + } + + if (!computedPosition) { + return { + insetBlockStart: 'anchor(end)', + insetInlineStart: 'anchor(start)', + }; + } + + return { + insetInlineStart: computedPosition, + }; +}; diff --git a/packages/react-components/react-positioning/library/src/utils/index.ts b/packages/react-components/react-positioning/library/src/utils/index.ts index 04feadbc94831b..c51c7a6384dede 100644 --- a/packages/react-components/react-positioning/library/src/utils/index.ts +++ b/packages/react-components/react-positioning/library/src/utils/index.ts @@ -15,3 +15,4 @@ export { hasAutofocusFilter } from './hasAutoFocusFilter'; export { writeArrowUpdates } from './writeArrowUpdates'; export { writeContainerUpdates } from './writeContainerupdates'; export { normalizeAutoSize } from './normalizeAutoSize'; +export { toAnchorInset } from './anchorPositioning/toAnchorInset'; diff --git a/packages/react-components/react-utilities/src/utils/properties.ts b/packages/react-components/react-utilities/src/utils/properties.ts index 60867cd652f8cb..4644b75cf6ddc7 100644 --- a/packages/react-components/react-utilities/src/utils/properties.ts +++ b/packages/react-components/react-utilities/src/utils/properties.ts @@ -84,6 +84,8 @@ export const baseElementEvents = toObjectMap([ 'onMouseUp', 'onMouseUpCapture', 'onSelect', + 'onBeforeToggle', + 'onToggle', 'onTouchCancel', 'onTouchEnd', 'onTouchMove', @@ -118,6 +120,7 @@ export const baseElementProperties = toObjectMap([ 'htmlFor', // global 'id', // global 'lang', // global + 'popover', // global 'ref', // global 'role', // global 'style', // global @@ -238,6 +241,9 @@ export const buttonProperties = toObjectMap(htmlElementProperties, [ 'formTarget', // input, button 'type', // a, button, input, link, menu, object, script, source, style 'value', // button, input, li, option, meter, progress, param, + 'popovertarget', // React types complain about camel case + 'popoverTarget', + 'popoverTargetAction', ]); /**