diff --git a/packages/checkbox-group/src/LionCheckboxGroup.js b/packages/checkbox-group/src/LionCheckboxGroup.js index 2f4a1643e..bf52e2e0f 100644 --- a/packages/checkbox-group/src/LionCheckboxGroup.js +++ b/packages/checkbox-group/src/LionCheckboxGroup.js @@ -1,73 +1,6 @@ import { LionFieldset } from '@lion/fieldset'; export class LionCheckboxGroup extends LionFieldset { - constructor() { - super(); - this._checkboxGroupTouched = false; - this._setTouchedAndPrefilled = this._setTouchedAndPrefilled.bind(this); - this._checkForOutsideClick = this._checkForOutsideClick.bind(this); - this._checkForChildrenClick = this._checkForChildrenClick.bind(this); - } - - connectedCallback() { - super.connectedCallback(); - // We listen for focusin(instead of foxus), because it bubbles and gives the right event order - window.addEventListener('focusin', this._setTouchedAndPrefilled); - - document.addEventListener('click', this._checkForOutsideClick); - this.addEventListener('click', this._checkForChildrenClick); - - // checks for any of the children to be prefilled - this._checkboxGroupPrefilled = super.prefilled; - } - - disconnectedCallback() { - super.disconnectedCallback(); - window.removeEventListener('focusin', this._setTouchedAndPrefilled); - document.removeEventListener('click', this._checkForOutsideClick); - this.removeEventListener('click', this._checkForChildrenClick); - } - - get touched() { - return this._checkboxGroupTouched; - } - - /** - * Leave event will be fired when previous document.activeElement - * is inside group and current document.activeElement is outside. - */ - _setTouchedAndPrefilled() { - const groupHasFocus = this.focused; - if (this.__groupHadFocus && !groupHasFocus) { - this._checkboxGroupTouched = true; - this._checkboxGroupPrefilled = super.prefilled; // right time to reconsider prefilled - this.__checkboxGroupPrefilledHasBeenSet = true; - } - this.__groupHadFocus = groupHasFocus; - } - - _checkForOutsideClick(event) { - const outsideGroupClicked = !this.contains(event.target); - if (outsideGroupClicked) { - this._setTouchedAndPrefilled(); - } - } - - // Whenever a user clicks a checkbox, error messages should become visible - _checkForChildrenClick(event) { - const childClicked = this._childArray.some(c => c === event.target || c.contains(event.target)); - if (childClicked) { - this._checkboxGroupTouched = true; - } - } - - get _childArray() { - // We assume here that the fieldset has one set of checkboxes/radios that are grouped via attr - // name="groupName[]" - const arrayKey = Object.keys(this.formElements).filter(k => k.substr(-2) === '[]')[0]; - return this.formElements[arrayKey] || []; - } - // eslint-disable-next-line class-methods-use-this __isRequired(modelValues) { const keys = Object.keys(modelValues); diff --git a/packages/checkbox-group/stories/index.stories.js b/packages/checkbox-group/stories/index.stories.js index 7f7cf9e1f..46fe26558 100644 --- a/packages/checkbox-group/stories/index.stories.js +++ b/packages/checkbox-group/stories/index.stories.js @@ -3,6 +3,7 @@ import { storiesOf, html } from '@open-wc/demoing-storybook'; import '../lion-checkbox-group.js'; import '@lion/checkbox/lion-checkbox.js'; import '@lion/form/lion-form.js'; +import { localize } from '@lion/localize'; storiesOf('Forms|Checkbox Group', module) .add( @@ -123,4 +124,58 @@ storiesOf('Forms|Checkbox Group', module) `; + }) + .add('Validation 2 checked', () => { + const hasMinTwoChecked = value => { + const selectedValues = value['scientists[]'].filter(v => v.checked === true); + return { + hasMinTwoChecked: selectedValues.length >= 2, + }; + }; + localize.locale = 'en-GB'; + try { + localize.addData('en-GB', 'lion-validate+hasMinTwoChecked', { + error: { + hasMinTwoChecked: 'You need to select at least 2 values', + }, + }); + } catch (error) { + // expected as it's a demo + } + + const submit = () => { + const form = document.querySelector('#form'); + if (form.errorState === false) { + console.log(form.serializeGroup()); + } + }; + return html` +
+ + + + + + +
+ `; }); diff --git a/packages/checkbox-group/test/lion-checkbox-group.test.js b/packages/checkbox-group/test/lion-checkbox-group.test.js index e439858ec..acc9ad8da 100644 --- a/packages/checkbox-group/test/lion-checkbox-group.test.js +++ b/packages/checkbox-group/test/lion-checkbox-group.test.js @@ -1,5 +1,4 @@ -import { expect, html, fixture, triggerFocusFor, nextFrame } from '@open-wc/testing'; -import sinon from 'sinon'; +import { expect, html, fixture, nextFrame } from '@open-wc/testing'; import { localizeTearDown } from '@lion/localize/test-helpers.js'; @@ -11,87 +10,6 @@ beforeEach(() => { }); describe('', () => { - // Note: these requirements seem to hold for checkbox-group only, not for radio-group (since we - // cannot tab through all input elements). - - it(`becomes "touched" once the last element of a group becomes blurred by keyboard - interaction (e.g. tabbing through the checkbox-group)`, async () => { - const el = await fixture(` - - - - - - `); - await nextFrame(); - - const button = await fixture(``); - - el.children[1].focus(); - expect(el.touched).to.equal(false, 'initially, touched state is false'); - el.children[2].focus(); - expect(el.touched).to.equal(false, 'focus is on second checkbox'); - button.focus(); - expect(el.touched).to.equal( - true, - `focus is on element behind second checkbox - (group has blurred)`, - ); - }); - - it(`becomes "touched" once the group as a whole becomes blurred via mouse interaction after - keyboard interaction (e.g. focus is moved inside the group and user clicks somewhere outside - the group)`, async () => { - const groupWrapper = await fixture(` -
- - - - - -
- `); - await nextFrame(); - - const el = groupWrapper.children[0]; - await el.children[1].updateComplete; - el.children[1].focus(); - expect(el.touched).to.equal(false, 'initially, touched state is false'); - el.children[2].focus(); // simulate tab - expect(el.touched).to.equal(false, 'focus is on second checkbox'); - // simulate click outside - sinon.spy(el, '_setTouchedAndPrefilled'); - groupWrapper.click(); // blur the group via a click - expect(el._setTouchedAndPrefilled.callCount).to.equal(1); - // For some reason, document.activeElement is not updated after groupWrapper.click() (this - // happens on user clicks, not on imperative clicks). So we check if the private callbacks - // for outside clicks are called (they trigger _setTouchedAndPrefilled call). - // To make sure focus is moved, we 'help' the test here to mimic browser behavior. - // groupWrapper.focus(); - await triggerFocusFor(groupWrapper); - expect(el.touched).to.equal(true, 'focus is on element outside checkbox group'); - }); - - it(`becomes "touched" once a single element of the group becomes "touched" via mouse interaction - (e.g. user clicks on checkbox)`, async () => { - const el = await fixture(` - - - - - `); - await nextFrame(); - - el.children[1].focus(); - expect(el.touched).to.equal(false, 'initially, touched state is false'); - el.children[1].click(); - expect(el.touched).to.equal( - true, - `focus is initiated via a mouse event, thus - fieldset/checkbox-group as a whole is considered touched`, - ); - }); - it('can be required', async () => { const el = await fixture(html` diff --git a/packages/field/src/FormControlMixin.js b/packages/field/src/FormControlMixin.js index 3fb099ae6..86562cb6c 100644 --- a/packages/field/src/FormControlMixin.js +++ b/packages/field/src/FormControlMixin.js @@ -495,7 +495,7 @@ export const FormControlMixin = dedupeMixin( * an error message shouldn't be shown either. * */ - return (this.touched && this.dirty && !this.prefilled) || this.prefilled || this.submitted; + return (this.touched && this.dirty) || this.prefilled || this.submitted; } // aria-labelledby and aria-describedby helpers diff --git a/packages/field/src/FormRegistrarMixin.js b/packages/field/src/FormRegistrarMixin.js index a1e283d76..4de54eb15 100644 --- a/packages/field/src/FormRegistrarMixin.js +++ b/packages/field/src/FormRegistrarMixin.js @@ -63,12 +63,16 @@ export const FormRegistrarMixin = dedupeMixin( this.__hasBeenRendered = true; } - addFormElement(child) { + addFormElement(child, index) { // This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent // eslint-disable-next-line no-param-reassign child.__parentFormGroup = this; - this.formElements.push(child); + if (index > 0) { + this.formElements.splice(index, 0, child); + } else { + this.formElements.push(child); + } } removeFormElement(child) { @@ -89,7 +93,14 @@ export const FormRegistrarMixin = dedupeMixin( return; } ev.stopPropagation(); - this.addFormElement(child); + + // Check for siblings to determine the right order to insert into formElements + // If there is no next sibling, index is -1 + let indexToInsertAt = -1; + if (this.formElements && Array.isArray(this.formElements)) { + indexToInsertAt = this.formElements.indexOf(child.nextElementSibling); + } + this.addFormElement(child, indexToInsertAt); } _onRequestToRemoveFormElement(ev) { diff --git a/packages/field/src/FormatMixin.js b/packages/field/src/FormatMixin.js index e321aafd9..6f92e60b8 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 { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { Unparseable } from '@lion/validate'; // For a future breaking release: @@ -50,7 +49,7 @@ import { Unparseable } from '@lion/validate'; export const FormatMixin = dedupeMixin( superclass => // eslint-disable-next-line no-unused-vars, no-shadow - class FormatMixin extends ObserverMixin(superclass) { + class FormatMixin extends superclass { static get properties() { return { /** @@ -121,13 +120,24 @@ export const FormatMixin = dedupeMixin( }; } - static get syncObservers() { - return { - ...super.syncObservers, - _onModelValueChanged: ['modelValue'], - _onSerializedValueChanged: ['serializedValue'], - _onFormattedValueChanged: ['formattedValue'], - }; + _requestUpdate(name, oldVal) { + super._requestUpdate(name, oldVal); + + if (name === 'modelValue' && this.modelValue !== oldVal) { + this._onModelValueChanged({ modelValue: this.modelValue }, { modelValue: oldVal }); + } + if (name === 'serializedValue' && this.serializedValue !== oldVal) { + this._onSerializedValueChanged( + { serializedValue: this.serializedValue }, + { serializedValue: oldVal }, + ); + } + if (name === 'formattedValue' && this.formattedValue !== oldVal) { + this._onFormattedValueChanged( + { formattedValue: this.formattedValue }, + { formattedValue: oldVal }, + ); + } } /** diff --git a/packages/field/src/InteractionStateMixin.js b/packages/field/src/InteractionStateMixin.js index 3abac16aa..e50e2798d 100644 --- a/packages/field/src/InteractionStateMixin.js +++ b/packages/field/src/InteractionStateMixin.js @@ -1,5 +1,4 @@ import { dedupeMixin } from '@lion/core'; -import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { Unparseable } from '@lion/validate'; /** @@ -15,7 +14,7 @@ import { Unparseable } from '@lion/validate'; export const InteractionStateMixin = dedupeMixin( superclass => // eslint-disable-next-line no-unused-vars, no-shadow - class InteractionStateMixin extends ObserverMixin(superclass) { + class InteractionStateMixin extends superclass { static get properties() { return { /** @@ -45,12 +44,15 @@ export const InteractionStateMixin = dedupeMixin( }; } - static get syncObservers() { - return { - ...super.syncObservers, - _onTouchedChanged: ['touched'], - _onDirtyChanged: ['dirty'], - }; + _requestUpdate(name, oldVal) { + super._requestUpdate(name, oldVal); + if (name === 'touched' && this.touched !== oldVal) { + this._onTouchedChanged(); + } + + if (name === 'dirty' && this.dirty !== oldVal) { + this._onDirtyChanged(); + } } static _isPrefilled(modelValue) { diff --git a/packages/field/src/LionField.js b/packages/field/src/LionField.js index ba4cf1bf3..c87118525 100644 --- a/packages/field/src/LionField.js +++ b/packages/field/src/LionField.js @@ -49,6 +49,10 @@ export class LionField extends FormControlMixin( type: String, reflect: true, }, + autocomplete: { + type: String, + reflect: true, + }, }; } @@ -141,6 +145,10 @@ export class LionField extends FormControlMixin( if (changedProps.has('name')) { this.inputElement.name = this.name; } + + if (changedProps.has('autocomplete')) { + this.inputElement.autocomplete = this.autocomplete; + } } /** diff --git a/packages/field/test-suites/FormRegistrationMixins.suite.js b/packages/field/test-suites/FormRegistrationMixins.suite.js index ab2b9ac12..08c60faa4 100644 --- a/packages/field/test-suites/FormRegistrationMixins.suite.js +++ b/packages/field/test-suites/FormRegistrationMixins.suite.js @@ -168,6 +168,44 @@ export const runRegistrationSuite = customConfig => { expect(el.formElements.length).to.equal(1); }); + it('adds elements to formElements in the right order', async () => { + const el = await fixture(html` + <${parentTag}> + <${childTag}> + <${childTag}> + <${childTag}> + + `); + + expect(el.formElements.length).to.equal(3); + + // In the middle + const secondChild = el.firstElementChild.nextElementSibling; + const newField = await fixture(html` + <${childTag}> + `); + secondChild.insertAdjacentElement('beforebegin', newField); + + expect(el.formElements.length).to.equal(4); + expect(el.formElements[1]).dom.to.equal(newField); + + // Prepending + const anotherField = await fixture(html` + <${childTag}> + `); + el.prepend(anotherField); + expect(el.formElements.length).to.equal(5); + expect(el.formElements[0]).dom.to.equal(anotherField); + + // Appending + const yetAnotherField = await fixture(html` + <${childTag}> + `); + el.appendChild(yetAnotherField); + expect(el.formElements.length).to.equal(6); + expect(el.formElements[5]).dom.to.equal(anotherField); + }); + // find a proper way to do this on polyfilled browsers it.skip('fires event "form-element-register" with the child as ev.target', async () => { const registerSpy = sinon.spy(); diff --git a/packages/field/test/lion-field.test.js b/packages/field/test/lion-field.test.js index 1377359ff..7b0f05e98 100644 --- a/packages/field/test/lion-field.test.js +++ b/packages/field/test/lion-field.test.js @@ -124,6 +124,17 @@ describe('', () => { expect(el.$$slot('input').value).to.equal('one'); }); + // This is necessary for security, so that inputElements autocomplete can be set to 'off' + it('delegates autocomplete property', async () => { + const el = await fixture(html`<${tag}>${inputSlot}`); + expect(el.inputElement.autocomplete).to.equal(''); + expect(el.inputElement.hasAttribute('autocomplete')).to.be.false; + el.autocomplete = 'off'; + await el.updateComplete; + expect(el.inputElement.autocomplete).to.equal('off'); + expect(el.inputElement.getAttribute('autocomplete')).to.equal('off'); + }); + // TODO: find out if we could put all listeners on this.value (instead of this.inputElement.value) // and make it act on this.value again it('has a class "state-filled" if this.value is filled', async () => { diff --git a/packages/fieldset/src/LionFieldset.js b/packages/fieldset/src/LionFieldset.js index 531a092b5..6e04af64b 100644 --- a/packages/fieldset/src/LionFieldset.js +++ b/packages/fieldset/src/LionFieldset.js @@ -1,5 +1,4 @@ -import { SlotMixin, html } from '@lion/core'; -import { LionLitElement } from '@lion/core/src/LionLitElement.js'; +import { SlotMixin, html, LitElement } from '@lion/core'; import { DisabledMixin } from '@lion/core/src/DisabledMixin.js'; import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { ValidateMixin } from '@lion/validate'; @@ -15,7 +14,7 @@ const pascalCase = str => str.charAt(0).toUpperCase() + str.slice(1); * @extends LionLitElement */ export class LionFieldset extends FormRegistrarMixin( - FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(ObserverMixin(LionLitElement))))), + FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(ObserverMixin(LitElement))))), ) { static get properties() { return { @@ -24,11 +23,33 @@ export class LionFieldset extends FormRegistrarMixin( }, submitted: { type: Boolean, - nonEmptyToClass: 'state-submitted', + reflect: true, + }, + focused: { + type: Boolean, + reflect: true, + }, + dirty: { + type: Boolean, + reflect: true, + }, + touched: { + type: Boolean, + reflect: true, }, }; } + get touched() { + return this.__touched; + } + + set touched(value) { + const oldVal = this.__touched; + this.__touched = value; + this.requestUpdate('touched', oldVal); + } + get inputElement() { return this; } @@ -57,20 +78,8 @@ export class LionFieldset extends FormRegistrarMixin( this._setValueMapForAllFormElements('formattedValue', values); } - get touched() { - return this._anyFormElementHas('touched'); - } - - get dirty() { - return this._anyFormElementHas('dirty'); - } - get prefilled() { - return this._anyFormElementHas('prefilled'); - } - - get focused() { - return this._anyFormElementHas('focused'); + return this._everyFormElementHas('prefilled'); } get formElementsArray() { @@ -84,28 +93,33 @@ export class LionFieldset extends FormRegistrarMixin( super(); this.disabled = false; this.submitted = false; + this.dirty = false; + this.touched = false; + this.focused = false; this.formElements = {}; this.__addedSubValidators = false; this.__createTypeAbsenceValidators(); + + this._checkForOutsideClick = this._checkForOutsideClick.bind(this); + + this.addEventListener('focusin', this._syncFocused); + this.addEventListener('focusout', this._onFocusOut); + this.addEventListener('validation-done', this.__validate); + this.addEventListener('dirty-changed', this._syncDirty); } connectedCallback() { - // eslint-disable-next-line wc/guard-super-call - super.connectedCallback(); - this.addEventListener('validation-done', this.__validate); - this.addEventListener('focused-changed', this._updateFocusedClass); - this.addEventListener('touched-changed', this._updateTouchedClass); - this.addEventListener('dirty-changed', this._updateDirtyClass); + super.connectedCallback(); // eslint-disable-line wc/guard-super-call this._setRole(); } disconnectedCallback() { - // eslint-disable-next-line wc/guard-super-call - super.disconnectedCallback(); - this.removeEventListener('validation-done', this.__validate); - this.removeEventListener('focused-changed', this._updateFocusedClass); - this.removeEventListener('touched-changed', this._updateTouchedClass); - this.removeEventListener('dirty-changed', this._updateDirtyClass); + super.disconnectedCallback(); // eslint-disable-line wc/guard-super-call + + if (this.__hasActiveOutsideClickHandling) { + document.removeEventListener('click', this._checkForOutsideClick); + this.__hasActiveOutsideClickHandling = false; + } } updated(changedProps) { @@ -114,12 +128,45 @@ export class LionFieldset extends FormRegistrarMixin( if (changedProps.has('disabled')) { if (this.disabled) { this.__requestChildrenToBeDisabled(); + /** @deprecated use disabled attribute instead */ this.classList.add('state-disabled'); // eslint-disable-line wc/no-self-class } else { this.__retractRequestChildrenToBeDisabled(); + /** @deprecated use disabled attribute instead */ this.classList.remove('state-disabled'); // eslint-disable-line wc/no-self-class } } + if (changedProps.has('touched')) { + /** @deprecated use touched attribute instead */ + this.classList[this.touched ? 'add' : 'remove']('state-touched'); + } + + if (changedProps.has('dirty')) { + /** @deprecated use dirty attribute instead */ + this.classList[this.dirty ? 'add' : 'remove']('state-dirty'); + } + + if (changedProps.has('focused')) { + /** @deprecated use touched attribute instead */ + this.classList[this.focused ? 'add' : 'remove']('state-focused'); + if (this.focused === true) { + this.__setupOutsideClickHandling(); + } + } + } + + __setupOutsideClickHandling() { + if (!this.__hasActiveOutsideClickHandling) { + document.addEventListener('click', this._checkForOutsideClick); + this.__hasActiveOutsideClickHandling = true; + } + } + + _checkForOutsideClick(event) { + const outsideGroupClicked = !this.contains(event.target); + if (outsideGroupClicked) { + this.touched = true; + } } __requestChildrenToBeDisabled() { @@ -190,6 +237,8 @@ export class LionFieldset extends FormRegistrarMixin( resetInteractionState() { // TODO: add submitted prop to InteractionStateMixin this.submitted = false; + this.touched = false; + this.dirty = false; this.formElementsArray.forEach(formElement => { if (typeof formElement.resetInteractionState === 'function') { formElement.resetInteractionState(); @@ -251,6 +300,15 @@ export class LionFieldset extends FormRegistrarMixin( }); } + _everyFormElementHas(property) { + return Object.keys(this.formElements).every(name => { + if (Array.isArray(this.formElements[name])) { + return this.formElements[name].every(el => !!el[property]); + } + return !!this.formElements[name][property]; + }); + } + /** * Gets triggered by event 'validation-done' which enabled us to handle 2 different situations * - react on modelValue change, which says something about the validity as a whole @@ -263,16 +321,20 @@ export class LionFieldset extends FormRegistrarMixin( } } - _updateFocusedClass() { - this.classList[this.touched ? 'add' : 'remove']('state-focused'); + _syncFocused() { + this.focused = this._anyFormElementHas('focused'); } - _updateTouchedClass() { - this.classList[this.touched ? 'add' : 'remove']('state-touched'); + _onFocusOut(ev) { + const lastEl = this.formElementsArray[this.formElementsArray.length - 1]; + if (ev.target === lastEl) { + this.touched = true; + } + this.focused = false; } - _updateDirtyClass() { - this.classList[this.dirty ? 'add' : 'remove']('state-dirty'); + _syncDirty() { + this.dirty = this._anyFormElementHas('dirty'); } _setRole(role) { diff --git a/packages/fieldset/stories/index.stories.js b/packages/fieldset/stories/index.stories.js index e4b291b93..7c59afc00 100644 --- a/packages/fieldset/stories/index.stories.js +++ b/packages/fieldset/stories/index.stories.js @@ -1,6 +1,10 @@ import { storiesOf, html } from '@open-wc/demoing-storybook'; import '../lion-fieldset.js'; +import { localize } from '@lion/localize'; +import { minLengthValidator } from '@lion/validate'; + +import '../../form-system/stories/helper-wc/h-output.js'; storiesOf('Forms|Fieldset', module) .add( @@ -79,4 +83,129 @@ storiesOf('Forms|Fieldset', module) `, - ); + ) + .add('Validation', () => { + function isDemoValidator() { + return false; + } + + const demoValidator = (...factoryParams) => [ + (...params) => ({ validator: isDemoValidator(...params) }), + ...factoryParams, + ]; + + try { + localize.addData('en-GB', 'lion-validate+validator', { + error: { + validator: 'Demo error message', + }, + }); + } catch (error) { + // expected as it's a demo + } + + return html` + + + + + +
+
+ + `; + }) + .add('Validation 2 inputs', () => { + const isCatsAndDogs = value => ({ + isCatsAndDogs: value.input1 === 'cats' && value.input2 === 'dogs', + }); + localize.locale = 'en-GB'; + try { + localize.addData('en-GB', 'lion-validate+isCatsAndDogs', { + error: { + isCatsAndDogs: + '[Fieldset Error] Input 1 needs to be "cats" and Input 2 needs to be "dogs"', + }, + }); + } catch (error) { + // expected as it's a demo + } + + return html` + + + + + `; + }) + .add('Validation 2 fieldsets', () => { + const isCats = value => ({ + isCats: value.input1 === 'cats', + }); + localize.locale = 'en-GB'; + try { + localize.addData('en-GB', 'lion-validate+isCats', { + error: { + isCats: '[Fieldset Nr. 1 Error] Input 1 needs to be "cats"', + }, + }); + } catch (error) { + // expected as it's a demo + } + + const isDogs = value => ({ + isDogs: value.input1 === 'dogs', + }); + localize.locale = 'en-GB'; + try { + localize.addData('en-GB', 'lion-validate+isDogs', { + error: { + isDogs: '[Fieldset Nr. 2 Error] Input 1 needs to be "dogs"', + }, + }); + } catch (error) { + // expected as it's a demo + } + + return html` + + + + +
+
+
+ + + + + `; + }); diff --git a/packages/fieldset/test/lion-fieldset.test.js b/packages/fieldset/test/lion-fieldset.test.js index 8bc3d15ff..d07ca0aae 100644 --- a/packages/fieldset/test/lion-fieldset.test.js +++ b/packages/fieldset/test/lion-fieldset.test.js @@ -1,12 +1,4 @@ -import { - expect, - fixture, - html, - unsafeStatic, - triggerFocusFor, - triggerBlurFor, - nextFrame, -} from '@open-wc/testing'; +import { expect, fixture, html, unsafeStatic, triggerFocusFor, nextFrame } from '@open-wc/testing'; import sinon from 'sinon'; import { localizeTearDown } from '@lion/localize/test-helpers.js'; import '@lion/input/lion-input.js'; @@ -23,8 +15,6 @@ const inputSlots = html` <${childTag} name="hobbies[]"> <${childTag} name="hobbies[]"> `; -const nonPrefilledModelValue = ''; -const prefilledModelValue = 'prefill'; beforeEach(() => { localizeTearDown(); @@ -342,66 +332,164 @@ describe('', () => { expect(fieldset.dirty).to.equal(true); }); - it('sets touched when field left after focus', async () => { + it('sets touched when last field in fieldset left after focus', async () => { const fieldset = await fixture(html`<${tag}>${inputSlots}`); - await nextFrame(); - await triggerFocusFor(fieldset.formElements['gender[]'][0].inputElement); - await triggerBlurFor(fieldset.formElements['gender[]'][0].inputElement); - expect(fieldset.touched).to.equal(true); - }); - - it('sets a class "state-(touched|dirty)"', async () => { - const fieldset = await fixture(html`<${tag}>${inputSlots}`); - await nextFrame(); - fieldset.formElements.color.touched = true; - await fieldset.updateComplete; - expect(fieldset.classList.contains('state-touched')).to.equal( - true, - 'has class "state-touched"', + await triggerFocusFor(fieldset.formElements['hobbies[]'][0].inputElement); + await triggerFocusFor( + fieldset.formElements['hobbies[]'][fieldset.formElements['gender[]'].length - 1] + .inputElement, ); + const el = await fixture(html` + + `); + el.focus(); - fieldset.formElements.color.dirty = true; - await fieldset.updateComplete; - expect(fieldset.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"'); + expect(fieldset.touched).to.be.true; }); - it('sets prefilled when field left and value non-empty', async () => { - const fieldset = await fixture(html`<${tag}>${inputSlots}`); - await nextFrame(); - fieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' }; - fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'football' }; - fieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' }; - fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' }; + it('sets attributes [touched][dirty]', async () => { + const el = await fixture(html`<${tag}>`); + el.touched = true; + await el.updateComplete; + expect(el).to.have.attribute('touched'); - fieldset.formElements.color.modelValue = nonPrefilledModelValue; - await triggerFocusFor(fieldset.formElements.color.inputElement); - fieldset.formElements.color.modelValue = prefilledModelValue; - await triggerBlurFor(fieldset.formElements.color.inputElement); - expect(fieldset.prefilled).to.equal(true, 'sets prefilled when left non empty'); - - await triggerFocusFor(fieldset.formElements.color.inputElement); - fieldset.formElements.color.modelValue = nonPrefilledModelValue; - await triggerBlurFor(fieldset.formElements.color.inputElement); - expect(fieldset.prefilled).to.equal(false, 'unsets prefilled when left empty'); + el.dirty = true; + await el.updateComplete; + expect(el).to.have.attribute('dirty'); }); - it('sets prefilled once instantiated', async () => { - // no prefilled when nothing has value - const fieldsetNotPrefilled = await fixture(html`<${tag}>${inputSlots}`); - expect(fieldsetNotPrefilled.prefilled).to.equal(false, 'not prefilled on init'); + it('[deprecated] sets a class "state-(touched|dirty)"', async () => { + 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"'); - // prefilled when at least one child has value - const fieldsetPrefilled = await fixture(html` + el.dirty = true; + await el.updateComplete; + expect(el.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"'); + }); + + it('becomes prefilled if all form elements are prefilled', async () => { + const el = await fixture(html` <${tag}> - <${childTag} name="gender[]" .modelValue=${prefilledModelValue}> - <${childTag} name="gender[]"> - <${childTag} name="color"> - <${childTag} name="hobbies[]"> - <${childTag} name="hobbies[]"> + <${childTag} name="input1" prefilled> + <${childTag} name="input2"> `); await nextFrame(); - expect(fieldsetPrefilled.prefilled).to.equal(true, 'prefilled on init'); + expect(el.prefilled).to.be.false; + + const el2 = await fixture(html` + <${tag}> + <${childTag} name="input1" prefilled> + <${childTag} name="input2" prefilled> + + `); + await nextFrame(); + expect(el2.prefilled).to.be.true; + }); + + it(`becomes "touched" once the last element of a group becomes blurred by keyboard + interaction (e.g. tabbing through the checkbox-group)`, async () => { + const el = await fixture(html` + <${tag}> + + <${childTag} name="myGroup[]" label="Option 1" value="1"> + <${childTag} name="myGroup[]" label="Option 2" value="2"> + + `); + await nextFrame(); + + const button = await fixture(``); + + expect(el.touched).to.equal(false, 'initially, touched state is false'); + el.children[2].focus(); + expect(el.touched).to.equal(false, 'focus is on second checkbox'); + button.focus(); + expect(el.touched).to.equal( + true, + `focus is on element behind second checkbox (group has blurred)`, + ); + }); + + it(`becomes "touched" once the group as a whole becomes blurred via mouse interaction after + keyboard interaction (e.g. focus is moved inside the group and user clicks somewhere outside + the group)`, async () => { + const el = await fixture(html` + <${tag}> + <${childTag} name="input1"> + <${childTag} name="input2"> + + `); + const el2 = await fixture(html` + <${tag}> + <${childTag} name="input1"> + <${childTag} name="input2"> + + `); + + await nextFrame(); + const outside = await fixture(html` + + `); + + outside.click(); + expect(el.touched, 'unfocused fieldset should stays untouched').to.be.false; + + el.children[1].focus(); + el.children[2].focus(); + expect(el.touched).to.be.false; + + outside.click(); // blur the group via a click + outside.focus(); // a real mouse click moves focus as well + expect(el.touched).to.be.true; + + expect(el2.touched).to.be.false; + }); + + it('potentially shows fieldset error message on interaction change', async () => { + const input1IsTen = value => ({ input1IsTen: value.input1 === 10 }); + const isNumber = value => ({ isNumber: typeof value === 'number' }); + const outSideButton = await fixture(html` + + `); + const el = await fixture(html` + <${tag} .errorValidators=${[[input1IsTen]]}> + <${childTag} name="input1" .errorValidators=${[[isNumber]]}> + + `); + await nextFrame(); + const input1 = el.querySelector(childTagString); + input1.modelValue = 2; + input1.focus(); + outSideButton.focus(); + + await el.updateComplete; + expect(el.error.input1IsTen).to.be.true; + expect(el.errorShow).to.be.true; + }); + + it('show error if tabbing "out" of last ', async () => { + const input1IsTen = value => ({ input1IsTen: value.input1 === 10 }); + const isNumber = value => ({ isNumber: typeof value === 'number' }); + const outSideButton = await fixture(html` + + `); + const el = await fixture(html` + <${tag} .errorValidators=${[[input1IsTen]]}> + <${childTag} name="input1" .errorValidators=${[[isNumber]]}> + <${childTag} name="input2" .errorValidators=${[[isNumber]]}> + + `); + const inputs = el.querySelectorAll(childTagString); + inputs[1].modelValue = 2; // make it dirty + inputs[1].focus(); + + outSideButton.focus(); + await nextFrame(); + + expect(el.error.input1IsTen).to.be.true; + expect(el.errorShow).to.be.true; }); }); @@ -609,32 +697,28 @@ describe('', () => { }); it('clears interaction state', async () => { - const fieldset = await fixture(html`<${tag}>${inputSlots}`); + const el = await fixture(html`<${tag} touched dirty>${inputSlots}`); await nextFrame(); // Safety check initially - fieldset._setValueForAllFormElements('dirty', true); - fieldset._setValueForAllFormElements('touched', true); - fieldset._setValueForAllFormElements('prefilled', true); - expect(fieldset.dirty).to.equal(true, '"dirty" initially'); - expect(fieldset.touched).to.equal(true, '"touched" initially'); - expect(fieldset.prefilled).to.equal(true, '"prefilled" initially'); + el._setValueForAllFormElements('prefilled', true); + expect(el.dirty).to.equal(true, '"dirty" initially'); + expect(el.touched).to.equal(true, '"touched" initially'); + expect(el.prefilled).to.equal(true, '"prefilled" initially'); // Reset all children states, with prefilled false - fieldset._setValueForAllFormElements('modelValue', {}); - fieldset.resetInteractionState(); - expect(fieldset.dirty).to.equal(false, 'not "dirty" after reset'); - expect(fieldset.touched).to.equal(false, 'not "touched" after reset'); - expect(fieldset.prefilled).to.equal(false, 'not "prefilled" after reset'); + el._setValueForAllFormElements('modelValue', {}); + el.resetInteractionState(); + expect(el.dirty).to.equal(false, 'not "dirty" after reset'); + expect(el.touched).to.equal(false, 'not "touched" after reset'); + expect(el.prefilled).to.equal(false, 'not "prefilled" after reset'); // Reset all children states with prefilled true - fieldset._setValueForAllFormElements('dirty', true); - fieldset._setValueForAllFormElements('touched', true); - fieldset._setValueForAllFormElements('modelValue', { checked: true }); // not prefilled - fieldset.resetInteractionState(); - expect(fieldset.dirty).to.equal(false, 'not "dirty" after 2nd reset'); - expect(fieldset.touched).to.equal(false, 'not "touched" after 2nd reset'); + el._setValueForAllFormElements('modelValue', { checked: true }); // not prefilled + el.resetInteractionState(); + expect(el.dirty).to.equal(false, 'not "dirty" after 2nd reset'); + expect(el.touched).to.equal(false, 'not "touched" after 2nd reset'); // prefilled state is dependant on value - expect(fieldset.prefilled).to.equal(true, '"prefilled" after 2nd reset'); + expect(el.prefilled).to.equal(true, '"prefilled" after 2nd reset'); }); it('clears submitted state', async () => { diff --git a/packages/form-system/stories/helper-wc/h-output.js b/packages/form-system/stories/helper-wc/h-output.js index 34a6a8902..dc1874592 100644 --- a/packages/form-system/stories/helper-wc/h-output.js +++ b/packages/form-system/stories/helper-wc/h-output.js @@ -1,5 +1,6 @@ import { LitElement, html, css } from '@lion/core'; import { LionField } from '@lion/field'; +import { LionFieldset } from '@lion/fieldset'; export class HelperOutput extends LitElement { static get properties() { @@ -41,7 +42,7 @@ export class HelperOutput extends LitElement { if (!this.field) { // Fuzzy logic, but... practical const prev = this.previousElementSibling; - if (prev instanceof LionField) { + if (prev instanceof LionField || prev instanceof LionFieldset) { this.field = prev; } } diff --git a/packages/radio-group/src/LionRadioGroup.js b/packages/radio-group/src/LionRadioGroup.js index 2e9cc90d8..5be3cba18 100644 --- a/packages/radio-group/src/LionRadioGroup.js +++ b/packages/radio-group/src/LionRadioGroup.js @@ -92,12 +92,18 @@ export class LionRadioGroup extends LionFieldset { } } + _onFocusOut() { + this.touched = true; + this.focused = false; + } + __triggerCheckedValueChanged() { const value = this.checkedValue; if (value != null && value !== this.__previousCheckedValue) { this.dispatchEvent( new CustomEvent('checked-value-changed', { bubbles: true, composed: true }), ); + this.touched = true; this.__previousCheckedValue = value; } } diff --git a/packages/radio-group/stories/index.stories.js b/packages/radio-group/stories/index.stories.js index 98b1d325c..4bddfcaf8 100644 --- a/packages/radio-group/stories/index.stories.js +++ b/packages/radio-group/stories/index.stories.js @@ -1,8 +1,10 @@ +/* eslint-disable import/no-extraneous-dependencies */ import { storiesOf, html } from '@open-wc/demoing-storybook'; +import { localize } from '@lion/localize'; -import '../lion-radio-group.js'; import '@lion/radio/lion-radio.js'; import '@lion/form/lion-form.js'; +import '../lion-radio-group.js'; storiesOf('Forms|Radio Group', module) .add( @@ -98,4 +100,34 @@ storiesOf('Forms|Radio Group', module) `; + }) + .add('Validation Item', () => { + const isBrontosaurus = value => { + const selectedValue = value['dinos[]'].find(v => v.checked === true); + return { + isBrontosaurus: selectedValue ? selectedValue.value === 'brontosaurus' : false, + }; + }; + localize.locale = 'en-GB'; + try { + localize.addData('en-GB', 'lion-validate+isBrontosaurus', { + error: { + isBrontosaurus: 'You need to select "brontosaurus"', + }, + }); + } catch (error) { + // expected as it's a demo + } + + return html` + + + + + + `; }); diff --git a/packages/radio-group/test/lion-radio-group.test.js b/packages/radio-group/test/lion-radio-group.test.js index fa9bd3616..57a16ffa2 100644 --- a/packages/radio-group/test/lion-radio-group.test.js +++ b/packages/radio-group/test/lion-radio-group.test.js @@ -252,4 +252,19 @@ describe('', () => { expect(group.serializedValue).to.deep.equal(''); }); + + it(`becomes "touched" once a single element of the group changes`, async () => { + const el = await fixture(html` + + + + + `); + await nextFrame(); + + el.children[1].focus(); + expect(el.touched).to.equal(false, 'initially, touched state is false'); + el.children[1].checked = true; + expect(el.touched, `focused via a mouse click, group should be touched`).to.be.true; + }); }); diff --git a/packages/validate/src/ValidateMixin.js b/packages/validate/src/ValidateMixin.js index dd3b9b83d..a46c77d9a 100644 --- a/packages/validate/src/ValidateMixin.js +++ b/packages/validate/src/ValidateMixin.js @@ -1,7 +1,6 @@ /* eslint-disable class-methods-use-this, camelcase, no-param-reassign */ import { dedupeMixin, SlotMixin } from '@lion/core'; -import { ObserverMixin } from '@lion/core/src/ObserverMixin.js'; import { localize, LocalizeMixin } from '@lion/localize'; import { Unparseable } from './Unparseable.js'; import { randomOk } from './validators.js'; @@ -14,10 +13,15 @@ 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 ObserverMixin(LocalizeMixin(SlotMixin(superclass))) { + class ValidateMixin extends LocalizeMixin(SlotMixin(superclass)) { /* * * * * * * * * * Configuration */ + constructor() { + super(); + this.__oldValues = {}; + } + get slots() { return { ...super.slots, @@ -196,12 +200,11 @@ export const ValidateMixin = dedupeMixin( }; } - static get asyncObservers() { - return { - ...super.asyncObservers, - // TODO: consider adding 'touched', 'dirty', 'submitted', 'prefilled' on LionFieldFundament - // level, since ValidateMixin doesn't have a direct dependency on interactionState - _createMessageAndRenderFeedback: [ + updated(changedProperties) { + super.updated(changedProperties); + + if ( + [ 'error', 'warning', 'info', @@ -211,30 +214,79 @@ export const ValidateMixin = dedupeMixin( 'submitted', 'prefilled', 'label', - ], - _onErrorShowChangedAsync: ['errorShow'], - }; + ].some(key => changedProperties.has(key)) + ) { + this._createMessageAndRenderFeedback(); + } + + if (changedProperties.has('errorShow')) { + this._onErrorShowChangedAsync(); + } } - static get syncObservers() { - return { - ...super.syncObservers, - validate: [ + _requestUpdate(name, oldVal) { + super._requestUpdate(name, oldVal); + + /** + * Validation needs to happen before other updates + * E.g. formatting should not happen before we know the updated errorState + */ + if ( + [ 'errorValidators', 'warningValidators', 'infoValidators', 'successValidators', 'modelValue', - ], - _onErrorChanged: ['error'], - _onWarningChanged: ['warning'], - _onInfoChanged: ['info'], - _onSuccessChanged: ['success'], - _onErrorStateChanged: ['errorState'], - _onWarningStateChanged: ['warningState'], - _onInfoStateChanged: ['infoState'], - _onSuccessStateChanged: ['successState'], - }; + ].some(key => name === key) + ) { + this.validate(); + } + + // @deprecated adding css classes for backwards compatibility + this.constructor.validationTypes.forEach(type => { + if (name === `${type}State`) { + this.classList[this[`${type}State`] ? 'add' : 'remove'](`state-${type}`); + } + if (name === `${type}Show`) { + this.classList[this[`${type}Show`] ? 'add' : 'remove'](`state-${type}-show`); + } + }); + if (name === 'invalid') { + this.classList[this.invalid ? 'add' : 'remove'](`state-invalid`); + } + + if (name === 'error' && this.error !== oldVal) { + this._onErrorChanged(); + } + + if (name === 'warning' && this.warning !== oldVal) { + this._onWarningChanged(); + } + + if (name === 'info' && this.info !== oldVal) { + this._onInfoChanged(); + } + + if (name === 'success' && this.success !== oldVal) { + this._onSuccessChanged(); + } + + if (name === 'errorState' && this.errorState !== oldVal) { + this._onErrorStateChanged(); + } + + if (name === 'warningState' && this.warningState !== oldVal) { + this._onWarningStateChanged(); + } + + if (name === 'infoState' && this.infoState !== oldVal) { + this._onInfoStateChanged(); + } + + if (name === 'successState' && this.successState !== oldVal) { + this._onSuccessStateChanged(); + } } static get validationTypes() { @@ -245,23 +297,6 @@ 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); @@ -327,26 +362,26 @@ export const ValidateMixin = dedupeMixin( } } - _onErrorChanged(newValues, oldValues) { - if (!this.constructor._objectEquals(newValues.error, oldValues.error)) { + _onErrorChanged() { + if (!this.constructor._objectEquals(this.error, this.__oldValues.error)) { this.dispatchEvent(new CustomEvent('error-changed', { bubbles: true, composed: true })); } } - _onWarningChanged(newValues, oldValues) { - if (!this.constructor._objectEquals(newValues.warning, oldValues.warning)) { + _onWarningChanged() { + if (!this.constructor._objectEquals(this.warning, this.__oldValues.warning)) { this.dispatchEvent(new CustomEvent('warning-changed', { bubbles: true, composed: true })); } } - _onInfoChanged(newValues, oldValues) { - if (!this.constructor._objectEquals(newValues.info, oldValues.info)) { + _onInfoChanged() { + if (!this.constructor._objectEquals(this.info, this.__oldValues.info)) { this.dispatchEvent(new CustomEvent('info-changed', { bubbles: true, composed: true })); } } - _onSuccessChanged(newValues, oldValues) { - if (!this.constructor._objectEquals(newValues.success, oldValues.success)) { + _onSuccessChanged() { + if (!this.constructor._objectEquals(this.success, this.__oldValues.success)) { this.dispatchEvent(new CustomEvent('success-changed', { bubbles: true, composed: true })); } } @@ -385,10 +420,10 @@ export const ValidateMixin = dedupeMixin( } } - _onErrorShowChangedAsync({ errorShow }) { + _onErrorShowChangedAsync() { // Screen reader output should be in sync with visibility of error messages if (this.inputElement) { - this.inputElement.setAttribute('aria-invalid', errorShow); + this.inputElement.setAttribute('aria-invalid', this.errorShow); // TODO: test and see if needed for a11y // this.inputElement.setCustomValidity(this._validationMessage || ''); } @@ -602,6 +637,7 @@ export const ValidateMixin = dedupeMixin( } this[`${type}State`] = resultList.length > 0; + this.__oldValues[type] = this[type]; this[type] = result; }