Skip to content

Commit 86973b7

Browse files
committed
fixup! feat(react-headless-components-preview): add headless Popover built on native CSS anchor positioning
1 parent 41aae8b commit 86973b7

19 files changed

Lines changed: 554 additions & 478 deletions

File tree

packages/react-components/react-headless-components-preview/library/etc/react-headless-components-preview.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ export const Popover: {
392392
};
393393

394394
// @public
395-
export type PopoverContextValue = Pick<PopoverState, 'open' | 'setOpen' | 'toggleOpen' | 'triggerRef' | 'contentRef' | 'arrowRef' | 'openOnHover' | 'openOnContext' | 'trapFocus' | 'withArrow' | 'inline' | 'mountNode'> & {
395+
export type PopoverContextValue = Pick<PopoverState, 'open' | 'setOpen' | 'toggleOpen' | 'triggerRef' | 'contentRef' | 'arrowRef' | 'openOnHover' | 'openOnContext' | 'trapFocus' | 'disableAutoFocus' | 'withArrow' | 'inline' | 'mountNode'> & {
396396
positioning: {
397397
targetRef: React_2.RefCallback<HTMLElement>;
398398
containerRef: React_2.RefCallback<HTMLElement>;

packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.test.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import { render, fireEvent } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
34
import { Popover } from './Popover';
45
import { PopoverTrigger } from './PopoverTrigger/PopoverTrigger';
56
import { PopoverSurface } from './PopoverSurface/PopoverSurface';
@@ -55,7 +56,7 @@ describe('Popover', () => {
5556

5657
expect(queryByText('Surface content')).not.toBeInTheDocument();
5758

58-
fireEvent.click(getByText('Trigger'));
59+
userEvent.click(getByText('Trigger'));
5960

6061
expect(getByText('Surface content')).toBeInTheDocument();
6162
});
@@ -72,7 +73,7 @@ describe('Popover', () => {
7273

7374
expect(getByText('Surface content')).toBeInTheDocument();
7475

75-
fireEvent.click(getByText('Trigger'));
76+
userEvent.click(getByText('Trigger'));
7677

7778
expect(queryByText('Surface content')).not.toBeInTheDocument();
7879
});
@@ -89,7 +90,7 @@ describe('Popover', () => {
8990
</Popover>,
9091
);
9192

92-
fireEvent.click(getByText('Trigger'));
93+
userEvent.click(getByText('Trigger'));
9394

9495
expect(onOpenChange).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ open: true }));
9596
});
@@ -131,7 +132,7 @@ describe('Popover', () => {
131132
const trigger = getByText('Trigger');
132133
expect(trigger).toHaveAttribute('aria-expanded', 'false');
133134

134-
fireEvent.click(trigger);
135+
userEvent.click(trigger);
135136

136137
expect(trigger).toHaveAttribute('aria-expanded', 'true');
137138
});

packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.types.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,31 @@ export type PopoverProps = {
3232
/** Controlled open state. */
3333
open?: boolean;
3434

35-
/** Default open state for uncontrolled mode. @default false */
35+
/**
36+
* Default open state for uncontrolled mode.
37+
* @default false
38+
*/
3639
defaultOpen?: boolean;
3740

3841
/** Callback when the open state changes. */
3942
onOpenChange?: EventHandler<OnOpenChangeData>;
4043

41-
/** Open on hover. @default false */
44+
/**
45+
* Open on hover.
46+
* @default false
47+
*/
4248
openOnHover?: boolean;
4349

44-
/** Open on context menu (right-click). @default false */
50+
/**
51+
* Open on context menu (right-click).
52+
* @default false
53+
*/
4554
openOnContext?: boolean;
4655

47-
/** Delay in ms before closing on mouse leave. @default 500 */
56+
/**
57+
* Delay in ms before closing on mouse leave.
58+
* @default 500
59+
*/
4860
mouseLeaveDelay?: number;
4961

5062
/**
@@ -53,10 +65,16 @@ export type PopoverProps = {
5365
*/
5466
positioning?: PositioningShorthand;
5567

56-
/** Display an arrow pointing to the target. @default false */
68+
/**
69+
* Display an arrow pointing to the target.
70+
* @default false
71+
*/
5772
withArrow?: boolean;
5873

59-
/** Enable focus trap. @default false */
74+
/**
75+
* Enable focus trap.
76+
* @default false
77+
*/
6078
trapFocus?: boolean;
6179

6280
/**
@@ -67,13 +85,22 @@ export type PopoverProps = {
6785
*/
6886
disableAutoFocus?: boolean;
6987

70-
/** Close when scrolling outside. @default false */
88+
/**
89+
* Close when scrolling outside.
90+
* @default false
91+
*/
7192
closeOnScroll?: boolean;
7293

73-
/** Close when an iframe outside focuses. @default true */
94+
/**
95+
* Close when an iframe outside focuses.
96+
* @default true
97+
*/
7498
closeOnIframeFocus?: boolean;
7599

76-
/** Render inline instead of using native popover top-layer. @default false */
100+
/**
101+
* Render inline instead of using native popover top-layer.
102+
* @default false
103+
*/
77104
inline?: boolean;
78105

79106
/**
@@ -110,7 +137,10 @@ export type PopoverState = Required<Pick<PopoverProps, 'open' | 'inline'>> &
110137
*/
111138
export type PopoverTriggerProps = {
112139
children: React.ReactElement;
113-
/** Disable ARIA button enhancement on the trigger. @default false */
140+
/**
141+
* Disable ARIA button enhancement on the trigger.
142+
* @default false
143+
*/
114144
disableButtonEnhancement?: boolean;
115145
};
116146

@@ -152,6 +182,7 @@ export type PopoverContextValue = Pick<
152182
| 'openOnHover'
153183
| 'openOnContext'
154184
| 'trapFocus'
185+
| 'disableAutoFocus'
155186
| 'withArrow'
156187
| 'inline'
157188
| 'mountNode'

packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type * as React from 'react';
44
import { useMergedRefs, slot, useEventCallback } from '@fluentui/react-utilities';
55
import { usePopoverContext } from '../popoverContext';
6+
import { useFocusScope } from '../../../hooks';
67
import { stringifyDataAttribute } from '../../../utils';
78
import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.types';
89

@@ -16,10 +17,30 @@ export const usePopoverSurface = (props: PopoverSurfaceProps, ref: React.Ref<HTM
1617
const arrowRef = usePopoverContext(context => context.arrowRef);
1718
const withArrow = usePopoverContext(context => context.withArrow);
1819
const trapFocus = usePopoverContext(context => context.trapFocus);
20+
const disableAutoFocus = usePopoverContext(context => context.disableAutoFocus);
1921
const inline = usePopoverContext(context => context.inline);
2022
const open = usePopoverContext(context => context.open);
2123
const mountNode = usePopoverContext(context => context.mountNode);
2224
const positioningCtx = usePopoverContext(context => context.positioning);
25+
const tabIndex = typeof props.tabIndex === 'number' ? props.tabIndex : undefined;
26+
27+
const onMountAutoFocus = useEventCallback((event: Event) => {
28+
if (disableAutoFocus) {
29+
event.preventDefault();
30+
return;
31+
}
32+
33+
if (tabIndex !== undefined) {
34+
event.preventDefault();
35+
contentRef.current?.focus({ preventScroll: true });
36+
}
37+
});
38+
39+
const focusScope = useFocusScope({
40+
trapped: trapFocus,
41+
loop: trapFocus,
42+
onMountAutoFocus,
43+
});
2344

2445
const mergedArrowRef = useMergedRefs(arrowRef, positioningCtx.arrowRef);
2546

@@ -31,14 +52,17 @@ export const usePopoverSurface = (props: PopoverSurfaceProps, ref: React.Ref<HTM
3152
components: { root: 'div' },
3253
root: slot.always(
3354
{
34-
ref: useMergedRefs(ref, contentRef, positioningCtx.containerRef) as React.Ref<HTMLDivElement>,
55+
ref: useMergedRefs(
56+
ref,
57+
contentRef,
58+
positioningCtx.containerRef,
59+
focusScope.containerRef,
60+
) as React.Ref<HTMLDivElement>,
3561
role: trapFocus ? 'dialog' : 'group',
3662
'aria-modal': trapFocus ? true : undefined,
37-
// The `popover` attribute is applied at runtime in `usePopover`'s
38-
// effect — only when the browser supports the native Popover API.
39-
// Emitting it unconditionally here would trigger console errors on
40-
// older browsers (Chrome <114, Safari <17) during SSR/hydration.
63+
tabIndex: focusScope.containerProps.tabIndex,
4164
...props,
65+
'data-popover-surface': '',
4266
'data-open': stringifyDataAttribute(open),
4367
},
4468
{ elementType: 'div' },
@@ -66,14 +90,20 @@ export const usePopoverSurface = (props: PopoverSurfaceProps, ref: React.Ref<HTM
6690
onMouseLeaveOriginal?.(e);
6791
});
6892

93+
const focusScopeKeyDown = focusScope.containerProps.onKeyDown;
94+
6995
state.root.onKeyDown = useEventCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
70-
if (e.key === 'Escape' && contentRef.current?.contains(e.target as HTMLElement)) {
71-
e.preventDefault();
72-
// Stop bubbling so nested popovers don't all close at once — each
73-
// Escape press should dismiss only the innermost open popover.
74-
e.stopPropagation();
75-
setOpen(e, false);
96+
focusScopeKeyDown(e);
97+
98+
if (e.key === 'Escape') {
99+
const target = e.target as HTMLElement;
100+
const surface = contentRef.current;
101+
if (surface && target.closest('[data-popover-surface]') === surface) {
102+
e.preventDefault();
103+
setOpen(e, false);
104+
}
76105
}
106+
77107
onKeyDownOriginal?.(e);
78108
});
79109

packages/react-components/react-headless-components-preview/library/src/components/Popover/popoverContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const popoverContextDefaultValue: PopoverContextValue = {
1818
openOnContext: false,
1919
openOnHover: false,
2020
trapFocus: false,
21+
disableAutoFocus: false,
2122
withArrow: false,
2223
inline: false,
2324
mountNode: null,

packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,9 @@ import {
1010
useTimeout,
1111
} from '@fluentui/react-utilities';
1212
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
13-
import { usePositioning, useFocusTrap, useInert, resolvePositioningShorthand } from '../../hooks';
14-
import { findFirstFocusable } from '../../utils';
13+
import { usePositioning, resolvePositioningShorthand } from '../../hooks';
1514
import type { PopoverProps, PopoverState, PopoverContextValue, OpenPopoverEvents } from './Popover.types';
1615

17-
const SUPPORTS_POPOVER_OPEN_SELECTOR =
18-
typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)');
19-
2016
/**
2117
* Returns the state for a Popover component, given its props and ref.
2218
*/
@@ -102,34 +98,6 @@ export const usePopover = (props: PopoverProps, ref: React.Ref<HTMLElement>): Po
10298
disabled: !open || !(openOnContext || closeOnScroll),
10399
});
104100

