diff --git a/.changeset/short-llamas-share.md b/.changeset/short-llamas-share.md new file mode 100644 index 000000000..712e7d344 --- /dev/null +++ b/.changeset/short-llamas-share.md @@ -0,0 +1,5 @@ +--- +'@lion/form-core': patch +--- + +support [focused-visible] when focusable node within matches :focus-visible diff --git a/packages/form-core/src/FocusMixin.js b/packages/form-core/src/FocusMixin.js index 39c4240fe..52583d082 100644 --- a/packages/form-core/src/FocusMixin.js +++ b/packages/form-core/src/FocusMixin.js @@ -1,25 +1,50 @@ import { dedupeMixin } from '@lion/core'; -import { FormControlMixin } from './FormControlMixin.js'; + +const windowWithOptionalPolyfill = /** @type {Window & typeof globalThis & {applyFocusVisiblePolyfill?: function}} */ (window); +const polyfilledNodes = new WeakMap(); + +/** + * @param {Node} node + */ +function applyFocusVisiblePolyfillWhenNeeded(node) { + if (windowWithOptionalPolyfill.applyFocusVisiblePolyfill && !polyfilledNodes.has(node)) { + windowWithOptionalPolyfill.applyFocusVisiblePolyfill(node); + polyfilledNodes.set(node, undefined); + } +} + /** * @typedef {import('../types/FocusMixinTypes').FocusMixin} FocusMixin * @type {FocusMixin} * @param {import('@open-wc/dedupe-mixin').Constructor} superclass */ const FocusMixinImplementation = superclass => - class FocusMixin extends FormControlMixin(superclass) { + class FocusMixin extends superclass { /** @type {any} */ static get properties() { return { - focused: { - type: Boolean, - reflect: true, - }, + focused: { type: Boolean, reflect: true }, + focusedVisible: { type: Boolean, reflect: true, attribute: 'focused-visible' }, }; } constructor() { super(); + + /** + * Whether the focusable element within (`._focusableNode`) is focused. + * Reflects to attribute '[focused]' as a styling hook + * @type {boolean} + */ this.focused = false; + + /** + * Whether the focusable element within (`._focusableNode`) matches ':focus-visible' + * Reflects to attribute '[focused-visible]' as a styling hook + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible + * @type {boolean} + */ + this.focusedVisible = false; } connectedCallback() { @@ -32,18 +57,32 @@ const FocusMixinImplementation = superclass => this.__teardownEventsForFocusMixin(); } + /** + * Calls `focus()` on focusable element within + */ focus() { - const native = this._inputNode; - if (native) { - native.focus(); - } + this._focusableNode?.focus(); } + /** + * Calls `blur()` on focusable element within + */ blur() { - const native = this._inputNode; - if (native) { - native.blur(); - } + this._focusableNode?.blur(); + } + + /** + * The focusable element: + * could be an input, textarea, select, button or any other element with tabindex > -1 + * @protected + * @type {HTMLElement} + */ + // @ts-ignore it's up to Subclassers to return the right element. This is needed for docs/types + // eslint-disable-next-line class-methods-use-this, getter-return, no-empty-function + get _focusableNode() { + // TODO: [v1]: remove return of _inputNode (it's now here for backwards compatibility) + // @ts-expect-error see above + return /** @type {HTMLElement} */ (this._inputNode || document.createElement('input')); } /** @@ -51,6 +90,16 @@ const FocusMixinImplementation = superclass => */ __onFocus() { this.focused = true; + + if (typeof windowWithOptionalPolyfill.applyFocusVisiblePolyfill === 'function') { + this.focusedVisible = this._focusableNode.hasAttribute('data-focus-visible-added'); + } else + try { + // Safari throws when matches is called + this.focusedVisible = this._focusableNode.matches(':focus-visible'); + } catch (_) { + this.focusedVisible = false; + } } /** @@ -58,12 +107,15 @@ const FocusMixinImplementation = superclass => */ __onBlur() { this.focused = false; + this.focusedVisible = false; } /** * @private */ __registerEventsForFocusMixin() { + applyFocusVisiblePolyfillWhenNeeded(this.getRootNode()); + /** * focus * @param {Event} ev @@ -72,7 +124,7 @@ const FocusMixinImplementation = superclass => ev.stopPropagation(); this.dispatchEvent(new Event('focus')); }; - this._inputNode.addEventListener('focus', this.__redispatchFocus); + this._focusableNode.addEventListener('focus', this.__redispatchFocus); /** * blur @@ -82,7 +134,7 @@ const FocusMixinImplementation = superclass => ev.stopPropagation(); this.dispatchEvent(new Event('blur')); }; - this._inputNode.addEventListener('blur', this.__redispatchBlur); + this._focusableNode.addEventListener('blur', this.__redispatchBlur); /** * focusin @@ -93,7 +145,7 @@ const FocusMixinImplementation = superclass => this.__onFocus(); this.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); }; - this._inputNode.addEventListener('focusin', this.__redispatchFocusin); + this._focusableNode.addEventListener('focusin', this.__redispatchFocusin); /** * focusout @@ -104,30 +156,35 @@ const FocusMixinImplementation = superclass => this.__onBlur(); this.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); }; - this._inputNode.addEventListener('focusout', this.__redispatchFocusout); + this._focusableNode.addEventListener('focusout', this.__redispatchFocusout); } /** * @private */ __teardownEventsForFocusMixin() { - this._inputNode.removeEventListener( + this._focusableNode.removeEventListener( 'focus', /** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocus), ); - this._inputNode.removeEventListener( + this._focusableNode.removeEventListener( 'blur', /** @type {EventListenerOrEventListenerObject} */ (this.__redispatchBlur), ); - this._inputNode.removeEventListener( + this._focusableNode.removeEventListener( 'focusin', /** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocusin), ); - this._inputNode.removeEventListener( + this._focusableNode.removeEventListener( 'focusout', /** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocusout), ); } }; +/** + * For browsers that not support the [spec](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible), + * be sure to load the polyfill into your application https://github.com/WICG/focus-visible + * (or go for progressive enhancement). + */ export const FocusMixin = dedupeMixin(FocusMixinImplementation); diff --git a/packages/form-core/src/LionField.js b/packages/form-core/src/LionField.js index 7bbe212fc..5c8f25584 100644 --- a/packages/form-core/src/LionField.js +++ b/packages/form-core/src/LionField.js @@ -106,4 +106,11 @@ export class LionField extends FormControlMixin( get _feedbackConditionMeta() { return { ...super._feedbackConditionMeta, focused: this.focused }; } + + /** + * @configure FocusMixin + */ + get _focusableNode() { + return this._inputNode; + } } diff --git a/packages/form-core/src/NativeTextFieldMixin.js b/packages/form-core/src/NativeTextFieldMixin.js index 47bd38b48..877bc45f0 100644 --- a/packages/form-core/src/NativeTextFieldMixin.js +++ b/packages/form-core/src/NativeTextFieldMixin.js @@ -110,6 +110,13 @@ const NativeTextFieldMixinImplementation = superclass => } catch (_) {} } } + + /** + * @configure FocusMixin + */ + get _focusableNode() { + return this._inputNode; + } }; export const NativeTextFieldMixin = dedupeMixin(NativeTextFieldMixinImplementation); diff --git a/packages/form-core/test/FocusMixin.test.js b/packages/form-core/test/FocusMixin.test.js index 4f2b2f9f5..7cf73626b 100644 --- a/packages/form-core/test/FocusMixin.test.js +++ b/packages/form-core/test/FocusMixin.test.js @@ -1,13 +1,73 @@ import { LitElement } from '@lion/core'; import { defineCE, expect, fixture, html, oneEvent, unsafeStatic } from '@open-wc/testing'; -import { getFormControlMembers } from '@lion/form-core/test-helpers'; +import sinon from 'sinon'; import { FocusMixin } from '../src/FocusMixin.js'; +const windowWithOptionalPolyfill = /** @type {Window & typeof globalThis & {applyFocusVisiblePolyfill?: function}} */ (window); + +/** + * Checks two things: + * 1. whether focus-visible should apply (if focus and keyboard interaction present) + * 2. whether the polyfill is used or not + * When the polyfill is used, it mocks `.hasAttribute` method, otherwise `.matches` method + * of focusable element. + * @param {HTMLElement} focusableEl focusable element + * @param {{phase: 'focusin'|'focusout', hasKeyboardInteraction: boolean }} options + * @returns {function} restore function + */ +function mockFocusVisible(focusableEl, { phase, hasKeyboardInteraction }) { + const focusVisibleApplies = phase === 'focusin' && hasKeyboardInteraction; + if (!focusVisibleApplies) { + return () => {}; + } + + /** @type {any} */ + const originalMatches = focusableEl.matches; + if (typeof windowWithOptionalPolyfill.applyFocusVisiblePolyfill !== 'function') { + // eslint-disable-next-line no-param-reassign + focusableEl.matches = selector => + selector === ':focus-visible' || originalMatches.call(focusableEl, selector); + return () => { + // eslint-disable-next-line no-param-reassign + focusableEl.matches = originalMatches; + }; + } + + const originalHasAttribute = focusableEl.hasAttribute; + // eslint-disable-next-line no-param-reassign + focusableEl.hasAttribute = attr => + attr === 'data-focus-visible-added' || originalHasAttribute.call(focusableEl, attr); + return () => { + // eslint-disable-next-line no-param-reassign + focusableEl.hasAttribute = originalHasAttribute; + }; +} + +/** + * @returns {function} restore function + */ +function mockPolyfill() { + const originalApplyFocusVisiblePolyfill = windowWithOptionalPolyfill.applyFocusVisiblePolyfill; + // @ts-ignore + window.applyFocusVisiblePolyfill = () => {}; + return () => { + // @ts-ignore + window.applyFocusVisiblePolyfill = originalApplyFocusVisiblePolyfill; + }; +} + describe('FocusMixin', () => { class Focusable extends FocusMixin(LitElement) { render() { return html``; } + + /** + * @configure FocusMixin + */ + get _focusableNode() { + return /** @type {HTMLInputElement} */ (this.querySelector('input')); + } } const tagString = defineCE(Focusable); @@ -17,12 +77,13 @@ describe('FocusMixin', () => { const el = /** @type {Focusable} */ (await fixture(html` <${tag}> `)); - const { _inputNode } = getFormControlMembers(el); + // @ts-ignore [allow-protected] in test + const { _focusableNode } = el; el.focus(); - expect(document.activeElement === _inputNode).to.be.true; + expect(document.activeElement === _focusableNode).to.be.true; el.blur(); - expect(document.activeElement === _inputNode).to.be.false; + expect(document.activeElement === _focusableNode).to.be.false; }); it('has an attribute focused when focused', async () => { @@ -43,12 +104,13 @@ describe('FocusMixin', () => { const el = /** @type {Focusable} */ (await fixture(html` <${tag}> `)); - const { _inputNode } = getFormControlMembers(el); + // @ts-ignore [allow-protected] in test + const { _focusableNode } = el; expect(el.focused).to.be.false; - _inputNode?.focus(); + _focusableNode?.focus(); expect(el.focused).to.be.true; - _inputNode?.blur(); + _focusableNode?.blur(); expect(el.focused).to.be.false; }); @@ -95,4 +157,155 @@ describe('FocusMixin', () => { expect(focusoutEv.bubbles).to.be.true; expect(focusoutEv.composed).to.be.true; }); + + describe('Having :focus-visible within', () => { + it('sets focusedVisible to true when focusable element matches :focus-visible', async () => { + const el = /** @type {Focusable} */ (await fixture(html` + <${tag}> + `)); + // @ts-ignore [allow-protected] in test + const { _focusableNode } = el; + + const restoreMock1 = mockFocusVisible(_focusableNode, { + phase: 'focusout', + hasKeyboardInteraction: true, + }); + _focusableNode.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.false; + restoreMock1(); + + const restoreMock2 = mockFocusVisible(_focusableNode, { + phase: 'focusin', + hasKeyboardInteraction: false, + }); + _focusableNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.false; + restoreMock2(); + + const restoreMock3 = mockFocusVisible(_focusableNode, { + phase: 'focusout', + hasKeyboardInteraction: false, + }); + _focusableNode.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.false; + restoreMock3(); + + const restoreMock4 = mockFocusVisible(_focusableNode, { + phase: 'focusin', + hasKeyboardInteraction: true, + }); + _focusableNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.true; + restoreMock4(); + }); + + it('has an attribute focused-visible when focusedVisible is true', async () => { + const el = /** @type {Focusable} */ (await fixture(html` + <${tag}> + `)); + // @ts-ignore [allow-protected] in test + const { _focusableNode } = el; + + const restoreMock1 = mockFocusVisible(_focusableNode, { + phase: 'focusout', + hasKeyboardInteraction: true, + }); + _focusableNode.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.hasAttribute('focused-visible')).to.be.false; + restoreMock1(); + + const restoreMock2 = mockFocusVisible(_focusableNode, { + phase: 'focusin', + hasKeyboardInteraction: true, + }); + _focusableNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.hasAttribute('focused-visible')).to.be.true; + restoreMock2(); + }); + + // For polyfill, see https://github.com/WICG/focus-visible + describe('Using polyfill', () => { + const restoreMockPolyfill = mockPolyfill(); + after(() => { + restoreMockPolyfill(); + }); + + it('calls polyfill once per node', async () => { + class UniqueHost extends LitElement { + render() { + return html`<${tag}><${tag}>`; + } + } + const hostTagString = defineCE(UniqueHost); + const hostTag = unsafeStatic(hostTagString); + + const polySpy = sinon.spy(windowWithOptionalPolyfill, 'applyFocusVisiblePolyfill'); + await fixture(html`<${hostTag}>`); + expect(polySpy).to.have.been.calledOnce; + }); + + it('sets focusedVisible to true when focusable element if :focus-visible polyfill is loaded', async () => { + const el = /** @type {Focusable} */ (await fixture(html` + <${tag}> + `)); + + // @ts-ignore [allow-protected] in test + const { _focusableNode } = el; + + const restoreMock1 = mockFocusVisible(_focusableNode, { + phase: 'focusout', + hasKeyboardInteraction: true, + }); + const spy1 = sinon.spy(_focusableNode, 'hasAttribute'); + _focusableNode.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.false; + expect(spy1).to.not.have.been.calledWith('data-focus-visible-added'); + spy1.restore(); + restoreMock1(); + + const restoreMock2 = mockFocusVisible(_focusableNode, { + phase: 'focusin', + hasKeyboardInteraction: false, + }); + const spy2 = sinon.spy(_focusableNode, 'hasAttribute'); + _focusableNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.false; + expect(spy2).to.have.been.calledWith('data-focus-visible-added'); + spy2.restore(); + restoreMock2(); + + const restoreMock3 = mockFocusVisible(_focusableNode, { + phase: 'focusout', + hasKeyboardInteraction: false, + }); + const spy3 = sinon.spy(_focusableNode, 'hasAttribute'); + _focusableNode.dispatchEvent(new Event('focusout', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.false; + expect(spy3).to.not.have.been.calledWith('data-focus-visible-added'); + spy3.restore(); + restoreMock3(); + + const restoreMock4 = mockFocusVisible(_focusableNode, { + phase: 'focusin', + hasKeyboardInteraction: true, + }); + const spy4 = sinon.spy(_focusableNode, 'hasAttribute'); + _focusableNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.focusedVisible).to.be.true; + expect(spy4).to.have.been.called; + spy4.restore(); + restoreMock4(); + }); + }); + }); }); diff --git a/packages/form-core/test/FormControlMixin.test.js b/packages/form-core/test/FormControlMixin.test.js index 705303a5b..ed3cd0a6b 100644 --- a/packages/form-core/test/FormControlMixin.test.js +++ b/packages/form-core/test/FormControlMixin.test.js @@ -161,7 +161,14 @@ describe('FormControlMixin', () => { const groupTag = unsafeStatic(groupTagString); const focusableTagString = defineCE( - class extends FocusMixin(FormControlMixin(LitElement)) {}, + class extends FocusMixin(FormControlMixin(LitElement)) { + /** + * @configure FocusMixin + */ + get _focusableNode() { + return this._inputNode; + } + }, ); const focusableTag = unsafeStatic(focusableTagString); diff --git a/packages/form-core/types/FocusMixinTypes.d.ts b/packages/form-core/types/FocusMixinTypes.d.ts index b5a2d2f0f..b3ff9d346 100644 --- a/packages/form-core/types/FocusMixinTypes.d.ts +++ b/packages/form-core/types/FocusMixinTypes.d.ts @@ -1,12 +1,33 @@ import { Constructor } from '@open-wc/dedupe-mixin'; import { LitElement } from '@lion/core'; -import { FormControlHost } from './FormControlMixinTypes'; export declare class FocusHost { + /** + * Whether the focusable element within (`._focusableNode`) is focused. + * Reflects to attribute '[focused]' as a styling hook + */ focused: boolean; + /** + * Whether the focusable element within (`._focusableNode`) matches ':focus-visible' + * Reflects to attribute '[focused-visible]' as a styling hook + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible + */ + focusedVisible: boolean; + /** + * Calls `focus()` on focusable element within + */ focus(): void; + /** + * Calls `blur()` on focusable element within + */ blur(): void; + /** + * The focusable element: + * could be an input, textarea, select, button or any other element with tabindex > -1 + */ + protected get _focusableNode(): HTMLElement; + private __onFocus(): void; private __onBlur(): void; private __registerEventsForFocusMixin(): void; @@ -18,8 +39,6 @@ export declare function FocusImplementation>( ): T & Constructor & Pick & - Constructor & - Pick & Pick; export type FocusMixin = typeof FocusImplementation; diff --git a/packages/listbox/src/LionListbox.js b/packages/listbox/src/LionListbox.js index 07227b613..7baef9353 100644 --- a/packages/listbox/src/LionListbox.js +++ b/packages/listbox/src/LionListbox.js @@ -17,4 +17,11 @@ export class LionListbox extends ListboxMixin( get _feedbackConditionMeta() { return { ...super._feedbackConditionMeta, focused: this.focused }; } + + /** + * @configure FocusMixin + */ + get _focusableNode() { + return this._inputNode; + } }