Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/PickerInput/RangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,9 @@ function RangePicker<DateType extends object = any>(
const calendarValue = getCalendarValue();

// ======================== Focus =========================
// Activity may detach the selector ref before a pending blur event is handled.
const isInternalPickerElement = useEvent((element: EventTarget | null) =>
isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]),
isTargetInContainers(element, [selectorRef.current?.nativeElement, popupRef.current]),
);

const [focused, onFieldFocus, onFieldBlur] = useFocusEvents(
Expand Down
3 changes: 2 additions & 1 deletion src/PickerInput/SinglePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,9 @@ function Picker<DateType extends object = any>(
const calendarValue = getCalendarValue();

// ======================== Focus =========================
// Activity may detach the selector ref before a pending blur event is handled.
const isInternalPickerElement = useEvent((element: EventTarget | null) =>
isTargetInContainers(element, [selectorRef.current.nativeElement, popupRef.current]),
isTargetInContainers(element, [selectorRef.current?.nativeElement, popupRef.current]),
);

const [focused, onFieldFocus, onFieldBlur] = useFocusEvents(
Expand Down
3 changes: 2 additions & 1 deletion src/PickerPanel/TimePanel/TimePanelBody/TimeColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export default function TimeColumn<DateType extends object>(props: TimeUnitColum

const target = event.target as HTMLUListElement;

if (!isScrolling() && changeOnScroll) {
// Activity may deliver a queued native scroll after detaching the column ref.
if (ulRef.current && !isScrolling() && changeOnScroll) {
checkDelayRef.current = setTimeout(() => {
const ul = ulRef.current!;
const firstLiTop = ul.querySelector<HTMLLIElement>(`li`).offsetTop;
Expand Down
3 changes: 3 additions & 0 deletions src/PickerPanel/TimePanel/TimePanelBody/useScrollTo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export default function useScrollTo(
if ((targetLiTop === 0 && targetLi !== firstLi) || !isVisible(ul)) {
if (scrollRafTimesRef.current <= 5) {
scrollRafRef.current = raf(doScroll);
} else {
// Reconnecting with a hidden popup must not leave user scrolling locked.
stopScroll();
}
return;
}
Expand Down
106 changes: 106 additions & 0 deletions tests/activity-scroll.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { fireEvent, render } from '@testing-library/react';
import { isVisible, spyElementPrototype } from '@rc-component/util';
import React from 'react';
import { DayPicker, DayRangePicker, getDay, openPicker, waitFakeTimer } from './util/commonUtil';

jest.mock('@rc-component/util/lib/Dom/isVisible', () => {
return jest.fn(() => true);
});

describe.each([false, true])('Activity time scrolling (range=%s)', (range) => {
let offsetTopMock: ReturnType<typeof spyElementPrototype>;

beforeEach(() => {
jest.mocked(isVisible).mockReturnValue(true);
jest.useFakeTimers().setSystemTime(getDay('1990-09-03 00:00:00').valueOf());
offsetTopMock = spyElementPrototype(HTMLLIElement, 'offsetTop', {
get() {
const children = Array.from(this.parentNode?.childNodes || []);
return children.indexOf(this) * 30;
},
});
});

afterEach(() => {
offsetTopMock.mockRestore();
jest.clearAllTimers();
jest.useRealTimers();
});

describe.each(['before', 'after'])('scroll arrives %s hiding', (scrollTiming) => {
it.each([
{ restore: 'before the delay', beforeDelay: true, popupHidden: false },
{ restore: 'after the delay', beforeDelay: false, popupHidden: false },
{ restore: 'before the delay with a closed popup', beforeDelay: true, popupHidden: true },
])(
'preserves the value when restored $restore and allows new scrolling',
async ({ beforeDelay, popupHidden }) => {
const onCalendarChange = jest.fn();
const initialValue = getDay('1990-09-03 00:00:00');
const view = (mode: 'visible' | 'hidden') => (
<React.StrictMode>
<React.Activity mode={mode}>
{range ? (
<DayRangePicker
picker="time"
defaultValue={[initialValue, initialValue]}
changeOnScroll
onCalendarChange={onCalendarChange}
/>
) : (
<DayPicker
picker="time"
defaultValue={initialValue}
changeOnScroll
onCalendarChange={onCalendarChange}
/>
)}
</React.Activity>
</React.StrictMode>
);
const { container, rerender } = render(view('visible'));
const input = container.querySelector('input');
openPicker(container);
await waitFakeTimer();
onCalendarChange.mockClear();
const column = document.querySelector('.rc-picker-time-panel-column');
const scroll = () => fireEvent.scroll(column, { target: { scrollTop: 30 } });

if (scrollTiming === 'before') {
scroll();
}
rerender(view('hidden'));
if (scrollTiming === 'after') {
// Browsers may deliver a queued native scroll after Activity cleanup.
scroll();
}
if (beforeDelay) {
if (popupHidden) {
// A closed popup is still hidden when Activity reconnects its effects.
jest.mocked(isVisible).mockReturnValue(false);
}
rerender(view('visible'));
}
await waitFakeTimer();
expect(onCalendarChange).not.toHaveBeenCalled();
expect(input).toHaveValue('00:00:00');

jest.mocked(isVisible).mockReturnValue(true);
rerender(view('visible'));
expect(container.querySelector('input')).toBe(input);
openPicker(container);
await waitFakeTimer();
onCalendarChange.mockClear();
fireEvent.scroll(document.querySelector('.rc-picker-time-panel-column'), {
target: { scrollTop: 60 },
});
await waitFakeTimer();
expect(onCalendarChange).toHaveBeenCalledTimes(1);
expect(onCalendarChange.mock.calls[0][1]).toEqual(
range ? ['02:00:00', '00:00:00'] : '02:00:00',
);
expect(input).toHaveValue('02:00:00');
},
);
});
});
45 changes: 45 additions & 0 deletions tests/blur.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,48 @@ describe('Picker.ChangeOnBlur', () => {
expect(endInputEle).toHaveValue('no valid 2');
});
});

describe.each([
['Picker', DayPicker, 0],
['RangePicker start', DayRangePicker, 0],
['RangePicker end', DayRangePicker, 1],
] as const)('%s with Activity', (_name, Picker, inputIndex) => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});

it.each(['outside', 'null'])(
'handles a delayed blur to %s after hiding and can reopen after restoring',
async (target) => {
const onBlur = jest.fn();
const onOpenChange = jest.fn();
const renderPicker = (mode: 'visible' | 'hidden') => (
<React.Activity mode={mode}>
<Picker onBlur={onBlur} onOpenChange={onOpenChange} />
</React.Activity>
);
const { container, rerender } = render(renderPicker('visible'));
const input = container.querySelectorAll('input')[inputIndex];

openPicker(container, inputIndex);
expect(onOpenChange).toHaveBeenLastCalledWith(true);

rerender(renderPicker('hidden'));
// A native blur can arrive after Activity has detached the selector ref.
fireEvent.blur(input, { relatedTarget: target === 'outside' ? document.body : null });
await waitFakeTimer();
expect(onBlur).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenLastCalledWith(false);

rerender(renderPicker('visible'));
expect(container.querySelectorAll('input')[inputIndex]).toBe(input);
openPicker(container, inputIndex);
expect(onOpenChange).toHaveBeenLastCalledWith(true);
},
);
});
Loading