v3: modals stop shifting the page — scroll lock moves to <html> and holds the scrollbar's gutter only while locked (and only if a bar was showing), shared by Modal and ConfirmModal with stacked-lock counting; scrollbars take a theme-tinted thumb, inner scrollers go thin and contain their overscroll
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 40s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-19 21:12:19 +00:00
parent a9edd8fbb8
commit 5205097bb6
5 changed files with 50 additions and 8 deletions

View File

@@ -33,6 +33,7 @@
border-radius: var(--radius-lg);
padding: 0.75rem 1rem;
overflow-x: auto;
scrollbar-width: thin;
margin: 0.6em 0;
}
.md pre code {
@@ -1412,6 +1413,8 @@ a.global-player-title:hover {
padding: 0 0.5rem 0.5rem;
max-height: 40vh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.global-player-track > button {
display: flex;
@@ -2893,6 +2896,8 @@ body.has-player .chat-fab {
trigger's viewport rect, flipping above it when there isn't room below */
min-width: 180px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
background: var(--color-surface);
border: var(--border-width) solid var(--color-border-subtle);
border-radius: var(--radius-xl);
@@ -3936,6 +3941,8 @@ body.has-player .chat-fab {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
display: flex;
flex-direction: column;
gap: 0.75rem;

View File

@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useScrollLock } from "../hooks/useScrollLock";
interface ConfirmModalProps {
message: string;
@@ -14,6 +15,7 @@ export function ConfirmModal(
{ message, confirmLabel, onConfirm, onCancel }: ConfirmModalProps,
) {
const label = confirmLabel ?? t`Delete`;
useScrollLock();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {

View File

@@ -1,6 +1,7 @@
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { t } from "@lingui/core/macro";
import { useScrollLock } from "../hooks/useScrollLock";
interface ModalProps {
title: string;
@@ -25,12 +26,7 @@ export function Modal(
onClose();
}, [onBeforeClose, onClose]);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
useScrollLock();
useEffect(() => {
const handler = (e: KeyboardEvent) => {

View File

@@ -0,0 +1,31 @@
import { useEffect } from "react";
let locks = 0;
/**
* Freezes the page's scroll while the calling component is mounted — for
* modals, whose backdrop shouldn't let the feed slide underneath.
*
* Hiding the viewport's overflow drops its scrollbar, which would widen the
* page and shift everything behind the backdrop. If a bar is showing, its
* gutter is held for the duration of the lock instead, so the layout stays
* put; no gutter is reserved the rest of the time. Locks are counted so a
* confirm stacked on a modal doesn't release the page when it closes first.
*/
export function useScrollLock() {
useEffect(() => {
const root = document.documentElement;
if (locks++ === 0) {
if (window.innerWidth > root.clientWidth) {
root.style.scrollbarGutter = "stable";
}
root.style.overflowY = "hidden";
}
return () => {
if (--locks === 0) {
root.style.overflowY = "";
root.style.scrollbarGutter = "";
}
};
}, []);
}

View File

@@ -4,11 +4,17 @@
box-sizing: border-box;
}
html {
overflow-x: clip;
/* Inherited by every scroller: a theme-tinted thumb on a clear track
instead of the stock grey bar. */
scrollbar-color: color-mix(in srgb, var(--color-text-muted) 45%, transparent)
transparent;
}
body {
margin: 0;
min-height: 100vh;
overflow-x: clip;
scrollbar-gutter: stable;
background-color: var(--color-bg);
color: var(--color-text);
-webkit-font-smoothing: antialiased;