Skip to content
Open
84 changes: 74 additions & 10 deletions core/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
import { Build, Component, Element, Event, Host, Method, Prop, State, h } from '@stencil/core';
import { checkInvalidState } from '@utils/forms';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
import { createColorClasses, hostContext } from '@utils/theme';
Expand Down Expand Up @@ -35,6 +36,7 @@ export class Checkbox implements ComponentInterface {
private helperTextId = `${this.inputId}-helper-text`;
private errorTextId = `${this.inputId}-error-text`;
private inheritedAttributes: Attributes = {};
private validationObserver?: MutationObserver;

@Element() el!: HTMLIonCheckboxElement;

Expand Down Expand Up @@ -120,6 +122,13 @@ export class Checkbox implements ComponentInterface {
*/
@Prop() required = false;

/**
* Track validation state for proper aria-live announcements.
*/
@State() isInvalid = false;

@State() private hintTextID?: string;

/**
* Emitted when the checked property has changed as a result of a user action such as a click.
*
Expand All @@ -137,10 +146,64 @@ export class Checkbox implements ComponentInterface {
*/
@Event() ionBlur!: EventEmitter<void>;

connectedCallback() {
const { el } = this;

// Watch for class changes to update validation state.
if (Build.isBrowser && typeof MutationObserver !== 'undefined') {
this.validationObserver = new MutationObserver(() => {
const newIsInvalid = checkInvalidState(el);
if (this.isInvalid !== newIsInvalid) {
this.isInvalid = newIsInvalid;
/**
* Screen readers tend to announce changes
* to `aria-describedby` when the attribute
* is changed during a blur event for a
* native form control.
* However, the announcement can be spotty
* when using a non-native form control
* and `forceUpdate()`.
* This is due to `forceUpdate()` internally
* rescheduling the DOM update to a lower
* priority queue regardless if it's called
* inside a Promise or not, thus causing
* the screen reader to potentially miss the
* change.
* By using a State variable inside a Promise,
* it guarantees a re-render immediately at
* a higher priority.
*/
Promise.resolve().then(() => {
console.log('updating hint text id');
this.hintTextID = this.getHintTextID();
});
}
});

this.validationObserver.observe(el, {
attributes: true,
attributeFilter: ['class'],
});
}

// Always set initial state
this.isInvalid = checkInvalidState(el);
}

componentWillLoad() {
this.inheritedAttributes = {
...inheritAriaAttributes(this.el),
};

this.hintTextID = this.getHintTextID();
}

disconnectedCallback() {
// Clean up validation observer to prevent memory leaks.
if (this.validationObserver) {
this.validationObserver.disconnect();
this.validationObserver = undefined;
}
}

/** @internal */
Expand Down Expand Up @@ -204,9 +267,9 @@ export class Checkbox implements ComponentInterface {
};

private getHintTextID(): string | undefined {
const { el, helperText, errorText, helperTextId, errorTextId } = this;
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;

if (el.classList.contains('ion-touched') && el.classList.contains('ion-invalid') && errorText) {
if (isInvalid && errorText) {
return errorTextId;
}

Expand All @@ -222,7 +285,7 @@ export class Checkbox implements ComponentInterface {
* This element should only be rendered if hint text is set.
*/
private renderHintText() {
const { helperText, errorText, helperTextId, errorTextId } = this;
const { helperText, errorText, helperTextId, errorTextId, isInvalid } = this;

/**
* undefined and empty string values should
Expand All @@ -235,11 +298,11 @@ export class Checkbox implements ComponentInterface {

return (
<div class="checkbox-bottom">
<div id={helperTextId} class="helper-text" part="supporting-text helper-text">
{helperText}
<div id={helperTextId} class="helper-text" part="supporting-text helper-text" aria-live="polite">
{!isInvalid ? helperText : null}
</div>
<div id={errorTextId} class="error-text" part="supporting-text error-text">
{errorText}
<div id={errorTextId} class="error-text" part="supporting-text error-text" role="alert">
{isInvalid ? errorText : null}
</div>
</div>
);
Expand Down Expand Up @@ -274,11 +337,12 @@ export class Checkbox implements ComponentInterface {
<Host
role="checkbox"
aria-checked={indeterminate ? 'mixed' : `${checked}`}
aria-describedby={this.getHintTextID()}
aria-invalid={this.getHintTextID() === this.errorTextId}
aria-describedby={this.hintTextID}
aria-invalid={this.isInvalid ? 'true' : undefined}
aria-labelledby={hasLabelContent ? this.inputLabelId : null}
aria-label={inheritedAttributes['aria-label'] || null}
aria-disabled={disabled ? 'true' : null}
aria-required={required ? 'true' : undefined}
tabindex={disabled ? undefined : 0}
onKeyDown={this.onKeyDown}
onFocus={this.onFocus}
Expand Down
184 changes: 184 additions & 0 deletions core/src/components/checkbox/test/validation/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Checkbox - Validation</title>
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-row-gap: 30px;
grid-column-gap: 30px;
}

h2 {
font-size: 12px;
font-weight: normal;

color: var(--ion-color-step-600);

margin-top: 10px;
margin-bottom: 5px;
}

.validation-info {
margin: 20px;
padding: 10px;
background: var(--ion-color-light);
border-radius: 4px;
}
</style>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Checkbox - Validation Test</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
<div class="validation-info">
<h2>Screen Reader Testing Instructions:</h2>
<ol>
<li>Enable your screen reader (VoiceOver, NVDA, JAWS, etc.)</li>
<li>Tab through the form fields</li>
<li>When you tab away from an empty required field, the error should be announced immediately</li>
<li>The error text should be announced BEFORE the next field is announced</li>
<li>Test in Chrome, Safari, and Firefox to verify consistent behavior</li>
</ol>
</div>

<div class="grid">
<div>
<h2>Required Field</h2>
<ion-checkbox
id="terms-checkbox"
helper-text="You must agree to continue"
error-text="Please accept the terms and conditions"
required
>I agree to the terms and conditions</ion-checkbox
>
</div>

<div>
<h2>Optional Field (No Validation)</h2>
<ion-checkbox id="optional-checkbox" helper-text="You can skip this field">Optional Checkbox</ion-checkbox>
</div>
</div>

<div class="ion-padding">
<ion-button id="submit-btn" expand="block" disabled>Submit Form</ion-button>
<ion-button id="reset-btn" expand="block" fill="outline">Reset Form</ion-button>
</div>
</ion-content>
</ion-app>

<script>
// Simple validation logic
const checkboxes = document.querySelectorAll('ion-checkbox');
const submitBtn = document.getElementById('submit-btn');
const resetBtn = document.getElementById('reset-btn');

// Track which fields have been touched
const touchedFields = new Set();

// Validation functions
const validators = {
'terms-checkbox': (checked) => {
return checked === true;
},
'optional-checkbox': () => true, // Always valid
};

function validateField(checkbox) {
const checkboxId = checkbox.id;
const checked = checkbox.checked;
const isValid = validators[checkboxId] ? validators[checkboxId](checked) : true;

// Only show validation state if field has been touched
if (touchedFields.has(checkboxId)) {
if (isValid) {
checkbox.classList.remove('ion-invalid');
checkbox.classList.add('ion-valid');
} else {
checkbox.classList.remove('ion-valid');
checkbox.classList.add('ion-invalid');
}
checkbox.classList.add('ion-touched');
}

return isValid;
}

function validateForm() {
let allValid = true;
checkboxes.forEach((checkbox) => {
if (checkbox.id !== 'optional-checkbox') {
const isValid = validateField(checkbox);
if (!isValid) {
allValid = false;
}
}
});
submitBtn.disabled = !allValid;
return allValid;
}

// Add event listeners
checkboxes.forEach((checkbox) => {
// Mark as touched on blur
checkbox.addEventListener('ionBlur', (e) => {
console.log('Blur event on:', checkbox.id);
touchedFields.add(checkbox.id);
validateField(checkbox);
validateForm();

const isInvalid = checkbox.classList.contains('ion-invalid');
if (isInvalid) {
console.log('Field marked invalid:', checkbox.innerText, checkbox.errorText);
}
});

// Validate on change
checkbox.addEventListener('ionChange', (e) => {
console.log('Change event on:', checkbox.id);
if (touchedFields.has(checkbox.id)) {
validateField(checkbox);
validateForm();
}
});
});

// Reset button
resetBtn.addEventListener('click', () => {
checkboxes.forEach((checkbox) => {
checkbox.checked = false;
checkbox.classList.remove('ion-valid', 'ion-invalid', 'ion-touched');
});
touchedFields.clear();
submitBtn.disabled = true;
});

// Submit button
submitBtn.addEventListener('click', () => {
if (validateForm()) {
alert('Form submitted successfully!');
}
});

// Initial setup
validateForm();
</script>
</body>
</html>
Loading
Loading