Skip to content

Commit

Permalink
feat: avoid internal validation before interacting with input
Browse files Browse the repository at this point in the history
  • Loading branch information
danielleroux committed Feb 7, 2025
1 parent 3f5d0a4 commit 07e2d2f
Show file tree
Hide file tree
Showing 19 changed files with 318 additions and 76 deletions.
20 changes: 20 additions & 0 deletions .changeset/six-wombats-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@siemens/ix-angular': minor
---

Add `suppressClassMapping` to value-accessors to prevent that the accessors automatically map `ng-`-classes to `ix-`-classes.

If `[suppressClassMapping]="true"` you need to control the `ix-`-classes on your own.

```html
<ix-input
label="Name:"
formControlName="name"
[suppressClassMapping]="true"
[class.ix-invalid]="!form.get('name')!.valid && form.get('name')!.touched"
required
>
</ix-input>
```

Fixes #1638 #1680
9 changes: 9 additions & 0 deletions .changeset/stale-ladybugs-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@siemens/ix': patch
---

Prevent input elements like (`ix-input`, `ix-number-input`, `ix-date-input`, `ix-select`, `ix-textarea`) to show `required` validation error without any user interaction.

If the class `ix-invalid` is applied programmatically an error message is still shown even without a user interaction.

Fixes #1638, #1680
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
import { Directive, HostListener, ElementRef, Injector } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ValueAccessor, mapNgToIxClassNames } from './value-accessor';
import { ValueAccessor } from './value-accessor';

