Merge pull request #647 from ing-bank/fix/model-value-event-consistent

Fix/model value event consistent
This commit is contained in:
Thijs Louisse 2020-03-19 10:04:18 +01:00 committed by GitHub
commit 5879e499fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 746 additions and 58 deletions

View file

@ -55,20 +55,7 @@ export const ChoiceGroupMixin = dedupeMixin(
constructor() {
super();
this.multipleChoice = false;
}
connectedCallback() {
super.connectedCallback();
if (!this.multipleChoice) {
this.addEventListener('model-value-changed', this._checkSingleChoiceElements);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (!this.multipleChoice) {
this.removeEventListener('model-value-changed', this._checkSingleChoiceElements);
}
this._repropagationRole = 'choice-group'; // configures event propagation logic of FormControlMixin
}
/**
@ -157,9 +144,10 @@ export const ChoiceGroupMixin = dedupeMixin(
}
}
__triggerCheckedValueChanged() {
__setChoiceGroupTouched() {
const value = this.modelValue;
if (value != null && value !== this.__previousCheckedValue) {
// TODO: what happens here exactly? Needs to be based on user interaction (?)
this.touched = true;
this.__previousCheckedValue = value;
}
@ -179,5 +167,24 @@ export const ChoiceGroupMixin = dedupeMixin(
);
}
}
/**
* @override FormControlMixin
*/
_onBeforeRepropagateChildrenValues(ev) {
// Normalize target, since we might receive 'portal events' (from children in a modal,
// see select-rich)
const target = (ev.detail && ev.detail.element) || ev.target;
if (this.multipleChoice || !target.checked) {
return;
}
this.formElements.forEach(option => {
if (target.choiceValue !== option.choiceValue) {
option.checked = false; // eslint-disable-line no-param-reassign
}
});
this.__setChoiceGroupTouched();
this.requestUpdate('modelValue');
}
},
);

View file

