diff --git a/.gitignore b/.gitignore index b464aa7dd0f..39031ff879e 100644 --- a/.gitignore +++ b/.gitignore @@ -200,3 +200,7 @@ packages/documentation/static/ionic-preview # artifact from bundle @siemens/ix-icons ### ix-icons/dist/collection/components/icon/icon.css + +# SonarQube +.scannerwork/ +sonar-project.properties diff --git a/packages/angular-standalone-test-app/src/app/app.routes.ts b/packages/angular-standalone-test-app/src/app/app.routes.ts index 8f2e0f1b684..a3c57de30f5 100644 --- a/packages/angular-standalone-test-app/src/app/app.routes.ts +++ b/packages/angular-standalone-test-app/src/app/app.routes.ts @@ -1158,6 +1158,11 @@ export const routes: Routes = [ loadComponent: () => import('../preview-examples/tile').then((m) => m.default), }, + { + path: 'time-input-form-test', + loadComponent: () => + import('../preview-examples/time-input-form-test').then((m) => m.default), + }, { path: 'timepicker', loadComponent: () => diff --git a/packages/angular-standalone-test-app/src/preview-examples/time-input-form-test.ts b/packages/angular-standalone-test-app/src/preview-examples/time-input-form-test.ts new file mode 100644 index 00000000000..7355771f6e4 --- /dev/null +++ b/packages/angular-standalone-test-app/src/preview-examples/time-input-form-test.ts @@ -0,0 +1,465 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IxModule } from '@siemens/ix-angular'; + +@Component({ + selector: 'app-time-input-form-test', + standalone: true, + imports: [IxModule, CommonModule], + template: ` +
+

Time Input Validation Tests

+
+ Testing: Required field validation (red border) after blur. Format: HH:MM:SS +
+ + + + +
+ + +
+

1️⃣ Required (Standalone)

+ +
+ + + + + +
+
+ + +
+

2️⃣ Optional (Standalone)

+ +
+ + + + + +
+
+ + +
+

