Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: avoid internal validation before interacting with input #1682

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,16 @@ test('select date by input with invalid date - i18n', async ({
test('required', async ({ mount, page }) => {
await mount(`<ix-date-input required label="MyLabel"></ix-date-input>`);
const dateInputElement = page.locator('ix-date-input');
const input = dateInputElement.locator('input');
await expect(dateInputElement).toHaveAttribute('required');

await expect(dateInputElement.locator('ix-field-label')).toHaveText(
'MyLabel *'
);

await input.focus();
await input.blur();

await expect(dateInputElement).toHaveClass(/ix-invalid--required/);
});

Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/components/field-label/tests/field-label.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,15 @@ test('invalid color with invalid text field', async ({ mount, page }) => {
`);

const fieldElement = page.locator('ix-input');
const inputElement = fieldElement.locator('input');
await expect(inputElement).toBeVisible();

const labelElement = page.locator('ix-field-label');

await expect(fieldElement).toHaveClass(/ix-invalid--required/);
await inputElement.focus();
await inputElement.blur();

await expect(fieldElement).toHaveClass(/ix-invalid--required/);
await expect(labelElement.locator('ix-typography')).toHaveAttribute(
'style',
'color: var(--theme-color-alarm-text);'
Expand Down Expand Up @@ -167,8 +172,14 @@ test('invalid color with invalid textarea field', async ({ mount, page }) => {
`);

const fieldElement = page.locator('ix-textarea');
const textareaElement = fieldElement.locator('textarea');
await expect(textareaElement).toBeVisible();

const labelElement = page.locator('ix-field-label');

await textareaElement.focus();
await textareaElement.blur();

await expect(fieldElement).toHaveClass(/ix-invalid--required/);

await expect(labelElement.locator('ix-typography')).toHaveAttribute(
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
Loading
Loading