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
*
* 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<ScopeScaleWrapperProps> = (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<ScopeScaleWrapperProps> = (props) => {
onMount(() => {
ro = new ResizeObserver(recalculate);
if (outerEl) ro.observe(outerEl);
if (innerEl) ro.observe(innerEl);
recalculate();
});
onCleanup(() => ro?.disconnect());
return (
<div
ref={outerEl}
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',
}}
>
<div ref={outerEl} class={`relative ${props.class ?? ''}`}>
<div ref={innerEl}>
{props.children}
</div>
</div>