@v-office/website-headless 0.0.1
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/README.md +86 -0
- package/dist/enhance/index.d.mts +181 -0
- package/dist/enhance/index.mjs +533 -0
- package/dist/index.d.mts +912 -0
- package/dist/index.mjs +1423 -0
- package/dist/manifest/index.d.mts +118 -0
- package/dist/manifest/index.mjs +264 -0
- package/package.json +48 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
//#region src/enhance/carousel.ts
|
|
2
|
+
/**
|
|
3
|
+
* Per-card image carousel: drives the prev/next chevrons by calling
|
|
4
|
+
* `scrollTo` on the natively-scrolling image track (overflow-x: auto
|
|
5
|
+
* with scroll-snap on the track on touch devices, or scroll-snap
|
|
6
|
+
* none + JS-driven scrollTo on hover-capable devices).
|
|
7
|
+
*
|
|
8
|
+
* Native scroll matters for two reasons:
|
|
9
|
+
* 1. Lazy-loaded `<img loading="lazy">` only triggers load when
|
|
10
|
+
* the image enters the visible viewport via real scroll. With
|
|
11
|
+
* transform-based shifting, the browser doesn't recognize the
|
|
12
|
+
* image as "in view", so subsequent images stay un-loaded
|
|
13
|
+
* (white squares) when the user clicks next.
|
|
14
|
+
* 2. The scroll-snap behavior native scroll provides on touch is
|
|
15
|
+
* what the iOS swipe momentum/snap relies on; transform-based
|
|
16
|
+
* shifting can't reproduce it.
|
|
17
|
+
*
|
|
18
|
+
* Dots are optional and only updated when present.
|
|
19
|
+
*/
|
|
20
|
+
function initImageCarousels(selector = ".card-images") {
|
|
21
|
+
document.querySelectorAll(selector).forEach((el) => {
|
|
22
|
+
const track = el.querySelector("[data-img-track]");
|
|
23
|
+
if (!track) return;
|
|
24
|
+
if (track.dataset.imgCarouselBound === "true") return;
|
|
25
|
+
track.dataset.imgCarouselBound = "true";
|
|
26
|
+
const dotsList = el.querySelector("[data-dots]");
|
|
27
|
+
const dots = dotsList ? Array.from(dotsList.querySelectorAll("li")) : [];
|
|
28
|
+
const prevBtn = el.querySelector("[data-img-prev]");
|
|
29
|
+
const nextBtn = el.querySelector("[data-img-next]");
|
|
30
|
+
const total = track.children.length;
|
|
31
|
+
if (total === 0) return;
|
|
32
|
+
const currentIndex = () => {
|
|
33
|
+
const w = track.clientWidth;
|
|
34
|
+
if (w === 0) return 0;
|
|
35
|
+
return Math.round(track.scrollLeft / w);
|
|
36
|
+
};
|
|
37
|
+
const updateButtons = (idx) => {
|
|
38
|
+
if (prevBtn) prevBtn.disabled = idx <= 0;
|
|
39
|
+
if (nextBtn) nextBtn.disabled = idx >= total - 1;
|
|
40
|
+
};
|
|
41
|
+
const updateDots = (idx) => {
|
|
42
|
+
if (!dotsList || !dots.length) return;
|
|
43
|
+
dots.forEach((d, i) => {
|
|
44
|
+
if (i === idx) {
|
|
45
|
+
d.className = "h-1.5 w-4 rounded-full transition-all duration-300 bg-[color:var(--color-white)]";
|
|
46
|
+
d.setAttribute("data-active", "true");
|
|
47
|
+
} else {
|
|
48
|
+
d.className = "h-1.5 w-1.5 rounded-full transition-all duration-300 bg-[color:var(--color-white)]/55";
|
|
49
|
+
d.removeAttribute("data-active");
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
dotsList.setAttribute("aria-label", `${total} Bilder, gerade angezeigt: ${idx + 1} von ${total}`);
|
|
53
|
+
};
|
|
54
|
+
const goTo = (idx) => {
|
|
55
|
+
const clamped = Math.max(0, Math.min(total - 1, idx));
|
|
56
|
+
track.scrollLeft = clamped * track.clientWidth;
|
|
57
|
+
updateButtons(clamped);
|
|
58
|
+
updateDots(clamped);
|
|
59
|
+
};
|
|
60
|
+
if (prevBtn) prevBtn.addEventListener("click", (e) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
e.stopPropagation();
|
|
63
|
+
goTo(currentIndex() - 1);
|
|
64
|
+
});
|
|
65
|
+
if (nextBtn) nextBtn.addEventListener("click", (e) => {
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
goTo(currentIndex() + 1);
|
|
69
|
+
});
|
|
70
|
+
let engaged = false;
|
|
71
|
+
track.addEventListener("scroll", () => {
|
|
72
|
+
if (!engaged) {
|
|
73
|
+
engaged = true;
|
|
74
|
+
el.classList.add("is-engaged");
|
|
75
|
+
}
|
|
76
|
+
const idx = currentIndex();
|
|
77
|
+
updateButtons(idx);
|
|
78
|
+
updateDots(idx);
|
|
79
|
+
}, { passive: true });
|
|
80
|
+
updateButtons(0);
|
|
81
|
+
updateDots(0);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const DRAG_THRESHOLD_PX = 2;
|
|
85
|
+
function initRentalCarousel(viewport) {
|
|
86
|
+
if (viewport.dataset.carouselBound === "true") return;
|
|
87
|
+
viewport.dataset.carouselBound = "true";
|
|
88
|
+
const findActiveTrack = () => viewport.querySelector(".rental-track:not([hidden])");
|
|
89
|
+
const tabsBlock = viewport.closest("[data-rental-tabs]");
|
|
90
|
+
const prevBtn = tabsBlock?.querySelector("[data-rental-prev]");
|
|
91
|
+
const nextBtn = tabsBlock?.querySelector("[data-rental-next]");
|
|
92
|
+
/**
|
|
93
|
+
* Sync prev/next button disabled state to the current native scroll
|
|
94
|
+
* position. The native scroll engine reports scrollLeft on every
|
|
95
|
+
* scroll frame; we just compare it to the bounds.
|
|
96
|
+
*/
|
|
97
|
+
const updateArrowButtons = () => {
|
|
98
|
+
if (!prevBtn && !nextBtn) return;
|
|
99
|
+
const max = viewport.scrollWidth - viewport.clientWidth;
|
|
100
|
+
if (prevBtn) prevBtn.disabled = viewport.scrollLeft <= 1;
|
|
101
|
+
if (nextBtn) nextBtn.disabled = viewport.scrollLeft >= max - 1;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Step the carousel forward or back by one card. Finds the card
|
|
105
|
+
* closest to the current scroll position, picks the neighbour in
|
|
106
|
+
* `direction`, and asks the browser to smoothly scroll there.
|
|
107
|
+
* scroll-snap CSS handles the snap; we just provide the target.
|
|
108
|
+
*
|
|
109
|
+
* Forward edge case: stepping to the last card would crop its
|
|
110
|
+
* right side (offsetLeft places card-left at port-start, card-
|
|
111
|
+
* right past port-end). When direction is forward AND we're
|
|
112
|
+
* landing on the last card, scroll to the maximum scrollLeft
|
|
113
|
+
* instead so the card is right-aligned (matches the
|
|
114
|
+
* `:last-child { scroll-snap-align: end }` CSS rule).
|
|
115
|
+
*/
|
|
116
|
+
const stepBy = (direction) => {
|
|
117
|
+
const track = findActiveTrack();
|
|
118
|
+
if (!track) return;
|
|
119
|
+
const cards = Array.from(track.children);
|
|
120
|
+
if (cards.length === 0) return;
|
|
121
|
+
const cs = getComputedStyle(viewport);
|
|
122
|
+
const padStart = parseFloat(cs.scrollPaddingInlineStart) || 0;
|
|
123
|
+
let currentIdx = 0;
|
|
124
|
+
let bestDist = Math.abs(cards[0].offsetLeft - padStart - viewport.scrollLeft);
|
|
125
|
+
for (let i = 1; i < cards.length; i++) {
|
|
126
|
+
const snapPoint = cards[i].offsetLeft - padStart;
|
|
127
|
+
const d = Math.abs(snapPoint - viewport.scrollLeft);
|
|
128
|
+
if (d < bestDist) {
|
|
129
|
+
bestDist = d;
|
|
130
|
+
currentIdx = i;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const newIdx = Math.max(0, Math.min(cards.length - 1, currentIdx + direction));
|
|
134
|
+
const max = viewport.scrollWidth - viewport.clientWidth;
|
|
135
|
+
const cardTarget = cards[newIdx].offsetLeft - padStart;
|
|
136
|
+
const target = direction > 0 && newIdx === cards.length - 1 && cardTarget < max ? max : cardTarget;
|
|
137
|
+
viewport.scrollTo({
|
|
138
|
+
left: target,
|
|
139
|
+
behavior: "smooth"
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
let isDown = false;
|
|
143
|
+
let dragging = false;
|
|
144
|
+
let startX = 0;
|
|
145
|
+
let startScrollLeft = 0;
|
|
146
|
+
let pointerId = null;
|
|
147
|
+
let suppressNextClick = false;
|
|
148
|
+
let suppressTimer = null;
|
|
149
|
+
const armClickSuppressor = () => {
|
|
150
|
+
suppressNextClick = true;
|
|
151
|
+
if (suppressTimer !== null) window.clearTimeout(suppressTimer);
|
|
152
|
+
suppressTimer = window.setTimeout(() => {
|
|
153
|
+
suppressNextClick = false;
|
|
154
|
+
suppressTimer = null;
|
|
155
|
+
}, 350);
|
|
156
|
+
};
|
|
157
|
+
const onPointerMove = (e) => {
|
|
158
|
+
if (!isDown || pointerId !== e.pointerId) return;
|
|
159
|
+
const dx = e.clientX - startX;
|
|
160
|
+
if (!dragging) {
|
|
161
|
+
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
|
|
162
|
+
dragging = true;
|
|
163
|
+
viewport.classList.add("is-dragging");
|
|
164
|
+
const sel = window.getSelection?.();
|
|
165
|
+
if (sel) sel.removeAllRanges();
|
|
166
|
+
}
|
|
167
|
+
e.preventDefault();
|
|
168
|
+
viewport.scrollLeft = startScrollLeft - dx;
|
|
169
|
+
};
|
|
170
|
+
const onPointerEnd = (e) => {
|
|
171
|
+
if (!isDown || pointerId !== e.pointerId) return;
|
|
172
|
+
if (dragging) armClickSuppressor();
|
|
173
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
174
|
+
document.removeEventListener("pointerup", onPointerEnd);
|
|
175
|
+
document.removeEventListener("pointercancel", onPointerEnd);
|
|
176
|
+
isDown = false;
|
|
177
|
+
dragging = false;
|
|
178
|
+
pointerId = null;
|
|
179
|
+
viewport.classList.remove("is-dragging");
|
|
180
|
+
};
|
|
181
|
+
const onPointerDown = (e) => {
|
|
182
|
+
if (e.pointerType !== "mouse") return;
|
|
183
|
+
if (e.button !== 0) return;
|
|
184
|
+
isDown = true;
|
|
185
|
+
dragging = false;
|
|
186
|
+
startX = e.clientX;
|
|
187
|
+
startScrollLeft = viewport.scrollLeft;
|
|
188
|
+
pointerId = e.pointerId;
|
|
189
|
+
document.addEventListener("pointermove", onPointerMove, { passive: false });
|
|
190
|
+
document.addEventListener("pointerup", onPointerEnd);
|
|
191
|
+
document.addEventListener("pointercancel", onPointerEnd);
|
|
192
|
+
};
|
|
193
|
+
viewport.addEventListener("pointerdown", onPointerDown);
|
|
194
|
+
document.addEventListener("click", (e) => {
|
|
195
|
+
if (!suppressNextClick) return;
|
|
196
|
+
const t = e.target;
|
|
197
|
+
if (!t || !viewport.contains(t)) return;
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
suppressNextClick = false;
|
|
201
|
+
if (suppressTimer !== null) {
|
|
202
|
+
window.clearTimeout(suppressTimer);
|
|
203
|
+
suppressTimer = null;
|
|
204
|
+
}
|
|
205
|
+
}, { capture: true });
|
|
206
|
+
viewport.addEventListener("scroll", updateArrowButtons, { passive: true });
|
|
207
|
+
if (prevBtn) prevBtn.addEventListener("click", () => stepBy(-1));
|
|
208
|
+
if (nextBtn) nextBtn.addEventListener("click", () => stepBy(1));
|
|
209
|
+
viewport.addEventListener("rental-track-changed", () => {
|
|
210
|
+
viewport.scrollLeft = 0;
|
|
211
|
+
updateArrowButtons();
|
|
212
|
+
});
|
|
213
|
+
requestAnimationFrame(updateArrowButtons);
|
|
214
|
+
}
|
|
215
|
+
//#endregion
|
|
216
|
+
//#region src/enhance/overlay.ts
|
|
217
|
+
/** Run `mutate` inside a view transition where the browser has one and the
|
|
218
|
+
* visitor has not asked for less motion; run it directly otherwise. */
|
|
219
|
+
function withViewTransition(mutate) {
|
|
220
|
+
const doc = document;
|
|
221
|
+
if (typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches || typeof doc.startViewTransition !== "function") {
|
|
222
|
+
mutate();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const transition = doc.startViewTransition(mutate);
|
|
226
|
+
transition.ready.catch(() => {});
|
|
227
|
+
transition.finished.catch(() => {});
|
|
228
|
+
}
|
|
229
|
+
let lastInputWasKeyboard = false;
|
|
230
|
+
let tracked = false;
|
|
231
|
+
const KEYBOARD_KEYS = /* @__PURE__ */ new Set([
|
|
232
|
+
"Tab",
|
|
233
|
+
"Enter",
|
|
234
|
+
" ",
|
|
235
|
+
"Escape",
|
|
236
|
+
"ArrowLeft",
|
|
237
|
+
"ArrowRight",
|
|
238
|
+
"ArrowUp",
|
|
239
|
+
"ArrowDown",
|
|
240
|
+
"Home",
|
|
241
|
+
"End"
|
|
242
|
+
]);
|
|
243
|
+
/** Install the page's single pair of modality listeners. Safe to call often. */
|
|
244
|
+
function trackInputModality() {
|
|
245
|
+
if (tracked) return;
|
|
246
|
+
tracked = true;
|
|
247
|
+
window.addEventListener("pointerdown", () => {
|
|
248
|
+
lastInputWasKeyboard = false;
|
|
249
|
+
}, { capture: true });
|
|
250
|
+
window.addEventListener("keydown", (e) => {
|
|
251
|
+
if (KEYBOARD_KEYS.has(e.key)) lastInputWasKeyboard = true;
|
|
252
|
+
}, { capture: true });
|
|
253
|
+
}
|
|
254
|
+
/** Focus `el` only when the last real input was a keyboard one. */
|
|
255
|
+
function focusForKeyboard(el) {
|
|
256
|
+
if (lastInputWasKeyboard && el && typeof el.focus === "function") el.focus();
|
|
257
|
+
}
|
|
258
|
+
/** Bind one overlay. Returns silently when the markup is not on this page, so
|
|
259
|
+
* a component whose overlay self-hides costs nothing. */
|
|
260
|
+
function initHistoryOverlay(options) {
|
|
261
|
+
const root = document.querySelector(options.root);
|
|
262
|
+
if (!root) return;
|
|
263
|
+
const overlay = root.querySelector(options.overlay);
|
|
264
|
+
const scroller = root.querySelector(options.scroller);
|
|
265
|
+
const openBtn = root.querySelector(options.open);
|
|
266
|
+
const closeBtn = root.querySelector(options.close);
|
|
267
|
+
if (!overlay || !scroller || !openBtn || !closeBtn) return;
|
|
268
|
+
trackInputModality();
|
|
269
|
+
let returnFocus = null;
|
|
270
|
+
const openModal = () => {
|
|
271
|
+
returnFocus = document.activeElement;
|
|
272
|
+
withViewTransition(() => {
|
|
273
|
+
overlay.removeAttribute("hidden");
|
|
274
|
+
document.body.style.overflow = "hidden";
|
|
275
|
+
});
|
|
276
|
+
history.pushState({ layer: options.historyKey }, "");
|
|
277
|
+
requestAnimationFrame(() => focusForKeyboard(closeBtn));
|
|
278
|
+
};
|
|
279
|
+
const closeModalInternal = () => {
|
|
280
|
+
withViewTransition(() => {
|
|
281
|
+
scroller.scrollTop = 0;
|
|
282
|
+
overlay.setAttribute("hidden", "");
|
|
283
|
+
document.body.style.overflow = "";
|
|
284
|
+
});
|
|
285
|
+
focusForKeyboard(returnFocus);
|
|
286
|
+
};
|
|
287
|
+
const closeModal = () => {
|
|
288
|
+
if (history.state && history.state.layer === options.historyKey) history.back();
|
|
289
|
+
else closeModalInternal();
|
|
290
|
+
};
|
|
291
|
+
openBtn.addEventListener("click", openModal);
|
|
292
|
+
closeBtn.addEventListener("click", closeModal);
|
|
293
|
+
overlay.addEventListener("click", (e) => {
|
|
294
|
+
if (e.target === overlay) closeModal();
|
|
295
|
+
});
|
|
296
|
+
document.addEventListener("keydown", (e) => {
|
|
297
|
+
if (!overlay.hasAttribute("hidden") && e.key === "Escape") {
|
|
298
|
+
e.preventDefault();
|
|
299
|
+
closeModal();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
window.addEventListener("popstate", () => {
|
|
303
|
+
if (!overlay.hasAttribute("hidden")) closeModalInternal();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/enhance/gallery.ts
|
|
308
|
+
/**
|
|
309
|
+
* The gallery's layered overlays, as an engine the component configures.
|
|
310
|
+
*
|
|
311
|
+
* WHAT IT OWNS: opening and closing a stack of layers, the browser-history
|
|
312
|
+
* entry each one pushes, the scroll lock, layered ESC, focus on open and
|
|
313
|
+
* restore on close, the lazily-loaded photo reveal, the scroll-snap viewer's
|
|
314
|
+
* index and its prev/next stepping, and keeping a page-level carousel on the
|
|
315
|
+
* photo the visitor was last looking at.
|
|
316
|
+
*
|
|
317
|
+
* WHAT IT DECIDES: nothing visual. It states **no selector of its own** — every
|
|
318
|
+
* one is passed in, the rule `overlay.ts` already set, so the markup and the
|
|
319
|
+
* string that finds it stay in the same file and a grep for
|
|
320
|
+
* `data-gallery-lightbox` still lands on the component that owns it. It sets no
|
|
321
|
+
* class, reads no colour and renders no markup.
|
|
322
|
+
*
|
|
323
|
+
* WHY THE LAYERS ARE A LIST. The controller this replaces hardcoded its three
|
|
324
|
+
* layers and their nesting order in three separate places — the `popstate`
|
|
325
|
+
* handler, the `keydown` handler and the body-scroll restore — and opened with
|
|
326
|
+
* `if (!lightbox || !lightboxClose || !fullscreen || !fsTrack) return`. A page
|
|
327
|
+
* that opens the viewer straight from the photos, with no intermediate grid,
|
|
328
|
+
* therefore got **no gallery at all** and no error saying so. That is not a
|
|
329
|
+
* hypothetical composition: it is the obvious one for a site whose photos are
|
|
330
|
+
* already a full-width strip. Layers are now whatever the caller lists,
|
|
331
|
+
* outermost first, and a layer whose markup is absent is simply not bound.
|
|
332
|
+
*
|
|
333
|
+
* WHY IT IS A MODULE AND NOT `is:inline`. It was inline from the gallery's first
|
|
334
|
+
* commit with no reason recorded, and the cost was stated in two places: the
|
|
335
|
+
* four-line copy of `withViewTransition` it had to carry, and the third copy of
|
|
336
|
+
* the input-modality listeners `overlay.ts` names in its own header. An
|
|
337
|
+
* `is:inline` script is processed by neither Vite nor Astro and can import
|
|
338
|
+
* nothing, so both copies were forced rather than chosen.
|
|
339
|
+
*
|
|
340
|
+
* The one thing that changes by importing: the emitted script crosses Vite's
|
|
341
|
+
* `assetsInlineLimit` (4096 bytes), so Astro links it as an external module
|
|
342
|
+
* instead of writing it into the page. Any gate that located MARKUP by
|
|
343
|
+
* searching the built HTML was, until it read markup only, able to match the
|
|
344
|
+
* selector literals in the inlined source — see the note in the blueprints'
|
|
345
|
+
* `fact-check.py`. That is fixed there rather than worked around here.
|
|
346
|
+
*/
|
|
347
|
+
function bindLazyReveal(root) {
|
|
348
|
+
root.querySelectorAll("img[loading=\"lazy\"]").forEach((img) => {
|
|
349
|
+
if (img.complete) img.dataset["loaded"] = "cache";
|
|
350
|
+
});
|
|
351
|
+
root.classList.add("is-img-fade");
|
|
352
|
+
root.addEventListener("load", (e) => {
|
|
353
|
+
const img = e.target;
|
|
354
|
+
if (img instanceof HTMLImageElement && img.loading === "lazy" && !img.dataset["loaded"]) img.dataset["loaded"] = "fade";
|
|
355
|
+
}, true);
|
|
356
|
+
root.addEventListener("error", (e) => {
|
|
357
|
+
const img = e.target;
|
|
358
|
+
if (img instanceof HTMLImageElement && img.loading === "lazy") img.dataset["loaded"] = "cache";
|
|
359
|
+
}, true);
|
|
360
|
+
}
|
|
361
|
+
/** Bind one gallery. Returns silently when its root is not on this page. */
|
|
362
|
+
function initGallery(options) {
|
|
363
|
+
const root = document.querySelector(options.root);
|
|
364
|
+
if (!root) return;
|
|
365
|
+
trackInputModality();
|
|
366
|
+
bindLazyReveal(root);
|
|
367
|
+
const q = (sel) => sel ? root.querySelector(sel) : null;
|
|
368
|
+
const track = q(options.track);
|
|
369
|
+
const carousel = q(options.carousel);
|
|
370
|
+
const counter = q(options.counter);
|
|
371
|
+
const carouselCounter = q(options.carouselCounter);
|
|
372
|
+
const indexAttr = options.indexAttr ?? "data-image-index";
|
|
373
|
+
const layers = [];
|
|
374
|
+
for (const spec of options.layers) {
|
|
375
|
+
const panel = q(spec.panel);
|
|
376
|
+
if (!panel) continue;
|
|
377
|
+
layers.push({
|
|
378
|
+
name: spec.name,
|
|
379
|
+
panel,
|
|
380
|
+
scroller: q(spec.scroller),
|
|
381
|
+
backdrop: q(spec.backdrop),
|
|
382
|
+
closeBtn: q(spec.close),
|
|
383
|
+
openers: Array.from(root.querySelectorAll(spec.open)),
|
|
384
|
+
isViewer: spec.opensAt === "index",
|
|
385
|
+
warmOnIntent: spec.warmOnIntent === true,
|
|
386
|
+
returnFocus: null
|
|
387
|
+
});
|
|
388
|
+
if (spec.fitImage) {
|
|
389
|
+
const img = panel.querySelector(spec.fitImage);
|
|
390
|
+
if (img) {
|
|
391
|
+
const applyRatio = () => {
|
|
392
|
+
if (!img.naturalWidth || !img.naturalHeight) return;
|
|
393
|
+
img.style.setProperty("--fp-ar", `${img.naturalWidth}/${img.naturalHeight}`);
|
|
394
|
+
};
|
|
395
|
+
if (img.complete) applyRatio();
|
|
396
|
+
img.addEventListener("load", applyRatio, { once: true });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!layers.length) return;
|
|
401
|
+
const total = layers.find((l) => l.isViewer)?.openers.length || root.querySelectorAll(`[${indexAttr}]`).length;
|
|
402
|
+
const isOpen = (l) => !l.panel.hasAttribute("hidden");
|
|
403
|
+
/** Innermost open layer — the one a back gesture or ESC should close. */
|
|
404
|
+
const innermostOpen = () => {
|
|
405
|
+
for (let i = layers.length - 1; i >= 0; i -= 1) {
|
|
406
|
+
const l = layers[i];
|
|
407
|
+
if (l && isOpen(l)) return l;
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
};
|
|
411
|
+
const currentIdx = () => {
|
|
412
|
+
if (!track) return 0;
|
|
413
|
+
const w = track.clientWidth;
|
|
414
|
+
return w === 0 ? 0 : Math.round(track.scrollLeft / w);
|
|
415
|
+
};
|
|
416
|
+
const setCounter = (el, idx) => {
|
|
417
|
+
if (el) el.textContent = `${idx + 1} / ${total}`;
|
|
418
|
+
};
|
|
419
|
+
const openLayer = (layer, idx) => {
|
|
420
|
+
layer.returnFocus = document.activeElement;
|
|
421
|
+
withViewTransition(() => {
|
|
422
|
+
layer.panel.removeAttribute("hidden");
|
|
423
|
+
document.body.style.overflow = "hidden";
|
|
424
|
+
});
|
|
425
|
+
history.pushState({ layer: `${options.root}:${layer.name}` }, "");
|
|
426
|
+
const landOn = (attempt) => {
|
|
427
|
+
if (!(layer.isViewer && track && typeof idx === "number")) {
|
|
428
|
+
focusForKeyboard(layer.closeBtn);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const w = track.clientWidth;
|
|
432
|
+
if (w === 0 && attempt < 5) {
|
|
433
|
+
requestAnimationFrame(() => landOn(attempt + 1));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
track.scrollTo({
|
|
437
|
+
left: idx * w,
|
|
438
|
+
behavior: "auto"
|
|
439
|
+
});
|
|
440
|
+
setCounter(counter, idx);
|
|
441
|
+
focusForKeyboard(layer.closeBtn);
|
|
442
|
+
};
|
|
443
|
+
requestAnimationFrame(() => landOn(0));
|
|
444
|
+
};
|
|
445
|
+
const closeLayerInternal = (layer) => {
|
|
446
|
+
const lastIdx = layer.isViewer ? currentIdx() : null;
|
|
447
|
+
withViewTransition(() => {
|
|
448
|
+
if (layer.scroller) layer.scroller.scrollTop = 0;
|
|
449
|
+
layer.panel.setAttribute("hidden", "");
|
|
450
|
+
if (!layers.some((l) => l !== layer && isOpen(l))) document.body.style.overflow = "";
|
|
451
|
+
});
|
|
452
|
+
if (lastIdx !== null && carousel && carousel.clientWidth > 0) {
|
|
453
|
+
carousel.scrollTo({
|
|
454
|
+
left: lastIdx * carousel.clientWidth,
|
|
455
|
+
behavior: "auto"
|
|
456
|
+
});
|
|
457
|
+
setCounter(carouselCounter, lastIdx);
|
|
458
|
+
}
|
|
459
|
+
focusForKeyboard(layer.returnFocus);
|
|
460
|
+
};
|
|
461
|
+
const closeLayer = (layer) => {
|
|
462
|
+
if (history.state && history.state.layer === `${options.root}:${layer.name}`) history.back();
|
|
463
|
+
else closeLayerInternal(layer);
|
|
464
|
+
};
|
|
465
|
+
const step = (delta) => {
|
|
466
|
+
if (!track) return;
|
|
467
|
+
const next = Math.min(total - 1, Math.max(0, currentIdx() + delta));
|
|
468
|
+
track.scrollTo({
|
|
469
|
+
left: next * track.clientWidth,
|
|
470
|
+
behavior: "smooth"
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
layers.forEach((layer) => {
|
|
474
|
+
let warmed = false;
|
|
475
|
+
const warm = () => {
|
|
476
|
+
if (warmed) return;
|
|
477
|
+
warmed = true;
|
|
478
|
+
const img = layer.panel.querySelector("img");
|
|
479
|
+
if (!img) return;
|
|
480
|
+
const pre = new Image();
|
|
481
|
+
if (img.srcset) pre.srcset = img.srcset;
|
|
482
|
+
if (img.sizes) pre.sizes = img.sizes;
|
|
483
|
+
pre.src = img.src;
|
|
484
|
+
};
|
|
485
|
+
layer.openers.forEach((t) => {
|
|
486
|
+
if (layer.warmOnIntent) {
|
|
487
|
+
t.addEventListener("pointerenter", warm, { once: true });
|
|
488
|
+
t.addEventListener("focus", warm, { once: true });
|
|
489
|
+
}
|
|
490
|
+
t.addEventListener("click", () => {
|
|
491
|
+
const idx = layer.isViewer ? Number(t.getAttribute(indexAttr)) || 0 : void 0;
|
|
492
|
+
openLayer(layer, idx);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
if (layer.closeBtn) layer.closeBtn.addEventListener("click", () => closeLayer(layer));
|
|
496
|
+
if (layer.backdrop) layer.backdrop.addEventListener("click", (e) => {
|
|
497
|
+
if (e.target === layer.backdrop) closeLayer(layer);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
const prevBtn = q(options.trackPrev);
|
|
501
|
+
const nextBtn = q(options.trackNext);
|
|
502
|
+
if (prevBtn) prevBtn.addEventListener("click", () => step(-1));
|
|
503
|
+
if (nextBtn) nextBtn.addEventListener("click", () => step(1));
|
|
504
|
+
if (track && counter) track.addEventListener("scroll", () => setCounter(counter, currentIdx()), { passive: true });
|
|
505
|
+
if (carousel && carouselCounter) {
|
|
506
|
+
const sync = () => {
|
|
507
|
+
const w = carousel.clientWidth;
|
|
508
|
+
if (w === 0) return;
|
|
509
|
+
setCounter(carouselCounter, Math.round(carousel.scrollLeft / w));
|
|
510
|
+
};
|
|
511
|
+
carousel.addEventListener("scroll", sync, { passive: true });
|
|
512
|
+
sync();
|
|
513
|
+
}
|
|
514
|
+
window.addEventListener("popstate", () => {
|
|
515
|
+
const layer = innermostOpen();
|
|
516
|
+
if (layer) closeLayerInternal(layer);
|
|
517
|
+
});
|
|
518
|
+
document.addEventListener("keydown", (e) => {
|
|
519
|
+
const layer = innermostOpen();
|
|
520
|
+
if (!layer) return;
|
|
521
|
+
if (e.key === "Escape") {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
closeLayer(layer);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (layer.isViewer && (e.key === "ArrowRight" || e.key === "ArrowLeft")) {
|
|
527
|
+
e.preventDefault();
|
|
528
|
+
step(e.key === "ArrowRight" ? 1 : -1);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
//#endregion
|
|
533
|
+
export { focusForKeyboard, initGallery, initHistoryOverlay, initImageCarousels, initRentalCarousel, trackInputModality, withViewTransition };
|