@bytebrand/fe-ui-core-autobahn 1.0.127 → 1.0.130

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.127",
3
+ "version": "1.0.130",
4
4
  "description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
5
5
  "main": "index.ts",
6
6
  "license": "UNLICENSED",
package/theme/md3.css CHANGED
@@ -3628,9 +3628,9 @@ line-height: 31px;
3628
3628
  Breakpoints: tablet ≤ 980px, phone ≤ 600px.
3629
3629
  ========================================================================== */
3630
3630
  .md3 .container {
3631
- max-width: 1180px;
3631
+ max-width: 1230px;
3632
3632
  margin: 0 auto;
3633
- width: 100%;
3633
+ padding: 15px 15px;
3634
3634
  }
3635
3635
 
3636
3636
  .md3 .hero-grid {
@@ -3665,6 +3665,14 @@ line-height: 31px;
3665
3665
  grid-template-columns: repeat(2, 1fr);
3666
3666
  gap: 18px;
3667
3667
  }
3668
+ /* fe-landing TopResults ("Top {Make} Angebote") only — a dedicated class rather than
3669
+ reusing .cards-2 (Alternatives also uses that, and must stay 2-up) or .cards-3
3670
+ (shared much more widely: VDP's "Ähnliche Fahrzeuge", elsewhere). */
3671
+ .md3 .top-results-grid {
3672
+ display: grid;
3673
+ grid-template-columns: repeat(3, 1fr);
3674
+ gap: 24px;
3675
+ }
3668
3676
  .md3 .cards-offers {
3669
3677
  display: grid;
3670
3678
  grid-template-columns: repeat(3, 1fr);
@@ -6565,7 +6573,9 @@ line-height: 31px;
6565
6573
  overrides below — kept as real CSS rules (not inline styles) since an inline style on the
6566
6574
  element would always beat the media-query override regardless of specificity. */
6567
6575
  .md3 .topbar-container {
6568
- padding: 0 16px;
6576
+ max-width: 1230px;
6577
+ margin: 0 auto;
6578
+ padding: 15px 15px;
6569
6579
  }
6570
6580
  .md3 .topbar-actions {
6571
6581
  gap: 16px;
@@ -7000,12 +7010,17 @@ line-height: 31px;
7000
7010
 
7001
7011
  @media (max-width: 600px) {
7002
7012
  .md3 .cards-3,
7003
- .md3 .cards-2 {
7013
+ .md3 .cards-2,
7014
+ .md3 .top-results-grid {
7004
7015
  grid-template-columns: 1fr;
7005
7016
  }
7006
- /* fe-landing (/angebot) TopResults + Alternatives grids — shared side gutter,
7007
- same 12px pattern as .md3 .container elsewhere. .cards-3 is untouched (not
7008
- used with this gutter treatment; only .cards-2 callers asked for it). */
7017
+ .md3 .top-results-grid {
7018
+ gap: 16px;
7019
+ }
7020
+ /* fe-landing (/angebot) Alternatives grid — shared side gutter, same 12px
7021
+ pattern as .md3 .container elsewhere. .cards-3 is untouched (not used with
7022
+ this gutter treatment). .top-results-grid deliberately excluded — its own
7023
+ container already handles horizontal spacing, this padding was redundant. */
7009
7024
  .md3 .cards-2 {
7010
7025
  padding-left: 12px !important;
7011
7026
  padding-right: 12px !important;
@@ -1,4 +1,22 @@
1
- import React, { useState, useRef, useEffect } from '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
- useEffect(() => {
48
- setI(0);
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
- const jump = (nx: number, e?: React.SyntheticEvent) => {
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
- setI(nx);
53
- onChange && onChange(nx);
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={{ position: 'relative', height, overflow: 'hidden', borderRadius: radius }}
77
- onTouchStart={(e) => {
78
- tx.current = e.touches[0].clientX;
79
- }}
80
- onTouchEnd={(e) => {
81
- if (tx.current == null) return;
82
- const dx = e.changedTouches[0].clientX - tx.current;
83
- tx.current = null;
84
- if (Math.abs(dx) > 40) go(dx > 0 ? -1 : 1);
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
- {images.map((src, k) => (
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
  ? {