105 lines
3.9 KiB
TypeScript
105 lines
3.9 KiB
TypeScript
/**
|
||
* 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;
|