@bytebrand/fe-ui-core-autobahn 1.0.130 → 1.0.132

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.
@@ -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.130",
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.132",
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
@@ -3672,6 +3672,7 @@ line-height: 31px;
3672
3672
  display: grid;
3673
3673
  grid-template-columns: repeat(3, 1fr);
3674
3674
  gap: 24px;
3675
+ margin-bottom: 16px;
3675
3676
  }
3676
3677
  .md3 .cards-offers {
3677
3678
  display: grid;
package/ui/Dropdown.tsx CHANGED
@@ -1,467 +1,467 @@
1
- // Autobahn (auto.de redesign) — md3 single-select dropdown.
2
- //
3
- // A real, themeable replacement for the native HTML `<select>`. The browser renders
4
- // a native `<select>`'s option list with the OS popup, which CANNOT be styled (it
5
- // ignores the scoped `.md3` theme and reads as "broken"/un-md3). This component
6
- // reproduces the control as a button trigger (`.mselect-btn`, styled like `.mfield`)
7
- // plus an absolutely-positioned `.mcard`-style popover menu we fully control.
8
- //
9
- // Behaviour parity with a real select:
10
- // - controlled: `value` + `options` ({value,label,disabled?}) + `onChange(value)`.
11
- // - keyboard: ↓/Enter/Space open; ↑/↓ move the active option; Enter selects;
12
- // Esc / Tab close (Esc refocuses the trigger); Home/End jump.
13
- // - click-away (document mousedown) closes; the active option scrolls into view.
14
- // - a11y: trigger `aria-haspopup=listbox` + `aria-expanded`; menu `role=listbox`;
15
- // options `role=option` + `aria-selected`; `aria-activedescendant` tracks focus.
16
- //
17
- // Pure/presentational — no store/API/window beyond the click-away listener (guarded
18
- // for SSR). Renders correctly only inside a `<div className="md3">` subtree.
19
-
20
- import React from 'react';
21
- import * as ReactDOM from 'react-dom';
22
-
23
- import Icon from './Icon';
24
- import { usePortalMenuPosition } from './usePortalMenuPosition';
25
-
26
- export interface DropdownOption {
27
- value: string;
28
- label: string;
29
- disabled?: boolean;
30
- }
31
-
32
- export interface DropdownProps {
33
- /** currently selected option value. */
34
- value: string;
35
- /** the selectable options, in display order. */
36
- options: DropdownOption[];
37
- /** fired with the chosen option's value. */
38
- onChange: (value: string) => void;
39
- /** shown (muted) when `value` matches no option. */
40
- placeholder?: string;
41
- /** leading lucide icon (kebab-case), e.g. 'arrow-up-down'. */
42
- icon?: string;
43
- /** custom leading icon element — takes precedence over `icon` when both are given. For a
44
- * one-off icon that isn't in lucide-react (e.g. a Figma-exported glyph); give it a
45
- * `className="lucide"` so it picks up the same `.mselect-btn .lucide` sizing/color rules
46
- * every named `icon` gets. */
47
- iconNode?: React.ReactNode;
48
- disabled?: boolean;
49
- /** full-width trigger (default true for form fields; pass false for inline controls). */
50
- block?: boolean;
51
- /** compact height (sort bar / inline). */
52
- size?: 'sm' | 'md';
53
- /** accessible label when there is no visible <label> wrapping the control. */
54
- ariaLabel?: string;
55
- /** marks the trigger aria-invalid (screen readers) when the owning field has a validation
56
- * error — purely additive/opt-in, undefined by default so existing callers are unaffected. */
57
- ariaInvalid?: boolean;
58
- /** id of the (possibly visually-hidden) error text describing why the field is invalid. */
59
- ariaDescribedBy?: string;
60
- /** extra class on the wrapper (e.g. 'srp-sort' for width rules). */
61
- className?: string;
62
- /** align the popover to the right edge of the trigger (default left). */
63
- menuAlign?: 'left' | 'right';
64
- /** show a text input at the top of the popover that filters `options` by label (substring, case-insensitive). */
65
- searchable?: boolean;
66
- /** placeholder for the search input (only used when `searchable`). */
67
- searchPlaceholder?: string;
68
- /** restrict the combobox input to digits (numeric "up to" fields) — filters every keystroke and sets inputMode="numeric" pattern="[0-9]*". Requires `searchable`. */
69
- numeric?: boolean;
70
- /** allow committing a typed value that matches no option, instead of only ever allowing an exact pick from the list — for numeric "up to" fields where any amount is valid, not just the preset rungs. Committed on close (blur/Tab/click-away/Enter), not on every keystroke. Requires `searchable`. */
71
- allowCustomValue?: boolean;
72
- /** renders the closed-trigger label for a custom (non-list) value. Falls back to the raw value itself when omitted — pass this whenever `allowCustomValue` so the trigger reads naturally (e.g. `(v) => \`bis ${v} km\``). */
73
- formatCustomLabel?: (value: string) => string;
74
- /**
75
- * Render the popover into a `document.body` portal instead of nesting it in place — escapes an
76
- * ancestor's `overflow:hidden`/clipping (e.g. inside a collapsing Accordion region, see
77
- * `.macc-region-inner` in md3.css) that would otherwise cut the option list off instead of
78
- * letting it float over the following content. Position is computed from the trigger's on-screen
79
- * rect, kept in sync on scroll/resize while open, and flips to open ABOVE the trigger when
80
- * there isn't room below. Off by default — every existing caller keeps the simple in-place
81
- * popover; opt in only at call sites with a known clipping ancestor.
82
- */
83
- menuPortal?: boolean;
84
- }
85
-
86
- const Dropdown: React.FunctionComponent<DropdownProps> = ({
87
- value,
88
- options,
89
- onChange,
90
- placeholder,
91
- icon,
92
- iconNode,
93
- disabled = false,
94
- block = true,
95
- size = 'md',
96
- ariaLabel,
97
- ariaInvalid,
98
- ariaDescribedBy,
99
- className,
100
- menuAlign = 'left',
101
- searchable = false,
102
- searchPlaceholder,
103
- numeric = false,
104
- allowCustomValue = false,
105
- formatCustomLabel,
106
- menuPortal = false,
107
- }) => {
108
- const [open, setOpen] = React.useState(false);
109
- const [activeIndex, setActiveIndex] = React.useState(-1);
110
- const [query, setQuery] = React.useState('');
111
- const rootRef = React.useRef<HTMLDivElement | null>(null);
112
- const btnRef = React.useRef<HTMLButtonElement | null>(null);
113
- const menuRef = React.useRef<HTMLUListElement | null>(null);
114
- const searchRef = React.useRef<HTMLInputElement | null>(null);
115
- // stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
116
- const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
117
-
118
- const selectedIndex = options.findIndex((o) => o.value === value);
119
- const selected = selectedIndex !== -1 ? options[selectedIndex] : undefined;
120
- // a real value that just doesn't happen to match any option — only possible with
121
- // allowCustomValue (e.g. a hand-typed "1421232" the preset rungs don't cover).
122
- const customLabel = allowCustomValue && value && !selected
123
- ? (formatCustomLabel ? formatCustomLabel(value) : value)
124
- : undefined;
125
- const hasValue = !!selected || customLabel !== undefined;
126
-
127
- const filteredOptions = React.useMemo(() => {
128
- if (!searchable || !query.trim()) return options;
129
- const q = query.trim().toLowerCase();
130
- const matches = options.filter((o) => o.label.toLowerCase().includes(q));
131
- // some option lists intentionally repeat entries across groups while closed (e.g. a "Top
132
- // Marken" shortlist followed by the FULL "Alle Marken" list, which includes those same top
133
- // brands again) — harmless when browsing both sections, but shows the same match twice while
134
- // searching. Keep only the first occurrence of each label once the user is actually filtering.
135
- const seen = new Set<string>();
136
- return matches.filter((o) => {
137
- const key = o.label.toLowerCase();
138
- if (seen.has(key)) return false;
139
- seen.add(key);
140
- return true;
141
- });
142
- }, [searchable, query, options]);
143
-
144
- // deferred: when searchable, the trigger <button> is swapped out for an <input> while
145
- // open (see render), so btnRef.current is null *during* the close() call itself — the
146
- // button only exists again after this state flips `open` back to false and React
147
- // re-renders. Consumed by the effect below, once that re-render has actually happened.
148
- const focusTriggerAfterClose = React.useRef(false);
149
-
150
- // close + restore focus to the trigger. `commitTyped` (default true) commits whatever's
151
- // currently typed as a custom value when `allowCustomValue` — this is what makes "just leave
152
- // the field" (blur/click-away/Tab) work, not only an explicit pick from the list. Callers that
153
- // already committed an explicit option (commit()) or that are cancelling (Escape) pass false.
154
- const close = React.useCallback((focusTrigger: boolean, commitTyped: boolean = true) => {
155
- if (allowCustomValue && commitTyped) {
156
- const raw = query.trim();
157
- if (raw !== value) onChange(raw);
158
- }
159
- setOpen(false);
160
- setActiveIndex(-1);
161
- setQuery('');
162
- focusTriggerAfterClose.current = focusTrigger;
163
- }, [allowCustomValue, query, value, onChange]);
164
-
165
- React.useEffect(() => {
166
- if (!open && focusTriggerAfterClose.current) {
167
- focusTriggerAfterClose.current = false;
168
- if (btnRef.current) btnRef.current.focus();
169
- }
170
- }, [open]);
171
-
172
- const openMenu = React.useCallback(() => {
173
- if (disabled) return;
174
- setOpen(true);
175
- // start the keyboard highlight on the current selection (or the first option).
176
- setActiveIndex(selectedIndex !== -1 ? selectedIndex : 0);
177
- // pre-fill the combobox with the current value (raw, not the formatted label) so a custom
178
- // entry can be edited/replaced in place instead of always starting from a blank field.
179
- if (allowCustomValue) setQuery(value || '');
180
- }, [disabled, selectedIndex, allowCustomValue, value]);
181
-
182
- // when opened searchable, focus the search input instead of leaving focus on the trigger.
183
- // scrollIntoView first: on mobile the keyboard is about to eat ~40% of the viewport, so the
184
- // trigger (and the popover below it) needs to already be scrolled above the fold before focus
185
- // fires, or the popover can end up hidden behind the keyboard with no way to see it open.
186
- React.useEffect(() => {
187
- if (!open || !searchable) return;
188
- if (rootRef.current && rootRef.current.scrollIntoView) {
189
- rootRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
190
- }
191
- if (searchRef.current) {
192
- searchRef.current.focus();
193
- // pre-filled custom value (see openMenu) starts selected, so typing immediately
194
- // replaces it instead of appending — same "click a number field, type over it" feel.
195
- if (allowCustomValue) searchRef.current.select();
196
- }
197
- }, [open, searchable, allowCustomValue]);
198
-
199
- // click-away (guarded for SSR). menuElRef is checked too — with menuPortal the popover renders
200
- // outside rootRef (at document.body), so without this every option click would look like a
201
- // click-away and close the menu before the option's own onClick had a chance to fire.
202
- React.useEffect(() => {
203
- if (!open || typeof document === 'undefined') return undefined;
204
- const onDocMouseDown = (e: MouseEvent) => {
205
- const target = e.target as Node;
206
- if (rootRef.current && rootRef.current.contains(target)) return;
207
- if (menuElRef.current && menuElRef.current.contains(target)) return;
208
- close(false);
209
- };
210
- document.addEventListener('mousedown', onDocMouseDown);
211
- return () => document.removeEventListener('mousedown', onDocMouseDown);
212
- }, [open, close]);
213
-
214
- // menuPortal: body-portal root + trigger-relative position, tracked on scroll/resize, flipping
215
- // above the trigger when there isn't room below. Shared with MultiDropdown's checklist menu
216
- // below — see usePortalMenuPosition's own doc comment for the full reasoning (every timing/CSS
217
- // fix that went into this belongs in one place so the two consumers can't drift apart).
218
- const onCloseAway = React.useCallback(() => close(false, false), [close]);
219
- const { portalRoot, menuPos, menuElRef } = usePortalMenuPosition({
220
- enabled: menuPortal,
221
- open,
222
- rootRef,
223
- menuAlign,
224
- onCloseAway,
225
- });
226
-
227
- // keep the active option scrolled into view while navigating by keyboard.
228
- React.useEffect(() => {
229
- if (!open || activeIndex < 0 || !menuRef.current) return;
230
- const el = menuRef.current.children[activeIndex] as HTMLElement | undefined;
231
- if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
232
- }, [open, activeIndex]);
233
-
234
- // reset the keyboard highlight to the top match whenever the filtered list changes.
235
- React.useEffect(() => {
236
- if (searchable && open) setActiveIndex(filteredOptions.length ? 0 : -1);
237
- // eslint-disable-next-line react-hooks/exhaustive-deps
238
- }, [filteredOptions]);
239
-
240
- const commit = (idx: number) => {
241
- const opt = filteredOptions[idx];
242
- if (!opt || opt.disabled) return;
243
- onChange(opt.value);
244
- // false: the explicit pick already won — don't also commit whatever's still sitting in the
245
- // (now-stale) typed query behind it.
246
- close(true, false);
247
- };
248
-
249
- // move the active highlight, skipping disabled options.
250
- const move = (dir: 1 | -1) => {
251
- if (!filteredOptions.length) return;
252
- let i = activeIndex;
253
- for (let step = 0; step < filteredOptions.length; step += 1) {
254
- i = (i + dir + filteredOptions.length) % filteredOptions.length;
255
- if (!filteredOptions[i].disabled) {
256
- setActiveIndex(i);
257
- return;
258
- }
259
- }
260
- };
261
-
262
- const onMenuKeyDown = (e: React.KeyboardEvent) => {
263
- switch (e.key) {
264
- case 'ArrowDown': e.preventDefault(); move(1); break;
265
- case 'ArrowUp': e.preventDefault(); move(-1); break;
266
- case 'Home': e.preventDefault(); setActiveIndex(filteredOptions.findIndex((o) => !o.disabled)); break;
267
- case 'End': e.preventDefault(); for (let i = filteredOptions.length - 1; i >= 0; i -= 1) { if (!filteredOptions[i].disabled) { setActiveIndex(i); break; } } break;
268
- case 'Enter':
269
- e.preventDefault();
270
- // an active list match wins when there is one; otherwise, for allowCustomValue fields,
271
- // Enter commits whatever's typed (same value close() would commit on blur anyway).
272
- if (activeIndex >= 0) commit(activeIndex);
273
- else if (allowCustomValue) close(true);
274
- break;
275
- // Escape cancels — close(..., false) so an in-progress custom edit is discarded, not committed.
276
- case 'Escape': e.preventDefault(); close(true, false); break;
277
- case 'Tab': close(false); break;
278
- default: break;
279
- }
280
- };
281
-
282
- const onTriggerKeyDown = (e: React.KeyboardEvent) => {
283
- if (disabled) return;
284
- if (!open) {
285
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
286
- e.preventDefault();
287
- openMenu();
288
- }
289
- return;
290
- }
291
- if (e.key === ' ') { e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); return; }
292
- onMenuKeyDown(e);
293
- };
294
-
295
- const wrapCls = ['mselect'];
296
- if (size === 'sm') wrapCls.push('mselect--sm');
297
- if (block) wrapCls.push('mselect--block');
298
- if (className) wrapCls.push(className);
299
-
300
- const label = selected ? selected.label : (customLabel !== undefined ? customLabel : placeholder || '');
301
-
302
- const editing = searchable && open;
303
-
304
- // Bug 53/37: "Rate bis" / "km bis" allow a hand-typed amount the preset rungs don't cover
305
- // (allowCustomValue) — that typed number never matches any option label, so filteredOptions
306
- // comes back empty and the popover would show "Keine Treffer", reading as "no cars match this"
307
- // to the customer even though the typed value is perfectly valid input. Once there's an actual
308
- // typed query with zero matches on an allowCustomValue field, suppress the popover entirely
309
- // (not just the "Keine Treffer" row) — an empty floating box would be just as confusing. Plain
310
- // (non-custom) searchable dropdowns, e.g. Make/Model, are unaffected and keep showing it.
311
- const suppressEmptyMenu = allowCustomValue && searchable && query.trim() !== '' && filteredOptions.length === 0;
312
-
313
- // shared between the in-place popover and the menuPortal one — identical option list either way.
314
- // maxHeight (menuPortal only — see positionMenu) overrides the CSS static 288px cap with the
315
- // space actually available in whichever direction the menu opened; inline style wins over the
316
- // class rule regardless of specificity, and falls back to the CSS default when omitted (the
317
- // in-place, non-portal popover keeps relying on that + whatever ancestor clipping already existed).
318
- const renderMenuList = (maxHeight?: number) => (
319
- <ul
320
- ref={menuRef}
321
- id={`${idBase}list`}
322
- className="mselect-list"
323
- role="listbox"
324
- tabIndex={-1}
325
- style={maxHeight != null ? { maxHeight } : undefined}
326
- aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
327
- >
328
- {filteredOptions.map((opt, i) => (
329
- <li key={`${opt.value}_${i}`} role="presentation">
330
- <button
331
- id={`${idBase}${i}`}
332
- type="button"
333
- role="option"
334
- aria-selected={opt.value === value}
335
- disabled={opt.disabled}
336
- className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
337
- onMouseEnter={() => setActiveIndex(i)}
338
- onClick={() => commit(i)}
339
- >
340
- <span className="mselect-opt-label">{opt.label}</span>
341
- {opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
342
- </button>
343
- </li>
344
- ))}
345
- {searchable && filteredOptions.length === 0 && (
346
- <li className="mselect-empty" role="presentation">Keine Treffer</li>
347
- )}
348
- </ul>
349
- );
350
-
351
- return (
352
- <div className={wrapCls.join(' ')} ref={rootRef}>
353
- {editing ? (
354
- // combobox mode: the trigger itself becomes the text field (not a separate input
355
- // inside the popover) — a <button> can't legally contain an <input>, so this is a
356
- // <div> standing in for it, same `.mselect-btn` class for identical chrome.
357
- <div className="mselect-btn mselect-btn--editing">
358
- {iconNode || (icon && <Icon name={icon} />)}
359
- <input
360
- ref={searchRef}
361
- type="text"
362
- className="mselect-input"
363
- placeholder={searchPlaceholder || placeholder}
364
- value={query}
365
- onChange={(e) => setQuery(numeric ? e.target.value.replace(/\D/g, '') : e.target.value)}
366
- onKeyDown={onMenuKeyDown}
367
- aria-label={ariaLabel}
368
- aria-haspopup="listbox"
369
- aria-expanded={open}
370
- aria-controls={`${idBase}list`}
371
- aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
372
- aria-invalid={ariaInvalid ? true : undefined}
373
- aria-describedby={ariaDescribedBy}
374
- {...(numeric ? { inputMode: 'numeric' as const, pattern: '[0-9]*' } : {})}
375
- />
376
- <button type="button" className="mselect-chev-btn" aria-label="Close" onClick={() => close(true)}>
377
- <Icon name="chevron-down" cls="mselect-chev" />
378
- </button>
379
- </div>
380
- ) : (
381
- <button
382
- ref={btnRef}
383
- type="button"
384
- className="mselect-btn"
385
- aria-haspopup="listbox"
386
- aria-expanded={open}
387
- aria-label={ariaLabel}
388
- aria-invalid={ariaInvalid ? true : undefined}
389
- aria-describedby={ariaDescribedBy}
390
- disabled={disabled}
391
- onClick={() => (open ? close(false) : openMenu())}
392
- onKeyDown={onTriggerKeyDown}
393
- >
394
- {iconNode || (icon && <Icon name={icon} />)}
395
- <span className={'mselect-label' + (hasValue ? '' : ' mselect-label--ph')}>{label}</span>
396
- <Icon name="chevron-down" cls="mselect-chev" />
397
- </button>
398
- )}
399
- {open && !suppressEmptyMenu && (menuPortal && portalRoot
400
- ? ReactDOM.createPortal(
401
- <div
402
- ref={menuElRef}
403
- className={'mselect-menu mselect-menu--portal' + (searchable ? ' mselect-menu--searchable' : '')}
404
- // menuPos is null for one frame on open, before the position effect has measured
405
- // the trigger — stay invisible (not just visually, via `visibility`) rather than
406
- // flash at (0,0)/full-width. `position: fixed` is set UNCONDITIONALLY, in both
407
- // branches — leaving it out of this fallback used to mean the element briefly
408
- // rendered as a normal in-flow block at the end of <body> for that one frame,
409
- // which could visibly shift the page's scroll position the instant a dropdown
410
- // opened (the block's real height — up to .mselect-list's 288px — briefly counted
411
- // toward the document's scrollable height before flipping back out of flow).
412
- style={
413
- menuPos
414
- ? {
415
- position: 'fixed',
416
- top: menuPos.top,
417
- bottom: menuPos.bottom,
418
- left: menuPos.left,
419
- right: menuPos.right,
420
- width: menuPos.width,
421
- minWidth: menuPos.width,
422
- // Root cause of the "open above" collapse (menuPos.top/left now always a
423
- // concrete number-or-'auto', never omitted — see the menuPos state comment):
424
- // top/bottom (and left/right) here used to leave the unanchored side as
425
- // `undefined`, which React just drops from the inline style rather than
426
- // writing `auto`. With `top` missing from the DOM's inline style, the
427
- // `.mselect-menu` stylesheet rule (`top: calc(100% + 4px)`) filled back in —
428
- // fighting the inline `bottom` we DID set. Both `top` and `bottom` pinned at
429
- // once, with `height` still auto, forces the used height to the distance
430
- // between them (CSS 2.1 §10.6.4), which computed out negative (top landing
431
- // just past the viewport's bottom edge, bottom landing just above the
432
- // trigger) — clamped to 0, leaving only .mselect-menu's own padding+border
433
- // visible (6px*2 + 1px*2 = 14px, exactly the reported near-zero height). The
434
- // `top`-anchored "open below" case never hit this: its inline `top` overrode
435
- // the stylesheet regardless, and `bottom` being class-omitted (not written at
436
- // all, `auto` is the CSS default for `bottom`) matched the intended math.
437
- // With every side now explicitly 'auto' when unused, no stylesheet fallback
438
- // can leak in and `height` is a normal shrink-to-fit-then-clamped-by-maxHeight
439
- // calculation in both directions. This own explicit maxHeight (list's
440
- // maxHeight + .mselect-menu's padding/border, 6px*2 + 1px*2 = 14) still
441
- // matters on top of that fix — box-sizing: border-box means maxHeight here
442
- // must include the padding/border the inner .mselect-list's own maxHeight
443
- // (renderMenuList below) doesn't count, or the list gets squeezed twice.
444
- maxHeight: menuPos.maxHeight + 14,
445
- }
446
- : { position: 'fixed', visibility: 'hidden' }
447
- }
448
- >
449
- {renderMenuList(menuPos?.maxHeight)}
450
- </div>,
451
- portalRoot,
452
- )
453
- : (
454
- <div
455
- ref={menuElRef}
456
- className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}
457
- >
458
- {renderMenuList()}
459
- </div>
460
- )
461
- )}
462
- </div>
463
- );
464
- };
465
-
466
- export default Dropdown;
467
-
1
+ // Autobahn (auto.de redesign) — md3 single-select dropdown.
2
+ //
3
+ // A real, themeable replacement for the native HTML `<select>`. The browser renders
4
+ // a native `<select>`'s option list with the OS popup, which CANNOT be styled (it
5
+ // ignores the scoped `.md3` theme and reads as "broken"/un-md3). This component
6
+ // reproduces the control as a button trigger (`.mselect-btn`, styled like `.mfield`)
7
+ // plus an absolutely-positioned `.mcard`-style popover menu we fully control.
8
+ //
9
+ // Behaviour parity with a real select:
10
+ // - controlled: `value` + `options` ({value,label,disabled?}) + `onChange(value)`.
11
+ // - keyboard: ↓/Enter/Space open; ↑/↓ move the active option; Enter selects;
12
+ // Esc / Tab close (Esc refocuses the trigger); Home/End jump.
13
+ // - click-away (document mousedown) closes; the active option scrolls into view.
14
+ // - a11y: trigger `aria-haspopup=listbox` + `aria-expanded`; menu `role=listbox`;
15
+ // options `role=option` + `aria-selected`; `aria-activedescendant` tracks focus.
16
+ //
17
+ // Pure/presentational — no store/API/window beyond the click-away listener (guarded
18
+ // for SSR). Renders correctly only inside a `<div className="md3">` subtree.
19
+
20
+ import React from 'react';
21
+ import * as ReactDOM from 'react-dom';
22
+
23
+ import Icon from './Icon';
24
+ import { usePortalMenuPosition } from './usePortalMenuPosition';
25
+
26
+ export interface DropdownOption {
27
+ value: string;
28
+ label: string;
29
+ disabled?: boolean;
30
+ }
31
+
32
+ export interface DropdownProps {
33
+ /** currently selected option value. */
34
+ value: string;
35
+ /** the selectable options, in display order. */
36
+ options: DropdownOption[];
37
+ /** fired with the chosen option's value. */
38
+ onChange: (value: string) => void;
39
+ /** shown (muted) when `value` matches no option. */
40
+ placeholder?: string;
41
+ /** leading lucide icon (kebab-case), e.g. 'arrow-up-down'. */
42
+ icon?: string;
43
+ /** custom leading icon element — takes precedence over `icon` when both are given. For a
44
+ * one-off icon that isn't in lucide-react (e.g. a Figma-exported glyph); give it a
45
+ * `className="lucide"` so it picks up the same `.mselect-btn .lucide` sizing/color rules
46
+ * every named `icon` gets. */
47
+ iconNode?: React.ReactNode;
48
+ disabled?: boolean;
49
+ /** full-width trigger (default true for form fields; pass false for inline controls). */
50
+ block?: boolean;
51
+ /** compact height (sort bar / inline). */
52
+ size?: 'sm' | 'md';
53
+ /** accessible label when there is no visible <label> wrapping the control. */
54
+ ariaLabel?: string;
55
+ /** marks the trigger aria-invalid (screen readers) when the owning field has a validation
56
+ * error — purely additive/opt-in, undefined by default so existing callers are unaffected. */
57
+ ariaInvalid?: boolean;
58
+ /** id of the (possibly visually-hidden) error text describing why the field is invalid. */
59
+ ariaDescribedBy?: string;
60
+ /** extra class on the wrapper (e.g. 'srp-sort' for width rules). */
61
+ className?: string;
62
+ /** align the popover to the right edge of the trigger (default left). */
63
+ menuAlign?: 'left' | 'right';
64
+ /** show a text input at the top of the popover that filters `options` by label (substring, case-insensitive). */
65
+ searchable?: boolean;
66
+ /** placeholder for the search input (only used when `searchable`). */
67
+ searchPlaceholder?: string;
68
+ /** restrict the combobox input to digits (numeric "up to" fields) — filters every keystroke and sets inputMode="numeric" pattern="[0-9]*". Requires `searchable`. */
69
+ numeric?: boolean;
70
+ /** allow committing a typed value that matches no option, instead of only ever allowing an exact pick from the list — for numeric "up to" fields where any amount is valid, not just the preset rungs. Committed on close (blur/Tab/click-away/Enter), not on every keystroke. Requires `searchable`. */
71
+ allowCustomValue?: boolean;
72
+ /** renders the closed-trigger label for a custom (non-list) value. Falls back to the raw value itself when omitted — pass this whenever `allowCustomValue` so the trigger reads naturally (e.g. `(v) => \`bis ${v} km\``). */
73
+ formatCustomLabel?: (value: string) => string;
74
+ /**
75
+ * Render the popover into a `document.body` portal instead of nesting it in place — escapes an
76
+ * ancestor's `overflow:hidden`/clipping (e.g. inside a collapsing Accordion region, see
77
+ * `.macc-region-inner` in md3.css) that would otherwise cut the option list off instead of
78
+ * letting it float over the following content. Position is computed from the trigger's on-screen
79
+ * rect, kept in sync on scroll/resize while open, and flips to open ABOVE the trigger when
80
+ * there isn't room below. Off by default — every existing caller keeps the simple in-place
81
+ * popover; opt in only at call sites with a known clipping ancestor.
82
+ */
83
+ menuPortal?: boolean;
84
+ }
85
+
86
+ const Dropdown: React.FunctionComponent<DropdownProps> = ({
87
+ value,
88
+ options,
89
+ onChange,
90
+ placeholder,
91
+ icon,
92
+ iconNode,
93
+ disabled = false,
94
+ block = true,
95
+ size = 'md',
96
+ ariaLabel,
97
+ ariaInvalid,
98
+ ariaDescribedBy,
99
+ className,
100
+ menuAlign = 'left',
101
+ searchable = false,
102
+ searchPlaceholder,
103
+ numeric = false,
104
+ allowCustomValue = false,
105
+ formatCustomLabel,
106
+ menuPortal = false,
107
+ }) => {
108
+ const [open, setOpen] = React.useState(false);
109
+ const [activeIndex, setActiveIndex] = React.useState(-1);
110
+ const [query, setQuery] = React.useState('');
111
+ const rootRef = React.useRef<HTMLDivElement | null>(null);
112
+ const btnRef = React.useRef<HTMLButtonElement | null>(null);
113
+ const menuRef = React.useRef<HTMLUListElement | null>(null);
114
+ const searchRef = React.useRef<HTMLInputElement | null>(null);
115
+ // stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
116
+ const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
117
+
118
+ const selectedIndex = options.findIndex((o) => o.value === value);
119
+ const selected = selectedIndex !== -1 ? options[selectedIndex] : undefined;
120
+ // a real value that just doesn't happen to match any option — only possible with
121
+ // allowCustomValue (e.g. a hand-typed "1421232" the preset rungs don't cover).
122
+ const customLabel = allowCustomValue && value && !selected
123
+ ? (formatCustomLabel ? formatCustomLabel(value) : value)
124
+ : undefined;
125
+ const hasValue = !!selected || customLabel !== undefined;
126
+
127
+ const filteredOptions = React.useMemo(() => {
128
+ if (!searchable || !query.trim()) return options;
129
+ const q = query.trim().toLowerCase();
130
+ const matches = options.filter((o) => o.label.toLowerCase().includes(q));
131
+ // some option lists intentionally repeat entries across groups while closed (e.g. a "Top
132
+ // Marken" shortlist followed by the FULL "Alle Marken" list, which includes those same top
133
+ // brands again) — harmless when browsing both sections, but shows the same match twice while
134
+ // searching. Keep only the first occurrence of each label once the user is actually filtering.
135
+ const seen = new Set<string>();
136
+ return matches.filter((o) => {
137
+ const key = o.label.toLowerCase();
138
+ if (seen.has(key)) return false;
139
+ seen.add(key);
140
+ return true;
141
+ });
142
+ }, [searchable, query, options]);
143
+
144
+ // deferred: when searchable, the trigger <button> is swapped out for an <input> while
145
+ // open (see render), so btnRef.current is null *during* the close() call itself — the
146
+ // button only exists again after this state flips `open` back to false and React
147
+ // re-renders. Consumed by the effect below, once that re-render has actually happened.
148
+ const focusTriggerAfterClose = React.useRef(false);
149
+
150
+ // close + restore focus to the trigger. `commitTyped` (default true) commits whatever's
151
+ // currently typed as a custom value when `allowCustomValue` — this is what makes "just leave
152
+ // the field" (blur/click-away/Tab) work, not only an explicit pick from the list. Callers that
153
+ // already committed an explicit option (commit()) or that are cancelling (Escape) pass false.
154
+ const close = React.useCallback((focusTrigger: boolean, commitTyped: boolean = true) => {
155
+ if (allowCustomValue && commitTyped) {
156
+ const raw = query.trim();
157
+ if (raw !== value) onChange(raw);
158
+ }
159
+ setOpen(false);
160
+ setActiveIndex(-1);
161
+ setQuery('');
162
+ focusTriggerAfterClose.current = focusTrigger;
163
+ }, [allowCustomValue, query, value, onChange]);
164
+
165
+ React.useEffect(() => {
166
+ if (!open && focusTriggerAfterClose.current) {
167
+ focusTriggerAfterClose.current = false;
168
+ if (btnRef.current) btnRef.current.focus();
169
+ }
170
+ }, [open]);
171
+
172
+ const openMenu = React.useCallback(() => {
173
+ if (disabled) return;
174
+ setOpen(true);
175
+ // start the keyboard highlight on the current selection (or the first option).
176
+ setActiveIndex(selectedIndex !== -1 ? selectedIndex : 0);
177
+ // pre-fill the combobox with the current value (raw, not the formatted label) so a custom
178
+ // entry can be edited/replaced in place instead of always starting from a blank field.
179
+ if (allowCustomValue) setQuery(value || '');
180
+ }, [disabled, selectedIndex, allowCustomValue, value]);
181
+
182
+ // when opened searchable, focus the search input instead of leaving focus on the trigger.
183
+ // scrollIntoView first: on mobile the keyboard is about to eat ~40% of the viewport, so the
184
+ // trigger (and the popover below it) needs to already be scrolled above the fold before focus
185
+ // fires, or the popover can end up hidden behind the keyboard with no way to see it open.
186
+ React.useEffect(() => {
187
+ if (!open || !searchable) return;
188
+ if (rootRef.current && rootRef.current.scrollIntoView) {
189
+ rootRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
190
+ }
191
+ if (searchRef.current) {
192
+ searchRef.current.focus();
193
+ // pre-filled custom value (see openMenu) starts selected, so typing immediately
194
+ // replaces it instead of appending — same "click a number field, type over it" feel.
195
+ if (allowCustomValue) searchRef.current.select();
196
+ }
197
+ }, [open, searchable, allowCustomValue]);
198
+
199
+ // click-away (guarded for SSR). menuElRef is checked too — with menuPortal the popover renders
200
+ // outside rootRef (at document.body), so without this every option click would look like a
201
+ // click-away and close the menu before the option's own onClick had a chance to fire.
202
+ React.useEffect(() => {
203
+ if (!open || typeof document === 'undefined') return undefined;
204
+ const onDocMouseDown = (e: MouseEvent) => {
205
+ const target = e.target as Node;
206
+ if (rootRef.current && rootRef.current.contains(target)) return;
207
+ if (menuElRef.current && menuElRef.current.contains(target)) return;
208
+ close(false);
209
+ };
210
+ document.addEventListener('mousedown', onDocMouseDown);
211
+ return () => document.removeEventListener('mousedown', onDocMouseDown);
212
+ }, [open, close]);
213
+
214
+ // menuPortal: body-portal root + trigger-relative position, tracked on scroll/resize, flipping
215
+ // above the trigger when there isn't room below. Shared with MultiDropdown's checklist menu
216
+ // below — see usePortalMenuPosition's own doc comment for the full reasoning (every timing/CSS
217
+ // fix that went into this belongs in one place so the two consumers can't drift apart).
218
+ const onCloseAway = React.useCallback(() => close(false, false), [close]);
219
+ const { portalRoot, menuPos, menuElRef } = usePortalMenuPosition({
220
+ enabled: menuPortal,
221
+ open,
222
+ rootRef,
223
+ menuAlign,
224
+ onCloseAway,
225
+ });
226
+
227
+ // keep the active option scrolled into view while navigating by keyboard.
228
+ React.useEffect(() => {
229
+ if (!open || activeIndex < 0 || !menuRef.current) return;
230
+ const el = menuRef.current.children[activeIndex] as HTMLElement | undefined;
231
+ if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
232
+ }, [open, activeIndex]);
233
+
234
+ // reset the keyboard highlight to the top match whenever the filtered list changes.
235
+ React.useEffect(() => {
236
+ if (searchable && open) setActiveIndex(filteredOptions.length ? 0 : -1);
237
+ // eslint-disable-next-line react-hooks/exhaustive-deps
238
+ }, [filteredOptions]);
239
+
240
+ const commit = (idx: number) => {
241
+ const opt = filteredOptions[idx];
242
+ if (!opt || opt.disabled) return;
243
+ onChange(opt.value);
244
+ // false: the explicit pick already won — don't also commit whatever's still sitting in the
245
+ // (now-stale) typed query behind it.
246
+ close(true, false);
247
+ };
248
+
249
+ // move the active highlight, skipping disabled options.
250
+ const move = (dir: 1 | -1) => {
251
+ if (!filteredOptions.length) return;
252
+ let i = activeIndex;
253
+ for (let step = 0; step < filteredOptions.length; step += 1) {
254
+ i = (i + dir + filteredOptions.length) % filteredOptions.length;
255
+ if (!filteredOptions[i].disabled) {
256
+ setActiveIndex(i);
257
+ return;
258
+ }
259
+ }
260
+ };
261
+
262
+ const onMenuKeyDown = (e: React.KeyboardEvent) => {
263
+ switch (e.key) {
264
+ case 'ArrowDown': e.preventDefault(); move(1); break;
265
+ case 'ArrowUp': e.preventDefault(); move(-1); break;
266
+ case 'Home': e.preventDefault(); setActiveIndex(filteredOptions.findIndex((o) => !o.disabled)); break;
267
+ case 'End': e.preventDefault(); for (let i = filteredOptions.length - 1; i >= 0; i -= 1) { if (!filteredOptions[i].disabled) { setActiveIndex(i); break; } } break;
268
+ case 'Enter':
269
+ e.preventDefault();
270
+ // an active list match wins when there is one; otherwise, for allowCustomValue fields,
271
+ // Enter commits whatever's typed (same value close() would commit on blur anyway).
272
+ if (activeIndex >= 0) commit(activeIndex);
273
+ else if (allowCustomValue) close(true);
274
+ break;
275
+ // Escape cancels — close(..., false) so an in-progress custom edit is discarded, not committed.
276
+ case 'Escape': e.preventDefault(); close(true, false); break;
277
+ case 'Tab': close(false); break;
278
+ default: break;
279
+ }
280
+ };
281
+
282
+ const onTriggerKeyDown = (e: React.KeyboardEvent) => {
283
+ if (disabled) return;
284
+ if (!open) {
285
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
286
+ e.preventDefault();
287
+ openMenu();
288
+ }
289
+ return;
290
+ }
291
+ if (e.key === ' ') { e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); return; }
292
+ onMenuKeyDown(e);
293
+ };
294
+
295
+ const wrapCls = ['mselect'];
296
+ if (size === 'sm') wrapCls.push('mselect--sm');
297
+ if (block) wrapCls.push('mselect--block');
298
+ if (className) wrapCls.push(className);
299
+
300
+ const label = selected ? selected.label : (customLabel !== undefined ? customLabel : placeholder || '');
301
+
302
+ const editing = searchable && open;
303
+
304
+ // Bug 53/37: "Rate bis" / "km bis" allow a hand-typed amount the preset rungs don't cover
305
+ // (allowCustomValue) — that typed number never matches any option label, so filteredOptions
306
+ // comes back empty and the popover would show "Keine Treffer", reading as "no cars match this"
307
+ // to the customer even though the typed value is perfectly valid input. Once there's an actual
308
+ // typed query with zero matches on an allowCustomValue field, suppress the popover entirely
309
+ // (not just the "Keine Treffer" row) — an empty floating box would be just as confusing. Plain
310
+ // (non-custom) searchable dropdowns, e.g. Make/Model, are unaffected and keep showing it.
311
+ const suppressEmptyMenu = allowCustomValue && searchable && query.trim() !== '' && filteredOptions.length === 0;
312
+
313
+ // shared between the in-place popover and the menuPortal one — identical option list either way.
314
+ // maxHeight (menuPortal only — see positionMenu) overrides the CSS static 288px cap with the
315
+ // space actually available in whichever direction the menu opened; inline style wins over the
316
+ // class rule regardless of specificity, and falls back to the CSS default when omitted (the
317
+ // in-place, non-portal popover keeps relying on that + whatever ancestor clipping already existed).
318
+ const renderMenuList = (maxHeight?: number) => (
319
+ <ul
320
+ ref={menuRef}
321
+ id={`${idBase}list`}
322
+ className="mselect-list"
323
+ role="listbox"
324
+ tabIndex={-1}
325
+ style={maxHeight != null ? { maxHeight } : undefined}
326
+ aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
327
+ >
328
+ {filteredOptions.map((opt, i) => (
329
+ <li key={`${opt.value}_${i}`} role="presentation">
330
+ <button
331
+ id={`${idBase}${i}`}
332
+ type="button"
333
+ role="option"
334
+ aria-selected={opt.value === value}
335
+ disabled={opt.disabled}
336
+ className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
337
+ onMouseEnter={() => setActiveIndex(i)}
338
+ onClick={() => commit(i)}
339
+ >
340
+ <span className="mselect-opt-label">{opt.label}</span>
341
+ {opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
342
+ </button>
343
+ </li>
344
+ ))}
345
+ {searchable && filteredOptions.length === 0 && (
346
+ <li className="mselect-empty" role="presentation">Keine Treffer</li>
347
+ )}
348
+ </ul>
349
+ );
350
+
351
+ return (
352
+ <div className={wrapCls.join(' ')} ref={rootRef}>
353
+ {editing ? (
354
+ // combobox mode: the trigger itself becomes the text field (not a separate input
355
+ // inside the popover) — a <button> can't legally contain an <input>, so this is a
356
+ // <div> standing in for it, same `.mselect-btn` class for identical chrome.
357
+ <div className="mselect-btn mselect-btn--editing">
358
+ {iconNode || (icon && <Icon name={icon} />)}
359
+ <input
360
+ ref={searchRef}
361
+ type="text"
362
+ className="mselect-input"
363
+ placeholder={searchPlaceholder || placeholder}
364
+ value={query}
365
+ onChange={(e) => setQuery(numeric ? e.target.value.replace(/\D/g, '') : e.target.value)}
366
+ onKeyDown={onMenuKeyDown}
367
+ aria-label={ariaLabel}
368
+ aria-haspopup="listbox"
369
+ aria-expanded={open}
370
+ aria-controls={`${idBase}list`}
371
+ aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
372
+ aria-invalid={ariaInvalid ? true : undefined}
373
+ aria-describedby={ariaDescribedBy}
374
+ {...(numeric ? { inputMode: 'numeric' as const, pattern: '[0-9]*' } : {})}
375
+ />
376
+ <button type="button" className="mselect-chev-btn" aria-label="Close" onClick={() => close(true)}>
377
+ <Icon name="chevron-down" cls="mselect-chev" />
378
+ </button>
379
+ </div>
380
+ ) : (
381
+ <button
382
+ ref={btnRef}
383
+ type="button"
384
+ className="mselect-btn"
385
+ aria-haspopup="listbox"
386
+ aria-expanded={open}
387
+ aria-label={ariaLabel}
388
+ aria-invalid={ariaInvalid ? true : undefined}
389
+ aria-describedby={ariaDescribedBy}
390
+ disabled={disabled}
391
+ onClick={() => (open ? close(false) : openMenu())}
392
+ onKeyDown={onTriggerKeyDown}
393
+ >
394
+ {iconNode || (icon && <Icon name={icon} />)}
395
+ <span className={'mselect-label' + (hasValue ? '' : ' mselect-label--ph')}>{label}</span>
396
+ <Icon name="chevron-down" cls="mselect-chev" />
397
+ </button>
398
+ )}
399
+ {open && !suppressEmptyMenu && (menuPortal && portalRoot
400
+ ? ReactDOM.createPortal(
401
+ <div
402
+ ref={menuElRef}
403
+ className={'mselect-menu mselect-menu--portal' + (searchable ? ' mselect-menu--searchable' : '')}
404
+ // menuPos is null for one frame on open, before the position effect has measured
405
+ // the trigger — stay invisible (not just visually, via `visibility`) rather than
406
+ // flash at (0,0)/full-width. `position: fixed` is set UNCONDITIONALLY, in both
407
+ // branches — leaving it out of this fallback used to mean the element briefly
408
+ // rendered as a normal in-flow block at the end of <body> for that one frame,
409
+ // which could visibly shift the page's scroll position the instant a dropdown
410
+ // opened (the block's real height — up to .mselect-list's 288px — briefly counted
411
+ // toward the document's scrollable height before flipping back out of flow).
412
+ style={
413
+ menuPos
414
+ ? {
415
+ position: 'fixed',
416
+ top: menuPos.top,
417
+ bottom: menuPos.bottom,
418
+ left: menuPos.left,
419
+ right: menuPos.right,
420
+ width: menuPos.width,
421
+ minWidth: menuPos.width,
422
+ // Root cause of the "open above" collapse (menuPos.top/left now always a
423
+ // concrete number-or-'auto', never omitted — see the menuPos state comment):
424
+ // top/bottom (and left/right) here used to leave the unanchored side as
425
+ // `undefined`, which React just drops from the inline style rather than
426
+ // writing `auto`. With `top` missing from the DOM's inline style, the
427
+ // `.mselect-menu` stylesheet rule (`top: calc(100% + 4px)`) filled back in —
428
+ // fighting the inline `bottom` we DID set. Both `top` and `bottom` pinned at
429
+ // once, with `height` still auto, forces the used height to the distance
430
+ // between them (CSS 2.1 §10.6.4), which computed out negative (top landing
431
+ // just past the viewport's bottom edge, bottom landing just above the
432
+ // trigger) — clamped to 0, leaving only .mselect-menu's own padding+border
433
+ // visible (6px*2 + 1px*2 = 14px, exactly the reported near-zero height). The
434
+ // `top`-anchored "open below" case never hit this: its inline `top` overrode
435
+ // the stylesheet regardless, and `bottom` being class-omitted (not written at
436
+ // all, `auto` is the CSS default for `bottom`) matched the intended math.
437
+ // With every side now explicitly 'auto' when unused, no stylesheet fallback
438
+ // can leak in and `height` is a normal shrink-to-fit-then-clamped-by-maxHeight
439
+ // calculation in both directions. This own explicit maxHeight (list's
440
+ // maxHeight + .mselect-menu's padding/border, 6px*2 + 1px*2 = 14) still
441
+ // matters on top of that fix — box-sizing: border-box means maxHeight here
442
+ // must include the padding/border the inner .mselect-list's own maxHeight
443
+ // (renderMenuList below) doesn't count, or the list gets squeezed twice.
444
+ maxHeight: menuPos.maxHeight + 14,
445
+ }
446
+ : { position: 'fixed', visibility: 'hidden' }
447
+ }
448
+ >
449
+ {renderMenuList(menuPos?.maxHeight)}
450
+ </div>,
451
+ portalRoot,
452
+ )
453
+ : (
454
+ <div
455
+ ref={menuElRef}
456
+ className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}
457
+ >
458
+ {renderMenuList()}
459
+ </div>
460
+ )
461
+ )}
462
+ </div>
463
+ );
464
+ };
465
+
466
+ export default Dropdown;
467
+
@@ -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