@bytebrand/fe-ui-core-autobahn 1.0.94 → 1.0.96

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/chrome/TopBar.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useState, useRef, useEffect } from 'react';
2
2
  import { TranslationFunction } from '../types';
3
3
 
4
4
  import { Button, Icon, IconButton } from '../ui';
@@ -110,6 +110,10 @@ export interface TopBarProps {
110
110
  * number/tel: link was confirmed for this, so it defaults to the contact page. Pass a `tel:...`
111
111
  * href here once one is confirmed. */
112
112
  phoneHref?: string;
113
+ /** mobile-only: slide the header out of view on scroll-down, back in on scroll-up (coordinated
114
+ * with the VDP sticky offer bottom bar's own show/hide). Off by default — only the car-detail
115
+ * page (topBarVariant 'conversion') opts in; every other page keeps the header always docked. */
116
+ autoHideOnScroll?: boolean;
113
117
  }
114
118
 
115
119
  /**
@@ -137,9 +141,28 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
137
141
  onLangSelect,
138
142
  onNavigate,
139
143
  phoneHref = '/service/kontakt/',
144
+ autoHideOnScroll = false,
140
145
  }) => {
141
146
  const [menu, setMenu] = useState(false); // mobile hamburger dropdown
142
147
  const [account, setAccount] = useState(false); // account popover
148
+ const [hidden, setHidden] = useState(false); // autoHideOnScroll: header slid out of view
149
+
150
+ // scroll-direction tracking for autoHideOnScroll — down hides, up shows, and it never hides
151
+ // near the very top (HIDE_AT) so the header doesn't disappear on the first tiny scroll nudge.
152
+ const lastScrollY = useRef(0);
153
+ useEffect(() => {
154
+ if (!autoHideOnScroll) return undefined;
155
+ const HIDE_AT = 80;
156
+ lastScrollY.current = window.scrollY;
157
+ const onScroll = (): void => {
158
+ const y = window.scrollY;
159
+ const goingDown = y > lastScrollY.current;
160
+ setHidden(y > HIDE_AT && goingDown);
161
+ lastScrollY.current = y;
162
+ };
163
+ window.addEventListener('scroll', onScroll, { passive: true });
164
+ return () => window.removeEventListener('scroll', onScroll);
165
+ }, [autoHideOnScroll]);
143
166
 
144
167
  const navigate = onNavigate || ((to: string): void => { window.location.href = to; });
145
168
 
@@ -455,14 +478,18 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
455
478
  </div>
456
479
  );
457
480
 
481
+ // suppress the auto-hide while the mobile hamburger/account layer is open — an in-progress
482
+ // interaction sliding away with the header would be jarring, and the account layer/menu render
483
+ // as children of <header> below so they'd vanish along with it.
484
+ const effectivelyHidden = hidden && !menu && !account;
485
+
458
486
  return (
459
487
  <header
488
+ className={'topbar' + (effectivelyHidden ? ' topbar--hidden' : '')}
460
489
  style={{
461
490
  position: 'sticky',
462
491
  top: 0,
463
492
  zIndex: 50,
464
- background: 'var(--md-surface)',
465
- borderBottom: '1px solid var(--md-outline-variant)',
466
493
  }}
467
494
  >
468
495
  <div
@@ -22,6 +22,16 @@ const CookieModal = ({ handleSentryInit }: ICookieModal) => {
22
22
  []
23
23
  );
24
24
 
25
+ useEffect(
26
+ () => {
27
+ if (!isVisible) return undefined;
28
+ const previousOverflow = document.body.style.overflow;
29
+ document.body.style.overflow = 'hidden';
30
+ return () => { document.body.style.overflow = previousOverflow; };
31
+ },
32
+ [isVisible]
33
+ );
34
+
25
35
  const onClearModalState = () => {
26
36
  setModal('');
27
37
  };
@@ -0,0 +1,12 @@
1
+ @import '../legacy-theme/mixins.styl'
2
+
3
+ // SURFACE (Modal.tsx) sets max-height: 92vh inline for desktop — on mobile the
4
+ // modal should grow to fit its full content instead of scrolling internally.
5
+ // !important is required to win over the inline style attribute.
6
+ .surface
7
+ +media-tablet-down()
8
+ max-height: none !important
9
+
10
+ .overlay
11
+ +media-tablet-down()
12
+ padding-top: 71px !important
@@ -16,6 +16,7 @@ import * as React from 'react';
16
16
  import * as ReactDOM from 'react-dom';
17
17
 
18
18
  import ModalsConfig from './ModalsConfig';
19
+ import styles from './Modal.styl';
19
20
 
20
21
  interface IBasicModal {
21
22
  isVisible: boolean;
@@ -33,9 +34,9 @@ const OVERLAY: React.CSSProperties = {
33
34
  };
34
35
 
35
36
  const SURFACE: React.CSSProperties = {
36
- background: 'var(--md-sc-lowest, #fff)',
37
- borderRadius: 'var(--md-r-lg, 16px)',
38
- boxShadow: 'var(--md-e3, 0 8px 24px rgba(0,0,0,.2))',
37
+ background: '#FFFFFF',
38
+ borderRadius: '10px',
39
+ boxShadow: '0px 0px 20px rgba(76, 78, 100, 0.4)',
39
40
  maxWidth: 640, width: '100%', maxHeight: '92vh', overflowY: 'auto'
40
41
  };
41
42
 
@@ -46,8 +47,8 @@ const BasicModal = ({ isVisible, toggleModal, name, modalProps, handleSentryInit
46
47
  // .mbtn used by the modal buttons) under `.md3` and intentionally does
47
48
  // nothing outside it — a bare document.body portal loses the theme.
48
49
  <div className='md3'>
49
- <div style={OVERLAY} role='dialog' aria-modal='true' aria-label='Cookie-Einstellungen'>
50
- <div style={SURFACE}>
50
+ <div className={styles.overlay} style={OVERLAY} role='dialog' aria-modal='true' aria-label='Cookie-Einstellungen'>
51
+ <div className={styles.surface} style={SURFACE}>
51
52
  {ModalsConfig[name] && ModalsConfig[name]({ ...modalProps, toggleModal, handleSentryInit })}
52
53
  </div>
53
54
  </div>
@@ -1,10 +1,12 @@
1
1
  @import '../../../legacy-theme/mixins.styl';
2
2
 
3
3
  .modalWrapper
4
- padding: 13px 15px 28px;
4
+ padding: 25px;
5
5
  text-align: center
6
6
  box-sizing: border-box
7
- max-width: 600px
7
+ max-width: 100%
8
+ +media-tablet-down()
9
+ padding: 16px 15px 12px 15px
8
10
 
9
11
  .modalTitle
10
12
  font-family: sans-serif
@@ -24,14 +26,18 @@
24
26
  text-align: justify;
25
27
  letter-spacing: 0.15px;
26
28
  color: rgba(76, 78, 100, 0.87);
27
- margin-top: 12px
28
- margin-right: 25px
29
+ margin-top: 5px
30
+ margin-right: 7px
31
+ .accordionDescription
32
+ display: block
33
+ font-size: 14px
34
+ line-height: 18px
35
+ text-align: left
36
+ margin-bottom: 5px
29
37
 
30
- +media-tablet-up()
31
- margin-top: 20px
32
38
  .accordions
33
39
  height: 278px
34
- overflow-y: scroll
40
+ overflow-y: auto
35
41
  padding-right: 10px
36
42
  margin-top: 16px
37
43
  &::-webkit-scrollbar {
@@ -49,10 +55,7 @@
49
55
  margin-top: 20px
50
56
  display: flex
51
57
  justify-content: center
52
- gap: 18px;
53
-
54
- +media-tablet-landscape-up()
55
- gap: 36px;
58
+ gap: 36px;
56
59
 
57
60
  .modalBtn
58
61
  height: 38px
@@ -64,6 +67,23 @@
64
67
  .outlinedBtn
65
68
  border: none !important;
66
69
 
70
+ .zustimmenBtn
71
+ width: calc(50% - 18px) !important
72
+ min-width: calc(50% - 18px) !important
73
+ height: 38px !important
74
+ background: #005CCB !important
75
+ border-radius: 4px !important
76
+ display: flex !important
77
+ align-items: center !important
78
+ justify-content: center !important
79
+ text-align: center !important
80
+ font-family: 'Arial' !important
81
+ font-style: normal !important
82
+ font-weight: 400 !important
83
+ font-size: 14px !important
84
+ line-height: 16px !important
85
+ color: #FFFFFF !important
86
+
67
87
  .switchBlock
68
88
  display: flex
69
89
  align-items: center
@@ -72,6 +92,27 @@
72
92
  letter-spacing: 1px
73
93
  color: rgba(76, 78, 100, 0.87)
74
94
  text-align: left
95
+ margin-bottom: 5px
96
+
97
+ &:last-child
98
+ margin-bottom: 0
99
+
100
+ > span:last-child
101
+ display: inline-flex
102
+ align-items: center
103
+
104
+ :global
105
+ .mswitch-track
106
+ margin-left: 5px
107
+ width: 38px
108
+ height: 22px
109
+
110
+ .mswitch-thumb
111
+ width: 16px
112
+ height: 16px
113
+
114
+ .mswitch input:checked + .mswitch-track .mswitch-thumb
115
+ transform: translateX(16px)
75
116
 
76
117
  .switchBlockTitle
77
118
  width: 140px;
@@ -115,11 +115,11 @@ const ManageCookieModal = ({ toggleModal, handleSentryInit } : IManageCookieModa
115
115
  <div className={styles.accordions}>
116
116
  <MaterialAccordionGroup
117
117
  title='Notwendig'
118
- tooltipText='Notwendig'
118
+ // tooltipText='Notwendig'
119
119
  withSwitch
120
120
  switchProps={necessarySwitchProps}
121
121
  >
122
- <span>Wir verwenden Browser-Cookies, die notwendig sind, damit die Website wie vorgesehen funktioniert. Beispiel: Wir speichern Ihre Präferenzen bei der Datenerfassung auf der Website, damit wir sie berücksichtigen können, wenn Sie auf unsere Website zurückkehren. Sie können diese Cookies in den Einstellungen Ihres Browsers deaktivieren, aber wenn Sie dies tun, funktioniert die Website möglicherweise nicht wie vorgesehen.</span>
122
+ <span className={styles.accordionDescription}>Wir verwenden Browser-Cookies, die notwendig sind, damit die Website wie vorgesehen funktioniert. Beispiel: Wir speichern Ihre Präferenzen bei der Datenerfassung auf der Website, damit wir sie berücksichtigen können, wenn Sie auf unsere Website zurückkehren. Sie können diese Cookies in den Einstellungen Ihres Browsers deaktivieren, aber wenn Sie dies tun, funktioniert die Website möglicherweise nicht wie vorgesehen.</span>
123
123
  {Object.keys(COOKIE_SCHEMA.necessary).map(cookie => {
124
124
  const getLinkFromCategory = _get(COOKIE_SCHEMA_LINKS, [cookie], '');
125
125
  return (
@@ -134,10 +134,10 @@ const ManageCookieModal = ({ toggleModal, handleSentryInit } : IManageCookieModa
134
134
  <MaterialAccordionGroup
135
135
  title='Analytisch'
136
136
  withSwitch
137
- tooltipText='Analytisch'
137
+ // tooltipText='Analytisch'
138
138
  switchProps={analyticsSwitchProps}
139
139
  >
140
- <span>Um das Nutzerverhalten zu verstehen und Ihnen ein relevanteres Surferlebnis zu bieten oder den Inhalt unserer Website zu personalisieren. Beispiel: Wir sammeln Informationen darüber, welche Seiten Sie besuchen, damit wir Ihnen relevantere Informationen präsentieren können.</span>
140
+ <span className={styles.accordionDescription}>Um das Nutzerverhalten zu verstehen und Ihnen ein relevanteres Surferlebnis zu bieten oder den Inhalt unserer Website zu personalisieren. Beispiel: Wir sammeln Informationen darüber, welche Seiten Sie besuchen, damit wir Ihnen relevantere Informationen präsentieren können.</span>
141
141
  {Object.keys(COOKIE_SCHEMA.analytics).map(cookie => {
142
142
  const getItemsFromCategory = _get(COOKIE_SCHEMA, `analytics['${cookie}']`, null);
143
143
  const getLinkFromCategory = _get(COOKIE_SCHEMA_LINKS, [cookie], '');
@@ -158,10 +158,10 @@ const ManageCookieModal = ({ toggleModal, handleSentryInit } : IManageCookieModa
158
158
  <MaterialAccordionGroup
159
159
  title='Marketing'
160
160
  withSwitch
161
- tooltipText='Marketing'
161
+ // tooltipText='Marketing'
162
162
  switchProps={marketingSwitchProps}
163
163
  >
164
- <span>Zur Personalisierung und Messung der Effektivität von Werbung auf unserer Website und anderen Websites. Beispiel: Wir können Ihnen eine personalisierte Werbung auf der Grundlage der von Ihnen besuchten Seiten auf unserer Website anzeigen.</span>
164
+ <span className={styles.accordionDescription}>Zur Personalisierung und Messung der Effektivität von Werbung auf unserer Website und anderen Websites. Beispiel: Wir können Ihnen eine personalisierte Werbung auf der Grundlage der von Ihnen besuchten Seiten auf unserer Website anzeigen.</span>
165
165
  {Object.keys(COOKIE_SCHEMA.marketing).map(cookie => {
166
166
  const getItemsFromCategory = _get(COOKIE_SCHEMA, `marketing['${cookie}']`, null);
167
167
  const getLinkFromCategory = _get(COOKIE_SCHEMA_LINKS, [cookie], '');
@@ -181,8 +181,8 @@ const ManageCookieModal = ({ toggleModal, handleSentryInit } : IManageCookieModa
181
181
  </MaterialAccordionGroup>
182
182
  </div>
183
183
  <div className={styles.buttonsContainer}>
184
- <Button onClick={() => onAccept()} className={classNames(styles.modalBtn, styles.outlinedBtn)} >Auswahl speichern</Button>
185
- <Button onClick={() => onAccept({ marketing: [], analytics: [] })} className={styles.modalBtn}>Alles akzeptieren</Button>
184
+ <Button onClick={() => onAccept()} className={classNames(styles.modalBtn, styles.outlinedBtn, styles.zustimmenBtn)} >Auswahl speichern</Button>
185
+ <Button onClick={() => onAccept({ marketing: [], analytics: [] })} className={classNames(styles.modalBtn, styles.zustimmenBtn)}>Alles akzeptieren</Button>
186
186
  </div>
187
187
  </div>
188
188
  );
@@ -1,12 +1,12 @@
1
1
  @import '../../../legacy-theme/mixins.styl';
2
2
 
3
3
  .modalWrapper
4
- padding: 28px 58px 28px
4
+ padding: 25px
5
5
  text-align: center
6
6
  box-sizing: border-box
7
- max-width: 600px
7
+ max-width: 100%
8
8
  +media-tablet-down()
9
- padding: 16px 15px 28px
9
+ padding: 16px 15px 12px 15px
10
10
 
11
11
  .modalTitle
12
12
  font-family: sans-serif
@@ -17,7 +17,12 @@
17
17
  margin-top: 20px
18
18
 
19
19
  +media-tablet-down()
20
+ font-family: 'Arial';
21
+ font-weight: 700;
20
22
  font-size: 16px;
23
+ line-height: 32px;
24
+ text-align: center;
25
+ color: rgba(76, 78, 100, 0.87);
21
26
  margin-top: 8px;
22
27
 
23
28
  .modalContent
@@ -29,9 +34,13 @@
29
34
  color: rgba(76, 78, 100, 0.87);
30
35
  margin-top: 20px
31
36
  +media-tablet-down()
37
+ font-family: 'Arial';
38
+ font-weight: 400;
39
+ font-size: 14px;
40
+ line-height: 16px;
41
+ letter-spacing: 0.15px;
42
+ color: rgba(76, 78, 100, 0.87);
32
43
  column-gap: 0;
33
- max-height: 320px;
34
- overflow-y: scroll;
35
44
  padding-right: 10px;
36
45
  margin-right: -10px;
37
46
  margin-top: 8px
@@ -55,13 +64,68 @@
55
64
  +media-tablet-down()
56
65
  font-size: 11px !important
57
66
 
67
+ .necessaryCookiesBtn
68
+ display: flex !important
69
+ flex-direction: column !important
70
+ align-items: center !important
71
+ justify-content: center !important
72
+ width: 184px !important
73
+ min-width: 184px !important
74
+ height: 38px !important
75
+ background: #005CCB !important
76
+ box-shadow: 0px 4px 8px -4px rgba(76, 78, 100, 0.42) !important
77
+ border-radius: 4px !important
78
+ font-family: 'Inter' !important
79
+ font-weight: 500 !important
80
+ font-size: 14px !important
81
+ line-height: 24px !important
82
+ letter-spacing: 0.4px !important
83
+ color: #FFFFFF !important
84
+
85
+ .anpassenBtn
86
+ border: 1px solid rgba(102, 108, 255, 0.5) !important
87
+ box-shadow: none !important
88
+ border-radius: 4px !important
89
+ width: 142.5px !important
90
+ min-width: 142.5px !important
91
+ height: 38px !important
92
+ display: flex !important
93
+ align-items: center !important
94
+ justify-content: center !important
95
+ text-align: center !important
96
+ font-family: 'Arial' !important
97
+ font-weight: 400 !important
98
+ font-size: 14px !important
99
+ line-height: 16px !important
100
+ color: #1976D2 !important
101
+
102
+ .zustimmenBtn
103
+ width: 142.5px !important
104
+ min-width: 142.5px !important
105
+ height: 38px !important
106
+ background: #005CCB !important
107
+ border-radius: 4px !important
108
+ display: flex !important
109
+ align-items: center !important
110
+ justify-content: center !important
111
+ text-align: center !important
112
+ font-family: 'Arial' !important
113
+ font-style: normal !important
114
+ font-weight: 400 !important
115
+ font-size: 14px !important
116
+ line-height: 16px !important
117
+ color: #FFFFFF !important
118
+
58
119
  .outlinedBtnLeft
59
120
  display: flex;
60
121
  justify-content: flex-end;
122
+ font-family: 'Arial';
123
+ font-weight: 400;
61
124
  color: #005CCB;
62
125
  font-size: 16px;
126
+ line-height: 24px;
63
127
  text-decoration: none;
64
- margin-top: 12px;
128
+ margin-top: 3px;
65
129
  margin-bottom: 0;
66
130
  margin-left: auto;
67
131
  max-width: 80px;
@@ -32,7 +32,7 @@ const PreviewCookieModal = ({ toggleModal, setModal, handleSentryInit } : IPrevi
32
32
  <div className={styles.modalWrapper}>
33
33
  <div className={styles.logoContainer}>
34
34
  <IconSVG name='autodeLogo' customDimensions />
35
- <Button onClick={onDisableAll} className={styles.modalBtn}>Nur notwendige Cookies</Button>
35
+ <Button onClick={onDisableAll} className={`${styles.modalBtn} ${styles.necessaryCookiesBtn}`}>Nur notwendige Cookies</Button>
36
36
  </div>
37
37
  <div className={styles.modalTitle}>Nach den Cookies geht die Fahrt weiter.</div>
38
38
  <div className={styles.modalContent}>
@@ -42,8 +42,8 @@ const PreviewCookieModal = ({ toggleModal, setModal, handleSentryInit } : IPrevi
42
42
  </div>
43
43
  <a href={`${environment}/impressum/`} target='_blank' className={styles.outlinedBtnLeft}>Impressum</a>
44
44
  <div className={styles.buttonsContainer}>
45
- <Button onClick={() => setModal('MANAGE_COOKIE_MODAL')} className={styles.modalBtn} variant='outlined'>Anpassen</Button>
46
- <Button onClick={onAcceptAll} className={styles.modalBtn}>zustimmen</Button>
45
+ <Button onClick={() => setModal('MANAGE_COOKIE_MODAL')} className={`${styles.modalBtn} ${styles.anpassenBtn}`} variant='outlined'>Anpassen</Button>
46
+ <Button onClick={onAcceptAll} className={`${styles.modalBtn} ${styles.zustimmenBtn}`}>zustimmen</Button>
47
47
  </div>
48
48
  </div>
49
49
  );
@@ -32,7 +32,7 @@ const MdAccordionGroup: React.FC<IMdAccordionGroupProps> = ({ title, tooltipText
32
32
  aria-expanded={open}
33
33
  onClick={toggle}
34
34
  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }}
35
- style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', width: '100%' }}
35
+ style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', width: '100%', paddingLeft: 15, paddingRight: 15 }}
36
36
  >
37
37
  <span className='macc-hd-text' style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
38
38
  <span className='macc-title md-title'>{title}</span>
@@ -45,7 +45,7 @@ const MdAccordionGroup: React.FC<IMdAccordionGroupProps> = ({ title, tooltipText
45
45
  ) : null}
46
46
  <Icon name='chevron-down' cls='macc-chev' />
47
47
  </div>
48
- {open ? <div style={{ padding: '4px 15px 12px' }}>{children}</div> : null}
48
+ {open ? <div style={{ padding: '10px' }}>{children}</div> : null}
49
49
  </section>
50
50
  );
51
51
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.94",
3
+ "version": "1.0.96",
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",
package/theme/md3.css CHANGED
@@ -595,7 +595,10 @@ line-height: 31px;
595
595
  transition: box-shadow 0.15s;
596
596
  }
597
597
  .md3 .mfield:focus-within {
598
- box-shadow: inset 0 0 0 2px var(--md-accent);
598
+ box-shadow: inset 0 0 0 2px #005CFF;
599
+ }
600
+ .md3 .srp-rail input[type="checkbox"] {
601
+ accent-color: #005CFF;
599
602
  }
600
603
  .md3 .mfield select,
601
604
  .md3 .mfield input {
@@ -1764,7 +1767,7 @@ line-height: 31px;
1764
1767
  }
1765
1768
  .md3 .mtextarea:focus {
1766
1769
  outline: 0;
1767
- box-shadow: inset 0 0 0 2px var(--md-accent);
1770
+ box-shadow: inset 0 0 0 2px #005CFF;
1768
1771
  }
1769
1772
 
1770
1773
  /* ---------- Impressum ---------- */
