Skip to content

Commit 578d74f

Browse files
committed
refactor(react-headless-components-preview): split usePopover and usePopoverAuto for tree-shaking
No behaviour change. Tests, type-check, lint, and api-report all pass.
1 parent 17152a2 commit 578d74f

8 files changed

Lines changed: 295 additions & 214 deletions

File tree

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,10 @@ export const renderPopoverSurface: (state: PopoverSurfaceState) => JSXElement;
129129
// @public
130130
export const renderPopoverTrigger: (state: PopoverTriggerState) => JSXElement | null;
131131

132-
// @public (undocumented)
132+
// @public
133133
export const usePopover: (props: PopoverProps) => PopoverState;
134134

135-
// @public (undocumented)
135+
// @public
136136
export const usePopoverAuto: (props: PopoverProps) => PopoverState;
137137

138138
// @public

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import type { PopoverProps } from './Popover.types';
44
import type { JSXElement } from '@fluentui/react-utilities';
5-
import { usePopover, usePopoverContextValues } from './usePopover';
5+
import { usePopover } from './usePopover';
6+
import { usePopoverContextValues } from './usePopoverBase';
67
import { renderPopover } from './renderPopover';
78

89
/**

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import type { PopoverProps } from './Popover.types';
44
import type { JSXElement } from '@fluentui/react-utilities';
5-
import { usePopoverAuto, usePopoverContextValues } from './usePopover';
5+
import { usePopoverAuto } from './usePopoverAuto';
6+
import { usePopoverContextValues } from './usePopoverBase';
67
import { renderPopover } from './renderPopover';
78

89
/**

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.
1111
*/
1212
export const usePopoverSurface = (props: PopoverSurfaceProps, ref: React.Ref<HTMLDivElement>): PopoverSurfaceState => {
1313
const contentRef = usePopoverContext(context => context.contentRef);
14+
const triggerRef = usePopoverContext(context => context.triggerRef);
1415
const openOnHover = usePopoverContext(context => context.openOnHover);
1516
const setOpen = usePopoverContext(context => context.setOpen);
1617
const arrowRef = usePopoverContext(context => context.arrowRef);
@@ -66,12 +67,20 @@ export const usePopoverSurface = (props: PopoverSurfaceProps, ref: React.Ref<HTM
6667
});
6768

