diff --git a/packages/field/src/FormControlMixin.js b/packages/field/src/FormControlMixin.js index c684ff78c..109edba76 100644 --- a/packages/field/src/FormControlMixin.js +++ b/packages/field/src/FormControlMixin.js @@ -1,4 +1,4 @@ -import { html, css, nothing, dedupeMixin } from '@lion/core'; +import { html, css, nothing, dedupeMixin, SlotMixin } from '@lion/core'; import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; /** @@ -14,7 +14,7 @@ import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; export const FormControlMixin = dedupeMixin( superclass => // eslint-disable-next-line no-shadow, no-unused-vars - class FormControlMixin extends ObserverMixin(superclass) { + class FormControlMixin extends ObserverMixin(SlotMixin(superclass)) { static get properties() { return { ...super.properties, @@ -75,8 +75,21 @@ export const FormControlMixin = dedupeMixin( }; } + /** @deprecated will be this._inputNode in next breaking release */ get inputElement() { - return (this.$$slot && this.$$slot('input')) || this.querySelector('[slot=input]'); // eslint-disable-line + return this.__getDirectSlotChild('input'); + } + + get _labelNode() { + return this.__getDirectSlotChild('label'); + } + + get _helpTextNode() { + return this.__getDirectSlotChild('help-text'); + } + + get _feedbackNode() { + return this.__getDirectSlotChild('feedback'); } constructor() { @@ -107,29 +120,31 @@ export const FormControlMixin = dedupeMixin( } _enhanceLightDomA11y() { - if (this.inputElement) { - this.inputElement.id = this.inputElement.id || this._inputId; + const { inputElement, _labelNode, _helpTextNode, _feedbackNode } = this; + + if (inputElement) { + inputElement.id = inputElement.id || this._inputId; } - if (this.$$slot('label')) { - this.$$slot('label').setAttribute('for', this._inputId); - this.$$slot('label').id = this.$$slot('label').id || `label-${this._inputId}`; - const labelledById = ` ${this.$$slot('label').id}`; + if (_labelNode) { + _labelNode.setAttribute('for', this._inputId); + _labelNode.id = _labelNode.id || `label-${this._inputId}`; + const labelledById = ` ${_labelNode.id}`; if (this._ariaLabelledby.indexOf(labelledById) === -1) { - this._ariaLabelledby += ` ${this.$$slot('label').id}`; + this._ariaLabelledby += ` ${_labelNode.id}`; } } - if (this.$$slot('help-text')) { - this.$$slot('help-text').id = this.$$slot('help-text').id || `help-text-${this._inputId}`; - const describeIdHelpText = ` ${this.$$slot('help-text').id}`; + if (_helpTextNode) { + _helpTextNode.id = _helpTextNode.id || `help-text-${this._inputId}`; + const describeIdHelpText = ` ${_helpTextNode.id}`; if (this._ariaDescribedby.indexOf(describeIdHelpText) === -1) { - this._ariaDescribedby += ` ${this.$$slot('help-text').id}`; + this._ariaDescribedby += ` ${_helpTextNode.id}`; } } - if (this.$$slot('feedback')) { - this.$$slot('feedback').id = this.$$slot('feedback').id || `feedback-${this._inputId}`; - const describeIdFeedback = ` ${this.$$slot('feedback').id}`; + if (_feedbackNode) { + _feedbackNode.id = _feedbackNode.id || `feedback-${this._inputId}`; + const describeIdFeedback = ` ${_feedbackNode.id}`; if (this._ariaDescribedby.indexOf(describeIdFeedback) === -1) { - this._ariaDescribedby += ` ${this.$$slot('feedback').id}`; + this._ariaDescribedby += ` ${_feedbackNode.id}`; } } this._enhanceLightDomA11yForAdditionalSlots(); @@ -181,7 +196,7 @@ export const FormControlMixin = dedupeMixin( additionalSlots = ['prefix', 'suffix', 'before', 'after'], ) { additionalSlots.forEach(additionalSlot => { - const element = this.$$slot(additionalSlot); + const element = this.__getDirectSlotChild(additionalSlot); if (element) { element.id = element.id || `${additionalSlot}-${this._inputId}`; if (element.hasAttribute('data-label') === true) { @@ -218,14 +233,14 @@ export const FormControlMixin = dedupeMixin( } _onLabelChanged({ label }) { - if (this.$$slot && this.$$slot('label')) { - this.$$slot('label').textContent = label; + if (this._labelNode) { + this._labelNode.textContent = label; } } _onHelpTextChanged({ helpText }) { - if (this.$$slot && this.$$slot('help-text')) { - this.$$slot('help-text').textContent = helpText; + if (this._helpTextNode) { + this._helpTextNode.textContent = helpText; } } @@ -552,7 +567,7 @@ export const FormControlMixin = dedupeMixin( // Returns dom references to all elements that should be referred to by field(s) _getAriaDescriptionElements() { - return [this.$$slot('help-text'), this.$$slot('feedback')]; + return [this._helpTextNode, this._feedbackNode]; } /** @@ -574,5 +589,9 @@ export const FormControlMixin = dedupeMixin( addToAriaDescription(id) { this._ariaDescribedby += ` ${id}`; } + + __getDirectSlotChild(slotName) { + return [...this.children].find(el => el.slot === slotName); + } }, ); diff --git a/packages/field/src/FormatMixin.js b/packages/field/src/FormatMixin.js index ea33cc36d..a63fcef44 100644 --- a/packages/field/src/FormatMixin.js +++ b/packages/field/src/FormatMixin.js @@ -1,7 +1,6 @@ /* eslint-disable class-methods-use-this */ import { dedupeMixin } from '@lion/core'; -import { EventMixin } from '@lion/core/src/EventMixin.js'; import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { Unparseable } from '@lion/validate'; @@ -21,7 +20,7 @@ import { Unparseable } from '@lion/validate'; export const FormatMixin = dedupeMixin( superclass => // eslint-disable-next-line no-unused-vars, no-shadow - class FormatMixin extends EventMixin(ObserverMixin(superclass)) { + class FormatMixin extends ObserverMixin(superclass) { static get properties() { return { ...super.properties, @@ -231,7 +230,7 @@ export const FormatMixin = dedupeMixin( // imperatively, we DO want to format a value (it is the only way to get meaningful // input into `.inputElement` with modelValue as input) - if (this.__isHandlingUserInput && this.errorState) { + if (this.__isHandlingUserInput && this.errorState && this.inputElement) { return this.inputElement ? this.value : undefined; } return this.formatter(this.modelValue, this.formatOptions); @@ -336,8 +335,6 @@ export const FormatMixin = dedupeMixin( // is guaranteed to be calculated setTimeout(this._reflectBackFormattedValueToUser); }; - this.inputElement.addEventListener(this.formatOn, this._reflectBackFormattedValueDebounced); - this.inputElement.addEventListener('input', this._proxyInputEvent); this.addEventListener('user-input-changed', this._onUserInputChanged); // Connect the value found in to the formatting/parsing/serializing loop as a // fallback mechanism. Assume the user uses the value property of the @@ -348,16 +345,26 @@ export const FormatMixin = dedupeMixin( this._syncValueUpwards(); } this._reflectBackFormattedValueToUser(); + + if (this.inputElement) { + this.inputElement.addEventListener( + this.formatOn, + this._reflectBackFormattedValueDebounced, + ); + this.inputElement.addEventListener('input', this._proxyInputEvent); + } } disconnectedCallback() { super.disconnectedCallback(); - this.inputElement.removeEventListener('input', this._proxyInputEvent); this.removeEventListener('user-input-changed', this._onUserInputChanged); - this.inputElement.removeEventListener( - this.formatOn, - this._reflectBackFormattedValueDebounced, - ); + if (this.inputElement) { + this.inputElement.removeEventListener('input', this._proxyInputEvent); + this.inputElement.removeEventListener( + this.formatOn, + this._reflectBackFormattedValueDebounced, + ); + } } }, ); diff --git a/packages/field/src/InteractionStateMixin.js b/packages/field/src/InteractionStateMixin.js index 17472f870..5d9968a6b 100644 --- a/packages/field/src/InteractionStateMixin.js +++ b/packages/field/src/InteractionStateMixin.js @@ -1,8 +1,6 @@ import { dedupeMixin } from '@lion/core'; -import { CssClassMixin } from '@lion/core/src/CssClassMixin.js'; import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { Unparseable } from '@lion/validate'; -import { FocusMixin } from './FocusMixin.js'; /** * `InteractionStateMixin` adds meta information about touched and dirty states, that can @@ -16,7 +14,7 @@ import { FocusMixin } from './FocusMixin.js'; export const InteractionStateMixin = dedupeMixin( superclass => // eslint-disable-next-line no-unused-vars, no-shadow - class InteractionStateMixin extends CssClassMixin(FocusMixin(ObserverMixin(superclass))) { + class InteractionStateMixin extends ObserverMixin(superclass) { static get properties() { return { ...super.properties, @@ -25,7 +23,7 @@ export const InteractionStateMixin = dedupeMixin( */ touched: { type: Boolean, - nonEmptyToClass: 'state-touched', + reflect: true, }, /** @@ -33,7 +31,7 @@ export const InteractionStateMixin = dedupeMixin( */ dirty: { type: Boolean, - nonEmptyToClass: 'state-dirty', + reflect: true, }, /** @@ -75,7 +73,7 @@ export const InteractionStateMixin = dedupeMixin( this.touched = false; this.dirty = false; this.prefilled = false; - this.leaveEvent = 'blur'; + this._leaveEvent = 'blur'; this._valueChangedEvent = 'model-value-changed'; this._iStateOnLeave = this._iStateOnLeave.bind(this); @@ -89,7 +87,7 @@ export const InteractionStateMixin = dedupeMixin( if (super.connectedCallback) { super.connectedCallback(); } - this.addEventListener(this.leaveEvent, this._iStateOnLeave); + this.addEventListener(this._leaveEvent, this._iStateOnLeave); this.addEventListener(this._valueChangedEvent, this._iStateOnValueChange); this.initInteractionState(); } @@ -98,10 +96,21 @@ export const InteractionStateMixin = dedupeMixin( if (super.disconnectedCallback) { super.disconnectedCallback(); } - this.removeEventListener(this.leaveEvent, this._iStateOnLeave); + this.removeEventListener(this._leaveEvent, this._iStateOnLeave); this.removeEventListener(this._valueChangedEvent, this._iStateOnValueChange); } + updated(changedProperties) { + super.updated(changedProperties); + // classes are added only for backward compatibility - they are deprecated + if (changedProperties.has('touched')) { + this.classList[this.touched ? 'add' : 'remove']('state-touched'); + } + if (changedProperties.has('dirty')) { + this.classList[this.dirty ? 'add' : 'remove']('state-dirty'); + } + } + /** * Evaluations performed on connectedCallback. Since some components can be out of sync * (due to interdependence on light children that can only be processed @@ -150,5 +159,19 @@ export const InteractionStateMixin = dedupeMixin( _onDirtyChanged() { this.dispatchEvent(new CustomEvent('dirty-changed', { bubbles: true, composed: true })); } + + /** + * @deprecated + */ + get leaveEvent() { + return this._leaveEvent; + } + + /** + * @deprecated + */ + set leaveEvent(eventName) { + this._leaveEvent = eventName; + } }, ); diff --git a/packages/field/src/LionField.js b/packages/field/src/LionField.js index 7cd490e38..9b06e0c0f 100644 --- a/packages/field/src/LionField.js +++ b/packages/field/src/LionField.js @@ -8,6 +8,7 @@ import { ValidateMixin } from '@lion/validate'; import { FormControlMixin } from './FormControlMixin.js'; import { InteractionStateMixin } from './InteractionStateMixin.js'; // applies FocusMixin import { FormatMixin } from './FormatMixin.js'; +import { FocusMixin } from './FocusMixin.js'; /** * LionField: wraps components input, textarea and select and potentially others @@ -29,9 +30,11 @@ import { FormatMixin } from './FormatMixin.js'; // eslint-disable-next-line max-len, no-unused-vars export class LionField extends FormControlMixin( InteractionStateMixin( - FormatMixin( - ValidateMixin( - CssClassMixin(ElementMixin(DelegateMixin(SlotMixin(ObserverMixin(LionLitElement))))), + FocusMixin( + FormatMixin( + ValidateMixin( + CssClassMixin(ElementMixin(DelegateMixin(SlotMixin(ObserverMixin(LionLitElement))))), + ), ), ), ), diff --git a/packages/field/test/FormatMixin.test.js b/packages/field/test/FormatMixin.test.js index 1c7905364..aa004ca70 100644 --- a/packages/field/test/FormatMixin.test.js +++ b/packages/field/test/FormatMixin.test.js @@ -1,7 +1,7 @@ import { expect, fixture, html, aTimeout, defineCE, unsafeStatic } from '@open-wc/testing'; import sinon from 'sinon'; -import { LionLitElement } from '@lion/core/src/LionLitElement.js'; +import { LitElement } from '@lion/core'; import { Unparseable } from '@lion/validate'; import { FormatMixin } from '../src/FormatMixin.js'; @@ -17,7 +17,7 @@ describe('FormatMixin', () => { before(async () => { const tagString = defineCE( - class extends FormatMixin(LionLitElement) { + class extends FormatMixin(LitElement) { render() { return html` @@ -176,6 +176,14 @@ describe('FormatMixin', () => { expect(el.inputElement.value).to.equal('foo: test2'); }); + it('works if there is no underlying inputElement', async () => { + const tagNoInputString = defineCE(class extends FormatMixin(LitElement) {}); + const tagNoInput = unsafeStatic(tagNoInputString); + expect(async () => { + await fixture(html`<${tagNoInput}>`); + }).to.not.throw(); + }); + describe('parsers/formatters/serializers', () => { it('should call the parser|formatter|serializer provided by user', async () => { const formatterSpy = sinon.spy(value => `foo: ${value}`); diff --git a/packages/field/test/InteractionStateMixin.test.js b/packages/field/test/InteractionStateMixin.test.js index 06b8d476f..c95555b66 100644 --- a/packages/field/test/InteractionStateMixin.test.js +++ b/packages/field/test/InteractionStateMixin.test.js @@ -1,14 +1,28 @@ -import { expect, fixture, unsafeStatic, html, defineCE } from '@open-wc/testing'; +import { + expect, + fixture, + unsafeStatic, + html, + defineCE, + triggerFocusFor, + triggerBlurFor, +} from '@open-wc/testing'; import sinon from 'sinon'; -import { LionLitElement } from '@lion/core/src/LionLitElement.js'; +import { LitElement } from '@lion/core'; import { InteractionStateMixin } from '../src/InteractionStateMixin.js'; describe('InteractionStateMixin', async () => { - let elem; + let tagString; + let tag; before(() => { - elem = defineCE( - class IState extends InteractionStateMixin(LionLitElement) { + tagString = defineCE( + class IState extends InteractionStateMixin(LitElement) { + connectedCallback() { + super.connectedCallback(); + this.tabIndex = 0; + } + set modelValue(v) { this._modelValue = v; this.dispatchEvent( @@ -19,149 +33,157 @@ describe('InteractionStateMixin', async () => { get modelValue() { return this._modelValue; } - - get inputElement() { - return this.querySelector('input'); - } }, ); + tag = unsafeStatic(tagString); }); it('sets states to false on init', async () => { - const input = await fixture(`<${elem}>`); - expect(input.dirty).to.equal(false); - expect(input.touched).to.equal(false); - expect(input.prefilled).to.equal(false); + const el = await fixture(html`<${tag}>`); + expect(el.dirty).to.be.false; + expect(el.touched).to.be.false; + expect(el.prefilled).to.be.false; }); it('sets dirty when value changed', async () => { - const input = await fixture(`<${elem}>`); - input.modelValue = 'foobar'; - expect(input.dirty).to.equal(true); + const el = await fixture(html`<${tag}>`); + expect(el.dirty).to.be.false; + el.modelValue = 'foobar'; + expect(el.dirty).to.be.true; }); - // Skipping, since this issue (not being able to set focus on element extending from LitElement) - // only occurs in WCT context (not in Storybook/Stackblitz). - // See: https://stackblitz.com/edit/lit-element-request-update-bug-g59tjq?file=blurry.js - // it.skip it('sets touched to true when field left after focus', async () => { - // const formElement = await LionTest.htmlFixture(`<${elem}>`); - // await triggerFocusFor(formElement.inputElement); // focus/blur can't be delegated - // await triggerBlurFor(formElement.inputElement); - // expect(formElement.touched).to.equal(true); + const el = await fixture(html`<${tag}>`); + await triggerFocusFor(el); + await triggerBlurFor(el); + expect(el.touched).to.be.true; }); + // classes are added only for backward compatibility - they are deprecated it('sets a class "state-(touched|dirty)"', async () => { - const state = await fixture(`<${elem}>`); - state.touched = true; - await state.updateComplete; - expect(state.classList.contains('state-touched')).to.equal(true, 'has class "state-touched"'); + const el = await fixture(html`<${tag}>`); + el.touched = true; + await el.updateComplete; + expect(el.classList.contains('state-touched')).to.equal(true, 'has class "state-touched"'); - state.dirty = true; - await state.updateComplete; - expect(state.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"'); + el.dirty = true; + await el.updateComplete; + expect(el.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"'); + }); + + it('sets an attribute "touched', async () => { + const el = await fixture(html`<${tag}>`); + el.touched = true; + await el.updateComplete; + expect(el.hasAttribute('touched')).to.be.true; + }); + + it('sets an attribute "dirty', async () => { + const el = await fixture(html`<${tag}>`); + el.dirty = true; + await el.updateComplete; + expect(el.hasAttribute('dirty')).to.be.true; }); it('fires "(touched|dirty)-state-changed" event when state changes', async () => { - const iState = await fixture(`<${elem}>`); - const cbTouched = sinon.spy(); - const cbDirty = sinon.spy(); + const touchedSpy = sinon.spy(); + const dirtySpy = sinon.spy(); + const el = await fixture( + html`<${tag} @touched-changed=${touchedSpy} @dirty-changed=${dirtySpy}>`, + ); - iState.addEventListener('touched-changed', cbTouched); - iState.addEventListener('dirty-changed', cbDirty); + el.touched = true; + expect(touchedSpy.callCount).to.equal(1); - iState.touched = true; - expect(cbTouched.callCount).to.equal(1); - - iState.dirty = true; - expect(cbDirty.callCount).to.equal(1); - }); - - // Skipping, since this issue (not being able to set focus on element extending from LitElement) - // only occurs in WCT context (not in Storybook/Stackblitz). - // See: https://stackblitz.com/edit/lit-element-request-update-bug-g59tjq?file=blurry.js - // it.skip - it('sets prefilled to true when field left and value non-empty', async () => { - // const iState = await LionTest.htmlFixture(`<${elem}>`); - // await triggerFocusFor(iState.inputElement); - // iState.modelValue = externalVariables.prefilledModelValue || '000'; - // await triggerBlurFor(iState.inputElement); - // expect(iState.prefilled).to.equal(true); - // await triggerFocusFor(iState.inputElement); - // iState.modelValue = externalVariables.nonPrefilledModelValue || ''; - // await triggerBlurFor(iState.inputElement); - // expect(iState.prefilled).to.equal(false); + el.dirty = true; + expect(dirtySpy.callCount).to.equal(1); }); it('sets prefilled once instantiated', async () => { - const tag = unsafeStatic(elem); - const element = await fixture(html` - <${tag} - .modelValue=${'prefilled'} - >`); - expect(element.prefilled).to.equal(true); + const el = await fixture(html` + <${tag} .modelValue=${'prefilled'}> + `); + expect(el.prefilled).to.be.true; const nonPrefilled = await fixture(html` - <${tag} - .modelValue=''} - >`); - expect(nonPrefilled.prefilled).to.equal(false); + <${tag} .modelValue=${''}> + `); + expect(nonPrefilled.prefilled).to.be.false; }); // This method actually tests the implementation of the _isPrefilled method. it(`can determine "prefilled" based on different modelValue types (Arrays, Objects, Numbers, Booleans, Strings)`, async () => { - const input = await fixture(`<${elem}>`); + const el = await fixture(html`<${tag}>`); const changeModelValueAndLeave = modelValue => { - input.dispatchEvent(new Event('focus', { bubbles: true })); - input.modelValue = modelValue; - input.dispatchEvent(new Event('blur', { bubbles: true })); + el.dispatchEvent(new Event('focus', { bubbles: true })); + el.modelValue = modelValue; + el.dispatchEvent(new Event('blur', { bubbles: true })); }; // Prefilled - changeModelValueAndLeave(input, ['bla']); - expect(input.prefilled).to.equal(false, 'empty array should be considered "prefilled"'); - changeModelValueAndLeave(input, { bla: 'bla' }); - expect(input.prefilled).to.equal(false, 'empty object should be considered "prefilled"'); - changeModelValueAndLeave(input, 0); - expect(input.prefilled).to.equal(false, 'numbers should be considered "prefilled"'); - changeModelValueAndLeave(input, false); - expect(input.prefilled).to.equal(false, 'Booleans should be considered "prefilled"'); - changeModelValueAndLeave(input, ''); - expect(input.prefilled).to.equal(false, 'empty string should be considered "prefilled"'); + changeModelValueAndLeave(['not-empty']); + expect(el.prefilled, 'not empty array should be "prefilled"').to.be.true; + changeModelValueAndLeave({ not: 'empty' }); + expect(el.prefilled, 'not empty object should be "prefilled"').to.be.true; + changeModelValueAndLeave(0); + expect(el.prefilled, 'numbers should be "prefilled"').to.be.true; + changeModelValueAndLeave(false); + expect(el.prefilled, 'booleans should be "prefilled"').to.be.true; // Not prefilled - changeModelValueAndLeave(input, []); - expect(input.prefilled).to.equal(false, 'empty array should not be considered "prefilled"'); - changeModelValueAndLeave(input, {}); - expect(input.prefilled).to.equal(false, 'empty object should not be considered "prefilled"'); - changeModelValueAndLeave(input, ''); - expect(input.prefilled).to.equal(false, 'empty string should not be considered "prefilled"'); - - changeModelValueAndLeave(input, null); - expect(input.prefilled).to.equal(false, 'null should not be considered "prefilled"'); - changeModelValueAndLeave(input, undefined); - expect(input.prefilled).to.equal(false, 'undefined should not be considered "prefilled"'); + changeModelValueAndLeave([]); + expect(el.prefilled, 'empty array should not be "prefilled"').to.be.false; + changeModelValueAndLeave({}); + expect(el.prefilled, 'empty object should not be "prefilled"').to.be.false; + changeModelValueAndLeave(''); + expect(el.prefilled, 'empty string should not be "prefilled"').to.be.false; + changeModelValueAndLeave(null); + expect(el.prefilled, 'null should not be "prefilled"').to.be.false; + changeModelValueAndLeave(undefined); + expect(el.prefilled, 'undefined should not be "prefilled"').to.be.false; }); it('has a method resetInteractionState()', async () => { - const input = await fixture(`<${elem}>`); - input.dirty = true; - input.touched = true; - input.prefilled = true; - input.resetInteractionState(); - expect(input.dirty).to.equal(false); - expect(input.touched).to.equal(false); - expect(input.prefilled).to.equal(false); + const el = await fixture(html`<${tag}>`); + el.dirty = true; + el.touched = true; + el.prefilled = true; + el.resetInteractionState(); + expect(el.dirty).to.be.false; + expect(el.touched).to.be.false; + expect(el.prefilled).to.be.false; - input.dirty = true; - input.touched = true; - input.prefilled = false; - input.modelValue = 'Some value'; - input.resetInteractionState(); - expect(input.dirty).to.equal(false); - expect(input.touched).to.equal(false); - expect(input.prefilled).to.equal(true); + el.dirty = true; + el.touched = true; + el.prefilled = false; + el.modelValue = 'Some value'; + el.resetInteractionState(); + expect(el.dirty).to.be.false; + expect(el.touched).to.be.false; + expect(el.prefilled).to.be.true; + }); + + describe('SubClassers', () => { + it('can override the `_leaveEvent`', async () => { + const tagLeaveString = defineCE( + class IState extends InteractionStateMixin(LitElement) { + constructor() { + super(); + this._leaveEvent = 'custom-blur'; + } + }, + ); + const tagLeave = unsafeStatic(tagLeaveString); + const el = await fixture(html`<${tagLeave}>`); + el.dispatchEvent(new Event('custom-blur')); + expect(el.touched).to.be.true; + }); + + it('can override the deprecated `leaveEvent`', async () => { + const el = await fixture(html`<${tag} .leaveEvent=${'custom-blur'}>`); + expect(el._leaveEvent).to.equal('custom-blur'); + }); }); }); diff --git a/packages/validate/src/ValidateMixin.js b/packages/validate/src/ValidateMixin.js index 0de305129..886e7d225 100644 --- a/packages/validate/src/ValidateMixin.js +++ b/packages/validate/src/ValidateMixin.js @@ -2,7 +2,6 @@ import { dedupeMixin, SlotMixin } from '@lion/core'; import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; -import { CssClassMixin } from '@lion/core/src/CssClassMixin.js'; import { localize, LocalizeMixin } from '@lion/localize'; import { Unparseable } from './Unparseable.js'; import { randomOk } from './validators.js'; @@ -15,7 +14,7 @@ const pascalCase = str => str.charAt(0).toUpperCase() + str.slice(1); export const ValidateMixin = dedupeMixin( superclass => // eslint-disable-next-line no-unused-vars, no-shadow, max-len - class ValidateMixin extends CssClassMixin(ObserverMixin(LocalizeMixin(SlotMixin(superclass)))) { + class ValidateMixin extends ObserverMixin(LocalizeMixin(SlotMixin(superclass))) { /* * * * * * * * * * Configuration */ @@ -120,11 +119,13 @@ export const ValidateMixin = dedupeMixin( }, errorState: { type: Boolean, - nonEmptyToClass: 'state-error', + attribute: 'error-state', + reflect: true, }, errorShow: { type: Boolean, - nonEmptyToClass: 'state-error-show', + attribute: 'error-show', + reflect: true, }, warningValidators: { type: Object, @@ -134,11 +135,13 @@ export const ValidateMixin = dedupeMixin( }, warningState: { type: Boolean, - nonEmptyToClass: 'state-warning', + attribute: 'warning-state', + reflect: true, }, warningShow: { type: Boolean, - nonEmptyToClass: 'state-warning-show', + attribute: 'warning-show', + reflect: true, }, infoValidators: { type: Object, @@ -148,11 +151,13 @@ export const ValidateMixin = dedupeMixin( }, infoState: { type: Boolean, - nonEmptyToClass: 'state-info', + attribute: 'info-state', + reflect: true, }, infoShow: { type: Boolean, - nonEmptyToClass: 'state-info-show', + attribute: 'info-show', + reflect: true, }, successValidators: { type: Object, @@ -162,15 +167,17 @@ export const ValidateMixin = dedupeMixin( }, successState: { type: Boolean, - nonEmptyToClass: 'state-success', + attribute: 'success-state', + reflect: true, }, successShow: { type: Boolean, - nonEmptyToClass: 'state-success-show', + attribute: 'success-show', + reflect: true, }, invalid: { type: Boolean, - nonEmptyToClass: 'state-invalid', + reflect: true, }, message: { type: Boolean, @@ -236,6 +243,23 @@ export const ValidateMixin = dedupeMixin( return (this.$$slot && this.$$slot('feedback')) || this.querySelector('[slot="feedback"]'); } + updated(changedProperties) { + super.updated(changedProperties); + + // @deprecated adding css classes for backwards compatibility + this.constructor.validationTypes.forEach(name => { + if (changedProperties.has(`${name}State`)) { + this.classList[this[`${name}State`] ? 'add' : 'remove'](`state-${name}`); + } + if (changedProperties.has(`${name}Show`)) { + this.classList[this[`${name}Show`] ? 'add' : 'remove'](`state-${name}-show`); + } + }); + if (changedProperties.has('invalid')) { + this.classList[this.invalid ? 'add' : 'remove'](`state-invalid`); + } + } + getFieldName(validatorParams) { const label = this.label || (this.$$slot && this.$$slot('label') && this.$$slot('label').textContent); diff --git a/packages/validate/test/ValidateMixin.test.js b/packages/validate/test/ValidateMixin.test.js index 99dd36d44..f58896071 100644 --- a/packages/validate/test/ValidateMixin.test.js +++ b/packages/validate/test/ValidateMixin.test.js @@ -143,6 +143,100 @@ describe('ValidateMixin', () => { expect(otherValidatorSpy.calledWith('foo')).to.equal(true); }); + // classes are added only for backward compatibility - they are deprecated + it('sets a class "state-(error|warning|info|success|invalid)"', async () => { + const el = await fixture(html`<${tag}>`); + el.errorState = true; + await el.updateComplete; + expect(el.classList.contains('state-error')).to.equal(true, 'has class "state-error"'); + + el.warningState = true; + await el.updateComplete; + expect(el.classList.contains('state-warning')).to.equal(true, 'has class "state-warning"'); + + el.infoState = true; + await el.updateComplete; + expect(el.classList.contains('state-info')).to.equal(true, 'has class "state-info"'); + + el.successState = true; + await el.updateComplete; + expect(el.classList.contains('state-success')).to.equal(true, 'has class "state-success"'); + + el.invalid = true; + await el.updateComplete; + expect(el.classList.contains('state-invalid')).to.equal(true, 'has class "state-invalid"'); + }); + + it('sets a class "state-(error|warning|info|success)-show"', async () => { + const el = await fixture(html`<${tag}>`); + el.errorShow = true; + await el.updateComplete; + expect(el.classList.contains('state-error-show')).to.equal( + true, + 'has class "state-error-show"', + ); + + el.warningShow = true; + await el.updateComplete; + expect(el.classList.contains('state-warning-show')).to.equal( + true, + 'has class "state-warning-show"', + ); + + el.infoShow = true; + await el.updateComplete; + expect(el.classList.contains('state-info-show')).to.equal(true, 'has class "state-info-show"'); + + el.successShow = true; + await el.updateComplete; + expect(el.classList.contains('state-success-show')).to.equal( + true, + 'has class "state-success-show"', + ); + }); + + it('sets attribute "(error|warning|info|success|invalid)-state"', async () => { + const el = await fixture(html`<${tag}>`); + el.errorState = true; + await el.updateComplete; + expect(el.hasAttribute('error-state'), 'has error-state attribute').to.be.true; + + el.warningState = true; + await el.updateComplete; + expect(el.hasAttribute('warning-state'), 'has warning-state attribute').to.be.true; + + el.infoState = true; + await el.updateComplete; + expect(el.hasAttribute('info-state'), 'has info-state attribute').to.be.true; + + el.successState = true; + await el.updateComplete; + expect(el.hasAttribute('success-state'), 'has error-state attribute').to.be.true; + + el.invalid = true; + await el.updateComplete; + expect(el.hasAttribute('invalid'), 'has invalid attribute').to.be.true; + }); + + it('sets attribute "(error|warning|info|success)-show"', async () => { + const el = await fixture(html`<${tag}>`); + el.errorShow = true; + await el.updateComplete; + expect(el.hasAttribute('error-show'), 'has error-show attribute').to.be.true; + + el.warningShow = true; + await el.updateComplete; + expect(el.hasAttribute('warning-show'), 'has warning-show attribute').to.be.true; + + el.infoShow = true; + await el.updateComplete; + expect(el.hasAttribute('info-show'), 'has info-show attribute').to.be.true; + + el.successShow = true; + await el.updateComplete; + expect(el.hasAttribute('success-show'), 'has success-show attribute').to.be.true; + }); + describe(`Validators ${suffixName}`, () => { function isCat(modelValue, opts) { const validateString = opts && opts.number ? `cat${opts.number}` : 'cat';