diff --git a/packages/choice-input/src/ChoiceGroupMixin.js b/packages/choice-input/src/ChoiceGroupMixin.js index 4d23092bf..85402e9b5 100644 --- a/packages/choice-input/src/ChoiceGroupMixin.js +++ b/packages/choice-input/src/ChoiceGroupMixin.js @@ -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'); + } }, ); diff --git a/packages/choice-input/test/ChoiceGroupMixin.test.js b/packages/choice-input/test/ChoiceGroupMixin.test.js index d3e7d2e97..ad7dd0901 100644 --- a/packages/choice-input/test/ChoiceGroupMixin.test.js +++ b/packages/choice-input/test/ChoiceGroupMixin.test.js @@ -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 () => { diff --git a/packages/field/package.json b/packages/field/package.json index d357f84fe..f7041ecc9 100644 --- a/packages/field/package.json +++ b/packages/field/package.json @@ -31,6 +31,7 @@ "stories", "test", "test-suites", + "test-helpers", "translations", "*.js" ], diff --git a/packages/field/src/FormControlMixin.js b/packages/field/src/FormControlMixin.js index 75e06e879..2bb8d4ddf 100644 --- a/packages/field/src/FormControlMixin.js +++ b/packages/field/src/FormControlMixin.js @@ -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 '; @@ -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"> + + + `); + 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"> + + + `); + 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} name="choice-group" id="option2"> + + + `); + 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]); + }); + }); + }); }); diff --git a/packages/fieldset/src/FormGroupMixin.js b/packages/fieldset/src/FormGroupMixin.js index bc29d8174..d328cfa35 100644 --- a/packages/fieldset/src/FormGroupMixin.js +++ b/packages/fieldset/src/FormGroupMixin.js @@ -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') { diff --git a/packages/fieldset/src/LionFieldset.js b/packages/fieldset/src/LionFieldset.js index 9ea5829d1..76fe3f57d 100644 --- a/packages/fieldset/src/LionFieldset.js +++ b/packages/fieldset/src/LionFieldset.js @@ -25,5 +25,6 @@ export class LionFieldset extends FormGroupMixin(LitElement) { super(); /** @override from FormRegistrarMixin */ this._isFormOrFieldset = true; + this._repropagationRole = 'fieldset'; // configures FormControlMixin } } diff --git a/packages/fieldset/test/lion-fieldset.test.js b/packages/fieldset/test/lion-fieldset.test.js index 13207bf9e..e0b05f6fb 100644 --- a/packages/fieldset/test/lion-fieldset.test.js +++ b/packages/fieldset/test/lion-fieldset.test.js @@ -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'; diff --git a/packages/form-system/package.json b/packages/form-system/package.json index 2e66134f9..20e490013 100644 --- a/packages/form-system/package.json +++ b/packages/form-system/package.json @@ -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", diff --git a/packages/form-system/stories/15-features-overview.stories.mdx b/packages/form-system/stories/15-features-overview.stories.mdx index 5ac3683e9..c2295ef62 100644 --- a/packages/form-system/stories/15-features-overview.stories.mdx +++ b/packages/form-system/stories/15-features-overview.stories.mdx @@ -33,9 +33,9 @@ For usage and installation please see the appropriate packages. {() => { Required.getMessage = () => 'Please enter a value'; return html` - +
- + `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}">`); + 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}>`); + 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'}">`); + 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'}">`); + 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} .choiceValue="${'option2'}"> + <${itemTag} .choiceValue="${'option3'}"> + + `); + 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} .choiceValue="${'option2'}"> + <${itemTag} .choiceValue="${'option3'}"> + + `); + 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` + + + + `); + expect(spy.callCount).to.equal(firstStampCount); + }); + + it(getInteractionTitle(interactionCount), async () => { + const spy = sinon.spy(); + const el = await fixture(html` + + + + `); + 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` + + + + + + + + `); + expect(spy.callCount).to.equal(firstStampCount); + }); + + it(getInteractionTitle(interactionCount), async () => { + const spy = sinon.spy(); + const el = await fixture(html` + + + + + + + + `); + 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` + + + + `); + expect(spy.callCount).to.equal(firstStampCount); + }); + + it(getInteractionTitle(interactionCount), async () => { + const spy = sinon.spy(); + const el = await fixture(html` + + + + `); + el.addEventListener('model-value-changed', spy); + const input = el.querySelector('lion-input'); + input.modelValue = 'foo'; + expect(spy.callCount).to.equal(interactionCount); + }); + }); +}); diff --git a/packages/form-system/test/model-value-event.test.js b/packages/form-system/test/model-value-event.test.js new file mode 100644 index 000000000..6308b8cba --- /dev/null +++ b/packages/form-system/test/model-value-event.test.js @@ -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` + + `); + 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` + + `); + 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` + + + + `); + 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` + + + + + + `); + 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` + +
+ +
+
+ +
+
+
+
+
+ `); + 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` + + `, + ); + 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` + + + + + + `); + 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); + } + }); + }); + }); +}); diff --git a/packages/overlays/src/configurations/withDropdownConfig.js b/packages/overlays/src/configurations/withDropdownConfig.js index a0a9d59b1..1bf9c45a6 100644 --- a/packages/overlays/src/configurations/withDropdownConfig.js +++ b/packages/overlays/src/configurations/withDropdownConfig.js @@ -1,7 +1,7 @@ export const withDropdownConfig = () => ({ placementMode: 'local', - inheritsReferenceWidth: true, + inheritsReferenceWidth: 'full', hidesOnOutsideClick: true, popperConfig: { placement: 'bottom-start', diff --git a/packages/select-rich/src/LionSelectRich.js b/packages/select-rich/src/LionSelectRich.js index c88d8946a..1f4efe594 100644 --- a/packages/select-rich/src/LionSelectRich.js +++ b/packages/select-rich/src/LionSelectRich.js @@ -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() { diff --git a/packages/select-rich/test/lion-select-rich-interaction.test.js b/packages/select-rich/test/lion-select-rich-interaction.test.js index 7b421e4e5..9c3ab4687 100644 --- a/packages/select-rich/test/lion-select-rich-interaction.test.js +++ b/packages/select-rich/test/lion-select-rich-interaction.test.js @@ -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'; diff --git a/packages/select-rich/test/lion-select-rich.test.js b/packages/select-rich/test/lion-select-rich.test.js index a8d33010b..274577688 100644 --- a/packages/select-rich/test/lion-select-rich.test.js +++ b/packages/select-rich/test/lion-select-rich.test.js @@ -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]); }); diff --git a/packages/select/src/LionSelect.js b/packages/select/src/LionSelect.js index 96708eddf..d1b7b18f4 100644 --- a/packages/select/src/LionSelect.js +++ b/packages/select/src/LionSelect.js @@ -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,