@bytebrand/fe-ui-core-autobahn 1.0.146 → 1.0.147

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.
@@ -2225,37 +2225,81 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2225
2225
  // via CSS) is the one scrolled to.
2226
2226
  const desktopBreakdownRef = React.useRef<HTMLDivElement | null>(null);
2227
2227
  const mobileBreakdownRef = React.useRef<HTMLDivElement | null>(null);
2228
- // mobile (ADV-276): revealing the breakdown scrolls the sheet body until the "Repräsentatives
2229
- // Beispiel" headline sits at its top — but never so far that the CTA row under the breakdown
2230
- // comes into view (on tall screens the breakdown is shorter than the sheet, so the headline
2231
- // then stops lower, with the bottom CTA row still just below the fold).
2232
- // The sheet keeps its fixed height (see .fin-modal in md3.css), so nothing jumps; only the
2233
- // body scrolls. Hand-rolled rAF ease-out so the duration is ours, not the browser's.
2228
+ // mobile (ADV-276): the sheet opens sized to its rate controls — its bottom edge sits right
2229
+ // under the "Finanzierungs-/Leasing-Details" link (the [data-sheet-fold] element; on Kaufen its
2230
+ // CTA row), with the always-mounted breakdown below the fold, reachable by scrolling by hand.
2231
+ // Tapping the link expands the sheet to (nearly) the full screen (.fin-modal--expanded, 0.35s
2232
+ // height transition in md3.css) and scrolls the body until the "Repräsentatives Beispiel"
2233
+ // headline sits at its top — never so far that the CTA row under the breakdown comes into
2234
+ // view (on tall screens the breakdown is shorter than the sheet; the headline stops lower).
2235
+ // Targets are re-read every frame so the scroll and the growing sheet finish together.
2234
2236
  const mobileBottomCtaRef = React.useRef<HTMLDivElement | null>(null);
2235
2237
  const breakdownScrollRaf = React.useRef(0);
2236
- const scrollSheetToBreakdown = React.useCallback((): void => {
2237
- const el = mobileBreakdownRef.current;
2238
- const body = el?.closest('.fin-modal-body') as HTMLElement | null | undefined;
2239
- if (!el || !body) return;
2240
- const from = body.scrollTop;
2241
- const bodyTop = body.getBoundingClientRect().top;
2242
- const headlineTop = el.getBoundingClientRect().top - bodyTop + from;
2243
- const cta = mobileBottomCtaRef.current;
2244
- const ctaLimit = cta ? cta.getBoundingClientRect().top - bodyTop + from - body.clientHeight : Infinity;
2245
- const to = Math.max(0, Math.min(headlineTop - 8, ctaLimit, body.scrollHeight - body.clientHeight));
2246
- if (to === from) return;
2238
+ const animateSheetScroll = React.useCallback((target: (body: HTMLElement) => number): void => {
2239
+ const body = mobileBreakdownRef.current?.closest('.fin-modal-body') as HTMLElement | null | undefined;
2240
+ if (!body) return;
2247
2241
  window.cancelAnimationFrame(breakdownScrollRaf.current);
2248
- const duration = 400;
2242
+ const from = body.scrollTop;
2243
+ const duration = 450;
2249
2244
  let start: number | null = null;
2250
2245
  const step = (now: number): void => {
2251
2246
  if (start === null) start = now;
2252
2247
  const p = Math.min(1, (now - start) / duration);
2248
+ const to = Math.max(0, Math.min(target(body), body.scrollHeight - body.clientHeight));
2253
2249
  body.scrollTop = from + (to - from) * (1 - Math.pow(1 - p, 3));
2254
2250
  if (p < 1) breakdownScrollRaf.current = window.requestAnimationFrame(step);
2255
2251
  };
2256
2252
  breakdownScrollRaf.current = window.requestAnimationFrame(step);
2257
2253
  }, []);
2254
+ const scrollSheetToBreakdown = React.useCallback((): void => {
2255
+ animateSheetScroll((body) => {
2256
+ const el = mobileBreakdownRef.current;
2257
+ if (!el) return body.scrollTop;
2258
+ const bodyTop = body.getBoundingClientRect().top;
2259
+ const headlineTop = el.getBoundingClientRect().top - bodyTop + body.scrollTop;
2260
+ const cta = mobileBottomCtaRef.current;
2261
+ const ctaLimit = cta ? cta.getBoundingClientRect().top - bodyTop + body.scrollTop - body.clientHeight : Infinity;
2262
+ return Math.min(headlineTop - 8, ctaLimit);
2263
+ });
2264
+ }, [animateSheetScroll]);
2258
2265
  React.useEffect(() => () => window.cancelAnimationFrame(breakdownScrollRaf.current), []);
2266
+ // collapsed sheet height = everything down to the [data-sheet-fold] element's bottom edge (its
2267
+ // own bottom padding is the breathing gap — the body's padding only exists at the very end of
2268
+ // the scroll content, so the breakdown would peek in there); re-measured when the content changes size (tab switch, leasing's extra
2269
+ // slider, rate text) or the viewport does. Fed to md3.css as --fin-sheet-fold (mobile only).
2270
+ const sheetRef = React.useRef<HTMLDivElement | null>(null);
2271
+ const [sheetFold, setSheetFold] = React.useState<number | null>(null);
2272
+ const [sheetAnimated, setSheetAnimated] = React.useState(false);
2273
+ React.useLayoutEffect(() => {
2274
+ if (!open) return;
2275
+ const sheet = sheetRef.current;
2276
+ const content = sheet?.querySelector('.fin-modal-body > .show-mobile') as HTMLElement | null | undefined;
2277
+ if (!sheet || !content) return;
2278
+ const measure = (): void => {
2279
+ const body = sheet.querySelector('.fin-modal-body') as HTMLElement | null;
2280
+ const fold = content.querySelector('[data-sheet-fold]') as HTMLElement | null;
2281
+ if (!body || !fold || !content.offsetParent) return;
2282
+ const bodyRect = body.getBoundingClientRect();
2283
+ const foldBottom = fold.getBoundingClientRect().bottom - bodyRect.top + body.scrollTop;
2284
+ const h = bodyRect.top - sheet.getBoundingClientRect().top + foldBottom;
2285
+ setSheetFold(Math.floor(h));
2286
+ };
2287
+ measure();
2288
+ // height transition only from the next frame on — the first measure must not animate the
2289
+ // freshly opened sheet from the 85vh fallback down to its fold
2290
+ const raf = window.requestAnimationFrame(() => setSheetAnimated(true));
2291
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
2292
+ ro?.observe(content);
2293
+ window.addEventListener('resize', measure);
2294
+ return () => {
2295
+ window.cancelAnimationFrame(raf);
2296
+ ro?.disconnect();
2297
+ window.removeEventListener('resize', measure);
2298
+ };
2299
+ }, [open, mode]);
2300
+ React.useEffect(() => {
2301
+ if (!open) setSheetAnimated(false);
2302
+ }, [open]);
2259
2303
  // opened directly via the page's details link: open expanded and scrolled to the breakdown.
2260
2304
  React.useEffect(() => {
2261
2305
  if (!open) return;
@@ -2268,19 +2312,19 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2268
2312
  return () => window.clearTimeout(timer);
2269
2313
  // eslint-disable-next-line react-hooks/exhaustive-deps
2270
2314
  }, [open, initialShowBreakdown]);
2271
- // in-sheet details toggle: scroll once the expanded breakdown has rendered. Collapsing doesn't scroll.
2315
+ // in-sheet details link: expanding scrolls down to the breakdown, collapsing back to the top.
2272
2316
  const revealBreakdownRef = React.useRef(false);
2273
2317
  React.useEffect(() => {
2274
- if (!showBreakdown || !revealBreakdownRef.current) return;
2318
+ if (!revealBreakdownRef.current) return;
2275
2319
  revealBreakdownRef.current = false;
2276
- scrollSheetToBreakdown();
2277
- }, [showBreakdown, scrollSheetToBreakdown]);
2320
+ if (showBreakdown) scrollSheetToBreakdown();
2321
+ else animateSheetScroll(() => 0);
2322
+ }, [showBreakdown, scrollSheetToBreakdown, animateSheetScroll]);
2278
2323
 
