Merge pull request #357 from ing-bank/fix/validationFieldset
fix(fieldset): manage when to show error messages
This commit is contained in:
commit
b95791fc69
14 changed files with 618 additions and 335 deletions
|
|
@ -1,73 +1,6 @@
|
||||||
import { LionFieldset } from '@lion/fieldset';
|
import { LionFieldset } from '@lion/fieldset';
|
||||||
|
|
||||||
export class LionCheckboxGroup extends LionFieldset {
|
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
|
// eslint-disable-next-line class-methods-use-this
|
||||||
__isRequired(modelValues) {
|
__isRequired(modelValues) {
|
||||||
const keys = Object.keys(modelValues);
|
const keys = Object.keys(modelValues);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { storiesOf, html } from '@open-wc/demoing-storybook';
|
||||||
import '../lion-checkbox-group.js';
|
import '../lion-checkbox-group.js';
|
||||||
import '@lion/checkbox/lion-checkbox.js';
|
import '@lion/checkbox/lion-checkbox.js';
|
||||||
import '@lion/form/lion-form.js';
|
import '@lion/form/lion-form.js';
|
||||||
|
import { localize } from '@lion/localize';
|
||||||
|
|
||||||
storiesOf('Forms|Checkbox Group', module)
|
storiesOf('Forms|Checkbox Group', module)
|
||||||
.add(
|
.add(
|
||||||
|
|
@ -123,4 +124,58 @@ storiesOf('Forms|Checkbox Group', module)
|
||||||
</form></lion-form
|
</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
|
||||||
|
>
|
||||||
|
`;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { expect, html, fixture, triggerFocusFor, nextFrame } from '@open-wc/testing';
|
import { expect, html, fixture, nextFrame } from '@open-wc/testing';
|
||||||
import sinon from 'sinon';
|
|
||||||
|
|
||||||
import { localizeTearDown } from '@lion/localize/test-helpers.js';
|
import { localizeTearDown } from '@lion/localize/test-helpers.js';
|
||||||
|
|
||||||
|
|
@ -11,87 +10,6 @@ beforeEach(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<lion-checkbox-group>', () => {
|
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 () => {
|
it('can be required', async () => {
|
||||||
const el = await fixture(html`
|
const el = await fixture(html`
|
||||||
<lion-checkbox-group .errorValidators=${[['required']]}>
|
<lion-checkbox-group .errorValidators=${[['required']]}>
|
||||||
|
|
|
||||||
|
|
@ -495,7 +495,7 @@ export const FormControlMixin = dedupeMixin(
|
||||||
* an error message shouldn't be shown either.
|
* 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
|
// aria-labelledby and aria-describedby helpers
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
/* eslint-disable class-methods-use-this */
|
/* eslint-disable class-methods-use-this */
|
||||||
|
|
||||||
import { dedupeMixin } from '@lion/core';
|
import { dedupeMixin } from '@lion/core';
|
||||||
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
|
|
||||||
import { Unparseable } from '@lion/validate';
|
import { Unparseable } from '@lion/validate';
|
||||||
|
|
||||||
// For a future breaking release:
|
// For a future breaking release:
|
||||||
|
|
@ -50,7 +49,7 @@ import { Unparseable } from '@lion/validate';
|
||||||
export const FormatMixin = dedupeMixin(
|
export const FormatMixin = dedupeMixin(
|
||||||
superclass =>
|
superclass =>
|
||||||
// eslint-disable-next-line no-unused-vars, no-shadow
|
// eslint-disable-next-line no-unused-vars, no-shadow
|
||||||
class FormatMixin extends ObserverMixin(superclass) {
|
class FormatMixin extends superclass {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
|
|
@ -121,13 +120,24 @@ export const FormatMixin = dedupeMixin(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get syncObservers() {
|
_requestUpdate(name, oldVal) {
|
||||||
return {
|
super._requestUpdate(name, oldVal);
|
||||||
...super.syncObservers,
|
|
||||||
_onModelValueChanged: ['modelValue'],
|
if (name === 'modelValue' && this.modelValue !== oldVal) {
|
||||||
_onSerializedValueChanged: ['serializedValue'],
|
this._onModelValueChanged({ modelValue: this.modelValue }, { modelValue: oldVal });
|
||||||
_onFormattedValueChanged: ['formattedValue'],
|
}
|
||||||
};
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { dedupeMixin } from '@lion/core';
|
import { dedupeMixin } from '@lion/core';
|
||||||
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
|
|
||||||
import { Unparseable } from '@lion/validate';
|
import { Unparseable } from '@lion/validate';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -15,7 +14,7 @@ import { Unparseable } from '@lion/validate';
|
||||||
export const InteractionStateMixin = dedupeMixin(
|
export const InteractionStateMixin = dedupeMixin(
|
||||||
superclass =>
|
superclass =>
|
||||||
// eslint-disable-next-line no-unused-vars, no-shadow
|
// eslint-disable-next-line no-unused-vars, no-shadow
|
||||||
class InteractionStateMixin extends ObserverMixin(superclass) {
|
class InteractionStateMixin extends superclass {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,12 +44,15 @@ export const InteractionStateMixin = dedupeMixin(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get syncObservers() {
|
_requestUpdate(name, oldVal) {
|
||||||
return {
|
super._requestUpdate(name, oldVal);
|
||||||
...super.syncObservers,
|
if (name === 'touched' && this.touched !== oldVal) {
|
||||||
_onTouchedChanged: ['touched'],
|
this._onTouchedChanged();
|
||||||
_onDirtyChanged: ['dirty'],
|
}
|
||||||
};
|
|
||||||
|
if (name === 'dirty' && this.dirty !== oldVal) {
|
||||||
|
this._onDirtyChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static _isPrefilled(modelValue) {
|
static _isPrefilled(modelValue) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { SlotMixin, html } from '@lion/core';
|
import { SlotMixin, html, LitElement } from '@lion/core';
|
||||||
import { LionLitElement } from '@lion/core/src/LionLitElement.js';
|
|
||||||
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
|
import { DisabledMixin } from '@lion/core/src/DisabledMixin.js';
|
||||||
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
|
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
|
||||||
import { ValidateMixin } from '@lion/validate';
|
import { ValidateMixin } from '@lion/validate';
|
||||||
|
|
@ -15,7 +14,7 @@ const pascalCase = str => str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
* @extends LionLitElement
|
* @extends LionLitElement
|
||||||
*/
|
*/
|
||||||
export class LionFieldset extends FormRegistrarMixin(
|
export class LionFieldset extends FormRegistrarMixin(
|
||||||
FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(ObserverMixin(LionLitElement))))),
|
FormControlMixin(ValidateMixin(DisabledMixin(SlotMixin(ObserverMixin(LitElement))))),
|
||||||
) {
|
) {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -24,11 +23,33 @@ export class LionFieldset extends FormRegistrarMixin(
|
||||||
},
|
},
|
||||||
submitted: {
|
submitted: {
|
||||||
type: Boolean,
|
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() {
|
get inputElement() {
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
@ -57,20 +78,8 @@ export class LionFieldset extends FormRegistrarMixin(
|
||||||
this._setValueMapForAllFormElements('formattedValue', values);
|
this._setValueMapForAllFormElements('formattedValue', values);
|
||||||
}
|
}
|
||||||
|
|
||||||
get touched() {
|
|
||||||
return this._anyFormElementHas('touched');
|
|
||||||
}
|
|
||||||
|
|
||||||
get dirty() {
|
|
||||||
return this._anyFormElementHas('dirty');
|
|
||||||
}
|
|
||||||
|
|
||||||
get prefilled() {
|
get prefilled() {
|
||||||
return this._anyFormElementHas('prefilled');
|
return this._everyFormElementHas('prefilled');
|
||||||
}
|
|
||||||
|
|
||||||
get focused() {
|
|
||||||
return this._anyFormElementHas('focused');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get formElementsArray() {
|
get formElementsArray() {
|
||||||
|
|
@ -84,28 +93,33 @@ export class LionFieldset extends FormRegistrarMixin(
|
||||||
super();
|
super();
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
this.submitted = false;
|
this.submitted = false;
|
||||||
|
this.dirty = false;
|
||||||
|
this.touched = false;
|
||||||
|
this.focused = false;
|
||||||
this.formElements = {};
|
this.formElements = {};
|
||||||
this.__addedSubValidators = false;
|
this.__addedSubValidators = false;
|
||||||
this.__createTypeAbsenceValidators();
|
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() {
|
connectedCallback() {
|
||||||
// eslint-disable-next-line wc/guard-super-call
|
super.connectedCallback(); // eslint-disable-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);
|
|
||||||
this._setRole();
|
this._setRole();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
// eslint-disable-next-line wc/guard-super-call
|
super.disconnectedCallback(); // eslint-disable-line wc/guard-super-call
|
||||||
super.disconnectedCallback();
|
|
||||||
this.removeEventListener('validation-done', this.__validate);
|
if (this.__hasActiveOutsideClickHandling) {
|
||||||
this.removeEventListener('focused-changed', this._updateFocusedClass);
|
document.removeEventListener('click', this._checkForOutsideClick);
|
||||||
this.removeEventListener('touched-changed', this._updateTouchedClass);
|
this.__hasActiveOutsideClickHandling = false;
|
||||||
this.removeEventListener('dirty-changed', this._updateDirtyClass);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changedProps) {
|
updated(changedProps) {
|
||||||
|
|
@ -114,12 +128,45 @@ export class LionFieldset extends FormRegistrarMixin(
|
||||||
if (changedProps.has('disabled')) {
|
if (changedProps.has('disabled')) {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
this.__requestChildrenToBeDisabled();
|
this.__requestChildrenToBeDisabled();
|
||||||
|
/** @deprecated use disabled attribute instead */
|
||||||
this.classList.add('state-disabled'); // eslint-disable-line wc/no-self-class
|
this.classList.add('state-disabled'); // eslint-disable-line wc/no-self-class
|
||||||
} else {
|
} else {
|
||||||
this.__retractRequestChildrenToBeDisabled();
|
this.__retractRequestChildrenToBeDisabled();
|
||||||
|
/** @deprecated use disabled attribute instead */
|
||||||
this.classList.remove('state-disabled'); // eslint-disable-line wc/no-self-class
|
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() {
|
__requestChildrenToBeDisabled() {
|
||||||
|
|
@ -190,6 +237,8 @@ export class LionFieldset extends FormRegistrarMixin(
|
||||||
resetInteractionState() {
|
resetInteractionState() {
|
||||||
// TODO: add submitted prop to InteractionStateMixin
|
// TODO: add submitted prop to InteractionStateMixin
|
||||||
this.submitted = false;
|
this.submitted = false;
|
||||||
|
this.touched = false;
|
||||||
|
this.dirty = false;
|
||||||
this.formElementsArray.forEach(formElement => {
|
this.formElementsArray.forEach(formElement => {
|
||||||
if (typeof formElement.resetInteractionState === 'function') {
|
if (typeof formElement.resetInteractionState === 'function') {
|
||||||
formElement.resetInteractionState();
|
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
|
* 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
|
* - react on modelValue change, which says something about the validity as a whole
|
||||||
|
|
@ -263,16 +321,20 @@ export class LionFieldset extends FormRegistrarMixin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateFocusedClass() {
|
_syncFocused() {
|
||||||
this.classList[this.touched ? 'add' : 'remove']('state-focused');
|
this.focused = this._anyFormElementHas('focused');
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateTouchedClass() {
|
_onFocusOut(ev) {
|
||||||
this.classList[this.touched ? 'add' : 'remove']('state-touched');
|
const lastEl = this.formElementsArray[this.formElementsArray.length - 1];
|
||||||
|
if (ev.target === lastEl) {
|
||||||
|
this.touched = true;
|
||||||
|
}
|
||||||
|
this.focused = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateDirtyClass() {
|
_syncDirty() {
|
||||||
this.classList[this.dirty ? 'add' : 'remove']('state-dirty');
|
this.dirty = this._anyFormElementHas('dirty');
|
||||||
}
|
}
|
||||||
|
|
||||||
_setRole(role) {
|
_setRole(role) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { storiesOf, html } from '@open-wc/demoing-storybook';
|
import { storiesOf, html } from '@open-wc/demoing-storybook';
|
||||||
|
|
||||||
import '../lion-fieldset.js';
|
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)
|
storiesOf('Forms|Fieldset', module)
|
||||||
.add(
|
.add(
|
||||||
|
|
@ -79,4 +83,129 @@ storiesOf('Forms|Fieldset', module)
|
||||||
</button>
|
</button>
|
||||||
</lion-fieldset>
|
</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>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,4 @@
|
||||||
import {
|
import { expect, fixture, html, unsafeStatic, triggerFocusFor, nextFrame } from '@open-wc/testing';
|
||||||
expect,
|
|
||||||
fixture,
|
|
||||||
html,
|
|
||||||
unsafeStatic,
|
|
||||||
triggerFocusFor,
|
|
||||||
triggerBlurFor,
|
|
||||||
nextFrame,
|
|
||||||
} from '@open-wc/testing';
|
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { localizeTearDown } from '@lion/localize/test-helpers.js';
|
import { localizeTearDown } from '@lion/localize/test-helpers.js';
|
||||||
import '@lion/input/lion-input.js';
|
import '@lion/input/lion-input.js';
|
||||||
|
|
@ -23,8 +15,6 @@ const inputSlots = html`
|
||||||
<${childTag} name="hobbies[]"></${childTag}>
|
<${childTag} name="hobbies[]"></${childTag}>
|
||||||
<${childTag} name="hobbies[]"></${childTag}>
|
<${childTag} name="hobbies[]"></${childTag}>
|
||||||
`;
|
`;
|
||||||
const nonPrefilledModelValue = '';
|
|
||||||
const prefilledModelValue = 'prefill';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localizeTearDown();
|
localizeTearDown();
|
||||||
|
|
@ -342,66 +332,164 @@ describe('<lion-fieldset>', () => {
|
||||||
expect(fieldset.dirty).to.equal(true);
|
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}>`);
|
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
|
||||||
await nextFrame();
|
await triggerFocusFor(fieldset.formElements['hobbies[]'][0].inputElement);
|
||||||
await triggerFocusFor(fieldset.formElements['gender[]'][0].inputElement);
|
await triggerFocusFor(
|
||||||
await triggerBlurFor(fieldset.formElements['gender[]'][0].inputElement);
|
fieldset.formElements['hobbies[]'][fieldset.formElements['gender[]'].length - 1]
|
||||||
expect(fieldset.touched).to.equal(true);
|
.inputElement,
|
||||||
});
|
|
||||||
|
|
||||||
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"',
|
|
||||||
);
|
);
|
||||||
|
const el = await fixture(html`
|
||||||
|
<button></button>
|
||||||
|
`);
|
||||||
|
el.focus();
|
||||||
|
|
||||||
fieldset.formElements.color.dirty = true;
|
expect(fieldset.touched).to.be.true;
|
||||||
await fieldset.updateComplete;
|
|
||||||
expect(fieldset.classList.contains('state-dirty')).to.equal(true, 'has class "state-dirty"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets prefilled when field left and value non-empty', async () => {
|
it('sets attributes [touched][dirty]', async () => {
|
||||||
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
|
const el = await fixture(html`<${tag}></${tag}>`);
|
||||||
await nextFrame();
|
el.touched = true;
|
||||||
fieldset.formElements['hobbies[]'][0].modelValue = { checked: false, value: 'chess' };
|
await el.updateComplete;
|
||||||
fieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'football' };
|
expect(el).to.have.attribute('touched');
|
||||||
fieldset.formElements['gender[]'][0].modelValue = { checked: false, value: 'male' };
|
|
||||||
fieldset.formElements['gender[]'][1].modelValue = { checked: false, value: 'female' };
|
|
||||||
|
|
||||||
fieldset.formElements.color.modelValue = nonPrefilledModelValue;
|
el.dirty = true;
|
||||||
await triggerFocusFor(fieldset.formElements.color.inputElement);
|
await el.updateComplete;
|
||||||
fieldset.formElements.color.modelValue = prefilledModelValue;
|
expect(el).to.have.attribute('dirty');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets prefilled once instantiated', async () => {
|
it('[deprecated] sets a class "state-(touched|dirty)"', async () => {
|
||||||
// no prefilled when nothing has value
|
const el = await fixture(html`<${tag}></${tag}>`);
|
||||||
const fieldsetNotPrefilled = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
|
el.touched = true;
|
||||||
expect(fieldsetNotPrefilled.prefilled).to.equal(false, 'not prefilled on init');
|
await el.updateComplete;
|
||||||
|
expect(el.classList.contains('state-touched')).to.equal(true, 'has class "state-touched"');
|
||||||
|
|
||||||
// prefilled when at least one child has value
|
el.dirty = true;
|
||||||
const fieldsetPrefilled = await fixture(html`
|
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}>
|
<${tag}>
|
||||||
<${childTag} name="gender[]" .modelValue=${prefilledModelValue}></${childTag}>
|
<${childTag} name="input1" prefilled></${childTag}>
|
||||||
<${childTag} name="gender[]"></${childTag}>
|
<${childTag} name="input2"></${childTag}>
|
||||||
<${childTag} name="color"></${childTag}>
|
|
||||||
<${childTag} name="hobbies[]"></${childTag}>
|
|
||||||
<${childTag} name="hobbies[]"></${childTag}>
|
|
||||||
</${tag}>
|
</${tag}>
|
||||||
`);
|
`);
|
||||||
await nextFrame();
|
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 () => {
|
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();
|
await nextFrame();
|
||||||
// Safety check initially
|
// Safety check initially
|
||||||
fieldset._setValueForAllFormElements('dirty', true);
|
el._setValueForAllFormElements('prefilled', true);
|
||||||
fieldset._setValueForAllFormElements('touched', true);
|
expect(el.dirty).to.equal(true, '"dirty" initially');
|
||||||
fieldset._setValueForAllFormElements('prefilled', true);
|
expect(el.touched).to.equal(true, '"touched" initially');
|
||||||
expect(fieldset.dirty).to.equal(true, '"dirty" initially');
|
expect(el.prefilled).to.equal(true, '"prefilled" initially');
|
||||||
expect(fieldset.touched).to.equal(true, '"touched" initially');
|
|
||||||
expect(fieldset.prefilled).to.equal(true, '"prefilled" initially');
|
|
||||||
|
|
||||||
// Reset all children states, with prefilled false
|
// Reset all children states, with prefilled false
|
||||||
fieldset._setValueForAllFormElements('modelValue', {});
|
el._setValueForAllFormElements('modelValue', {});
|
||||||
fieldset.resetInteractionState();
|
el.resetInteractionState();
|
||||||
expect(fieldset.dirty).to.equal(false, 'not "dirty" after reset');
|
expect(el.dirty).to.equal(false, 'not "dirty" after reset');
|
||||||
expect(fieldset.touched).to.equal(false, 'not "touched" after reset');
|
expect(el.touched).to.equal(false, 'not "touched" after reset');
|
||||||
expect(fieldset.prefilled).to.equal(false, 'not "prefilled" after reset');
|
expect(el.prefilled).to.equal(false, 'not "prefilled" after reset');
|
||||||
|
|
||||||
// Reset all children states with prefilled true
|
// Reset all children states with prefilled true
|
||||||
fieldset._setValueForAllFormElements('dirty', true);
|
el._setValueForAllFormElements('modelValue', { checked: true }); // not prefilled
|
||||||
fieldset._setValueForAllFormElements('touched', true);
|
el.resetInteractionState();
|
||||||
fieldset._setValueForAllFormElements('modelValue', { checked: true }); // not prefilled
|
expect(el.dirty).to.equal(false, 'not "dirty" after 2nd reset');
|
||||||
fieldset.resetInteractionState();
|
expect(el.touched).to.equal(false, 'not "touched" after 2nd reset');
|
||||||
expect(fieldset.dirty).to.equal(false, 'not "dirty" after 2nd reset');
|
|
||||||
expect(fieldset.touched).to.equal(false, 'not "touched" after 2nd reset');
|
|
||||||
// prefilled state is dependant on value
|
// 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 () => {
|
it('clears submitted state', async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { LitElement, html, css } from '@lion/core';
|
import { LitElement, html, css } from '@lion/core';
|
||||||
import { LionField } from '@lion/field';
|
import { LionField } from '@lion/field';
|
||||||
|
import { LionFieldset } from '@lion/fieldset';
|
||||||
|
|
||||||
export class HelperOutput extends LitElement {
|
export class HelperOutput extends LitElement {
|
||||||
static get properties() {
|
static get properties() {
|
||||||
|
|
@ -41,7 +42,7 @@ export class HelperOutput extends LitElement {
|
||||||
if (!this.field) {
|
if (!this.field) {
|
||||||
// Fuzzy logic, but... practical
|
// Fuzzy logic, but... practical
|
||||||
const prev = this.previousElementSibling;
|
const prev = this.previousElementSibling;
|
||||||
if (prev instanceof LionField) {
|
if (prev instanceof LionField || prev instanceof LionFieldset) {
|
||||||
this.field = prev;
|
this.field = prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,12 +92,18 @@ export class LionRadioGroup extends LionFieldset {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onFocusOut() {
|
||||||
|
this.touched = true;
|
||||||
|
this.focused = false;
|
||||||
|
}
|
||||||
|
|
||||||
__triggerCheckedValueChanged() {
|
__triggerCheckedValueChanged() {
|
||||||
const value = this.checkedValue;
|
const value = this.checkedValue;
|
||||||
if (value != null && value !== this.__previousCheckedValue) {
|
if (value != null && value !== this.__previousCheckedValue) {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('checked-value-changed', { bubbles: true, composed: true }),
|
new CustomEvent('checked-value-changed', { bubbles: true, composed: true }),
|
||||||
);
|
);
|
||||||
|
this.touched = true;
|
||||||
this.__previousCheckedValue = value;
|
this.__previousCheckedValue = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import { storiesOf, html } from '@open-wc/demoing-storybook';
|
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/radio/lion-radio.js';
|
||||||
import '@lion/form/lion-form.js';
|
import '@lion/form/lion-form.js';
|
||||||
|
import '../lion-radio-group.js';
|
||||||
|
|
||||||
storiesOf('Forms|Radio Group', module)
|
storiesOf('Forms|Radio Group', module)
|
||||||
.add(
|
.add(
|
||||||
|
|
@ -98,4 +100,34 @@ storiesOf('Forms|Radio Group', module)
|
||||||
</form></lion-form
|
</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>
|
||||||
|
`;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -252,4 +252,19 @@ describe('<lion-radio-group>', () => {
|
||||||
|
|
||||||
expect(group.serializedValue).to.deep.equal('');
|
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;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
/* eslint-disable class-methods-use-this, camelcase, no-param-reassign */
|
/* eslint-disable class-methods-use-this, camelcase, no-param-reassign */
|
||||||
|
|
||||||
import { dedupeMixin, SlotMixin } from '@lion/core';
|
import { dedupeMixin, SlotMixin } from '@lion/core';
|
||||||
import { ObserverMixin } from '@lion/core/src/ObserverMixin.js';
|
|
||||||
import { localize, LocalizeMixin } from '@lion/localize';
|
import { localize, LocalizeMixin } from '@lion/localize';
|
||||||
import { Unparseable } from './Unparseable.js';
|
import { Unparseable } from './Unparseable.js';
|
||||||
import { randomOk } from './validators.js';
|
import { randomOk } from './validators.js';
|
||||||
|
|
@ -14,10 +13,15 @@ const pascalCase = str => str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
export const ValidateMixin = dedupeMixin(
|
export const ValidateMixin = dedupeMixin(
|
||||||
superclass =>
|
superclass =>
|
||||||
// eslint-disable-next-line no-unused-vars, no-shadow, max-len
|
// 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 */
|
Configuration */
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.__oldValues = {};
|
||||||
|
}
|
||||||
|
|
||||||
get slots() {
|
get slots() {
|
||||||
return {
|
return {
|
||||||
...super.slots,
|
...super.slots,
|
||||||
|
|
@ -196,12 +200,11 @@ export const ValidateMixin = dedupeMixin(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get asyncObservers() {
|
updated(changedProperties) {
|
||||||
return {
|
super.updated(changedProperties);
|
||||||
...super.asyncObservers,
|
|
||||||
// TODO: consider adding 'touched', 'dirty', 'submitted', 'prefilled' on LionFieldFundament
|
if (
|
||||||
// level, since ValidateMixin doesn't have a direct dependency on interactionState
|
[
|
||||||
_createMessageAndRenderFeedback: [
|
|
||||||
'error',
|
'error',
|
||||||
'warning',
|
'warning',
|
||||||
'info',
|
'info',
|
||||||
|
|
@ -211,30 +214,79 @@ export const ValidateMixin = dedupeMixin(
|
||||||
'submitted',
|
'submitted',
|
||||||
'prefilled',
|
'prefilled',
|
||||||
'label',
|
'label',
|
||||||
],
|
].some(key => changedProperties.has(key))
|
||||||
_onErrorShowChangedAsync: ['errorShow'],
|
) {
|
||||||
};
|
this._createMessageAndRenderFeedback();
|
||||||
}
|
}
|
||||||
|
|
||||||
static get syncObservers() {
|
if (changedProperties.has('errorShow')) {
|
||||||
return {
|
this._onErrorShowChangedAsync();
|
||||||
...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',
|
'errorValidators',
|
||||||
'warningValidators',
|
'warningValidators',
|
||||||
'infoValidators',
|
'infoValidators',
|
||||||
'successValidators',
|
'successValidators',
|
||||||
'modelValue',
|
'modelValue',
|
||||||
],
|
].some(key => name === key)
|
||||||
_onErrorChanged: ['error'],
|
) {
|
||||||
_onWarningChanged: ['warning'],
|
this.validate();
|
||||||
_onInfoChanged: ['info'],
|
}
|
||||||
_onSuccessChanged: ['success'],
|
|
||||||
_onErrorStateChanged: ['errorState'],
|
// @deprecated adding css classes for backwards compatibility
|
||||||
_onWarningStateChanged: ['warningState'],
|
this.constructor.validationTypes.forEach(type => {
|
||||||
_onInfoStateChanged: ['infoState'],
|
if (name === `${type}State`) {
|
||||||
_onSuccessStateChanged: ['successState'],
|
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() {
|
static get validationTypes() {
|
||||||
|
|
@ -245,23 +297,6 @@ export const ValidateMixin = dedupeMixin(
|
||||||
return (this.$$slot && this.$$slot('feedback')) || this.querySelector('[slot="feedback"]');
|
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) {
|
getFieldName(validatorParams) {
|
||||||
const label =
|
const label =
|
||||||
this.label || (this.$$slot && this.$$slot('label') && this.$$slot('label').textContent);
|
this.label || (this.$$slot && this.$$slot('label') && this.$$slot('label').textContent);
|
||||||
|
|
@ -327,26 +362,26 @@ export const ValidateMixin = dedupeMixin(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onErrorChanged(newValues, oldValues) {
|
_onErrorChanged() {
|
||||||
if (!this.constructor._objectEquals(newValues.error, oldValues.error)) {
|
if (!this.constructor._objectEquals(this.error, this.__oldValues.error)) {
|
||||||
this.dispatchEvent(new CustomEvent('error-changed', { bubbles: true, composed: true }));
|
this.dispatchEvent(new CustomEvent('error-changed', { bubbles: true, composed: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onWarningChanged(newValues, oldValues) {
|
_onWarningChanged() {
|
||||||
if (!this.constructor._objectEquals(newValues.warning, oldValues.warning)) {
|
if (!this.constructor._objectEquals(this.warning, this.__oldValues.warning)) {
|
||||||
this.dispatchEvent(new CustomEvent('warning-changed', { bubbles: true, composed: true }));
|
this.dispatchEvent(new CustomEvent('warning-changed', { bubbles: true, composed: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onInfoChanged(newValues, oldValues) {
|
_onInfoChanged() {
|
||||||
if (!this.constructor._objectEquals(newValues.info, oldValues.info)) {
|
if (!this.constructor._objectEquals(this.info, this.__oldValues.info)) {
|
||||||
this.dispatchEvent(new CustomEvent('info-changed', { bubbles: true, composed: true }));
|
this.dispatchEvent(new CustomEvent('info-changed', { bubbles: true, composed: true }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onSuccessChanged(newValues, oldValues) {
|
_onSuccessChanged() {
|
||||||
if (!this.constructor._objectEquals(newValues.success, oldValues.success)) {
|
if (!this.constructor._objectEquals(this.success, this.__oldValues.success)) {
|
||||||
this.dispatchEvent(new CustomEvent('success-changed', { bubbles: true, composed: true }));
|
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
|
// Screen reader output should be in sync with visibility of error messages
|
||||||
if (this.inputElement) {
|
if (this.inputElement) {
|
||||||
this.inputElement.setAttribute('aria-invalid', errorShow);
|
this.inputElement.setAttribute('aria-invalid', this.errorShow);
|
||||||
// TODO: test and see if needed for a11y
|
// TODO: test and see if needed for a11y
|
||||||
// this.inputElement.setCustomValidity(this._validationMessage || '');
|
// this.inputElement.setCustomValidity(this._validationMessage || '');
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +637,7 @@ export const ValidateMixin = dedupeMixin(
|
||||||
}
|
}
|
||||||
|
|
||||||
this[`${type}State`] = resultList.length > 0;
|
this[`${type}State`] = resultList.length > 0;
|
||||||
|
this.__oldValues[type] = this[type];
|
||||||
this[type] = result;
|
this[type] = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue