feat(form-core): add [focused-visible] when matching :focus-visible
This commit is contained in:
parent
77250aab73
commit
3b187fa2c9
8 changed files with 355 additions and 33 deletions
5
.changeset/short-llamas-share.md
Normal file
5
.changeset/short-llamas-share.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/form-core': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
support [focused-visible] when focusable node within matches :focus-visible
|
||||||
|
|
@ -1,25 +1,50 @@
|
||||||
import { dedupeMixin } from '@lion/core';
|
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
|
* @typedef {import('../types/FocusMixinTypes').FocusMixin} FocusMixin
|
||||||
* @type {FocusMixin}
|
* @type {FocusMixin}
|
||||||
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
|
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
|
||||||
*/
|
*/
|
||||||
const FocusMixinImplementation = superclass =>
|
const FocusMixinImplementation = superclass =>
|
||||||
class FocusMixin extends FormControlMixin(superclass) {
|
class FocusMixin extends superclass {
|
||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
focused: {
|
focused: { type: Boolean, reflect: true },
|
||||||
type: Boolean,
|
focusedVisible: { type: Boolean, reflect: true, attribute: 'focused-visible' },
|
||||||
reflect: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the focusable element within (`._focusableNode`) is focused.
|
||||||
|
* Reflects to attribute '[focused]' as a styling hook
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
this.focused = false;
|
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() {
|
connectedCallback() {
|
||||||
|
|
@ -32,18 +57,32 @@ const FocusMixinImplementation = superclass =>
|
||||||
this.__teardownEventsForFocusMixin();
|
this.__teardownEventsForFocusMixin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `focus()` on focusable element within
|
||||||
|
*/
|
||||||
focus() {
|
focus() {
|
||||||
const native = this._inputNode;
|
this._focusableNode?.focus();
|
||||||
if (native) {
|
|
||||||
native.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `blur()` on focusable element within
|
||||||
|
*/
|
||||||
blur() {
|
blur() {
|
||||||
const native = this._inputNode;
|
this._focusableNode?.blur();
|
||||||
if (native) {
|
}
|
||||||
native.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() {
|
__onFocus() {
|
||||||
this.focused = true;
|
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() {
|
__onBlur() {
|
||||||
this.focused = false;
|
this.focused = false;
|
||||||
|
this.focusedVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
__registerEventsForFocusMixin() {
|
__registerEventsForFocusMixin() {
|
||||||
|
applyFocusVisiblePolyfillWhenNeeded(this.getRootNode());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* focus
|
* focus
|
||||||
* @param {Event} ev
|
* @param {Event} ev
|
||||||
|
|
@ -72,7 +124,7 @@ const FocusMixinImplementation = superclass =>
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
this.dispatchEvent(new Event('focus'));
|
this.dispatchEvent(new Event('focus'));
|
||||||
};
|
};
|
||||||
this._inputNode.addEventListener('focus', this.__redispatchFocus);
|
this._focusableNode.addEventListener('focus', this.__redispatchFocus);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* blur
|
* blur
|
||||||
|
|
@ -82,7 +134,7 @@ const FocusMixinImplementation = superclass =>
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
this.dispatchEvent(new Event('blur'));
|
this.dispatchEvent(new Event('blur'));
|
||||||
};
|
};
|
||||||
this._inputNode.addEventListener('blur', this.__redispatchBlur);
|
this._focusableNode.addEventListener('blur', this.__redispatchBlur);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* focusin
|
* focusin
|
||||||
|
|
@ -93,7 +145,7 @@ const FocusMixinImplementation = superclass =>
|
||||||
this.__onFocus();
|
this.__onFocus();
|
||||||
this.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
|
this.dispatchEvent(new Event('focusin', { bubbles: true, composed: true }));
|
||||||
};
|
};
|
||||||
this._inputNode.addEventListener('focusin', this.__redispatchFocusin);
|
this._focusableNode.addEventListener('focusin', this.__redispatchFocusin);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* focusout
|
* focusout
|
||||||
|
|
@ -104,30 +156,35 @@ const FocusMixinImplementation = superclass =>
|
||||||
this.__onBlur();
|
this.__onBlur();
|
||||||
this.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
|
this.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
|
||||||
};
|
};
|
||||||
this._inputNode.addEventListener('focusout', this.__redispatchFocusout);
|
this._focusableNode.addEventListener('focusout', this.__redispatchFocusout);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
__teardownEventsForFocusMixin() {
|
__teardownEventsForFocusMixin() {
|
||||||
this._inputNode.removeEventListener(
|
this._focusableNode.removeEventListener(
|
||||||
'focus',
|
'focus',
|
||||||
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocus),
|
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocus),
|
||||||
);
|
);
|
||||||
this._inputNode.removeEventListener(
|
this._focusableNode.removeEventListener(
|
||||||
'blur',
|
'blur',
|
||||||
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchBlur),
|
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchBlur),
|
||||||
);
|
);
|
||||||
this._inputNode.removeEventListener(
|
this._focusableNode.removeEventListener(
|
||||||
'focusin',
|
'focusin',
|
||||||
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocusin),
|
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocusin),
|
||||||
);
|
);
|
||||||
this._inputNode.removeEventListener(
|
this._focusableNode.removeEventListener(
|
||||||
'focusout',
|
'focusout',
|
||||||
/** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocusout),
|
/** @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);
|
export const FocusMixin = dedupeMixin(FocusMixinImplementation);
|
||||||
|
|
|
||||||
|
|
@ -106,4 +106,11 @@ export class LionField extends FormControlMixin(
|
||||||
get _feedbackConditionMeta() {
|
get _feedbackConditionMeta() {
|
||||||
return { ...super._feedbackConditionMeta, focused: this.focused };
|
return { ...super._feedbackConditionMeta, focused: this.focused };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FocusMixin
|
||||||
|
*/
|
||||||
|
get _focusableNode() {
|
||||||
|
return this._inputNode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,13 @@ const NativeTextFieldMixinImplementation = superclass =>
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FocusMixin
|
||||||
|
*/
|
||||||
|
get _focusableNode() {
|
||||||
|
return this._inputNode;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NativeTextFieldMixin = dedupeMixin(NativeTextFieldMixinImplementation);
|
export const NativeTextFieldMixin = dedupeMixin(NativeTextFieldMixinImplementation);
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,73 @@
|
||||||
import { LitElement } from '@lion/core';
|
import { LitElement } from '@lion/core';
|
||||||
import { defineCE, expect, fixture, html, oneEvent, unsafeStatic } from '@open-wc/testing';
|
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';
|
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', () => {
|
describe('FocusMixin', () => {
|
||||||
class Focusable extends FocusMixin(LitElement) {
|
class Focusable extends FocusMixin(LitElement) {
|
||||||
render() {
|
render() {
|
||||||
return html`<slot name="input"></slot>`;
|
return html`<slot name="input"></slot>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FocusMixin
|
||||||
|
*/
|
||||||
|
get _focusableNode() {
|
||||||
|
return /** @type {HTMLInputElement} */ (this.querySelector('input'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagString = defineCE(Focusable);
|
const tagString = defineCE(Focusable);
|
||||||
|
|
@ -17,12 +77,13 @@ describe('FocusMixin', () => {
|
||||||
const el = /** @type {Focusable} */ (await fixture(html`
|
const el = /** @type {Focusable} */ (await fixture(html`
|
||||||
<${tag}><input slot="input"></${tag}>
|
<${tag}><input slot="input"></${tag}>
|
||||||
`));
|
`));
|
||||||
const { _inputNode } = getFormControlMembers(el);
|
// @ts-ignore [allow-protected] in test
|
||||||
|
const { _focusableNode } = el;
|
||||||
|
|
||||||
el.focus();
|
el.focus();
|
||||||
expect(document.activeElement === _inputNode).to.be.true;
|
expect(document.activeElement === _focusableNode).to.be.true;
|
||||||
el.blur();
|
el.blur();
|
||||||
expect(document.activeElement === _inputNode).to.be.false;
|
expect(document.activeElement === _focusableNode).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has an attribute focused when focused', async () => {
|
it('has an attribute focused when focused', async () => {
|
||||||
|
|
@ -43,12 +104,13 @@ describe('FocusMixin', () => {
|
||||||
const el = /** @type {Focusable} */ (await fixture(html`
|
const el = /** @type {Focusable} */ (await fixture(html`
|
||||||
<${tag}><input slot="input"></${tag}>
|
<${tag}><input slot="input"></${tag}>
|
||||||
`));
|
`));
|
||||||
const { _inputNode } = getFormControlMembers(el);
|
// @ts-ignore [allow-protected] in test
|
||||||
|
const { _focusableNode } = el;
|
||||||
|
|
||||||
expect(el.focused).to.be.false;
|
expect(el.focused).to.be.false;
|
||||||
_inputNode?.focus();
|
_focusableNode?.focus();
|
||||||
expect(el.focused).to.be.true;
|
expect(el.focused).to.be.true;
|
||||||
_inputNode?.blur();
|
_focusableNode?.blur();
|
||||||
expect(el.focused).to.be.false;
|
expect(el.focused).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -95,4 +157,155 @@ describe('FocusMixin', () => {
|
||||||
expect(focusoutEv.bubbles).to.be.true;
|
expect(focusoutEv.bubbles).to.be.true;
|
||||||
expect(focusoutEv.composed).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,14 @@ describe('FormControlMixin', () => {
|
||||||
const groupTag = unsafeStatic(groupTagString);
|
const groupTag = unsafeStatic(groupTagString);
|
||||||
|
|
||||||
const focusableTagString = defineCE(
|
const focusableTagString = defineCE(
|
||||||
class extends FocusMixin(FormControlMixin(LitElement)) {},
|
class extends FocusMixin(FormControlMixin(LitElement)) {
|
||||||
|
/**
|
||||||
|
* @configure FocusMixin
|
||||||
|
*/
|
||||||
|
get _focusableNode() {
|
||||||
|
return this._inputNode;
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const focusableTag = unsafeStatic(focusableTagString);
|
const focusableTag = unsafeStatic(focusableTagString);
|
||||||
|
|
||||||
|
|
|
||||||
25
packages/form-core/types/FocusMixinTypes.d.ts
vendored
25
packages/form-core/types/FocusMixinTypes.d.ts
vendored
|
|
@ -1,12 +1,33 @@
|
||||||
import { Constructor } from '@open-wc/dedupe-mixin';
|
import { Constructor } from '@open-wc/dedupe-mixin';
|
||||||
import { LitElement } from '@lion/core';
|
import { LitElement } from '@lion/core';
|
||||||
import { FormControlHost } from './FormControlMixinTypes';
|
|
||||||
|
|
||||||
export declare class FocusHost {
|
export declare class FocusHost {
|
||||||
|
/**
|
||||||
|
* Whether the focusable element within (`._focusableNode`) is focused.
|
||||||
|
* Reflects to attribute '[focused]' as a styling hook
|
||||||
|
*/
|
||||||
focused: boolean;
|
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;
|
focus(): void;
|
||||||
|
/**
|
||||||
|
* Calls `blur()` on focusable element within
|
||||||
|
*/
|
||||||
blur(): void;
|
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 __onFocus(): void;
|
||||||
private __onBlur(): void;
|
private __onBlur(): void;
|
||||||
private __registerEventsForFocusMixin(): void;
|
private __registerEventsForFocusMixin(): void;
|
||||||
|
|
@ -18,8 +39,6 @@ export declare function FocusImplementation<T extends Constructor<LitElement>>(
|
||||||
): T &
|
): T &
|
||||||
Constructor<FocusHost> &
|
Constructor<FocusHost> &
|
||||||
Pick<typeof FocusHost, keyof typeof FocusHost> &
|
Pick<typeof FocusHost, keyof typeof FocusHost> &
|
||||||
Constructor<FormControlHost> &
|
|
||||||
Pick<typeof FormControlHost, keyof typeof FormControlHost> &
|
|
||||||
Pick<typeof LitElement, keyof typeof LitElement>;
|
Pick<typeof LitElement, keyof typeof LitElement>;
|
||||||
|
|
||||||
export type FocusMixin = typeof FocusImplementation;
|
export type FocusMixin = typeof FocusImplementation;
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,11 @@ export class LionListbox extends ListboxMixin(
|
||||||
get _feedbackConditionMeta() {
|
get _feedbackConditionMeta() {
|
||||||
return { ...super._feedbackConditionMeta, focused: this.focused };
|
return { ...super._feedbackConditionMeta, focused: this.focused };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @configure FocusMixin
|
||||||
|
*/
|
||||||
|
get _focusableNode() {
|
||||||
|
return this._inputNode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue