Skip to content

feat(react-headless-components-preview): add Popover & positioning#36006

Open
mainframev wants to merge 17 commits intomicrosoft:masterfrom
mainframev:feat/headless-popover-v2
Open

feat(react-headless-components-preview): add Popover & positioning#36006
mainframev wants to merge 17 commits intomicrosoft:masterfrom
mainframev:feat/headless-popover-v2

Conversation

@mainframev
Copy link
Copy Markdown
Contributor

@mainframev mainframev commented Apr 20, 2026

Adds a headless Popover (Popover, PopoverTrigger, PopoverSurface) to the @fluentui/react-headless-components-preview package, positioned via the native CSS Anchor Positioning API instead of floating-ui

Feature with v9 @fluentui/react-popover

v9 feature Status
open / defaultOpen / onOpenChange
openOnHover + mouseLeaveDelay
openOnContext (right-click, cursor-anchored)
withArrow (+ consumer-owned arrow CSS via [data-placement]) ⚠️ User provides styles for arrow based on data-placement and data-arrow attributes
trapFocus + aria-modal / role="dialog" ❌ Not implemented (TBA)
disableAutoFocus
closeOnScroll, closeOnIframeFocus
inline (skip top-layer) ⚠️ v9: skips the <Portal> wrapper - DOM placement only, positioning math unchanged. Headless: skips HTML Popover API top-layer promotion, also changes the overflow boundary CSS position-try-fallbacks flips against (viewport → nearest + scroll port / containing block)
mountNode (portal target)
Nested popovers (per-instance Escape / dismiss)
positioning.position + positioning.align
positioning.offset (number or { mainAxis, crossAxis })
positioning.coverTarget
positioning.fallbackPositions
positioning.autoSize (true / 'width' / 'height') ❌ Not implemented (TBA)
positioning.overflowBoundary ❌ Not implemented (TBA)
positioning.overflowBoundaryPadding ❌ Not implemented (TBA)
positioning.overflowBoundaryRect ❌ Not implemented (TBA)
positioning.matchTargetSize: 'width'
positioning.strategy: 'absolute' | 'fixed'
positioning.pinned
positioning.target (custom anchor)
positioning.positioningRef (imperative setTarget)
flipBoundary ❌ Not implemented (TBA)
arrowPadding ➖ Configurable on user side via styles
shiftToCoverTarget ❌ Not implemented (TBA)
onPositioningEnd ➖ Native API has no "settle" event
disableUpdateOnResize ➖ Native anchor positioning + targeted usage is always-on
useTransform ➖ Native anchor positioning doesn't use transform, no conceptual equivalent

Related Issue(s)

@github-actions
Copy link
Copy Markdown

Pull request demo site: URL

@mainframev mainframev force-pushed the feat/headless-popover-v2 branch from 22f36ba to e9fef9d Compare April 20, 2026 01:46
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 20, 2026

📊 Bundle size report

✅ No changes found

@mainframev mainframev force-pushed the feat/headless-popover-v2 branch 12 times, most recently from 58218ca to c32d139 Compare April 22, 2026 10:30
@mainframev mainframev changed the title WIP: feat(react-headless-components-preview): add Popover & positioning feat(react-headless-components-preview): add Popover & positioning Apr 22, 2026
@mainframev mainframev marked this pull request as ready for review April 22, 2026 10:42
@mainframev mainframev requested review from a team as code owners April 22, 2026 10:42
@mainframev mainframev requested a review from dmytrokirpa April 22, 2026 10:47
@mainframev mainframev force-pushed the feat/headless-popover-v2 branch from c32d139 to 4774cce Compare April 22, 2026 12:21
@mainframev mainframev requested a review from a team as a code owner April 22, 2026 12:21
@mainframev mainframev force-pushed the feat/headless-popover-v2 branch from 4774cce to 0022e71 Compare April 22, 2026 12:57
@mainframev mainframev marked this pull request as ready for review April 23, 2026 09:35
@mainframev mainframev force-pushed the feat/headless-popover-v2 branch from c16a425 to e42cb0f Compare April 23, 2026 12:42
@mainframev mainframev marked this pull request as draft April 23, 2026 13:18

