@bytebrand/fe-ui-core-autobahn 1.0.120 → 1.0.122
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 +19 -5
- package/cards/CarRow.tsx +20 -7
- package/cards/cardParts.tsx +55 -22
- package/cards/types.ts +10 -0
- package/compat/MdField/MdField.tsx +5 -0
- package/compat/VehicleSmallCard/VehicleData/VechiclePriceItem/VechiclePriceItem.tsx +3 -6
- package/compat/VehicleSmallCard/VehicleData/VehicleInfo/VehicleInfo.tsx +2 -2
- package/compat/VehicleSmallCard/VehicleSmallCard.tsx +6 -4
- package/data/toAutobahnCar.ts +24 -0
- package/package.json +2 -2
- package/theme/md3.css +18 -0
- package/ui/Dropdown.tsx +52 -59
- package/ui/FormDropdown.tsx +104 -32
- package/ui/usePortalMenuPosition.ts +213 -0
- package/vdp/VdpModules.tsx +9 -3
package/cards/CarCard.tsx
CHANGED
|
@@ -51,8 +51,14 @@ export interface CarCardProps {
|
|
|
51
51
|
isFavorite: boolean;
|
|
52
52
|
/** toggle the favorite; the heart calls this (click is kept off the card). */
|
|
53
53
|
onToggleFavorite: () => void;
|
|
54
|
-
/** fired when the card is clicked (opens the detail page).
|
|
55
|
-
|
|
54
|
+
/** fired when the card is clicked (opens the detail page). The second argument is the payment
|
|
55
|
+
* tab the card was showing at click time (ADV-181) — pages forward it to the VDP via the
|
|
56
|
+
* `?mode=` query flag so the originating card and the CDP always agree on the product. */
|
|
57
|
+
onOpen: (car: AutobahnCar, mode?: PaymentMode) => void;
|
|
58
|
+
/** ADV-181 — fired when the user explicitly switches the card's payment tab. Pages use it to
|
|
59
|
+
* persist the per-car product choice (FinancingStore.setSelectedProduct) + analytics. The
|
|
60
|
+
* card still owns the visual tab state; this is a notification, not a controlled input. */
|
|
61
|
+
onModeChange?: (mode: PaymentMode) => void;
|
|
56
62
|
/** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
|
|
57
63
|
onMediaEnter?: () => void;
|
|
58
64
|
/**
|
|
@@ -113,8 +119,16 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
113
119
|
onCall,
|
|
114
120
|
onOpenRateAdjust,
|
|
115
121
|
onShowFinancingDetails,
|
|
122
|
+
onModeChange,
|
|
116
123
|
}) => {
|
|
117
|
-
|
|
124
|
+
// ADV-181: start on the centrally-selected default product (cheaper valid rate of
|
|
125
|
+
// financing vs leasing, or the user's saved per-car choice) — computed by toAutobahnCar,
|
|
126
|
+
// so SSR/hydration agree. Absent (older producer) → financing, the pre-ADV-181 default.
|
|
127
|
+
const [mode, setMode] = useState<PaymentMode>(car.defaultMode ?? 'financing');
|
|
128
|
+
const changeMode = (m: PaymentMode) => {
|
|
129
|
+
setMode(m);
|
|
130
|
+
onModeChange && onModeChange(m);
|
|
131
|
+
};
|
|
118
132
|
const [photoIndex, setPhotoIndex] = useState(0);
|
|
119
133
|
const [viewerOpen, setViewerOpen] = useState(false);
|
|
120
134
|
const mediaRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -160,7 +174,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
160
174
|
return (
|
|
161
175
|
<article
|
|
162
176
|
className="mcard mcard--interactive cc-card"
|
|
163
|
-
onClick={() => onOpen(car)}
|
|
177
|
+
onClick={() => onOpen(car, mode)}
|
|
164
178
|
style={{ display: 'flex', flexDirection: 'column' }}
|
|
165
179
|
>
|
|
166
180
|
<div
|
|
@@ -275,7 +289,7 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
|
|
|
275
289
|
</div>
|
|
276
290
|
<SpecLine items={cardSpec(car)} oneLine />
|
|
277
291
|
<div className="cc-price" style={{ marginTop: 0, borderTop: '1px solid var(--md-outline-variant)' }}>
|
|
278
|
-
<PriceTabs car={car} mode={mode} onModeChange={
|
|
292
|
+
<PriceTabs car={car} mode={mode} onModeChange={changeMode} t={t} />
|
|
279
293
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
|
|
280
294
|
<PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
|
|
281
295
|
<PriceEvalCol car={car} t={t} />
|
package/cards/CarRow.tsx
CHANGED
|
@@ -51,8 +51,13 @@ export interface CarRowProps {
|
|
|
51
51
|
isFavorite: boolean;
|
|
52
52
|
/** toggle the favorite; the hearts call this (click is kept off the card). */
|
|
53
53
|
onToggleFavorite: () => void;
|
|
54
|
-
/** fired when the card (or the CTA) is clicked (opens the detail page).
|
|
55
|
-
|
|
54
|
+
/** fired when the card (or the CTA) is clicked (opens the detail page). The second argument is
|
|
55
|
+
* the payment tab/product the card was showing at click time (ADV-181) — pages forward it to
|
|
56
|
+
* the VDP via `?mode=` so the originating card and the CDP always agree on the product. */
|
|
57
|
+
onOpen: (car: AutobahnCar, mode?: PaymentMode) => void;
|
|
58
|
+
/** ADV-181 — fired when the user explicitly switches the mobile payment tab. Pages use it to
|
|
59
|
+
* persist the per-car product choice (FinancingStore.setSelectedProduct) + analytics. */
|
|
60
|
+
onModeChange?: (mode: PaymentMode) => void;
|
|
56
61
|
/** fired on first hover/touch of the media box — the SRP uses it to lazy-load the gallery. */
|
|
57
62
|
onMediaEnter?: () => void;
|
|
58
63
|
/**
|
|
@@ -91,8 +96,16 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
91
96
|
t,
|
|
92
97
|
adminAction,
|
|
93
98
|
onShowFinancingDetails,
|
|
99
|
+
onModeChange,
|
|
94
100
|
}) => {
|
|
95
|
-
|
|
101
|
+
// ADV-181: start on the centrally-selected default product (see CarCard's identical init).
|
|
102
|
+
// Drives the mobile tab switcher AND the mode reported to onOpen; the desktop branch's
|
|
103
|
+
// untabbed PricePaths reads car.defaultMode directly.
|
|
104
|
+
const [mode, setMode] = useState<PaymentMode>(car.defaultMode ?? 'financing');
|
|
105
|
+
const changeMode = (m: PaymentMode) => {
|
|
106
|
+
setMode(m);
|
|
107
|
+
onModeChange && onModeChange(m);
|
|
108
|
+
};
|
|
96
109
|
const [photoIndex, setPhotoIndex] = useState(0);
|
|
97
110
|
const mediaRef = useRef<HTMLDivElement | null>(null);
|
|
98
111
|
// always call the LATEST onMediaEnter without re-arming the observer on every render (the
|
|
@@ -145,7 +158,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
145
158
|
return (
|
|
146
159
|
<article
|
|
147
160
|
className="mcard mcard--interactive listcard"
|
|
148
|
-
onClick={() => onOpen(car)}
|
|
161
|
+
onClick={() => onOpen(car, mode)}
|
|
149
162
|
style={{ background: 'var(--md-sc-lowest)' }}
|
|
150
163
|
>
|
|
151
164
|
<div className="listcard-media" ref={mediaRef} style={{ position: 'relative' }} onMouseEnter={onMediaEnter} onTouchStart={onMediaEnter}>
|
|
@@ -292,7 +305,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
292
305
|
the previously-unused PricePaths/PriceKauf pair. Both variants share the same
|
|
293
306
|
Preisbewertung meter + CTA, anchored to the bottom (margin-top:auto on .pp-purchase). */}
|
|
294
307
|
<div className="listcard-price">
|
|
295
|
-
<PriceTabs car={car} mode={mode} onModeChange={
|
|
308
|
+
<PriceTabs car={car} mode={mode} onModeChange={changeMode} t={t} className="show-mobile" />
|
|
296
309
|
<div className="pp-purchase show-mobile">
|
|
297
310
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
|
298
311
|
<PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
|
|
@@ -311,7 +324,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
311
324
|
iconRight="arrow-right"
|
|
312
325
|
onClick={(e) => {
|
|
313
326
|
e.stopPropagation();
|
|
314
|
-
onOpen(car);
|
|
327
|
+
onOpen(car, mode);
|
|
315
328
|
}}
|
|
316
329
|
>
|
|
317
330
|
{t('autobahn:card.cta.viewOffer')}
|
|
@@ -360,7 +373,7 @@ const CarRow: React.FunctionComponent<CarRowProps> = ({
|
|
|
360
373
|
iconRight="arrow-right"
|
|
361
374
|
onClick={(e) => {
|
|
362
375
|
e.stopPropagation();
|
|
363
|
-
onOpen(car);
|
|
376
|
+
onOpen(car, mode);
|
|
364
377
|
}}
|
|
365
378
|
>
|
|
366
379
|
{t('autobahn:card.cta.viewOffer')}
|
package/cards/cardParts.tsx
CHANGED
|
@@ -1103,6 +1103,11 @@ const PpRow: React.FunctionComponent<PpRowProps> = ({ label, value, unit, old, f
|
|
|
1103
1103
|
*/
|
|
1104
1104
|
export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, className, onShowDetails }) => {
|
|
1105
1105
|
const hasLease = car.lease != null;
|
|
1106
|
+
// ADV-181: the hero rate follows the centrally-selected default product (cheaper valid rate,
|
|
1107
|
+
// computed in toAutobahnCar). Leasing leads only when it actually won AND is displayable;
|
|
1108
|
+
// 'buy'/absent both fall back to the financing hero (pre-ADV-181 layout) — the purchase price
|
|
1109
|
+
// keeps its own PriceKauf row below either way, per the desktop rate-first rule.
|
|
1110
|
+
const heroMode: PaymentMode = car.defaultMode === 'leasing' && hasLease ? 'leasing' : 'financing';
|
|
1106
1111
|
// Terms of the config the displayed rate was actually computed with (per-car:
|
|
1107
1112
|
// dealer rate + the user's saved localStorage adjustments) — default config
|
|
1108
1113
|
// terms only when the car doesn't carry its own.
|
|
@@ -1130,42 +1135,70 @@ export const PricePaths: React.FunctionComponent<PricePathsProps> = ({ car, t, c
|
|
|
1130
1135
|
) : (
|
|
1131
1136
|
<span className="pp-details-link">{t('autobahn:card.price.details')}</span>
|
|
1132
1137
|
);
|
|
1138
|
+
const finTermsLine = (
|
|
1139
|
+
<>
|
|
1140
|
+
{t('autobahn:card.price.financeTerms', {
|
|
1141
|
+
firstInstallment: finFirstInstallment,
|
|
1142
|
+
months: finMonths,
|
|
1143
|
+
count: finMonths,
|
|
1144
|
+
})}{' '}
|
|
1145
|
+
· {detailsLink('financing')}
|
|
1146
|
+
</>
|
|
1147
|
+
);
|
|
1148
|
+
const leasCompactTerms = hasLeasTerms && (
|
|
1149
|
+
<div className="pp-sub-terms">
|
|
1150
|
+
{/* dedicated keys (not a rework of leaseTerms) so mobile/VDP's own reviewed
|
|
1151
|
+
wording there stays untouched, but includes the same Sonderzahlung figure.
|
|
1152
|
+
Split across two lines with an explicit <br/> (column is too narrow for all
|
|
1153
|
+
three figures + Details on one line) — forced right after km/Jahr so "36" and
|
|
1154
|
+
"Mon." always land on the same line instead of an awkward mid-number wrap. */}
|
|
1155
|
+
{t('autobahn:card.price.leaseTermsCompactLine1', {
|
|
1156
|
+
firstInstallment: leasFirstInstallment,
|
|
1157
|
+
mileage: leasMileageStr,
|
|
1158
|
+
})}{' · '}
|
|
1159
|
+
<br />
|
|
1160
|
+
{t('autobahn:card.price.leaseTermsCompactLine2', { months: leasMonths })}{' '}
|
|
1161
|
+
· {detailsLink('leasing')}
|
|
1162
|
+
</div>
|
|
1163
|
+
);
|
|
1133
1164
|
return (
|
|
1134
1165
|
<div className={'pricepaths' + (className ? ' ' + className : '')}>
|
|
1135
|
-
<div className="pp-eyebrow">
|
|
1136
|
-
|
|
1166
|
+
<div className="pp-eyebrow">
|
|
1167
|
+
{t(heroMode === 'leasing' ? 'autobahn:card.price.leasingFrom' : 'autobahn:card.price.financingFrom')}
|
|
1168
|
+
</div>
|
|
1169
|
+
{/* reuses PriceHero's own rendering — the exact same stacked "UVP-Rate ⓘ /
|
|
1137
1170
|
struck price / mtl." group beside the rate that .cc-price and mobile's tab switcher
|
|
1138
1171
|
already use, so the rate + unit never fight for room in one flat row (the earlier
|
|
1139
1172
|
hand-rolled version here put them side by side and wrapped for any 4+-digit rate). */}
|
|
1140
|
-
<PriceHero car={car} mode=
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
firstInstallment: finFirstInstallment,
|
|
1144
|
-
months: finMonths,
|
|
1145
|
-
count: finMonths,
|
|
1146
|
-
})}{' '}
|
|
1147
|
-
· {detailsLink('financing')}
|
|
1148
|
-
</div>
|
|
1149
|
-
{hasLease && (
|
|
1150
|
-
<div className="pp-lease-row">
|
|
1151
|
-
<PpRow label={t('autobahn:card.price.leasing')} value={car.lease as number} unit="mtl." t={t} />
|
|
1173
|
+
<PriceHero car={car} mode={heroMode} t={t} alwaysShowUnit />
|
|
1174
|
+
{heroMode === 'leasing' ? (
|
|
1175
|
+
<>
|
|
1152
1176
|
{hasLeasTerms && (
|
|
1153
|
-
<div className="pp-
|
|
1154
|
-
{/* dedicated keys (not a rework of leaseTerms) so mobile/VDP's own reviewed
|
|
1155
|
-
wording there stays untouched, but includes the same Sonderzahlung figure.
|
|
1156
|
-
Split across two lines with an explicit <br/> (column is too narrow for all
|
|
1157
|
-
three figures + Details on one line) — forced right after km/Jahr so "36" and
|
|
1158
|
-
"Mon." always land on the same line instead of an awkward mid-number wrap. */}
|
|
1177
|
+
<div className="pp-hero-terms">
|
|
1159
1178
|
{t('autobahn:card.price.leaseTermsCompactLine1', {
|
|
1160
1179
|
firstInstallment: leasFirstInstallment,
|
|
1161
1180
|
mileage: leasMileageStr,
|
|
1162
1181
|
})}{' · '}
|
|
1163
|
-
<br />
|
|
1164
1182
|
{t('autobahn:card.price.leaseTermsCompactLine2', { months: leasMonths })}{' '}
|
|
1165
1183
|
· {detailsLink('leasing')}
|
|
1166
1184
|
</div>
|
|
1167
1185
|
)}
|
|
1168
|
-
|
|
1186
|
+
{/* financing follows as the quiet row — same layout slot the leasing row used before. */}
|
|
1187
|
+
<div className="pp-lease-row">
|
|
1188
|
+
<PpRow label={t('autobahn:finance.mode.financing')} value={car.monthly} unit="mtl." t={t} />
|
|
1189
|
+
<div className="pp-sub-terms">{finTermsLine}</div>
|
|
1190
|
+
</div>
|
|
1191
|
+
</>
|
|
1192
|
+
) : (
|
|
1193
|
+
<>
|
|
1194
|
+
<div className="pp-hero-terms">{finTermsLine}</div>
|
|
1195
|
+
{hasLease && (
|
|
1196
|
+
<div className="pp-lease-row">
|
|
1197
|
+
<PpRow label={t('autobahn:card.price.leasing')} value={car.lease as number} unit="mtl." t={t} />
|
|
1198
|
+
{leasCompactTerms}
|
|
1199
|
+
</div>
|
|
1200
|
+
)}
|
|
1201
|
+
</>
|
|
1169
1202
|
)}
|
|
1170
1203
|
</div>
|
|
1171
1204
|
);
|
package/cards/types.ts
CHANGED
|
@@ -57,6 +57,16 @@ export interface AutobahnCar {
|
|
|
57
57
|
// --- price (always real) ---
|
|
58
58
|
/** financing monthly rate in EUR — the LEAD price on every card. */
|
|
59
59
|
monthly: number;
|
|
60
|
+
/**
|
|
61
|
+
* ADV-181 — which payment product leads on this card. Computed centrally by
|
|
62
|
+
* toAutobahnCar via fe-financing's pickDefaultProduct (cheaper valid rate of
|
|
63
|
+
* financing vs leasing; tie → financing; neither valid → 'buy'), then
|
|
64
|
+
* overridden by the user's saved explicit per-car selection when the fe-main
|
|
65
|
+
* transform stamped one (storeCar.selectedProduct). Cards initialise their
|
|
66
|
+
* tab state from this; absent (older producer) → 'financing', the pre-ADV-181
|
|
67
|
+
* behavior. Derived purely from card data, so SSR and hydration agree.
|
|
68
|
+
*/
|
|
69
|
+
defaultMode?: 'financing' | 'leasing' | 'buy';
|
|
60
70
|
/**
|
|
61
71
|
* financing terms the `monthly` rate was computed with — the per-car merged
|
|
62
72
|
* config (dealer rate + the user's saved localStorage adjustments, ADV-157).
|
|
@@ -79,6 +79,11 @@ const MdField: React.FunctionComponent<MdFieldProps> = ({
|
|
|
79
79
|
<div
|
|
80
80
|
className={
|
|
81
81
|
'mfield'
|
|
82
|
+
// .mfield's base height:48px + align-items:center is sized for a single-line input.
|
|
83
|
+
// .mfield.mtextarea (md3.css) overrides that to height:auto + align-items:stretch for a
|
|
84
|
+
// multiline field — without it a tall `rows` textarea just overflows past the 48px box
|
|
85
|
+
// instead of growing it, visually spilling over whatever sits below/beside the field.
|
|
86
|
+
+ (multiline ? ' mtextarea' : '')
|
|
82
87
|
+ (hasError ? ' mfield--error' : '')
|
|
83
88
|
+ (disabled ? ' mfield--disabled' : '')
|
|
84
89
|
+ (floatingLabel ? ' mfield--floating' : '')
|
|
@@ -39,10 +39,9 @@ export interface IVehiclePriceItemProps {
|
|
|
39
39
|
isMarge?: boolean;
|
|
40
40
|
}
|
|
41
41
|
class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
|
|
42
|
-
routeToActiveTab
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
42
|
+
// ADV-181: the old routeToActiveTab (localStorage 'activeTabCDP' write) was removed — its only
|
|
43
|
+
// reader was the unrouted legacy VDP sidebar, so the write was a no-op on the live site. The
|
|
44
|
+
// live VDP resolves its tab centrally (lowest-rate rule / saved per-car choice / `?mode=`).
|
|
46
45
|
|
|
47
46
|
render(): React.ReactNode {
|
|
48
47
|
const {
|
|
@@ -103,7 +102,6 @@ class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
|
|
|
103
102
|
...routeObj,
|
|
104
103
|
target,
|
|
105
104
|
rel,
|
|
106
|
-
onClick: this.routeToActiveTab,
|
|
107
105
|
className: priceItemCategoryClassName,
|
|
108
106
|
href: url
|
|
109
107
|
})}
|
|
@@ -188,7 +186,6 @@ class VehiclePriceItem extends React.Component<IVehiclePriceItemProps> {
|
|
|
188
186
|
...routeObj,
|
|
189
187
|
target,
|
|
190
188
|
rel,
|
|
191
|
-
onClick: this.routeToActiveTab,
|
|
192
189
|
className: styles.priceItemPrice,
|
|
193
190
|
href: url
|
|
194
191
|
})}
|
|
@@ -166,8 +166,8 @@ const VehicleInfo: React.FC<IVehicleInfoProps> = (props) => {
|
|
|
166
166
|
),
|
|
167
167
|
...routeObj,
|
|
168
168
|
className: wrapClasses,
|
|
169
|
-
// ADV-
|
|
170
|
-
|
|
169
|
+
// ADV-181: the dead activeTabCDP write removed (its only reader was the unrouted legacy
|
|
170
|
+
// VDP sidebar) — the live VDP resolves its tab centrally.
|
|
171
171
|
href: url,
|
|
172
172
|
target,
|
|
173
173
|
rel
|
|
@@ -319,8 +319,9 @@ const VehicleSmallCard: FunctionComponent<IVehicleSmallCardProps> = (props) => {
|
|
|
319
319
|
target,
|
|
320
320
|
rel,
|
|
321
321
|
className: `${styles.redirectClassLink} ${styles.gridAreaTitle}`,
|
|
322
|
-
// ADV-
|
|
323
|
-
|
|
322
|
+
// ADV-181: the dead activeTabCDP write removed (its only reader was the unrouted
|
|
323
|
+
// legacy VDP sidebar) — the live VDP resolves its tab centrally (lowest-rate rule /
|
|
324
|
+
// saved per-car choice / `?mode=` query flag).
|
|
324
325
|
href: url
|
|
325
326
|
})}
|
|
326
327
|
|
|
@@ -373,8 +374,9 @@ const VehicleSmallCard: FunctionComponent<IVehicleSmallCardProps> = (props) => {
|
|
|
373
374
|
target,
|
|
374
375
|
rel,
|
|
375
376
|
className: `${styles.redirectClassLink} ${styles.gridAreaImage}`,
|
|
376
|
-
// ADV-
|
|
377
|
-
|
|
377
|
+
// ADV-181: the dead activeTabCDP write removed (its only reader was the unrouted
|
|
378
|
+
// legacy VDP sidebar) — the live VDP resolves its tab centrally (lowest-rate rule /
|
|
379
|
+
// saved per-car choice / `?mode=` query flag).
|
|
378
380
|
href: url
|
|
379
381
|
})}
|
|
380
382
|
<VehicleInfo {...vehicleInfoProps} />
|
package/data/toAutobahnCar.ts
CHANGED
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
// Every field path below was confirmed against the real code (file:line in
|
|
24
24
|
// ./fieldMapping.ts), not guessed.
|
|
25
25
|
|
|
26
|
+
import { pickDefaultProduct } from '@bytebrand/fe-financing';
|
|
27
|
+
|
|
26
28
|
import { getPriceRating, getPredictablePrice } from '../utils/priceRating';
|
|
27
29
|
import { formatTimestamp } from '../utils/DateUtils';
|
|
28
30
|
import { getPowerLabel } from '../utils/power';
|
|
@@ -427,6 +429,27 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
|
|
|
427
429
|
const leaseMonths = leasingActive ? num(price?.leasing?.paybackPeriod) : undefined;
|
|
428
430
|
const leaseFirstInstallment = leasingActive ? num(price?.leasing?.firstInstallment) : undefined;
|
|
429
431
|
|
|
432
|
+
// --- ADV-181: leading payment product — ONE central rule for every card surface. ---
|
|
433
|
+
// Automatic default = fe-financing's pickDefaultProduct on the same displayed rates the
|
|
434
|
+
// card renders (cheaper valid rate wins, tie → financing, neither valid → 'buy'). The
|
|
435
|
+
// user's saved explicit per-car choice (storeCar.selectedProduct, stamped by fe-main's
|
|
436
|
+
// transformDataForVehicles from FinancingStore) overrides the automatic rule — but only
|
|
437
|
+
// while that product is actually displayable on this card, so a stale "leasing" choice on
|
|
438
|
+
// a car that aged out of leasing eligibility falls back to the automatic rule instead of
|
|
439
|
+
// selecting a product the card can't show. Pure input → pure output: SSR (fe-landing)
|
|
440
|
+
// computes the identical value from the same payload, so hydration never flips the tab.
|
|
441
|
+
const picked = pickDefaultProduct({
|
|
442
|
+
financing: { monthlyInstallment: monthly },
|
|
443
|
+
leasing: { isActive: leasingActive, monthlyInstallment: lease ?? null },
|
|
444
|
+
});
|
|
445
|
+
const savedProduct = (storeCar as any).selectedProduct;
|
|
446
|
+
const defaultMode: 'financing' | 'leasing' | 'buy' =
|
|
447
|
+
savedProduct === 'leasing' && lease != null
|
|
448
|
+
? 'leasing'
|
|
449
|
+
: savedProduct === 'financing' && monthly > 0
|
|
450
|
+
? 'financing'
|
|
451
|
+
: picked.product;
|
|
452
|
+
|
|
430
453
|
// --- optional: list price / savings — ONLY from a real strike price ---
|
|
431
454
|
// transformDataForVehicles exposes oldMonthlyInstallment / highestPrice via
|
|
432
455
|
// the financing spread, and price.historyPriceDifference. There is NO reliable
|
|
@@ -494,6 +517,7 @@ export function toAutobahnCar(storeCar: any, deps: ToAutobahnCarDeps = {}): Auto
|
|
|
494
517
|
|
|
495
518
|
// price
|
|
496
519
|
monthly,
|
|
520
|
+
defaultMode,
|
|
497
521
|
...(finFirstInstallment != null ? { finFirstInstallment } : {}),
|
|
498
522
|
...(finMonths != null ? { finMonths } : {}),
|
|
499
523
|
price: purchase,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebrand/fe-ui-core-autobahn",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.122",
|
|
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",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@bytebrand/car-schema-selectors": "^2.0.7",
|
|
13
|
-
"@bytebrand/fe-financing": "^5.14.
|
|
13
|
+
"@bytebrand/fe-financing": "^5.14.20",
|
|
14
14
|
"classnames": "2.2.6",
|
|
15
15
|
"clsx": "^2.1.1",
|
|
16
16
|
"d3-format": "1.4.5",
|
package/theme/md3.css
CHANGED
|
@@ -9563,6 +9563,11 @@ color: #005CFF;
|
|
|
9563
9563
|
align-items: flex-start;
|
|
9564
9564
|
padding: 7px 14px;
|
|
9565
9565
|
}
|
|
9566
|
+
/* nothing selected yet: single-line placeholder, same as a normal select — center it instead
|
|
9567
|
+
of top-aligning (top-align only earns its keep once there are wrapped chips to protect). */
|
|
9568
|
+
.md3 .mfd-multi-btn--empty {
|
|
9569
|
+
align-items: center;
|
|
9570
|
+
}
|
|
9566
9571
|
.md3 .mfd-multi-btn .mselect-chev {
|
|
9567
9572
|
align-self: center;
|
|
9568
9573
|
}
|
|
@@ -9650,6 +9655,11 @@ color: #005CFF;
|
|
|
9650
9655
|
display: flex;
|
|
9651
9656
|
flex-direction: column;
|
|
9652
9657
|
gap: 6px;
|
|
9658
|
+
/* this is the element that actually sits IN a CSS grid column (e.g. VehicleEdit Safety
|
|
9659
|
+
.row) — grid items default to a content-based auto min-width too, so this needs the
|
|
9660
|
+
same override as .mcheck/.mcheck-label below or a long label can still force its own
|
|
9661
|
+
column wider than its track instead of wrapping inside it. */
|
|
9662
|
+
min-width: 0;
|
|
9653
9663
|
}
|
|
9654
9664
|
|
|
9655
9665
|
/* 44px touch target: the label is the hit area; the visual box sits centred inside. */
|
|
@@ -9663,6 +9673,12 @@ color: #005CFF;
|
|
|
9663
9673
|
font-family: var(--md-font);
|
|
9664
9674
|
color: var(--md-on-surface);
|
|
9665
9675
|
-webkit-tap-highlight-color: transparent;
|
|
9676
|
+
/* a flex item's automatic min-width is its content's un-wrapped size, not 0 — inside a
|
|
9677
|
+
narrow grid column (see VehicleEdit Safety .row) that let a long label's text refuse to
|
|
9678
|
+
wrap and push past the column instead. 0 lets this actually shrink to whatever width its
|
|
9679
|
+
container gives it, same as .mcheck-label below (belt-and-suspenders: either alone fixes
|
|
9680
|
+
it depending on which element the browser treats as the shrink boundary). */
|
|
9681
|
+
min-width: 0;
|
|
9666
9682
|
}
|
|
9667
9683
|
.md3 .mcheck--disabled {
|
|
9668
9684
|
cursor: not-allowed;
|
|
@@ -9720,6 +9736,8 @@ color: #005CFF;
|
|
|
9720
9736
|
line-height: 1.5;
|
|
9721
9737
|
padding-top: 7px;
|
|
9722
9738
|
color: var(--md-on-surface);
|
|
9739
|
+
/* see .mcheck's min-width:0 above — same fix, on the actual text-bearing flex item. */
|
|
9740
|
+
min-width: 0;
|
|
9723
9741
|
}
|
|
9724
9742
|
/* rich labels (T&C/permission) — links pick up the brand colour. */
|
|
9725
9743
|
.md3 .mcheck-label a {
|
package/ui/Dropdown.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import React from 'react';
|
|
|
21
21
|
import * as ReactDOM from 'react-dom';
|
|
22
22
|
|
|
23
23
|
import Icon from './Icon';
|
|
24
|
+
import { usePortalMenuPosition } from './usePortalMenuPosition';
|
|
24
25
|
|
|
25
26
|
export interface DropdownOption {
|
|
26
27
|
value: string;
|
|
@@ -104,7 +105,6 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
104
105
|
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
105
106
|
const btnRef = React.useRef<HTMLButtonElement | null>(null);
|
|
106
107
|
const menuRef = React.useRef<HTMLUListElement | null>(null);
|
|
107
|
-
const menuElRef = React.useRef<HTMLDivElement | null>(null);
|
|
108
108
|
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
|
109
109
|
// stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
|
|
110
110
|
const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
|
|
@@ -205,60 +205,18 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
205
205
|
return () => document.removeEventListener('mousedown', onDocMouseDown);
|
|
206
206
|
}, [open, close]);
|
|
207
207
|
|
|
208
|
-
// menuPortal:
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return () => {
|
|
221
|
-
if (portalRoot.parentNode) portalRoot.parentNode.removeChild(portalRoot);
|
|
222
|
-
};
|
|
223
|
-
}, [menuPortal, open, portalRoot]);
|
|
224
|
-
|
|
225
|
-
// menuPortal position: computed from the trigger's on-screen rect (position:fixed, so it's
|
|
226
|
-
// viewport-relative — kept in sync on scroll/resize below). ESTIMATED_MENU_HEIGHT (the list's
|
|
227
|
-
// own max-height, 288px, plus the menu's padding/border) only needs to be a reasonable guess —
|
|
228
|
-
// it's used to DECIDE whether to flip above the trigger, not to size anything; the "above"
|
|
229
|
-
// placement is positioned via `bottom` instead of `top` so it grows upward regardless of its
|
|
230
|
-
// real height.
|
|
231
|
-
const ESTIMATED_MENU_HEIGHT = 300;
|
|
232
|
-
const [menuPos, setMenuPos] = React.useState<
|
|
233
|
-
{ width: number; top?: number; bottom?: number; left?: number; right?: number } | null
|
|
234
|
-
>(null);
|
|
235
|
-
|
|
236
|
-
const positionMenu = React.useCallback(() => {
|
|
237
|
-
if (!menuPortal || !rootRef.current || typeof window === 'undefined') return;
|
|
238
|
-
const rect = rootRef.current.getBoundingClientRect();
|
|
239
|
-
const spaceBelow = window.innerHeight - rect.bottom;
|
|
240
|
-
const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && rect.top > spaceBelow;
|
|
241
|
-
setMenuPos({
|
|
242
|
-
width: rect.width,
|
|
243
|
-
left: menuAlign === 'right' ? undefined : rect.left,
|
|
244
|
-
right: menuAlign === 'right' ? window.innerWidth - rect.right : undefined,
|
|
245
|
-
top: openAbove ? undefined : rect.bottom + 4,
|
|
246
|
-
bottom: openAbove ? window.innerHeight - rect.top + 4 : undefined,
|
|
247
|
-
});
|
|
248
|
-
}, [menuPortal, menuAlign]);
|
|
249
|
-
|
|
250
|
-
React.useEffect(() => {
|
|
251
|
-
if (!open || !menuPortal) return undefined;
|
|
252
|
-
positionMenu();
|
|
253
|
-
// capture:true — a scroll inside any nested scrollable ancestor (not just the window) must
|
|
254
|
-
// also reposition, since the trigger moves relative to the viewport either way.
|
|
255
|
-
window.addEventListener('scroll', positionMenu, true);
|
|
256
|
-
window.addEventListener('resize', positionMenu);
|
|
257
|
-
return () => {
|
|
258
|
-
window.removeEventListener('scroll', positionMenu, true);
|
|
259
|
-
window.removeEventListener('resize', positionMenu);
|
|
260
|
-
};
|
|
261
|
-
}, [open, menuPortal, positionMenu]);
|
|
208
|
+
// menuPortal: body-portal root + trigger-relative position, tracked on scroll/resize, flipping
|
|
209
|
+
// above the trigger when there isn't room below. Shared with MultiDropdown's checklist menu
|
|
210
|
+
// below — see usePortalMenuPosition's own doc comment for the full reasoning (every timing/CSS
|
|
211
|
+
// fix that went into this belongs in one place so the two consumers can't drift apart).
|
|
212
|
+
const onCloseAway = React.useCallback(() => close(false, false), [close]);
|
|
213
|
+
const { portalRoot, menuPos, menuElRef } = usePortalMenuPosition({
|
|
214
|
+
enabled: menuPortal,
|
|
215
|
+
open,
|
|
216
|
+
rootRef,
|
|
217
|
+
menuAlign,
|
|
218
|
+
onCloseAway,
|
|
219
|
+
});
|
|
262
220
|
|
|
263
221
|
// keep the active option scrolled into view while navigating by keyboard.
|
|
264
222
|
React.useEffect(() => {
|
|
@@ -347,13 +305,18 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
347
305
|
const suppressEmptyMenu = allowCustomValue && searchable && query.trim() !== '' && filteredOptions.length === 0;
|
|
348
306
|
|
|
349
307
|
// shared between the in-place popover and the menuPortal one — identical option list either way.
|
|
350
|
-
|
|
308
|
+
// maxHeight (menuPortal only — see positionMenu) overrides the CSS static 288px cap with the
|
|
309
|
+
// space actually available in whichever direction the menu opened; inline style wins over the
|
|
310
|
+
// class rule regardless of specificity, and falls back to the CSS default when omitted (the
|
|
311
|
+
// in-place, non-portal popover keeps relying on that + whatever ancestor clipping already existed).
|
|
312
|
+
const renderMenuList = (maxHeight?: number) => (
|
|
351
313
|
<ul
|
|
352
314
|
ref={menuRef}
|
|
353
315
|
id={`${idBase}list`}
|
|
354
316
|
className="mselect-list"
|
|
355
317
|
role="listbox"
|
|
356
318
|
tabIndex={-1}
|
|
319
|
+
style={maxHeight != null ? { maxHeight } : undefined}
|
|
357
320
|
aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
|
|
358
321
|
>
|
|
359
322
|
{filteredOptions.map((opt, i) => (
|
|
@@ -433,7 +396,13 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
433
396
|
ref={menuElRef}
|
|
434
397
|
className={'mselect-menu mselect-menu--portal' + (searchable ? ' mselect-menu--searchable' : '')}
|
|
435
398
|
// menuPos is null for one frame on open, before the position effect has measured
|
|
436
|
-
// the trigger — stay invisible
|
|
399
|
+
// the trigger — stay invisible (not just visually, via `visibility`) rather than
|
|
400
|
+
// flash at (0,0)/full-width. `position: fixed` is set UNCONDITIONALLY, in both
|
|
401
|
+
// branches — leaving it out of this fallback used to mean the element briefly
|
|
402
|
+
// rendered as a normal in-flow block at the end of <body> for that one frame,
|
|
403
|
+
// which could visibly shift the page's scroll position the instant a dropdown
|
|
404
|
+
// opened (the block's real height — up to .mselect-list's 288px — briefly counted
|
|
405
|
+
// toward the document's scrollable height before flipping back out of flow).
|
|
437
406
|
style={
|
|
438
407
|
menuPos
|
|
439
408
|
? {
|
|
@@ -444,11 +413,34 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
444
413
|
right: menuPos.right,
|
|
445
414
|
width: menuPos.width,
|
|
446
415
|
minWidth: menuPos.width,
|
|
416
|
+
// Root cause of the "open above" collapse (menuPos.top/left now always a
|
|
417
|
+
// concrete number-or-'auto', never omitted — see the menuPos state comment):
|
|
418
|
+
// top/bottom (and left/right) here used to leave the unanchored side as
|
|
419
|
+
// `undefined`, which React just drops from the inline style rather than
|
|
420
|
+
// writing `auto`. With `top` missing from the DOM's inline style, the
|
|
421
|
+
// `.mselect-menu` stylesheet rule (`top: calc(100% + 4px)`) filled back in —
|
|
422
|
+
// fighting the inline `bottom` we DID set. Both `top` and `bottom` pinned at
|
|
423
|
+
// once, with `height` still auto, forces the used height to the distance
|
|
424
|
+
// between them (CSS 2.1 §10.6.4), which computed out negative (top landing
|
|
425
|
+
// just past the viewport's bottom edge, bottom landing just above the
|
|
426
|
+
// trigger) — clamped to 0, leaving only .mselect-menu's own padding+border
|
|
427
|
+
// visible (6px*2 + 1px*2 = 14px, exactly the reported near-zero height). The
|
|
428
|
+
// `top`-anchored "open below" case never hit this: its inline `top` overrode
|
|
429
|
+
// the stylesheet regardless, and `bottom` being class-omitted (not written at
|
|
430
|
+
// all, `auto` is the CSS default for `bottom`) matched the intended math.
|
|
431
|
+
// With every side now explicitly 'auto' when unused, no stylesheet fallback
|
|
432
|
+
// can leak in and `height` is a normal shrink-to-fit-then-clamped-by-maxHeight
|
|
433
|
+
// calculation in both directions. This own explicit maxHeight (list's
|
|
434
|
+
// maxHeight + .mselect-menu's padding/border, 6px*2 + 1px*2 = 14) still
|
|
435
|
+
// matters on top of that fix — box-sizing: border-box means maxHeight here
|
|
436
|
+
// must include the padding/border the inner .mselect-list's own maxHeight
|
|
437
|
+
// (renderMenuList below) doesn't count, or the list gets squeezed twice.
|
|
438
|
+
maxHeight: menuPos.maxHeight + 14,
|
|
447
439
|
}
|
|
448
|
-
: { visibility: 'hidden' }
|
|
440
|
+
: { position: 'fixed', visibility: 'hidden' }
|
|
449
441
|
}
|
|
450
442
|
>
|
|
451
|
-
{renderMenuList()}
|
|
443
|
+
{renderMenuList(menuPos?.maxHeight)}
|
|
452
444
|
</div>,
|
|
453
445
|
portalRoot,
|
|
454
446
|
)
|
|
@@ -466,3 +458,4 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
466
458
|
};
|
|
467
459
|
|
|
468
460
|
export default Dropdown;
|
|
461
|
+
|
package/ui/FormDropdown.tsx
CHANGED
|
@@ -30,10 +30,12 @@
|
|
|
30
30
|
// `.md3` subtree.
|
|
31
31
|
|
|
32
32
|
import * as React from 'react';
|
|
33
|
+
import * as ReactDOM from 'react-dom';
|
|
33
34
|
import { observer } from 'mobx-react';
|
|
34
35
|
|
|
35
36
|
import Icon from './Icon';
|
|
36
37
|
import Dropdown, { DropdownOption } from './Dropdown';
|
|
38
|
+
import { usePortalMenuPosition } from './usePortalMenuPosition';
|
|
37
39
|
|
|
38
40
|
export interface FormDropdownProps {
|
|
39
41
|
/** mobx-react-form field (form.$('basicDataForm.mainData_make') etc.). Untyped — the lib ships no TS types. */
|
|
@@ -59,10 +61,10 @@ export interface FormDropdownProps {
|
|
|
59
61
|
style?: React.CSSProperties;
|
|
60
62
|
/** extra class on the wrapper (legacy `className`). */
|
|
61
63
|
className?: string;
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
64
|
+
/** escapes a known clipping ancestor (e.g. a collapsing Accordion region) by rendering the
|
|
65
|
+
* popover/checklist into a document.body portal instead of nesting it in place — see
|
|
66
|
+
* Dropdown.tsx's own doc comment (single-select) / usePortalMenuPosition.ts (shared with
|
|
67
|
+
* MultiDropdown's checklist below). Off by default. */
|
|
66
68
|
menuPortal?: boolean;
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -137,6 +139,7 @@ const FormDropdown: React.FunctionComponent<FormDropdownProps> = observer(
|
|
|
137
139
|
ariaLabel={label}
|
|
138
140
|
style={style}
|
|
139
141
|
className={className}
|
|
142
|
+
menuPortal={menuPortal}
|
|
140
143
|
/>
|
|
141
144
|
);
|
|
142
145
|
}
|
|
@@ -211,25 +214,45 @@ interface MultiProps {
|
|
|
211
214
|
ariaLabel: string;
|
|
212
215
|
style?: React.CSSProperties;
|
|
213
216
|
className?: string;
|
|
217
|
+
/** see FormDropdownProps.menuPortal above. */
|
|
218
|
+
menuPortal?: boolean;
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
|
|
217
|
-
({ field, options, label, hasError, disabled, ariaLabel, style, className }) => {
|
|
222
|
+
({ field, options, label, hasError, disabled, ariaLabel, style, className, menuPortal }) => {
|
|
218
223
|
const [open, setOpen] = React.useState(false);
|
|
219
224
|
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
220
225
|
const btnRef = React.useRef<HTMLButtonElement | null>(null);
|
|
221
226
|
|
|
222
227
|
const selected: string[] = Array.isArray(field.value) ? field.value.map(String) : [];
|
|
223
228
|
|
|
224
|
-
//
|
|
229
|
+
// menuPortal: same body-portal + trigger-relative positioning as the single-select Dropdown
|
|
230
|
+
// — see usePortalMenuPosition.ts. A checklist can run just as long as any other option list
|
|
231
|
+
// (Safety's Airbag field has 13 entries) and lives in the exact same clipping Accordion
|
|
232
|
+
// section the single-select fix targeted, so it needs the identical escape hatch.
|
|
233
|
+
const onCloseAway = React.useCallback(() => setOpen(false), []);
|
|
234
|
+
const { portalRoot, menuPos, menuElRef } = usePortalMenuPosition<HTMLUListElement>({
|
|
235
|
+
enabled: !!menuPortal,
|
|
236
|
+
open,
|
|
237
|
+
rootRef,
|
|
238
|
+
onCloseAway,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// click-away (guarded for SSR), matching the base Dropdown. menuElRef is checked too — with
|
|
242
|
+
// menuPortal the checklist renders outside rootRef (at document.body), so without this every
|
|
243
|
+
// option click would look like a click-away and close the menu before toggle() had a chance
|
|
244
|
+
// to fire.
|
|
225
245
|
React.useEffect(() => {
|
|
226
246
|
if (!open || typeof document === 'undefined') return undefined;
|
|
227
247
|
const onDocMouseDown = (e: MouseEvent) => {
|
|
228
|
-
|
|
248
|
+
const target = e.target as Node;
|
|
249
|
+
if (rootRef.current && rootRef.current.contains(target)) return;
|
|
250
|
+
if (menuElRef.current && menuElRef.current.contains(target)) return;
|
|
251
|
+
setOpen(false);
|
|
229
252
|
};
|
|
230
253
|
document.addEventListener('mousedown', onDocMouseDown);
|
|
231
254
|
return () => document.removeEventListener('mousedown', onDocMouseDown);
|
|
232
|
-
}, [open]);
|
|
255
|
+
}, [open, menuElRef]);
|
|
233
256
|
|
|
234
257
|
const toggle = (value: string) => {
|
|
235
258
|
if (disabled) return;
|
|
@@ -261,6 +284,29 @@ const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
|
|
|
261
284
|
return o ? o.label : value;
|
|
262
285
|
};
|
|
263
286
|
|
|
287
|
+
// shared between the in-place checklist and the menuPortal one — identical options either way.
|
|
288
|
+
const renderOptions = () =>
|
|
289
|
+
options.map((opt, i) => {
|
|
290
|
+
const checked = selected.indexOf(opt.value) > -1;
|
|
291
|
+
return (
|
|
292
|
+
<li key={`${opt.value}_${i}`} role="presentation">
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
role="option"
|
|
296
|
+
aria-selected={checked}
|
|
297
|
+
disabled={opt.disabled}
|
|
298
|
+
className={'mselect-opt mfd-multi-opt' + (checked ? ' is-checked' : '')}
|
|
299
|
+
onClick={() => toggle(opt.value)}
|
|
300
|
+
>
|
|
301
|
+
<span className={'mfd-check' + (checked ? ' mfd-check--on' : '')}>
|
|
302
|
+
{checked ? <Icon name="check" size={14} /> : null}
|
|
303
|
+
</span>
|
|
304
|
+
<span className="mselect-opt-label">{opt.label}</span>
|
|
305
|
+
</button>
|
|
306
|
+
</li>
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
264
310
|
return (
|
|
265
311
|
<div className={'mfd' + (className ? ' ' + className : '')} style={style}>
|
|
266
312
|
{label ? <label className="md-label-sm mfield-label mfd-label">{label}</label> : null}
|
|
@@ -268,7 +314,16 @@ const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
|
|
|
268
314
|
<button
|
|
269
315
|
ref={btnRef}
|
|
270
316
|
type="button"
|
|
271
|
-
|
|
317
|
+
// .mfd-multi-btn top-aligns its content (align-items:flex-start) so wrapped chips
|
|
318
|
+
// don't clip once there are enough of them to reach a second line — but with nothing
|
|
319
|
+
// selected yet, the trigger shows one single-line placeholder just like a normal
|
|
320
|
+
// select, and that same top-align just shoved it up instead of centering it.
|
|
321
|
+
// --empty switches it back to centered for exactly that (no chips) case.
|
|
322
|
+
className={
|
|
323
|
+
'mselect-btn mfd-multi-btn' +
|
|
324
|
+
(selected.length ? '' : ' mfd-multi-btn--empty') +
|
|
325
|
+
(hasError ? ' mfd-invalid' : '')
|
|
326
|
+
}
|
|
272
327
|
aria-haspopup="listbox"
|
|
273
328
|
aria-expanded={open}
|
|
274
329
|
aria-label={ariaLabel}
|
|
@@ -299,29 +354,46 @@ const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
|
|
|
299
354
|
)}
|
|
300
355
|
<Icon name="chevron-down" cls="mselect-chev" />
|
|
301
356
|
</button>
|
|
302
|
-
{open && (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
357
|
+
{open && (menuPortal && portalRoot
|
|
358
|
+
? ReactDOM.createPortal(
|
|
359
|
+
<ul
|
|
360
|
+
ref={menuElRef}
|
|
361
|
+
className="mselect-menu mselect-menu--portal mfd-multi-menu"
|
|
362
|
+
role="listbox"
|
|
363
|
+
aria-multiselectable="true"
|
|
364
|
+
tabIndex={-1}
|
|
365
|
+
// see Dropdown.tsx's render for why every side is explicit ('auto', never
|
|
366
|
+
// omitted — the "opens but nothing visible" collapse otherwise) and why
|
|
367
|
+
// maxHeight adds the box's own padding/border (+14, box-sizing:border-box).
|
|
368
|
+
// overflowY:auto: unlike the single-select's .mselect-list, .mfd-multi-menu has
|
|
369
|
+
// no height cap of its own (in-place it just grows — fine there, clipped by the
|
|
370
|
+
// accordion instead of scrolling; portaled it needs to scroll internally once it
|
|
371
|
+
// hits the space actually available).
|
|
372
|
+
style={
|
|
373
|
+
menuPos
|
|
374
|
+
? {
|
|
375
|
+
position: 'fixed',
|
|
376
|
+
top: menuPos.top,
|
|
377
|
+
bottom: menuPos.bottom,
|
|
378
|
+
left: menuPos.left,
|
|
379
|
+
right: menuPos.right,
|
|
380
|
+
width: menuPos.width,
|
|
381
|
+
minWidth: menuPos.width,
|
|
382
|
+
maxHeight: menuPos.maxHeight + 14,
|
|
383
|
+
overflowY: 'auto',
|
|
384
|
+
}
|
|
385
|
+
: { position: 'fixed', visibility: 'hidden' }
|
|
386
|
+
}
|
|
387
|
+
>
|
|
388
|
+
{renderOptions()}
|
|
389
|
+
</ul>,
|
|
390
|
+
portalRoot,
|
|
391
|
+
)
|
|
392
|
+
: (
|
|
393
|
+
<ul className="mselect-menu mfd-multi-menu" role="listbox" aria-multiselectable="true" tabIndex={-1}>
|
|
394
|
+
{renderOptions()}
|
|
395
|
+
</ul>
|
|
396
|
+
)
|
|
325
397
|
)}
|
|
326
398
|
</div>
|
|
327
399
|
{hasError ? <span className="mfield-error-text mfd-error">{field.error}</span> : null}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Autobahn (auto.de redesign) — shared body-portal positioning for md3 popover menus.
|
|
2
|
+
//
|
|
3
|
+
// Extracted out of Dropdown.tsx (the single-select) so MultiDropdown (FormDropdown.tsx's
|
|
4
|
+
// isMulti checklist) can get the exact same `menuPortal` escape-hatch — same clipping problem
|
|
5
|
+
// (a checklist menu inside a VehicleEdit Accordion section gets cut off by `.macc-region-inner`'s
|
|
6
|
+
// overflow:hidden, see md3.css), same fix, and every hard-won timing/CSS fix that went into this
|
|
7
|
+
// belongs in ONE place so the two consumers can't drift out of sync. See Dropdown.tsx's own
|
|
8
|
+
// `menuPortal` doc comment for the user-facing behaviour this produces (escapes clipping
|
|
9
|
+
// ancestors, tracks the trigger on scroll/resize while open, flips above when there's no room
|
|
10
|
+
// below, closes instead of trying to visually "chase" the trigger on a real user scroll).
|
|
11
|
+
//
|
|
12
|
+
// Consumers own their OWN <ul>/menu markup and className — this hook only owns: the body-portal
|
|
13
|
+
// root element, the trigger-relative position math, and the effect timing/scroll-listener
|
|
14
|
+
// plumbing. Apply `menuPos` to whatever floating element you render into `portalRoot` via
|
|
15
|
+
// ReactDOM.createPortal, and pass that same element's ref as `menuElRef` so its own internal
|
|
16
|
+
// scrolling doesn't get misread as a user scrolling the trigger away.
|
|
17
|
+
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
|
|
20
|
+
export interface PortalMenuPos {
|
|
21
|
+
width: number;
|
|
22
|
+
// always a concrete value — a real px number for whichever side is anchoring the box, 'auto'
|
|
23
|
+
// (never omitted) for the other. React drops a style property from the DOM entirely when its
|
|
24
|
+
// value is `undefined`, so `undefined` is NOT "unset" the way it is in the style object here in
|
|
25
|
+
// JS — it just means "don't touch whatever's already cascading in from CSS", and the menu's own
|
|
26
|
+
// stylesheet rule (`top: calc(100% + 4px); left: 0;`) was right there to fill back in and fight
|
|
27
|
+
// the side that WAS set inline, forcing a negative used-height (CSS 2.1 §10.6.4, clamped to 0 —
|
|
28
|
+
// the "opens but nothing visible" collapse). Explicit 'auto' writes a real `top: auto` / `left:
|
|
29
|
+
// auto` inline, which — unlike an omitted property — reliably beats the stylesheet rule
|
|
30
|
+
// regardless of specificity, so only the intended side ever constrains the box.
|
|
31
|
+
top: number | 'auto';
|
|
32
|
+
bottom: number | 'auto';
|
|
33
|
+
left: number | 'auto';
|
|
34
|
+
right: number | 'auto';
|
|
35
|
+
// the space actually available in whichever direction the menu opened, clamped to a sane
|
|
36
|
+
// [120, ESTIMATED_MENU_HEIGHT-12] range — apply as the floating element's OWN maxHeight (plus
|
|
37
|
+
// its padding/border, box-sizing:border-box) so it never relies on shrink-to-fit auto-sizing.
|
|
38
|
+
maxHeight: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface UsePortalMenuPositionArgs {
|
|
42
|
+
/** opt-in flag (the `menuPortal` prop on the consumer). */
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
open: boolean;
|
|
45
|
+
/** the trigger's wrapping element — always rendered (regardless of `open`), measured for position. */
|
|
46
|
+
rootRef: React.RefObject<HTMLElement>;
|
|
47
|
+
menuAlign?: 'left' | 'right';
|
|
48
|
+
/** called when the trigger scrolls out of view entirely, or a real user scroll happens after
|
|
49
|
+
* the post-open grace window — the menu has nothing sensible left to anchor to or chase. */
|
|
50
|
+
onCloseAway: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// TMenuEl: the concrete element type of whatever you portal (Dropdown.tsx portals a wrapping
|
|
54
|
+
// `<div>`; MultiDropdown's checklist has no separate wrapper and portals its `<ul>` directly) —
|
|
55
|
+
// keeps `menuElRef` assignable straight to that element's own `ref` without a cast.
|
|
56
|
+
export interface UsePortalMenuPositionResult<TMenuEl extends HTMLElement = HTMLDivElement> {
|
|
57
|
+
/** a fresh `div.md3` appended to `document.body` while open (so --md-* tokens and every
|
|
58
|
+
* `.md3 .mselect-*` rule still resolve at body level, same technique as ui/Modal.tsx) — pass
|
|
59
|
+
* as the portal target to `ReactDOM.createPortal`. `null` when `!enabled`. */
|
|
60
|
+
portalRoot: HTMLDivElement | null;
|
|
61
|
+
/** `null` for one frame on open, before position is first measured — render `visibility:hidden`
|
|
62
|
+
* (not just omit inline styles) in that case, see Dropdown.tsx's render for the exact pattern. */
|
|
63
|
+
menuPos: PortalMenuPos | null;
|
|
64
|
+
/** attach to the actual floating menu element you portal — excludes its own internal scrolling
|
|
65
|
+
* (e.g. a long option list) from being misread as the user scrolling the trigger away, and is
|
|
66
|
+
* also what click-away logic should check alongside the trigger's own root. */
|
|
67
|
+
menuElRef: React.RefObject<TMenuEl>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// the list's own max-height guess (plus the menu box's padding/border) — only needs to be a
|
|
71
|
+
// reasonable estimate; it decides whether to flip ABOVE the trigger. The real size is then
|
|
72
|
+
// clamped to the space actually available (see `maxHeight` above), not just guessed at.
|
|
73
|
+
const ESTIMATED_MENU_HEIGHT = 300;
|
|
74
|
+
// a scroll within this window of opening REPOSITIONS instead of closing — see the effect below.
|
|
75
|
+
const SELF_SCROLL_GRACE_MS = 500;
|
|
76
|
+
|
|
77
|
+
export function usePortalMenuPosition<TMenuEl extends HTMLElement = HTMLDivElement>({
|
|
78
|
+
enabled,
|
|
79
|
+
open,
|
|
80
|
+
rootRef,
|
|
81
|
+
menuAlign = 'left',
|
|
82
|
+
onCloseAway,
|
|
83
|
+
}: UsePortalMenuPositionArgs): UsePortalMenuPositionResult<TMenuEl> {
|
|
84
|
+
const menuElRef = React.useRef<TMenuEl | null>(null);
|
|
85
|
+
// timestamp of the last open — lets the close-on-scroll listener below tell a genuine user
|
|
86
|
+
// scroll apart from a scroll OUR OWN opening sequence causes (e.g. a searchable dropdown's own
|
|
87
|
+
// scrollIntoView, or the browser's native "scroll a just-focused element into view" behaviour).
|
|
88
|
+
// useLayoutEffect (not useEffect): must be set before the scroll-listener effect further down
|
|
89
|
+
// can possibly attach and fire — same synchronous pre-paint phase as the portal-attach/position
|
|
90
|
+
// effects below, so there's no window where a scroll could be misjudged against a stale (or
|
|
91
|
+
// zero) timestamp.
|
|
92
|
+
const openedAtRef = React.useRef(0);
|
|
93
|
+
React.useLayoutEffect(() => {
|
|
94
|
+
if (open) openedAtRef.current = Date.now();
|
|
95
|
+
}, [open]);
|
|
96
|
+
|
|
97
|
+
const portalRoot = React.useMemo<HTMLDivElement | null>(() => {
|
|
98
|
+
if (!enabled || typeof document === 'undefined') return null;
|
|
99
|
+
const el = document.createElement('div');
|
|
100
|
+
el.className = 'md3';
|
|
101
|
+
return el;
|
|
102
|
+
}, [enabled]);
|
|
103
|
+
|
|
104
|
+
// useLayoutEffect, not useEffect: createPortal(children, portalRoot) renders content INTO
|
|
105
|
+
// portalRoot the moment `open` becomes true, but that's a detached node until THIS effect
|
|
106
|
+
// appends it to document.body; a plain useEffect only runs after the browser paints, so there
|
|
107
|
+
// was a real window where the (correctly rendered, correctly positioned) menu simply wasn't
|
|
108
|
+
// attached to the visible page yet — reading as "opens but nothing appears" rather than a late
|
|
109
|
+
// attach.
|
|
110
|
+
React.useLayoutEffect(() => {
|
|
111
|
+
if (!enabled || !open || !portalRoot || typeof document === 'undefined') return undefined;
|
|
112
|
+
document.body.appendChild(portalRoot);
|
|
113
|
+
return () => {
|
|
114
|
+
if (portalRoot.parentNode) portalRoot.parentNode.removeChild(portalRoot);
|
|
115
|
+
};
|
|
116
|
+
}, [enabled, open, portalRoot]);
|
|
117
|
+
|
|
118
|
+
// the portal menu's z-index (1400, .mselect-menu--portal) is intentionally higher than every
|
|
119
|
+
// other fixed overlay it must paint over (checkout sheets/modals) — which also means it would
|
|
120
|
+
// paint over the sticky app header (.topbar, position:sticky/top:0/z-index:50) if left
|
|
121
|
+
// unconstrained. Reading the header's live bottom edge (0 when absent, e.g. inside a modal, or
|
|
122
|
+
// when TopBar's autoHideOnScroll has slid it away) gives a correct top boundary without
|
|
123
|
+
// hardcoding a pixel height that could drift from the header's own CSS.
|
|
124
|
+
const getSafeTop = (): number => {
|
|
125
|
+
if (typeof document === 'undefined') return 0;
|
|
126
|
+
const header = document.querySelector('.topbar');
|
|
127
|
+
return header ? Math.max(0, header.getBoundingClientRect().bottom) : 0;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const [menuPos, setMenuPos] = React.useState<PortalMenuPos | null>(null);
|
|
131
|
+
|
|
132
|
+
const positionMenu = React.useCallback(() => {
|
|
133
|
+
if (!enabled || !rootRef.current || typeof window === 'undefined') return;
|
|
134
|
+
const rect = rootRef.current.getBoundingClientRect();
|
|
135
|
+
const GAP = 4;
|
|
136
|
+
const safeTop = getSafeTop();
|
|
137
|
+
// the trigger itself scrolled out of view (fully under the header or past either viewport
|
|
138
|
+
// edge) — position:fixed keeps the menu exactly where it last was, visually "stuck" over
|
|
139
|
+
// unrelated content instead of tracking a trigger that isn't on screen to track anymore. Just
|
|
140
|
+
// dismiss it, same as a native <select>'s options close the instant you scroll the trigger away.
|
|
141
|
+
if (rect.bottom <= safeTop || rect.top >= window.innerHeight) {
|
|
142
|
+
onCloseAway();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const spaceBelow = window.innerHeight - rect.bottom - GAP;
|
|
146
|
+
const spaceAbove = rect.top - safeTop - GAP;
|
|
147
|
+
const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow;
|
|
148
|
+
// cap the menu to the space it ACTUALLY has in whichever direction it opens, instead of
|
|
149
|
+
// trusting a static CSS max-height on its own — near the header or viewport edge that static
|
|
150
|
+
// cap can still be taller than what's really available, which is what let the menu paint over
|
|
151
|
+
// the header in the first place. The consumer's own overflow-y:auto takes it from there once
|
|
152
|
+
// content exceeds this.
|
|
153
|
+
const available = Math.max(120, Math.min(ESTIMATED_MENU_HEIGHT - 12, openAbove ? spaceAbove : spaceBelow));
|
|
154
|
+
setMenuPos({
|
|
155
|
+
width: rect.width,
|
|
156
|
+
left: menuAlign === 'right' ? 'auto' : rect.left,
|
|
157
|
+
right: menuAlign === 'right' ? window.innerWidth - rect.right : 'auto',
|
|
158
|
+
top: openAbove ? 'auto' : rect.bottom + GAP,
|
|
159
|
+
bottom: openAbove ? window.innerHeight - rect.top + GAP : 'auto',
|
|
160
|
+
maxHeight: available,
|
|
161
|
+
});
|
|
162
|
+
}, [enabled, menuAlign, onCloseAway, rootRef]);
|
|
163
|
+
|
|
164
|
+
// Synchronous (pre-paint) position computation. useLayoutEffect — not useEffect — matters here:
|
|
165
|
+
// it runs right after the DOM mutation that mounted the portal but BEFORE the browser paints, so
|
|
166
|
+
// there is no frame where the menu is mounted-but-unpositioned. positionMenu also needs rootRef
|
|
167
|
+
// already attached, which it is by the time this runs — the wrapper element it measures is
|
|
168
|
+
// always rendered regardless of `open`, so its ref was set on an earlier commit already.
|
|
169
|
+
React.useLayoutEffect(() => {
|
|
170
|
+
if (!open || !enabled) return;
|
|
171
|
+
positionMenu();
|
|
172
|
+
}, [open, enabled, positionMenu]);
|
|
173
|
+
|
|
174
|
+
React.useEffect(() => {
|
|
175
|
+
if (!open || !enabled) return undefined;
|
|
176
|
+
// A scroll of the page/an ancestor CLOSES the menu instead of repositioning it. Continuously
|
|
177
|
+
// chasing the trigger during a scroll (recompute + setState + re-render) always lags a frame or
|
|
178
|
+
// more behind the browser's own native, synchronous scroll — the fixed-position menu visibly
|
|
179
|
+
// trails the trigger and then "snaps" into place once scrolling settles. A native <select> (and
|
|
180
|
+
// most real-world custom ones) just closes on scroll instead; that reads as correct rather than
|
|
181
|
+
// janky, and sidesteps the lag entirely instead of trying to out-run it. capture:true so a
|
|
182
|
+
// scroll on any nested scrollable ancestor is caught too, not just the window. Scrolling INSIDE
|
|
183
|
+
// the menu itself (menuElRef's own subtree) must NOT close it — that's just browsing a long
|
|
184
|
+
// list, not scrolling the trigger away — so that's the one scroll target excluded.
|
|
185
|
+
// A short grace window after opening REPOSITIONS instead of closing on scroll — two different
|
|
186
|
+
// self-triggered scrolls land in this window: a searchable dropdown's own scrollIntoView effect
|
|
187
|
+
// smooth-scrolls the trigger into view the instant the menu opens, AND — for every dropdown —
|
|
188
|
+
// the BROWSER ITSELF auto-scrolls a just-focused element into view when it was partially cut
|
|
189
|
+
// off at the viewport edge. Both were previously just swallowed (ignored) here, on the theory
|
|
190
|
+
// that "our own open" scroll isn't a real user scroll-away — true, but positionMenu() was
|
|
191
|
+
// computed ONCE, before that scroll happened, so the menu was left floating at its PRE-scroll
|
|
192
|
+
// coordinates while the page settled at a new scroll offset under it. Resyncing the position on
|
|
193
|
+
// every grace-window scroll fixes that (cheap: one rect read + setState) without reintroducing
|
|
194
|
+
// the janky continuous-chase during a real user scroll — this only runs for the ~500ms settle
|
|
195
|
+
// window right after open, not for scrolling in general (that still closes, below).
|
|
196
|
+
const onScroll = (e: Event) => {
|
|
197
|
+
if (menuElRef.current && e.target instanceof Node && menuElRef.current.contains(e.target)) return;
|
|
198
|
+
if (Date.now() - openedAtRef.current < SELF_SCROLL_GRACE_MS) {
|
|
199
|
+
positionMenu();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
onCloseAway();
|
|
203
|
+
};
|
|
204
|
+
window.addEventListener('scroll', onScroll, true);
|
|
205
|
+
window.addEventListener('resize', positionMenu);
|
|
206
|
+
return () => {
|
|
207
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
208
|
+
window.removeEventListener('resize', positionMenu);
|
|
209
|
+
};
|
|
210
|
+
}, [open, enabled, positionMenu, onCloseAway]);
|
|
211
|
+
|
|
212
|
+
return { portalRoot, menuPos, menuElRef };
|
|
213
|
+
}
|
package/vdp/VdpModules.tsx
CHANGED
|
@@ -1424,12 +1424,18 @@ export interface VdpStickyBarProps {
|
|
|
1424
1424
|
show: boolean;
|
|
1425
1425
|
/** primary CTA click. */
|
|
1426
1426
|
onSecure?: () => void;
|
|
1427
|
+
/** ADV-181: monthly rate for the CURRENTLY selected payment product (fin/leasing differ) —
|
|
1428
|
+
* falls back to vehicle.monthly (the financing rate) when omitted, the pre-ADV-181 display. */
|
|
1429
|
+
monthly?: number;
|
|
1430
|
+
/** ADV-181: label of the selected product ("Finanzierung"/"Leasing") — replaces the generic
|
|
1431
|
+
* "ab" eyebrow so the sticky bar's label always matches the rate it shows. */
|
|
1432
|
+
productLabel?: string;
|
|
1427
1433
|
/** i18n function (drilled from the page). */
|
|
1428
1434
|
t: TranslationFunction;
|
|
1429
1435
|
}
|
|
1430
1436
|
|
|
1431
1437
|
/** Desktop sticky summary bar (AS24 pattern) — docks to top, driven by `show`. */
|
|
1432
|
-
export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, t }) => (
|
|
1438
|
+
export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehicle, image, show, onSecure, monthly, productLabel, t }) => (
|
|
1433
1439
|
<div className={'vdp-stickytop hide-mobile' + (show ? ' vdp-stickytop--on' : '')}>
|
|
1434
1440
|
<div className="container" style={{ display: 'flex', alignItems: 'center', gap: 18, padding: '10px 24px' }}>
|
|
1435
1441
|
<img
|
|
@@ -1455,9 +1461,9 @@ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehic
|
|
|
1455
1461
|
</div>
|
|
1456
1462
|
<div style={{ textAlign: 'right', flex: 'none' }}>
|
|
1457
1463
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, justifyContent: 'flex-end' }}>
|
|
1458
|
-
<span className="md-label-sm">{t('autobahn:sticky.from')}</span>
|
|
1464
|
+
<span className="md-label-sm">{productLabel || t('autobahn:sticky.from')}</span>
|
|
1459
1465
|
<span className="num" style={{ fontWeight: 700, fontSize: 20, color: 'var(--md-primary)', letterSpacing: '-.4px' }}>
|
|
1460
|
-
{vehicle.monthly} €
|
|
1466
|
+
{monthly ?? vehicle.monthly} €
|
|
1461
1467
|
</span>
|
|
1462
1468
|
<span className="md-label-sm">{t('autobahn:sticky.perMonth')}</span>
|
|
1463
1469
|
</div>
|