@ -3,7 +3,8 @@ import { FormGroupMixin } from '@lion/fieldset';
import '@lion/fieldset/lion-fieldset.js';
import { LionInput } from '@lion/input';
import { Required } from '@lion/validate';
import { expect, fixture, nextFrame } from '@open-wc/testing';
import { expect, nextFrame } from '@open-wc/testing';
import { formFixture as fixture } from '@lion/field/test-helpers.js';
import { ChoiceGroupMixin } from '../src/ChoiceGroupMixin.js';
import { ChoiceInputMixin } from '../src/ChoiceInputMixin.js';
@ -194,21 +195,21 @@ describe('ChoiceGroupMixin', () => {
counter = 0; // reset after setup which may result in different results
el.formElements[0].checked = true;
expect(counter).to.equal(2); // male becomes checked, female becomes unchecked
expect(counter).to.equal(1); // male becomes checked, female becomes unchecked
// not changed values trigger no event
el.formElements[0].checked = true;
expect(counter).to.equal(2);
expect(counter).to.equal(1);
el.formElements[2].checked = true;
expect(counter).to.equal(4); // other becomes checked, male becomes unchecked
expect(counter).to.equal(2); // other becomes checked, male becomes unchecked
// not found values trigger no event
el.modelValue = 'foo';
expect(counter).to.equal(4);
expect(counter).to.equal(2);
el.modelValue = 'male';
expect(counter).to.equal(6); // male becomes checked, other becomes unchecked
expect(counter).to.equal(3); // male becomes checked, other becomes unchecked
});
it('can be required', async () => {

View file

@ -31,6 +31,7 @@
"stories",
"test",
"test-suites",
"test-helpers",
"translations",
"*.js"
],

View file

@ -55,6 +55,22 @@ export const FormControlMixin = dedupeMixin(
* Contains all elements that should end up in aria-describedby of `._inputNode`
*/
_ariaDescribedNodes: Array,
/**
* Based on the role, details of handling model-value-changed repropagation differ.
* @type {'child'|'fieldset'|'choice-group'}
*/
_repropagationRole: String,
/**
* By default, a field with _repropagationRole 'choice-group' will act as an
* 'endpoint'. This means it will be considered as an individual field: for
* a select, individual options will not be part of the formPath. They
* will.
* Similarly, components that (a11y wise) need to be fieldsets, but 'interaction wise'
* (from Application Developer perspective) need to be more like fields
* (think of an amount-input with a currency select box next to it), can set this
* to true to hide private internals in the formPath.
*/
_isRepropagationEndpoint: Boolean,
};
}
@ -151,6 +167,8 @@ export const FormControlMixin = dedupeMixin(
this._inputId = uuid(this.localName);
this._ariaLabelledNodes = [];
this._ariaDescribedNodes = [];
this._repropagationRole = 'child';
this.addEventListener('model-value-changed', this.__repropagateChildrenValues);
}
connectedCallback() {
@ -553,5 +571,95 @@ export const FormControlMixin = dedupeMixin(
__getDirectSlotChild(slotName) {
return [...this.children].find(el => el.slot === slotName);
}
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__dispatchInitialModelValueChangedEvent();
}
async __dispatchInitialModelValueChangedEvent() {
// When we are not a fieldset / choice-group, we don't need to wait for our children
// to send a unified event
if (this._repropagationRole === 'child') {
return;
}
await this.registrationComplete;
// Initially we don't repropagate model-value-changed events coming
// from children. On firstUpdated we re-dispatch this event to maintain
// 'count consistency' (to not confuse the application developer with a
// large number of initial events). Initially the source field will not
// be part of the formPath but afterwards it will.
this.__repropagateChildrenInitialized = true;
this.dispatchEvent(
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this], initialize: true },
}),
);
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars
_onBeforeRepropagateChildrenValues(ev) {}
__repropagateChildrenValues(ev) {
// Allows sub classes to internally listen to the children change events
// (before stopImmediatePropagation is called below).
this._onBeforeRepropagateChildrenValues(ev);
// Normalize target, we also might get it from 'portals' (rich select)
const target = (ev.detail && ev.detail.element) || ev.target;
const isEndpoint =
this._isRepropagationEndpoint || this._repropagationRole === 'choice-group';
// Prevent eternal loops after we sent the event below.
if (target === this) {
return;
}
// A. Stop sibling handlers
//
// Make sure our sibling event listeners (added by Application developers) will not get
// the child model-value-changed event, but the repropagated one at the bottom of this
// method
ev.stopImmediatePropagation();
// B1. Are we still initializing? If so, halt...
//
// Stop repropagating children events before firstUpdated and make sure we de not
// repropagate init events of our children (we already sent our own
// initial model-value-change event in firstUpdated)
const isGroup = this._repropagationRole !== 'child'; // => fieldset or choice-group
const isSelfInitializing = isGroup && !this.__repropagateChildrenInitialized;
const isChildGroupInitializing = ev.detail && ev.detail.initialize;
if (isSelfInitializing || isChildGroupInitializing) {
return;
}
// B2. Are we a single choice choice-group? If so, halt when unchecked
//
// We only send the checked changed up (not the unchecked). In this way a choice group
// (radio-group, checkbox-group, select/listbox) acts as an 'endpoint' (a single Field)
// just like the native <select>
if (this._repropagationRole === 'choice-group' && !this.multipleChoice && !target.checked) {
return;
}
// C1. We are ready to dispatch. Create a formPath
//
// Compute the formPath. Choice groups are regarded 'end points'
let parentFormPath = [];
if (!isEndpoint) {
parentFormPath = (ev.detail && ev.detail.formPath) || [target];
}
const formPath = [...parentFormPath, this];
// C2. Finally, redispatch a fresh model-value-changed event from our host, consumable
// for an Application Developer
//
// 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 } }),
);
}
},
);

View file

