Skip to content

[scheduler] Fix keyboard and focus issues around the "+N more" popover - #23312

Open
rita-codes wants to merge 18 commits into
mui:masterfrom
rita-codes:fix/scheduler-editing-anchor-cleared-by-sibling-trigger
Open

[scheduler] Fix keyboard and focus issues around the "+N more" popover#23312
rita-codes wants to merge 18 commits into
mui:masterfrom
rita-codes:fix/scheduler-editing-anchor-cleared-by-sibling-trigger

Conversation

@rita-codes

@rita-codes rita-codes commented Aug 6, 2026

Copy link
Copy Markdown
Member

Closes #23307.

Keyboard and focus problems around the month view's "+N more" popover, found while chasing an intermittent failure of MonthView.test.tsx > should allow Enter key to activate events in the popover that turned out to be a real bug rather than a flaky test. Every fix has a regression test that was checked to fail without it.

Anchor ownership

An occurrence is routinely rendered by several EventEditingTriggers at once, and it takes no popover to get there: getOccurrenceKey is the event id alone, so a multi-day event has one trigger per day it spans in the agenda, one per week row it crosses in the month view, and the "+N more" popover repeats what the cell already shows. All of them anchored the shared editing surface from a layout effect, which caused two distinct bugs.

The dialog closed itself right after opening. The effect cleanup released the anchor unconditionally, so a trigger unmounting after a sibling had re-anchored cleared an anchor it no longer owned. EventDialogSurface bails on anchor == null, so the dialog vanished a moment after opening while the store still said editing.

The dialog anchored to the wrong instance. startEditing anchors to the trigger the user activated, but every trigger's effect then overwrote it in tree order, so the last one mounted won. Clicking a three-day event on Wednesday in the agenda opened the dialog next to the Friday instance.

Ownership is now a registry of the mounted triggers for the edited occurrence. Registering only claims an anchor that is vacant or detached, so the activated trigger keeps it while it is mounted; unregistering hands over to a live sibling in the same state update. That last part matters on its own: releasing to null first made React commit an intermediate render with no anchor, which unmounted the editing surface and brought the form back reseeded from the event — silently discarding whatever the user had typed. The trigger is back to a single effect.

Focus lost when the dialog is submitted

Closing the dialog with Enter submits the form, which ends editing and closes the popover with it. The dialog hands focus back to the event it was opened from, that event lives in the popover, and the popover then unmounts it — leaving focus on <body>, so the next Tab lands in the browser chrome instead of the calendar.

The popover now restores focus as it exits. It has to happen on the transition's onExited: any earlier and the dialog's Modal is still mounted, and its focus trap pulls focus straight back. It reads activeElement from the paper's ownerDocument so a calendar inside a shadow root behaves the same, and falls back to the day cell when editing emptied the day and unmounted the "+N more" button itself.

Focus is only taken back from where it was about to be lost — the document, or an element inside the popover that is going away with it. Tabbing out of the popover before its exit transition ends is reachable, and there the restore leaves the user where they got to.

Focus ring and cursor on popover events

An event continuing past the day edge gets a chevron via clip-path, which clips the outline away with everything else outside the shape, so those events took focus showing no ring at all. DayGridEvent already solved this by dropping the chevron while focused; that rule moves to arrowClips and both components use it, instead of the fix existing twice with each copy restating its own border radius.

EventItem also set cursor: pointer on the compact and regular variants only, so all-day and multi-day events, which render filled inside the popover, showed the default cursor while the same event in the day grid or the week view showed a pointer. The pointer moved to the card root, matching DayGridEvent, which sets it in its base styles for every variant.

Changelog

  • <EventCalendar /> no longer closes the editing dialog immediately when an event is opened from the "+N more" popover.

  • The editing dialog now anchors to the event the user activated, instead of another instance of the same event elsewhere in the view.

  • Edits in progress are no longer discarded when the event the dialog was opened from unmounts.

  • Submitting the editing dialog from the "+N more" popover returns focus to the "+N more" button instead of dropping it on the document.

  • Events continuing past the day edge now show a focus ring, and all-day events in the "+N more" popover show a pointer cursor.

  • I have followed (at least) the PR section of the contributing guide.

rita-codes and others added 4 commits August 6, 2026 13:17
The same occurrence is rendered by two triggers whenever the "+N more"
popover is open: one in the month cell, one in the popover. Both anchor
the editing surface on mount, so when the first one unmounts its cleanup
cleared an anchor the other one already owned. The surface then rendered
null while the store was still editing, and the dialog vanished right
after opening.

Release the anchor only when the unmounting trigger still owns it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Take over a vacant anchor: when the trigger owning it unmounts, no
  sibling re-anchored and the editing surface stopped rendering while the
  store was still editing.
