From 98ab73209c719c48c876ed2e0104873e79d87722 Mon Sep 17 00:00:00 2001 From: 1307-Dev Date: Wed, 19 Nov 2025 13:07:28 +0530 Subject: [PATCH 01/18] Pushed changes for input, textarea, number input and select --- packages/angular/src/components.ts | 8 +- packages/angular/standalone/src/components.ts | 8 +- packages/core/src/components.d.ts | 20 ++ packages/core/src/components/input/input.tsx | 10 + .../core/src/components/input/input.util.ts | 22 +++ .../src/components/input/number-input.tsx | 10 + .../components/input/tests/validation.ct.ts | 63 ++++++ .../core/src/components/input/textarea.tsx | 24 ++- .../core/src/components/select/select.tsx | 116 +++++++++++ .../src/components/select/test/select.ct.ts | 184 ++++++++++++++++++ .../src/components/utils/input/validation.ts | 6 + 11 files changed, 462 insertions(+), 9 deletions(-) diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 535f3df4385..e02cadd480f 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -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', @@ -2307,7 +2307,7 @@ export declare interface IxRow extends Components.IxRow {} @ProxyCmp({ inputs: ['allowClear', 'ariaLabelChevronDownIconButton', 'ariaLabelClearIconButton', 'collapseMultipleSelection', 'disabled', 'dropdownMaxWidth', 'dropdownWidth', 'editable', 'helperText', 'hideListHeader', 'i18nAllSelected', 'i18nNoMatches', 'i18nPlaceholder', 'i18nPlaceholderEditable', 'i18nSelectListHeader', 'infoText', 'invalidText', 'label', 'mode', 'name', 'readonly', 'required', 'showTextAsTooltip', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'reset'] }) @Component({ selector: 'ix-select', @@ -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', diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index 30696df496b..d90ee8e4b68 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -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', @@ -2408,7 +2408,7 @@ export declare interface IxRow extends Components.IxRow {} @ProxyCmp({ defineCustomElementFn: defineIxSelect, inputs: ['allowClear', 'ariaLabelChevronDownIconButton', 'ariaLabelClearIconButton', 'collapseMultipleSelection', 'disabled', 'dropdownMaxWidth', 'dropdownWidth', 'editable', 'helperText', 'hideListHeader', 'i18nAllSelected', 'i18nNoMatches', 'i18nPlaceholder', 'i18nPlaceholderEditable', 'i18nSelectListHeader', 'infoText', 'invalidText', 'label', 'mode', 'name', 'readonly', 'required', 'showTextAsTooltip', 'validText', 'value', 'warningText'], - methods: ['getNativeInputElement', 'focusInput'] + methods: ['getNativeInputElement', 'focusInput', 'reset'] }) @Component({ selector: 'ix-select', @@ -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', diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 954fde3cfb6..198f0f01291 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -1969,6 +1969,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 +2584,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 */ @@ -3096,6 +3104,10 @@ export namespace Components { * @default false */ "required": boolean; + /** + * Resets the select field validation state by removing the touched state and clearing validation states while preserving the current value. + */ + "reset": () => Promise; /** * Show helper, error, info, warning text as tooltip */ @@ -3351,6 +3363,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. @@ -3398,6 +3414,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' 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 1e96554290c..ffc8ce46061 100644 --- a/packages/core/src/components/input/input.util.ts +++ b/packages/core/src/components/input/input.util.ts @@ -214,3 +214,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..e52980930b7 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; @@ -422,6 +423,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 4f0881ac9ff..3a828974ea5 100644 --- a/packages/core/src/components/input/tests/validation.ct.ts +++ b/packages/core/src/components/input/tests/validation.ct.ts @@ -201,3 +201,66 @@ test.describe('prevent initial require validation', async () => { }); }); }); + +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() isValid = false; @State() isInfo = false; @State() isWarning = false; + @State() isInvalidByRequired = false; + private formSubmissionAttempted = false; + private formSubmitHandler?: (event: Event) => void; private readonly dropdownWrapperRef = makeRef(); private readonly dropdownAnchorRef = makeRef(); private readonly inputRef = makeRef(); @@ -252,6 +256,24 @@ export class Select implements IxInputFieldComponent { return Array.from(this.hostElement.querySelectorAll('ix-select-item')); } + 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)); + } + get visibleNonShadowItems() { return this.nonShadowItems.filter( (item) => !item.classList.contains('display-none') @@ -304,6 +326,7 @@ export class Select implements IxInputFieldComponent { watchValue(value: string | string[]) { this.value = value; this.updateSelection(); + this.syncValidationClasses(); } @Watch('dropdownShow') @@ -475,6 +498,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 +538,9 @@ export class Select implements IxInputFieldComponent { componentWillLoad() { this.updateSelection(); this.updateFormInternalValue(this.value); + if (this.required) { + this.syncValidationClasses(); + } } componentDidRender(): void { @@ -516,6 +572,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 +840,7 @@ export class Select implements IxInputFieldComponent { private onInputBlur(event: Event) { this.ixBlur.emit(); this.touched = true; + this.syncValidationClasses(); if (this.editable) { return; @@ -876,13 +972,32 @@ export class Select implements IxInputFieldComponent { return Promise.resolve(this.touched); } + /** + * Resets the select field validation state by removing the touched state + * and clearing validation states while preserving the current value. + */ + @Method() + async reset(): Promise { + this.formSubmissionAttempted = false; + return resetInputValidation(this); + } + render() { 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/utils/input/validation.ts b/packages/core/src/components/utils/input/validation.ts index 900fafae2c1..5f7c89135e3 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 + ); } }; From 08923ca6479c169e70f256f4bfca3bcf5eacfc89 Mon Sep 17 00:00:00 2001 From: Khathija Ahamadi Date: Wed, 19 Nov 2025 23:24:56 +0530 Subject: [PATCH 02/18] Checkbox - form validations handled. --- .../checkbox-group/checkbox-group.tsx | 104 ++++++++++++-- .../core/src/components/checkbox/checkbox.tsx | 134 +++++++++++++++++- .../components/utils/checkbox-validation.ts | 96 +++++++++++++ 3 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/components/utils/checkbox-validation.ts diff --git a/packages/core/src/components/checkbox-group/checkbox-group.tsx b/packages/core/src/components/checkbox-group/checkbox-group.tsx index 11d1199250d..c1dc5e9fc83 100644 --- a/packages/core/src/components/checkbox-group/checkbox-group.tsx +++ b/packages/core/src/components/checkbox-group/checkbox-group.tsx @@ -15,6 +15,8 @@ import { } from '../utils/input'; import { IxComponent } from '../utils/internal'; import { makeRef } from '../utils/make-ref'; +import { getParentForm, hasAnyCheckboxChecked, isFormNoValidate, setupFormSubmitListener, updateCheckboxValidationClasses } from '../utils/checkbox-validation'; + /** * @form-ready @@ -25,8 +27,7 @@ import { makeRef } from '../utils/make-ref'; shadow: true, }) export class CheckboxGroup - implements FieldWrapperInterface, IxFormValidationState, IxComponent -{ + implements FieldWrapperInterface, IxFormValidationState, IxComponent { @Element() hostElement!: HTMLIxCheckboxGroupElement; /** * Optional helper text displayed below the checkbox group @@ -77,26 +78,22 @@ export class CheckboxGroup @State() isWarning = false; private touched = false; + private formSubmissionAttempted = false; + private cleanupFormListener?: () => void; private readonly groupRef = makeRef(); get checkboxElements(): HTMLIxCheckboxElement[] { return Array.from(this.hostElement.querySelectorAll('ix-checkbox')); } - private readonly observer = new MutationObserver(() => { - this.checkForRequiredCheckbox(); - }); - private checkForRequiredCheckbox() { this.required = this.checkboxElements.some((checkbox) => checkbox.required); } connectedCallback(): void { - this.observer.observe(this.hostElement, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: ['checked', 'required'], + this.cleanupFormListener = setupFormSubmitListener(this.hostElement, () => { + this.formSubmissionAttempted = true; + this.syncValidationClasses(); }); } @@ -105,8 +102,8 @@ export class CheckboxGroup } disconnectedCallback(): void { - if (this.observer) { - this.observer.disconnect(); + if (this.cleanupFormListener) { + this.cleanupFormListener(); } } @@ -144,9 +141,88 @@ export class CheckboxGroup ); } + private hasAnyChecked(): boolean { + const checkboxes = this.checkboxElements.filter((el) => el.required); + if (checkboxes.length > 0 && checkboxes[0].name) { + const name = checkboxes[0].name; + const form = getParentForm(this.hostElement); + const allWithSameName: NodeListOf = form + ? form.querySelectorAll(`ix-checkbox[name="${name}"]`) + : document.querySelectorAll(`ix-checkbox[name="${name}"]`); + return hasAnyCheckboxChecked(Array.from(allWithSameName).filter((el: any) => el.required)); + } + return checkboxes.some((checkbox) => (checkbox as any).checked); + } + + 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 handleRequiredValidation() { + const requiredCheckboxes = this.checkboxElements.filter((el) => el.required); + const isChecked = this.hasAnyChecked(); + const anyTouched = requiredCheckboxes.some( + (el: any) => el.touched || el.formSubmissionAttempted + ); + const isRequiredInvalid = + !isChecked && (this.touched || this.formSubmissionAttempted || anyTouched); + + this.hostElement.classList.toggle('ix-invalid--required', isRequiredInvalid); + + if (isRequiredInvalid) { + this.hostElement.classList.add('ix-invalid'); + this.invalidText = + this.invalidText && this.invalidText.trim().length > 0 + ? this.invalidText + : 'Please select the required field.'; + } else { + this.hostElement.classList.remove('ix-invalid', 'ix-invalid--required'); + if (this.invalidText === 'Please select the required field.') { + this.invalidText = ''; + } + } + + updateCheckboxValidationClasses( + this.checkboxElements, + isChecked, + this.touched, + this.formSubmissionAttempted + ); + + if (isChecked) { + this.hostElement.classList.remove('ix-invalid', 'ix-invalid--required'); + } + } + + 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(); + } + }} + > { @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 syncValidationClasses() { + if (isFormNoValidate(this.hostElement)) { + this.hostElement.classList.remove('ix-invalid--required', 'ix-invalid'); + return; + } + + if (!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)) { + 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'); + } + } + 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); + } + } else if (checkboxGroup && this.name) { + const checkboxes: NodeListOf = checkboxGroup.querySelectorAll( + `ix-checkbox[name="${this.name}"]` + ); + + if (isFormNoValidate(this.hostElement)) { + Array.from(checkboxes).forEach((el: any) => { + el.classList.remove('ix-invalid--required', 'ix-invalid'); + }); + updateGroupValidationClasses(checkboxGroup, checkboxes, true); + return; + } + + isChecked = hasAnyCheckboxChecked(checkboxes); + + updateCheckboxValidationClasses( + checkboxes, + isChecked, + this.touched, + this.formSubmissionAttempted + ); + + updateGroupValidationClasses(checkboxGroup, checkboxes, isChecked); + } else { + 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 readonly inputRef = makeRef((checkboxRef) => { checkboxRef.checked = this.checked; @@ -105,6 +202,7 @@ export class Checkbox implements IxFormComponent { onCheckedChange() { this.touched = true; this.updateFormInternalValue(); + this.syncValidationClasses(); } @Watch('value') @@ -114,6 +212,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 +312,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(); + }} >