feat: synchronous form registration system

This commit is contained in:
Thomas Allmer 2020-07-16 13:24:38 +02:00 committed by Thomas Allmer
parent 96b82b70b2
commit 8698f73418
25 changed files with 1581 additions and 1487 deletions

View file

@ -25,8 +25,9 @@
"storybook:build": "build-storybook", "storybook:build": "build-storybook",
"storybook:build:start": "es-dev-server --root-dir storybook-static --open", "storybook:build:start": "es-dev-server --root-dir storybook-static --open",
"test": "run-p test:browser test:node", "test": "run-p test:browser test:node",
"test:browser": "wtr packages/**/*/test/**/*.test.js --coverage", "test:browser": "wtr \"packages/**/*/test/**/*.test.js\" --coverage",
"test:browser:watch": "wtr packages/**/*/test/**/*.test.js --watch", "test:browser:watch": "wtr \"packages/**/*/test/**/*.test.js\" --watch",
"test:browserstack": "wtr --config ./web-test-runner-browserstack.config.js \"packages/form-core/test/**/*.test.js\"",
"test:node": "lerna run test:node" "test:node": "lerna run test:node"
}, },
"devDependencies": { "devDependencies": {
@ -40,8 +41,10 @@
"@open-wc/testing-helpers": "^1.0.0", "@open-wc/testing-helpers": "^1.0.0",
"@storybook/addon-a11y": "~5.0.0", "@storybook/addon-a11y": "~5.0.0",
"@types/chai-dom": "^0.0.8", "@types/chai-dom": "^0.0.8",
"@web/test-runner": "^0.6.33", "@web/dev-server-legacy": "^0.0.1",
"@webcomponents/webcomponentsjs": "^2.2.5", "@web/test-runner": "^0.6.40",
"@web/test-runner-browserstack": "^0.0.6",
"@webcomponents/webcomponentsjs": "^2.4.4",
"babel-eslint": "^8.2.6", "babel-eslint": "^8.2.6",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"bundlesize": "^0.17.1", "bundlesize": "^0.17.1",

View file

@ -1,6 +1,5 @@
import { LionField, IsNumber, Validator } from '@lion/form-core'; import { LionField, IsNumber, Validator } from '@lion/form-core';
import '@lion/form-core/lion-field.js'; import '@lion/form-core/lion-field.js';
import { formFixture as fixture } from '@lion/form-core/test-helpers.js';
import { localizeTearDown } from '@lion/localize/test-helpers.js'; import { localizeTearDown } from '@lion/localize/test-helpers.js';
import { import {
defineCE, defineCE,
@ -10,6 +9,8 @@ import {
nextFrame, nextFrame,
triggerFocusFor, triggerFocusFor,
unsafeStatic, unsafeStatic,
fixture,
aTimeout,
} from '@open-wc/testing'; } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
import '../lion-fieldset.js'; import '../lion-fieldset.js';
@ -56,9 +57,9 @@ describe('<lion-fieldset>', () => {
}); });
it(`can override fieldName`, async () => { it(`can override fieldName`, async () => {
const el = await fixture( const el = await fixture(html`
html`<${tag} label="foo" .fieldName="${'bar'}">${inputSlots}</${tag}>`, <${tag} label="foo" .fieldName="${'bar'}">${inputSlots}</${tag}>
); `);
expect(el.__fieldName).to.equal(el.fieldName); expect(el.__fieldName).to.equal(el.fieldName);
}); });
@ -187,9 +188,7 @@ describe('<lion-fieldset>', () => {
<${tag} name="newfieldset">${inputSlots}</${tag}> <${tag} name="newfieldset">${inputSlots}</${tag}>
</${tag}> </${tag}>
`); `);
await el.registrationReady;
const newFieldset = el.querySelector('lion-fieldset'); const newFieldset = el.querySelector('lion-fieldset');
await newFieldset.registrationReady;
el.formElements.lastName.modelValue = 'Bar'; el.formElements.lastName.modelValue = 'Bar';
newFieldset.formElements['hobbies[]'][0].modelValue = { checked: true, value: 'chess' }; newFieldset.formElements['hobbies[]'][0].modelValue = { checked: true, value: 'chess' };
newFieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'football' }; newFieldset.formElements['hobbies[]'][1].modelValue = { checked: false, value: 'football' };
@ -211,6 +210,9 @@ describe('<lion-fieldset>', () => {
color: { checked: false, value: 'blue' }, color: { checked: false, value: 'blue' },
}, },
}); });
// make sure values are full settled before changing them
await aTimeout();
el.modelValue = { el.modelValue = {
lastName: 2, lastName: 2,
newfieldset: { newfieldset: {
@ -225,6 +227,7 @@ describe('<lion-fieldset>', () => {
color: { checked: false, value: 'blue' }, color: { checked: false, value: 'blue' },
}, },
}; };
expect(newFieldset.formElements['hobbies[]'][0].modelValue).to.deep.equal({ expect(newFieldset.formElements['hobbies[]'][0].modelValue).to.deep.equal({
checked: true, checked: true,
value: 'chess', value: 'chess',
@ -250,10 +253,6 @@ describe('<lion-fieldset>', () => {
</${tag}> </${tag}>
</${tag}> </${tag}>
`); `);
await el.registrationReady;
const newFieldset = el.querySelector('lion-fieldset');
await newFieldset.registrationReady;
expect(el.modelValue).to.deep.equal({ expect(el.modelValue).to.deep.equal({
b: 'x', b: 'x',
newFieldset: { newFieldset: {
@ -298,12 +297,12 @@ describe('<lion-fieldset>', () => {
}); });
it('does not propagate/override initial disabled value on nested form elements', async () => { it('does not propagate/override initial disabled value on nested form elements', async () => {
const el = await fixture( const el = await fixture(html`
html`<${tag}> <${tag}>
<${tag} name="sub" disabled>${inputSlots}</${tag}> <${tag} name="sub" disabled>${inputSlots}</${tag}>
</${tag}>`, </${tag}>
); `);
await el.updateComplete; await el.registrationComplete;
expect(el.disabled).to.equal(false); expect(el.disabled).to.equal(false);
expect(el.formElements.sub.disabled).to.be.true; expect(el.formElements.sub.disabled).to.be.true;
expect(el.formElements.sub.formElements.color.disabled).to.be.true; expect(el.formElements.sub.formElements.color.disabled).to.be.true;
@ -312,33 +311,25 @@ describe('<lion-fieldset>', () => {
}); });
it('can set initial modelValue on creation', async () => { it('can set initial modelValue on creation', async () => {
const initialModelValue = {
lastName: 'Bar',
};
const el = await fixture(html` const el = await fixture(html`
<${tag} .modelValue=${initialModelValue}> <${tag} .modelValue=${{ lastName: 'Bar' }}>
<${childTag} name="lastName"></${childTag}> <${childTag} name="lastName"></${childTag}>
</${tag}> </${tag}>
`); `);
await el.registrationComplete;
await el.registrationReady; expect(el.modelValue).to.eql({
await el.updateComplete; lastName: 'Bar',
expect(el.modelValue).to.eql(initialModelValue); });
}); });
it('can set initial serializedValue on creation', async () => { it('can set initial serializedValue on creation', async () => {
const initialSerializedValue = {
lastName: 'Bar',
};
const el = await fixture(html` const el = await fixture(html`
<${tag} .modelValue=${initialSerializedValue}> <${tag} .modelValue=${{ lastName: 'Bar' }}>
<${childTag} name="lastName"></${childTag}> <${childTag} name="lastName"></${childTag}>
</${tag}> </${tag}>
`); `);
await el.registrationComplete;
await el.registrationReady; expect(el.modelValue).to.eql({ lastName: 'Bar' });
await el.updateComplete;
expect(el.modelValue).to.eql(initialSerializedValue);
}); });
describe('Validation', () => { describe('Validation', () => {
@ -366,10 +357,10 @@ describe('<lion-fieldset>', () => {
}); });
it('validates when a value changes', async () => { it('validates when a value changes', async () => {
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`); const el = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await nextFrame(); await el.registrationComplete;
const spy = sinon.spy(fieldset, 'validate'); const spy = sinon.spy(el, 'validate');
fieldset.formElements.color.modelValue = { checked: true, value: 'red' }; el.formElements.color.modelValue = { checked: true, value: 'red' };
expect(spy.callCount).to.equal(1); expect(spy.callCount).to.equal(1);
}); });
@ -455,15 +446,16 @@ describe('<lion-fieldset>', () => {
}); });
it('sets touched when last field in fieldset left after focus', async () => { it('sets touched when last field in fieldset left after focus', async () => {
const fieldset = await fixture(html`<${tag}>${inputSlots}</${tag}>`); const el = await fixture(html`<${tag}>${inputSlots}</${tag}>`);
await triggerFocusFor(fieldset.formElements['hobbies[]'][0]._inputNode); await el.registrationComplete;
await triggerFocusFor(el.formElements['hobbies[]'][0]._inputNode);
await triggerFocusFor( await triggerFocusFor(
fieldset.formElements['hobbies[]'][fieldset.formElements['gender[]'].length - 1]._inputNode, el.formElements['hobbies[]'][el.formElements['gender[]'].length - 1]._inputNode,
); );
const el = await fixture(html`<button></button>`); const button = await fixture(html`<button></button>`);
el.focus(); button.focus();
expect(fieldset.touched).to.be.true; expect(el.touched).to.be.true;
}); });
it('sets attributes [touched][dirty]', async () => { it('sets attributes [touched][dirty]', async () => {
@ -611,22 +603,23 @@ describe('<lion-fieldset>', () => {
}); });
it('(re)initializes children interaction states on registration ready', async () => { it('(re)initializes children interaction states on registration ready', async () => {
const fieldset = await fixtureSync(html` const el = await fixtureSync(html`
<${tag} .modelValue="${{ a: '1', b: '2' }}"> <${tag} .modelValue="${{ a: '1', b: '2' }}">
<${childTag} name="a"></${childTag}> <${childTag} name="a"></${childTag}>
<${childTag} name="b"></${childTag}> <${childTag} name="b"></${childTag}>
</${tag}>`); </${tag}>
const childA = fieldset.querySelector('[name="a"]'); `);
const childB = fieldset.querySelector('[name="b"]'); const childA = el.querySelector('[name="a"]');
const childB = el.querySelector('[name="b"]');
const spyA = sinon.spy(childA, 'initInteractionState'); const spyA = sinon.spy(childA, 'initInteractionState');
const spyB = sinon.spy(childB, 'initInteractionState'); const spyB = sinon.spy(childB, 'initInteractionState');
expect(fieldset.prefilled).to.be.false; expect(el.prefilled).to.be.false;
expect(fieldset.dirty).to.be.false; expect(el.dirty).to.be.false;
await fieldset.registrationComplete; await el.registrationComplete;
expect(spyA).to.have.been.called; expect(spyA).to.have.been.called;
expect(spyB).to.have.been.called; expect(spyB).to.have.been.called;
expect(fieldset.prefilled).to.be.true; expect(el.prefilled).to.be.true;
expect(fieldset.dirty).to.be.false; expect(el.dirty).to.be.false;
}); });
}); });
@ -917,7 +910,10 @@ describe('<lion-fieldset>', () => {
} }
execute(value) { execute(value) {
const hasError = value.color.indexOf('a') === -1; let hasError = true;
if (value && value.color) {
hasError = value.color.indexOf('a') === -1;
}
return hasError; return hasError;
} }
} }
@ -928,7 +924,6 @@ describe('<lion-fieldset>', () => {
<${childTag} name="color2"></${childTag}> <${childTag} name="color2"></${childTag}>
</${tag}> </${tag}>
`); `);
await el.registrationReady;
expect(el.hasFeedbackFor).to.deep.equal(['error']); expect(el.hasFeedbackFor).to.deep.equal(['error']);
expect(el.validationStates.error.ColorContainsA).to.be.true; expect(el.validationStates.error.ColorContainsA).to.be.true;
expect(el.formElements.color.hasFeedbackFor).to.deep.equal([]); expect(el.formElements.color.hasFeedbackFor).to.deep.equal([]);

View file

@ -174,6 +174,11 @@ export const FormControlMixin = dedupeMixin(
super.connectedCallback(); super.connectedCallback();
this._enhanceLightDomClasses(); this._enhanceLightDomClasses();
this._enhanceLightDomA11y(); this._enhanceLightDomA11y();
this._triggerInitialModelValueChangedEvent();
}
_triggerInitialModelValueChangedEvent() {
this.__dispatchInitialModelValueChangedEvent();
} }
_enhanceLightDomClasses() { _enhanceLightDomClasses() {
@ -590,19 +595,13 @@ export const FormControlMixin = dedupeMixin(
return [...this.children].find(el => el.slot === slotName); return [...this.children].find(el => el.slot === slotName);
} }
firstUpdated(changedProperties) { __dispatchInitialModelValueChangedEvent() {
super.firstUpdated(changedProperties);
this.__dispatchInitialModelValueChangedEvent();
}
async __dispatchInitialModelValueChangedEvent() {
// When we are not a fieldset / choice-group, we don't need to wait for our children // When we are not a fieldset / choice-group, we don't need to wait for our children
// to send a unified event // to send a unified event
if (this._repropagationRole === 'child') { if (this._repropagationRole === 'child') {
return; return;
} }
await this.registrationComplete;
// Initially we don't repropagate model-value-changed events coming // Initially we don't repropagate model-value-changed events coming
// from children. On firstUpdated we re-dispatch this event to maintain // from children. On firstUpdated we re-dispatch this event to maintain
// 'count consistency' (to not confuse the application developer with a // 'count consistency' (to not confuse the application developer with a

View file

@ -30,7 +30,14 @@ export const ChoiceGroupMixin = dedupeMixin(
} }
set modelValue(value) { set modelValue(value) {
this._setCheckedElements(value, (el, val) => el.modelValue.value === val); if (this.__isInitialModelValue) {
this.__isInitialModelValue = false;
this.registrationComplete.then(() => {
this._setCheckedElements(value, (el, val) => el.modelValue.value === val);
});
} else {
this._setCheckedElements(value, (el, val) => el.modelValue.value === val);
}
} }
get serializedValue() { get serializedValue() {
@ -50,13 +57,63 @@ export const ChoiceGroupMixin = dedupeMixin(
} }
set serializedValue(value) { set serializedValue(value) {
this._setCheckedElements(value, (el, val) => el.serializedValue.value === val); if (this.__isInitialSerializedValue) {
this.__isInitialSerializedValue = false;
this.registrationComplete.then(() => {
this._setCheckedElements(value, (el, val) => el.serializedValue.value === val);
});
} else {
this._setCheckedElements(value, (el, val) => el.serializedValue.value === val);
}
} }
constructor() { constructor() {
super(); super();
this.multipleChoice = false; this.multipleChoice = false;
this._repropagationRole = 'choice-group'; // configures event propagation logic of FormControlMixin this._repropagationRole = 'choice-group'; // configures event propagation logic of FormControlMixin
this.__isInitialModelValue = true;
this.__isInitialSerializedValue = true;
this.registrationComplete = new Promise((resolve, reject) => {
this.__resolveRegistrationComplete = resolve;
this.__rejectRegistrationComplete = reject;
});
this.registrationComplete.done = false;
this.registrationComplete.then(
() => {
this.registrationComplete.done = true;
},
() => {
this.registrationComplete.done = true;
throw new Error(
'Registration could not finish. Please use await el.registrationComplete;',
);
},
);
}
connectedCallback() {
super.connectedCallback();
this.__registrationCompleteTimer = setTimeout(() => {
this.__resolveRegistrationComplete();
});
this.registrationComplete.then(() => {
this.__isInitialModelValue = false;
this.__isInitialSerializedValue = false;
});
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
clearTimeout(this.__registrationCompleteTimer);
if (this.registrationComplete.done === false) {
this.__rejectRegistrationComplete();
}
} }
/** /**
@ -68,6 +125,15 @@ export const ChoiceGroupMixin = dedupeMixin(
super.addFormElement(child, indexToInsertAt); super.addFormElement(child, indexToInsertAt);
} }
/**
* @override from FormControlMixin
*/
_triggerInitialModelValueChangedEvent() {
this.registrationComplete.then(() => {
this.__dispatchInitialModelValueChangedEvent();
});
}
/** /**
* @override * @override
*/ */
@ -129,11 +195,7 @@ export const ChoiceGroupMixin = dedupeMixin(
return this.formElements.filter(el => el.checked && !el.disabled); return this.formElements.filter(el => el.checked && !el.disabled);
} }
async _setCheckedElements(value, check) { _setCheckedElements(value, check) {
if (!this.__readyForRegistration) {
await this.registrationReady;
}
for (let i = 0; i < this.formElements.length; i += 1) { for (let i = 0; i < this.formElements.length; i += 1) {
if (this.multipleChoice) { if (this.multipleChoice) {
this.formElements[i].checked = value.includes(this.formElements[i].value); this.formElements[i].checked = value.includes(this.formElements[i].value);

View file

@ -76,7 +76,14 @@ export const FormGroupMixin = dedupeMixin(
} }
set modelValue(values) { set modelValue(values) {
this._setValueMapForAllFormElements('modelValue', values); if (this.__isInitialModelValue) {
this.__isInitialModelValue = false;
this.registrationComplete.then(() => {
this._setValueMapForAllFormElements('modelValue', values);
});
} else {
this._setValueMapForAllFormElements('modelValue', values);
}
} }
get serializedValue() { get serializedValue() {
@ -84,7 +91,14 @@ export const FormGroupMixin = dedupeMixin(
} }
set serializedValue(values) { set serializedValue(values) {
this._setValueMapForAllFormElements('serializedValue', values); if (this.__isInitialSerializedValue) {
this.__isInitialSerializedValue = false;
this.registrationComplete.then(() => {
this._setValueMapForAllFormElements('serializedValue', values);
});
} else {
this._setValueMapForAllFormElements('serializedValue', values);
}
} }
get formattedValue() { get formattedValue() {
@ -107,6 +121,8 @@ export const FormGroupMixin = dedupeMixin(
this.touched = false; this.touched = false;
this.focused = false; this.focused = false;
this.__addedSubValidators = false; this.__addedSubValidators = false;
this.__isInitialModelValue = true;
this.__isInitialSerializedValue = true;
this._checkForOutsideClick = this._checkForOutsideClick.bind(this); this._checkForOutsideClick = this._checkForOutsideClick.bind(this);
@ -116,28 +132,56 @@ export const FormGroupMixin = dedupeMixin(
this.addEventListener('validate-performed', this.__onChildValidatePerformed); this.addEventListener('validate-performed', this.__onChildValidatePerformed);
this.defaultValidators = [new FormElementsHaveNoError()]; this.defaultValidators = [new FormElementsHaveNoError()];
this.registrationComplete = new Promise((resolve, reject) => {
this.__resolveRegistrationComplete = resolve;
this.__rejectRegistrationComplete = reject;
});
this.registrationComplete.done = false;
this.registrationComplete.then(
() => {
this.registrationComplete.done = true;
},
() => {
this.registrationComplete.done = true;
throw new Error(
'Registration could not finish. Please use await el.registrationComplete;',
);
},
);
} }
connectedCallback() { connectedCallback() {
// eslint-disable-next-line wc/guard-super-call
super.connectedCallback(); super.connectedCallback();
this.setAttribute('role', 'group'); this.setAttribute('role', 'group');
this.__initInteractionStates();
this.__registrationCompleteTimer = setTimeout(() => {
this.__resolveRegistrationComplete();
});
this.registrationComplete.then(() => {
this.__isInitialModelValue = false;
this.__isInitialSerializedValue = false;
this.__initInteractionStates();
});
} }
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); // eslint-disable-line wc/guard-super-call if (super.disconnectedCallback) {
super.disconnectedCallback();
}
if (this.__hasActiveOutsideClickHandling) { if (this.__hasActiveOutsideClickHandling) {
document.removeEventListener('click', this._checkForOutsideClick); document.removeEventListener('click', this._checkForOutsideClick);
this.__hasActiveOutsideClickHandling = false; this.__hasActiveOutsideClickHandling = false;
} }
clearTimeout(this.__registrationCompleteTimer);
if (this.registrationComplete.done === false) {
this.__rejectRegistrationComplete();
}
} }
async __initInteractionStates() { __initInteractionStates() {
if (!this.registrationHasCompleted) {
await this.registrationComplete;
}
this.formElements.forEach(el => { this.formElements.forEach(el => {
if (typeof el.initInteractionState === 'function') { if (typeof el.initInteractionState === 'function') {
el.initInteractionState(); el.initInteractionState();
@ -145,6 +189,15 @@ export const FormGroupMixin = dedupeMixin(
}); });
} }
/**
* @override from FormControlMixin
*/
_triggerInitialModelValueChangedEvent() {
this.registrationComplete.then(() => {
this.__dispatchInitialModelValueChangedEvent();
});
}
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
@ -269,20 +322,13 @@ export const FormGroupMixin = dedupeMixin(
return result; return result;
} }
async _setValueForAllFormElements(property, value) { _setValueForAllFormElements(property, value) {
if (!this.__readyForRegistration) {
await this.registrationReady;
}
this.formElements.forEach(el => { this.formElements.forEach(el => {
el[property] = value; // eslint-disable-line no-param-reassign el[property] = value; // eslint-disable-line no-param-reassign
}); });
} }
async _setValueMapForAllFormElements(property, values) { _setValueMapForAllFormElements(property, values) {
if (!this.__readyForRegistration) {
await this.registrationReady;
}
if (values && typeof values === 'object') { if (values && typeof values === 'object') {
Object.keys(values).forEach(name => { Object.keys(values).forEach(name => {
if (Array.isArray(this.formElements[name])) { if (Array.isArray(this.formElements[name])) {

View file

@ -1,5 +1,4 @@
import { dedupeMixin } from '@lion/core'; import { dedupeMixin } from '@lion/core';
import { formRegistrarManager } from './formRegistrarManager.js';
/** /**
* #FormRegisteringMixin: * #FormRegisteringMixin:
@ -13,50 +12,22 @@ export const FormRegisteringMixin = dedupeMixin(
superclass => superclass =>
// eslint-disable-next-line no-shadow, no-unused-vars // eslint-disable-next-line no-shadow, no-unused-vars
class FormRegisteringMixin extends superclass { class FormRegisteringMixin extends superclass {
constructor() {
super();
this.__boundDispatchRegistration = this._dispatchRegistration.bind(this);
}
connectedCallback() { connectedCallback() {
if (super.connectedCallback) { if (super.connectedCallback) {
super.connectedCallback(); super.connectedCallback();
} }
this.__setupRegistrationHook();
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
this._unregisterFormElement();
}
__setupRegistrationHook() {
if (formRegistrarManager.ready) {
this._dispatchRegistration();
} else {
formRegistrarManager.addEventListener(
'all-forms-open-for-registration',
this.__boundDispatchRegistration,
);
}
}
_dispatchRegistration() {
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('form-element-register', { new CustomEvent('form-element-register', {
detail: { element: this }, detail: { element: this },
bubbles: true, bubbles: true,
}), }),
); );
formRegistrarManager.removeEventListener(
'all-forms-open-for-registration',
this.__boundDispatchRegistration,
);
} }
_unregisterFormElement() { disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
if (this.__parentFormGroup) { if (this.__parentFormGroup) {
this.__parentFormGroup.removeFormElement(this); this.__parentFormGroup.removeFormElement(this);
} }

View file

@ -1,7 +1,6 @@
// eslint-disable-next-line max-classes-per-file // eslint-disable-next-line max-classes-per-file
import { dedupeMixin } from '@lion/core'; import { dedupeMixin } from '@lion/core';
import { FormRegisteringMixin } from './FormRegisteringMixin.js'; import { FormRegisteringMixin } from './FormRegisteringMixin.js';
import { formRegistrarManager } from './formRegistrarManager.js';
import { FormControlsCollection } from './FormControlsCollection.js'; import { FormControlsCollection } from './FormControlsCollection.js';
// TODO: rename .formElements to .formControls? (or .$controls ?) // TODO: rename .formElements to .formControls? (or .$controls ?)
@ -31,7 +30,7 @@ export const FormRegistrarMixin = dedupeMixin(
* (multi)select) * (multi)select)
* @type {boolean} * @type {boolean}
*/ */
_isFormOrFieldset: Boolean, _isFormOrFieldset: { type: Boolean },
}; };
} }
@ -41,58 +40,14 @@ export const FormRegistrarMixin = dedupeMixin(
this._isFormOrFieldset = false; this._isFormOrFieldset = false;
this.__readyForRegistration = false;
this.__hasBeenRendered = false;
this.registrationReady = new Promise(resolve => {
this.__resolveRegistrationReady = resolve;
});
this.registrationComplete = new Promise(resolve => {
this.__resolveRegistrationComplete = resolve;
});
this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this); this._onRequestToAddFormElement = this._onRequestToAddFormElement.bind(this);
this.addEventListener('form-element-register', this._onRequestToAddFormElement); this.addEventListener('form-element-register', this._onRequestToAddFormElement);
} }
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
formRegistrarManager.add(this);
if (this.__hasBeenRendered) {
formRegistrarManager.becomesReady();
}
}
disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback();
}
formRegistrarManager.remove(this);
}
isRegisteredFormElement(el) { isRegisteredFormElement(el) {
return this.formElements.some(exitingEl => exitingEl === el); return this.formElements.some(exitingEl => exitingEl === el);
} }
firstUpdated(changedProperties) {
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;
}
addFormElement(child, indexToInsertAt) { addFormElement(child, indexToInsertAt) {
// This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent // This is a way to let the child element (a lion-fieldset or lion-field) know, about its parent
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign

View file

@ -1,5 +1,4 @@
import { dedupeMixin } from '@lion/core'; import { dedupeMixin } from '@lion/core';
import { formRegistrarManager } from './formRegistrarManager.js';
/** /**
* This allows to register fields within a form even though they are not within the same dom tree. * This allows to register fields within a form even though they are not within the same dom tree.
@ -19,64 +18,27 @@ export const FormRegistrarPortalMixin = dedupeMixin(
class FormRegistrarPortalMixin extends superclass { class FormRegistrarPortalMixin extends superclass {
constructor() { constructor() {
super(); super();
this.formElements = [];
this.registrationTarget = undefined; this.registrationTarget = undefined;
this.__hasBeenRendered = false; this.__redispatchEventForFormRegistrarPortalMixin = this.__redispatchEventForFormRegistrarPortalMixin.bind(
this.__readyForRegistration = false; this,
this.registrationReady = new Promise(resolve => { );
this.__resolveRegistrationReady = resolve;
});
}
connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback();
}
formRegistrarManager.add(this);
if (this.__hasBeenRendered) {
formRegistrarManager.becomesReady();
}
this.__redispatchEventForFormRegistrarPortalMixin = ev => {
ev.stopPropagation();
this.registrationTarget.dispatchEvent(
new CustomEvent('form-element-register', {
detail: { element: ev.detail.element },
bubbles: true,
}),
);
};
this.addEventListener( this.addEventListener(
'form-element-register', 'form-element-register',
this.__redispatchEventForFormRegistrarPortalMixin, this.__redispatchEventForFormRegistrarPortalMixin,
); );
} }
disconnectedCallback() { __redispatchEventForFormRegistrarPortalMixin(ev) {
if (super.disconnectedCallback) { ev.stopPropagation();
super.disconnectedCallback();
}
formRegistrarManager.remove(this);
this.removeEventListener(
'form-element-register',
this.__redispatchEventForFormRegistrarPortalMixin,
);
}
firstUpdated(changedProperties) {
this.__checkRegistrationTarget();
super.firstUpdated(changedProperties);
this.__resolveRegistrationReady();
this.__readyForRegistration = true;
formRegistrarManager.becomesReady(this);
this.__hasBeenRendered = true;
}
__checkRegistrationTarget() {
if (!this.registrationTarget) { if (!this.registrationTarget) {
throw new Error('A FormRegistrarPortal element requires a .registrationTarget'); throw new Error('A FormRegistrarPortal element requires a .registrationTarget');
} }
this.registrationTarget.dispatchEvent(
new CustomEvent('form-element-register', {
detail: { element: ev.detail.element },
bubbles: true,
}),
);
} }
}, },
); );

View file

@ -1,38 +0,0 @@
/**
* Allows to align the timing for all Registrars (like form, fieldset).
* e.g. it will only be ready once all Registrars have been fully rendered
*
* This is a requirement for ShadyDOM as otherwise forms can not catch registration events
*/
class FormRegistrarManager {
constructor() {
this.__elements = [];
this._fakeExtendsEventTarget();
this.ready = false;
}
add(registrar) {
this.__elements.push(registrar);
this.ready = false;
}
remove(registrar) {
this.__elements.splice(this.__elements.indexOf(registrar), 1);
}
becomesReady() {
if (this.__elements.every(el => el.__readyForRegistration === true)) {
this.dispatchEvent(new Event('all-forms-open-for-registration'));
this.ready = true;
}
}
_fakeExtendsEventTarget() {
const delegate = document.createDocumentFragment();
['addEventListener', 'dispatchEvent', 'removeEventListener'].forEach(funcName => {
this[funcName] = (...args) => delegate[funcName](...args);
});
}
}
export const formRegistrarManager = new FormRegistrarManager();

View file

@ -1,4 +1,3 @@
export { formFixture } from './test-helpers/formFixture.js';
export { export {
AlwaysInvalid, AlwaysInvalid,
AlwaysValid, AlwaysValid,

View file

@ -1,11 +0,0 @@
// 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,8 +1,6 @@
import { LitElement } from '@lion/core'; import { LitElement } from '@lion/core';
import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing'; import { defineCE, expect, fixture, html, unsafeStatic } from '@open-wc/testing';
import sinon from 'sinon';
import { FormRegisteringMixin } from '../src/registration/FormRegisteringMixin.js'; import { FormRegisteringMixin } from '../src/registration/FormRegisteringMixin.js';
import { formRegistrarManager } from '../src/registration/formRegistrarManager.js';
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js'; import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
import { FormRegistrarPortalMixin } from '../src/registration/FormRegistrarPortalMixin.js'; import { FormRegistrarPortalMixin } from '../src/registration/FormRegistrarPortalMixin.js';
@ -12,11 +10,10 @@ export const runRegistrationSuite = customConfig => {
...customConfig, ...customConfig,
}; };
describe('FormRegistrationMixins', () => { describe(`FormRegistrationMixins ${cfg.suffix}`, () => {
let parentTag; let parentTag;
let childTag; let childTag;
let portalTag; let portalTag;
let portalTagString;
before(async () => { before(async () => {
if (!cfg.parentTagString) { if (!cfg.parentTagString) {
@ -30,7 +27,6 @@ export const runRegistrationSuite = customConfig => {
} }
parentTag = unsafeStatic(cfg.parentTagString); parentTag = unsafeStatic(cfg.parentTagString);
portalTagString = cfg.portalTagString;
childTag = unsafeStatic(cfg.childTagString); childTag = unsafeStatic(cfg.childTagString);
portalTag = unsafeStatic(cfg.portalTagString); portalTag = unsafeStatic(cfg.portalTagString);
}); });
@ -41,44 +37,33 @@ export const runRegistrationSuite = customConfig => {
<${childTag}></${childTag}> <${childTag}></${childTag}>
</${parentTag}> </${parentTag}>
`); `);
await el.registrationReady; expect(el.formElements.length).to.equal(1);
});
it('can register a formElement with arbitrary dom tree in between registrar and registering', async () => {
const el = await fixture(html`
<${parentTag}>
<div>
<${childTag}></${childTag}>
</div>
</${parentTag}>
`);
expect(el.formElements.length).to.equal(1); expect(el.formElements.length).to.equal(1);
}); });
it('supports nested registration parents', async () => { it('supports nested registration parents', async () => {
const el = await fixture(html` const el = await fixture(html`
<${parentTag}> <${parentTag}>
<${parentTag}> <${parentTag} class="sub-group">
<${childTag}></${childTag}> <${childTag}></${childTag}>
<${childTag}></${childTag}> <${childTag}></${childTag}>
</${parentTag}> </${parentTag}>
</${parentTag}> </${parentTag}>
`); `);
await el.registrationReady;
expect(el.formElements.length).to.equal(1); expect(el.formElements.length).to.equal(1);
expect(el.querySelector(cfg.parentTagString).formElements.length).to.equal(2);
});
it('forgets disconnected registrars', async () => { const subGroup = el.querySelector('.sub-group');
const el = await fixture(html` expect(subGroup.formElements.length).to.equal(2);
<${parentTag}>
<${parentTag}>
<${childTag}</${childTag}
</${parentTag}>
</${parentTag}>
`);
const secondRegistrar = await fixture(html`
<${parentTag}>
<${childTag}</${childTag}
</${parentTag}>
`);
el.appendChild(secondRegistrar);
expect(formRegistrarManager.__elements.length).to.equal(3);
el.removeChild(secondRegistrar);
expect(formRegistrarManager.__elements.length).to.equal(2);
}); });
it('works for components that have a delayed render', async () => { it('works for components that have a delayed render', async () => {
@ -100,7 +85,6 @@ export const runRegistrationSuite = customConfig => {
<${childTag}></${childTag}> <${childTag}></${childTag}>
</${tagWrapper}> </${tagWrapper}>
`); `);
await el.registrationReady;
expect(el.formElements.length).to.equal(1); expect(el.formElements.length).to.equal(1);
}); });
@ -113,7 +97,6 @@ export const runRegistrationSuite = customConfig => {
const newField = await fixture(html` const newField = await fixture(html`
<${childTag}></${childTag}> <${childTag}></${childTag}>
`); `);
expect(el.formElements.length).to.equal(1); expect(el.formElements.length).to.equal(1);
el.appendChild(newField); el.appendChild(newField);
@ -136,22 +119,14 @@ export const runRegistrationSuite = customConfig => {
`); `);
newField.myProp = 'test'; newField.myProp = 'test';
el.children[1].insertAdjacentElement('beforebegin', newField); el.insertBefore(newField, el.children[1]);
expect(el.formElements.length).to.equal(4); expect(el.formElements.length).to.equal(4);
expect(el.children[1].myProp).to.equal('test'); expect(el.children[1].myProp).to.equal('test');
expect(el.formElements[1].myProp).to.equal('test');
}); });
describe('FormRegistrarPortalMixin', () => { describe('FormRegistrarPortalMixin', () => {
it('throws if there is no .registrationTarget', async () => {
// we test the private api directly as errors thrown from a web component are in a
// different context and we can not catch them here
const el = document.createElement(portalTagString);
expect(() => {
el.__checkRegistrationTarget();
}).to.throw('A FormRegistrarPortal element requires a .registrationTarget');
});
it('forwards registrations to the .registrationTarget', async () => { it('forwards registrations to the .registrationTarget', async () => {
const el = await fixture(html`<${parentTag}></${parentTag}>`); const el = await fixture(html`<${parentTag}></${parentTag}>`);
await fixture(html` await fixture(html`
@ -221,21 +196,6 @@ export const runRegistrationSuite = customConfig => {
expect(el.formElements[5]).dom.to.equal(anotherField); expect(el.formElements[5]).dom.to.equal(anotherField);
}); });
// find a proper way to do this on polyfilled browsers
it.skip('fires event "form-element-register" with the child as ev.target', async () => {
const registerSpy = sinon.spy();
const el = await fixture(
html`<${parentTag} @form-element-register=${registerSpy}></${parentTag}>`,
);
const portal = await fixture(html`
<${portalTag} .registrationTarget=${el}>
<${childTag}></${childTag}>
</${portalTag}>
`);
const childEl = portal.children[0];
expect(registerSpy.args[2][0].target.tagName).to.equal(childEl.tagName);
});
it('keeps working if moving the portal itself', async () => { it('keeps working if moving the portal itself', async () => {
const el = await fixture(html`<${parentTag}></${parentTag}>`); const el = await fixture(html`<${parentTag}></${parentTag}>`);
const portal = await fixture(html` const portal = await fixture(html`
@ -280,7 +240,6 @@ export const runRegistrationSuite = customConfig => {
</${delayedPortalTag}> </${delayedPortalTag}>
`); `);
await el.registrationReady;
expect(el.formElements.length).to.equal(1); expect(el.formElements.length).to.equal(1);
}); });
}); });

View file

@ -240,7 +240,11 @@ export function runValidateMixinFeedbackPart() {
} }
render() { render() {
return html`Custom for ${this.feedbackData[0].validator.constructor.validatorName}`; const name =
this.feedbackData && this.feedbackData[0]
? this.feedbackData[0].validator.constructor.validatorName
: '';
return html`Custom for ${name}`;
} }
}, },
); );

View file

@ -1,7 +1,6 @@
import { expect, html, defineCE, unsafeStatic } from '@open-wc/testing'; import { expect, html, defineCE, unsafeStatic, fixture } from '@open-wc/testing';
import { LitElement, SlotMixin } from '@lion/core'; import { LitElement, SlotMixin } from '@lion/core';
import sinon from 'sinon'; import sinon from 'sinon';
import { formFixture as fixture } from '../test-helpers/formFixture.js';
import { FormControlMixin } from '../src/FormControlMixin.js'; import { FormControlMixin } from '../src/FormControlMixin.js';
import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js'; import { FormRegistrarMixin } from '../src/registration/FormRegistrarMixin.js';
@ -219,6 +218,8 @@ describe('FormControlMixin', () => {
</${groupTag}> </${groupTag}>
`); `);
const fieldsetEl = formEl.querySelector('[name=fieldset]'); const fieldsetEl = formEl.querySelector('[name=fieldset]');
await formEl.registrationComplete;
await fieldsetEl.registrationComplete;
expect(fieldsetSpy.callCount).to.equal(1); expect(fieldsetSpy.callCount).to.equal(1);
const fieldsetEv = fieldsetSpy.firstCall.args[0]; const fieldsetEv = fieldsetSpy.firstCall.args[0];

View file

@ -1,9 +1,8 @@
import { html, LitElement } from '@lion/core'; import { html, LitElement } from '@lion/core';
import { formFixture as fixture } from '@lion/form-core/test-helpers.js';
import '@lion/fieldset/lion-fieldset.js'; import '@lion/fieldset/lion-fieldset.js';
import { LionInput } from '@lion/input'; import { LionInput } from '@lion/input';
import { FormGroupMixin, Required } from '@lion/form-core'; import { FormGroupMixin, Required } from '@lion/form-core';
import { expect, nextFrame } from '@open-wc/testing'; import { expect, fixture } from '@open-wc/testing';
import { ChoiceGroupMixin } from '../../src/choice-group/ChoiceGroupMixin.js'; import { ChoiceGroupMixin } from '../../src/choice-group/ChoiceGroupMixin.js';
import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js'; import { ChoiceInputMixin } from '../../src/choice-group/ChoiceInputMixin.js';
@ -32,7 +31,6 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame();
expect(el.modelValue).to.equal('female'); expect(el.modelValue).to.equal('female');
el.formElements[0].checked = true; el.formElements[0].checked = true;
expect(el.modelValue).to.equal('male'); expect(el.modelValue).to.equal('male');
@ -48,7 +46,6 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame();
const invalidChild = await fixture(html` const invalidChild = await fixture(html`
<choice-group-input .modelValue=${'Lara'}></choice-group-input> <choice-group-input .modelValue=${'Lara'}></choice-group-input>
`); `);
@ -67,7 +64,7 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame(); await el.registrationComplete;
expect(el.formElements[0].name).to.equal('gender'); expect(el.formElements[0].name).to.equal('gender');
expect(el.formElements[1].name).to.equal('gender'); expect(el.formElements[1].name).to.equal('gender');
@ -87,7 +84,7 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame();
const invalidChild = await fixture(html` const invalidChild = await fixture(html`
<choice-group-input name="foo" .choiceValue=${'male'}></choice-group-input> <choice-group-input name="foo" .choiceValue=${'male'}></choice-group-input>
`); `);
@ -107,10 +104,7 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await el.registrationComplete;
await nextFrame();
await el.registrationReady;
await el.updateComplete;
expect(el.modelValue).to.equal('other'); expect(el.modelValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true; expect(el.formElements[2].checked).to.be.true;
@ -124,8 +118,10 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await el.registrationComplete;
expect(el.serializedValue).to.equal('other'); expect(el.serializedValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true;
}); });
it('can handle complex data via choiceValue', async () => { it('can handle complex data via choiceValue', async () => {
@ -137,7 +133,6 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${date} checked></choice-group-input> <choice-group-input .choiceValue=${date} checked></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame();
expect(el.modelValue).to.equal(date); expect(el.modelValue).to.equal(date);
el.formElements[0].checked = true; el.formElements[0].checked = true;
@ -151,7 +146,6 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${''}></choice-group-input> <choice-group-input .choiceValue=${''}></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame();
expect(el.modelValue).to.equal(0); expect(el.modelValue).to.equal(0);
el.formElements[1].checked = true; el.formElements[1].checked = true;
@ -170,7 +164,8 @@ describe('ChoiceGroupMixin', () => {
></choice-group-input> ></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame(); await el.registrationComplete;
expect(el.modelValue).to.equal('female'); expect(el.modelValue).to.equal('female');
el.modelValue = 'other'; el.modelValue = 'other';
expect(el.formElements[2].checked).to.be.true; expect(el.formElements[2].checked).to.be.true;
@ -190,7 +185,8 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame(); await el.registrationComplete;
counter = 0; // reset after setup which may result in different results counter = 0; // reset after setup which may result in different results
el.formElements[0].checked = true; el.formElements[0].checked = true;
@ -253,7 +249,6 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'female'}></choice-group-input> <choice-group-input .choiceValue=${'female'}></choice-group-input>
</choice-group> </choice-group>
`); `);
await nextFrame();
expect(el.serializedValue).to.deep.equal(''); expect(el.serializedValue).to.deep.equal('');
}); });
@ -267,7 +262,7 @@ describe('ChoiceGroupMixin', () => {
<choice-group-input .choiceValue=${'other'}></choice-group-input> <choice-group-input .choiceValue=${'other'}></choice-group-input>
</choice-group-multiple> </choice-group-multiple>
`); `);
await nextFrame();
expect(el.modelValue).to.eql(['female']); expect(el.modelValue).to.eql(['female']);
el.formElements[0].checked = true; el.formElements[0].checked = true;
expect(el.modelValue).to.eql(['male', 'female']); expect(el.modelValue).to.eql(['male', 'female']);
@ -284,9 +279,7 @@ describe('ChoiceGroupMixin', () => {
</choice-group-multiple> </choice-group-multiple>
`); `);
await nextFrame(); await el.registrationComplete;
await el.registrationReady;
await el.updateComplete;
el.modelValue = ['male', 'other']; el.modelValue = ['male', 'other'];
expect(el.modelValue).to.eql(['male', 'other']); expect(el.modelValue).to.eql(['male', 'other']);
expect(el.formElements[0].checked).to.be.true; expect(el.formElements[0].checked).to.be.true;
@ -302,9 +295,7 @@ describe('ChoiceGroupMixin', () => {
</choice-group-multiple> </choice-group-multiple>
`); `);
await nextFrame(); await el.registrationComplete;
await el.registrationReady;
await el.updateComplete;
expect(el.modelValue).to.eql(['male', 'other']); expect(el.modelValue).to.eql(['male', 'other']);
expect(el.formElements[0].checked).to.be.true; expect(el.formElements[0].checked).to.be.true;
expect(el.formElements[2].checked).to.be.true; expect(el.formElements[2].checked).to.be.true;
@ -328,8 +319,6 @@ describe('ChoiceGroupMixin', () => {
</lion-fieldset> </lion-fieldset>
`); `);
await nextFrame();
await el.registrationReady;
await el.updateComplete; await el.updateComplete;
expect(el.serializedValue).to.eql({ expect(el.serializedValue).to.eql({
gender: 'female', gender: 'female',

View file

@ -1,5 +1,4 @@
import { expect, html, unsafeStatic } from '@open-wc/testing'; import { expect, html, unsafeStatic, fixture } from '@open-wc/testing';
import { formFixture as fixture } from '@lion/form-core/test-helpers.js';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import sinon from 'sinon'; import sinon from 'sinon';
@ -82,13 +81,14 @@ const choiceGroupDispatchesCountOnFirstPaint = (groupTagname, itemTagname, count
const itemTag = unsafeStatic(itemTagname); const itemTag = unsafeStatic(itemTagname);
it(getFirstPaintTitle(count), async () => { it(getFirstPaintTitle(count), async () => {
const spy = sinon.spy(); const spy = sinon.spy();
await fixture(html` const el = await fixture(html`
<${groupTag} @model-value-changed="${spy}"> <${groupTag} @model-value-changed="${spy}">
<${itemTag} .choiceValue="${'option1'}"></${itemTag}> <${itemTag} .choiceValue="${'option1'}"></${itemTag}>
<${itemTag} .choiceValue="${'option2'}"></${itemTag}> <${itemTag} .choiceValue="${'option2'}"></${itemTag}>
<${itemTag} .choiceValue="${'option3'}"></${itemTag}> <${itemTag} .choiceValue="${'option3'}"></${itemTag}>
</${groupTag}> </${groupTag}>
`); `);
await el.registrationComplete;
expect(spy.callCount).to.equal(count); expect(spy.callCount).to.equal(count);
}); });
}; };
@ -105,6 +105,7 @@ const choiceGroupDispatchesCountOnInteraction = (groupTagname, itemTagname, coun
<${itemTag} .choiceValue="${'option3'}"></${itemTag}> <${itemTag} .choiceValue="${'option3'}"></${itemTag}>
</${groupTag}> </${groupTag}>
`); `);
await el.registrationComplete;
el.addEventListener('model-value-changed', spy); el.addEventListener('model-value-changed', spy);
const option2 = el.querySelector(`${itemTagname}:nth-child(2)`); const option2 = el.querySelector(`${itemTagname}:nth-child(2)`);
option2.checked = true; option2.checked = true;
@ -209,7 +210,7 @@ describe('lion-select-rich', () => {
describe(featureName, () => { describe(featureName, () => {
it(getFirstPaintTitle(firstStampCount), async () => { it(getFirstPaintTitle(firstStampCount), async () => {
const spy = sinon.spy(); const spy = sinon.spy();
await fixture(html` const el = await fixture(html`
<lion-select-rich @model-value-changed="${spy}"> <lion-select-rich @model-value-changed="${spy}">
<lion-options slot="input"> <lion-options slot="input">
<lion-option .choiceValue="${'option1'}"></lion-option> <lion-option .choiceValue="${'option1'}"></lion-option>
@ -218,6 +219,7 @@ describe('lion-select-rich', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
expect(spy.callCount).to.equal(firstStampCount); expect(spy.callCount).to.equal(firstStampCount);
}); });
@ -232,6 +234,7 @@ describe('lion-select-rich', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
el.addEventListener('model-value-changed', spy); el.addEventListener('model-value-changed', spy);
const option2 = el.querySelector('lion-option:nth-child(2)'); const option2 = el.querySelector('lion-option:nth-child(2)');
option2.checked = true; option2.checked = true;
@ -250,11 +253,12 @@ describe('lion-fieldset', () => {
describe(featureName, () => { describe(featureName, () => {
it(getFirstPaintTitle(firstStampCount), async () => { it(getFirstPaintTitle(firstStampCount), async () => {
const spy = sinon.spy(); const spy = sinon.spy();
await fixture(html` const el = await fixture(html`
<lion-fieldset name="parent" @model-value-changed="${spy}"> <lion-fieldset name="parent" @model-value-changed="${spy}">
<lion-input name="input"></lion-input> <lion-input name="input"></lion-input>
</lion-fieldset> </lion-fieldset>
`); `);
await el.registrationComplete;
expect(spy.callCount).to.equal(firstStampCount); expect(spy.callCount).to.equal(firstStampCount);
}); });
@ -265,6 +269,7 @@ describe('lion-fieldset', () => {
<lion-input name="input"></lion-input> <lion-input name="input"></lion-input>
</lion-fieldset> </lion-fieldset>
`); `);
await el.registrationComplete;
el.addEventListener('model-value-changed', spy); el.addEventListener('model-value-changed', spy);
const input = el.querySelector('lion-input'); const input = el.querySelector('lion-input');
input.modelValue = 'foo'; input.modelValue = 'foo';

View file

@ -1,7 +1,6 @@
import { formFixture as fixture } from '@lion/form-core/test-helpers.js';
import '@lion/fieldset/lion-fieldset.js'; import '@lion/fieldset/lion-fieldset.js';
import '@lion/input/lion-input.js'; import '@lion/input/lion-input.js';
import { expect, html } from '@open-wc/testing'; import { expect, html, fixture } from '@open-wc/testing';
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
import sinon from 'sinon'; import sinon from 'sinon';
@ -32,6 +31,7 @@ describe('model value event', () => {
<lion-input name="input"></lion-input> <lion-input name="input"></lion-input>
</lion-fieldset> </lion-fieldset>
`); `);
await fieldset.registrationComplete;
fieldset.addEventListener('model-value-changed', spy); fieldset.addEventListener('model-value-changed', spy);
const input = fieldset.querySelector('lion-input'); const input = fieldset.querySelector('lion-input');
input.modelValue = 'foo'; input.modelValue = 'foo';
@ -50,6 +50,9 @@ describe('model value event', () => {
`); `);
const parent = grandparent.querySelector('[name=parent]'); const parent = grandparent.querySelector('[name=parent]');
const input = grandparent.querySelector('[name=input]'); const input = grandparent.querySelector('[name=input]');
await grandparent.registrationComplete;
await parent.registrationComplete;
grandparent.addEventListener('model-value-changed', spy); grandparent.addEventListener('model-value-changed', spy);
input.modelValue = 'foo'; input.modelValue = 'foo';
const e = spy.firstCall.args[0]; const e = spy.firstCall.args[0];
@ -73,6 +76,9 @@ describe('model value event', () => {
`); `);
const parent = grandparent.querySelector('[name=parent]'); const parent = grandparent.querySelector('[name=parent]');
const input = grandparent.querySelector('[name=input]'); const input = grandparent.querySelector('[name=input]');
await grandparent.registrationComplete;
await parent.registrationComplete;
grandparent.addEventListener('model-value-changed', spy); grandparent.addEventListener('model-value-changed', spy);
input.modelValue = 'foo'; input.modelValue = 'foo';
const e = spy.firstCall.args[0]; const e = spy.firstCall.args[0];
@ -115,6 +121,9 @@ describe('model value event', () => {
`); `);
const parent = grandparent.querySelector('[name="parent"]'); const parent = grandparent.querySelector('[name="parent"]');
const input = grandparent.querySelector('[name="input"]'); const input = grandparent.querySelector('[name="input"]');
await grandparent.registrationComplete;
await parent.registrationComplete;
const spies = []; const spies = [];
[grandparent, parent, input].forEach(element => { [grandparent, parent, input].forEach(element => {
const spy = sinon.spy(); const spy = sinon.spy();

View file

@ -17,7 +17,7 @@ describe('<lion-input>', () => {
it('delegates value attribute', async () => { it('delegates value attribute', async () => {
const el = await fixture(html`<${tag} value="prefilled"></${tag}>`); const el = await fixture(html`<${tag} value="prefilled"></${tag}>`);
expect(el._inputNode.value).to.equal('prefilled'); expect(el._inputNode.getAttribute('value')).to.equal('prefilled');
}); });
it('automatically creates an <input> element if not provided by user', async () => { it('automatically creates an <input> element if not provided by user', async () => {

View file

@ -7,7 +7,6 @@ import {
} from '@lion/form-core'; } from '@lion/form-core';
import { css, html, LitElement, ScopedElementsMixin, SlotMixin } from '@lion/core'; import { css, html, LitElement, ScopedElementsMixin, SlotMixin } from '@lion/core';
import { formRegistrarManager } from '@lion/form-core/src/registration/formRegistrarManager.js';
import { OverlayMixin, withDropdownConfig } from '@lion/overlays'; import { OverlayMixin, withDropdownConfig } from '@lion/overlays';
import './differentKeyNamesShimIE.js'; import './differentKeyNamesShimIE.js';
import { LionSelectInvoker } from './LionSelectInvoker.js'; import { LionSelectInvoker } from './LionSelectInvoker.js';
@ -137,24 +136,6 @@ export class LionSelectRich extends ScopedElementsMixin(
return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`); return this._listboxNode.querySelector(`#${this._listboxActiveDescendant}`);
} }
get modelValue() {
const el = this.formElements.find(option => option.checked);
return el ? el.modelValue.value : '';
}
set modelValue(value) {
const el = this.formElements.find(option => option.modelValue.value === value);
if (el) {
el.checked = true;
} else {
// cache user set modelValue, and then try it again when registration is done
this.__cachedUserSetModelValue = value;
}
this.requestUpdate('modelValue');
}
get serializedValue() { get serializedValue() {
return this.modelValue; return this.modelValue;
} }
@ -209,30 +190,27 @@ export class LionSelectRich extends ScopedElementsMixin(
this.__hasInitialSelectedFormElement = false; this.__hasInitialSelectedFormElement = false;
this.hasNoDefaultSelected = false; this.hasNoDefaultSelected = false;
this._repropagationRole = 'choice-group'; // configures FormControlMixin this._repropagationRole = 'choice-group'; // configures FormControlMixin
this.__initInteractionStates();
} }
firstUpdated(changedProperties) { connectedCallback() {
super.firstUpdated(changedProperties); if (super.connectedCallback) {
super.connectedCallback();
}
this._listboxNode.registrationTarget = this;
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
this.__setupInvokerNode();
this.__setupListboxNode();
this.__setupEventListeners();
this.__toggleInvokerDisabled();
this.registrationComplete.then(() => {
this.__initInteractionStates();
});
this._overlaySetupComplete.then(() => { this._overlaySetupComplete.then(() => {
this.__setupOverlay(); this.__setupOverlay();
}); });
this.__setupInvokerNode();
this.__setupListboxNode();
this.__setupEventListeners();
this._listboxNode.registrationTarget = this;
formRegistrarManager.addEventListener('all-forms-open-for-registration', () => {
// Now that we have rendered + registered our listbox, try setting the user defined modelValue again
if (this.__cachedUserSetModelValue) {
this.modelValue = this.__cachedUserSetModelValue;
}
});
this._invokerNode.selectedElement = this.formElements[this.checkedIndex];
this.__toggleInvokerDisabled();
} }
_requestUpdate(name, oldValue) { _requestUpdate(name, oldValue) {
@ -248,13 +226,18 @@ export class LionSelectRich extends ScopedElementsMixin(
} }
} }
async __initInteractionStates() { /**
await this.registrationComplete; * In the select disabled options are still going to a possible value for example
// This timeout is here, so that we know we handle after the initial model-value * when prefilling or programmatically setting it.
// event (see firstUpdated method FormConrtolMixin) has fired. *
setTimeout(() => { * @override
this.initInteractionState(); */
}); _getCheckedElements() {
return this.formElements.filter(el => el.checked);
}
__initInteractionStates() {
this.initInteractionState();
} }
get _inputNode() { get _inputNode() {

View file

@ -33,6 +33,7 @@ describe('Select Rich Integration tests', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await nestedEl.registrationComplete;
await fixture(html` await fixture(html`
<${tag} id="main"> <${tag} id="main">
@ -42,7 +43,7 @@ describe('Select Rich Integration tests', () => {
</div> </div>
<button slot="invoker">invoker button</button> <button slot="invoker">invoker button</button>
</${tag}> </${tag}>
`); `);
properlyInstantiated = true; properlyInstantiated = true;
} catch (e) { } catch (e) {
throw new Error(e); throw new Error(e);

View file

@ -1,6 +1,5 @@
import { Required } from '@lion/form-core'; import { Required } from '@lion/form-core';
import { expect, html, triggerBlurFor, triggerFocusFor } from '@open-wc/testing'; import { expect, html, triggerBlurFor, triggerFocusFor, fixture } from '@open-wc/testing';
import { formFixture as fixture } from '@lion/form-core/test-helpers.js';
import '../lion-option.js'; import '../lion-option.js';
import '../lion-options.js'; import '../lion-options.js';
@ -270,6 +269,7 @@ describe('lion-select-rich interactions', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
expect(el.modelValue).to.equal(10); expect(el.modelValue).to.equal(10);
}); });
@ -489,6 +489,7 @@ describe('lion-select-rich interactions', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
expect(el.dirty).to.be.false; expect(el.dirty).to.be.false;
el.modelValue = 20; el.modelValue = 20;
expect(el.dirty).to.be.true; expect(el.dirty).to.be.true;
@ -541,6 +542,7 @@ describe('lion-select-rich interactions', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
expect(el.hasFeedbackFor).to.include('error'); expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.a.property('error');
@ -578,6 +580,7 @@ describe('lion-select-rich interactions', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('first'); expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('first');
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' }));
expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('second'); expect(el._listboxNode.getAttribute('aria-activedescendant')).to.equal('second');

View file

@ -1,8 +1,15 @@
import { LitElement } from '@lion/core'; import { LitElement } from '@lion/core';
import { formFixture as fixture } from '@lion/form-core/test-helpers.js';
import { OverlayController } from '@lion/overlays'; import { OverlayController } from '@lion/overlays';
import { Required } from '@lion/form-core'; import { Required } from '@lion/form-core';
import { aTimeout, defineCE, expect, html, nextFrame, unsafeStatic } from '@open-wc/testing'; import {
aTimeout,
defineCE,
expect,
html,
nextFrame,
unsafeStatic,
fixture,
} from '@open-wc/testing';
import { LionSelectInvoker, LionSelectRich } from '../index.js'; import { LionSelectInvoker, LionSelectRich } from '../index.js';
import '../lion-option.js'; import '../lion-option.js';
import '../lion-options.js'; import '../lion-options.js';
@ -32,7 +39,7 @@ describe('lion-select-rich', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await nextFrame(); await el.registrationComplete;
expect(el.formElements[0].name).to.equal('foo'); expect(el.formElements[0].name).to.equal('foo');
expect(el.formElements[1].name).to.equal('foo'); expect(el.formElements[1].name).to.equal('foo');
@ -95,46 +102,39 @@ describe('lion-select-rich', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
expect(el.modelValue).to.equal('other'); expect(el.modelValue).to.equal('other');
expect(el.formElements[2].checked).to.be.true; expect(el.formElements[2].checked).to.be.true;
}); });
it(`has a fieldName based on the label`, async () => { it(`has a fieldName based on the label`, async () => {
const el1 = await fixture( const el1 = await fixture(html`
html` <lion-select-rich label="foo"><lion-options slot="input"></lion-options></lion-select-rich>
<lion-select-rich label="foo"><lion-options slot="input"></lion-options></lion-select-rich> `);
`,
);
expect(el1.fieldName).to.equal(el1._labelNode.textContent); expect(el1.fieldName).to.equal(el1._labelNode.textContent);
const el2 = await fixture( const el2 = await fixture(html`
html` <lion-select-rich
<lion-select-rich ><label slot="label">bar</label><lion-options slot="input"></lion-options
><label slot="label">bar</label><lion-options slot="input"></lion-options ></lion-select-rich>
></lion-select-rich> `);
`,
);
expect(el2.fieldName).to.equal(el2._labelNode.textContent); expect(el2.fieldName).to.equal(el2._labelNode.textContent);
}); });
it(`has a fieldName based on the name if no label exists`, async () => { it(`has a fieldName based on the name if no label exists`, async () => {
const el = await fixture( const el = await fixture(html`
html` <lion-select-rich name="foo"><lion-options slot="input"></lion-options></lion-select-rich>
<lion-select-rich name="foo"><lion-options slot="input"></lion-options></lion-select-rich> `);
`,
);
expect(el.fieldName).to.equal(el.name); expect(el.fieldName).to.equal(el.name);
}); });
it(`can override fieldName`, async () => { it(`can override fieldName`, async () => {
const el = await fixture( const el = await fixture(html`
html` <lion-select-rich label="foo" .fieldName="${'bar'}"
<lion-select-rich label="foo" .fieldName="${'bar'}" ><lion-options slot="input"></lion-options
><lion-options slot="input"></lion-options ></lion-select-rich>
></lion-select-rich> `);
`,
);
expect(el.__fieldName).to.equal(el.fieldName); expect(el.__fieldName).to.equal(el.fieldName);
}); });
@ -446,6 +446,7 @@ describe('lion-select-rich', () => {
</lion-options> </lion-options>
</lion-select-rich> </lion-select-rich>
`); `);
await el.registrationComplete;
// The default is min, so we override that behavior here // The default is min, so we override that behavior here
el._overlayCtrl.inheritsReferenceWidth = 'full'; el._overlayCtrl.inheritsReferenceWidth = 'full';
el._initialInheritsReferenceWidth = 'full'; el._initialInheritsReferenceWidth = 'full';
@ -532,7 +533,6 @@ describe('lion-select-rich', () => {
</lion-select-rich> </lion-select-rich>
`); `);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }));
await el.updateComplete;
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
@ -544,7 +544,6 @@ describe('lion-select-rich', () => {
`); `);
// tab can only be caught via keydown // tab can only be caught via keydown
el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' }));
await el.updateComplete;
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
}); });
@ -584,7 +583,6 @@ describe('lion-select-rich', () => {
</lion-select-rich> </lion-select-rich>
`); `);
el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); el._listboxNode.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' }));
await el.updateComplete;
expect(el.opened).to.be.false; expect(el.opened).to.be.false;
}); });
}); });