- Return focus to the "+N more" button when the popover closes with the
  editing surface. It was handing focus back to an event that unmounts
  with it, leaving focus on the document so the next Tab left the page.
- Paint the focus ring inside the chevron shape. `clip-path` clips the
  outline away, so an event continuing past the day edge took focus
  without showing anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the chevron while focused instead of painting a ring inside it, so
the focus outline reads like every other event. This is what the day grid
event already does; `clip-path` clips an outline away either way.

Move `cursor: pointer` to the card root as well. It sat on the compact
and regular variants only, so all-day events, which render `filled`
inside the "+N more" popover, showed the default cursor while the same
event in the grid or the week view showed a pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches how programmatic focus is done elsewhere in the repo, so handing
focus back cannot scroll the grid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rita-codes rita-codes self-assigned this Aug 6, 2026
@rita-codes rita-codes added type: bug It doesn't behave as expected. scope: scheduler Changes related to the scheduler. labels Aug 6, 2026
@code-infra-dashboard

code-infra-dashboard Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploy preview

Bundle size

Bundle Parsed size Gzip size
@mui/x-data-grid 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-pro 0B(0.00%) 0B(0.00%)
@mui/x-data-grid-premium 0B(0.00%) 0B(0.00%)
@mui/x-charts 0B(0.00%) 0B(0.00%)
@mui/x-charts-pro 0B(0.00%) 0B(0.00%)
@mui/x-charts-premium 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers 0B(0.00%) 0B(0.00%)
@mui/x-date-pickers-pro 0B(0.00%) 0B(0.00%)
@mui/x-tree-view 0B(0.00%) 0B(0.00%)
@mui/x-tree-view-pro 0B(0.00%) 0B(0.00%)
@mui/x-scheduler 🔺+644B(+0.17%) 🔺+275B(+0.27%)
@mui/x-scheduler-premium 🔺+644B(+0.12%) 🔺+292B(+0.20%)
@mui/x-chat 0B(0.00%) 0B(0.00%)
@mui/x-license 0B(0.00%) 0B(0.00%)

Details of bundle changes


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

rita-codes and others added 11 commits August 6, 2026 17:01
The key press could land before the dialog handed focus to the title
field, so nothing submitted and the dialog stayed open. React 19 won the
race locally; React 18 lost it on CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anchor ownership is now a registry of the mounted triggers for the edited
occurrence. Registering only claims a vacant or detached anchor, so the
trigger the user activated keeps it instead of the last one in tree order
— an occurrence is routinely rendered several times (a multi-day event
across agenda days or month week rows, the "+N more" popover repeating
the cell), so the previous "last mount wins" opened the dialog next to
the wrong instance. Unregistering hands over to a live sibling in the
same update: the intermediate `null` unmounted the editing surface and
the form came back reseeded, discarding the user's draft.

Popover focus restore takes the paper node the transition already passes,
reads `activeElement` from its `ownerDocument`, and falls back to the day
cell when the opener itself unmounted.

The chevron focus reset moves to `arrowClips` so it is not stated twice.

Tests now pin what they claim: the anchor never passing through `null`,
ownership staying with the activated trigger, the submit actually firing,
and the `filled` variant being present for the cursor assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dialog and its focus trap settle on different elements across React
versions, so waiting for the title to hold focus timed out on React 18.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
UPPER_CASE is for the primitive constants in these suites; the object
fixtures around them are camelCase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilding it by hand restated the id, title and span, so the two could
drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard reads as a positive condition: focus is restored from the two
places it is about to be lost from. There is no reachable case of another
element holding focus at that point, since the popover is modal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tabbing out of the popover before its exit transition ends is reachable,
so the guard is load-bearing: without it the restore drags focus back to
the "+N more" button from wherever the user had got to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@flaviendelangle flaviendelangle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

PR review

One merge-blocking shadow-DOM focus regression remains. The focused jsdom and Chromium suites passed 62 tests, and PR CI is green, but the new coverage misses several important focus paths.

Bugs (1)

1. 🔴 Shadow-root portals skip focus restoration

Location: packages/x-scheduler/src/internals/components/more-events-popover/MoreEventsPopover.tsx:116

const activeElement = ownerDocument.activeElement;
// ...
paper.contains(activeElement);

When the popover is portaled into a shadow root, ownerDocument.activeElement returns the shadow host rather than the focused event inside the popover. The guard therefore incorrectly concludes focus moved elsewhere and skips restoration. A focused DOM reproduction confirmed this behavior.

Failure scenario: Embed the scheduler in a shadow root and configure Material UI portals to use that root. After submitting an event from “+N more,” the focused popover event unmounts and focus remains on the shadow host instead of returning to the trigger or day cell.