2279
2324
  // mobile-only: swipe-down-to-dismiss on the grabber/header (the mockup's drag handle).
2280
2325
  // The sheet follows the finger (imperative transform — no re-renders while dragging);
2281
2326
  // released past 90px it slides out and closes, otherwise it springs back. The element
2282
2327
  // unmounts on close (`open` gate), so the inline transform never leaks into the next open.
2283
- const sheetRef = React.useRef<HTMLDivElement | null>(null);
2284
2328
  const dragStartY = React.useRef<number | null>(null);
2285
2329
  const onGrabStart = (e: React.TouchEvent): void => {
2286
2330
  // sheet gesture is phone-only — the desktop centered modal must not be draggable
@@ -2298,7 +2342,8 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2298
2342
  const dy = e.changedTouches[0].clientY - dragStartY.current;
2299
2343
  dragStartY.current = null;
2300
2344
  const el = sheetRef.current;
2301
- el.style.transition = 'transform var(--dur-2, .25s)';
2345
+ // keep .fin-modal's own height transition (expand/collapse) alongside the spring-back
2346
+ el.style.transition = 'transform var(--dur-2, .25s), height 0.35s var(--ease-standard, ease)';
2302
2347
  if (dy > 90) {
2303
2348
  el.style.transform = 'translateY(100%)';
2304
2349
  window.setTimeout(onClose, 200);
@@ -2509,7 +2554,8 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2509
2554
  return (
2510
2555
  <div className="fin-modal-scrim" onClick={onClose}>
2511
2556
  <div
2512
- className="fin-modal"
2557
+ className={'fin-modal' + (showBreakdown ? ' fin-modal--expanded' : '') + (sheetAnimated ? ' fin-modal--animated' : '')}
2558
+ style={sheetFold != null ? ({ '--fin-sheet-fold': `${sheetFold}px` } as React.CSSProperties) : undefined}
2513
2559
  role="dialog"
2514
2560
  aria-modal="true"
2515
2561
  aria-label={title}
@@ -3265,7 +3311,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
3265
3311
  />
3266
3312
  )}
3267
3313
  {/* pushed to the sheet's bottom edge — Figma keeps a big breathing gap above */}
3268
- <div className="fin-sheet-cta-row fin-sheet-cta-row--cash" style={{ display: 'flex', gap: 7, marginTop: 'auto' }}>
3314
+ <div data-sheet-fold="" className="fin-sheet-cta-row fin-sheet-cta-row--cash" style={{ display: 'flex', gap: 7, marginTop: 'auto' }}>
3269
3315
  <Button
3270
3316
  variant="filled"
3271
3317
  size="lg"
@@ -3449,8 +3495,9 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
3449
3495
  {/* centered ⓘ details link (mockup shows no chevron) — scrolls to the breakdown below */}
3450
3496
  <button
3451
3497
  type="button"
3498
+ data-sheet-fold=""
3452
3499
  onClick={() => {
3453
- revealBreakdownRef.current = !showBreakdown;
3500
+ revealBreakdownRef.current = true;
3454
3501
  setShowBreakdown((v) => !v);
3455
3502
  }}
3456
3503
  style={{ ...expanderBtnStyle, justifyContent: 'center', padding: '2px 0 20px' }}>