6869
state.root.onKeyDown = useEventCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
69-
if (!browserHandlesDismiss && e.key === 'Escape') {
70+
// `isDefaultPrevented` lets a nested trigger / inner surface close itself
71+
// first and tell ancestors the Escape is already handled — without it,
72+
// a single Escape would walk up the React tree and close every surface
73+
// whose `data-popover-surface` ancestry happens to enclose `e.target`.
74+
if (!browserHandlesDismiss && e.key === 'Escape' && !e.isDefaultPrevented()) {
7075
const target = e.target as HTMLElement;
7176
const surface = contentRef.current;
7277
if (surface && target.closest('[data-popover-surface]') === surface) {
7378
e.preventDefault();
7479
setOpen(e, false);
80+
// Native `popover="manual"` does not restore focus; do it ourselves so
81+
// Escape inside the surface lands back on the trigger (matches the
82+
// chained Escape behaviour in nested popovers).
83+
triggerRef.current?.focus();
7584
}
7685
}
7786

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
export { Popover } from './Popover';
22
export { PopoverAuto } from './PopoverAuto';
33
export { renderPopover } from './renderPopover';
4-
export { usePopover, usePopoverAuto, usePopoverContextValues } from './usePopover';
4+
export { usePopover } from './usePopover';
5+
export { usePopoverAuto } from './usePopoverAuto';
6+
export { usePopoverContextValues } from './usePopoverBase';
57
export { usePopoverContext } from './popoverContext';
68
export type {
79
PopoverProps,
Lines changed: 22 additions & 208 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,33 @@
11
'use client';
22

33
import * as React from 'react';
4-
import {
5-
useControllableState,
6-
useEventCallback,
7-
useOnClickOutside,
8-
useOnScrollOutside,
9-
elementContains,
10-
useTimeout,
11-
} from '@fluentui/react-utilities';
4+
import { useOnClickOutside, useOnScrollOutside, elementContains } from '@fluentui/react-utilities';
125
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
13-
import { usePositioning, resolvePositioningShorthand } from '../../hooks';
14-
import type { PopoverProps, PopoverState, PopoverContextValue, OpenPopoverEvents, PopoverType } from './Popover.types';
6+
import type { PopoverProps, PopoverState } from './Popover.types';
7+
import { usePopoverBase, ensureNativePopoverShown } from './usePopoverBase';
8+
9+
export { usePopoverContextValues } from './usePopoverBase';
10+
11+
/**
12+
* Returns the state for a Popover component.
13+
*
14+
* Renders the surface with `popover="manual"`, leaving dismiss behaviour
15+
* (click-outside, scroll, Escape) under React's control. Open state is
16+
* the React state — the surface is in the top layer for painting only,
17+
* never owns visibility decisions.
18+
*/
19+
export const usePopover = (props: PopoverProps): PopoverState => {
20+
const base = usePopoverBase(props);
21+
const { open, setOpen, triggerRef, contentRef, inline, openOnContext, closeOnScroll, closeOnIframeFocus } = base;
1522

16-
const SUPPORTS_POPOVER_OPEN_SELECTOR =
17-
typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)');
18-
19-
type ToggleEventLike = Event & { newState?: 'open' | 'closed' };
20-
21-
function useInternalPopover(props: PopoverProps, popoverType: PopoverType): PopoverState {
22-
const {
23-
openOnHover = false,
24-
openOnContext = false,
25-
mouseLeaveDelay = 500,
26-
withArrow = false,
27-
disableAutoFocus = false,
28-
closeOnScroll = false,
29-
closeOnIframeFocus = true,
30-
inline = false,
31-
mountNode,
32-
} = props;
33-
34-
const [open, setOpenState] = useControllableState({
35-
state: props.open,
36-
defaultState: props.defaultOpen,
37-
initialState: false,
38-
});
39-
40-
const [contextTarget, setContextTarget] = React.useState<{ x: number; y: number } | undefined>(undefined);
4123
const { targetDocument } = useFluent();
4224

43-
const onOpenChange = useEventCallback((e: OpenPopoverEvents, shouldOpen: boolean) => {
44-
props.onOpenChange?.(e, { event: e, type: e.type, open: shouldOpen });
45-
});
46-
47-
const [setOpenTimeout, clearOpenTimeout] = useTimeout();
48-
49-
const setOpen = useEventCallback((e: OpenPopoverEvents, shouldOpen: boolean) => {
50-
clearOpenTimeout();
51-
52-
if (shouldOpen && e.type === 'contextmenu') {
53-
const mouseEvent = e as React.MouseEvent<HTMLElement>;
54-
setContextTarget({ x: mouseEvent.clientX, y: mouseEvent.clientY });
55-
}
56-
57-
if (!shouldOpen) {
58-
setContextTarget(undefined);
59-
}
60-
61-
if (e.type === 'mouseleave') {
62-
setOpenTimeout(() => {
63-
setOpenState(shouldOpen);
64-
onOpenChange(e, shouldOpen);
65-
}, mouseLeaveDelay);
66-
} else {
67-
setOpenState(shouldOpen);
68-
onOpenChange(e, shouldOpen);
69-
}
70-
});
71-
72-
const toggleOpen = React.useCallback(
73-
(e: OpenPopoverEvents) => {
74-
setOpen(e, !open);
75-
},
76-
[setOpen, open],
77-
);
78-
79-
const triggerRef = React.useRef<HTMLElement>(null);
80-
const contentRef = React.useRef<HTMLElement>(null);
81-
const arrowRef = React.useRef<HTMLDivElement>(null);
82-
83-
const positioning = usePositioning(resolvePositioningShorthand(props.positioning));
84-
85-
const isAutoMode = popoverType === 'auto' && !inline;
86-
8725
useOnClickOutside({
8826
contains: elementContains,
8927
element: targetDocument,
9028
callback: ev => setOpen(ev, false),
9129
refs: [triggerRef, contentRef],
92-
disabled: !open || isAutoMode,
30+
disabled: !open,
9331
disabledFocusOnIframe: !closeOnIframeFocus,
9432
});
9533

@@ -98,142 +36,18 @@ function useInternalPopover(props: PopoverProps, popoverType: PopoverType): Popo
9836
element: targetDocument,
9937
callback: ev => setOpen(ev, false),
10038
refs: [triggerRef, contentRef],
101-
disabled: !open || !(openOnContext || closeOnScroll) || isAutoMode,
39+
disabled: !open || !(openOnContext || closeOnScroll),
10240
});
10341

104-
// Mirror the browser-driven toggle events into React state when in auto mode.
105-
// Covers Escape, click-outside, and the popover-stack dismissal that happens
106-
// when an unrelated `popover="auto"` opens. Skip the no-op transition the
107-
// browser fires for our own `showPopover()` call (newState='open' while
108-
// React already has `open=true`).
109-
const onSurfaceToggle = useEventCallback((event: Event) => {
110-
const toggle = event as ToggleEventLike;
111-
const nextOpen = toggle.newState === 'open';
112-
if (nextOpen === open) {
113-
return;
114-
}
115-
setOpenState(nextOpen);
116-
props.onOpenChange?.(event, { event, type: event.type, open: nextOpen });
117-
});
118-
119-
// The surface is unmounted while closed (`state.open ? popoverSurface : null`),
120-
// so this effect must re-run when `open` flips so we attach `showPopover()`
121-
// and the `toggle` listener to the freshly-mounted surface element.
12242
React.useEffect(() => {
12343
const surface = contentRef.current;
12444

12545
if (!surface || inline || !open) {
12646
return;
12747
}
12848

129-
if (typeof surface.showPopover !== 'function') {
130-
return;
131-
}
132-
133-
if (!surface.hasAttribute('popover') || surface.getAttribute('popover') !== popoverType) {
134-
surface.setAttribute('popover', popoverType);
135-
}
136-
137-
if (!(SUPPORTS_POPOVER_OPEN_SELECTOR && surface.matches(':popover-open'))) {
138-
surface.showPopover();
139-
}
140-
141-
if (popoverType !== 'auto') {
142-
return;
143-
}
144-
145-
surface.addEventListener('toggle', onSurfaceToggle);
146-
return () => surface.removeEventListener('toggle', onSurfaceToggle);
147-
}, [open, inline, popoverType, onSurfaceToggle]);
148-
149-
const children = React.Children.toArray(props.children) as React.ReactElement[];
150-
151-
if (process.env.NODE_ENV !== 'production') {
152-
if (children.length === 0) {
153-
// eslint-disable-next-line no-console
154-
console.warn('Popover must contain at least one child');
155-
}
156-
157-
if (children.length > 2) {
158-
// eslint-disable-next-line no-console
159-
console.warn('Popover must contain at most two children');
160-
}
161-
}
162-
163-
let popoverTrigger: React.ReactElement | undefined;
164-
let popoverSurface: React.ReactElement | undefined;
165-
166-
if (children.length === 2) {
167-
popoverTrigger = children[0];
168-
popoverSurface = children[1];
169-
} else if (children.length === 1) {
170-
popoverSurface = children[0];
171-
}
172-
173-
return {
174-
open,
175-
setOpen,
176-
toggleOpen,
177-
triggerRef,
178-
contentRef,
179-
arrowRef,
180-
popoverTrigger,
181-
popoverSurface,
182-
openOnHover,
183-
openOnContext,
184-
withArrow,
185-
disableAutoFocus,
186-
inline,
187-
mountNode,
188-
onOpenChange: props.onOpenChange,
189-
contextTarget,
190-
setContextTarget,
191-
positioning,
192-
popoverType,
193-
};
194-
}
195-
196-
export const usePopover = (props: PopoverProps): PopoverState => useInternalPopover(props, 'manual');
197-
198-
export const usePopoverAuto = (props: PopoverProps): PopoverState => useInternalPopover(props, 'auto');
199-
200-
export const usePopoverContextValues = (state: PopoverState): { popover: PopoverContextValue } => {
201-
const {
202-
open,
203-
setOpen,
204-
toggleOpen,
205-
triggerRef,
206-
contentRef,
207-
arrowRef,
208-
openOnHover,
209-
openOnContext,
210-
disableAutoFocus,
211-
withArrow,
212-
inline,
213-
mountNode,
214-
positioning,
215-
popoverType,
216-
} = state;
49+
ensureNativePopoverShown(surface, 'manual');
50+
}, [open, inline, contentRef]);
21751

