Skip to content

Instantly share code, notes, and snippets.

@samthor
Last active May 18, 2020 18:55
Show Gist options
  • Save samthor/7b307408e73784971ef0fcf4a8af6edd to your computer and use it in GitHub Desktop.
Save samthor/7b307408e73784971ef0fcf4a8af6edd to your computer and use it in GitHub Desktop.
Listener to provide up-to-date Shadow DOM focus events
/**
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @fileoverview Listener to provide Shadow DOM focus events, even inside shadow roots
*
* Normally, listening for focus with Shadow DOM will just return the top-most 'host' with a shadow
* root. That may not be a problem! But if you want to find the 'real' focused element...
*
* To use this library, add this after the script is included-
* document.addEventListener('focus', shadowFocusHandler, true);
*
* You can then listen for `-shadow-focus` events, which will return the furthest focused element-
* document.addEventListener('-shadow-focus', function(ev) {
* console.info('got focused element', ev.detail, '..inside outer-most shadow root', ev.target);
* });
*/
const shadowFocusHandler = (function() {
const eventName = '-shadow-focus';
const dispatch = function(target) {
const args = {composed: true, bubbles: true, detail: target};
const customEvent = new CustomEvent(eventName, args);
target.dispatchEvent(customEvent);
};
if (!window.WeakSet || !window.ShadowRoot) {
return event => dispatch(event.target);
}
const focusHandlerSet = new WeakSet();
/**
* @param {Node} target to work on
* @param {function(!FocusEvent)} callback to invoke on focus change
*/
function _internal(target, callback) {
let currentFocus = target; // save real focus
// #1: get the nearest ShadowRoot
while (!(target instanceof ShadowRoot)) {
if (!target) { return; }
target = target.parentNode;
}
// #2: are we already handling it?
if (focusHandlerSet.has(target)) { return; }
focusHandlerSet.add(target);
// #3: setup focus/blur handlers
const hostEl = target.host;
const focusinHandler = function(ev) {
if (ev.target !== currentFocus) { // prevent dup calls for same focus
currentFocus = ev.target;
callback(ev);
}
};
const blurHandler = function(ev) {
hostEl.removeEventListener('blur', blurHandler, false);
target.removeEventListener('focusin', focusinHandler, true);
focusHandlerSet.delete(target);
};
// #3: add blur handler to host element
hostEl.addEventListener('blur', blurHandler, false);
// #4: add focus handler within shadow root, to observe changes
target.addEventListener('focusin', focusinHandler, true);
// #5: find next parent SR, do it again
_internal(target.host, callback);
}
/**
* @param {!FocusEvent} event to process
*/
function shadowFocusHandler(event) {
const target = (event.composedPath ? event.composedPath()[0] : null) || event.target;
_internal(target, shadowFocusHandler);
dispatch(target);
}
return shadowFocusHandler;
}());
@samthor
Copy link
Author

samthor commented Feb 21, 2017

Example of usage-

document.addEventListener('focus', shadowFocusHandler, true);
document.addEventListener('-shadow-focus', function(ev) {
  console.info('got focused element', ev.detail, 'outer-most element with shadow root', ev.target);
  // nb. ev.detail will stop at closed roots - don't use them!
});

@rodneyrehm
Copy link

Nice approach! You've managed to create the smallest stand-alone solution to the problem I've seen so far.

See ally.event.shadowFocus and FocusObserver for alternative APIs. The former is used by ally.style.focusWithin to polyfill/shim CSS Selectors Level 4 :focus-within :)

@dfreedm
Copy link

dfreedm commented Jan 31, 2018

in #1, you can just iterate the composedPath instead of using the DOM accessors.

let target = composedPath[0];
for (let i = composedPath.length - 2; i >= 0; i--) {
  if (composedPath[i] instanceof ShadowRoot) {
    target = composedPath[i];
    break;
  }
}

@robdodson
Copy link

Was chatting with @azakus about this. There's also the .getRootNode() method which will find the nearest parent ShadowRoot or Document.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment