@@ -15,6 +15,10 @@ import PopupContent from './PopupContent';
1515import useOffsetStyle from '../hooks/useOffsetStyle' ;
1616import { useEvent } from '@rc-component/util' ;
1717import type { PortalProps } from '@rc-component/portal' ;
18+ import {
19+ focusPopupRootOrFirst ,
20+ handlePopupTabTrap ,
21+ } from '../focusUtils' ;
1822
1923export interface MobileConfig {
2024 mask ?: boolean ;
@@ -85,6 +89,12 @@ export interface PopupProps {
8589
8690 // Mobile
8791 mobile ?: MobileConfig ;
92+
93+ /**
94+ * Move focus into the popup when it opens and return it to `target` when it closes.
95+ * Tab cycles within the popup. Escape is handled by Portal `onEsc`.
96+ */
97+ focusPopup ?: boolean ;
8898}
8999
90100const Popup = React . forwardRef < HTMLDivElement , PopupProps > ( ( props , ref ) => {
@@ -149,8 +159,13 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
149159 stretch,
150160 targetWidth,
151161 targetHeight,
162+
163+ focusPopup,
152164 } = props ;
153165
166+ const rootRef = React . useRef < HTMLDivElement > ( null ) ;
167+ const prevOpenRef = React . useRef ( false ) ;
168+
154169 const popupContent = typeof popup === 'function' ? popup ( ) : popup ;
155170
156171 // We can not remove holder only when motion finished.
@@ -208,12 +223,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
208223 offsetY ,
209224 ) ;
210225
211- // ========================= Render =========================
212- if ( ! show ) {
213- return null ;
214- }
215-
216- // >>>>> Misc
226+ // >>>>> Misc (computed before conditional return; hooks must run every render)
217227 const miscStyle : React . CSSProperties = { } ;
218228 if ( stretch ) {
219229 if ( stretch . includes ( 'height' ) && targetHeight ) {
@@ -232,6 +242,49 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
232242 miscStyle . pointerEvents = 'none' ;
233243 }
234244
245+ useLayoutEffect ( ( ) => {
246+ if ( ! focusPopup ) {
247+ prevOpenRef . current = open ;
248+ return ;
249+ }
250+
251+ const root = rootRef . current ;
252+ const wasOpen = prevOpenRef . current ;
253+ prevOpenRef . current = open ;
254+
255+ if ( open && ! wasOpen && root && isNodeVisible ) {
256+ focusPopupRootOrFirst ( root ) ;
257+ } else if ( ! open && wasOpen && root ) {
258+ const active = document . activeElement as HTMLElement | null ;
259+ if (
260+ target &&
261+ active &&
262+ ( root === active || root . contains ( active ) )
263+ ) {
264+ if ( target . isConnected ) {
265+ target . focus ( ) ;
266+ }
267+ }
268+ }
269+ } , [ open , focusPopup , isNodeVisible , target ] ) ;
270+
271+ const onPopupKeyDownCapture = useEvent (
272+ ( e : React . KeyboardEvent < HTMLDivElement > ) => {
273+ if ( ! focusPopup || ! open ) {
274+ return ;
275+ }
276+ const root = rootRef . current ;
277+ if ( root ) {
278+ handlePopupTabTrap ( e , root ) ;
279+ }
280+ } ,
281+ ) ;
282+
283+ // ========================= Render =========================
284+ if ( ! show ) {
285+ return null ;
286+ }
287+
235288 return (
236289 < Portal
237290 open = { forceRender || isNodeVisible }
@@ -276,7 +329,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
276329
277330 return (
278331 < div
279- ref = { composeRef ( resizeObserverRef , ref , motionRef ) }
332+ ref = { composeRef ( resizeObserverRef , ref , motionRef , rootRef ) }
280333 className = { cls }
281334 style = {
282335 {
@@ -295,6 +348,7 @@ const Popup = React.forwardRef<HTMLDivElement, PopupProps>((props, ref) => {
295348 onPointerEnter = { onPointerEnter }
296349 onClick = { onClick }
297350 onPointerDownCapture = { onPointerDownCapture }
351+ onKeyDownCapture = { onPopupKeyDownCapture }
298352 >
299353 { arrow && (
300354 < Arrow
0 commit comments