Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 230 additions & 1 deletion packages/react-aria-components/test/RangeCalendar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';
Expand Down Expand Up @@ -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(
<TestCalendar
calendarProps={{
onChange,
defaultFocusedValue: new CalendarDate(2019, 6, 5),
...calendarProps
}}
/>,
{container}
);

return {
onChange,
shadowRoot,
cleanup,
calendar: shadowRoot.querySelector<HTMLElement>('[role="application"]')!,
grid: shadowRoot.querySelector<HTMLElement>('[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<HTMLElement>('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();
});
});
});
4 changes: 2 additions & 2 deletions packages/react-aria/src/calendar/useRangeCalendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -76,7 +76,7 @@ export function useRangeCalendar<T extends DateValue>(
return;
}

let target = e.target as Element;
let target = getEventTarget(e) as Element;
if (
ref.current &&
isFocusWithin(ref.current) &&
Expand Down