@@ -2919,7 +2922,12 @@ line-height: 31px;
2919
2922
  box-shadow: inset 0 0 0 1px var(--md-outline);
2920
2923
  }
2921
2924
  .md3 .mselect-btn[aria-expanded="true"] {
2922
- box-shadow: inset 0 0 0 2px var(--md-accent);
2925
+ box-shadow: inset 0 0 0 2px #005CFF;
2926
+ }
2927
+ .md3 .mselect-btn:focus-visible {
2928
+ /* already has its own aria-expanded/hover box-shadow feedback — the generic site-wide
2929
+ .md3 :focus-visible box-shadow is redundant here. */
2930
+ outline: none;
2923
2931
  }
2924
2932
  .md3 .mselect-btn:disabled {
2925
2933
  opacity: 0.5;
@@ -2964,11 +2972,8 @@ line-height: 31px;
2964
2972
  top: calc(100% + 4px);
2965
2973
  left: 0;
2966
2974
  min-width: 100%;
2967
- max-height: 288px;
2968
- overflow-y: auto;
2969
2975
  padding: 6px;
2970
2976
  margin: 0;
2971
- list-style: none;
2972
2977
  background: var(--md-sc-lowest, #fff);
2973
2978
  border-radius: var(--md-r-sm, 12px);
2974
2979
  border: 1px solid var(--md-outline-variant);
@@ -2978,6 +2983,70 @@ line-height: 31px;
2978
2983
  left: auto;
2979
2984
  right: 0;
2980
2985
  }
2986
+ .md3 .mselect-list {
2987
+ max-height: 288px;
2988
+ overflow-y: auto;
2989
+ margin: 0;
2990
+ padding: 0;
2991
+ list-style: none;
2992
+ }
2993
+ .md3 .mselect-btn--editing {
2994
+ cursor: text;
2995
+ /* always rendered while `open` (see editing = searchable && open), so no [aria-expanded]
2996
+ attribute-selector match is needed the way .mselect-btn[aria-expanded="true"] uses one.
2997
+ !important: .mselect-btn:hover's box-shadow has higher specificity (class+pseudo-class)
2998
+ than a single .mselect-btn--editing class and would otherwise win on hover. */
2999
+ box-shadow: inset 0 0 0 2px #005CFF !important;
3000
+ }
3001
+ .md3 .mselect-input {
3002
+ flex: 1;
3003
+ min-width: 0;
3004
+ border: 0;
3005
+ padding: 0;
3006
+ margin: 0;
3007
+ background: transparent;
3008
+ font: inherit;
3009
+ /* 14px to match .mselect-label (the closed-state text) so the field doesn't visually
3010
+ change size the moment it's clicked. Bumped to 16px on mobile below — iOS Safari
3011
+ auto-zooms the whole page on focusing any input under 16px, and that only matters
3012
+ on touch/mobile, not desktop. */
3013
+ font-size: 14px;
3014
+ color: var(--md-on-surface);
3015
+ }
3016
+ @media (max-width: 600px) {
3017
+ .md3 .mselect-input {
3018
+ font-size: 16px;
3019
+ }
3020
+ }
3021
+ .md3 .mselect-input:focus {
3022
+ /* the #005CFF ring already lives on the parent .mselect-btn--editing — the input's own
3023
+ native focus outline/box-shadow would double it up. */
3024
+ outline: none;
3025
+ box-shadow: none !important;
3026
+ }
3027
+ .md3 .mselect-input::placeholder {
3028
+ color: var(--md-on-surface-variant);
3029
+ }
3030
+ .md3 .mselect-chev-btn {
3031
+ display: inline-flex;
3032
+ align-items: center;
3033
+ margin-left: auto;
3034
+ padding: 0;
3035
+ border: 0;
3036
+ background: transparent;
3037
+ cursor: pointer;
3038
+ flex: none;
3039
+ }
3040
+ .md3 .mselect-chev-btn:focus-visible {
3041
+ /* the #005CFF ring already lives on the parent .mselect-btn--editing. */
3042
+ outline: none;
3043
+ box-shadow: none !important;
3044
+ }
3045
+ .md3 .mselect-empty {
3046
+ padding: 9px 10px;
3047
+ font-size: 13px;
3048
+ color: var(--md-on-surface-variant);
3049
+ }
2981
3050
  .md3 .mselect-opt {
2982
3051
  display: flex;
2983
3052
  align-items: center;
@@ -3246,8 +3315,20 @@ line-height: 31px;
3246
3315
  background: var(--autode-swoosh);
3247
3316
  }
3248
3317
  .md3 :focus-visible {
3249
- outline: 2px solid var(--md-accent);
3250
- outline-offset: 2px;
3318
+ outline: none;
3319
+ box-shadow: inset 0 0 0 2px #005CFF !important;
3320
+ }
3321
+ /* these already draw their own #005CFF ring on a wrapper (.mfield/.enq-float via
3322
+ :focus-within) or via border (.enquiry-*) — the generic ring above would double up
3323
+ directly on the input/select/textarea itself, showing two mismatched rectangles. */
3324
+ .md3 .mfield input:focus-visible,
3325
+ .md3 .mfield select:focus-visible,
3326
+ .md3 .mfield textarea:focus-visible,
3327
+ .md3 .enq-float input:focus-visible,
3328
+ .md3 .enq-float textarea:focus-visible,
3329
+ .md3 .enquiry-name-row input:focus-visible,
3330
+ .md3 .enquiry-email-wrap input:focus-visible {
3331
+ box-shadow: none !important;
3251
3332
  }
3252
3333
  .md3 .link-accent {
3253
3334
  color: var(--md-primary);
@@ -4254,7 +4335,7 @@ line-height: 31px;
4254
4335
  box-shadow: 0 0 0 10px rgba(11, 83, 204, 0.18);
4255
4336
  }
4256
4337
  .md3 .exslider input[type="range"]:focus-visible::-webkit-slider-thumb {
4257
- box-shadow: 0 0 0 3px var(--md-accent);
4338
+ box-shadow: 0 0 0 3px #005CFF;
4258
4339
  }
4259
4340
  /* body-type tile grid */
4260
4341
  /* body-type tile grid: 2-col in the narrow rail, auto-fill on the full Detailsuche page */
@@ -5711,8 +5792,9 @@ line-height: 31px;
5711
5792
  box-shadow: none;
5712
5793
  }
