Layout fixed

This commit is contained in:
2026-03-13 18:53:59 -05:00
parent b52564d44d
commit 6a1af4b723
+55 -46
View File
@@ -1,64 +1,83 @@
/** /**
* ScopeScaleWrapper * ScopeScaleWrapper
* *
* Wraps a scope's grid area (header + rows) in a container that: * Wraps a scope's items/header block so that on narrow screens the
* 1. Scales the inner content DOWN to fit the available width on * whole thing scales down as a unit rather than overflowing.
* 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.
* *
* Key behaviour: * Critical CSS behaviour used here:
* - When content fits (scale = 1): inner is width:100%, outer has *
* overflow-x:hidden — zero unused scroll space. * overflow: clip
* - When too wide to fit: inner is width:max-content so it renders * Clips the inner element at the outer's edges — identical to
* at its natural size, scale transform shrinks it visually, and * `overflow: hidden` visually, BUT does NOT create a scroll
* outer height is corrected for the scaled layout height. * 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 type { Component, JSX } from 'solid-js';
import { onMount, onCleanup, createSignal } from 'solid-js'; import { onMount, onCleanup } from 'solid-js';
interface ScopeScaleWrapperProps { interface ScopeScaleWrapperProps {
children: JSX.Element; children: JSX.Element;
class?: string; 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; minScale?: number;
} }
const ScopeScaleWrapper: Component<ScopeScaleWrapperProps> = (props) => { const ScopeScaleWrapper: Component<ScopeScaleWrapperProps> = (props) => {
let outerEl: HTMLDivElement | undefined; let outerEl: HTMLDivElement | undefined;
let innerEl: HTMLDivElement | undefined; let innerEl: HTMLDivElement | undefined;
const [scale, setScale] = createSignal(1);
const [isZooming, setIsZooming] = createSignal(false);
const recalculate = () => { const recalculate = () => {
if (!outerEl || !innerEl) return; if (!outerEl || !innerEl) return;
const minScale = props.minScale ?? 0.65;
// Temporarily switch inner to max-content so we can measure // ── Step 1: measure the inner's TRUE natural width ───────────────
// its natural width without being constrained by the parent. // 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'; 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; const outerWidth = outerEl.clientWidth;
if (naturalWidth <= outerWidth) { if (naturalWidth <= outerWidth) {
// Content fits — no zoom, no excess scroll // ── Fits naturally — fill available width, no clip/scale ──────
setScale(1);
setIsZooming(false);
innerEl.style.width = '100%'; innerEl.style.width = '100%';
innerEl.style.height = ''; innerEl.style.transform = '';
innerEl.style.removeProperty('--scale-factor'); outerEl.style.height = '';
outerEl.style.overflow = '';
} else { } else {
const min = props.minScale ?? 0.65; // ── Too wide — scale down ─────────────────────────────────────
const newScale = Math.max(min, outerWidth / naturalWidth); const rawScale = outerWidth / naturalWidth;
setScale(newScale); const newScale = Math.max(minScale, rawScale);
setIsZooming(true);
// Keep width:max-content so content renders at natural size // Fix inner to its natural pixel width so transform-origin math
innerEl.style.width = 'max-content'; // is reliable, then scale from the top-left corner
// Correct height so the card shrinks with the scaled content innerEl.style.width = `${naturalWidth}px`;
const naturalHeight = innerEl.scrollHeight; innerEl.style.transformOrigin = 'top left';
innerEl.style.height = (naturalHeight * newScale) + 'px'; innerEl.style.transform = `scale(${newScale})`;
innerEl.style.setProperty('--scale-factor', String(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<ScopeScaleWrapperProps> = (props) => {
onMount(() => { onMount(() => {
ro = new ResizeObserver(recalculate); ro = new ResizeObserver(recalculate);
if (outerEl) ro.observe(outerEl); if (outerEl) ro.observe(outerEl);
if (innerEl) ro.observe(innerEl);
recalculate(); recalculate();
}); });
onCleanup(() => ro?.disconnect()); onCleanup(() => ro?.disconnect());
return ( return (
<div <div ref={outerEl} class={`relative ${props.class ?? ''}`}>
ref={outerEl} <div ref={innerEl}>
class={`relative ${isZooming() ? 'overflow-x-auto' : 'overflow-x-hidden'} overflow-y-visible ${props.class ?? ''}`}
>
<div
ref={innerEl}
style={{
'transform-origin': 'top left',
'transform': `scale(${scale()})`,
'transition': 'transform 0.2s ease',
'will-change': 'transform',
}}
>
{props.children} {props.children}
</div> </div>
</div> </div>