@ -289,7 +289,10 @@ export const FormatMixin = dedupeMixin(
_dispatchModelValueChangedEvent() {
/** @event model-value-changed */
this.dispatchEvent(
new CustomEvent('model-value-changed', { bubbles: true, composed: true }),
new CustomEvent('model-value-changed', {
bubbles: true,
detail: { formPath: [this] },
}),
);
}

View file

@ -46,6 +46,9 @@ export const FormRegistrarMixin = dedupeMixin(
this.registrationReady = new Promise(resolve => {
this.__resolveRegistrationReady = resolve;
});
this.registrationComplete = new Promise(resolve => {
this.__resolveRegistrationComplete = resolve;
});
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.addEventListener('form-element-register', this._onRequestToAddFormElement);
@ -76,6 +79,16 @@ export const FormRegistrarMixin = dedupeMixin(
super.firstUpdated(changedProperties);
this.__resolveRegistrationReady();
this.__readyForRegistration = true;
// After we allow our children to register, we need to wait one tick before they
// all sent their 'form-element-register' event.
// TODO: allow developer to delay this moment, similar to LitElement.performUpdate can be
// delayed.
setTimeout(() => {
this.registrationHasCompleted = true;
this.__resolveRegistrationComplete();
});
formRegistrarManager.becomesReady();
this.__hasBeenRendered = true;
}

View file

@ -0,0 +1 @@
export { formFixture } from './test-helpers/formFixture.js';

View file

@ -0,0 +1,11 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { fixture, aTimeout } from '@open-wc/testing';
export async function formFixture(...args) {
const el = await fixture(...args);
if (el.registrationComplete) {
await el.registrationComplete;
await aTimeout();
}
return el;
}

View file

@ -1,7 +1,9 @@
import { expect, fixture, html, defineCE, unsafeStatic } from '@open-wc/testing';
import { expect, html, defineCE, unsafeStatic } from '@open-wc/testing';
import { LitElement, SlotMixin } from '@lion/core';
import sinon from 'sinon';
import { formFixture as fixture } from '../test-helpers/formFixture.js';
import { FormControlMixin } from '../src/FormControlMixin.js';
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
describe('FormControlMixin', () => {
const inputSlot = '<input slot="input" />';
@ -179,4 +181,113 @@ describe('FormControlMixin', () => {
.getAttribute('aria-live'),
).to.equal('polite');
});
describe('Model-value-changed event propagation', () => {
const FormControlWithRegistrarMixinClass = class extends FormControlMixin(
FormRegistrarMixin(SlotMixin(LitElement)),
) {
static get properties() {
return {
modelValue: {
type: String,
},
};
}
};
const groupElem = defineCE(FormControlWithRegistrarMixinClass);
const groupTag = unsafeStatic(groupElem);
describe('On initialization', () => {
it('redispatches one event from host', async () => {
const formSpy = sinon.spy();
const fieldsetSpy = sinon.spy();
const formEl = await fixture(html`
<${groupTag} name="form" ._repropagationRole=${'form-group'} @model-value-changed=${formSpy}>
<${groupTag} name="fieldset" ._repropagationRole=${'form-group'} @model-value-changed=${fieldsetSpy}>
<${tag} name="field"></${tag}>
</${groupTag}>
</${groupTag}>
`);
const fieldsetEl = formEl.querySelector('[name=fieldset]');
expect(fieldsetSpy.callCount).to.equal(1);
const fieldsetEv = fieldsetSpy.firstCall.args[0];
expect(fieldsetEv.target).to.equal(fieldsetEl);
expect(fieldsetEv.detail.formPath).to.eql([fieldsetEl]);
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]);
});
});
describe('After initialization', () => {
it('redispatches one event from host and keeps formPath history', 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 Event('model-value-changed', { bubbles: true }));
expect(fieldsetSpy.callCount).to.equal(1);
const fieldsetEv = fieldsetSpy.firstCall.args[0];
expect(fieldsetEv.target).to.equal(fieldsetEl);
expect(fieldsetEv.detail.formPath).to.eql([fieldEl, fieldsetEl]);
expect(formSpy.callCount).to.equal(1);
const formEv = formSpy.firstCall.args[0];
expect(formEv.target).to.equal(formEl);
expect(formEv.detail.formPath).to.eql([fieldEl, fieldsetEl, formEl]);
});
it('sends one event for single select choice-groups', async () => {
const formSpy = sinon.spy();
const choiceGroupSpy = sinon.spy();
const formEl = await fixture(html`
<${groupTag} name="form">
<${groupTag} name="choice-group" ._repropagationRole=${'choice-group'}>
<${tag} name="choice-group" id="option1" .checked=${true}></${tag}>
<${tag} name="choice-group" id="option2"></${tag}>
</${groupTag}>
</${groupTag}>
`);
const choiceGroupEl = formEl.querySelector('[name=choice-group]');
const option1El = formEl.querySelector('#option1');
const option2El = formEl.querySelector('#option2');
formEl.addEventListener('model-value-changed', formSpy);
choiceGroupEl.addEventListener('model-value-changed', choiceGroupSpy);
// Simulate check
option2El.checked = true;
option2El.dispatchEvent(new Event('model-value-changed', { bubbles: true }));
option1El.checked = false;
option1El.dispatchEvent(new Event('model-value-changed', { bubbles: true }));
expect(choiceGroupSpy.callCount).to.equal(1);
const choiceGroupEv = choiceGroupSpy.firstCall.args[0];
expect(choiceGroupEv.target).to.equal(choiceGroupEl);
expect(choiceGroupEv.detail.formPath).to.eql([choiceGroupEl]);
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]);
});
});
});
});

