@bytebrand/fe-ui-core-autobahn 1.0.95 → 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
@@ -1,12 +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
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
@@ -5,6 +5,8 @@
5
5
  text-align: center
6
6
  box-sizing: border-box
7
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.95",
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). */