Repository: mui/material-ui (TypeScript)
Date: 2018-11-01 13:57:58+00:00
Task type: Bug Fix

Issue:
[ButtonBase] Shadow Root activeElement resolution
When using custom elements with a shadowRoot, the `focusVisibleClassName` is applied to the outer-most custom element, instead of the actual element that is focused within the shadow DOM.

## Expected Behavior
`focusVisibleClassName` should be added to deepest-level `activeElement` when a shadowRoot is present.

## Current Behavior
`focusVisibleClassName` is applied to the outer-most custom element that is returned from `document.activeElement`.

## Possible Solution
Traverse `activeElement` targets that have a shadowRoot to find the lowest level active element for a given component; or just return `document.activeElement` if no shadowRoot is present.

Following solution appears to work as expected:

```patch
diff --git a/packages/material-ui/src/ButtonBase/focusVisible.js b/packages/material-ui/src/ButtonBase/focusVisible.js
index d10c1a00d..462376f72 100644
--- a/packages/material-ui/src/ButtonBase/focusVisible.js
+++ b/packages/material-ui/src/ButtonBase/focusVisible.js
@@ -16,10 +16,11 @@ export function detectFocusVisible(instance, element, callback, attempt = 1) {
 
   instance.focusVisibleTimeout = setTimeout(() => {
     const doc = ownerDocument(element);
+    const activeElement = findActiveElement(doc);
 
     if (
       internal.focusKeyPressed &&
-      (doc.activeElement === element || element.contains(doc.activeElement))
+      (activeElement === element || element.contains(activeElement))
     ) {
       callback();
     } else if (attempt < instance.focusVisibleMaxCheckTimes) {
@@ -28,6 +29,18 @@ export function detectFocusVisible(instance, element, callback, attempt = 1) {
   }, instance.focusVisibleCheckTime);
 }
 
+function findActiveElement(doc) {
+  let activeElement = doc.activeElement;
+  while (
+    activeElement &&
+    activeElement.shadowRoot &&
+    activeElement.shadowRoot.activeElement
+  ) {
+    activeElement = activeElement.shadowRoot.activeElement;
+  }
+  return activeElement;
+}
+
 const FOCUS_KEYS = ['tab', 'enter', 'space', 'esc', 'up', 'down', 'left', 'right'];
 
 function isFocusKey(event) {
```


Issue discussion:
Does `:focus-visible` also apply to elements within a shadow root?
> Does `:focus-visible` also apply to elements within a shadow root?

This is potentially handled by `delegatesFocus`? http://w3c.github.io/webcomponents/spec/shadow/#widl-ShadowRootInit-delegatesFocus

Although, a similar issue to this one was raised on the WICG/focus-visible polyfill @ https://github.com/WICG/focus-visible/issues/28
Do you have a reproduction example? What the use case? 
Sure. https://codesandbox.io/s/8lxr89x2p2
You can see that tabbing only applies focus to the elements not inside a shadow root.
With the above changes to the `activeElement` code, it works as expected.
@jaipe Well done with the JSS setup for shadow DOM. In the documentation, I wish we had a demo with an iframe, a popup, and a shadow dom. 

The fix looks good enough to me. There is a bundle size overhead concern but the fact it's usable within a shadow DOM is a convincing argument. @eps1lon What do you think?
I wasn't very convinced at first but native buttons do gain a visible focus state within a shadow root so I'd say we improve the polyfill.
Can I take this one??
Actually already got something working locally. I can just tidy it up and add some tests.

