Files
EstiMaker/src/components/ui/ScopeScaleWrapper.tsx
T
2026-03-13 18:53:59 -05:00

105 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ScopeScaleWrapper
*
* Wraps a scope's items/header block so that on narrow screens the
* whole thing scales down as a unit rather than overflowing.
*
* 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 } from 'solid-js';
interface ScopeScaleWrapperProps {
children: JSX.Element;
class?: string;
/** 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 recalculate = () => {
if (!outerEl || !innerEl) return;
const minScale = props.minScale ?? 0.65;
// ── 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';
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) {
// ── Fits naturally — fill available width, no clip/scale ──────
innerEl.style.width = '100%';
innerEl.style.transform = '';
outerEl.style.height = '';
outerEl.style.overflow = '';
} else {
// ── 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';
}
};
let ro: ResizeObserver;
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 ${props.class ?? ''}`}>
<div ref={innerEl}>
{props.children}
</div>
</div>
);
};
export default ScopeScaleWrapper;