5713
5794
  .md3 .enq-float .mselect-btn:hover,
5714
- .md3 .enq-float .mselect-btn[aria-expanded="true"] {
5715
- box-shadow: none;
5795
+ .md3 .enq-float .mselect-btn[aria-expanded="true"],
5796
+ .md3 .enq-float .mselect-btn:focus-visible {
5797
+ box-shadow: none !important;
5716
5798
  }
5717
5799
  .md3 .enq-float .mselect-btn .lucide {
5718
5800
  width: 14px;
@@ -5766,7 +5848,7 @@ line-height: 31px;
5766
5848
  z-index: 60;
5767
5849
  width: 390px;
5768
5850
  height: 196px;
5769
- background: #F4F3F3;
5851
+ background: rgba(244, 243, 243, 0.95);
5770
5852
  border: 1px solid #D4DDE7;
5771
5853
  border-radius: 16px 16px 0 0;
5772
5854
  box-shadow: 0 -2px 14px rgba(0, 0, 0, 0.1);
@@ -6018,15 +6100,31 @@ line-height: 31px;
6018
6100
  padding: 8px 16px 16px;
6019
6101
  }
6020
6102
 
6021
- /* Base (desktop) values for the two TopBar layout knobs the mobile Header-System spec overrides
6022
- below — kept as real CSS rules (not inline styles) since an inline style on the element would
6023
- always beat the media-query override regardless of specificity. */
6103
+ /* Base (desktop) values for the TopBar layout/chrome knobs the mobile Header-System spec
6104
+ overrides below — kept as real CSS rules (not inline styles) since an inline style on the
6105
+ element would always beat the media-query override regardless of specificity. */
6024
6106
  .md3 .topbar-container {
6025
6107
  padding: 0 16px;
6026
6108
  }
6027
6109
  .md3 .topbar-actions {
6028
6110
  gap: 16px;
6029
6111
  }
6112
+ .md3 .topbar {
6113
+ background: var(--md-surface);
6114
+ border-bottom: 1px solid var(--md-outline-variant);
6115
+ transition: transform 0.25s, background 0.15s;
6116
+ }
6117
+ @media (max-width: 600px) {
6118
+ /* slight transparency (0.95) — mobile only, coordinated with .vdp-bottom-bar's own 0.95. */
6119
+ .md3 .topbar {
6120
+ background: color-mix(in srgb, var(--md-surface) 95%, transparent);
6121
+ }
6122
+ /* autoHideOnScroll (CDP/VDP only, see TopBar.tsx) — slides the header off the top of the
6123
+ viewport; transform doesn't affect its `position: sticky` layout slot, just the paint. */
6124
+ .md3 .topbar.topbar--hidden {
6125
+ transform: translateY(-100%);
6126
+ }
6127
+ }
6030
6128
 
6031
6129
  /* mobile-only "Account Layer" sheet (TopBar) — full-width panel under the header, per the Figma
6032
6130
  "03 | Account Layer" mockups. .show-mobile forces display:flex (see the ≤600px override
@@ -6905,7 +7003,10 @@ line-height: 31px;
6905
7003
  line-height: 1.2;
6906
7004
  text-align: center;
6907
7005
  color: var(--fig-gray);
6908
- white-space: nowrap;
7006
+ /* two lines (e.g. "wtd." / "comb.") rather than one overflowing/truncated line — the column
7007
+ is narrower than either label at this font-size, so letting it wrap at the natural word
7008
+ break is what actually fits without spilling into neighbouring columns. */
7009
+ white-space: normal;
6909
7010
  overflow: visible;
6910
7011
  }
6911
7012
  .md3 .co2-seg {
@@ -6927,6 +7028,9 @@ line-height: 31px;
6927
7028
  transform: scale(1.06);
6928
7029
  }
6929
7030
  .md3 .co2-seg--on2 {
7031
+ /* PHEV discharged-class marker — was missing this, so only the weighted class (--on) showed
7032
+ its full saturated colour while this one stayed the dim pastel base fill. */
7033
+ background: var(--seg);
6930
7034
  border: 2px solid #fff;
6931
7035
  box-shadow: 0 2px 8px rgba(16, 22, 42, 0.22);
6932
7036
  transform: scale(1.04);
@@ -7971,6 +8075,16 @@ color: #005CFF;
7971
8075
  text-align: center;
7972
8076
  color: #ffffff;
7973
8077
  }
8078
+ /* grid, not flex-wrap: with 4 stats (PHEV — weighted/discharged pairs) flex-wrap sized each
8079
+ row's columns independently off that row's own content width, so the 2nd column drifted out
8080
+ of alignment between rows whenever the two left-column labels weren't the same length
8081
+ ("Stromverbrauch (gew.)" vs "Kraftstoff (entladen)"). Grid's 1fr columns stay a fixed width
8082
+ shared across every row regardless of content, so both columns line up vertically. Mobile
8083
+ only — desktop keeps the original flex-wrap layout. */
8084
+ .md3 #vdp-eco .co2-stats {
8085
+ display: grid;
8086
+ grid-template-columns: repeat(2, 1fr);
8087
+ }
7974
8088
  .md3 #vdp-eco {
