diff --git a/change/@fluentui-react-headless-components-preview-b4355b45-2078-49a5-85ef-8914bb5b16e2.json b/change/@fluentui-react-headless-components-preview-b4355b45-2078-49a5-85ef-8914bb5b16e2.json new file mode 100644 index 00000000000000..b95ceff2239ea8 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-b4355b45-2078-49a5-85ef-8914bb5b16e2.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add headless Popover'", + "packageName": "@fluentui/react-headless-components-preview", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md new file mode 100644 index 00000000000000..e3a863157240a5 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -0,0 +1,288 @@ +# Popover — Headless Spec + +## Overview + +Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. The surface can elevate into the browser's **top layer** via the native HTML Popover API, or render inline in DOM order when `inline={true}`. Placement is computed via the native **CSS Anchor Positioning API** — no JS layout loop. + +Popover manages dismissal (click-outside, scroll-outside, Escape, iframe blur), opt-in hover/context interaction, and keeps `data-placement` in sync with the browser's post-flip decision so consumer CSS can style flipped placements. Focus trapping is deferred to a later iteration — the surface is currently a non-modal `role="group"`. + +## Composition + +``` +Popover +├── PopoverTrigger (optional — clones a single child, wires events) +└── PopoverSurface + ├── [arrow] (optional — rendered when withArrow={true}) + └── children / content +``` + +Popover is a compound component. `PopoverTrigger` is optional — a surface with no trigger can be opened via `defaultOpen`, a controlled `open` prop, or imperatively via `positioning.target` / `positioning.positioningRef.setTarget`. + +`PopoverTrigger` takes **exactly one child** and clones it to attach click/keydown/hover/context-menu handlers plus a merged ref. + +## Props API + +### `Popover` + +| Prop | Type | Default | Description | +| -------------------- | ----------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `open` | `boolean` | `undefined` | Controlled: whether the surface is visible. Omit for uncontrolled. | +| `defaultOpen` | `boolean` | `false` | Uncontrolled: initial visibility. | +| `onOpenChange` | `(e, data: { open: boolean; type: string; event }) => void` | — | Fires whenever the surface wants to open or close. Always paired with the originating event and its `type` (click/key/etc.). | +| `openOnHover` | `boolean` | `false` | Open on `mouseenter` of the trigger; close on `mouseleave` (with delay). | +| `mouseLeaveDelay` | `number` (ms) | `500` | Delay before closing when hover leaves, giving the user time to move into the surface. | +| `openOnContext` | `boolean` | `false` | Open on the trigger's context-menu event (right-click / Shift+F10). Click and keyboard activation are ignored while on. | +| `closeOnScroll` | `boolean` | `false` | Close when the user scrolls anywhere outside the trigger + surface. | +| `closeOnIframeFocus` | `boolean` | `true` | Close when focus moves into an external iframe. Internal iframes (inside the surface) don't dismiss. | +| `disableAutoFocus` | `boolean` | `false` | Reserved for the upcoming focus-management iteration. Currently inert — the surface no longer auto-focuses on open. | +| `withArrow` | `boolean` | `false` | Render an arrow element inside the surface. Consumer CSS positions/rotates it using `[data-placement]`. | +| `inline` | `boolean` | `false` | Render the surface in DOM order (no top-layer elevation, no `popover="manual"`). | +| `mountNode` | `HTMLElement \| null` | `null` | Optional portal target for the surface. When omitted, the surface renders in place (top layer if not `inline`). | +| `positioning` | `PositioningShorthand` | `undefined` | Shorthand (`'below-start'`) or object (`{ position, align, offset, ... }`). See [Positioning](#positioning). | + +### `PopoverTrigger` + +| Prop | Type | Default | Description | +| -------------------------- | -------------- | ------- | -------------------------------------------------------------------------------------------------- | +| `children` | `ReactElement` | — | Exactly one child element. Cloned with merged handlers and ref. | +| `disableButtonEnhancement` | `boolean` | `false` | Skip `useARIAButtonProps` enhancement. Use when the child is already a fully-featured ARIA button. | + +### `PopoverSurface` + +| Prop | Type | Default | Description | +| ---------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------- | +| `tabIndex` | `number` | — | Forwarded to the rendered `
` so the surface can be focusable when the consumer needs it (e.g. `tabIndex={-1}`). | +| `children` | `ReactNode` | — | Surface content. | + +## States + +| State | Trigger | Behaviour | ARIA | +| ------------------ | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| **Closed** | Initial, or after dismissal | Surface unmounted. Trigger has `aria-expanded="false"`, no `data-open`. | `aria-expanded="false"` on trigger. | +| **Open** | `open={true}` / click / keyboard activation / hover / right-click (depending on props) | Surface mounted. In non-`inline` mode it's promoted into the top layer via `showPopover()` (feature-detected). `data-placement` reflects the requested placement; `usePlacementObserver` overwrites it with the resolved placement. | `aria-expanded="true"`, `data-open` on trigger; `role="group"`, `data-open` on surface. | +| **Hover-held** | `openOnHover` and pointer inside trigger or surface | Popover stays open while pointer is inside either element; closes `mouseLeaveDelay` ms after it leaves both. | Same as Open. | +| **Context-pinned** | `openOnContext` + right-click | `onOpenChange(e, { type: 'contextmenu', open: true })` with the mouse event; `contextTarget` state stores `{ x, y }`. Click and keyboard activation on the trigger do nothing. | Same as Open. | +| **Dismissing** | Click-outside / Escape inside surface / scroll-outside (if `closeOnScroll`) / iframe-focus move | `onOpenChange(e, { open: false, type })` fires with the originating DOM event. Consumer decides to close by updating state or letting uncontrolled state flip. | `aria-expanded` returns to `"false"` on trigger. | +| **Nested** | Popover rendered inside another Popover's surface | Each instance manages its own Escape / click-outside. Escape filters via `e.target.closest('[data-popover-surface]') === ownSurface` — no `stopPropagation`, no cross-popover coupling. | Each surface keeps its own `role="group"`. | + +## Keyboard Navigation + +### On trigger + +| Key | Action | +| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **Enter / Space** | Toggle open state. (Provided by `useARIAButtonProps` when the child is not already a button/link; disabled when `openOnContext={true}`.) | +| **Escape** | If the surface is open, close it. (Handled on trigger + inside surface — see below.) | +| **Context-menu key / Shift+F10** | Fires the native `contextmenu` event. When `openOnContext={true}`, opens the popover. | + +### Inside surface + +| Key | Action | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Tab** | Default browser tab order. The surface does **not** trap focus in this iteration; Tab can move focus out of the surface. | +| **Shift + Tab** | Default browser reverse tab order. | +| **Escape** | Dismiss the current popover. Filtered to the nearest enclosing surface, so Escape in a nested popover only closes that popover — not its ancestors. | +| **Enter / Space** | Default button activation inside the surface. | + +## Events + +| Event | Signature | When it fires | +| -------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `onOpenChange` | `(e: SyntheticEvent \| DOMEvent, data: { event; type; open: boolean }) => void` | Whenever the popover wants to open or close (trigger click, trigger keyboard activation, hover, context menu, Escape, click-outside, scroll-outside, iframe focus move). | + +There is no separate `onDismiss`. The `open: false` dispatches go through `onOpenChange` with a `type` that identifies the source (`'click'`, `'keydown'`, `'mouseleave'`, `'contextmenu'`, `'scroll'`, …). + +## Accessibility + +### ARIA pattern + +```tsx +// Trigger + + + …content… + +``` + +### Controlled + +```tsx +const [open, setOpen] = React.useState(false); + + setOpen(data.open)} positioning={{ position: 'below', align: 'start' }}> + + + + …content… +; +``` + +### Without a trigger + +```tsx +const triggerRef = React.useRef(null); + +<> + + + + +; +``` + +## RTL support + +Positioning uses CSS _logical_ properties throughout (`block-start`, `block-end`, `inline-start`, `inline-end`, `margin-inline-start`, …), so placement semantics flip correctly in RTL: + +- `position: 'before'` anchors on the inline-start side — left in LTR, right in RTL. +- `align: 'start'` anchors at the writing-mode start — top in horizontal-tb, left in vertical-rl. +- Physical v9 aliases (`top` / `bottom` / `left` / `right`) are normalized at the shorthand boundary and become logical internally. + +## Native API surface + +The package relies on three native browser APIs: + +- **CSS Anchor Positioning** (Chromium 125+) — `anchor-name`, `position-anchor`, `position-area`, `anchor-size()`, `position-try-fallbacks`. +- **HTML Popover API** (Chromium 114+) — `popover="manual"` + `showPopover()` for top-layer elevation. Feature-detected (`typeof el.showPopover === 'function'`); SSR-safe; `inline={true}` opts out entirely. +- **ResizeObserver** — used sparingly by `usePlacementObserver` (for live `data-placement`) + +Firefox and Safari are implementing CSS Anchor Positioning; most features work but flip behaviour is still WIP. `inline={true}` works in every engine. + +## Notes + +- **Top layer vs inline**: by default the surface is promoted to the top layer via `showPopover()`, which escapes `overflow: hidden` and `z-index` stacking contexts. Set `inline={true}` for scenarios where the surface must stay within a containing block (containerized demos, `contain: layout` ancestors). +- **Nested popovers**: each popover runs its own Escape / click-outside handlers. Escape in a nested surface closes only that surface. +- **Hover-to-open**: `openOnHover` opens on `mouseenter` of the trigger and _stays_ open while the pointer is over the surface (the surface has its own `mouseenter`/`mouseleave` handlers). `mouseLeaveDelay` protects against accidental close during pointer transitions. +- **Context popovers**: when `openOnContext={true}`, the mouse event's `clientX` / `clientY` are stored as `contextTarget` state — available to consumers via the popover context if they want to anchor the surface at the cursor position instead of on the trigger. +- **Positioning is CSS, not JS**: because placement computation is pushed to the browser, there's no JS layout loop and `positioning.updatePosition()` is a no-op. Consumers that need imperative retargeting use `positioning.setTarget(el)`. diff --git a/packages/react-components/react-headless-components-preview/library/etc/popover.api.md b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md new file mode 100644 index 00000000000000..80e1b00b4aba06 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/popover.api.md @@ -0,0 +1,154 @@ +## 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 + +import type { ComponentProps } from '@fluentui/react-utilities'; +import type { ComponentState } from '@fluentui/react-utilities'; +import type { ContextSelector } from '@fluentui/react-context-selector'; +import type { EventData } from '@fluentui/react-utilities'; +import type { EventHandler } from '@fluentui/react-utilities'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; +import type { Slot } from '@fluentui/react-utilities'; + +// @public +export type OnOpenChangeData = EventData & { + open: boolean; +}; + +// @public +export type OpenPopoverEvents = MouseEvent | TouchEvent | React_2.FocusEvent | React_2.KeyboardEvent | React_2.MouseEvent; + +// @public +export const Popover: { + (props: PopoverProps): JSXElement; + displayName: string; +}; + +// @public +export const PopoverAuto: { + (props: PopoverProps): JSXElement; + displayName: string; +}; + +// @public +export type PopoverContextValue = Pick & { + positioning: { + targetRef: React_2.RefCallback; + containerRef: React_2.RefCallback; + }; +}; + +// @public +export type PopoverProps = { + children: [JSXElement, JSXElement] | JSXElement; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: EventHandler; + openOnHover?: boolean; + openOnContext?: boolean; + mouseLeaveDelay?: number; + positioning?: PositioningShorthand; + withArrow?: boolean; + disableAutoFocus?: boolean; + closeOnScroll?: boolean; + closeOnIframeFocus?: boolean; + inline?: boolean; + mountNode?: HTMLElement | null; +}; + +// @public +export type PopoverState = Required> & Pick & { + setOpen: (e: OpenPopoverEvents, open: boolean) => void; + toggleOpen: (e: OpenPopoverEvents) => void; + triggerRef: React_2.RefObject; + contentRef: React_2.RefObject; + arrowRef: React_2.RefObject; + popoverTrigger: React_2.ReactElement | undefined; + popoverSurface: React_2.ReactElement | undefined; + contextTarget: { + x: number; + y: number; + } | undefined; + setContextTarget: (target: { + x: number; + y: number; + } | undefined) => void; + positioning: PositioningReturn; + popoverType: PopoverType; +}; + +// @public +export const PopoverSurface: ForwardRefComponent; + +// @public (undocumented) +export type PopoverSurfaceProps = ComponentProps; + +// @public +export type PopoverSurfaceSlots = { + root: Slot<'div'>; +}; + +// @public (undocumented) +export type PopoverSurfaceState = ComponentState & { + inline: boolean; + withArrow: boolean | undefined; + arrowRef: React_2.RefObject; + mountNode: HTMLElement | null | undefined; + 'data-open': string; +}; + +// @public +export const PopoverTrigger: React_2.FC; + +// @public +export type PopoverTriggerProps = { + children: React_2.ReactElement; + disableButtonEnhancement?: boolean; +}; + +// @public +export type PopoverTriggerState = { + children: React_2.ReactElement | null; +}; + +// @public +export type PopoverType = 'manual' | 'auto'; + +// @public +export const renderPopover: (state: PopoverState, contextValues: { + popover: PopoverContextValue; +}) => React_2.ReactElement; + +// @public +export const renderPopoverSurface: (state: PopoverSurfaceState) => JSXElement; + +// @public +export const renderPopoverTrigger: (state: PopoverTriggerState) => JSXElement | null; + +// @public +export const usePopover: (props: PopoverProps) => PopoverState; + +// @public +export const usePopoverAuto: (props: PopoverProps) => PopoverState; + +// @public +export const usePopoverContext: (selector: ContextSelector) => T; + +// @public (undocumented) +export const usePopoverContextValues: (state: PopoverState) => { + popover: PopoverContextValue; +}; + +// @public +export const usePopoverSurface: (props: PopoverSurfaceProps, ref: React_2.Ref) => PopoverSurfaceState; + +// @public +export const usePopoverTrigger: (props: PopoverTriggerProps) => PopoverTriggerState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md new file mode 100644 index 00000000000000..e2a3f51c5edb92 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -0,0 +1,76 @@ +## 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 + +import type * as React_2 from 'react'; + +// @public (undocumented) +export type Alignment = 'top' | 'bottom' | 'start' | 'end' | 'center'; + +// @public (undocumented) +export const ALIGNMENTS: { + readonly start: "start"; + readonly center: "center"; + readonly end: "end"; +}; + +// @public +export function getPlacementString(position: Position, align: LogicalAlignment): string; + +// @public (undocumented) +export type Position = 'above' | 'below' | 'before' | 'after'; + +// @public +export type PositioningImperativeRef = { + setTarget: (target: HTMLElement | null) => void; + updatePosition: () => void; +}; + +// @public (undocumented) +export type PositioningProps = { + position?: Position; + align?: Alignment; + offset?: number | { + mainAxis?: number; + crossAxis?: number; + }; + fallbackPositions?: PositioningShorthandValue[]; + coverTarget?: boolean; + target?: HTMLElement | React_2.RefObject | null; + strategy?: 'absolute' | 'fixed'; + matchTargetSize?: 'width'; + pinned?: boolean; + positioningRef?: React_2.Ref; +}; + +// @public (undocumented) +export type PositioningReturn = { + targetRef: React_2.RefCallback; + containerRef: React_2.RefCallback; +}; + +// @public (undocumented) +export type PositioningShorthand = PositioningProps | PositioningShorthandValue; + +// @public (undocumented) +export type PositioningShorthandValue = 'above' | 'above-start' | 'above-end' | 'below' | 'below-start' | 'below-end' | 'before' | 'before-start' | 'before-end' | 'after' | 'after-start' | 'after-end'; + +// @public (undocumented) +export const POSITIONS: { + readonly above: "above"; + readonly below: "below"; + readonly before: "before"; + readonly after: "after"; +}; + +// @public +export function resolvePositioningShorthand(value: PositioningShorthand | undefined): PositioningProps; + +// @public (undocumented) +export function usePositioning(options: PositioningProps): PositioningReturn; + +// (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..06dff721d7e46f 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", @@ -132,6 +135,18 @@ "import": "./lib/message-bar.js", "require": "./lib-commonjs/message-bar.js" }, + "./popover": { + "types": "./dist/popover.d.ts", + "node": "./lib-commonjs/popover.js", + "import": "./lib/popover.js", + "require": "./lib-commonjs/popover.js" + }, + "./positioning": { + "types": "./dist/positioning.d.ts", + "node": "./lib-commonjs/positioning.js", + "import": "./lib/positioning.js", + "require": "./lib-commonjs/positioning.js" + }, "./progress-bar": { "types": "./dist/progress-bar.d.ts", "node": "./lib-commonjs/progress-bar.js", 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..546a8ce5aeb965 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.cy.tsx @@ -0,0 +1,580 @@ +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"]'; + +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 role="group" on surface by default', () => { + mount( + + + + + Content + , + ); + + cy.get(popoverTriggerSelector).click(); + cy.get(popoverContentSelector).should('exist'); + }); + }); + + 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(popoverContentSelector).should('not.exist'); + }); + + it('should not dismiss when clicking on nested content', () => { + cy.contains('Second nested button').click().get(popoverContentSelector).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(popoverContentSelector) + .should('have.length', 2) + .contains('Root button') + .click({ force: true }) + .get(popoverContentSelector) + .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(popoverContentSelector) + .should('have.length', 3) + .get(secondNestedTriggerSelector) + .eq(0) + .click({ force: true }) + .get(popoverContentSelector) + .should('have.length', 3); + }); + + it('should dismiss each popover in the stack with Escape keydown', () => { + cy.focused().realPress('Escape'); + cy.get(popoverContentSelector).should('have.length', 2); + cy.focused().realPress('Escape'); + cy.get(popoverContentSelector).should('have.length', 1); + cy.focused().realPress('Escape'); + cy.get(popoverContentSelector).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 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