@bytebrand/fe-ui-core-autobahn 1.0.114 → 1.0.115

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/ui/Dropdown.tsx CHANGED
@@ -18,6 +18,7 @@
18
18
  // for SSR). Renders correctly only inside a `<div className="md3">` subtree.
19
19
 
20
20
  import React from 'react';
21
+ import * as ReactDOM from 'react-dom';
21
22
 
22
23
  import Icon from './Icon';
23
24
 
@@ -53,6 +54,22 @@ export interface DropdownProps {
53
54
  searchable?: boolean;
54
55
  /** placeholder for the search input (only used when `searchable`). */
55
56
  searchPlaceholder?: string;
57
+ /** restrict the combobox input to digits (numeric "up to" fields) — filters every keystroke and sets inputMode="numeric" pattern="[0-9]*". Requires `searchable`. */
58
+ numeric?: boolean;
59
+ /** allow committing a typed value that matches no option, instead of only ever allowing an exact pick from the list — for numeric "up to" fields where any amount is valid, not just the preset rungs. Committed on close (blur/Tab/click-away/Enter), not on every keystroke. Requires `searchable`. */
60
+ allowCustomValue?: boolean;
61
+ /** renders the closed-trigger label for a custom (non-list) value. Falls back to the raw value itself when omitted — pass this whenever `allowCustomValue` so the trigger reads naturally (e.g. `(v) => \`bis ${v} km\``). */
62
+ formatCustomLabel?: (value: string) => string;
63
+ /**
64
+ * Render the popover into a `document.body` portal instead of nesting it in place — escapes an
65
+ * ancestor's `overflow:hidden`/clipping (e.g. inside a collapsing Accordion region, see
66
+ * `.macc-region-inner` in md3.css) that would otherwise cut the option list off instead of
67
+ * letting it float over the following content. Position is computed from the trigger's on-screen
68
+ * rect, kept in sync on scroll/resize while open, and flips to open ABOVE the trigger when
69
+ * there isn't room below. Off by default — every existing caller keeps the simple in-place
70
+ * popover; opt in only at call sites with a known clipping ancestor.
71
+ */
72
+ menuPortal?: boolean;
56
73
  }
57
74
 
