diff --git a/packages/angular/src/components.ts b/packages/angular/src/components.ts index 1cb488b210b..3810fdf4877 100644 --- a/packages/angular/src/components.ts +++ b/packages/angular/src/components.ts @@ -403,7 +403,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', @@ -443,7 +444,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', diff --git a/packages/angular/standalone/src/components.ts b/packages/angular/standalone/src/components.ts index e28381dab64..bb0fd7b23b2 100644 --- a/packages/angular/standalone/src/components.ts +++ b/packages/angular/standalone/src/components.ts @@ -506,7 +506,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', @@ -546,7 +547,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', diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 94c1d24a1ae..206835c2f2d 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -650,6 +650,10 @@ export namespace Components { * @default false */ "checked": boolean; + /** + * Resets the checkbox to its initial unchecked state and clears validation. + */ + "clear": () => Promise; /** * Disabled state of the checkbox component * @default false @@ -686,6 +690,7 @@ export namespace Components { * @form-ready */ interface IxCheckboxGroup { + "clear": () => Promise; /** * Alignment of the checkboxes in the group * @default 'column' diff --git a/packages/core/src/components/checkbox-group/checkbox-group.tsx b/packages/core/src/components/checkbox-group/checkbox-group.tsx index e4f79739a00..65b3e723cd2 100644 --- a/packages/core/src/components/checkbox-group/checkbox-group.tsx +++ b/packages/core/src/components/checkbox-group/checkbox-group.tsx @@ -15,6 +15,7 @@ import { } from '../utils/input'; import { IxComponentInterface } from '../utils/internal'; import { makeRef } from '../utils/make-ref'; +import { clearCheckboxGroupValidationState } from '../checkbox/checkbox-validation'; /** * @form-ready @@ -144,6 +145,19 @@ export class CheckboxGroup ); } + @Method() + async clear(): Promise { + this.touched = false; + this.checkboxElements.forEach((checkbox) => { + checkbox.clear(); + }); + this.clearValidationState(); + } + + private clearValidationState() { + clearCheckboxGroupValidationState(this.hostElement, this.checkboxElements); + } + render() { return ( (this.touched = true)}> diff --git a/packages/core/src/components/checkbox-group/test/checkbox-group.ct.ts b/packages/core/src/components/checkbox-group/test/checkbox-group.ct.ts index 995188ff66c..7a6b9b30a10 100644 --- a/packages/core/src/components/checkbox-group/test/checkbox-group.ct.ts +++ b/packages/core/src/components/checkbox-group/test/checkbox-group.ct.ts @@ -29,6 +29,31 @@ regressionTest('renders', async ({ mount, page }) => { await expect(radioOption3).toHaveClass(/hydrated/); }); +regressionTest( + 'should apply invalid class to checkbox-group when required checkbox is touched', + async ({ mount, page }) => { + await mount(` +
+ + + + + +
+ `); + + const checkboxGroup = page.locator('ix-checkbox-group'); + const checkbox1 = page.locator('ix-checkbox').nth(0); + await expect(checkbox1).toHaveClass(/\bhydrated\b/); + + await checkbox1.focus(); + await page.locator('button[type="button"]').focus(); + + await expect(checkboxGroup).toHaveClass(/\bix-invalid--required\b/); + await expect(checkboxGroup).toHaveClass(/\bix-invalid\b/); + } +); + regressionTest('required', async ({ mount, page }) => { await mount( ` 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..9946d7c86e1 --- /dev/null +++ b/packages/core/src/components/checkbox/checkbox-validation.ts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +interface CheckboxElement extends HTMLElement { + required: boolean; + checked: boolean; +} + +export function isFormNoValidate(element: HTMLElement): boolean { + const form = element.closest('form'); + if (!form) return false; + return ( + form.hasAttribute('novalidate') || + form.dataset.novalidate !== undefined || + form.hasAttribute('ngnovalidate') + ); +} + +export function setupFormSubmitListener( + element: HTMLElement, + callback: () => void +): (() => void) | undefined { + const form = element.closest('form'); + if (!form) return undefined; + + const handler = () => callback(); + form.addEventListener('submit', handler); + + return () => { + form.removeEventListener('submit', handler); + }; +} + +export function applyCheckboxValidation( + checkboxes: NodeListOf | CheckboxElement[], + group: Element | null, + touched: boolean, + formSubmissionAttempted: boolean +) { + const checkboxArr = Array.from(checkboxes); + const requiredCheckboxes = checkboxArr.filter((el) => el.required); + const targets = + requiredCheckboxes.length > 0 ? requiredCheckboxes : checkboxArr; + + const anyChecked = targets.some((el) => el.checked); + const shouldShowError = !anyChecked && (touched || formSubmissionAttempted); + + checkboxArr.forEach((el) => { + const isTarget = targets.includes(el); + el.classList.toggle('ix-invalid--required', isTarget && shouldShowError); + el.classList.toggle('ix-invalid', isTarget && shouldShowError); + }); + + if (group) { + group.classList.toggle('ix-invalid--required', shouldShowError); + group.classList.toggle('ix-invalid', shouldShowError); + } +} + +export function clearCheckboxGroupValidationState( + group: HTMLElement, + checkboxes: CheckboxElement[] | NodeListOf +) { + const checkboxArr = Array.from(checkboxes); + group.classList.remove('ix-invalid--required', 'ix-invalid'); + checkboxArr.forEach((el) => { + el.classList.remove('ix-invalid', 'ix-invalid--required'); + }); +} diff --git a/packages/core/src/components/checkbox/checkbox.tsx b/packages/core/src/components/checkbox/checkbox.tsx index 241e6310353..16be496cee4 100644 --- a/packages/core/src/components/checkbox/checkbox.tsx +++ b/packages/core/src/components/checkbox/checkbox.tsx @@ -23,6 +23,11 @@ import { import { a11yBoolean } from '../utils/a11y'; import { HookValidationLifecycle, IxFormComponent } from '../utils/input'; import { makeRef } from '../utils/make-ref'; +import { + isFormNoValidate, + applyCheckboxValidation, + setupFormSubmitListener, +} from './checkbox-validation'; /** * @form-ready @@ -30,7 +35,7 @@ import { makeRef } from '../utils/make-ref'; @Component({ tag: 'ix-checkbox', styleUrl: 'checkbox.scss', - shadow: true, + shadow: { delegatesFocus: true }, formAssociated: true, }) export class Checkbox implements IxFormComponent { @@ -91,6 +96,8 @@ export class Checkbox implements IxFormComponent { @Event() ixBlur!: EventEmitter; private touched = false; + private formSubmissionAttempted = false; + private cleanupFormListener?: () => void; private readonly inputRef = makeRef((checkboxRef) => { checkboxRef.checked = this.checked; @@ -105,11 +112,61 @@ export class Checkbox implements IxFormComponent { onCheckedChange() { this.touched = true; this.updateFormInternalValue(); + this.syncValidationClasses(); + } + + private syncValidationClasses() { + if (isFormNoValidate(this.hostElement)) { + this.hostElement.classList.remove('ix-invalid--required', 'ix-invalid'); + return; + } + + const checkboxGroup = this.hostElement.closest('ix-checkbox-group'); + let checkboxes: HTMLIxCheckboxElement[]; + let group: Element | null; + + if (checkboxGroup) { + checkboxes = Array.from( + checkboxGroup.querySelectorAll('ix-checkbox') + ); + group = checkboxGroup; + } else { + checkboxes = [this.hostElement]; + group = null; + } + + const hasRequired = checkboxes.some((c) => c.required); + if (!hasRequired) { + this.hostElement.classList.remove('ix-invalid--required', 'ix-invalid'); + return; + } + + applyCheckboxValidation( + checkboxes, + group, + this.touched, + this.formSubmissionAttempted + ); } @Watch('value') onValueChange() { this.valueChange.emit(this.value); + this.syncValidationClasses(); + } + + connectedCallback(): void { + this.syncValidationClasses(); + this.cleanupFormListener = setupFormSubmitListener(this.hostElement, () => { + this.formSubmissionAttempted = true; + this.syncValidationClasses(); + }); + } + + disconnectedCallback(): void { + if (this.cleanupFormListener) { + this.cleanupFormListener(); + } } componentWillLoad() { @@ -147,6 +204,18 @@ export class Checkbox implements IxFormComponent { /** This function is intentionally empty */ } + /** + * Resets the checkbox to its initial unchecked state and clears validation. + */ + @Method() + async clear(): Promise { + this.checked = false; + this.touched = false; + this.formSubmissionAttempted = false; + this.updateFormInternalValue(); + this.syncValidationClasses(); + } + private renderCheckmark() { return ( { indeterminate: this.indeterminate, }} onFocus={() => (this.touched = true)} - onBlur={() => this.ixBlur.emit()} + onBlur={() => { + this.ixBlur.emit(); + this.touched = true; + this.syncValidationClasses(); + }} >