From 48ea6d6f6c0181a57d30802121f8c05eb2064625 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:12:29 -0400 Subject: [PATCH 1/2] fix: RangeCalendar range selection in shadow DOM The window-level pointerup listener in useRangeCalendar read the raw e.target, which is retargeted to the shadow host for events originating inside a shadow root. nodeContains() then always reported the release as outside the calendar, committing {start === end} on the first click. Resolve the real target with the shadow-safe getEventTarget() helper. Fixes #10330 --- .../test/RangeCalendar.test.tsx | 91 ++++++++++++++++++- .../src/calendar/useRangeCalendar.ts | 4 +- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/test/RangeCalendar.test.tsx b/packages/react-aria-components/test/RangeCalendar.test.tsx index b140c3772d7..6dded08ac76 100644 --- a/packages/react-aria-components/test/RangeCalendar.test.tsx +++ b/packages/react-aria-components/test/RangeCalendar.test.tsx @@ -10,7 +10,15 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import { + act, + createShadowRoot, + fireEvent, + installPointerEvent, + pointerMap, + render, + within +} from '@react-spectrum/test-utils-internal'; import {Button} from '../src/Button'; import { @@ -34,6 +42,7 @@ import { today } from '@internationalized/date'; import {DateValue} from 'react-stately/useRangeCalendarState'; +import {enableShadowDOM} from 'react-stately/private/flags/flags'; import {RangeValue} from '@react-types/shared'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -666,4 +675,84 @@ describe('RangeCalendar', () => { let heading = tree.container.querySelector('.react-aria-CalendarHeading'); expect(heading).toHaveTextContent('April 1 – 2, 2026'); }); + + describe('shadow DOM', () => { + installPointerEvent(); + + beforeAll(() => { + enableShadowDOM(); + }); + + let pointerOpts = {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, detail: 1}; + let clickCell = (cell: Element) => { + fireEvent.pointerDown(cell, pointerOpts); + fireEvent.pointerUp(cell, pointerOpts); + fireEvent.click(cell, {detail: 1}); + }; + + it('should support selecting a range by clicking two dates', () => { + let {shadowRoot, cleanup} = createShadowRoot(); + let container = document.createElement('div'); + shadowRoot.appendChild(container); + let onChange = jest.fn(); + render( + , + {container} + ); + + let grid = shadowRoot.querySelector('[role="grid"]')!; + let startCell = within(grid).getByText('17'); + clickCell(startCell); + + // The window pointerup listener receives an event retargeted to the shadow host. + // It must resolve the real target and not treat the click as a release outside + // the calendar, which would commit the selection early. + expect(startCell).toHaveAttribute('data-selection-start', 'true'); + expect(startCell).toHaveAttribute('data-selection-end', 'true'); + expect(onChange).not.toHaveBeenCalled(); + + let endCell = within(grid).getByText('23'); + clickCell(endCell); + + expect(startCell).toHaveAttribute('data-selection-start', 'true'); + expect(endCell).toHaveAttribute('data-selection-end', 'true'); + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + + cleanup(); + }); + + it('should commit the selection when releasing a drag outside the calendar', () => { + let {shadowRoot, cleanup} = createShadowRoot(); + let container = document.createElement('div'); + shadowRoot.appendChild(container); + let onChange = jest.fn(); + render( + , + {container} + ); + + let grid = shadowRoot.querySelector('[role="grid"]')!; + fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('23')); + expect(onChange).not.toHaveBeenCalled(); + + // Guards the inverse path: resolving the real target must not make releases + // outside the shadow root look like they are inside the calendar. + fireEvent.pointerUp(document.body, pointerOpts); + + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + + cleanup(); + }); + }); }); diff --git a/packages/react-aria/src/calendar/useRangeCalendar.ts b/packages/react-aria/src/calendar/useRangeCalendar.ts index 7708c00c56d..6aa7a1e9e93 100644 --- a/packages/react-aria/src/calendar/useRangeCalendar.ts +++ b/packages/react-aria/src/calendar/useRangeCalendar.ts @@ -13,7 +13,7 @@ import {AriaLabelingProps, DOMProps, FocusableElement, RefObject} from '@react-types/shared'; import {CalendarAria, useCalendarBase} from './useCalendarBase'; import {DateValue, RangeCalendarState} from 'react-stately/useRangeCalendarState'; -import {isFocusWithin, nodeContains} from '../utils/shadowdom/DOMFunctions'; +import {getEventTarget, isFocusWithin, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {RangeCalendarProps} from 'react-stately/useRangeCalendarState'; import {useEvent} from '../utils/useEvent'; import {useRef} from 'react'; @@ -76,7 +76,7 @@ export function useRangeCalendar( return; } - let target = e.target as Element; + let target = getEventTarget(e) as Element; if ( ref.current && isFocusWithin(ref.current) && From c50913382dc17942f248921e8ce12c47bc89fe6d Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Sat, 8 Aug 2026 18:19:00 -0400 Subject: [PATCH 2/2] test: cover shadow DOM edge cases for RangeCalendar range selection Extend the shadow DOM describe with paths the first regression test did not reach, all of which fail without the getEventTarget() fix: drag selection (onChange fired twice), the month navigation buttons (the closest('button') branch), commitBehavior "clear", and nested shadow roots. Add guards on the inverse paths so a later change cannot over-correct: releasing outside the calendar but inside the same shadow root, and committing when focus leaves the shadow root. Share the per-test setup through a renderInShadowRoot() helper, and note that enableShadowDOM() is a one way flag so this describe must stay last. --- .../test/RangeCalendar.test.tsx | 182 ++++++++++++++++-- 1 file changed, 161 insertions(+), 21 deletions(-) diff --git a/packages/react-aria-components/test/RangeCalendar.test.tsx b/packages/react-aria-components/test/RangeCalendar.test.tsx index 6dded08ac76..8a523030d09 100644 --- a/packages/react-aria-components/test/RangeCalendar.test.tsx +++ b/packages/react-aria-components/test/RangeCalendar.test.tsx @@ -679,32 +679,49 @@ describe('RangeCalendar', () => { describe('shadow DOM', () => { installPointerEvent(); + // enableShadowDOM() is a one way flag with no disable, so this describe has to stay + // last in the file — anything declared after it would run in shadow DOM mode too. beforeAll(() => { enableShadowDOM(); }); let pointerOpts = {pointerType: 'mouse', pointerId: 1, width: 1, height: 1, detail: 1}; - let clickCell = (cell: Element) => { - fireEvent.pointerDown(cell, pointerOpts); - fireEvent.pointerUp(cell, pointerOpts); - fireEvent.click(cell, {detail: 1}); + let pointerClick = (element: Element) => { + fireEvent.pointerDown(element, pointerOpts); + fireEvent.pointerUp(element, pointerOpts); + fireEvent.click(element, {detail: 1}); }; - it('should support selecting a range by clicking two dates', () => { - let {shadowRoot, cleanup} = createShadowRoot(); + let renderInShadowRoot = (calendarProps = {}, attachTo?: HTMLElement) => { + let {shadowRoot, cleanup} = createShadowRoot(attachTo); let container = document.createElement('div'); shadowRoot.appendChild(container); let onChange = jest.fn(); render( , {container} ); - let grid = shadowRoot.querySelector('[role="grid"]')!; + return { + onChange, + shadowRoot, + cleanup, + calendar: shadowRoot.querySelector('[role="application"]')!, + grid: shadowRoot.querySelector('[role="grid"]')! + }; + }; + + it('should support selecting a range by clicking two dates', () => { + let {grid, onChange, cleanup} = renderInShadowRoot(); + let startCell = within(grid).getByText('17'); - clickCell(startCell); + pointerClick(startCell); // The window pointerup listener receives an event retargeted to the shadow host. // It must resolve the real target and not treat the click as a release outside @@ -714,7 +731,7 @@ describe('RangeCalendar', () => { expect(onChange).not.toHaveBeenCalled(); let endCell = within(grid).getByText('23'); - clickCell(endCell); + pointerClick(endCell); expect(startCell).toHaveAttribute('data-selection-start', 'true'); expect(endCell).toHaveAttribute('data-selection-end', 'true'); @@ -726,19 +743,121 @@ describe('RangeCalendar', () => { cleanup(); }); + it('should support selecting a range by dragging', () => { + let {grid, onChange, cleanup} = renderInShadowRoot(); + + fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('20')); + fireEvent.pointerEnter(within(grid).getByText('23')); + expect(onChange).not.toHaveBeenCalled(); + + let endCell = within(grid).getByText('23'); + fireEvent.pointerUp(endCell, pointerOpts); + fireEvent.click(endCell, {detail: 1}); + + expect(within(grid).getByText('17')).toHaveAttribute('data-selection-start', 'true'); + expect(endCell).toHaveAttribute('data-selection-end', 'true'); + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + + cleanup(); + }); + + it('should not commit the selection when pressing the month navigation buttons', () => { + let {calendar, grid, onChange, cleanup} = renderInShadowRoot(); + + pointerClick(within(grid).getByText('17')); + expect(onChange).not.toHaveBeenCalled(); + + // Resolving the real target also feeds the `closest('button')` check: pressing + // the month navigation buttons must keep the in progress selection alive. + pointerClick(calendar.querySelector('button[slot="next"]')!); + expect(onChange).not.toHaveBeenCalled(); + + pointerClick(within(grid).getByText('5')); + + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 7, 5)); + + cleanup(); + }); + + it('should not clear the selection when clicking a date with commitBehavior="clear"', () => { + let {grid, onChange, cleanup} = renderInShadowRoot({ + commitBehavior: 'clear', + defaultValue: {start: new CalendarDate(2019, 6, 10), end: new CalendarDate(2019, 6, 20)} + }); + + let startCell = within(grid).getByText('17'); + pointerClick(startCell); + + // `clear` must only run for releases genuinely outside the calendar. + expect(startCell).toHaveAttribute('data-selection-start', 'true'); + expect(onChange).not.toHaveBeenCalled(); + + pointerClick(within(grid).getByText('23')); + + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + + cleanup(); + }); + + it('should support selecting a range inside nested shadow roots', () => { + let outer = createShadowRoot(); + let wrapper = document.createElement('div'); + outer.shadowRoot.appendChild(wrapper); + let {grid, onChange, cleanup} = renderInShadowRoot({}, wrapper); + + // The window listener only sees the outermost host, so the real target has to + // be resolved through both shadow boundaries. + pointerClick(within(grid).getByText('17')); + expect(onChange).not.toHaveBeenCalled(); + + pointerClick(within(grid).getByText('23')); + + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + + cleanup(); + outer.cleanup(); + }); + + it('should commit the selection when focus leaves the shadow root', () => { + let outsideButton = document.createElement('button'); + document.body.appendChild(outsideButton); + let {grid, onChange, cleanup} = renderInShadowRoot(); + + fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('23')); + expect(onChange).not.toHaveBeenCalled(); + + // The blur path commits via `relatedTarget` rather than the pointerup target, + // so it should keep working across a shadow boundary. + act(() => { + outsideButton.focus(); + }); + + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + + document.body.removeChild(outsideButton); + cleanup(); + }); + it('should commit the selection when releasing a drag outside the calendar', () => { - let {shadowRoot, cleanup} = createShadowRoot(); - let container = document.createElement('div'); - shadowRoot.appendChild(container); - let onChange = jest.fn(); - render( - , - {container} - ); + let {grid, onChange, cleanup} = renderInShadowRoot(); - let grid = shadowRoot.querySelector('[role="grid"]')!; fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); fireEvent.pointerEnter(within(grid).getByText('23')); expect(onChange).not.toHaveBeenCalled(); @@ -754,5 +873,26 @@ describe('RangeCalendar', () => { cleanup(); }); + + it('should commit the selection when releasing a drag outside the calendar but inside the shadow root', () => { + let {shadowRoot, grid, onChange, cleanup} = renderInShadowRoot(); + let sibling = document.createElement('div'); + shadowRoot.appendChild(sibling); + + fireEvent.pointerDown(within(grid).getByText('17'), pointerOpts); + fireEvent.pointerEnter(within(grid).getByText('23')); + expect(onChange).not.toHaveBeenCalled(); + + // The resolved target is a sibling within the same shadow root — still outside + // the calendar, so the selection commits. + fireEvent.pointerUp(sibling, pointerOpts); + + expect(onChange).toHaveBeenCalledTimes(1); + let {start, end} = onChange.mock.calls[0][0]; + expect(start).toEqual(new CalendarDate(2019, 6, 17)); + expect(end).toEqual(new CalendarDate(2019, 6, 23)); + + cleanup(); + }); }); });