export type PositioningReturn = {
targetRef: React.RefCallback<HTMLElement>;
containerRef: React.RefCallback<HTMLElement>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be surfaceRef or is it for consistency with v9?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, for consistency

function mountHook(options: PositioningProps = {}) {
const resultRef = React.createRef<{ current: PositioningReturn }>();
const Capture = () => {
const result = usePositioning(options);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, but we can probably type-cast once if it's really needed

Suggested change
const result = usePositioning(options);
const result = usePositioning(options) as unknown as { current: PositioningReturn };

result.current.containerRef(node);

expect(node.style.getPropertyValue('position-anchor')).toMatch(/^--popover-anchor-/);
expect(node.style.getPropertyValue('position-area')).toBe('block-end span-inline-end');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, but we can use the https://github.com/testing-library/jest-dom#tohavestyle to make it a bit cleaner

Suggested change
expect(node.style.getPropertyValue('position-area')).toBe('block-end span-inline-end');
expect(node).toHaveStyle({ positionArea: 'block-end span-inline-end' });

const node = document.createElement('div');
result.current.containerRef(node);

expect(node.style.position).toBe('absolute');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, but can be combined to one assertion for all styles

const childProps = (child?.props ?? {}) as Record<string, unknown>;

const triggerChildProps = {
'aria-expanded': `${open}` as 'true' | 'false',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

@mainframev mainframev Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current implementation uses showPopover() and I found it more suitable as we need to also show Popover on hover (v9 canonical Popover has that feature), have it controlled and uncontrolled, mouseLeaveDelay for openOnHover etc


const positioning = usePositioning(resolvePositioningShorthand(props.positioning));

useOnClickOutside({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to confrim, did you check if Popover API has this functionality out-of-the-box?

Copy link
Copy Markdown
Contributor Author

@mainframev mainframev Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does, but only when popover has popover='auto' / popover attribute, which has native dismiss on Esc and overlay click, great that it works also with nested popovers, but in current implementation it uses manual, which does not have such functionality out of the box, because we need multiple non-nested Popovers to be opened. I think auto will be a great fit to Tooltip

Screenshot 2026-04-23 at 19 16 47

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may need to set auto as the default option and offer a way to switch to manual + custom logic when advanced features are required. I'm not entirely sure how this would work yet, but we might need a similar approach for this and positioning as well

Copy link
Copy Markdown
Contributor Author

@mainframev mainframev Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should split this into two separate components (as subcomponents of Popover). Embedding logic like useOnClickOutside and useOnScrollOutside directly in hooks doesn't seem appropriate, especially when auto is only used to support dismissing an already open Popover. This approach as a default also diverges from v9 Popover behavior, where multiple Popovers can be open simultaneously

@mainframev mainframev changed the title feat(react-headless-components-preview): add Popover & positioning WIP: feat(react-headless-components-preview): add Popover & positioning Apr 24, 2026
@mainframev mainframev force-pushed the feat/headless-popover-v2 branch 3 times, most recently from 773c5b6 to 6aea7b4 Compare April 27, 2026 15:50
@mainframev mainframev force-pushed the feat/headless-popover-v2 branch from b5ae6c5 to 13c85ad Compare April 27, 2026 16:26
@mainframev mainframev changed the title WIP: feat(react-headless-components-preview): add Popover & positioning feat(react-headless-components-preview): add Popover & positioning Apr 27, 2026
Adds a PopoverAuto component that renders its surface with
`popover="auto"` instead of `popover="manual"`, so light dismiss
(Escape, click-outside, popover-stack peer dismissal) is handled by
the browser rather than React.

Implementation:
- Extracts a shared internal hook `useInternalPopover(props, popoverType)`
  in usePopover.ts. Both the existing `usePopover` (manual) and a new
  `usePopoverAuto` are thin wrappers that fix the popoverType.
- Sets the `popover` attribute to the actual mode value when calling
  `showPopover()`.
- In auto mode: disables `useOnClickOutside` / `useOnScrollOutside` and
  attaches a `toggle` event listener on the surface to mirror the
  browser's open/closed state into React and fire onOpenChange.
- Plumbs `popoverType` through PopoverContextValue so PopoverSurface
  can skip its own Escape handler in auto mode (the browser owns it).
- Exports `PopoverAuto` and `usePopoverAuto` from the popover subpath.
- Updates the Nested Storybook story to use PopoverAuto, where browser
  popover-stack semantics keep ancestor surfaces open when a descendant
  popover opens, and dismiss the ancestor chain on outside interaction.

Tests, type-check, lint, and api-report all pass.
@mainframev mainframev force-pushed the feat/headless-popover-v2 branch from 6e877df to ce13da9 Compare April 27, 2026 17:36
@mainframev mainframev marked this pull request as ready for review April 27, 2026 19:58
@mainframev mainframev force-pushed the feat/headless-popover-v2 branch from ce13da9 to 68886c7 Compare April 27, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants