diff --git a/packages/button/src/LionButton.js b/packages/button/src/LionButton.js index 46526e5f3..8a7281e32 100644 --- a/packages/button/src/LionButton.js +++ b/packages/button/src/LionButton.js @@ -1,9 +1,6 @@ -import { css, html, DelegateMixin, SlotMixin, DisabledWithTabIndexMixin } from '@lion/core'; -import { LionLitElement } from '@lion/core/src/LionLitElement.js'; +import { css, html, SlotMixin, DisabledWithTabIndexMixin, LitElement } from '@lion/core'; -export class LionButton extends DisabledWithTabIndexMixin( - DelegateMixin(SlotMixin(LionLitElement)), -) { +export class LionButton extends DisabledWithTabIndexMixin(SlotMixin(LitElement)) { static get properties() { return { role: { @@ -14,6 +11,10 @@ export class LionButton extends DisabledWithTabIndexMixin( type: Boolean, reflect: true, }, + type: { + type: String, + reflect: true, + }, }; } @@ -107,14 +108,6 @@ export class LionButton extends DisabledWithTabIndexMixin( ]; } - get delegations() { - return { - ...super.delegations, - target: () => this.$$slot('_button'), - attributes: ['type'], - }; - } - get slots() { return { ...super.slots, @@ -129,9 +122,14 @@ export class LionButton extends DisabledWithTabIndexMixin( }; } + get _nativeButtonNode() { + return this.querySelector('[slot=_button]'); + } + constructor() { super(); this.role = 'button'; + this.type = 'submit'; this.active = false; this.__setupDelegationInConstructor(); } @@ -146,12 +144,22 @@ export class LionButton extends DisabledWithTabIndexMixin( this.__teardownEvents(); } + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('type')) { + const native = this._nativeButtonNode; + if (native) { + native.type = this.type; + } + } + } + _redispatchClickEvent(oldEvent) { // replacing `MouseEvent` with `oldEvent.constructor` breaks IE const newEvent = new MouseEvent(oldEvent.type, oldEvent); newEvent.__isRedispatchedOnNativeButton = true; this.__enforceHostEventTarget(newEvent); - this.$$slot('_button').dispatchEvent(newEvent); + this._nativeButtonNode.dispatchEvent(newEvent); } /** diff --git a/packages/button/test/lion-button.test.js b/packages/button/test/lion-button.test.js index 0b6792aa6..713f5ac01 100644 --- a/packages/button/test/lion-button.test.js +++ b/packages/button/test/lion-button.test.js @@ -27,23 +27,26 @@ describe('lion-button', () => { expect(el.getAttribute('tabindex')).to.equal('0'); }); - it('has no type by default on the native button', async () => { + it('has .type="submit" and type="submit" by default', async () => { const el = await fixture(`foo`); - const nativeButton = el.$$slot('_button'); - expect(nativeButton.getAttribute('type')).to.be.null; + expect(el.type).to.equal('submit'); + expect(el.getAttribute('type')).to.be.equal('submit'); + expect(el._nativeButtonNode.type).to.equal('submit'); + expect(el._nativeButtonNode.getAttribute('type')).to.be.equal('submit'); }); - it('has type="submit" on the native button when set', async () => { - const el = await fixture(`foo`); - const nativeButton = el.$$slot('_button'); - expect(nativeButton.getAttribute('type')).to.equal('submit'); + it('sync type down to the native button', async () => { + const el = await fixture(`foo`); + expect(el.type).to.equal('button'); + expect(el.getAttribute('type')).to.be.equal('button'); + expect(el._nativeButtonNode.type).to.equal('button'); + expect(el._nativeButtonNode.getAttribute('type')).to.be.equal('button'); }); it('hides the native button in the UI', async () => { const el = await fixture(`foo`); - const nativeButton = el.$$slot('_button'); - expect(nativeButton.getAttribute('tabindex')).to.equal('-1'); - expect(window.getComputedStyle(nativeButton).visibility).to.equal('hidden'); + expect(el._nativeButtonNode.getAttribute('tabindex')).to.equal('-1'); + expect(window.getComputedStyle(el._nativeButtonNode).visibility).to.equal('hidden'); }); it('can be disabled imperatively', async () => { diff --git a/packages/choice-input/src/ChoiceInputMixin.js b/packages/choice-input/src/ChoiceInputMixin.js index b0479a186..01927ee58 100644 --- a/packages/choice-input/src/ChoiceInputMixin.js +++ b/packages/choice-input/src/ChoiceInputMixin.js @@ -8,7 +8,6 @@ export const ChoiceInputMixin = superclass => class ChoiceInputMixin extends FormatMixin(superclass) { static get properties() { return { - ...super.properties, /** * Boolean indicating whether or not this element is checked by the end user. */ diff --git a/packages/field/src/FieldCustomMixin.js b/packages/field/src/FieldCustomMixin.js index a0f315bb7..46777495c 100644 --- a/packages/field/src/FieldCustomMixin.js +++ b/packages/field/src/FieldCustomMixin.js @@ -13,7 +13,6 @@ export const FieldCustomMixin = dedupeMixin( class FieldCustomMixin extends superclass { static get properties() { return { - ...super.properties, /** * When no light dom defined and prop set */ 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/src/FormControlMixin.js b/packages/field/src/FormControlMixin.js index 470836c51..a33b52c12 100644 --- a/packages/field/src/FormControlMixin.js +++ b/packages/field/src/FormControlMixin.js @@ -18,7 +18,6 @@ export const FormControlMixin = dedupeMixin( class FormControlMixin extends FormRegisteringMixin(ObserverMixin(SlotMixin(superclass))) { static get properties() { return { - ...super.properties, /** * A list of ids that will be put on the inputElement as a serialized string */ diff --git a/packages/field/src/FormatMixin.js b/packages/field/src/FormatMixin.js index 1a9b48d0c..e321aafd9 100644 --- a/packages/field/src/FormatMixin.js +++ b/packages/field/src/FormatMixin.js @@ -53,8 +53,6 @@ export const FormatMixin = dedupeMixin( class FormatMixin extends ObserverMixin(superclass) { static get properties() { return { - ...super.properties, - /** * The model value is the result of the parser function(when available). * It should be considered as the internal value used for validation and reasoning/logic. diff --git a/packages/field/src/InteractionStateMixin.js b/packages/field/src/InteractionStateMixin.js index 9e7deb32d..3abac16aa 100644 --- a/packages/field/src/InteractionStateMixin.js +++ b/packages/field/src/InteractionStateMixin.js @@ -18,7 +18,6 @@ export const InteractionStateMixin = dedupeMixin( class InteractionStateMixin extends ObserverMixin(superclass) { static get properties() { return { - ...super.properties, /** * True when user has focused and left(blurred) the field. */ diff --git a/packages/field/src/LionField.js b/packages/field/src/LionField.js index 05cd34c81..f9abd5f2d 100644 --- a/packages/field/src/LionField.js +++ b/packages/field/src/LionField.js @@ -1,6 +1,6 @@ -import { DelegateMixin, SlotMixin, LitElement } from '@lion/core'; +import { SlotMixin, LitElement } from '@lion/core'; import { ElementMixin } from '@lion/core/src/ElementMixin.js'; -import { CssClassMixin } from '@lion/core/src/CssClassMixin.js'; +import { DisabledMixin } from '@lion/core/src/DisabledMixin.js'; import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { ValidateMixin } from '@lion/validate'; import { FormControlMixin } from './FormControlMixin.js'; @@ -35,40 +35,53 @@ import { FocusMixin } from './FocusMixin.js'; export class LionField extends FormControlMixin( InteractionStateMixin( FocusMixin( - FormatMixin( - ValidateMixin( - CssClassMixin(ElementMixin(DelegateMixin(SlotMixin(ObserverMixin(LitElement))))), - ), - ), + FormatMixin(ValidateMixin(DisabledMixin(ElementMixin(SlotMixin(ObserverMixin(LitElement)))))), ), ), ) { - get delegations() { - return { - ...super.delegations, - target: () => this.inputElement, - properties: [ - ...super.delegations.properties, - 'name', - 'type', - 'disabled', - 'selectionStart', - 'selectionEnd', - ], - attributes: [...super.delegations.attributes, 'name', 'type', 'disabled'], - }; - } - static get properties() { return { - ...super.properties, submitted: { // make sure validation can be triggered based on observer type: Boolean, }, + name: { + type: String, + reflect: true, + }, }; } + get selectionStart() { + const native = this.inputElement; + if (native && native.selectionStart) { + return native.selectionStart; + } + return 0; + } + + set selectionStart(value) { + const native = this.inputElement; + if (native && native.selectionStart) { + native.selectionStart = value; + } + } + + get selectionEnd() { + const native = this.inputElement; + if (native && native.selectionEnd) { + return native.selectionEnd; + } + return 0; + } + + set selectionEnd(value) { + const native = this.inputElement; + if (native && native.selectionEnd) { + native.selectionEnd = value; + } + } + // We don't delegate, because we want to preserve caret position via _setValueAndPreserveCaret set value(value) { // if not yet connected to dom can't change the value @@ -82,20 +95,23 @@ export class LionField extends FormControlMixin( return (this.inputElement && this.inputElement.value) || ''; } - static get asyncObservers() { - return { - ...super.asyncObservers, - _setDisabledClass: ['disabled'], - }; + constructor() { + super(); + this.name = ''; + this.submitted = false; } connectedCallback() { + // TODO: Normally we put super calls on top for predictability, + // here we temporarily need to do attribute delegation before, + // so the FormatMixin uses the right value. Should be solved + // when value delegation is part of the calculation loop of + // FormatMixin + this._delegateInitialValueAttr(); super.connectedCallback(); this._onChange = this._onChange.bind(this); this.inputElement.addEventListener('change', this._onChange); - this._delegateInitialValueAttr(); - this._setDisabledClass(); this.classList.add('form-field'); // eslint-disable-line } @@ -112,8 +128,22 @@ export class LionField extends FormControlMixin( this.inputElement.removeEventListener('change', this._onChange); } - _setDisabledClass() { - this.classList[this.disabled ? 'add' : 'remove']('state-disabled'); + updated(changedProps) { + super.updated(changedProps); + + if (changedProps.has('disabled')) { + if (this.disabled) { + this.inputElement.disabled = true; + this.classList.add('state-disabled'); // eslint-disable-line wc/no-self-class + } else { + this.inputElement.disabled = false; + this.classList.remove('state-disabled'); // eslint-disable-line wc/no-self-class + } + } + + if (changedProps.has('name')) { + this.inputElement.name = this.name; + } } /** diff --git a/packages/field/test-suites/FormatMixin.suite.js b/packages/field/test-suites/FormatMixin.suite.js new file mode 100644 index 000000000..dc54013e6 --- /dev/null +++ b/packages/field/test-suites/FormatMixin.suite.js @@ -0,0 +1,401 @@ +import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing'; +import sinon from 'sinon'; + +import { LitElement } from '@lion/core'; +import { Unparseable } from '@lion/validate'; +import { FormatMixin } from '../src/FormatMixin.js'; + +function mimicUserInput(formControl, newViewValue) { + formControl.value = newViewValue; // eslint-disable-line no-param-reassign + formControl.inputElement.dispatchEvent(new CustomEvent('input', { bubbles: true })); +} + +export function runFormatMixinSuite(customConfig) { + // TODO: Maybe remove suffix + const cfg = { + tagString: null, + modelValueType: String, + suffix: '', + ...customConfig, + }; + + /** + * Mocks a value for you based on the data type + * Optionally toggles you a different value + * for needing to mimic a value-change. + * + * TODO: The FormatMixin can know about platform types like + * Date, but not about modelValue of input-iban etc. + * Make this concept expandable by allowing 'non standard' + * modelValues to hook into this. + */ + function generateValueBasedOnType(opts = {}) { + const options = { type: cfg.modelValueType, toggleValue: false, viewValue: false, ...opts }; + + switch (options.type) { + case Number: + return !options.toggleValue ? '123' : '456'; + case Date: + // viewValue instead of modelValue, since a Date object is unparseable. + // Note: this viewValue needs to point to the same date as the + // default returned modelValue. + if (options.viewValue) { + return !options.toggleValue ? '5-5-2005' : '10-10-2010'; + } + return !options.toggleValue ? new Date('5-5-2005') : new Date('10-10-2010'); + case Array: + return !options.toggleValue ? ['foo', 'bar'] : ['baz', 'yay']; + case Object: + return !options.toggleValue ? { foo: 'bar' } : { bar: 'foo' }; + case Boolean: + return !options.toggleValue; + case 'email': + return !options.toggleValue ? 'some-user@ing.com' : 'another-user@ing.com'; + case 'iban': + return !options.toggleValue ? 'SE3550000000054910000003' : 'CH9300762011623852957'; + default: + return !options.toggleValue ? 'foo' : 'bar'; + } + } + + describe(`FormatMixin ${cfg.suffix ? `(${cfg.suffix})` : ''}`, async () => { + let elem; + let nonFormat; + let fooFormat; + + before(async () => { + if (!cfg.tagString) { + cfg.tagString = defineCE( + class extends FormatMixin(LitElement) { + render() { + return html` + + `; + } + + set value(newValue) { + this.inputElement.value = newValue; + } + + get value() { + return this.inputElement.value; + } + + get inputElement() { + return this.querySelector('input'); + } + }, + ); + } + + elem = unsafeStatic(cfg.tagString); + nonFormat = await fixture(html`<${elem} + .formatter="${v => v}" + .parser="${v => v}" + .serializer="${v => v}" + .deserializer="${v => v}" + > + `); + fooFormat = await fixture(html` + <${elem} + .formatter="${value => `foo: ${value}`}" + .parser="${value => value.replace('foo: ', '')}" + .serializer="${value => `[foo] ${value}`}" + .deserializer="${value => value.replace('[foo] ', '')}" + > + `); + }); + + it('fires `model-value-changed` for every change on the input', async () => { + const formatEl = await fixture(html`<${elem}>`); + + let counter = 0; + formatEl.addEventListener('model-value-changed', () => { + counter += 1; + }); + + mimicUserInput(formatEl, generateValueBasedOnType()); + expect(counter).to.equal(1); + + // Counter offset +1 for Date because parseDate created a new Date object + // when the user changes the value. + // This will result in a model-value-changed trigger even if the user value was the same + // TODO: a proper solution would be to add `hasChanged` to input-date, like isSameDate() + // from calendar utils + const counterOffset = cfg.modelValueType === Date ? 1 : 0; + + mimicUserInput(formatEl, generateValueBasedOnType()); + expect(counter).to.equal(1 + counterOffset); + + mimicUserInput(formatEl, generateValueBasedOnType({ toggleValue: true })); + expect(counter).to.equal(2 + counterOffset); + }); + + it('fires `model-value-changed` for every modelValue change', async () => { + const el = await fixture(html`<${elem}>`); + let counter = 0; + el.addEventListener('model-value-changed', () => { + counter += 1; + }); + + el.modelValue = 'one'; + expect(counter).to.equal(1); + + // no change means no event + el.modelValue = 'one'; + expect(counter).to.equal(1); + + el.modelValue = 'two'; + expect(counter).to.equal(2); + }); + + it('has modelValue, formattedValue and serializedValue which are computed synchronously', async () => { + expect(nonFormat.modelValue).to.equal('', 'modelValue initially'); + expect(nonFormat.formattedValue).to.equal('', 'formattedValue initially'); + expect(nonFormat.serializedValue).to.equal('', 'serializedValue initially'); + const generatedValue = generateValueBasedOnType(); + nonFormat.modelValue = generatedValue; + expect(nonFormat.modelValue).to.equal(generatedValue, 'modelValue as provided'); + expect(nonFormat.formattedValue).to.equal(generatedValue, 'formattedValue synchronized'); + expect(nonFormat.serializedValue).to.equal(generatedValue, 'serializedValue synchronized'); + }); + + it('has an input node (like /