diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 27500eaf4c9..3413d854412 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -401,7 +401,8 @@ export declare interface IxCategoryFilter extends Components.IxCategoryFilter { @ProxyCmp({ - inputs: ['checked', 'disabled', 'indeterminate', 'label', 'name', 'required', 'value'] + inputs: ['checked', 'disabled', 'indeterminate', 'label', 'name', 'required', 'value'], + methods: ['clear'] }) @Component({ selector: 'ix-checkbox', @@ -441,7 +442,8 @@ export declare interface IxCheckbox extends Components.IxCheckbox { @ProxyCmp({ - inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'warningText'] + inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'warningText'], + methods: ['clear'] }) @Component({ selector: 'ix-checkbox-group', @@ -629,7 +631,7 @@ The event payload contains information about the selected date range. @ProxyCmp({ inputs: ['ariaLabelCalendarButton', 'ariaLabelNextMonthButton', 'ariaLabelPreviousMonthButton', 'disabled', 'enableTopLayer', '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 +1328,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', 'clear'] }) @Component({ selector: 'ix-input', @@ -2003,7 +2005,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', 'clear'] }) @Component({ selector: 'ix-number-input', @@ -2214,7 +2216,8 @@ export declare interface IxPushCard extends Components.IxPushCard {} @ProxyCmp({ - inputs: ['checked', 'disabled', 'label', 'name', 'required', 'value'] + inputs: ['checked', 'disabled', 'label', 'name', 'required', 'value'], + methods: ['clear'] }) @Component({ selector: 'ix-radio', @@ -2254,7 +2257,8 @@ export declare interface IxRadio extends Components.IxRadio { @ProxyCmp({ - inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'value', 'warningText'] + inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'value', 'warningText'], + methods: ['clear'] }) @Component({ selector: 'ix-radio-group', @@ -2528,7 +2532,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', 'clear'] }) @Component({ selector: 'ix-textarea', @@ -2592,7 +2596,7 @@ export declare interface IxTile extends Components.IxTile {} @ProxyCmp({ inputs: ['disabled', 'enableTopLayer', '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 fabb174b735..dee74da82be 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -502,7 +502,8 @@ export declare interface IxCategoryFilter extends Components.IxCategoryFilter { @ProxyCmp({ defineCustomElementFn: defineIxCheckbox, - inputs: ['checked', 'disabled', 'indeterminate', 'label', 'name', 'required', 'value'] + inputs: ['checked', 'disabled', 'indeterminate', 'label', 'name', 'required', 'value'], + methods: ['clear'] }) @Component({ selector: 'ix-checkbox', @@ -542,7 +543,8 @@ export declare interface IxCheckbox extends Components.IxCheckbox { @ProxyCmp({ defineCustomElementFn: defineIxCheckboxGroup, - inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'warningText'] + inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'warningText'], + methods: ['clear'] }) @Component({ selector: 'ix-checkbox-group', @@ -730,7 +732,7 @@ The event payload contains information about the selected date range. @ProxyCmp({ defineCustomElementFn: defineIxDateInput, inputs: ['ariaLabelCalendarButton', 'ariaLabelNextMonthButton', 'ariaLabelPreviousMonthButton', 'disabled', 'enableTopLayer', '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 +1429,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', 'clear'] }) @Component({ selector: 'ix-input', @@ -2104,7 +2106,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', 'clear'] }) @Component({ selector: 'ix-number-input', @@ -2315,7 +2317,8 @@ export declare interface IxPushCard extends Components.IxPushCard {} @ProxyCmp({ defineCustomElementFn: defineIxRadio, - inputs: ['checked', 'disabled', 'label', 'name', 'required', 'value'] + inputs: ['checked', 'disabled', 'label', 'name', 'required', 'value'], + methods: ['clear'] }) @Component({ selector: 'ix-radio', @@ -2355,7 +2358,8 @@ export declare interface IxRadio extends Components.IxRadio { @ProxyCmp({ defineCustomElementFn: defineIxRadioGroup, - inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'value', 'warningText'] + inputs: ['direction', 'helperText', 'infoText', 'invalidText', 'label', 'showTextAsTooltip', 'validText', 'value', 'warningText'], + methods: ['clear'] }) @Component({ selector: 'ix-radio-group', @@ -2629,7 +2633,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', 'clear'] }) @Component({ selector: 'ix-textarea', @@ -2693,7 +2697,7 @@ export declare interface IxTile extends Components.IxTile {} @ProxyCmp({ defineCustomElementFn: defineIxTimeInput, inputs: ['disabled', 'enableTopLayer', '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 ee8dcbeb8b9..4b1126c23fb 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -629,6 +629,10 @@ export namespace Components { * @default false */ "checked": boolean; + /** + * Clear the checked state and reset validation + */ + "clear": () => Promise; /** * Disabled state of the checkbox component * @default false @@ -665,6 +669,10 @@ export namespace Components { * @form-ready */ interface IxCheckboxGroup { + /** + * Clear all checked checkboxes and reset validation state + */ + "clear": () => Promise; /** * Alignment of the checkboxes in the group * @default 'column' @@ -978,6 +986,7 @@ 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; + "clear": () => Promise; /** * Disabled attribute * @default false @@ -989,9 +998,6 @@ export namespace Components { * @since 4.3.0 */ "enableTopLayer": boolean; - /** - * Focuses the input field - */ "focusInput": () => Promise; /** * Date format string. See {@link https://moment.github.io/luxon/#/formatting?id=table-of-tokens} for all available tokens. @@ -999,9 +1005,6 @@ export namespace Components { */ "format": string; "getAssociatedFormElement": () => Promise; - /** - * Get the native input element - */ "getNativeInputElement": () => Promise; "getValidityState": () => Promise; "hasValidValue": () => Promise; @@ -1022,9 +1025,6 @@ export namespace Components { * Error text below the input field */ "invalidText"?: string; - /** - * Returns whether the text field has been touched. - */ "isTouched": () => Promise; /** * Label of the input field @@ -1958,6 +1958,10 @@ export namespace Components { * The allowed characters pattern for the text field. */ "allowedCharactersPattern"?: string; + /** + * Clears the input field value and resets validation state. Sets the value to empty and removes touched state to suppress validation. + */ + "clear": () => Promise; /** * Specifies whether the text field is disabled. * @default false @@ -2601,6 +2605,10 @@ export namespace Components { * The allowed characters pattern for the input field */ "allowedCharactersPattern"?: string; + /** + * Clears the input field value and resets validation state. Sets the value to empty and removes touched state to suppress validation. + */ + "clear": () => Promise; /** * Disables the input field * @default false @@ -2992,6 +3000,10 @@ export namespace Components { * @default false */ "checked": boolean; + /** + * Clear the checked state and reset validation + */ + "clear": () => Promise; /** * Disabled state of the radio component * @default false @@ -3023,6 +3035,10 @@ export namespace Components { * @form-ready */ interface IxRadioGroup { + /** + * Clear the selected radio button and reset validation state + */ + "clear": () => Promise; /** * Alignment of the radio buttons in the group * @default 'column' @@ -3488,6 +3504,10 @@ export namespace Components { * @form-ready */ interface IxTextarea { + /** + * Clears the input field value and resets validation state. Sets the value to empty and removes touched state to suppress validation. + */ + "clear": () => Promise; /** * Determines if the textarea field is disabled. * @default false @@ -3502,6 +3522,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. @@ -3600,6 +3624,7 @@ export namespace Components { * @form-ready */ interface IxTimeInput { + "clear": () => Promise; /** * Disabled attribute * @default false @@ -3611,9 +3636,6 @@ export namespace Components { * @since 4.3.0 */ "enableTopLayer": boolean; - /** - * Focuses the input field - */ "focusInput": () => Promise; /** * Format of time string See {@link https://moment.github.io/luxon/#/formatting?id=table-of-tokens} for all available tokens. @@ -3621,9 +3643,6 @@ export namespace Components { */ "format": string; "getAssociatedFormElement": () => Promise; - /** - * Get the native input element - */ "getNativeInputElement": () => Promise; "getValidityState": () => Promise; "hasValidValue": () => Promise; @@ -3685,9 +3704,6 @@ export namespace Components { * Error text below the input field */ "invalidText"?: string; - /** - * Returns whether the text field has been touched. - */ "isTouched": () => Promise; /** * Label of the input field diff --git a/packages/core/src/components/checkbox-group/checkbox-group.tsx b/packages/core/src/components/checkbox-group/checkbox-group.tsx index 11d1199250d..559582afccc 100644 --- a/packages/core/src/components/checkbox-group/checkbox-group.tsx +++ b/packages/core/src/components/checkbox-group/checkbox-group.tsx @@ -14,7 +14,14 @@ 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'; +import { clearInputValue } from '../input/input.util'; /** * @form-ready @@ -77,36 +84,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 +154,140 @@ export class CheckboxGroup ); } + /** + * Clear all checked checkboxes and reset validation state + */ + @Method() + async clear(): Promise { + await clearInputValue(this.hostElement); + } + + 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(); + } + }} + > | 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..2c7ac7952f2 100644 --- a/packages/core/src/components/checkbox/checkbox.tsx +++ b/packages/core/src/components/checkbox/checkbox.tsx @@ -23,6 +23,15 @@ import { import { a11yBoolean } from '../utils/a11y'; import { HookValidationLifecycle, IxFormComponent } from '../utils/input'; import { makeRef } from '../utils/make-ref'; +import { clearInputValue } from '../input/input.util'; +import { + getParentForm, + hasAnyCheckboxChecked, + isFormNoValidate, + setupFormSubmitListener, + updateCheckboxValidationClasses, + updateGroupValidationClasses, +} from './checkbox-validation'; /** * @form-ready @@ -91,6 +100,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 +219,7 @@ export class Checkbox implements IxFormComponent { onCheckedChange() { this.touched = true; this.updateFormInternalValue(); + this.syncValidationClasses(); } @Watch('value') @@ -114,6 +229,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() { @@ -142,6 +280,18 @@ export class Checkbox implements IxFormComponent { return Promise.resolve(this.touched); } + /** + * Clear the checked state and reset validation + */ + @Method() + async clear(): Promise { + await clearInputValue(this.hostElement, { + additionalCleanup: () => { + this.checked = false; + }, + }); + } + @HookValidationLifecycle() updateClassMappings() { /** This function is intentionally empty */ @@ -191,8 +341,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(); + }} >