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/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/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}>
<${childTag} name="hobbies[]">${childTag}>
`;
-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}${tag}>`);
- 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}${tag}>`);
- 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}${tag}>`);
- 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}>${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}${tag}>`);
- 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}>${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}>
- <${childTag} name="gender[]">${childTag}>
- <${childTag} name="color">${childTag}>
- <${childTag} name="hobbies[]">${childTag}>
- <${childTag} name="hobbies[]">${childTag}>
+ <${childTag} name="input1" prefilled>${childTag}>
+ <${childTag} name="input2">${childTag}>
${tag}>
`);
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}>
+ <${childTag} name="input2" prefilled>${childTag}>
+ ${tag}>
+ `);
+ 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}>
+ <${childTag} name="myGroup[]" label="Option 2" value="2">${childTag}>
+ ${tag}>
+ `);
+ 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}>
+ <${childTag} name="input2">${childTag}>
+ ${tag}>
+ `);
+ const el2 = await fixture(html`
+ <${tag}>
+ <${childTag} name="input1">${childTag}>
+ <${childTag} name="input2">${childTag}>
+ ${tag}>
+ `);
+
+ 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]]}>${childTag}>
+ ${tag}>
+ `);
+ 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}>
+ <${childTag} name="input2" .errorValidators=${[[isNumber]]}>${childTag}>
+ ${tag}>
+ `);
+ 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}${tag}>`);
+ const el = await fixture(html`<${tag} touched dirty>${inputSlots}${tag}>`);
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;
}