UNPKG

@ou-imdt/utils

Version:

Utility library for interactive media development

199 lines (169 loc) 6.84 kB
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'); // } // } }