@bytebrand/fe-ui-core-autobahn 1.0.119 → 1.0.121

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.
@@ -79,6 +79,11 @@ const MdField: React.FunctionComponent<MdFieldProps> = ({
79
79
  <div
80
80
  className={
81
81
  'mfield'
82
+ // .mfield's base height:48px + align-items:center is sized for a single-line input.
83
+ // .mfield.mtextarea (md3.css) overrides that to height:auto + align-items:stretch for a
84
+ // multiline field — without it a tall `rows` textarea just overflows past the 48px box
85
+ // instead of growing it, visually spilling over whatever sits below/beside the field.
86
+ + (multiline ? ' mtextarea' : '')
82
87
  + (hasError ? ' mfield--error' : '')
83
88
  + (disabled ? ' mfield--disabled' : '')
84
89
  + (floatingLabel ? ' mfield--floating' : '')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bytebrand/fe-ui-core-autobahn",
3
- "version": "1.0.119",
3
+ "version": "1.0.121",
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
@@ -9563,6 +9563,11 @@ color: #005CFF;
9563
9563
  align-items: flex-start;
9564
9564
  padding: 7px 14px;
9565
9565
  }
9566
+ /* nothing selected yet: single-line placeholder, same as a normal select — center it instead
9567
+ of top-aligning (top-align only earns its keep once there are wrapped chips to protect). */
9568
+ .md3 .mfd-multi-btn--empty {
9569
+ align-items: center;
9570
+ }
9566
9571
  .md3 .mfd-multi-btn .mselect-chev {
9567
9572
  align-self: center;
9568
9573
  }
@@ -9650,6 +9655,11 @@ color: #005CFF;
9650
9655
  display: flex;
9651
9656
  flex-direction: column;
9652
9657
  gap: 6px;
9658
+ /* this is the element that actually sits IN a CSS grid column (e.g. VehicleEdit Safety
9659
+ .row) — grid items default to a content-based auto min-width too, so this needs the
9660
+ same override as .mcheck/.mcheck-label below or a long label can still force its own
9661
+ column wider than its track instead of wrapping inside it. */
9662
+ min-width: 0;
9653
9663
  }
9654
9664
 
9655
9665
  /* 44px touch target: the label is the hit area; the visual box sits centred inside. */
@@ -9663,6 +9673,12 @@ color: #005CFF;
9663
9673
  font-family: var(--md-font);
9664
9674
  color: var(--md-on-surface);
9665
9675
  -webkit-tap-highlight-color: transparent;
9676
+ /* a flex item's automatic min-width is its content's un-wrapped size, not 0 — inside a
9677
+ narrow grid column (see VehicleEdit Safety .row) that let a long label's text refuse to
9678
+ wrap and push past the column instead. 0 lets this actually shrink to whatever width its
9679
+ container gives it, same as .mcheck-label below (belt-and-suspenders: either alone fixes
9680
+ it depending on which element the browser treats as the shrink boundary). */
9681
+ min-width: 0;
9666
9682
  }
9667
9683
  .md3 .mcheck--disabled {
9668
9684
  cursor: not-allowed;
@@ -9720,6 +9736,8 @@ color: #005CFF;
9720
9736
  line-height: 1.5;
9721
9737
  padding-top: 7px;
9722
9738
  color: var(--md-on-surface);
9739
+ /* see .mcheck's min-width:0 above — same fix, on the actual text-bearing flex item. */
9740
+ min-width: 0;
9723
9741
  }
9724
9742
  /* rich labels (T&C/permission) — links pick up the brand colour. */
9725
9743
  .md3 .mcheck-label a {
package/ui/Dropdown.tsx CHANGED
@@ -21,6 +21,7 @@ import React from 'react';
21
21
  import * as ReactDOM from 'react-dom';
22
22
 
23
23
  import Icon from './Icon';
24
+ import { usePortalMenuPosition } from './usePortalMenuPosition';
24
25
 
25
26
  export interface DropdownOption {
26
27
  value: string;
@@ -104,7 +105,6 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
104
105
  const rootRef = React.useRef<HTMLDivElement | null>(null);
105
106
  const btnRef = React.useRef<HTMLButtonElement | null>(null);
106
107
  const menuRef = React.useRef<HTMLUListElement | null>(null);
107
- const menuElRef = React.useRef<HTMLDivElement | null>(null);
108
108
  const searchRef = React.useRef<HTMLInputElement | null>(null);
109
109
  // stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
110
110
  const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
@@ -205,60 +205,18 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
205
205
  return () => document.removeEventListener('mousedown', onDocMouseDown);
206
206
  }, [open, close]);
207
207
 
