@bytebrand/fe-ui-core-autobahn 1.0.130 → 1.0.131
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/package.json +1 -1
- package/theme/md3.css +11278 -11277
- package/ui/Dropdown.tsx +467 -467
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
|
+
|