diff --git a/.agents/skills/webjs/references/styling.md b/.agents/skills/webjs/references/styling.md index 86c77a1d4..d57c926a3 100644 --- a/.agents/skills/webjs/references/styling.md +++ b/.agents/skills/webjs/references/styling.md @@ -206,3 +206,32 @@ new ResizeObserver(apply).observe(hdr); ``` For a dashboard, an alternative is an app-shell scroll container (a non-scrolling `100dvh` flex column with `
` as the internal scroller), which needs no offset but changes the scroll model. + +### A fixed header and a modal that locks scroll + +Anything that locks page scroll (a modal, a drawer, an off-canvas menu) hides the page scrollbar, and a classic scrollbar takes real layout width, so hiding it widens the viewport. The usual compensation is padding the body, which holds in-flow content still. **It does nothing for a fixed header**, because a fixed box lays out against the initial containing block, never against the body's padding box, so the header widens with the viewport and its centred content slides right by half the scrollbar width. + +`@webjsdev/ui`'s `` / `` handle this for you (#1144). Their scroll lock reserves the scrollbar gutter for its duration, so the viewport width never changes and nothing moves. It leaves the gutter alone if your page already declared its own `scrollbar-gutter`, on the assumption that a page which made that choice meant it. + +Engines differ, and the lock does not need to know which one it is on. Where the gutter is honoured (measured on Chromium) nothing moves and the lock does nothing else. Where it is ignored (measured on WebKit) the viewport does widen, so the lock measures how much, pads `` by it to hold in-flow content still (added to any padding you already had, and restored on close), and publishes the amount as `--wj-scrollbar-compensation` so a fixed element can opt in with one line: + +```css +header { position: fixed; inset-inline: 0; top: 0; border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; } +``` + +A transparent border rather than `padding-right`, for two reasons. It composes with whatever padding the element already has, where a padding form has to restate that base value and restate it again per responsive variant. And a background still paints across a border, so a header that carries its own background stays full bleed instead of ending short of the edge. Declare it after your Tailwind link if you use the `border-*` utilities, since those set `border-right-color` too. + +The property is only set while a lock is active AND the viewport actually widened, so the `0px` fallback covers every other moment and the two mechanisms never double-compensate. If you find inline `padding-right` on your `` while a modal is open, that is this, and it comes off on close. + +**Put it on the element that is both viewport-width and painting.** Both halves are load-bearing, and getting either wrong fails quietly. + +- **Viewport-width**, or a left-aligned child still moves. Insetting a `max-width` container holds its CENTRED children still but not its leading ones, because the container's own box is not what widened. This is easy to miss: measure a left-aligned child, not just a centred one. +- **Painting**, or the background stops short of the widened edge. Insetting a wrapper that paints nothing insets the child that does, which leaves an unpainted strip. + +In this repo `examples/blog` has one element that is both (the fixed header paints its own chrome, so the border goes straight on it), and `website` has a non-painting fixed wrapper around a painting header around a centring bar, where the header is the one that qualifies. + +Rolling your own scroll lock? Three things the kit learned the hard way, and the first is that almost every implementation of this (including Next's own dev overlay and Radix) gets the fixed case wrong by design. + +1. Reserve the gutter rather than relying on padding alone, or a fixed element will jump however carefully you compensate the body. +2. When you do pad, pad ``, not ``. A `max-width` body does not widen when the viewport does, so padding it misses the shift entirely, while padding the root holds in-flow content whatever the body's width is. +3. Measure the root's own border box to decide the amount. `documentElement.clientWidth` can grow while nothing actually moved (Chromium reports exactly that under a reserved gutter), and a `position: fixed` probe reads its pre-lock box on WebKit until the next rendering update, so both mislead. diff --git a/examples/blog/app/layout.ts b/examples/blog/app/layout.ts index 8f62f04e5..81135cd6d 100644 --- a/examples/blog/app/layout.ts +++ b/examples/blog/app/layout.ts @@ -265,6 +265,18 @@ export default function RootLayout({ children }: LayoutProps) { radial-gradient(58% 44% at 50% -4%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 100%), transparent), transparent 72%), radial-gradient(40% 36% at 88% 8%, color-mix(in oklch, var(--glow-a) calc(var(--glow-strength) * 60%), transparent), transparent 70%); } + /* #1144: while a modal holds the page scroll lock, an engine that ignores + scrollbar-gutter widens the viewport, and this header is position: fixed + so it lays out against the viewport and cannot be reached by the padding + that holds in-flow content still. A TRANSPARENT RIGHT BORDER is the + opt-in: it insets the content by the published amount while the + background still paints across it (backgrounds fill the border box), so + the chrome stays full bleed. A border also composes with whatever + padding the element already has, which padding-right cannot: that form + has to restate the base value, once per responsive variant. Declared + after the Tailwind link above, so it wins the border-right-color the + border-* utilities also set. Resolves to 0px at every other moment. */ + .site-header { border-right: var(--wj-scrollbar-compensation, 0px) solid transparent; } ::selection { background: var(--primary-tint); color: var(--foreground); } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; } diff --git a/examples/blog/components/ui/dialog.ts b/examples/blog/components/ui/dialog.ts index d21614be6..ed97d3641 100644 --- a/examples/blog/components/ui/dialog.ts +++ b/examples/blog/components/ui/dialog.ts @@ -62,6 +62,15 @@ * within the dialog (native focus trap). * * Design tokens used: --background, --border, --muted-foreground. + * + * Scroll lock (#1144): opening the dialog locks body scroll, which hides the + * page scrollbar and would widen the viewport. The lock reserves the scrollbar + * gutter so the width never changes and a `position: fixed` header stays put. + * Where the engine ignores `scrollbar-gutter` (WebKit today) it publishes the + * leftover width on as `--wj-scrollbar-compensation`, so a fixed header + * opts in with `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent` + * (a transparent border composes with existing padding, and a background paints + * across it, so a header keeps its own chrome full bleed). */ import { WebComponent, html, unsafeHTML, prop } from '@webjsdev/core'; import { ref, createRef } from '@webjsdev/core/directives'; @@ -122,30 +131,130 @@ function installStyles(): void { } // -------------------------------------------------------------------------- -// Body scroll lock. Refcounted so nested dialogs unlock in order. Native -// does not lock body scroll, only inert-ifies the background. +// Page scroll lock, refcounted so nested dialogs release safely in ANY order. +// Native does not lock scrolling, it only inert-ifies the background. +// +// Hiding the page scrollbar widens the viewport by the scrollbar's width, so +// anything laid out against the viewport moves. Padding the page holds in-flow +// content still, but a `position: fixed` header lays out against the initial +// containing block, never against any padding box, so padding alone leaves it +// sliding right by half the scrollbar width. Two mechanisms fix it: +// +// 1. Reserve the scrollbar gutter for the duration of the lock, so the +// viewport width never changes and NOTHING moves, in flow or fixed. This +// needs no cooperation from the page. Measured honoured on Chromium. +// 2. Where the engine ignores it (measured on WebKit), fall back to +// padding and publish the leftover width as +// `--wj-scrollbar-compensation` on it, so a fixed element can opt in with +// `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`. +// +// Everything below is MEASURED rather than assumed, because engines disagree +// about both scrollbar geometry and gutter support. When mechanism 1 works the +// measured residual is zero, so no padding is applied and the custom property is +// never set: the two mechanisms cannot double-compensate. // -------------------------------------------------------------------------- -let scrollLockCount = 0; -let savedOverflow = ''; -let savedPaddingRight = ''; +// The lock's state is DOCUMENT level, so it is keyed on globalThis rather than +// module scope. dialog.ts and alert-dialog.ts ship as separate copies (so +// `webjs ui add alert-dialog` stays self contained), and two independent +// counters mutating the same can only be released safely in LIFO order. +// They are not: `disconnectedCallback` fires in tree order and the #766 +// before-cache close runs in registration order, so a confirm inside a dialog +// releases OUTER first, and the inner unlock would then re-apply the values it +// captured with nothing left to clear them, leaving padded for good. One +// shared counter makes release order irrelevant. +interface ScrollLockState { + count: number; + overflow: string; + rootPaddingRight: string; + scrollbarGutter: string; + compensation: string; +} + +const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation'; + +function scrollLockState(): ScrollLockState { + // The key follows the framework's global-key prefix (see `__webjsSonnerBus` in + // sonner.ts, the same pattern for the same reason), and the cast is typed + // rather than a string-keyed bag, so a change to the shape is a type error in + // both copies instead of a silent undefined at runtime. + const store = globalThis as unknown as { __webjsScrollLock?: ScrollLockState }; + let state = store.__webjsScrollLock; + if (!state) { + state = { count: 0, overflow: '', rootPaddingRight: '', scrollbarGutter: '', compensation: '' }; + store.__webjsScrollLock = state; + } + return state; +} function lockScroll(): void { - if (scrollLockCount === 0) { - const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; - savedOverflow = document.body.style.overflow; - savedPaddingRight = document.body.style.paddingRight; - document.body.style.overflow = 'hidden'; - if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`; + const state = scrollLockState(); + if (state.count === 0) { + const root = document.documentElement; + const body = document.body; + const rootStyle = getComputedStyle(root); + + state.overflow = body.style.overflow; + state.rootPaddingRight = root.style.paddingRight; + state.scrollbarGutter = root.style.scrollbarGutter; + state.compensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION); + + // is an in-flow block filling the initial containing block, so its + // border box IS the viewport width, and it is re-laid-out synchronously. + // A `position: fixed` probe is NOT a substitute: WebKit keeps reporting its + // old box until the next rendering update, so it reads a zero delta and the + // compensation below never fires. + const widthBefore = root.getBoundingClientRect().width; + const padBefore = parseFloat(rootStyle.paddingRight) || 0; + // An engine with no `scrollbar-gutter` at all reads back undefined, which + // must be treated as "the page has not chosen" so the gutter is still + // attempted (setting an unsupported property is a harmless no-op, and the + // measured residual below is what actually decides the fallback). + const chosenGutter = rootStyle.scrollbarGutter || 'auto'; + + // Reserve the gutter, but only when the page has not already made its own + // choice. An app running `stable both-edges` keeps both gutters through the + // lock, and overwriting that with the single-edge value would drop one and + // introduce the very shift this exists to prevent. + if (chosenGutter === 'auto' && window.innerWidth > root.clientWidth) { + root.style.scrollbarGutter = 'stable'; + } + body.style.overflow = 'hidden'; + + const grew = root.getBoundingClientRect().width - widthBefore; + if (grew > 0) { + // Padding the ROOT holds in-flow content still whatever the body's own + // width is (a `max-width` body does not widen with the viewport, so + // padding the body would miss it), and it leaves the page's own body + // padding untouched. + root.style.paddingRight = `${padBefore + grew}px`; + // A fixed box lays out against the viewport, so no padding here can reach + // it. Publish what it moved by and let it opt in. + root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`); + } } - scrollLockCount++; + state.count++; } function unlockScroll(): void { - scrollLockCount = Math.max(0, scrollLockCount - 1); - if (scrollLockCount === 0) { - document.body.style.overflow = savedOverflow; - document.body.style.paddingRight = savedPaddingRight; + const state = scrollLockState(); + // An unlock with no matching lock is a no-op, NOT the last release: clamping to + // zero and restoring would replay a stale snapshot onto whatever the page owns + // now. Belt-and-braces as things stand, because _teardown() only unlocks what + // its own element locked, so no path through the components can reach here with + // a zero count. It is kept because the cost is one comparison and the failure + // it prevents is silent. + if (state.count === 0) return; + state.count--; + if (state.count === 0) { + const root = document.documentElement; + document.body.style.overflow = state.overflow; + root.style.paddingRight = state.rootPaddingRight; + root.style.scrollbarGutter = state.scrollbarGutter; + // Restored rather than removed, so a value the PAGE set before any dialog + // opened survives the lock. + if (state.compensation) root.style.setProperty(SCROLLBAR_COMPENSATION, state.compensation); + else root.style.removeProperty(SCROLLBAR_COMPENSATION); } } @@ -212,15 +321,36 @@ export class UiDialog extends WebComponent({ return this.querySelector('ui-dialog-content') as UiDialogContent | null; } + _scrollLocked?: boolean; + _setup(): void { + // A detached element must not lock: `updated()` defers this to a microtask, + // and a removal in between already ran _teardown() as a no-op, so locking + // here would never be released (nothing disconnects twice). + if (!this.isConnected) return; const content = this._content; if (!content) return; - lockScroll(); + // Idempotent, because the count is an integer and the flag is a boolean. A + // coalesced hide() then show() in one task produces ONE update whose + // changedProperties still reports open, so _setup() can run twice with no + // _teardown() between; double-counting there would leave the page locked for + // the rest of the session. + if (!this._scrollLocked) { + lockScroll(); + this._scrollLocked = true; + } content.showModal(); } _teardown(): void { - unlockScroll(); + // Release only what THIS element locked. _setup() returns before locking + // when there is no content child, so an unconditional unlock here would + // consume ANOTHER open dialog's count and restore the page out from under + // it, dropping its compensation while it is still open. + if (this._scrollLocked) { + this._scrollLocked = false; + unlockScroll(); + } this._content?.close(); } } diff --git a/examples/blog/test/layout/fixed-header-scroll-lock.test.ts b/examples/blog/test/layout/fixed-header-scroll-lock.test.ts new file mode 100644 index 000000000..f70f3a512 --- /dev/null +++ b/examples/blog/test/layout/fixed-header-scroll-lock.test.ts @@ -0,0 +1,86 @@ +/** + * The blog's fixed header opts into the dialog scroll lock's compensation (#1144). + * + * Opening a `` locks page scroll, which hides the scrollbar and widens + * the viewport on any engine that ignores `scrollbar-gutter`. The header here is + * `position: fixed`, so it lays out against the viewport and cannot be reached by + * the padding the lock puts on `` to hold in-flow content still. It opts in + * instead, via `--wj-scrollbar-compensation`. + * + * This app owns its copy of the kit's dialog (`components/ui/dialog.ts`, the + * shadcn model), so the header and the lock have to stay in step by hand. That is + * exactly how the app kept the #1144 shift for two review rounds after the kit + * was fixed. The marketing site's copy is a gitignored mirror, so it picked the + * fix up automatically; this one is real tracked source and did not. + * + * The opt-in is a single declaration in the root layout's inline `