View file

@ -0,0 +1,75 @@
const { legacyPlugin } = require('@web/dev-server-legacy');
const { browserstackLauncher } = require('@web/test-runner-browserstack');
const sharedCapabilities = {
// it's recommended to store user and key as environment variables
'browserstack.user': process.env.BROWSER_STACK_USERNAME,
'browserstack.key': process.env.BROWSER_STACK_ACCESS_KEY,
project: '@lion',
name: '@lion web components',
// if you are running tests in a CI, the build id might be available as an
// environment variable. this is useful for identifying test runs
// this is for example the name for github actions
build: `build ${process.env.GITHUB_RUN_NUMBER || 'unknown'}`,
};
module.exports = {
plugins: [legacyPlugin()],
nodeResolve: true,
sessionStartTimeout: 60000,
concurrency: 1,
coverageConfig: {
threshold: {
statements: 80,
branches: 70,
functions: 70,
lines: 80,
},
},
browsers: [
// browserstackLauncher({
// capabilities: {
// ...sharedCapabilities,
// browserName: 'Chrome',
// os: 'Windows',
// os_version: '10',
// },
// }),
// browserstackLauncher({
// capabilities: {
// ...sharedCapabilities,
// browserName: 'Firefox',
// os: 'Windows',
// os_version: '10',
// },
// }),
// browserstackLauncher({
// capabilities: {
// ...sharedCapabilities,
// browserName: 'Firefox',
// browser_version: '60.0',
// os: 'Windows',
// os_version: '10',
// },
// }),
// browserstackLauncher({
// capabilities: {
// ...sharedCapabilities,
// browserName: 'Safari',
// browser_version: '11.1',
// os: 'OS X',
// os_version: 'High Sierra',
// },
// }),
browserstackLauncher({
capabilities: {
...sharedCapabilities,
browserName: 'IE',
browser_version: '11.0',
os: 'Windows',
os_version: '7',
},
}),
],
};

View file

@ -1,4 +1,7 @@
const { legacyPlugin } = require('@web/dev-server-legacy');
module.exports = { module.exports = {
plugins: [legacyPlugin()],
nodeResolve: true, nodeResolve: true,
sessionStartTimeout: 30000, sessionStartTimeout: 30000,
concurrency: 5, concurrency: 5,

2215
yarn.lock

File diff suppressed because it is too large Load diff