Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/mui-material/src/Tabs/Tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import useEventCallback from '../utils/useEventCallback';
import tabsClasses, { getTabsUtilityClass } from './tabsClasses';
import ownerDocument from '../utils/ownerDocument';
import ownerWindow from '../utils/ownerWindow';
import getActiveElement from '../utils/getActiveElement';
import useSlot from '../utils/useSlot';

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

const list = tabListRef.current;
const currentFocus = ownerDocument(list).activeElement;
const currentFocus = getActiveElement(ownerDocument(list));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by adding a getActiveElement utility that recursively traverses shadow roots to find the actual focused
element. The issue was that document.activeElement returns the shadow host instead of the focused element inside
shadow DOM, causing keyboard navigation to fail the role="tab" check.

// Keyboard navigation assumes that [role="tab"] are siblings
// though we might warn in the future about nested, interactive elements
// as a a11y violation
const role = currentFocus.getAttribute('role');
const role = currentFocus?.getAttribute('role');
if (role !== 'tab') {
return;
}
Expand Down
54 changes: 54 additions & 0 deletions packages/mui-material/src/Tabs/Tabs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,60 @@ describe('<Tabs />', () => {
});
});

describe('keyboard navigation in shadow DOM', () => {
it('should navigate between tabs using arrow keys when rendered in shadow DOM', async function test() {
if (isJSDOM) {
this.skip();
}

// Create a shadow root
const shadowHost = document.createElement('div');
document.body.appendChild(shadowHost);
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });

// Render directly into shadow root
const shadowContainer = document.createElement('div');
shadowRoot.appendChild(shadowContainer);

const { unmount, user } = render(
<Tabs value={0}>
<Tab />
<Tab />
<Tab />
</Tabs>,
{ container: shadowContainer },
);

const tabs = shadowRoot.querySelectorAll('[role="tab"]');
const [firstTab, secondTab, thirdTab] = Array.from(tabs);

await act(async () => {
firstTab.focus();
});

// Verify first tab has focus
expect(shadowRoot.activeElement).to.equal(firstTab);

// Navigate to second tab using ArrowRight
await user.keyboard('{ArrowRight}');
expect(shadowRoot.activeElement).to.equal(secondTab);

// Navigate to third tab using ArrowRight
await user.keyboard('{ArrowRight}');
expect(shadowRoot.activeElement).to.equal(thirdTab);

// Navigate back to second tab using ArrowLeft
await user.keyboard('{ArrowLeft}');
expect(shadowRoot.activeElement).to.equal(secondTab);

// Cleanup
unmount();
if (shadowHost.parentNode) {
document.body.removeChild(shadowHost);
}
});
});

describe('dynamic tabs', () => {
const pause = (timeout) =>
new Promise((resolve) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/mui-material/src/utils/getActiveElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import getActiveElement from '@mui/utils/getActiveElement';

export default getActiveElement;
117 changes: 117 additions & 0 deletions packages/mui-utils/src/getActiveElement/getActiveElement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { expect } from 'chai';
import { stub } from 'sinon';
import getActiveElement from './getActiveElement';

describe('getActiveElement', () => {
it('should return the active element from document', () => {
const button = document.createElement('button');
document.body.appendChild(button);
button.focus();

const activeElement = getActiveElement(document);
expect(activeElement).to.equal(button);

document.body.removeChild(button);
});

it('should return null when no element has focus', () => {
const activeElementStub = stub(document, 'activeElement').get(() => null);

const activeElement = getActiveElement(document);
expect(activeElement).to.equal(null);

activeElementStub.restore();
});

it('should traverse shadow roots to find the actual focused element', () => {
// Create a shadow host
const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });

// Create an element inside the shadow root
const button = document.createElement('button');
shadowRoot.appendChild(button);
button.focus();

// document.activeElement should point to the host
expect(document.activeElement).to.equal(host);

// getActiveElement should traverse into the shadow root
const activeElement = getActiveElement(document);
expect(activeElement).to.equal(button);

document.body.removeChild(host);
});

it('should handle nested shadow roots', () => {
// Create outer shadow host
const outerHost = document.createElement('div');
document.body.appendChild(outerHost);
const outerShadowRoot = outerHost.attachShadow({ mode: 'open' });

// Create inner shadow host inside outer shadow root
const innerHost = document.createElement('div');
outerShadowRoot.appendChild(innerHost);
const innerShadowRoot = innerHost.attachShadow({ mode: 'open' });

// Create a button inside the inner shadow root
const button = document.createElement('button');
innerShadowRoot.appendChild(button);
button.focus();

// document.activeElement should point to the outer host
expect(document.activeElement).to.equal(outerHost);

// getActiveElement should traverse through both shadow roots
const activeElement = getActiveElement(document);
expect(activeElement).to.equal(button);

document.body.removeChild(outerHost);
});

it('should return the element inside shadow root when it has focus', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });

const button = document.createElement('button');
shadowRoot.appendChild(button);
button.focus();

const activeElement = getActiveElement(document);
expect(activeElement).to.equal(button);

document.body.removeChild(host);
});

it('should work when starting from a shadow root', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });

const button = document.createElement('button');
shadowRoot.appendChild(button);
button.focus();

// When called with the shadow root directly
const activeElement = getActiveElement(shadowRoot);
expect(activeElement).to.equal(button);

document.body.removeChild(host);
});

it('should handle shadow root with null activeElement', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });

const activeElementStub = stub(shadowRoot, 'activeElement').get(() => null);

const activeElement = getActiveElement(shadowRoot);
expect(activeElement).to.equal(null);

activeElementStub.restore();
document.body.removeChild(host);
});
});
33 changes: 33 additions & 0 deletions packages/mui-utils/src/getActiveElement/getActiveElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Gets the actual active element, traversing through shadow roots if necessary.
*
* When an element inside a shadow root has focus, `document.activeElement` returns
* the shadow host element. This function recursively traverses shadow roots to find
* the actual focused element.
*
* @param root - The document or shadow root to start from. Defaults to document.
* @returns The actual focused element, or null if no element has focus.
*
* @example
* // In a shadow DOM context
* const activeElement = getActiveElement(document);
* // Returns the actual focused element inside the shadow root
*
* @example
* // Starting from a specific document
* const activeElement = getActiveElement(ownerDocument(element));
*/
export default function getActiveElement(root: Document | ShadowRoot = document): Element | null {
const activeEl = root.activeElement;

if (!activeEl) {
return null;
}

// If the active element has a shadow root, recursively check inside it
if (activeEl.shadowRoot && activeEl.shadowRoot.activeElement) {
return getActiveElement(activeEl.shadowRoot);
}

return activeEl;
}
1 change: 1 addition & 0 deletions packages/mui-utils/src/getActiveElement/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './getActiveElement';
1 change: 1 addition & 0 deletions packages/mui-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { default as elementAcceptingRef } from './elementAcceptingRef';
export { default as elementTypeAcceptingRef } from './elementTypeAcceptingRef';
export { default as exactProp } from './exactProp';
export { default as formatMuiErrorMessage } from './formatMuiErrorMessage';
export { default as unstable_getActiveElement } from './getActiveElement';
export { default as getDisplayName } from './getDisplayName';
export { default as HTMLElementType } from './HTMLElementType';
export { default as ponyfillGlobal } from './ponyfillGlobal';
Expand Down
Loading