58
75
  const Dropdown: React.FunctionComponent<DropdownProps> = ({
@@ -69,6 +86,10 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
69
86
  menuAlign = 'left',
70
87
  searchable = false,
71
88
  searchPlaceholder,
89
+ numeric = false,
90
+ allowCustomValue = false,
91
+ formatCustomLabel,
92
+ menuPortal = false,
72
93
  }) => {
73
94
  const [open, setOpen] = React.useState(false);
74
95
  const [activeIndex, setActiveIndex] = React.useState(-1);
@@ -76,12 +97,19 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
76
97
  const rootRef = React.useRef<HTMLDivElement | null>(null);
77
98
  const btnRef = React.useRef<HTMLButtonElement | null>(null);
78
99
  const menuRef = React.useRef<HTMLUListElement | null>(null);
100
+ const menuElRef = React.useRef<HTMLDivElement | null>(null);
79
101
  const searchRef = React.useRef<HTMLInputElement | null>(null);
80
102
  // stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
81
103
  const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
82
104
 
83
105
  const selectedIndex = options.findIndex((o) => o.value === value);
84
106
  const selected = selectedIndex !== -1 ? options[selectedIndex] : undefined;
107
+ // a real value that just doesn't happen to match any option — only possible with
108
+ // allowCustomValue (e.g. a hand-typed "1421232" the preset rungs don't cover).
109
+ const customLabel = allowCustomValue && value && !selected
110
+ ? (formatCustomLabel ? formatCustomLabel(value) : value)
111
+ : undefined;
112
+ const hasValue = !!selected || customLabel !== undefined;
85
113
 
86
114
  const filteredOptions = React.useMemo(() => {
87
115
  if (!searchable || !query.trim()) return options;
@@ -106,13 +134,20 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
106
134
  // re-renders. Consumed by the effect below, once that re-render has actually happened.
107
135
  const focusTriggerAfterClose = React.useRef(false);
108
136
 
109
- // close + restore focus to the trigger.
110
- const close = React.useCallback((focusTrigger: boolean) => {
137
+ // close + restore focus to the trigger. `commitTyped` (default true) commits whatever's
138
+ // currently typed as a custom value when `allowCustomValue` — this is what makes "just leave
139
+ // the field" (blur/click-away/Tab) work, not only an explicit pick from the list. Callers that
140
+ // already committed an explicit option (commit()) or that are cancelling (Escape) pass false.
141
+ const close = React.useCallback((focusTrigger: boolean, commitTyped: boolean = true) => {
142
+ if (allowCustomValue && commitTyped) {
143
+ const raw = query.trim();
144
+ if (raw !== value) onChange(raw);
145
+ }
111
146
  setOpen(false);
112
147
  setActiveIndex(-1);
113
148
  setQuery('');
114
149
  focusTriggerAfterClose.current = focusTrigger;
115
- }, []);
150
+ }, [allowCustomValue, query, value, onChange]);
116
151
 
117
152
  React.useEffect(() => {
118
153
  if (!open && focusTriggerAfterClose.current) {
@@ -126,7 +161,10 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
126
161
  setOpen(true);
127
162
  // start the keyboard highlight on the current selection (or the first option).
128
163
  setActiveIndex(selectedIndex !== -1 ? selectedIndex : 0);
129
- }, [disabled, selectedIndex]);
164
+ // pre-fill the combobox with the current value (raw, not the formatted label) so a custom
165
+ // entry can be edited/replaced in place instead of always starting from a blank field.
166
+ if (allowCustomValue) setQuery(value || '');
167
+ }, [disabled, selectedIndex, allowCustomValue, value]);
130
168
 
131
169
  // when opened searchable, focus the search input instead of leaving focus on the trigger.
132
170
  // scrollIntoView first: on mobile the keyboard is about to eat ~40% of the viewport, so the
@@ -137,19 +175,84 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
137
175
  if (rootRef.current && rootRef.current.scrollIntoView) {
138
176
  rootRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
139
177
  }
140
- if (searchRef.current) searchRef.current.focus();
141
- }, [open, searchable]);
178
+ if (searchRef.current) {
179
+ searchRef.current.focus();
180
+ // pre-filled custom value (see openMenu) starts selected, so typing immediately
181
+ // replaces it instead of appending — same "click a number field, type over it" feel.
182
+ if (allowCustomValue) searchRef.current.select();
183
+ }
184
+ }, [open, searchable, allowCustomValue]);
142
185
 
143
- // click-away (guarded for SSR).
186
+ // click-away (guarded for SSR). menuElRef is checked too — with menuPortal the popover renders
187
+ // outside rootRef (at document.body), so without this every option click would look like a
188
+ // click-away and close the menu before the option's own onClick had a chance to fire.
144
189
  React.useEffect(() => {
145
190
  if (!open || typeof document === 'undefined') return undefined;
146
191
  const onDocMouseDown = (e: MouseEvent) => {
147
- if (rootRef.current && !rootRef.current.contains(e.target as Node)) close(false);
192
+ const target = e.target as Node;
193
+ if (rootRef.current && rootRef.current.contains(target)) return;
194
+ if (menuElRef.current && menuElRef.current.contains(target)) return;
195
+ close(false);
148
196
  };
149
197
  document.addEventListener('mousedown', onDocMouseDown);
150
198
  return () => document.removeEventListener('mousedown', onDocMouseDown);
151
199
  }, [open, close]);
152
200
 