7975
8089
  border: 1px solid #D4DDE7;
7976
8090
  box-shadow: none;
package/ui/Dropdown.tsx CHANGED
@@ -49,6 +49,10 @@ export interface DropdownProps {
49
49
  className?: string;
50
50
  /** align the popover to the right edge of the trigger (default left). */
51
51
  menuAlign?: 'left' | 'right';
52
+ /** show a text input at the top of the popover that filters `options` by label (substring, case-insensitive). */
53
+ searchable?: boolean;
54
+ /** placeholder for the search input (only used when `searchable`). */
55
+ searchPlaceholder?: string;
52
56
  }
53
57
 
54
58
  const Dropdown: React.FunctionComponent<DropdownProps> = ({
@@ -63,25 +67,49 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
63
67
  ariaLabel,
64
68
  className,
65
69
  menuAlign = 'left',
70
+ searchable = false,
71
+ searchPlaceholder,
66
72
  }) => {
67
73
  const [open, setOpen] = React.useState(false);
68
74
  const [activeIndex, setActiveIndex] = React.useState(-1);
75
+ const [query, setQuery] = React.useState('');
69
76
  const rootRef = React.useRef<HTMLDivElement | null>(null);
70
77
  const btnRef = React.useRef<HTMLButtonElement | null>(null);
71
78
  const menuRef = React.useRef<HTMLUListElement | null>(null);
79
+ const searchRef = React.useRef<HTMLInputElement | null>(null);
72
80
  // stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
73
81
  const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
74
82
 
75
83
  const selectedIndex = options.findIndex((o) => o.value === value);
76
84
  const selected = selectedIndex !== -1 ? options[selectedIndex] : undefined;
77
85
 
86
+ const filteredOptions = React.useMemo(() => {
87
+ if (!searchable || !query.trim()) return options;
88
+ const q = query.trim().toLowerCase();
89
+ return options.filter((o) => o.label.toLowerCase().includes(q));
90
+ }, [searchable, query, options]);
91
+
92
+ // deferred: when searchable, the trigger <button> is swapped out for an <input> while
93
+ // open (see render), so btnRef.current is null *during* the close() call itself — the
94
+ // button only exists again after this state flips `open` back to false and React
95
+ // re-renders. Consumed by the effect below, once that re-render has actually happened.
96
+ const focusTriggerAfterClose = React.useRef(false);
97
+
78
98
  // close + restore focus to the trigger.
79
99
  const close = React.useCallback((focusTrigger: boolean) => {
80
100
  setOpen(false);
81
101
  setActiveIndex(-1);
82
- if (focusTrigger && btnRef.current) btnRef.current.focus();
102
+ setQuery('');
103
+ focusTriggerAfterClose.current = focusTrigger;
83
104
  }, []);
84
105
 
106
+ React.useEffect(() => {
107
+ if (!open && focusTriggerAfterClose.current) {
108
+ focusTriggerAfterClose.current = false;
109
+ if (btnRef.current) btnRef.current.focus();
110
+ }
111
+ }, [open]);
112
+
85
113
  const openMenu = React.useCallback(() => {
86
114
  if (disabled) return;
87
115
  setOpen(true);
@@ -89,6 +117,18 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
89
117
  setActiveIndex(selectedIndex !== -1 ? selectedIndex : 0);
90
118
  }, [disabled, selectedIndex]);
91
119
 
120
+ // when opened searchable, focus the search input instead of leaving focus on the trigger.
121
+ // scrollIntoView first: on mobile the keyboard is about to eat ~40% of the viewport, so the
122
+ // trigger (and the popover below it) needs to already be scrolled above the fold before focus
123
+ // fires, or the popover can end up hidden behind the keyboard with no way to see it open.
124
+ React.useEffect(() => {
125
+ if (!open || !searchable) return;
126
+ if (rootRef.current && rootRef.current.scrollIntoView) {
127
+ rootRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
128
+ }
129
+ if (searchRef.current) searchRef.current.focus();
130
+ }, [open, searchable]);
131
+
92
132
  // click-away (guarded for SSR).
93
133
  React.useEffect(() => {
94
134
  if (!open || typeof document === 'undefined') return undefined;
@@ -106,8 +146,14 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
106
146
  if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
107
147
  }, [open, activeIndex]);
108
148
 
149
+ // reset the keyboard highlight to the top match whenever the filtered list changes.
150
+ React.useEffect(() => {
151
+ if (searchable && open) setActiveIndex(filteredOptions.length ? 0 : -1);
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
153
+ }, [filteredOptions]);
154
+
109
155
  const commit = (idx: number) => {
110
- const opt = options[idx];
156
+ const opt = filteredOptions[idx];
111
157
  if (!opt || opt.disabled) return;
112
158
  onChange(opt.value);
113
159
  close(true);
@@ -115,17 +161,30 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
115
161
 
116
162
  // move the active highlight, skipping disabled options.
117
163
  const move = (dir: 1 | -1) => {
118
- if (!options.length) return;
164
+ if (!filteredOptions.length) return;
119
165
  let i = activeIndex;
120
- for (let step = 0; step < options.length; step += 1) {
121
- i = (i + dir + options.length) % options.length;
122
- if (!options[i].disabled) {
166
+ for (let step = 0; step < filteredOptions.length; step += 1) {
167
+ i = (i + dir + filteredOptions.length) % filteredOptions.length;
168
+ if (!filteredOptions[i].disabled) {
123
169
  setActiveIndex(i);
124
170
  return;
125
171
  }
126
172
  }
127
173
  };
128
174
 
175
+ const onMenuKeyDown = (e: React.KeyboardEvent) => {
176
+ switch (e.key) {
177
+ case 'ArrowDown': e.preventDefault(); move(1); break;
178
+ case 'ArrowUp': e.preventDefault(); move(-1); break;
179
+ case 'Home': e.preventDefault(); setActiveIndex(filteredOptions.findIndex((o) => !o.disabled)); break;
180
+ case 'End': e.preventDefault(); for (let i = filteredOptions.length - 1; i >= 0; i -= 1) { if (!filteredOptions[i].disabled) { setActiveIndex(i); break; } } break;
181
+ case 'Enter': e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); break;
182
+ case 'Escape': e.preventDefault(); close(true); break;
183
+ case 'Tab': close(false); break;
184
+ default: break;
185
+ }
186
+ };
187
+
129
188
  const onTriggerKeyDown = (e: React.KeyboardEvent) => {
130
189
  if (disabled) return;
131
190
  if (!open) {
@@ -135,16 +194,8 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
135
194
  }
136
195
  return;
137
196
  }
138
- switch (e.key) {
139
- case 'ArrowDown': e.preventDefault(); move(1); break;
140
- case 'ArrowUp': e.preventDefault(); move(-1); break;
141
- case 'Home': e.preventDefault(); setActiveIndex(options.findIndex((o) => !o.disabled)); break;
142
- case 'End': e.preventDefault(); for (let i = options.length - 1; i >= 0; i -= 1) { if (!options[i].disabled) { setActiveIndex(i); break; } } break;
143
- case 'Enter': case ' ': e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); break;
144
- case 'Escape': e.preventDefault(); close(true); break;
145
- case 'Tab': close(false); break;
146
- default: break;
147
- }
197
+ if (e.key === ' ') { e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); return; }
198
+ onMenuKeyDown(e);
148
199
  };