3️⃣ Required (In Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

4️⃣ Optional (In Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

5️⃣ Required (NoValidate Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

6️⃣ Optional (NoValidate Form)

+
+ +
+ + + + + + +
+
+
+ +
+ + +
+

🪵 Event Log

+
+
+ No events yet... Interact with fields above +
+
+ {{ log }} +
+
+
+
+ `, + styles: [] +}) +export class TimeInputFormTestComponent { + logs: string[] = []; + + private addLog(message: string): void { + const timestamp = new Date().toLocaleTimeString(); + this.logs.push(`[${timestamp}] ${message}`); + } + + onValueChange(event: any, emoji: string): void { + const value = event?.detail ?? 'empty'; + this.addLog(`${emoji} Value: "${value}"`); + } + + onFocus(emoji: string): void { + this.addLog(`${emoji} Focus`); + } + + onBlur(emoji: string): void { + this.addLog(`${emoji} Blur`); + } + + onFormSubmit(event: Event, emoji: string): void { + event.preventDefault(); + this.addLog(`${emoji} Form submitted`); + } + + setEmpty(input: any, emoji: string): void { + if (input?.nativeElement) { + input.nativeElement.value = ''; + } + } + + setValid(input: any, emoji: string): void { + if (input?.nativeElement) { + input.nativeElement.value = '14:30:00'; + } + } + + setInvalid(input: any, emoji: string): void { + if (input?.nativeElement) { + input.nativeElement.value = 'invalid'; + } + } + + async triggerManualBlur(input: any, emoji: string): Promise { + const element = input?.nativeElement; + if (element) { + await this.triggerNativeBlur(element); + setTimeout(() => this.debugValidationState(element, `${emoji} MANUAL`), 100); + } + } + + async clearInput(input: any, emoji: string): Promise { + const element = input?.nativeElement; + if (element && typeof element.clear === 'function') { + await element.clear(); + this.addLog(`${emoji} Cleared`); + } + } + + clearLogs(): void { + this.logs = []; + } + + private async debugValidationState(element: any, label: string): Promise { + if (!element) { + this.addLog(`${label} - Element not available`); + return; + } + + const classList = Array.from(element.classList); + const hasRequiredClass = classList.includes('ix-invalid--required'); + const required = element.required; + const value = element.value; + + let touched = 'unknown'; + try { + if (typeof element.isTouched === 'function') { + touched = String(await element.isTouched()); + } + } catch { + touched = 'error'; + } + + this.addLog(`${label} - Req:${required}, Val:"${value}", Touch:${touched}, RedClass:${hasRequiredClass}`); + } + + private async triggerNativeBlur(element: any): Promise { + if (!element) return; + try { + const nativeInput = await element.getNativeInputElement(); + if (nativeInput) { + nativeInput.dispatchEvent(new FocusEvent('blur', { bubbles: true })); + this.addLog('✅ Native blur triggered'); + } + } catch (error) { + this.addLog(`❌ Error: ${error}`); + } + } + + getLogColor(message: string): string { + const isError = message.includes('❌'); + const isDebug = message.includes('BLUR') || message.includes('FOCUS') || message.includes('MANUAL'); + if (isError) { + return '#d32f2f'; + } else if (isDebug) { + return '#1976d2'; + } + return 'inherit'; + } +} + +export default TimeInputFormTestComponent; 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 4edd319db7c..25e6d6e5fc1 100644 --- a/packages/angular-test-app/src/app/app-routing.module.ts +++ b/packages/angular-test-app/src/app/app-routing.module.ts @@ -60,6 +60,7 @@ import DateInputLabel from '../preview-examples/date-input-label'; import DateInputReadonly from '../preview-examples/date-input-readonly'; import DateInputValidation from '../preview-examples/date-input-validation'; import DateInputWithSlots from '../preview-examples/date-input-with-slots'; +import DateInputFormTest from '../preview-examples/date-input-form-test'; import Datepicker from '../preview-examples/datepicker'; import DatepickerLocale from '../preview-examples/datepicker-locale'; import DatepickerRange from '../preview-examples/datepicker-range'; @@ -210,6 +211,13 @@ 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 TimeInputDisabled from '../preview-examples/time-input-disabled'; +import TimeInputFormTest from '../preview-examples/time-input-form-test'; +import TimeInputLabel from '../preview-examples/time-input-label'; +import TimeInputReadonly from '../preview-examples/time-input-readonly'; +import TimeInputValidation from '../preview-examples/time-input-validation'; +import TimeInputWithSlots from '../preview-examples/time-input-with-slots'; import Timepicker from '../preview-examples/timepicker'; import Toast from '../preview-examples/toast'; import ToastCustom from '../preview-examples/toast-custom'; @@ -454,6 +462,10 @@ const routes: Routes = [ path: 'date-input-with-slots', component: DateInputWithSlots, }, + { + path: 'date-input-form-test', + component: DateInputFormTest, + }, { path: 'datepicker', component: Datepicker, @@ -797,6 +809,13 @@ const routes: Routes = [ { path: 'input-with-slots', component: InputWithSlots }, { path: 'theme-switcher', component: ThemeService }, { path: 'tile', component: Tile }, + { path: 'time-input', component: TimeInput }, + { path: 'time-input-disabled', component: TimeInputDisabled }, + { path: 'time-input-form-test', component: TimeInputFormTest }, + { path: 'time-input-label', component: TimeInputLabel }, + { path: 'time-input-readonly', component: TimeInputReadonly }, + { path: 'time-input-validation', component: TimeInputValidation }, + { path: 'time-input-with-slots', component: TimeInputWithSlots }, { path: 'timepicker', component: Timepicker }, { path: 'toggle-button-primary', component: ToggleButtonPrimary }, { path: 'toggle-button-secondary', component: ToggleButtonSecondary }, @@ -881,6 +900,13 @@ const routes: Routes = [ path: 'tile', component: Tile, }, + { path: 'time-input', component: TimeInput }, + { path: 'time-input-disabled', component: TimeInputDisabled }, + { path: 'time-input-form-test', component: TimeInputFormTest }, + { path: 'time-input-label', component: TimeInputLabel }, + { path: 'time-input-readonly', component: TimeInputReadonly }, + { path: 'time-input-validation', component: TimeInputValidation }, + { path: 'time-input-with-slots', component: TimeInputWithSlots }, { path: 'timepicker', component: Timepicker, diff --git a/packages/angular-test-app/src/app/app.module.ts b/packages/angular-test-app/src/app/app.module.ts index 8a6762f9f25..cfab6f26a42 100644 --- a/packages/angular-test-app/src/app/app.module.ts +++ b/packages/angular-test-app/src/app/app.module.ts @@ -69,6 +69,7 @@ import DateInputLabel from '../preview-examples/date-input-label'; import DateInputReadonly from '../preview-examples/date-input-readonly'; import DateInputValidation from '../preview-examples/date-input-validation'; import DateInputWithSlots from '../preview-examples/date-input-with-slots'; +import DateInputFormTest from '../preview-examples/date-input-form-test'; import Datepicker from '../preview-examples/datepicker'; import DatepickerLocale from '../preview-examples/datepicker-locale'; import DatepickerRange from '../preview-examples/datepicker-range'; @@ -218,6 +219,13 @@ 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 TimeInputDisabled from '../preview-examples/time-input-disabled'; +import TimeInputFormTest from '../preview-examples/time-input-form-test'; +import TimeInputLabel from '../preview-examples/time-input-label'; +import TimeInputReadonly from '../preview-examples/time-input-readonly'; +import TimeInputValidation from '../preview-examples/time-input-validation'; +import TimeInputWithSlots from '../preview-examples/time-input-with-slots'; import Timepicker from '../preview-examples/timepicker'; import Toast from '../preview-examples/toast'; import ToastCustom from '../preview-examples/toast-custom'; @@ -299,6 +307,7 @@ import WorkflowVertical from '../preview-examples/workflow-vertical'; DateInputReadonly, DateInputValidation, DateInputWithSlots, + DateInputFormTest, ContentExample, ContentHeader, ContentHeaderNoBack, @@ -433,6 +442,13 @@ import WorkflowVertical from '../preview-examples/workflow-vertical'; TextareaLegacy, ThemeSwitcher, Tile, + TimeInput, + TimeInputDisabled, + TimeInputFormTest, + TimeInputLabel, + TimeInputReadonly, + TimeInputValidation, + TimeInputWithSlots, Timepicker, ToastCustom, ToastPosition, diff --git a/packages/angular-test-app/src/preview-examples/date-input-form-test.html b/packages/angular-test-app/src/preview-examples/date-input-form-test.html new file mode 100644 index 00000000000..69fc27ff111 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/date-input-form-test.html @@ -0,0 +1,223 @@ + + +
+

Date Input Validation Tests

+
+ Testing: Required field validation (red border) after blur. Format: YYYY-MM-DD +
+ + + +
+ + +
+

1️⃣ Required (Standalone)

+ +
+ + + + + +
+
+ + +
+

2️⃣ Optional (Standalone)

+ +
+ + + + + +
+
+ + +
+

3️⃣ Form Required

+
+ +
+ + + + + + +
+
+
+
+ + +
+

4️⃣ Date Range

+
+
+ +
+
+ +
+
+ + +
+
+
+ + +
+

📋 Event Logs

+
+
+ No events yet. Interact with the date inputs above to see logs here. +
+
+ {{ log }} +
+
+
+
diff --git a/packages/angular-test-app/src/preview-examples/date-input-form-test.ts b/packages/angular-test-app/src/preview-examples/date-input-form-test.ts new file mode 100644 index 00000000000..268dbc3fd60 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/date-input-form-test.ts @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './date-input-form-test.html', +}) +export default class DateInputFormTest implements AfterViewInit { + @ViewChild('requiredInput') requiredInput!: ElementRef; + @ViewChild('optionalInput') optionalInput!: ElementRef; + @ViewChild('formRequiredInput') formRequiredInput!: ElementRef; + @ViewChild('rangeStartInput') rangeStartInput!: ElementRef; + @ViewChild('rangeEndInput') rangeEndInput!: ElementRef; + + logs: string[] = []; + testForm: FormGroup; + + constructor(private readonly fb: FormBuilder) { + this.testForm = this.fb.group({ + requiredDate: ['', Validators.required], + optionalDate: [''] + }); + } + + ngAfterViewInit(): void { + this.addLog('🔧 Component initialized with ViewChild references'); + } + + onValueChange(event: CustomEvent, emoji: string): void { + const value = event.detail; + this.addLog(`${emoji} Value changed: ${value || 'empty'}`); + } + + onFocus(emoji: string): void { + this.addLog(`${emoji} 🔍 Focus`); + } + + onBlur(emoji: string): void { + this.addLog(`${emoji} 😴 Blur`); + } + + onFormSubmit(event: Event, emoji: string): void { + event.preventDefault(); + this.addLog(`${emoji} Form submitted`); + this.addLog(`Form valid: ${this.testForm.valid}`); + this.addLog(`Form values: ${JSON.stringify(this.testForm.value)}`); + } + + addLog(message: string): void { + const timestamp = new Date().toLocaleTimeString(); + this.logs.push(`[${timestamp}] ${message}`); + if (this.logs.length > 50) { + this.logs = this.logs.slice(-50); + } + } + + clearLogs(): void { + this.addLog('🧹 Clear logs button clicked'); + this.logs = []; + } + + setEmpty(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} 🔘 Empty button clicked`); + try { + const element = inputComponent?.nativeElement; + + if (element) { + // Method 1: Set value and dispatch events to trigger validation + element.value = ''; + + // Trigger input event to notify the component of value change + element.dispatchEvent(new Event('input', { bubbles: true })); + + // Trigger change event for additional validation clearing + element.dispatchEvent(new Event('change', { bubbles: true })); + + // Trigger blur to ensure validation state updates + setTimeout(() => { + element.dispatchEvent(new Event('blur', { bubbles: true })); + }, 10); + + this.addLog(`${emoji} Cleared via nativeElement with events`); + + // Method 2: Try component-specific clear method if available + if (inputComponent.clear && typeof inputComponent.clear === 'function') { + inputComponent.clear(); + this.addLog(`${emoji} Also called component.clear()`); + } + + // Method 3: Try setting component value property + if (inputComponent.value !== undefined) { + inputComponent.value = ''; + this.addLog(`${emoji} Also set component.value`); + } + + } else if (inputComponent?.value !== undefined) { + inputComponent.value = ''; + this.addLog(`${emoji} Set via component.value`); + } else { + this.addLog(`${emoji} ❌ Could not clear - unknown component structure`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error clearing input: ${error}`); + } + } + + setValid(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} ✅ Valid button clicked`); + try { + const validDate = '2024-12-25'; + if (inputComponent?.nativeElement) { + inputComponent.nativeElement.value = validDate; + this.addLog(`${emoji} Set via nativeElement.value: ${validDate}`); + } else if (inputComponent?.value !== undefined) { + inputComponent.value = validDate; + this.addLog(`${emoji} Set via component.value: ${validDate}`); + } else { + this.addLog(`${emoji} ❌ Could not set valid date - unknown component structure`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error setting valid date: ${error}`); + } + } + + setInvalid(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} ❌ Invalid button clicked`); + try { + const invalidDate = 'invalid-date'; + if (inputComponent?.nativeElement) { + inputComponent.nativeElement.value = invalidDate; + this.addLog(`${emoji} Set via nativeElement.value: ${invalidDate}`); + } else if (inputComponent?.value !== undefined) { + inputComponent.value = invalidDate; + this.addLog(`${emoji} Set via component.value: ${invalidDate}`); + } else { + this.addLog(`${emoji} ❌ Could not set invalid date - unknown component structure`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error setting invalid date: ${error}`); + } + } + + triggerManualBlur(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} 🔶 Manual blur button clicked`); + try { + if (inputComponent?.nativeElement?.blur) { + inputComponent.nativeElement.blur(); + this.addLog(`${emoji} Blur triggered via nativeElement.blur()`); + } else if (inputComponent?.blur) { + inputComponent.blur(); + this.addLog(`${emoji} Blur triggered via component.blur()`); + } else { + this.addLog(`${emoji} ❌ Could not trigger blur - no blur method found`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error triggering blur: ${error}`); + } + } + + clearInput(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} 🧹 Clear button clicked`); + + try { + // Method 1: Try to call the IX component's internal clear method first + if (inputComponent && typeof inputComponent.clear === 'function') { + inputComponent.clear(); + this.addLog(`${emoji} ✅ Called IX component.clear() method`); + } else { + this.addLog(`${emoji} ⚠️ No clear() method found on IX component`); + } + + // Method 2: Try calling clear on the native element if it exists + const element = inputComponent?.nativeElement; + if (element && typeof element.clear === 'function') { + element.clear(); + this.addLog(`${emoji} ✅ Called nativeElement.clear() method`); + } else { + this.addLog(`${emoji} ⚠️ No clear() method found on native element`); + } + + // Method 3: Fallback to setEmpty which sets value and triggers events + this.setEmpty(inputComponent, emoji); + + // Method 4: Force validation update by triggering focus and blur + setTimeout(() => { + if (element && element.focus && element.blur) { + element.focus(); + setTimeout(() => element.blur(), 10); + this.addLog(`${emoji} 🔄 Triggered focus/blur for validation update`); + } + }, 20); + + } catch (error) { + this.addLog(`${emoji} ❌ Error in clearInput: ${error}`); + // Fallback to setEmpty if there's an error + this.setEmpty(inputComponent, emoji); + } + } + + getLogColor(log: string): string { + if (log.includes('❌')) return '#f44336'; + if (log.includes('✅')) return '#4caf50'; + if (log.includes('🔍')) return '#2196f3'; + if (log.includes('😴')) return '#ff9800'; + if (log.includes('⚠️')) return '#ff5722'; + return '#333'; + } + + setDateRange(): void { + this.addLog('4️⃣ 📅 Setting date range (2024-01-01 to 2024-12-31)'); + try { + if (this.rangeStartInput?.nativeElement) { + this.rangeStartInput.nativeElement.value = '2024-01-01'; + this.addLog('4️⃣ Start date set to January 1, 2024'); + } + if (this.rangeEndInput?.nativeElement) { + this.rangeEndInput.nativeElement.value = '2024-12-31'; + this.addLog('4️⃣ End date set to December 31, 2024'); + } + } catch (error) { + this.addLog(`4️⃣ ❌ Error setting date range: ${error}`); + } + } + + setToday(): void { + this.addLog('4️⃣ 📅 Setting to today\'s date'); + try { + const today = new Date().toISOString().split('T')[0]; // Gets YYYY-MM-DD + if (this.rangeStartInput?.nativeElement) { + this.rangeStartInput.nativeElement.value = today; + this.addLog(`4️⃣ Today's date set: ${today}`); + } + } catch (error) { + this.addLog(`4️⃣ ❌ Error setting today's date: ${error}`); + } + } +} diff --git a/packages/angular-test-app/src/preview-examples/time-input-form-test.html b/packages/angular-test-app/src/preview-examples/time-input-form-test.html new file mode 100644 index 00000000000..9a083ec2efc --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/time-input-form-test.html @@ -0,0 +1,229 @@ + + +
+

Time Input Validation Tests

+
+ Testing: Required field validation (red border) after blur. Format: HH:MM:SS +
+ + + +
+ + +
+

1️⃣ Required (Standalone)

+ +
+ + + + + +
+
+ + +
+

2️⃣ Optional (Standalone)

+ +
+ + + + + +
+
+ + +
+

3️⃣ Form Required

+
+ +
+ + + + + + +
+
+
+
+ + +
+

4️⃣ Time Range

+
+
+ +
+
+ +
+
+ + + +
+
+
+ + +
+

📋 Event Logs

+
+
+ No events yet. Interact with the time inputs above to see logs here. +
+
+ {{ log }} +
+
+
+
diff --git a/packages/angular-test-app/src/preview-examples/time-input-form-test.ts b/packages/angular-test-app/src/preview-examples/time-input-form-test.ts new file mode 100644 index 00000000000..dc16b547503 --- /dev/null +++ b/packages/angular-test-app/src/preview-examples/time-input-form-test.ts @@ -0,0 +1,268 @@ +/* + * SPDX-FileCopyrightText: 2025 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +@Component({ + standalone: false, + selector: 'app-example', + templateUrl: './time-input-form-test.html', +}) +export default class TimeInputFormTest implements AfterViewInit { + @ViewChild('requiredInput') requiredInput!: ElementRef; + @ViewChild('optionalInput') optionalInput!: ElementRef; + @ViewChild('formRequiredInput') formRequiredInput!: ElementRef; + @ViewChild('rangeStartInput') rangeStartInput!: ElementRef; + @ViewChild('rangeEndInput') rangeEndInput!: ElementRef; + + logs: string[] = []; + testForm: FormGroup; + + constructor(private readonly fb: FormBuilder) { + this.testForm = this.fb.group({ + requiredTime: ['', Validators.required], + optionalTime: [''] + }); + } + + ngAfterViewInit(): void { + this.addLog('🔧 Component initialized with ViewChild references'); + } + + onValueChange(event: CustomEvent, emoji: string): void { + const value = event.detail; + this.addLog(`${emoji} Value changed: ${value || 'empty'}`); + } + + onFocus(emoji: string): void { + this.addLog(`${emoji} 🔍 Focus`); + } + + onBlur(emoji: string): void { + this.addLog(`${emoji} 😴 Blur`); + } + + onFormSubmit(event: Event, emoji: string): void { + event.preventDefault(); + this.addLog(`${emoji} Form submitted`); + this.addLog(`Form valid: ${this.testForm.valid}`); + this.addLog(`Form values: ${JSON.stringify(this.testForm.value)}`); + } + + addLog(message: string): void { + const timestamp = new Date().toLocaleTimeString(); + this.logs.push(`[${timestamp}] ${message}`); + if (this.logs.length > 50) { + this.logs = this.logs.slice(-50); + } + } + + clearLogs(): void { + this.addLog('🧹 Clear logs button clicked'); + this.logs = []; + } + + setEmpty(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} 🔘 Empty button clicked`); + try { + const element = inputComponent?.nativeElement; + + if (element) { + // Method 1: Set value and dispatch events to trigger validation + element.value = ''; + + // Trigger input event to notify the component of value change + element.dispatchEvent(new Event('input', { bubbles: true })); + + // Trigger change event for additional validation clearing + element.dispatchEvent(new Event('change', { bubbles: true })); + + // Trigger blur to ensure validation state updates + setTimeout(() => { + element.dispatchEvent(new Event('blur', { bubbles: true })); + }, 10); + + this.addLog(`${emoji} Cleared via nativeElement with events`); + + // Method 2: Try component-specific clear method if available + if (inputComponent.clear && typeof inputComponent.clear === 'function') { + inputComponent.clear(); + this.addLog(`${emoji} Also called component.clear()`); + } + + // Method 3: Try setting component value property + if (inputComponent.value !== undefined) { + inputComponent.value = ''; + this.addLog(`${emoji} Also set component.value`); + } + + } else if (inputComponent?.value !== undefined) { + inputComponent.value = ''; + this.addLog(`${emoji} Set via component.value`); + } else { + this.addLog(`${emoji} ❌ Could not clear - unknown component structure`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error clearing input: ${error}`); + } + } + + setValid(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} ✅ Valid button clicked`); + try { + const validTime = '14:30:00'; + if (inputComponent?.nativeElement) { + inputComponent.nativeElement.value = validTime; + this.addLog(`${emoji} Set via nativeElement.value: ${validTime}`); + } else if (inputComponent?.value !== undefined) { + inputComponent.value = validTime; + this.addLog(`${emoji} Set via component.value: ${validTime}`); + } else { + this.addLog(`${emoji} ❌ Could not set valid time - unknown component structure`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error setting valid time: ${error}`); + } + } + + setInvalid(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} ❌ Invalid button clicked`); + try { + const invalidTime = '25:99:99'; + if (inputComponent?.nativeElement) { + inputComponent.nativeElement.value = invalidTime; + this.addLog(`${emoji} Set via nativeElement.value: ${invalidTime}`); + } else if (inputComponent?.value !== undefined) { + inputComponent.value = invalidTime; + this.addLog(`${emoji} Set via component.value: ${invalidTime}`); + } else { + this.addLog(`${emoji} ❌ Could not set invalid time - unknown component structure`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error setting invalid time: ${error}`); + } + } + + triggerManualBlur(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} 🔶 Manual blur button clicked`); + try { + if (inputComponent?.nativeElement?.blur) { + inputComponent.nativeElement.blur(); + this.addLog(`${emoji} Blur triggered via nativeElement.blur()`); + } else if (inputComponent?.blur) { + inputComponent.blur(); + this.addLog(`${emoji} Blur triggered via component.blur()`); + } else { + this.addLog(`${emoji} ❌ Could not trigger blur - no blur method found`); + console.log('Input component:', inputComponent); + } + } catch (error) { + this.addLog(`${emoji} ❌ Error triggering blur: ${error}`); + } + } + + clearInput(inputComponent: any, emoji: string): void { + this.addLog(`${emoji} 🧹 Clear button clicked`); + + try { + // Method 1: Try to call the IX component's internal clear method first + if (inputComponent && typeof inputComponent.clear === 'function') { + inputComponent.clear(); + this.addLog(`${emoji} ✅ Called IX component.clear() method`); + } else { + this.addLog(`${emoji} ⚠️ No clear() method found on IX component`); + } + + // Method 2: Try calling clear on the native element if it exists + const element = inputComponent?.nativeElement; + if (element && typeof element.clear === 'function') { + element.clear(); + this.addLog(`${emoji} ✅ Called nativeElement.clear() method`); + } else { + this.addLog(`${emoji} ⚠️ No clear() method found on native element`); + } + + // Method 3: Fallback to setEmpty which sets value and triggers events + this.setEmpty(inputComponent, emoji); + + // Method 4: Force validation update by triggering focus and blur + setTimeout(() => { + if (element && element.focus && element.blur) { + element.focus(); + setTimeout(() => element.blur(), 10); + this.addLog(`${emoji} 🔄 Triggered focus/blur for validation update`); + } + }, 20); + + } catch (error) { + this.addLog(`${emoji} ❌ Error in clearInput: ${error}`); + // Fallback to setEmpty if there's an error + this.setEmpty(inputComponent, emoji); + } + } + + setTimeRange(): void { + this.addLog('4️⃣ ⏰ Setting work hours (9:00 - 17:00)'); + try { + if (this.rangeStartInput?.nativeElement) { + this.rangeStartInput.nativeElement.value = '09:00:00'; + this.addLog('4️⃣ Start time set to 9:00 AM'); + } + if (this.rangeEndInput?.nativeElement) { + this.rangeEndInput.nativeElement.value = '17:00:00'; + this.addLog('4️⃣ End time set to 5:00 PM'); + } + } catch (error) { + this.addLog(`4️⃣ ❌ Error setting work hours: ${error}`); + } + } + + setMeetingTimes(): void { + this.addLog('4️⃣ 📅 Setting meeting times (14:00 - 15:30)'); + try { + if (this.rangeStartInput?.nativeElement) { + this.rangeStartInput.nativeElement.value = '14:00:00'; + this.addLog('4️⃣ Meeting start: 2:00 PM'); + } + if (this.rangeEndInput?.nativeElement) { + this.rangeEndInput.nativeElement.value = '15:30:00'; + this.addLog('4️⃣ Meeting end: 3:30 PM'); + } + } catch (error) { + this.addLog(`4️⃣ ❌ Error setting meeting times: ${error}`); + } + } + + setCurrentTime(): void { + this.addLog('4️⃣ 🕐 Setting current time'); + try { + const now = new Date(); + const timeString = now.toTimeString().split(' ')[0]; // Gets HH:MM:SS + if (this.rangeStartInput?.nativeElement) { + this.rangeStartInput.nativeElement.value = timeString; + this.addLog(`4️⃣ Current time set: ${timeString}`); + } + } catch (error) { + this.addLog(`4️⃣ ❌ Error setting current time: ${error}`); + } + } + + getLogColor(log: string): string { + if (log.includes('❌')) return '#f44336'; + if (log.includes('✅')) return '#4caf50'; + if (log.includes('🔍')) return '#2196f3'; + if (log.includes('😴')) return '#ff9800'; + if (log.includes('⚠️')) return '#ff5722'; + return '#333'; + } +} \ No newline at end of file diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 34908ffa1f2..2afc815a317 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -629,7 +629,7 @@ The event payload contains information about the selected date range. @ProxyCmp({ inputs: ['ariaLabelCalendarButton', 'ariaLabelNextMonthButton', 'ariaLabelPreviousMonthButton', 'disabled', 'format', 'helperText', 'i18nErrorDateUnparsable', 'infoText', 'invalidText', 'label', 'locale', 'maxDate', 'minDate', 'name', 'placeholder', 'readonly', 'required', 'showTextAsTooltip', 'showWeekNumbers', 'suppressSubmitOnEnter', 'textAlignment', 'validText', 'value', 'warningText', 'weekStartIndex'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'clear'] }) @Component({ selector: 'ix-date-input', @@ -1326,7 +1326,7 @@ export declare interface IxIconToggleButton extends Components.IxIconToggleButto @ProxyCmp({ inputs: ['allowedCharactersPattern', 'disabled', 'helperText', 'infoText', 'invalidText', 'label', 'maxLength', 'minLength', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'showTextAsTooltip', 'suppressSubmitOnEnter', 'textAlignment', 'type', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'getValidityState', 'focusInput'] + methods: ['getNativeInputElement', 'getValidityState', 'focusInput', 'reset'] }) @Component({ selector: 'ix-input', @@ -2003,7 +2003,7 @@ Can be prevented, in which case only the event is triggered, and the modal remai @ProxyCmp({ inputs: ['allowEmptyValueChange', 'allowedCharactersPattern', 'disabled', 'helperText', 'infoText', 'invalidText', 'label', 'max', 'min', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'showStepperButtons', 'showTextAsTooltip', 'step', 'suppressSubmitOnEnter', 'textAlignment', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'reset'] }) @Component({ selector: 'ix-number-input', @@ -2526,7 +2526,7 @@ export declare interface IxTabs extends Components.IxTabs { @ProxyCmp({ inputs: ['disabled', 'helperText', 'infoText', 'invalidText', 'label', 'maxLength', 'minLength', 'name', 'placeholder', 'readonly', 'required', 'resizeBehavior', 'showTextAsTooltip', 'textareaCols', 'textareaHeight', 'textareaRows', 'textareaWidth', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'getValidityState', 'focusInput', 'reset'] }) @Component({ selector: 'ix-textarea', @@ -2590,7 +2590,7 @@ export declare interface IxTile extends Components.IxTile {} @ProxyCmp({ inputs: ['disabled', 'format', 'helperText', 'hideHeader', 'hourInterval', 'i18nErrorTimeUnparsable', 'i18nHourColumnHeader', 'i18nMillisecondColumnHeader', 'i18nMinuteColumnHeader', 'i18nSecondColumnHeader', 'i18nSelectTime', 'i18nTime', 'infoText', 'invalidText', 'label', 'millisecondInterval', 'minuteInterval', 'name', 'placeholder', 'readonly', 'required', 'secondInterval', 'showTextAsTooltip', 'suppressSubmitOnEnter', 'textAlignment', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'clear'] }) @Component({ selector: 'ix-time-input', diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index b395b030597..a7b76861fcf 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -730,7 +730,7 @@ The event payload contains information about the selected date range. @ProxyCmp({ defineCustomElementFn: defineIxDateInput, inputs: ['ariaLabelCalendarButton', 'ariaLabelNextMonthButton', 'ariaLabelPreviousMonthButton', 'disabled', 'format', 'helperText', 'i18nErrorDateUnparsable', 'infoText', 'invalidText', 'label', 'locale', 'maxDate', 'minDate', 'name', 'placeholder', 'readonly', 'required', 'showTextAsTooltip', 'showWeekNumbers', 'suppressSubmitOnEnter', 'textAlignment', 'validText', 'value', 'warningText', 'weekStartIndex'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'clear'] }) @Component({ selector: 'ix-date-input', @@ -1427,7 +1427,7 @@ export declare interface IxIconToggleButton extends Components.IxIconToggleButto @ProxyCmp({ defineCustomElementFn: defineIxInput, inputs: ['allowedCharactersPattern', 'disabled', 'helperText', 'infoText', 'invalidText', 'label', 'maxLength', 'minLength', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'showTextAsTooltip', 'suppressSubmitOnEnter', 'textAlignment', 'type', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'getValidityState', 'focusInput'] + methods: ['getNativeInputElement', 'getValidityState', 'focusInput', 'reset'] }) @Component({ selector: 'ix-input', @@ -2104,7 +2104,7 @@ Can be prevented, in which case only the event is triggered, and the modal remai @ProxyCmp({ defineCustomElementFn: defineIxNumberInput, inputs: ['allowEmptyValueChange', 'allowedCharactersPattern', 'disabled', 'helperText', 'infoText', 'invalidText', 'label', 'max', 'min', 'name', 'pattern', 'placeholder', 'readonly', 'required', 'showStepperButtons', 'showTextAsTooltip', 'step', 'suppressSubmitOnEnter', 'textAlignment', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'reset'] }) @Component({ selector: 'ix-number-input', @@ -2627,7 +2627,7 @@ export declare interface IxTabs extends Components.IxTabs { @ProxyCmp({ defineCustomElementFn: defineIxTextarea, inputs: ['disabled', 'helperText', 'infoText', 'invalidText', 'label', 'maxLength', 'minLength', 'name', 'placeholder', 'readonly', 'required', 'resizeBehavior', 'showTextAsTooltip', 'textareaCols', 'textareaHeight', 'textareaRows', 'textareaWidth', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'getValidityState', 'focusInput', 'reset'] }) @Component({ selector: 'ix-textarea', @@ -2691,7 +2691,7 @@ export declare interface IxTile extends Components.IxTile {} @ProxyCmp({ defineCustomElementFn: defineIxTimeInput, inputs: ['disabled', 'format', 'helperText', 'hideHeader', 'hourInterval', 'i18nErrorTimeUnparsable', 'i18nHourColumnHeader', 'i18nMillisecondColumnHeader', 'i18nMinuteColumnHeader', 'i18nSecondColumnHeader', 'i18nSelectTime', 'i18nTime', 'infoText', 'invalidText', 'label', 'millisecondInterval', 'minuteInterval', 'name', 'placeholder', 'readonly', 'required', 'secondInterval', 'showTextAsTooltip', 'suppressSubmitOnEnter', 'textAlignment', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'clear'] }) @Component({ selector: 'ix-time-input', diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index c3ee619d03c..a34fd78973a 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -943,6 +943,10 @@ export namespace Components { * ARIA label for the previous month icon button Will be set as aria-label on the nested HTML button element */ "ariaLabelPreviousMonthButton"?: string; + /** + * Clears the input field value and resets validation state. Sets the value to empty and removes touched state to suppress validation. + */ + "clear": () => Promise; /** * Disabled attribute * @default false @@ -1969,6 +1973,10 @@ export namespace Components { * @default false */ "required": boolean; + /** + * Resets the input field validation state by removing the touched state and clearing validation states while preserving the current value. + */ + "reset": () => Promise; /** * Specifies whether to show the text as a tooltip. */ @@ -2580,6 +2588,10 @@ export namespace Components { * @default false */ "required": boolean; + /** + * Resets the input field validation state by removing the touched state and clearing validation states while preserving the current value. + */ + "reset": () => Promise; /** * Indicates if the stepper buttons should be shown */ @@ -3357,6 +3369,10 @@ export namespace Components { * Get the native textarea element. */ "getNativeInputElement": () => Promise; + /** + * Returns the validity state of the textarea field. + */ + "getValidityState": () => Promise; "hasValidValue": () => Promise; /** * The helper text for the textarea field. @@ -3404,6 +3420,10 @@ export namespace Components { * @default false */ "required": boolean; + /** + * Resets the input field validation state by removing the touched state and clearing validation states while preserving the current value. + */ + "reset": () => Promise; /** * Determines the resize behavior of the textarea field. Resizing can be enabled in one direction, both directions or completely disabled. * @default 'both' @@ -3455,6 +3475,10 @@ export namespace Components { * @form-ready */ interface IxTimeInput { + /** + * Clears the input field value and resets validation state. Sets the value to empty and removes touched state to suppress validation. + */ + "clear": () => Promise; /** * Disabled attribute * @default false diff --git a/packages/core/src/components/date-input/date-input.tsx b/packages/core/src/components/date-input/date-input.tsx index fc46c3d8ed5..b4fe80d4c29 100644 --- a/packages/core/src/components/date-input/date-input.tsx +++ b/packages/core/src/components/date-input/date-input.tsx @@ -14,7 +14,6 @@ import { Element, Event, EventEmitter, - Host, Method, Prop, State, @@ -27,7 +26,6 @@ import { DisposableChangesAndVisibilityObservers, addDisposableChangesAndVisibilityObservers, adjustPaddingForStartAndEnd, - handleSubmitOnEnterKeydown, } from '../input/input.util'; import { ClassMutationObserver, @@ -36,6 +34,21 @@ import { ValidationResults, createClassMutationObserver, getValidationText, + shouldSuppressInternalValidation, + syncState, + watchValue, + onInput, + onClick, + onFocus, + onBlur, + onKeyDown, + getNativeInput, + renderFieldWrapper, + createEventConfig, + createInputMethods, + createDropdownMethods, + createKeyDownHandler, + handleValidationLifecycle, } from '../utils/input'; import { makeRef } from '../utils/make-ref'; import type { DateInputValidityState } from './date-input.types'; @@ -78,7 +91,13 @@ export class DateInput implements IxInputFieldComponent { @Prop({ reflect: true, mutable: true }) value?: string = ''; @Watch('value') watchValuePropHandler(newValue: string) { - this.onInput(newValue); + watchValue({ + newValue, + required: this.required, + onInput: (value: string) => void this.onInput(value), + setTouched: (touched: boolean) => (this.touched = touched), + isClearing: this._isClearing, + }); } /** @@ -113,6 +132,11 @@ export class DateInput implements IxInputFieldComponent { */ @Prop() required?: boolean; + @Watch('required') + onRequiredChange() { + this.syncValidationClasses(); + } + /** * Helper text below the input field */ @@ -232,6 +256,7 @@ export class DateInput implements IxInputFieldComponent { @State() isInfo = false; @State() isWarning = false; @State() focus = false; + @State() suppressValidation = false; private readonly slotStartRef = makeRef(); private readonly slotEndRef = makeRef(); @@ -243,6 +268,7 @@ export class DateInput implements IxInputFieldComponent { private classObserver?: ClassMutationObserver; private invalidReason?: string; private touched = false; + private _isClearing = false; private disposableChangesAndVisibilityObservers?: DisposableChangesAndVisibilityObservers; @@ -300,6 +326,10 @@ export class DateInput implements IxInputFieldComponent { /** @internal */ @Method() hasValidValue(): Promise { + if (!this.required) { + return Promise.resolve(true); + } + return Promise.resolve(!!this.value); } @@ -311,8 +341,12 @@ export class DateInput implements IxInputFieldComponent { async onInput(value: string | undefined) { this.value = value; + this.suppressValidation = await shouldSuppressInternalValidation(this); + if (!value) { - this.valueChange.emit(value); + this.isInputInvalid = false; + this.invalidReason = undefined; + this.emitChangesAndSync(value); return; } @@ -320,21 +354,53 @@ export class DateInput implements IxInputFieldComponent { return; } + if (this.suppressValidation) { + this.isInputInvalid = false; + this.invalidReason = undefined; + this.handleValidInput(value); + return; + } + const date = DateTime.fromFormat(value, this.format); const minDate = DateTime.fromFormat(this.minDate, this.format); const maxDate = DateTime.fromFormat(this.maxDate, this.format); - this.isInputInvalid = !date.isValid || date < minDate || date > maxDate; + const isDateInvalid = !date.isValid || date < minDate || date > maxDate; + this.isInputInvalid = isDateInvalid; + this.invalidReason = isDateInvalid + ? date.invalidReason || undefined + : undefined; - if (this.isInputInvalid) { - this.invalidReason = date.invalidReason || undefined; - this.from = undefined; + if (isDateInvalid) { + this.handleInvalidInput(value); } else { - this.updateFormInternalValue(value); - this.closeDropdown(); + this.handleValidInput(value); } + } + + private emitChangesAndSync(value: string | undefined): void { + syncState({ + updateFormInternalValue: (val) => this.updateFormInternalValue(val), + valueChange: this.valueChange, + value: value, + hostElement: this.hostElement, + suppressValidation: this.suppressValidation, + required: this.required, + touched: this.touched, + isInputInvalid: this.isInputInvalid, + }); + } - this.valueChange.emit(value); + private handleValidInput(value: string | undefined): void { + this.from = value; + this.closeDropdown(); + this.emitChangesAndSync(value); + } + + private handleInvalidInput(value: string | undefined): void { + this.touched = true; + this.from = undefined; + this.emitChangesAndSync(value); } onCalenderClick(event: Event) { @@ -355,18 +421,34 @@ export class DateInput implements IxInputFieldComponent { } private checkClassList() { - this.isInvalid = this.hostElement.classList.contains('ix-invalid'); + this.isInvalid = this.dropdownMethods.checkClassList(); + } + + private getEventConfig() { + return createEventConfig({ + show: this.show, + setTouched: (touched: boolean) => (this.touched = touched), + onInput: (value: string) => void this.onInput(value), + openDropdown: () => this.openDropdown(), + ixFocus: this.ixFocus, + ixBlur: this.ixBlur, + syncValidationClasses: () => this.syncValidationClasses(), + handleInputKeyDown: (event: KeyboardEvent) => + this.handleInputKeyDown(event), + alwaysSetTouchedOnBlur: true, + }); } private handleInputKeyDown(event: KeyboardEvent) { - handleSubmitOnEnterKeydown( - event, + return createKeyDownHandler( this.suppressSubmitOnEnter, - this.formInternals.form - ); + this.formInternals + )(event); } private renderInput() { + const eventConfig = this.getEventConfig(); + return (
{ value={this.value ?? ''} placeholder={this.placeholder} name={this.name} - onInput={(event) => { - const target = event.target as HTMLInputElement; - this.onInput(target.value); - }} - onClick={(event) => { - if (this.show) { - event.stopPropagation(); - event.preventDefault(); - } - }} - onFocus={async () => { - this.openDropdown(); - this.ixFocus.emit(); - }} - onBlur={() => { - this.ixBlur.emit(); - this.touched = true; - }} - onKeyDown={(event) => this.handleInputKeyDown(event)} + onInput={onInput(eventConfig)} + onClick={onClick(eventConfig)} + onFocus={onFocus(eventConfig)} + onBlur={onBlur(eventConfig)} + onKeyDown={onKeyDown(eventConfig)} style={{ textAlign: this.textAlignment, }} @@ -428,17 +496,21 @@ export class DateInput implements IxInputFieldComponent { } @HookValidationLifecycle() - hookValidationLifecycle({ - isInfo, - isInvalid, - isInvalidByRequired, - isValid, - isWarning, - }: ValidationResults) { - this.isInvalid = isInvalid || isInvalidByRequired || this.isInputInvalid; - this.isInfo = isInfo; - this.isValid = isValid; - this.isWarning = isWarning; + hookValidationLifecycle(results: ValidationResults) { + const shouldShowInputInvalid = this.isInputInvalid && this.touched; + + // Use utility but with custom logic for shouldShowInputInvalid + handleValidationLifecycle( + this.suppressValidation, + shouldShowInputInvalid, // Pass shouldShowInputInvalid instead of this.isInputInvalid + results, + { + setIsInvalid: (value) => (this.isInvalid = value), + setIsInfo: (value) => (this.isInfo = value), + setIsValid: (value) => (this.isValid = value), + setIsWarning: (value) => (this.isWarning = value), + } + ); } @Watch('isInputInvalid') @@ -448,6 +520,16 @@ export class DateInput implements IxInputFieldComponent { patternMismatch: state.patternMismatch, invalidReason: this.invalidReason, }); + + if (this.suppressValidation) { + return; + } + + const shouldShowInputInvalid = this.isInputInvalid && this.touched; + + if (shouldShowInputInvalid) { + this.isInvalid = true; + } } /** @internal */ @@ -463,7 +545,37 @@ export class DateInput implements IxInputFieldComponent { */ @Method() getNativeInputElement(): Promise { - return this.inputElementRef.waitForCurrent(); + return getNativeInput(this.inputElementRef); + } + + private get commonMethods() { + return createInputMethods({ + inputElementRef: this.inputElementRef, + touched: this.touched, + hostElement: this.hostElement, + suppressValidation: this.suppressValidation, + required: this.required, + value: this.value, + isInputInvalid: this.isInputInvalid, + }); + } + + private get dropdownMethods() { + return createDropdownMethods({ + dropdownElementRef: this.dropdownElementRef, + hostElement: this.hostElement, + show: this.show, + touched: this.touched, + openDropdown: async () => { + return openDropdownUtil(this.dropdownElementRef); + }, + ixFocus: this.ixFocus, + ixBlur: this.ixBlur, + syncValidationClasses: () => this.syncValidationClasses(), + onInput: (value: string) => void this.onInput(value), + handleInputKeyDown: (event: KeyboardEvent) => + this.handleInputKeyDown(event), + }); } /** @@ -471,7 +583,7 @@ export class DateInput implements IxInputFieldComponent { */ @Method() async focusInput(): Promise { - return (await this.getNativeInputElement()).focus(); + return this.commonMethods.focusInput(); } /** @@ -480,70 +592,86 @@ export class DateInput implements IxInputFieldComponent { */ @Method() isTouched(): Promise { - return Promise.resolve(this.touched); + return this.commonMethods.isTouched(); + } + + /** + * @internal + */ + syncValidationClasses(): void { + return this.commonMethods.syncValidationClasses(); + } + + /** + * Clears the input field value and resets validation state. + * Sets the value to empty and removes touched state to suppress validation. + */ + @Method() + async clear(): Promise { + this._isClearing = true; + this.touched = false; + this.isInputInvalid = false; + this.isInvalid = false; + this.invalidReason = undefined; + this.value = ''; + this.from = undefined; + this.updateFormInternalValue(''); + this.valueChange.emit(''); + this.syncValidationClasses(); + this._isClearing = false; } render() { const invalidText = getValidationText( - this.isInputInvalid, + this.isInputInvalid && !this.suppressValidation, this.invalidText, this.i18nErrorDateUnparsable ); - return ( - - - {this.renderInput()} - - { - this.show = event.detail; + return renderFieldWrapper({ + host: this.hostElement, + disabled: this.disabled, + readonly: this.readonly, + label: this.label, + helper: this.helperText, + invalid: this.isInvalid, + invalidText, + info: this.infoText, + isInfo: this.isInfo, + warning: this.isWarning, + warningText: this.warningText, + valid: this.isValid, + validText: this.validText, + tooltip: this.showTextAsTooltip, + required: this.required, + inputRef: this.inputElementRef, + input: this.renderInput(), + dropdown: ( + { + const { from } = event.detail; + this.onInput(from); }} - > - { - const { from } = event.detail; - this.onInput(from); - }} - showWeekNumbers={this.showWeekNumbers} - ariaLabelNextMonthButton={this.ariaLabelNextMonthButton} - ariaLabelPreviousMonthButton={this.ariaLabelPreviousMonthButton} - embedded - > - - - ); + showWeekNumbers={this.showWeekNumbers} + ariaLabelNextMonthButton={this.ariaLabelNextMonthButton} + ariaLabelPreviousMonthButton={this.ariaLabelPreviousMonthButton} + embedded + > + ), + testId: 'date-dropdown', + trigger: () => this.inputElementRef.waitForCurrent(), + dropdownRef: this.dropdownElementRef, + show: this.show, + onShow: (event) => { + this.show = event.detail; + }, + }); } } diff --git a/packages/core/src/components/date-input/tests/date-input.ct.ts b/packages/core/src/components/date-input/tests/date-input.ct.ts index 342a06a6324..2c0c316acef 100644 --- a/packages/core/src/components/date-input/tests/date-input.ct.ts +++ b/packages/core/src/components/date-input/tests/date-input.ct.ts @@ -222,3 +222,301 @@ regressionTest( ).toHaveText('Custom error message'); } ); + +regressionTest( + 'Required input: Invalid input > Removing value with keyboard > Stays invalid', + async ({ page, mount }) => { + await mount( + `` + ); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(dateInput).toHaveClass(/ix-invalid--required/); + } +); + +regressionTest( + 'Required input: Invalid input > Remove touched state > Valid again', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.fill('invalid-date'); + await input.blur(); + + await dateInput.evaluate((el: any) => el.clear()); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Required input: Invalid input > Programmatically setting to empty > Stays invalid', + async ({ page, mount }) => { + await mount( + `` + ); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await dateInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(dateInput).toHaveClass(/ix-invalid--required/); + } +); + +regressionTest( + 'Required input: Valid input > Removing value with keyboard > It is invalid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(dateInput).toHaveClass(/ix-invalid--required/); + } +); + +regressionTest( + 'Required input: Valid input > Remove touched state > Valid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await dateInput.evaluate((el: any) => el.clear()); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Required input: Valid input > Programmatically setting to empty > It is invalid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await dateInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(dateInput).toHaveClass(/ix-invalid--required/); + } +); + +regressionTest( + 'Not required input: Invalid input > Removing value with keyboard > Valid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Not required input: Invalid input > Remove touched state > Valid again', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.fill('invalid-date'); + await input.blur(); + + await dateInput.evaluate((el: any) => el.clear()); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Not required input: Invalid input > Programmatically setting to empty > Valid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await dateInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Not required input: Valid input > Removing value with keyboard > Valid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Not required input: Valid input > Remove touched state > Valid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await dateInput.evaluate((el: any) => el.clear()); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Not required input: Valid input > Programmatically setting to empty > Valid', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await dateInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'novalidate form suppresses validation for required field', + async ({ page, mount }) => { + await mount(` +
+ +
+ `); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(input).not.toHaveClass(/is-invalid/); + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + } +); + +regressionTest( + 'novalidate form suppresses validation for non-required field', + async ({ page, mount }) => { + await mount(` +
+ +
+ `); + + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await expect(input).not.toHaveClass(/is-invalid/); + } +); + +regressionTest( + 'Validation works after switching between required and non-required', + async ({ page, mount }) => { + await mount(``); + + const dateInput = page.locator('ix-date-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(input).not.toHaveClass(/is-invalid/); + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + + await dateInput.evaluate((el: any) => { + el.required = true; + }); + + await expect(dateInput).toHaveClass(/ix-invalid--required/); + + await dateInput.evaluate((el: any) => { + el.required = false; + }); + + await expect(dateInput).not.toHaveClass(/ix-invalid--required/); + } +); diff --git a/packages/core/src/components/date-picker/date-picker.tsx b/packages/core/src/components/date-picker/date-picker.tsx index edb1fccf189..8da9240a45b 100644 --- a/packages/core/src/components/date-picker/date-picker.tsx +++ b/packages/core/src/components/date-picker/date-picker.tsx @@ -315,12 +315,8 @@ export class DatePicker implements IxDatePickerComponent { componentWillLoad() { this.setTranslations(); - this.currFromDate = this.from - ? DateTime.fromFormat(this.from, this.format) - : undefined; - this.currToDate = this.to - ? DateTime.fromFormat(this.to, this.format) - : undefined; + this.currFromDate = this.from ? this.parseDateString(this.from) : undefined; + this.currToDate = this.to ? this.parseDateString(this.to) : undefined; const year = this.currFromDate?.year ?? this.getDateTimeNow().year; this.startYear = year - 5; diff --git a/packages/core/src/components/field-label/field-label.tsx b/packages/core/src/components/field-label/field-label.tsx index 9a4b657ea40..9bc89c5bb81 100644 --- a/packages/core/src/components/field-label/field-label.tsx +++ b/packages/core/src/components/field-label/field-label.tsx @@ -45,6 +45,13 @@ export class FormFieldLabel implements IxComponent { /** @internal */ @Prop({ mutable: true }) isInvalid: boolean = false; + private explicitIsInvalid: boolean = false; + + @Watch('isInvalid') + isInvalidChanged(newValue: boolean) { + this.explicitIsInvalid = newValue; + } + private readonly htmlForObserver = new MutationObserver(() => this.checkForInternalState() ); @@ -106,7 +113,7 @@ export class FormFieldLabel implements IxComponent { this.controlRefClassObserver.destroy(); } - if (this.controlRef) { + if (this.controlRef && !this.htmlFor) { const input = await this.controlRef.waitForCurrent(); this.controlRefClassObserver = createClassMutationObserver(input, () => @@ -128,6 +135,10 @@ export class FormFieldLabel implements IxComponent { } private checkForInvalidState(elementToCheck: HTMLElement) { + if (this.explicitIsInvalid) { + return; + } + this.isInvalid = elementToCheck.classList.contains('is-invalid') || elementToCheck.classList.contains('ix-invalid'); @@ -145,6 +156,8 @@ export class FormFieldLabel implements IxComponent { this.registerHtmlForClassObserver(forElement); this.checkForInvalidState(forElement); + + return; } } diff --git a/packages/core/src/components/input/input.tsx b/packages/core/src/components/input/input.tsx index baa86b27e65..ecf00c9c89d 100644 --- a/packages/core/src/components/input/input.tsx +++ b/packages/core/src/components/input/input.tsx @@ -37,6 +37,7 @@ import { getAriaAttributesForInput, mapValidationResult, onInputBlur, + resetInputValidation, } from './input.util'; let inputIds = 0; @@ -274,6 +275,15 @@ export class Input implements IxInputFieldComponent { return Promise.resolve(this.touched); } + /** + * Resets the input field validation state by removing the touched state + * and clearing validation states while preserving the current value. + */ + @Method() + async reset(): Promise { + return resetInputValidation(this); + } + render() { const inputAria: A11yAttributes = getAriaAttributesForInput(this); return ( diff --git a/packages/core/src/components/input/input.util.ts b/packages/core/src/components/input/input.util.ts index 3107d2697aa..3a2ee9bc2ed 100644 --- a/packages/core/src/components/input/input.util.ts +++ b/packages/core/src/components/input/input.util.ts @@ -219,3 +219,25 @@ export function handleSubmitOnEnterKeydown( form.requestSubmit(); } } + +export async function resetInputValidation( + comp: IxInputFieldComponent +): Promise { + (comp as any).touched = false; + + const input = await comp.getNativeInputElement(); + input.removeAttribute('data-ix-touched'); + + comp.isInvalid = false; + comp.isValid = false; + comp.isInfo = false; + comp.isWarning = false; + (comp as any).isInvalidByRequired = false; + + comp.hostElement.dispatchEvent( + new CustomEvent('valueChange', { + detail: comp.value, + bubbles: true, + }) + ); +} diff --git a/packages/core/src/components/input/number-input.tsx b/packages/core/src/components/input/number-input.tsx index e0eda27824b..3e32c05f3ea 100644 --- a/packages/core/src/components/input/number-input.tsx +++ b/packages/core/src/components/input/number-input.tsx @@ -36,6 +36,7 @@ import { DisposableChangesAndVisibilityObservers, mapValidationResult, onInputBlur, + resetInputValidation, } from './input.util'; let numberInputIds = 0; @@ -352,9 +353,7 @@ export class NumberInput implements IxInputFieldComponent { typeof this.step === 'string' ? Number.parseFloat(this.step) : (this.step ?? 1); - let newValue: number; - if (operation === 'up') { newValue = currentValue + stepValue; } else { @@ -422,6 +421,15 @@ export class NumberInput implements IxInputFieldComponent { return Promise.resolve(this.touched); } + /** + * Resets the input field validation state by removing the touched state + * and clearing validation states while preserving the current value. + */ + @Method() + async reset(): Promise { + return resetInputValidation(this); + } + render() { const showStepperButtons = this.showStepperButtons && (this.disabled || this.readonly) === false; diff --git a/packages/core/src/components/input/tests/validation.ct.ts b/packages/core/src/components/input/tests/validation.ct.ts index 8247abb7b1e..b32a190709b 100644 --- a/packages/core/src/components/input/tests/validation.ct.ts +++ b/packages/core/src/components/input/tests/validation.ct.ts @@ -262,4 +262,67 @@ test.describe('prevent initial require validation', async () => { await expect(inputComponent).toHaveClass(/ix-invalid/); }); }); + + test.describe('reset method', () => { + ['ix-input', 'ix-number-input', 'ix-textarea'].forEach((selector) => { + test(`${selector} - should reset validation state when required field becomes invalid then reset`, async ({ + mount, + page, + }) => { + await mount(`<${selector} required>`); + + const inputComponent = page.locator(selector); + const input = inputComponent.locator( + selector !== 'ix-textarea' ? 'input' : 'textarea' + ); + + await expect(inputComponent).not.toHaveClass(/ix-invalid/); + + await input.click(); + await input.fill(''); + await input.blur(); + + await expect(inputComponent).toHaveClass(/ix-invalid--required/); + + await inputComponent.evaluate((element: any) => element.reset()); + + await expect(inputComponent).not.toHaveClass(/ix-invalid--required/); + await expect(inputComponent).not.toHaveClass(/ix-invalid/); + }); + + test(`${selector} - should reset validation state with value then make invalid then reset`, async ({ + mount, + page, + }) => { + await mount(`<${selector} required>`); + + const inputComponent = page.locator(selector); + const input = inputComponent.locator( + selector !== 'ix-textarea' ? 'input' : 'textarea' + ); + + await input.click(); + // Use appropriate test values for different input types + if (selector === 'ix-number-input') { + await input.fill('123'); + } else { + await input.fill('test value'); + } + await input.blur(); + + await expect(inputComponent).not.toHaveClass(/ix-invalid/); + + await input.click(); + await input.fill(''); + await input.blur(); + + await expect(inputComponent).toHaveClass(/ix-invalid--required/); + + await inputComponent.evaluate((element: any) => element.reset()); + + await expect(inputComponent).not.toHaveClass(/ix-invalid--required/); + await expect(inputComponent).not.toHaveClass(/ix-invalid/); + }); + }); + }); }); diff --git a/packages/core/src/components/input/textarea.tsx b/packages/core/src/components/input/textarea.tsx index 0a120966fc5..eb66cf714ed 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, + resetInputValidation, +} from './input.util'; import type { TextareaResizeBehavior } from './textarea.types'; /** @@ -259,6 +263,15 @@ export class Textarea implements IxInputFieldComponent { return this.textAreaRef.waitForCurrent(); } + /** + * Returns the validity state of the textarea field. + */ + @Method() + async getValidityState(): Promise { + const textarea = await this.textAreaRef.waitForCurrent(); + return Promise.resolve(textarea.validity); + } + /** * Focuses the input field */ @@ -276,6 +289,15 @@ export class Textarea implements IxInputFieldComponent { return Promise.resolve(this.touched); } + /** + * Resets the input field validation state by removing the touched state + * and clearing validation states while preserving the current value. + */ + @Method() + async reset(): Promise { + return resetInputValidation(this); + } + render() { return ( { @State() isInfo = false; @State() isWarning = false; + private formSubmissionAttempted = false; + private formSubmitHandler?: (event: Event) => void; private readonly dropdownWrapperRef = makeRef(); private readonly dropdownAnchorRef = makeRef(); private readonly inputRef = makeRef(); @@ -245,6 +247,24 @@ export class Select implements IxInputFieldComponent { this.arrowFocusController.items = this.visibleNonShadowItems; }); + private get parentForm(): HTMLFormElement | null { + return this.hostElement.closest('form'); + } + + private isFormNoValidate(): boolean { + const form = this.parentForm; + if (!form) { + return false; + } + const noValidateAttributes = [ + 'novalidate', + 'data-novalidate', + 'ngnovalidate', + ]; + + return noValidateAttributes.some((attr) => form.hasAttribute(attr)); + } + private readonly focusControllerCallbackBind = this.focusDropdownItem.bind(this); @@ -304,6 +324,7 @@ export class Select implements IxInputFieldComponent { watchValue(value: string | string[]) { this.value = value; this.updateSelection(); + this.syncValidationClasses(); } @Watch('dropdownShow') @@ -475,6 +496,36 @@ export class Select implements IxInputFieldComponent { return false; } + connectedCallback(): void { + const form = this.parentForm; + if (form) { + this.formSubmitHandler = (event: Event) => { + this.formSubmissionAttempted = true; + this.touched = true; + this.syncValidationClasses(); + if (this.required && !this.hasValue()) { + event.preventDefault(); + event.stopPropagation(); + this.hostElement.focus(); + return false; + } + }; + form.addEventListener('submit', this.formSubmitHandler, { + capture: true, + }); + } + + this.hostElement.addEventListener('invalid', (event: Event) => { + event.preventDefault(); + }); + + this.hostElement.addEventListener('focus', () => { + if (this.inputElement) { + this.inputElement.focus(); + } + }); + } + componentDidLoad() { this.inputElement?.addEventListener('input', () => { this.dropdownShow = true; @@ -485,6 +536,9 @@ export class Select implements IxInputFieldComponent { componentWillLoad() { this.updateSelection(); this.updateFormInternalValue(this.value); + if (this.required) { + this.syncValidationClasses(); + } } componentDidRender(): void { @@ -516,6 +570,45 @@ export class Select implements IxInputFieldComponent { disconnectedCallback() { this.cleanupResources(); + const form = this.parentForm; + if (form && this.formSubmitHandler) { + form.removeEventListener('submit', this.formSubmitHandler, { + capture: true, + }); + } + } + + async syncValidationClasses() { + if (this.isFormNoValidate()) { + this.hostElement.classList.remove('ix-invalid--required'); + this.formInternals.setValidity({}); + return; + } + + if (this.required) { + const isRequiredInvalid = + !this.hasValue() && (this.touched || this.formSubmissionAttempted); + + this.hostElement.classList.toggle( + 'ix-invalid--required', + isRequiredInvalid + ); + this.isInvalid = isRequiredInvalid; + + if (isRequiredInvalid) { + const message = + this.invalidText && this.invalidText.trim().length > 0 + ? this.invalidText + : ' '; + + this.formInternals.setValidity({ valueMissing: true }, message); + } else { + this.formInternals.setValidity({}); + } + } else { + this.hostElement.classList.remove('ix-invalid--required'); + this.formInternals.setValidity({}); + } } private itemExists(item: string | undefined) { @@ -745,6 +838,7 @@ export class Select implements IxInputFieldComponent { private onInputBlur(event: Event) { this.ixBlur.emit(); this.touched = true; + this.syncValidationClasses(); if (this.editable) { return; @@ -880,9 +974,18 @@ export class Select implements IxInputFieldComponent { return ( { + if ( + this.inputElement && + document.activeElement === this.hostElement + ) { + this.inputElement.focus(); + } + }} > { class={{ 'allow-clear': this.allowClear && !!this.selectedLabels?.length, + 'ix-invalid': this.isInvalid, }} placeholder={this.placeholderValue()} value={this.inputValue ?? ''} diff --git a/packages/core/src/components/select/test/select.ct.ts b/packages/core/src/components/select/test/select.ct.ts index 41c247b55a7..ce467492aea 100644 --- a/packages/core/src/components/select/test/select.ct.ts +++ b/packages/core/src/components/select/test/select.ct.ts @@ -1048,3 +1048,187 @@ test('should not show "All" chip of de-selected a item', async ({ await expect(allChip).not.toBeVisible(); }); + +test('required select prevents form submission when empty', async ({ + mount, + page, +}) => { + await mount(` +
+ + Test + Test + + +
+ `); + + const form = page.locator('form'); + const select = page.locator('ix-select'); + const submitButton = page.locator('button[type="submit"]'); + + await preventFormSubmission(form); + + await expect(select).toHaveClass(/hydrated/); + + await submitButton.click(); + + await expect(select).toHaveClass(/ix-invalid--required/); + + const tabIndex = await select.getAttribute('tabindex'); + expect(tabIndex).toBe('0'); +}); + +test('multiple required selects prevent form submission when any is empty', async ({ + mount, + page, +}) => { + await mount(` +
+ + Test + Test + + + Test + Test + + +
+ `); + + const form = page.locator('form'); + const departmentSelect = page.locator('ix-select[name="department"]'); + const locationSelect = page.locator('ix-select[name="location"]'); + const submitButton = page.locator('button[type="submit"]'); + + await preventFormSubmission(form); + + await departmentSelect.locator('[data-select-dropdown]').click(); + await departmentSelect.locator('ix-select-item').first().click(); + + await submitButton.click(); + await expect(locationSelect).toHaveClass(/ix-invalid--required/); + + await locationSelect.locator('[data-select-dropdown]').click(); + await locationSelect.locator('ix-select-item').first().click(); + + const isFormValidNow = await form.evaluate((form: HTMLFormElement) => + form.checkValidity() + ); + expect(isFormValidNow).toBe(true); +}); + +test('custom invalidText is used for validation feedback', async ({ + mount, + page, +}) => { + await mount(` +
+ + Test + Test + + +
+ `); + + const form = page.locator('form'); + const select = page.locator('ix-select'); + const submitButton = page.locator('button[type="submit"]'); + + await preventFormSubmission(form); + + await submitButton.click(); + const fieldWrapper = select.locator('ix-field-wrapper'); + await expect(fieldWrapper).toContainText('Please select your department'); +}); + +test('novalidate form attribute disables validation', async ({ + mount, + page, +}) => { + await mount(` +
+ + Test + Test + + +
+ `); + + const form = page.locator('form'); + const select = page.locator('ix-select'); + const submitButton = page.locator('button[type="submit"]'); + + await preventFormSubmission(form); + + const isFormValid = await form.evaluate((form: HTMLFormElement) => + form.checkValidity() + ); + expect(isFormValid).toBe(true); + + await submitButton.click(); + await expect(select).not.toHaveClass(/ix-invalid--required/); +}); + +test('multiple mode validation works correctly', async ({ mount, page }) => { + await mount(` +
+ + Test + Test + Test + + +
+ `); + + const form = page.locator('form'); + const select = page.locator('ix-select'); + const submitButton = page.locator('button[type="submit"]'); + + await preventFormSubmission(form); + + await submitButton.click(); + await expect(select).toHaveClass(/ix-invalid--required/); + + await page.locator('[data-select-dropdown]').click(); + await page.locator('ix-select-item').first().click(); + await page.locator('ix-select-item').nth(1).click(); + + const isFormValid = await form.evaluate((form: HTMLFormElement) => + form.checkValidity() + ); + expect(isFormValid).toBe(true); + await expect(select).not.toHaveClass(/ix-invalid--required/); +}); + +test('programmatic value setting updates validation state', async ({ + mount, + page, +}) => { + await mount(` +
+ + Test + Test + + +
+ `); + + const select = page.locator('ix-select'); + const submitButton = page.locator('button[type="submit"]'); + + await submitButton.click(); + await expect(select).toHaveClass(/ix-invalid--required/); + + await select.evaluate((el: HTMLIxSelectElement) => { + el.value = '1'; + }); + + await page.waitForTimeout(100); + await expect(select).not.toHaveClass(/ix-invalid--required/); +}); diff --git a/packages/core/src/components/time-input/test/time-input.ct.ts b/packages/core/src/components/time-input/test/time-input.ct.ts index 9a4444315b4..2b38bdbf589 100644 --- a/packages/core/src/components/time-input/test/time-input.ct.ts +++ b/packages/core/src/components/time-input/test/time-input.ct.ts @@ -184,4 +184,302 @@ regressionTest.describe('time input tests', () => { ).toHaveText('Custom time error'); } ); + + regressionTest( + 'Required input: Invalid input > Removing value with keyboard > Stays invalid', + async ({ page, mount }) => { + await mount( + `` + ); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(timeInput).toHaveClass(/ix-invalid--required/); + } + ); + + regressionTest( + 'Required input: Invalid input > Remove touched state > Valid again', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.fill('invalid-time'); + await input.blur(); + + await timeInput.evaluate((el: any) => el.clear()); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Required input: Invalid input > Programmatically setting to empty > Stays invalid', + async ({ page, mount }) => { + await mount( + `` + ); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await timeInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(timeInput).toHaveClass(/ix-invalid--required/); + } + ); + + regressionTest( + 'Required input: Valid input > Removing value with keyboard > It is invalid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(timeInput).toHaveClass(/ix-invalid--required/); + } + ); + + regressionTest( + 'Required input: Valid input > Remove touched state > Valid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await timeInput.evaluate((el: any) => el.clear()); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Required input: Valid input > Programmatically setting to empty > It is invalid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await timeInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(timeInput).toHaveClass(/ix-invalid--required/); + } + ); + + regressionTest( + 'Not required input: Invalid input > Removing value with keyboard > Valid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Not required input: Invalid input > Remove touched state > Valid again', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.fill('invalid-time'); + await input.blur(); + + await timeInput.evaluate((el: any) => el.clear()); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Not required input: Invalid input > Programmatically setting to empty > Valid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await timeInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Not required input: Valid input > Removing value with keyboard > Valid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Not required input: Valid input > Remove touched state > Valid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await timeInput.evaluate((el: any) => el.clear()); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Not required input: Valid input > Programmatically setting to empty > Valid', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await timeInput.evaluate((el: any) => { + el.value = ''; + }); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'novalidate form suppresses validation for required field', + async ({ page, mount }) => { + await mount(` +
+ +
+ `); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(input).not.toHaveClass(/is-invalid/); + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + } + ); + + regressionTest( + 'novalidate form suppresses validation for non-required field', + async ({ page, mount }) => { + await mount(` +
+ +
+ `); + + const input = page.locator('input'); + + await input.focus(); + await input.blur(); + + await expect(input).not.toHaveClass(/is-invalid/); + } + ); + + regressionTest( + 'Validation works after switching between required and non-required', + async ({ page, mount }) => { + await mount(``); + + const timeInput = page.locator('ix-time-input'); + const input = page.locator('input'); + + await input.focus(); + await input.selectText(); + await input.press('Delete'); + await input.blur(); + + await expect(input).not.toHaveClass(/is-invalid/); + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + + await timeInput.evaluate((el: any) => { + el.required = true; + }); + + await expect(timeInput).toHaveClass(/ix-invalid--required/); + + await timeInput.evaluate((el: any) => { + el.required = false; + }); + + await expect(timeInput).not.toHaveClass(/ix-invalid--required/); + } + ); }); diff --git a/packages/core/src/components/time-input/time-input.tsx b/packages/core/src/components/time-input/time-input.tsx index 84d2c598e16..b6dc7267544 100644 --- a/packages/core/src/components/time-input/time-input.tsx +++ b/packages/core/src/components/time-input/time-input.tsx @@ -14,7 +14,6 @@ import { Element, Event, EventEmitter, - Host, Method, Prop, State, @@ -27,7 +26,6 @@ import { DisposableChangesAndVisibilityObservers, addDisposableChangesAndVisibilityObservers, adjustPaddingForStartAndEnd, - handleSubmitOnEnterKeydown, } from '../input/input.util'; import { ClassMutationObserver, @@ -36,6 +34,21 @@ import { ValidationResults, createClassMutationObserver, getValidationText, + shouldSuppressInternalValidation, + syncState, + watchValue, + onInput, + onClick, + onFocus, + onBlur, + onKeyDown, + getNativeInput, + renderFieldWrapper, + createEventConfig, + createInputMethods, + createDropdownMethods, + createKeyDownHandler, + handleValidationLifecycle, } from '../utils/input'; import { makeRef } from '../utils/make-ref'; import { IxTimePickerCustomEvent } from '../../components'; @@ -79,7 +92,13 @@ export class TimeInput implements IxInputFieldComponent { @Prop({ reflect: true, mutable: true }) value: string = ''; @Watch('value') watchValuePropHandler(newValue: string) { - this.onInput(newValue); + watchValue({ + newValue, + required: this.required, + onInput: (value: string) => void this.onInput(value), + setTouched: (touched: boolean) => (this.touched = touched), + isClearing: this._isClearing, + }); } /** @@ -93,6 +112,11 @@ export class TimeInput implements IxInputFieldComponent { */ @Prop() required?: boolean; + @Watch('required') + onRequiredChange() { + this.syncValidationClasses(); + } + /** * Helper text below the input field */ @@ -238,6 +262,7 @@ export class TimeInput implements IxInputFieldComponent { @State() show = false; @State() time: string | null = null; @State() isInputInvalid = false; + @State() suppressValidation = false; @State() isInvalid = false; @State() isValid = false; @State() isInfo = false; @@ -254,15 +279,15 @@ export class TimeInput implements IxInputFieldComponent { private classObserver?: ClassMutationObserver; private invalidReason?: string; private touched = false; + private _isClearing = false; private disposableChangesAndVisibilityObservers?: DisposableChangesAndVisibilityObservers; private handleInputKeyDown(event: KeyboardEvent) { - handleSubmitOnEnterKeydown( - event, + return createKeyDownHandler( this.suppressSubmitOnEnter, - this.formInternals.form - ); + this.formInternals + )(event); } updateFormInternalValue(value: string): void { @@ -301,6 +326,10 @@ export class TimeInput implements IxInputFieldComponent { this.updateFormInternalValue(this.value); } + componentDidLoad(): void { + this.syncValidationClasses(); + } + private updatePaddings() { adjustPaddingForStartAndEnd( this.slotStartRef.current, @@ -333,10 +362,13 @@ export class TimeInput implements IxInputFieldComponent { async onInput(value: string) { this.value = value; + + this.suppressValidation = await shouldSuppressInternalValidation(this); + if (!value) { this.isInputInvalid = false; - this.updateFormInternalValue(value); - this.valueChange.emit(value); + this.invalidReason = undefined; + this.emitChangesAndSync(value); return; } @@ -344,16 +376,31 @@ export class TimeInput implements IxInputFieldComponent { return; } - const time = DateTime.fromFormat(value, this.format); - if (time.isValid) { + if (this.suppressValidation) { this.isInputInvalid = false; - } else { - this.isInputInvalid = true; - this.invalidReason = time.invalidReason; + this.invalidReason = undefined; + this.emitChangesAndSync(value); + return; } - this.updateFormInternalValue(value); - this.valueChange.emit(value); + const time = DateTime.fromFormat(value, this.format); + this.isInputInvalid = !time.isValid; + this.invalidReason = time.isValid ? undefined : time.invalidReason; + + this.emitChangesAndSync(value); + } + + private emitChangesAndSync(value: string): void { + syncState({ + updateFormInternalValue: (val) => this.updateFormInternalValue(val), + valueChange: this.valueChange, + value: value, + hostElement: this.hostElement, + suppressValidation: this.suppressValidation, + required: this.required, + touched: this.touched, + isInputInvalid: this.isInputInvalid, + }); } onTimeIconClick(event: Event) { @@ -368,7 +415,6 @@ export class TimeInput implements IxInputFieldComponent { async openDropdown() { // keep picker in sync with input this.time = this.value; - return openDropdownUtil(this.dropdownElementRef); } @@ -377,10 +423,27 @@ export class TimeInput implements IxInputFieldComponent { } private checkClassList() { - this.isInvalid = this.hostElement.classList.contains('ix-invalid'); + this.isInvalid = this.dropdownMethods.checkClassList(); + } + + private getEventConfig() { + return createEventConfig({ + show: this.show, + setTouched: (touched: boolean) => (this.touched = touched), + onInput: (value: string) => void this.onInput(value), + openDropdown: () => this.openDropdown(), + ixFocus: this.ixFocus, + ixBlur: this.ixBlur, + syncValidationClasses: () => this.syncValidationClasses(), + handleInputKeyDown: (event: KeyboardEvent) => + this.handleInputKeyDown(event), + alwaysSetTouchedOnBlur: true, + }); } private renderInput() { + const eventConfig = this.getEventConfig(); + return (
{ value={this.value} placeholder={this.placeholder} name={this.name} - onInput={(event) => { - const target = event.target as HTMLInputElement; - this.onInput(target.value); - }} - onClick={(event) => { - if (this.show) { - event.stopPropagation(); - event.preventDefault(); - } - }} - onFocus={async () => { - this.openDropdown(); - this.ixFocus.emit(); - }} - onBlur={() => { - this.ixBlur.emit(); - this.touched = true; - }} - onKeyDown={(event) => this.handleInputKeyDown(event)} + onInput={onInput(eventConfig)} + onClick={onClick(eventConfig)} + onFocus={onFocus(eventConfig)} + onBlur={onBlur(eventConfig)} + onKeyDown={onKeyDown(eventConfig)} > { } @HookValidationLifecycle() - hookValidationLifecycle({ - isInfo, - isInvalid, - isInvalidByRequired, - isValid, - isWarning, - }: ValidationResults) { - this.isInvalid = isInvalid || isInvalidByRequired || this.isInputInvalid; - this.isInfo = isInfo; - this.isValid = isValid; - this.isWarning = isWarning; + hookValidationLifecycle(results: ValidationResults) { + handleValidationLifecycle( + this.suppressValidation, + this.isInputInvalid, + results, + { + setIsInvalid: (value) => (this.isInvalid = value), + setIsInfo: (value) => (this.isInfo = value), + setIsValid: (value) => (this.isValid = value), + setIsWarning: (value) => (this.isWarning = value), + } + ); } @Watch('isInputInvalid') @@ -477,7 +527,38 @@ export class TimeInput implements IxInputFieldComponent { */ @Method() getNativeInputElement(): Promise { - return this.inputElementRef.waitForCurrent(); + return getNativeInput(this.inputElementRef); + } + + private get commonMethods() { + return createInputMethods({ + inputElementRef: this.inputElementRef, + touched: this.touched, + hostElement: this.hostElement, + suppressValidation: this.suppressValidation, + required: this.required, + value: this.value, + isInputInvalid: this.isInputInvalid, + }); + } + + private get dropdownMethods() { + return createDropdownMethods({ + dropdownElementRef: this.dropdownElementRef, + hostElement: this.hostElement, + show: this.show, + touched: this.touched, + openDropdown: async () => { + this.time = this.value; + return openDropdownUtil(this.dropdownElementRef); + }, + ixFocus: this.ixFocus, + ixBlur: this.ixBlur, + syncValidationClasses: () => this.syncValidationClasses(), + onInput: (value: string) => void this.onInput(value), + handleInputKeyDown: (event: KeyboardEvent) => + this.handleInputKeyDown(event), + }); } /** @@ -485,7 +566,7 @@ export class TimeInput implements IxInputFieldComponent { */ @Method() async focusInput(): Promise { - return (await this.getNativeInputElement()).focus(); + return this.commonMethods.focusInput(); } /** @@ -494,7 +575,33 @@ export class TimeInput implements IxInputFieldComponent { */ @Method() isTouched(): Promise { - return Promise.resolve(this.touched); + return this.commonMethods.isTouched(); + } + + /** + * @internal + */ + syncValidationClasses(): void { + return this.commonMethods.syncValidationClasses(); + } + + /** + * Clears the input field value and resets validation state. + * Sets the value to empty and removes touched state to suppress validation. + */ + @Method() + async clear(): Promise { + this._isClearing = true; + this.touched = false; + this.isInputInvalid = false; + this.isInvalid = false; + this.invalidReason = undefined; + this.value = ''; + this.time = null; + this.updateFormInternalValue(''); + this.valueChange.emit(''); + this.syncValidationClasses(); + this._isClearing = false; } render() { @@ -504,65 +611,54 @@ export class TimeInput implements IxInputFieldComponent { this.i18nErrorTimeUnparsable ); - return ( - - - {this.renderInput()} - - { - this.show = event.detail; + return renderFieldWrapper({ + host: this.hostElement, + disabled: this.disabled, + readonly: this.readonly, + label: this.label, + helper: this.helperText, + invalid: this.isInvalid, + invalidText, + info: this.infoText, + isInfo: this.isInfo, + warning: this.isWarning, + warningText: this.warningText, + valid: this.isValid, + validText: this.validText, + tooltip: this.showTextAsTooltip, + required: this.required, + inputRef: this.inputElementRef, + input: this.renderInput(), + dropdown: ( + ) => { + this.onInput(event.detail); + this.show = false; }} - > - ) => { - this.onInput(event.detail); - - this.show = false; - }} - > - - - ); + > + ), + testId: 'time-dropdown', + trigger: () => this.inputElementRef.waitForCurrent(), + dropdownRef: this.dropdownElementRef, + show: this.show, + onShow: (event) => { + this.show = event.detail; + }, + }); } } diff --git a/packages/core/src/components/utils/input/index.ts b/packages/core/src/components/utils/input/index.ts index bced371b1a4..933d2a41ad4 100644 --- a/packages/core/src/components/utils/input/index.ts +++ b/packages/core/src/components/utils/input/index.ts @@ -10,6 +10,7 @@ import { EventEmitter } from '@stencil/core'; import { IxComponent } from '../internal'; export * from './validation'; +export * from './picker-input-common'; export interface FieldWrapperInterface { /** diff --git a/packages/core/src/components/utils/input/picker-input-common.tsx b/packages/core/src/components/utils/input/picker-input-common.tsx new file mode 100644 index 00000000000..1c7a6ba1970 --- /dev/null +++ b/packages/core/src/components/utils/input/picker-input-common.tsx @@ -0,0 +1,356 @@ +/* + * SPDX-FileCopyrightText: 2024 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import { EventEmitter, h, Host } from '@stencil/core'; +import { syncValidationClasses, ValidationResults } from './validation'; +import { handleSubmitOnEnterKeydown } from '../../input/input.util'; +import { closeDropdown as closeDropdownUtil } from './picker-input.util'; + +export interface SyncOptions { + updateFormInternalValue: (value: T) => void; + valueChange: EventEmitter; + value: T; + + hostElement: HTMLElement; + suppressValidation: boolean; + required?: boolean; + touched: boolean; + isInputInvalid: boolean; +} + +export function syncState( + options: SyncOptions +): void { + options.updateFormInternalValue(options.value); + options.valueChange.emit(options.value); + + syncValidationClasses({ + hostElement: options.hostElement, + suppressValidation: options.suppressValidation, + required: options.required, + value: options.value as string | undefined, + touched: options.touched, + isInputInvalid: options.isInputInvalid, + }); +} + +export interface WatchConfig { + newValue: T; + required?: boolean; + onInput: (value: T) => void; + setTouched?: (touched: boolean) => void; + isClearing?: boolean; +} + +export function watchValue(config: WatchConfig): void { + if ( + !config.isClearing && + !config.newValue && + config.required && + config.setTouched + ) { + config.setTouched(true); + } + + config.onInput(config.newValue); +} + +export interface EventConfig { + show: boolean; + setTouched: (touched: boolean) => void; + onInput: (value: string) => void; + openDropdown: () => void; + ixFocus: EventEmitter; + ixBlur: EventEmitter; + syncValidationClasses: () => void; + handleInputKeyDown?: (event: KeyboardEvent) => void; + alwaysSetTouchedOnBlur?: boolean; +} + +export function onInput(config: EventConfig) { + return (event: Event) => { + const target = event.target as HTMLInputElement; + config.setTouched(true); + config.onInput(target.value); + }; +} + +export function onClick(config: EventConfig) { + return (event: Event) => { + if (config.show) { + event.stopPropagation(); + event.preventDefault(); + } + }; +} + +export function onFocus(config: EventConfig) { + return async () => { + config.openDropdown(); + config.ixFocus.emit(); + }; +} + +export function onBlur(config: EventConfig) { + return () => { + config.ixBlur.emit(); + if (config.alwaysSetTouchedOnBlur) { + config.setTouched(true); + } + config.syncValidationClasses(); + }; +} + +export function onKeyDown(config: EventConfig) { + return (event: KeyboardEvent) => { + if (config.handleInputKeyDown) { + config.handleInputKeyDown(event); + } + }; +} + +export interface ValidationOptions { + hostElement: HTMLElement; + suppressValidation: boolean; + required?: boolean; + value: string | undefined; + touched: boolean; + isInputInvalid: boolean; +} + +export function syncValidation(options: ValidationOptions): void { + syncValidationClasses({ + hostElement: options.hostElement, + suppressValidation: options.suppressValidation, + required: options.required, + value: options.value, + touched: options.touched, + isInputInvalid: options.isInputInvalid, + }); +} + +export function getNativeInput(inputElementRef: { + waitForCurrent: () => Promise; +}): Promise { + return inputElementRef.waitForCurrent(); +} + +export async function focusInput(inputElementRef: { + waitForCurrent: () => Promise; +}): Promise { + return (await getNativeInput(inputElementRef)).focus(); +} + +export function getTouchedState(touched: boolean): Promise { + return Promise.resolve(touched); +} + +export interface PickerFieldWrapperProps { + host: HTMLElement; + disabled?: boolean; + readonly?: boolean; + label?: string; + helper?: string; + invalid?: boolean; + invalidText?: string; + info?: string; + isInfo?: boolean; + warning?: boolean; + warningText?: string; + valid?: boolean; + validText?: string; + tooltip?: boolean; + required?: boolean; + inputRef: any; + input: any; + dropdown: any; + testId: string; + trigger: () => Promise; + dropdownRef?: any; + show?: boolean; + onShow?: (event: any) => void; +} + +export function renderFieldWrapper(props: PickerFieldWrapperProps) { + return ( + + + {props.input} + + + {props.dropdown} + + + ); +} + +export interface PickerEventConfigOptions { + show: boolean; + setTouched: (touched: boolean) => void; + onInput: (value: string) => void; + openDropdown: () => Promise; + ixFocus: EventEmitter; + ixBlur: EventEmitter; + syncValidationClasses: () => void; + handleInputKeyDown?: (event: KeyboardEvent) => void; + alwaysSetTouchedOnBlur?: boolean; +} + +export function createEventConfig( + options: PickerEventConfigOptions +): EventConfig { + return { + show: options.show, + setTouched: options.setTouched, + onInput: options.onInput, + openDropdown: () => { + options.openDropdown(); + }, + ixFocus: options.ixFocus, + ixBlur: options.ixBlur, + syncValidationClasses: options.syncValidationClasses, + handleInputKeyDown: options.handleInputKeyDown, + alwaysSetTouchedOnBlur: options.alwaysSetTouchedOnBlur, + }; +} + +export interface InputMethodsContext { + inputElementRef: { waitForCurrent: () => Promise }; + touched: boolean; + hostElement: HTMLElement; + suppressValidation: boolean; + required?: boolean; + value: string | undefined; + isInputInvalid: boolean; +} + +export function createInputMethods(context: InputMethodsContext) { + return { + async focusInput(): Promise { + return focusInput(context.inputElementRef); + }, + + isTouched(): Promise { + return getTouchedState(context.touched); + }, + + syncValidationClasses(): void { + syncValidation({ + hostElement: context.hostElement, + suppressValidation: context.suppressValidation, + required: context.required, + value: context.value, + touched: context.touched, + isInputInvalid: context.isInputInvalid, + }); + }, + }; +} + +export interface DropdownMethodsContext { + dropdownElementRef: { current: HTMLIxDropdownElement | null }; + hostElement: HTMLElement; + show: boolean; + touched: boolean; + openDropdown: () => Promise; + ixFocus: EventEmitter; + ixBlur: EventEmitter; + syncValidationClasses: () => void; + onInput: (value: string) => void; + handleInputKeyDown: (event: KeyboardEvent) => void; +} + +export function createDropdownMethods(context: DropdownMethodsContext) { + return { + openDropdown: context.openDropdown, + closeDropdown: () => closeDropdownUtil(context.dropdownElementRef), + getEventConfig: () => + createEventConfig({ + show: context.show, + setTouched: (touched: boolean) => (context.touched = touched), + onInput: context.onInput, + openDropdown: context.openDropdown, + ixFocus: context.ixFocus, + ixBlur: context.ixBlur, + syncValidationClasses: context.syncValidationClasses, + handleInputKeyDown: context.handleInputKeyDown, + }), + checkClassList: () => { + return context.hostElement.classList.contains('ix-invalid'); + }, + }; +} + +export function createKeyDownHandler( + suppressSubmitOnEnter: boolean, + formInternals: ElementInternals +) { + return (event: KeyboardEvent) => { + handleSubmitOnEnterKeydown( + event, + suppressSubmitOnEnter, + formInternals.form + ); + }; +} + +export function handleValidationLifecycle( + suppressValidation: boolean, + isInputInvalid: boolean, + results: ValidationResults, + setters: { + setIsInvalid: (value: boolean) => void; + setIsInfo: (value: boolean) => void; + setIsValid: (value: boolean) => void; + setIsWarning: (value: boolean) => void; + } +) { + const { isInfo, isInvalid, isInvalidByRequired, isValid, isWarning } = + results; + + if (suppressValidation) { + setters.setIsInvalid(false); + setters.setIsInfo(false); + setters.setIsValid(false); + setters.setIsWarning(false); + return; + } + + setters.setIsInvalid(isInvalid || isInvalidByRequired || isInputInvalid); + setters.setIsInfo(isInfo); + setters.setIsValid(isValid); + setters.setIsWarning(isWarning); +} diff --git a/packages/core/src/components/utils/input/validation.ts b/packages/core/src/components/utils/input/validation.ts index 3c7d1dec6c8..8ba7b19809a 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 + ); } }; @@ -201,3 +207,45 @@ export function getValidationText( } return customInvalidText; } + +export interface ValidationClassSyncOptions { + hostElement: HTMLElement; + suppressValidation: boolean; + required?: boolean; + value?: string; + touched: boolean; + isInputInvalid: boolean; +} + +/** + * Synchronizes CSS validation classes with the component's validation state. + * This method ensures proper visual styling based on validation status, particularly for Vue. + * @param options - Configuration object containing validation state + * @internal + */ +export function syncValidationClasses( + options: ValidationClassSyncOptions +): void { + if (options.suppressValidation) { + return; + } + + const isValuePresent = options.required ? !!options.value : true; + const isRequiredInvalid = + options.required && !isValuePresent && options.touched; + const shouldShowPatternMismatch = options.isInputInvalid && options.touched; + + if (options.required) { + options.hostElement.classList.toggle( + 'ix-invalid--required', + isRequiredInvalid + ); + } else { + options.hostElement.classList.remove('ix-invalid--required'); + } + + options.hostElement.classList.toggle( + 'ix-invalid--validity-patternMismatch', + shouldShowPatternMismatch + ); +} diff --git a/packages/html-test-app/src/preview-examples/date-input-form-test.html b/packages/html-test-app/src/preview-examples/date-input-form-test.html new file mode 100644 index 00000000000..543ee875252 --- /dev/null +++ b/packages/html-test-app/src/preview-examples/date-input-form-test.html @@ -0,0 +1,323 @@ + + + + + + + + Date Input Form Test + + +
+

Date Input Validation Tests

+
+ Testing: Required field validation (red border) after blur. Format: YYYY/MM/DD +
+ + + + +
+ + +
+

1️⃣ Required (Standalone)

+ +
+ + + + + +
+
+ + +
+

2️⃣ Optional (Standalone)

+ +
+ + + + + +
+
+ + +
+

3️⃣ Required (In Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

4️⃣ Optional (In Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

5️⃣ Required (NoValidate Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

6️⃣ Optional (NoValidate Form)

+
+ +
+ + + + + + +
+
+
+ +
+ + +
+

🪵 Event Log

+
+
No events yet... Interact with fields above
+
+
+
+ + + + + diff --git a/packages/html-test-app/src/preview-examples/time-input-form-test.html b/packages/html-test-app/src/preview-examples/time-input-form-test.html new file mode 100644 index 00000000000..b98369fb2ad --- /dev/null +++ b/packages/html-test-app/src/preview-examples/time-input-form-test.html @@ -0,0 +1,323 @@ + + + + + + + + Time Input Form Test + + +
+

Time Input Validation Tests

+
+ Testing: Required field validation (red border) after blur. Format: HH:MM:SS +
+ + + + +
+ + +
+

1️⃣ Required (Standalone)

+ +
+ + + + + +
+
+ + +
+

2️⃣ Optional (Standalone)

+ +
+ + + + + +
+
+ + +
+

3️⃣ Required (In Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

4️⃣ Optional (In Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

5️⃣ Required (NoValidate Form)

+
+ +
+ + + + + + +
+
+
+ + +
+

6️⃣ Optional (NoValidate Form)

+
+ +
+ + + + + + +
+
+
+ +
+ + +
+

🪵 Event Log

+
+
No events yet... Interact with fields above
+
+
+
+ + + + + diff --git a/packages/react-test-app/src/main.tsx b/packages/react-test-app/src/main.tsx index 49b5164f92a..a752d4bd9d0 100644 --- a/packages/react-test-app/src/main.tsx +++ b/packages/react-test-app/src/main.tsx @@ -59,6 +59,8 @@ import CustomFieldValidation from './preview-examples/custom-field-validation'; import DateDropdown from './preview-examples/date-dropdown'; import DateDropdownUserRange from './preview-examples/date-dropdown-user-range'; import DateInput from './preview-examples/date-input'; +import DateInputFormTest from './preview-examples/date-input-form-test'; +import TimeInputFormTest from './preview-examples/time-input-form-test'; import DateInputWithSlots from './preview-examples/date-input-with-slots'; import Datepicker from './preview-examples/datepicker'; import DatepickerLocale from './preview-examples/datepicker-locale'; @@ -688,6 +690,8 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( element={} /> } /> + } /> + } /> } diff --git a/packages/react-test-app/src/preview-examples/date-input-form-test.tsx b/packages/react-test-app/src/preview-examples/date-input-form-test.tsx new file mode 100644 index 00000000000..494864829af --- /dev/null +++ b/packages/react-test-app/src/preview-examples/date-input-form-test.tsx @@ -0,0 +1,270 @@ +/* + * SPDX-FileCopyrightText: 2024 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IxDateInput } from '@siemens/ix-react'; +import { useState, useRef } from 'react'; + +export default function DateInputFormTest() { + const [logs, setLogs] = useState>([]); + const logIdCounter = useRef(0); + + // State for different scenarios + const [requiredValue, setRequiredValue] = useState(''); + const [optionalValue, setOptionalValue] = useState(''); + const [formRequiredValue, setFormRequiredValue] = useState(''); + const [formOptionalValue, setFormOptionalValue] = useState(''); + const [noValidateRequiredValue, setNoValidateRequiredValue] = useState(''); + const [noValidateOptionalValue, setNoValidateOptionalValue] = useState(''); + + // Refs for debugging + const requiredRef = useRef(null); + const optionalRef = useRef(null); + const formRequiredRef = useRef(null); + const formOptionalRef = useRef(null); + const noValidateRequiredRef = useRef(null); + const noValidateOptionalRef = useRef(null); + + const addLog = (message: string) => { + logIdCounter.current += 1; + const uniqueId = `${Date.now()}-${logIdCounter.current}`; + setLogs(prev => [...prev, { id: uniqueId, message: `[${new Date().toLocaleTimeString()}] ${message}` }]); + }; + + const debugValidationState = async (inputRef: React.RefObject, label: string) => { + if (!inputRef.current) { + addLog(`${label} - Element not available`); + return; + } + + const element = inputRef.current; + const classList = Array.from(element.classList); + const hasRequiredClass = classList.includes('ix-invalid--required'); + const required = element.required; + const value = element.value; + + let touched = 'unknown'; + try { + const isTouchedMethod = (element as any).isTouched; + if (typeof isTouchedMethod === 'function') { + touched = String(await isTouchedMethod.call(element)); + } + } catch { + touched = 'error'; + } + + addLog(`${label} - Req:${required}, Val:"${value}", Touch:${touched}, RedClass:${hasRequiredClass}`); + }; + + const triggerNativeBlur = async (elementRef: React.RefObject) => { + if (!elementRef.current) return; + try { + const nativeInput = await elementRef.current.getNativeInputElement(); + if (nativeInput) { + nativeInput.dispatchEvent(new FocusEvent('blur', { bubbles: true })); + addLog('✅ Native blur triggered'); + } + } catch (error) { + addLog(`❌ Error: ${error}`); + } + }; + + const clearLogs = () => setLogs([]); + + const buttonStyle = { padding: '0.4rem 0.8rem', fontSize: '0.85rem', border: 'none', borderRadius: '4px', cursor: 'pointer' }; + + return ( +
+

Date Input Validation Tests

+
+ Testing: Required field validation (red border) after blur. Format: YYYY/MM/DD +
+ + + + {/* Grid Layout for Examples */} +
+ + {/* 1. Required Standalone */} +
+

1️⃣ Required (Standalone)

+ { setRequiredValue(e.detail ?? ''); addLog(`1️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('1️⃣ Focus'); setTimeout(() => debugValidationState(requiredRef, '1️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('1️⃣ Blur'); setTimeout(() => debugValidationState(requiredRef, '1️⃣ BLUR'), 100); }} + /> +
+ + + + + +
+
+ + {/* 2. Optional Standalone */} +
+

2️⃣ Optional (Standalone)

+ { setOptionalValue(e.detail ?? ''); addLog(`2️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('2️⃣ Focus'); setTimeout(() => debugValidationState(optionalRef, '2️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('2️⃣ Blur'); setTimeout(() => debugValidationState(optionalRef, '2️⃣ BLUR'), 100); }} + /> +
+ + + + + +
+
+ + {/* 3. Form Required */} +
+

3️⃣ Required (In Form)

+
{ e.preventDefault(); addLog('3️⃣ Form submitted'); }}> + { setFormRequiredValue(e.detail ?? ''); addLog(`3️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('3️⃣ Focus'); setTimeout(() => debugValidationState(formRequiredRef, '3️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('3️⃣ Blur'); setTimeout(() => debugValidationState(formRequiredRef, '3️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ + {/* 4. Form Optional */} +
+

4️⃣ Optional (In Form)

+
{ e.preventDefault(); addLog('4️⃣ Form submitted'); }}> + { setFormOptionalValue(e.detail ?? ''); addLog(`4️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('4️⃣ Focus'); setTimeout(() => debugValidationState(formOptionalRef, '4️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('4️⃣ Blur'); setTimeout(() => debugValidationState(formOptionalRef, '4️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ + {/* 5. NoValidate Required */} +
+

5️⃣ Required (NoValidate Form)

+
{ e.preventDefault(); addLog('5️⃣ NoValidate form submitted'); }}> + { setNoValidateRequiredValue(e.detail ?? ''); addLog(`5️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('5️⃣ Focus'); setTimeout(() => debugValidationState(noValidateRequiredRef, '5️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('5️⃣ Blur'); setTimeout(() => debugValidationState(noValidateRequiredRef, '5️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ + {/* 6. NoValidate Optional */} +
+

6️⃣ Optional (NoValidate Form)

+
{ e.preventDefault(); addLog('6️⃣ NoValidate form submitted'); }}> + { setNoValidateOptionalValue(e.detail ?? ''); addLog(`6️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('6️⃣ Focus'); setTimeout(() => debugValidationState(noValidateOptionalRef, '6️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('6️⃣ Blur'); setTimeout(() => debugValidationState(noValidateOptionalRef, '6️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ +
{/* End of grid */} + + {/* Event Log */} +
+

🪵 Event Log

+
+ {logs.length === 0 ? ( +
No events yet... Interact with fields above
+ ) : ( + logs.map((log) => { + const isError = log.message.includes('❌'); + const isDebug = log.message.includes('BLUR') || log.message.includes('FOCUS') || log.message.includes('MANUAL'); + let logColor = 'inherit'; + if (isError) { + logColor = '#d32f2f'; + } else if (isDebug) { + logColor = '#1976d2'; + } + + return ( +
+ {log.message} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/packages/react-test-app/src/preview-examples/time-input-form-test.tsx b/packages/react-test-app/src/preview-examples/time-input-form-test.tsx new file mode 100644 index 00000000000..3ab521f7c08 --- /dev/null +++ b/packages/react-test-app/src/preview-examples/time-input-form-test.tsx @@ -0,0 +1,270 @@ +/* + * SPDX-FileCopyrightText: 2024 Siemens AG + * + * SPDX-License-Identifier: MIT + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { IxTimeInput } from '@siemens/ix-react'; +import { useState, useRef } from 'react'; + +export default function TimeInputFormTest() { + const [logs, setLogs] = useState>([]); + const logIdCounter = useRef(0); + + // State for different scenarios + const [requiredValue, setRequiredValue] = useState(''); + const [optionalValue, setOptionalValue] = useState(''); + const [formRequiredValue, setFormRequiredValue] = useState(''); + const [formOptionalValue, setFormOptionalValue] = useState(''); + const [noValidateRequiredValue, setNoValidateRequiredValue] = useState(''); + const [noValidateOptionalValue, setNoValidateOptionalValue] = useState(''); + + // Refs for debugging + const requiredRef = useRef(null); + const optionalRef = useRef(null); + const formRequiredRef = useRef(null); + const formOptionalRef = useRef(null); + const noValidateRequiredRef = useRef(null); + const noValidateOptionalRef = useRef(null); + + const addLog = (message: string) => { + logIdCounter.current += 1; + const uniqueId = `${Date.now()}-${logIdCounter.current}`; + setLogs(prev => [...prev, { id: uniqueId, message: `[${new Date().toLocaleTimeString()}] ${message}` }]); + }; + + const debugValidationState = async (inputRef: React.RefObject, label: string) => { + if (!inputRef.current) { + addLog(`${label} - Element not available`); + return; + } + + const element = inputRef.current; + const classList = Array.from(element.classList); + const hasRequiredClass = classList.includes('ix-invalid--required'); + const required = element.required; + const value = element.value; + + let touched = 'unknown'; + try { + const isTouchedMethod = (element as any).isTouched; + if (typeof isTouchedMethod === 'function') { + touched = String(await isTouchedMethod.call(element)); + } + } catch { + touched = 'error'; + } + + addLog(`${label} - Req:${required}, Val:"${value}", Touch:${touched}, RedClass:${hasRequiredClass}`); + }; + + const triggerNativeBlur = async (elementRef: React.RefObject) => { + if (!elementRef.current) return; + try { + const nativeInput = await elementRef.current.getNativeInputElement(); + if (nativeInput) { + nativeInput.dispatchEvent(new FocusEvent('blur', { bubbles: true })); + addLog('✅ Native blur triggered'); + } + } catch (error) { + addLog(`❌ Error: ${error}`); + } + }; + + const clearLogs = () => setLogs([]); + + const buttonStyle = { padding: '0.4rem 0.8rem', fontSize: '0.85rem', border: 'none', borderRadius: '4px', cursor: 'pointer' }; + + return ( +
+

Time Input Validation Tests

+
+ Testing: Required field validation (red border) after blur. Format: HH:MM:SS +
+ + + + {/* Grid Layout for Examples */} +
+ + {/* 1. Required Standalone */} +
+

1️⃣ Required (Standalone)

+ { setRequiredValue(e.detail ?? ''); addLog(`1️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('1️⃣ Focus'); setTimeout(() => debugValidationState(requiredRef, '1️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('1️⃣ Blur'); setTimeout(() => debugValidationState(requiredRef, '1️⃣ BLUR'), 100); }} + /> +
+ + + + + +
+
+ + {/* 2. Optional Standalone */} +
+

2️⃣ Optional (Standalone)

+ { setOptionalValue(e.detail ?? ''); addLog(`2️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('2️⃣ Focus'); setTimeout(() => debugValidationState(optionalRef, '2️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('2️⃣ Blur'); setTimeout(() => debugValidationState(optionalRef, '2️⃣ BLUR'), 100); }} + /> +
+ + + + + +
+
+ + {/* 3. Form Required */} +
+

3️⃣ Required (In Form)

+
{ e.preventDefault(); addLog('3️⃣ Form submitted'); }}> + { setFormRequiredValue(e.detail ?? ''); addLog(`3️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('3️⃣ Focus'); setTimeout(() => debugValidationState(formRequiredRef, '3️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('3️⃣ Blur'); setTimeout(() => debugValidationState(formRequiredRef, '3️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ + {/* 4. Form Optional */} +
+

4️⃣ Optional (In Form)

+
{ e.preventDefault(); addLog('4️⃣ Form submitted'); }}> + { setFormOptionalValue(e.detail ?? ''); addLog(`4️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('4️⃣ Focus'); setTimeout(() => debugValidationState(formOptionalRef, '4️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('4️⃣ Blur'); setTimeout(() => debugValidationState(formOptionalRef, '4️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ + {/* 5. NoValidate Required */} +
+

5️⃣ Required (NoValidate Form)

+
{ e.preventDefault(); addLog('5️⃣ NoValidate form submitted'); }}> + { setNoValidateRequiredValue(e.detail ?? ''); addLog(`5️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('5️⃣ Focus'); setTimeout(() => debugValidationState(noValidateRequiredRef, '5️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('5️⃣ Blur'); setTimeout(() => debugValidationState(noValidateRequiredRef, '5️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ + {/* 6. NoValidate Optional */} +
+

6️⃣ Optional (NoValidate Form)

+
{ e.preventDefault(); addLog('6️⃣ NoValidate form submitted'); }}> + { setNoValidateOptionalValue(e.detail ?? ''); addLog(`6️⃣ Value: "${e.detail ?? 'empty'}"`); }} + onFocus={() => { addLog('6️⃣ Focus'); setTimeout(() => debugValidationState(noValidateOptionalRef, '6️⃣ FOCUS'), 10); }} + onBlur={() => { addLog('6️⃣ Blur'); setTimeout(() => debugValidationState(noValidateOptionalRef, '6️⃣ BLUR'), 100); }} + /> +
+ + + + + + +
+ +
+ +
{/* End of grid */} + + {/* Event Log */} +
+

🪵 Event Log

+
+ {logs.length === 0 ? ( +
No events yet... Interact with fields above
+ ) : ( + logs.map((log) => { + const isError = log.message.includes('❌'); + const isDebug = log.message.includes('BLUR') || log.message.includes('FOCUS') || log.message.includes('MANUAL'); + let logColor = 'inherit'; + if (isError) { + logColor = '#d32f2f'; + } else if (isDebug) { + logColor = '#1976d2'; + } + + return ( +
+ {log.message} +
+ ); + }) + )} +
+
+
+ ); +} diff --git a/packages/vue-test-app/src/Root.vue b/packages/vue-test-app/src/Root.vue index e311f14fa75..964ef525875 100644 --- a/packages/vue-test-app/src/Root.vue +++ b/packages/vue-test-app/src/Root.vue @@ -62,6 +62,8 @@ import DateDropdownUserRange from './preview-examples/date-dropdown-user-range.v import DateDropdown from './preview-examples/date-dropdown.vue'; import DateInputWithSlots from './preview-examples/date-input-with-slots.vue'; import DateInput from './preview-examples/date-input.vue'; +import DateInputFormTest from './preview-examples/date-input-form-test.vue'; +import TimeInputFormTest from './preview-examples/time-input-form-test.vue'; import DatepickerLocale from './preview-examples/datepicker-locale.vue'; import DatepickerRange from './preview-examples/datepicker-range.vue'; import Datepicker from './preview-examples/datepicker.vue'; @@ -399,6 +401,8 @@ const routes: any = { '/preview/number-input-with-slots': NumberInputWithSlots, '/preview/date-input': DateInput, '/preview/date-input-with-slots': DateInputWithSlots, + '/preview/date-input-form-test': DateInputFormTest, + '/preview/time-input-form-test': TimeInputFormTest, '/preview/workflow': Workflow, '/preview/workflow-vertical': WorkflowVertical, '/preview/tooltip': Tooltip, diff --git a/packages/vue-test-app/src/preview-examples/date-input-form-test.vue b/packages/vue-test-app/src/preview-examples/date-input-form-test.vue new file mode 100644 index 00000000000..452a2ea30a4 --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/date-input-form-test.vue @@ -0,0 +1,408 @@ + + + + + diff --git a/packages/vue-test-app/src/preview-examples/time-input-form-test.vue b/packages/vue-test-app/src/preview-examples/time-input-form-test.vue new file mode 100644 index 00000000000..251218dfcef --- /dev/null +++ b/packages/vue-test-app/src/preview-examples/time-input-form-test.vue @@ -0,0 +1,286 @@ + + + + +