diff --git a/src/components/ui/ScopeScaleWrapper.tsx b/src/components/ui/ScopeScaleWrapper.tsx index 98a9e55..f622ba7 100644 --- a/src/components/ui/ScopeScaleWrapper.tsx +++ b/src/components/ui/ScopeScaleWrapper.tsx @@ -1,64 +1,83 @@ /** * ScopeScaleWrapper * - * Wraps a scope's grid area (header + rows) in a container that: - * 1. Scales the inner content DOWN to fit the available width on - * small screens, so there is no excess horizontal scroll. - * 2. If content is even too wide at minScale, falls back to - * horizontal scroll only at that point. + * Wraps a scope's items/header block so that on narrow screens the + * whole thing scales down as a unit rather than overflowing. * - * Key behaviour: - * - When content fits (scale = 1): inner is width:100%, outer has - * overflow-x:hidden — zero unused scroll space. - * - When too wide to fit: inner is width:max-content so it renders - * at its natural size, scale transform shrinks it visually, and - * outer height is corrected for the scaled layout height. + * Critical CSS behaviour used here: + * + * overflow: clip + * Clips the inner element at the outer's edges — identical to + * `overflow: hidden` visually, BUT does NOT create a scroll + * container. This matters because `position: sticky` elements + * inside reference the nearest scroll container ancestor. With + * `overflow: hidden` that would be this element (which doesn't + * scroll → sticky breaks). With `overflow: clip` there is no + * new scroll container → sticky still references the page. + * + * transform: scale() + transform-origin: top left + * Visually shrinks the inner block. Layout size is unaffected + * (transform never changes layout). We compensate by setting + * the outer div's explicit height = naturalHeight × scale, and + * clipping with overflow: clip. */ import type { Component, JSX } from 'solid-js'; -import { onMount, onCleanup, createSignal } from 'solid-js'; +import { onMount, onCleanup } from 'solid-js'; interface ScopeScaleWrapperProps { children: JSX.Element; class?: string; - /** minimum scale — won't zoom below this (default 0.65) */ + /** Minimum scale factor — won't zoom below this. Default: 0.65 */ minScale?: number; } const ScopeScaleWrapper: Component = (props) => { let outerEl: HTMLDivElement | undefined; let innerEl: HTMLDivElement | undefined; - const [scale, setScale] = createSignal(1); - const [isZooming, setIsZooming] = createSignal(false); const recalculate = () => { if (!outerEl || !innerEl) return; + const minScale = props.minScale ?? 0.65; - // Temporarily switch inner to max-content so we can measure - // its natural width without being constrained by the parent. + // ── Step 1: measure the inner's TRUE natural width ─────────────── + // Must set max-content BEFORE measuring — if width is unset the + // element stretches to fill the parent, making scrollWidth == outerWidth + // and we'd never think scaling is needed. innerEl.style.width = 'max-content'; - const naturalWidth = innerEl.scrollWidth; + innerEl.style.transform = 'none'; + outerEl.style.height = ''; + outerEl.style.overflow = ''; + void outerEl.offsetWidth; // force reflow + const naturalWidth = innerEl.scrollWidth; + const naturalHeight = innerEl.scrollHeight; const outerWidth = outerEl.clientWidth; if (naturalWidth <= outerWidth) { - // Content fits — no zoom, no excess scroll - setScale(1); - setIsZooming(false); + // ── Fits naturally — fill available width, no clip/scale ────── innerEl.style.width = '100%'; - innerEl.style.height = ''; - innerEl.style.removeProperty('--scale-factor'); + innerEl.style.transform = ''; + outerEl.style.height = ''; + outerEl.style.overflow = ''; } else { - const min = props.minScale ?? 0.65; - const newScale = Math.max(min, outerWidth / naturalWidth); - setScale(newScale); - setIsZooming(true); - // Keep width:max-content so content renders at natural size - innerEl.style.width = 'max-content'; - // Correct height so the card shrinks with the scaled content - const naturalHeight = innerEl.scrollHeight; - innerEl.style.height = (naturalHeight * newScale) + 'px'; - innerEl.style.setProperty('--scale-factor', String(newScale)); + // ── Too wide — scale down ───────────────────────────────────── + const rawScale = outerWidth / naturalWidth; + const newScale = Math.max(minScale, rawScale); + + // Fix inner to its natural pixel width so transform-origin math + // is reliable, then scale from the top-left corner + innerEl.style.width = `${naturalWidth}px`; + innerEl.style.transformOrigin = 'top left'; + innerEl.style.transform = `scale(${newScale})`; + + // Set outer height to visual (post-scale) height so no dead space + // appears below the scaled block + outerEl.style.height = `${naturalHeight * newScale}px`; + + // overflow: clip — clips layout overflow WITHOUT creating a scroll + // container, so position:sticky inside still works against the page + outerEl.style.overflow = 'clip'; } }; @@ -67,25 +86,15 @@ const ScopeScaleWrapper: Component = (props) => { onMount(() => { ro = new ResizeObserver(recalculate); if (outerEl) ro.observe(outerEl); + if (innerEl) ro.observe(innerEl); recalculate(); }); onCleanup(() => ro?.disconnect()); return ( -
-
+
+
{props.children}