Solution patch:
diff --git a/.size-limit.js b/.size-limit.js
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -22,7 +22,7 @@ module.exports = [
     name: 'The size of all the material-ui modules.',
     webpack: true,
     path: 'packages/material-ui/build/index.js',
-    limit: '94.0 KB',
+    limit: '94.1 KB',
   },
   {
     name: 'The main docs bundle',
diff --git a/packages/material-ui/src/ButtonBase/focusVisible.js b/packages/material-ui/src/ButtonBase/focusVisible.js
--- a/packages/material-ui/src/ButtonBase/focusVisible.js
+++ b/packages/material-ui/src/ButtonBase/focusVisible.js
@@ -7,6 +7,14 @@ const internal = {
   keyUpEventTimeout: -1,
 };
 
+function findActiveElement(doc) {
+  let activeElement = doc.activeElement;
+  while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) {
+    activeElement = activeElement.shadowRoot.activeElement;
+  }
+  return activeElement;
+}
+
 export function detectFocusVisible(instance, element, callback, attempt = 1) {
   warning(instance.focusVisibleCheckTime, 'Material-UI: missing instance.focusVisibleCheckTime.');
   warning(
@@ -16,10 +24,11 @@ export function detectFocusVisible(instance, element, callback, attempt = 1) {
 
   instance.focusVisibleTimeout = setTimeout(() => {
     const doc = ownerDocument(element);
+    const activeElement = findActiveElement(doc);
 
     if (
       internal.focusKeyPressed &&
-      (doc.activeElement === element || element.contains(doc.activeElement))
+      (activeElement === element || element.contains(activeElement))
     ) {
       callback();
     } else if (attempt < instance.focusVisibleMaxCheckTimes) {


Test patch:
diff --git a/packages/material-ui/src/ButtonBase/ButtonBase.test.js b/packages/material-ui/src/ButtonBase/ButtonBase.test.js
--- a/packages/material-ui/src/ButtonBase/ButtonBase.test.js
+++ b/packages/material-ui/src/ButtonBase/ButtonBase.test.js
@@ -263,6 +263,65 @@ describe('<ButtonBase />', () => {
     });
   });
 
+  describe('focus inside shadowRoot', () => {
+    // Only run on HeadlessChrome which has native shadowRoot support.
+    // And jsdom which has limited support for shadowRoot (^12.0.0).
+    if (!/HeadlessChrome|jsdom/.test(window.navigator.userAgent)) {
+      return;
+    }
+
+    let wrapper;
+    let instance;
+    let button;
+    let clock;
+    let rootElement;
+
+    beforeEach(() => {
+      clock = useFakeTimers();
+      rootElement = document.createElement('div');
+      rootElement.tabIndex = 0;
+      document.body.appendChild(rootElement);
+      rootElement.attachShadow({ mode: 'open' });
+      wrapper = mount(
+        <ButtonBaseNaked theme={{}} classes={{}} id="test-button">
+          Hello
+        </ButtonBaseNaked>,
+        { attachTo: rootElement.shadowRoot },
+      );
+      instance = wrapper.instance();
+      button = rootElement.shadowRoot.getElementById('test-button');
+      if (!button) {
+        throw new Error('missing button');
+      }
+
+      button.focus();
+
+      if (document.activeElement !== rootElement) {
+        // Mock activeElement value and simulate host-retargeting in shadow root for
+        // jsdom@12.0.0 (https://github.com/jsdom/jsdom/issues/2343)
+        rootElement.focus();
+        rootElement.shadowRoot.activeElement = button;
+        wrapper.simulate('focus');
+      }
+
+      const event = new window.Event('keyup');
+      event.which = keycode('tab');
+      window.dispatchEvent(event);
+    });
+
+    afterEach(() => {
+      clock.restore();
+      ReactDOM.unmountComponentAtNode(rootElement.shadowRoot);
+      document.body.removeChild(rootElement);
+    });
+
+    it('should set focus state for shadowRoot children', () => {
+      assert.strictEqual(wrapper.state().focusVisible, false);
+      clock.tick(instance.focusVisibleCheckTime * instance.focusVisibleMaxCheckTimes);
+      assert.strictEqual(wrapper.state().focusVisible, true);
+    });
+  });
+
   describe('mounted tab press listener', () => {
     let wrapper;
     let instance;