@bytebrand/fe-ui-core-autobahn 1.0.95 → 1.0.97
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/chrome/TopBar.tsx +30 -3
- package/compat/CookieModal/Modal.styl +12 -12
- package/compat/CookieModal/modals/ManageCookieModal/ManageCookieModal.styl +2 -0
- package/package.json +1 -1
- package/theme/md3.css +130 -16
- package/ui/Dropdown.tsx +152 -56
- package/vdp/VdpModules.tsx +5 -4
package/chrome/TopBar.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react';
|
|
2
2
|
import { TranslationFunction } from '../types';
|
|
3
3
|
|
|
4
4
|
import { Button, Icon, IconButton } from '../ui';
|
|
@@ -110,6 +110,10 @@ export interface TopBarProps {
|
|
|
110
110
|
* number/tel: link was confirmed for this, so it defaults to the contact page. Pass a `tel:...`
|
|
111
111
|
* href here once one is confirmed. */
|
|
112
112
|
phoneHref?: string;
|
|
113
|
+
/** mobile-only: slide the header out of view on scroll-down, back in on scroll-up (coordinated
|
|
114
|
+
* with the VDP sticky offer bottom bar's own show/hide). Off by default — only the car-detail
|
|
115
|
+
* page (topBarVariant 'conversion') opts in; every other page keeps the header always docked. */
|
|
116
|
+
autoHideOnScroll?: boolean;
|
|
113
117
|
}
|
|
114
118
|
|
|
115
119
|
/**
|
|
@@ -137,9 +141,28 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
|
137
141
|
onLangSelect,
|
|
138
142
|
onNavigate,
|
|
139
143
|
phoneHref = '/service/kontakt/',
|
|
144
|
+
autoHideOnScroll = false,
|
|
140
145
|
}) => {
|
|
141
146
|
const [menu, setMenu] = useState(false); // mobile hamburger dropdown
|
|
142
147
|
const [account, setAccount] = useState(false); // account popover
|
|
148
|
+
const [hidden, setHidden] = useState(false); // autoHideOnScroll: header slid out of view
|
|
149
|
+
|
|
150
|
+
// scroll-direction tracking for autoHideOnScroll — down hides, up shows, and it never hides
|
|
151
|
+
// near the very top (HIDE_AT) so the header doesn't disappear on the first tiny scroll nudge.
|
|
152
|
+
const lastScrollY = useRef(0);
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!autoHideOnScroll) return undefined;
|
|
155
|
+
const HIDE_AT = 80;
|
|
156
|
+
lastScrollY.current = window.scrollY;
|
|
157
|
+
const onScroll = (): void => {
|
|
158
|
+
const y = window.scrollY;
|
|
159
|
+
const goingDown = y > lastScrollY.current;
|
|
160
|
+
setHidden(y > HIDE_AT && goingDown);
|
|
161
|
+
lastScrollY.current = y;
|
|
162
|
+
};
|
|
163
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
164
|
+
return () => window.removeEventListener('scroll', onScroll);
|
|
165
|
+
}, [autoHideOnScroll]);
|
|
143
166
|
|
|
144
167
|
const navigate = onNavigate || ((to: string): void => { window.location.href = to; });
|
|
145
168
|
|
|
@@ -455,14 +478,18 @@ const TopBar: React.FunctionComponent<TopBarProps> = ({
|
|
|
455
478
|
</div>
|
|
456
479
|
);
|
|
457
480
|
|
|
481
|
+
// suppress the auto-hide while the mobile hamburger/account layer is open — an in-progress
|
|
482
|
+
// interaction sliding away with the header would be jarring, and the account layer/menu render
|
|
483
|
+
// as children of <header> below so they'd vanish along with it.
|
|
484
|
+
const effectivelyHidden = hidden && !menu && !account;
|
|
485
|
+
|
|
458
486
|
return (
|
|
459
487
|
<header
|
|
488
|
+
className={'topbar' + (effectivelyHidden ? ' topbar--hidden' : '')}
|
|
460
489
|
style={{
|
|
461
490
|
position: 'sticky',
|
|
462
491
|
top: 0,
|
|
463
492
|
zIndex: 50,
|
|
464
|
-
background: 'var(--md-surface)',
|
|
465
|
-
borderBottom: '1px solid var(--md-outline-variant)',
|
|
466
493
|
}}
|
|
467
494
|
>
|
|
468
495
|
<div
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
@import '../legacy-theme/mixins.styl'
|
|
2
|
-
|
|
3
|
-
// SURFACE (Modal.tsx) sets max-height: 92vh inline for desktop — on mobile the
|
|
4
|
-
// modal should grow to fit its full content instead of scrolling internally.
|
|
5
|
-
// !important is required to win over the inline style attribute.
|
|
6
|
-
.surface
|
|
7
|
-
+media-tablet-down()
|
|
8
|
-
max-height: none !important
|
|
9
|
-
|
|
10
|
-
.overlay
|
|
11
|
-
+media-tablet-down()
|
|
12
|
-
padding-top: 71px !important
|
|
1
|
+
@import '../legacy-theme/mixins.styl'
|
|
2
|
+
|
|
3
|
+
// SURFACE (Modal.tsx) sets max-height: 92vh inline for desktop — on mobile the
|
|
4
|
+
// modal should grow to fit its full content instead of scrolling internally.
|
|
5
|
+
// !important is required to win over the inline style attribute.
|
|
6
|
+
.surface
|
|
7
|
+
+media-tablet-down()
|
|
8
|
+
max-height: none !important
|
|
9
|
+
|
|
10
|
+
.overlay
|
|
11
|
+
+media-tablet-down()
|
|
12
|
+
padding-top: 71px !important
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytebrand/fe-ui-core-autobahn",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.97",
|
|
4
4
|
"description": "auto.de md3 (Autobahn) design system — UI primitives, cards, chrome, VDP modules, theme CSS and locales. Ships raw TypeScript source (like fe-ui-core before it); each consumer's bundler compiles it.",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"license": "UNLICENSED",
|
package/theme/md3.css
CHANGED
|
@@ -595,7 +595,10 @@ line-height: 31px;
|
|
|
595
595
|
transition: box-shadow 0.15s;
|
|
596
596
|
}
|
|
597
597
|
.md3 .mfield:focus-within {
|
|
598
|
-
box-shadow: inset 0 0 0 2px
|
|
598
|
+
box-shadow: inset 0 0 0 2px #005CFF;
|
|
599
|
+
}
|
|
600
|
+
.md3 .srp-rail input[type="checkbox"] {
|
|
601
|
+
accent-color: #005CFF;
|
|
599
602
|
}
|
|
600
603
|
.md3 .mfield select,
|
|
601
604
|
.md3 .mfield input {
|
|
@@ -1764,7 +1767,7 @@ line-height: 31px;
|
|
|
1764
1767
|
}
|
|
1765
1768
|
.md3 .mtextarea:focus {
|
|
1766
1769
|
outline: 0;
|
|
1767
|
-
box-shadow: inset 0 0 0 2px
|
|
1770
|
+
box-shadow: inset 0 0 0 2px #005CFF;
|
|
1768
1771
|
}
|
|
1769
1772
|
|
|
1770
1773
|
/* ---------- Impressum ---------- */
|
|
@@ -2919,7 +2922,12 @@ line-height: 31px;
|
|
|
2919
2922
|
box-shadow: inset 0 0 0 1px var(--md-outline);
|
|
2920
2923
|
}
|
|
2921
2924
|
.md3 .mselect-btn[aria-expanded="true"] {
|
|
2922
|
-
box-shadow: inset 0 0 0 2px
|
|
2925
|
+
box-shadow: inset 0 0 0 2px #005CFF;
|
|
2926
|
+
}
|
|
2927
|
+
.md3 .mselect-btn:focus-visible {
|
|
2928
|
+
/* already has its own aria-expanded/hover box-shadow feedback — the generic site-wide
|
|
2929
|
+
.md3 :focus-visible box-shadow is redundant here. */
|
|
2930
|
+
outline: none;
|
|
2923
2931
|
}
|
|
2924
2932
|
.md3 .mselect-btn:disabled {
|
|
2925
2933
|
opacity: 0.5;
|
|
@@ -2964,11 +2972,8 @@ line-height: 31px;
|
|
|
2964
2972
|
top: calc(100% + 4px);
|
|
2965
2973
|
left: 0;
|
|
2966
2974
|
min-width: 100%;
|
|
2967
|
-
max-height: 288px;
|
|
2968
|
-
overflow-y: auto;
|
|
2969
2975
|
padding: 6px;
|
|
2970
2976
|
margin: 0;
|
|
2971
|
-
list-style: none;
|
|
2972
2977
|
background: var(--md-sc-lowest, #fff);
|
|
2973
2978
|
border-radius: var(--md-r-sm, 12px);
|
|
2974
2979
|
border: 1px solid var(--md-outline-variant);
|
|
@@ -2978,6 +2983,70 @@ line-height: 31px;
|
|
|
2978
2983
|
left: auto;
|
|
2979
2984
|
right: 0;
|
|
2980
2985
|
}
|
|
2986
|
+
.md3 .mselect-list {
|
|
2987
|
+
max-height: 288px;
|
|
2988
|
+
overflow-y: auto;
|
|
2989
|
+
margin: 0;
|
|
2990
|
+
padding: 0;
|
|
2991
|
+
list-style: none;
|
|
2992
|
+
}
|
|
2993
|
+
.md3 .mselect-btn--editing {
|
|
2994
|
+
cursor: text;
|
|
2995
|
+
/* always rendered while `open` (see editing = searchable && open), so no [aria-expanded]
|
|
2996
|
+
attribute-selector match is needed the way .mselect-btn[aria-expanded="true"] uses one.
|
|
2997
|
+
!important: .mselect-btn:hover's box-shadow has higher specificity (class+pseudo-class)
|
|
2998
|
+
than a single .mselect-btn--editing class and would otherwise win on hover. */
|
|
2999
|
+
box-shadow: inset 0 0 0 2px #005CFF !important;
|
|
3000
|
+
}
|
|
3001
|
+
.md3 .mselect-input {
|
|
3002
|
+
flex: 1;
|
|
3003
|
+
min-width: 0;
|
|
3004
|
+
border: 0;
|
|
3005
|
+
padding: 0;
|
|
3006
|
+
margin: 0;
|
|
3007
|
+
background: transparent;
|
|
3008
|
+
font: inherit;
|
|
3009
|
+
/* 14px to match .mselect-label (the closed-state text) so the field doesn't visually
|
|
3010
|
+
change size the moment it's clicked. Bumped to 16px on mobile below — iOS Safari
|
|
3011
|
+
auto-zooms the whole page on focusing any input under 16px, and that only matters
|
|
3012
|
+
on touch/mobile, not desktop. */
|
|
3013
|
+
font-size: 14px;
|
|
3014
|
+
color: var(--md-on-surface);
|
|
3015
|
+
}
|
|
3016
|
+
@media (max-width: 600px) {
|
|
3017
|
+
.md3 .mselect-input {
|
|
3018
|
+
font-size: 16px;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
.md3 .mselect-input:focus {
|
|
3022
|
+
/* the #005CFF ring already lives on the parent .mselect-btn--editing — the input's own
|
|
3023
|
+
native focus outline/box-shadow would double it up. */
|
|
3024
|
+
outline: none;
|
|
3025
|
+
box-shadow: none !important;
|
|
3026
|
+
}
|
|
3027
|
+
.md3 .mselect-input::placeholder {
|
|
3028
|
+
color: var(--md-on-surface-variant);
|
|
3029
|
+
}
|
|
3030
|
+
.md3 .mselect-chev-btn {
|
|
3031
|
+
display: inline-flex;
|
|
3032
|
+
align-items: center;
|
|
3033
|
+
margin-left: auto;
|
|
3034
|
+
padding: 0;
|
|
3035
|
+
border: 0;
|
|
3036
|
+
background: transparent;
|
|
3037
|
+
cursor: pointer;
|
|
3038
|
+
flex: none;
|
|
3039
|
+
}
|
|
3040
|
+
.md3 .mselect-chev-btn:focus-visible {
|
|
3041
|
+
/* the #005CFF ring already lives on the parent .mselect-btn--editing. */
|
|
3042
|
+
outline: none;
|
|
3043
|
+
box-shadow: none !important;
|
|
3044
|
+
}
|
|
3045
|
+
.md3 .mselect-empty {
|
|
3046
|
+
padding: 9px 10px;
|
|
3047
|
+
font-size: 13px;
|
|
3048
|
+
color: var(--md-on-surface-variant);
|
|
3049
|
+
}
|
|
2981
3050
|
.md3 .mselect-opt {
|
|
2982
3051
|
display: flex;
|
|
2983
3052
|
align-items: center;
|
|
@@ -3246,8 +3315,20 @@ line-height: 31px;
|
|
|
3246
3315
|
background: var(--autode-swoosh);
|
|
3247
3316
|
}
|
|
3248
3317
|
.md3 :focus-visible {
|
|
3249
|
-
outline:
|
|
3250
|
-
|
|
3318
|
+
outline: none;
|
|
3319
|
+
box-shadow: inset 0 0 0 2px #005CFF !important;
|
|
3320
|
+
}
|
|
3321
|
+
/* these already draw their own #005CFF ring on a wrapper (.mfield/.enq-float via
|
|
3322
|
+
:focus-within) or via border (.enquiry-*) — the generic ring above would double up
|
|
3323
|
+
directly on the input/select/textarea itself, showing two mismatched rectangles. */
|
|
3324
|
+
.md3 .mfield input:focus-visible,
|
|
3325
|
+
.md3 .mfield select:focus-visible,
|
|
3326
|
+
.md3 .mfield textarea:focus-visible,
|
|
3327
|
+
.md3 .enq-float input:focus-visible,
|
|
3328
|
+
.md3 .enq-float textarea:focus-visible,
|
|
3329
|
+
.md3 .enquiry-name-row input:focus-visible,
|
|
3330
|
+
.md3 .enquiry-email-wrap input:focus-visible {
|
|
3331
|
+
box-shadow: none !important;
|
|
3251
3332
|
}
|
|
3252
3333
|
.md3 .link-accent {
|
|
3253
3334
|
color: var(--md-primary);
|
|
@@ -4254,7 +4335,7 @@ line-height: 31px;
|
|
|
4254
4335
|
box-shadow: 0 0 0 10px rgba(11, 83, 204, 0.18);
|
|
4255
4336
|
}
|
|
4256
4337
|
.md3 .exslider input[type="range"]:focus-visible::-webkit-slider-thumb {
|
|
4257
|
-
box-shadow: 0 0 0 3px
|
|
4338
|
+
box-shadow: 0 0 0 3px #005CFF;
|
|
4258
4339
|
}
|
|
4259
4340
|
/* body-type tile grid */
|
|
4260
4341
|
/* body-type tile grid: 2-col in the narrow rail, auto-fill on the full Detailsuche page */
|
|
@@ -5711,8 +5792,9 @@ line-height: 31px;
|
|
|
5711
5792
|
box-shadow: none;
|
|
5712
5793
|
}
|
|
5713
5794
|
.md3 .enq-float .mselect-btn:hover,
|
|
5714
|
-
.md3 .enq-float .mselect-btn[aria-expanded="true"]
|
|
5715
|
-
|
|
5795
|
+
.md3 .enq-float .mselect-btn[aria-expanded="true"],
|
|
5796
|
+
.md3 .enq-float .mselect-btn:focus-visible {
|
|
5797
|
+
box-shadow: none !important;
|
|
5716
5798
|
}
|
|
5717
5799
|
.md3 .enq-float .mselect-btn .lucide {
|
|
5718
5800
|
width: 14px;
|
|
@@ -5766,7 +5848,7 @@ line-height: 31px;
|
|
|
5766
5848
|
z-index: 60;
|
|
5767
5849
|
width: 390px;
|
|
5768
5850
|
height: 196px;
|
|
5769
|
-
background:
|
|
5851
|
+
background: rgba(244, 243, 243, 0.95);
|
|
5770
5852
|
border: 1px solid #D4DDE7;
|
|
5771
5853
|
border-radius: 16px 16px 0 0;
|
|
5772
5854
|
box-shadow: 0 -2px 14px rgba(0, 0, 0, 0.1);
|
|
@@ -6018,15 +6100,31 @@ line-height: 31px;
|
|
|
6018
6100
|
padding: 8px 16px 16px;
|
|
6019
6101
|
}
|
|
6020
6102
|
|
|
6021
|
-
/* Base (desktop) values for the
|
|
6022
|
-
below — kept as real CSS rules (not inline styles) since an inline style on the
|
|
6023
|
-
always beat the media-query override regardless of specificity. */
|
|
6103
|
+
/* Base (desktop) values for the TopBar layout/chrome knobs the mobile Header-System spec
|
|
6104
|
+
overrides below — kept as real CSS rules (not inline styles) since an inline style on the
|
|
6105
|
+
element would always beat the media-query override regardless of specificity. */
|
|
6024
6106
|
.md3 .topbar-container {
|
|
6025
6107
|
padding: 0 16px;
|
|
6026
6108
|
}
|
|
6027
6109
|
.md3 .topbar-actions {
|
|
6028
6110
|
gap: 16px;
|
|
6029
6111
|
}
|
|
6112
|
+
.md3 .topbar {
|
|
6113
|
+
background: var(--md-surface);
|
|
6114
|
+
border-bottom: 1px solid var(--md-outline-variant);
|
|
6115
|
+
transition: transform 0.25s, background 0.15s;
|
|
6116
|
+
}
|
|
6117
|
+
@media (max-width: 600px) {
|
|
6118
|
+
/* slight transparency (0.95) — mobile only, coordinated with .vdp-bottom-bar's own 0.95. */
|
|
6119
|
+
.md3 .topbar {
|
|
6120
|
+
background: color-mix(in srgb, var(--md-surface) 95%, transparent);
|
|
6121
|
+
}
|
|
6122
|
+
/* autoHideOnScroll (CDP/VDP only, see TopBar.tsx) — slides the header off the top of the
|
|
6123
|
+
viewport; transform doesn't affect its `position: sticky` layout slot, just the paint. */
|
|
6124
|
+
.md3 .topbar.topbar--hidden {
|
|
6125
|
+
transform: translateY(-100%);
|
|
6126
|
+
}
|
|
6127
|
+
}
|
|
6030
6128
|
|
|
6031
6129
|
/* mobile-only "Account Layer" sheet (TopBar) — full-width panel under the header, per the Figma
|
|
6032
6130
|
"03 | Account Layer" mockups. .show-mobile forces display:flex (see the ≤600px override
|
|
@@ -6905,7 +7003,10 @@ line-height: 31px;
|
|
|
6905
7003
|
line-height: 1.2;
|
|
6906
7004
|
text-align: center;
|
|
6907
7005
|
color: var(--fig-gray);
|
|
6908
|
-
|
|
7006
|
+
/* two lines (e.g. "wtd." / "comb.") rather than one overflowing/truncated line — the column
|
|
7007
|
+
is narrower than either label at this font-size, so letting it wrap at the natural word
|
|
7008
|
+
break is what actually fits without spilling into neighbouring columns. */
|
|
7009
|
+
white-space: normal;
|
|
6909
7010
|
overflow: visible;
|
|
6910
7011
|
}
|
|
6911
7012
|
.md3 .co2-seg {
|
|
@@ -6927,6 +7028,9 @@ line-height: 31px;
|
|
|
6927
7028
|
transform: scale(1.06);
|
|
6928
7029
|
}
|
|
6929
7030
|
.md3 .co2-seg--on2 {
|
|
7031
|
+
/* PHEV discharged-class marker — was missing this, so only the weighted class (--on) showed
|
|
7032
|
+
its full saturated colour while this one stayed the dim pastel base fill. */
|
|
7033
|
+
background: var(--seg);
|
|
6930
7034
|
border: 2px solid #fff;
|
|
6931
7035
|
box-shadow: 0 2px 8px rgba(16, 22, 42, 0.22);
|
|
6932
7036
|
transform: scale(1.04);
|
|
@@ -7971,6 +8075,16 @@ color: #005CFF;
|
|
|
7971
8075
|
text-align: center;
|
|
7972
8076
|
color: #ffffff;
|
|
7973
8077
|
}
|
|
8078
|
+
/* grid, not flex-wrap: with 4 stats (PHEV — weighted/discharged pairs) flex-wrap sized each
|
|
8079
|
+
row's columns independently off that row's own content width, so the 2nd column drifted out
|
|
8080
|
+
of alignment between rows whenever the two left-column labels weren't the same length
|
|
8081
|
+
("Stromverbrauch (gew.)" vs "Kraftstoff (entladen)"). Grid's 1fr columns stay a fixed width
|
|
8082
|
+
shared across every row regardless of content, so both columns line up vertically. Mobile
|
|
8083
|
+
only — desktop keeps the original flex-wrap layout. */
|
|
8084
|
+
.md3 #vdp-eco .co2-stats {
|
|
8085
|
+
display: grid;
|
|
8086
|
+
grid-template-columns: repeat(2, 1fr);
|
|
8087
|
+
}
|
|
7974
8088
|
.md3 #vdp-eco {
|
|
7975
8089
|
border: 1px solid #D4DDE7;
|
|
7976
8090
|
box-shadow: none;
|
package/ui/Dropdown.tsx
CHANGED
|
@@ -49,6 +49,10 @@ export interface DropdownProps {
|
|
|
49
49
|
className?: string;
|
|
50
50
|
/** align the popover to the right edge of the trigger (default left). */
|
|
51
51
|
menuAlign?: 'left' | 'right';
|
|
52
|
+
/** show a text input at the top of the popover that filters `options` by label (substring, case-insensitive). */
|
|
53
|
+
searchable?: boolean;
|
|
54
|
+
/** placeholder for the search input (only used when `searchable`). */
|
|
55
|
+
searchPlaceholder?: string;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
@@ -63,25 +67,60 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
63
67
|
ariaLabel,
|
|
64
68
|
className,
|
|
65
69
|
menuAlign = 'left',
|
|
70
|
+
searchable = false,
|
|
71
|
+
searchPlaceholder,
|
|
66
72
|
}) => {
|
|
67
73
|
const [open, setOpen] = React.useState(false);
|
|
68
74
|
const [activeIndex, setActiveIndex] = React.useState(-1);
|
|
75
|
+
const [query, setQuery] = React.useState('');
|
|
69
76
|
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
|
70
77
|
const btnRef = React.useRef<HTMLButtonElement | null>(null);
|
|
71
78
|
const menuRef = React.useRef<HTMLUListElement | null>(null);
|
|
79
|
+
const searchRef = React.useRef<HTMLInputElement | null>(null);
|
|
72
80
|
// stable id base for aria-activedescendant (avoids Math.random / Date.now, which are unavailable).
|
|
73
81
|
const idBase = React.useMemo(() => `mselect-${Math.round(value ? value.length : 0)}-`, [value]);
|
|
74
82
|
|
|
75
83
|
const selectedIndex = options.findIndex((o) => o.value === value);
|
|
76
84
|
const selected = selectedIndex !== -1 ? options[selectedIndex] : undefined;
|
|
77
85
|
|
|
86
|
+
const filteredOptions = React.useMemo(() => {
|
|
87
|
+
if (!searchable || !query.trim()) return options;
|
|
88
|
+
const q = query.trim().toLowerCase();
|
|
89
|
+
const matches = options.filter((o) => o.label.toLowerCase().includes(q));
|
|
90
|
+
// some option lists intentionally repeat entries across groups while closed (e.g. a "Top
|
|
91
|
+
// Marken" shortlist followed by the FULL "Alle Marken" list, which includes those same top
|
|
92
|
+
// brands again) — harmless when browsing both sections, but shows the same match twice while
|
|
93
|
+
// searching. Keep only the first occurrence of each label once the user is actually filtering.
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
return matches.filter((o) => {
|
|
96
|
+
const key = o.label.toLowerCase();
|
|
97
|
+
if (seen.has(key)) return false;
|
|
98
|
+
seen.add(key);
|
|
99
|
+
return true;
|
|
100
|
+
});
|
|
101
|
+
}, [searchable, query, options]);
|
|
102
|
+
|
|
103
|
+
// deferred: when searchable, the trigger <button> is swapped out for an <input> while
|
|
104
|
+
// open (see render), so btnRef.current is null *during* the close() call itself — the
|
|
105
|
+
// button only exists again after this state flips `open` back to false and React
|
|
106
|
+
// re-renders. Consumed by the effect below, once that re-render has actually happened.
|
|
107
|
+
const focusTriggerAfterClose = React.useRef(false);
|
|
108
|
+
|
|
78
109
|
// close + restore focus to the trigger.
|
|
79
110
|
const close = React.useCallback((focusTrigger: boolean) => {
|
|
80
111
|
setOpen(false);
|
|
81
112
|
setActiveIndex(-1);
|
|
82
|
-
|
|
113
|
+
setQuery('');
|
|
114
|
+
focusTriggerAfterClose.current = focusTrigger;
|
|
83
115
|
}, []);
|
|
84
116
|
|
|
117
|
+
React.useEffect(() => {
|
|
118
|
+
if (!open && focusTriggerAfterClose.current) {
|
|
119
|
+
focusTriggerAfterClose.current = false;
|
|
120
|
+
if (btnRef.current) btnRef.current.focus();
|
|
121
|
+
}
|
|
122
|
+
}, [open]);
|
|
123
|
+
|
|
85
124
|
const openMenu = React.useCallback(() => {
|
|
86
125
|
if (disabled) return;
|
|
87
126
|
setOpen(true);
|
|
@@ -89,6 +128,18 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
89
128
|
setActiveIndex(selectedIndex !== -1 ? selectedIndex : 0);
|
|
90
129
|
}, [disabled, selectedIndex]);
|
|
91
130
|
|
|
131
|
+
// when opened searchable, focus the search input instead of leaving focus on the trigger.
|
|
132
|
+
// scrollIntoView first: on mobile the keyboard is about to eat ~40% of the viewport, so the
|
|
133
|
+
// trigger (and the popover below it) needs to already be scrolled above the fold before focus
|
|
134
|
+
// fires, or the popover can end up hidden behind the keyboard with no way to see it open.
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
if (!open || !searchable) return;
|
|
137
|
+
if (rootRef.current && rootRef.current.scrollIntoView) {
|
|
138
|
+
rootRef.current.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
139
|
+
}
|
|
140
|
+
if (searchRef.current) searchRef.current.focus();
|
|
141
|
+
}, [open, searchable]);
|
|
142
|
+
|
|
92
143
|
// click-away (guarded for SSR).
|
|
93
144
|
React.useEffect(() => {
|
|
94
145
|
if (!open || typeof document === 'undefined') return undefined;
|
|
@@ -106,8 +157,14 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
106
157
|
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
|
107
158
|
}, [open, activeIndex]);
|
|
108
159
|
|
|
160
|
+
// reset the keyboard highlight to the top match whenever the filtered list changes.
|
|
161
|
+
React.useEffect(() => {
|
|
162
|
+
if (searchable && open) setActiveIndex(filteredOptions.length ? 0 : -1);
|
|
163
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
|
+
}, [filteredOptions]);
|
|
165
|
+
|
|
109
166
|
const commit = (idx: number) => {
|
|
110
|
-
const opt =
|
|
167
|
+
const opt = filteredOptions[idx];
|
|
111
168
|
if (!opt || opt.disabled) return;
|
|
112
169
|
onChange(opt.value);
|
|
113
170
|
close(true);
|
|
@@ -115,17 +172,30 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
115
172
|
|
|
116
173
|
// move the active highlight, skipping disabled options.
|
|
117
174
|
const move = (dir: 1 | -1) => {
|
|
118
|
-
if (!
|
|
175
|
+
if (!filteredOptions.length) return;
|
|
119
176
|
let i = activeIndex;
|
|
120
|
-
for (let step = 0; step <
|
|
121
|
-
i = (i + dir +
|
|
122
|
-
if (!
|
|
177
|
+
for (let step = 0; step < filteredOptions.length; step += 1) {
|
|
178
|
+
i = (i + dir + filteredOptions.length) % filteredOptions.length;
|
|
179
|
+
if (!filteredOptions[i].disabled) {
|
|
123
180
|
setActiveIndex(i);
|
|
124
181
|
return;
|
|
125
182
|
}
|
|
126
183
|
}
|
|
127
184
|
};
|
|
128
185
|
|
|
186
|
+
const onMenuKeyDown = (e: React.KeyboardEvent) => {
|
|
187
|
+
switch (e.key) {
|
|
188
|
+
case 'ArrowDown': e.preventDefault(); move(1); break;
|
|
189
|
+
case 'ArrowUp': e.preventDefault(); move(-1); break;
|
|
190
|
+
case 'Home': e.preventDefault(); setActiveIndex(filteredOptions.findIndex((o) => !o.disabled)); break;
|
|
191
|
+
case 'End': e.preventDefault(); for (let i = filteredOptions.length - 1; i >= 0; i -= 1) { if (!filteredOptions[i].disabled) { setActiveIndex(i); break; } } break;
|
|
192
|
+
case 'Enter': e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); break;
|
|
193
|
+
case 'Escape': e.preventDefault(); close(true); break;
|
|
194
|
+
case 'Tab': close(false); break;
|
|
195
|
+
default: break;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
129
199
|
const onTriggerKeyDown = (e: React.KeyboardEvent) => {
|
|
130
200
|
if (disabled) return;
|
|
131
201
|
if (!open) {
|
|
@@ -135,16 +205,8 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
135
205
|
}
|
|
136
206
|
return;
|
|
137
207
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
case 'ArrowUp': e.preventDefault(); move(-1); break;
|
|
141
|
-
case 'Home': e.preventDefault(); setActiveIndex(options.findIndex((o) => !o.disabled)); break;
|
|
142
|
-
case 'End': e.preventDefault(); for (let i = options.length - 1; i >= 0; i -= 1) { if (!options[i].disabled) { setActiveIndex(i); break; } } break;
|
|
143
|
-
case 'Enter': case ' ': e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); break;
|
|
144
|
-
case 'Escape': e.preventDefault(); close(true); break;
|
|
145
|
-
case 'Tab': close(false); break;
|
|
146
|
-
default: break;
|
|
147
|
-
}
|
|
208
|
+
if (e.key === ' ') { e.preventDefault(); if (activeIndex >= 0) commit(activeIndex); return; }
|
|
209
|
+
onMenuKeyDown(e);
|
|
148
210
|
};
|
|
149
211
|
|
|
150
212
|
const wrapCls = ['mselect'];
|
|
@@ -154,49 +216,83 @@ const Dropdown: React.FunctionComponent<DropdownProps> = ({
|
|
|
154
216
|
|
|
155
217
|
const label = selected ? selected.label : placeholder || '';
|
|
156
218
|
|
|
219
|
+
const editing = searchable && open;
|
|
220
|
+
|
|
157
221
|
return (
|
|
158
222
|
<div className={wrapCls.join(' ')} ref={rootRef}>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
223
|
+
{editing ? (
|
|
224
|
+
// combobox mode: the trigger itself becomes the text field (not a separate input
|
|
225
|
+
// inside the popover) — a <button> can't legally contain an <input>, so this is a
|
|
226
|
+
// <div> standing in for it, same `.mselect-btn` class for identical chrome.
|
|
227
|
+
<div className="mselect-btn mselect-btn--editing">
|
|
228
|
+
{icon && <Icon name={icon} />}
|
|
229
|
+
<input
|
|
230
|
+
ref={searchRef}
|
|
231
|
+
type="text"
|
|
232
|
+
className="mselect-input"
|
|
233
|
+
placeholder={searchPlaceholder || placeholder}
|
|
234
|
+
value={query}
|
|
235
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
236
|
+
onKeyDown={onMenuKeyDown}
|
|
237
|
+
aria-label={ariaLabel}
|
|
238
|
+
aria-haspopup="listbox"
|
|
239
|
+
aria-expanded={open}
|
|
240
|
+
aria-controls={`${idBase}list`}
|
|
241
|
+
aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
|
|
242
|
+
/>
|
|
243
|
+
<button type="button" className="mselect-chev-btn" aria-label="Close" onClick={() => close(true)}>
|
|
244
|
+
<Icon name="chevron-down" cls="mselect-chev" />
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
) : (
|
|
248
|
+
<button
|
|
249
|
+
ref={btnRef}
|
|
250
|
+
type="button"
|
|
251
|
+
className="mselect-btn"
|
|
252
|
+
aria-haspopup="listbox"
|
|
253
|
+
aria-expanded={open}
|
|
254
|
+
aria-label={ariaLabel}
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
onClick={() => (open ? close(false) : openMenu())}
|
|
257
|
+
onKeyDown={onTriggerKeyDown}
|
|
181
258
|
>
|
|
182
|
-
{
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
259
|
+
{icon && <Icon name={icon} />}
|
|
260
|
+
<span className={'mselect-label' + (selected ? '' : ' mselect-label--ph')}>{label}</span>
|
|
261
|
+
<Icon name="chevron-down" cls="mselect-chev" />
|
|
262
|
+
</button>
|
|
263
|
+
)}
|
|
264
|
+
{open && (
|
|
265
|
+
<div className={'mselect-menu' + (menuAlign === 'right' ? ' mselect-menu--right' : '') + (searchable ? ' mselect-menu--searchable' : '')}>
|
|
266
|
+
<ul
|
|
267
|
+
ref={menuRef}
|
|
268
|
+
id={`${idBase}list`}
|
|
269
|
+
className="mselect-list"
|
|
270
|
+
role="listbox"
|
|
271
|
+
tabIndex={-1}
|
|
272
|
+
aria-activedescendant={activeIndex >= 0 ? `${idBase}${activeIndex}` : undefined}
|
|
273
|
+
>
|
|
274
|
+
{filteredOptions.map((opt, i) => (
|
|
275
|
+
<li key={`${opt.value}_${i}`} role="presentation">
|
|
276
|
+
<button
|
|
277
|
+
id={`${idBase}${i}`}
|
|
278
|
+
type="button"
|
|
279
|
+
role="option"
|
|
280
|
+
aria-selected={opt.value === value}
|
|
281
|
+
disabled={opt.disabled}
|
|
282
|
+
className={'mselect-opt' + (i === activeIndex ? ' is-active' : '')}
|
|
283
|
+
onMouseEnter={() => setActiveIndex(i)}
|
|
284
|
+
onClick={() => commit(i)}
|
|
285
|
+
>
|
|
286
|
+
<span className="mselect-opt-label">{opt.label}</span>
|
|
287
|
+
{opt.value === value && <Icon name="check" size={16} cls="mselect-check" />}
|
|
288
|
+
</button>
|
|
289
|
+
</li>
|
|
290
|
+
))}
|
|
291
|
+
{searchable && filteredOptions.length === 0 && (
|
|
292
|
+
<li className="mselect-empty" role="presentation">Keine Treffer</li>
|
|
293
|
+
)}
|
|
294
|
+
</ul>
|
|
295
|
+
</div>
|
|
200
296
|
)}
|
|
201
297
|
</div>
|
|
202
298
|
);
|
package/vdp/VdpModules.tsx
CHANGED
|
@@ -1442,10 +1442,11 @@ export const VdpStickyBar: React.FunctionComponent<VdpStickyBarProps> = ({ vehic
|
|
|
1442
1442
|
export interface VdpBottomBarProps {
|
|
1443
1443
|
vehicle: VehicleDetail;
|
|
1444
1444
|
/**
|
|
1445
|
-
* Whether the bar is docked/visible —
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1445
|
+
* Whether the bar is docked/visible — page-owned scroll-position toggle: shows after scrolling
|
|
1446
|
+
* past the main offer card (same STICKY_SHOW_AT threshold VdpStickyBar uses), and additionally
|
|
1447
|
+
* hides again once the page scrolls into the similar-vehicles/legal-footer zone so it never
|
|
1448
|
+
* covers that content. That bottom-of-page rule is mobile-only and NOT shared with
|
|
1449
|
+
* VdpStickyBar's own `show` (the desktop bar has no such lower boundary).
|
|
1449
1450
|
*/
|
|
1450
1451
|
show: boolean;
|
|
1451
1452
|
/** active payment mode — drives the tabs + the displayed figure (rate vs price). */
|