fix(field): no delegate in FocusMixin; sync focused, redispatch events

This commit is contained in:
Thomas Allmer 2019-08-06 12:26:12 +02:00 committed by Joren Broekema
parent 6a4931e74c
commit 88f52646b8
3 changed files with 213 additions and 69 deletions

View file

@ -1,52 +1,120 @@
import { dedupeMixin, DelegateMixin } from '@lion/core';
import { dedupeMixin } from '@lion/core';
export const FocusMixin = dedupeMixin(
superclass =>
// eslint-disable-next-line no-unused-vars, max-len, no-shadow
class FocusMixin extends DelegateMixin(superclass) {
get delegations() {
class FocusMixin extends superclass {
static get properties() {
return {
...super.delegations,
target: () => this.inputElement,
events: [...super.delegations.events, 'focus', 'blur'], // since these events don't bubble
methods: [...super.delegations.methods, 'focus', 'blur'],
properties: [...super.delegations.properties, 'onfocus', 'onblur', 'autofocus'],
attributes: [...super.delegations.attributes, 'onfocus', 'onblur', 'autofocus'],
focused: {
type: Boolean,
reflect: true,
},
};
}
constructor() {
super();
this.focused = false;
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this);
this.inputElement.addEventListener('focusin', this._onFocus);
this.inputElement.addEventListener('focusout', this._onBlur);
}
this.__registerEventsForFocusMixin();
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
this.inputElement.removeEventListener('focusin', this._onFocus);
this.inputElement.removeEventListener('focusout', this._onBlur);
}
this.__teardownEventsForFocusMixin();
}
focus() {
const native = this.inputElement;
if (native) {
native.focus();
}
}
blur() {
const native = this.inputElement;
if (native) {
native.blur();
}
}
updated(changedProperties) {
super.updated(changedProperties);
// 'state-focused' css classes are deprecated
if (changedProperties.has('focused')) {
this.classList[this.focused ? 'add' : 'remove']('state-focused');
}
}
/**
* Helper Function to easily check if the element is being focused
* Functions should be private
*
* TODO: performance comparision vs
* return this.inputElement === document.activeElement;
* @deprecated
*/
get focused() {
return this.classList.contains('state-focused');
}
_onFocus() {
if (super._onFocus) super._onFocus();
this.classList.add('state-focused');
if (super._onFocus) {
super._onFocus();
}
this.focused = true;
}
/**
* Functions should be private
*
* @deprecated
*/
_onBlur() {
if (super._onBlur) super._onBlur();
this.classList.remove('state-focused');
if (super._onBlur) {
super._onBlur();
}
this.focused = false;
}
__registerEventsForFocusMixin() {
// focus
this.__redispatchFocus = ev => {
ev.stopPropagation();
this.dispatchEvent(new FocusEvent('focus'));
};
this.inputElement.addEventListener('focus', this.__redispatchFocus);
// blur
this.__redispatchBlur = ev => {
ev.stopPropagation();
this.dispatchEvent(new FocusEvent('blur'));
};
this.inputElement.addEventListener('blur', this.__redispatchBlur);
// focusin
this.__redispatchFocusin = ev => {
ev.stopPropagation();
this._onFocus(ev);
this.dispatchEvent(new FocusEvent('focusin', { bubbles: true, composed: true }));
};
this.inputElement.addEventListener('focusin', this.__redispatchFocusin);
// focusout
this.__redispatchFocusout = ev => {
ev.stopPropagation();
this._onBlur();
this.dispatchEvent(new FocusEvent('focusout', { bubbles: true, composed: true }));
};
this.inputElement.addEventListener('focusout', this.__redispatchFocusout);
}
__teardownEventsForFocusMixin() {
this.inputElement.removeEventListener('focus', this.__redispatchFocus);
this.inputElement.removeEventListener('blur', this.__redispatchBlur);
this.inputElement.removeEventListener('focusin', this.__redispatchFocusin);
this.inputElement.removeEventListener('focusout', this.__redispatchFocusout);
}
},
);

View file

