Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/afraid-penguins-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@siemens/ix": patch
---

Trigger **validityStateChange** events everytime values change for **ix-input**, **ix-number-input**, **ix-textarea**, **ix-date-input** and **ix-time-input**.

Fixes #2315 and #2323
2 changes: 2 additions & 0 deletions packages/angular-test-app/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ import TextareaRowsCols from '../preview-examples/textarea-rows-cols';
import TextareaValidation from '../preview-examples/textarea-validation';
import ThemeService from '../preview-examples/theme-switcher';
import Tile from '../preview-examples/tile';
import TimeInput from '../preview-examples/time-input';
import Timepicker from '../preview-examples/timepicker';
import Toast from '../preview-examples/toast';
import ToastCustom from '../preview-examples/toast-custom';
Expand Down Expand Up @@ -797,6 +798,7 @@ const routes: Routes = [
{ path: 'input-with-slots', component: InputWithSlots },
{ path: 'theme-switcher', component: ThemeService },
{ path: 'tile', component: Tile },
{ path: 'time-input', component: TimeInput },
{ path: 'timepicker', component: Timepicker },
{ path: 'toggle-button-primary', component: ToggleButtonPrimary },
{ path: 'toggle-button-secondary', component: ToggleButtonSecondary },
Expand Down
2 changes: 2 additions & 0 deletions packages/angular-test-app/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ import TextareaRowsCols from '../preview-examples/textarea-rows-cols';
import TextareaValidation from '../preview-examples/textarea-validation';
import ThemeSwitcher from '../preview-examples/theme-switcher';
import Tile from '../preview-examples/tile';
import TimeInput from '../preview-examples/time-input';
import Timepicker from '../preview-examples/timepicker';
import Toast from '../preview-examples/toast';
import ToastCustom from '../preview-examples/toast-custom';
Expand Down Expand Up @@ -433,6 +434,7 @@ import WorkflowVertical from '../preview-examples/workflow-vertical';
TextareaLegacy,
ThemeSwitcher,
Tile,
TimeInput,
Timepicker,
ToastCustom,
ToastPosition,
Expand Down
30 changes: 20 additions & 10 deletions packages/core/src/components/date-input/date-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,11 @@ import { DateTime } from 'luxon';
import { SlotEnd, SlotStart } from '../input/input.fc';
import {
DisposableChangesAndVisibilityObservers,
PickerValidityStateTracker,
addDisposableChangesAndVisibilityObservers,
adjustPaddingForStartAndEnd,
createPickerValidityStateTracker,
emitPickerValidityState,
handleSubmitOnEnterKeydown,
} from '../input/input.util';
import {
Expand Down Expand Up @@ -249,8 +252,14 @@ export class DateInput implements IxInputFieldComponent<string | undefined> {
private readonly inputElementRef = makeRef<HTMLInputElement>();
private readonly dropdownElementRef = makeRef<HTMLIxDropdownElement>();
private classObserver?: ClassMutationObserver;
private invalidReason?: string;
private touched = false;

/** @internal */
public invalidReason?: string;
/** @internal */
public touched = false;
/** @internal */
public validityTracker: PickerValidityStateTracker =
createPickerValidityStateTracker();

private disposableChangesAndVisibilityObservers?: DisposableChangesAndVisibilityObservers;

Expand Down Expand Up @@ -320,6 +329,10 @@ export class DateInput implements IxInputFieldComponent<string | undefined> {
async onInput(value: string | undefined) {
this.value = value;
if (!value) {
this.isInputInvalid = false;
this.invalidReason = undefined;
this.emitValidityStateChangeIfChanged();
this.updateFormInternalValue(value);
this.valueChange.emit(value);
return;
}
Expand All @@ -335,13 +348,14 @@ export class DateInput implements IxInputFieldComponent<string | undefined> {
this.isInputInvalid = !date.isValid || date < minDate || date > maxDate;

if (this.isInputInvalid) {
this.invalidReason = date.invalidReason || undefined;
this.invalidReason = date.invalidReason ?? undefined;
this.from = undefined;
} else {
this.updateFormInternalValue(value);
this.closeDropdown();
}

this.emitValidityStateChangeIfChanged();
this.valueChange.emit(value);
}

Expand Down Expand Up @@ -412,6 +426,7 @@ export class DateInput implements IxInputFieldComponent<string | undefined> {
onBlur={() => {
this.ixBlur.emit();
this.touched = true;
this.emitValidityStateChangeIfChanged();
}}
onKeyDown={(event) => this.handleInputKeyDown(event)}
style={{
Expand Down Expand Up @@ -449,13 +464,8 @@ export class DateInput implements IxInputFieldComponent<string | undefined> {
this.isWarning = isWarning;
}

@Watch('isInputInvalid')
async onInputValidationChange() {
const state = await this.getValidityState();
this.validityStateChange.emit({
patternMismatch: state.patternMismatch,
invalidReason: this.invalidReason,
});
private emitValidityStateChangeIfChanged() {
return emitPickerValidityState(this);
}

/** @internal */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
*/
export type DateInputValidityState = {
patternMismatch: boolean;
valueMissing: boolean;
invalidReason?: string;
};
5 changes: 5 additions & 0 deletions packages/core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
addDisposableChangesAndVisibilityObservers,
adjustPaddingForStartAndEnd,
checkAllowedKeys,
checkInternalValidity,
DisposableChangesAndVisibilityObservers,
getAriaAttributesForInput,
mapValidationResult,
Expand Down Expand Up @@ -226,6 +227,10 @@ export class Input implements IxInputFieldComponent<string> {
updateFormInternalValue(value: string) {
this.formInternals.setFormValue(value);
this.value = value;

if (this.inputRef.current && this.touched) {
checkInternalValidity(this, this.inputRef.current);
}
}

/** @internal */
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/components/input/input.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export async function checkInternalValidity<T>(
if (eventResult.defaultPrevented) {
return;
}

comp.hostElement.classList.toggle(
'ix-invalid--validity-invalid',
!newValidityState
);
}

if (comp.value === null || comp.value === undefined) {
Expand Down Expand Up @@ -219,3 +224,72 @@ export function handleSubmitOnEnterKeydown(
form.requestSubmit();
}
}

export interface PickerValidityStateTracker {
lastEmittedPatternMismatch?: boolean;
lastEmittedValueMissing?: boolean;
}

export function createPickerValidityStateTracker(): PickerValidityStateTracker {
return {
lastEmittedPatternMismatch: false,
lastEmittedValueMissing: false,
};
}

export interface PickerValidityContext {
touched: boolean;
invalidReason?: string;
getValidityState: () => Promise<ValidityState>;
emit: (state: {
patternMismatch: boolean;
valueMissing: boolean;
invalidReason?: string;
}) => void;
}

export interface PickerInputComponent<T> {
validityTracker: PickerValidityStateTracker;
touched: boolean;
invalidReason?: string;
getValidityState(): Promise<ValidityState>;
validityStateChange: { emit: (state: T) => void };
}

export function emitPickerValidityState<T>(component: PickerInputComponent<T>) {
return emitPickerValidityStateChangeIfChanged(component.validityTracker, {
touched: component.touched,
invalidReason: component.invalidReason,
getValidityState: () => component.getValidityState(),
emit: (state) => component.validityStateChange.emit(state as T),
});
}

export async function emitPickerValidityStateChangeIfChanged(
tracker: PickerValidityStateTracker,
context: PickerValidityContext
): Promise<void> {
if (!context.touched) {
return;
}

const state = await context.getValidityState();
const currentPatternMismatch = state.patternMismatch;
const currentValueMissing = state.valueMissing;

if (
tracker.lastEmittedPatternMismatch === currentPatternMismatch &&
tracker.lastEmittedValueMissing === currentValueMissing
) {
return;
}

tracker.lastEmittedPatternMismatch = currentPatternMismatch;
tracker.lastEmittedValueMissing = currentValueMissing;

context.emit({
patternMismatch: currentPatternMismatch,
valueMissing: currentValueMissing,
invalidReason: context.invalidReason,
});
}
3 changes: 3 additions & 0 deletions packages/core/src/components/input/number-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ export class NumberInput implements IxInputFieldComponent<number> {
value !== undefined && value !== null ? value.toString() : '';
this.formInternals.setFormValue(formValue);
this.value = value;
if (this.inputRef.current && this.touched) {
checkInternalValidity(this, this.inputRef.current);
}
}

private readonly handleInputChange = (inputValue: string) => {
Expand Down
65 changes: 20 additions & 45 deletions packages/core/src/components/input/tests/validation.ct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,59 +64,34 @@ test.describe('validation', () => {
await mount('<ix-number-input required value="10"></ix-number-input>');

const ixInput = page.locator('ix-number-input');

const shadowDomInput = ixInput.locator('input');

let eventTriggered = ixInput.evaluate(
(element, [eventTriggered]) =>
new Promise<{
event: string;
count?: number;
}>((resolve) => {
element.addEventListener('validityStateChange', () => {
eventTriggered++;
resolve({
event: 'validityStateChange',
count: eventTriggered,
});
});

element.addEventListener('valueChange', () =>
resolve({
event: 'valueChange',
})
);
}),
[0]
);
await ixInput.evaluate((el) => {
(el as any).__validityChanged = false;
el.addEventListener('validityStateChange', () => {
(el as any).__validityChanged = true;
});
});

await shadowDomInput.fill('15');
await shadowDomInput.blur();
expect((await eventTriggered).event).not.toBe('validityStateChange');

eventTriggered = ixInput.evaluate(
(element) =>
new Promise<{
event: string;
count?: number;
}>((resolve) => {
element.addEventListener('validityStateChange', () => {
resolve({
event: 'validityStateChange',
});
});

element.addEventListener('valueChange', () =>
resolve({
event: 'valueChange',
})
);
})

const firstCheckResult = await ixInput.evaluate(
(el) => (el as any).__validityChanged
);
expect(firstCheckResult).toBe(false);

await shadowDomInput.fill('');
await ixInput.evaluate((el) => {
(el as any).__validityChanged = false;
});

await shadowDomInput.clear();
await shadowDomInput.blur();
expect((await eventTriggered).event).toBe('validityStateChange');

const secondCheckResult = await ixInput.evaluate(
(el) => (el as any).__validityChanged
);
expect(secondCheckResult).toBe(true);
});

test('number input should be invalid if value is empty and required', async ({
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/components/input/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
} from '../utils/input';
import { makeRef } from '../utils/make-ref';
import { TextareaElement } from './input.fc';
import { mapValidationResult, onInputBlur } from './input.util';
import {
mapValidationResult,
onInputBlur,
checkInternalValidity,
} from './input.util';
import type { TextareaResizeBehavior } from './textarea.types';

/**
Expand Down Expand Up @@ -237,6 +241,9 @@ export class Textarea implements IxInputFieldComponent<string> {
updateFormInternalValue(value: string) {
this.formInternals.setFormValue(value);
this.value = value;
if (this.textAreaRef.current && this.touched) {
checkInternalValidity(this, this.textAreaRef.current);
}
}

/** @internal */
Expand Down
Loading
Loading