Skip to content

[popover][menu] De-duplicate focus guard handlers for popover and menu triggers#4522

Merged
sai6855 merged 5 commits intomui:masterfrom
sai6855:usefoucusguard
Apr 6, 2026
Merged

[popover][menu] De-duplicate focus guard handlers for popover and menu triggers#4522
sai6855 merged 5 commits intomui:masterfrom
sai6855:usefoucusguard

Conversation

@sai6855
Copy link
Copy Markdown
Member

@sai6855 sai6855 commented Apr 5, 2026

This PR refactors the focus guard logic used in MenuTrigger and PopoverTrigger by extracting it into a shared hook (useTriggerFocusGuards) to reduce duplication and improve maintainability.

@sai6855 sai6855 added component: menu Changes related to the menu component. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature. labels Apr 5, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 5, 2026

commit: 6616486

@sai6855 sai6855 added the component: popover Changes related to the popover component. label Apr 5, 2026
@mui-bot
Copy link
Copy Markdown

mui-bot commented Apr 5, 2026

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react ▼-183B(-0.04%) ▼-57B(-0.04%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 5, 2026

Deploy Preview for base-ui ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 6616486
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69d37f3b1652320008179350
😎 Deploy Preview https://deploy-preview-4522--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@sai6855 sai6855 marked this pull request as ready for review April 5, 2026 09:52
Copilot AI review requested due to automatic review settings April 5, 2026 09:52
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors duplicated focus-guard tab-out handling in PopoverTrigger and MenuTrigger into a shared useTriggerFocusGuards hook under utils/popups, aiming to reduce duplication and centralize the focus-management behavior.

Changes:

  • Added useTriggerFocusGuards hook that encapsulates the pre/post focus guard handlers and refs.
  • Updated PopoverTrigger and MenuTrigger to use the shared hook instead of local duplicated logic.
  • Re-exported the new hook from utils/popups barrel.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/react/src/utils/popups/useTriggerFocusGuards.ts Introduces shared focus guard behavior for popup triggers.
packages/react/src/utils/popups/index.ts Exposes the new hook via the popups barrel export.
packages/react/src/popover/trigger/PopoverTrigger.tsx Replaces inline focus-guard logic with the shared hook.
packages/react/src/menu/trigger/MenuTrigger.tsx Replaces inline focus-guard logic with the shared hook.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

export * from './popupStoreUtils';
export * from './popupTriggerMap';
export * from './store';
export * from './useTriggerFocusGuards';
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-exporting useTriggerFocusGuards from this barrel will cause every import ... from '../../utils/popups' to pull in a 'use client' module (and react-dom), which can unintentionally turn otherwise non-client modules into client-only in React Server Components setups. Since many non-'use client' files import from ../../utils/popups (e.g. stores/utilities), consider not exporting this hook from the barrel and instead importing it directly from ./useTriggerFocusGuards in PopoverTrigger/MenuTrigger, or split out a dedicated client-only barrel.

Suggested change
export * from './useTriggerFocusGuards';

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +26
/**
* Minimal store interface required by the focus guard hook.
* Both PopoverStore and MenuStore satisfy this interface.
*/
interface TriggerFocusGuardStore {
setOpen(open: boolean, eventDetails: any): void;
select(key: 'positionerElement', ...args: any[]): HTMLElement | null;
context: {
readonly beforeContentFocusGuardRef: React.RefObject<HTMLElement | null>;
readonly triggerFocusTargetRef: React.RefObject<HTMLElement | null>;
};
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TriggerFocusGuardStore currently uses any for setOpen's eventDetails and select args, which weakens type-safety for a shared hook that’s meant to be reused across popup stores. Consider typing eventDetails as Parameters<Store['setOpen']>[1] via a generic store parameter (or otherwise referencing the store’s ChangeEventDetails type), and narrowing select to just the 'positionerElement' selector signature used here.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@atomiks atomiks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex Review (GPT-5.4)

Dominant type: refactor (secondary: tests, react-ui). I reviewed the full branch diff against a freshly fetched upstream/master; the extraction looks behavior-preserving overall, but it leaves one menu-side regression path unpinned.

1. Bugs / Issues

1. 🟡 Missing menu regression for the newly shared trigger focus guards (non-blocking)

packages/react/src/utils/popups/useTriggerFocusGuards.ts

const handlePreFocusGuardFocus = useStableCallback((event: React.FocusEvent) => {
  ReactDOM.flushSync(() => {
    store.setOpen(false, createChangeEventDetails(REASONS.focusOut, event.nativeEvent, event.currentTarget as HTMLElement));
  });

  const previousTabbable: FocusableElement | null = getTabbableBeforeElement(preFocusGuardRef.current);
  previousTabbable?.focus();
});

const handleFocusTargetFocus = useStableCallback((event: React.FocusEvent) => {
  const positionerElement = store.select('positionerElement');
  if (positionerElement && isOutsideEvent(event, positionerElement)) {
    store.context.beforeContentFocusGuardRef.current?.focus();
  } else {
    // closes and advances focus past the trigger/popup
  }
});

packages/react/src/menu/root/MenuRoot.test.tsx

it('closes submenus when focus is lost by shift-tabbing from a nested menu', async () => {
  // ...
  await user.keyboard('{Shift>}{Tab}{/Shift}');
  // ...
});

This hook now owns MenuTrigger's root-trigger tab-out behavior too, but the menu suites still only cover nested submenu/dialog Shift+Tab scenarios and never directly assert the root trigger guards themselves. Popover already has explicit forward/backward tab coverage for the same extracted logic, so a future menu-only regression in this shared hook could slip through without a failing test.

Recommendation: Add at least one root-menu regression that tabs forward from an open menu to the element after the trigger, and one backward-tab case that returns focus to the trigger while preserving the expected close behavior.

Behavior Preservation Assessment

The extraction is a straight lift of the previous PopoverTrigger/MenuTrigger handlers: the flushSync timing, focusOut event details, and popup-skipping tabbable loop all match the pre-refactor code. Importing the hook directly also avoids the client-only barrel issue that would have been riskier.

Test Coverage Assessment

I ran these targeted validations and they passed:

  • pnpm test:jsdom PopoverTrigger --no-watch
  • pnpm test:jsdom PopoverRoot --no-watch
  • pnpm test:jsdom MenuRoot --no-watch
  • pnpm test:chromium PopoverRoot --no-watch
  • pnpm test:chromium MenuRoot --no-watch

Those runs make me comfortable with the refactor itself; the remaining concern is specifically the missing menu regression around the newly shared root-trigger focus-guard path.

Recommendation

Approve after nits are addressed 🟡

I do not see a behavior regression in the branch as-is, but I would add the missing menu coverage before merging so this shared hook is protected on both consumers rather than only implicitly through popover-oriented tests.

@sai6855
Copy link
Copy Markdown
Member Author

sai6855 commented Apr 6, 2026

@atomiks added tests recommended by codex

@sai6855 sai6855 merged commit 31a44c7 into mui:master Apr 6, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: menu Changes related to the menu component. component: popover Changes related to the popover component. type: enhancement It’s an improvement, but we can’t make up our mind whether it's a bug fix or a new feature.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants