@lion/field
Advanced tools
Comparing version 0.11.3 to 0.11.4
@@ -6,2 +6,13 @@ # Change Log | ||
## [0.11.4](https://github.com/ing-bank/lion/compare/@lion/field@0.11.3...@lion/field@0.11.4) (2020-03-19) | ||
### Bug Fixes | ||
* normalization model-value-changed events ([1b6c3a4](https://github.com/ing-bank/lion/commit/1b6c3a44c820b9d61c26849b91bbb1bc8d6c772b)) | ||
## [0.11.3](https://github.com/ing-bank/lion/compare/@lion/field@0.11.2...@lion/field@0.11.3) (2020-03-05) | ||
@@ -8,0 +19,0 @@ |
{ | ||
"name": "@lion/field", | ||
"version": "0.11.3", | ||
"version": "0.11.4", | ||
"description": "Fields are the most fundamental building block of the Form System", | ||
@@ -34,2 +34,3 @@ "author": "ing-bank", | ||
"test-suites", | ||
"test-helpers", | ||
"translations", | ||
@@ -48,3 +49,3 @@ "*.js" | ||
}, | ||
"gitHead": "54c4432b818cf1f6023557d0df5c461ae50412da" | ||
"gitHead": "f286e3ac28fe0b34284383344fda4dc8e9ea593d" | ||
} |
@@ -292,3 +292,6 @@ /* eslint-disable class-methods-use-this */ | ||
this.dispatchEvent( | ||
new CustomEvent('model-value-changed', { bubbles: true, composed: true }), | ||
new CustomEvent('model-value-changed', { | ||
bubbles: true, | ||
detail: { formPath: [this] }, | ||
}), | ||
); | ||
@@ -295,0 +298,0 @@ } |
@@ -58,2 +58,18 @@ import { html, css, nothing, dedupeMixin, SlotMixin } from '@lion/core'; | ||
_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, | ||
}; | ||
@@ -155,2 +171,4 @@ } | ||
this._ariaDescribedNodes = []; | ||
this._repropagationRole = 'child'; | ||
this.addEventListener('model-value-changed', this.__repropagateChildrenValues); | ||
} | ||
@@ -558,3 +576,93 @@ | ||
} | ||
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 } }), | ||
); | ||
} | ||
}, | ||
); |
@@ -49,2 +49,5 @@ // eslint-disable-next-line max-classes-per-file | ||
}); | ||
this.registrationComplete = new Promise(resolve => { | ||
this.__resolveRegistrationComplete = resolve; | ||
}); | ||
@@ -80,2 +83,12 @@ this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this); | ||
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(); | ||
@@ -82,0 +95,0 @@ this.__hasBeenRendered = true; |
@@ -1,5 +0,7 @@ | ||
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'; | ||
@@ -182,2 +184,111 @@ describe('FormControlMixin', () => { | ||
}); | ||
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]); | ||
}); | ||
}); | ||
}); | ||
}); |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
178471
33
3545