@ -0,0 +1,117 @@
import { expect, fixture, html, defineCE, unsafeStatic, oneEvent } from '@open-wc/testing';
import { LitElement } from '@lion/core';
import { FocusMixin } from '../src/FocusMixin.js';
describe('FocusMixin', () => {
let tag;
before(async () => {
const tagString = defineCE(
class extends FocusMixin(LitElement) {
render() {
return html`
<slot name="input"></slot>
`;
}
get inputElement() {
return this.querySelector('input');
}
},
);
tag = unsafeStatic(tagString);
});
it('focuses/blurs the underlaying native element on .focus()/.blur()', async () => {
const el = await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
el.focus();
expect(document.activeElement === el.inputElement).to.be.true;
el.blur();
expect(document.activeElement === el.inputElement).to.be.false;
});
it('has an attribute focused when focused', async () => {
const el = await fixture(html`
<${tag}><input slot="input"></${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 = await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
expect(el.focused).to.be.false;
el.inputElement.focus();
expect(el.focused).to.be.true;
el.inputElement.blur();
expect(el.focused).to.be.false;
});
it('has a deprecated "state-focused" css class when focused', async () => {
const el = await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
el.focus();
await el.updateComplete;
expect(el.classList.contains('state-focused')).to.be.true;
el.blur();
await el.updateComplete;
expect(el.classList.contains('state-focused')).to.be.false;
});
it('dispatches [focus, blur] events', async () => {
const el = await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
setTimeout(() => el.focus());
const focusEv = await oneEvent(el, 'focus');
expect(focusEv).to.be.instanceOf(FocusEvent);
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(FocusEvent);
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 = await fixture(html`
<${tag}><input slot="input"></${tag}>
`);
setTimeout(() => el.focus());
const focusinEv = await oneEvent(el, 'focusin');
expect(focusinEv).to.be.instanceOf(FocusEvent);
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(FocusEvent);
expect(focusoutEv.target).to.equal(el);
expect(focusoutEv.bubbles).to.be.true;
expect(focusoutEv.composed).to.be.true;
});
});

View file

@ -68,24 +68,6 @@ describe('<lion-field>', () => {
expect(cbBlurNativeInput.callCount).to.equal(2);
});
it('has class "state-focused" if focused', async () => {
const el = await fixture(`<${tagString}>${inputSlotString}</${tagString}>`);
expect(el.classList.contains('state-focused')).to.equal(false, 'no state-focused initially');
await triggerFocusFor(el.inputElement);
expect(el.classList.contains('state-focused')).to.equal(true, 'state-focused after focus()');
await triggerBlurFor(el.inputElement);
expect(el.classList.contains('state-focused')).to.equal(false, 'no state-focused after blur()');
});
it('offers simple getter "this.focused" returning true/false for the current focus state', async () => {
const el = await fixture(`<${tagString}>${inputSlotString}</${tagString}>`);
expect(el.focused).to.equal(false);
await triggerFocusFor(el);
expect(el.focused).to.equal(true);
await triggerBlurFor(el);
expect(el.focused).to.equal(false);
});
it('can be disabled via attribute', async () => {
const elDisabled = await fixture(`<${tagString} disabled>${inputSlotString}</${tagString}>`);
expect(elDisabled.disabled).to.equal(true);
@ -395,12 +377,6 @@ describe('<lion-field>', () => {
});
describe(`Delegation${nameSuffix}`, () => {
it('delegates attribute autofocus', async () => {
const el = await fixture(`<${tagString} autofocus>${inputSlotString}</${tagString}>`);
expect(el.hasAttribute('autofocus')).to.be.false;
expect(el.inputElement.hasAttribute('autofocus')).to.be.true;
});
it('delegates property value', async () => {
const el = await fixture(`<${tagString}>${inputSlotString}</${tagString}>`);
expect(el.inputElement.value).to.equal('');
@ -426,23 +402,6 @@ describe('<lion-field>', () => {
}
});
it('delegates property onfocus', async () => {
const el = await fixture(`<${tagString}>${inputSlotString}</${tagString}>`);
const cbFocusHost = sinon.spy();
el.onfocus = cbFocusHost;
await triggerFocusFor(el.inputElement);
expect(cbFocusHost.callCount).to.equal(1);
});
it('delegates property onblur', async () => {
const el = await fixture(`<${tagString}>${inputSlotString}</${tagString}>`);
const cbBlurHost = sinon.spy();
el.onblur = cbBlurHost;
await triggerFocusFor(el.inputElement);
await triggerBlurFor(el.inputElement);
expect(cbBlurHost.callCount).to.equal(1);
});
it('delegates property selectionStart and selectionEnd', async () => {
const el = await fixture(html`
<${tag}