import { LitElement } from '@lion/core'; import { defineCE, expect, fixture, html, oneEvent, unsafeStatic } from '@open-wc/testing'; 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); const tag = unsafeStatic(tagString); it('focuses/blurs the underlaying native element on .focus()/.blur()', async () => { const el = /** @type {Focusable} */ ( await fixture(html` <${tag}> `) ); // @ts-ignore [allow-protected] in test const { _focusableNode } = el; el.focus(); expect(document.activeElement === _focusableNode).to.be.true; el.blur(); expect(document.activeElement === _focusableNode).to.be.false; }); it('has an attribute focused when focused', async () => { const el = /** @type {Focusable} */ ( await fixture(html` <${tag}> `) ); el.focus(); await el.updateComplete; expect(el.hasAttribute('focused')).to.be.true; el.blur(); await el.updateComplete; expect(el.hasAttribute('focused')).to.be.false; }); it('becomes focused/blurred if the native element gets focused/blurred', async () => { const el = /** @type {Focusable} */ ( await fixture(html` <${tag}> `) ); // @ts-ignore [allow-protected] in test const { _focusableNode } = el; expect(el.focused).to.be.false; _focusableNode?.focus(); expect(el.focused).to.be.true; _focusableNode?.blur(); expect(el.focused).to.be.false; }); it('dispatches [focus, blur] events', async () => { const el = /** @type {Focusable} */ ( await fixture(html` <${tag}> `) ); setTimeout(() => el.focus()); const focusEv = await oneEvent(el, 'focus'); expect(focusEv).to.be.instanceOf(Event); expect(focusEv.target).to.equal(el); expect(focusEv.bubbles).to.be.false; expect(focusEv.composed).to.be.false; setTimeout(() => { el.focus(); el.blur(); }); const blurEv = await oneEvent(el, 'blur'); expect(blurEv).to.be.instanceOf(Event); expect(blurEv.target).to.equal(el); expect(blurEv.bubbles).to.be.false; expect(blurEv.composed).to.be.false; }); it('dispatches [focusin, focusout] events with { bubbles: true, composed: true }', async () => { const el = /** @type {Focusable} */ ( await fixture(html` <${tag}> `) ); setTimeout(() => el.focus()); const focusinEv = await oneEvent(el, 'focusin'); expect(focusinEv).to.be.instanceOf(Event); expect(focusinEv.target).to.equal(el); expect(focusinEv.bubbles).to.be.true; expect(focusinEv.composed).to.be.true; setTimeout(() => { el.focus(); el.blur(); }); const focusoutEv = await oneEvent(el, 'focusout'); expect(focusoutEv).to.be.instanceOf(Event); expect(focusoutEv.target).to.equal(el); 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(); }); }); }); });