149
200
 
150
201
  const wrapCls = ['mselect'];
@@ -154,49 +205,83 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
154
205
 
155
206
  const label = selected ? selected.label : placeholder || '';
156
207
 
208
+ const editing = searchable && open;
209
+
157
210
  return (
158
211
  <div className={wrapCls.join(' ')} ref={rootRef}>
159
- <button
160
- ref={btnRef}
161
- type="button"
162
- className="mselect-btn"
163
- aria-haspopup="listbox"
164
- aria-expanded={open}
165
- aria-label={ariaLabel}
166
- disabled={disabled}
167
- onClick={() => (open ? close(false) : openMenu())}
168
- onKeyDown={onTriggerKeyDown}
169
- >
170
- {icon && <Icon name={icon} />}
171
- <span className={'mselect-label' + (selected ? '' : ' mselect-label--ph')}>{label}</span>
172
- <Icon name="chevron-down" cls="mselect-chev" />
173
- </button>
174
- {open && (
175
- <ul
176
- ref={menuRef}
177
- className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '')}
178
- role="listbox"
179
- tabIndex={-1}
180
- aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
212
+ {editing ? (
213
+ // combobox mode: the trigger itself becomes the text field (not a separate input
214
+ // inside the popover) — a <button> can't legally contain an <input>, so this is a
215
+ // <div> standing in for it, same `.mselect-btn` class for identical chrome.
216
+ <div className="mselect-btn mselect-btn--editing">
217
+ {icon && <Icon name={icon} />}
218
+ <input
219
+ ref={searchRef}
220
+ type="text"
221
+ className="mselect-input"
222
+ placeholder={searchPlaceholder || placeholder}
223
+ value={query}
224
+ onChange={(e) => setQuery(e.target.value)}
225
+ onKeyDown={onMenuKeyDown}
226
+ aria-label={ariaLabel}
227
+ aria-haspopup="listbox"
228
+ aria-expanded={open}
229
+ aria-controls={`${idBase}list`}
230
+ aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
231
+ />
232
+ <button type="button" className="mselect-chev-btn" aria-label="Close" onClick={() => close(true)}>
233
+ <Icon name="chevron-down" cls="mselect-chev" />
234
+ </button>
235
+ </div>
236
+ ) : (
237
+ <button
238
+ ref={btnRef}
239
+ type="button"
240
+ className="mselect-btn"
241
+ aria-haspopup="listbox"
242
+ aria-expanded={open}
243
+ aria-label={ariaLabel}
244
+ disabled={disabled}
245
+ onClick={() => (open ? close(false) : openMenu())}
246
+ onKeyDown={onTriggerKeyDown}
181
247
  >
182
- {options.map((opt, i) => (
183
- <li key={`${opt.value}_${i}`} role="presentation">
184
- <button
185
- id={`${idBase}${i}`}
186
- type="button"
187
- role="option"
188
- aria-selected={opt.value === value}
189
- disabled={opt.disabled}
190
- className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
191
- onMouseEnter={() => setActiveIndex(i)}
192
- onClick={() => commit(i)}
193
- >
194
- <span className="mselect-opt-label">{opt.label}</span>
195
- {opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
196
- </button>
197
- </li>
198
- ))}
199
- </ul>
248
+ {icon && <Icon name={icon} />}
249
+ <span className={'mselect-label' + (selected ? '' : ' mselect-label--ph')}>{label}</span>
250
+ <Icon name="chevron-down" cls="mselect-chev" />
251
+ </button>
252
+ )}
253
+ {open && (
254
+ <div className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}>
255
+ <ul
256
+ ref={menuRef}
257
+ id={`${idBase}list`}
258
+ className="mselect-list"
259
+ role="listbox"
260
+ tabIndex={-1}
261
+ aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
262
+ >
263
+ {filteredOptions.map((opt, i) => (
264
+ <li key={`${opt.value}_${i}`} role="presentation">
265
+ <button
266
+ id={`${idBase}${i}`}
267
+ type="button"
268
+ role="option"
269
+ aria-selected={opt.value === value}
270
+ disabled={opt.disabled}
271
+ className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
272
+ onMouseEnter={() => setActiveIndex(i)}
273
+ onClick={() => commit(i)}
274
+ >
275
+ <span className="mselect-opt-label">{opt.label}</span>
276
+ {opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
277
+ </button>
278
+ </li>
279
+ ))}
280
+ {searchable && filteredOptions.length === 0 && (
281
+ <li className="mselect-empty" role="presentation">Keine Treffer</li>
282
+ )}
283
+ </ul>
284
+ </div>
200
285
  )}
201
286
  </div>
202
287
  );
@@ -1442,10 +1442,11 @@ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehic
1442
1442
  export interface VdpBottomBarProps {
1443
1443
  vehicle: VehicleDetail;
1444
1444
  /**
1445
- * Whether the bar is docked/visible — mirrors VdpStickyBar's `show` (same
1446
- * page-owned scroll-position toggle, STICKY_SHOW_AT). Was unconditionally
1447
- * visible before, unlike the desktop VdpStickyBar; the mockup only shows it
1448
- * after scrolling past the main offer card.
1445
+ * Whether the bar is docked/visible — page-owned scroll-position toggle: shows after scrolling
1446
+ * past the main offer card (same STICKY_SHOW_AT threshold VdpStickyBar uses), and additionally
1447
+ * hides again once the page scrolls into the similar-vehicles/legal-footer zone so it never
1448
+ * covers that content. That bottom-of-page rule is mobile-only and NOT shared with
1449
+ * VdpStickyBar's own `show` (the desktop bar has no such lower boundary).
1449
1450
  */
1450
1451
  show: boolean;
1451
1452
  /** active payment mode — drives the tabs + the displayed figure (rate vs price). */