Skip to content

Commit 5536c15

Browse files
[select] Fix keyboard navigation while rendering in shadow DOM (#47380)
Co-authored-by: Zeeshan Tamboli <[email protected]>
1 parent 1b40e0f commit 5536c15

File tree

3 files changed

+71
-13
lines changed

3 files changed

+71
-13
lines changed

packages/mui-material/src/MenuList/MenuList.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isFragment } from 'react-is';
44
import PropTypes from 'prop-types';
55
import ownerDocument from '../utils/ownerDocument';
66
import List from '../List';
7+
import getActiveElement from '../utils/getActiveElement';
78
import getScrollbarSize from '../utils/getScrollbarSize';
89
import useForkRef from '../utils/useForkRef';
910
import useEnhancedEffect from '../utils/useEnhancedEffect';
@@ -161,7 +162,7 @@ const MenuList = React.forwardRef(function MenuList(props, ref) {
161162
* or document.body or document.documentElement. Only the first case will
162163
* trigger this specific handler.
163164
*/
164-
const currentFocus = ownerDocument(list).activeElement;
165+
const currentFocus = getActiveElement(ownerDocument(list));
165166

166167
if (key === 'ArrowDown') {
167168
// Prevent scroll of the page

packages/mui-material/src/Select/Select.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1910,4 +1910,58 @@ describe('<Select />', () => {
19101910
const event = handleMouseDown.firstCall.args[0];
19111911
expect(event.button).to.equal(0);
19121912
});
1913+
1914+
describe('keyboard navigation in shadow DOM', () => {
1915+
it('should navigate between options using arrow keys', async function test() {
1916+
// reset fake timers
1917+
clock.restore();
1918+
1919+
if (window.navigator.userAgent.includes('jsdom')) {
1920+
this.skip();
1921+
}
1922+
1923+
// Create a shadow container
1924+
const shadowHost = document.createElement('div');
1925+
document.body.appendChild(shadowHost);
1926+
const shadowContainer = shadowHost.attachShadow({ mode: 'open' });
1927+
1928+
// Render directly into shadow container
1929+
const shadowRoot = document.createElement('div');
1930+
shadowContainer.appendChild(shadowRoot);
1931+
1932+
const { unmount, user } = render(
1933+
<Select value="" MenuProps={{ container: shadowRoot }}>
1934+
<MenuItem value={10}>Ten</MenuItem>
1935+
<MenuItem value={20}>Twenty</MenuItem>
1936+
<MenuItem value={30}>Thirty</MenuItem>
1937+
</Select>,
1938+
{ container: shadowRoot },
1939+
);
1940+
1941+
const trigger = shadowRoot.querySelector('[role="combobox"]');
1942+
expect(trigger).not.to.equal(null);
1943+
1944+
// Open Select
1945+
await user.click(trigger);
1946+
1947+
const options = shadowRoot.querySelectorAll('[role="option"]');
1948+
expect(options.length).to.equal(3);
1949+
1950+
expect(shadowContainer.activeElement).to.equal(options[0]);
1951+
1952+
await user.keyboard('{ArrowDown}');
1953+
1954+
expect(shadowContainer.activeElement).to.equal(options[1]);
1955+
1956+
await user.keyboard('{ArrowUp}');
1957+
1958+
expect(shadowContainer.activeElement).to.equal(options[0]);
1959+
1960+
// Cleanup
1961+
unmount();
1962+
if (shadowHost.parentNode) {
1963+
document.body.removeChild(shadowHost);
1964+
}
1965+
});
1966+
});
19131967
});

packages/mui-material/src/Unstable_TrapFocus/FocusTrap.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import ownerDocument from '@mui/utils/ownerDocument';
77
import getReactElementRef from '@mui/utils/getReactElementRef';
88
import exactProp from '@mui/utils/exactProp';
99
import elementAcceptingRef from '@mui/utils/elementAcceptingRef';
10+
import getActiveElement from '../utils/getActiveElement';
1011
import { FocusTrapProps } from './FocusTrap.types';
1112

1213
// Inspired by https://github.com/focus-trap/tabbable
@@ -162,8 +163,9 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
162163
}
163164

164165
const doc = ownerDocument(rootRef.current);
166+
const activeElement = getActiveElement(doc);
165167

166-
if (!rootRef.current.contains(doc.activeElement)) {
168+
if (!rootRef.current.contains(activeElement)) {
167169
if (!rootRef.current.hasAttribute('tabIndex')) {
168170
if (process.env.NODE_ENV !== 'production') {
169171
console.error(
@@ -209,6 +211,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
209211
}
210212

211213
const doc = ownerDocument(rootRef.current);
214+
const activeElement = getActiveElement(doc);
212215

213216
const loopFocus = (nativeEvent: KeyboardEvent) => {
214217
lastKeydown.current = nativeEvent;
@@ -218,8 +221,8 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
218221
}
219222

220223
// Make sure the next tab starts from the right place.
221-
// doc.activeElement refers to the origin.
222-
if (doc.activeElement === rootRef.current && nativeEvent.shiftKey) {
224+
// activeElement refers to the origin.
225+
if (activeElement === rootRef.current && nativeEvent.shiftKey) {
223226
// We need to ignore the next contain as
224227
// it will try to move the focus back to the rootRef element.
225228
ignoreNextEnforceFocus.current = true;
@@ -238,27 +241,29 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
238241
return;
239242
}
240243

244+
const activeEl = getActiveElement(doc);
245+
241246
if (!doc.hasFocus() || !isEnabled() || ignoreNextEnforceFocus.current) {
242247
ignoreNextEnforceFocus.current = false;
243248
return;
244249
}
245250

246251
// The focus is already inside
247-
if (rootElement.contains(doc.activeElement)) {
252+
if (rootElement.contains(activeEl)) {
248253
return;
249254
}
250255

251256
// The disableEnforceFocus is set and the focus is outside of the focus trap (and sentinel nodes)
252257
if (
253258
disableEnforceFocus &&
254-
doc.activeElement !== sentinelStart.current &&
255-
doc.activeElement !== sentinelEnd.current
259+
activeEl !== sentinelStart.current &&
260+
activeEl !== sentinelEnd.current
256261
) {
257262
return;
258263
}
259264

260265
// if the focus event is not coming from inside the children's react tree, reset the refs
261-
if (doc.activeElement !== reactFocusEventTarget.current) {
266+
if (activeEl !== reactFocusEventTarget.current) {
262267
reactFocusEventTarget.current = null;
263268
} else if (reactFocusEventTarget.current !== null) {
264269
return;
@@ -269,10 +274,7 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
269274
}
270275

271276
let tabbable: ReadonlyArray<HTMLElement> = [];
272-
if (
273-
doc.activeElement === sentinelStart.current ||
274-
doc.activeElement === sentinelEnd.current
275-
) {
277+
if (activeEl === sentinelStart.current || activeEl === sentinelEnd.current) {
276278
tabbable = getTabbable(rootRef.current!);
277279
}
278280

@@ -309,7 +311,8 @@ function FocusTrap(props: FocusTrapProps): React.JSX.Element {
309311
// The whatwg spec defines how the browser should behave but does not explicitly mention any events:
310312
// https://html.spec.whatwg.org/multipage/interaction.html#focus-fixup-rule.
311313
const interval = setInterval(() => {
312-
if (doc.activeElement && doc.activeElement.tagName === 'BODY') {
314+
const activeEl = getActiveElement(doc);
315+
if (activeEl && activeEl.tagName === 'BODY') {
313316
contain();
314317
}
315318
}, 50);

0 commit comments

Comments
 (0)