Skip to content

Commit

Permalink
feat(form): support validation change events
Browse files Browse the repository at this point in the history
Fixes #2702
  • Loading branch information
platosha committed Dec 2, 2024
1 parent cbd0e07 commit 5d646e1
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 46 deletions.
11 changes: 5 additions & 6 deletions packages/ts/lit-form/src/BinderNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,11 @@ export class BinderNode<M extends AbstractModel = AbstractModel> extends EventTa

set value(value: Value<M> | undefined) {
this.initializeValue();
const oldValue = this.value;
this.#setValueState(value, undefined);
if (value !== oldValue) {
this[_updateValidation]();
}
}

/**
Expand All @@ -281,7 +285,6 @@ export class BinderNode<M extends AbstractModel = AbstractModel> extends EventTa
set visited(v: boolean) {
if (this.#visited !== v) {
this.#visited = v;
this[_updateValidation]().catch(() => {});
this.dispatchEvent(CHANGED);
}
}
Expand Down Expand Up @@ -406,12 +409,8 @@ export class BinderNode<M extends AbstractModel = AbstractModel> extends EventTa
}

protected async [_updateValidation](): Promise<void> {
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]()),
);
}
}

Expand Down
1 change: 0 additions & 1 deletion packages/ts/lit-form/src/BinderRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ export class BinderRoot<M extends AbstractModel = AbstractModel> extends BinderN
const oldValue = this.#value;
this.#value = newValue;
this[_update](oldValue);
this[_updateValidation]().catch(() => {});
}

/**
Expand Down
79 changes: 71 additions & 8 deletions packages/ts/lit-form/src/Field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,12 @@ interface FieldState<T> extends Field<T>, FieldElementHolder<T> {
strategy: FieldStrategy<T>;
}

type EventHandler = (event: Event) => void;

export type FieldStrategy<T = any> = Field<T> &
FieldConstraintValidation & {
onChange?: EventHandler;
onInput?: EventHandler;
removeEventListeners(): void;
};

Expand All @@ -80,6 +84,8 @@ export abstract class AbstractFieldStrategy<T = any, E extends FieldElement<T> =
*/
#validityFallback: ValidityState = defaultValidity;

#eventHandlers = new Map<string, EventHandler>();

constructor(element: E, model?: AbstractModel<T>) {
this.#element = element;
this.model = model;
Expand Down Expand Up @@ -115,6 +121,22 @@ export abstract class AbstractFieldStrategy<T = any, E extends FieldElement<T> =
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;
Expand All @@ -137,7 +159,29 @@ export abstract class AbstractFieldStrategy<T = any, E extends FieldElement<T> =
}
}

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<Partial<ValidityState>> {
if (!('inputElement' in this.#element)) {
Expand Down Expand Up @@ -166,10 +210,12 @@ export class VaadinFieldStrategy<T = any, E extends FieldElement<T> = FieldEleme
> {
#invalid = false;
readonly #boundOnValidated = this.#onValidated.bind(this);
readonly #boundOnUnparsableChange = this.#onUnparsableChange.bind(this);

constructor(element: E, model?: AbstractModel<T>) {
super(element, model);
element.addEventListener('validated', this.#boundOnValidated);
element.addEventListener('unparsable-change', this.#boundOnUnparsableChange);
}

set required(value: boolean) {
Expand All @@ -187,6 +233,7 @@ export class VaadinFieldStrategy<T = any, E extends FieldElement<T> = FieldEleme

override removeEventListeners(): void {
this.element.removeEventListener('validated', this.#boundOnValidated);
this.element.removeEventListener('unparsable-change', this.#boundOnUnparsableChange);
}

#onValidated(e: Event): void {
Expand All @@ -196,10 +243,20 @@ export class VaadinFieldStrategy<T = any, E extends FieldElement<T> = 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<ValidityState> as Partial<ValidityState>).valid;
const invalid = !((e.detail ?? {}) satisfies Partial<ValidityState>).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 <vaadin-date-picker>. 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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
7 changes: 2 additions & 5 deletions packages/ts/lit-form/test/Field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
12 changes: 11 additions & 1 deletion packages/ts/lit-form/test/Validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,14 +596,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 () => {
Expand Down
50 changes: 25 additions & 25 deletions packages/ts/react-form/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ type FieldState<T = unknown> = {
errorMessage: string;
strategy?: FieldStrategy<T>;
element?: HTMLElement;
changeBlurHandler(): void;
updateValue(): void;
markVisited(): void;
inputHandler(): void;
changeHandler(): void;
blurHandler(): void;
ref(element: HTMLElement | null): void;
};

Expand Down Expand Up @@ -146,21 +146,32 @@ function useFields<M extends AbstractModel>(node: BinderNode<M>): FieldDirective

if (!fieldState) {
fieldState = {
changeHandler() {
fieldState!.inputHandler();
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();
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;
Expand All @@ -174,26 +185,15 @@ function useFields<M extends AbstractModel>(node: BinderNode<M>): 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);
Expand Down

0 comments on commit 5d646e1

Please sign in to comment.