feat(form-core): add details.isTriggeredByUser to model-value-changed

This commit is contained in:
Thijs Louisse 2021-01-06 00:44:30 +01:00 committed by Joren Broekema
parent a9c55bc17b
commit b88760d578
12 changed files with 357 additions and 31 deletions

View file

@ -4,6 +4,16 @@ import { FormRegisteringMixin } from './registration/FormRegisteringMixin.js';
import { getAriaElementsInRightDomOrder } from './utils/getAriaElementsInRightDomOrder.js';
import { Unparseable } from './validate/Unparseable.js';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {import('@lion/core').CSSResult} CSSResult
* @typedef {import('@lion/core').nothing} nothing
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
* @typedef {import('../types/FormControlMixinTypes.js').FormControlHost} FormControlHost
* @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin
* @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
*/
/**
* Generates random unique identifier (for dom elements)
* @param {string} prefix
@ -18,11 +28,6 @@ function uuid(prefix) {
* This Mixin is a shared fundament for all form components, it's applied on:
* - LionField (which is extended to LionInput, LionTextarea, LionSelect etc. etc.)
* - LionFieldset (which is extended to LionRadioGroup, LionCheckboxGroup, LionForm)
* @typedef {import('@lion/core').TemplateResult} TemplateResult
* @typedef {import('@lion/core').CSSResult} CSSResult
* @typedef {import('@lion/core').nothing} nothing
* @typedef {import('@lion/core/types/SlotMixinTypes').SlotsMap} SlotsMap
* @typedef {import('../types/FormControlMixinTypes.js').FormControlMixin} FormControlMixin
* @param {import('@open-wc/dedupe-mixin').Constructor<import('@lion/core').LitElement>} superclass
* @type {FormControlMixin}
*/
@ -750,7 +755,11 @@ const FormControlMixinImplementation = superclass =>
this.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this], initialize: true },
detail: /** @type {ModelValueEventDetails} */ ({
formPath: [this],
initialize: true,
isTriggeredByUser: false,
}),
}),
);
}
@ -822,7 +831,13 @@ const FormControlMixinImplementation = superclass =>
//
// Since for a11y everything needs to be in lightdom, we don't add 'composed:true'
this.dispatchEvent(
new CustomEvent('model-value-changed', { bubbles: true, detail: { formPath } }),
new CustomEvent('model-value-changed', {
bubbles: true,
detail: /** @type {ModelValueEventDetails} */ ({
formPath,
isTriggeredByUser: Boolean(ev.detail?.isTriggeredByUser),
}),
}),
);
}

View file

@ -8,6 +8,7 @@ import { ValidateMixin } from './validate/ValidateMixin.js';
/**
* @typedef {import('../types/FormatMixinTypes').FormatMixin} FormatMixin
* @typedef {import('@lion/localize/types/LocalizeMixinTypes').FormatNumberOptions} FormatOptions
* @typedef {import('../types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
*/
// For a future breaking release:
@ -316,7 +317,10 @@ const FormatMixinImplementation = superclass =>
this.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this] },
detail: /** @type { ModelValueEventDetails } */ ({
formPath: [this],
isTriggeredByUser: Boolean(this.__isHandlingUserInput),
}),
}),
);
}

View file

