@motion-proto/live-tokens 0.30.0 → 0.32.0

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.
@@ -1,47 +1,118 @@
1
1
  <script lang="ts">
2
2
  import { onMount, tick } from 'svelte';
3
+ import { portal } from '../internal/portal';
3
4
 
4
- interface Props {
5
+ interface GalleryImage {
5
6
  src: string;
6
7
  alt: string;
8
+ width?: number;
9
+ height?: number;
10
+ }
11
+
12
+ interface Props {
13
+ src?: string;
14
+ alt?: string;
7
15
  width?: number | undefined;
8
16
  height?: number | undefined;
17
+ /** Pass two or more to turn the lightbox into a gallery: left/right chevrons
18
+ and an `i / n` counter appear once open, plus arrow-key navigation. A
19
+ single-entry array behaves exactly like a lone `src`. */
20
+ images?: GalleryImage[];
9
21
  maxWidth?: number | string | undefined;
22
+ /** Closed-thumbnail object-fit. `cover` crops the thumbnail to fill its box;
23
+ the expanded modal always uses `contain` so the whole image stays visible.
24
+ `cover` only crops when the thumbnail has its own box (an aspect from
25
+ `width`/`height`, or a CSS-constrained container). */
26
+ fit?: 'contain' | 'cover';
10
27
  /** When true, shows a bottom toolbar (zoom in/out + percent) and a top-right close button, and enables wheel/drag zoom inside the open modal. When false, click anywhere closes. */
11
28
  extended?: boolean;
12
29
  }
13
30
 
14
31
  let {
15
- src,
16
- alt,
32
+ src = undefined,
33
+ alt = undefined,
17
34
  width = undefined,
18
35
  height = undefined,
36
+ images = undefined,
19
37
  maxWidth = undefined,
38
+ fit = 'contain',
20
39
  extended = false,
21
40
  }: Props = $props();
22
41
 
42
+ const items = $derived(
43
+ images && images.length
44
+ ? images
45
+ : src != null
46
+ ? [{ src, alt: alt ?? '', width, height }]
47
+ : [],
48
+ );
49
+
50
+ let index = $state(0);
51
+ const current = $derived(items[index]);
52
+ const cover = $derived(items[0]);
53
+ const isGallery = $derived(items.length > 1);
54
+
55
+ const dialogLabel = $derived(
56
+ isGallery
57
+ ? `Image ${index + 1} of ${items.length}${current?.alt ? `: ${current.alt}` : ''}`
58
+ : current?.alt || 'Image',
59
+ );
60
+
23
61
  const MIN_SCALE = 1;
24
62
  const MAX_SCALE = 5;
25
63
  const ZOOM_STEP = 1.5;
26
64
  const TRANSITION_MS = 350;
27
65
  const TRANSITION_EASE = 'cubic-bezier(0.65, 0, 0.35, 1)';
28
66
 
29
- let wrapperEl: HTMLDivElement;
30
- let tileEl: HTMLDivElement;
31
- let transformEl: HTMLDivElement;
32
- let overlayEl: HTMLDivElement;
67
+ // Gallery navigation: outgoing slides out + shrinks + fades; incoming slides
68
+ // in from the opposite side, staggered. Easings mirror --ease-in/out-cubic.
69
+ const NAV_MS = 250;
70
+ const NAV_STAGGER_MS = 125;
71
+ const NAV_SHIFT_PX = 32;
72
+ const NAV_SCALE = 0.95;
73
+ const EASE_IN = 'cubic-bezier(0.32, 0, 0.67, 0)';
74
+ const EASE_OUT = 'cubic-bezier(0.33, 1, 0.68, 1)';
75
+
76
+ let thumbEl: HTMLButtonElement | undefined = $state();
77
+ let modalEl: HTMLDivElement | undefined = $state();
78
+ let stageEl: HTMLDivElement | undefined = $state();
79
+ let transformEl: HTMLDivElement | undefined = $state();
80
+ let overlayEl: HTMLDivElement | undefined = $state();
33
81
  let toolbarEl: HTMLDivElement | undefined = $state();
34
82
  let closeBtnEl: HTMLButtonElement | undefined = $state();
35
-
83
+ let prevBtnEl: HTMLButtonElement | undefined = $state();
84
+ let nextBtnEl: HTMLButtonElement | undefined = $state();
85
+ let counterEl: HTMLDivElement | undefined = $state();
86
+ let incomingEl: HTMLImageElement | undefined = $state();
87
+ let outgoingEl: HTMLImageElement | undefined = $state();
88
+
89
+ // `mounted` keeps the portaled modal in the DOM through the close animation;
90
+ // `open` is the visual state the chrome reacts to.
91
+ let mounted = $state(false);
36
92
  let open = $state(false);
37
93
  let scale = $state(1);
38
94
  let offset = { x: 0, y: 0 };
39
95
 
96
+ // The image leaving during a gallery transition; rendered as a second layer
97
+ // until its slide-out finishes, then dropped.
98
+ let outgoing = $state<GalleryImage | null>(null);
99
+ let navigating = false;
100
+
101
+ // Aspect ratio per image, keyed by src: explicit width/height when given, else
102
+ // measured from the loaded <img>. The thumbnail reads the cover's entry and the
103
+ // modal reads the current image's, so they are never confused for each other.
104
+ let measured = $state<Record<string, number>>({});
105
+ let reducedMotion = $state(false);
106
+ const dur = (ms = TRANSITION_MS) => (reducedMotion ? 0 : ms);
107
+
40
108
  // Pointer drag for pan (extended + zoomed only).
41
109
  let dragState: { startX: number; startY: number; baseX: number; baseY: number; pointerId: number } | null = null;
42
110
  let didDrag = false;
43
111
 
44
- const aspect = $derived(width && height ? width / height : undefined);
112
+ const aspectOf = (it?: GalleryImage) =>
113
+ it ? (it.width && it.height ? it.width / it.height : measured[it.src]) : undefined;
114
+ const coverAspect = $derived(aspectOf(cover)); // inline thumbnail box
115
+ const aspect = $derived(aspectOf(current)); // open modal box
45
116
 
46
117
  function viewportTarget() {
47
118
  const vw = window.innerWidth;
@@ -62,8 +133,8 @@
62
133
  }
63
134
 
64
135
  function clampOffset(x: number, y: number, s: number) {
65
- if (!tileEl) return { x: 0, y: 0 };
66
- const r = tileEl.getBoundingClientRect();
136
+ if (!stageEl) return { x: 0, y: 0 };
137
+ const r = stageEl.getBoundingClientRect();
67
138
  const maxX = Math.max(0, (r.width * s - r.width) / 2);
68
139
  const maxY = Math.max(0, (r.height * s - r.height) / 2);
69
140
  return {
@@ -101,10 +172,8 @@
101
172
  function cancelAnimations() {
102
173
  // Commit each animation's current value to inline styles before cancelling,
103
174
  // so a mid-flight cancel doesn't visually snap the element back to its
104
- // pre-animation state. After cancel, the WAAPI effect is gone and the
105
- // committed inline values are what we'll either animate from next or
106
- // clear via cssText = ''.
107
- for (const el of [tileEl, overlayEl, toolbarEl, closeBtnEl]) {
175
+ // pre-animation state.
176
+ for (const el of [stageEl, overlayEl, toolbarEl, closeBtnEl, prevBtnEl, nextBtnEl, counterEl]) {
108
177
  if (!el) continue;
109
178
  for (const a of el.getAnimations()) {
110
179
  try { a.commitStyles(); } catch {}
@@ -114,100 +183,218 @@
114
183
  }
115
184
 
116
185
  async function openLightbox() {
117
- if (open || !tileEl) return;
118
- cancelAnimations();
119
- const start = tileEl.getBoundingClientRect();
120
- const target = viewportTarget();
186
+ if (open || !thumbEl) return;
187
+ const start = thumbEl.getBoundingClientRect();
121
188
 
189
+ mounted = true;
122
190
  open = true;
123
191
  document.body.style.overflow = 'hidden';
124
192
  hideEditorOverlay();
125
193
  await tick();
194
+ if (!stageEl || !overlayEl) return;
126
195
 
127
- Object.assign(tileEl.style, {
128
- position: 'fixed',
196
+ const target = viewportTarget();
197
+ Object.assign(stageEl.style, {
129
198
  top: `${start.top}px`,
130
199
  left: `${start.left}px`,
131
200
  width: `${start.width}px`,
132
201
  height: `${start.height}px`,
133
- zIndex: 'var(--z-modal)',
134
202
  });
135
203
 
136
- tileEl.animate(
204
+ stageEl.animate(
137
205
  [
138
206
  { top: `${start.top}px`, left: `${start.left}px`, width: `${start.width}px`, height: `${start.height}px` },
139
207
  { top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
140
208
  ],
141
- { duration: TRANSITION_MS, easing: TRANSITION_EASE, fill: 'forwards' },
209
+ { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
142
210
  );
143
211
 
144
212
  overlayEl.animate([{ opacity: 0 }, { opacity: 1 }], {
145
- duration: TRANSITION_MS,
213
+ duration: dur(),
146
214
  easing: TRANSITION_EASE,
147
215
  fill: 'forwards',
148
216
  });
149
217
 
218
+ fadeChrome(true);
150
219
  if (extended) {
151
220
  toolbarEl?.animate([{ opacity: 0, transform: 'translate(-50%, 16px)' }, { opacity: 1, transform: 'translate(-50%, 0)' }], {
152
- duration: TRANSITION_MS,
221
+ duration: dur(),
153
222
  easing: TRANSITION_EASE,
154
223
  fill: 'forwards',
155
224
  delay: 80,
156
225
  });
157
- closeBtnEl?.animate([{ opacity: 0 }, { opacity: 1 }], {
158
- duration: TRANSITION_MS,
226
+ }
227
+
228
+ // Move focus into the modal so keyboard users land inside the dialog; the
229
+ // stage (tabindex -1) is the fallback when there is no chrome to focus.
230
+ (closeBtnEl ?? stageEl)?.focus();
231
+ }
232
+
233
+ // Close button, gallery chevrons, and counter all share a plain opacity fade;
234
+ // the zoom toolbar keeps its bespoke slide so it's handled separately.
235
+ function chromeFadeEls(): HTMLElement[] {
236
+ const els: (HTMLElement | undefined)[] = [];
237
+ if (extended || isGallery) els.push(closeBtnEl);
238
+ if (isGallery) els.push(prevBtnEl, nextBtnEl, counterEl);
239
+ return els.filter((el): el is HTMLElement => !!el);
240
+ }
241
+
242
+ function fadeChrome(showing: boolean) {
243
+ for (const el of chromeFadeEls()) {
244
+ el.animate([{ opacity: showing ? 0 : 1 }, { opacity: showing ? 1 : 0 }], {
245
+ duration: dur(),
159
246
  easing: TRANSITION_EASE,
160
247
  fill: 'forwards',
161
- delay: 80,
248
+ delay: showing ? 80 : 0,
162
249
  });
163
250
  }
164
251
  }
165
252
 
166
253
  function closeLightbox() {
167
- if (!open || !tileEl || !wrapperEl) return;
254
+ if (!open || !stageEl || !thumbEl || !overlayEl) return;
168
255
  cancelAnimations();
169
- const target = wrapperEl.getBoundingClientRect();
170
- const current = tileEl.getBoundingClientRect();
256
+ const target = thumbEl.getBoundingClientRect();
257
+ const from = stageEl.getBoundingClientRect();
171
258
 
172
- const anim = tileEl.animate(
259
+ const anim = stageEl.animate(
173
260
  [
174
- { top: `${current.top}px`, left: `${current.left}px`, width: `${current.width}px`, height: `${current.height}px` },
261
+ { top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px` },
175
262
  { top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
176
263
  ],
177
- { duration: TRANSITION_MS, easing: TRANSITION_EASE, fill: 'forwards' },
264
+ { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
178
265
  );
179
266
 
180
267
  overlayEl.animate([{ opacity: 1 }, { opacity: 0 }], {
181
- duration: TRANSITION_MS,
268
+ duration: dur(),
182
269
  easing: TRANSITION_EASE,
183
270
  fill: 'forwards',
184
271
  });
185
272
 
273
+ fadeChrome(false);
186
274
  if (extended) {
187
275
  toolbarEl?.animate([{ opacity: 1 }, { opacity: 0 }], {
188
- duration: TRANSITION_MS,
189
- easing: TRANSITION_EASE,
190
- fill: 'forwards',
191
- });
192
- closeBtnEl?.animate([{ opacity: 1 }, { opacity: 0 }], {
193
- duration: TRANSITION_MS,
276
+ duration: dur(),
194
277
  easing: TRANSITION_EASE,
195
278
  fill: 'forwards',
196
279
  });
197
280
  }
198
281
 
199
282
  anim.onfinish = () => {
200
- cancelAnimations();
201
- tileEl.style.cssText = '';
202
- transformEl.style.transform = '';
203
283
  scale = 1;
204
284
  offset = { x: 0, y: 0 };
285
+ index = 0;
205
286
  open = false;
287
+ mounted = false;
206
288
  document.body.style.overflow = '';
207
289
  restoreEditorOverlay();
290
+ thumbEl?.focus();
208
291
  };
209
292
  }
210
293
 
294
+ // Re-fit the open modal to the viewport — used on resize (snap) and when the
295
+ // gallery image or its measured aspect changes (animated morph).
296
+ function fitToViewport(animate: boolean) {
297
+ if (!open || !stageEl) return;
298
+ const target = viewportTarget();
299
+ if (animate) {
300
+ const from = stageEl.getBoundingClientRect();
301
+ for (const a of stageEl.getAnimations()) {
302
+ try { a.commitStyles(); } catch {}
303
+ a.cancel();
304
+ }
305
+ stageEl.animate(
306
+ [
307
+ { top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px` },
308
+ { top: `${target.top}px`, left: `${target.left}px`, width: `${target.width}px`, height: `${target.height}px` },
309
+ ],
310
+ { duration: dur(), easing: TRANSITION_EASE, fill: 'forwards' },
311
+ );
312
+ } else {
313
+ Object.assign(stageEl.style, {
314
+ top: `${target.top}px`,
315
+ left: `${target.left}px`,
316
+ width: `${target.width}px`,
317
+ height: `${target.height}px`,
318
+ });
319
+ }
320
+ offset = clampOffset(offset.x, offset.y, scale);
321
+ applyTransform(scale, offset);
322
+ }
323
+
324
+ function settleNav() {
325
+ incomingEl?.getAnimations().forEach((a) => a.cancel());
326
+ outgoingEl?.getAnimations().forEach((a) => a.cancel());
327
+ outgoing = null;
328
+ navigating = false;
329
+ }
330
+
331
+ // dir: +1 when paging via the right chevron (content exits left, the next
332
+ // image enters from the right), -1 for the left chevron.
333
+ async function goTo(target: number, dir: number) {
334
+ if (!isGallery) return;
335
+ const n = ((target % items.length) + items.length) % items.length;
336
+ if (n === index) return;
337
+ if (navigating) settleNav();
338
+
339
+ scale = 1;
340
+ offset = { x: 0, y: 0 };
341
+ applyTransform(scale, offset);
342
+
343
+ outgoing = current;
344
+ index = n;
345
+ navigating = true;
346
+ await tick();
347
+
348
+ const exit = -NAV_SHIFT_PX * dir;
349
+ outgoingEl?.animate(
350
+ [
351
+ { transform: 'translateX(0) scale(1)', opacity: 1 },
352
+ { transform: `translateX(${exit}px) scale(${NAV_SCALE})`, opacity: 0 },
353
+ ],
354
+ { duration: dur(NAV_MS), easing: EASE_IN, fill: 'forwards' },
355
+ );
356
+
357
+ const enter = incomingEl?.animate(
358
+ [
359
+ { transform: `translateX(${-exit}px) scale(${NAV_SCALE})`, opacity: 0 },
360
+ { transform: 'translateX(0) scale(1)', opacity: 1 },
361
+ ],
362
+ // `fill: both` holds the opacity-0 start state through the stagger delay,
363
+ // otherwise the incoming image flashes at full opacity before sliding in.
364
+ { duration: dur(NAV_MS), easing: EASE_OUT, fill: 'both', delay: dur(NAV_STAGGER_MS) },
365
+ );
366
+
367
+ fitToViewport(true); // resize the stage if the incoming aspect differs; a no-op when it matches
368
+
369
+ if (enter) enter.onfinish = settleNav;
370
+ else settleNav();
371
+ }
372
+
373
+ const next = () => goTo(index + 1, 1);
374
+ const prev = () => goTo(index - 1, -1);
375
+
376
+ function record(src: string, e: Event) {
377
+ const img = e.currentTarget as HTMLImageElement;
378
+ if (img.naturalWidth && img.naturalHeight) measured[src] = img.naturalWidth / img.naturalHeight;
379
+ }
380
+
381
+ function onCoverLoad(e: Event) {
382
+ if (cover) record(cover.src, e);
383
+ }
384
+
385
+ function onImgLoad(e: Event) {
386
+ if (current) record(current.src, e);
387
+ if (open) fitToViewport(true);
388
+ }
389
+
390
+ $effect(() => {
391
+ if (!isGallery || typeof Image === 'undefined') return;
392
+ for (const d of [index + 1, index - 1]) {
393
+ const it = items[((d % items.length) + items.length) % items.length];
394
+ if (it) new Image().src = it.src;
395
+ }
396
+ });
397
+
211
398
  function zoomTo(nextScale: number, anchor?: { x: number; y: number }) {
212
399
  const s = Math.max(MIN_SCALE, Math.min(MAX_SCALE, nextScale));
213
400
  if (s <= MIN_SCALE) {
@@ -216,18 +403,18 @@
216
403
  applyTransform(scale, offset);
217
404
  return;
218
405
  }
219
- let next: { x: number; y: number };
220
- if (anchor && tileEl) {
221
- const r = tileEl.getBoundingClientRect();
406
+ let nextOffset: { x: number; y: number };
407
+ if (anchor && stageEl) {
408
+ const r = stageEl.getBoundingClientRect();
222
409
  const dx = anchor.x - (r.left + r.width / 2);
223
410
  const dy = anchor.y - (r.top + r.height / 2);
224
411
  const ratio = scale > 0 ? s / scale : 1;
225
- next = clampOffset(dx * (1 - ratio) + offset.x * ratio, dy * (1 - ratio) + offset.y * ratio, s);
412
+ nextOffset = clampOffset(dx * (1 - ratio) + offset.x * ratio, dy * (1 - ratio) + offset.y * ratio, s);
226
413
  } else {
227
- next = clampOffset(offset.x, offset.y, s);
414
+ nextOffset = clampOffset(offset.x, offset.y, s);
228
415
  }
229
416
  scale = s;
230
- offset = next;
417
+ offset = nextOffset;
231
418
  applyTransform(scale, offset);
232
419
  }
233
420
 
@@ -267,44 +454,63 @@
267
454
  }
268
455
  }
269
456
 
270
- function onTileClick() {
457
+ function onStageClick() {
271
458
  if (didDrag) {
272
459
  didDrag = false;
273
460
  return;
274
461
  }
275
- if (!open) {
276
- openLightbox();
277
- } else if (!extended) {
278
- closeLightbox();
279
- }
462
+ if (!extended && !isGallery) closeLightbox();
280
463
  }
281
464
 
282
- function onTileKeyDown(e: KeyboardEvent) {
283
- if (e.key === 'Enter' || e.code === 'Space') {
465
+ // Keep Tab inside the open dialog. The stage (tabindex -1) is excluded, so an
466
+ // image-only lightbox with no chrome simply holds focus on the stage.
467
+ function trapTab(e: KeyboardEvent) {
468
+ if (!modalEl) return;
469
+ const f = [
470
+ ...modalEl.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'),
471
+ ];
472
+ if (f.length === 0) {
473
+ e.preventDefault();
474
+ stageEl?.focus();
475
+ return;
476
+ }
477
+ const first = f[0];
478
+ const last = f[f.length - 1];
479
+ const active = document.activeElement as HTMLElement | null;
480
+ const inside = active && modalEl.contains(active);
481
+ if (e.shiftKey && (!inside || active === first)) {
482
+ e.preventDefault();
483
+ last.focus();
484
+ } else if (!e.shiftKey && (!inside || active === last)) {
284
485
  e.preventDefault();
285
- onTileClick();
486
+ first.focus();
286
487
  }
287
488
  }
288
489
 
289
490
  onMount(() => {
290
491
  const onKey = (e: KeyboardEvent) => {
291
- if (e.key === 'Escape' && open) closeLightbox();
292
- };
293
- const onResize = () => {
294
- if (!open || !tileEl) return;
295
- const target = viewportTarget();
296
- Object.assign(tileEl.style, {
297
- top: `${target.top}px`,
298
- left: `${target.left}px`,
299
- width: `${target.width}px`,
300
- height: `${target.height}px`,
301
- });
302
- offset = clampOffset(offset.x, offset.y, scale);
303
- applyTransform(scale, offset);
492
+ if (!open) return;
493
+ if (e.key === 'Escape') {
494
+ closeLightbox();
495
+ } else if (e.key === 'Tab') {
496
+ trapTab(e);
497
+ } else if (isGallery && e.key === 'ArrowRight') {
498
+ e.preventDefault();
499
+ next();
500
+ } else if (isGallery && e.key === 'ArrowLeft') {
501
+ e.preventDefault();
502
+ prev();
503
+ }
304
504
  };
505
+ const onResize = () => fitToViewport(false);
506
+ const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
507
+ reducedMotion = motionQuery.matches;
508
+ const onMotionChange = (e: MediaQueryListEvent) => { reducedMotion = e.matches; };
509
+ motionQuery.addEventListener('change', onMotionChange);
305
510
  document.addEventListener('keydown', onKey);
306
511
  window.addEventListener('resize', onResize);
307
512
  return () => {
513
+ motionQuery.removeEventListener('change', onMotionChange);
308
514
  document.removeEventListener('keydown', onKey);
309
515
  window.removeEventListener('resize', onResize);
310
516
  document.body.style.overflow = '';
@@ -316,97 +522,151 @@
316
522
  </script>
317
523
 
318
524
  <div
319
- bind:this={wrapperEl}
320
525
  class="image-lightbox-wrapper"
321
- style:aspect-ratio={aspect ? `${width} / ${height}` : undefined}
526
+ style:aspect-ratio={coverAspect ? `${coverAspect}` : undefined}
322
527
  style:max-width={typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth}
323
528
  >
324
- <div
325
- bind:this={tileEl}
326
- class="image-lightbox-tile"
327
- class:open
328
- role="button"
329
- tabindex="0"
330
- aria-label={open ? `Close image: ${alt}` : `Expand image: ${alt}`}
331
- onclick={onTileClick}
332
- onkeydown={onTileKeyDown}
333
- onpointerdown={onPointerDown}
334
- onpointermove={onPointerMove}
335
- onpointerup={onPointerUp}
336
- onpointercancel={onPointerUp}
337
- onwheel={onWheel}
338
- >
339
- <div class="image-lightbox-clip">
340
- <div bind:this={transformEl} class="image-lightbox-transform">
341
- <img {src} {alt} draggable="false" />
342
- </div>
343
- </div>
344
- </div>
345
- </div>
346
-
347
- <div
348
- bind:this={overlayEl}
349
- class="image-lightbox-overlay"
350
- class:active={open}
351
- aria-hidden="true"
352
- onclick={closeLightbox}
353
- role="presentation"
354
- ></div>
355
-
356
- {#if extended}
357
529
  <button
358
- bind:this={closeBtnEl}
359
- class="image-lightbox-chrome image-lightbox-close"
360
- class:active={open}
530
+ bind:this={thumbEl}
531
+ class="image-lightbox-thumb"
532
+ style:--imagelightbox-tile-object-fit={fit}
361
533
  type="button"
362
- aria-label="Close"
363
- onclick={closeLightbox}
534
+ aria-label={cover?.alt ? `Expand image: ${cover.alt}` : 'Expand image'}
535
+ onclick={openLightbox}
364
536
  >
365
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
366
- <path d="M18 6L6 18" />
367
- <path d="M6 6l12 12" />
368
- </svg>
537
+ <img src={cover?.src} alt={cover?.alt} draggable="false" onload={onCoverLoad} />
369
538
  </button>
539
+ </div>
370
540
 
541
+ {#if mounted}
371
542
  <div
372
- bind:this={toolbarEl}
373
- class="image-lightbox-toolbar"
374
- class:active={open}
543
+ bind:this={modalEl}
544
+ class="image-lightbox-modal"
545
+ role="dialog"
546
+ aria-modal="true"
547
+ aria-label={dialogLabel}
548
+ use:portal
375
549
  >
376
- <button
377
- class="image-lightbox-chrome-button"
378
- type="button"
379
- aria-label="Zoom out"
380
- disabled={scale <= MIN_SCALE + 0.001}
381
- onclick={() => zoomTo(scale / ZOOM_STEP)}
550
+ <div
551
+ bind:this={overlayEl}
552
+ class="image-lightbox-overlay"
553
+ class:active={open}
554
+ aria-hidden="true"
555
+ onclick={closeLightbox}
556
+ role="presentation"
557
+ ></div>
558
+
559
+ {#if isGallery}
560
+ <div class="image-lightbox-sr" aria-live="polite">{`Image ${index + 1} of ${items.length}`}</div>
561
+ {/if}
562
+
563
+ <div
564
+ bind:this={stageEl}
565
+ class="image-lightbox-stage"
566
+ role="presentation"
567
+ tabindex="-1"
568
+ onclick={onStageClick}
569
+ onpointerdown={onPointerDown}
570
+ onpointermove={onPointerMove}
571
+ onpointerup={onPointerUp}
572
+ onpointercancel={onPointerUp}
573
+ onwheel={onWheel}
382
574
  >
383
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
384
- <path d="M5 12h14" />
385
- </svg>
386
- </button>
387
- <span class="image-lightbox-toolbar-label">{percentLabel}</span>
388
- <button
389
- class="image-lightbox-chrome-button"
390
- type="button"
391
- aria-label="Zoom in"
392
- disabled={scale >= MAX_SCALE - 0.001}
393
- onclick={() => zoomTo(scale * ZOOM_STEP)}
394
- >
395
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
396
- <path d="M12 5v14" />
397
- <path d="M5 12h14" />
398
- </svg>
399
- </button>
575
+ <div class="image-lightbox-clip">
576
+ <div bind:this={transformEl} class="image-lightbox-transform">
577
+ {#if outgoing}
578
+ <img bind:this={outgoingEl} class="image-lightbox-layer image-lightbox-layer-exit" src={outgoing.src} alt="" draggable="false" />
579
+ {/if}
580
+ <img bind:this={incomingEl} class="image-lightbox-layer" src={current?.src} alt={current?.alt} draggable="false" onload={onImgLoad} />
581
+ </div>
582
+ </div>
583
+ </div>
584
+
585
+ {#if extended || isGallery}
586
+ <button
587
+ bind:this={closeBtnEl}
588
+ class="image-lightbox-chrome image-lightbox-close"
589
+ class:active={open}
590
+ type="button"
591
+ aria-label="Close"
592
+ onclick={closeLightbox}
593
+ >
594
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
595
+ <path d="M18 6L6 18" />
596
+ <path d="M6 6l12 12" />
597
+ </svg>
598
+ </button>
599
+ {/if}
600
+
601
+ {#if isGallery}
602
+ <button
603
+ bind:this={prevBtnEl}
604
+ class="image-lightbox-chrome image-lightbox-nav image-lightbox-nav-prev"
605
+ class:active={open}
606
+ type="button"
607
+ aria-label="Previous image"
608
+ onclick={prev}
609
+ >
610
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
611
+ <path d="M15 18l-6-6 6-6" />
612
+ </svg>
613
+ </button>
614
+ <button
615
+ bind:this={nextBtnEl}
616
+ class="image-lightbox-chrome image-lightbox-nav image-lightbox-nav-next"
617
+ class:active={open}
618
+ type="button"
619
+ aria-label="Next image"
620
+ onclick={next}
621
+ >
622
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
623
+ <path d="M9 6l6 6-6 6" />
624
+ </svg>
625
+ </button>
626
+ <div bind:this={counterEl} class="image-lightbox-counter" aria-hidden="true">
627
+ {index + 1} / {items.length}
628
+ </div>
629
+ {/if}
630
+
631
+ {#if extended}
632
+ <div bind:this={toolbarEl} class="image-lightbox-toolbar" class:active={open}>
633
+ <button
634
+ class="image-lightbox-chrome-button"
635
+ type="button"
636
+ aria-label="Zoom out"
637
+ disabled={scale <= MIN_SCALE + 0.001}
638
+ onclick={() => zoomTo(scale / ZOOM_STEP)}
639
+ >
640
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
641
+ <path d="M5 12h14" />
642
+ </svg>
643
+ </button>
644
+ <span class="image-lightbox-toolbar-label">{percentLabel}</span>
645
+ <button
646
+ class="image-lightbox-chrome-button"
647
+ type="button"
648
+ aria-label="Zoom in"
649
+ disabled={scale >= MAX_SCALE - 0.001}
650
+ onclick={() => zoomTo(scale * ZOOM_STEP)}
651
+ >
652
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
653
+ <path d="M12 5v14" />
654
+ <path d="M5 12h14" />
655
+ </svg>
656
+ </button>
657
+ </div>
658
+ {/if}
400
659
  </div>
401
660
  {/if}
402
661
 
403
662
  <style>
404
663
  :global(:root) {
405
- /* tile (closed inline + animated modal surface) */
664
+ /* thumbnail (closed inline) + animated modal stage */
406
665
  --imagelightbox-tile-radius: var(--radius-2xl);
407
666
  --imagelightbox-tile-border: var(--color-transparent);
408
667
  --imagelightbox-tile-border-width: var(--border-width-0);
409
668
  --imagelightbox-tile-shadow: var(--shadow-md);
669
+ --imagelightbox-tile-object-fit: contain;
410
670
 
411
671
  /* overlay */
412
672
  --imagelightbox-overlay-surface: color-mix(in srgb, var(--color-neutral-950) 76%, transparent);
@@ -425,31 +685,63 @@
425
685
  width: 100%;
426
686
  }
427
687
 
428
- .image-lightbox-tile {
688
+ .image-lightbox-thumb {
429
689
  position: absolute;
430
690
  inset: 0;
691
+ padding: 0;
431
692
  cursor: zoom-in;
432
693
  border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
433
694
  border-radius: var(--imagelightbox-tile-radius);
434
695
  box-shadow: var(--imagelightbox-tile-shadow);
435
696
  background: transparent;
436
- overflow: visible;
697
+ overflow: hidden;
437
698
  transition: transform 250ms ease;
438
699
  }
439
700
 
440
- .image-lightbox-tile:hover:not(.open) {
701
+ .image-lightbox-thumb:hover {
441
702
  transform: scale(1.02);
442
703
  }
443
704
 
444
- .image-lightbox-tile.open {
445
- cursor: zoom-out;
446
- }
447
-
448
- .image-lightbox-tile:focus-visible {
705
+ .image-lightbox-thumb:focus-visible {
449
706
  outline: 2px solid var(--border-brand-medium);
450
707
  outline-offset: 2px;
451
708
  }
452
709
 
710
+ .image-lightbox-thumb img {
711
+ width: 100%;
712
+ height: 100%;
713
+ object-fit: var(--imagelightbox-tile-object-fit);
714
+ object-position: center;
715
+ user-select: none;
716
+ display: block;
717
+ }
718
+
719
+ .image-lightbox-modal {
720
+ display: contents;
721
+ }
722
+
723
+ .image-lightbox-sr {
724
+ position: fixed;
725
+ width: 1px;
726
+ height: 1px;
727
+ padding: 0;
728
+ margin: -1px;
729
+ overflow: hidden;
730
+ clip: rect(0 0 0 0);
731
+ white-space: nowrap;
732
+ border: 0;
733
+ }
734
+
735
+ .image-lightbox-stage {
736
+ position: fixed;
737
+ z-index: var(--z-modal);
738
+ cursor: zoom-out;
739
+ border: var(--imagelightbox-tile-border-width) solid var(--imagelightbox-tile-border);
740
+ border-radius: var(--imagelightbox-tile-radius);
741
+ box-shadow: var(--imagelightbox-tile-shadow);
742
+ background: transparent;
743
+ }
744
+
453
745
  .image-lightbox-clip {
454
746
  position: absolute;
455
747
  inset: 0;
@@ -464,14 +756,26 @@
464
756
  transform-origin: center center;
465
757
  }
466
758
 
467
- .image-lightbox-transform img {
759
+ .image-lightbox-layer {
760
+ position: absolute;
761
+ inset: 0;
468
762
  width: 100%;
469
763
  height: 100%;
470
764
  object-fit: contain;
471
765
  object-position: center;
766
+ transform-origin: center center;
472
767
  user-select: none;
473
768
  pointer-events: none;
474
769
  display: block;
770
+ /* Incoming layer sits above the exiting one so the new image fades in over
771
+ the old; own compositor layer keeps the crossfade from flickering. */
772
+ z-index: 1;
773
+ will-change: transform, opacity;
774
+ backface-visibility: hidden;
775
+ }
776
+
777
+ .image-lightbox-layer-exit {
778
+ z-index: 0;
475
779
  }
476
780
 
477
781
  .image-lightbox-overlay {
@@ -509,6 +813,7 @@
509
813
  .image-lightbox-close {
510
814
  top: var(--space-24);
511
815
  right: var(--space-24);
816
+ /* 2.75rem = 44px, the min touch-target floor for primary nav. */
512
817
  width: 2.75rem;
513
818
  height: 2.75rem;
514
819
  display: flex;
@@ -521,6 +826,48 @@
521
826
  background: var(--imagelightbox-chrome-hover-surface);
522
827
  }
523
828
 
829
+ .image-lightbox-nav {
830
+ top: 50%;
831
+ transform: translateY(-50%);
832
+ /* 2.75rem = 44px, the min touch-target floor for primary nav. */
833
+ width: 2.75rem;
834
+ height: 2.75rem;
835
+ display: flex;
836
+ align-items: center;
837
+ justify-content: center;
838
+ cursor: pointer;
839
+ }
840
+
841
+ .image-lightbox-nav-prev {
842
+ left: var(--space-24);
843
+ }
844
+
845
+ .image-lightbox-nav-next {
846
+ right: var(--space-24);
847
+ }
848
+
849
+ .image-lightbox-nav:hover {
850
+ background: var(--imagelightbox-chrome-hover-surface);
851
+ }
852
+
853
+ .image-lightbox-counter {
854
+ position: fixed;
855
+ right: var(--space-24);
856
+ bottom: var(--space-24);
857
+ z-index: var(--z-modal);
858
+ padding: var(--space-8) var(--space-16);
859
+ background: var(--imagelightbox-chrome-surface);
860
+ border: var(--imagelightbox-chrome-border-width) solid var(--imagelightbox-chrome-border);
861
+ border-radius: var(--imagelightbox-chrome-radius);
862
+ color: var(--imagelightbox-chrome-icon);
863
+ backdrop-filter: blur(var(--blur-md));
864
+ font-family: var(--font-mono);
865
+ font-size: var(--font-size-xs);
866
+ letter-spacing: var(--letter-spacing-wider);
867
+ opacity: 0;
868
+ pointer-events: none;
869
+ }
870
+
524
871
  .image-lightbox-toolbar {
525
872
  position: fixed;
526
873
  left: 50%;