Merge pull request #357 from ing-bank/fix/validationFieldset

fix(fieldset): manage when to show error messages
This commit is contained in:
Thomas Allmer 2019-11-01 15:25:28 +01:00 committed by GitHub
commit b95791fc69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 618 additions and 335 deletions

View file

@ -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);

View file

@ -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)
</form></lion-form
>
`;
})
.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`
<lion-form id="form" @submit="${submit}"
><form>
<lion-checkbox-group
name="scientistsGroup"
label="Who are your favorite scientists?"
help-text="You should have at least 2 of those"
.errorValidators=${[[hasMinTwoChecked]]}
>
<lion-checkbox
name="scientists[]"
label="Archimedes"
.choiceValue=${'Archimedes'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Francis Bacon"
.choiceValue=${'Francis Bacon'}
></lion-checkbox>
<lion-checkbox
name="scientists[]"
label="Marie Curie"
.choiceValue=${'Marie Curie'}
></lion-checkbox>
</lion-checkbox-group>
<button type="submit">Submit</button>
</form></lion-form
>
`;
});

View file

@ -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('<lion-checkbox-group>', () => {
// 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(`
<lion-checkbox-group>
<label slot="label">My group</label>
<lion-checkbox name="myGroup[]" label="Option 1" value="1"></lion-checkbox>
<lion-checkbox name="myGroup[]" label="Option 2" value="2"></lion-checkbox>
</lion-checkbox-group>
`);
await nextFrame();
const button = await fixture(`<button>Blur</button>`);
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(`
<div tabindex="0">
<lion-checkbox-group>
<label slot="label">My group</label>
<lion-checkbox name="myGroup[]" label="Option 1" value="1"></lion-checkbox>
<lion-checkbox name="myGroup[]" label="Option 2" vallue="2"></lion-checkbox>
</lion-checkbox-group>
</div>
`);
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(`
<lion-checkbox-group>
<lion-checkbox name="myGroup[]"></lion-checkbox>
<lion-checkbox name="myGroup[]"></lion-checkbox>
</lion-checkbox-group>
`);
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`
<lion-checkbox-group .errorValidators=${[['required']]}>

View file

@ -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

View file

@ -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 },
);
}
}
/**

View file

@ -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) {

View file

@ -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) {

View file

@ -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)
</button>
</lion-fieldset>
`,
);
)
.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`
<lion-fieldset id="someId" .errorValidators=${[demoValidator()]}>
<lion-input name="input1" label="Label"></lion-input>
<button
@click=${() => {
document.getElementById('someId').serializeGroup();
}}
>
Submit
</button>
</lion-fieldset>
<br />
<br />
<button>
Tab-able
</button>
`;
})
.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`
<lion-fieldset .errorValidators=${[[isCatsAndDogs]]}>
<lion-input
label="An all time YouTube favorite"
name="input1"
help-text="longer then 2 characters"
.errorValidators=${[minLengthValidator(3)]}
></lion-input>
<lion-input
label="Another all time YouTube favorite"
name="input2"
help-text="longer then 2 characters"
.errorValidators=${[minLengthValidator(3)]}
></lion-input>
</lion-fieldset>
`;
})
.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`
<lion-fieldset .errorValidators=${[[isCats]]}>
<label slot="label">Fieldset Nr. 1</label>
<lion-input
label="An all time YouTube favorite"
name="input1"
help-text="longer then 2 characters"
.errorValidators=${[minLengthValidator(3)]}
></lion-input>
</lion-fieldset>
<br />
<hr />
<br />
<lion-fieldset .errorValidators=${[[isDogs]]}>
<label slot="label">Fieldset Nr. 2</label>
<lion-input
label="An all time YouTube favorite"
name="input1"
help-text="longer then 2 characters"
.errorValidators=${[minLengthValidator(3)]}
></lion-input>
</lion-fieldset>
`;
});

View file

@ -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('<lion-fieldset>', () => {
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`
<button></button>
`);
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}>
<label slot="label">My group</label>
<${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(`<button>Blur</button>`);
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`
<button>outside</button>
`);
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`
<button>outside</button>
`);
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`
<button>outside</button>
`);
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('<lion-fieldset>', () => {
});
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 () => {

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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)
</form></lion-form
>
`;
})
.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`
<lion-radio-group
name="dinosGroup"
label="What are your favourite dinosaurs?"
.errorValidators=${[['required'], [isBrontosaurus]]}
>
<lion-radio name="dinos[]" label="allosaurus" .choiceValue=${'allosaurus'}></lion-radio>
<lion-radio name="dinos[]" label="brontosaurus" .choiceValue=${'brontosaurus'}></lion-radio>
<lion-radio name="dinos[]" label="diplodocus" .choiceValue=${'diplodocus'}></lion-radio>
</lion-radio-group>
`;
});

View file

@ -252,4 +252,19 @@ describe('<lion-radio-group>', () => {
expect(group.serializedValue).to.deep.equal('');
});
it(`becomes "touched" once a single element of the group changes`, async () => {
const el = await fixture(html`
<lion-radio-group>
<lion-radio name="myGroup[]"></lion-radio>
<lion-radio name="myGroup[]"></lion-radio>
</lion-radio-group>
`);
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;
});
});

View file

@ -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();
}
static get syncObservers() {
return {
...super.syncObservers,
validate: [
if (changedProperties.has('errorShow')) {
this._onErrorShowChangedAsync();
}
}
_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;
}