@ -175,6 +175,7 @@ const ChoiceInputMixinImplementation = superclass =>
<small class="choice-field__help-text">
<slot name="help-text"></slot>
</small>
${this._afterTemplate()}
`;
}
@ -182,6 +183,10 @@ const ChoiceInputMixinImplementation = superclass =>
return nothing;
}
_afterTemplate() {
return nothing;
}
connectedCallback() {
super.connectedCallback();
this.addEventListener('user-input-changed', this.__toggleChecked);
@ -196,7 +201,9 @@ const ChoiceInputMixinImplementation = superclass =>
if (this.disabled) {
return;
}
this.__isHandlingUserInput = true;
this.checked = !this.checked;
this.__isHandlingUserInput = false;
}
/**

View file

@ -123,18 +123,23 @@ export function runFormatMixinSuite(customConfig) {
`);
});
it('fires `model-value-changed` for every change on the input', async () => {
it('fires `model-value-changed` for every input triggered by user', async () => {
const formatEl = /** @type {FormatClass} */ (await fixture(
html`<${elem}><input slot="input"></${elem}>`,
));
let counter = 0;
formatEl.addEventListener('model-value-changed', () => {
let isTriggeredByUser = false;
formatEl.addEventListener('model-value-changed', (
/** @param {CustomEvent} event */ event,
) => {
counter += 1;
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
});
mimicUserInput(formatEl, generateValueBasedOnType());
expect(counter).to.equal(1);
expect(isTriggeredByUser).to.be.true;
// Counter offset +1 for Date because parseDate created a new Date object
// when the user changes the value.
@ -150,17 +155,21 @@ export function runFormatMixinSuite(customConfig) {
expect(counter).to.equal(2 + counterOffset);
});
it('fires `model-value-changed` for every modelValue change', async () => {
it('fires `model-value-changed` for every programmatic modelValue change', async () => {
const el = /** @type {FormatClass} */ (await fixture(
html`<${elem}><input slot="input"></${elem}>`,
));
let counter = 0;
el.addEventListener('model-value-changed', () => {
let isTriggeredByUser = false;
el.addEventListener('model-value-changed', event => {
counter += 1;
isTriggeredByUser = /** @type {CustomEvent} */ (event).detail.isTriggeredByUser;
});
el.modelValue = 'one';
expect(counter).to.equal(1);
expect(isTriggeredByUser).to.be.false;
// no change means no event
el.modelValue = 'one';

View file

@ -95,6 +95,21 @@ export function runChoiceInputMixinSuite({ tagString } = {}) {
expect(counter).to.equal(1);
});
it('adds "isTriggerByUser" flag on model-value-changed', async () => {
let isTriggeredByUser;
const el = /** @type {ChoiceInput} */ (await fixture(html`
<${tag}
@model-value-changed="${(/** @type {CustomEvent} */ event) => {
isTriggeredByUser = event.detail.isTriggeredByUser;
}}"
>
<input slot="input" />
</${tag}>
`));
el._inputNode.dispatchEvent(new CustomEvent('change', { bubbles: true }));
expect(isTriggeredByUser).to.be.true;
});
it('can be required', async () => {
const el = /** @type {ChoiceInput} */ (await fixture(html`
<${tag} .choiceValue=${'foo'} .validators=${[new Required()]}></${tag}>

View file

@ -238,14 +238,19 @@ describe('FormControlMixin', () => {
const fieldsetEv = fieldsetSpy.firstCall.args[0];
expect(fieldsetEv.target).to.equal(fieldsetEl);
expect(fieldsetEv.detail.formPath).to.eql([fieldsetEl]);
expect(fieldsetEv.detail.initialize).to.be.true;
expect(formSpy.callCount).to.equal(1);
const formEv = formSpy.firstCall.args[0];
expect(formEv.target).to.equal(formEl);
expect(formEv.detail.formPath).to.eql([formEl]);
expect(formEv.detail.initialize).to.be.true;
});
});
/**
* After initialization means: events triggered programmatically or by user actions
*/
describe('After initialization', () => {
it('redispatches one event from host and keeps formPath history', async () => {
const formSpy = sinon.spy();
@ -310,11 +315,45 @@ describe('FormControlMixin', () => {
const choiceGroupEv = choiceGroupSpy.firstCall.args[0];
expect(choiceGroupEv.target).to.equal(choiceGroupEl);
expect(choiceGroupEv.detail.formPath).to.eql([choiceGroupEl]);
expect(choiceGroupEv.detail.isTriggeredByUser).to.be.false;
expect(formSpy.callCount).to.equal(1);
const formEv = formSpy.firstCall.args[0];
expect(formEv.target).to.equal(formEl);
expect(formEv.detail.formPath).to.eql([choiceGroupEl, formEl]);
expect(formEv.detail.isTriggeredByUser).to.be.false;
});
it('sets "isTriggeredByUser" event detail when event triggered by user', async () => {
const formSpy = sinon.spy();
const fieldsetSpy = sinon.spy();
const fieldSpy = sinon.spy();
const formEl = await fixture(html`
<${groupTag} name="form">
<${groupTag} name="fieldset">
<${tag} name="field"></${tag}>
</${groupTag}>
</${groupTag}>
`);
const fieldEl = formEl.querySelector('[name=field]');
const fieldsetEl = formEl.querySelector('[name=fieldset]');
formEl.addEventListener('model-value-changed', formSpy);
fieldsetEl?.addEventListener('model-value-changed', fieldsetSpy);
fieldEl?.addEventListener('model-value-changed', fieldSpy);
fieldEl?.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { isTriggeredByUser: true },
}),
);
const fieldsetEv = fieldsetSpy.firstCall.args[0];
expect(fieldsetEv.detail.isTriggeredByUser).to.be.true;
const formEv = formSpy.firstCall.args[0];
expect(formEv.detail.isTriggeredByUser).to.be.true;
});
});
});

View file

@ -6,6 +6,29 @@ import { DisabledHost } from '@lion/core/types/DisabledMixinTypes';
import { LionValidationFeedback } from '../src/validate/LionValidationFeedback';
import { FormRegisteringHost } from './registration/FormRegisteringMixinTypes';
export type ModelValueEventDetails {
/**
* A list that represents the path of FormControls the model-value-changed event
* 'traveled through'.
* (every FormControl stops propagation of its child and sends a new event, hereby adding
* itself to the beginning of formPath)
*/
formPath: HTMLElement[];
/**
* Whether the model-value-changed event is triggered via user interaction. This information
* can be helpful for both Application Developers and Subclassers.
* This concept is related to the native isTrusted property:
* https://developer.mozilla.org/en-US/docs/Web/API/Event/isTrusted
*/
isTriggeredByUser: boolean;
/**
* Whether it is the first event sent on initialization of the form (other
* model-value-changed events are triggered imperatively or via user input (in the latter
* case `isTriggeredByUser` is true))
*/
initialize?: boolean;
}
declare interface HTMLElementWithValue extends HTMLElement {
value: string;
}
@ -40,12 +63,12 @@ export declare class FormControlHost {
* controls until they are enabled.
* (From: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-readonly)
*/
readOnly: boolean;
public readOnly: boolean;
/**
* The name the element will be registered on to the .formElements collection
* The name the element will be registered with to the .formElements collection
* of the parent.
*/
name: string;
public name: string;
/**
* The model value is the result of the parser function(when available).
* It should be considered as the internal value used for validation and reasoning/logic.
@ -58,25 +81,25 @@ export declare class FormControlHost {
* - For a number input: a formatted String '1.234,56' will be converted to a Number:
* 1234.56
*/
modelValue: unknown;
public modelValue: unknown;
/**
* The label text for the input node.
* When no light dom defined via [slot=label], this value will be used
*/
get label(): string;
set label(arg: string);
public get label(): string;
public set label(arg: string);
__label: string | undefined;
/**
* The helpt text for the input node.
* When no light dom defined via [slot=help-text], this value will be used
*/
get helpText(): string;
set helpText(arg: string);
public get helpText(): string;
public set helpText(arg: string);
__helpText: string | undefined;
set fieldName(arg: string);
get fieldName(): string;
public set fieldName(arg: string);
public get fieldName(): string;
__fieldName: string | undefined;
get slots(): SlotsMap;
public get slots(): SlotsMap;
get _inputNode(): HTMLElementWithValue;
get _labelNode(): HTMLElement;
get _helpTextNode(): HTMLElement;
@ -123,7 +146,7 @@ export declare class FormControlHost {
__reflectAriaAttr(attrName: string, nodes: HTMLElement[], reorder: boolean | undefined): void;
_isEmpty(modelValue?: unknown): boolean;
_getAriaDescriptionElements(): HTMLElement[];
addToAriaLabelledBy(
public addToAriaLabelledBy(
element: HTMLElement,
customConfig?: {
idPrefix?: string | undefined;
@ -131,7 +154,7 @@ export declare class FormControlHost {
},
): void;
__reorderAriaLabelledNodes: boolean | undefined;
addToAriaDescribedBy(
public addToAriaDescribedBy(
element: HTMLElement,
customConfig?: {
idPrefix?: string | undefined;

View file

@ -58,11 +58,7 @@ Useful on input elements it allows to define how many characters can be entered.
```js preview-story
export const stringValidators = () => html`
<lion-input
.validators=${[new EqualsLength(7)]}
.modelValue=${'not exactly'}
label="EqualsLength"
></lion-input>
<lion-input .validators=${[new EqualsLength(7)]} label="EqualsLength"></lion-input>
<lion-input
.validators=${[new MinLength(10)]}
.modelValue=${'too short'}

View file

@ -26,6 +26,8 @@ import '@lion/listbox/lion-option.js';
import '@lion/select-rich/lion-select-rich.js';
import '@lion/fieldset/lion-fieldset.js';
import '@lion/form/lion-form.js';
import '@lion/form-core/lion-field.js';
const featureName = 'model value';
@ -278,3 +280,178 @@ describe('lion-fieldset', () => {
});
});
});
describe('detail.isTriggeredByUser', () => {
const allFormControls = [
// 1) Fields
'field',
// 1a) Input Fields
'input',
'input-amount',
'input-date',
'input-datepicker',
'input-email',
'input-iban',
'input-range',
'textarea',
// 1b) Choice Fields
'option',
'checkbox',
'radio',
// 1c) Choice Group Fields
'select',
'listbox',
'select-rich',
'combobox',
// 2) FormGroups
// 2a) Choice FormGroups
'checkbox-group',
'radio-group',
// 2v) Fieldset
'fieldset',
// 2c) Form
'form',
];
/**
* "isTriggeredByUser" for different types of fields:
*
* RegularField:
* - true: when change/input (c.q. user-input-changed) fired
* - false: when .modelValue set programmatically
*
* ChoiceField:
* - true: when 'change' event fired
* - false: when .modelValue (or checked) set programmatically
*
* OptionChoiceField:
* - true: when 'click' event fired
* - false: when .modelValue (or checked) set programmatically
*
* ChoiceGroupField (listbox, select-rich, combobox, radio-group, checkbox-group):
* - true: when child formElement condition for Choice Field is met
* - false: when child formElement condition for Choice Field is not met
*
* FormOrFieldset (fieldset, form):
* - true: when child formElement condition for Field is met
* - false: when child formElement condition for Field is not met
*/
const featureDetectChoiceField = el => 'checked' in el && 'choiceValue' in el;
const featureDetectOptionChoiceField = el => 'active' in el;
/**
* @param {FormControl} el
* @returns {'RegularField'|'ChoiceField'|'OptionChoiceField'|'ChoiceGroupField'|'FormOrFieldset'}
*/
function detectType(el) {
if (el._repropagationRole === 'child') {
if (featureDetectChoiceField(el)) {
return featureDetectOptionChoiceField(el) ? 'OptionChoiceField' : 'ChoiceField';
}
return 'RegularField';
}
return el._repropagationRole === 'choice-group' ? 'ChoiceGroupField' : 'FormOrFieldset';
}
/**
* @param {FormControl} el
* @param {string} newViewValue
* @returns {'RegularField'|'ChoiceField'|'OptionChoiceField'|'ChoiceGroupField'|'FormOrFieldset'}
*/
function mimicUserInput(el, newViewValue) {
const type = detectType(el);
let userInputEv;
if (type === 'RegularField') {
userInputEv = el._inputNode.tagName === 'SELECT' ? 'change' : 'input';
el.value = newViewValue; // eslint-disable-line no-param-reassign
el._inputNode.dispatchEvent(new Event(userInputEv, { bubbles: true }));
} else if (type === 'ChoiceField') {
el._inputNode.dispatchEvent(new Event('change', { bubbles: true }));
} else if (type === 'OptionChoiceField') {
el.dispatchEvent(new Event('click', { bubbles: true }));
}
}
allFormControls.forEach(controlName => {
it(`lion-${controlName} adds "detail.isTriggeredByUser" to model-value-changed event`, async () => {
const spy = sinon.spy();
const tagname = `lion-${controlName}`;
const tag = unsafeStatic(tagname);
let childrenEl;
if (controlName === 'select') {
childrenEl = await fixture(
html`<select slot="input">
<option value="x"></option>
</select>`,
);
} else if (controlName === 'form') {
childrenEl = await fixture(html`<form></form>`);
} else if (controlName === 'field') {
childrenEl = await fixture(html`<input slot="input" />`);
}
const el = await fixture(html`<${tag}>${childrenEl}</${tag}>`);
await el.registrationComplete;
el.addEventListener('model-value-changed', spy);
function expectCorrectEventMetaRegularField(formControl) {
mimicUserInput(formControl, 'userValue', 'RegularField');
expect(spy.firstCall.args[0].detail.isTriggeredByUser).to.be.true;
// eslint-disable-next-line no-param-reassign
formControl.modelValue = 'programmaticValue';
expect(spy.secondCall.args[0].detail.isTriggeredByUser).to.be.false;
}
function resetChoiceFieldToForceRepropagation(formControl) {
// eslint-disable-next-line no-param-reassign
formControl.checked = false;
spy.resetHistory();
}
function expectCorrectEventMetaChoiceField(formControl) {
resetChoiceFieldToForceRepropagation(formControl);
mimicUserInput(formControl, 'userValue', 'ChoiceField');
expect(spy.firstCall.args[0].detail.isTriggeredByUser).to.be.true;
resetChoiceFieldToForceRepropagation(formControl);
// eslint-disable-next-line no-param-reassign
formControl.checked = true;
expect(spy.firstCall.args[0].detail.isTriggeredByUser).to.be.false;
// eslint-disable-next-line no-param-reassign
formControl.modelValue = { value: 'programmaticValue', checked: false };
expect(spy.secondCall.args[0].detail.isTriggeredByUser).to.be.false;
}
// 1. Derive the type of field we're dealing with
const type = detectType(el);
if (type === 'RegularField') {
expectCorrectEventMetaRegularField(el);
} else if (type === 'ChoiceField' || type === 'OptionChoiceField') {
expectCorrectEventMetaChoiceField(el);
} else if (type === 'ChoiceGroupField') {
let childName = 'option';
if (controlName.endsWith('-group')) {
[childName] = controlName.split('-group');
}
const childTagName = `lion-${childName}`;
const childTag = unsafeStatic(childTagName);
const childrenEls = await fixture(
html`<div><${childTag}></${childTag}><${childTag}></${childTag}></div>`,
);
el.appendChild(childrenEls);
await el.registrationComplete;
expectCorrectEventMetaChoiceField(el.formElements[0]);
} else if (type === 'FormOrFieldset') {
const childrenEls = await fixture(
html`<div><lion-input name="one"></lion-input><lion-input name="two"></lion-input></div>`,
);
el.appendChild(childrenEls);
await el.registrationComplete;
await el.updateComplete;
expectCorrectEventMetaRegularField(el.formElements[0]);
}
});
});
});

View file

@ -123,6 +123,7 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
return;
}
const parentForm = /** @type {unknown} */ (this.__parentFormGroup);
this.__isHandlingUserInput = true;
if (parentForm && /** @type {ChoiceGroupHost} */ (parentForm).multipleChoice) {
this.checked = !this.checked;
this.active = !this.active;
@ -130,5 +131,6 @@ export class LionOption extends DisabledMixin(ChoiceInputMixin(FormRegisteringMi
this.checked = true;
this.active = true;
}
this.__isHandlingUserInput = false;
}
}

View file

@ -14,6 +14,7 @@ import { LionOptions } from './LionOptions.js';
* @typedef {import('../types/ListboxMixinTypes').ListboxMixin} ListboxMixin
* @typedef {import('../types/ListboxMixinTypes').ListboxHost} ListboxHost
* @typedef {import('@lion/form-core/types/registration/FormRegistrarPortalMixinTypes').FormRegistrarPortalHost} FormRegistrarPortalHost
* @typedef {import('@lion/form-core/types/FormControlMixinTypes.js').ModelValueEventDetails} ModelValueEventDetails
*/
function uuid() {
@ -699,7 +700,13 @@ const ListboxMixinImplementation = superclass =>
// only send model-value-changed if the event is caused by one of its children
if (ev.detail && ev.detail.formPath) {
this.dispatchEvent(
new CustomEvent('model-value-changed', { detail: { element: ev.target } }),
new CustomEvent('model-value-changed', {
detail: /** @type {ModelValueEventDetails} */ ({
formPath: ev.detail.formPath,
isTriggeredByUser: ev.detail.isTriggeredByUser,
element: ev.target,
}),
}),
);
}
this.__oldModelValue = this.modelValue;

View file

@ -13,6 +13,38 @@ describe('lion-option', () => {
expect(el.modelValue).to.deep.equal({ value: 10, checked: false });
});
it('fires model-value-changed on click', async () => {
let isTriggeredByUser;
const el = /** @type {LionOption} */ (await fixture(html`
<lion-option
@model-value-changed="${(/** @type {CustomEvent} */ event) => {
isTriggeredByUser = event.detail.isTriggeredByUser;
}}"
>
</lion-option>
`));
el.dispatchEvent(new CustomEvent('click', { bubbles: true }));
expect(isTriggeredByUser).to.be.true;
});
it('fires model-value-changed on programmatic "checked" change', async () => {
let count = 0;
let isTriggeredByUser;
const el = /** @type {LionOption} */ (await fixture(html`
<lion-option
@model-value-changed="${(/** @type {CustomEvent} */ event) => {
count += 1;
isTriggeredByUser = event.detail.isTriggeredByUser;
}}"
>
</lion-option>
`));
el.checked = true;
expect(count).to.equal(1);
expect(isTriggeredByUser).to.be.false;
});
it('can be checked', async () => {
const el = /** @type {LionOption} */ (await fixture(
html`<lion-option .choiceValue=${10} checked></lion-option>`,