Layout fixed
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user