---
/**
* Lens — a round magnifier that follows the pointer over a picture: the slot is drawn twice, once as it is and once
* scaled up inside the lens, so it magnifies an <img>, an <Image>, an SVG drawing or any markup alike. The lens is placed
* by two custom properties a few lines of script write on pointer moves; a keyboard focuses the picture, which shows the
* lens in the middle, and moves it with the arrow keys; Escape puts it away. Without JavaScript the picture is shown
* whole and nothing magnifies.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** How many times larger the lens shows the picture. */
zoom?: number;
/** The lens's width, any CSS length. */
size?: string;
/** What the picture shows, read before it (the copy in the lens is hidden from a screen reader). */
label?: string;
}
const { zoom = 2.5, size = '10rem', label, class: className, style, ...rest } = Astro.props;
const html = await Astro.slots.render('default');
const vars = [`--ma-lens-zoom:${zoom}`, `--ma-lens-size:${size}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div class:list={['ma-lens', className]} data-ma-lens tabindex="0" role="group" aria-label={label} aria-roledescription="magnifier" style={vars} {...rest}>
<div class="ma-lens__base" set:html={html} />
<div class="ma-lens__glass" aria-hidden="true"><div class="ma-lens__copy" inert set:html={html} /></div>
</div>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-lens]', (el, { signal }) => {
const at = (x: number, y: number) => {
el.style.setProperty('--ma-lens-x', String(Math.min(1, Math.max(0, x))));
el.style.setProperty('--ma-lens-y', String(Math.min(1, Math.max(0, y))));
};
let x = 0.5;
let y = 0.5;
const on = (t: string, f: (e: Event) => void) => el.addEventListener(t, f, { signal });
on('pointermove', (e) => {
const p = e as PointerEvent;
const r = el.getBoundingClientRect();
x = (p.clientX - r.left) / r.width;
y = (p.clientY - r.top) / r.height;
at(x, y);
el.dataset.on = '';
});
on('pointerleave', () => document.activeElement !== el && delete el.dataset.on);
on('focus', () => { at(x, y); el.dataset.on = ''; });
on('blur', () => delete el.dataset.on);
on('keydown', (e) => {
const k = (e as KeyboardEvent).key;
if (k === 'Escape') return delete el.dataset.on;
const d = { ArrowLeft: [-0.05, 0], ArrowRight: [0.05, 0], ArrowUp: [0, -0.05], ArrowDown: [0, 0.05] }[k];
if (!d) return;
e.preventDefault();
x = Math.min(1, Math.max(0, x + d[0]!));
y = Math.min(1, Math.max(0, y + d[1]!));
at(x, y);
el.dataset.on = '';
});
});
</script>
<style is:global>
@layer components {
:where(.ma-lens) {
--ma-lens-x: 0.5;
--ma-lens-y: 0.5;
position: relative;
display: block;
container-type: inline-size;
overflow: hidden;
border-radius: var(--ma-radius-lg, 1rem);
cursor: zoom-in;
touch-action: pan-y;
}
:where(.ma-lens:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
:where(.ma-lens__base > *), :where(.ma-lens__copy > *) { display: block; inline-size: 100%; block-size: auto; }
/* the lens sits on the point; inside it the copy is as large as the picture, scaled up about its own corner and
moved so the point lands in the lens's middle (a translate's percentages are of the copy's own box) */
:where(.ma-lens__glass) {
position: absolute;
/* physical sides: the point is where the pointer is, whatever the writing direction */
left: calc(var(--ma-lens-x) * 100% - var(--ma-lens-size) / 2);
top: calc(var(--ma-lens-y) * 100% - var(--ma-lens-size) / 2);
inline-size: var(--ma-lens-size);
aspect-ratio: 1;
overflow: hidden;
border-radius: 50%;
box-shadow: 0 0 0 3px var(--background, #fff), 0 12px 32px -10px oklch(0 0 0 / 0.5);
pointer-events: none;
opacity: 0;
scale: 0.85;
transition: opacity var(--ma-duration-fast, 150ms) var(--ma-ease-out, ease-out), scale var(--ma-duration-fast, 150ms) var(--ma-ease-out, ease-out);
}
:where(.ma-lens[data-on] .ma-lens__glass) { opacity: 1; scale: 1; }
:where(.ma-lens__copy) {
position: absolute;
top: 0;
left: 0;
inline-size: 100cqi;
transform-origin: 0 0;
translate: calc(var(--ma-lens-size) / 2 - var(--ma-lens-x) * var(--ma-lens-zoom) * 100%) calc(var(--ma-lens-size) / 2 - var(--ma-lens-y) * var(--ma-lens-zoom) * 100%);
scale: var(--ma-lens-zoom);
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-lens__glass) { transition: none; scale: 1; }
}
}
</style>