Skip to content
Merged
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
56 changes: 48 additions & 8 deletions packages/react-aria-components/src/Sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export const SheetOverlay = forwardRef(function SheetOverlay(
onEnter={element => {
// The overlay is the document-anchored wrapper; the swipe gesture scrolls the inner container.
let scroller = element.querySelector<HTMLElement>('[data-sheet-scroll]')!;
let content = element.querySelector<HTMLElement>('[data-sheet-content]');
let vp =
axis === 'y'
? window.innerHeight + parseFloat(calcToPx(viewportPadding(after, axis)))
Expand All @@ -377,18 +378,37 @@ export const SheetOverlay = forwardRef(function SheetOverlay(
}
};
if (enteredScroll === 0) {
// The rest position is at scroll 0, so the sheet mounts already entered. Jump to the exit
// first, then animate back in on the next frame — doing both in the same task can leave
// the browser stuck at the jumped-to position instead of running the smooth scroll.
// The rest position is at scroll 0, so the sheet mounts already entered. Jump to the exit first.
scrollAlongAxis(scroller, axis, (maxScroll / 100) * vp);
requestAnimationFrame(() => scrollToRest(true));
} else {
scrollToRest(true);
}
// Skip the part of the enter scroll where the sheet is still entirely off screen.
let snapType = scroller.style.scrollSnapType;
let skipped =
content != null && !reduceMotion() && skipOffscreenTravel(scroller, content, axis, vp);

return new Promise<void>(resolve => {
// eslint-disable-next-line rsp-rules/no-non-composing-event-listener
scroller.addEventListener('scrollend', () => resolve(), {once: true});
let startEnterScroll = () => {
scrollToRest(true);
// Listen only once the smooth scroll has started, so the jump's own scrollend is ignored.
// eslint-disable-next-line rsp-rules/no-non-composing-event-listener
scroller.addEventListener(
'scrollend',
() => {
if (skipped) {
scroller.style.scrollSnapType = snapType;
}
resolve();
},
{once: true}
);
};

// Jumping and smooth scrolling in the same task can leave the browser stuck at the jumped-to position.
if (enteredScroll === 0 || skipped) {
requestAnimationFrame(startEnterScroll);
} else {
startEnterScroll();
}
});
}}
onExit={element => {
Expand Down Expand Up @@ -1113,6 +1133,26 @@ function scrollAlongAxis(element: HTMLElement, axis: Axis, value: number, smooth
element.scrollTo(axis === 'y' ? {top: value, behavior} : {left: value, behavior});
}

// Moves the scroller so the sheet's leading edge sits at the viewport edge, with snapping
// disabled so the browser doesn't pull it back to a snap point. Returns whether it moved.
function skipOffscreenTravel(scroller: HTMLElement, content: HTMLElement, axis: Axis, vp: number) {
let rect = content.getBoundingClientRect();
let start = axis === 'y' ? rect.top : rect.left;
let end = axis === 'y' ? rect.bottom : rect.right;
let delta = start > vp ? start - vp : end < 0 ? end : 0;
if (delta === 0) {
return false;
}

scroller.style.scrollSnapType = 'none';
if (axis === 'y') {
scroller.scrollTop += delta;
} else {
scroller.scrollLeft += delta;
}
return true;
}

function scrollDetentIntoView(
scroller: HTMLElement,
marker: HTMLElement,
Expand Down
92 changes: 92 additions & 0 deletions packages/react-aria-components/test/Sheet.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,98 @@ describe('Sheet', () => {
}
});

describe('enter scroll', () => {
let rect;
function mockContentRect(contentRect) {
rect = jest
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function () {
return this.hasAttribute('data-sheet-content')
? {top: 0, bottom: 0, left: 0, right: 0, ...contentRect}
: {top: 0, bottom: 0, left: 0, right: 0};
});
}

beforeEach(() => {
// jsdom skips enter animations unless getAnimations is implemented.
Element.prototype.getAnimations = () => [];
});

afterEach(() => {
rect?.mockRestore();
rect = undefined;
delete Element.prototype.getAnimations;
});

it('skips the part where the sheet is still off screen past the end edge', async () => {
mockContentRect({top: window.innerHeight + 200, bottom: window.innerHeight + 500});
let tree = render(<TestSheet />);
await open(tree);
let scroller = tree.getByTestId('overlay').querySelector('[data-sheet-scroll]');

expect(scroller.scrollTop).toBe(200);
expect(scroller.style.scrollSnapType).toBe('none');

act(() => jest.runAllTimers());
expect(scroller.scrollTop).toBe(window.innerHeight);

await act(async () => {
scroller.dispatchEvent(new Event('scrollend'));
});
expect(scroller.style.scrollSnapType).toBe('y mandatory');
});

it('skips the part where the sheet is still off screen past the start edge', async () => {
mockContentRect({left: -500, right: -200});
let tree = render(<TestSheet overlayProps={{position: 'left'}} />);
await open(tree);
let scroller = tree.getByTestId('overlay').querySelector('[data-sheet-scroll]');

expect(scroller.scrollLeft).toBe(window.innerWidth - 200);
expect(scroller.style.scrollSnapType).toBe('none');

act(() => jest.runAllTimers());
expect(scroller.scrollLeft).toBe(0);

await act(async () => {
scroller.dispatchEvent(new Event('scrollend'));
});
expect(scroller.style.scrollSnapType).toBe('x mandatory');
});

it('does not skip when the sheet is already partly on screen', async () => {
mockContentRect({top: window.innerHeight - 100, bottom: window.innerHeight + 200});
let tree = render(<TestSheet />);
await open(tree);
let scroller = tree.getByTestId('overlay').querySelector('[data-sheet-scroll]');

expect(scroller.scrollTop).toBe(window.innerHeight);
expect(scroller.style.scrollSnapType).toBe('y mandatory');
});

it('ignores the scrollend from the jump and waits for the enter scroll to finish', async () => {
mockContentRect({top: window.innerHeight + 200, bottom: window.innerHeight + 500});
let tree = render(<TestSheet />);
await open(tree);
let overlay = tree.getByTestId('overlay');
let scroller = overlay.querySelector('[data-sheet-scroll]');

// The instant jump fires its own scrollend before the smooth scroll starts on the next frame.
await act(async () => {
scroller.dispatchEvent(new Event('scrollend'));
});
expect(scroller.style.scrollSnapType).toBe('none');
expect(overlay).toHaveAttribute('data-entering');

act(() => jest.runAllTimers());
await act(async () => {
scroller.dispatchEvent(new Event('scrollend'));
});
expect(scroller.style.scrollSnapType).toBe('y mandatory');
expect(overlay).not.toHaveAttribute('data-entering');
});
});

it('closes via the close function passed to SheetContent children', async () => {
let tree = render(<TestSheet />);
let dialog = await open(tree);
Expand Down
Loading