astro-image-zoom 0.1.0-beta.2 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ImageZoom.astro CHANGED
@@ -4,8 +4,6 @@
4
4
  * Medium-style zoom overlay with accessibility and performance features
5
5
  */
6
6
 
7
- // Global stylesheet: selectors are prefixed and live in @layer astro-image-zoom
8
- import "./zoom.css";
9
7
  import { wrapImages } from "./wrapImages";
10
8
 
11
9
  export interface Props {
@@ -107,6 +105,12 @@ const wrapperClasses = ["astro-image-zoom-wrapper", className]
107
105
 
108
106
  // Wrap the slot's images in accessible links
109
107
  const slotContent = wrapImages(await Astro.slots.render("default"));
108
+
109
+ // The element only groups its content and must not add a box. Its own declarative shadow root says
110
+ // so, with no stylesheet on the page: any rule of the site, layered or not, wins over :host. A string,
111
+ // since Astro would take <slot> for its own
112
+ const shadowRoot =
113
+ '<template shadowrootmode="open"><style>:host { display: contents; }</style><slot></slot></template>';
110
114
  ---
111
115
 
112
116
  <astro-image-zoom
@@ -122,7 +126,7 @@ const slotContent = wrapImages(await Astro.slots.render("default"));
122
126
  data-caption-position={captionPosition}
123
127
  style={variables || undefined}
124
128
  >
125
- <Fragment set:html={slotContent} />
129
+ <Fragment set:html={shadowRoot + slotContent} />
126
130
  </astro-image-zoom>
127
131
 
128
132
  <script>
@@ -134,6 +138,15 @@ const slotContent = wrapImages(await Astro.slots.render("default"));
134
138
  private zoom: Zoom | null = null;
135
139
 
136
140
  connectedCallback() {
141
+ // The browser attaches the declarative shadow root while parsing, but not in pages parsed
142
+ // with DOMParser, as the client router of Astro does: attach it from the template then
143
+ if (!this.shadowRoot) {
144
+ const template = this.querySelector<HTMLTemplateElement>(":scope > template[shadowrootmode]");
145
+ if (template) {
146
+ this.attachShadow({ mode: "open" }).append(template.content.cloneNode(true));
147
+ template.remove();
148
+ }
149
+ }
137
150
  this.zoom = new Zoom(this);
138
151
  }
139
152
 
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # astro-image-zoom
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/astro-image-zoom/beta)](https://www.npmjs.com/package/astro-image-zoom)
4
+
3
5
  Medium-style image zoom for Astro: a click grows each image from its place on the page to fill the
4
6
  screen, and the images of a gallery become a carousel you can swipe.
5
7
 
@@ -9,7 +11,9 @@ screen, and the images of a gallery become a carousel you can swipe.
9
11
  ## Features
10
12
 
11
13
  - **Zoom from the page**: FLIP animations with CSS transforms and `clip-path`, from the thumbnail to
12
- the full image and back, crops included.
14
+ the full image and back, crops included: the `object-fit` and `object-position` of the thumbnail,
15
+ and thumbnail files cropped to another shape, such as Astro's `<Image width height>`. A file
16
+ stretched to another shape with `object-fit: fill` animates as with `cover`.
13
17
  - **Accessible**: a native modal `<dialog>` that traps and restores focus, keyboard navigation,
14
18
  labelled controls, reduced motion and forced colors.
15
19
  - **Galleries**: a native scroll-snap carousel with touch and touchpad swipes, arrow keys, buttons and
@@ -62,17 +66,19 @@ const fullSize = await getImage({ src: photo, width: 1920 });
62
66
 
63
67
  ### Which image the zoom shows
64
68
 
65
- The zoom opens, in this order:
66
-
67
- 1. The URL in `data-zoom-src`, if the image has it.
68
- 2. The `href` of a link around the image (`<a href="…" data-zoom>`).
69
- 3. The image's own `src`.
70
-
71
- Plain images work out of the box: `<img src="/photo.jpg">` opens that same file, at full size.
72
-
73
- > **Warning: resized images look blurry when zoomed.** If the image on the page is a smaller
74
- > version (a thumbnail, or Astro's `<Image width={400}>`, which generates a 400 px file), the zoom
75
- > enlarges that small file. Add `data-zoom-src` with the full-size version, as shown in
69
+ - **An image on its own** opens the URL in its `data-zoom-src`, or its own `src` without it. Plain
70
+ images work out of the box: `<img src="/photo.jpg">` opens that same file, at full size.
71
+ - **An image inside a link with `data-zoom`** (`<a href="…" data-zoom>`) opens the `href` of the
72
+ link. Put `data-zoom-caption` (or `title`) on the link: the image inside gives only its `alt`, and
73
+ its own `data-zoom-src` and `data-zoom-caption` are ignored. See
74
+ [Gallery with Links](#gallery-with-links).
75
+ - **An image inside any other link** is left alone: the link keeps working as a link (a card, a
76
+ logo) and the image is not part of the gallery.
77
+
78
+ > **Resized images barely grow when zoomed.** The zoom shows each image at most at its real size,
79
+ > never enlarged. If the image on the page is a smaller version (a thumbnail, or Astro's
80
+ > `<Image width={400}>`, which generates a 400 px file), the zoom opens that small file and it
81
+ > stays small. Add `data-zoom-src` with the full-size version, as shown in
76
82
  > [High-Resolution Images](#high-resolution-images) and
77
83
  > [Using with Astro Assets](#using-with-astro-assets-optimized-images).
78
84
 
@@ -219,10 +225,6 @@ You can override the default styles using CSS variables:
219
225
  --zoom-caption-font-size: 13px;
220
226
  --zoom-color-scheme: light dark; /* set "dark" or "light" to follow your own theme toggle */
221
227
  --zoom-caption-radius: 6px;
222
-
223
- /* Focus ring drawn on the image when its link has keyboard focus */
224
- --zoom-focus-outline: 3px solid rebeccapurple; /* default: 2px solid currentColor */
225
- --zoom-focus-offset: 3px; /* use a negative value if a parent with overflow: hidden clips it */
226
228
  }
227
229
  ```
228
230
 
@@ -262,30 +264,49 @@ astro-image-zoom-overlay::part(caption) {
262
264
  | `nav`, `prev`, `next` | The arrows (`nav` matches both) |
263
265
  | `counter` | The position in the gallery |
264
266
 
265
- The component loads its own stylesheet, so you don't need to import `zoom.css` yourself. Import
266
- `astro-image-zoom/zoom.css` directly only if you use `ZoomClass` without the `<ImageZoom>` component.
267
+ #### Your page
267
268
 
268
- #### Cascade layer
269
+ How your images look on the page is up to your site: the component doesn't style them, not even
270
+ their focus ring. It only wraps each image in a link, so it opens with the keyboard and, without
271
+ JavaScript, links to the full-size image:
269
272
 
270
- The styles for the images on your page (the zoom cursor and the focus ring) live in the
271
- `astro-image-zoom` [cascade layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer).
272
- Any unlayered CSS on your page overrides them, with no specificity tricks or `!important`.
273
+ ```html
274
+ <a href="/full-size.jpg" data-zoom-generated aria-label="Enlarge image: A red car">
275
+ <img src="/photo.jpg" alt="A red car" />
276
+ </a>
277
+ ```
273
278
 
274
- If your site uses its own layers, add `astro-image-zoom` to your layer order so you decide what wins.
275
- Declare the order before any stylesheet loads (for example, in an inline `<style>` at the top of
276
- `<head>`), because the first time a layer appears fixes its position:
279
+ The link is inline and unstyled, so it never changes your layout, and it gets your site's link
280
+ styles, focus ring included. If your images are `display: block`, the outline of an inline link
281
+ doesn't wrap them: depending on the browser it is invisible or spans the whole line. Draw your
282
+ focus ring on the image instead, in your own style:
277
283
 
278
- ```html
279
- <style is:inline>
280
- @layer reset, base, astro-image-zoom, components;
281
- </style>
284
+ ```css
285
+ a[data-zoom-generated]:focus-visible {
286
+ outline: none;
287
+ }
288
+
289
+ a[data-zoom-generated]:focus-visible img {
290
+ outline: 2px solid green; /* your focus ring */
291
+ outline-offset: 3px; /* negative if a parent with overflow: hidden clips it */
292
+ }
293
+ ```
294
+
295
+ A zoom cursor, if you want one, is also yours to add:
296
+
297
+ ```css
298
+ a[data-zoom-generated] img {
299
+ cursor: zoom-in;
300
+ }
282
301
  ```
283
302
 
284
- With this order your reset can't break the zoom cursor or the focus ring, and your `components`
285
- layer can still customize them. Class names (`.astro-image-zoom-*`) are prefixed, so the styles don't
286
- clash with the rest of your site.
303
+ The component adds no stylesheet to your page. `<astro-image-zoom>` groups the images without
304
+ adding a box of its own (`display: contents`), from its own
305
+ [declarative shadow root](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM#declaratively_with_html),
306
+ so it works without JavaScript and any rule of your site wins over it, cascade layers included.
287
307
 
288
- To style the images of one gallery on the page, pass a class:
308
+ To style one gallery, pass a class. It can style the images, or lay out the gallery on the element
309
+ itself:
289
310
 
290
311
  ```astro
291
312
  <ImageZoom class="custom-zoom">
@@ -294,6 +315,11 @@ To style the images of one gallery on the page, pass a class:
294
315
 
295
316
  <style>
296
317
  /* Global: the wrapper is rendered by the component, outside the scope of your page */
318
+ :global(.custom-zoom) {
319
+ display: grid;
320
+ grid-template-columns: repeat(3, 1fr);
321
+ }
322
+
297
323
  :global(.custom-zoom img) {
298
324
  border-radius: 8px;
299
325
  }
@@ -318,7 +344,8 @@ To style the images of one gallery on the page, pass a class:
318
344
 
319
345
  - A native modal `<dialog>`: focus moves to the close button, stays inside while it is open and
320
346
  returns to the image when it closes.
321
- - Each image becomes a link, reachable with the keyboard, with a visible focus ring.
347
+ - Each image becomes a link, reachable with the keyboard. Its focus ring is the one of your site;
348
+ see [Your page](#your-page) to draw it on the image.
322
349
  - Labelled buttons; the caption is announced when the image changes.
323
350
  - Reduced motion turns the animations off; forced colors (Windows high contrast) keep the controls
324
351
  visible.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-image-zoom",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Medium-style image zoom for Astro: accessible, animated from the page, with swipeable galleries",
5
5
  "type": "module",
6
6
  "author": "Antonio (https://antoniohg.com)",
@@ -10,7 +10,7 @@
10
10
  "url": "git+https://github.com/antoniohg/astro-image-zoom.git",
11
11
  "directory": "packages/astro-image-zoom"
12
12
  },
13
- "homepage": "https://github.com/antoniohg/astro-image-zoom#readme",
13
+ "homepage": "https://antoniohg.com/astro-image-zoom/",
14
14
  "bugs": {
15
15
  "url": "https://github.com/antoniohg/astro-image-zoom/issues"
16
16
  },
@@ -25,8 +25,7 @@
25
25
  ],
26
26
  "exports": {
27
27
  ".": "./index.js",
28
- "./ImageZoom.astro": "./ImageZoom.astro",
29
- "./zoom.css": "./zoom.css"
28
+ "./ImageZoom.astro": "./ImageZoom.astro"
30
29
  },
31
30
  "files": [
32
31
  "index.js",
@@ -35,7 +34,6 @@
35
34
  "overlay.css",
36
35
  "env.d.ts",
37
36
  "wrapImages.ts",
38
- "zoom.css",
39
37
  "README.md"
40
38
  ],
41
39
  "peerDependencies": {
@@ -48,6 +46,7 @@
48
46
  "vitest": "5.0.1"
49
47
  },
50
48
  "scripts": {
49
+ "check": "astro check",
51
50
  "test": "vitest run",
52
51
  "test:e2e": "playwright test -c e2e/playwright.config.ts"
53
52
  }
package/zoom.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  * Medium-style zoom with accessibility and performance optimizations
4
4
  */
5
5
 
6
- /// <reference path="./env.d.ts" />
7
6
  import overlayStyles from './overlay.css?inline';
8
7
 
9
8
  interface ZoomImage {
@@ -135,6 +134,15 @@ function getOverlay(): HTMLDialogElement {
135
134
  // A box on the screen, such as the one getBoundingClientRect() returns
136
135
  type Box = Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>;
137
136
 
137
+ // How a thumbnail draws its file inside its box: the computed object-fit and object-position of the
138
+ // <img>, and the size of the file
139
+ export interface ThumbnailFit {
140
+ objectFit: string;
141
+ objectPosition: string;
142
+ naturalWidth: number;
143
+ naturalHeight: number;
144
+ }
145
+
138
146
  interface FlipTransform {
139
147
  x: number;
140
148
  y: number;
@@ -142,23 +150,91 @@ interface FlipTransform {
142
150
  clipPath: string;
143
151
  }
144
152
 
145
- /**
146
- * The FLIP transform that lays an element with the `final` box over the `source` box: the
147
- * translation between their centers, the scale that makes it cover the source box, and the
148
- * clip-path (in the element's own, unscaled pixels) that trims what overflows it. A thumbnail
149
- * cropped with object-fit: cover so grows into the whole image.
150
- */
151
- export function flipTransform(source: Box, final: Box): FlipTransform {
152
- // Cover the source box, as object-fit: cover does
153
- const scale = Math.max(source.width / final.width, source.height / final.height);
153
+ function thumbnailFit(img: HTMLImageElement): ThumbnailFit {
154
+ const { objectFit, objectPosition } = getComputedStyle(img);
155
+ return { objectFit, objectPosition, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight };
156
+ }
154
157
 
155
- const x = source.left + source.width / 2 - (final.left + final.width / 2);
156
- const y = source.top + source.height / 2 - (final.top + final.height / 2);
158
+ // Where object-position puts the drawn file along one axis, given the free space of the box
159
+ // (negative when the file overflows it). Browsers compute it as a percentage of that space, a
160
+ // length in px, or calc() of both: "right 10px" is calc(100% - 10px). Anything else, or nothing,
161
+ // centers the file, the default
162
+ function positionOffset(value: string | undefined, free: number): number {
163
+ const percent = /(-?[\d.]+)%/.exec(value ?? '');
164
+ const pixels = /([+-]?)\s*(-?[\d.]+)px/.exec(value ?? '');
165
+ if (!percent && !pixels) return free / 2;
166
+
167
+ const offset =
168
+ (percent ? (free * Number(percent[1])) / 100 : 0) +
169
+ (pixels ? Number(pixels[2]) * (pixels[1] === '-' ? -1 : 1) : 0);
170
+ return Number.isFinite(offset) ? offset : free / 2;
171
+ }
157
172
 
158
- // What overflows the source box once scaled, on each side, back in unscaled pixels
159
- const insetX = (final.width * scale - source.width) / 2 / scale;
160
- const insetY = (final.height * scale - source.height) / 2 / scale;
161
- const clipPath = `inset(${insetY}px ${insetX}px ${insetY}px ${insetX}px)`;
173
+ // The box where a thumbnail draws its file on the screen, which object-fit can make smaller than
174
+ // its box (contain) or bigger (cover, none). Without a fit, or with fill, it is the box itself.
175
+ // fill also stretches a file of another shape, which one uniform scale cannot follow: such a
176
+ // thumbnail animates as with cover
177
+ function drawnBox(source: Box, fit?: ThumbnailFit): Box {
178
+ if (!fit || !fit.naturalWidth || !fit.naturalHeight) return source;
179
+
180
+ const widthRatio = source.width / fit.naturalWidth;
181
+ const heightRatio = source.height / fit.naturalHeight;
182
+ const scales: Record<string, number> = {
183
+ contain: Math.min(widthRatio, heightRatio),
184
+ cover: Math.max(widthRatio, heightRatio),
185
+ none: 1,
186
+ 'scale-down': Math.min(1, widthRatio, heightRatio),
187
+ };
188
+ const scale = scales[fit.objectFit];
189
+ if (scale === undefined) return source;
190
+
191
+ const width = fit.naturalWidth * scale;
192
+ const height = fit.naturalHeight * scale;
193
+ // One value per axis; a calc() has spaces of its own
194
+ const [positionX, positionY] = fit.objectPosition.match(/calc\([^)]*\)|\S+/g) ?? [];
195
+ return {
196
+ left: source.left + positionOffset(positionX, source.width - width),
197
+ top: source.top + positionOffset(positionY, source.height - height),
198
+ width,
199
+ height,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * The FLIP transform that lays the zoomed image, with the `final` box, over its thumbnail, with the
205
+ * `source` box: the translation between their centers, the scale, and the clip-path (in the
206
+ * image's own, unscaled pixels) that trims what the thumbnail does not show.
207
+ *
208
+ * `fit` tells how the thumbnail draws its file (object-fit, object-position). That file may also be
209
+ * cropped from the full image already, as Astro's <Image> does with a width and a height of another
210
+ * shape: it is taken as a centered crop, so the scale matches what the thumbnail really shows.
211
+ * Without `fit`, the image covers the source box, as object-fit: cover does.
212
+ */
213
+ export function flipTransform(source: Box, final: Box, fit?: ThumbnailFit): FlipTransform {
214
+ const drawn = drawnBox(source, fit);
215
+
216
+ // The whole image around the drawn file, at the same scale, taking the file as a centered crop
217
+ const imageRatio = final.width / final.height;
218
+ const drawnRatio = drawn.width / drawn.height;
219
+ const width = imageRatio > drawnRatio ? drawn.height * imageRatio : drawn.width;
220
+ const height = width / imageRatio;
221
+ const left = drawn.left + (drawn.width - width) / 2;
222
+ const top = drawn.top + (drawn.height - height) / 2;
223
+ const scale = width / final.width;
224
+
225
+ const x = left + width / 2 - (final.left + final.width / 2);
226
+ const y = top + height / 2 - (final.top + final.height / 2);
227
+
228
+ // Only what the thumbnail shows stays visible: the drawn file, cut to the box of the thumbnail
229
+ const visibleLeft = Math.max(drawn.left, source.left);
230
+ const visibleTop = Math.max(drawn.top, source.top);
231
+ const visibleRight = Math.min(drawn.left + drawn.width, source.left + source.width);
232
+ const visibleBottom = Math.min(drawn.top + drawn.height, source.top + source.height);
233
+ const insetTop = (visibleTop - top) / scale;
234
+ const insetRight = (left + width - visibleRight) / scale;
235
+ const insetBottom = (top + height - visibleBottom) / scale;
236
+ const insetLeft = (visibleLeft - left) / scale;
237
+ const clipPath = `inset(${insetTop}px ${insetRight}px ${insetBottom}px ${insetLeft}px)`;
162
238
 
163
239
  return { x, y, scale, clipPath };
164
240
  }
@@ -234,10 +310,10 @@ class Zoom {
234
310
  this.setupEventListeners();
235
311
  }
236
312
 
313
+ // Only the links the component generated and those the site marks with data-zoom: any other link
314
+ // with an image (a card, a logo) keeps navigating
237
315
  private collectImages(): void {
238
- const links = this.wrapper.querySelectorAll<HTMLAnchorElement>(
239
- 'a[data-zoom-generated], a[data-zoom], a:has(img)'
240
- );
316
+ const links = this.wrapper.querySelectorAll<HTMLAnchorElement>('a[data-zoom-generated], a[data-zoom]');
241
317
 
242
318
  this.state.images = Array.from(links)
243
319
  .map((anchor) => {
@@ -249,7 +325,7 @@ class Zoom {
249
325
  element: anchor
250
326
  };
251
327
  })
252
- .filter(({ src }) => src && src !== window.location.origin + '/' && src !== 'about:blank');
328
+ .filter(({ src }) => src);
253
329
  }
254
330
 
255
331
  private setupEventListeners(): void {
@@ -326,6 +402,7 @@ class Zoom {
326
402
  // Get source image and position
327
403
  const sourceImg = this.state.images[index].element.querySelector('img')!;
328
404
  const sourceRect = sourceImg.getBoundingClientRect();
405
+ const sourceFit = thumbnailFit(sourceImg);
329
406
 
330
407
  this.buildSlides();
331
408
  this.renderActive(index);
@@ -378,7 +455,7 @@ class Zoom {
378
455
 
379
456
  // FLIP Animation
380
457
  const finalRect = this.imageElement.getBoundingClientRect();
381
- const transform = flipTransform(sourceRect, finalRect);
458
+ const transform = flipTransform(sourceRect, finalRect, sourceFit);
382
459
 
383
460
  // Set CSS variables for animation
384
461
  this.setAnimationVariables(transform);
@@ -450,7 +527,7 @@ class Zoom {
450
527
  const targetRect = sourceImg.getBoundingClientRect();
451
528
 
452
529
  // Calculate transform for closing animation
453
- const transform = flipTransform(targetRect, startRect);
530
+ const transform = flipTransform(targetRect, startRect, thumbnailFit(sourceImg));
454
531
 
455
532
  // Set CSS variables for closing animation
456
533
  this.setAnimationVariables(transform);
package/zoom.css DELETED
@@ -1,36 +0,0 @@
1
- /*
2
- * astro-image-zoom page styles: the images on the page, not the zoom overlay.
3
- *
4
- * The overlay lives in a shadow root and brings its own styles (overlay.css), so no CSS of
5
- * the page can reach it. Style it with the --zoom-* custom properties or with ::part().
6
- *
7
- * These page styles live in the `astro-image-zoom` cascade layer, so any unlayered CSS on the page
8
- * overrides them without specificity fights. To control their priority among your own layers,
9
- * name the layer in your layer order, e.g. `@layer reset, astro-image-zoom, components;`.
10
- */
11
- @layer astro-image-zoom {
12
- /* The element only groups its content, it must not add a box */
13
- astro-image-zoom {
14
- display: contents;
15
- }
16
-
17
- /* Links wrapping images: the image is the click target */
18
- .astro-image-zoom-wrapper a img {
19
- cursor: zoom-in;
20
- }
21
-
22
- /*
23
- * Focus ring on the image, not on the generated link: the link is inline, so when the image
24
- * is display: block its outline does not wrap the image. Customize it with
25
- * --zoom-focus-outline and --zoom-focus-offset (a negative offset draws it inside the image,
26
- * useful when a parent with overflow: hidden would clip it).
27
- */
28
- .astro-image-zoom-wrapper a[data-zoom-generated]:focus-visible {
29
- outline: none;
30
-
31
- img {
32
- outline: var(--zoom-focus-outline, 2px solid currentColor);
33
- outline-offset: var(--zoom-focus-offset, 3px);
34
- }
35
- }
36
- }