@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/cards/CarCard.tsx +2 -2
- package/cards/cardParts.tsx +116 -9
- package/locales/de/autobahn.json +2 -1
- package/locales/en/autobahn.json +1 -0
- package/package.json +1 -1
- package/theme/md3.css +127 -18
- package/ui/CardCarousel.tsx +8 -4
- package/ui/Dropdown.tsx +192 -45
- package/ui/FormDropdown.tsx +7 -1
- package/vdp/FinanceCalculator.tsx +62 -13
- package/vdp/VdpModules.tsx +13 -2
- package/vdp/VdpViewer.tsx +141 -50
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
|
-
|
|
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
|
-
|
|
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)
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
|
193
|
-
|
|
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' + (
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
{
|
|
275
|
-
|
|
276
|
-
|
|
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
|
);
|
package/ui/FormDropdown.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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>
|
package/vdp/VdpModules.tsx
CHANGED
|
@@ -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
|
|