@Directive({
selector: 'ix-checkbox,ix-toggle',
Expand All @@ -27,7 +27,7 @@ export class BooleanValueAccessorDirective extends ValueAccessor {

override writeValue(value: boolean): void {
this.elementRef.nativeElement.checked = this.lastValue = value;
mapNgToIxClassNames(this.elementRef);
super.mapNgToIxClassNames(this.elementRef);
}

@HostListener('checkedChange', ['$event.target'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/
import { Directive, HostListener, ElementRef, Injector } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ValueAccessor, mapNgToIxClassNames } from './value-accessor';
import { ValueAccessor } from './value-accessor';

@Directive({
selector: 'ix-radio',
Expand All @@ -29,7 +29,7 @@ export class RadioValueAccessorDirective extends ValueAccessor {
this.lastValue = value;
this.elementRef.nativeElement.checked =
this.elementRef.nativeElement.value === value;
mapNgToIxClassNames(this.elementRef);
super.mapNgToIxClassNames(this.elementRef);
}

@HostListener('checkedChange', ['$event.target'])
Expand Down
111 changes: 56 additions & 55 deletions packages/angular/src/control-value-accessors/value-accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
OnDestroy,
Directive,
HostListener,
Input,
} from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { Subscription } from 'rxjs';
Expand All @@ -32,6 +33,8 @@ export class ValueAccessor
protected lastValue: any;
private statusChanges?: Subscription;

@Input() suppressClassMapping = false;

constructor(protected injector: Injector, protected elementRef: ElementRef) {}

writeValue(value: any): void {
Expand Down Expand Up @@ -88,7 +91,7 @@ export class ValueAccessor
});
}

detourFormControlMethods(ngControl, this.elementRef);
this.detourFormControlMethods(ngControl, this.elementRef);
}

getAssignedNgControl(): NgControl | null {
Expand All @@ -107,63 +110,61 @@ export class ValueAccessor
if (!ngControl) {
return;
}
mapNgToIxClassNames(this.elementRef);
this.mapNgToIxClassNames(this.elementRef);
}
}

const detourFormControlMethods = (
ngControl: NgControl,
elementRef: ElementRef
) => {
const formControl = ngControl.control as any;
if (formControl) {
const methodsToPatch = [
'markAsTouched',
'markAllAsTouched',
'markAsUntouched',
'markAsDirty',
'markAsPristine',
];
methodsToPatch.forEach((method) => {
if (typeof formControl[method] !== 'undefined') {
const oldFn = formControl[method].bind(formControl);
formControl[method] = (...params: any[]) => {
oldFn(...params);
mapNgToIxClassNames(elementRef);
};
}
detourFormControlMethods(ngControl: NgControl, elementRef: ElementRef) {
const formControl = ngControl.control as any;
if (formControl) {
const methodsToPatch = [
'markAsTouched',
'markAllAsTouched',
'markAsUntouched',
'markAsDirty',
'markAsPristine',
];
methodsToPatch.forEach((method) => {
if (typeof formControl[method] !== 'undefined') {
const oldFn = formControl[method].bind(formControl);
formControl[method] = (...params: any[]) => {
oldFn(...params);
this.mapNgToIxClassNames(elementRef);
};
}
});
}
}

async mapNgToIxClassNames(element: ElementRef): Promise<void> {
if (this.suppressClassMapping) {
return;
}
setTimeout(async () => {
const input = element.nativeElement;

const classes = this.getClasses(input);
const classList = input.classList;
classList.remove(
'ix-valid',
'ix-invalid',
'ix-touched',
'ix-untouched',
'ix-dirty',
'ix-pristine'
);
classList.add(...classes);
});
}
};

export const mapNgToIxClassNames = async (
element: ElementRef
): Promise<void> => {
setTimeout(async () => {
const input = element.nativeElement;

const classes = getClasses(input);
const classList = input.classList;
classList.remove(
'ix-valid',
'ix-invalid',
'ix-touched',
'ix-untouched',
'ix-dirty',
'ix-pristine'
);
classList.add(...classes);
});
};

const getClasses = (element: HTMLElement) => {
const classList = element.classList;
const classes: string[] = [];
for (let i = 0; i < classList.length; i++) {
const item = classList.item(i);
if (item?.startsWith(ValueAccessor.ANGULAR_CLASS_PREFIX)) {
classes.push(`ix-${item.substring(3)}`);

getClasses(element: HTMLElement) {
const classList = element.classList;
const classes: string[] = [];
for (let i = 0; i < classList.length; i++) {
const item = classList.item(i);
if (item?.startsWith(ValueAccessor.ANGULAR_CLASS_PREFIX)) {
classes.push(`ix-${item.substring(3)}`);
}
}
return classes;
}
return classes;
};
}
20 changes: 20 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,6 +777,10 @@ export namespace Components {
* error text below the input field
*/
"invalidText"?: string;
/**
* Returns whether the text field has been touched.
*/
"isTouched": () => Promise<boolean>;
/**
* label of the input field
*/
Expand Down Expand Up @@ -1585,6 +1589,10 @@ export namespace Components {
* The error text for the text field.
*/
"invalidText"?: string;
/**
* Returns whether the text field has been touched.
*/
"isTouched": () => Promise<boolean>;
/**
* The label for the text field.
*/
Expand Down Expand Up @@ -2153,6 +2161,10 @@ export namespace Components {
* The error text for the input field
*/
"invalidText"?: string;
/**
* Returns true if the input field has been touched
*/
"isTouched": () => Promise<boolean>;
/**
* The label for the input field
*/
Expand Down Expand Up @@ -2522,6 +2534,10 @@ export namespace Components {
* @since 2.6.0
*/
"invalidText"?: string;
/**
* Check if the input field has been touched.
*/
"isTouched": () => Promise<boolean>;
/**
* Label for the select component
* @since 2.6.0
Expand Down Expand Up @@ -2785,6 +2801,10 @@ export namespace Components {
* The error text for the textarea field.
*/
"invalidText"?: string;
/**
* Check if the textarea field has been touched.
*/
"isTouched": () => Promise<boolean>;
/**
* The label for the textarea field.
*/
Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/components/date-input/date-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export class DateInput implements IxInputFieldComponent<string> {
private readonly dropdownElementRef = makeRef<HTMLIxDropdownElement>();
private classObserver?: ClassMutationObserver;
private invalidReason?: string;
private touched = false;

updateFormInternalValue(value: string): void {
this.formInternals.setFormValue(value);
Expand Down Expand Up @@ -337,7 +338,10 @@ export class DateInput implements IxInputFieldComponent<string> {
this.openDropdown();
this.ixFocus.emit();
}}
onBlur={() => this.ixBlur.emit()}
onBlur={() => {
this.ixBlur.emit();
this.touched = true;
}}
></input>
<SlotEnd
slotEndRef={this.slotEndRef}
Expand Down Expand Up @@ -412,6 +416,15 @@ export class DateInput implements IxInputFieldComponent<string> {
return (await this.getNativeInputElement()).focus();
}

/**
* Returns whether the text field has been touched.
* @internal
*/
@Method()
isTouched(): Promise<boolean> {
return Promise.resolve(this.touched);
}

render() {
const invalidText = this.isInputInvalid
? this.i18nErrorDateUnparsable
Expand Down
16 changes: 14 additions & 2 deletions packages/core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ export class Input implements IxInputFieldComponent<string> {
private readonly inputRef = makeRef<HTMLInputElement>();
private readonly slotEndRef = makeRef<HTMLDivElement>();
private readonly slotStartRef = makeRef<HTMLDivElement>();

private readonly inputId = `input-${inputIds++}`;
private touched = false;

@HookValidationLifecycle()
updateClassMappings(result: ValidationResults) {
Expand Down Expand Up @@ -234,6 +234,15 @@ export class Input implements IxInputFieldComponent<string> {
return (await this.getNativeInputElement()).focus();
}

/**
* Returns whether the text field has been touched.
* @internal
*/
@Method()
isTouched(): Promise<boolean> {
return Promise.resolve(this.touched);
}

render() {
const inputAria: A11yAttributes = getAriaAttributesForInput(this);
return (
Expand Down Expand Up @@ -282,7 +291,10 @@ export class Input implements IxInputFieldComponent<string> {
updateFormInternalValue={(value) =>
this.updateFormInternalValue(value)
}
onBlur={() => onInputBlur(this, this.inputRef.current)}
onBlur={() => {
onInputBlur(this, this.inputRef.current);
this.touched = true;
}}
ariaAttributes={inputAria}
></InputElement>
<SlotEnd
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/components/input/input.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,16 @@ export async function checkInternalValidity<T>(
}

export function onInputBlur<T>(
comp: IxInputFieldComponent<T>,
comp: IxFormComponent<T>,
input?: HTMLInputElement | HTMLTextAreaElement | null
) {
comp.ixBlur.emit();

if (!input) {
throw new Error('Input element is not available');
}

input.setAttribute('data-ix-touched', 'true');
checkInternalValidity(comp, input);
}

Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/components/input/number-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export class NumberInput implements IxInputFieldComponent<number> {
private readonly slotEndRef = makeRef<HTMLDivElement>();
private readonly slotStartRef = makeRef<HTMLDivElement>();
private readonly numberInputId = `number-input-${numberInputIds++}`;
private touched = false;

@HookValidationLifecycle()
updateClassMappings(result: ValidationResults) {
Expand Down Expand Up @@ -223,6 +224,15 @@ export class NumberInput implements IxInputFieldComponent<number> {
return (await this.getNativeInputElement()).focus();
}

/**
* Returns true if the input field has been touched
* @internal
*/
@Method()
isTouched(): Promise<boolean> {
return Promise.resolve(this.touched);
}

render() {
const showStepperButtons =
this.showStepperButtons && (this.disabled || this.readonly) === false;
Expand Down Expand Up @@ -278,7 +288,10 @@ export class NumberInput implements IxInputFieldComponent<number> {
updateFormInternalValue={(value) =>
this.updateFormInternalValue(Number(value))
}
onBlur={() => onInputBlur(this, this.inputRef.current)}
onBlur={() => {
onInputBlur(this, this.inputRef.current);
this.touched = true;
}}
></InputElement>
<SlotEnd
slotEndRef={this.slotEndRef}
Expand Down
Loading

0 comments on commit 07e2d2f

Please sign in to comment.