@bytebrand/fe-ui-core-autobahn 1.0.157 → 1.0.159

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 CHANGED
@@ -157,6 +157,8 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
157
157
  // active that's already 3, so NEU is dropped rather than causing a 4th to overflow.
158
158
  const activeDealChips = [showDrop || showSave, showPct, !!car.zeroDown].filter(Boolean).length;
159
159
  const showNeu = asGoodAsNew && car.isNew && activeDealChips < 3;
160
+ // tabs the price block can switch to (PriceTabs shows Leasing only when the car has a lease rate)
161
+ const paymentModes: PaymentMode[] = car.lease != null ? ['financing', 'leasing', 'buy'] : ['financing', 'buy'];
160
162
  return (
161
163
  <article
162
164
  className="mcard mcard--interactive cc-card"
@@ -277,18 +279,46 @@ const CarCard: React.FunctionComponent<CarCardProps> = ({
277
279
  <div className="cc-price" style={{ marginTop: 0, borderTop: '1px solid var(--md-outline-variant)' }}>
278
280
  <PriceTabs car={car} mode={mode} onModeChange={setMode} t={t} />
279
281
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginTop: 10 }}>
280
- <PriceHero car={car} mode={mode} t={t} alwaysShowUnit />
282
+ {/* every tab's hero stacked in one grid cell (.cc-hero-stack), only the active one
283
+ visible, bottom-aligned — Kaufen's struck list price sits on its own line above
284
+ the price, so without this the row (and the card) grew/shrank by that line on
285
+ every tab switch. Same trick as .cc-terms-stack below. */}
286
+ <div className="cc-hero-stack">
287
+ {paymentModes.map((m) => (
288
+ <div
289
+ key={m}
290
+ className={'cc-hero-slot' + (m === mode ? '' : ' cc-hero-slot--hidden')}
291
+ aria-hidden={m === mode ? undefined : true}
292
+ >
293
+ <PriceHero car={car} mode={m} t={t} alwaysShowUnit />
294
+ </div>
295
+ ))}
296
+ </div>
281
297
  <PriceEvalCol car={car} t={t} />
282
298
  </div>
283
299
  {/* same fallback CardCtaRow already uses below: pages that haven't wired a real
284
- "Rate anpassen" sheet yet still get a working "Details" link (opens the car). */}
285
- <PriceTerms
286
- car={car}
287
- mode={mode}
288
- onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))}
289
- onShowDetails={onShowFinancingDetails ? () => onShowFinancingDetails(car, mode) : undefined}
290
- t={t}
291
- />
300
+ "Rate anpassen" sheet yet still get a working "Details" link (opens the car).
301
+ Every tab's terms line is rendered into the same grid cell (.cc-terms-stack) and
302
+ only the active one is visible — the cell is as tall as the tallest line, so the
303
+ card keeps one height across Finanzierung/Leasing/Kaufen (Leasing's longer line
304
+ wraps to 2 lines on narrow phones). No change to the visible line itself. */}
305
+ <div className="cc-terms-stack">
306
+ {paymentModes.map((m) => (
307
+ <div
308
+ key={m}
309
+ className={'cc-terms-slot' + (m === mode ? '' : ' cc-terms-slot--hidden')}
310
+ aria-hidden={m === mode ? undefined : true}
311
+ >
312
+ <PriceTerms
313
+ car={car}
314
+ mode={m}
315
+ onOpenRateAdjust={onOpenRateAdjust || (() => onOpen(car))}
316
+ onShowDetails={onShowFinancingDetails ? () => onShowFinancingDetails(car, m) : undefined}
317
+ t={t}
318
+ />
319
+ </div>
320
+ ))}
321
+ </div>
292
322
  </div>