208
- // menuPortal: a fresh div.md3 appended to body while open, so --md-* tokens and every
209
- // `.md3 .mselect-*` rule still resolve at body level (same technique as ui/Modal.tsx).
210
- const portalRoot = React.useMemo<HTMLDivElement | null>(() => {
211
- if (!menuPortal || typeof document === 'undefined') return null;
212
- const el = document.createElement('div');
213
- el.className = 'md3';
214
- return el;
215
- }, [menuPortal]);
216
-
217
- React.useEffect(() => {
218
- if (!menuPortal || !open || !portalRoot || typeof document === 'undefined') return undefined;
219
- document.body.appendChild(portalRoot);
220
- return () => {
221
- if (portalRoot.parentNode) portalRoot.parentNode.removeChild(portalRoot);
222
- };
223
- }, [menuPortal, open, portalRoot]);
224
-
225
- // menuPortal position: computed from the trigger's on-screen rect (position:fixed, so it's
226
- // viewport-relative — kept in sync on scroll/resize below). ESTIMATED_MENU_HEIGHT (the list's
227
- // own max-height, 288px, plus the menu's padding/border) only needs to be a reasonable guess —
228
- // it's used to DECIDE whether to flip above the trigger, not to size anything; the "above"
229
- // placement is positioned via `bottom` instead of `top` so it grows upward regardless of its
230
- // real height.
231
- const ESTIMATED_MENU_HEIGHT = 300;
232
- const [menuPos, setMenuPos] = React.useState<
233
- { width: number; top?: number; bottom?: number; left?: number; right?: number } | null
234
- >(null);
235
-
236
- const positionMenu = React.useCallback(() => {
237
- if (!menuPortal || !rootRef.current || typeof window === 'undefined') return;
238
- const rect = rootRef.current.getBoundingClientRect();
239
- const spaceBelow = window.innerHeight - rect.bottom;
240
- const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && rect.top > spaceBelow;
241
- setMenuPos({
242
- width: rect.width,
243
- left: menuAlign === 'right' ? undefined : rect.left,
244
- right: menuAlign === 'right' ? window.innerWidth - rect.right : undefined,
245
- top: openAbove ? undefined : rect.bottom + 4,
246
- bottom: openAbove ? window.innerHeight - rect.top + 4 : undefined,
247
- });
248
- }, [menuPortal, menuAlign]);
249
-
250
- React.useEffect(() => {
251
- if (!open || !menuPortal) return undefined;
252
- positionMenu();
253
- // capture:true — a scroll inside any nested scrollable ancestor (not just the window) must
254
- // also reposition, since the trigger moves relative to the viewport either way.
255
- window.addEventListener('scroll', positionMenu, true);
256
- window.addEventListener('resize', positionMenu);
257
- return () => {
258
- window.removeEventListener('scroll', positionMenu, true);
259
- window.removeEventListener('resize', positionMenu);
260
- };
261
- }, [open, menuPortal, positionMenu]);
208
+ // menuPortal: body-portal root + trigger-relative position, tracked on scroll/resize, flipping
209
+ // above the trigger when there isn't room below. Shared with MultiDropdown's checklist menu
210
+ // below — see usePortalMenuPosition's own doc comment for the full reasoning (every timing/CSS
211
+ // fix that went into this belongs in one place so the two consumers can't drift apart).
212
+ const onCloseAway = React.useCallback(() => close(false, false), [close]);
213
+ const { portalRoot, menuPos, menuElRef } = usePortalMenuPosition({
214
+ enabled: menuPortal,
215
+ open,
216
+ rootRef,
217
+ menuAlign,
218
+ onCloseAway,
219
+ });
262
220
 
263
221
  // keep the active option scrolled into view while navigating by keyboard.
