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/checkbox-group/checkbox-group.tsx b/packages/core/src/components/checkbox-group/checkbox-group.tsx index 11d1199250d..3f67a81e80c 100644 --- a/packages/core/src/components/checkbox-group/checkbox-group.tsx +++ b/packages/core/src/components/checkbox-group/checkbox-group.tsx @@ -14,7 +14,13 @@ import { ValidationResults, } from '../utils/input'; import { IxComponent } from '../utils/internal'; +import { useFieldGroupValidation } from '../utils/field-group-utils'; import { makeRef } from '../utils/make-ref'; +import { + isFormNoValidate, + setupFormSubmitListener, + updateCheckboxValidationClasses, +} from '../checkbox/checkbox-validation'; /** * @form-ready @@ -77,36 +83,39 @@ export class CheckboxGroup @State() isWarning = false; private touched = false; + private formSubmissionAttempt = false; + private cleanFormListener?: () => void; private readonly groupRef = makeRef(); - get checkboxElements(): HTMLIxCheckboxElement[] { - return Array.from(this.hostElement.querySelectorAll('ix-checkbox')); - } - - private readonly observer = new MutationObserver(() => { - this.checkForRequiredCheckbox(); - }); + private readonly validation = useFieldGroupValidation( + this.hostElement, + { + selector: 'ix-checkbox', + isChecked: (el) => el.checked, + isRequired: (el) => el.required, + updateValidationClasses: updateCheckboxValidationClasses, + clearValidationState: this.clearValidationState.bind(this), + } + ); - private checkForRequiredCheckbox() { - this.required = this.checkboxElements.some((checkbox) => checkbox.required); + get checkboxElements(): HTMLIxCheckboxElement[] { + return this.validation.getElements(); } - connectedCallback(): void { - this.observer.observe(this.hostElement, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['checked', 'required'], + private setupFormListener() { + this.cleanFormListener = setupFormSubmitListener(this.hostElement, () => { + this.formSubmissionAttempt = true; + this.syncValidationClasses(); }); } - componentWillLoad(): void | Promise { - this.checkForRequiredCheckbox(); + connectedCallback(): void { + this.setupFormListener(); } disconnectedCallback(): void { - if (this.observer) { - this.observer.disconnect(); + if (this.cleanFormListener) { + this.cleanFormListener(); } } @@ -144,9 +153,132 @@ export class CheckboxGroup ); } + private hasAnyChecked(): boolean { + return this.validation.hasAnyChecked(); + } + + private clearValidationState() { + this.hostElement.classList.remove('ix-invalid--required', 'ix-invalid'); + if (this.invalidText) { + this.invalidText = ''; + } + this.checkboxElements.forEach((el: any) => { + el.classList.remove('ix-invalid', 'ix-invalid--required'); + }); + } + + private handleRequiredValidationShared(params: { + elements: HTMLElement[]; + hasAnyChecked: boolean; + touched: boolean; + formSubmissionAttempt: boolean; + invalidText: string | undefined; + hostElement: HTMLElement; + clearValidationState: () => void; + updateValidationClasses: ( + elements: HTMLElement[], + isChecked: boolean, + touched: boolean, + formSubmissionAttempt: boolean + ) => void; + }) { + const { + elements, + hasAnyChecked, + touched, + formSubmissionAttempt, + invalidText, + hostElement, + clearValidationState, + updateValidationClasses, + } = params; + + if (isFormNoValidate(hostElement)) { + clearValidationState(); + return; + } + const requiredElements = elements.filter( + (el) => (el as HTMLIxCheckboxElement).required + ); + const isChecked = hasAnyChecked; + const anyTouched = requiredElements.some( + (el) => + ( + el as HTMLIxCheckboxElement & { + touched?: boolean; + formSubmissionAttempted?: boolean; + } + ).touched || + ( + el as HTMLIxCheckboxElement & { + touched?: boolean; + formSubmissionAttempted?: boolean; + } + ).formSubmissionAttempted + ); + const isRequiredInvalid = + !isChecked && (touched || formSubmissionAttempt || anyTouched); + hostElement.classList.toggle('ix-invalid--required', isRequiredInvalid); + if (isRequiredInvalid) { + hostElement.classList.add('ix-invalid'); + this.invalidText = + invalidText && invalidText.trim().length > 0 + ? invalidText + : 'Please select the required field.'; + } else { + hostElement.classList.remove('ix-invalid', 'ix-invalid--required'); + if (invalidText === 'Please select the required field.') { + this.invalidText = ''; + } + } + if (!isFormNoValidate(hostElement)) { + updateValidationClasses( + elements, + isChecked, + touched, + formSubmissionAttempt + ); + } + if (isChecked) { + hostElement.classList.remove('ix-invalid', 'ix-invalid--required'); + } + } + + private handleRequiredValidation() { + this.handleRequiredValidationShared({ + elements: this.checkboxElements, + hasAnyChecked: this.hasAnyChecked(), + touched: this.touched, + formSubmissionAttempt: this.formSubmissionAttempt, + invalidText: this.invalidText, + hostElement: this.hostElement, + clearValidationState: this.clearValidationState.bind(this), + updateValidationClasses: updateCheckboxValidationClasses, + }); + } + + async syncValidationClasses() { + if (isFormNoValidate(this.hostElement)) { + this.clearValidationState(); + return; + } + if (this.required) { + this.handleRequiredValidation(); + } else { + this.clearValidationState(); + } + } + render() { return ( - (this.touched = true)}> + { + if (!this.touched) { + this.touched = true; + this.syncValidationClasses(); + } + }} + > { await expect(radioOption2).toHaveClass(/ix-invalid--required/); await expect(radioOption3).not.toHaveClass(/ix-invalid/); }); + +regressionTest( + 'validation works when noValidate is not set (default)', + async ({ mount, page }) => { + await mount( + ` +
+ + + + + + +
+ ` + ); + + const form = page.locator('form'); + const checkboxGroup = page.locator('ix-checkbox-group'); + const checkbox1 = page.locator('ix-checkbox').nth(0); + const checkbox2 = page.locator('ix-checkbox').nth(1); + const checkbox3 = page.locator('ix-checkbox').nth(2); + const submitButton = page.locator('button[type="submit"]'); + + await form.evaluate((form: HTMLFormElement) => { + form.addEventListener('submit', (e) => e.preventDefault()); + }); + + await expect(checkboxGroup).not.toHaveClass(/ix-invalid/); + await submitButton.click(); + await expect(checkboxGroup).toHaveClass(/ix-invalid--required/); + await expect(checkbox1).toHaveClass(/ix-invalid--required/); + await expect(checkbox2).toHaveClass(/ix-invalid--required/); + await expect(checkbox3).toHaveClass(/ix-invalid--required/); + await checkbox1.click(); + await expect(checkboxGroup).not.toHaveClass(/ix-invalid/); + await expect(checkbox1).not.toHaveClass(/ix-invalid/); + await expect(checkbox2).not.toHaveClass(/ix-invalid/); + await expect(checkbox3).not.toHaveClass(/ix-invalid/); + } +); + +regressionTest( + 'noValidate=true disables validation', + async ({ mount, page }) => { + await mount( + ` +
+ + + + + + +
+ ` + ); + + const form = page.locator('form'); + const checkboxGroup = page.locator('ix-checkbox-group'); + const checkbox1 = page.locator('ix-checkbox').nth(0); + const checkbox2 = page.locator('ix-checkbox').nth(1); + const checkbox3 = page.locator('ix-checkbox').nth(2); + const submitButton = page.locator('button[type="submit"]'); + await form.evaluate((form: HTMLFormElement) => { + form.addEventListener('submit', (e) => e.preventDefault()); + }); + + await submitButton.click(); + await expect(checkboxGroup).not.toHaveClass(/ix-invalid--required/); + await expect(checkboxGroup).not.toHaveClass(/ix-invalid/); + await expect(checkbox1).not.toHaveClass(/ix-invalid--required/); + await expect(checkbox2).not.toHaveClass(/ix-invalid--required/); + await expect(checkbox3).not.toHaveClass(/ix-invalid--required/); + await checkbox1.click(); + await checkbox1.press('Tab'); + + await expect(checkboxGroup).not.toHaveClass(/ix-invalid/); + await expect(checkbox1).not.toHaveClass(/ix-invalid/); + } +); + +regressionTest( + 'validation behavior with blur when noValidate is not set', + async ({ mount, page }) => { + await mount( + ` +
+ + + + +
+ ` + ); + + const checkboxGroup = page.locator('ix-checkbox-group'); + const checkbox1 = page.locator('ix-checkbox').nth(0); + const checkbox2 = page.locator('ix-checkbox').nth(1); + await checkbox1.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(checkboxGroup).toHaveClass(/ix-invalid--required/); + await expect(checkbox1).toHaveClass(/ix-invalid--required/); + await expect(checkbox2).toHaveClass(/ix-invalid--required/); + } +); + +regressionTest( + 'no validation on blur when noValidate=true', + async ({ mount, page }) => { + await mount( + ` +
+ + + + +
+ ` + ); + + const checkboxGroup = page.locator('ix-checkbox-group'); + const checkbox1 = page.locator('ix-checkbox').nth(0); + const checkbox2 = page.locator('ix-checkbox').nth(1); + + await checkbox1.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(checkboxGroup).not.toHaveClass(/ix-invalid--required/); + await expect(checkboxGroup).not.toHaveClass(/ix-invalid/); + await expect(checkbox1).not.toHaveClass(/ix-invalid/); + await expect(checkbox2).not.toHaveClass(/ix-invalid/); + } +); diff --git a/packages/core/src/components/checkbox/checkbox-validation.ts b/packages/core/src/components/checkbox/checkbox-validation.ts new file mode 100644 index 00000000000..b430d596482 --- /dev/null +++ b/packages/core/src/components/checkbox/checkbox-validation.ts @@ -0,0 +1,127 @@ +/* + * 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. + */ + +export function isFormNoValidate(element: HTMLElement): boolean { + const form = element.closest('form'); + if (!form) return false; + return ( + form.hasAttribute('novalidate') || + form.getAttribute('novalidate') === '' || + form.dataset.novalidate !== undefined || + form.hasAttribute('ngnovalidate') + ); +} + +export function getParentForm(element: HTMLElement): HTMLFormElement | null { + return element.closest('form'); +} + +export function hasAnyCheckboxChecked( + checkboxes: NodeListOf | HTMLElement[] +): boolean { + return Array.from(checkboxes) + .filter((el: any) => el.required) + .some((el: any) => el.checked); +} + +export function setupFormSubmitListener( + element: HTMLElement, + callback: () => void +): (() => void) | undefined { + const form = getParentForm(element); + if (!form) return undefined; + + const handler = () => callback(); + form.addEventListener('submit', handler); + + return () => { + form.removeEventListener('submit', handler); + }; +} + +function shouldSkipValidationAndClear( + checkboxes: NodeListOf | HTMLElement[] +): boolean { + if (checkboxes.length > 0) { + const form = checkboxes[0].closest('form'); + if ( + form && + (form.hasAttribute('novalidate') || + form.getAttribute('novalidate') === '' || + form.dataset.novalidate !== undefined || + form.hasAttribute('ngnovalidate')) + ) { + Array.from(checkboxes).forEach((el: any) => { + el.classList.remove('ix-invalid--required', 'ix-invalid'); + }); + return true; + } + } + return false; +} + +export function updateCheckboxValidationClasses( + checkboxes: NodeListOf | HTMLElement[], + isChecked: boolean, + touched: boolean, + formSubmissionAttempted: boolean +) { + if (shouldSkipValidationAndClear(checkboxes)) { + return; + } + + const requiredCheckboxes = Array.from(checkboxes).filter( + (el: any) => el.required + ); + + requiredCheckboxes.forEach((el: any) => { + if (el.checked) { + el.classList.remove('ix-invalid--required', 'ix-invalid'); + } + }); + + if (isChecked) { + return; + } + + const shouldShow = touched || formSubmissionAttempted; + requiredCheckboxes.forEach((el: any) => { + if (!el.checked && shouldShow) { + el.classList.add('ix-invalid--required', 'ix-invalid'); + } else if (!el.checked) { + el.classList.remove('ix-invalid--required', 'ix-invalid'); + } + }); +} + +export function updateGroupValidationClasses( + group: Element | null, + checkboxes: NodeListOf | HTMLElement[], + isChecked: boolean +) { + if (!group) return; + + if (shouldSkipValidationAndClear(checkboxes)) { + group.classList.remove('ix-invalid', 'ix-invalid--required'); + return; + } + + if (isChecked) { + group.classList.remove('ix-invalid', 'ix-invalid--required'); + return; + } + + const anyTouched = Array.from(checkboxes).some( + (el: any) => el.touched || el.formSubmissionAttempted + ); + group.classList.toggle('ix-invalid--required', anyTouched); + if (anyTouched) { + group.classList.add('ix-invalid'); + } +} diff --git a/packages/core/src/components/checkbox/checkbox.tsx b/packages/core/src/components/checkbox/checkbox.tsx index 241e6310353..bd5b1c1b926 100644 --- a/packages/core/src/components/checkbox/checkbox.tsx +++ b/packages/core/src/components/checkbox/checkbox.tsx @@ -23,6 +23,14 @@ import { import { a11yBoolean } from '../utils/a11y'; import { HookValidationLifecycle, IxFormComponent } from '../utils/input'; import { makeRef } from '../utils/make-ref'; +import { + getParentForm, + hasAnyCheckboxChecked, + isFormNoValidate, + setupFormSubmitListener, + updateCheckboxValidationClasses, + updateGroupValidationClasses, +} from './checkbox-validation'; /** * @form-ready @@ -91,6 +99,111 @@ export class Checkbox implements IxFormComponent { @Event() ixBlur!: EventEmitter; private touched = false; + private formSubmissionAttempted = false; + private cleanupFormListener?: () => void; + + connectedCallback(): void { + this.cleanupFormListener = setupFormSubmitListener(this.hostElement, () => { + this.formSubmissionAttempted = true; + this.syncValidationClasses(); + }); + } + + disconnectedCallback(): void { + if (this.cleanupFormListener) { + this.cleanupFormListener(); + } + } + + private removeInvalidClassesFromCheckboxes( + checkboxes: NodeListOf + ) { + Array.from(checkboxes).forEach((el: any) => { + el.classList.remove('ix-invalid--required', 'ix-invalid'); + }); + } + + private removeInvalidClassesFromGroup(checkboxes: NodeListOf) { + if (checkboxes.length > 0) { + const group = checkboxes[0].closest('ix-checkbox-group'); + if (group) { + group.classList.remove('ix-invalid', 'ix-invalid--required'); + } + } + } + + private handleStandaloneCheckbox(isChecked: boolean) { + const isRequiredInvalid = + !isChecked && (this.touched || this.formSubmissionAttempted); + this.hostElement.classList.toggle( + 'ix-invalid--required', + isRequiredInvalid + ); + if (isChecked) { + this.hostElement.classList.remove('ix-invalid'); + } else if (isRequiredInvalid) { + this.hostElement.classList.add('ix-invalid'); + } + } + + private syncValidationClasses() { + if (isFormNoValidate(this.hostElement) || !this.required) { + this.hostElement.classList.remove('ix-invalid--required', 'ix-invalid'); + return; + } + + let isChecked = this.checked; + const checkboxGroup = this.hostElement.closest('ix-checkbox-group'); + + if (!checkboxGroup && this.name) { + const form = getParentForm(this.hostElement); + const checkboxes: NodeListOf = form + ? form.querySelectorAll(`ix-checkbox[name="${this.name}"]`) + : document.querySelectorAll(`ix-checkbox[name="${this.name}"]`); + + if (isFormNoValidate(this.hostElement)) { + this.removeInvalidClassesFromCheckboxes(checkboxes); + this.removeInvalidClassesFromGroup(checkboxes); + return; + } + + isChecked = hasAnyCheckboxChecked(checkboxes); + updateCheckboxValidationClasses( + checkboxes, + isChecked, + this.touched, + this.formSubmissionAttempted + ); + if (checkboxes.length > 0) { + const group = checkboxes[0].closest('ix-checkbox-group'); + updateGroupValidationClasses(group, checkboxes, isChecked); + } + return; + } + + if (checkboxGroup && this.name) { + const checkboxes: NodeListOf = + checkboxGroup.querySelectorAll(`ix-checkbox[name="${this.name}"]`); + + if (isFormNoValidate(this.hostElement)) { + this.removeInvalidClassesFromCheckboxes(checkboxes); + updateGroupValidationClasses(checkboxGroup, checkboxes, true); + return; + } + + isChecked = hasAnyCheckboxChecked(checkboxes); + updateCheckboxValidationClasses( + checkboxes, + isChecked, + this.touched, + this.formSubmissionAttempted + ); + updateGroupValidationClasses(checkboxGroup, checkboxes, isChecked); + return; + } + + this.handleStandaloneCheckbox(isChecked); + } private readonly inputRef = makeRef((checkboxRef) => { checkboxRef.checked = this.checked; @@ -105,6 +218,7 @@ export class Checkbox implements IxFormComponent { onCheckedChange() { this.touched = true; this.updateFormInternalValue(); + this.syncValidationClasses(); } @Watch('value') @@ -114,6 +228,29 @@ export class Checkbox implements IxFormComponent { componentWillLoad() { this.updateFormInternalValue(); + this.syncValidationClasses(); + + if (this.required && this.name) { + const form = getParentForm(this.hostElement); + const checkboxes: NodeListOf = form + ? form.querySelectorAll(`ix-checkbox[name="${this.name}"]`) + : document.querySelectorAll(`ix-checkbox[name="${this.name}"]`); + + const isChecked = hasAnyCheckboxChecked(checkboxes); + + if (isChecked) { + Array.from(checkboxes).forEach((el: any) => { + el.classList.remove('ix-invalid--required', 'ix-invalid'); + }); + + if (checkboxes.length > 0) { + const group = checkboxes[0].closest('ix-checkbox-group'); + if (group) { + group.classList.remove('ix-invalid', 'ix-invalid--required'); + } + } + } + } } updateFormInternalValue() { @@ -191,8 +328,17 @@ export class Checkbox implements IxFormComponent { checked: this.checked, indeterminate: this.indeterminate, }} - onFocus={() => (this.touched = true)} - onBlur={() => this.ixBlur.emit()} + onFocus={() => { + if (!this.touched) { + this.touched = true; + this.syncValidationClasses(); + } + }} + onBlur={() => { + this.ixBlur.emit(); + this.touched = true; + this.syncValidationClasses(); + }} >