293
323
  {/* Mobile-only row (CardCtaRow itself carries the show-mobile class) — always
294
324
  rendered, with real fallbacks the page can override once it wires its own
@@ -710,7 +710,12 @@ export const PriceTerms: React.FunctionComponent<{
710
710
  onShowDetails?: () => void;
711
711
  t: TranslationFunction;
712
712
  }> = ({ car, mode, onOpenRateAdjust, onShowDetails, t }) => {
713
- if (mode === 'buy') return null;
713
+ // Kaufen (buy): a cash purchase has no financing terms/details link — used to `return null`
714
+ // here, which collapsed .cc-terms to 0 height on that tab while Financing/Leasing rendered a
715
+ // real line, jumping the price row (and everything below it: rating pill, CTA row) up/down on
716
+ // every tab switch. Render an empty, still-present .cc-terms div instead — same fix as the VDP
717
+ // FinanceCalculator's .fin-cond (md3.css), which reserves its line's height the same way.
718
+ if (mode === 'buy') return <div className="cc-terms" />;
714
719
  const finFirstInstallment =
715
720
  car.finFirstInstallment != null ? car.finFirstInstallment.toLocaleString('de-DE') : _finFirstInstallment;
716
721
  const finMonths = car.finMonths ?? _finMonths;
@@ -7,7 +7,7 @@ export interface ICookieModal {
7
7
  handleSentryInit?: () => void;
8
8
  }
9
9
 
10
- const CookieModal = ({ handleSentryInit }: ICookieModal) => {
10
+ const CookieModal = ({ handleSentryInit = () => {} }: ICookieModal) => {
11
11
  const { isVisible, toggleModal } = useModal();
12
12
  const [modal, setModal] = useState('');
13
13
 
@@ -52,8 +52,4 @@ const CookieModal = ({ handleSentryInit }: ICookieModal) => {
52
52
  : <></>;
53
53
  };
54
54
 
55
- CookieModal.defaultProps = {
56
- handleSentryInit: () => {}
57
- };
58
-
59
55
  export default CookieModal;
@@ -10,7 +10,7 @@ export interface ILoaderProps {
10
10
  fluid?: boolean;
11
11
  }
12
12
 
13
- const Loader: React.FC<ILoaderProps> = ({ className, size, fluid }) => {
13
+ const Loader: React.FC<ILoaderProps> = ({ className = '', size = 'medium', fluid = false }) => {
14
14
  const classNames = classnames(
15
15
  styles.loader,
16
16
  { [styles[size]]: !fluid },
@@ -23,10 +23,4 @@ const Loader: React.FC<ILoaderProps> = ({ className, size, fluid }) => {
23
23
  );
24
24
  };
25
25
 
26
- Loader.defaultProps = {
27
- className: '',
28
- size: 'medium',
29
- fluid: false
30
- };
31
-
32
26
  export default Loader;
package/constants/app.ts CHANGED
@@ -40,7 +40,17 @@ export const LOCAL_STORAGE_KEY = {
40
40
  i18n_KEY: 'i18nLanguage'
41
41
  };
42
42
 
43
- export const DEFAULT_LANG: string = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_KEY.i18n_KEY) : 'de';
43
+ // ADV-284: evaluated at import time — with site storage blocked, reading localStorage throws a
44
+ // SecurityError that took down the whole bundle. Blocked storage behaves like "nothing saved".
45
+ const readStoredLang = (): string | null => {
46
+ try {
47
+ return localStorage.getItem(LOCAL_STORAGE_KEY.i18n_KEY);
48
+ } catch (e) {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ export const DEFAULT_LANG: string = typeof window !== 'undefined' ? readStoredLang() : 'de';
44
54
 
45
55
  // Default support contact phone
46
56
  export const CONTACT_PHONE: string = '+498004030182';
package/constants/core.ts CHANGED
@@ -35,7 +35,18 @@ export const LOCAL_STORAGE_KEY = {
35
35
  i18n_KEY: 'i18nLanguage'
36
36
  };
37
37
 
38
- export const DEFAULT_LANG: string = (typeof window !== 'undefined' ? window.localStorage.getItem(LOCAL_STORAGE_KEY.i18n_KEY) : null) ?? 'de';
38
+ // ADV-284: evaluated at import time — with site storage blocked, merely reading
39
+ // window.localStorage throws a SecurityError, which took down the whole bundle (landing
40
+ // page never rendered). Treat blocked storage like "no language saved".
41
+ const readStoredLang = (): string | null => {
42
+ try {
43
+ return typeof window !== 'undefined' ? window.localStorage.getItem(LOCAL_STORAGE_KEY.i18n_KEY) : null;
44
+ } catch (e) {
45
+ return null;
46
+ }
47
+ };
48
+
49
+ export const DEFAULT_LANG: string = readStoredLang() ?? 'de';
39
50
 
40
51
  export const ROW_STYLES = {
41
52
  marginBottom: 10
@@ -7,7 +7,7 @@ type TIconSVG = {
7
7
  customDimensions?: boolean;
8
8
  };
9
9
 
10
- const IconSVG: React.FC<TIconSVG> = ({ name, isWarrantyActive, customDimensions, ...props }: TIconSVG) => {
10
+ const IconSVG: React.FC<TIconSVG> = ({ name, isWarrantyActive, customDimensions = false, ...props }: TIconSVG) => {
11
11
  if (!name) return null;
12
12
 
13
13
  const Component: any = components[name.toString()];
@@ -23,8 +23,4 @@ const IconSVG: React.FC<TIconSVG> = ({ name, isWarrantyActive, customDimensions,
23
23
  );
24
24
  };
25
25
 
26
- IconSVG.defaultProps = {
27
- customDimensions: false
28
- };
29
-
30
26
  export default IconSVG;
@@ -103,7 +103,8 @@ export const vehicleProps = (car?: any, isOfferAvailable: boolean = true) => {
103
103
  wltpDischargedCombinedLabel: `${getFormattedNumber(vehicleOption.wltpCombined != null ? vehicleOption.wltpCombined : 19.9)}l/100km (entladen, komb)`,
104
104
  wltpCo2WtdCombinedShort: `${getFormattedNumber(vehicleOption.wltpCo2)}g CO2/km (gew., komb)*`,
105
105
  co2ClassWeighted: `CO2-Klasse ${(cbd.cbd as any)[vehicleOption.wltpEnergyEfficiencyClass] || vehicleOption.wltpEnergyEfficiencyClass || ''} (gew., komb)`,
106
- co2ClassDischarged: `CO2-Klasse ${(cbd.cbd as any)[vehicleOption.wltpDischargedEnergyEfficiencyClass || 'selector_energyEfficiencyClass_g'] || ''} (entladen, komb)`
106
+ // ADV-187: no hard-coded fallback class — callers only render this when the class is defined.
107
+ co2ClassDischarged: `CO2-Klasse ${(cbd.cbd as any)[vehicleOption.wltpDischargedEnergyEfficiencyClass] || vehicleOption.wltpDischargedEnergyEfficiencyClass || ''} (entladen, komb)`
107
108
  },
108
109
  title: {
109
110
  'addOfferToMainSlide': 'Zum Schieberegler hinzufügen',
package/package.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.157",
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
- "main": "index.ts",
6
- "license": "UNLICENSED",
7
- "peerDependencies": {
8
- "react": "^18.0.0",
9
- "react-dom": "^18.0.0"
10
- },
11
- "dependencies": {
12
- "@bytebrand/car-schema-selectors": "^2.0.7",
13
- "@bytebrand/fe-financing": "^5.14.20",
14
- "classnames": "2.2.6",
15
- "clsx": "^2.1.1",
16
- "d3-format": "1.4.5",
17
- "js-cookie": "^2.2.0",
18
- "lodash": "^4.17.21",
19
- "lucide-react": "^0.288.0",
20
- "mobx-react": "^7.6.0",
21
- "moment": "^2.29.1",
22
- "qs": "^6.6.0",
23
- "react-device-detect": "^2.1.2",
24
- "react-grid-system": "^5.0.2",
25
- "react-lazyload": "^3.2.0",
26
- "react-scroll": "^1.9.3",
27
- "react-slick": "^0.29.0",
28
- "slick-carousel": "1.8.1"
29
- }
30
- }
1
+ {
2
+ "name": "@bytebrand/fe-ui-core-autobahn",
3
+ "version": "1.0.159",
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
+ "main": "index.ts",
6
+ "license": "UNLICENSED",
7
+ "peerDependencies": {
8
+ "react": "^18.0.0",
9
+ "react-dom": "^18.0.0"
10
+ },
11
+ "dependencies": {
12
+ "@bytebrand/car-schema-selectors": "^2.0.7",
13
+ "@bytebrand/fe-financing": "^5.14.20",
14
+ "classnames": "2.2.6",
15
+ "clsx": "^2.1.1",
16
+ "d3-format": "1.4.5",
17
+ "js-cookie": "^2.2.0",
18
+ "lodash": "^4.17.21",
19
+ "lucide-react": "^0.288.0",
20
+ "mobx-react": "^7.6.0",
21
+ "moment": "^2.29.1",
22
+ "qs": "^6.6.0",
23
+ "react-device-detect": "^2.1.2",
24
+ "react-grid-system": "^5.0.2",
25
+ "react-lazyload": "^3.2.0",
26
+ "react-scroll": "^1.9.3",
27
+ "react-slick": "^0.29.0",
28
+ "slick-carousel": "1.8.1"
29
+ }
30
+ }
package/theme/md3.css CHANGED
@@ -2991,6 +2991,12 @@ line-height: 31px;
2991
2991
  .md3 .co-address-grid .mfield-error-text {
2992
2992
  display: none;
2993
2993
  }
2994
+ /* co-birthdate opts back in: future-date/under-18/empty all show the same red border, so
2995
+ unlike the rest of .co-personal-grid the reason actually needs to be spelled out. */
2996
+ .md3 .co-personal-grid .co-birthdate .mfield-error-text {
2997
+ display: block;
2998
+ margin-top: 4px;
2999
+ }
2994
3000
  /* bug: clearing a valid field and tabbing away flashed the base .mfield:focus-within blue
2995
3001
  ring (#005CFF) for a moment before the error class/red border landed — green -> blue ->
2996
3002
  red. Dropping the blue ring here removes that flash; valid/error still show via their own
@@ -3152,6 +3158,20 @@ line-height: 31px;
3152
3158
  /* Vorwahl/Telefonnummer values (the "Land" dropdown has no <input>, so this only hits these two). */
3153
3159
  .md3 .co-phone .mfield input {
3154
3160
  font-size: 15px;
3161
+ padding: 0;
3162
+ }
3163
+ /* valid check floats in the corner, not in the flex row — the digits need the column's width */
3164
+ .md3 .co-phone .mfield .mfield-ok {
3165
+ position: absolute;
3166
+ top: 50%;
3167
+ right: 10px;
3168
+ transform: translateY(-50%);
3169
+ }
3170
+ .md3 .co-phone .mfield {
3171
+ padding: 0 8px;
3172
+ }
3173
+ .md3 .co-phone .mfield.mfield--floating.mfield--valid {
3174
+ padding: 0 25px 0 8px;
3155
3175
  }
3156
3176
  /* "Land" country-code select — same floating-notched-label look as RegistrationForm.tsx's
3157
3177
  .countryCode (OfferRequestModals/modals.styl + mixins.styl's floatingField(), Stylus/CSS
@@ -4812,8 +4832,38 @@ line-height: 31px;
4812
4832
  gap: 12px;
4813
4833
  }
4814
4834
  .md3 .oo-ls-cell {
4815
- padding: 0 !important;
4816
- border-left: 0 !important;
4835
+ padding: 0;
4836
+ }
4837
+ .md3 .oo-ls-cell:not(:first-child) {
4838
+ border-left: 0;
4839
+ }
4840
+ }
4841
+ /* ≤600px Figma mobile variant (63571:28382): rules between rows with 12px above and below,
4842
+ replacing the 800px stack's gap. */
4843
+ @media (max-width: 600px) {
4844
+ .md3 .oo-ls-row {
4845
+ gap: 0;
4846
+ }
4847
+ .md3 .oo-ls-cell {
4848
+ padding: 0 0 12px;
4849
+ }
4850
+ .md3 .oo-ls-cell:not(:first-child) {
4851
+ padding-top: 12px;
4852
+ border-top: 1px solid var(--md-outline-variant);
4853
+ }
4854
+ .md3 .oo-ls-cell:last-child {
4855
+ padding-bottom: 0;
4856
+ }
4857
+ .md3 .oo-ls-title-row .oo-ls-title,
4858
+ .md3 .oo-ls-title-row ~ .oo-ls-sub,
4859
+ .md3 .oo-ls-terms-link {
4860
+ font-family: Arimo, Arial, sans-serif;
4861
+ }
4862
+ .md3 .oo-ls-terms-link {
4863
+ margin-top: 0;
4864
+ }
4865
+ .md3 .oo-ls-title-row .lucide {
4866
+ stroke: var(--md-on-surface-variant);
4817
4867
  }
4818
4868
  }
4819
4869
  /* "2 Jahre Garantie" "i" popover (OrderOverviewNext.tsx) — same AddServicesTooltipBody/
@@ -5440,6 +5490,12 @@ line-height: 31px;
5440
5490
  min-width: 0;
5441
5491
  flex: 1 0 0;
5442
5492
  }
5493
+ .md3 .oo-vs-text {
5494
+ display: flex;
5495
+ flex-direction: column;
5496
+ gap: 3px;
5497
+ min-width: 0;
5498
+ }
5443
5499
  .md3 .oo-vs-name {
5444
5500
  font-family: Arial;
5445
5501
  font-weight: 700;
@@ -5486,6 +5542,40 @@ line-height: 31px;
5486
5542
  .md3 .oo-vs-seller-icon {
5487
5543
  flex: none;
5488
5544
  }
5545
+ /* mobile only (Figma 63571:28325): photo 110×70 and the vehicle text share row 1, the text
5546
+ centred on the photo; the seller row spans the full width below, 12px under the row */
5547
+ @media (max-width: 600px) {
5548
+ .md3 .oo-vs-row {
5549
+ display: grid;
5550
+ grid-template-columns: 110px minmax(0, 1fr);
5551
+ grid-template-rows: minmax(70px, auto) auto;
5552
+ column-gap: 14px;
5553
+ row-gap: 12px;
5554
+ }
5555
+ .md3 .oo-vs-info {
5556
+ display: contents;
5557
+ }
5558
+ .md3 .oo-vs-photo {
5559
+ grid-column: 1;
5560
+ grid-row: 1;
5561
+ width: 110px;
5562
+ height: 70px;
5563
+ border-radius: 0;
5564
+ }
5565
+ .md3 .oo-vs-text {
5566
+ grid-column: 2;
5567
+ grid-row: 1;
5568
+ align-self: center;
5569
+ }
5570
+ .md3 .oo-vs-name {
5571
+ font-size: 16px;
5572
+ }
5573
+ .md3 .oo-vs-seller {
5574
+ grid-column: 1 / -1;
5575
+ grid-row: 2;
5576
+ justify-self: start;
5577
+ }
5578
+ }
5489
5579
  .md3 .oo-row {
5490
5580
  padding: 12px 0;
5491
5581
  border-bottom: 1px solid var(--md-outline-variant);
@@ -5573,10 +5663,18 @@ line-height: 31px;
5573
5663
  gap (14px), same as every other direct child in this step. */
5574
5664
  .md3 .oo-legal-notice {
5575
5665
  margin: 0;
5576
- font-family: Arial;
5666
+ font-family: 'Arimo';
5667
+ font-weight: 400;
5577
5668
  font-size: 13px;
5578
5669
  line-height: 1.5;
5670
+ letter-spacing: 0;
5579
5671
  color: var(--md-on-surface-variant);
5672
+ text-align: left;
5673
+ }
5674
+ @media (max-width: 600px) {
5675
+ .md3 .oo-legal-notice {
5676
+ text-align: justify;
5677
+ }
5580
5678
  }
5581
5679
  .md3 .oo-legal-link {
5582
5680
  text-decoration: underline;
@@ -5864,6 +5962,17 @@ line-height: 31px;
5864
5962
  line instead of sitting visibly lower (more obvious once the label wraps to 2 lines). */
5865
5963
  margin-top: 7px;
5866
5964
  }
5965
+ @media (max-width: 600px) {
5966
+ .md3 .oo-terms-block .mcheck {
5967
+ padding: 0;
5968
+ }
5969
+ .md3 .oo-terms-block .mcheck-box {
5970
+ margin-top: 0;
5971
+ }
5972
+ .md3 .oo-terms-block .mcheck-label {
5973
+ padding-top: 0;
5974
+ }
5975
+ }
5867
5976
  /* Error state (Figma 64670:106293, "Jetzt kaufen" clicked without confirming both agreements) —
5868
5977
  unconfirmed checkbox gets a thicker red box, plus an icon+message row underneath, indented
5869
5978
  32px (20px box + 12px gap) to sit under the label rather than the box. errorIcon (FormCheckbox
@@ -6026,6 +6135,10 @@ line-height: 31px;
6026
6135
  background: var(--md-outline-variant);
6027
6136
  }
6028
6137
  }
6138
+ /* legal-document dialogs: title clears .mmodal-head-action too (24 + 40 + 4 + 40) */
6139
+ .md3 .oo-terms-modal--doc .mmodal-head {
6140
+ padding-right: 108px;
6141
+ }
6029
6142
  /* "8. Rabatt-Code hinzufügen" card (Figma 63570:28464) — its own full-width .co-card at the
6030
6143
  bottom of the page. Unlike "6."/"7." (where only the small chevron button toggles), the whole
6031
6144
  header row is the click target here — the .h3.co-card-title wraps a full-width
@@ -6366,7 +6479,7 @@ line-height: 31px;
6366
6479
  /* every narrowed checkout field (Vorname/Nachname/Straße/Nr./PLZ/Stadt/…) has the same
6367
6480
  .mfield flex gap:10px reserving room for the trailing .mfield-ok checkmark — fine at
6368
6481
  full width, but at mobile widths it was squeezing the input text out of view (same
6369
- root cause the .co-phone-only fix below already addresses for Vorwahl/Telefonnummer;
6482
+ root cause the .co-phone rules above already address for Vorwahl/Telefonnummer;
6370
6483
  this covers the rest of .co-form). Taking the icon out of flow removes that reserved
6371
6484
  space; the field's own right padding is bumped so text still clears the icon. */
6372
6485
  .md3 .co-form .mfield-ok {
@@ -6399,20 +6512,6 @@ line-height: 31px;
6399
6512
  .md3 .co-phone .co-birthdate {
6400
6513
  grid-column: 1 / -1;
6401
6514
  }
6402
- /* Vorwahl/Telefonnummer at 78px/1fr are too narrow for the flex gap:10px .mfield base rule
6403
- to reserve room for .mfield-ok inline — it was squeezing the input itself. Taking the
6404
- checkmark out of flow (.mfield already has position:relative, base rule above) removes
6405
- that reserved space; the input's own right padding (from .mfield--floating.mfield--valid,
6406
- 10px) is bumped so its text still clears the icon. */
6407
- .md3 .co-phone .mfield-ok {
6408
- position: absolute;
6409
- top: 50%;
6410
- right: 10px;
6411
- transform: translateY(-50%);
6412
- }
6413
- .md3 .co-phone .mfield--floating.mfield--valid {
6414
- padding-right: 34px;
6415
- }
6416
6515
  .md3 .co-address-row {
6417
6516
  display: flex;
6418
6517
  flex-direction: column;
@@ -14072,6 +14171,10 @@ color: #005CFF;
14072
14171
  max-width: 800px;
14073
14172
  }
14074
14173
 
14174
+ /* the dialog itself takes focus on open (Modal.tsx): keep its shadow, not the generic focus ring */
14175
+ .md3 .mmodal:focus-visible {
14176
+ box-shadow: var(--md-e3) !important;
14177
+ }
14075
14178
  .md3 .mmodal-head {
14076
14179
  flex: none;
14077
14180
  display: flex;
@@ -14115,6 +14218,25 @@ color: #005CFF;
14115
14218
  .md3 .mmodal-close:hover {
14116
14219
  background: var(--md-sc-high);
14117
14220
  }
14221
+ /* Modal.tsx headerAction: absolute like .mmodal-close, 4px to its left (right = 24 + 40 + 4). */
14222
+ .md3 .mmodal-head-action {
14223
+ position: absolute;
14224
+ top: 20px;
14225
+ right: 68px;
14226
+ width: 40px;
14227
+ height: 40px;
14228
+ padding: 0;
14229
+ border: 0;
14230
+ border-radius: var(--md-r-full);
14231
+ background: transparent;
14232
+ cursor: pointer;
14233
+ display: inline-flex;
14234
+ align-items: center;
14235
+ justify-content: center;
14236
+ }
14237
+ .md3 .mmodal-head-action:hover {
14238
+ background: var(--md-sc-high);
14239
+ }
14118
14240
 
14119
14241
  .md3 .mmodal-body {
14120
14242
  flex: 1 1 auto;
package/ui/Modal.tsx CHANGED
@@ -51,6 +51,8 @@ export interface ModalProps {
51
51
  * md3.css), so callers with a specific close-icon spec (exact asset, absolute position
52
52
  * independent of title height) aren't stuck fighting the default's flex layout. */
53
53
  closeIcon?: React.ReactNode;
54
+ /** optional extra header button rendered before the close button (e.g. a download action). */
55
+ headerAction?: React.ReactNode;
54
56
  }
55
57
 
56
58
  // focusable descendants used by the basic focus trap.
@@ -67,6 +69,7 @@ const Modal: React.FunctionComponent<ModalProps> = ({
67
69
  dismissOnScrim = true,
68
70
  className,
69
71
  closeIcon,
72
+ headerAction,
70
73
  }) => {
71
74
  const surfaceRef = React.useRef<HTMLDivElement | null>(null);
72
75
  // the element focused before the dialog opened, so focus can be restored on close.
@@ -133,7 +136,10 @@ const Modal: React.FunctionComponent<ModalProps> = ({
133
136
  const prevOverflow = document.body.style.overflow;
134
137
  document.body.style.overflow = 'hidden';
135
138
 
136
- // move focus into the dialog (first focusable, else the surface itself).
139
+ // move focus to the first control in the body/footer (an input, a link, a button) so forms are
140
+ // ready to type into and browser autofill can offer itself; the head row (close + headerAction
141
+ // icons) is skipped so a header icon never takes the :focus-visible ring on open. Falls back to
142
+ // the surface itself when the body has nothing focusable.
137
143
  // { preventScroll: true }: this runs at the very first animation frame of the mobile
138
144
  // sheet's own mmodal-sheet-up entrance (md3.css) — at that instant the surface's actual
139
145
  // computed transform is still the keyframe's `from` state (translateY(100%), pushed
@@ -146,7 +152,9 @@ const Modal: React.FunctionComponent<ModalProps> = ({
146
152
  // (same fix as fe-main's BaseModal.tsx, which hit this same glitch first).
147
153
  const surface = surfaceRef.current;
148
154
  if (surface) {
149
- const first = surface.querySelector<HTMLElement>(FOCUSABLE);
155
+ const first = Array.prototype.slice
156
+ .call(surface.querySelectorAll<HTMLElement>(FOCUSABLE))
157
+ .find((el: HTMLElement) => !el.closest('.mmodal-head'));
150
158
  (first || surface).focus({ preventScroll: true });
151
159
  }
152
160
 
@@ -237,6 +245,7 @@ const Modal: React.FunctionComponent<ModalProps> = ({
237
245
  ) : (
238
246
  <span className="mmodal-title" aria-hidden="true" />
239
247
  )}
248
+ {headerAction}
240
249
  {closeIcon ? (
241
250
  <button type="button" className="mmodal-close" aria-label="Schließen" onClick={onClose}>
242
251
  {closeIcon}
@@ -165,7 +165,10 @@ const getDecoratedLightProps = (
165
165
  if (hybridPlugin) {
166
166
  if (wltpWeightedPowerCombinedContent && wltpWeightedCombinedContent) {
167
167
  const dischargedCombined = Number.isFinite(wltpConsumptionCombined) ? wltpConsumptionCombined : 19.9;
168
- const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass || 'selector_energyEfficiencyClass_g';
168
+ // ADV-187: the discharged class is derived server-side from wltpCo2Discharged; when the
169
+ // backend could not derive it the field is selector_unknown — hide the segment, never
170
+ // fall back to a hard-coded class here.
171
+ const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass;
169
172
  const weightedCombinedLabeled = t('vehicleProps:value.wltpWeightedCombinedLabel', { consumption: wltpWeightedCombined.toLocaleString(language), unit: translatedUnit });
170
173
  const dischargedCombinedLabeled = t('vehicleProps:value.wltpDischargedCombinedLabel', { consumption: dischargedCombined.toLocaleString(language), unit: translatedUnit });
171
174
  const co2WeightedContent = Number.isFinite(wltpCo2)
@@ -174,14 +177,16 @@ const getDecoratedLightProps = (
174
177
  const co2ClassWeighted = isPropDefined(wltpEnergyEfficiencyClass)
175
178
  ? t('vehicleProps:value.co2ClassWeighted', { class: t(`cbd:${wltpEnergyEfficiencyClass}`) })
176
179
  : null;
177
- const co2ClassDischarged = t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) });
180
+ const co2ClassDischarged = isPropDefined(dischargedClass)
181
+ ? t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) })
182
+ : null;
178
183
  return <React.Fragment>
179
184
  {`${wltpWeightedPowerCombinedContent} + `}
180
185
  {`${weightedCombinedLabeled}, `}
181
186
  {`${dischargedCombinedLabeled}`}
182
187
  {co2WeightedContent && <>, {co2WeightedContent}</>}
183
188
  {co2ClassWeighted && <>, {co2ClassWeighted}</>}
184
- {<>, {co2ClassDischarged}</>}
189
+ {co2ClassDischarged && <>, {co2ClassDischarged}</>}
185
190
  </React.Fragment>
186
191
  }
187
192
 
@@ -316,7 +321,10 @@ const getDecoratedLightProps = (
316
321
  if (hybridPlugin) {
317
322
  if (wltpWeightedPowerCombinedContent && wltpWeightedCombinedContent) {
318
323
  const dischargedCombined = Number.isFinite(wltpConsumptionCombined) ? wltpConsumptionCombined : 19.9;
319
- const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass || 'selector_energyEfficiencyClass_g';
324
+ // ADV-187: the discharged class is derived server-side from wltpCo2Discharged; when the
325
+ // backend could not derive it the field is selector_unknown — hide the segment, never
326
+ // fall back to a hard-coded class here.
327
+ const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass;
320
328
  const weightedCombinedLabeled = t('vehicleProps:value.wltpWeightedCombinedLabel', { consumption: wltpWeightedCombined.toLocaleString(language), unit: translatedUnit });
321
329
  const dischargedCombinedLabeled = t('vehicleProps:value.wltpDischargedCombinedLabel', { consumption: dischargedCombined.toLocaleString(language), unit: translatedUnit });
322
330
  const co2WeightedContent = Number.isFinite(wltpCo2)
@@ -325,14 +333,16 @@ const getDecoratedLightProps = (
325
333
  const co2ClassWeighted = isPropDefined(wltpEnergyEfficiencyClass)
326
334
  ? t('vehicleProps:value.co2ClassWeighted', { class: t(`cbd:${wltpEnergyEfficiencyClass}`) })
327
335
  : null;
328
- const co2ClassDischarged = t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) });
336
+ const co2ClassDischarged = isPropDefined(dischargedClass)
337
+ ? t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) })
338
+ : null;
329
339
  return <React.Fragment>
330
340
  {`${wltpWeightedPowerCombinedContent} + `}
331
341
  {`${weightedCombinedLabeled}, `}
332
342
  {`${dischargedCombinedLabeled}`}
333
343
  {co2WeightedContent && <>, {co2WeightedContent}</>}
334
344
  {co2ClassWeighted && <>, {co2ClassWeighted}</>}
335
- {<>, {co2ClassDischarged}</>}
345
+ {co2ClassDischarged && <>, {co2ClassDischarged}</>}
336
346
  </React.Fragment>
337
347
  }
338
348
 
@@ -880,10 +880,12 @@ const getDecoratedProps = (
880
880
  // (entladen, komb) fuel consumption + a discharged CO2 class.
881
881
  if (hybridPlugin && wltpConsumptionWeightedPowerCombinedContent && wltpConsumptionWeightedCombinedContent) {
882
882
  const ADV88_DEFAULT_DISCHARGED_COMBINED = 19.9;
883
- const ADV88_DEFAULT_DISCHARGED_CO2_CLASS = 'selector_energyEfficiencyClass_g';
884
883
 
885
884
  const dischargedCombined = Number.isFinite(wltpCombined) ? wltpCombined : ADV88_DEFAULT_DISCHARGED_COMBINED;
886
- const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass || ADV88_DEFAULT_DISCHARGED_CO2_CLASS;
885
+ // ADV-187: the discharged class is derived server-side from wltpCo2Discharged; when the
886
+ // backend could not derive it the field is selector_unknown — hide the segment, never
887
+ // fall back to a hard-coded class here.
888
+ const dischargedClass = car.environmentEmissions.wltpDischargedEnergyEfficiencyClass;
887
889
 
888
890
  const weightedCombinedLabeled = t('vehicleProps:value.wltpWeightedCombinedLabel', { consumption: wltpWeightedCombined.toLocaleString(language), unit: translatedUnit });
889
891
  const dischargedCombinedLabeled = t('vehicleProps:value.wltpDischargedCombinedLabel', { consumption: dischargedCombined.toLocaleString(language), unit: translatedUnit });
@@ -893,7 +895,9 @@ const getDecoratedProps = (
893
895
  const co2ClassWeighted = isPropDefined(wltpEnergyEfficiencyClass)
894
896
  ? t('vehicleProps:value.co2ClassWeighted', { class: t(`cbd:${wltpEnergyEfficiencyClass}`) })
895
897
  : null;
896
- const co2ClassDischarged = t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) });
898
+ const co2ClassDischarged = isPropDefined(dischargedClass)
899
+ ? t('vehicleProps:value.co2ClassDischarged', { class: t(`cbd:${dischargedClass}`) })
900
+ : null;
897
901
 
898
902
  return <React.Fragment>
899
903
  {wrapValue(wltpConsumptionWeightedPowerCombinedContent)}{' + '}
@@ -901,7 +905,7 @@ const getDecoratedProps = (
901
905
  {wrapValue(dischargedCombinedLabeled)}
902
906
  {co2WeightedContent && <>{', '}{wrapValue(co2WeightedContent)}</>}
903
907
  {co2ClassWeighted && <>{', '}{co2ClassWeighted}</>}
904
- {', '}{co2ClassDischarged}
908
+ {co2ClassDischarged && <>{', '}{co2ClassDischarged}</>}
905
909
  </React.Fragment>
906
910
  }
907
911