218-
return {
219-
popover: {
220-
open,
221-
setOpen,
222-
toggleOpen,
223-
triggerRef,
224-
contentRef,
225-
arrowRef,
226-
openOnHover,
227-
openOnContext,
228-
disableAutoFocus,
229-
withArrow,
230-
inline,
231-
mountNode,
232-
popoverType,
233-
positioning: {
234-
targetRef: positioning.targetRef,
235-
containerRef: positioning.containerRef,
236-
},
237-
},
238-
};
52+
return { ...base, popoverType: 'manual' };
23953
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { useEventCallback } from '@fluentui/react-utilities';
5+
import type { PopoverProps, PopoverState } from './Popover.types';
6+
import { usePopoverBase, ensureNativePopoverShown } from './usePopoverBase';
7+
8+
type ToggleEventLike = Event & { newState?: 'open' | 'closed' };
9+
10+
/**
11+
* Returns the state for a PopoverAuto component.
12+
*
13+
* Renders the surface with `popover="auto"`, deferring light-dismiss
14+
* (Escape, click-outside, popover-stack peer dismissal) to the browser.
15+
* Browser `toggle` events are mirrored back into React state and
16+
* `onOpenChange`. The library's React-driven dismiss hooks
17+
* (`useOnClickOutside`, `useOnScrollOutside`) are never imported here, so
18+
* a bundle that only uses `PopoverAuto` does not pull them in.
19+
*/
20+
export const usePopoverAuto = (props: PopoverProps): PopoverState => {
21+
const base = usePopoverBase(props);
22+
const { open, setOpenState, contentRef, inline } = base;
23+
24+
// Skip the no-op transition the browser fires for our own `showPopover()`
25+
// call (newState='open' while React already has `open=true`).
26+
const onSurfaceToggle = useEventCallback((event: Event) => {
27+
const toggle = event as ToggleEventLike;
28+
const nextOpen = toggle.newState === 'open';
29+
if (nextOpen === open) {
30+
return;
31+
}
32+
setOpenState(nextOpen);
33+
props.onOpenChange?.(event, { event, type: event.type, open: nextOpen });
34+
});
35+
36+
// The surface is unmounted while closed (`state.open ? popoverSurface : null`),
37+
// so this effect must re-run when `open` flips so we attach `showPopover()`
38+
// and the `toggle` listener to the freshly-mounted surface element.
39+
React.useEffect(() => {
40+
const surface = contentRef.current;
41+
42+
if (!surface || inline || !open) {
43+
return;
44+
}
45+
46+
ensureNativePopoverShown(surface, 'auto');
47+
surface.addEventListener('toggle', onSurfaceToggle);
48+
return () => surface.removeEventListener('toggle', onSurfaceToggle);
49+
}, [open, inline, contentRef, onSurfaceToggle]);
50+
51+
return { ...base, popoverType: 'auto' };
52+
};

0 commit comments

Comments
 (0)