105-
useFocusTrap(contentRef, open && trapFocus);
106-
useInert(contentRef, open && trapFocus);
107-
108-
const previouslyFocusedRef = React.useRef<HTMLElement | null>(null);
109-
const wasOpenRef = React.useRef(false);
110-
111-
React.useEffect(() => {
112-
if (open) {
113-
previouslyFocusedRef.current = (targetDocument?.activeElement as HTMLElement | null) ?? null;
114-
115-
if (!disableAutoFocus) {
116-
const contentElement = contentRef.current;
117-
if (contentElement) {
118-
const tabIndexValue = contentElement.getAttribute('tabIndex');
119-
const shouldFocusContainer = tabIndexValue !== null && !isNaN(Number(tabIndexValue));
120-
const firstFocusable = shouldFocusContainer ? contentElement : findFirstFocusable(contentElement);
121-
firstFocusable?.focus({ preventScroll: true });
122-
}
123-
}
124-
wasOpenRef.current = true;
125-
} else if (wasOpenRef.current) {
126-
const restoreTarget = triggerRef.current ?? previouslyFocusedRef.current;
127-
restoreTarget?.focus({ preventScroll: true });
128-
previouslyFocusedRef.current = null;
129-
wasOpenRef.current = false;
130-
}
131-
}, [open, disableAutoFocus, targetDocument]);
132-
133101
React.useEffect(() => {
134102
const surface = contentRef.current;
135103

@@ -145,10 +113,6 @@ export const usePopover = (props: PopoverProps, ref: React.Ref<HTMLElement>): Po
145113
surface.setAttribute('popover', 'manual');
146114
}
147115

148-
if (SUPPORTS_POPOVER_OPEN_SELECTOR && surface.matches(':popover-open')) {
149-
return;
150-
}
151-
152116
surface.showPopover();
153117
}, [open, inline]);
154118

@@ -210,6 +174,7 @@ export const usePopoverContextValues = (state: PopoverState): { popover: Popover
210174
openOnHover,
211175
openOnContext,
212176
trapFocus,
177+
disableAutoFocus,
213178
withArrow,
214179
inline,
215180
mountNode,
@@ -227,6 +192,7 @@ export const usePopoverContextValues = (state: PopoverState): { popover: Popover
227192
openOnHover,
228193
openOnContext,
229194
trapFocus,
195+
disableAutoFocus,
230196
withArrow,
231197
inline,
232198
mountNode,

packages/react-components/react-headless-components-preview/library/src/hooks/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { usePositioning } from './usePositioning';
2-
export { useFocusTrap } from './useFocusTrap';
3-
export { useInert } from './useInert';
2+
export { useFocusScope } from './useFocusScope';
3+
export type { UseFocusScopeOptions, UseFocusScopeReturn } from './useFocusScope';
44
export type {
55
Position,
66
Alignment,

0 commit comments

Comments
 (0)