diff --git a/packages/react-aria-components/test/RangeCalendar.test.tsx b/packages/react-aria-components/test/RangeCalendar.test.tsx index b140c3772d7..8a523030d09 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,224 @@ describe('RangeCalendar', () => { let heading = tree.container.querySelector('.react-aria-CalendarHeading'); expect(heading).toHaveTextContent('April 1 – 2, 2026'); }); + + 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 pointerClick = (element: Element) => { + fireEvent.pointerDown(element, pointerOpts); + fireEvent.pointerUp(element, pointerOpts); + fireEvent.click(element, {detail: 1}); + }; + + let renderInShadowRoot = (calendarProps = {}, attachTo?: HTMLElement) => { + let {shadowRoot, cleanup} = createShadowRoot(attachTo); + let container = document.createElement('div'); + shadowRoot.appendChild(container); + let onChange = jest.fn(); + render( + , + {container} + ); + + 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'); + 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 + // 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'); + pointerClick(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 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 {grid, onChange, cleanup} = renderInShadowRoot(); + + 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(); + }); + + 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(); + }); + }); }); 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) &&