264
222
  React.useEffect(() => {
@@ -347,13 +305,18 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
347
305
  const suppressEmptyMenu = allowCustomValue && searchable && query.trim() !== '' && filteredOptions.length === 0;
348
306
 
349
307
  // shared between the in-place popover and the menuPortal one — identical option list either way.
350
- const renderMenuList = () => (
308
+ // maxHeight (menuPortal only — see positionMenu) overrides the CSS static 288px cap with the
309
+ // space actually available in whichever direction the menu opened; inline style wins over the
310
+ // class rule regardless of specificity, and falls back to the CSS default when omitted (the
311
+ // in-place, non-portal popover keeps relying on that + whatever ancestor clipping already existed).
312
+ const renderMenuList = (maxHeight?: number) => (
351
313
  <ul
352
314
  ref={menuRef}
353
315
  id={`${idBase}list`}
354
316
  className="mselect-list"
355
317
  role="listbox"
356
318
  tabIndex={-1}
319
+ style={maxHeight != null ? { maxHeight } : undefined}
357
320
  aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
358
321
  >
359
322
  {filteredOptions.map((opt, i) => (
@@ -433,7 +396,13 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
433
396
  ref={menuElRef}
434
397
  className={'mselect-menu mselect-menu--portal' + (searchable ? ' mselect-menu--searchable' : '')}
435
398
  // menuPos is null for one frame on open, before the position effect has measured
436
- // the trigger — stay invisible rather than flash at (0,0)/full-width.
399
+ // the trigger — stay invisible (not just visually, via `visibility`) rather than
400
+ // flash at (0,0)/full-width. `position: fixed` is set UNCONDITIONALLY, in both
401
+ // branches — leaving it out of this fallback used to mean the element briefly
402
+ // rendered as a normal in-flow block at the end of <body> for that one frame,
403
+ // which could visibly shift the page's scroll position the instant a dropdown
404
+ // opened (the block's real height — up to .mselect-list's 288px — briefly counted
405
+ // toward the document's scrollable height before flipping back out of flow).
437
406
  style={
438
407
  menuPos
439
408
  ? {
@@ -444,11 +413,34 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
444
413
  right: menuPos.right,
445
414
  width: menuPos.width,
446
415
  minWidth: menuPos.width,
416
+ // Root cause of the "open above" collapse (menuPos.top/left now always a
417
+ // concrete number-or-'auto', never omitted — see the menuPos state comment):
418
+ // top/bottom (and left/right) here used to leave the unanchored side as
419
+ // `undefined`, which React just drops from the inline style rather than
420
+ // writing `auto`. With `top` missing from the DOM's inline style, the
421
+ // `.mselect-menu` stylesheet rule (`top: calc(100% + 4px)`) filled back in —
422
+ // fighting the inline `bottom` we DID set. Both `top` and `bottom` pinned at
423
+ // once, with `height` still auto, forces the used height to the distance
424
+ // between them (CSS 2.1 §10.6.4), which computed out negative (top landing
425
+ // just past the viewport's bottom edge, bottom landing just above the
426
+ // trigger) — clamped to 0, leaving only .mselect-menu's own padding+border
427
+ // visible (6px*2 + 1px*2 = 14px, exactly the reported near-zero height). The
428
+ // `top`-anchored "open below" case never hit this: its inline `top` overrode
429
+ // the stylesheet regardless, and `bottom` being class-omitted (not written at
430
+ // all, `auto` is the CSS default for `bottom`) matched the intended math.
431
+ // With every side now explicitly 'auto' when unused, no stylesheet fallback
432
+ // can leak in and `height` is a normal shrink-to-fit-then-clamped-by-maxHeight
433
+ // calculation in both directions. This own explicit maxHeight (list's
434
+ // maxHeight + .mselect-menu's padding/border, 6px*2 + 1px*2 = 14) still
435
+ // matters on top of that fix — box-sizing: border-box means maxHeight here
436
+ // must include the padding/border the inner .mselect-list's own maxHeight
437
+ // (renderMenuList below) doesn't count, or the list gets squeezed twice.
438
+ maxHeight: menuPos.maxHeight + 14,
447
439
  }
448
- : { visibility: 'hidden' }
440
+ : { position: 'fixed', visibility: 'hidden' }
449
441
  }
450
442
  >
451
- {renderMenuList()}
443
+ {renderMenuList(menuPos?.maxHeight)}
452
444
  </div>,
453
445
  portalRoot,
454
446
  )
@@ -466,3 +458,4 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
466
458
  };
467
459
 
468
460
  export default Dropdown;
461
+
@@ -30,10 +30,12 @@
30
30
  // `.md3` subtree.
31
31
 
32
32
  import * as React from 'react';
33
+ import * as ReactDOM from 'react-dom';
33
34
  import { observer } from 'mobx-react';
34
35
 
35
36
  import Icon from './Icon';
36
37
  import Dropdown, { DropdownOption } from './Dropdown';
38
+ import { usePortalMenuPosition } from './usePortalMenuPosition';
37
39
 
38
40
  export interface FormDropdownProps {
39
41
  /** mobx-react-form field (form.$('basicDataForm.mainData_make') etc.). Untyped — the lib ships no TS types. */
@@ -59,10 +61,10 @@ export interface FormDropdownProps {
59
61
  style?: React.CSSProperties;
60
62
  /** extra class on the wrapper (legacy `className`). */
61
63
  className?: string;
62
- /** forwarded to the underlying Dropdown — see Dropdown.tsx's own doc comment. Escapes a known
63
- * clipping ancestor (e.g. a collapsing Accordion region) by rendering the popover into a
64
- * document.body portal instead of nesting it in place. Single-select only (isMulti unaffected —
65
- * its checklist menu doesn't go through the base Dropdown). Off by default. */
64
+ /** escapes a known clipping ancestor (e.g. a collapsing Accordion region) by rendering the
65
+ * popover/checklist into a document.body portal instead of nesting it in place — see
66
+ * Dropdown.tsx's own doc comment (single-select) / usePortalMenuPosition.ts (shared with
67
+ * MultiDropdown's checklist below). Off by default. */
66
68
  menuPortal?: boolean;
67
69
  }
68
70
 
@@ -137,6 +139,7 @@ const FormDropdown: React.FunctionComponent<FormDropdownProps> = observer(
137
139
  ariaLabel={label}
138
140
  style={style}
139
141
  className={className}
142
+ menuPortal={menuPortal}
140
143
  />
141
144
  );
142
145
  }
@@ -211,25 +214,45 @@ interface MultiProps {
211
214
  ariaLabel: string;
212
215
  style?: React.CSSProperties;
213
216
  className?: string;
217
+ /** see FormDropdownProps.menuPortal above. */
218
+ menuPortal?: boolean;
214
219
  }
215
220
 
216
221
  const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
217
- ({ field, options, label, hasError, disabled, ariaLabel, style, className }) => {
222
+ ({ field, options, label, hasError, disabled, ariaLabel, style, className, menuPortal }) => {
218
223
  const [open, setOpen] = React.useState(false);
219
224
  const rootRef = React.useRef<HTMLDivElement | null>(null);
220
225
  const btnRef = React.useRef<HTMLButtonElement | null>(null);
221
226
 
222
227
  const selected: string[] = Array.isArray(field.value) ? field.value.map(String) : [];
223
228
 
224
- // click-away (guarded for SSR), matching the base Dropdown.
229
+ // menuPortal: same body-portal + trigger-relative positioning as the single-select Dropdown
230
+ // — see usePortalMenuPosition.ts. A checklist can run just as long as any other option list
231
+ // (Safety's Airbag field has 13 entries) and lives in the exact same clipping Accordion
232
+ // section the single-select fix targeted, so it needs the identical escape hatch.
233
+ const onCloseAway = React.useCallback(() => setOpen(false), []);
234
+ const { portalRoot, menuPos, menuElRef } = usePortalMenuPosition<HTMLUListElement>({
235
+ enabled: !!menuPortal,
236
+ open,
237
+ rootRef,
238
+ onCloseAway,
239
+ });
240
+
241
+ // click-away (guarded for SSR), matching the base Dropdown. menuElRef is checked too — with
242
+ // menuPortal the checklist renders outside rootRef (at document.body), so without this every
243
+ // option click would look like a click-away and close the menu before toggle() had a chance
244
+ // to fire.
225
245
  React.useEffect(() => {
226
246
  if (!open || typeof document === 'undefined') return undefined;
227
247
  const onDocMouseDown = (e: MouseEvent) => {
228
- if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
248
+ const target = e.target as Node;
249
+ if (rootRef.current && rootRef.current.contains(target)) return;
250
+ if (menuElRef.current && menuElRef.current.contains(target)) return;
251
+ setOpen(false);
229
252
  };
230
253
  document.addEventListener('mousedown', onDocMouseDown);
231
254
  return () => document.removeEventListener('mousedown', onDocMouseDown);
232
- }, [open]);
255
+ }, [open, menuElRef]);
233
256
 
234
257
  const toggle = (value: string) => {
235
258
  if (disabled) return;
@@ -261,6 +284,29 @@ const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
261
284
  return o ? o.label : value;
262
285
  };
263
286
 
287
+ // shared between the in-place checklist and the menuPortal one — identical options either way.
288
+ const renderOptions = () =>
289
+ options.map((opt, i) => {
290
+ const checked = selected.indexOf(opt.value) > -1;
291
+ return (
292
+ <li key={`${opt.value}_${i}`} role="presentation">
293
+ <button
294
+ type="button"
295
+ role="option"
296
+ aria-selected={checked}
297
+ disabled={opt.disabled}
298
+ className={'mselect-opt mfd-multi-opt' + (checked ? ' is-checked' : '')}
299
+ onClick={() => toggle(opt.value)}
300
+ >
301
+ <span className={'mfd-check' + (checked ? ' mfd-check--on' : '')}>
302
+ {checked ? <Icon name="check" size={14} /> : null}
303
+ </span>
304
+ <span className="mselect-opt-label">{opt.label}</span>
305
+ </button>
306
+ </li>
307
+ );
308
+ });
309
+
264
310
  return (
265
311
  <div className={'mfd' + (className ? ' ' + className : '')} style={style}>
266
312
  {label ? <label className="md-label-sm mfield-label mfd-label">{label}</label> : null}
@@ -268,7 +314,16 @@ const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
268
314
  <button
269
315
  ref={btnRef}
270
316
  type="button"
271
- className={'mselect-btn mfd-multi-btn' + (hasError ? ' mfd-invalid' : '')}
317
+ // .mfd-multi-btn top-aligns its content (align-items:flex-start) so wrapped chips
318
+ // don't clip once there are enough of them to reach a second line — but with nothing
319
+ // selected yet, the trigger shows one single-line placeholder just like a normal
320
+ // select, and that same top-align just shoved it up instead of centering it.
321
+ // --empty switches it back to centered for exactly that (no chips) case.
322
+ className={
323
+ 'mselect-btn mfd-multi-btn' +
324
+ (selected.length ? '' : ' mfd-multi-btn--empty') +
325
+ (hasError ? ' mfd-invalid' : '')
326
+ }
272
327
  aria-haspopup="listbox"
273
328
  aria-expanded={open}
274
329
  aria-label={ariaLabel}
@@ -299,29 +354,46 @@ const MultiDropdown: React.FunctionComponent<MultiProps> = observer(
299
354
  )}
300
355
  <Icon name="chevron-down" cls="mselect-chev" />
301
356
  </button>
302
- {open && (
303
- <ul className="mselect-menu mfd-multi-menu" role="listbox" aria-multiselectable="true" tabIndex={-1}>
304
- {options.map((opt, i) => {
305
- const checked = selected.indexOf(opt.value) > -1;
306
- return (
307
- <li key={`${opt.value}_${i}`} role="presentation">
308
- <button
309
- type="button"
310
- role="option"
311
- aria-selected={checked}
312
- disabled={opt.disabled}
313
- className={'mselect-opt mfd-multi-opt' + (checked ? ' is-checked' : '')}
314
- onClick={() => toggle(opt.value)}
315
- >
316
- <span className={'mfd-check' + (checked ? ' mfd-check--on' : '')}>
317
- {checked ? <Icon name="check" size={14} /> : null}
318
- </span>
319
- <span className="mselect-opt-label">{opt.label}</span>
320
- </button>
321
- </li>
322
- );
323
- })}
324
- </ul>
357
+ {open && (menuPortal && portalRoot
358
+ ? ReactDOM.createPortal(
359
+ <ul
360
+ ref={menuElRef}
361
+ className="mselect-menu mselect-menu--portal mfd-multi-menu"
362
+ role="listbox"
363
+ aria-multiselectable="true"
364
+ tabIndex={-1}
365
+ // see Dropdown.tsx's render for why every side is explicit ('auto', never
366
+ // omitted — the "opens but nothing visible" collapse otherwise) and why
367
+ // maxHeight adds the box's own padding/border (+14, box-sizing:border-box).
368
+ // overflowY:auto: unlike the single-select's .mselect-list, .mfd-multi-menu has
369
+ // no height cap of its own (in-place it just grows — fine there, clipped by the
370
+ // accordion instead of scrolling; portaled it needs to scroll internally once it
371
+ // hits the space actually available).
372
+ style={
373
+ menuPos
374
+ ? {
375
+ position: 'fixed',
376
+ top: menuPos.top,
377
+ bottom: menuPos.bottom,
378
+ left: menuPos.left,
379
+ right: menuPos.right,
380
+ width: menuPos.width,
381
+ minWidth: menuPos.width,
382
+ maxHeight: menuPos.maxHeight + 14,
383
+ overflowY: 'auto',
384
+ }
385
+ : { position: 'fixed', visibility: 'hidden' }
386
+ }
387
+ >
388
+ {renderOptions()}
389
+ </ul>,
390
+ portalRoot,
391
+ )
392
+ : (
393
+ <ul className="mselect-menu mfd-multi-menu" role="listbox" aria-multiselectable="true" tabIndex={-1}>
394
+ {renderOptions()}
395
+ </ul>
396
+ )
325
397
  )}
326
398
  </div>
327
399
  {hasError ? <span className="mfield-error-text mfd-error">{field.error}</span> : null}
@@ -0,0 +1,213 @@
1
+ // Autobahn (auto.de redesign) — shared body-portal positioning for md3 popover menus.
2
+ //
3
+ // Extracted out of Dropdown.tsx (the single-select) so MultiDropdown (FormDropdown.tsx's
4
+ // isMulti checklist) can get the exact same `menuPortal` escape-hatch — same clipping problem
5
+ // (a checklist menu inside a VehicleEdit Accordion section gets cut off by `.macc-region-inner`'s
6
+ // overflow:hidden, see md3.css), same fix, and every hard-won timing/CSS fix that went into this
7
+ // belongs in ONE place so the two consumers can't drift out of sync. See Dropdown.tsx's own
8
+ // `menuPortal` doc comment for the user-facing behaviour this produces (escapes clipping
9
+ // ancestors, tracks the trigger on scroll/resize while open, flips above when there's no room
10
+ // below, closes instead of trying to visually "chase" the trigger on a real user scroll).
11
+ //
12
+ // Consumers own their OWN <ul>/menu markup and className — this hook only owns: the body-portal
13
+ // root element, the trigger-relative position math, and the effect timing/scroll-listener
14
+ // plumbing. Apply `menuPos` to whatever floating element you render into `portalRoot` via
15
+ // ReactDOM.createPortal, and pass that same element's ref as `menuElRef` so its own internal
16
+ // scrolling doesn't get misread as a user scrolling the trigger away.
17
+
18
+ import * as React from 'react';
19
+
20
+ export interface PortalMenuPos {
21
+ width: number;
22
+ // always a concrete value — a real px number for whichever side is anchoring the box, 'auto'
23
+ // (never omitted) for the other. React drops a style property from the DOM entirely when its
24
+ // value is `undefined`, so `undefined` is NOT "unset" the way it is in the style object here in
25
+ // JS — it just means "don't touch whatever's already cascading in from CSS", and the menu's own
26
+ // stylesheet rule (`top: calc(100% + 4px); left: 0;`) was right there to fill back in and fight
27
+ // the side that WAS set inline, forcing a negative used-height (CSS 2.1 §10.6.4, clamped to 0 —
28
+ // the "opens but nothing visible" collapse). Explicit 'auto' writes a real `top: auto` / `left:
29
+ // auto` inline, which — unlike an omitted property — reliably beats the stylesheet rule
30
+ // regardless of specificity, so only the intended side ever constrains the box.
31
+ top: number | 'auto';
32
+ bottom: number | 'auto';
33
+ left: number | 'auto';
34
+ right: number | 'auto';
35
+ // the space actually available in whichever direction the menu opened, clamped to a sane
36
+ // [120, ESTIMATED_MENU_HEIGHT-12] range — apply as the floating element's OWN maxHeight (plus
37
+ // its padding/border, box-sizing:border-box) so it never relies on shrink-to-fit auto-sizing.
38
+ maxHeight: number;
39
+ }
40
+
41
+ export interface UsePortalMenuPositionArgs {
42
+ /** opt-in flag (the `menuPortal` prop on the consumer). */
43
+ enabled: boolean;
44
+ open: boolean;
45
+ /** the trigger's wrapping element — always rendered (regardless of `open`), measured for position. */
46
+ rootRef: React.RefObject<HTMLElement>;
47
+ menuAlign?: 'left' | 'right';
48
+ /** called when the trigger scrolls out of view entirely, or a real user scroll happens after
49
+ * the post-open grace window — the menu has nothing sensible left to anchor to or chase. */
50
+ onCloseAway: () => void;
51
+ }
52
+
53
+ // TMenuEl: the concrete element type of whatever you portal (Dropdown.tsx portals a wrapping
54
+ // `<div>`; MultiDropdown's checklist has no separate wrapper and portals its `<ul>` directly) —
55
+ // keeps `menuElRef` assignable straight to that element's own `ref` without a cast.
56
+ export interface UsePortalMenuPositionResult<TMenuEl extends HTMLElement = HTMLDivElement> {
57
+ /** a fresh `div.md3` appended to `document.body` while open (so --md-* tokens and every
58
+ * `.md3 .mselect-*` rule still resolve at body level, same technique as ui/Modal.tsx) — pass
59
+ * as the portal target to `ReactDOM.createPortal`. `null` when `!enabled`. */
60
+ portalRoot: HTMLDivElement | null;
61
+ /** `null` for one frame on open, before position is first measured — render `visibility:hidden`
62
+ * (not just omit inline styles) in that case, see Dropdown.tsx's render for the exact pattern. */
63
+ menuPos: PortalMenuPos | null;
64
+ /** attach to the actual floating menu element you portal — excludes its own internal scrolling
65
+ * (e.g. a long option list) from being misread as the user scrolling the trigger away, and is
66
+ * also what click-away logic should check alongside the trigger's own root. */
67
+ menuElRef: React.RefObject<TMenuEl>;
68
+ }
69
+
70
+ // the list's own max-height guess (plus the menu box's padding/border) — only needs to be a
71
+ // reasonable estimate; it decides whether to flip ABOVE the trigger. The real size is then
72
+ // clamped to the space actually available (see `maxHeight` above), not just guessed at.
73
+ const ESTIMATED_MENU_HEIGHT = 300;
74
+ // a scroll within this window of opening REPOSITIONS instead of closing — see the effect below.
75
+ const SELF_SCROLL_GRACE_MS = 500;
76
+
77
+ export function usePortalMenuPosition<TMenuEl extends HTMLElement = HTMLDivElement>({
78
+ enabled,
79
+ open,
80
+ rootRef,
81
+ menuAlign = 'left',
82
+ onCloseAway,
83
+ }: UsePortalMenuPositionArgs): UsePortalMenuPositionResult<TMenuEl> {
84
+ const menuElRef = React.useRef<TMenuEl | null>(null);
85
+ // timestamp of the last open — lets the close-on-scroll listener below tell a genuine user
86
+ // scroll apart from a scroll OUR OWN opening sequence causes (e.g. a searchable dropdown's own
87
+ // scrollIntoView, or the browser's native "scroll a just-focused element into view" behaviour).
88
+ // useLayoutEffect (not useEffect): must be set before the scroll-listener effect further down
89
+ // can possibly attach and fire — same synchronous pre-paint phase as the portal-attach/position
90
+ // effects below, so there's no window where a scroll could be misjudged against a stale (or
91
+ // zero) timestamp.
92
+ const openedAtRef = React.useRef(0);
93
+ React.useLayoutEffect(() => {
94
+ if (open) openedAtRef.current = Date.now();
95
+ }, [open]);
96
+
97
+ const portalRoot = React.useMemo<HTMLDivElement | null>(() => {
98
+ if (!enabled || typeof document === 'undefined') return null;
99
+ const el = document.createElement('div');
100
+ el.className = 'md3';
101
+ return el;
102
+ }, [enabled]);
103
+
104
+ // useLayoutEffect, not useEffect: createPortal(children, portalRoot) renders content INTO
105
+ // portalRoot the moment `open` becomes true, but that's a detached node until THIS effect
106
+ // appends it to document.body; a plain useEffect only runs after the browser paints, so there
107
+ // was a real window where the (correctly rendered, correctly positioned) menu simply wasn't
108
+ // attached to the visible page yet — reading as "opens but nothing appears" rather than a late
109
+ // attach.
110
+ React.useLayoutEffect(() => {
111
+ if (!enabled || !open || !portalRoot || typeof document === 'undefined') return undefined;
112
+ document.body.appendChild(portalRoot);
113
+ return () => {
114
+ if (portalRoot.parentNode) portalRoot.parentNode.removeChild(portalRoot);
115
+ };
116
+ }, [enabled, open, portalRoot]);
117
+
118
+ // the portal menu's z-index (1400, .mselect-menu--portal) is intentionally higher than every
119
+ // other fixed overlay it must paint over (checkout sheets/modals) — which also means it would
120
+ // paint over the sticky app header (.topbar, position:sticky/top:0/z-index:50) if left
121
+ // unconstrained. Reading the header's live bottom edge (0 when absent, e.g. inside a modal, or
122
+ // when TopBar's autoHideOnScroll has slid it away) gives a correct top boundary without
123
+ // hardcoding a pixel height that could drift from the header's own CSS.
124
+ const getSafeTop = (): number => {
125
+ if (typeof document === 'undefined') return 0;
126
+ const header = document.querySelector('.topbar');
127
+ return header ? Math.max(0, header.getBoundingClientRect().bottom) : 0;
128
+ };
129
+
130
+ const [menuPos, setMenuPos] = React.useState<PortalMenuPos | null>(null);
131
+
132
+ const positionMenu = React.useCallback(() => {
133
+ if (!enabled || !rootRef.current || typeof window === 'undefined') return;
134
+ const rect = rootRef.current.getBoundingClientRect();
135
+ const GAP = 4;
136
+ const safeTop = getSafeTop();
137
+ // the trigger itself scrolled out of view (fully under the header or past either viewport
138
+ // edge) — position:fixed keeps the menu exactly where it last was, visually "stuck" over
139
+ // unrelated content instead of tracking a trigger that isn't on screen to track anymore. Just
140
+ // dismiss it, same as a native <select>'s options close the instant you scroll the trigger away.
141
+ if (rect.bottom <= safeTop || rect.top >= window.innerHeight) {
142
+ onCloseAway();
143
+ return;
144
+ }
145
+ const spaceBelow = window.innerHeight - rect.bottom - GAP;
146
+ const spaceAbove = rect.top - safeTop - GAP;
147
+ const openAbove = spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow;
148
+ // cap the menu to the space it ACTUALLY has in whichever direction it opens, instead of
149
+ // trusting a static CSS max-height on its own — near the header or viewport edge that static
150
+ // cap can still be taller than what's really available, which is what let the menu paint over
151
+ // the header in the first place. The consumer's own overflow-y:auto takes it from there once
152
+ // content exceeds this.
153
+ const available = Math.max(120, Math.min(ESTIMATED_MENU_HEIGHT - 12, openAbove ? spaceAbove : spaceBelow));
154
+ setMenuPos({
155
+ width: rect.width,
156
+ left: menuAlign === 'right' ? 'auto' : rect.left,
157
+ right: menuAlign === 'right' ? window.innerWidth - rect.right : 'auto',
158
+ top: openAbove ? 'auto' : rect.bottom + GAP,
159
+ bottom: openAbove ? window.innerHeight - rect.top + GAP : 'auto',
160
+ maxHeight: available,
161
+ });
162
+ }, [enabled, menuAlign, onCloseAway, rootRef]);
163
+
164
+ // Synchronous (pre-paint) position computation. useLayoutEffect — not useEffect — matters here:
165
+ // it runs right after the DOM mutation that mounted the portal but BEFORE the browser paints, so
166
+ // there is no frame where the menu is mounted-but-unpositioned. positionMenu also needs rootRef
167
+ // already attached, which it is by the time this runs — the wrapper element it measures is
168
+ // always rendered regardless of `open`, so its ref was set on an earlier commit already.
169
+ React.useLayoutEffect(() => {
170
+ if (!open || !enabled) return;
171
+ positionMenu();
172
+ }, [open, enabled, positionMenu]);
173
+
174
+ React.useEffect(() => {
175
+ if (!open || !enabled) return undefined;
176
+ // A scroll of the page/an ancestor CLOSES the menu instead of repositioning it. Continuously
177
+ // chasing the trigger during a scroll (recompute + setState + re-render) always lags a frame or
178
+ // more behind the browser's own native, synchronous scroll — the fixed-position menu visibly
179
+ // trails the trigger and then "snaps" into place once scrolling settles. A native <select> (and
180
+ // most real-world custom ones) just closes on scroll instead; that reads as correct rather than
181
+ // janky, and sidesteps the lag entirely instead of trying to out-run it. capture:true so a
182
+ // scroll on any nested scrollable ancestor is caught too, not just the window. Scrolling INSIDE
183
+ // the menu itself (menuElRef's own subtree) must NOT close it — that's just browsing a long
184
+ // list, not scrolling the trigger away — so that's the one scroll target excluded.
185
+ // A short grace window after opening REPOSITIONS instead of closing on scroll — two different
186
+ // self-triggered scrolls land in this window: a searchable dropdown's own scrollIntoView effect
187
+ // smooth-scrolls the trigger into view the instant the menu opens, AND — for every dropdown —
188
+ // the BROWSER ITSELF auto-scrolls a just-focused element into view when it was partially cut
189
+ // off at the viewport edge. Both were previously just swallowed (ignored) here, on the theory
190
+ // that "our own open" scroll isn't a real user scroll-away — true, but positionMenu() was
191
+ // computed ONCE, before that scroll happened, so the menu was left floating at its PRE-scroll
192
+ // coordinates while the page settled at a new scroll offset under it. Resyncing the position on
193
+ // every grace-window scroll fixes that (cheap: one rect read + setState) without reintroducing
194
+ // the janky continuous-chase during a real user scroll — this only runs for the ~500ms settle
195
+ // window right after open, not for scrolling in general (that still closes, below).
196
+ const onScroll = (e: Event) => {
197
+ if (menuElRef.current && e.target instanceof Node && menuElRef.current.contains(e.target)) return;
198
+ if (Date.now() - openedAtRef.current < SELF_SCROLL_GRACE_MS) {
199
+ positionMenu();
200
+ return;
201
+ }
202
+ onCloseAway();
203
+ };
204
+ window.addEventListener('scroll', onScroll, true);
205
+ window.addEventListener('resize', positionMenu);
206
+ return () => {
207
+ window.removeEventListener('scroll', onScroll, true);
208
+ window.removeEventListener('resize', positionMenu);
209
+ };
210
+ }, [open, enabled, positionMenu, onCloseAway]);
211
+
212
+ return { portalRoot, menuPos, menuElRef };
213
+ }
package/vdp/index.ts CHANGED
@@ -71,7 +71,7 @@ export type {
71
71
  } from './VdpModules';
72
72
 
73
73
  // View-model types + the spec-table helper.
74
- export { defaultVdpSpecs } from './types';
74
+ export { defaultVdpSpecs, isZeroSpecValue } from './types';
75
75
  export type {
76
76
  VehicleDetail,
77
77
  VehicleCondition,
package/vdp/types.ts CHANGED
@@ -20,6 +20,39 @@ export type { AutobahnPhev };
20
20
  /** One row of the Fahrzeugdaten spec table: [kebab-case lucide icon, label, value]. */
21
21
  export type VdpSpec = readonly [icon: string, label: string, value: string];
22
22
 
23
+ // ---------------------------------------------------------------------------
24
+ // Zero-value spec visibility (CDP ticket "Hide meaningless zero-value vehicle
25
+ // attributes") — the single shared rule behind whether a Fahrzeugdaten row
26
+ // renders when its underlying measurement is 0.
27
+ //
28
+ // Most numeric vehicle attributes (Leergewicht, Hubraum, Drehmoment, …) are
29
+ // physical measurements that are never legitimately 0 — a 0 there means "no
30
+ // data", not "the car weighs nothing", and rendering "0 kg" actively damages
31
+ // trust in the data (the reported bug). The DEFAULT for a numeric spec is
32
+ // therefore to hide the row entirely rather than show a fabricated-looking
33
+ // zero.
34
+ //
35
+ // That default must NOT apply blindly to every field, per two carve-outs:
36
+ // - some fields have a real, meaningful zero (0 Vorbesitzer = never owned,
37
+ // 0 km = brand-new car) — callers opt these OUT of hiding explicitly;
38
+ // - the Pkw-EnVKV legally-required consumption/emissions figures (cons,
39
+ // co2, co2class, and by the same regulatory family Verbrauch innerorts/
40
+ // außerorts) must never be suppressed by this generic rule — their own
41
+ // compliance/data-quality logic decides what renders, so callers building
42
+ // those rows must never run them through this check at all.
43
+ //
44
+ // isZeroSpecValue operates on the already-FORMATTED display string (not the
45
+ // raw number) because callers combine both string-typed values (decorated
46
+ // getters that already bake in a unit, e.g. "0 kg") and plain numbers
47
+ // (`${n} Sitze`) into the same VdpSpec value slot — checking the rendered
48
+ // string is the one place both shapes agree. It matches a leading zero
49
+ // magnitude (optionally with an all-zero decimal, "0,0"/"0.0") followed by
50
+ // whitespace (a unit) or end-of-string, so "0 kg"/"0,0 l/100 km"/"0" all
51
+ // count as zero but "10 kg"/"0.5 l" (a real non-zero measurement) do not.
52
+ export function isZeroSpecValue(formatted: string): boolean {
53
+ return /^-?0([.,]0+)?(\s|$)/.test(formatted.trim());
54
+ }
55
+
23
56
  /**
24
57
  * Grouped equipment: each entry is a section title mapped to its item labels,
25
58
  * e.g. { Komfort: ['Klimaautomatik', …], Sicherheit: [...] }. Replaces the design
@@ -200,20 +233,27 @@ export interface VehicleDetail {
200
233
  * Build the design's twelve-row Fahrzeugdaten table from the typed fields. Used
201
234
  * when a VehicleDetail carries no explicit `specs`. Pure — produces the same rows
202
235
  * and order as the spec's ScreenVDP `specs` array.
236
+ *
237
+ * `allowZero: true` marks fields where 0 is a real, meaningful value (or isn't a
238
+ * zero-able numeric at all) — everything else gets the isZeroSpecValue default
239
+ * (hide the row rather than render a fabricated-looking "0 X"). cons/co2class
240
+ * are Pkw-EnVKV legally-required and are marked allowZero for that reason, not
241
+ * because 0 is plausible there — see isZeroSpecValue's own comment.
203
242
  */
204
243
  export function defaultVdpSpecs(v: VehicleDetail): VdpSpec[] {
205
- return [
206
- ['gauge', 'Kilometerstand', `${v.km.toLocaleString('de-DE')} km`],
207
- ['calendar', 'Erstzulassung', v.reg],
208
- ['zap', 'Leistung', v.power],
209
- ['fuel', 'Kraftstoff', v.fuel],
210
- ['cog', 'Getriebe', v.gear],
211
- ['users', 'Sitze', `${v.seats} Sitze`],
212
- ['door-open', 'Türen', `${v.doors} Türen`],
213
- ['user', 'Halter', `${v.owners}. Hand`],
214
- ['palette', 'Außenfarbe', v.color],
215
- ['car-front', 'Karosserie', v.body],
216
- ['droplet', 'Verbrauch komb.', v.cons],
217
- ['leaf', 'CO₂ · Klasse', `${v.co2} · ${v.co2class}`],
244
+ const rows: ReadonlyArray<readonly [spec: VdpSpec, allowZero: boolean]> = [
245
+ [['gauge', 'Kilometerstand', `${v.km.toLocaleString('de-DE')} km`], true], // Neuwagen: 0 km is valid
246
+ [['calendar', 'Erstzulassung', v.reg], true],
247
+ [['zap', 'Leistung', v.power], true],
248
+ [['fuel', 'Kraftstoff', v.fuel], true],
249
+ [['cog', 'Getriebe', v.gear], true],
250
+ [['users', 'Sitze', `${v.seats} Sitze`], false],
251
+ [['door-open', 'Türen', `${v.doors} Türen`], false],
252
+ [['user', 'Halter', `${v.owners}. Hand`], true], // 0 Vorbesitzer is valid
253
+ [['palette', 'Außenfarbe', v.color], true],
254
+ [['car-front', 'Karosserie', v.body], true],
255
+ [['droplet', 'Verbrauch komb.', v.cons], true], // Pkw-EnVKV — exempt from this rule
256
+ [['leaf', 'CO₂ · Klasse', `${v.co2} · ${v.co2class}`], true], // Pkw-EnVKV — exempt
218
257
  ];
258
+ return rows.filter(([spec, allowZero]) => allowZero || !isZeroSpecValue(spec[2])).map(([spec]) => spec);
219
259
  }