View file

@ -135,8 +135,8 @@ export const FormGroupMixin = dedupeMixin(
}
async __initInteractionStates() {
if (!this.__readyForRegistration) {
await this.registrationReady;
if (!this.registrationHasCompleted) {
await this.registrationComplete;
}
this.formElements.forEach(el => {
if (typeof el.initInteractionState === 'function') {

View file

@ -25,5 +25,6 @@ export class LionFieldset extends FormGroupMixin(LitElement) {
super();
/** @override from FormRegistrarMixin */
this._isFormOrFieldset = true;
this._repropagationRole = 'fieldset'; // configures FormControlMixin
}
}

View file

@ -1,6 +1,5 @@
import {
expect,
fixture,
fixtureSync,
html,
unsafeStatic,
@ -8,6 +7,7 @@ import {
nextFrame,
defineCE,
} from '@open-wc/testing';
import { formFixture as fixture } from '@lion/field/test-helpers.js';
import sinon from 'sinon';
import { Validator, IsNumber } from '@lion/validate';
import { localizeTearDown } from '@lion/localize/test-helpers.js';

View file

@ -41,6 +41,7 @@
"@lion/input": "0.5.18",
"@lion/input-amount": "0.5.18",
"@lion/input-date": "0.5.19",
"@lion/input-datepicker": "0.10.1",
"@lion/input-email": "0.6.3",
"@lion/input-iban": "0.6.3",
"@lion/input-range": "0.2.18",

View file

@ -33,9 +33,9 @@ For usage and installation please see the appropriate packages.
{() => {
Required.getMessage = () => 'Please enter a value';
return html`
<lion-form>
<lion-form @model-value-changed="${(ev) => { console.log('lion-form::', ev.target.name, ':', ev.detail.formPath) }}">
<form>
<lion-fieldset name="full_name">
<lion-fieldset name="full_name" @model-value-changed="${(ev) => { console.log('lion-fieldset::', ev.target.name, ':', ev.detail.formPath) }}">
<lion-input
name="first_name"
label="First Name"
@ -69,6 +69,7 @@ For usage and installation please see the appropriate packages.
<lion-input-iban name="iban" label="Iban"></lion-input-iban>
<lion-input-email name="email" label="Email"></lion-input-email>
<lion-checkbox-group
@model-value-changed="${(ev) => { console.log('lion-cb-group::', ev.target.name, ':', ev.detail.formPath) }}"
label="What do you like?"
name="checkers"
.validators="${[new Required()]}"
@ -78,6 +79,7 @@ For usage and installation please see the appropriate packages.
<lion-checkbox .choiceValue=${'baz'} label="I like baz"></lion-checkbox>
</lion-checkbox-group>
<lion-radio-group
@model-value-changed="${(ev) => { console.log('lion-radio-group::', ev.target.name, ':', ev.detail.formPath) }}"
name="dinosaurs"
label="Favorite dinosaur"
.validators="${[new Required()]}"

View file

@ -0,0 +1,274 @@
import { expect, html, unsafeStatic } from '@open-wc/testing';
import { formFixture as fixture } from '@lion/field/test-helpers.js';
// eslint-disable-next-line import/no-extraneous-dependencies
import sinon from 'sinon';
import '@lion/input/lion-input.js';
import '@lion/input-amount/lion-input-amount.js';
import '@lion/input-date/lion-input-date.js';
import '@lion/input-datepicker/lion-input-datepicker.js';
import '@lion/input-email/lion-input-email.js';
import '@lion/input-iban/lion-input-iban.js';
import '@lion/input-range/lion-input-range.js';
import '@lion/textarea/lion-textarea.js';
import '@lion/checkbox-group/lion-checkbox-group.js';
import '@lion/checkbox-group/lion-checkbox.js';
import '@lion/radio-group/lion-radio-group.js';
import '@lion/radio-group/lion-radio.js';
import '@lion/select/lion-select.js';
import '@lion/select-rich/lion-select-rich.js';
import '@lion/select-rich/lion-options.js';
import '@lion/select-rich/lion-option.js';
import '@lion/fieldset/lion-fieldset.js';
const featureName = 'model value';
const getFirstPaintTitle = count => `should dispatch ${count} time(s) on first paint`;
const getInteractionTitle = count => `should dispatch ${count} time(s) on interaction`;
const firstStampCount = 1;
const interactionCount = 1;
const fieldDispatchesCountOnFirstPaint = (tagname, count) => {
const tag = unsafeStatic(tagname);
const spy = sinon.spy();
it(getFirstPaintTitle(count), async () => {
await fixture(html`<${tag} @model-value-changed="${spy}"></${tag}>`);
expect(spy.callCount).to.equal(count);
});
};
const fieldDispatchesCountOnInteraction = (tagname, count) => {
const tag = unsafeStatic(tagname);
const spy = sinon.spy();
it(getInteractionTitle(count), async () => {
const el = await fixture(html`<${tag}></${tag}>`);
el.addEventListener('model-value-changed', spy);
// TODO: discuss if this is the "correct" way to interact with component
el.modelValue = 'foo';
await el.updateComplete;
expect(spy.callCount).to.equal(count);
});
};
const choiceDispatchesCountOnFirstPaint = (tagname, count) => {
const tag = unsafeStatic(tagname);
const spy = sinon.spy();
it(getFirstPaintTitle(count), async () => {
await fixture(html`<${tag} @model-value-changed="${spy}" .choiceValue="${'option'}"></${tag}>`);
expect(spy.callCount).to.equal(count);
});
};
const choiceDispatchesCountOnInteraction = (tagname, count) => {
const tag = unsafeStatic(tagname);
const spy = sinon.spy();
it(getInteractionTitle(count), async () => {
const el = await fixture(html`<${tag} .choiceValue="${'option'}"></${tag}>`);
el.addEventListener('model-value-changed', spy);
el.checked = true;
expect(spy.callCount).to.equal(count);
});
};
const choiceGroupDispatchesCountOnFirstPaint = (groupTagname, itemTagname, count) => {
const groupTag = unsafeStatic(groupTagname);
const itemTag = unsafeStatic(itemTagname);
it(getFirstPaintTitle(count), async () => {
const spy = sinon.spy();
await fixture(html`
<${groupTag} @model-value-changed="${spy}">
<${itemTag} .choiceValue="${'option1'}"></${itemTag}>
<${itemTag} .choiceValue="${'option2'}"></${itemTag}>
<${itemTag} .choiceValue="${'option3'}"></${itemTag}>
</${groupTag}>
`);
expect(spy.callCount).to.equal(count);
});
};
const choiceGroupDispatchesCountOnInteraction = (groupTagname, itemTagname, count) => {
const groupTag = unsafeStatic(groupTagname);
const itemTag = unsafeStatic(itemTagname);
it(getInteractionTitle(count), async () => {
const spy = sinon.spy();
const el = await fixture(html`
<${groupTag}>
<${itemTag} .choiceValue="${'option1'}"></${itemTag}>
<${itemTag} .choiceValue="${'option2'}"></${itemTag}>
<${itemTag} .choiceValue="${'option3'}"></${itemTag}>
</${groupTag}>
`);
el.addEventListener('model-value-changed', spy);
const option2 = el.querySelector(`${itemTagname}:nth-child(2)`);
option2.checked = true;
expect(spy.callCount).to.equal(count);
spy.resetHistory();
const option3 = el.querySelector(`${itemTagname}:nth-child(3)`);
option3.checked = true;
expect(spy.callCount).to.equal(count);
});
};
[
'input',
'input-amount',
'input-date',
'input-datepicker',
'input-email',
'input-iban',
'input-range',
'textarea',
].forEach(chunk => {
const tagname = `lion-${chunk}`;
describe(`${tagname}`, () => {
describe(featureName, () => {
fieldDispatchesCountOnFirstPaint(tagname, firstStampCount);
fieldDispatchesCountOnInteraction(tagname, interactionCount);
});
});
});
['checkbox', 'radio'].forEach(chunk => {
const groupTagname = `lion-${chunk}-group`;
const itemTagname = `lion-${chunk}`;
describe(`${itemTagname}`, () => {
describe(featureName, () => {
choiceDispatchesCountOnFirstPaint(itemTagname, firstStampCount);
choiceDispatchesCountOnInteraction(itemTagname, interactionCount);
});
});
describe(`${groupTagname}`, () => {
describe(featureName, () => {
choiceGroupDispatchesCountOnFirstPaint(groupTagname, itemTagname, firstStampCount);
choiceGroupDispatchesCountOnInteraction(groupTagname, itemTagname, interactionCount);
});
});
});
describe('lion-select', () => {
describe(featureName, () => {
it(getFirstPaintTitle(firstStampCount), async () => {
const spy = sinon.spy();
await fixture(html`
<lion-select @model-value-changed="${spy}">
<select slot="input">
<option value="option1"></option>
<option value="option2"></option>
<option value="option3"></option>
</select>
</lion-select>
`);
expect(spy.callCount).to.equal(firstStampCount);
});
it(getInteractionTitle(interactionCount), async () => {
const spy = sinon.spy();
const el = await fixture(html`
<lion-select>
<select slot="input">
<option value="option1"></option>
<option value="option2"></option>
<option value="option3"></option>
</select>
</lion-select>
`);
el.addEventListener('model-value-changed', spy);
const option2 = el.querySelector('option:nth-child(2)');
// mimic user input
option2.selected = true;
el._inputNode.dispatchEvent(new CustomEvent('change'));
expect(spy.callCount).to.equal(interactionCount);
spy.resetHistory();
const option3 = el.querySelector('option:nth-child(3)');
// mimic user input
option3.selected = true;
el._inputNode.dispatchEvent(new CustomEvent('change'));
expect(spy.callCount).to.equal(interactionCount);
});
});
});
describe('lion-select-rich', () => {
describe(featureName, () => {
it(getFirstPaintTitle(firstStampCount), async () => {
const spy = sinon.spy();
await fixture(html`
<lion-select-rich @model-value-changed="${spy}">
<lion-options slot="input">
<lion-option .choiceValue="${'option1'}"></lion-option>
<lion-option .choiceValue="${'option2'}"></lion-option>
<lion-option .choiceValue="${'option3'}"></lion-option>
</lion-options>
</lion-select-rich>
`);
expect(spy.callCount).to.equal(firstStampCount);
});
it(getInteractionTitle(interactionCount), async () => {
const spy = sinon.spy();
const el = await fixture(html`
<lion-select-rich>
<lion-options slot="input">
<lion-option .choiceValue="${'option1'}"></lion-option>
<lion-option .choiceValue="${'option2'}"></lion-option>
<lion-option .choiceValue="${'option3'}"></lion-option>
</lion-options>
</lion-select-rich>
`);
el.addEventListener('model-value-changed', spy);
const option2 = el.querySelector('lion-option:nth-child(2)');
option2.checked = true;
expect(spy.callCount).to.equal(interactionCount);
spy.resetHistory();
const option3 = el.querySelector('lion-option:nth-child(3)');
option3.checked = true;
expect(spy.callCount).to.equal(interactionCount);
});
});
});
describe('lion-fieldset', () => {
describe(featureName, () => {
it(getFirstPaintTitle(firstStampCount), async () => {
const spy = sinon.spy();
await fixture(html`
<lion-fieldset name="parent" @model-value-changed="${spy}">
<lion-input name="input"></lion-input>
</lion-fieldset>
`);
expect(spy.callCount).to.equal(firstStampCount);
});
it(getInteractionTitle(interactionCount), async () => {
const spy = sinon.spy();
const el = await fixture(html`
<lion-fieldset name="parent">
<lion-input name="input"></lion-input>
</lion-fieldset>
`);
el.addEventListener('model-value-changed', spy);
const input = el.querySelector('lion-input');
input.modelValue = 'foo';
expect(spy.callCount).to.equal(interactionCount);
});
});
});

View file

@ -0,0 +1,144 @@
import { expect, html } from '@open-wc/testing';
import { formFixture as fixture } from '@lion/field/test-helpers.js';
// eslint-disable-next-line import/no-extraneous-dependencies
import sinon from 'sinon';
import '@lion/input/lion-input.js';
import '@lion/fieldset/lion-fieldset.js';
describe('model value event', () => {
describe('form path', () => {
it('should be property', async () => {
const spy = sinon.spy();
const input = await fixture(html`
<lion-input></lion-input>
`);
input.addEventListener('model-value-changed', spy);
input.modelValue = 'woof';
const e = spy.firstCall.args[0];
expect(e.detail).to.have.a.property('formPath');
});
it('should contain dispatching field', async () => {
const spy = sinon.spy();
const input = await fixture(html`
<lion-input></lion-input>
`);
input.addEventListener('model-value-changed', spy);
input.modelValue = 'foo';
const e = spy.firstCall.args[0];
expect(e.detail.formPath).to.eql([input]);
});
it('should contain field and group', async () => {
const spy = sinon.spy();
const fieldset = await fixture(html`
<lion-fieldset name="fieldset">
<lion-input name="input"></lion-input>
</lion-fieldset>
`);
fieldset.addEventListener('model-value-changed', spy);
const input = fieldset.querySelector('lion-input');
input.modelValue = 'foo';
const e = spy.firstCall.args[0];
expect(e.detail.formPath).to.eql([input, fieldset]);
});
it('should contain deep elements', async () => {
const spy = sinon.spy();
const grandparent = await fixture(html`
<lion-fieldset name="grandparent">
<lion-fieldset name="parent">
<lion-input name="input"></lion-input>
</lion-fieldset>
</lion-fieldset>
`);
const parent = grandparent.querySelector('[name=parent]');
const input = grandparent.querySelector('[name=input]');
grandparent.addEventListener('model-value-changed', spy);
input.modelValue = 'foo';
const e = spy.firstCall.args[0];
expect(e.detail.formPath).to.eql([input, parent, grandparent]);
});
it('should ignore elements that are not fields or fieldsets', async () => {
const spy = sinon.spy();
const grandparent = await fixture(html`
<lion-fieldset name="grandparent">
<div>
<lion-fieldset name="parent">
<div>
<div>
<lion-input name="input"></lion-input>
</div>
</div>
</lion-fieldset>
</div>
</lion-fieldset>
`);
const parent = grandparent.querySelector('[name=parent]');
const input = grandparent.querySelector('[name=input]');
grandparent.addEventListener('model-value-changed', spy);
input.modelValue = 'foo';
const e = spy.firstCall.args[0];
expect(e.detail.formPath).to.eql([input, parent, grandparent]);
});
});
describe('signature', () => {
let e;
beforeEach(async () => {
const spy = sinon.spy();
const el = await fixture(
html`
<lion-input></lion-input>
`,
);
el.addEventListener('model-value-changed', spy);
el.modelValue = 'foo';
// eslint-disable-next-line prefer-destructuring
e = spy.firstCall.args[0];
});
// TODO: Re-enable at some point...
// In my opinion (@CubLion) we should not bubble events.
// Instead each parent should explicitly listen to children events,
// and then re-dispatch on themselves.
it.skip('should not bubble', async () => {
expect(e.bubbles).to.be.false;
});
it('should not leave shadow boundary', async () => {
expect(e.composed).to.be.false;
});
});
describe('propagation', () => {
it('should dispatch different event at each level', async () => {
const grandparent = await fixture(html`
<lion-fieldset name="grandparent">
<lion-fieldset name="parent">
<lion-input name="input"></lion-input>
</lion-fieldset>
</lion-fieldset>
`);
const parent = grandparent.querySelector('[name="parent"]');
const input = grandparent.querySelector('[name="input"]');
const spies = [];
[grandparent, parent, input].forEach(element => {
const spy = sinon.spy();
spies.push(spy);
element.addEventListener('model-value-changed', spy);
});
input.modelValue = 'foo';
spies.forEach((spy, index) => {
const currentEvent = spy.firstCall.args[0];
for (let i = index + 1; i < spies.length; i += 1) {
const nextEvent = spies[i].firstCall.args[0];
expect(currentEvent).not.to.eql(nextEvent);
}
});
});
});
});

View file

@ -1,7 +1,7 @@
export const withDropdownConfig = () => ({
placementMode: 'local',
inheritsReferenceWidth: true,
inheritsReferenceWidth: 'full',
hidesOnOutsideClick: true,
popperConfig: {
placement: 'bottom-start',

View file

@ -148,7 +148,6 @@ export class LionSelectRich extends ScopedElementsMixin(
this.__cachedUserSetModelValue = value;
}
this.__syncInvokerElement();
this.requestUpdate('modelValue');
}
@ -199,8 +198,9 @@ export class LionSelectRich extends ScopedElementsMixin(
// for interaction states
this._listboxActiveDescendant = null;
this.__hasInitialSelectedFormElement = false;
this._repropagationRole = 'choice-group'; // configures FormControlMixin
this.__setupEventListeners();
this.__initInteractionStates();
}
connectedCallback() {
@ -250,6 +250,15 @@ export class LionSelectRich extends ScopedElementsMixin(
}
}
async __initInteractionStates() {
await this.registrationComplete;
// This timeout is here, so that we know we handle after the initial model-value
// event (see firstUpdated method FormConrtolMixin) has fired.
setTimeout(() => {
this.initInteractionState();
});
}
get _inputNode() {
// In FormControl, we get direct child [slot="input"]. This doesn't work, because the overlay
// system wraps it in [slot="_overlay-shadow-outlet"]
@ -298,6 +307,10 @@ export class LionSelectRich extends ScopedElementsMixin(
this._invokerNode.setAttribute('aria-invalid', this._hasFeedbackVisibleFor('error'));
}
}
if (changedProperties.has('modelValue')) {
this.__syncInvokerElement();
}
}
toggle() {
@ -345,24 +358,27 @@ export class LionSelectRich extends ScopedElementsMixin(
this.__setAttributeForAllFormElements('aria-setsize', this.formElements.length);
child.setAttribute('aria-posinset', this.formElements.length);
this.__onChildModelValueChanged({ target: child });
this.__proxyChildModelValueChanged({ target: child });
this.resetInteractionState();
/* eslint-enable no-param-reassign */
}
__setupEventListeners() {
this.__onChildActiveChanged = this.__onChildActiveChanged.bind(this);
this.__onChildModelValueChanged = this.__onChildModelValueChanged.bind(this);
this.__proxyChildModelValueChanged = this.__proxyChildModelValueChanged.bind(this);
this.__onKeyUp = this.__onKeyUp.bind(this);
this._listboxNode.addEventListener('active-changed', this.__onChildActiveChanged);
this._listboxNode.addEventListener('model-value-changed', this.__onChildModelValueChanged);
this._listboxNode.addEventListener('model-value-changed', this.__proxyChildModelValueChanged);
this.addEventListener('keyup', this.__onKeyUp);
}
__teardownEventListeners() {
this._listboxNode.removeEventListener('active-changed', this.__onChildActiveChanged);
this._listboxNode.removeEventListener('model-value-changed', this.__onChildModelValueChanged);
this._listboxNode.removeEventListener(
'model-value-changed',
this.__proxyChildModelValueChanged,
);
this._listboxNode.removeEventListener('keyup', this.__onKeyUp);
}
@ -391,16 +407,14 @@ export class LionSelectRich extends ScopedElementsMixin(
});
}
__onChildModelValueChanged({ target }) {
if (target.checked) {
this.formElements.forEach(formElement => {
if (formElement !== target) {
// eslint-disable-next-line no-param-reassign
formElement.checked = false;
}
});
this.modelValue = target.value;
__proxyChildModelValueChanged(ev) {
// We need to redispatch the model-value-changed event on 'this', so it will
// align with FormControl.__repropagateChildrenValues method. Also, this makes
// it act like a portal, in case the listbox is put in a modal overlay on body level.
if (ev.stopPropagation) {
ev.stopPropagation();
}
this.dispatchEvent(new CustomEvent('model-value-changed', { detail: { element: ev.target } }));
}
__syncInvokerElement() {

View file

@ -1,5 +1,6 @@
import { Required } from '@lion/validate';
import { expect, fixture, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing';
import { expect, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing';
import { formFixture as fixture } from '@lion/field/test-helpers.js';
import '../lion-option.js';
import '../lion-options.js';

View file

@ -1,15 +1,9 @@
import { LitElement } from '@lion/core';
import { OverlayController } from '@lion/overlays';
import { Required } from '@lion/validate';
import {
aTimeout,
defineCE,
expect,
fixture,
html,
nextFrame,
unsafeStatic,
} from '@open-wc/testing';
import { aTimeout, defineCE, expect, html, nextFrame, unsafeStatic } from '@open-wc/testing';
import { formFixture as fixture } from '@lion/field/test-helpers.js';
import { LionSelectRich } from '../index.js';
import '../lion-option.js';
import '../lion-options.js';
@ -246,6 +240,7 @@ describe('lion-select-rich', () => {
expect(el._invokerNode.selectedElement).dom.to.equal(options[1]);
el.checkedIndex = 0;
await el.updateComplete;
expect(el._invokerNode.selectedElement).dom.to.equal(options[0]);
});

View file

@ -32,16 +32,16 @@ import { LionField } from '@lion/field';
export class LionSelect extends LionField {
connectedCallback() {
super.connectedCallback();
this.addEventListener('change', this._proxyChangeEvent);
this._inputNode.addEventListener('change', this._proxyChangeEvent);
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('change', this._proxyChangeEvent);
this._inputNode.removeEventListener('change', this._proxyChangeEvent);
}
_proxyChangeEvent() {
this._inputNode.dispatchEvent(
this.dispatchEvent(
new CustomEvent('user-input-changed', {
bubbles: true,
composed: true,