From 88f52646b84bbd2c533ffb88504a439dac8e446d Mon Sep 17 00:00:00 2001 From: Thomas Allmer Date: Tue, 6 Aug 2019 12:26:12 +0200 Subject: [PATCH] fix(field): no delegate in FocusMixin; sync focused, redispatch events --- packages/field/src/FocusMixin.js | 124 +++++++++++++++++++------ packages/field/test/FocusMixin.test.js | 117 +++++++++++++++++++++++ packages/field/test/lion-field.test.js | 41 -------- 3 files changed, 213 insertions(+), 69 deletions(-) create mode 100644 packages/field/test/FocusMixin.test.js diff --git a/packages/field/src/FocusMixin.js b/packages/field/src/FocusMixin.js index 17f33bc81..2837c05dd 100644 --- a/packages/field/src/FocusMixin.js +++ b/packages/field/src/FocusMixin.js @@ -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() { - 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); + if (super.connectedCallback) { + super.connectedCallback(); + } + this.__registerEventsForFocusMixin(); } disconnectedCallback() { - super.disconnectedCallback(); - this.inputElement.removeEventListener('focusin', this._onFocus); - this.inputElement.removeEventListener('focusout', this._onBlur); + if (super.disconnectedCallback) { + super.disconnectedCallback(); + } + 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); } }, ); diff --git a/packages/field/test/FocusMixin.test.js b/packages/field/test/FocusMixin.test.js new file mode 100644 index 000000000..e2085f2cc --- /dev/null +++ b/packages/field/test/FocusMixin.test.js @@ -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` + + `; + } + + 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}> + `); + 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}> + `); + 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}> + `); + 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}> + `); + 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}> + `); + 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}> + `); + 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; + }); +}); diff --git a/packages/field/test/lion-field.test.js b/packages/field/test/lion-field.test.js index c0adcf008..d35988db4 100644 --- a/packages/field/test/lion-field.test.js +++ b/packages/field/test/lion-field.test.js @@ -68,24 +68,6 @@ describe('', () => { expect(cbBlurNativeInput.callCount).to.equal(2); }); - it('has class "state-focused" if focused', async () => { - const el = await fixture(`<${tagString}>${inputSlotString}`); - 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}`); - 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}`); expect(elDisabled.disabled).to.equal(true); @@ -395,12 +377,6 @@ describe('', () => { }); describe(`Delegation${nameSuffix}`, () => { - it('delegates attribute autofocus', async () => { - const el = await fixture(`<${tagString} autofocus>${inputSlotString}`); - 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}`); expect(el.inputElement.value).to.equal(''); @@ -426,23 +402,6 @@ describe('', () => { } }); - it('delegates property onfocus', async () => { - const el = await fixture(`<${tagString}>${inputSlotString}`); - 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}`); - 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}