Skip to content

Commit 6e74971

Browse files
committed
fix: modal backdrop for header action bar item dropdowns
1 parent 46a9ce4 commit 6e74971

File tree

5 files changed

+165
-92
lines changed

5 files changed

+165
-92
lines changed

packages/react/src/components/header/components/headerActionBar/HeaderActionBar.tsx

Lines changed: 1 addition & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -24,67 +24,10 @@ import { useHeaderContext, useSetHeaderContext } from '../../HeaderContext';
2424
import { HeaderActionBarItemProps } from '../headerActionBarItem/HeaderActionBarItem';
2525
import { IconCross, IconMenuHamburger } from '../../../../icons';
2626
import { AllElementPropsWithoutRef } from '../../../../utils/elementTypings';
27+
import { addDocumentFocusPrevention } from '../../utils/focusPrevention';
2728

2829
const classNames = styleBoundClassNames(styles);
2930

30-
const addDocumentFocusPrevention = (
31-
modalContainer: HTMLElement,
32-
originalTabIndexes: React.MutableRefObject<Map<Element, string | null>>,
33-
) => {
34-
const isFocusableByDefault = (element: Element): boolean => {
35-
const focusableElements = [
36-
'a[href]',
37-
'area[href]',
38-
'input:not([disabled])',
39-
'select:not([disabled])',
40-
'textarea:not([disabled])',
41-
'button:not([disabled])',
42-
'iframe',
43-
'object',
44-
'embed',
45-
'[contenteditable]',
46-
'[tabindex]:not([tabindex="-1"])',
47-
];
48-
return focusableElements.some((selector) => element.matches(selector));
49-
};
50-
51-
const isFocusableByTabIndex = (element: Element): boolean => {
52-
const tabIndex = window.getComputedStyle(element).getPropertyValue('tabindex');
53-
return tabIndex !== '' && tabIndex !== '-1';
54-
};
55-
56-
const isFocusableByScroll = (element: Element): boolean => {
57-
const { overflowX, overflowY } = window.getComputedStyle(element);
58-
return overflowY === 'auto' || overflowY === 'scroll' || overflowX === 'auto' || overflowX === 'scroll';
59-
};
60-
61-
if (modalContainer) {
62-
const allElements = document.querySelectorAll('*');
63-
64-
Array.from(allElements)
65-
.filter((element) => !modalContainer.contains(element))
66-
.filter(
67-
(element) => isFocusableByDefault(element) || isFocusableByTabIndex(element) || isFocusableByScroll(element),
68-
)
69-
.forEach((element) => {
70-
originalTabIndexes.current.set(element, element.getAttribute('tabindex'));
71-
element.setAttribute('tabindex', '-1');
72-
});
73-
74-
return () => {
75-
originalTabIndexes.current.forEach((tabIndex, element) => {
76-
if (tabIndex === null) {
77-
element.removeAttribute('tabindex');
78-
} else {
79-
element.setAttribute('tabindex', tabIndex);
80-
}
81-
});
82-
originalTabIndexes.current.clear();
83-
};
84-
}
85-
return null;
86-
};
87-
8831
const addDocumentScrollPrevention = (actionBar?: HTMLElement) => {
8932
if (document && actionBar) {
9033
// Getting the closest ancestor HTML. Get the closest in case of iframes or such

packages/react/src/components/header/components/headerActionBarItem/HeaderActionBarItem.module.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
transition-duration: var(--animation-duration-dropwdown);
88
transition-property: max-height, padding-bottom;
99
transition-timing-function: ease;
10-
z-index: 20;
10+
z-index: 100;
1111
}
1212

1313
.container {

packages/react/src/components/header/components/headerActionBarItem/HeaderActionBarItem.tsx

Lines changed: 96 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { HeaderActionBarItemButton, HeaderActionBarItemButtonProps } from './Hea
66
import { useHeaderContext } from '../../HeaderContext';
77
import classNames from '../../../../utils/classNames';
88
import { AllElementPropsWithRef } from '../../../../utils/elementTypings';
9+
import { addDocumentFocusPrevention } from '../../utils/focusPrevention';
910

1011
export type HeaderActionBarItemProps = React.PropsWithChildren<
1112
AllElementPropsWithRef<'div'> & {
@@ -87,6 +88,8 @@ export const HeaderActionBarItem = (properties: HeaderActionBarItemProps) => {
8788
} = properties;
8889
const dropdownContentElementRef = useRef<HTMLDivElement>(null);
8990
const containerElementRef = useRef<HTMLDivElement>(null);
91+
const backdropRef = useRef<HTMLDivElement>(null);
92+
const originalTabIndexes = useRef<Map<Element, string | null>>(new Map());
9093
const [hasContent, setHasContent] = useState(false);
9194
const { isSmallScreen } = useHeaderContext();
9295
const [visible, setDisplayProperty] = useState(false);
@@ -101,25 +104,68 @@ export const HeaderActionBarItem = (properties: HeaderActionBarItemProps) => {
101104
if (!visible) return;
102105
const container = getContainer();
103106
const eventTargetNode = event.target;
104-
if (!container.contains(eventTargetNode)) {
105-
setDisplayProperty(false);
107+
108+
// Check if click is inside the container
109+
if (container.contains(eventTargetNode)) {
110+
return;
111+
}
112+
113+
// For Menu item, also check if click is inside the controlled element (mobile menu)
114+
if (id === 'Menu') {
115+
const controlledElement = document.getElementById('hds-mobile-menu');
116+
if (controlledElement && controlledElement.contains(eventTargetNode)) {
117+
return;
118+
}
106119
}
120+
121+
// Click is outside - close the dropdown and prevent this click from doing anything else
122+
event.preventDefault();
123+
event.stopPropagation();
124+
event.stopImmediatePropagation();
125+
setDisplayProperty(false);
107126
};
108127

109128
const handleBlur = (event) => {
110129
if (!visible) return;
111130
const container = getContainer();
131+
const backdrop = backdropRef.current;
112132
const eventTargetNode = event.relatedTarget;
113133
// close the dropdown if the focus is outside the container on large screens
114-
if (!container.contains(eventTargetNode) && !isSmallScreen) {
134+
// but not if focus moved to the backdrop
135+
if (!container.contains(eventTargetNode) && eventTargetNode !== backdrop && !isSmallScreen) {
136+
setDisplayProperty(false);
137+
}
138+
};
139+
140+
const handleKeyDown = (event) => {
141+
if (event.key === 'Escape' && visible) {
142+
event.preventDefault();
115143
setDisplayProperty(false);
116144
}
117145
};
118146

119147
useEffect(() => {
120-
document.addEventListener('click', handleDocumentClick);
121-
return () => document.removeEventListener('click', handleDocumentClick);
122-
}, [containerElementRef.current]);
148+
if (visible) {
149+
// Only add the listener when dropdown is visible
150+
document.addEventListener('click', handleDocumentClick, true);
151+
document.addEventListener('keydown', handleKeyDown);
152+
153+
// Use the shared utility function to prevent focus and pointer events on elements outside the header
154+
const header = containerElementRef.current?.closest('header') as HTMLElement;
155+
const cleanupFocusPrevention = header ? addDocumentFocusPrevention(header, originalTabIndexes, true) : null;
156+
157+
return () => {
158+
document.removeEventListener('click', handleDocumentClick, true);
159+
document.removeEventListener('keydown', handleKeyDown);
160+
161+
// Cleanup focus prevention
162+
if (cleanupFocusPrevention) {
163+
cleanupFocusPrevention();
164+
}
165+
};
166+
}
167+
return undefined;
168+
}, [visible]);
123169

124170
// Hide the component if there is no content
125171
useEffect(() => {
@@ -162,33 +208,51 @@ export const HeaderActionBarItem = (properties: HeaderActionBarItemProps) => {
162208

163209
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
164210
return (
165-
<div {...rest} id={id} className={className} ref={containerElementRef} onBlur={handleBlur}>
166-
<HeaderActionBarItemButton
167-
className={iconClassName}
168-
onClick={handleButtonClick}
169-
label={iconLabel}
170-
icon={iconClass}
171-
aria-expanded={visible}
172-
aria-label={ariaLabel !== undefined ? ariaLabel : String(label)}
173-
aria-controls={id === 'Menu' ? `hds-mobile-menu` : `${id}-dropdown`}
174-
labelOnRight={labelOnRight}
175-
fixedRightPosition={fixedRightPosition}
176-
isActive={visible}
177-
fullWidth={fullWidth}
178-
hasSubItems={hasSubItems}
179-
avatar={avatar}
180-
preventButtonResize={preventButtonResize}
181-
{...buttonOverlayProps}
182-
/>
183-
{hasSubItems && (
184-
<div className={classes.dropdownWrapper}>
185-
<div id={`${id}-dropdown`} className={dropdownClassName} ref={dropdownContentElementRef}>
186-
{heading && <h3>{label}</h3>}
187-
<ul>{children}</ul>
188-
</div>
189-
</div>
211+
<>
212+
{visible && hasSubItems && (
213+
<div
214+
ref={backdropRef}
215+
className={classes.backdrop}
216+
aria-hidden="true"
217+
style={{
218+
position: 'fixed',
219+
top: 0,
220+
left: 0,
221+
right: 0,
222+
bottom: 0,
223+
zIndex: 99,
224+
pointerEvents: 'none',
225+
}}
226+
/>
190227
)}
191-
</div>
228+
<div {...rest} id={id} className={className} ref={containerElementRef} onBlur={handleBlur}>
229+
<HeaderActionBarItemButton
230+
className={iconClassName}
231+
onClick={handleButtonClick}
232+
label={iconLabel}
233+
icon={iconClass}
234+
aria-expanded={visible}
235+
aria-label={ariaLabel !== undefined ? ariaLabel : String(label)}
236+
aria-controls={id === 'Menu' ? `hds-mobile-menu` : `${id}-dropdown`}
237+
labelOnRight={labelOnRight}
238+
fixedRightPosition={fixedRightPosition}
239+
isActive={visible}
240+
fullWidth={fullWidth}
241+
hasSubItems={hasSubItems}
242+
avatar={avatar}
243+
preventButtonResize={preventButtonResize}
244+
{...buttonOverlayProps}
245+
/>
246+
{hasSubItems && (
247+
<div className={classes.dropdownWrapper}>
248+
<div id={`${id}-dropdown`} className={dropdownClassName} ref={dropdownContentElementRef}>
249+
{heading && <h3>{label}</h3>}
250+
<ul>{children}</ul>
251+
</div>
252+
</div>
253+
)}
254+
</div>
255+
</>
192256
);
193257
};
194258

packages/react/src/components/header/components/headerSearch/HeaderSearch.module.scss

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@
1717
display: flex;
1818
flex-direction: column;
1919
gap: var(--spacing-s);
20-
margin: var(--spacing-2-xl) 0 2px 0;
20+
margin: 0 0 2px;
2121
min-height: 509px;
2222
outline: none !important;
23+
padding-top: var(--spacing-2-xl);
2324
position: relative;
2425
width: 100%;
2526

2627
h3 {
28+
background: var(--header-background-color);
29+
color: var(--header-color);
2730
font-size: var(--fontsize-heading-l);
2831
font-weight: 400;
2932
margin: 0;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from 'react';
2+
3+
/**
4+
* Utility function to prevent focus and pointer events on elements outside a modal container.
5+
* Used by HeaderActionBar mobile menu and HeaderActionBarItem dropdowns.
6+
*/
7+
export const addDocumentFocusPrevention = (
8+
modalContainer: HTMLElement,
9+
originalTabIndexes: React.MutableRefObject<Map<Element, string | null>>,
10+
blockPointerEvents = false,
11+
) => {
12+
// Get all focusable elements that are outside the modal container
13+
const focusableElements = [
14+
...Array.from(
15+
document.querySelectorAll(
16+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), details, summary',
17+
),
18+
),
19+
// Also add elements with a tabindex attribute
20+
...Array.from(document.querySelectorAll('[tabindex]')),
21+
// And elements that can scroll
22+
...Array.from(document.querySelectorAll('[style*="overflow"]')),
23+
].filter((element) => !modalContainer.contains(element));
24+
25+
// Save original tabindex values and set to -1
26+
focusableElements.forEach((element) => {
27+
const currentTabindex = element.getAttribute('tabindex');
28+
originalTabIndexes.current.set(element, currentTabindex);
29+
element.setAttribute('tabindex', '-1');
30+
31+
// Also block pointer events if requested
32+
if (blockPointerEvents) {
33+
const htmlElement = element as HTMLElement;
34+
const originalPointerEvents = htmlElement.style.pointerEvents;
35+
// Store the original pointer events value (we'll use a data attribute for this)
36+
htmlElement.dataset.originalPointerEvents = originalPointerEvents || '';
37+
htmlElement.style.pointerEvents = 'none';
38+
}
39+
});
40+
41+
// Return cleanup function
42+
return () => {
43+
originalTabIndexes.current.forEach((originalValue, element) => {
44+
if (originalValue === null) {
45+
element.removeAttribute('tabindex');
46+
} else {
47+
element.setAttribute('tabindex', originalValue);
48+
}
49+
50+
// Restore pointer events if they were blocked
51+
if (blockPointerEvents) {
52+
const htmlElement = element as HTMLElement;
53+
const { dataset } = htmlElement;
54+
const { originalPointerEvents } = dataset;
55+
if (originalPointerEvents !== undefined) {
56+
htmlElement.style.pointerEvents = originalPointerEvents;
57+
delete dataset.originalPointerEvents;
58+
}
59+
}
60+
});
61+
originalTabIndexes.current.clear();
62+
};
63+
};

0 commit comments

Comments
 (0)