201
+ // menuPortal: a fresh div.md3 appended to body while open, so --md-* tokens and every
202
+ // `.md3 .mselect-*` rule still resolve at body level (same technique as ui/Modal.tsx).
203
+ const portalRoot = React.useMemo<HTMLDivElement | null>(() => {
204
+ if (!menuPortal || typeof document === 'undefined') return null;
205
+ const el = document.createElement('div');
206
+ el.className = 'md3';
207
+ return el;
208
+ }, [menuPortal]);
209
+
210
+ React.useEffect(() => {
211
+ if (!menuPortal || !open || !portalRoot || typeof document === 'undefined') return undefined;
212
+ document.body.appendChild(portalRoot);
213
+ return () => {
214
+ if (portalRoot.parentNode) portalRoot.parentNode.removeChild(portalRoot);
215
+ };
216
+ }, [menuPortal, open, portalRoot]);
217
+
218
+ // menuPortal position: computed from the trigger's on-screen rect (position:fixed, so it's
219
+ // viewport-relative — kept in sync on scroll/resize below). ESTIMATED_MENU_HEIGHT (the list's
220
+ // own max-height, 288px, plus the menu's padding/border) only needs to be a reasonable guess —
221
+ // it's used to DECIDE whether to flip above the trigger, not to size anything; the "above"
222
+ // placement is positioned via `bottom` instead of `top` so it grows upward regardless of its
223
+ // real height.
224
+ const ESTIMATED_MENU_HEIGHT = 300;
225
+ const [menuPos, setMenuPos] = React.useState<
226
+ { width: number; top?: number; bottom?: number; left?: number; right?: number } | null
227
+ >(null);
228
+
229
+ const positionMenu = React.useCallback(() => {
230
+ if (!menuPortal || !rootRef.current || typeof window === 'undefined') return;
231
+ const rect = rootRef.current.getBoundingClientRect();
232
+ const spaceBelow = window.innerHeight - rect.bottom;
233
+ const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && rect.top > spaceBelow;
234
+ setMenuPos({
235
+ width: rect.width,
236
+ left: menuAlign === 'right' ? undefined : rect.left,
237
+ right: menuAlign === 'right' ? window.innerWidth - rect.right : undefined,
238
+ top: openAbove ? undefined : rect.bottom + 4,
239
+ bottom: openAbove ? window.innerHeight - rect.top + 4 : undefined,
240
+ });
241
+ }, [menuPortal, menuAlign]);
242
+
243
+ React.useEffect(() => {
244
+ if (!open || !menuPortal) return undefined;
245
+ positionMenu();
246
+ // capture:true — a scroll inside any nested scrollable ancestor (not just the window) must
247
+ // also reposition, since the trigger moves relative to the viewport either way.
248
+ window.addEventListener('scroll', positionMenu, true);
249
+ window.addEventListener('resize', positionMenu);
250
+ return () => {
251
+ window.removeEventListener('scroll', positionMenu, true);
252
+ window.removeEventListener('resize', positionMenu);
253
+ };
254
+ }, [open, menuPortal, positionMenu]);
255
+
153
256
  // keep the active option scrolled into view while navigating by keyboard.
154
257
  React.useEffect(() => {
155
258
  if (!open || activeIndex < 0 || !menuRef.current) return;
@@ -167,7 +270,9 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
167
270
  const opt = filteredOptions[idx];
168
271
  if (!opt || opt.disabled) return;
169
272
  onChange(opt.value);
170
- close(true);
273
+ // false: the explicit pick already won — don't also commit whatever's still sitting in the
274
+ // (now-stale) typed query behind it.
275
+ close(true, false);
171
276
  };
172
277
 
173
278
  // move the active highlight, skipping disabled options.
@@ -189,8 +294,15 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
189
294
  case 'ArrowUp': e.preventDefault(); move(-1); break;
190
295
  case 'Home': e.preventDefault(); setActiveIndex(filteredOptions.findIndex((o) => !o.disabled)); break;
191
296
  case 'End': e.preventDefault(); for (let i = filteredOptions.length - 1; i >= 0; i -= 1) { if (!filteredOptions[i].disabled) { setActiveIndex(i); break; } } break;
192
- case 'Enter': e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); break;
193
- case 'Escape': e.preventDefault(); close(true); break;
297
+ case 'Enter':
298
+ e.preventDefault();
299
+ // an active list match wins when there is one; otherwise, for allowCustomValue fields,
300
+ // Enter commits whatever's typed (same value close() would commit on blur anyway).
301
+ if (activeIndex >= 0) commit(activeIndex);
302
+ else if (allowCustomValue) close(true);
303
+ break;
304
+ // Escape cancels — close(..., false) so an in-progress custom edit is discarded, not committed.
305
+ case 'Escape': e.preventDefault(); close(true, false); break;
194
306
  case 'Tab': close(false); break;
