···8888 return null;
8989}
90909191-let __activeScrollLock = null;
9191+// Locks are tracked as a stack of holders: the first holder locks the
9292+// page and only the last holder to unlock restores it.
9393+let __scrollLockHolders = [];
9494+let __lockedContainer = null;
92959396export class ScrollLock {
9497 constructor(target) {
···100103 }
101104102105 lock() {
103103- if (__activeScrollLock) {
104104- // If scroll is already locked by another element, don't lock it again
105105- return;
106106- }
107106 if (this.locked) {
108107 return;
109108 }
···113112 );
114113 return;
115114 }
116116- lockScroll(this.container);
115115+ if (__scrollLockHolders.length === 0) {
116116+ lockScroll(this.container);
117117+ __lockedContainer = this.container;
118118+ }
119119+ __scrollLockHolders.push(this);
117120 // If target is passed, lock the nearest scrollable ancestor of that target in addition to the outer page
118121 const ancestor = this.target ? findScrollableAncestor(this.target) : null;
119122 if (ancestor) {
···122125 ancestor.style.overflow = "hidden";
123126 }
124127 this.locked = true;
125125- __activeScrollLock = this;
126128 }
127129128130 unlock() {
···134136 this._lockedAncestor = null;
135137 this._previousAncestorOverflow = "";
136138 }
137137- unlockScroll(this.container);
139139+ __scrollLockHolders = __scrollLockHolders.filter(
140140+ (holder) => holder !== this,
141141+ );
142142+ if (__scrollLockHolders.length === 0) {
143143+ unlockScroll(__lockedContainer);
144144+ __lockedContainer = null;
145145+ }
138146 this.locked = false;
139139- __activeScrollLock = null;
140147 }
141148}
+7-2
src/js/utils.js
···466466467467// iOS Safari: dismissing the keyboard via the "Done" button leaves the
468468// dialog's inner scroll area offset, which makes buttons unclickable
469469-// until the dialog is swiped or re-tapped. Reset scroll on blur.
469469+// until the dialog is swiped or re-tapped. Reset scroll on text input blur.
470470export function resetScrollOnBlur(dialog, scrollArea) {
471471 dialog.addEventListener(
472472 "blur",
473473- () => {
473473+ (event) => {
474474+ if (
475475+ !event.target.matches?.('input, textarea, [contenteditable="true"]')
476476+ ) {
477477+ return;
478478+ }
474479 if (scrollArea) scrollArea.scrollTop = 0;
475480 window.scrollTo(0, 0);
476481 },
+42
tests/e2e/specs/flows/reportPost.test.js
···7474 cid: post.cid,
7575 });
7676 });
7777+7878+ test("should keep page scroll locked while the report dialog is open", async ({
7979+ page,
8080+ }) => {
8181+ const mockServer = new MockServer();
8282+ const post = createPost({
8383+ uri: "at://did:plc:author1/app.bsky.feed.post/post1",
8484+ text: "Post to report",
8585+ authorHandle: "author1.bsky.social",
8686+ authorDisplayName: "Author One",
8787+ });
8888+ mockServer.addTimelinePosts([post]);
8989+ await mockServer.setup(page);
9090+9191+ await login(page);
9292+ await page.goto("/");
9393+9494+ const homeView = page.locator("#home-view");
9595+ const feedItem = homeView.locator('[data-testid="feed-item"]');
9696+ await expect(feedItem).toHaveCount(1, { timeout: 10000 });
9797+9898+ await feedItem.locator(".text-button").click();
9999+ await page.locator('[data-testid="menu-action-post-report"]').click();
100100+101101+ const reportDialog = page.locator("report-dialog");
102102+ await expect(reportDialog.locator(".report-dialog")).toBeVisible({
103103+ timeout: 5000,
104104+ });
105105+106106+ // The report dialog opens from the menu item's click handler before the
107107+ // context menu itself closes, so the menu's scroll lock must hand off to
108108+ // the dialog instead of unlocking the page underneath it.
109109+ await expect
110110+ .poll(() => page.evaluate(() => document.body.style.position))
111111+ .toBe("fixed");
112112+113113+ await reportDialog.locator(".report-dialog-close").click();
114114+ await expect(reportDialog).toHaveCount(0, { timeout: 5000 });
115115+ await expect
116116+ .poll(() => page.evaluate(() => document.body.style.position))
117117+ .toBe("");
118118+ });
77119});