packages/react-components/react-popover/library/docs/Spec.md
Popovers contain content that is opened after interacting with visible content. The content does not belong to the flow of visible information and is rendered out of DOM order. The content can display complementary information to existing content, or serve as a lightweight Dialog with interactable content.
The v8 Callout component only covers the positioned content functionality. The default usage involves conditional rendering of the Callout. The onDismiss prop requests the Callout to close on events such as clicking outside or pressing the escape key.
{isCalloutVisible && (
<Callout
target={`#${targetId}`}
onDismiss={toggleIsCalloutVisible}
>
{children}
</Callout>
}
Focus traps are supported for this scenario using a different component.
{isCalloutVisible && (
<FocusTrapCallout
setInitialFocus
target={`#${targetId}`}
onDismiss={toggleIsCalloutVisible}
>
{children}
</FocusTrapCallout>
}
The v0 Popup comes in both the controlled an uncontrolled variant that includes a trigger element which will open the Popup. Four interactions to open the Popup are supported:
In the controlled variant, an onOpenChange callback requests open/close of the popup to the user. This callback handles all the interactions for the trigger (above) and events such as clicking outside and the escape key.
// Uncontrolled
<Popup
trigger={<Button icon={<MoreIcon />} title="Show popup" />}
content={popupContent}
on=['click', 'hover', 'context', 'focus']
/>
// Controlled
<Popup
open={open}
onOpenChange={(e, { open }) => setOpen(open)}
trigger={<Button icon={<MoreIcon />} title="Show popup" />}
content={popupContent}
on=['click', 'hover', 'context', 'focus']
/>
Focus trap is enabled using the trapFocus prop.
A target prop also exists so that the Popup does not have to be anchored to the trigger element.
Both libraries provide an API that achieves the same end result for positioning and alignment. Below is a table that maps the v8 DirectionalHint with the v0 props of position and alignment
| DirectionalHint (v7) | position (v0) | align (v0) |
|---|---|---|
| topLeftEdge | above | start |
| topCenter | above | center |
| topRightEdge | above | bottom |
| topAutoEdge | above | |
| bottomLeftEdge | below | start |
| bottomCenter | below | center |
| bottomRightEdge | below | bottom |
| bottomAutoEdge | below | |
| leftTopEdge | before | top |
| leftCenter | before | center |
| leftBottomEdge | before | bottom |
| rightTopEdge | after | before |
| rightCenter | after | center |
| rightBottomEdge | after | bottom |
v8 uses left and right. v0 uses before and after. v0 vocabulary tries to be consistent regardless of RTL state. It's also possible to supply an explicit RTL hint to v8 which is a flip by default. v0 will flip by default but requires the consumer to detect RTL scenarios and modify props in these situations.
In general the separation of both the position and alignment in v0 results in an API that is easier to use if a consumer only needs to modify one of the two props. However both try to achieve the same result in the end.
It's important to note that if an incorrect pair of position and align are provided in v0, then position takes priority and align is set to center
<Callout
// single number value
gap={100}
/>
<Popup
offset={[-100, 100]}
/>
// offset can also be a function of raw Popper properties
const offsetFunction = ({
popper: PopperJs.Rect;
reference: PopperJs.Rect;
placement: PopperJs.Placement;
}) => ([popper.width, -popper.height])
v8 positioning can only apply a numerical value to the first part position attribute of DirectionalHint. v0 supports a function to defer calculation at runtime. v0 also supports offset of the Popup in both axes while supporting RTL flips for offset values.
<Popup
...
flipBoundary={htmlElement}
overflowBoundary={htmlElement}
mountNode={htmlElement}
/>
v0 Popup provides 3 different properties to handle bounds and overflow:
<Callout
// pixel values for bounding rectangle
// defaults to target window as default bounding rectangle
bounds={{height: 0, width: 0, top: 0, left:0 , right: 0, bottom: 0}}
// callback for bounds
bounds{(target, targetWindow) => ()}
target={htmlElement}
// renders to a portal node on body
layerProps=
// every single one of the above can all be declared here too
calloutProps={{bounds, target}}
/>
v8 Callout has no notion of separate boundaries for flip or overflow, and auto behaviour is used for flip and overflow 'pushing'.
v8 provides the following positioning event callbacks
Callout calls this when it finishes positioning the elementCallout calls this when the contents are scrolledv8 Callout provides two props which will allow mounting a hidden popup and disabling renders when the component is hidden. According to the PRs that introduced the features, it should be a performance optimization.
<Popover>
<PopoverTrigger>
<button>Opens popover</button>
</PopoverTrigger>
<PopoverSurface>
<h1>Popover</h2>
<div>Some section</div>
<div>Some section</div>
</PopoverSurface>
</Popover>
The Popover component will use React context to manage both a trigger and content component.
Outer component that sets up context and does not render DOM.
TODO Discuss: dismiss on scroll ?
TODO Discuss: v8
hiddenshouldUpdateHiddenprop #4419 #10465
TODO Discuss: v8
onPositioned
TODO Discuss: A11y -> Should only one popup be open at a time or is aria-hidden enough ?
TODO Discuss: merge position and align props -> no real reason they were separated in v0 in the first place
TODO Discuss: v0
unstableprops supported out of the box ? (pinned, disableTether)
TODO Discuss v0
shouldDismissOnWindowFocus?
The @fluentui/react-positioning library that exports the usePopper hook which will power the Popover contains more than the declared props here. These extra positioning props should be exposed as required.
export type PopoverProps = {
/**
* Controls the popover open state
*/
open?: boolean;
/**
* Call back when the component requests to change value
*/
onOpenChange?: (e: OpenPopoverEvents, data: OpenEventData) => void;
/**
* Flag to open the Popover by hovering the trigger
*/
openOnHover?: boolean;
/**
* Anchor the popover to an element other than the trigger
*/
target?: HTMLElement;
/**
* Popover position relative to target
*/
position?: 'above' | 'below' | 'before' | 'after';
/**
* Popover alignment relative to target
*/
align?: 'top' | 'bottom' | 'start' | 'end' | 'center';
/**
* Popover offset value or callback with positioning props
*/
offset?: OffsetFunction | [number, number];
/**
* Renders `PopoverSurface` to a portal out of DOM order
*
* @default document.body
*/
mountNode?: string;
/**
* Explicitly render the popover in DOM order
*/
inline?: boolean;
/**
* Traps focus inside the popup and applies modal dialog behaviour
*/
trapFocus?: boolean;
/**
* Covers the target that the popover is anchored to. This is the `PopoverTrigger` unless `target` prop is used
*/
coverTarget?: boolean;
/**
* Do not render an arrow pointing to the target element. This is the `PopoverTrigger` unless `target` prop is used
*/
noArrow?: boolean;
/**
* Sets the delay for closing popover on mouse leave
*/
mouseLeaveDelay?: number;
/**
* Close when scrolling outside of it
*/
closeOnScroll?: boolean;
};
This component does not render DOM. Utility component that clones a single child and applies HTML event callbacks to control the open/dismiss of the popover.
export type PopoverTriggerProps = {
/**
* Should only be a single child
*/
children?: React.ReactElement;
};
This component renders the positioned HTML element and renders user provided children. Renders as <div> by default.
export type PopoverSurfaceProps = {
children?: React.ReactNode;
};
Default popover
<div id="container">
<Popover>
<PopoverTrigger>
<button>Trigger</button>
</PopoverTrigger>
<PopoverSurface>
{children}
</PopoverSurface>
</Popover>
<div>
// Expected Markup
<div id="container">
<button aria-expanded="false">Trigger</button>
</div>
// on document.body
<div role="group">
</div>
Popover that traps focus
<div id="container">
<Popover trapFocus>
<PopoverTrigger>
<button>Trigger</button>
</PopoverTrigger>
<PopoverSurface>
{children}
</PopoverSurface>
</Popover>
<div>
// Expected Markup
<div id="container">
<button aria-expanded="false">Trigger</button>
</div>
// on document.body
<div role="dialog" aria-modal="true">
</div>
Inline popover
<div id="container">
<Popover inline>
<PopoverTrigger>
<button>Trigger</button>
</PopoverTrigger>
<PopoverSurface>
{children}
</PopoverSurface>
</Popover>
<div>
// Expected Markup
<div id="container">
<button aria-expanded="false">Trigger</button>
<div>
</div>
</div>
onDismiss should listen to onOpenChange.preventDismissOnEvent no longer exists, onOpenChange will return the associated event, so this functionality is still possible.onRestoreFocus. Popover will focus the trigger when closed by default. Any other behaviour can be done through an effect by users.onPositioned.doNotLayer with inline.Callout. makeStyles should be used instead.coverTarget, use offset callback instead.setInitialFocus. If trapFocus is used, first focusable element is used. Users must handle other specific cases.autoFocus for scenarios without trapFocus. Users should handle this scenario manually.tabbableTrigger. Users can do this with their own trigger element.PopoverSurface props, v0 duplicated props from Popover to PopoverSurface, all props should be declared on converged Popover.A popover should support click, hover, context menu and focus interactions for the PopoverTrigger. These interactions should also be composable.
Clicking the trigger when the popover is closed opens the popover.
Clicking the trigger when the popover is open closes the popover.
Click also includes Spacebar and Enter keys.
When the mouse hovers over the trigger the popover opens.
When the mouse leaves the trigger and popover content, the popover closes.
Right click on the trigger opens the popover. The popover anchors to the mouse position.
Shift + F10 on the trigger opens the popover
The popover closes with the escape key when the trigger or popover content has focus.
The popover closes when a click happens outside the popover trigger or content.
The context menu popover closes when scroll happens outside the popover trigger or content.
When popover is configured with closeOnScroll, popover closes when scroll happens outside the popover trigger or content.
When the popover is configured to be a focus trap, focus the first focusable element inside the popover on open.
Popovers should allow nesting
The WAI Dialog pattern and its variants are the inspirations for Popover accessibility.
Only the PopoverSurface component will render DOM markup. By default the components renders an HTML div element.
Using a Popover with a focus trap is no different from a modal dialog in terms of a11y. Therefore, aria-hidden must be applied to all non-interactive elements of the page when the Popover is open.
This also means that a Popover should be closed when another Popover is opened if they are not nested. In a nested case, the parent Popovers need to be hidden.
Accessible markup is divided into two scenarios:
// Popover that does not trap focus
<button aria-expanded="false">Trigger</button>
<div role="group">
No focus trap
</div>
// Popover that does trap focus
<div aria-hidden="true" /> // other content
<div aria-hidden="true" /> // other content
<div aria-hidden="true" className='fui-provider'>
<button aria-expanded="false">Trigger</button>
</div>
<div role="dialog" aria-modal="true">
Focus trapped
</div>