From a337d933f3dc86f76327cb9cecbfe4903bcca61b Mon Sep 17 00:00:00 2001 From: mainframev Date: Mon, 20 Apr 2026 02:54:27 +0200 Subject: [PATCH 01/17] feat(react-headless-components-preview): add headless Popover built on native CSS anchor positioning --- .../Dialog/useDialogContextValues copy.ts | 38 + .../react-headless-components-preview.api.md | 9 - .../library/package.json | 3 + .../src/components/Popover/Popover.cy.tsx | 653 ++++++++++++++++++ .../src/components/Popover/Popover.test.tsx | 234 +++++++ .../src/components/Popover/Popover.tsx | 22 + .../src/components/Popover/Popover.types.ts | 195 ++++++ .../PopoverSurface/PopoverSurface.test.tsx | 165 +++++ .../Popover/PopoverSurface/PopoverSurface.tsx | 20 + .../PopoverSurface/PopoverSurface.types.ts | 1 + .../Popover/PopoverSurface/index.ts | 4 + .../PopoverSurface/renderPopoverSurface.tsx | 24 + .../PopoverSurface/usePopoverSurface.ts | 111 +++ .../PopoverTrigger/PopoverTrigger.test.tsx | 140 ++++ .../Popover/PopoverTrigger/PopoverTrigger.tsx | 19 + .../PopoverTrigger/PopoverTrigger.types.ts | 1 + .../Popover/PopoverTrigger/index.ts | 4 + .../PopoverTrigger/renderPopoverTrigger.ts | 9 + .../PopoverTrigger/usePopoverTrigger.ts | 103 +++ .../library/src/components/Popover/index.ts | 17 + .../src/components/Popover/popoverContext.ts | 38 + .../src/components/Popover/renderPopover.tsx | 17 + .../src/components/Popover/usePopover.ts | 210 ++++++ .../library/src/hooks/index.ts | 13 + .../library/src/hooks/useFocusScope.ts | 428 ++++++++++++ .../src/hooks/usePositioning/constants.ts | 46 ++ .../hooks/usePositioning/fallbackStyles.ts | 55 ++ .../library/src/hooks/usePositioning/index.ts | 12 + .../src/hooks/usePositioning/placement.ts | 39 ++ .../src/hooks/usePositioning/resolvers.ts | 30 + .../src/hooks/usePositioning/styleHelpers.ts | 137 ++++ .../library/src/hooks/usePositioning/types.ts | 99 +++ .../src/hooks/usePositioning/useAnchorName.ts | 44 ++ .../usePositioning/useAutoSizeBoundary.ts | 126 ++++ .../hooks/usePositioning/usePlacementSync.ts | 170 +++++ .../usePositioning/usePositioning.test.tsx | 67 ++ .../hooks/usePositioning/usePositioning.ts | 166 +++++ .../library/src/popover.ts | 35 + .../src/Popover/PopoverNested.stories.tsx | 64 ++ 39 files changed, 3559 insertions(+), 9 deletions(-) create mode 100644 packages/react-components/react-dialog/library/src/components/Dialog/useDialogContextValues copy.ts delete mode 100644 packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/PopoverSurface.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/PopoverSurface.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/PopoverSurface.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/PopoverTrigger.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/PopoverTrigger.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/PopoverTrigger.types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/renderPopoverTrigger.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverTrigger/usePopoverTrigger.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/renderPopover.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/useFocusScope.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/constants.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackStyles.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/placement.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/resolvers.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/styleHelpers.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/types.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAnchorName.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/useAutoSizeBoundary.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePlacementSync.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx create mode 100644 packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts create mode 100644 packages/react-components/react-headless-components-preview/library/src/popover.ts create mode 100644 packages/react-components/react-headless-components-preview/stories/src/Popover/PopoverNested.stories.tsx diff --git a/packages/react-components/react-dialog/library/src/components/Dialog/useDialogContextValues copy.ts b/packages/react-components/react-dialog/library/src/components/Dialog/useDialogContextValues copy.ts new file mode 100644 index 00000000000000..a56ed7233ebbc7 --- /dev/null +++ b/packages/react-components/react-dialog/library/src/components/Dialog/useDialogContextValues copy.ts @@ -0,0 +1,38 @@ +import type { DialogContextValue, DialogSurfaceContextValue } from '../../contexts'; +import type { DialogContextValues, DialogState } from './Dialog.types'; + +export function useDialogContextValues_unstable(state: DialogState): DialogContextValues { + const { + modalType, + open, + dialogRef, + dialogTitleId, + isNestedDialog, + inertTrapFocus, + requestOpenChange, + modalAttributes, + triggerAttributes, + unmountOnClose, + } = state; + + /** + * This context is created with "@fluentui/react-context-selector", + * there is no sense to memoize it + */ + const dialog: DialogContextValue = { + open, + modalType, + dialogRef, + dialogTitleId, + isNestedDialog, + inertTrapFocus, + modalAttributes, + triggerAttributes, + unmountOnClose, + requestOpenChange, + }; + + const dialogSurface: DialogSurfaceContextValue = false; + + return { dialog, dialogSurface }; +} diff --git a/packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md b/packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md deleted file mode 100644 index 6bef1c6e594fed..00000000000000 --- a/packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md +++ /dev/null @@ -1,9 +0,0 @@ -## API Report File for "@fluentui/react-headless-components-preview" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -// (No @packageDocumentation comment for this package) - -``` diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index f9ca4be78888cc..552e5e6061c43f 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -19,6 +19,8 @@ "license": "MIT", "dependencies": { "@fluentui/react-accordion": "^9.11.0", + "@fluentui/react-aria": "^9.17.10", + "@fluentui/keyboard-keys": "^9.0.8", "@fluentui/react-avatar": "^9.11.1", "@fluentui/react-badge": "^9.5.2", "@fluentui/react-button": "^9.9.1", @@ -41,6 +43,7 @@ "@fluentui/react-search": "^9.4.2", "@fluentui/react-select": "^9.5.1", "@fluentui/react-shared-contexts": "^9.26.2", + "@fluentui/react-context-selector": "^9.2.15", "@fluentui/react-skeleton": "^9.7.2", "@fluentui/react-slider": "^9.6.2", "@fluentui/react-spinbutton": "^9.6.2", diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx new file mode 100644 index 00000000000000..e94ce6adff9dfe --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -0,0 +1,653 @@ +import * as React from 'react'; +import { mount as mountBase } from '@fluentui/scripts-cypress'; +import { Popover } from './Popover'; +import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger'; +import { PopoverSurface } from './PopoverSurface/PopoverSurface'; +import type { PopoverProps } from './Popover.types'; +import type { JSXElement } from '@fluentui/react-utilities'; + +const mount = (element: JSXElement) => { + mountBase(element); +}; + +const popoverTriggerSelector = '[aria-expanded]'; +const popoverContentSelector = '[role="group"]'; +const popoverDialogContentSelector = '[role="dialog"]'; + +describe('Popover', () => { + ['uncontrolled', 'controlled'].forEach(scenario => { + const UncontrolledExample = () => ( + + + + + This is a popover + + ); + + const ControlledExample = () => { + const [open, setOpen] = React.useState(false); + + return ( + setOpen(data.open)}> + + + + This is a popover + + ); + }; + + describe(scenario, () => { + const Example = scenario === 'controlled' ? ControlledExample : UncontrolledExample; + + beforeEach(() => { + mount(); + cy.get('body').click('bottomRight'); + }); + + it('should open when clicked', () => { + cy.get(popoverTriggerSelector).click().get(popoverContentSelector).should('be.visible'); + }); + + (['{enter}', 'Space'] as const).forEach((key: '{enter}' | 'Space') => { + it(`should open with ${key}`, () => { + cy.get(popoverTriggerSelector).focus().realPress(key); + cy.get(popoverContentSelector).should('be.visible'); + }); + }); + + it('should dismiss on click outside', () => { + cy.get(popoverTriggerSelector) + .click() + .get('body') + .click('bottomRight') + .get(popoverContentSelector) + .should('not.exist'); + }); + + it('should dismiss on Escape keydown', () => { + cy.get(popoverTriggerSelector).click().realPress('Escape'); + cy.get(popoverContentSelector).should('not.exist'); + }); + + it('should keep open state on scroll outside', () => { + cy.get(popoverTriggerSelector).click().get(popoverContentSelector).should('be.visible'); + cy.get('body').trigger('wheel').get(popoverContentSelector).should('be.visible'); + }); + }); + }); + + describe('ARIA attributes', () => { + it('should set aria-expanded="false" on closed trigger', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).should('have.attr', 'aria-expanded', 'false'); + }); + + it('should set aria-expanded="true" when open', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).click().should('have.attr', 'aria-expanded', 'true'); + }); + + it('should set aria-haspopup="true" by default', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).should('have.attr', 'aria-haspopup', 'true'); + }); + + it('should set aria-haspopup="dialog" with trapFocus', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).should('have.attr', 'aria-haspopup', 'dialog'); + }); + + it('should set role="group" on surface by default', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverContentSelector).should('exist'); + }); + + it('should set role="dialog" and aria-modal with trapFocus', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverDialogContentSelector).should('have.attr', 'aria-modal', 'true'); + }); + }); + + describe('data-* attributes', () => { + it('should set data-open on trigger when open', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).should('not.have.attr', 'data-open'); + cy.get(popoverTriggerSelector).click().should('have.attr', 'data-open'); + }); + + it('should set data-open on surface', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverContentSelector).should('have.attr', 'data-open'); + }); + + it('should set popover="manual" on surface', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverContentSelector).should('have.attr', 'popover', 'manual'); + }); + }); + + describe('Open on hover', () => { + beforeEach(() => { + mount( + + + + + This is a popover + , + ); + cy.get('body').click('bottomRight'); + }); + + it('should open on hover, and keep open on mouse move to content', () => { + cy.get(popoverTriggerSelector).trigger('mouseover').get(popoverContentSelector).should('be.visible'); + cy.get(popoverContentSelector).trigger('mouseover').get(popoverContentSelector).should('be.visible'); + }); + }); + + describe('With custom trigger', () => { + const CustomTrigger = React.forwardRef((props, ref) => { + return ( + + ); + }); + + it('should dismiss on click outside', () => { + mount( + + + + + This is a popover + , + ); + cy.get(popoverTriggerSelector).get('body').click('bottomRight').get(popoverContentSelector).should('not.exist'); + }); + }); + + describe('Context popover', () => { + beforeEach(() => { + mount( + + + + + This is a popover + , + ); + cy.get('body').click('bottomRight'); + }); + + it('should open when right clicked', () => { + cy.get(popoverTriggerSelector).rightclick().get(popoverContentSelector).should('be.visible'); + }); + + it('should dismiss on scroll outside', () => { + cy.get(popoverTriggerSelector) + .rightclick() + .get('body') + .trigger('wheel') + .get(popoverContentSelector) + .should('not.exist'); + }); + }); + + describe('popover with closeOnScroll', () => { + beforeEach(() => { + mount( + + + + + This is a popover + , + ); + cy.get('body').click('bottomRight'); + }); + + it('should dismiss on scroll outside', () => { + cy.get(popoverTriggerSelector).click().get(popoverContentSelector).should('be.visible'); + cy.get('body').trigger('wheel').get(popoverContentSelector).should('not.exist'); + }); + }); + + describe('Nested', () => { + const PopoverL1 = () => { + return ( + + + + + + + + + + + ); + }; + + const PopoverL2 = () => { + return ( + + + + + + + + + ); + }; + + const Example = () => { + return ( + + + + + + + + + + ); + }; + + beforeEach(() => { + mount(); + cy.contains('Root').click().get('body').contains('First').click().get('body').contains('Second').first().click(); + }); + + it('should trap focus with tab', () => { + cy.focused().then(beforeFocused => { + cy.focused().realPress('Tab'); + cy.realPress(['Shift', 'Tab']); + cy.focused().then(afterFocused => { + expect(beforeFocused[0]).eq(afterFocused[0]); + }); + }); + }); + + it('should trap focus with shift+tab', () => { + cy.focused().then(beforeFocused => { + cy.focused().realPress('Tab'); + cy.realPress(['Shift', 'Tab']); + cy.focused().then(afterFocused => { + expect(beforeFocused[0]).eq(afterFocused[0]); + }); + }); + }); + + it('should dismiss all nested popovers on outside click', () => { + cy.get('body').click('bottomRight').get(popoverDialogContentSelector).should('not.exist'); + }); + + it('should not dismiss when clicking on nested content', () => { + cy.contains('Second nested button').click().get(popoverDialogContentSelector).should('have.length', 3); + }); + + it('should dismiss child popovers when clicking on parents', () => { + // Native top-layer popovers stack visually, so deeper popovers cover + // ancestor surface buttons. `{ force: true }` bypasses Cypress's + // obscurement check — we're asserting dismissal behavior, not + // spatial layout. + cy.contains('First nested button') + .click({ force: true }) + .get(popoverDialogContentSelector) + .should('have.length', 2) + .contains('Root button') + .click({ force: true }) + .get(popoverDialogContentSelector) + .should('have.length', 1); + }); + + it('should when opening a sibling popover, should dismiss other sibling popover', () => { + const secondNestedTriggerSelector = 'button:contains(Second nested trigger)'; + + // The first sibling's popover is in the top layer and can cover the + // other sibling's trigger depending on viewport size. `{ force: true }` + // bypasses Cypress's obscurement check — the test asserts dismissal + // behavior, not spatial layout. + cy.get(secondNestedTriggerSelector) + .eq(1) + .click({ force: true }) + .get(popoverDialogContentSelector) + .should('have.length', 3) + .get(secondNestedTriggerSelector) + .eq(0) + .click({ force: true }) + .get(popoverDialogContentSelector) + .should('have.length', 3); + }); + + it('should dismiss each popover in the stack with Escape keydown', () => { + cy.focused().realPress('Escape'); + cy.get(popoverDialogContentSelector).should('have.length', 2); + cy.focused().realPress('Escape'); + cy.get(popoverDialogContentSelector).should('have.length', 1); + cy.focused().realPress('Escape'); + cy.get(popoverDialogContentSelector).should('not.exist'); + }); + }); + + describe('updating content', () => { + const Example = () => { + const [visible, setVisible] = React.useState(false); + + const changeContent = () => setVisible(true); + const onOpenChange: PopoverProps['onOpenChange'] = (e, data) => { + if (data.open === false) { + setVisible(false); + } + }; + + return ( + + + + + + {visible ? ( +
The second panel
+ ) : ( +
+ +
+ )} +
+
+ ); + }; + + it('should not close popover', () => { + mount(); + cy.get(popoverTriggerSelector) + .click() + .get(popoverContentSelector) + .within(() => { + cy.get('button').click(); + }) + .get(popoverContentSelector) + .should('exist'); + }); + }); + + describe('with inline prop', () => { + it('should render PopoverSurface in DOM order', () => { + mount( + <> +
+ + + + + This is a Popover + +
+
Outside content
+ , + ); + + cy.get(popoverTriggerSelector) + .click() + .get(popoverContentSelector) + .prev() + .then(popoverSurfacePrev => { + cy.get(popoverTriggerSelector).then(popoverTrigger => { + expect(popoverTrigger[0]).eq(popoverSurfacePrev[0]); + }); + }); + }); + + it('should not have popover attribute when inline', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverContentSelector).should('not.have.attr', 'popover'); + }); + }); + + describe('focus trap', () => { + it('Tab should cycle within the surface', () => { + mount( + + + + + + + + + , + ); + + cy.get(popoverTriggerSelector).focus().realPress('Enter'); + + cy.contains('One').should('have.focus').realPress('Tab'); + cy.contains('Two').should('have.focus').realPress('Tab'); + cy.contains('One').should('have.focus').realPress(['Shift', 'Tab']); + cy.contains('Two').should('have.focus'); + }); + + it('should focus on PopoverSurface when its tabIndex is a number', () => { + mount( + + + + + + + + + , + ); + + cy.get(popoverTriggerSelector).focus().realPress('Enter'); + + cy.get('#popover-surface').should('have.focus').realPress('Tab'); + cy.contains('One').should('have.focus').realPress('Tab'); + cy.contains('Two').should('have.focus').realPress('Tab'); + cy.contains('One').should('have.focus').realPress(['Shift', 'Tab']); + cy.contains('Two').should('have.focus'); + }); + }); + + describe('Focus restoration', () => { + it('should restore focus to trigger on close', () => { + mount( + + + + + + + + , + ); + + cy.get('#trigger') + .click() + .get(popoverContentSelector) + .should('exist') + .get('#button') + .focus() + .type('{esc}') + .get(popoverContentSelector) + .should('not.exist') + .get('#trigger') + .should('have.focus'); + }); + }); + + describe('with Iframe', () => { + const iframeContent = `
+ +
`; + + const ExampleFrame = () => { + return