From f2a92096a2a8c3522a1aa2c32c34991d9e30dd1e Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Tue, 12 Nov 2024 10:18:38 +0200 Subject: [PATCH 1/6] feat(form): support validation change events Fixes #2702 --- packages/ts/lit-form/src/BinderNode.ts | 13 ++-- packages/ts/lit-form/src/BinderRoot.ts | 1 - packages/ts/lit-form/src/Field.ts | 79 ++++++++++++++++++-- packages/ts/lit-form/test/Field.test.ts | 7 +- packages/ts/lit-form/test/Validation.test.ts | 39 +++++++++- packages/ts/react-form/src/index.ts | 50 ++++++------- 6 files changed, 141 insertions(+), 48 deletions(-) diff --git a/packages/ts/lit-form/src/BinderNode.ts b/packages/ts/lit-form/src/BinderNode.ts index cc3776ebb1..6416282e76 100644 --- a/packages/ts/lit-form/src/BinderNode.ts +++ b/packages/ts/lit-form/src/BinderNode.ts @@ -268,7 +268,11 @@ export class BinderNode extends EventTa set value(value: Value | undefined) { this.initializeValue(); - this.#setValueState(value, undefined); + const oldValue = this.value; + if (value !== oldValue) { + this.#setValueState(value, undefined); + this[_updateValidation](); + } } /** @@ -281,7 +285,6 @@ export class BinderNode extends EventTa set visited(v: boolean) { if (this.#visited !== v) { this.#visited = v; - this[_updateValidation]().catch(() => {}); this.dispatchEvent(CHANGED); } } @@ -406,12 +409,8 @@ export class BinderNode extends EventTa } protected async [_updateValidation](): Promise { - if (this.#visited) { + if (this.invalid) { await this.validate(); - } else if (this.dirty || this.invalid) { - await Promise.all( - [...this.#getChildBinderNodes()].map(async (childBinderNode) => childBinderNode[_updateValidation]()), - ); } } diff --git a/packages/ts/lit-form/src/BinderRoot.ts b/packages/ts/lit-form/src/BinderRoot.ts index 063c440260..3c6c3bb534 100644 --- a/packages/ts/lit-form/src/BinderRoot.ts +++ b/packages/ts/lit-form/src/BinderRoot.ts @@ -118,7 +118,6 @@ export class BinderRoot extends BinderN const oldValue = this.#value; this.#value = newValue; this[_update](oldValue); - this[_updateValidation]().catch(() => {}); } /** diff --git a/packages/ts/lit-form/src/Field.ts b/packages/ts/lit-form/src/Field.ts index 90cb04bee1..fd2ee6aa16 100644 --- a/packages/ts/lit-form/src/Field.ts +++ b/packages/ts/lit-form/src/Field.ts @@ -58,8 +58,12 @@ interface FieldState extends Field, FieldElementHolder { strategy: FieldStrategy; } +type EventHandler = (event: Event) => void; + export type FieldStrategy = Field & FieldConstraintValidation & { + onChange?: EventHandler; + onInput?: EventHandler; removeEventListeners(): void; }; @@ -80,6 +84,8 @@ export abstract class AbstractFieldStrategy = */ #validityFallback: ValidityState = defaultValidity; + #eventHandlers = new Map(); + constructor(element: E, model?: AbstractModel) { this.#element = element; this.model = model; @@ -115,6 +121,22 @@ export abstract class AbstractFieldStrategy = return this.#element.validity ?? this.#validityFallback; } + get onChange(): EventHandler | undefined { + return this.#eventHandlers.get('change'); + } + + set onChange(onChange: EventHandler | undefined) { + this.#setEventHandler('change', onChange); + } + + get onInput(): EventHandler | undefined { + return this.#getEventHandler('input'); + } + + set onInput(onInput: EventHandler | undefined) { + this.#setEventHandler('input', onInput); + } + checkValidity(): boolean { if (!this.#element.checkValidity) { return true; @@ -137,7 +159,29 @@ export abstract class AbstractFieldStrategy = } } - removeEventListeners(): void {} + removeEventListeners(): void { + for (const [type, handler] of this.#eventHandlers) { + this.element.removeEventListener(type, handler); + this.#eventHandlers.delete(type); + } + } + + #getEventHandler(type: string): EventHandler | undefined { + return this.#eventHandlers.get(type); + } + + #setEventHandler(type: string, handler?: EventHandler) { + if (this.#eventHandlers.has(type)) { + this.element.removeEventListener(type, this.#eventHandlers.get(type)!); + } + + if (handler) { + this.element.addEventListener(type, handler); + this.#eventHandlers.set(type, handler); + } else { + this.#eventHandlers.delete(type); + } + } #detectValidityError(): Readonly> { if (!('inputElement' in this.#element)) { @@ -166,10 +210,12 @@ export class VaadinFieldStrategy = FieldEleme > { #invalid = false; readonly #boundOnValidated = this.#onValidated.bind(this); + readonly #boundOnUnparsableChange = this.#onUnparsableChange.bind(this); constructor(element: E, model?: AbstractModel) { super(element, model); element.addEventListener('validated', this.#boundOnValidated); + element.addEventListener('unparsable-change', this.#boundOnUnparsableChange); } set required(value: boolean) { @@ -187,6 +233,7 @@ export class VaadinFieldStrategy = FieldEleme override removeEventListeners(): void { this.element.removeEventListener('validated', this.#boundOnValidated); + this.element.removeEventListener('unparsable-change', this.#boundOnUnparsableChange); } #onValidated(e: Event): void { @@ -196,10 +243,20 @@ export class VaadinFieldStrategy = FieldEleme // Override built-in changes of the `invalid` flag in Vaadin components // to keep the `invalid` property state of the web component in sync. - const invalid = !(e.detail satisfies Partial as Partial).valid; + const invalid = !((e.detail ?? {}) satisfies Partial).valid; if (this.#invalid !== invalid) { this.element.invalid = this.#invalid; } + + // Some user interactions in Vaadin components do not dispatch `input` + // event, such as validation upon closing the overlay, pressing Enter key. + // One notable example is . Use `validated` event in + // addition to standard input events to handle those. + this.onInput?.call(this.element, e); + } + + #onUnparsableChange(e: Event) { + this.onChange?.call(this.element, e); } override checkValidity(): boolean { @@ -450,7 +507,7 @@ export const field = directive( this.fieldState = fieldState; - const updateValueFromElement = () => { + const inputHandler = () => { fieldState.strategy.checkValidity(); // When bad input is detected, skip reading new value in binder state if (!fieldState.strategy.validity.badInput) { @@ -464,21 +521,27 @@ export const field = directive( } }; - element.addEventListener('input', updateValueFromElement); + fieldState.strategy.onInput = inputHandler; + fieldState.strategy.onChange = () => { + inputHandler(); + binderNode.validate(); + }; - const changeBlurHandler = () => { - updateValueFromElement(); + const blurHandler = () => { + inputHandler(); + binderNode.validate(); binderNode.visited = true; }; - element.addEventListener('blur', changeBlurHandler); - element.addEventListener('change', changeBlurHandler); + element.addEventListener('blur', blurHandler); } const { fieldState } = this; if (fieldState.element !== element || fieldState.model !== model) { + const onInput = fieldState?.strategy.onInput; fieldState.strategy = binderNode.binder.getFieldStrategy(element, model); + fieldState.strategy.onInput = onInput; } const { name } = binderNode; diff --git a/packages/ts/lit-form/test/Field.test.ts b/packages/ts/lit-form/test/Field.test.ts index 75bce17fa1..3be68c9923 100644 --- a/packages/ts/lit-form/test/Field.test.ts +++ b/packages/ts/lit-form/test/Field.test.ts @@ -230,11 +230,11 @@ describe('@vaadin/hilla-lit-form', () => { expect(orderViewWithTextField.requestUpdateSpy).to.be.calledOnce; }); - it('should update binder value on blur event', async () => { + it('should update binder value on validated event', async () => { orderViewWithTextField.requestUpdateSpy.resetHistory(); orderViewWithTextField.notesField!.value = 'foo'; orderViewWithTextField.notesField!.dispatchEvent( - new CustomEvent('blur', { bubbles: true, cancelable: false, composed: true }), + new CustomEvent('validated', { bubbles: true, cancelable: false, composed: true, detail: { valid: true } }), ); await orderViewWithTextField.updateComplete; @@ -838,9 +838,6 @@ describe('@vaadin/hilla-lit-form', () => { binderNode.value = value; await resetBinderNodeValidation(binderNode); - binderNode.validators = []; - await binderNode.validate(); - binderNode.validators = [{ message: 'any-err-msg', validate: () => false }, new Required()]; element = renderElement(); diff --git a/packages/ts/lit-form/test/Validation.test.ts b/packages/ts/lit-form/test/Validation.test.ts index dc20888c00..c7fef807b1 100644 --- a/packages/ts/lit-form/test/Validation.test.ts +++ b/packages/ts/lit-form/test/Validation.test.ts @@ -51,6 +51,12 @@ class NumberOutput extends HTMLElement { } customElements.define('number-output', NumberOutput); +class MockDatePickerElement extends HTMLElement { + // pretend it’s a Vaadin component to use VaadinFieldStrategy + static readonly version = '0.0.0'; +} +customElements.define('mock-date-picker', MockDatePickerElement); + @customElement('order-view') class OrderView extends LitElement { static override readonly styles = css` @@ -104,7 +110,7 @@ class OrderView extends LitElement { - + ${repeat( products, @@ -596,14 +602,24 @@ describe('@vaadin/hilla-lit-form', () => { expect(orderView.notes).to.not.have.attribute('invalid'); }); - it(`should validate field on input after first visit`, async () => { + it(`should not validate field on input after first visit`, async () => { orderView.notes.value = 'foo'; await fireEvent(orderView.notes, 'blur'); expect(orderView.notes).to.not.have.attribute('invalid'); orderView.notes.value = ''; await fireEvent(orderView.notes, 'input'); + expect(orderView.notes).to.not.have.attribute('invalid'); + }); + + it(`should revalidate field on input after invalid change`, async () => { + orderView.notes.value = ''; + await fireEvent(orderView.notes, 'change'); expect(orderView.notes).to.have.attribute('invalid'); + + orderView.notes.value = 'foo'; + await fireEvent(orderView.notes, 'input'); + expect(orderView.notes).to.not.have.attribute('invalid'); }); it(`should validate fields on submit`, async () => { @@ -850,6 +866,25 @@ describe('@vaadin/hilla-lit-form', () => { errors = await binder.validate(); expect(errors).to.have.length(0); }); + + it('should track unparsable-change event and fail validation', async () => { + const value = binder.defaultValue; + value.customer.fullName = 'Jane Doe'; + value.notes = '42'; + value.total = 1; + value.priority = 0; + value.dateStart = '02-11-2099'; + binder.value = value; + await orderView.updateComplete; + + // Simulate bad user date input with unparsable-change event + orderView.dateStart.value = 'not a date'; + await fireEvent(orderView.dateStart, 'unparsable-change'); + + let errors = await binder.validate(); + expect(errors).to.have.length(1); + expect(errors[0]).to.have.property('property', 'dateStart'); + }); }); describe('message interpolation', () => { diff --git a/packages/ts/react-form/src/index.ts b/packages/ts/react-form/src/index.ts index 64d9248487..a79585a144 100644 --- a/packages/ts/react-form/src/index.ts +++ b/packages/ts/react-form/src/index.ts @@ -91,9 +91,9 @@ type FieldState = { errorMessage: string; strategy?: FieldStrategy; element?: HTMLElement; - changeBlurHandler(): void; - updateValue(): void; - markVisited(): void; + inputHandler(): void; + changeHandler(): void; + blurHandler(): void; ref(element: HTMLElement | null): void; }; @@ -146,21 +146,32 @@ function useFields(node: BinderNode): FieldDirective if (!fieldState) { fieldState = { + changeHandler() { + fieldState!.inputHandler(); + void n.validate(); + }, element: undefined, errorMessage: '', - invalid: false, - changeBlurHandler() { - fieldState!.updateValue(); - fieldState!.markVisited(); + inputHandler() { + if (fieldState!.strategy) { + // Remove invalid flag, so that .checkValidity() in Vaadin Components + // does not interfere with errors from Hilla. + fieldState!.strategy.invalid = false; + // When bad input is detected, skip reading new value in binder state + fieldState!.strategy.checkValidity(); + n[_validity] = fieldState!.strategy.validity; + n.value = convertFieldValue(model, fieldState!.strategy.value); + } }, - markVisited() { + invalid: false, + blurHandler() { + fieldState!.inputHandler(); + void n.validate(); n.visited = true; }, ref(element: HTMLElement | null) { if (!element) { - fieldState!.element?.removeEventListener('change', fieldState!.changeBlurHandler); - fieldState!.element?.removeEventListener('input', fieldState!.updateValue); - fieldState!.element?.removeEventListener('blur', fieldState!.changeBlurHandler); + fieldState!.element?.removeEventListener('blur', fieldState!.blurHandler); fieldState!.strategy?.removeEventListeners(); fieldState!.element = undefined; fieldState!.strategy = undefined; @@ -174,26 +185,15 @@ function useFields(node: BinderNode): FieldDirective if (fieldState!.element !== element) { fieldState!.element = element; - fieldState!.element.addEventListener('change', fieldState!.changeBlurHandler); - fieldState!.element.addEventListener('input', fieldState!.updateValue); - fieldState!.element.addEventListener('blur', fieldState!.changeBlurHandler); + fieldState!.element.addEventListener('blur', fieldState!.blurHandler); fieldState!.strategy = getDefaultFieldStrategy(element, model); + fieldState!.strategy.onInput = fieldState!.inputHandler; + fieldState!.strategy.onChange = fieldState!.changeHandler; update(); } }, required: false, strategy: undefined, - updateValue() { - if (fieldState!.strategy) { - // Remove invalid flag, so that .checkValidity() in Vaadin Components - // does not interfere with errors from Hilla. - fieldState!.strategy.invalid = false; - // When bad input is detected, skip reading new value in binder state - fieldState!.strategy.checkValidity(); - n[_validity] = fieldState!.strategy.validity; - n.value = convertFieldValue(model, fieldState!.strategy.value); - } - }, }; registry.set(model, fieldState); From 24cde0fe645d9b6b97c46bae37dd8d94f0f59fa8 Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Tue, 3 Dec 2024 18:24:45 +0200 Subject: [PATCH 2/6] test(react-crud): make locale test environment-independant --- packages/ts/react-crud/test/locale.spec.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ts/react-crud/test/locale.spec.tsx b/packages/ts/react-crud/test/locale.spec.tsx index dbaa295d28..120696ea24 100644 --- a/packages/ts/react-crud/test/locale.spec.tsx +++ b/packages/ts/react-crud/test/locale.spec.tsx @@ -1,7 +1,7 @@ import { expect } from '@esm-bundle/chai'; import { render } from '@testing-library/react'; import type { DatePickerDate } from '@vaadin/react-components/DatePicker.js'; -import { LocaleFormatter, useDatePickerI18n } from '../src/locale.js'; +import { LocaleContext, LocaleFormatter, useDatePickerI18n } from '../src/locale.js'; describe('@vaadin/hilla-react-crud', () => { describe('LocaleFormatter', () => { @@ -89,7 +89,11 @@ describe('@vaadin/hilla-react-crud', () => { } it('uses "formatDate" and "parse" correctly', () => { - const { getByText } = render(); + const { getByText } = render( + + + , + ); expect(getByText(dateAsString)).to.exist; const json = JSON.stringify(dateAsObject); expect(getByText(json)).to.exist; From 4b177a4be5ebb3c42ada241a50a01eca80b00915 Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Wed, 4 Dec 2024 15:47:31 +0200 Subject: [PATCH 3/6] chore(lit-form): lint errors --- packages/ts/lit-form/src/BinderNode.ts | 4 ++-- packages/ts/lit-form/src/Field.ts | 12 ++++++------ packages/ts/lit-form/test/Validation.test.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ts/lit-form/src/BinderNode.ts b/packages/ts/lit-form/src/BinderNode.ts index 6416282e76..16e5a23afd 100644 --- a/packages/ts/lit-form/src/BinderNode.ts +++ b/packages/ts/lit-form/src/BinderNode.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable no-void,@typescript-eslint/prefer-nullish-coalescing */ /* * Copyright 2000-2020 Vaadin Ltd. * @@ -271,7 +271,7 @@ export class BinderNode extends EventTa const oldValue = this.value; if (value !== oldValue) { this.#setValueState(value, undefined); - this[_updateValidation](); + void this[_updateValidation](); } } diff --git a/packages/ts/lit-form/src/Field.ts b/packages/ts/lit-form/src/Field.ts index fd2ee6aa16..4f92c49292 100644 --- a/packages/ts/lit-form/src/Field.ts +++ b/packages/ts/lit-form/src/Field.ts @@ -1,4 +1,4 @@ -/* eslint-disable accessor-pairs,sort-keys */ +/* eslint-disable accessor-pairs,no-void,sort-keys */ import { type ElementPart, noChange, nothing, type PropertyPart } from 'lit'; import { directive, Directive, type DirectiveParameters, type PartInfo, PartType } from 'lit/directive.js'; import { getBinderNode } from './BinderNode.js'; @@ -84,7 +84,7 @@ export abstract class AbstractFieldStrategy = */ #validityFallback: ValidityState = defaultValidity; - #eventHandlers = new Map(); + readonly #eventHandlers = new Map(); constructor(element: E, model?: AbstractModel) { this.#element = element; @@ -243,7 +243,7 @@ export class VaadinFieldStrategy = FieldEleme // Override built-in changes of the `invalid` flag in Vaadin components // to keep the `invalid` property state of the web component in sync. - const invalid = !((e.detail ?? {}) satisfies Partial).valid; + const invalid = !((e.detail ?? {}) as Partial).valid; if (this.#invalid !== invalid) { this.element.invalid = this.#invalid; } @@ -524,12 +524,12 @@ export const field = directive( fieldState.strategy.onInput = inputHandler; fieldState.strategy.onChange = () => { inputHandler(); - binderNode.validate(); + void binderNode.validate(); }; const blurHandler = () => { inputHandler(); - binderNode.validate(); + void binderNode.validate(); binderNode.visited = true; }; @@ -539,7 +539,7 @@ export const field = directive( const { fieldState } = this; if (fieldState.element !== element || fieldState.model !== model) { - const onInput = fieldState?.strategy.onInput; + const { onInput } = fieldState.strategy; fieldState.strategy = binderNode.binder.getFieldStrategy(element, model); fieldState.strategy.onInput = onInput; } diff --git a/packages/ts/lit-form/test/Validation.test.ts b/packages/ts/lit-form/test/Validation.test.ts index c7fef807b1..2f333ed3b2 100644 --- a/packages/ts/lit-form/test/Validation.test.ts +++ b/packages/ts/lit-form/test/Validation.test.ts @@ -881,7 +881,7 @@ describe('@vaadin/hilla-lit-form', () => { orderView.dateStart.value = 'not a date'; await fireEvent(orderView.dateStart, 'unparsable-change'); - let errors = await binder.validate(); + const errors = await binder.validate(); expect(errors).to.have.length(1); expect(errors[0]).to.have.property('property', 'dateStart'); }); From 485b08c37f35f8280edc2353e94394e12adbf4aa Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Wed, 4 Dec 2024 16:12:57 +0200 Subject: [PATCH 4/6] chore(react-form): lint errors --- packages/ts/react-form/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ts/react-form/src/index.ts b/packages/ts/react-form/src/index.ts index a79585a144..56661df3f0 100644 --- a/packages/ts/react-form/src/index.ts +++ b/packages/ts/react-form/src/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable no-void,@typescript-eslint/unbound-method */ import { _fromString, _validity, From be0708a8495be7b0fc59164672a182110af46782 Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Mon, 13 Jan 2025 16:53:26 +0200 Subject: [PATCH 5/6] chore: address code analysis feedback --- packages/ts/lit-form/src/BinderNode.ts | 4 ++-- packages/ts/lit-form/src/BinderRoot.ts | 9 +-------- packages/ts/react-form/src/index.ts | 6 +++--- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/ts/lit-form/src/BinderNode.ts b/packages/ts/lit-form/src/BinderNode.ts index 0c4d24539f..3bfc3c4eee 100644 --- a/packages/ts/lit-form/src/BinderNode.ts +++ b/packages/ts/lit-form/src/BinderNode.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-void,@typescript-eslint/prefer-nullish-coalescing */ +/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* * Copyright 2000-2020 Vaadin Ltd. * @@ -271,7 +271,7 @@ export class BinderNode extends EventTa const oldValue = this.value; if (value !== oldValue) { this.#setValueState(value, undefined); - void this[_updateValidation](); + this[_updateValidation]().catch(() => {}); } } diff --git a/packages/ts/lit-form/src/BinderRoot.ts b/packages/ts/lit-form/src/BinderRoot.ts index 3c6c3bb534..ac48f1e904 100644 --- a/packages/ts/lit-form/src/BinderRoot.ts +++ b/packages/ts/lit-form/src/BinderRoot.ts @@ -1,12 +1,5 @@ import { EndpointValidationError, type ValidationErrorData } from '@vaadin/hilla-frontend/EndpointErrors.js'; -import { - _clearValidation, - _setErrorsWithDescendants, - _update, - _updateValidation, - BinderNode, - CHANGED, -} from './BinderNode.js'; +import { _clearValidation, _setErrorsWithDescendants, _update, BinderNode, CHANGED } from './BinderNode.js'; import { type FieldElement, type FieldStrategy, getDefaultFieldStrategy } from './Field.js'; import { _parent, diff --git a/packages/ts/react-form/src/index.ts b/packages/ts/react-form/src/index.ts index 56661df3f0..45f4912006 100644 --- a/packages/ts/react-form/src/index.ts +++ b/packages/ts/react-form/src/index.ts @@ -1,4 +1,4 @@ -/* eslint-disable no-void,@typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/unbound-method */ import { _fromString, _validity, @@ -148,7 +148,7 @@ function useFields(node: BinderNode): FieldDirective fieldState = { changeHandler() { fieldState!.inputHandler(); - void n.validate(); + n.validate().catch(() => {}); }, element: undefined, errorMessage: '', @@ -166,7 +166,7 @@ function useFields(node: BinderNode): FieldDirective invalid: false, blurHandler() { fieldState!.inputHandler(); - void n.validate(); + n.validate().catch(() => {}); n.visited = true; }, ref(element: HTMLElement | null) { From 0f9874dc3d963b06d5cf30265cea630267749613 Mon Sep 17 00:00:00 2001 From: Anton Platonov Date: Wed, 15 Jan 2025 13:19:55 +0200 Subject: [PATCH 6/6] test(lit-form): verify listener removal --- packages/ts/lit-form/test/Field.test.ts | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/packages/ts/lit-form/test/Field.test.ts b/packages/ts/lit-form/test/Field.test.ts index cbd0e1a524..976224d923 100644 --- a/packages/ts/lit-form/test/Field.test.ts +++ b/packages/ts/lit-form/test/Field.test.ts @@ -1011,6 +1011,67 @@ describe('@vaadin/hilla-lit-form', () => { }); }); + describe('Event listeners cleanup', () => { + function renderElement(elementName: string) { + const tag = unsafeStatic(elementName); + render(html`<${tag} />`, div); + return div.firstElementChild as HTMLElement; + } + + it('should remove event standard listeners for ', () => { + const inputHandler = () => {}; + const changeHandler = () => {}; + const element: HTMLElement = renderElement('input'); + const addEventListenerSpy = sinon.spy(element, 'addEventListener'); + const removeEventListenerSpy = sinon.spy(element, 'removeEventListener'); + + const strategy = binder.getFieldStrategy(element, binder.model.fieldString); + strategy.onInput = inputHandler; + strategy.onChange = changeHandler; + + expect(addEventListenerSpy).to.be.calledWith('input', inputHandler); + expect(addEventListenerSpy).to.be.calledWith('change', changeHandler); + + strategy.onInput = undefined; + strategy.onChange = undefined; + + expect(removeEventListenerSpy).to.be.calledWith('input', inputHandler); + expect(removeEventListenerSpy).to.be.calledWith('change', changeHandler); + }); + + it('should remove event standard listeners for on removeEventListeners()', () => { + const inputHandler = () => {}; + const changeHandler = () => {}; + const element: HTMLElement = renderElement('input'); + const removeEventListenerSpy = sinon.spy(element, 'removeEventListener'); + + const strategy = binder.getFieldStrategy(element, binder.model.fieldString); + strategy.onInput = inputHandler; + strategy.onChange = changeHandler; + + strategy.removeEventListeners(); + + expect(removeEventListenerSpy).to.be.calledWith('input', inputHandler); + expect(removeEventListenerSpy).to.be.calledWith('change', changeHandler); + }); + + it('should remove custom event listeners for on removeEventListeners()', () => { + const element: HTMLElement = renderElement('mock-text-field'); + const addEventListenerSpy = sinon.spy(element, 'addEventListener'); + const removeEventListenerSpy = sinon.spy(element, 'removeEventListener'); + + const strategy = binder.getFieldStrategy(element, binder.model.fieldString); + + expect(addEventListenerSpy).to.be.calledWith('validated'); + expect(addEventListenerSpy).to.be.calledWith('unparsable-change'); + + strategy.removeEventListeners(); + + expect(removeEventListenerSpy).to.be.calledWith('validated'); + expect(removeEventListenerSpy).to.be.calledWith('unparsable-change'); + }); + }); + describe('Dynamic strategy', () => { function renderElement(tag: string, renderModel: AbstractModel): T { const tagName = unsafeStatic(tag);