Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat(react-headless-components-preview): add trapFocus prop to Popover for modal focus-trap via native dialog API",
"packageName": "@fluentui/react-headless-components-preview",
"email": "vgenaev@gmail.com",
"dependentChangeType": "patch"
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const Popover: {
};

// @public
export type PopoverContextValue = Pick<PopoverState, 'open' | 'setOpen' | 'toggleOpen' | 'triggerRef' | 'contentRef' | 'arrowRef' | 'openOnHover' | 'openOnContext' | 'withArrow' | 'surfaceId'> & {
export type PopoverContextValue = Pick<PopoverState, 'open' | 'setOpen' | 'toggleOpen' | 'triggerRef' | 'contentRef' | 'arrowRef' | 'openOnHover' | 'openOnContext' | 'withArrow' | 'surfaceId' | 'trapFocus'> & {
positioning: {
targetRef: React_2.RefCallback<HTMLElement>;
containerRef: React_2.RefCallback<HTMLElement>;
Expand All @@ -51,10 +51,11 @@ export type PopoverProps = {
mouseLeaveDelay?: number;
positioning?: PositioningShorthand;
withArrow?: boolean;
trapFocus?: boolean;
};

// @public
export type PopoverState = Required<Pick<PopoverProps, 'open'>> & Pick<PopoverProps, 'onOpenChange' | 'openOnContext' | 'openOnHover' | 'withArrow'> & {
export type PopoverState = Required<Pick<PopoverProps, 'open' | 'trapFocus'>> & Pick<PopoverProps, 'onOpenChange' | 'openOnContext' | 'openOnHover' | 'withArrow'> & {
setOpen: (e: OpenPopoverEvents, open: boolean) => void;
toggleOpen: (e: OpenPopoverEvents) => void;
triggerRef: React_2.RefObject<HTMLElement | null>;
Expand Down Expand Up @@ -82,7 +83,7 @@ export type PopoverSurfaceProps = ComponentProps<PopoverSurfaceSlots>;

// @public
export type PopoverSurfaceSlots = {
root: Slot<'div'>;
root: Slot<'dialog'>;
};

// @public (undocumented)
Expand Down Expand Up @@ -129,7 +130,7 @@ export const usePopoverContextValues: (state: PopoverState) => {
};

// @public
export const usePopoverSurface: (props: PopoverSurfaceProps, ref: React_2.Ref<HTMLDivElement>) => PopoverSurfaceState;
export const usePopoverSurface: (props: PopoverSurfaceProps, ref: React_2.Ref<HTMLDialogElement>) => PopoverSurfaceState;

// @public
export const usePopoverTrigger: (props: PopoverTriggerProps) => PopoverTriggerState;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,70 +312,101 @@ describe('Popover', () => {
});
});

describe('with Iframe', () => {
const iframeContent = `<div id="iframecontent">
<button>Hello World!</button>
</div>`;
describe('Focus trap', () => {
const trapSurfaceSelector = '[role="dialog"]';

const ExampleFrame = () => {
return <iframe title="frame" srcDoc={iframeContent} />;
};
it('moves focus into the surface on open and walks through focusables', () => {
mount(
<Popover trapFocus>
<PopoverTrigger disableButtonEnhancement>
<button data-testid="trigger">Trigger</button>
</PopoverTrigger>
<PopoverSurface data-testid="surface">
<button data-testid="first">First</button>
<button data-testid="second">Second</button>
<button data-testid="third">Third</button>
</PopoverSurface>
</Popover>,
);

cy.get('[data-testid=trigger]').realClick();
cy.get(trapSurfaceSelector).should('be.visible');

cy.focused().should('have.attr', 'data-testid', 'first');

cy.realPress('Tab');
cy.focused().should('have.attr', 'data-testid', 'second');
cy.realPress('Tab');
cy.focused().should('have.attr', 'data-testid', 'third');
});

it('honors the autofocus HTML attribute on a child', () => {
const setAutofocus = (el: HTMLInputElement | null) => el?.setAttribute('autofocus', '');

it('should not close when focus is on an internal iframe', () => {
mount(
<>
<Popover>
<PopoverTrigger disableButtonEnhancement>
<button>Popover trigger</button>
</PopoverTrigger>
<PopoverSurface>
<ExampleFrame />
</PopoverSurface>
</Popover>
</>,
<Popover trapFocus>
<PopoverTrigger disableButtonEnhancement>
<button data-testid="trigger">Trigger</button>
</PopoverTrigger>
<PopoverSurface data-testid="surface">
<button data-testid="first">First</button>
<input data-testid="auto" ref={setAutofocus} />
<button data-testid="last">Last</button>
</PopoverSurface>
</Popover>,
);

cy.get(popoverTriggerSelector)
.click()
.get('iframe')
.focus()
.wait(2000)
.get(popoverContentSelector)
.should('exist');
cy.get('[data-testid=trigger]').realClick();
cy.get(trapSurfaceSelector).should('be.visible');
cy.focused().should('have.attr', 'data-testid', 'auto');
});

it('should not close when focus is on an internal iframe in a nested popover', () => {
it('restores focus to the trigger on Escape', () => {
mount(
<>
<Popover>
<PopoverTrigger disableButtonEnhancement>
<button>First</button>
</PopoverTrigger>
<PopoverSurface>
<Popover>
<PopoverTrigger>
<button>Second</button>
</PopoverTrigger>
<PopoverSurface>
<ExampleFrame />
</PopoverSurface>
</Popover>
</PopoverSurface>
</Popover>
</>,
<Popover trapFocus>
<PopoverTrigger disableButtonEnhancement>
<button data-testid="trigger">Trigger</button>
</PopoverTrigger>
<PopoverSurface data-testid="surface">
<button data-testid="inner">Inner</button>
</PopoverSurface>
</Popover>,
);

cy.get(popoverTriggerSelector)
.first()
.click()
.get(popoverTriggerSelector)
.eq(1)
.click()
.get('iframe')
.focus()
.wait(2000)
.get(popoverContentSelector)
.should('have.length', 2);
cy.get('[data-testid=trigger]').realClick();
cy.get(trapSurfaceSelector).should('be.visible');
cy.realPress('Escape');
cy.get(trapSurfaceSelector).should('not.exist');
cy.focused().should('have.attr', 'data-testid', 'trigger');
});

it('makes content behind the surface inert (cannot be clicked)', () => {
const Example = () => {
const [outsideClicks, setOutsideClicks] = React.useState(0);
return (
<>
<button data-testid="outside" onClick={() => setOutsideClicks(c => c + 1)}>
Outside ({outsideClicks})
</button>
<Popover trapFocus>
<PopoverTrigger disableButtonEnhancement>
<button data-testid="trigger">Trigger</button>
</PopoverTrigger>
<PopoverSurface data-testid="surface">
<button data-testid="inner">Inner</button>
</PopoverSurface>
</Popover>
</>
);
};

mount(<Example />);
cy.get('[data-testid=trigger]').realClick();
cy.get(trapSurfaceSelector).should('be.visible');

cy.get('[data-testid=outside]').realClick();
cy.get('[data-testid=outside]').should('have.text', 'Outside (0)');
cy.get(trapSurfaceSelector).should('be.visible');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ describe('Popover', () => {
</Popover>,
);

expect(getByRole('group')).toBeInTheDocument();
expect(getByRole('group', { hidden: true })).toBeInTheDocument();
});

it('sets data-open attribute on surface', () => {
Expand All @@ -149,7 +149,7 @@ describe('Popover', () => {
</Popover>,
);

expect(getByRole('group')).toHaveAttribute('data-open');
expect(getByRole('group', { hidden: true })).toHaveAttribute('data-open');
});

it('mirrors a browser-driven `toggle` event into React state and closes the surface', () => {
Expand All @@ -162,7 +162,7 @@ describe('Popover', () => {
</Popover>,
);

const surface = getByRole('group');
const surface = getByRole('group', { hidden: true });
const toggleEvent = new Event('toggle');
(toggleEvent as unknown as { newState: string }).newState = 'closed';
fireEvent(surface, toggleEvent);
Expand All @@ -180,6 +180,6 @@ describe('Popover', () => {
</Popover>,
);

expect(getByRole('group')).toHaveAttribute('popover', 'auto');
expect(getByRole('group', { hidden: true })).toHaveAttribute('popover', 'auto');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,27 @@ export type PopoverProps = {
* @default false
*/
withArrow?: boolean;

/**
* When true, opens the popover as a modal via `HTMLDialogElement.showModal()`.
* Focus is trapped inside the surface, the rest of the page is inert, and
* focus is restored to the trigger when the popover closes — all spec-mandated
* by the native dialog element.
*
* When false (default), the popover is non-modal: opens via `showPopover()`,
* the browser handles light dismiss (Escape, click-outside, popover-stack
* peer dismissal), and no focus trap or autofocus is applied unless a child
* has the HTML `autofocus` attribute.
*
* @default false
*/
trapFocus?: boolean;
};

/**
* Popover State
*/
export type PopoverState = Required<Pick<PopoverProps, 'open'>> &
export type PopoverState = Required<Pick<PopoverProps, 'open' | 'trapFocus'>> &
Pick<PopoverProps, 'onOpenChange' | 'openOnContext' | 'openOnHover' | 'withArrow'> & {
setOpen: (e: OpenPopoverEvents, open: boolean) => void;
toggleOpen: (e: OpenPopoverEvents) => void;
Expand Down Expand Up @@ -105,6 +120,7 @@ export type PopoverContextValue = Pick<
| 'openOnContext'
| 'withArrow'
| 'surfaceId'
| 'trapFocus'
> & {
positioning: {
targetRef: React.RefCallback<HTMLElement>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('PopoverSurface', () => {
</Popover>,
);

expect(getByRole('group')).toBeInTheDocument();
expect(getByRole('group', { hidden: true })).toBeInTheDocument();
});

it('has data-open attribute when open', () => {
Expand All @@ -41,7 +41,7 @@ describe('PopoverSurface', () => {
</Popover>,
);

expect(getByRole('group')).toHaveAttribute('data-open');
expect(getByRole('group', { hidden: true })).toHaveAttribute('data-open');
});

it('has popover="auto" attribute by default', () => {
Expand All @@ -54,7 +54,7 @@ describe('PopoverSurface', () => {
</Popover>,
);

expect(getByRole('group')).toHaveAttribute('popover', 'auto');
expect(getByRole('group', { hidden: true })).toHaveAttribute('popover', 'auto');
});

it('mirrors a browser-driven `toggle` event into onOpenChange', () => {
Expand All @@ -69,7 +69,7 @@ describe('PopoverSurface', () => {
</Popover>,
);

const surface = getByRole('group');
const surface = getByRole('group', { hidden: true });
const toggleEvent = new Event('toggle');
(toggleEvent as unknown as { newState: string }).newState = 'closed';
fireEvent(surface, toggleEvent);
Expand All @@ -87,7 +87,7 @@ describe('PopoverSurface', () => {
</Popover>,
);

expect(getByRole('group').querySelector('[data-arrow]')).toBeInTheDocument();
expect(getByRole('group', { hidden: true }).querySelector('[data-arrow]')).toBeInTheDocument();
});

it('applies custom className', () => {
Expand All @@ -100,11 +100,11 @@ describe('PopoverSurface', () => {
</Popover>,
);

expect(getByRole('group')).toHaveClass('my-surface');
expect(getByRole('group', { hidden: true })).toHaveClass('my-surface');
});

it('keeps trigger aria-details linked to the surface even when a custom id is provided', () => {
const { getByText, getByRole } = render(
const { getByRole, getByText } = render(
<Popover open>
<PopoverTrigger>
<button>Trigger</button>
Expand All @@ -113,7 +113,7 @@ describe('PopoverSurface', () => {
</Popover>,
);

const surface = getByRole('group');
const surface = getByRole('group', { hidden: true });
const trigger = getByText('Trigger');

expect(surface.id).not.toBe('user-provided-id');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ import { renderPopoverSurface } from './renderPopoverSurface';
/**
* Headless PopoverSurface component.
*
* Renders the popover content area using native HTML popover attribute
* for top-layer rendering.
* Renders the popover content area as a native `<dialog popover="auto">` so
* a single element supports both non-modal (`showPopover()`) and modal
* (`showModal()`) show modes; the choice is driven by the parent
* `Popover`'s `trapFocus` prop.
*/
export const PopoverSurface: ForwardRefComponent<PopoverSurfaceProps> = React.forwardRef((props, ref) => {
export const PopoverSurface: ForwardRefComponent<PopoverSurfaceProps> = React.forwardRef<
HTMLDialogElement,
PopoverSurfaceProps
>((props, ref) => {
const state = usePopoverSurface(props, ref);
return renderPopoverSurface(state);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import type * as React from 'react';
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';

/**
* PopoverSurface Slots
* PopoverSurface Slots.
*
* The root renders as a native `<dialog popover="auto">` so a single element
* supports both show modes: `showPopover()` for non-modal (default) and
* `showModal()` for the modal/focus-trap path.
*/
export type PopoverSurfaceSlots = {
root: Slot<'div'>;
root: Slot<'dialog'>;
};

export type PopoverSurfaceProps = ComponentProps<PopoverSurfaceSlots>;
Expand Down
Loading
Loading