@v-office/website-headless 0.2.0 → 1.1.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,237 +1,160 @@
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. Their accessible label
19
- * follows `data-label-template` on the dots list (`{count}` images,
20
- * `{current}` shown) — the component renders the template from its copy, so
21
- * the enhancer carries no language of its own.
22
- */
23
- function initImageCarousels(selector = ".card-images") {
24
- document.querySelectorAll(selector).forEach((el) => {
25
- const track = el.querySelector("[data-img-track]");
26
- if (!track) return;
27
- if (track.dataset.imgCarouselBound === "true") return;
28
- track.dataset.imgCarouselBound = "true";
29
- const dotsList = el.querySelector("[data-dots]");
30
- const dots = dotsList ? Array.from(dotsList.querySelectorAll("li")) : [];
31
- const prevBtn = el.querySelector("[data-img-prev]");
32
- const nextBtn = el.querySelector("[data-img-next]");
33
- const total = track.children.length;
34
- if (total === 0) return;
35
- const currentIndex = () => {
36
- const w = track.clientWidth;
37
- if (w === 0) return 0;
38
- return Math.round(track.scrollLeft / w);
39
- };
40
- const updateButtons = (idx) => {
41
- if (prevBtn) prevBtn.disabled = idx <= 0;
42
- if (nextBtn) nextBtn.disabled = idx >= total - 1;
43
- };
44
- const updateDots = (idx) => {
45
- if (!dotsList || !dots.length) return;
46
- dots.forEach((d, i) => {
47
- if (i === idx) {
48
- d.className = "h-1.5 w-4 rounded-full transition-all duration-300 bg-[color:var(--color-white)]";
49
- d.setAttribute("data-active", "true");
50
- } else {
51
- d.className = "h-1.5 w-1.5 rounded-full transition-all duration-300 bg-[color:var(--color-white)]/55";
52
- d.removeAttribute("data-active");
53
- }
54
- });
55
- const template = dotsList.dataset.labelTemplate;
56
- if (template) dotsList.setAttribute("aria-label", template.replace(/\{current\}/g, String(idx + 1)).replace(/\{count\}/g, String(total)));
57
- };
58
- const goTo = (idx) => {
59
- const clamped = Math.max(0, Math.min(total - 1, idx));
60
- track.scrollLeft = clamped * track.clientWidth;
61
- updateButtons(clamped);
62
- updateDots(clamped);
63
- };
64
- if (prevBtn) prevBtn.addEventListener("click", (e) => {
65
- e.preventDefault();
66
- e.stopPropagation();
67
- goTo(currentIndex() - 1);
68
- });
69
- if (nextBtn) nextBtn.addEventListener("click", (e) => {
70
- e.preventDefault();
71
- e.stopPropagation();
72
- goTo(currentIndex() + 1);
73
- });
74
- let engaged = false;
75
- track.addEventListener("scroll", () => {
76
- if (!engaged) {
77
- engaged = true;
78
- el.classList.add("is-engaged");
79
- }
80
- const idx = currentIndex();
81
- updateButtons(idx);
82
- updateDots(idx);
83
- }, { passive: true });
84
- updateButtons(0);
85
- updateDots(0);
1
+ import { C as shareUrl, O as fillTemplate, _ as acquireFavorites, c as circleOffsets, g as FAVORITES_EVENT, n as GATE_MARKERS, r as DEFAULT_CLUSTER, t as ENHANCE_PARTS } from "../parts-CS1VBrPt.mjs";
2
+ //#region src/enhance/dom.ts
3
+ /** See {@link PartSelector}. */
4
+ const partSelector = (...parts) => parts.map((part) => `[data-part="${part}"]`).join(", ");
5
+ /** See {@link SelectAll}. */
6
+ const selectAll = (root, selector) => {
7
+ return [...root instanceof HTMLElement && root.matches(selector) ? [root] : [], ...root.querySelectorAll(selector)];
8
+ };
9
+ /** See {@link SelectOwned}. */
10
+ const selectOwned = (scope, selector, owner) => [...scope.querySelectorAll(selector)].filter((element) => element.closest(owner) === scope);
11
+ /** See {@link OnIdle}. */
12
+ const onIdle = (task) => {
13
+ if (typeof window.requestIdleCallback === "function") {
14
+ const handle = window.requestIdleCallback(task, { timeout: 500 });
15
+ return () => window.cancelIdleCallback(handle);
16
+ }
17
+ const handle = window.setTimeout(task, 0);
18
+ return () => window.clearTimeout(handle);
19
+ };
20
+ /** See {@link PrefersReducedMotion}. */
21
+ const prefersReducedMotion = () => typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
22
+ /** See {@link HasBox}. */
23
+ const hasBox = (element) => element.getClientRects().length > 0;
24
+ //#endregion
25
+ //#region src/enhance/lifecycle.ts
26
+ /** See {@link Listen}. */
27
+ const listen = (target, type, handler, options) => {
28
+ const listener = handler;
29
+ target.addEventListener(type, listener, options);
30
+ return () => target.removeEventListener(type, listener, options);
31
+ };
32
+ /** See {@link Combine}. */
33
+ const combine = (disposes) => {
34
+ let done = false;
35
+ return () => {
36
+ if (done) return;
37
+ done = true;
38
+ for (const dispose of [...disposes].reverse()) dispose();
39
+ };
40
+ };
41
+ const bound = /* @__PURE__ */ new WeakMap();
42
+ /** See {@link BindOnce}. */
43
+ const bindOnce = (key, family, create) => {
44
+ const families = bound.get(key) ?? /* @__PURE__ */ new Map();
45
+ bound.set(key, families);
46
+ const existing = families.get(family);
47
+ if (existing) return existing;
48
+ const handle = create();
49
+ const teardown = handle.dispose;
50
+ handle.dispose = combine([teardown, () => families.delete(family)]);
51
+ families.set(family, handle);
52
+ return handle;
53
+ };
54
+ /** See {@link IsBound}. */
55
+ const isBound = (key, family) => bound.get(key)?.has(family) ?? false;
56
+ //#endregion
57
+ //#region src/enhance/favorites.ts
58
+ const HEART = `[data-fav], ${partSelector(ENHANCE_PARTS.favorite)}`;
59
+ const BADGE = `[data-fav-badge], ${partSelector(ENHANCE_PARTS.favoriteBadge)}`;
60
+ /** See {@link BindFavorites}. */
61
+ const bindFavorites = (root, options) => bindOnce(root, "favorites", () => ({ dispose: createFavoritesBinding(root, options.storageKey) })).dispose;
62
+ function createFavoritesBinding(root, storageKey) {
63
+ const onChange = (change) => {
64
+ if (favorites.ids().size > 0) for (const badge of selectAll(root, BADGE)) replay(badge, "data-fav-bump", "on");
65
+ window.dispatchEvent(new CustomEvent(FAVORITES_EVENT, { detail: change }));
66
+ };
67
+ const { favorites, release } = acquireFavorites({
68
+ storageKey,
69
+ onChange
86
70
  });
87
- }
88
- const DRAG_THRESHOLD_PX = 2;
89
- function initRentalCarousel(viewport) {
90
- if (viewport.dataset.carouselBound === "true") return;
91
- viewport.dataset.carouselBound = "true";
92
- const findActiveTrack = () => viewport.querySelector(".rental-track:not([hidden])");
93
- const tabsBlock = viewport.closest("[data-rental-tabs]");
94
- const prevBtn = tabsBlock?.querySelector("[data-rental-prev]");
95
- const nextBtn = tabsBlock?.querySelector("[data-rental-next]");
96
- /**
97
- * Sync prev/next button disabled state to the current native scroll
98
- * position. The native scroll engine reports scrollLeft on every
99
- * scroll frame; we just compare it to the bounds.
100
- */
101
- const updateArrowButtons = () => {
102
- if (!prevBtn && !nextBtn) return;
103
- const max = viewport.scrollWidth - viewport.clientWidth;
104
- if (prevBtn) prevBtn.disabled = viewport.scrollLeft <= 1;
105
- if (nextBtn) nextBtn.disabled = viewport.scrollLeft >= max - 1;
106
- };
107
- /**
108
- * Step the carousel forward or back by one card. Finds the card
109
- * closest to the current scroll position, picks the neighbour in
110
- * `direction`, and asks the browser to smoothly scroll there.
111
- * scroll-snap CSS handles the snap; we just provide the target.
112
- *
113
- * Forward edge case: stepping to the last card would crop its
114
- * right side (offsetLeft places card-left at port-start, card-
115
- * right past port-end). When direction is forward AND we're
116
- * landing on the last card, scroll to the maximum scrollLeft
117
- * instead so the card is right-aligned (matches the
118
- * `:last-child { scroll-snap-align: end }` CSS rule).
119
- */
120
- const stepBy = (direction) => {
121
- const track = findActiveTrack();
122
- if (!track) return;
123
- const cards = Array.from(track.children);
124
- if (cards.length === 0) return;
125
- const cs = getComputedStyle(viewport);
126
- const padStart = parseFloat(cs.scrollPaddingInlineStart) || 0;
127
- let currentIdx = 0;
128
- let bestDist = Math.abs(cards[0].offsetLeft - padStart - viewport.scrollLeft);
129
- for (let i = 1; i < cards.length; i++) {
130
- const snapPoint = cards[i].offsetLeft - padStart;
131
- const d = Math.abs(snapPoint - viewport.scrollLeft);
132
- if (d < bestDist) {
133
- bestDist = d;
134
- currentIdx = i;
135
- }
136
- }
137
- const newIdx = Math.max(0, Math.min(cards.length - 1, currentIdx + direction));
138
- const max = viewport.scrollWidth - viewport.clientWidth;
139
- const cardTarget = cards[newIdx].offsetLeft - padStart;
140
- const target = direction > 0 && newIdx === cards.length - 1 && cardTarget < max ? max : cardTarget;
141
- viewport.scrollTo({
142
- left: target,
143
- behavior: "smooth"
144
- });
71
+ const paintAll = () => {
72
+ for (const heart of selectAll(root, HEART)) paintHeart(heart, favorites);
73
+ for (const badge of selectAll(root, BADGE)) paintBadge(badge, favorites.ids().size);
145
74
  };
146
- let isDown = false;
147
- let dragging = false;
148
- let startX = 0;
149
- let startScrollLeft = 0;
150
- let pointerId = null;
151
- let suppressNextClick = false;
152
- let suppressTimer = null;
153
- const armClickSuppressor = () => {
154
- suppressNextClick = true;
155
- if (suppressTimer !== null) window.clearTimeout(suppressTimer);
156
- suppressTimer = window.setTimeout(() => {
157
- suppressNextClick = false;
158
- suppressTimer = null;
159
- }, 350);
160
- };
161
- const onPointerMove = (e) => {
162
- if (!isDown || pointerId !== e.pointerId) return;
163
- const dx = e.clientX - startX;
164
- if (!dragging) {
165
- if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
166
- dragging = true;
167
- viewport.classList.add("is-dragging");
168
- const sel = window.getSelection?.();
169
- if (sel) sel.removeAllRanges();
75
+ const onClick = (event) => {
76
+ const heart = event.target instanceof Element ? event.target.closest(HEART) : null;
77
+ const id = heart ? heartId(heart) : null;
78
+ if (!heart || !id) return;
79
+ if (!event.defaultPrevented) {
80
+ event.preventDefault();
81
+ event.stopPropagation();
82
+ favorites.toggle(id);
170
83
  }
171
- e.preventDefault();
172
- viewport.scrollLeft = startScrollLeft - dx;
173
- };
174
- const onPointerEnd = (e) => {
175
- if (!isDown || pointerId !== e.pointerId) return;
176
- if (dragging) armClickSuppressor();
177
- document.removeEventListener("pointermove", onPointerMove);
178
- document.removeEventListener("pointerup", onPointerEnd);
179
- document.removeEventListener("pointercancel", onPointerEnd);
180
- isDown = false;
181
- dragging = false;
182
- pointerId = null;
183
- viewport.classList.remove("is-dragging");
184
- };
185
- const onPointerDown = (e) => {
186
- if (e.pointerType !== "mouse") return;
187
- if (e.button !== 0) return;
188
- isDown = true;
189
- dragging = false;
190
- startX = e.clientX;
191
- startScrollLeft = viewport.scrollLeft;
192
- pointerId = e.pointerId;
193
- document.addEventListener("pointermove", onPointerMove, { passive: false });
194
- document.addEventListener("pointerup", onPointerEnd);
195
- document.addEventListener("pointercancel", onPointerEnd);
196
- };
197
- viewport.addEventListener("pointerdown", onPointerDown);
198
- document.addEventListener("click", (e) => {
199
- if (!suppressNextClick) return;
200
- const t = e.target;
201
- if (!t || !viewport.contains(t)) return;
202
- e.preventDefault();
203
- e.stopPropagation();
204
- suppressNextClick = false;
205
- if (suppressTimer !== null) {
206
- window.clearTimeout(suppressTimer);
207
- suppressTimer = null;
84
+ replay(heart.querySelector("svg") ?? heart, "data-fav-burst", favorites.has(id) ? "save" : "remove");
85
+ };
86
+ const onStorage = (event) => {
87
+ if (event.key === storageKey) favorites.reload();
88
+ };
89
+ paintAll();
90
+ return combine([
91
+ release,
92
+ favorites.subscribe(paintAll),
93
+ listen(root, "click", onClick),
94
+ listen(window, "storage", onStorage),
95
+ paintArrivals(root, favorites)
96
+ ]);
97
+ }
98
+ /** The rental a heart belongs to: its own `data-fav-id`, else the result card around it. */
99
+ function heartId(heart) {
100
+ return heart.dataset["favId"] || heart.closest("[data-result-card]")?.getAttribute("data-result-card") || null;
101
+ }
102
+ function paintHeart(heart, favorites) {
103
+ const id = heartId(heart);
104
+ if (!id) return;
105
+ const saved = favorites.has(id);
106
+ heart.setAttribute("aria-pressed", String(saved));
107
+ heart.toggleAttribute("data-saved", saved);
108
+ }
109
+ function paintBadge(badge, count) {
110
+ badge.textContent = String(count);
111
+ if (count > 0) badge.setAttribute("data-active", "true");
112
+ else badge.removeAttribute("data-active");
113
+ const template = count === 1 ? badge.dataset["templateOne"] ?? badge.dataset["template"] : badge.dataset["template"];
114
+ if (count > 0) badge.removeAttribute("aria-hidden");
115
+ else badge.setAttribute("aria-hidden", "true");
116
+ if (count > 0 && template) badge.setAttribute("aria-label", fillTemplate(template, { count }));
117
+ else badge.removeAttribute("aria-label");
118
+ }
119
+ /** Hearts rendered after binding (an island committing its cards) get their state as they arrive. */
120
+ function paintArrivals(root, favorites) {
121
+ const target = root instanceof Document ? root.documentElement : root;
122
+ if (!(target instanceof Node)) return () => {};
123
+ const observer = new MutationObserver((records) => {
124
+ for (const record of records) for (const node of record.addedNodes) {
125
+ if (!(node instanceof HTMLElement)) continue;
126
+ for (const heart of selectAll(node, HEART)) paintHeart(heart, favorites);
127
+ for (const badge of selectAll(node, BADGE)) paintBadge(badge, favorites.ids().size);
208
128
  }
209
- }, { capture: true });
210
- viewport.addEventListener("scroll", updateArrowButtons, { passive: true });
211
- if (prevBtn) prevBtn.addEventListener("click", () => stepBy(-1));
212
- if (nextBtn) nextBtn.addEventListener("click", () => stepBy(1));
213
- viewport.addEventListener("rental-track-changed", () => {
214
- viewport.scrollLeft = 0;
215
- updateArrowButtons();
216
129
  });
217
- requestAnimationFrame(updateArrowButtons);
130
+ observer.observe(target, {
131
+ childList: true,
132
+ subtree: true
133
+ });
134
+ return () => observer.disconnect();
135
+ }
136
+ /** Restart an animation that may already run (a fast double tap answers the second press too): remove the
137
+ * attribute, flush layout, set it again; it comes off when the animation ends. Without the site's
138
+ * keyframes nothing moves, and the attribute still comes and goes. */
139
+ function replay(element, attribute, value) {
140
+ element.removeAttribute(attribute);
141
+ element.getBoundingClientRect();
142
+ element.setAttribute(attribute, value);
143
+ element.addEventListener("animationend", () => element.removeAttribute(attribute), { once: true });
218
144
  }
219
145
  //#endregion
220
- //#region src/enhance/overlay.ts
221
- /** Run `mutate` inside a view transition where the browser has one and the
222
- * visitor has not asked for less motion; run it directly otherwise. */
223
- function withViewTransition(mutate) {
146
+ //#region src/enhance/motion.ts
147
+ /** See {@link WithViewTransition}. */
148
+ const withViewTransition = (mutate) => {
224
149
  const doc = document;
225
- if (typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches || typeof doc.startViewTransition !== "function") {
150
+ if (prefersReducedMotion() || typeof doc.startViewTransition !== "function") {
226
151
  mutate();
227
152
  return;
228
153
  }
229
154
  const transition = doc.startViewTransition(mutate);
230
155
  transition.ready.catch(() => {});
231
156
  transition.finished.catch(() => {});
232
- }
233
- let lastInputWasKeyboard = false;
234
- let tracked = false;
157
+ };
235
158
  const KEYBOARD_KEYS = /* @__PURE__ */ new Set([
236
159
  "Tab",
237
160
  "Enter",
@@ -244,294 +167,1287 @@ const KEYBOARD_KEYS = /* @__PURE__ */ new Set([
244
167
  "Home",
245
168
  "End"
246
169
  ]);
247
- /** Install the page's single pair of modality listeners. Safe to call often. */
248
- function trackInputModality() {
249
- if (tracked) return;
250
- tracked = true;
251
- window.addEventListener("pointerdown", () => {
252
- lastInputWasKeyboard = false;
253
- }, { capture: true });
254
- window.addEventListener("keydown", (e) => {
255
- if (KEYBOARD_KEYS.has(e.key)) lastInputWasKeyboard = true;
256
- }, { capture: true });
257
- }
258
- /** Focus `el` only when the last real input was a keyboard one. */
259
- function focusForKeyboard(el) {
260
- if (lastInputWasKeyboard && el && typeof el.focus === "function") el.focus();
261
- }
262
- /** Bind one overlay. Returns silently when the markup is not on this page, so
263
- * a component whose overlay self-hides costs nothing. */
264
- function initHistoryOverlay(options) {
265
- const root = document.querySelector(options.root);
266
- if (!root) return;
267
- const overlay = root.querySelector(options.overlay);
268
- const scroller = root.querySelector(options.scroller);
269
- const openBtn = root.querySelector(options.open);
270
- const closeBtn = root.querySelector(options.close);
271
- if (!overlay || !scroller || !openBtn || !closeBtn) return;
272
- trackInputModality();
170
+ let keyboard = false;
171
+ let claims = 0;
172
+ let removeListeners = () => {};
173
+ /** See {@link TrackInputModality}. */
174
+ const trackInputModality = () => {
175
+ claims += 1;
176
+ if (claims === 1) removeListeners = combine([listen(window, "pointerdown", () => keyboard = false, { capture: true }), listen(window, "keydown", (event) => {
177
+ if (KEYBOARD_KEYS.has(event.key)) keyboard = true;
178
+ }, { capture: true })]);
179
+ let released = false;
180
+ return () => {
181
+ if (released) return;
182
+ released = true;
183
+ claims -= 1;
184
+ if (claims === 0) removeListeners();
185
+ };
186
+ };
187
+ /** See {@link FocusForKeyboard}. */
188
+ const focusForKeyboard = (element) => {
189
+ if (keyboard && element.isConnected) element.focus();
190
+ };
191
+ /** See {@link LastInputWasKeyboard}. */
192
+ const lastInputWasKeyboard = () => keyboard;
193
+ //#endregion
194
+ //#region src/enhance/layers.ts
195
+ const OPEN = partSelector(ENHANCE_PARTS.layerOpen);
196
+ const CLOSE = partSelector(ENHANCE_PARTS.layerClose);
197
+ const BODY = partSelector(ENHANCE_PARTS.layerBody);
198
+ /** See {@link BindLayer}. */
199
+ const bindLayer = (dialog, options = {}) => bindOnce(dialog, "layer", () => createLayer(dialog, options));
200
+ function createLayer(dialog, options) {
201
+ const name = options.name ?? dialog.id;
202
+ if (!name) throw new Error("bindLayer: the dialog needs an id or options.name (it names the URL hash).");
203
+ const hash = `#${name}`;
204
+ const scroller = options.scroller === void 0 ? dialog.querySelector(BODY) : options.scroller;
205
+ const ownsHash = () => location.hash === hash;
206
+ let open = false;
273
207
  let returnFocus = null;
274
- const openModal = () => {
275
- returnFocus = document.activeElement;
208
+ let unlock = () => {};
209
+ const show = (opener) => {
210
+ open = true;
211
+ returnFocus = opener ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
212
+ unlock = lockScroll();
276
213
  withViewTransition(() => {
277
- overlay.removeAttribute("hidden");
278
- document.body.style.overflow = "hidden";
214
+ if (!dialog.open) dialog.showModal();
215
+ settleFocus(dialog);
216
+ options.onOpen?.(opener);
279
217
  });
280
- history.pushState({ layer: options.historyKey }, "");
281
- requestAnimationFrame(() => focusForKeyboard(closeBtn));
282
218
  };
283
- const closeModalInternal = () => {
284
- withViewTransition(() => {
285
- scroller.scrollTop = 0;
286
- overlay.setAttribute("hidden", "");
287
- document.body.style.overflow = "";
219
+ const hide = (native) => {
220
+ if (!open) return;
221
+ open = false;
222
+ options.onClose?.();
223
+ if (scroller) scroller.scrollTop = 0;
224
+ if (!native) withViewTransition(() => dialog.close());
225
+ unlock();
226
+ if (returnFocus) focusForKeyboard(returnFocus);
227
+ };
228
+ const handle = {
229
+ open(opener = null) {
230
+ if (open) return;
231
+ history.pushState(history.state, "", hash);
232
+ show(opener);
233
+ },
234
+ close() {
235
+ if (!open) return;
236
+ if (ownsHash()) history.back();
237
+ else hide(false);
238
+ },
239
+ get isOpen() {
240
+ return open;
241
+ },
242
+ dispose: () => {}
243
+ };
244
+ const onDocumentClick = (event) => {
245
+ const opener = event.target instanceof Element ? event.target.closest(OPEN) : null;
246
+ if (!opener || opener.getAttribute("aria-controls") !== dialog.id) return;
247
+ event.preventDefault();
248
+ handle.open(opener);
249
+ };
250
+ const onDialogClick = (event) => {
251
+ const target = event.target;
252
+ if (target === dialog) {
253
+ if (options.closeOnBackdrop ?? true) handle.close();
254
+ return;
255
+ }
256
+ const closer = target instanceof Element ? target.closest(CLOSE) : null;
257
+ if (closer && closer.closest("dialog") === dialog) handle.close();
258
+ };
259
+ const onCancel = (event) => {
260
+ event.preventDefault();
261
+ handle.close();
262
+ };
263
+ const onNativeClose = () => {
264
+ if (!open) return;
265
+ hide(true);
266
+ if (ownsHash()) history.back();
267
+ };
268
+ const onPopState = () => {
269
+ if (ownsHash() && !open) show(null);
270
+ else if (!ownsHash() && open) hide(false);
271
+ };
272
+ const release = combine([
273
+ trackInputModality(),
274
+ listen(document, "click", onDocumentClick),
275
+ listen(dialog, "click", onDialogClick),
276
+ listen(dialog, "cancel", onCancel),
277
+ listen(dialog, "close", onNativeClose),
278
+ listen(window, "popstate", onPopState)
279
+ ]);
280
+ handle.dispose = () => {
281
+ release();
282
+ if (!open) return;
283
+ open = false;
284
+ dialog.close();
285
+ unlock();
286
+ };
287
+ if (ownsHash()) {
288
+ history.replaceState(history.state, "", location.pathname + location.search);
289
+ handle.open(null);
290
+ }
291
+ return handle;
292
+ }
293
+ /** showModal() focuses the first focusable element. Keep it for keyboard users on the first closer; a tap
294
+ * gets no focus move at all (a ring on a button nobody aimed at). */
295
+ function settleFocus(dialog) {
296
+ const closer = dialog.querySelector(CLOSE);
297
+ if (lastInputWasKeyboard()) {
298
+ if (closer) focusForKeyboard(closer);
299
+ return;
300
+ }
301
+ const focused = document.activeElement;
302
+ if (focused instanceof HTMLElement && dialog.contains(focused)) focused.blur();
303
+ }
304
+ let locks = 0;
305
+ let previousOverflow = "";
306
+ /** Lock the page's scroll while any layer is open; the Dispose releases this claim. */
307
+ function lockScroll() {
308
+ locks += 1;
309
+ if (locks === 1) {
310
+ previousOverflow = document.body.style.overflow;
311
+ document.body.style.overflow = "hidden";
312
+ }
313
+ let released = false;
314
+ return () => {
315
+ if (released) return;
316
+ released = true;
317
+ locks -= 1;
318
+ if (locks === 0) document.body.style.overflow = previousOverflow;
319
+ };
320
+ }
321
+ //#endregion
322
+ //#region src/enhance/strip.ts
323
+ /** Strip roots, the legacy kit card markup included. Internal to ./enhance. */
324
+ const STRIP_ROOT = `${partSelector(ENHANCE_PARTS.strip)}, .card-images`;
325
+ const TRACK = `${partSelector(ENHANCE_PARTS.stripTrack)}, [data-img-track]`;
326
+ const ITEM = partSelector(ENHANCE_PARTS.stripItem);
327
+ const PREV = `${partSelector(ENHANCE_PARTS.stripPrev)}, [data-img-prev]`;
328
+ const NEXT = `${partSelector(ENHANCE_PARTS.stripNext)}, [data-img-next]`;
329
+ const DOTS = `${partSelector(ENHANCE_PARTS.stripDots)}, [data-dots]`;
330
+ const COUNTER = partSelector(ENHANCE_PARTS.stripCounter);
331
+ const DEFAULT_COUNTER = "{current} / {count}";
332
+ const DRAG_THRESHOLD_PX = 2;
333
+ const CLICK_SUPPRESS_MS = 350;
334
+ /** See {@link BindStrip}. */
335
+ const bindStrip = (root, options = {}) => bindOnce(root, "strip", () => createStrip(root, settingsFor(root, options)));
336
+ function settingsFor(root, options) {
337
+ const paging = options.paging ?? (root.dataset["paging"] === "snap" ? "snap" : "page");
338
+ return {
339
+ paging,
340
+ edges: options.edges ?? (root.dataset["edges"] === "wrap" ? "wrap" : "stop"),
341
+ drag: options.drag ?? paging === "snap",
342
+ deferSlides: options.deferSlides ?? true
343
+ };
344
+ }
345
+ function createStrip(root, settings) {
346
+ const own = (selector) => selectOwned(root, selector, STRIP_ROOT)[0] ?? null;
347
+ const track = own(TRACK);
348
+ const controls = {
349
+ prev: own(PREV),
350
+ next: own(NEXT),
351
+ dots: own(DOTS),
352
+ counter: own(COUNTER)
353
+ };
354
+ const items = () => track ? visibleItems(track) : [];
355
+ const index = () => track ? currentIndex(track, items(), settings.paging) : 0;
356
+ const paint = () => {
357
+ if (!track) return;
358
+ const list = items();
359
+ const current = currentIndex(track, list, settings.paging);
360
+ for (const item of allItems(track)) item.toggleAttribute("data-current", item === list[current]);
361
+ paintArrows(controls.prev, controls.next, edgesOf(track, current, list.length, settings), settings.edges);
362
+ paintDots(controls.dots, current, list.length);
363
+ paintCounter(controls.counter, current, list.length);
364
+ };
365
+ const goTo = (target, how) => {
366
+ if (!track || track.clientWidth === 0) return;
367
+ const list = items();
368
+ if (list.length === 0) return;
369
+ const at = clamp(target, 0, list.length - 1);
370
+ const left = settings.paging === "page" ? at * track.clientWidth : snapOffsets(track, list)[at] ?? 0;
371
+ const smooth = (how ?? (settings.paging === "page" ? "instant" : "smooth")) === "smooth";
372
+ track.scrollTo({
373
+ left,
374
+ behavior: smooth && !prefersReducedMotion() ? "smooth" : "instant"
288
375
  });
289
- focusForKeyboard(returnFocus);
376
+ paint();
290
377
  };
291
- const closeModal = () => {
292
- if (history.state && history.state.layer === options.historyKey) history.back();
293
- else closeModalInternal();
378
+ const step = (delta) => {
379
+ if (!track) return;
380
+ const list = items();
381
+ if (list.length === 0) return;
382
+ const current = currentIndex(track, list, settings.paging);
383
+ const edge = edgesOf(track, current, list.length, settings);
384
+ const leaving = delta > 0 ? edge.atEnd : edge.atStart;
385
+ if (leaving && settings.edges === "stop") return;
386
+ goTo(leaving ? delta > 0 ? 0 : list.length - 1 : current + delta);
387
+ };
388
+ const handle = {
389
+ get index() {
390
+ return index();
391
+ },
392
+ get total() {
393
+ return items().length;
394
+ },
395
+ goTo,
396
+ step,
397
+ reset: () => goTo(0, "instant"),
398
+ dispose: () => {}
294
399
  };
295
- openBtn.addEventListener("click", openModal);
296
- closeBtn.addEventListener("click", closeModal);
297
- overlay.addEventListener("click", (e) => {
298
- if (e.target === overlay) closeModal();
400
+ if (!track) return handle;
401
+ const disposes = [];
402
+ const start = () => {
403
+ disposes.push(bindArrow(controls.prev, () => step(-1)), bindArrow(controls.next, () => step(1)), listen(track, "scroll", () => {
404
+ root.toggleAttribute("data-engaged", true);
405
+ paint();
406
+ }, { passive: true }));
407
+ if (settings.drag) disposes.push(bindDrag(root, track));
408
+ if (settings.deferSlides) disposes.push(bindDeferredSlides(root, track, () => upcoming(track, items(), settings.paging)));
409
+ paint();
410
+ };
411
+ if (track.children.length > 0) start();
412
+ else disposes.push(whenFilled(track, start));
413
+ handle.dispose = combine(disposes);
414
+ return handle;
415
+ }
416
+ /** The track's items, whether or not they render. */
417
+ function allItems(track) {
418
+ const marked = selectOwned(track, ITEM, TRACK);
419
+ return marked.length > 0 ? marked : [...track.children].filter((child) => child instanceof HTMLElement);
420
+ }
421
+ /** The items that render a box: a hidden tile reports offsetLeft 0 and would make an arrow dead. */
422
+ function visibleItems(track) {
423
+ return allItems(track).filter(hasBox);
424
+ }
425
+ function currentIndex(track, list, paging) {
426
+ if (list.length === 0) return 0;
427
+ if (paging === "page") {
428
+ const width = track.clientWidth;
429
+ return width === 0 ? 0 : clamp(Math.round(track.scrollLeft / width), 0, list.length - 1);
430
+ }
431
+ const offsets = snapOffsets(track, list);
432
+ const distance = (at) => Math.abs((offsets[at] ?? 0) - track.scrollLeft);
433
+ return offsets.reduce((best, _, at) => distance(at) < distance(best) ? at : best, 0);
434
+ }
435
+ /** Per item, the scroll position that puts it at the track's start, honouring the scroll padding, clamped to
436
+ * the scrollable range (the last items cannot reach the start). */
437
+ function snapOffsets(track, list) {
438
+ const padding = scrollPaddingStart(track);
439
+ const origin = track.getBoundingClientRect().left - track.scrollLeft;
440
+ const max = maxScroll(track);
441
+ return list.map((item) => clamp(item.getBoundingClientRect().left - origin - padding, 0, max));
442
+ }
443
+ const measuredPadding = /* @__PURE__ */ new WeakMap();
444
+ /** The used value of `scroll-padding-inline-start` in px. A `max()`/`calc()` value reads back unresolved,
445
+ * and parseFloat of it is NaN (cards landed hundreds of px off): such a value is measured once per value
446
+ * and track width on a probe, never on every scroll frame. */
447
+ function scrollPaddingStart(track) {
448
+ const value = getComputedStyle(track).scrollPaddingInlineStart;
449
+ if (!value || value === "auto") return 0;
450
+ if (/^-?\d+(\.\d+)?px$/.test(value)) return Number.parseFloat(value);
451
+ const key = `${value}|${track.clientWidth}`;
452
+ const cached = measuredPadding.get(track);
453
+ if (cached?.key === key) return cached.px;
454
+ const probe = document.createElement("div");
455
+ probe.style.cssText = `display:block;flex:none;height:0;visibility:hidden;width:${value}`;
456
+ track.append(probe);
457
+ const width = probe.getBoundingClientRect().width;
458
+ probe.remove();
459
+ const px = Number.isFinite(width) ? width : 0;
460
+ measuredPadding.set(track, {
461
+ key,
462
+ px
299
463
  });
300
- document.addEventListener("keydown", (e) => {
301
- if (!overlay.hasAttribute("hidden") && e.key === "Escape") {
302
- e.preventDefault();
303
- closeModal();
304
- }
464
+ return px;
465
+ }
466
+ function maxScroll(track) {
467
+ return Math.max(0, track.scrollWidth - track.clientWidth);
468
+ }
469
+ /** Where the strip stands: a page strip by its index, a snap strip by its scroll position (several items
470
+ * share the last screen, so the last index is never reached there). */
471
+ function edgesOf(track, current, total, settings) {
472
+ if (total <= 1) return {
473
+ atStart: true,
474
+ atEnd: true
475
+ };
476
+ if (settings.paging === "page") return {
477
+ atStart: current <= 0,
478
+ atEnd: current >= total - 1
479
+ };
480
+ return {
481
+ atStart: track.scrollLeft <= 1,
482
+ atEnd: track.scrollLeft >= maxScroll(track) - 1
483
+ };
484
+ }
485
+ /** The item the visitor is about to reach (one past the current), for the deferred photos. */
486
+ function upcoming(track, list, paging) {
487
+ if (paging === "page" && track.clientWidth > 0) return Math.ceil(track.scrollLeft / track.clientWidth) + 1;
488
+ return currentIndex(track, list, paging) + 1;
489
+ }
490
+ function paintArrows(prev, next, edge, edges) {
491
+ const single = edge.atStart && edge.atEnd;
492
+ setDisabled(prev, single || edges === "stop" && edge.atStart);
493
+ setDisabled(next, single || edges === "stop" && edge.atEnd);
494
+ }
495
+ function setDisabled(control, disabled) {
496
+ if (!control) return;
497
+ if (control instanceof HTMLButtonElement) control.disabled = disabled;
498
+ else control.toggleAttribute("aria-disabled", disabled);
499
+ }
500
+ function paintDots(dots, current, total) {
501
+ if (!dots) return;
502
+ [...dots.children].forEach((dot, at) => {
503
+ if (at === current) dot.setAttribute("data-active", "true");
504
+ else dot.removeAttribute("data-active");
505
+ });
506
+ const template = dots.dataset["template"];
507
+ if (template) dots.setAttribute("aria-label", fillTemplate(template, {
508
+ current: current + 1,
509
+ count: total
510
+ }));
511
+ }
512
+ function paintCounter(counter, current, total) {
513
+ if (!counter || total === 0) return;
514
+ counter.textContent = fillTemplate(counter.dataset["template"] ?? DEFAULT_COUNTER, {
515
+ current: current + 1,
516
+ count: total
305
517
  });
306
- window.addEventListener("popstate", () => {
307
- if (!overlay.hasAttribute("hidden")) closeModalInternal();
518
+ }
519
+ /** Arrows sit inside a card link: the click must step the strip and nothing else. */
520
+ function bindArrow(control, onPress) {
521
+ if (!control) return () => {};
522
+ return listen(control, "click", (event) => {
523
+ event.preventDefault();
524
+ event.stopPropagation();
525
+ onPress();
308
526
  });
309
527
  }
528
+ /** Wait for the first child (an island that fills its strip after mount), then start once. */
529
+ function whenFilled(track, start) {
530
+ const observer = new MutationObserver(() => {
531
+ if (track.children.length === 0) return;
532
+ observer.disconnect();
533
+ start();
534
+ });
535
+ observer.observe(track, { childList: true });
536
+ return () => observer.disconnect();
537
+ }
538
+ /** Mouse drag for desktop (touch and pen scroll natively). The drag stays where it is released; the
539
+ * click that ends a drag is swallowed so a stretched card link does not navigate. */
540
+ function bindDrag(root, track) {
541
+ let suppressUntil = 0;
542
+ let stopDrag = () => {};
543
+ const onPointerDown = (down) => {
544
+ if (down.pointerType !== "mouse" || down.button !== 0) return;
545
+ const startX = down.clientX;
546
+ const startLeft = track.scrollLeft;
547
+ let dragging = false;
548
+ const onMove = (move) => {
549
+ if (move.pointerId !== down.pointerId) return;
550
+ const dx = move.clientX - startX;
551
+ if (!dragging && Math.abs(dx) < DRAG_THRESHOLD_PX) return;
552
+ if (!dragging) {
553
+ dragging = true;
554
+ root.toggleAttribute("data-dragging", true);
555
+ window.getSelection()?.removeAllRanges();
556
+ }
557
+ move.preventDefault();
558
+ track.scrollLeft = startLeft - dx;
559
+ };
560
+ const onEnd = (end) => {
561
+ if (end.pointerId !== down.pointerId) return;
562
+ if (dragging) suppressUntil = performance.now() + CLICK_SUPPRESS_MS;
563
+ root.removeAttribute("data-dragging");
564
+ stopDrag();
565
+ };
566
+ stopDrag = combine([
567
+ listen(document, "pointermove", onMove, { passive: false }),
568
+ listen(document, "pointerup", onEnd),
569
+ listen(document, "pointercancel", onEnd)
570
+ ]);
571
+ };
572
+ const onClick = (event) => {
573
+ if (performance.now() > suppressUntil) return;
574
+ suppressUntil = 0;
575
+ event.preventDefault();
576
+ event.stopPropagation();
577
+ };
578
+ return combine([
579
+ listen(track, "pointerdown", onPointerDown),
580
+ listen(root, "click", onClick, { capture: true }),
581
+ () => stopDrag()
582
+ ]);
583
+ }
584
+ /** Promote deferred photos one item ahead of the visitor: a pointer over the strip, a finger on it or focus
585
+ * inside it gives the next item its source; scrolling gives every item up to one past the one arriving. */
586
+ function bindDeferredSlides(root, track, upTo) {
587
+ const promote = () => {
588
+ allItems(track).filter(hasBox).slice(0, upTo() + 1).forEach(promoteImages);
589
+ };
590
+ return combine([
591
+ listen(root, "pointerover", promote, { passive: true }),
592
+ listen(root, "touchstart", promote, { passive: true }),
593
+ listen(root, "focusin", promote),
594
+ listen(track, "scroll", promote, { passive: true })
595
+ ]);
596
+ }
597
+ /** srcset before src, so the browser picks from the set and never fetches the fallback first. The image
598
+ * keeps loading="lazy", so the browser still owns when it fetches. */
599
+ function promoteImages(item) {
600
+ for (const image of item.querySelectorAll("img[data-src]")) {
601
+ const srcset = image.dataset["srcset"];
602
+ if (srcset) image.srcset = srcset;
603
+ image.src = image.dataset["src"] ?? image.src;
604
+ image.removeAttribute("data-src");
605
+ image.removeAttribute("data-srcset");
606
+ }
607
+ }
608
+ function clamp(value, min, max) {
609
+ return Math.min(max, Math.max(min, value));
610
+ }
310
611
  //#endregion
311
612
  //#region src/enhance/gallery.ts
312
- /**
313
- * The gallery's layered overlays, as an engine the component configures.
314
- *
315
- * WHAT IT OWNS: opening and closing a stack of layers, the browser-history
316
- * entry each one pushes, the scroll lock, layered ESC, focus on open and
317
- * restore on close, the lazily-loaded photo reveal, the scroll-snap viewer's
318
- * index and its prev/next stepping, and keeping a page-level carousel on the
319
- * photo the visitor was last looking at.
320
- *
321
- * WHAT IT DECIDES: nothing visual. It states **no selector of its own** — every
322
- * one is passed in, the rule `overlay.ts` already set, so the markup and the
323
- * string that finds it stay in the same file and a grep for
324
- * `data-gallery-lightbox` still lands on the component that owns it. It sets no
325
- * class, reads no colour and renders no markup.
326
- *
327
- * WHY THE LAYERS ARE A LIST. The controller this replaces hardcoded its three
328
- * layers and their nesting order in three separate places — the `popstate`
329
- * handler, the `keydown` handler and the body-scroll restore — and opened with
330
- * `if (!lightbox || !lightboxClose || !fullscreen || !fsTrack) return`. A page
331
- * that opens the viewer straight from the photos, with no intermediate grid,
332
- * therefore got **no gallery at all** and no error saying so. That is not a
333
- * hypothetical composition: it is the obvious one for a site whose photos are
334
- * already a full-width strip. Layers are now whatever the caller lists,
335
- * outermost first, and a layer whose markup is absent is simply not bound.
336
- *
337
- * WHY IT IS A MODULE AND NOT `is:inline`. It was inline from the gallery's first
338
- * commit with no reason recorded, and the cost was stated in two places: the
339
- * four-line copy of `withViewTransition` it had to carry, and the third copy of
340
- * the input-modality listeners `overlay.ts` names in its own header. An
341
- * `is:inline` script is processed by neither Vite nor Astro and can import
342
- * nothing, so both copies were forced rather than chosen.
343
- *
344
- * The one thing that changes by importing: the emitted script crosses Vite's
345
- * `assetsInlineLimit` (4096 bytes), so Astro links it as an external module
346
- * instead of writing it into the page. Any gate that located MARKUP by
347
- * searching the built HTML was, until it read markup only, able to match the
348
- * selector literals in the inlined source — see the note in the blueprints'
349
- * `fact-check.py`. That is fixed there rather than worked around here.
350
- */
351
- function bindLazyReveal(root) {
352
- root.querySelectorAll("img[loading=\"lazy\"]").forEach((img) => {
353
- if (img.complete) img.dataset["loaded"] = "cache";
613
+ const DIALOGS = {
614
+ grid: `dialog${partSelector(ENHANCE_PARTS.galleryGrid)}`,
615
+ viewer: `dialog${partSelector(ENHANCE_PARTS.galleryViewer)}`,
616
+ floorplan: `dialog${partSelector(ENHANCE_PARTS.floorplan)}`
617
+ };
618
+ const LAYER_OPEN = partSelector(ENHANCE_PARTS.layerOpen);
619
+ const LANDING_FRAMES = 5;
620
+ /** See {@link BindGallery}. */
621
+ const bindGallery = (root) => bindOnce(root, "gallery", () => ({ dispose: createGallery(root) })).dispose;
622
+ function createGallery(root) {
623
+ const strips = [...root.querySelectorAll(STRIP_ROOT)].map((strip) => ({
624
+ element: strip,
625
+ handle: bindStrip(strip, { paging: "page" })
626
+ }));
627
+ const stripIn = (scope) => strips.find(({ element }) => element.closest("dialog") === scope)?.handle ?? null;
628
+ const pageStrip = stripIn(null);
629
+ const viewer = root.querySelector(DIALOGS.viewer);
630
+ const viewerStrip = viewer ? stripIn(viewer) : null;
631
+ const viewerTrack = viewer?.querySelector(partSelector(ENHANCE_PARTS.stripTrack)) ?? null;
632
+ const floorplan = root.querySelector(DIALOGS.floorplan);
633
+ let lastViewed = null;
634
+ const layers = /* @__PURE__ */ new Map();
635
+ for (const dialog of root.querySelectorAll(`${DIALOGS.grid}, ${DIALOGS.viewer}, ${DIALOGS.floorplan}`)) {
636
+ const viewerOptions = dialog === viewer && viewerStrip && viewerTrack ? {
637
+ onOpen: (opener) => landOn(viewerTrack, viewerStrip, photoIndex(opener) ?? pageStrip?.index ?? 0),
638
+ onClose: () => lastViewed = viewerStrip.index
639
+ } : {};
640
+ layers.set(dialog, bindLayer(dialog, viewerOptions));
641
+ }
642
+ const onViewerClosed = () => {
643
+ if (lastViewed === null) return;
644
+ pageStrip?.goTo(lastViewed, "instant");
645
+ lastViewed = null;
646
+ };
647
+ const onClick = (event) => {
648
+ const opener = openerOf(event.target, root);
649
+ if (!opener || opener.matches(LAYER_OPEN)) return;
650
+ const layer = layerFor(opener, layers);
651
+ if (!layer) return;
652
+ event.preventDefault();
653
+ layer.open(opener);
654
+ };
655
+ const onViewerKey = (event) => {
656
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
657
+ event.preventDefault();
658
+ viewerStrip?.step(event.key === "ArrowRight" ? 1 : -1);
659
+ };
660
+ return combine([
661
+ ...strips.map(({ handle }) => handle.dispose),
662
+ ...[...layers.values()].map((layer) => layer.dispose),
663
+ listen(root, "click", onClick),
664
+ viewer ? listen(viewer, "keydown", onViewerKey) : () => {},
665
+ viewer ? listen(viewer, "close", onViewerClosed) : () => {},
666
+ floorplan ? publishRatios(floorplan) : () => {},
667
+ floorplan ? warmOnIntent(root, floorplan) : () => {}
668
+ ]);
669
+ }
670
+ /** The nearest element (from a click target up to the gallery root) whose aria-controls names a dialog. */
671
+ function openerOf(target, root) {
672
+ const opener = target instanceof Element ? target.closest("[aria-controls]") : null;
673
+ return opener && root.contains(opener) ? opener : null;
674
+ }
675
+ function layerFor(opener, layers) {
676
+ const id = opener.getAttribute("aria-controls");
677
+ for (const [dialog, layer] of layers) if (dialog.id === id) return layer;
678
+ return null;
679
+ }
680
+ function photoIndex(opener) {
681
+ const value = opener?.dataset["index"];
682
+ const index = value === void 0 ? NaN : Number(value);
683
+ return Number.isInteger(index) && index >= 0 ? index : null;
684
+ }
685
+ /** The viewer may still be closed in the first frame (a view transition runs the mutation once it has its
686
+ * snapshot), and a closed track has width 0, so 0 × index lands on photo 1. Wait for a real width, and give
687
+ * up after a handful of frames rather than spin: a viewer that never lays out has a problem this cannot fix. */
688
+ function landOn(track, strip, index, attempt = 0) {
689
+ requestAnimationFrame(() => {
690
+ if (track.clientWidth === 0 && attempt < LANDING_FRAMES) {
691
+ landOn(track, strip, index, attempt + 1);
692
+ return;
693
+ }
694
+ strip.goTo(index, "instant");
354
695
  });
355
- root.classList.add("is-img-fade");
356
- root.addEventListener("load", (e) => {
357
- const img = e.target;
358
- if (img instanceof HTMLImageElement && img.loading === "lazy" && !img.dataset["loaded"]) img.dataset["loaded"] = "fade";
359
- }, true);
360
- root.addEventListener("error", (e) => {
361
- const img = e.target;
362
- if (img instanceof HTMLImageElement && img.loading === "lazy") img.dataset["loaded"] = "cache";
363
- }, true);
364
- }
365
- /** Bind one gallery. Returns silently when its root is not on this page. */
366
- function initGallery(options) {
367
- const root = document.querySelector(options.root);
368
- if (!root) return;
369
- trackInputModality();
370
- bindLazyReveal(root);
371
- const q = (sel) => sel ? root.querySelector(sel) : null;
372
- const track = q(options.track);
373
- const carousel = q(options.carousel);
374
- const counter = q(options.counter);
375
- const carouselCounter = q(options.carouselCounter);
376
- const indexAttr = options.indexAttr ?? "data-image-index";
377
- const layers = [];
378
- for (const spec of options.layers) {
379
- const panel = q(spec.panel);
380
- if (!panel) continue;
381
- layers.push({
382
- name: spec.name,
383
- panel,
384
- scroller: q(spec.scroller),
385
- backdrop: q(spec.backdrop),
386
- closeBtn: q(spec.close),
387
- openers: Array.from(root.querySelectorAll(spec.open)),
388
- isViewer: spec.opensAt === "index",
389
- warmOnIntent: spec.warmOnIntent === true,
390
- returnFocus: null
696
+ }
697
+ /** The plan's real ratio replaces the width/height guess: sizing keys on `--fp-ar`, so a guess that is off
698
+ * shows a correctly sized plan the moment the bytes arrive. */
699
+ function publishRatios(floorplan) {
700
+ const publish = (image) => {
701
+ if (image.naturalWidth && image.naturalHeight) image.style.setProperty("--fp-ar", `${image.naturalWidth}/${image.naturalHeight}`);
702
+ };
703
+ const images = [...floorplan.querySelectorAll("img")];
704
+ images.filter((image) => image.complete).forEach(publish);
705
+ return combine(images.map((image) => listen(image, "load", () => publish(image))));
706
+ }
707
+ /** The floor plan is lazy, so nobody pays for a plan never opened, but it would open onto an empty surface
708
+ * while the bytes arrive. Hover, focus and (on a touchscreen) the tap itself put it in the HTTP cache. */
709
+ function warmOnIntent(root, floorplan) {
710
+ const image = floorplan.querySelector("img");
711
+ if (!image || !floorplan.id) return () => {};
712
+ let warmed = false;
713
+ const warm = (event) => {
714
+ if (warmed || !(event.target instanceof Element)) return;
715
+ if (event.target.closest("[aria-controls]")?.getAttribute("aria-controls") !== floorplan.id) return;
716
+ warmed = true;
717
+ const preload = new Image();
718
+ if (image.srcset) preload.srcset = image.srcset;
719
+ if (image.sizes) preload.sizes = image.sizes;
720
+ preload.src = image.currentSrc || image.src;
721
+ };
722
+ return combine([listen(root, "pointerover", warm, { passive: true }), listen(root, "focusin", warm)]);
723
+ }
724
+ //#endregion
725
+ //#region src/enhance/image-fade.ts
726
+ /** See {@link BindImageFade}. */
727
+ const bindImageFade = (root) => bindOnce(root, "image-fade", () => ({ dispose: createImageFade(root) })).dispose;
728
+ function createImageFade(root) {
729
+ for (const image of root.querySelectorAll("img[loading=\"lazy\"]")) if (image.complete && (image.currentSrc || image.getAttribute("src"))) image.dataset["loaded"] = "cache";
730
+ root.toggleAttribute("data-image-fade", true);
731
+ const mark = (value) => (event) => {
732
+ const image = event.target;
733
+ if (image instanceof HTMLImageElement && image.getAttribute("loading") === "lazy" && !image.dataset["loaded"]) image.dataset["loaded"] = value;
734
+ };
735
+ return combine([
736
+ () => root.removeAttribute("data-image-fade"),
737
+ listen(root, "load", mark("fade"), { capture: true }),
738
+ listen(root, "error", mark("cache"), { capture: true })
739
+ ]);
740
+ }
741
+ //#endregion
742
+ //#region src/enhance/reveal.ts
743
+ const REVEAL = partSelector(ENHANCE_PARTS.reveal);
744
+ const CLAMP = partSelector(ENHANCE_PARTS.clamp);
745
+ /** See {@link BindReveal}. */
746
+ const bindReveal = (root, options = {}) => bindOnce(root, "reveal", () => ({ dispose: createReveal(root, options) })).dispose;
747
+ /** See {@link BindClamp}. */
748
+ const bindClamp = (root) => bindOnce(root, "clamp", () => ({ dispose: createClamp(root) })).dispose;
749
+ function createReveal(root, options) {
750
+ const own = (part) => selectOwned(root, partSelector(part), REVEAL);
751
+ const collapsedItems = own(ENHANCE_PARTS.revealItem).filter((item) => item.hidden);
752
+ const more = own(ENHANCE_PARTS.revealMore)[0] ?? null;
753
+ const less = own(ENHANCE_PARTS.revealLess)[0] ?? null;
754
+ const step = options.step ?? positiveInteger(root.dataset["step"]) ?? Number.POSITIVE_INFINITY;
755
+ const collapsible = less !== null && (options.collapsible ?? true);
756
+ const paint = () => {
757
+ const remaining = collapsedItems.some((item) => item.hidden);
758
+ const revealed = collapsedItems.some((item) => !item.hidden);
759
+ if (more) {
760
+ more.hidden = !remaining;
761
+ more.setAttribute("aria-expanded", String(!remaining));
762
+ }
763
+ if (less) less.hidden = !(collapsible && revealed);
764
+ root.toggleAttribute("data-open", revealed);
765
+ };
766
+ const showMore = () => {
767
+ const next = collapsedItems.filter((item) => item.hidden).slice(0, step);
768
+ for (const item of next) item.hidden = false;
769
+ paint();
770
+ const first = next[0];
771
+ if (first) {
772
+ if (first.tabIndex < 0 && !first.hasAttribute("tabindex")) first.tabIndex = -1;
773
+ focusForKeyboard(first);
774
+ }
775
+ };
776
+ const showLess = () => {
777
+ for (const item of collapsedItems) item.hidden = true;
778
+ paint();
779
+ if (more) focusForKeyboard(more);
780
+ };
781
+ paint();
782
+ return combine([
783
+ trackInputModality(),
784
+ more ? listen(more, "click", showMore) : () => {},
785
+ less ? listen(less, "click", showLess) : () => {}
786
+ ]);
787
+ }
788
+ function createClamp(root) {
789
+ const text = selectOwned(root, partSelector(ENHANCE_PARTS.clampText), CLAMP)[0];
790
+ const toggle = selectOwned(root, partSelector(ENHANCE_PARTS.clampToggle), CLAMP)[0];
791
+ if (!text || !toggle) return () => {};
792
+ const expanded = () => root.hasAttribute("data-expanded");
793
+ const measure = () => {
794
+ if (expanded()) return;
795
+ toggle.hidden = text.scrollHeight <= text.clientHeight + 1;
796
+ };
797
+ const onToggle = () => {
798
+ const next = !expanded();
799
+ root.toggleAttribute("data-expanded", next);
800
+ toggle.setAttribute("aria-expanded", String(next));
801
+ measure();
802
+ };
803
+ toggle.setAttribute("aria-expanded", String(expanded()));
804
+ measure();
805
+ const observer = new ResizeObserver(measure);
806
+ observer.observe(text);
807
+ return combine([listen(toggle, "click", onToggle), () => observer.disconnect()]);
808
+ }
809
+ function positiveInteger(value) {
810
+ const parsed = value === void 0 ? NaN : Number(value);
811
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
812
+ }
813
+ //#endregion
814
+ //#region src/enhance/share.ts
815
+ /** Share buttons, the legacy kit attribute included. Internal to ./enhance. */
816
+ const SHARE_BUTTON = `${partSelector(ENHANCE_PARTS.share)}, [data-share]`;
817
+ const STATUS = `${partSelector(ENHANCE_PARTS.shareStatus)}, [data-share-live]`;
818
+ const DONE_MS = 2e3;
819
+ /** See {@link BindShare}. */
820
+ const bindShare = (button, options = {}) => bindOnce(button, "share", () => ({ dispose: createShare(button, options) })).dispose;
821
+ function createShare(button, options) {
822
+ let timer = 0;
823
+ const onClick = async () => {
824
+ const url = shareUrl(location, {
825
+ origin: options.origin || button.dataset["shareOrigin"] || location.origin,
826
+ ...options.strip ? { strip: options.strip } : {}
391
827
  });
392
- if (spec.fitImage) {
393
- const img = panel.querySelector(spec.fitImage);
394
- if (img) {
395
- const applyRatio = () => {
396
- if (!img.naturalWidth || !img.naturalHeight) return;
397
- img.style.setProperty("--fp-ar", `${img.naturalWidth}/${img.naturalHeight}`);
398
- };
399
- if (img.complete) applyRatio();
400
- img.addEventListener("load", applyRatio, { once: true });
401
- }
828
+ if (prefersNativeShare() && await nativeShare(button, url)) return;
829
+ try {
830
+ await navigator.clipboard.writeText(url);
831
+ } catch {
832
+ window.prompt(button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "", url);
833
+ return;
402
834
  }
835
+ window.clearTimeout(timer);
836
+ showDone(button, true);
837
+ timer = window.setTimeout(() => showDone(button, false), DONE_MS);
838
+ };
839
+ return combine([listen(button, "click", () => void onClick()), () => {
840
+ window.clearTimeout(timer);
841
+ showDone(button, false);
842
+ }]);
843
+ }
844
+ /** The OS share sheet is the expected pattern on a phone or tablet, not with mouse and hover (even where
845
+ * `navigator.share` exists). */
846
+ function prefersNativeShare() {
847
+ return typeof navigator.share === "function" && typeof window.matchMedia === "function" && window.matchMedia("(hover: none) and (pointer: coarse)").matches;
848
+ }
849
+ /** True when the sheet handled it (shared or dismissed); false to fall back to the clipboard. */
850
+ async function nativeShare(button, url) {
851
+ try {
852
+ await navigator.share({
853
+ title: button.dataset["shareTitle"] || document.title,
854
+ url
855
+ });
856
+ return true;
857
+ } catch (error) {
858
+ return error instanceof DOMException && error.name === "AbortError";
403
859
  }
404
- if (!layers.length) return;
405
- const total = layers.find((l) => l.isViewer)?.openers.length || root.querySelectorAll(`[${indexAttr}]`).length;
406
- const isOpen = (l) => !l.panel.hasAttribute("hidden");
407
- /** Innermost open layer — the one a back gesture or ESC should close. */
408
- const innermostOpen = () => {
409
- for (let i = layers.length - 1; i >= 0; i -= 1) {
410
- const l = layers[i];
411
- if (l && isOpen(l)) return l;
412
- }
413
- return null;
860
+ }
861
+ function showDone(button, done) {
862
+ if (done) button.setAttribute("data-state", "done");
863
+ else button.removeAttribute("data-state");
864
+ const idle = button.querySelector("[data-share-idle]");
865
+ const doneLabel = button.querySelector("[data-share-done]");
866
+ if (idle) idle.hidden = done;
867
+ if (doneLabel) doneLabel.hidden = !done;
868
+ const status = button.querySelector(STATUS);
869
+ if (status) status.textContent = done ? status.dataset["message"] ?? doneLabel?.textContent?.trim() ?? "" : "";
870
+ }
871
+ //#endregion
872
+ //#region src/enhance/enhance.ts
873
+ const GALLERY = partSelector(ENHANCE_PARTS.gallery);
874
+ /** See {@link Enhance}. */
875
+ const enhance = (root = document, options = {}) => {
876
+ const disposes = [];
877
+ const each = (selector, family, bind) => {
878
+ for (const element of selectAll(root, selector)) if (!isBound(element, family)) disposes.push(bind(element));
414
879
  };
415
- const currentIdx = () => {
416
- if (!track) return 0;
417
- const w = track.clientWidth;
418
- return w === 0 ? 0 : Math.round(track.scrollLeft / w);
880
+ if (options.galleries ?? true) each(GALLERY, "gallery", bindGallery);
881
+ if (options.imageFade ?? true) each(GALLERY, "image-fade", bindImageFade);
882
+ if (options.layers ?? true) each(`dialog${partSelector(ENHANCE_PARTS.layer)}`, "layer", (dialog) => {
883
+ if (!(dialog instanceof HTMLDialogElement)) return () => {};
884
+ return bindLayer(dialog).dispose;
885
+ });
886
+ if (options.reveals ?? true) each(partSelector(ENHANCE_PARTS.reveal), "reveal", (element) => bindReveal(element));
887
+ if (options.clamps ?? true) each(partSelector(ENHANCE_PARTS.clamp), "clamp", bindClamp);
888
+ const share = options.share ?? true;
889
+ if (share !== false) each(SHARE_BUTTON, "share", (button) => bindShare(button, share === true ? {} : share));
890
+ if (options.favorites && !isBound(root, "favorites")) disposes.push(bindFavorites(root, options.favorites));
891
+ if (options.strips ?? true) disposes.push(onIdle(() => each(STRIP_ROOT, "strip", (strip) => bindStrip(strip).dispose)));
892
+ return combine(disposes);
893
+ };
894
+ //#endregion
895
+ //#region src/enhance/map/card-hover.ts
896
+ /** See {@link ObserveResultCardHover}. */
897
+ const observeResultCardHover = (onHover) => {
898
+ let current = null;
899
+ const report = (id) => {
900
+ if (id === current) return;
901
+ current = id;
902
+ onHover(id);
419
903
  };
420
- const setCounter = (el, idx) => {
421
- if (el) el.textContent = `${idx + 1} / ${total}`;
904
+ const cardOf = (target) => target instanceof Element ? target.closest("[data-result-card]")?.getAttribute("data-result-card") ?? null : null;
905
+ return combine([
906
+ listen(document, "pointerover", (event) => report(cardOf(event.target))),
907
+ listen(document, "focusin", (event) => report(cardOf(event.target))),
908
+ listen(document, "pointerout", (event) => {
909
+ if (event.relatedTarget === null) report(null);
910
+ }),
911
+ listen(document, "focusout", (event) => {
912
+ if (event.relatedTarget === null) report(null);
913
+ })
914
+ ]);
915
+ };
916
+ //#endregion
917
+ //#region src/enhance/map/maplibre.ts
918
+ let pending = null;
919
+ /** See {@link LoadMapLibre}. */
920
+ const loadMapLibre = (load) => {
921
+ pending ??= load().then((module) => "default" in module ? module.default : module, (error) => {
922
+ pending = null;
923
+ throw error;
924
+ });
925
+ return pending;
926
+ };
927
+ //#endregion
928
+ //#region src/enhance/map/surface.ts
929
+ /** Moves shorter than this between press and release are a click, on the map (`clickTolerance`) AND on a
930
+ * pin: one number in two places, because two thresholds deciding the same thing disagreed (MapLibre's
931
+ * default 3 px started a drag that carried the pin away from under the pointer). Half a pin's width:
932
+ * the blur that aiming itself has. */
933
+ const CLICK_TOLERANCE_PX = 12;
934
+ const GUEST_INPUT = [
935
+ "mousedown",
936
+ "wheel",
937
+ "touchstart",
938
+ "keydown"
939
+ ];
940
+ /** See {@link CreateMapSurface}. */
941
+ const createMapSurface = (options) => {
942
+ let map = null;
943
+ let building = null;
944
+ let destroyed = false;
945
+ let byGuest = false;
946
+ const cleanups = [];
947
+ const report = () => {
948
+ if (!map) return;
949
+ const { clientWidth, clientHeight } = options.container;
950
+ const guest = byGuest;
951
+ byGuest = false;
952
+ options.onView?.(clientWidth === 0 || clientHeight === 0 ? null : boundsOf(map), guest);
422
953
  };
423
- const openLayer = (layer, idx) => {
424
- layer.returnFocus = document.activeElement;
425
- withViewTransition(() => {
426
- layer.panel.removeAttribute("hidden");
427
- document.body.style.overflow = "hidden";
954
+ const create = async () => {
955
+ await whenWide(options.container, cleanups);
956
+ const lib = await loadMapLibre(options.load);
957
+ if (destroyed || map) return;
958
+ const created = new lib.Map({
959
+ container: options.container,
960
+ style: options.styleUrl,
961
+ scrollZoom: options.scrollZoom,
962
+ attributionControl: { compact: false },
963
+ clickTolerance: 12,
964
+ ...cameraOptions(options.camera)
428
965
  });
429
- history.pushState({ layer: `${options.root}:${layer.name}` }, "");
430
- const landOn = (attempt) => {
431
- if (!(layer.isViewer && track && typeof idx === "number")) {
432
- focusForKeyboard(layer.closeBtn);
433
- return;
434
- }
435
- const w = track.clientWidth;
436
- if (w === 0 && attempt < 5) {
437
- requestAnimationFrame(() => landOn(attempt + 1));
438
- return;
439
- }
440
- track.scrollTo({
441
- left: idx * w,
442
- behavior: "auto"
966
+ map = created;
967
+ created.once("load", () => {
968
+ if (!destroyed) options.onReady?.(created, lib);
969
+ });
970
+ created.on("moveend", report);
971
+ cleanups.push(...GUEST_INPUT.map((type) => listen(options.container, type, () => byGuest = true, { passive: true })), observeSize(options.container, report));
972
+ };
973
+ const surface = {
974
+ build: () => building ??= create(),
975
+ map: () => map,
976
+ zoomIn: () => {
977
+ byGuest = true;
978
+ map?.zoomIn();
979
+ },
980
+ zoomOut: () => {
981
+ byGuest = true;
982
+ map?.zoomOut();
983
+ },
984
+ fit: (bounds, padding, maxZoom) => {
985
+ byGuest = false;
986
+ map?.fitBounds(bounds, {
987
+ padding,
988
+ maxZoom,
989
+ animate: false
443
990
  });
444
- setCounter(counter, idx);
445
- focusForKeyboard(layer.closeBtn);
446
- };
447
- requestAnimationFrame(() => landOn(0));
991
+ },
992
+ resize: () => map?.resize(),
993
+ destroy: () => {
994
+ destroyed = true;
995
+ combine(cleanups)();
996
+ map?.remove();
997
+ map = null;
998
+ }
448
999
  };
449
- const closeLayerInternal = (layer) => {
450
- const lastIdx = layer.isViewer ? currentIdx() : null;
451
- withViewTransition(() => {
452
- if (layer.scroller) layer.scroller.scrollTop = 0;
453
- layer.panel.setAttribute("hidden", "");
454
- if (!layers.some((l) => l !== layer && isOpen(l))) document.body.style.overflow = "";
1000
+ if (options.start === "near") cleanups.push(whenNear(options.container, options.nearMargin ?? "100% 0px", () => void surface.build()));
1001
+ return surface;
1002
+ };
1003
+ /** See {@link MarkerElement}. */
1004
+ const markerElement = ({ tag, part }) => {
1005
+ const el = document.createElement(tag);
1006
+ if (el instanceof HTMLButtonElement) el.type = "button";
1007
+ const body = document.createElement("span");
1008
+ body.dataset["part"] = part;
1009
+ el.append(body);
1010
+ return {
1011
+ el,
1012
+ body
1013
+ };
1014
+ };
1015
+ /** See {@link BindActivation}. */
1016
+ const bindActivation = (element, onActivate) => {
1017
+ let start = null;
1018
+ return combine([
1019
+ listen(element, "pointerdown", (event) => {
1020
+ start = {
1021
+ x: event.clientX,
1022
+ y: event.clientY
1023
+ };
1024
+ try {
1025
+ element.setPointerCapture(event.pointerId);
1026
+ } catch {}
1027
+ }),
1028
+ listen(element, "pointercancel", () => start = null),
1029
+ listen(element, "pointerup", (event) => {
1030
+ if (!start) return;
1031
+ const moved = Math.hypot(event.clientX - start.x, event.clientY - start.y);
1032
+ start = null;
1033
+ if (moved > 12) return;
1034
+ event.stopPropagation();
1035
+ onActivate();
1036
+ }),
1037
+ listen(element, "click", (event) => {
1038
+ event.stopPropagation();
1039
+ if (event.detail === 0) onActivate();
1040
+ })
1041
+ ]);
1042
+ };
1043
+ function cameraOptions(camera) {
1044
+ if ("center" in camera) return {
1045
+ center: camera.center,
1046
+ zoom: camera.zoom
1047
+ };
1048
+ return {
1049
+ bounds: camera.bounds(),
1050
+ fitBoundsOptions: {
1051
+ padding: camera.padding,
1052
+ maxZoom: camera.maxZoom
1053
+ }
1054
+ };
1055
+ }
1056
+ function boundsOf(map) {
1057
+ const bounds = map.getBounds();
1058
+ return [[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]];
1059
+ }
1060
+ /** MapLibre measures during init and a zero-width container renders broken: wait for a width. */
1061
+ function whenWide(container, cleanups) {
1062
+ if (container.clientWidth > 0) return Promise.resolve();
1063
+ return new Promise((resolve) => {
1064
+ const observer = new ResizeObserver(() => {
1065
+ if (container.clientWidth === 0) return;
1066
+ observer.disconnect();
1067
+ resolve();
455
1068
  });
456
- if (lastIdx !== null && carousel && carousel.clientWidth > 0) {
457
- carousel.scrollTo({
458
- left: lastIdx * carousel.clientWidth,
459
- behavior: "auto"
460
- });
461
- setCounter(carouselCounter, lastIdx);
1069
+ observer.observe(container);
1070
+ cleanups.push(() => observer.disconnect());
1071
+ });
1072
+ }
1073
+ /** Run `task` once, the first time `element` comes within `margin` of the viewport. */
1074
+ function whenNear(element, margin, task) {
1075
+ const observer = new IntersectionObserver((entries) => {
1076
+ if (!entries.some((entry) => entry.isIntersecting)) return;
1077
+ observer.disconnect();
1078
+ task();
1079
+ }, { rootMargin: margin });
1080
+ observer.observe(element);
1081
+ return () => observer.disconnect();
1082
+ }
1083
+ /** Report after every size change once built (a layer closing to 0×0, a breakpoint hiding the map). */
1084
+ function observeSize(container, onChange) {
1085
+ let first = true;
1086
+ const observer = new ResizeObserver(() => {
1087
+ if (first) {
1088
+ first = false;
1089
+ return;
462
1090
  }
463
- focusForKeyboard(layer.returnFocus);
1091
+ onChange();
1092
+ });
1093
+ observer.observe(container);
1094
+ return () => observer.disconnect();
1095
+ }
1096
+ //#endregion
1097
+ //#region src/enhance/map/place-markers.ts
1098
+ /** Popup distance from the spot: above a teardrop's head, beside a dot. */
1099
+ const POPUP_OFFSET = {
1100
+ bottom: 36,
1101
+ center: 14
1102
+ };
1103
+ /** See {@link BindPlaceMarkers}. */
1104
+ const bindPlaceMarkers = (map, lib, options) => {
1105
+ let popup = null;
1106
+ let disposed = false;
1107
+ const closePopup = () => {
1108
+ popup?.remove();
1109
+ popup = null;
464
1110
  };
465
- const closeLayer = (layer) => {
466
- if (history.state && history.state.layer === `${options.root}:${layer.name}`) history.back();
467
- else closeLayerInternal(layer);
1111
+ const openPopup = (marker, text) => {
1112
+ closePopup();
1113
+ const content = document.createElement("p");
1114
+ content.dataset["part"] = "map-popup";
1115
+ content.textContent = text;
1116
+ popup = new lib.Popup({
1117
+ closeButton: false,
1118
+ closeOnClick: false,
1119
+ offset: POPUP_OFFSET[options.anchor]
1120
+ }).setLngLat(marker.at).setDOMContent(content).addTo(map);
468
1121
  };
469
- const step = (delta) => {
470
- if (!track) return;
471
- const next = Math.min(total - 1, Math.max(0, currentIdx() + delta));
472
- track.scrollTo({
473
- left: next * track.clientWidth,
474
- behavior: "smooth"
1122
+ const placed = options.markers.map((marker) => {
1123
+ const { el, body } = markerElement({
1124
+ tag: options.popup ? "button" : "div",
1125
+ part: "map-marker"
475
1126
  });
1127
+ options.render(body, marker);
1128
+ const instance = new lib.Marker({
1129
+ element: el,
1130
+ anchor: options.anchor
1131
+ }).setLngLat(marker.at).addTo(map);
1132
+ const text = options.popup?.(marker);
1133
+ const release = text ? bindActivation(el, () => openPopup(marker, text)) : () => {};
1134
+ return combine([release, () => instance.remove()]);
1135
+ });
1136
+ const onMapClick = () => {
1137
+ if (!disposed) closePopup();
476
1138
  };
477
- layers.forEach((layer) => {
478
- let warmed = false;
479
- const warm = () => {
480
- if (warmed) return;
481
- warmed = true;
482
- const img = layer.panel.querySelector("img");
483
- if (!img) return;
484
- const pre = new Image();
485
- if (img.srcset) pre.srcset = img.srcset;
486
- if (img.sizes) pre.sizes = img.sizes;
487
- pre.src = img.src;
1139
+ map.on("click", onMapClick);
1140
+ return combine([...placed, () => {
1141
+ disposed = true;
1142
+ map.off("click", onMapClick);
1143
+ closePopup();
1144
+ }]);
1145
+ };
1146
+ //#endregion
1147
+ //#region src/enhance/map/result-markers.ts
1148
+ const SOURCE = "voffice-results";
1149
+ const EMPTY = {
1150
+ type: "FeatureCollection",
1151
+ features: []
1152
+ };
1153
+ const NO_OFFSET = [0, 0];
1154
+ /** See {@link BindResultMarkers}. */
1155
+ const bindResultMarkers = (map, lib, options) => {
1156
+ let { points, layout, priced } = options;
1157
+ let expanded = null;
1158
+ let pinWidth = 0;
1159
+ let disposed = false;
1160
+ let active = null;
1161
+ const sources = /* @__PURE__ */ new Set();
1162
+ const entries = /* @__PURE__ */ new Map();
1163
+ let onScreen = [];
1164
+ const groups = () => new Map(layout.groups.map((group) => [group.key, group]));
1165
+ const publish = () => {
1166
+ const sourceId = sourceIdFor(options.cluster, priced);
1167
+ const data = featureCollection(points, layout);
1168
+ if (sources.has(sourceId)) map.getSource(sourceId)?.setData(data);
1169
+ else {
1170
+ addSource(map, sourceId, options.cluster, priced, data);
1171
+ sources.add(sourceId);
1172
+ }
1173
+ if (active && active !== sourceId) map.getSource(active)?.setData(EMPTY);
1174
+ active = sourceId;
1175
+ };
1176
+ const notify = () => {
1177
+ options.onMarkers(onScreen.flatMap((key) => entries.get(key)?.record ?? []));
1178
+ };
1179
+ const destroy = (key) => {
1180
+ const entry = entries.get(key);
1181
+ if (!entry) return;
1182
+ entry.marker.remove();
1183
+ entry.release();
1184
+ entries.delete(key);
1185
+ };
1186
+ const create = (key, wanted) => {
1187
+ const part = wanted.kind === "pin" ? "map-pin" : wanted.kind === "group" ? "map-group" : "map-cluster";
1188
+ const { el: shell, body } = markerElement({
1189
+ tag: "button",
1190
+ part
1191
+ });
1192
+ const at = wanted.kind === "group" ? wanted.group.at : wanted.at;
1193
+ const offset = wanted.kind === "pin" ? wanted.offset : NO_OFFSET;
1194
+ const marker = new lib.Marker({
1195
+ element: shell,
1196
+ offset
1197
+ }).setLngLat(at);
1198
+ if (wanted.kind === "pin") {
1199
+ body.dataset["id"] = wanted.id;
1200
+ const release = bindActivation(shell, () => options.onPin(wanted.id));
1201
+ return {
1202
+ record: {
1203
+ kind: "pin",
1204
+ key,
1205
+ id: wanted.id,
1206
+ body,
1207
+ shell
1208
+ },
1209
+ marker,
1210
+ offset: String(offset),
1211
+ release
1212
+ };
1213
+ }
1214
+ if (wanted.kind === "group") {
1215
+ const { group } = wanted;
1216
+ body.dataset["leafIds"] = group.ids.join("|");
1217
+ const release = bindActivation(shell, () => options.onGroup(group.key));
1218
+ return {
1219
+ record: {
1220
+ kind: "group",
1221
+ key,
1222
+ count: group.ids.length,
1223
+ leafIds: group.ids,
1224
+ body,
1225
+ shell
1226
+ },
1227
+ marker,
1228
+ offset: String(offset),
1229
+ release
1230
+ };
1231
+ }
1232
+ const release = bindActivation(shell, () => zoomInto(map, active, wanted.clusterId, wanted.at));
1233
+ resolveLeaves(map, active, wanted.clusterId, groups()).then((leafIds) => {
1234
+ const entry = entries.get(key);
1235
+ if (!entry || disposed || entry.record.kind === "pin") return;
1236
+ entry.record = {
1237
+ ...entry.record,
1238
+ leafIds
1239
+ };
1240
+ body.dataset["leafIds"] = leafIds.join("|");
1241
+ if (onScreen.includes(key)) notify();
1242
+ }, () => {});
1243
+ return {
1244
+ record: {
1245
+ kind: "cluster",
1246
+ key,
1247
+ count: wanted.count,
1248
+ leafIds: [],
1249
+ body,
1250
+ shell
1251
+ },
1252
+ marker,
1253
+ offset: String(offset),
1254
+ release
488
1255
  };
489
- layer.openers.forEach((t) => {
490
- if (layer.warmOnIntent) {
491
- t.addEventListener("pointerenter", warm, { once: true });
492
- t.addEventListener("focus", warm, { once: true });
1256
+ };
1257
+ const sync = () => {
1258
+ if (disposed || !active || !map.isSourceLoaded(active)) return;
1259
+ const wanted = wantedMarkers(map.querySourceFeatures(active), layout, groups(), expanded, pinWidth);
1260
+ const shown = new Set(onScreen);
1261
+ for (const [key, want] of wanted) {
1262
+ const entry = entries.get(key) ?? create(key, want);
1263
+ entries.set(key, entry);
1264
+ const offset = want.kind === "pin" ? want.offset : NO_OFFSET;
1265
+ if (entry.offset !== String(offset)) {
1266
+ entry.marker.setOffset(offset);
1267
+ entry.offset = String(offset);
493
1268
  }
494
- t.addEventListener("click", () => {
495
- const idx = layer.isViewer ? Number(t.getAttribute(indexAttr)) || 0 : void 0;
496
- openLayer(layer, idx);
1269
+ if (entry.record.kind === "group") entry.record.body.toggleAttribute("data-expanded", key === `g${expanded}`);
1270
+ if (!shown.has(key)) entry.marker.addTo(map);
1271
+ }
1272
+ const next = [...wanted.keys()];
1273
+ for (const key of shown) if (!wanted.has(key)) entries.get(key)?.marker.remove();
1274
+ const changed = next.length !== onScreen.length || next.some((key, at) => key !== onScreen[at]);
1275
+ onScreen = next;
1276
+ if (changed) notify();
1277
+ };
1278
+ publish();
1279
+ const onBackground = () => {
1280
+ if (!disposed) options.onBackground();
1281
+ };
1282
+ map.on("render", sync);
1283
+ map.on("click", onBackground);
1284
+ map.on("zoomstart", onBackground);
1285
+ return {
1286
+ update(nextPoints, nextLayout, nextPriced) {
1287
+ if (disposed) return;
1288
+ points = nextPoints;
1289
+ layout = nextLayout;
1290
+ priced = nextPriced;
1291
+ const kept = /* @__PURE__ */ new Set([...points.map((point) => `p${point.id}`), ...layout.groups.map((group) => `g${group.key}`)]);
1292
+ for (const key of entries.keys()) if (!kept.has(key)) destroy(key);
1293
+ onScreen = onScreen.filter((key) => entries.has(key));
1294
+ publish();
1295
+ sync();
1296
+ },
1297
+ expand(groupKey, pinWidthPx) {
1298
+ if (disposed) return;
1299
+ expanded = groupKey;
1300
+ pinWidth = pinWidthPx;
1301
+ const group = groupKey === null ? void 0 : groups().get(groupKey);
1302
+ if (group) map.easeTo({
1303
+ center: group.at,
1304
+ duration: 300
497
1305
  });
498
- });
499
- if (layer.closeBtn) layer.closeBtn.addEventListener("click", () => closeLayer(layer));
500
- if (layer.backdrop) layer.backdrop.addEventListener("click", (e) => {
501
- if (e.target === layer.backdrop) closeLayer(layer);
502
- });
1306
+ sync();
1307
+ },
1308
+ dispose() {
1309
+ if (disposed) return;
1310
+ disposed = true;
1311
+ map.off("render", sync);
1312
+ map.off("click", onBackground);
1313
+ map.off("zoomstart", onBackground);
1314
+ for (const key of entries.keys()) destroy(key);
1315
+ for (const sourceId of sources) map.getSource(sourceId)?.setData(EMPTY);
1316
+ onScreen = [];
1317
+ }
1318
+ };
1319
+ };
1320
+ /** One source per cluster setting (a source's clustering is fixed when it is added, and a priced set may
1321
+ * cluster wider than a set of dots). */
1322
+ function sourceIdFor(cluster, priced) {
1323
+ const settings = clusterSettings(cluster, priced);
1324
+ return settings ? `${SOURCE}-${settings.radius}-${settings.maxZoom}` : SOURCE;
1325
+ }
1326
+ function clusterSettings(cluster, priced) {
1327
+ if (cluster === false) return null;
1328
+ const radius = cluster.radius ?? DEFAULT_CLUSTER.radius;
1329
+ const maxZoom = cluster.maxZoom ?? DEFAULT_CLUSTER.maxZoom;
1330
+ if (!priced || !cluster.whenPriced) return {
1331
+ radius,
1332
+ maxZoom
1333
+ };
1334
+ return {
1335
+ radius: cluster.whenPriced.radius ?? radius,
1336
+ maxZoom: cluster.whenPriced.maxZoom ?? maxZoom
1337
+ };
1338
+ }
1339
+ function addSource(map, id, cluster, priced, data) {
1340
+ const settings = clusterSettings(cluster, priced);
1341
+ map.addSource(id, {
1342
+ type: "geojson",
1343
+ data,
1344
+ cluster: settings !== null,
1345
+ ...settings ? {
1346
+ clusterRadius: settings.radius,
1347
+ clusterMaxZoom: settings.maxZoom,
1348
+ clusterProperties: { size: ["+", ["get", "size"]] }
1349
+ } : {}
503
1350
  });
504
- const prevBtn = q(options.trackPrev);
505
- const nextBtn = q(options.trackNext);
506
- if (prevBtn) prevBtn.addEventListener("click", () => step(-1));
507
- if (nextBtn) nextBtn.addEventListener("click", () => step(1));
508
- if (track && counter) track.addEventListener("scroll", () => setCounter(counter, currentIdx()), { passive: true });
509
- if (carousel && carouselCounter) {
510
- const sync = () => {
511
- const w = carousel.clientWidth;
512
- if (w === 0) return;
513
- setCounter(carouselCounter, Math.round(carousel.scrollLeft / w));
514
- };
515
- carousel.addEventListener("scroll", sync, { passive: true });
516
- sync();
517
- }
518
- window.addEventListener("popstate", () => {
519
- const layer = innermostOpen();
520
- if (layer) closeLayerInternal(layer);
1351
+ map.addLayer({
1352
+ id: `${id}-points`,
1353
+ type: "circle",
1354
+ source: id,
1355
+ paint: {
1356
+ "circle-radius": 0,
1357
+ "circle-opacity": 0
1358
+ }
521
1359
  });
522
- document.addEventListener("keydown", (e) => {
523
- const layer = innermostOpen();
524
- if (!layer) return;
525
- if (e.key === "Escape") {
526
- e.preventDefault();
527
- closeLayer(layer);
528
- return;
1360
+ }
1361
+ /** Ungrouped points one feature each; a same-spot group one feature with its size, so a cluster that
1362
+ * swallows it counts its rentals, not its bubble. */
1363
+ function featureCollection(points, layout) {
1364
+ const grouped = new Set(layout.groups.flatMap((group) => group.ids));
1365
+ const feature = (at, properties) => ({
1366
+ type: "Feature",
1367
+ geometry: {
1368
+ type: "Point",
1369
+ coordinates: [at[0], at[1]]
1370
+ },
1371
+ properties
1372
+ });
1373
+ return {
1374
+ type: "FeatureCollection",
1375
+ features: [...points.filter((point) => !grouped.has(point.id)).map((point) => feature(point.at, {
1376
+ id: point.id,
1377
+ size: 1
1378
+ })), ...layout.groups.map((group) => feature(group.at, {
1379
+ group: group.key,
1380
+ size: group.ids.length
1381
+ }))]
1382
+ };
1383
+ }
1384
+ /** The markers the loaded features call for, keyed and deduped (a feature repeats in every tile it
1385
+ * touches). An expanded group keeps its bubble as the anchor and adds its pins on a circle around it. */
1386
+ function wantedMarkers(features, layout, groups, expanded, pinWidth) {
1387
+ const wanted = /* @__PURE__ */ new Map();
1388
+ for (const { properties, geometry } of features) {
1389
+ const at = lngLatOf(geometry.coordinates);
1390
+ if (!at) continue;
1391
+ if (properties["cluster"]) {
1392
+ const clusterId = Number(properties["cluster_id"]);
1393
+ const count = Number(properties["size"] ?? properties["point_count"]);
1394
+ wanted.set(`c${clusterId}`, {
1395
+ kind: "cluster",
1396
+ clusterId,
1397
+ at,
1398
+ count
1399
+ });
1400
+ continue;
529
1401
  }
530
- if (layer.isViewer && (e.key === "ArrowRight" || e.key === "ArrowLeft")) {
531
- e.preventDefault();
532
- step(e.key === "ArrowRight" ? 1 : -1);
1402
+ const group = typeof properties["group"] === "string" ? groups.get(properties["group"]) : void 0;
1403
+ if (group) {
1404
+ wanted.set(`g${group.key}`, {
1405
+ kind: "group",
1406
+ group
1407
+ });
1408
+ if (group.key !== expanded) continue;
1409
+ const offsets = circleOffsets(group.ids.length, pinWidth);
1410
+ group.ids.forEach((id, at) => {
1411
+ wanted.set(`p${id}`, {
1412
+ kind: "pin",
1413
+ id,
1414
+ at: group.at,
1415
+ offset: offsets[at] ?? NO_OFFSET
1416
+ });
1417
+ });
1418
+ continue;
533
1419
  }
1420
+ if (typeof properties["id"] !== "string") continue;
1421
+ const id = properties["id"];
1422
+ wanted.set(`p${id}`, {
1423
+ kind: "pin",
1424
+ id,
1425
+ at,
1426
+ offset: layout.offsets[id] ?? NO_OFFSET
1427
+ });
1428
+ }
1429
+ return wanted;
1430
+ }
1431
+ function lngLatOf(coordinates) {
1432
+ if (!Array.isArray(coordinates)) return null;
1433
+ const [lng, lat] = coordinates;
1434
+ return typeof lng === "number" && typeof lat === "number" ? [lng, lat] : null;
1435
+ }
1436
+ /** The rental ids inside a cluster (its groups' members included). */
1437
+ async function resolveLeaves(map, sourceId, clusterId, groups) {
1438
+ const source = sourceId ? map.getSource(sourceId) : void 0;
1439
+ if (!source) return [];
1440
+ return (await source.getClusterLeaves(clusterId, Number.POSITIVE_INFINITY, 0)).flatMap(({ properties }) => {
1441
+ if (typeof properties["group"] === "string") return [...groups.get(properties["group"])?.ids ?? []];
1442
+ return typeof properties["id"] === "string" ? [properties["id"]] : [];
534
1443
  });
535
1444
  }
1445
+ /** A cluster opens by easing to the zoom at which it breaks apart. */
1446
+ function zoomInto(map, sourceId, clusterId, at) {
1447
+ (sourceId ? map.getSource(sourceId) : void 0)?.getClusterExpansionZoom(clusterId).then((zoom) => map.easeTo({
1448
+ center: at,
1449
+ zoom
1450
+ }), () => {});
1451
+ }
536
1452
  //#endregion
537
- export { focusForKeyboard, initGallery, initHistoryOverlay, initImageCarousels, initRentalCarousel, trackInputModality, withViewTransition };
1453
+ export { CLICK_TOLERANCE_PX, ENHANCE_PARTS, GATE_MARKERS, bindClamp, bindFavorites, bindGallery, bindImageFade, bindLayer, bindPlaceMarkers, bindResultMarkers, bindReveal, bindShare, bindStrip, createMapSurface, enhance, focusForKeyboard, markerElement, observeResultCardHover, trackInputModality, withViewTransition };