Fix: Use the shadow-aware getActiveElement helper from @mui/utils/getActiveElement, and add a regression test with the popover container inside a shadow root.

Tests (3)

1. 🟠 The transition-race test runs after the transition has finished

Location: packages/x-scheduler/src/month-view/tests/MonthView.test.tsx:250

await user.keyboard('{Escape}');
await user.keyboard('{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}{Tab}');

test/setupVitest.ts globally sets react-transition-group’s config.disabled = true. Consequently, onExited runs before the Tab presses, so this does not exercise focus moving while the exit transition is active.

Failure scenario: An unconditional onExited restoration would still pass because the test moves focus only after restoration already occurred.

Fix: Enable transitions for this test, move focus to a known external element while the popover remains connected, then await exit and assert focus remains external.

2. 🟠 The disconnected-trigger fallback is untested

Location: packages/x-scheduler/src/month-view/tests/MonthView.test.tsx:217

const onEventsChange = spy();
const { user, popover } = await renderAndOpenPopover({ onEventsChange });

The controlled event list is never updated, so the “+N more” button remains connected. Only the anchor.isConnected branch is covered; the newly added grid-cell fallback is not.

Failure scenario: Moving or deleting enough events during submission removes the trigger, but a broken fallback could leave focus on <body> while all tests pass.

Fix: Update the controlled events during submission so the button unmounts, then assert focus moves to the originating grid cell.

3. 🟡 The focus-ring test does not isolate each edge selector

Location: packages/x-scheduler/src/internals/components/event/event-item/EventItem.test.tsx:52

const clipped = popover.querySelector<HTMLElement>('[data-starting-before-edge]');

The fixture spans both sides of the displayed day, so it has both edge attributes. Removing either half of the comma-separated selector would still leave the element matching the other half.

Failure scenario: Start-only or end-only events could lose their focus outline without failing this test.

Fix: Parameterize separate start-only and end-only occurrences.

Simplifications (0)

No findings.

Docs (2)

1. 🟠 The focus-restoration guarantee is too broad

Location: docs/data/scheduler/accessibility/accessibility.md:161

When the popover closes, focus returns to the **"X more"** button that opened it...

The implementation intentionally preserves focus when it already moved elsewhere and falls back to the day cell when the trigger unmounts.

Failure scenario: Consumers relying on the documented guarantee observe focus on another element or the day cell in supported flows.

Fix: Document that focus returns to the opener only when it remains mounted and focus has not already moved; otherwise it falls back to the day cell.

2. 🟡 The dialog anchoring comment describes the old behavior

Location: packages/x-scheduler/src/internals/components/event-dialog/EventDialog.tsx:162

 * Mounts the desktop dialog during the `'edit'` stage, anchored to the element editing started from.

The new registry re-anchors the dialog to a surviving trigger when the original unmounts.

Failure scenario: Maintainers infer that the initial trigger remains the anchor throughout editing, contradicting the new ownership behavior.

Fix: Say that the initial trigger is preferred while mounted, with handoff to another registered trigger when necessary.

Verdict

Request changes - shadow-root portal usage can still lose the intended keyboard focus position.


🤖 Review generated with Codex

Make the focus tests exercise what they claim: the transition-race test ran
after the exit had already finished, and the day-cell fallback and the
per-edge focus ring selectors had no coverage at all.

Read the active element past the shadow host, matching what Material UI's
own FocusTrap does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvJymj8hcaW3yVoCiBkpb
@rita-codes

Copy link
Copy Markdown
Member Author

The three test findings were right — each one passed with its fix removed. Fixed and re-verified in jsdom and Chromium:

  • Transition race: transitions are disabled globally and the auto duration measures 0 in jsdom, so the test now enables them and forces 300ms through the theme before moving focus out. Needed react-transition-group as a devDependency, same as the Data Grid packages.
  • Disconnected trigger: the controlled events now shrink on submit so the button unmounts. Without the fallback, focus ends on <body>.
  • Focus ring: split into start-only and end-only occurrences selected with :not(...). Removing either half of the selector now fails exactly one test.

Docs and the EventDialog comment updated.

Shadow root: applied, but I don't think it's reproducible. I built the scenario and it passed with the broken read, so I instrumented focus(): the Popover's own FocusTrap moves focus out of the paper (FocusTrap.mjs:141) before onExited runs, so activeElement is already <body> — which both reads agree on. The paper.contains(...) branch is unreachable there, and focus parked elsewhere in the shadow tree reads as the host, which correctly skips the restore. I kept getActiveElement since it's what FocusTrap uses and it removes the dependency on that ordering, but there's no test to go with it — happy to drop it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: scheduler Changes related to the scheduler. type: bug It doesn't behave as expected.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[scheduler] Editing anchor, focus and cursor issues when an event is rendered more than once

2 participants