feat(form-core): add [focused-visible] when matching :focus-visible

This commit is contained in:
Thijs Louisse 2021-04-30 16:35:12 +02:00 committed by Thomas Allmer
parent 77250aab73
commit 3b187fa2c9
8 changed files with 355 additions and 33 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/form-core': patch
---
support [focused-visible] when focusable node within matches :focus-visible

View file

@ -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<import('@lion/core').LitElement>} 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);

View file

@ -106,4 +106,11 @@ export class LionField extends FormControlMixin(
get _feedbackConditionMeta() {
return { ...super._feedbackConditionMeta, focused: this.focused };
}
/**
* @configure FocusMixin
*/
get _focusableNode() {
return this._inputNode;
}
}

View file

@ -110,6 +110,13 @@ const NativeTextFieldMixinImplementation = superclass =>
} catch (_) {}
}
}
/**
* @configure FocusMixin
*/
get _focusableNode() {
return this._inputNode;
}
};
export const NativeTextFieldMixin = dedupeMixin(NativeTextFieldMixinImplementation);

View file

@ -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`<slot name="input"></slot>`;
}
/**
* @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}><input slot="input"></${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}><input slot="input"></${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}><input slot="input"></${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}><input slot="input"></${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}><input slot="input"></${tag}><${tag}><input slot="input"></${tag}>`;
}
}
const hostTagString = defineCE(UniqueHost);
const hostTag = unsafeStatic(hostTagString);
const polySpy = sinon.spy(windowWithOptionalPolyfill, 'applyFocusVisiblePolyfill');
await fixture(html`<${hostTag}></${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}><input slot="input"></${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();
});
});
});
});

View file

@ -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);

View file

@ -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 extends Constructor<LitElement>>(
): T &
Constructor<FocusHost> &
Pick<typeof FocusHost, keyof typeof FocusHost> &
Constructor<FormControlHost> &
Pick<typeof FormControlHost, keyof typeof FormControlHost> &
Pick<typeof LitElement, keyof typeof LitElement>;
export type FocusMixin = typeof FocusImplementation;

View file

@ -17,4 +17,11 @@ export class LionListbox extends ListboxMixin(
get _feedbackConditionMeta() {
return { ...super._feedbackConditionMeta, focused: this.focused };
}
/**
* @configure FocusMixin
*/
get _focusableNode() {
return this._inputNode;
}
}