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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/Dropdown.tsx CHANGED
@@ -1,461 +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
- disabled?: boolean;
44
- /** full-width trigger (default true for form fields; pass false for inline controls). */
45
- block?: boolean;
46
- /** compact height (sort bar / inline). */
47
- size?: 'sm' | 'md';
48
- /** accessible label when there is no visible <label> wrapping the control. */
49
- ariaLabel?: string;
50
- /** marks the trigger aria-invalid (screen readers) when the owning field has a validation
51
- * error — purely additive/opt-in, undefined by default so existing callers are unaffected. */
52
- ariaInvalid?: boolean;
53
- /** id of the (possibly visually-hidden) error text describing why the field is invalid. */
54
- ariaDescribedBy?: string;
55
- /** extra class on the wrapper (e.g. 'srp-sort' for width rules). */
56
- className?: string;
57
- /** align the popover to the right edge of the trigger (default left). */
58
- menuAlign?: 'left' | 'right';
59
- /** show a text input at the top of the popover that filters `options` by label (substring, case-insensitive). */
60
- searchable?: boolean;
61
- /** placeholder for the search input (only used when `searchable`). */
62
- searchPlaceholder?: string;
63
- /** restrict the combobox input to digits (numeric "up to" fields) — filters every keystroke and sets inputMode="numeric" pattern="[0-9]*". Requires `searchable`. */
64
- numeric?: boolean;
65
- /** 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`. */
66
- allowCustomValue?: boolean;
67
- /** 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\``). */
68
- formatCustomLabel?: (value: string) => string;
69
- /**
70
- * Render the popover into a `document.body` portal instead of nesting it in place — escapes an
71
- * ancestor's `overflow:hidden`/clipping (e.g. inside a collapsing Accordion region, see
72
- * `.macc-region-inner` in md3.css) that would otherwise cut the option list off instead of
73
- * letting it float over the following content. Position is computed from the trigger's on-screen
74
- * rect, kept in sync on scroll/resize while open, and flips to open ABOVE the trigger when
75
- * there isn't room below. Off by default — every existing caller keeps the simple in-place
76
- * popover; opt in only at call sites with a known clipping ancestor.
77
- */
78
- menuPortal?: boolean;
79
- }
80
-
81
- const Dropdown: React.FunctionComponent<DropdownProps> = ({
82
- value,
83
- options,
84
- onChange,
85
- placeholder,
86
- icon,
87
- disabled = false,
88
- block = true,
89
- size = 'md',
90
- ariaLabel,
91
- ariaInvalid,
92
- ariaDescribedBy,
93
- className,
94
- menuAlign = 'left',
95
- searchable = false,
96
- searchPlaceholder,
97
- numeric = false,
98
- allowCustomValue = false,
99
- formatCustomLabel,
100
- menuPortal = false,
101
- }) => {
102
- const [open, setOpen] = React.useState(false);
103
- const [activeIndex, setActiveIndex] = React.useState(-1);
104
- const [query, setQuery] = React.useState('');
105
- const rootRef = React.useRef<HTMLDivElement | null>(null);
106
- const btnRef = React.useRef<HTMLButtonElement | null>(null);
107
- const menuRef = React.useRef<HTMLUListElement | null>(null);
108
- const searchRef = React.useRef<HTMLInputElement | null>(null);
109
- // stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
110
- const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
111
-
112
- const selectedIndex = options.findIndex((o) => o.value === value);
113
- const selected = selectedIndex !== -1 ? options[selectedIndex] : undefined;
114
- // a real value that just doesn't happen to match any option — only possible with
115
- // allowCustomValue (e.g. a hand-typed "1421232" the preset rungs don't cover).
116
- const customLabel = allowCustomValue && value && !selected
117
- ? (formatCustomLabel ? formatCustomLabel(value) : value)
118
- : undefined;
119
- const hasValue = !!selected || customLabel !== undefined;
120
-
121
- const filteredOptions = React.useMemo(() => {
122
- if (!searchable || !query.trim()) return options;
123
- const q = query.trim().toLowerCase();
124
- const matches = options.filter((o) => o.label.toLowerCase().includes(q));
125
- // some option lists intentionally repeat entries across groups while closed (e.g. a "Top
126
- // Marken" shortlist followed by the FULL "Alle Marken" list, which includes those same top
127
- // brands again) — harmless when browsing both sections, but shows the same match twice while
128
- // searching. Keep only the first occurrence of each label once the user is actually filtering.
129
- const seen = new Set<string>();
130
- return matches.filter((o) => {
131
- const key = o.label.toLowerCase();
132
- if (seen.has(key)) return false;
133
- seen.add(key);
134
- return true;
135
- });
136
- }, [searchable, query, options]);
137
-
138
- // deferred: when searchable, the trigger <button> is swapped out for an <input> while
139
- // open (see render), so btnRef.current is null *during* the close() call itself — the
140
- // button only exists again after this state flips `open` back to false and React
141
- // re-renders. Consumed by the effect below, once that re-render has actually happened.
142
- const focusTriggerAfterClose = React.useRef(false);
143
-
144
- // close + restore focus to the trigger. `commitTyped` (default true) commits whatever's
145
- // currently typed as a custom value when `allowCustomValue` — this is what makes "just leave
146
- // the field" (blur/click-away/Tab) work, not only an explicit pick from the list. Callers that
147
- // already committed an explicit option (commit()) or that are cancelling (Escape) pass false.
148
- const close = React.useCallback((focusTrigger: boolean, commitTyped: boolean = true) => {
149
- if (allowCustomValue && commitTyped) {
150
- const raw = query.trim();
151
- if (raw !== value) onChange(raw);
152
- }
153
- setOpen(false);
154
- setActiveIndex(-1);
155
- setQuery('');
156
- focusTriggerAfterClose.current = focusTrigger;
157
- }, [allowCustomValue, query, value, onChange]);
158
-
159
- React.useEffect(() => {
160
- if (!open && focusTriggerAfterClose.current) {
161
- focusTriggerAfterClose.current = false;
162
- if (btnRef.current) btnRef.current.focus();
163
- }
164
- }, [open]);
165
-
166
- const openMenu = React.useCallback(() => {
167
- if (disabled) return;
168
- setOpen(true);
169
- // start the keyboard highlight on the current selection (or the first option).
170
- setActiveIndex(selectedIndex !== -1 ? selectedIndex : 0);
171
- // pre-fill the combobox with the current value (raw, not the formatted label) so a custom
172
- // entry can be edited/replaced in place instead of always starting from a blank field.
173
- if (allowCustomValue) setQuery(value || '');
174
- }, [disabled, selectedIndex, allowCustomValue, value]);
175
-
176
- // when opened searchable, focus the search input instead of leaving focus on the trigger.
177
- // scrollIntoView first: on mobile the keyboard is about to eat ~40% of the viewport, so the
178
- // trigger (and the popover below it) needs to already be scrolled above the fold before focus
179
- // fires, or the popover can end up hidden behind the keyboard with no way to see it open.
180
- React.useEffect(() => {
181
- if (!open || !searchable) return;
182
- if (rootRef.current && rootRef.current.scrollIntoView) {
183
- rootRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
184
- }
185
- if (searchRef.current) {
186
- searchRef.current.focus();
187
- // pre-filled custom value (see openMenu) starts selected, so typing immediately
188
- // replaces it instead of appending — same "click a number field, type over it" feel.
189
- if (allowCustomValue) searchRef.current.select();
190
- }
191
- }, [open, searchable, allowCustomValue]);
192
-
193
- // click-away (guarded for SSR). menuElRef is checked too — with menuPortal the popover renders
194
- // outside rootRef (at document.body), so without this every option click would look like a
195
- // click-away and close the menu before the option's own onClick had a chance to fire.
196
- React.useEffect(() => {
197
- if (!open || typeof document === 'undefined') return undefined;
198
- const onDocMouseDown = (e: MouseEvent) => {
199
- const target = e.target as Node;
200
- if (rootRef.current && rootRef.current.contains(target)) return;
201
- if (menuElRef.current && menuElRef.current.contains(target)) return;
202
- close(false);
203
- };
204
- document.addEventListener('mousedown', onDocMouseDown);
205
- return () => document.removeEventListener('mousedown', onDocMouseDown);
206
- }, [open, close]);
207
-
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
- });
220
-
221
- // keep the active option scrolled into view while navigating by keyboard.
222
- React.useEffect(() => {
223
- if (!open || activeIndex < 0 || !menuRef.current) return;
224
- const el = menuRef.current.children[activeIndex] as HTMLElement | undefined;
225
- if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
226
- }, [open, activeIndex]);
227
-
228
- // reset the keyboard highlight to the top match whenever the filtered list changes.
229
- React.useEffect(() => {
230
- if (searchable && open) setActiveIndex(filteredOptions.length ? 0 : -1);
231
- // eslint-disable-next-line react-hooks/exhaustive-deps
232
- }, [filteredOptions]);
233
-
234
- const commit = (idx: number) => {
235
- const opt = filteredOptions[idx];
236
- if (!opt || opt.disabled) return;
237
- onChange(opt.value);
238
- // false: the explicit pick already won — don't also commit whatever's still sitting in the
239
- // (now-stale) typed query behind it.
240
- close(true, false);
241
- };
242
-
243
- // move the active highlight, skipping disabled options.
244
- const move = (dir: 1 | -1) => {
245
- if (!filteredOptions.length) return;
246
- let i = activeIndex;
247
- for (let step = 0; step < filteredOptions.length; step += 1) {
248
- i = (i + dir + filteredOptions.length) % filteredOptions.length;
249
- if (!filteredOptions[i].disabled) {
250
- setActiveIndex(i);
251
- return;
252
- }
253
- }
254
- };
255
-
256
- const onMenuKeyDown = (e: React.KeyboardEvent) => {
257
- switch (e.key) {
258
- case 'ArrowDown': e.preventDefault(); move(1); break;
259
- case 'ArrowUp': e.preventDefault(); move(-1); break;
260
- case 'Home': e.preventDefault(); setActiveIndex(filteredOptions.findIndex((o) => !o.disabled)); break;
261
- case 'End': e.preventDefault(); for (let i = filteredOptions.length - 1; i >= 0; i -= 1) { if (!filteredOptions[i].disabled) { setActiveIndex(i); break; } } break;
262
- case 'Enter':
263
- e.preventDefault();
264
- // an active list match wins when there is one; otherwise, for allowCustomValue fields,
265
- // Enter commits whatever's typed (same value close() would commit on blur anyway).
266
- if (activeIndex >= 0) commit(activeIndex);
267
- else if (allowCustomValue) close(true);
268
- break;
269
- // Escape cancels — close(..., false) so an in-progress custom edit is discarded, not committed.
270
- case 'Escape': e.preventDefault(); close(true, false); break;
271
- case 'Tab': close(false); break;
272
- default: break;
273
- }
274
- };
275
-
276
- const onTriggerKeyDown = (e: React.KeyboardEvent) => {
277
- if (disabled) return;
278
- if (!open) {
279
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
280
- e.preventDefault();
281
- openMenu();
282
- }
283
- return;
284
- }
285
- if (e.key === ' ') { e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); return; }
286
- onMenuKeyDown(e);
287
- };
288
-
289
- const wrapCls = ['mselect'];
290
- if (size === 'sm') wrapCls.push('mselect--sm');
291
- if (block) wrapCls.push('mselect--block');
292
- if (className) wrapCls.push(className);
293
-
294
- const label = selected ? selected.label : (customLabel !== undefined ? customLabel : placeholder || '');
295
-
296
- const editing = searchable && open;
297
-
298
- // Bug 53/37: "Rate bis" / "km bis" allow a hand-typed amount the preset rungs don't cover
299
- // (allowCustomValue) — that typed number never matches any option label, so filteredOptions
300
- // comes back empty and the popover would show "Keine Treffer", reading as "no cars match this"
301
- // to the customer even though the typed value is perfectly valid input. Once there's an actual
302
- // typed query with zero matches on an allowCustomValue field, suppress the popover entirely
303
- // (not just the "Keine Treffer" row) — an empty floating box would be just as confusing. Plain
304
- // (non-custom) searchable dropdowns, e.g. Make/Model, are unaffected and keep showing it.
305
- const suppressEmptyMenu = allowCustomValue && searchable && query.trim() !== '' && filteredOptions.length === 0;
306
-
307
- // shared between the in-place popover and the menuPortal one — identical option list either way.
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) => (
313
- <ul
314
- ref={menuRef}
315
- id={`${idBase}list`}
316
- className="mselect-list"
317
- role="listbox"
318
- tabIndex={-1}
319
- style={maxHeight != null ? { maxHeight } : undefined}
320
- aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
321
- >
322
- {filteredOptions.map((opt, i) => (
323
- <li key={`${opt.value}_${i}`} role="presentation">
324
- <button
325
- id={`${idBase}${i}`}
326
- type="button"
327
- role="option"
328
- aria-selected={opt.value === value}
329
- disabled={opt.disabled}
330
- className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
331
- onMouseEnter={() => setActiveIndex(i)}
332
- onClick={() => commit(i)}
333
- >
334
- <span className="mselect-opt-label">{opt.label}</span>
335
- {opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
336
- </button>
337
- </li>
338
- ))}
339
- {searchable && filteredOptions.length === 0 && (
340
- <li className="mselect-empty" role="presentation">Keine Treffer</li>
341
- )}
342
- </ul>
343
- );
344
-
345
- return (
346
- <div className={wrapCls.join(' ')} ref={rootRef}>
347
- {editing ? (
348
- // combobox mode: the trigger itself becomes the text field (not a separate input
349
- // inside the popover) — a <button> can't legally contain an <input>, so this is a
350
- // <div> standing in for it, same `.mselect-btn` class for identical chrome.
351
- <div className="mselect-btn mselect-btn--editing">
352
- {icon && <Icon name={icon} />}
353
- <input
354
- ref={searchRef}
355
- type="text"
356
- className="mselect-input"
357
- placeholder={searchPlaceholder || placeholder}
358
- value={query}
359
- onChange={(e) => setQuery(numeric ? e.target.value.replace(/\D/g, '') : e.target.value)}
360
- onKeyDown={onMenuKeyDown}
361
- aria-label={ariaLabel}
362
- aria-haspopup="listbox"
363
- aria-expanded={open}
364
- aria-controls={`${idBase}list`}
365
- aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
366
- aria-invalid={ariaInvalid ? true : undefined}
367
- aria-describedby={ariaDescribedBy}
368
- {...(numeric ? { inputMode: 'numeric' as const, pattern: '[0-9]*' } : {})}
369
- />
370
- <button type="button" className="mselect-chev-btn" aria-label="Close" onClick={() => close(true)}>
371
- <Icon name="chevron-down" cls="mselect-chev" />
372
- </button>
373
- </div>
374
- ) : (
375
- <button
376
- ref={btnRef}
377
- type="button"
378
- className="mselect-btn"
379
- aria-haspopup="listbox"
380
- aria-expanded={open}
381
- aria-label={ariaLabel}
382
- aria-invalid={ariaInvalid ? true : undefined}
383
- aria-describedby={ariaDescribedBy}
384
- disabled={disabled}
385
- onClick={() => (open ? close(false) : openMenu())}
386
- onKeyDown={onTriggerKeyDown}
387
- >
388
- {icon && <Icon name={icon} />}
389
- <span className={'mselect-label' + (hasValue ? '' : ' mselect-label--ph')}>{label}</span>
390
- <Icon name="chevron-down" cls="mselect-chev" />
391
- </button>
392
- )}
393
- {open && !suppressEmptyMenu && (menuPortal && portalRoot
394
- ? ReactDOM.createPortal(
395
- <div
396
- ref={menuElRef}
397
- className={'mselect-menu mselect-menu--portal' + (searchable ? ' mselect-menu--searchable' : '')}
398
- // menuPos is null for one frame on open, before the position effect has measured
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).
406
- style={
407
- menuPos
408
- ? {
409
- position: 'fixed',
410
- top: menuPos.top,
411
- bottom: menuPos.bottom,
412
- left: menuPos.left,
413
- right: menuPos.right,
414
- width: menuPos.width,
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,
439
- }
440
- : { position: 'fixed', visibility: 'hidden' }
441
- }
442
- >
443
- {renderMenuList(menuPos?.maxHeight)}
444
- </div>,
445
- portalRoot,
446
- )
447
- : (
448
- <div
449
- ref={menuElRef}
450
- className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}
451
- >
452
- {renderMenuList()}
453
- </div>
454
- )
455
- )}
456
- </div>
457
- );
458
- };
459
-
460
- export default Dropdown;
461
-
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
+