Skip to content

Commit 15ff395

Browse files
authored
[tabs] Fix Arrow key navigation failing when component is rendered in shadow DOM (#47178)
1 parent 4da8a56 commit 15ff395

File tree

7 files changed

+207
-2
lines changed

7 files changed

+207
-2
lines changed

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import useEventCallback from '../utils/useEventCallback';
1818
import tabsClasses, { getTabsUtilityClass } from './tabsClasses';
1919
import ownerDocument from '../utils/ownerDocument';
2020
import ownerWindow from '../utils/ownerWindow';
21+
import getActiveElement from '../utils/getActiveElement';
2122
import useSlot from '../utils/useSlot';
2223

2324
const nextItem = (list, item) => {
@@ -820,11 +821,11 @@ const Tabs = React.forwardRef(function Tabs(inProps, ref) {
820821
}
821822

822823
const list = tabListRef.current;
823-
const currentFocus = ownerDocument(list).activeElement;
824+
const currentFocus = getActiveElement(ownerDocument(list));
824825
// Keyboard navigation assumes that [role="tab"] are siblings
825826
// though we might warn in the future about nested, interactive elements
826827
// as a a11y violation
827-
const role = currentFocus.getAttribute('role');
828+
const role = currentFocus?.getAttribute('role');
828829
if (role !== 'tab') {
829830
return;
830831
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,6 +1455,60 @@ describe('<Tabs />', () => {
14551455
});
14561456
});
14571457

1458+
describe('keyboard navigation in shadow DOM', () => {
1459+
it('should navigate between tabs using arrow keys when rendered in shadow DOM', async function test() {
1460+
if (isJSDOM) {
1461+
this.skip();
1462+
}
1463+
1464+
// Create a shadow root
1465+
const shadowHost = document.createElement('div');
1466+
document.body.appendChild(shadowHost);
1467+
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
1468+
1469+
// Render directly into shadow root
1470+
const shadowContainer = document.createElement('div');
1471+
shadowRoot.appendChild(shadowContainer);
1472+
1473+
const { unmount, user } = render(
1474+
<Tabs value={0}>
1475+
<Tab />
1476+
<Tab />
1477+
<Tab />
1478+
</Tabs>,
1479+
{ container: shadowContainer },
1480+
);
1481+
1482+
const tabs = shadowRoot.querySelectorAll('[role="tab"]');
1483+
const [firstTab, secondTab, thirdTab] = Array.from(tabs);
1484+
1485+
await act(async () => {
1486+
firstTab.focus();
1487+
});
1488+
1489+
// Verify first tab has focus
1490+
expect(shadowRoot.activeElement).to.equal(firstTab);
1491+
1492+
// Navigate to second tab using ArrowRight
1493+
await user.keyboard('{ArrowRight}');
1494+
expect(shadowRoot.activeElement).to.equal(secondTab);
1495+
1496+
// Navigate to third tab using ArrowRight
1497+
await user.keyboard('{ArrowRight}');
1498+
expect(shadowRoot.activeElement).to.equal(thirdTab);
1499+
1500+
// Navigate back to second tab using ArrowLeft
1501+
await user.keyboard('{ArrowLeft}');
1502+
expect(shadowRoot.activeElement).to.equal(secondTab);
1503+
1504+
// Cleanup
1505+
unmount();
1506+
if (shadowHost.parentNode) {
1507+
document.body.removeChild(shadowHost);
1508+
}
1509+
});
1510+
});
1511+
14581512
describe('dynamic tabs', () => {
14591513
const pause = (timeout) =>
14601514
new Promise((resolve) => {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import getActiveElement from '@mui/utils/getActiveElement';
2+
3+
export default getActiveElement;
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { expect } from 'chai';
2+
import { stub } from 'sinon';
3+
import getActiveElement from './getActiveElement';
4+
5+
describe('getActiveElement', () => {
6+
it('should return the active element from document', () => {
7+
const button = document.createElement('button');
8+
document.body.appendChild(button);
9+
button.focus();
10+
11+
const activeElement = getActiveElement(document);
12+
expect(activeElement).to.equal(button);
13+
14+
document.body.removeChild(button);
15+
});
16+
17+
it('should return null when no element has focus', () => {
18+
const activeElementStub = stub(document, 'activeElement').get(() => null);
19+
20+
const activeElement = getActiveElement(document);
21+
expect(activeElement).to.equal(null);
22+
23+
activeElementStub.restore();
24+
});
25+
26+
it('should traverse shadow roots to find the actual focused element', () => {
27+
// Create a shadow host
28+
const host = document.createElement('div');
29+
document.body.appendChild(host);
30+
const shadowRoot = host.attachShadow({ mode: 'open' });
31+
32+
// Create an element inside the shadow root
33+
const button = document.createElement('button');
34+
shadowRoot.appendChild(button);
35+
button.focus();
36+
37+
// document.activeElement should point to the host
38+
expect(document.activeElement).to.equal(host);
39+
40+
// getActiveElement should traverse into the shadow root
41+
const activeElement = getActiveElement(document);
42+
expect(activeElement).to.equal(button);
43+
44+
document.body.removeChild(host);
45+
});
46+
47+
it('should handle nested shadow roots', () => {
48+
// Create outer shadow host
49+
const outerHost = document.createElement('div');
50+
document.body.appendChild(outerHost);
51+
const outerShadowRoot = outerHost.attachShadow({ mode: 'open' });
52+
53+
// Create inner shadow host inside outer shadow root
54+
const innerHost = document.createElement('div');
55+
outerShadowRoot.appendChild(innerHost);
56+
const innerShadowRoot = innerHost.attachShadow({ mode: 'open' });
57+
58+
// Create a button inside the inner shadow root
59+
const button = document.createElement('button');
60+
innerShadowRoot.appendChild(button);
61+
button.focus();
62+
63+
// document.activeElement should point to the outer host
64+
expect(document.activeElement).to.equal(outerHost);
65+
66+
// getActiveElement should traverse through both shadow roots
67+
const activeElement = getActiveElement(document);
68+
expect(activeElement).to.equal(button);
69+
70+
document.body.removeChild(outerHost);
71+
});
72+
73+
it('should return the element inside shadow root when it has focus', () => {
74+
const host = document.createElement('div');
75+
document.body.appendChild(host);
76+
const shadowRoot = host.attachShadow({ mode: 'open' });
77+
78+
const button = document.createElement('button');
79+
shadowRoot.appendChild(button);
80+
button.focus();
81+
82+
const activeElement = getActiveElement(document);
83+
expect(activeElement).to.equal(button);
84+
85+
document.body.removeChild(host);
86+
});
87+
88+
it('should work when starting from a shadow root', () => {
89+
const host = document.createElement('div');
90+
document.body.appendChild(host);
91+
const shadowRoot = host.attachShadow({ mode: 'open' });
92+
93+
const button = document.createElement('button');
94+
shadowRoot.appendChild(button);
95+
button.focus();
96+
97+
// When called with the shadow root directly
98+
const activeElement = getActiveElement(shadowRoot);
99+
expect(activeElement).to.equal(button);
100+
101+
document.body.removeChild(host);
102+
});
103+
104+
it('should handle shadow root with null activeElement', () => {
105+
const host = document.createElement('div');
106+
document.body.appendChild(host);
107+
const shadowRoot = host.attachShadow({ mode: 'open' });
108+
109+
const activeElementStub = stub(shadowRoot, 'activeElement').get(() => null);
110+
111+
const activeElement = getActiveElement(shadowRoot);
112+
expect(activeElement).to.equal(null);
113+
114+
activeElementStub.restore();
115+
document.body.removeChild(host);
116+
});
117+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Gets the actual active element, traversing through shadow roots if necessary.
3+
*
4+
* When an element inside a shadow root has focus, `document.activeElement` returns
5+
* the shadow host element. This function recursively traverses shadow roots to find
6+
* the actual focused element.
7+
*
8+
* @param root - The document or shadow root to start the search from.
9+
* @returns The actual focused element, or null if no element has focus.
10+
*
11+
* @example
12+
* // In a shadow DOM context
13+
* const activeElement = getActiveElement(document);
14+
* // Returns the actual focused element inside the shadow root
15+
*
16+
* @example
17+
* // Starting from a specific document
18+
* const activeElement = getActiveElement(ownerDocument(element));
19+
*/
20+
export default function activeElement(doc: Document | ShadowRoot): Element | null {
21+
let element = doc.activeElement;
22+
23+
while (element?.shadowRoot?.activeElement != null) {
24+
element = element.shadowRoot.activeElement;
25+
}
26+
27+
return element;
28+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './getActiveElement';

packages/mui-utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { default as elementAcceptingRef } from './elementAcceptingRef';
55
export { default as elementTypeAcceptingRef } from './elementTypeAcceptingRef';
66
export { default as exactProp } from './exactProp';
77
export { default as formatMuiErrorMessage } from './formatMuiErrorMessage';
8+
export { default as unstable_getActiveElement } from './getActiveElement';
89
export { default as getDisplayName } from './getDisplayName';
910
export { default as HTMLElementType } from './HTMLElementType';
1011
export { default as ponyfillGlobal } from './ponyfillGlobal';

0 commit comments

Comments
 (0)