theKashey / react-focus-lock

It is a trap! A lock for a Focus. 🔓
MIT License
1.27k stars 67 forks source link

Cannot focus elements in ShadowRoot mode open #206

Closed gavinxgu closed 2 years ago

gavinxgu commented 2 years ago

Demo

https://codesandbox.io/s/vigorous-bird-bpy9s3?file=/src/App.tsx

It occurs when focus-lock 0.10.2 adds shadowRoot support for getActiveElement , but it uses node.contains to determine containment relationship.

https://github.com/theKashey/focus-lock/blob/1fe68ec4fb489f5b78be5790aa07f5e0d5bf00b5/src/focusInside.ts#L18

For example, we have a cuz element my-input like this.

class MyIput extends HTMLElement {
  constructor() {
    super();
    const shadow = this.attachShadow({ mode: "open" });
    const input = document.createElement("input");
    shadow.appendChild(input);
  }
}
customElements.define("my-input", MyIput);

Suppose we have a React component like this.

<FocusLock>
  <div>
    <my-input />
  </div>
</FocusLock>

When we click input to focus, focusInside function returns false, then we lost the focus state.

export const focusInside = (topNode: HTMLElement | HTMLElement[]): boolean => {
  const activeElement = document && getActiveElement();

  if (!activeElement || (activeElement.dataset && activeElement.dataset.focusGuard)) {
    return false;
  }

  return getAllAffectedNodes(topNode).reduce(
    (result, node) => result || node.contains(activeElement) || focusInsideIframe(node),
    false as boolean
  );
};

node value is the div element and activeElement is the input element inside shadowRoot,but div.contains(innerInput) is false.

P.S. react-remove-scroll also has this problem.

theKashey commented 2 years ago

Thanks for the report, looking into it...

pavelsherm commented 2 years ago

I was able to fix following issue with following function:

import { getAllAffectedNodes } from './utils/all-affected';
import { toArray } from './utils/array';
import { getActiveElement } from './utils/getActiveElement';

const focusInFrame = (frame: HTMLIFrameElement) => frame === document.activeElement;

const focusInsideIframe = (topNode: Element) =>
  Boolean(toArray(topNode.querySelectorAll<HTMLIFrameElement>('iframe')).some((node) => focusInFrame(node)));

const focusInsideShadowDom = (activeElement: HTMLElement, node: Element): boolean => {
  let currentElement = activeElement;

  while (currentElement && currentElement.parentNode) {
    if (currentElement.parentNode === node) {
      return true;
    } else if (currentElement.parentNode instanceof ShadowRoot) {
      currentElement = currentElement.parentNode.host as HTMLElement;
    } else {
      currentElement = currentElement.parentNode as HTMLElement;
    }
  }

  return false;
};

export const focusInside = (topNode: HTMLElement | HTMLElement[]): boolean => {
  const activeElement = document && getActiveElement();

  if (!activeElement || (activeElement.dataset && activeElement.dataset.focusGuard)) {
    return false;
  }

  return getAllAffectedNodes(topNode).reduce(
    (result, node) =>
      result || node.contains(activeElement) || focusInsideIframe(node) || focusInsideShadowDom(activeElement, node),
    false as boolean
  );
};
theKashey commented 2 years ago

react-focus-lock@2.9.0 has been released with the all required patches.