195
307
  default: break;
196
308
  }
@@ -214,10 +326,43 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
214
326
  if (block) wrapCls.push('mselect--block');
215
327
  if (className) wrapCls.push(className);
216
328
 
217
- const label = selected ? selected.label : placeholder || '';
329
+ const label = selected ? selected.label : (customLabel !== undefined ? customLabel : placeholder || '');
218
330
 
219
331
  const editing = searchable && open;
220
332
 
333
+ // shared between the in-place popover and the menuPortal one — identical option list either way.
334
+ const renderMenuList = () => (
335
+ <ul
336
+ ref={menuRef}
337
+ id={`${idBase}list`}
338
+ className="mselect-list"
339
+ role="listbox"
340
+ tabIndex={-1}
341
+ aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
342
+ >
343
+ {filteredOptions.map((opt, i) => (
344
+ <li key={`${opt.value}_${i}`} role="presentation">
345
+ <button
346
+ id={`${idBase}${i}`}
347
+ type="button"
348
+ role="option"
349
+ aria-selected={opt.value === value}
350
+ disabled={opt.disabled}
351
+ className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
352
+ onMouseEnter={() => setActiveIndex(i)}
353
+ onClick={() => commit(i)}
354
+ >
355
+ <span className="mselect-opt-label">{opt.label}</span>
356
+ {opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
357
+ </button>
358
+ </li>
359
+ ))}
360
+ {searchable && filteredOptions.length === 0 && (
361
+ <li className="mselect-empty" role="presentation">Keine Treffer</li>
362
+ )}
363
+ </ul>
364
+ );
365
+
221
366
  return (
222
367
  <div className={wrapCls.join(' ')} ref={rootRef}>
223
368
  {editing ? (
@@ -232,13 +377,14 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
232
377
  className="mselect-input"
233
378
  placeholder={searchPlaceholder || placeholder}
234
379
  value={query}
235
- onChange={(e) => setQuery(e.target.value)}
380
+ onChange={(e) => setQuery(numeric ? e.target.value.replace(/\D/g, '') : e.target.value)}
236
381
  onKeyDown={onMenuKeyDown}
237
382
  aria-label={ariaLabel}
238
383
  aria-haspopup="listbox"
239
384
  aria-expanded={open}
240
385
  aria-controls={`${idBase}list`}
241
386
  aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
387
+ {...(numeric ? { inputMode: 'numeric' as const, pattern: '[0-9]*' } : {})}
242
388
  />
243
389
  <button type="button" className="mselect-chev-btn" aria-label="Close" onClick={() => close(true)}>
244
390
  <Icon name="chevron-down" cls="mselect-chev" />
@@ -257,42 +403,43 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
257
403
  onKeyDown={onTriggerKeyDown}
258
404
  >
259
405
  {icon && <Icon name={icon} />}
260
- <span className={'mselect-label' + (selected ? '' : ' mselect-label--ph')}>{label}</span>
406
+ <span className={'mselect-label' + (hasValue ? '' : ' mselect-label--ph')}>{label}</span>
261
407
  <Icon name="chevron-down" cls="mselect-chev" />
262
408
  </button>
263
409
  )}
264
- {open && (
265
- <div className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}>
266
- <ul
267
- ref={menuRef}
268
- id={`${idBase}list`}
269
- className="mselect-list"
270
- role="listbox"
271
- tabIndex={-1}
272
- aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
410
+ {open && (menuPortal && portalRoot
411
+ ? ReactDOM.createPortal(
412
+ <div
413
+ ref={menuElRef}
414
+ className={'mselect-menu' + (searchable ? ' mselect-menu--searchable' : '')}
415
+ // menuPos is null for one frame on open, before the position effect has measured
416
+ // the trigger — stay invisible rather than flash at (0,0)/full-width.
417
+ style={
418
+ menuPos
419
+ ? {
420
+ position: 'fixed',
421
+ top: menuPos.top,
422
+ bottom: menuPos.bottom,
423
+ left: menuPos.left,
424
+ right: menuPos.right,
425
+ width: menuPos.width,
426
+ minWidth: menuPos.width,
427
+ }
428
+ : { visibility: 'hidden' }
429
+ }
430
+ >
431
+ {renderMenuList()}
432
+ </div>,
433
+ portalRoot,
434
+ )
435
+ : (
436
+ <div
437
+ ref={menuElRef}
438
+ className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}
273
439
  >
274
- {filteredOptions.map((opt, i) => (
275
- <li key={`${opt.value}_${i}`} role="presentation">
276
- <button
277
- id={`${idBase}${i}`}
278
- type="button"
279
- role="option"
280
- aria-selected={opt.value === value}
281
- disabled={opt.disabled}
282
- className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
283
- onMouseEnter={() => setActiveIndex(i)}
284
- onClick={() => commit(i)}
285
- >
286
- <span className="mselect-opt-label">{opt.label}</span>
287
- {opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
288
- </button>
289
- </li>
290
- ))}
291
- {searchable && filteredOptions.length === 0 && (
292
- <li className="mselect-empty" role="presentation">Keine Treffer</li>
293
- )}
294
- </ul>
295
- </div>
440
+ {renderMenuList()}
441
+ </div>
442
+ )
296
443
  )}
297
444
  </div>
298
445
  );
@@ -59,6 +59,11 @@ export interface FormDropdownProps {
59
59
  style?: React.CSSProperties;
60
60
  /** extra class on the wrapper (legacy `className`). */
61
61
  className?: string;
62
+ /** forwarded to the underlying Dropdown — see Dropdown.tsx's own doc comment. Escapes a known
63
+ * clipping ancestor (e.g. a collapsing Accordion region) by rendering the popover into a
64
+ * document.body portal instead of nesting it in place. Single-select only (isMulti unaffected —
65
+ * its checklist menu doesn't go through the base Dropdown). Off by default. */
66
+ menuPortal?: boolean;
62
67
  }
63
68
 
64
69
  /** Raw option as it can appear in field.extra. */
@@ -86,7 +91,7 @@ function normalizeOption(opt: RawOption): { value: string; rawLabel: any; disabl
86
91
  }
87
92
 
88
93
  const FormDropdown: React.FunctionComponent<FormDropdownProps> = observer(
89
- ({ field, t, namespace, isObj, isApiTranslated, isMulti, disableClearable, freeSolo, disabled, style, className, hideLabel }) => {
94
+ ({ field, t, namespace, isObj, isApiTranslated, isMulti, disableClearable, freeSolo, disabled, style, className, hideLabel, menuPortal }) => {
90
95
  // ---- label resolution (legacy getOptions parity) -------------------------------------------
91
96
  const labelFor = React.useCallback(
92
97
  (rawLabel: any): string => {
@@ -184,6 +189,7 @@ const FormDropdown: React.FunctionComponent<FormDropdownProps> = observer(
184
189
  ariaLabel={label}
185
190
  disabled={isDisabled}
186
191
  className={hasError ? 'mfd-invalid' : undefined}
192
+ menuPortal={menuPortal}
187
193
  />
188
194
  {hasError ? <span className="mfield-error-text mfd-error">{field.error}</span> : null}
189
195
  </div>
@@ -201,8 +201,17 @@ export const ToggleRow: React.FunctionComponent<{
201
201
  tooltip?: React.ReactNode;
202
202
  /** aria-label for the popover's close button — required together with `tooltip`. */
203
203
  tooltipCloseLabel?: string;
204
- }> = ({ label, price, checked, disabled, onChange, className, staticNote, tooltip, tooltipCloseLabel }) => {
204
+ /** required together with `tooltip` — builds the trigger's own accessible name (see `infoLabel` below). */
205
+ t?: TranslationFunction;
206
+ }> = ({ label, price, checked, disabled, onChange, className, staticNote, tooltip, tooltipCloseLabel, t }) => {
205
207
  const [tooltipOpen, setTooltipOpen] = useState(false);
208
+ const toggleTooltip = (e: { stopPropagation: () => void }) => {
209
+ e.stopPropagation();
210
+ setTooltipOpen((v) => !v);
211
+ };
212
+ // the visible label alone ("Rundum-Sorglos-Paket") reads as ambiguous out of context (toggle
213
+ // the package? show info about it?) — spell out what activating the trigger actually does.
214
+ const infoLabel = tooltip && t ? t('autobahn:finance.moreInfoAria', { label }) : undefined;
206
215
  return (
207
216
  <div
208
217
  className={className}
@@ -214,15 +223,40 @@ export const ToggleRow: React.FunctionComponent<{
214
223
  position: tooltip ? 'relative' : undefined,
215
224
  }}
216
225
  >
217
- <span
218
- className="md-title-sm"
219
- style={{ display: 'inline-flex', alignItems: 'center', gap: 5, cursor: tooltip ? 'pointer' : undefined }}
220
- role={tooltip ? 'button' : undefined}
221
- tabIndex={tooltip ? 0 : undefined}
222
- onClick={tooltip ? (e) => { e.stopPropagation(); setTooltipOpen((v) => !v); } : undefined}
223
- >
226
+ <span className="md-title-sm" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
224
227
  {label}
225
- {tooltip && <Icon name="info" size={13} color="var(--md-on-surface-variant)" />}
228
+ {/* custom info glyph (not the shared lucide "info" from the Icon registry — that name is
229
+ used in many unrelated places, this swap is scoped to just the Rundum-Sorglos-Paket
230
+ tooltip trigger). Shared by all three tabs (Finanzierung/Leasing/Kaufen) and both
231
+ desktop/mobile — ToggleRow itself is the one shared render for all of them. Viewbox
232
+ already bounds every path (1.875–13.125 within 0–15), so no clip-path needed.
233
+ The interactive/focusable/touch-target element is THIS icon wrapper only — the label
234
+ text next to it is plain, inert text, not part of the trigger. */}
235
+ {tooltip && (
236
+ <span
237
+ className="fin-toggle-info-trigger"
238
+ style={{ display: 'inline-flex', cursor: 'pointer' }}
239
+ role="button"
240
+ tabIndex={0}
241
+ aria-label={infoLabel}
242
+ aria-expanded={tooltipOpen}
243
+ onClick={toggleTooltip}
244
+ onKeyDown={(e) => {
245
+ // ARIA authoring practices: a role="button" element must answer to both Enter and
246
+ // Space, not just Enter — a native <button> gets this for free, this span doesn't.
247
+ if (e.key === 'Enter' || e.key === ' ') {
248
+ e.preventDefault();
249
+ toggleTooltip(e);
250
+ }
251
+ }}
252
+ >
253
+ <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" className="lucide">
254
+ <path d="M7.5 13.125C10.6066 13.125 13.125 10.6066 13.125 7.5C13.125 4.3934 10.6066 1.875 7.5 1.875C4.3934 1.875 1.875 4.3934 1.875 7.5C1.875 10.6066 4.3934 13.125 7.5 13.125Z" stroke="#8B98AA" strokeWidth={1.8} />
255
+ <path d="M7.5 7.2V10.5125" stroke="#8B98AA" strokeWidth={1.8} strokeLinecap="round" />
256
+ <path d="M7.5 4.8125H7.50625" stroke="#8B98AA" strokeWidth={2.4} strokeLinecap="round" />
257
+ </svg>
258
+ </span>
259
+ )}
226
260
  </span>
227
261
  {tooltip && tooltipOpen && (
228
262
  <div className="pp-uvp-tooltip fin-toggle-tooltip" onClick={(e) => e.stopPropagation()}>
@@ -243,8 +277,12 @@ export const ToggleRow: React.FunctionComponent<{
243
277
  </div>
244
278
  )}
245
279
  <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginLeft: staticNote ? 'auto' : undefined }}>
246
- {price && (
247
- <span className="num" style={{ fontWeight: 700, fontSize: 13.5, marginRight: staticNote ? 'auto' : undefined }}>
280
+ {/* staticNote (cash mode: package is always included, price already baked into the
281
+ fixed price above) fully replaces the price+switch pair — showing "+1.499 €" right
282
+ next to "inklusive" read as contradictory, so price is suppressed whenever
283
+ staticNote is set, not just the switch. */}
284
+ {price && !staticNote && (
285
+ <span className="num" style={{ fontWeight: 700, fontSize: 13.5 }}>
248
286
  {price}
249
287
  </span>
250
288
  )}
@@ -370,6 +408,13 @@ export interface FinanceCalculatorProps {
370
408
  onDownChange: (down: number) => void;
371
409
  /** term changed. */
372
410
  onTermChange: (term: number) => void;
411
+ /** whether leasing is actually offered for this car (financingConfig.leasing.isActive — same
412
+ * age/mileage-gated flag SearchResultsStore.getLeasingActive stamps for the cards, see
413
+ * cardParts.tsx's PriceTabs / toAutobahnCar.ts's `lease`). Default true keeps existing callers'
414
+ * behavior; the VDP page passes the real computed value. Hides the Leasing tab when false,
415
+ * same as the card — instead of showing a tab that would compute/display a rate the car isn't
416
+ * eligible for. */
417
+ leasingAvailable?: boolean;
373
418
  /** i18n function (drilled from the page). */
374
419
  t: TranslationFunction;
375
420
  }
@@ -461,6 +506,7 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
461
506
  onModeChange,
462
507
  onDownChange,
463
508
  onTermChange,
509
+ leasingAvailable = true,
464
510
  t,
465
511
  }) => {
466
512
  // Pure view state only — whether the adjust/details expander is open.
@@ -518,12 +564,13 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
518
564
  <div style={{ fontFamily: 'var(--md-font-display)', fontSize: 18, fontWeight: 700 }}>{t('autobahn:finance.headerTitle')}</div>
519
565
  </div>
520
566
 
521
- {/* payment-type TABS on top */}
567
+ {/* payment-type TABS on top — Leasing omitted when the car isn't leasing-eligible, same
568
+ rule cardParts.tsx's PriceTabs already applies (car.lease != null there). */}
522
569
  <div className="fin-tabs" role="tablist">
523
570
  {(
524
571
  [
525
572
  ['fin', 'autobahn:finance.mode.financing'],
526
- ['leasing', 'autobahn:finance.mode.leasing'],
573
+ ...(leasingAvailable ? ([['leasing', 'autobahn:finance.mode.leasing']] as Array<[FinanceMode, string]>) : []),
527
574
  ['cash', 'autobahn:finance.mode.cash'],
528
575
  ] as Array<[FinanceMode, string]>
529
576
  ).map(([k, labelKey]) => (
@@ -846,6 +893,7 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
846
893
  staticNote={t('autobahn:finance.addServicesIncluded')}
847
894
  tooltip={<AddServicesTooltipBody t={t} isCash />}
848
895
  tooltipCloseLabel={t('autobahn:modal.close')}
896
+ t={t}
849
897
  />
850
898
  )}
851
899
  </>
@@ -911,6 +959,7 @@ const FinanceCalculator: React.FunctionComponent<FinanceCalculatorProps> = ({
911
959
  onChange={onToggleAddServices}
912
960
  tooltip={<AddServicesTooltipBody t={t} isLease={isLease} />}
913
961
  tooltipCloseLabel={t('autobahn:modal.close')}
962
+ t={t}
914
963
  />
915
964
  )}
916
965
  </div>
@@ -1514,6 +1514,9 @@ export interface VdpBottomBarProps {
1514
1514
  annualMileage?: number;
1515
1515
  /** opens the Finanzierungs-/Leasingdetails popup (expanded); when omitted the link is hidden. */
1516
1516
  onShowDetails?: () => void;
1517
+ /** whether leasing is actually offered for this car — see FinanceCalculatorProps.leasingAvailable
1518
+ * for the full rationale. Default true keeps existing callers' behavior. */
1519
+ leasingAvailable?: boolean;
1517
1520
  /** i18n function (drilled from the page). */
1518
1521
  t: TranslationFunction;
1519
1522
  }
@@ -1538,6 +1541,7 @@ export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({
1538
1541
  term,
1539
1542
  annualMileage,
1540
1543
  onShowDetails,
1544
+ leasingAvailable = true,
1541
1545
  t,
1542
1546
  }) => {
1543
1547
  const [uvpTooltipOpen, setUvpTooltipOpen] = useState(false);
@@ -1592,7 +1596,7 @@ export const VdpBottomBar: React.FunctionComponent<VdpBottomBarProps> = ({
1592
1596
  {(
1593
1597
  [
1594
1598
  ['fin', 'autobahn:finance.mode.financing'],
1595
- ['leasing', 'autobahn:finance.mode.leasing'],
1599
+ ...(leasingAvailable ? ([['leasing', 'autobahn:finance.mode.leasing']] as Array<[FinanceMode, string]>) : []),
1596
1600
  ['cash', 'autobahn:card.price.buy'],
1597
1601
  ] as Array<[FinanceMode, string]>
1598
1602
  ).map(([k, labelKey]) => (
@@ -1963,6 +1967,9 @@ export interface FinanceDetailsModalProps {
1963
1967
  addServicesCostRaw?: number;
1964
1968
  addServicesDisabled?: boolean;
1965
1969
  onToggleAddServices?: (checked: boolean) => void;
1970
+ /** whether leasing is actually offered for this car — see FinanceCalculatorProps.leasingAvailable
1971
+ * for the full rationale. Default true keeps existing callers' behavior. */
1972
+ leasingAvailable?: boolean;
1966
1973
  /** i18n function (drilled from the page). */
1967
1974
  t: TranslationFunction;
1968
1975
  }
@@ -2020,6 +2027,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2020
2027
  addServicesCostRaw,
2021
2028
  addServicesDisabled,
2022
2029
  onToggleAddServices,
2030
+ leasingAvailable = true,
2023
2031
  t,
2024
2032
  }) => {
2025
2033
  // mobile-only: the Finanzierungs-/Leasingdetails breakdown starts collapsed (opened via "Rate
@@ -2546,6 +2554,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2546
2554
  onChange={onToggleAddServices}
2547
2555
  tooltip={<AddServicesTooltipBody t={t} isLease={isLease} />}
2548
2556
  tooltipCloseLabel={t('autobahn:modal.close')}
2557
+ t={t}
2549
2558
  />
2550
2559
  )}
2551
2560
  <div ref={desktopBreakdownRef} style={!isCash ? { marginTop: 18 } : undefined}>
@@ -2825,7 +2834,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
2825
2834
  {(
2826
2835
  [
2827
2836
  ['fin', 'autobahn:finance.mode.financing'],
2828
- ['leasing', 'autobahn:finance.mode.leasing'],
2837
+ ...(leasingAvailable ? ([['leasing', 'autobahn:finance.mode.leasing']] as Array<[FinanceMode, string]>) : []),
2829
2838
  // this sheet is mobile-only, so the cash tab is "Kaufen" here (desktop card says "Barzahlung")
2830
2839
  ['cash', 'autobahn:card.price.buy'],
2831
2840
  ] as Array<[FinanceMode, string]>
@@ -3034,6 +3043,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
3034
3043
  staticNote={t('autobahn:finance.addServicesIncluded')}
3035
3044
  tooltip={<AddServicesTooltipBody t={t} isCash />}
3036
3045
  tooltipCloseLabel={t('autobahn:modal.close')}
3046
+ t={t}
3037
3047
  />
3038
3048
  )}
3039
3049
  {/* pushed to the sheet's bottom edge — Figma keeps a big breathing gap above */}
@@ -3169,6 +3179,7 @@ export const FinanceDetailsModal: React.FunctionComponent<FinanceDetailsModalPro
3169
3179
  onChange={onToggleAddServices}
3170
3180
  tooltip={<AddServicesTooltipBody t={t} isLease={isLease} />}
3171
3181
  tooltipCloseLabel={t('autobahn:modal.close')}
3182
+ t={t}
3172
3183
  />
3173
3184
  )}
3174
3185