@ou-imdt/utils
Version:
Utility library for interactive media development
199 lines (169 loc) • 6.84 kB
JavaScript
import composedAncestors from '../../composedAncestors.js';
import closestFocusableDescendant from '../../closestFocusableDescendant.js';
import firstFocusableNodeFrom from '../../firstFocusableNodeFrom.js';
import hasTextSelection from '../../hasTextSelection.js';
import { defaultState } from '../Base.js';
// defines a prop that references the element focus is delegated to, i.e. the first focusable descendant
export const focusDelegate = Symbol('focusDelegate');
// defines a boolean prop indicating focus state
export const focus = Symbol('focus');
// defines a boolean prop indicating focus-visible state
export const focusVisible = Symbol('focusVisible');
// replaced with private fields... TODO remove when tested
// symbol refs for methods to avoid naming collisions/accidental override down the line
// const handleMousedown = Symbol('handleMousedown');
// const handleMouseup = Symbol('handleMouseup');
// const handleFocus = Symbol('handleFocus');
// const handleBlur = Symbol('handleBlur');
// const removeTempEventListeners = Symbol('handleDragStart');
// const blur = Symbol('blur');
/**
* shim to manage focus within web components
* shadowRoot.delegatesFocus should be used when the below are resolved
*
* chrome
* - appears to preventDefault() when delegating focus preventing drag events, text selection etc.
* see https://issues.chromium.org/issues/40622041
*
* firefox
* - pointer events don't delegate focus where shadowRoot.delegatesFocus
* - clears text selection on programmatic focus (but not when user initiated)
*/
export default (superClass) => class DelegateFocusMixin extends superClass {
static get [defaultState]() {
return {
...super[defaultState],
// delegate has focus
[focus]: false,
// delegate matches :focus-visible
[focusVisible]: null // unset so as to check if set both states (true/false)
}
}
#handleMouseup = null;
constructor() {
super();
}
get [focusDelegate]() {
return closestFocusableDescendant(this, focusDelegate);
}
connectedCallback() {
super.connectedCallback();
if (this.shadowRoot?.delegatesFocus) {
console.warn('DelegateFocusMixin not intended to be used with ShadowRoot.delegatesFocus, remove mixin or set delegatesFocus to false.');
}
this.addEventListener('mousedown', this.#handleMousedown, true);
this.addEventListener('touchstart', this.#handleMousedown, true);
this.addEventListener('focus', this.#handleFocus, true);
this.addEventListener('blur', this.#handleBlur, true);
}
/**
* sets focus on focusDelegate if defined and implements .focus()
* programmatic focus will always match :focus-visible so use (experimental) options.focusVisible
* and set prop internally as polyfill until supported/there is no longer a need for this mixin
* @param {object} [optionList]
* @param {boolean} [optionList.focusVisible] - experimental (native) feature, see HTMLElement.focus()
*/
focus(options) {
// console.log('focus', this[focusDelegate], options);
if (typeof this[focusDelegate]?.focus === 'function') {
this[focusDelegate].focus(options);
} else {
super.focus(options);
}
if (typeof options?.focusVisible === 'boolean') {
this[focusVisible] = options.focusVisible;
}
}
#blur() {
this[focus] = false;
this[focusVisible] = null;
}
async #handleMousedown(e) {
// console.log('handle mousedown', e.target);
const selfAndComposedAncestors = [e.target, ...composedAncestors(e.target)];
const firstFocusableNode = firstFocusableNodeFrom(selfAndComposedAncestors, focusDelegate);
// the main (usually left) button
if (e.button !== 0) return;
// ensure closest focusable is this (possibly via delegate)
if (firstFocusableNode !== this) return;
// temporary function to handle mouseup asd well as flag click in progress for blur
this.#handleMouseup = async () => {
// console.log('handle mouseup', this);
this.#removeTempEventListeners();
// await selectionchange with new macro task in case selection has been clicked/dismissed
// await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => requestAnimationFrame(resolve));
if (hasTextSelection(document.body)) {
// blur to avoid clearing the new text selection (firefox)
if (this[focus]) this.#blur();
} else {
// delegate focus
this.focus({ focusVisible: false });
}
}
// clear current selection (seems native mousedown behaviour for non-focusables anyway)
window.getSelection().empty();
// wait for mouseup to avoid interfering with drag type interactions
window.addEventListener('mouseup', this.#handleMouseup, true);
// listen for dragstart as well as mouseup will be surpressed on drag
window.addEventListener('dragstart', this.#removeTempEventListeners, true);
}
/**
* removes temporary listeners and deletes handleMousup ref
* MUST be arrow function to retain lexical scope of this
* (binding for listener basically creates a closure resulting in unreferenced function)
*/
#removeTempEventListeners = () => {
// console.log('removing temp event listeners.............', this);
window.removeEventListener('mouseup', this.#handleMouseup, true);
window.removeEventListener('dragstart', this.#removeTempEventListeners, true);
this.#handleMouseup = null;
}
/**
* sets focus props (focus/focus within)
* @param {Event} e
*/
#handleFocus(e) {
// console.log('handle focus', e.target);
if (e.target === this[focusDelegate]) {
this[focus] = true;
// focus visible if still null and matches pseudo selector
this[focusVisible] ??= this[focusDelegate].matches(':scope:focus-visible');
} else {
if (this[focus]) this.#blur();
}
}
/**
* calls blur to reseet focus props
*/
#handleBlur(e) {
// console.log('handle blur', e.target);
// ignore while awaiting mouseup
if (typeof this.#handleMouseup === 'function') return;
if (this[focus]) this.#blur();
}
/**
* e.relatedTarget = previously focused
* @param {FocusEvent} e
*/
// _handleFocusin(e) {
// console.log('handle focusin');
// if (e.currentTarget.contains(e.relatedTarget)) {
// console.log('focus already within');
// } else {
// console.log('focus received from outside');
// }
// }
/**
* e.relatedTarget = next to receive focus
* @param {FocusEvent} e
*/
// _handleFocusout(e) {
// console.log('handle focusout');
// if (e.currentTarget.contains(e.relatedTarget)) {
// console.log('focus still within');
// } else {
// console.log('focus leaving to outside');
// }
// }
}