Files
EventSnap/frontend/src/lib/actions/scroll-lock.ts
fabi e79e020566 feat(eventsnap): UX review batch-3 — feed virtualization, a11y/UX fixes, shared primitives
Frontend
- Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window
  virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and
  bottom nav working. List = dynamic measured heights keyed by upload id +
  anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured
  square rows. Drops the content-visibility band-aid and the old FeedGrid.
  measureElement(null) on row unmount prevents ResizeObserver retention; stable
  option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches.
- Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest +
  maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers.
- Feed reactivity: like/comment SSE patch the single card in place; upload-processed
  debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock.
- CLS: list cards reserve the skeleton aspect box.
- Destructive actions (promote/demote/unban, gallery release) routed through
  ConfirmSheet (host + admin).
- Export OOM: streamed download via single-use ticket instead of res.blob().
- Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y.
- Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh
  passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode
  gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry,
  ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons.

Backend
- social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients
  patch one card in place instead of refetching page 1.
- admin.rs / main.rs: supporting changes for the above.

Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed
scroll-feel validation still owed (documented in FOLLOWUPS.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 19:16:48 +02:00

51 lines
1.6 KiB
TypeScript

// Body scroll-lock for open dialogs/sheets. While any locker is active, the
// document body can't scroll behind the overlay. Ref-counted so that nested or
// stacked dialogs (e.g. a ConfirmSheet opened from the LightboxModal) don't let
// the first one to close unlock the page out from under the others. Compensates
// for the vanished scrollbar width so the layout doesn't jump on lock.
import type { ActionReturn } from 'svelte/action';
let lockCount = 0;
let savedOverflow = '';
let savedPaddingRight = '';
function lock() {
if (typeof document === 'undefined') return;
if (lockCount === 0) {
const body = document.body;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
savedOverflow = body.style.overflow;
savedPaddingRight = body.style.paddingRight;
body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
const current = parseFloat(getComputedStyle(body).paddingRight) || 0;
body.style.paddingRight = `${current + scrollbarWidth}px`;
}
}
lockCount++;
}
function unlock() {
if (typeof document === 'undefined') return;
lockCount = Math.max(0, lockCount - 1);
if (lockCount === 0) {
document.body.style.overflow = savedOverflow;
document.body.style.paddingRight = savedPaddingRight;
}
}
/**
* Locks body scroll for the lifetime of the node. Mount the node only while the
* dialog is open (e.g. inside `{#if open}` or alongside a `class:` toggle) so the
* action's create/destroy line up with open/close.
*/
export function scrollLock(node: HTMLElement): ActionReturn {
lock();
return {
destroy() {
unlock();
}
};
}