diff --git a/.changeset/afraid-penguins-impress.md b/.changeset/afraid-penguins-impress.md new file mode 100644 index 00000000000..0380cf0b16e --- /dev/null +++ b/.changeset/afraid-penguins-impress.md @@ -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 diff --git a/packages/angular-test-app/src/app/app-routing.module.ts b/packages/angular-test-app/src/app/app-routing.module.ts index 35125b2f761..4fb4cf009f0 100644 --- a/packages/angular-test-app/src/app/app-routing.module.ts +++ b/packages/angular-test-app/src/app/app-routing.module.ts @@ -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'; @@ -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 }, diff --git a/packages/angular-test-app/src/app/app.module.ts b/packages/angular-test-app/src/app/app.module.ts index 8fc5ae07c34..2729330d0da 100644 --- a/packages/angular-test-app/src/app/app.module.ts +++ b/packages/angular-test-app/src/app/app.module.ts @@ -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'; @@ -433,6 +434,7 @@ import WorkflowVertical from '../preview-examples/workflow-vertical'; TextareaLegacy, ThemeSwitcher, Tile, + TimeInput, Timepicker, ToastCustom, ToastPosition, diff --git a/packages/core/src/components/date-input/date-input.tsx b/packages/core/src/components/date-input/date-input.tsx index 606f6154c60..f3d0a20b693 100644 --- a/packages/core/src/components/date-input/date-input.tsx +++ b/packages/core/src/components/date-input/date-input.tsx @@ -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 { @@ -249,8 +252,14 @@ export class DateInput implements IxInputFieldComponent { private readonly inputElementRef = makeRef(); private readonly dropdownElementRef = makeRef(); 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; @@ -320,6 +329,10 @@ export class DateInput implements IxInputFieldComponent { 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; } @@ -335,13 +348,14 @@ export class DateInput implements IxInputFieldComponent { 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); } @@ -412,6 +426,7 @@ export class DateInput implements IxInputFieldComponent { onBlur={() => { this.ixBlur.emit(); this.touched = true; + this.emitValidityStateChangeIfChanged(); }} onKeyDown={(event) => this.handleInputKeyDown(event)} style={{ @@ -449,13 +464,8 @@ export class DateInput implements IxInputFieldComponent { 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 */ diff --git a/packages/core/src/components/date-input/date-input.types.ts b/packages/core/src/components/date-input/date-input.types.ts index 487374a6a2d..852dc9c7aff 100644 --- a/packages/core/src/components/date-input/date-input.types.ts +++ b/packages/core/src/components/date-input/date-input.types.ts @@ -8,5 +8,6 @@ */ export type DateInputValidityState = { patternMismatch: boolean; + valueMissing: boolean; invalidReason?: string; }; diff --git a/packages/core/src/components/input/input.tsx b/packages/core/src/components/input/input.tsx index baa86b27e65..d263acd2b46 100644 --- a/packages/core/src/components/input/input.tsx +++ b/packages/core/src/components/input/input.tsx @@ -33,6 +33,7 @@ import { addDisposableChangesAndVisibilityObservers, adjustPaddingForStartAndEnd, checkAllowedKeys, + checkInternalValidity, DisposableChangesAndVisibilityObservers, getAriaAttributesForInput, mapValidationResult, @@ -226,6 +227,10 @@ export class Input implements IxInputFieldComponent { updateFormInternalValue(value: string) { this.formInternals.setFormValue(value); this.value = value; + + if (this.inputRef.current && this.touched) { + checkInternalValidity(this, this.inputRef.current); + } } /** @internal */ diff --git a/packages/core/src/components/input/input.util.ts b/packages/core/src/components/input/input.util.ts index 3107d2697aa..4cf531cac8f 100644 --- a/packages/core/src/components/input/input.util.ts +++ b/packages/core/src/components/input/input.util.ts @@ -66,6 +66,11 @@ export async function checkInternalValidity( if (eventResult.defaultPrevented) { return; } + + comp.hostElement.classList.toggle( + 'ix-invalid--validity-invalid', + !newValidityState + ); } if (comp.value === null || comp.value === undefined) { @@ -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; + emit: (state: { + patternMismatch: boolean; + valueMissing: boolean; + invalidReason?: string; + }) => void; +} + +export interface PickerInputComponent { + validityTracker: PickerValidityStateTracker; + touched: boolean; + invalidReason?: string; + getValidityState(): Promise; + validityStateChange: { emit: (state: T) => void }; +} + +export function emitPickerValidityState(component: PickerInputComponent) { + 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 { + 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, + }); +} diff --git a/packages/core/src/components/input/number-input.tsx b/packages/core/src/components/input/number-input.tsx index e0eda27824b..886735c501b 100644 --- a/packages/core/src/components/input/number-input.tsx +++ b/packages/core/src/components/input/number-input.tsx @@ -267,6 +267,9 @@ export class NumberInput implements IxInputFieldComponent { 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) => { diff --git a/packages/core/src/components/input/tests/validation.ct.ts b/packages/core/src/components/input/tests/validation.ct.ts index 8247abb7b1e..670dd23689c 100644 --- a/packages/core/src/components/input/tests/validation.ct.ts +++ b/packages/core/src/components/input/tests/validation.ct.ts @@ -64,59 +64,34 @@ test.describe('validation', () => { await mount(''); 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 ({ diff --git a/packages/core/src/components/input/textarea.tsx b/packages/core/src/components/input/textarea.tsx index 0a120966fc5..ebc01746171 100644 --- a/packages/core/src/components/input/textarea.tsx +++ b/packages/core/src/components/input/textarea.tsx @@ -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'; /** @@ -237,6 +241,9 @@ export class Textarea implements IxInputFieldComponent { updateFormInternalValue(value: string) { this.formInternals.setFormValue(value); this.value = value; + if (this.textAreaRef.current && this.touched) { + checkInternalValidity(this, this.textAreaRef.current); + } } /** @internal */ diff --git a/packages/core/src/components/time-input/time-input.tsx b/packages/core/src/components/time-input/time-input.tsx index ae3f727b457..40d631614f4 100644 --- a/packages/core/src/components/time-input/time-input.tsx +++ b/packages/core/src/components/time-input/time-input.tsx @@ -26,8 +26,11 @@ import { IxTimePickerCustomEvent } from '../../components'; import { SlotEnd, SlotStart } from '../input/input.fc'; import { DisposableChangesAndVisibilityObservers, + PickerValidityStateTracker, addDisposableChangesAndVisibilityObservers, adjustPaddingForStartAndEnd, + createPickerValidityStateTracker, + emitPickerValidityState, handleSubmitOnEnterKeydown, } from '../input/input.util'; import { @@ -260,8 +263,14 @@ export class TimeInput implements IxInputFieldComponent { private readonly inputElementRef = makeRef(); private readonly dropdownElementRef = makeRef(); 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; @@ -343,6 +352,8 @@ export class TimeInput implements IxInputFieldComponent { this.value = value; if (!value) { this.isInputInvalid = false; + this.invalidReason = undefined; + this.emitValidityStateChangeIfChanged(); this.updateFormInternalValue(value); this.valueChange.emit(value); return; @@ -355,11 +366,13 @@ export class TimeInput implements IxInputFieldComponent { const time = DateTime.fromFormat(value, this.format); if (time.isValid) { this.isInputInvalid = false; + this.invalidReason = undefined; } else { this.isInputInvalid = true; - this.invalidReason = time.invalidReason; + this.invalidReason = time.invalidReason ?? undefined; } + this.emitValidityStateChangeIfChanged(); this.updateFormInternalValue(value); this.valueChange.emit(value); } @@ -428,6 +441,7 @@ export class TimeInput implements IxInputFieldComponent { onBlur={() => { this.ixBlur.emit(); this.touched = true; + this.emitValidityStateChangeIfChanged(); }} onKeyDown={(event) => this.handleInputKeyDown(event)} > @@ -463,13 +477,8 @@ export class TimeInput implements IxInputFieldComponent { 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 */ diff --git a/packages/core/src/components/time-input/time-input.types.ts b/packages/core/src/components/time-input/time-input.types.ts index e7e7b9078b2..1426e505ff3 100644 --- a/packages/core/src/components/time-input/time-input.types.ts +++ b/packages/core/src/components/time-input/time-input.types.ts @@ -8,5 +8,6 @@ */ export type TimeInputValidityState = { patternMismatch: boolean; + valueMissing: boolean; invalidReason?: string; }; diff --git a/packages/core/src/components/utils/input/validation.ts b/packages/core/src/components/utils/input/validation.ts index 3c7d1dec6c8..61f6bc07f40 100644 --- a/packages/core/src/components/utils/input/validation.ts +++ b/packages/core/src/components/utils/input/validation.ts @@ -139,11 +139,17 @@ export function HookValidationLifecycle(options?: { typeof host.getValidityState === 'function' ) { const validityState = await host.getValidityState(); + const touched = await isTouched(host); host.classList.toggle( `ix-invalid--validity-patternMismatch`, validityState.patternMismatch ); + + host.classList.toggle( + 'ix-invalid--validity-invalid', + !validityState.valid && touched + ); } };