@bytebrand/fe-ui-core-autobahn 1.0.126 → 1.0.129
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/compat/Checkbox/Checkbox.tsx +191 -191
- package/package.json +1 -1
- package/theme/md3.css +29 -0
- package/ui/CardCarousel.tsx +284 -22
- package/vdp/FinanceCalculator.tsx +44 -36
- package/vdp/VdpViewer.tsx +351 -78
package/ui/CardCarousel.tsx
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// Spring constants for the track's slide animation — critically damped (damping = 2·√stiffness,
|
|
4
|
+
// so it approaches the target with no overshoot/bounce, matching what sampling mobile.de's own
|
|
5
|
+
// VDP gallery showed — see the spring loop's doc comment below) and tuned so a single ±1 move
|
|
6
|
+
// settles in ~800-850ms, matching mobile.de's measured settle time for the same move.
|
|
7
|
+
const SPRING_STIFFNESS = 64;
|
|
8
|
+
const SPRING_DAMPING = 16;
|
|
9
|
+
// Below these thresholds the spring is considered "arrived": stop stepping it (saves CPU while
|
|
10
|
+
// idle instead of running a rAF loop forever) and snap the last fractional pixel so the resting
|
|
11
|
+
// position is exact, not just "close enough". Both are small enough that the final snap is
|
|
12
|
+
// sub-perceptible (0.015 of a slide's width is single-digit pixels at any real card size, at a
|
|
13
|
+
// point where residual velocity is already crawling) — chosen by MEASURING actual settle time
|
|
14
|
+
// live and tuning until it landed at mobile.de's own measured ~800-850ms, rather than picking
|
|
15
|
+
// numbers that merely looked reasonable: 0.001/0.01 both measured over 1s to hit in practice
|
|
16
|
+
// (VELOCITY decays slower than position for a critically damped spring, so it — not position —
|
|
17
|
+
// ends up the binding constraint), well past the ~800ms the motion is already imperceptible.
|
|
18
|
+
const SETTLE_POS_EPS = 0.015;
|
|
19
|
+
const SETTLE_VEL_EPS = 0.09;
|
|
2
20
|
|
|
3
21
|
export interface CardCarouselProps {
|
|
4
22
|
/** ready-to-use image URLs (already resolved by the caller — no asset/window lookup here). */
|
|
@@ -34,7 +52,6 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
|
|
|
34
52
|
fit = 'cover',
|
|
35
53
|
padded = true,
|
|
36
54
|
}) => {
|
|
37
|
-
const [i, setI] = useState(0);
|
|
38
55
|
const n = images.length;
|
|
39
56
|
// MOBX-CQ-2: reset to the first slide whenever the caller hands us a
|
|
40
57
|
// DIFFERENT gallery (this instance reused for another car) — otherwise a
|
|
@@ -44,17 +61,242 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
|
|
|
44
61
|
// every arrow click whose onChange triggered a parent re-render (the
|
|
45
62
|
// counter advanced while the track snapped back to slide 0).
|
|
46
63
|
const gallerySignature = images.join('|');
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
// Loop-wrap (last→first / first→last): the flex row gets one extra CLONE slide at each
|
|
65
|
+
// end — clone of the LAST photo before the first, clone of the FIRST photo after the last
|
|
66
|
+
// — so a swipe/click past either edge keeps animating in the SAME direction into a
|
|
67
|
+
// real-looking next photo (mobile.de's own VDP gallery does the same) instead of jump-
|
|
68
|
+
// cutting or animating backward through every photo in between.
|
|
69
|
+
const extendedImages = n > 1 ? [images[n - 1], ...images, images[0]] : images;
|
|
70
|
+
// `trackIndex` (0..n+1, extended-array units; real slides live at 1..n) is the spring's
|
|
71
|
+
// TARGET and the only piece of animation state React itself renders from — used below to
|
|
72
|
+
// derive `i` (0..n-1, the "real" active slide for dots/onChange/the photo-count pill).
|
|
73
|
+
const [trackIndex, setTrackIndex] = useState(n > 1 ? 1 : 0);
|
|
74
|
+
// Mirrors `trackIndex`, updated SYNCHRONOUSLY inside go()/drag itself — not via React state,
|
|
75
|
+
// which only becomes readable to the NEXT interaction after a render has committed. Reading
|
|
76
|
+
// this (not the closured `trackIndex` state, and not a functional setState updater) is what
|
|
77
|
+
// makes go() correct no matter how soon after the last call it runs — see go()'s doc comment.
|
|
78
|
+
const trackIndexRef = useRef(trackIndex);
|
|
79
|
+
// The ANIMATED position, same 0..n+1 units as trackIndex but a continuous float — a ref, not
|
|
80
|
+
// state: the spring loop below writes it up to 60×/sec straight into the DOM via trackRef,
|
|
81
|
+
// never through React's render cycle (far too slow for smooth 60fps motion, and it would
|
|
82
|
+
// fight with the loop's own direct writes if React re-rendered the same style prop).
|
|
83
|
+
const visualPosRef = useRef(trackIndex);
|
|
84
|
+
const velocityRef = useRef(0);
|
|
85
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
86
|
+
const rafIdRef = useRef<number | null>(null);
|
|
87
|
+
// `n` read fresh inside the spring loop's closure would go stale if the gallery changes while
|
|
88
|
+
// a loop iteration is still in flight (a reused SRP card instance recycled mid-animation) —
|
|
89
|
+
// the gallery-reset effect below already forces a fresh start in that case, but mirroring `n`
|
|
90
|
+
// into a ref too costs nothing and removes the edge case outright.
|
|
91
|
+
const nRef = useRef(n);
|
|
92
|
+
nRef.current = n;
|
|
93
|
+
|
|
94
|
+
// Plain modulo, not clamped into [0, n+1] first: trackIndex is intentionally UNBOUNDED (see
|
|
95
|
+
// go()'s doc comment) — several rapid clicks can leave it well past a single clone-width
|
|
96
|
+
// before the spring loop's own rebase (below) has had a chance to catch up, and modulo maps
|
|
97
|
+
// ANY integer straight to the right real slide regardless, so `i`/the dots/the photo-count
|
|
98
|
+
// pill are correct the instant a click fires, never waiting on the animation to catch up.
|
|
99
|
+
const i = n > 1 ? (((trackIndex - 1) % n) + n) % n : 0;
|
|
100
|
+
|
|
101
|
+
const writeTransform = (pos: number) => {
|
|
102
|
+
const el = trackRef.current;
|
|
103
|
+
if (el) el.style.transform = `translateX(${(-pos * 100).toFixed(4)}%)`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// The spring loop: steps visualPosRef toward trackIndexRef every frame via real spring-damper
|
|
107
|
+
// physics (F = -k·x - c·v), not a fixed-duration easing curve. That's the whole reason this
|
|
108
|
+
// component moved off a CSS transition: a CSS transition RESTARTS from zero velocity on every
|
|
109
|
+
// RETARGET (a new target set before the old transition finishes — arrows don't debounce, so
|
|
110
|
+
// this happens on anything faster than one click per ~800ms), and this curve's fast start
|
|
111
|
+
// meant every retarget produced a visible little "kick" of re-acceleration — read as jitter,
|
|
112
|
+
// a wrong-direction flicker, or an uneven stutter under fast repeat clicking (confirmed live:
|
|
113
|
+
// sampling the track's transform frame-by-frame during a burst of real clicks showed exactly
|
|
114
|
+
// this). A spring's velocity is a real, continuous state variable carried across retargets —
|
|
115
|
+
// clicking again before the previous move settles blends smoothly into the new target instead
|
|
116
|
+
// of restarting. This also matches mobile.de's own gallery: sampling ITS transform earlier
|
|
117
|
+
// showed a front-loaded, no-overshoot, ~800-900ms settle that a fixed-duration bezier curve
|
|
118
|
+
// could only approximate for an ISOLATED move — under retargeting a spring is what actually
|
|
119
|
+
// reproduces it, not just the single-move numbers.
|
|
120
|
+
const stepLoop = (lastT: number) => {
|
|
121
|
+
rafIdRef.current = requestAnimationFrame((now) => {
|
|
122
|
+
const dt = Math.min((now - lastT) / 1000, 1 / 30); // cap dt so a lag spike can't overshoot
|
|
123
|
+
const n2 = nRef.current;
|
|
124
|
+
const accel = SPRING_STIFFNESS * (trackIndexRef.current - visualPosRef.current) - SPRING_DAMPING * velocityRef.current;
|
|
125
|
+
velocityRef.current += accel * dt;
|
|
126
|
+
visualPosRef.current += velocityRef.current * dt;
|
|
127
|
+
|
|
128
|
+
// Loop-wrap rebase: whenever the ANIMATED position itself crosses a clone boundary,
|
|
129
|
+
// invisibly shift BOTH trackIndex and visualPos by ∓n into the matching REAL position —
|
|
130
|
+
// same photo either side, so each shift is imperceptible — keeping visualPos inside the
|
|
131
|
+
// single-clone-wide extended array this exact frame. Triggered by visualPos crossing 0 /
|
|
132
|
+
// n+1, NOT by trackIndex reaching them: trackIndex (the spring's TARGET) is intentionally
|
|
133
|
+
// UNBOUNDED — go() below no longer clamps it — so several clicks landing faster than the
|
|
134
|
+
// spring can visually catch up leave it arbitrarily far past a single clone-width. An
|
|
135
|
+
// earlier version rebased the moment the TARGET reached a boundary, shifting visualPos by
|
|
136
|
+
// the same ∓n regardless of where it actually was — correct ONLY if visualPos had already
|
|
137
|
+
// caught up to that boundary too; under a rapid-click burst it usually hadn't (confirmed
|
|
138
|
+
// live: sampling mid-burst caught visualPos at position ~2.9 of 4 getting yanked to ~-0.1
|
|
139
|
+
// by a rebase meant for position 4), producing exactly the kind of jarring cross-carousel
|
|
140
|
+
// jump this whole rewrite exists to eliminate. Rebasing on visualPos's own crossing avoids
|
|
141
|
+
// that by construction: it only ever fires within one frame's worth of motion of the
|
|
142
|
+
// boundary, however far past it trackIndex itself has drifted. A `while`, not `if`, so an
|
|
143
|
+
// extreme burst (or a large dt from a lag spike) rebases as many times in one frame as
|
|
144
|
+
// visualPos has actually crossed — real motion the array can only render one clone-width
|
|
145
|
+
// of at a time, not something to skip over in a single jump.
|
|
146
|
+
while (n2 > 1 && visualPosRef.current >= n2 + 1) {
|
|
147
|
+
trackIndexRef.current -= n2;
|
|
148
|
+
visualPosRef.current -= n2;
|
|
149
|
+
setTrackIndex(trackIndexRef.current);
|
|
150
|
+
}
|
|
151
|
+
while (n2 > 1 && visualPosRef.current <= 0) {
|
|
152
|
+
trackIndexRef.current += n2;
|
|
153
|
+
visualPosRef.current += n2;
|
|
154
|
+
setTrackIndex(trackIndexRef.current);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
writeTransform(visualPosRef.current);
|
|
158
|
+
|
|
159
|
+
const atRest = Math.abs(trackIndexRef.current - visualPosRef.current) < SETTLE_POS_EPS && Math.abs(velocityRef.current) < SETTLE_VEL_EPS;
|
|
160
|
+
if (atRest) {
|
|
161
|
+
visualPosRef.current = trackIndexRef.current;
|
|
162
|
+
velocityRef.current = 0;
|
|
163
|
+
writeTransform(visualPosRef.current);
|
|
164
|
+
rafIdRef.current = null;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
stepLoop(now);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const ensureLoopRunning = () => {
|
|
171
|
+
if (rafIdRef.current == null) stepLoop(performance.now());
|
|
172
|
+
};
|
|
173
|
+
useEffect(
|
|
174
|
+
() => () => {
|
|
175
|
+
if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current);
|
|
176
|
+
},
|
|
177
|
+
[],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Gallery-swap reset — instant, no spring: jump straight to slide 0 with zero velocity so a
|
|
181
|
+
// reused instance (SRP list) never slides through a stale gallery's photos. useLayoutEffect
|
|
182
|
+
// (not useEffect) so the DOM is corrected before paint — no one-frame flash of the old
|
|
183
|
+
// position with the new gallery's images already swapped in underneath it.
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
if (rafIdRef.current != null) {
|
|
186
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
187
|
+
rafIdRef.current = null;
|
|
188
|
+
}
|
|
189
|
+
const start = n > 1 ? 1 : 0;
|
|
190
|
+
trackIndexRef.current = start;
|
|
191
|
+
visualPosRef.current = start;
|
|
192
|
+
velocityRef.current = 0;
|
|
193
|
+
setTrackIndex(start);
|
|
194
|
+
writeTransform(start);
|
|
195
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
196
|
}, [gallerySignature]);
|
|
50
|
-
|
|
197
|
+
// Fires onChange whenever the DERIVED active slide actually changes — not on every
|
|
198
|
+
// trackIndex change, so the loop-wrap's snap-back (n+1→1 / 0→n, same photo either side)
|
|
199
|
+
// doesn't re-fire it. Reads onChange through a ref instead of listing it as a dependency:
|
|
200
|
+
// the VDP hands in a fresh inline arrow function every render, and depending on it directly
|
|
201
|
+
// would re-fire this effect (with the SAME `i`) on every unrelated parent re-render instead
|
|
202
|
+
// of only on an actual slide change.
|
|
203
|
+
const onChangeRef = useRef(onChange);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
onChangeRef.current = onChange;
|
|
206
|
+
});
|
|
207
|
+
const isFirstRender = useRef(true);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (isFirstRender.current) {
|
|
210
|
+
isFirstRender.current = false;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
onChangeRef.current && onChangeRef.current(i);
|
|
214
|
+
}, [i]);
|
|
215
|
+
// Every ±1 move goes through here, whether from a click or a released drag past the
|
|
216
|
+
// threshold. Reads/writes trackIndexRef synchronously (see its own doc comment above) so two
|
|
217
|
+
// calls landing back to back — realistic under fast repeat clicking — never race. Deliberately
|
|
218
|
+
// does NOT clamp/rebase trackIndex to stay within the single clone this component renders on
|
|
219
|
+
// either end — see the spring loop's rebase, which handles that (correctly: only once
|
|
220
|
+
// visualPos itself has actually caught up to a boundary, not the instant the discrete target
|
|
221
|
+
// passes one) — so several rapid clicks just keep pushing the target further out, and the
|
|
222
|
+
// spring keeps chasing it, rebasing its own coordinate system as needed along the way.
|
|
223
|
+
const go = (d: number, e?: React.SyntheticEvent) => {
|
|
224
|
+
if (n <= 1) return;
|
|
51
225
|
e && e.stopPropagation();
|
|
52
|
-
|
|
53
|
-
|
|
226
|
+
trackIndexRef.current += d;
|
|
227
|
+
setTrackIndex(trackIndexRef.current);
|
|
228
|
+
ensureLoopRunning();
|
|
229
|
+
};
|
|
230
|
+
// Pointer Events (not separate touch/mouse handlers) so mouse-drag on desktop works the exact
|
|
231
|
+
// same way touch-swipe already did — same pattern as VdpViewer's onStagePointer* handlers.
|
|
232
|
+
// Live-follow: visualPosRef tracks the pointer 1:1 while the gesture is in progress — same
|
|
233
|
+
// feel as mobile.de's / most gallery sliders' drag, instead of only reacting on release. The
|
|
234
|
+
// spring loop is paused for the duration (grabbing mid-flight kills any residual velocity —
|
|
235
|
+
// picking the track up should stop it dead, not have it keep drifting under your finger) and
|
|
236
|
+
// resumes on release, either toward a new target (go(), past the 40px threshold) or back to
|
|
237
|
+
// the unchanged one (springing back into place — also covers a plain click/tap, dx never
|
|
238
|
+
// moves, which still bubbles to the card as before since stopPropagation only happens in go()).
|
|
239
|
+
const dragRef = useRef<{ x: number; startPos: number } | null>(null);
|
|
240
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
241
|
+
// Set for one tick after a drag that crossed the 40px threshold — see onClick below for why.
|
|
242
|
+
const justSwipedRef = useRef(false);
|
|
243
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
244
|
+
if (n <= 1 || (e.target as HTMLElement).closest('button')) return;
|
|
245
|
+
dragRef.current = { x: e.clientX, startPos: visualPosRef.current };
|
|
246
|
+
velocityRef.current = 0;
|
|
247
|
+
if (rafIdRef.current != null) {
|
|
248
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
249
|
+
rafIdRef.current = null;
|
|
250
|
+
}
|
|
251
|
+
setIsDragging(true);
|
|
252
|
+
try {
|
|
253
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
254
|
+
} catch {
|
|
255
|
+
// same "nice to have" as VdpViewer's stage capture — safe to ignore if rejected.
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
259
|
+
const start = dragRef.current;
|
|
260
|
+
if (!start) return;
|
|
261
|
+
const width = e.currentTarget.clientWidth || 1;
|
|
262
|
+
visualPosRef.current = start.startPos - (e.clientX - start.x) / width;
|
|
263
|
+
writeTransform(visualPosRef.current);
|
|
264
|
+
};
|
|
265
|
+
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
266
|
+
const start = dragRef.current;
|
|
267
|
+
dragRef.current = null;
|
|
268
|
+
setIsDragging(false);
|
|
269
|
+
if (!start) return;
|
|
270
|
+
const dx = e.clientX - start.x;
|
|
271
|
+
if (Math.abs(dx) > 40) {
|
|
272
|
+
justSwipedRef.current = true;
|
|
273
|
+
go(dx > 0 ? -1 : 1, e);
|
|
274
|
+
} else {
|
|
275
|
+
ensureLoopRunning(); // spring back to the unchanged trackIndex
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const onPointerCancel = () => {
|
|
279
|
+
dragRef.current = null;
|
|
280
|
+
setIsDragging(false);
|
|
281
|
+
ensureLoopRunning();
|
|
282
|
+
};
|
|
283
|
+
// A completed drag/swipe (mousedown-move-up or touchstart-move-end, all on this same element)
|
|
284
|
+
// makes the browser fire a `click` right after pointerup REGARDLESS of how far the pointer
|
|
285
|
+
// moved in between — go()'s own e.stopPropagation() (called from onPointerUp, above) only
|
|
286
|
+
// stops THAT pointerup event; it can't reach forward to stop a separate click the browser
|
|
287
|
+
// hasn't dispatched yet. Left unguarded, that click still bubbles to a card this carousel
|
|
288
|
+
// lives inside (CarCard/CarRow wrap it in an onClick-to-navigate `<article>`) — swiping
|
|
289
|
+
// through a "similar cars" card's photos was navigating to that car's own page on release.
|
|
290
|
+
// Catching it here, one bubble-phase step before it would reach the card, and swallowing it
|
|
291
|
+
// ONLY when justSwipedRef says a real swipe (not a plain tap, which should still navigate —
|
|
292
|
+
// matches the click/tap-bubbles-through behavior noted in onPointerUp's own doc comment)
|
|
293
|
+
// just happened fixes it without the card needing to know anything about drag state itself.
|
|
294
|
+
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
295
|
+
if (justSwipedRef.current) {
|
|
296
|
+
justSwipedRef.current = false;
|
|
297
|
+
e.stopPropagation();
|
|
298
|
+
}
|
|
54
299
|
};
|
|
55
|
-
const go = (d: number, e?: React.SyntheticEvent) => jump((i + d + n) % n, e);
|
|
56
|
-
const trackRef = useRef<HTMLDivElement>(null);
|
|
57
|
-
const tx = useRef<number | null>(null);
|
|
58
300
|
|
|
59
301
|
// 'auto' mode only diverges from plain 'contain' at desktop widths — below 601px it resolves
|
|
60
302
|
// to 'contain' unconditionally, so mobile's existing rendering is untouched either way.
|
|
@@ -73,33 +315,53 @@ const CardCarousel: React.FunctionComponent<CardCarouselProps> = ({
|
|
|
73
315
|
return (
|
|
74
316
|
<div
|
|
75
317
|
className="cardcar"
|
|
76
|
-
style={{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
318
|
+
style={{
|
|
319
|
+
position: 'relative',
|
|
320
|
+
height,
|
|
321
|
+
overflow: 'hidden',
|
|
322
|
+
borderRadius: radius,
|
|
323
|
+
cursor: n > 1 ? (isDragging ? 'grabbing' : 'grab') : undefined,
|
|
324
|
+
// Without this, mobile browsers treat a horizontal touch-drag on this element as an
|
|
325
|
+
// ambiguous gesture and can hand it to their OWN native scroll/pan handling before our
|
|
326
|
+
// onPointerMove ever sees it (a pointercancel fires instead) — swipe-to-flip silently
|
|
327
|
+
// does nothing on a real touch device even though desktop mouse-drag (unaffected by
|
|
328
|
+
// touch-action) works fine. 'pan-y', not 'none': tells the browser only HORIZONTAL
|
|
329
|
+
// panning is ours to handle, so a mostly-vertical drag that starts on the photo still
|
|
330
|
+
// scrolls the page normally instead of getting swallowed. VdpViewer's full-screen stage
|
|
331
|
+
// already sets this (as `none` — no page scroll to preserve there); this was the one
|
|
332
|
+
// carousel surface that never got it.
|
|
333
|
+
touchAction: n > 1 ? 'pan-y' : undefined,
|
|
85
334
|
}}
|
|
335
|
+
onPointerDown={onPointerDown}
|
|
336
|
+
onPointerMove={onPointerMove}
|
|
337
|
+
onPointerUp={onPointerUp}
|
|
338
|
+
onPointerCancel={onPointerCancel}
|
|
339
|
+
onClick={onClick}
|
|
86
340
|
>
|
|
341
|
+
{/* transform is intentionally NOT part of this style object — it's owned entirely by
|
|
342
|
+
writeTransform() (the spring loop + drag handlers above), written straight to the DOM
|
|
343
|
+
node via trackRef. Letting React manage it here too, even with matching values, would
|
|
344
|
+
mean every trackIndex-driven re-render (a click, a rebase) re-applies React's own
|
|
345
|
+
understanding of the style — fighting the loop's per-frame writes for the same
|
|
346
|
+
property. The useLayoutEffect above sets the correct INITIAL value before first paint. */}
|
|
87
347
|
<div
|
|
88
348
|
ref={trackRef}
|
|
89
349
|
style={{
|
|
90
350
|
display: 'flex',
|
|
91
351
|
height: '100%',
|
|
92
|
-
transform: `translateX(-${i * 100}%)`,
|
|
93
|
-
transition: 'transform var(--dur-3) var(--ease-emphasized)',
|
|
94
352
|
}}
|
|
95
353
|
>
|
|
96
|
-
{
|
|
354
|
+
{extendedImages.map((src, k) => (
|
|
97
355
|
<img
|
|
98
356
|
key={k}
|
|
99
357
|
src={src}
|
|
100
358
|
alt=""
|
|
101
359
|
loading="lazy"
|
|
102
360
|
decoding="async"
|
|
361
|
+
// otherwise the browser's own native image-drag (ghost preview + a dragstart
|
|
362
|
+
// that swallows the following pointer events) hijacks the gesture instead of
|
|
363
|
+
// our onPointerMove/onPointerUp swipe logic above.
|
|
364
|
+
draggable={false}
|
|
103
365
|
style={
|
|
104
366
|
autoDesktop
|
|
105
367
|
? {
|
|
@@ -656,47 +656,55 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
|
|
|
656
656
|
</div>
|
|
657
657
|
)}
|
|
658
658
|
{/* desktop: same struck list price ABOVE the rate, same fin-rate-old-list styling +
|
|
659
|
-
clickable UVP badge/tooltip as the mobile sheet's own Kaufen block.
|
|
660
|
-
|
|
659
|
+
clickable UVP badge/tooltip as the mobile sheet's own Kaufen block.
|
|
660
|
+
Wrapper now renders on EVERY Kaufen car (isCash alone, not isCash && showStrike) —
|
|
661
|
+
cross-tab height parity (md3.css) reserves this row's height once, assuming it's
|
|
662
|
+
always there; a car with no discount used to skip it entirely, landing .fin-rate-main
|
|
663
|
+
(and the whole card) ~20px shorter than a discounted one and breaking that parity.
|
|
664
|
+
fin-rate-strike-slot (md3.css) gives it a fixed height so the slot itself doesn't
|
|
665
|
+
move even though the <s> price inside is still conditional on showStrike. */}
|
|
666
|
+
{isCash && (
|
|
661
667
|
<div
|
|
662
|
-
className="hide-mobile"
|
|
663
|
-
style={{ display: 'flex', justifyContent: 'flex-end', lineHeight: 1, marginTop: 0, cursor: 'pointer' }}
|
|
668
|
+
className="hide-mobile fin-rate-strike-slot"
|
|
669
|
+
style={{ display: 'flex', justifyContent: 'flex-end', lineHeight: 1, marginTop: 0, cursor: showStrike ? 'pointer' : 'default' }}
|
|
664
670
|
role="button"
|
|
665
671
|
tabIndex={0}
|
|
666
672
|
onClick={() => setUvpTooltipOpen((v) => !v)}
|
|
667
673
|
>
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
e
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
<
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
e
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
<
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
674
|
+
{showStrike && (
|
|
675
|
+
<s className="num fin-rate-old fin-rate-old-list">
|
|
676
|
+
{eur(listPrice as number)} €
|
|
677
|
+
<span
|
|
678
|
+
className="pp-eyebrow-badge"
|
|
679
|
+
role="button"
|
|
680
|
+
tabIndex={0}
|
|
681
|
+
onClick={(e) => {
|
|
682
|
+
e.stopPropagation();
|
|
683
|
+
setUvpTooltipOpen((v) => !v);
|
|
684
|
+
}}
|
|
685
|
+
>
|
|
686
|
+
2
|
|
687
|
+
</span>
|
|
688
|
+
{uvpTooltipOpen && (
|
|
689
|
+
<div className="pp-uvp-tooltip" onClick={(e) => e.stopPropagation()}>
|
|
690
|
+
<button
|
|
691
|
+
type="button"
|
|
692
|
+
className="pp-uvp-tooltip-close"
|
|
693
|
+
aria-label={t('autobahn:modal.close')}
|
|
694
|
+
onClick={(e) => {
|
|
695
|
+
e.stopPropagation();
|
|
696
|
+
setUvpTooltipOpen(false);
|
|
697
|
+
}}
|
|
698
|
+
>
|
|
699
|
+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
700
|
+
<path d="M5 5L15 15M15 5L5 15" stroke="white" strokeWidth={2.4} strokeLinecap="round" />
|
|
701
|
+
</svg>
|
|
702
|
+
</button>
|
|
703
|
+
<div className="pp-uvp-tooltip-body">{t('autobahn:card.price.uvpListTooltipBody')}</div>
|
|
704
|
+
</div>
|
|
705
|
+
)}
|
|
706
|
+
</s>
|
|
707
|
+
)}
|
|
700
708
|
</div>
|
|
701
709
|
)}
|
|
702
710
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|